关键字volatile
可以说是Java虚拟机提供的最轻量的同步机制,但是它并不容易完全被正确的理解,以至于很多程序员不习惯使用它,遇到多线程竞争的情况时一律使用synchronized
来进行同步。
在Java中,当一个变量定义了volatile
关键字之后,它具备两种特性,第一是保证此变量对所有线程的可见性。这里的“可见性”是指当其中某一条线程修改了这个变量的值,其它线程是可以立即得知这个变量的新值。
普通变量做不到这一点,普通变量的值在线程间传递均需要通过主内存来完成。例如,线程A修改一个普通变量的值,然后向主内存进行回写,另外一条线程B在线程A回写完成之后再从主内存进行读取操作,新值才会对线程B可见。
关于volatile
变量的可见性,经常被开发人员误认为:volatile
变量对所有线程都是立即可见的,对volatile
变量的所有写操作都能立刻反应到其它线程中,所以基于volatile
变量的运算在并发下是安全的。
这句话的论据部分并没有错,但是Java里面的运算并非原子操作,导致volatile
变量的运算在并发下一样是不安全的,下面看个例子:
1 | private static volatile int count = 0; |
上面这段代码对count
变量加上了volatile
关键字,然后创建了一个线程池,再对该变量进行 5000 次自增操作,如果这段代码能够正确并发执行的话,最后输出的结果应该是 5000。当我们执行这段代码时,发现最后的结果都是一个小于 5000 的数字,并且每次执行结果都不一样。这是为什么?
问题就出现在 count++
之中,它并不是一个原子操作,而是一个复合指令操作。可以简单地理解为,当线程A和线程B都同时进行count++
操作,count++
简单的分为 “读” 和 “写” 操作。当读取count
的值的时候,此时的值虽然基于volatile
关键字保证了它的正确性,但是在执行递增操作时,其它线程可能已经把原来的count
值增大了,导致当前线程在操作的值是一个过期的旧数据,所以此次的操作算是“无效”的操作了,我们才会看到最后期望的结果并不是 5000。
由于volatile
变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然需要通过加锁(使用 synchronized 或 java.util.concurrent 中的原子类)来保存原子性。
一、运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
二、变量不需要于其它的状态变量共同参与不变约束。
如下面这类场景就适合使用volatile
变量来控制并发,当 shutdown()
方法被调用时,能保证所有线程中执行的 doWork()
方法都立即停下来。
1 | private volatile boolean isEnd = false; |
volatile
变量还可以禁止CPU指令重排序优化,普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值的操作顺序于程序代码中的执行顺序一致。下面看一段单例模式代码:
1 | public class SingleTon { |
这段代码加了volatile
关键字后保证了CPU指令按顺序执行,而不是重排序后执行,所以是线程安全的。而未加volatile
关键字会出现什么问题呢?
当执行 singleTon = new SingleTon();
这段代码时,实际上分为3个步骤:
1.分配该对象的内存空间
2.初始化对象
3.将 singleTon
指向刚分配的内存空间
如果指令是按照上面这种顺序执行,那么是没有问题的。接着我们改造下,CPU按照下面这种情况执行:
1.分配该对象的内存空间
2.将 singleTon
指向刚分配的内存空间
3.初始化对象
当按照这种情况执行,我们再去看看上面的代码中的线程A,当它执行这段代码后,进入到 2.将
singleTon指向刚分配的内存空间
这个步骤时,线程B判断到 singleTon
此时已经不为 null
,所以直接返回对象。而这个时候还没有初始化对象,所以当执行该对象的操作时,就会出现错误。这种情况虽然出现的概率极小,但是也是会出现的,所以volatile
可以禁止指令重排序引发的线程安全问题。