1.什么是volatile
volatile关键字是Java虚拟机提供的最轻量级的同步机制。
在多线程编程中volatile和synchronized都起着举足轻重的作用,没有这两者,也就没有那么多JUC供我们使用。
2.volatile的两大特性
对于并发编程来说,有3大重要特性:
- 原子性:一个操作或者多个操作,要么全部执行成功,要么全部执行失败。满足原子性的操作,中途不能被中断。
- 可见性:多个线程共同访问共享变量时,某个线程修改了此变量,其他线程能立即看到修改后的值。
- 有序性:程序的顺序按照代码的先后顺序执行。(JMM模型中允许编译器和处理器为了效率,进行指令重排序的优化。指令重排序在单线程内表现为串行语义,在多线程中会表现为无序。那么多线程并发编程中,就要考虑如何在多线程环境下可以允许部分指令重排,又要保证有序性)
synchronized关键字同时满足上述三种特性。
- synchronized是同步锁,同步块内的代码相当于同一时刻单线程执行,故不存在原子性和指令重排序的问题。
- synchronized关键字的语义JMM有两个规定,保证其实现内存可见性:
- 线程解锁前,必须把共享变量的最新值刷新到主内存中
- 线程加锁前,将清空工作内存中共享变量的值,从主内存中重新取值。
volatile关键字是保证可见性和有序性,并不保证原子性。
2.volatile变量的可见性
Java虚拟机规范中定义了一种Java内存模型(JMM)来屏蔽到各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果。Java内存模型的主要目标就是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的细节。
JMM中规定所有的变量都存储在主内存(Main Memory)中,每条线程都有自己的工作内存(Work Memory),线程的工作内存中保存了该线程所使用的变量从主内存中拷贝的副本。线程对于变量的读、写都必须在工作内存中进行,不能直接读、写主内存中的变量。同时,本线程的工作内存的变量也无法被其他线程直接访问,必须通过主内存完成。
整体内存模型:
对于普通共享变量,线程A将变量修改后,体现在此线程的工作内存。在尚未同步到主内存时,若线程B使用此变量,从主内存中获取到的是修改前的值,便发生了共享变量值的不一致,也就是出现了线程可见性问题。
因此:volatile定义:
- 当对volatile变量执行写操作后,JMM会把工作内存中的最新变量值强制刷新到主内存
- 写操作会导致其他线程中的缓存无效
这样,其他线程使用缓存时,发现本地工作内存中此变量无效,便从主内存中获取,这样获取到的变量便是最新的值,实现了线程的可见性。
volatile变量的禁止指令重排序(有序性)
volatile是通过编译器在生成字节码时,在指令序列中添加“内存屏障”来禁止指令重排序的。
硬件层面的“内存屏障”:
- sfence:即写屏障(Store Barrier),在写指令之后插入写屏障,能让写入缓存的最新数据写回到主内存,以保证写入的数据立刻对其他线程可见。
- Ifence:即读屏障(Load Barrier),在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数据,以保证读取的是最新的数据。
- mfence,即全能屏障(modify/mix Barrier),兼具sfence和ifence的功能。
- lock前缀:lock不是内存屏障,而是一种锁。执行时会锁住内存子系统来确保执行顺序,甚至跨多个CPU。
JMM层面的内存屏障:
- LoadLoad屏障:对于这样的语句Load1;LoadLoad;Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
- StoreStroe屏障:对于这样的语句Store1、StoreStore、Store2,在Store及后续写入操作执行前,保证Store1的写入操作对其他处理器可见。
- LoadStore屏障:对于这样的语句Load1、LoadStore、Store2,在Store2 及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
- StoreLoad屏障:对于这样的语句Store1、StoreLoad、Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。
JVM的实现会在volatile读写前后加上内存屏障,在一定程度上保证有序性。如下所示:
//保证在后续读取之前,需要读取的操作已经读取完毕
LoadLoad Barrier
//读操作
volatile
//保证在写入操作之前,需要读取的数据已经读取完毕
LoadStore Barrier
//表示在写操作之前,需要写入的操作对其他处理器可见。
StoreStore Barrier
//写操作
volatile
//表示在读取操作之前,需要写入的操作对其他处理器可见
StoreLoad Barrier
为什么不保证原子性呢
严格来说,对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。
volatile的使用优化
Doug lea 在JDK7的并发包里新增了一个队列集合类LinkedTransferQueue,它在使用volatile变量时,用一种追加字节的方式来优化队列出队和入队的性能。
//队列中的头部节点
private transient final PaddedAtomicReference<QNode> head;
//队列中的尾部节点
private transient final PaddedAtomicReference<QNode> tail;
static final class PaddeAtomicReference<T> extends AtomicReference<T>{
//使用很多4个字节的引用追加到64个字节
Object p0,p1,p2,p3,p4,p5,p6,p7,p8,p9,pa,pb,pc,pd,pe;
PaddeAtomicReference(T r){
super(r);
}
}
public class AtomicReference<V> implements java.io.Serializable{
private volatile V value;
}
这个LinkedTransferQueue这个类,它使用一个内部类类型来定义头节点和尾节点,而这个内部类PaddedAtomicReferemce相对于父类AtomicReference只做了一件事情,就是将共享变量追加到64字节。追加了15个变量,再加上父类的value,一个对象的引用。
为什么追加到64字节可以提高并发编程的效率呢?
因为对于常见的处理器的高速缓存行是64个字节宽,不支持部分填充缓冲行。如果不满64个字节,
队列的头节点和尾节点会位于同一个缓存行中。如果有个处理器在修改头节点时,会将整个缓存行锁定,那么在缓存一致性机制下,其他处理器就不能访问尾节点。而队列的入队和出队操作则需要不停修改头节点和尾节点,所以在多处理器的情况下将会严重影响到队列的入队和出队效率。Doug lea使用追加64字节的方式来填满高速缓冲区的缓存行,避免头节点和尾节点加载到同一个缓存行,实头尾节点在修改时不会互相锁定。
住:
1.是不是在使用volatile变量时都应该追加到64字节呢答。不是。
- 在缓存行非64字节宽的处理器。
比如P6和奔腾都是32个字节宽的缓存行 - 共享变量不会被频繁地写。
因为追加字节的方式需要处理器读取更多的字节到高速缓冲区,这本身就会带来一定的性能消耗,如果共享变量不被频繁写的话,锁的几率也非常小,就没必要通过追加字节的方式来避免相互锁定。