内存模型对volatile的特殊处理

当一个变量被定义成volatile之后,它将具备两项特性:

特征一、保证此变量对所有线程的可见性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。

而普通变量并不能做到这一点,普通变量的值在线程间传递时均需要通过主内存来完成。比如A修改了一个值,这个值要写回主内存,而线程B只有在A写回主内存后并且读取主内存,才会得知该值改变了。

volatile变量在各个线程的工作内存中是不存在一致性问题的(从物理存储的角度看,各个线程的工作内存中volatile变量也可以存在不一致的情况,但由于每次使用之前都要先刷新,执行引擎看不到不一致的情况,因此可以认为不存在一致性问题),但是Java里面的运算操作符并非原子操作,这导致volatile变量的运算在并发下一样是不安全的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class VolatileTest {
public static volatile int race = 0;

public static void increase() {
race++;
}

private static final int THREADS_COUNT = 20;

public static void main(String[] args) {
Thread[] threads = new Thread[THREADS_COUNT];
for (int i = 0; i < THREADS_COUNT; i++) {
threads[i] = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
increase();
}
}
});
threads[i].start();
}
// 等待所有累加线程都结束
while (Thread.activeCount() > 1)
Thread.yield();
System.out.println(race);
}
}

这段代码开了20个线程来对race变量进行++,但是会发现每次运行都是不一样的数。volatile关键字保证了race被取到操作栈顶时是正确的,但是在执行race++操作时,由于++操作不是原子进行的。反编译后increase()方法代码如下:

1
2
3
4
5
6
7
8
9
10
11
public static void increase();
Code:
Stack=2, Locals=0, Args_size=0
0: getstatic #13; //Field race:I
3: iconst_1
4: iadd
5: putstatic #13; //Field race:I
8: return
LineNumberTable:
line 14: 0
line 15: 8

这步++有4个命令,在中途会被中断,然后其他线程会对race进行修改,而中断完之后再回来执行,就有可能把旧值写到race。

由于volatile关键字只能保证可见性,所以不符合以下场景中需要加锁来保证原子性:

​ 1)运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。

​ 2)变量不需要与其他的状态变量共同参与不变约束。

特征二、volatile变量禁止指令重排序优化,普通的变量仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。

使用volatile修饰的变量,在进行编译后会生成一个内存屏障(与垃圾回收那里的内存屏障不同),表示重排序时,屏障后面的指令不能出现再屏障之前。当只有一个处理器时,并不需要屏障,但是当有多个,则需要屏障来保证一致性。

从硬件上讲,指令重排序指处理器采用了允许将多条指令不按程序的顺序分开发送给各个相应的电路单元进行处理,并不是指可以任意排序,因为要保证运行结果与正序运行一致。

一些其他的规定

1、使用volatile修饰的关键字还有一些规定:只有当线程T对变量V执行的前一个动作是load的时候,线程T才能对变量V执行use动作;并且,只有当线程T对变量V执行的后一个动作是use的时候,线程T才能对变量V执行load动作。线程T对变量V的use动作可以认为是和线程T对变量V的load、read动作相关联的,必须连续且一起出现。

这条规则要求在工作内存中,每次使用V前都必须先从主内存刷新最新的值用于保证能看见其他线程对变量V所做的修改

2、只有当线程T对变量V执行的前一个动作是assign的时候,线程T才能对变量V执行store动作;并且,只有当线程T对变量V执行的后一个动作是store的时候,线程T才能对变量V执行assign动作。线程T对变量V的assign动作可以认为是和线程T对变量V的store、write动作相关联的,必须连续且一起出现。

这条规则要求在工作内存中,每次修改V后都必须立刻同步回主内存中,用于保证其他线程可以看到自己对变量V所做的修改。

3、假定动作A是线程T对变量V实施的use或assign动作,假定动作F是和动作A相关联的load或store动作,假定动作P是和动作F相应的对变量V的read或write动作;与此类似,假定动作B是线程T对变量W实施的use或assign动作,假定动作G是和动作B相关联的load或store动作,假定动作Q是和动作G相应的对变量W的read或write动作。如果A先于B,那么P先于Q。

这条规则要求volatile修饰的变量不会被指令重排序优化,从而保证代码的执行顺序与程序的顺序相同。

总结

volatile关键字其实只是保证了在每个线程之间可以感知到数据的变化。使用volatile修饰的关键字,在每次使用时都会刷新,拿到最新的值。而且对该关键字修改可以直接让其他线程感知到,并不需要像普通关键字,先由线程A的工作内存写入主内存,然后线程B从主内存读取才能感知到数据的变化。另一特性就是使用volatile修饰的关键字不会受指令重排序的影响。

参考

《深入理解Java虚拟机》