深入理解Java并发编程(三):volatile关键字详解

一、volatile关键字简介

synchronized关键字是阻塞式同步,在线程竞争激烈的时候会逐渐由偏向锁膨胀为重量级锁。而volatile是JVM提供的最轻量级的同步机制。JMM告诉我们各个线程会将共享变量从主内存中拷贝到工作内存,然后执行引擎会基于工作内存中的数据进行操作处理。不过线程在工作内存中进行操作后将会何时写入主内存中?这个时机普通机制是没有规定的。

volatile一般用于修饰会被不同线程访问和修改的变量,而针对volatile修饰的变量给JVM给了规定:线程对volatile变量的修改会立刻被其他线程感知,即被volatile修饰的变量能够保证每个线程能够获取该变量的最新值,这样就不会出现数据脏读的现象,保证了数据的可见性

volatile具有可见性和有序性

二、volatile实现原理

加入volatile关键字的代码的class字节码中会多出了一个lock前缀指令,lock指令相当于一个内存屏障,主要做了三件事:

  1. 重排序时不能把后面的指令重排序到内存屏障之前的位置
  2. 将当前处理器缓存行的数据写回系统内存
  3. 这个写回内存的操作会使其他CPU里缓存的该内存地址的数据无效,即新写入的值对别的线程可见

经过这一波操作后,其他的线程发现自己工作内存中的缓存失效后,就会从内存中重新读取该变量数据,即保证了其他线程可以获取当前最新值。即可以说volatile实现了缓存一致性协议:每个处理器通过嗅探在总线上传播的数据来检查自己的缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。

三、volatile的happens-before关系和内存语义分析

在之前JMM一文中对happens-before规则介绍,有一条是:对一个volatile域的写,happens-before于任意后续对这个volatile域的读

来看一段代码

class VolatileExam{
	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;       //4
		}
	}
}

对volatile的happens-before分析:

  • 线程1先执行writer方法,然后线程2执行reader方法。
  • 我们由happens-before规则推知,2 happens-before 3 (volatile变量的写happens-before于任意后续对volatile变量的读)
  • 由传递性可以得知1 happens-before 4
  • 由happens-before规则:如果A happens-before B,则A的执行结果对B可见,且A的执行顺序先于B的执行顺序
  • 那么2的执行结果对3可见,也就是说线程1将flag修改为true,线程2能够迅速感知

volatile的内存语义分析:

  • 如果线程1先进行writer方法,随后线程2进行reader方法。一开始的本地都是a和flag的初始化状态
  • 在线程1线程2的本地内存里,线程1对初始值进行了修改并写入主内存中,而在线程2的本地内存里还是原来的值
  • 由于volatile变量写后,线程中本地内存中共享变量就会置为失效状态,因此线程2需要再次从主内存中读取最新的共享变量值。从横向看,线程1和线程2进行了通信,线程1在写volatile变量的时候告诉线程2:你的本地内存中的值是旧的
  • 线程2在读取volatile变量的时候就被告知目前自己的本地值是旧的,那线程2就只能去主内存中去取最新值了

四、volatile内存语义的具体实现

为了性能优化,JMM在不改变正确语义的前提下,会允许编译器和处理器对指令序列进行重排序,那如果想阻止重排序就得添加内存屏障。
深入理解Java并发编程(三):volatile关键字详解_第1张图片
四个内存屏障:

屏障类型 指令类型 说明
LoadLoadBarriers Load1;LoadLoad;Load2 确保Load1的数据的装载先于Load2及所有后续装载指令的装载
StoreStoreBarriers Store1;StoreStore;Store2 确保Store1数据对其他处理器可见(刷新到内存)先于Store2及所有后续存储指令的存储
LoadStoreBarriers Load1;LoadStore;Store2 确保Load1的数据的装载先于Store2及所有后续存储指令的存储
StoreLoadBarriers Store1;StoreLoad;Load2 确保Store1的数据对其他处理器可见(刷新到内存)先于Load2及所有后续的装载指令的装载

为了实现volatile内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序:

  1. 在每个volatile写操作的前面插入一个StoreStore屏障
  2. 在每个volatile写操作的后面插入一个StoreLoad屏障
  3. 在每个volatile读操作的后面插入一个LoadLoad屏障
  4. 在每个volatile读操作的后面插入一个LoadStore屏障

StoreStore屏障:禁止上面的普通写和下面的volatile写重排序;
StoreLoad屏障:防止上面的volatile写与下面可能有的volatile读/写重排序
LoadLoad屏障:禁止下面所有的普通读操作和上面的volatile读重排序
LoadStore屏障:禁止下面所有的普通写操作和上面的volatile读重排序

五、volatile如何保证内存可见性

复习一下JMM的8种原子操作:

  1. lock(锁定):作用于主内存中的变量,把一个变量表示为一个线程独占的状态
  2. unlock(解锁):作用于主内存中的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
  3. read(读取):作用于主内存的变量,把一个变量的值从主内存读取到线程的工作内存中,以便于后面的load操作
  4. load(载入):作用于工作内存中的变量,把read操作从主存中得到的变量值放入工作内存中的变量副本
  5. use(使用):作用于工作内存中的变量,把工作内存中的一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作
  6. assign(赋值):作用于工作内存中的变量,把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时就执行这个操作
  7. store(存储):作用于工作内存中的变量,把工作内存中一个变量的值传送给主存中以便于后面的write操作
  8. write(写入):作用于主内存中的变量,把store操作从工作内存中得到的变量的值放入主内存的变量中

volatile规定:read、load、use动作必须连续出现assign、store、write动作必须连续出现
所以volatile保证:每次读取前必须先从主内存刷新最新的值,每次写入后必须立即同步回主内存当中。即volatile关键字修饰的变量看到的随时是自己的最新值。

六、volatile的注意事项

public class VolatileExample {

	private static volatile int counter = 0;

	public static void main(String[] args) {
		//开十个线程,让他们每个都自增10000次,理论上应该得到10000
		for (int i = 0; i < 10; i++) {
			Thread thread = new Thread(new Runnable() {
				@Override
				public void run() {
					for (int i = 0; i < 1000; i++)
						counter++;
				}
			});
			thread.start();
		}
		try {
			Thread.sleep(1000);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		System.out.println(counter);
	}
}

多次运行看出,每次都得不到10000,这说明volatile并不能保证整体原子性。问题就是counter++并不是一个原子操作,他包含了三个步骤:

  1. 读取counter的值
  2. 对counter+1
  3. 将新的值赋给变量counter

这么看来如果线程1读取counter到工作内存后,其他线程对这个值已经做了自增操作,那么线程A的这个值自然就是一个过期的值,造成了数据的脏读,因此结果必然小于10000。

如果想让volatile保证整体原子性,必须符合:

  1. 运算结果不依赖变量的当前值,或者能够确保只有一个线程修改变量的值;
  2. 变量不需要与其他的状态变量共同参与不变约束

如果编译器经过分析后,认定一个volatile变量只会被单个线程访问,那么编译器可以把这个volatile变量当做一个普通的变量来对待。

你可能感兴趣的:(Java并发与多线程)