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虚拟机》

上一篇:javascript中数组的map方法


下一篇:c# volatile 关键字的拾遗补漏