volatile 是轻量级的synchronized,它在多处理开发中保证了共享变量的“可见性”。可见性的意思是当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。如果volatile变量修饰符使用恰当的话,它比synchronized的使用和执行成本更低,因为它不会引起线程上下文的切换和调度。
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);
}
}
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前缀的指令在多核处理器下会引发两件事情:
把对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变量自身具有下列特性:
从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关系:
A线程写一个volatile变量后,B线程读同一个volatile变量。A线程在写volatile变量之前所有可见的共享变量,在B线程读同一个volatile变量后,将立即变得对B线程可见。
volatile写和volatile读的内存语义总结:
线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了消息(对共享变量所做的修改)——通信。
线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的消息(volatile写)。
线程A写一个volatile变量,线程B读一个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息。
为了实现volatile内存语义,JMM会分别限制两种类型的重排序:编译重排序和处理器重排序。
如下JMM针对编译器制定的volatile重排序规则:
当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。
基于保守策略的JMM内存屏障插入策略(为了实现volatile内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序) :
保守策略下volatile写插入内存屏障后生成指令的示意图:
保守策略下volatile读插入内存屏障后生成指令的示意图:
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写
} …
// 其他方法
}
编译器在生成字节码时做出如下优化:
注意:最后的StoreLoad屏障不能省略。因为第二个volatile写之后,方法立即return。此时编译器可能无法准确断定后面是否会有volatile读或写,为了安全起见,编译器通常会在这里插入一个StoreLoad屏障。
加锁机制可以确保可见性和原子性,volatile变量只能确保可见性。volatile变量通常用做某个操作完成,发生中断或者状态的标志。
仅当volatile变量能简化代码的实现以及对同步策略的验证时才应该被使用。如果在验证正确性时需要对可见性进行复杂的判断,就不应该使用volatile变量。volatile变量的正确使用方式包括:确保自身状态的可见性,确保它们所引用对象的状态的可见性,以及标识一些重要的程序生命周期时间的发生(eg:初始化或关闭)
当且仅当满足以下所有条件时,才应该使用volatile变量:
对变量的写入操作不依赖变量的当前值,或者能确保只有单个线程更新变量的值
该变量不会与其他状态变量一起纳入不变性条件之中
在访问变量时不需要加锁