在Java中,同步机制有很多种,其中volatile变量被称作为”轻量级“的同步机制,之所以被称之为”轻量级“同步机制,是因为在和sychronized关键词进行对比之下,volatile变量所需要的代码和运行时开销更少。当然,因为是”轻量级“的同步机制,volatile变量所具备的功能也仅仅是sychronized的一部分。例如,volatile能够保证内存的可见性,并不能够保证操作的原子性。
关于可见性,简单一句话来说,就是一个线程对共享变量的改动要能够立刻被其他线程知晓。那么就会有个疑问,所有的线程都是在同一块内存中读写共享变量,按道理来说不会存在上述所说的一个线程对共享变量的改动,而其他线程不知晓的情况。实际上,在JVM内存模型中,共享变量是存储在主内存中,为了加快程序运行的速度,每个线程都会拥有自己独立的工作内存(比如CPU缓存、寄存器等等)。每个线程会将主内存中的共享变量在自己的工作内存中保存一份副本,对共享变量的操作只能通过先对工作内存中的副本进行操作,然后再同步到主内存中。此外,每个线程只能访问自己的工作内存,不能访问其他线程的工作内存。关于工作内存与主内存之间的交互,JVM中定义了8种原子操作:
1、lock
将主内存中的变量锁定,为一个线程所独占
2、unlock
将lock加的锁定解除,此时其他线程有机会访问该变量
3、read
将主内存中的变量值读取到工作内存中
4、load
将read读取到的值保存到工作内存中的变量副本中
5、use
将工作内存中的变量值传递给代码执行引擎
6、assign
将代码执行引擎返回的值重新赋值到工作内存中的变量副本中
7、store
将变量副本的值存储到主内存中
8、write
将store存储的值写入到主内存的共享变量中
备注:如果是把一个变量从主内存赋值到工作内存,需要顺序的执行read、load操作,不要求连续执行。如果是要把一个变量从工作内存中同步到主内存中,要顺序的执行store、write,不要求连续执行。此外还要求:
不允许read和load操作单独的出现(即不允许一个变量从主内存读取了但是工作内存接受),
不允许store和write操作单独的出现(即不允许工作内存发起了写回主内存但是主主内存不接受),
不允许一个线程丢弃它最近的assign操作(即变量在工作内存中改变了后,必须把该变化同步到主内存中)
不允许一个线程在没有发生过任何assign操作的情况下,把数据从工作内存中同步到主内存中
这样子会导致一个问题,如果一个线程对于共享变量做了改动,那么其他线程如何能够立刻感知到改动呢?这时候,volatile变量就派上用场了。当对volatile变量进行改动后,会将其他线程工作内存中存储的改动前的变量清除掉,其他线程在需要使用该变量时从主内存中重新读取该volatile变量的值(实际上,其他线程每次使用共享变量时,都必须先从主内存中刷新获取最新的值,不管自己工作内存中的变量副本是否被清除掉,因为JVM对被volatile修饰的变量要求:load use两个操作必须连续执行,assign、store两个操作必须连续执行)。这样子,就保证了volatile变量在所有线程中的一致性。可以看得出,volatile变量会带来一定的性能损耗,但是如果要是在读操作远远大于写操作的情况下,volatile变量可以 提供比sychronized关键词更好的性能优势。
至于原子性,volatile变量不能够像sychronized关键词那样,保证临界区(比如一个方法或者一个代码块)同一时刻只能有一个线程进入,因此volatile变量不能保证操作的原子性。举个例,如下所示
//变量声明
volatile int x = 0;
//变量自增操作
void doSomeThing(){
...
x++;
...
}
在上述代码中,声明了一个volatile类型的变量x,在方法中 doSomeThing() 中,对变量x做了一次自增操作。x++ 看上去是一个单独的操作,实际上它是一个组合操作:读取 -> 修改 -> 写入,当doSomeThing() 方法在多线程环境下,假如A线程读取到的x值为0,线程B读取到的x值也为0,当A修改x值为1,还没有将最新的x值写入主内存中时,此时线程B也修改x为1。然后线程A把变量x的最新值写入主内存,线程B也把变量x的最新值写入主内存,会导致其中一个写入的结果被后面写入的结果覆盖掉,导致最终doSomeThing() 方法被调用2次,但是变量x的值为1。
volatile变量除了可以保证内存可见性,还可以防止指令重排序。指令冲排序是JVM为了提高程序执行效率,优化指令,在不影响单线程环境执行结果的前提下,尽可能的提高并行度。如下代码所示
int width = 30; //语句1
int len = 40; //语句2
int area = width * len; //语句3
代码中,语句3依赖于语句1、语句2,但是语句1和语句2之间并没有任何依赖关系,因此指令重排后,执行的顺序可能不是 语句1 -> 语句2 -> 语句3,可能就是 语句2 -> 语句1 -> 语句3,这就是指令冲排序导致的。当然在单线程环境下,指令重排序不会对执行结果造成任何影响,但是在多线程环境下,就会造成问题。比如经典的懒加载方式的双重检测锁的单单例模式,具体代码如下所示:
public class Singleton {
private static volatile Singleton instance = null;
private Singletong(){
...
}
public static Singleton getInstance(){
if(instance == null){
synchronized(Singleton.class){
if(instance == null){
instance = new Singleton();
}
}
}
return instance;
}
}
在上述代码中,静态变量 instance 使用了volatile来进行修饰,之所以使用volatile修饰,是因为 instance = new Singleton()这个操作并不是一个原子性操作,也是一个组合操作,具体的操作如下所示:
memory = allocate(); //1. 分配对象内存空间
ctorInstance(memory); //2. 初始化对象
instance = memory; //3. 设置instance指向刚刚分配的内存地址
可以看到,操作2、操作3都依赖于操作1,但是操作2和操作3之间并不存在依赖,经过指令冲排序之后,可能出现,先执行操作3,后执行操作操作2,如下所示
memory = allocate(); //1. 分配对象内存空间
instance = memory; //3. 设置instance指向刚刚分配的内存地址
ctorInstance(memory); //2. 初始化对象
假设线程A执行这段经过重排序后的操作,当执行完操作3时,线程B监测到instance不为NULL,因此线程B直接返回instance引用,但是instance并没有完成初始化,导致一个不完整的对象被发布出去。
volatile变量防止指令重排序的底层实现机制是通过内存屏障来进行实现的。在硬件层,内存屏障分为两种:读屏障(Load Barrier)和写屏障(Store Barrier),他们的作用分别如下所示:
Load Barrier:在指令前插入Load Barrier,可以让缓存、寄存器中的数据失效,强制从主内存中加载数据
StoreBarrier:在指令后插入Store Barrier,可以让写入缓存中最新数据同步到主内存中,让其他线程可见。
在JVM中,通常使用的内存屏障为:LoadLoad、StoreStore、LoadStore、StoreLoad,也就是上述两种内存屏障的组合。
LoadLoad:该屏障之前的所有加载操作,一定在该屏障之后的所有加载操作之前完成
StoreStore:该屏障之前的所有存储操作,一定在该屏障之后的所有存储操作之前完成
LoadStore:该屏障之前的所有加载操作,一定在该屏障之后的所有存储操作之前完成
StoreLoad:该屏障之前的所有存储操作,一定在该屏障之后的所有加载操作之前完成
对于volatile变量,JVM中的实现如下:
在写操作之前,插入一个StoreStore屏障,在写操作之后,插入一个StoreLoad屏障。
在读操作之前,插入一个LoadLoad屏障,在读操作之后,插入一个LoadStore屏障。
最后,总结一下volatile变量的使用条件:
1、对变量的写操作不依赖于当前值
2、该变量不包含在具有其他变量的不变式中
volatile与sychronized的区别:
1、volatile只能使用在变量级别,synchronized可以使用方法、代码块上
2、volatile不会造成线程阻塞,sychronized会造成线程阻塞
3、volatile不能够保证操作的原子性,sychronized可以保证操作的原子性‘
参考文章
《深入理解Java虚拟机第二版》