volatile

volatile

在多线程并发编程中,volatile可以看做轻量级的synchronized,它在多处理器开发中保证了共享变量的“可见性”。可见性的意思是当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。如果volatile变量修饰符使用恰当的话,它比synchronized的使用和执行成本更低,因为它不会引起线程上下文的切换和调度。volatile变量具备以下特征:

  • 可见性,对于volatile变量的读,线程总是能读到当前最新的volatile值,也就是任一线程对volatile变量的写入对其余线程都是立即可见;
  • 有序性,禁止编译器和处理器为了提高性能而进行指令重排序;
  • 基本不保证原子性,由于存在long/double非原子性协议,long/double32x86hotspot虚拟机下允许没有被volatile修饰的变量读写操作划分为两次进行。但是从JDK9开始,hotspot也明确约束所有数据类型访问保持原子性,所以volatile变量保证原子性可以基本忽略。

在实际的编程中,要注意,除非是在保证仅有一个线程处于写,而其他线程处于读的状态下的时候,才可以使用volatile来保证可见性,而不需要使用原子变量或者锁来保证原子性。

volatile示例

JVM中每一个变量都有一个主内存,为了保证最佳性能,JVM允许线程从主内存拷贝一份私有拷贝,然后在线程读取变量的时候从主内存里面读,退出的时候,将修改的值同步到主内存。形象而言,对于变量tA线程对t变量修改的值,对B线程是可见的。但是A获取到t的值加1之后,突然挂起了,B获取到的值还是最新的值,volatile能保证B能获取到的t是最新的值,因为At+1并没有写到主内存里面去。

public class VolatileTest {
    public static volatile int race = 0;
    public static int value = 0;
    public static void increase() {
        race++;
        value++;
    }
    private static final int THREAD_COUNT = 20;
    public static void main(String[] args) {
        Thread[] threads = new Thread[THREAD_COUNT];
        for (int i = 0; i < THREAD_COUNT; i++) {
            threads[i] = new Thread(() -> {
               for (int j = 0; j < 10000; j++) {
                   increase();
               }
            });
            threads[i].start();
        }
        while (Thread.activeCount()> 1) {
            Thread.yield();
        }
        System.out.println("race: " + race + " value: " + value);
    }
}

volatile实现原理

Java内存模型中,通过as-if-serialhappens-before(先行先发生)来保证从重排的正确性,同时对于volatile变量有特殊的规则:对一个变量的写操作先行发生于后面对这个变量的读操作,保证了volatile变量的可见性;也就是说JMM保证新值能马上同步到主内存,同时把其他线程的工作内存中对应的变量副本置为无效,以及每次使用前立即从主内存读取共享变量。

instance = new Singleton(); // instance是volatile变量
0x01a3de1d: movb $0×0,0×1104800(%esi);0x01a3de24: lock addl $0×0,(%esp);

将带有volatile变量操作的Java代码转换成汇编代码后,可以看到多了个lock前缀指令;这个lock指令是关键,在多核处理器下实现两个重要操作:

  • 将当前处理器缓存行的数据写回到系统内存。
  • 这个写回内存的操作会使其他处理器里缓存该内存地址的数据失效

Java 内存屏障

该操作主要基于内存模型中的内存屏障来实现;如上图所示,在Java内存模型中,主要有以下4种类型的内存屏障:

  • LoadLoad屏障:对于Load1,LoadLoad,Load2这样的语句,在Load2及后续读取操作前要保证Load1要读取的数据读取完毕;
  • LoadStore屏障:对于Load1,LoadStore,Store2这样的语句,在Store2及后续写入操作前要保证Load1要读取的数据读取完毕;
  • StoreStore屏障:对于Store1,StoreStore,Store2这样的语句,在Store2及后续写入操作前要保证Store1的写入操作对其他处理器可见;
  • StoreLoad屏障:对于Store1,StoreLoad,Load2这样的语句,在Load2及后续读取操作前,Store1的写入对所有处理器可见。

Java的内存屏障与我们在《Concurrent-Notes》中讨论的读写屏障的关系如下:

  • 写屏障与(StoreStoreStoreLoad)屏障的关系:在volatile变量写之前加入StoreSore屏障保证了volatile写之前,写缓冲器中的内容已全部刷回告诉缓存,防止前面的写操作和volatile写操作之间发生指令重排,在volatile写之后加入StoreLoad屏障,保证了后面的读/写操作与volatile写操作发生指令重排,所以写屏障同时具有StoreStoreStoreLoad的功能。
  • 读屏障与(LoadLoad、LoadStore)屏障的关系:在volatile变量读之后加入LoadLoad屏障保证了后面其他读操作的无效队列中无效消息已经被刷回到了高速缓存,在volatile变量读操作后加入LoadStore屏障,保证了后面其他写操作的无效队列中无效消息已经被刷回高速缓存。读屏障同时具有了LoadLoadLoadStore的功能。

缓存行与volatile优化

Doug leaJDK 7的并发包里新增一个队列集合类LinkedTransferQueue,它在使用volatile变量时,用一种追加字节的方式来优化队列出队和入队的性能。LinkedTransferQueue的代码如下。

/** 队列中的头部节点 */
private transient final PaddedAtomicReference < QNode > head;
/** 队列中的尾部节点 */
private transient final PaddedAtomicReference < QNode > tail;
static final class PaddedAtomicReference < T > extends AtomicReference T > {
    // 使用很多4个字节的引用追加到64个字节
    Object p0,
    p1,
    p2,
    p3,
    p4,
    p5,
    p6,
    p7,
    p8,
    p9,
    pa,
    pb,
    pc,
    pd,
    pe;
    PaddedAtomicReference(T r) {
        super(r);
    }
}
public class AtomicReference <
    V > implements java.io.Serializable {
        private volatile V value;
        // 省略其他代码
        

追加字节能优化性能?这种方式看起来很神奇,但如果深入理解处理器架构就能理解其中的奥秘。让我们先来看看LinkedTransferQueue这个类,它使用一个内部类类型来定义队列的头节点(head)和尾节点(tail,而这个内部类PaddedAtomicReference相对于父类AtomicReference只做了一件事情,就是将共享变量追加到64字节。我们可以来计算下,一个对象的引用占4个字节,它追加了15个变量(共占60个字节,再加上父类的value变量,一共64个字节。

为什么追加64字节能够提高并发编程的效率呢?因为对于英特尔酷睿i7、酷睿、AtomNetBurst,以及Core SoloPentium M处理器的L1L2L3缓存的高速缓存行是64个字节宽,不支持部分填充缓存行,这意味着,如果队列的头节点和尾节点都不足64字节的话,处理器会将它们都读到同一个高速缓存行中,在多处理器下每个处理器都会缓存同样的头、尾节点,当一个处理器试图修改头节点时,会将整个缓存行锁定,那么在缓存一致性机制的作用下,会导致其他处理器不能访问自己高速缓存中的尾节点,而队列的入队和出队操作则需要不停修改头节点和尾节点,所以在多处理器的情况下将会严重影响到队列的入队和出队效率。Douglea使用追加到64字节的方式来填满高速缓冲区的缓存行,避免头节点和尾节点加载到同一个缓存行,使头、尾节点在修改时不会互相锁定。

那么是不是在使用volatile变量时都应该追加到64字节呢?不是的。在两种场景下不应该使用这种方式。

  • 缓存行非64字节宽的处理器。如P6系列和奔腾处理器,它们的L1L2高速缓存行是32个字节宽。
  • 共享变量不会被频繁地写。因为使用追加字节的方式需要处理器读取更多的字节到高速缓冲区,这本身就会带来一定的性能消耗,如果共享变量不被频繁写的话,锁的几率也非常小,就没必要通过追加字节的方式来避免相互锁定。

不过这种追加字节的方式在Java 7下可能不生效,因为Java 7变得更加智慧,它会淘汰或重新排列无用字段,需要使用其他追加字节的方式