深入理解 volatile

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

volatile 定义与实现原理

 volatile的定义:Java编程语言允许线程访问共享变量,为了保证共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量

如下展示使用hsdis查看jit生成的汇编代码过程,通过汇编指令来查看volatile的语义:

//源代码
public class VolatileTest {

    volatile static StringBuilder str = new StringBuilder("Hello world!");

    public static void main(String[] args) {

        str.append("My Lover!");

        System.out.println(str);
    }
 }
  • 下载hsdis工具,将其放在 %JAVA_HOME%\jre\bin\server 目录下

image

  • 使用如下命令得到汇编代码
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly

//了解内联优化的实际情况以及优化发生的级别:
java -XX:+PrintCompilation -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining -XX:+TieredCompilation VolatileTest

部分汇编代码如下:

 0x000000000328326a: or     %r15,%rdi
  0x000000000328326d: lock cmpxchg %rdi,(%rdx)
  0x0000000003283272: jne    0x00000000032836c5
  0x0000000003283278: jmpq   0x00000000032832b8
  0x000000000328327d: mov    0x8(%rdx),%edi
  0x0000000003283280: shl    $0x3,%rdi
  0x0000000003283284: mov    0xa8(%rdi),%rdi
  0x000000000328328b: lock cmpxchg %rdi,(%rdx)
  0x0000000003283290: mov    (%rdx),%rax
  0x0000000003283293: or     $0x1,%rax
  0x0000000003283297: mov    %rax,(%rsi)
  0x000000000328329a: lock cmpxchg %rsi,(%rdx)  //volatile修饰的变量前加上lock前缀
  0x000000000328329f: je     0x00000000032832b8
  0x00000000032832a5: sub    %rsp,%rax
  0x00000000032832a8: and    $0xfffffffffffff007,%rax
  0x00000000032832af: mov    %rax,(%rsi)
  0x00000000032832b2: jne    0x00000000032836c5
  0x00000000032832b8: movabs $0x16c811e0,%rsi   ;   {metadata(method data for {method} {0x0000000016a9ff08} 'append' '(C)Ljava/lang/StringBuffer;' in 'java/lang/StringBuffer')}
  0x00000000032832c2: mov    0xdc(%rsi),%edi
  0x00000000032832c8: add    $0x8,%edi
  0x00000000032832cb: mov    %edi,0xdc(%rsi)
  0x00000000032832d1: movabs $0x16a9ff00,%rsi   ;   {metadata({method} {0x0000000016a9ff08} 'append' '(C)Ljava/lang/StringBuffer;' in 'java/lang/StringBuffer')}
  0x00000000032832db: and    $0x1ff8,%edi
  0x00000000032832e1: cmp    $0x0,%edi
  0x00000000032832e4: je     0x00000000032836d8  ;*aload_0
                                                ; - java.lang.StringBuffer::append@0 (line 380)

 volatile的原理:volatile变量修饰的共享变量进行写操作时会在汇编代码前加上lock前缀,lock前缀的指令在多核处理器下会引发两件事情:

  • 将当前处理器缓存行的数据写回到系统内存
  • 该写回内存的操作会使在其他CPU里缓存了该内存地址的额数据无效
volatile 的特性

 把对volatile变量的单个 读/写 看成是使用同一个锁对这些单个 读/写 操作做了同步,如下是使用 volatile变量 和 使用 方法锁(synchronized) 实现等同的语义:

class VolatileFeaturesExample {
    volatile long vl = 0L;          // 使用volatile声明64位的long型变量

    public void set(long l) {
        vl = l;                     // 单个volatile变量的写(具有原子性)
    } 

    public void getAndIncrement () {
        vl++;                       // 复合(多个)volatile变量的读/写(不具有原子性)
    } 
    public long get() {
        return vl;                  // 单个volatile变量的读
    }
}


class VolatileFeaturesExample {
    long vl = 0L; // 64位的long型普通变量

    public synchronized void set(long l) { // 对单个的普通变量的写用同一个锁同步
        vl = l;
    } 

    public void getAndIncrement () {        // 普通方法调用
        long temp = get();              // 调用已同步的读方法
        temp += 1L;                     // 普通写操作
        set(temp);                      // 调用已同步的写方法
    } 

    public synchronized long get() { // 对单个的普通变量的读用同一个锁同步
        return vl;
    }
}

 锁的 happens-before规则保证释放锁和获取锁的两个线程之间的内存可见性,这意味着对一个volatile变量的读,总是能看到任意线程对这个volatile变量最后的写入。锁的语意决定了临界区代码的执行具有原子性,这意味着volatile修饰的64位long型变量和double变量,在对其进行 读/写 时具有原子性。如果是多个 volatile操作(类似于 volatile++这种复合操作)就不具有原子性。volatile变量自身具有下列特性:

  • 可见性。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
  • 原子性:对任意单个volatile变量的 读/写 具有原子性,但类似于volatile++这种复合操作不具有原子性。
volatile建立的 happens-before

 从JDK1.5开始,volatile变量的 写-读 可以实现线程间的通信。从内存语意上来讲,volatile的 写-读 与锁的 释放-获取 有相同的内存效果:volatile写和锁的释放有相同的内存语义;volatile读与锁的获取具有相同的内存语义。

//假设线程A执行writer方法,线程B执行reader方法
class VolatileExample {
    int a = 0;
    volatile boolean flag = false;

    public void writer() {
        a = 1;              // 1 线程A修改共享变量
        flag = true;        // 2 线程A写volatile变量
    } 

    public void reader() {
        if (flag) {         // 3 线程B读同一个volatile变量
        int i = a;          // 4 线程B读共享变量
        ……
        }
    }
}

 根据happens-before规则,如上过程建立了3类happens-before关系:

  • 根据程序次序规则:1 happens-before 2;3 happens-before 4。
  • 根据volatile规则:2 happens-before 3。
  • 根据happens-before的传递性规则:1 happens-before 4。

 A线程写一个volatile变量后,B线程读同一个volatile变量。A线程在写volatile变量之前所有可见的共享变量,在B线程读同一个volatile变量后,将立即变得对B线程可见。

volatile的内存语义
  • volatile写的内存语义:当写一个volatile变量时,JMM(Java内存模型)会把该线程对应的本地内存中的共享变量值刷新到主内存。

image

  • volatile读的内存语义:当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。

image

volatile写和volatile读的内存语义总结:

  • 线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了消息(对共享变量所做的修改)——通信。

  • 线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的消息(volatile写)。

  • 线程A写一个volatile变量,线程B读一个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息。

volatile内存语义的实现

 为了实现volatile内存语义,JMM会分别限制两种类型的重排序:编译重排序和处理器重排序。

如下JMM针对编译器制定的volatile重排序规则:

  • 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。

  • 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。

  • 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

基于保守策略的JMM内存屏障插入策略(为了实现volatile内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序) :

  • 在每个volatile写操作的前面插入一个StoreStore屏障
  • 在每个volatile写操作的后面插入一个StoreLoad屏障
  • 在每个volatile读操作的后面插入一个LoadLoad屏障
  • 在每个volatile读操作的后面插入一个LoadStore屏障

保守策略下volatile写插入内存屏障后生成指令的示意图:

image

保守策略下volatile读插入内存屏障后生成指令的示意图:

image

volatile写-读内存语义的常见使用模式是:一个写线程写volatile变量,多个读线程读同一个volatile变量。当读线程的数量大大超过写线程时,选择在volatile写之后插入StoreLoad屏障将带来可观的执行效率的提升。从这里可以看到JMM在实现上的一个特点:首先确保正确性,然后再去追求执行效率。

 上述volatile写和volatile读的内存屏障非常保守。在实际执行时,只要不改变volatile 写-读 的内存语义,编译器可以根据具体情况省略不必要的内存屏障,如下例子:

class VolatileBarrierExample {
    int a;
    volatile int v1 = 1;
    volatile int v2 = 2;

    void readAndWrite() {
        int i = v1; // 第一个volatile读
        int j = v2; // 第二个volatile读
        a = i + j; // 普通写
        v1 = i + 1; // 第一个volatile写
        v2 = j * 2; // 第二个 volatile写
    } …
    // 其他方法
}

编译器在生成字节码时做出如下优化:

image

注意:最后的StoreLoad屏障不能省略。因为第二个volatile写之后,方法立即return。此时编译器可能无法准确断定后面是否会有volatile读或写,为了安全起见,编译器通常会在这里插入一个StoreLoad屏障。

volatile变量的使用

加锁机制可以确保可见性和原子性,volatile变量只能确保可见性。volatile变量通常用做某个操作完成,发生中断或者状态的标志。

仅当volatile变量能简化代码的实现以及对同步策略的验证时才应该被使用。如果在验证正确性时需要对可见性进行复杂的判断,就不应该使用volatile变量。volatile变量的正确使用方式包括:确保自身状态的可见性,确保它们所引用对象的状态的可见性,以及标识一些重要的程序生命周期时间的发生(eg:初始化或关闭)

当且仅当满足以下所有条件时,才应该使用volatile变量:

  • 对变量的写入操作不依赖变量的当前值,或者能确保只有单个线程更新变量的值

  • 该变量不会与其他状态变量一起纳入不变性条件之中

  • 在访问变量时不需要加锁

猜你喜欢

转载自blog.csdn.net/king123456man/article/details/81673314