从JAVA、JVM以及汇编三个层面去理解volatile关键字


volatile的特性

  • 可见性:对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
  • 原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性(基于这点,我们通过会认为volatile不具备原子性)。
  • 有序性:对volatile修饰的变量的读写操作前后加上各种特定的内存屏障来禁止指令重排序来保障有序性。

volatile写-读的内存操作方式

  • 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。
  • 当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,线程接下来将从主内存中读取共享变量。

volatile在hotspot的实现

  • 字节码解释器实现:直接调用内存屏障OrderAccess::storeload()
  • 模板解释器实现:使用lock前缀指令实现。lock addl $0, $0(%rsp)
  • // (JMM源码)下面这两句插入了一条lock前缀指令: lock addl $0, $0(%rsp)
  • lock(); // lock前缀指令
  • addl(Address(rsp, offset), 0); // addl $0, $0(%rsp)

lock前缀指令的作用

  • 确保后续指令执行的原子性。在Pentium及之前的处理器中,带有lock前缀的指令在执行期间会锁住总线,使得其它处理器暂时无法通过总线访问内存,很显然,这个开销很大。在新的处理器中,Intel使用缓存锁定来保证指令执行的原子性,缓存锁定将大大降低lock前缀指令的执行开销。
  • LOCK前缀指令具有类似于内存屏障的功能,禁止该指令与前面和后面的读写指令重排序。
  • LOCK前缀指令会等待它之前所有的指令完成、并且所有缓冲的写操作写回内存(也就是将store buffer中的内容写入内存)之后才开始执行,并且根据缓存一致性协议,刷新store buffer的操作会导致其他cache中的副本失效。

volatile在汇编层面的实现

  • 查看方式jvm参数增加(需要一个hsdis-amd64.dll文件):-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -Xcomp
  • 内容较多,可以在日志中找到:lock addl $0x0,(%rsp)
  • 《64-ia-32-architectures-software-developer-vol-3a-part-1-manual》书中有这么一段话,翻译后:32位的IA-32处理器支持对系统内存中的位置进行锁定的原子操作。这些操作通常用于管理共享的数据结构(如信号量、段描述符、系统段或页表),在这些结构中,两个或多个处理器可能同时试图修改相同的字段或标志。处理器使用三种相互依赖的机制来执行锁定的原子操作:
  • 有保证的原子操作
  • 总线锁定,使用LOCK#信号和LOCK指令前缀
  • 缓存一致性协议,确保原子操作可以在缓存的数据结构上执行(缓存锁);这种机制出现在Pentium 4、Intel Xeon和P6系列处理器中

volatile可见性实现原理

  • volatile修饰的变量的read、load、use操作和assign、store、write必须是连续的,即修改后必须立即同步回主内存,使用时必须从主内存刷新,由此保证volatile变量操作对多线程的可见性。

volatile有序性实现原理

  • 指令的重排序:java语言规范规定JVM线程内部维持顺序化语义。即只要程序的最终结果与它顺序化情况的结果相等,那么指令的执行顺序可以与代码顺序不一致
  • 为什么要重排序:JVM能根据处理器特性(CPU多级缓存系统、多核处理器等)适当的对机器指令进行重排序,使机器指令能更符合CPU的执行特性,最大限度的发挥机器性能。

代码执行过程中可能涉及到的重排序.png

内存屏障的作用

  • 阻止屏障两边的指令重排序
  • 刷新处理器缓存/冲刷处理器缓存

volatile重排序规则

volatile重排序规则.png

  • 第二个操作是volatile写,不管第一个操作是什么都不会重排序
  • 第一个操作是volatile读,不管第二个操作是什么都不会重排序
  • 第一个操作是volatile写,第二个操作是volatile读,也不会发生重排序

JMM内存屏障插入策略

  • 在每个volatile写操作的前面插入一个StoreStore屏障:(指令Store1; StoreStore; Store2),在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
  • 在每个volatile写操作的后面插入一个StoreLoad屏障:(指令Store1; StoreLoad; Load2),在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能
  • 在每个volatile读操作的后面插入一个LoadLoad屏障:(指令Load1; LoadLoad; Load2),在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
  • 在每个volatile读操作的后面插入一个LoadStore屏障:(指令Load1; LoadStore; Store2),在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。

JMM内存屏障插入策略.png

验证方式

package com.cangqiong.utils.test;

public class Test2 {

	private static int x = 0, y = 0;

	private static int a = 0, b = 0;

	public static void main(String[] args) throws InterruptedException {
		int i = 0;
		while (true) {
			i++;
			x = 0;
			y = 0;
			a = 0;
			b = 0;

			/**
			 * x,y:
			 */
			Thread thread1 = new Thread(new Runnable() {
				@Override
				public void run() {
					a = 1;
					x = b;

				}
			});
			Thread thread2 = new Thread(new Runnable() {
				@Override
				public void run() {
					b = 1;
					y = a;
				}
			});

			thread1.start();
			thread2.start();
			thread1.join();
			thread2.join();

			System.out.println("第" + i + "次(" + x + "," + y + ")");
			
			// 不出现重排序,不会出现这种状态!
			if (x == 0 && y == 0) {
				break;
			}

		}

	}
}

硬件层内存屏障

  • lfence,是一种Load Barrier 读屏障
  • sfence, 是一种Store Barrier 写屏障
  • mfence, 是一种全能型的屏障,具备lfence和sfence的能力
  • Lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。

结束语

  • 获取更多有价值的文章,让我们一起成为架构师!
  • 关注公众号,可以让你逐步对MySQL以及并发编程有更深入的理解!
  • 这个公众号,无广告!!!每日更新!!!
    作者公众号.jpg