Java中为了线程通信的安全性(数据一致性),除了提供内置锁synchronized和显示锁ReentrantLock,还提供了另外一种线程同步机制——volatile,是一种轻量级同步机制。不过,通常很难轻易的理解volatile的真正意义。下面通过一个例子来认识一下volatile(摘自《深入理解Java虚拟机》):
public volatile static int race = 0;
private static void increase() {
race++;
}
private static final int THREADS_COUNT = 20;
public static void main(String[] args) {
Thread[] threads = new Thread[THREADS_COUNT];
for (int i = 0; i < THREADS_COUNT; i++) {
threads[i] = new Thread(() -> {
for (int i1 = 0; i1 < 10000; i1++) {
increase();
}
});
threads[i].start();
}
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(race);
}
通过运行这个例子,我们发现race变量最后的值,肯定会小于200000,这是为什么呢?由于volatile只能保证可见性,不能保证原子性。而race++不是原子操作,所以需要在increase方法加锁来保证原子性。而在increase方法加锁后,即使race不用volatile修饰也能得到期望值。看完之后还是一头雾水,没有真正理解其中的意义。
下面我们通过Java内存模型分析,来真正理解volatile的含义以及使用场景:
我们知道现代计算机为了解决存储设备与处理器处理速度的差距,在CPU和主存之间增加了多层高速缓存,每个CPU都会有一套高速缓存,那么多核计算机就有多套高速缓存。这些高速缓存与内存之间进行读写访问的过程,在不同处理器有不一样的实现方式,也就是不同处理器的内存模型是不一致的(图a,Intel的共享L2缓存;图b,AMD的独享L2缓存)。
当程序运行过程中,会将运算所需要的数据从主存中复制一份到高速缓存中,各个处理器都操作对应的高速缓存的数据,而不直接操作主存,当运算结束后,将高速缓存中的最新数据刷到主存中。这样可以极大的提升CPU的吞吐量,但也引入了新的问题,也就是缓存一致性问题。Intel的MESI协议的提出,就是为了保证每个缓存中使用的共享数据都是一致的,它的具体思想是,每个缓存(Cache)不仅知道自己的读写操作,而且也监听其他Cache的读写操作。当进行读操作时,Cache可以从主存中或者其他Cache中读取数据;当进行写数据时,不仅将Cache的数据反向更新到主存中,也要通知其他Cache该数据无效,需要其他Cache重新从主存中读取数据。
Java虚拟机也有自己的Java内存模型(Java Memory Model JMM),用来屏蔽掉各种硬件和操作系统的内存访问差异,以实现各种平台下Java内存访问效果的一致。Java内存模型规定了所有共享数据的存储都在Java虚拟机的主内存(堆)中,每个线程拥有自己的工作内存(栈),用来保存被该线程使用到的数据的主内存副本。线程对数据的操作都必须在工作内存中进行,而不能直接操作主内存,也不能直接读写其他线程工作内存的数据,线程间的数据传递都需要通过主内存来完成。
Java内存模型定义了8种原子性的操作,来保证数据在主内存和工作内存中的交互,包括:锁定(lock)、解锁(unlock)、读取(read)、载入(load)、使用(use)、赋值(assign)、存储(store)。
在Java语言中,对基本数据类型的变量的读取赋值操作也是原子性的(long、double的非原子性协定),对引用类型变量的读取和赋值操作也是原子性的,但对于一些++、x=y这种运算赋值不是原子性的,也就是多个原子性操作组合在一起就不是原子性的操作了。Java还提供了更大范围的原子性操作,通过synchronized和显式锁ReentrantLock来实现。
可见性是指当一个线程修改了共享数据的值时,其他线程能理解得知这个变化。volatile能保证新值能立即同步到主内存中,普通变量不能保证立即,所以普通变量不能保证可见性。另外同步机制synchronized与显式锁ReentrantLock也能保证可见性,因为每次只允许一个线程操作共享数据,只有等当前线程操作完,其他线程才能获得权限,所以同步机制也能保证可见性。
Java线程的天然有序性可以一句话来总结:如果在本线程内观察,所有的操作都是有序的,如果在一个线程中观察另一个线程,所有操作都是无序的。由于Java编译器可以对Java代码进行指令重排序,也就是单线程下,指令重排序不会影响结果,但多线程情况下,会影响执行结果的。Java内存模型通过happens-before原则,来推导两个操作的执行顺序,否则编译器将无法保证有序性。所以为了保证代码执行的有序性,Java提供volatile和内置锁synchronized来完成。
特点一:volatile保证可见性
特点二:volatile禁止指令重排序
再看开始的例子,volatile修饰的race只能保证可见性和顺序性,不能保证race这个非原子性操作的原子性,所以不能得到期望值200000。
在并发编程中,volatile只是对共享数据的一种优化方式,并不能代替synchronized,只有在一些特殊的情景下,才能使用它:
所以volatile很适合做某些状态值,如用volatile修饰初始化标识,加载一些初始化配置信息后,其他线程可以继续初始化的后续工作;还可以用volatile可以当做一些通知变量和数据,比如一些只能通过设置来配置的共享数据,其他线程不会修改,只会依赖其值的变化,做特定的操作。