1、确保内存可视性
内存可见性,即线程a对volatile变量的更改,其他线程获取的volatile变量是最新的。
内存可见性需要提到Java的内存模型,如下图所示。
如上图所示,所有线程的共享变量都存储在主内存中,每个线程都有自己的工作内存。 每个线程不直接操作主内存中的变量,而是将主内存中的变量副本放入自己的工作内存中,只处理工作内存中的数据。 修改完成后,将修改后的结果返回主存储器。 每个线程只能操作自己工作存储器中的变量,不能直接访问对方工作存储器中的变量。 线程间变量值的传递必须在主内存中进行。
上述Java内存模型在单线程环境中不会出现问题,但在多线程环境中可能会出现脏数据。 例如,假设AB的两个线程同时获得变量I并执行增量操作。 a线程将变量I放入自己的工作存储器中进行1次操作,此时线程a还没有将修正后的值刷新回主存储器中,但此时线程b也从主存储器中接收了修正前的变量I,再次进行了1次操作。 最后,a和b线程将各自的结果刷新回主内存,变量I只进行了一次操作,实际上a和b累计进行了两次操作,结果发生了错误。 这是因为线程b读取了变量I的脏数据。
此时,如果对变量I添加volatile关键字修饰,则a线程在变量I的值变更后立即刷新到主存储器,即使其他线程读取变量值也无效,可以保证强制从主存储器读取变量值,从而
1.1 MESI缓存完整性协议
volatile的可见性是通过将程序集与Lock前缀指令结合以触发底层MESI缓存完整性协议来实现的。 当然这个协议有很多,但最常用的是MESI。 MESI表示4种状态,如下所示。 状态描述修改(Modified )此时高速缓存线的数据与主存储器中的数据不匹配,数据只存在于该工作存储器中。 其他线程从主内存读取共享变量值的操作将延迟,直到将高速缓存行中的数据写入主内存
e(exclusive )此时,高速缓存线的数据与主存储器的数据一致,数据只存在于本工作存储器中。 在这种情况下,其他线程必须监听读取主内存中的共享变量的操作,如果发生,则高速缓存行必须处于共享状态
s共享(Shared )时,高速缓存线的数据与主存储器的数据一致,数据存在于多个工作存储器中。 此时,其他线程接收到禁用缓存行的请求,如果发生该请求,则必须禁用缓存行
I无效(Invalid )缓存线此时无效
现在,如果某个cpu在主存储器中得到变量x的值,最初是1,则将其放入自己的工作存储器中。 此时的状态独占状态e,此时其他的cpu也得到该x的值并将其存储在自己的工作存储器中。 此时,该cpu一个接一个地监听存储器总线,并注意到多个cpu取得了这个x。 此时,这两个cpu取得的x的值的状态都是共享状态s。 第一个cpu将自己工作存储器中的x的值带到自己的ALU计算单元进行计算,返回的x的值变为2,然后传递到存储器总线,将此时的x的状态设为修正状态m。 同时,另一个cpu此时也相继去监听存储器总线,发现另一个cpu已经在这个x上处于修改状态,于是它内部的x状态被设为无效状态I,第一个cpu将修改后的值刷新回主存储器,然后再刷新新的值这个谁先改变了x的值有可能在同一时间被修正。 在这种情况下,cpu通过底层硬件在同一指令周期内进行裁决,裁决是谁修改的处于修正状态,另一个处于无效状态,被废弃或复盖(存在争议)。
当然,MESI也可能失效。 高速缓存的最小单位是高速缓存线,如果当前共享数据的长度超过高速缓存线的长度,MESI协议将失败,在这种情况下总线锁定机制将启动。 当第一个线程的cpu得到这个x时,不允许其他线程获取这个x的值。
2、禁止命令重排
指令的执行顺序不一定按我们创建的顺序执行,为了确保执行效率,JVM (包括CPU )可以对指令进行重新排序。 例如,int i=1;
int j=2;
上述两个赋值语句位于同一线程中,根据程序上的顺序,“int i=1;” 的操作为“int j=2; 中必须先行,“int j=2; ”的代码可能完全由处理器先执行。 对于单线程,JVM确保排序后的执行结果与排序前的结果相匹配。 但在多线程场景中,情况并不总是如此。 最典型的例子是双重检查封锁版的单实例实现。 代码如下。 公共类单一{
私密性评估服务;
私有singleton () }
publicstaticsingletongetinstance (
if(instance==null ) {
同步(singleton.class ) {
if(instance==null ) {
instance=new Singleton (;
}
p>}
}
return instance;
}
}
由上可以看到,instance变量被volatile关键字所修饰,但是如果去掉该关键字,就不能保证该代码执行的正确性。这是因为“instance = new Singleton();”这行代码并不是原子操作,其在JVM中被分为如下三个阶段执行:为instance分配内存
初始化instance
将instance变量指向分配的内存空间
由于JVM可能存在重排序,上述的二三步骤没有依赖的关系,可能会出现先执行第三步,后执行第二步的情况。也就是说可能会出现instance变量还没初始化完成,其他线程就已经判断了该变量值不为null,结果返回了一个没有初始化完成的半成品的情况。而加上volatile关键字修饰后,可以保证instance变量的操作不会被JVM所重排序,每个线程都是按照上述一二三的步骤顺序的执行,这样就不会出现问题。
2.1 内存屏障
volatile有序性是通过内存屏障实现的。JVM和CPU都会对指令做重排优化,所以在指令间插入一个屏障点,就告诉JVM和CPU,不能进行重排优化。具体的会分为读读、读写、写读、写写屏障这四种,同时它也会有一些插入屏障点的策略,下面是JMM基于保守策略的内存屏障点插入策略:屏障点描述每个volatile写的前面插入一个store-store屏障禁止上面的普通写和下面的volatile写重排序
每个volatile写的后面插入一个store-load屏障禁止上面的volatile写与下面的volatile读/写重排序
每个volatile读的后面插入一个load-load屏障禁止下面的普通读和上面的volatile读重排序
每个volatile读的后面插入一个load-store屏障禁止下面的普通写和上面的volatile读重排序
上面的插入策略非常保守,但是它可以保证在任意处理器平台上的正确性。在实际执行时,编译器可以省略没必要的屏障点,同时在某些处理器上会做进一步的优化。
3、不保证原子性
需要重点说明的一点是,尽管volatile关键字可以保证内存可见性和有序性,但不能保证原子性。也就是说,对volatile修饰的变量进行的操作,不保证多线程安全。请看以下的例子:public class Test {
private static CountDownLatch countDownLatch = new CountDownLatch(1000);
private volatile static int num = 0;
public static void main(String[] args) {
ExecutorService executor = Executors.newCachedThreadPool();
for (int i = 0; i < 1000; i++) {
executor.execute(() -> {
try {
num++;
} catch (Exception e) {
e.printStackTrace();
} finally {
countDownLatch.countDown();
}
});
}
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
executor.shutdown();
System.out.println(num);
}
}
静态变量num被volatile所修饰,并且同时开启1000个线程对其进行累加的操作,按道理来说,其结果应该为1000,但实际的情况是,每次运行结果可能都是一个小于1000的数字(也有结果为1000的时候,但出现几率很小),并且不固定。那么这是为什么呢?原因是因为“num++;”这行代码并不是原子操作,尽管它被volatile所修饰了也依然如此。++操作的执行过程如下面所示:首先获取变量i的值
将该变量的值+1
将该变量的值写回到对应的主内存中
虽然每次获取num值的时候,也就是执行上述第一步的时候,都拿到的是主内存的最新变量值,但是在进行第二步num+1的时候,可能其他线程在此期间已经对num做了修改,这时候就会触发MESI协议的失效动作,将该线程内部的值作废。那么该次+1的动作就会失效了,也就是少加了一次1。比如说:线程A在执行第一步的时候读取到此时num的值为3,然后在执行第二步之前,其他多个线程已经对该值进行了修改,使得num值变为了4。而线程A此时的num值就会失效,重新从主内存中读取最新值。也就是两个线程做了两次+1的动作,但实际的结果最后只加了一次1。所以这也就是最后的执行结果为什么大概率会是一个小于1000的值的原因。
所以如果要解决上面代码的多线程安全问题,可以采取加锁synchronized的方式,也可以使用JUC包下的原子类AtomicInteger,以下的代码演示了使用AtomicInteger来包装num变量的方式:public class Test {
private static CountDownLatch countDownLatch = new CountDownLatch(1000);
private static AtomicInteger num = new AtomicInteger();
public static void main(String[] args) {
ExecutorService executor = Executors.newCachedThreadPool();
for (int i = 0; i < 1000; i++) {
executor.execute(() -> {
try {
num.getAndIncrement();
} catch (Exception e) {
e.printStackTrace();
} finally {
countDownLatch.countDown();
}
});
}
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
executor.shutdown();
System.out.println(num);
}
}
多次运行上面的代码,结果都为1000。