全文是在阅读《JAVA并发编程的艺术》后进行的个人总结与摘录。
volatile的作用
在多处理器开发中保证了共享变量的“可见性”:当一个线程修改一个共享变量时,另外一个线程马上就能读到这个修改后的值。如果使用恰当,它比synchronized的使用和执行成本更低,它不会引起线程上下文的切换和调度
- 保证共享变量的可见性(可见性)
- 禁止了指令重排序(指令有序性)
- 复合操作不能保证原子性(不能保证原子性)
保证共享变量的可见性
有volatile变量修饰的共享变量进行写操作的时候字节码转成汇编语言会多出这行代码;0x01a3de24: lock addl $0×0,(%esp);
,这行代码的作用:在多核处理器下会引发了两件事情
1)将当前处理器缓存行的数据写回到系统内存。
2)这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。
现代处理器一般都是多核处理器,为了提高处理速度,处理器不直接和主内存进行通信,而是先将系统内存的数据读到各自的高速缓存中后再对高速缓存中的数据进行操作,但是不知道何时会写到主内存中。
如果对声明了volatile的变量进行写操作的时候字节码转成汇编语言会多出这行代码;0x01a3de24: lock addl $0×0,(%esp);
,将这个变量所在缓存行的数据写到主内存。这个时候虽然主内存更新了,但是CPU2中的高速缓存并没有更新。所以在多处理器下,实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当检查到过期就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行操作的时候,会重新从主内存中把数据读到高速缓存中再操作。
禁止了指令重排序
先说说何谓重排序:
在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。
-
编译器重排序
1)编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。 -
处理器重排序
1)指令级并行的重排序。现代处理器在数据间不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
2) 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
对于处理器重排序,Java编译器在生成指令序列时,会插入特定类型的内存屏障指令,通过内存屏障指令来禁止特定类型的处理器重排序,来保证有序性。
所以开发者只需要处理好在编译器优化重排序即可
编译器会在不改变单线程程序语义的前提下(会遵守数据依赖性,),可以重新安排语句的执行顺序。线程在单线程的语义下得到了保证,但是多线程情况下是不被保证的。
数据依赖性
行为 | 例子 |
---|---|
写后读 | a = 1;System.out.println(a) |
写后写 | a = 1;a=2 |
读后写 | System.out.println(a);a = 1; |
以上三种行为都存在数据依赖性,两行代码之间重排序都会改变程序语义
重排序对多线程的影响
public static void main(String[] args) {
ReorderExample example = new ReorderExample();
new Thread(new Runnable() {
@Override
public void run() {
example.reader();
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
example.writer();
}
}).start();
}
static class ReorderExample {
private int a = 0;
private boolean flag = false;
public void writer() {
a = 1; // 1
flag = true; // 2
}
public void reader() {
if (flag) { // 3
int i = a * a; // 4
System.out.println(Thread.currentThread().getName()+"结果"+i);
}
}
}
控制台可能输出三种情况
情况1:Thread-0结果1,这个比较好理解线程执行完writer()方法,另一个线程执行了reader()
情况2:没输出 reader()优先执行,flag没做修改,if进不去
情况3:Thread-0结果0,指定之间做了重排序
具体说下情况3 ,为什么flag为true时,a还等于0;
假设有两个线程A和B,A首先执行writer()方法,随后B线程接着执行reader()方法。线程B先读取了flag为true,在执行i=a*a时候,由于操作1和操作2没有数据依赖关系,编译器可以对这两个操作重排序,那么线程A的执行顺序就有可能是先执行flag=true,再执行a=1;在这之间线程B执行reader()方法进入if语句,输出结果为0的情况。
将代码稍加改造,使用volatile修饰flag变量。
static class ReorderExample {
private int a = 0;
private volatile boolean flag = false;
public void writer() {
a = 1; // 1
flag = true; // 2
}
public void reader() {
if (flag) { // 3
int i = a * a; // 4
System.out.println(Thread.currentThread().getName()+"结果"+i);
}
}
}
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定场景中的重排序。
- 在每个volatile写操作的前面插入一个StoreStore屏障。
StoreStore屏障可以保证在volatile写之前,StoreStore屏障将保障上面所有的普通写在volatile写之前刷新到主内存。 - 在每个volatile写操作的后面插入一个StoreLoad屏障。
volatile写后面的StoreLoad屏障。此屏障的作用是避免volatile写与后面可能有的volatile读/写操作重排序。 - 在每个volatile读操作的后面插入一个LoadLoad屏障和LoadStore屏障。
禁止处理器把上面的volatile读与下面的普通读重排序
禁止处理器把上面的volatile读与下面的普通写重排序
StoreStore屏障,StoreLoad屏障
LoadLoad屏障,LoadStore屏障
其实从对于volatile的使用中也可以推测出来,volatile写会将之前的所有操作都刷新到主内存中,如果与上面的普通写交换了顺序,这个普通写便没法刷新到主内存中去。volatile读会重新获取主内存中的数据到缓存中去,先拉取主内存中最新的再执行普通数据读写。要不然可能读取的是缓存中的过期数据。
所以一般在开发不存在数据依赖的情况下可以将volatile写放在最后,volatile读放在最前。
volatile重排序规则表
- 当第一个操作为普通写/读,第二个操作为volatile写,因为要将“volatile写”操作之前的共享变量刷新到主内存中去,所以不允许重排序
- 当第一个操作为volatile读,第二个操作无论什么都不能重排序,因为读vaolatile变量会将本地的缓存都设置无效,从主内存获取,所以后续操作的共享变量都要从主内存中重新获取
复合操作不能保证原子性
先看一个例子
public class Main {
public volatile int i = 0;
public static void main(String[] args) {
Main t = new Main();
for (int i = 0; i < 10; i++) {
new Thread() {
@Override
public void run() {
for (int j = 0; j < 1000; j++) {
t.i++;
}
System.out.println(Thread.currentThread().getName()+":"+t.i);
}
}.start();
}
}
}
控制台输出
Thread-0:1000
Thread-2:2000
Thread-3:3000
Thread-6:4000
Thread-1:5611
Thread-4:5890
Thread-5:6890
Thread-9:7890
Thread-8:8890
Thread-7:9890
从代码上看开启了10个线程,每个线程进行i++,感觉最终的结果是10*1000得到一万。但是并不是(多执行几次能看到这个结果)。
这里要分清楚线程可见性和原子性。
i++:对volatile变量进行 i++的过程可以分为三步:
- 首先获取i的值
- 其次对i的值进行加1
- 最后将新值赋值给i
- 最后写回主内存
假设有线程AB。A线程在读取到i的值100,+1得到101,这个时候B线程读取也读取到i的值为100。
注意这里A线程还没将101赋值给i(没有写volatile变量),所以B线程读取到依旧是100。
只要B线程在A线程修改i(写volatile变量)之前读取到数据,那么数据就会发生少加。这跟线程可见性无关,线程可见性是指,线程AB同时拥有i的旧的值位于高速缓存,在A线程写volatile变量刷新到主内存种,B线程再去获取时之间从主线程获取。
其上发生的是同时读取主内存,又写回主内存。虽然两个线程都执行了一次++但是其最终结果还是101
如下图
线程可见性
线程可见性,只是体现在读取时,不再从缓存中获取,而是到主内存中获取。
理解volatile特性的一个好方法是把对volatile变量的单个读/写,看成是使用同一个锁对这些单个读/写操作做了同步。以下两段代码的语义相同。
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(); // A:调用已同步的读方法
temp += 1L; // B:普通写操作
set(temp); // C:调用已同步的写方法
}
public synchronized long get() { // 对单个的普通变量的读用同一个锁同步
return vl;
}
}
以上程序表明一个volatile变量的读或者写操作,与一个普通变量使用用一个锁来同步是等价的。
对一个volatile变量的读,总是能看到对这个volatile变量最后的写入。因为当你读volatile变量时,获取了锁,其他线程已经无法对变量进行修改。
但是volatile的复合操作或类似于volatile++这种操作,这些操作整体上不具有原子性。因为在你执行B语句时,已经释放了锁,这时就有可能有其他线程已经修改了变量的值,你再执行C语句时,volatile变量的值已经被修改过了。
简而言之,volatile变量自身具有下列特性。
- 可见性。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入
- 原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。
volatile写-读的内存语义
当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。本地内存中所有的共享变量值刷新到主内存中
例如这里的a=1也会一并被送入主内存中。
当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
读到一个volatile变量时,本地共享变量都无效,线程回去主内存读过。
下面对volatile写和volatile读的内存语义做个总结。
- 线程A写一个volatile变量,类似线程A向接下来将要读这个volatile变量的某个线程发出了(其对在写volatile之前的对共享变量所做修改的)消息。
- 线程B读一个volatile变量,类似线程B接收了之前某个线程发出的(在写这个volatile变量之前对共享变量所做修改的)消息。线程会把本地的缓存设为无效,重新从主内存中读所有的共享变量
- 线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程可以看作线程A通过主内存向线程B发送消息。