Volatile 关键字的原理和实现
1. 前言
Volatile 是一个经常用于多线程并发下的关键字,作用是标记某个变量,让其多个线程并发读写时必须取最新的值。理解volatile关键字,先要理解内存交互操作。
2. 内存间交互操作
JVM 规定了以下8种操作是原子性的(因为long和double类型的非原子性协定,以下只针对32位的基础类型)。作为使用者一般只要用先行发生(Happens-Before)原则,思考下面加粗的内容即可。即如果A发生的操作能被B观察到,且指令顺序不在JVM规定的先行发生规则时,就有可能发生指令重排而线程不安全。
- lock(锁定)
- unlock(解锁)
- read(读取)
- load(载入)
- use(使用)
- assign(赋值)
- store(存储)
- write(写入)
3. Volatitle特性
一般来说有两个特性:1. 对所有线程的可见性;2. 禁止指令重排优化。
3.1 对所有线程的可见性
一个线程修改了 Volatitle变量后,能将影响立即同步到其他线程。其做法是比较容易理解的,线程的私有变量是放在栈帧里不让访问的(子内存区),共享变量则是栈帧里保留了共享变量的引用,读写时再去主内存区(堆或者直接内存)里读写。那么在读Volatitle变量时,必须先从主内存区load载入然后立即read读取。写Volatitle变量时,在write写值后立即store存回主内存区。
这里的关键就是立即,两个原子性的操作组成了一个新的原子操作(load-read、write-store),期间不允许干其他能影响该值的事情,以此保证读时总是读到最新值,写时立即能影响到其他线程。
这里需要注意,Volatitle在仅有单纯读和单纯写时是线程安全的,在做读写计算操作时并不是线程安全的。这是因为java的运算操作符不是原子操作。
public class Test{
public static volatile int sum = 0;
public static void increase() {
sum++;
}
public static void main(String[] args) {
Thread[] threads = new Thread[10];
for(int i = 0; i < 10; i++) {
threads[i] = new Thread(new Runnable() {
@Override
public void run(){
for(int i = 0; i < 10000; i++) {
increase();
}
System.out.println("Thread now = " + sum);
}
});
threads[i].start();
}
while(Thread.activeCount() > 1) {
Thread.yield();
}
System.out.println(sum);
}
}
预计应该是10^6,但一般都是小于该值,《深入了解JAVA虚拟机》里解释了这个问题,从字节码上看,++指令会有一个将变量取至操作栈顶再加一赋值的操作,取栈顶时数据无误,但进行此时已经是子内存了,接下来加一和赋值时,主内存值可能已经修改,子内存和主内存不同步,故写回主内存时数据子内存已经是过期数据。
3.2 禁止指令重排优化
因为read是原子的,write是原子的,单一线程内指令串行的,故保证有序。指令重排时会对所有线程的指令进行重新排序以优化执行效率,这个是机器级别的优化,故线程看自己时有序的,看其他线程就是乱序的,做为线程自身没法保证所有线程对volatile的变量的操作有序。
于是只能有JVM这个老大哥出面进行协调了,具体的做法是在读写的赋值前,JVM会插入一条lock addl $0x0,(%esp)
之类的指令,含义为对esp寄存器的值加0并写入缓存。因为lock不允许和专门的nop空指令配合使用,故用这种无意义的操作来替代空操作,同时用lock将缓存值写入内存中(stroe-write),构成了一个内存屏障(Memory Barrier 或 Memory Fence)。内存屏障告诉操作系统,在重排序时不允许将后面的指令排到内存屏障的前面,也就是只能在两个内存屏障间进行指令重排优化,这样就保证了屏障前的volatile变量修改值能立刻影响到所有处理器和线程。
参考资料:《深入理解java虚拟机》