volatile的扩展分析(2)——happens-before 与 内存屏障

简介: volatile的扩展分析(2)——happens-before 与 内存屏障

前言

我们在volatile精讲篇提到了volatile的一个作用:在jvm编译和解释volatile相关字段的读写时会加入内存屏障,但是内存屏障其实是很大的一块内容,因此我们单独开一篇出来讲这个问题


免责声明

因为关于这块的内容实在太混乱了,而且由于硬件平台和JVM版本的不同,众多文献混于一体。导致在我探索的过程中,不断发现理论与实际的出入,或者说规范和实际的出入。一些细节的规则出现的非常突兀,并没有详细解释出处和原由。因此无法保证所有细节和分析都是对的。另外本文一些内容源自chatGPT,亦无法保证其阐述的一定正确。个人建议可以把本文作为一种角度的诠释,有助于解惑,如果你有好的看法或发现错误,可以在评论区留言


一、从Happens-before 到 内存屏障

1. 什么是Happens-before

我们知道由于硬件的效率的需要,指令在执行时是会被重排序的,但是重排序不能肆无忌惮没有任何限制。它必须要遵守一定的原则,有些地方可以重排序,有些地方不行:


这个规则即Happens-before规则,满足这些规则可以在避免被重排,这是由java内存模型规定的一组抽象规则,java虚拟机和java编译器都需要遵守这些规则:


  • 次序规则
  • 一个线程内,按照代码顺序,写在前面的操作先行发生于写在后面的操作

  • 锁定规则
  • 一个unLock操作先行发生于后面((这里的“后面”是指时间上的先后))对同一个锁的lock操作

  • volatile变量规则
  • 对一个volatile变量的写操作先行发生于后面对这个变量的读操作,前面的写对后面的读是可见的,这里的“后面”同样是指时间上的先后

  • 传递规则
  • 如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;

我们必须在这里强调三个东西:


  1. 上述次序规则和指令重排并不冲突,实际上次序规则仅仅是要求一个线程内自己的逻辑和结果正确,并非严格意义的每一个指令都必须按照代码顺序执行,否则就没有指令重排这种东西了,但如果我们想人为的在某些地方禁止重排,就需要显式的建立遵循happens-before规则的代码
  2. 反过来讲,如果我们在代码里没有显式地建立起happens-before关系的操作,那么就无法保证重排的结果,多线程下结果自然更无法保证
  3. 怎样显式的建立happens-before关系?要使用volatile关键字,synchronized关键字,lock,Semaphore等锁机制,这些机制会在适当的时候插入内存屏障,维护程序的执行顺序。

2. volatile变量规则解读——指令重排规则

我们本篇文章就是做volatile的扩展分析,因此以volatile变量的读写来举例说明它是如何维护程序的可见顺序的。


050f4981a54e4a62a2f3ca7d6a5e0cab.png


首先两个普通读写如果没有关联关系,那将无法控制指令重排我们已经知道了;而两次volatile读写之间控制重排也容易理解,因为这就是我们要做的,要想让volatile能维护程序顺序,volatile读写作为锚点,自身肯定要保证顺序不能乱。


关键在于一个volatile操作 和 一个普通操作,它们之间是否能重排,为什么?我们配合一段代码来看

Integer a = 0, 
Integer b = 0;
// 线程1 
{
  a = 10;  // 序列1
  b = 5;   // 序列2
}
// 线程2
{
  if (b = 5) {   // 序列3
    assert a == 10;   // 序列4
  }
}

上面的代码中,我们假定线程2比线程1后运行一些,我们的目的自然是想要确保通过断言,此时,就必须按照序列1 2 3 4的顺序来执行,然而序列1 和 2 可能会被重排,2 和 3 又跨线程可能不可见,这将导致断言可能无法通过。我们可以给变量a b 都使用volatile来修饰,那将确保结果正确,但如果只能给一个变量标上volatile呢?那我们怎么保证断言通过?


如果我们给a修饰上volatile,我们能得到什么排序?

序列1->序列2:我们得补充重排序规则:volataile写之后的指令不能重排序到volatile写之前;

序列3->序列4:有逻辑关系,必须遵守次序规则

序列2->序列3:跨线程,可能不可见,这我们就没法控制了,导致根本无法进入判断。


如果我们给b修饰上volatile呢?

序列1->序列2:得补充个重排序规则:volataile写之前的指令不能重排序到volatile写之后

序列2->序列3:happens-before 里的 ”volatile变量规则“

序列3->序列4:有逻辑关系,必须遵守次序规则,


其实我们已经看到了结果,第二个方案才行得通,你也许会说,你这仅仅是一个例子,我还是没明白为什么volatile的重排序规则是这样。其实可以简单说明:


因为现行的happens-before规则里,次序规则并没有严格意义上控制指令的执行顺序,只要保证本线程逻辑正确即可,然而当代码中出现volatile写的时候,鉴于volatile的规则(对一个volatile变量的写操作先行发生于后面对这个变量的读操作,前面的写对后面的读是可见的)支持跨线程,就意味着本线程可能会和其他线程以volatile指令为桥梁产生关联。因此本来可以随便乱序,反正没人看,到现在有人看了,那就必须保证当前该执行的指令都执行而且可见了,即volatile写之前的指令不允许排到后面去

同样的,当代码中有volatile读的时候,说明本线程可能在这个位置与其他线程发生关联,那么本线程也就不能随便乱序了,执行必须遵守次序规则,保证volatile读后面的指令能看到这次volatile读,即volatile读后面的指令不能重排到volatile读之前

更通俗的讲:次序规则本来只需保证本线程结果正确,真正的执行顺序其实是乱的。但现在因为代码里有volatile,将会与其他线程沟通,volatile相当于一个稳定的线程A 、B的传话筒,因此A线程在volatile写之前,得先把话准备好。而B线程则得在volatile读之后,再决定做什么


当然,这种规则导致volatile的禁止重排是单向的,更严格的办法自然是出现volatile,不管读写,前面的指令就不能跨过volatile排到后面,后面也不能排到前面,但那没有必要,至少在当前happens-before的原则下,这种控制会导致效率降低,却不能提供更多的帮助了,如下图的代码。

Integer a = 0; 
volatile Integer b = 0;
// 线程1 
{
  a = 10;  // 序列1
  b = 5;   // 序列2
  a = 20;  // 补充序列
}
// 线程2
{
  if (b = 5) {   // 序列3
    assert a == 10;   // 序列4
  }
}

我们加入了a = 20的语句,如果volatile写单纯禁止前面的指令往后排,那么此处可见性顺序就是


序列1->序列2 ———— volatile补充规则

序列1->补充序列 ———— 同变量,必须遵守次序规则

序列2->序列3 ———— volatile 的 happes-before规则

序列3->序列4 ————逻辑关联,次序规则


无法确定补充序列和序列2、3、4的可见性顺序,线程2读到的a可能是10,也可能是20,断言结果无法保证。

而如果volatile写双向禁止重排,那么此处可见顺序就是


序列1->序列2->补充序列 ———— volatile补充规则

序列2->序列3 ———— volatile 的 happes-before规则

序列3->序列4 ————逻辑关联,次序规则


我们可以看到,即使有更严格的重排序规则,这个补充序列 和 序列3、序列4的可见关系我们仍然无法确定,导致

线程2读到的a可能是10,也可能是20,断言结果无法保证。


产生这种情况的原因主要是a = 20这个指令和线程2 没有产生可见性保障:

采用现行规则,无法保障线程1里是否会有重排情况,即a = 20可能在b = 5后执行,也可能在b = 5前执行,导致线程2看到的a值并不确定。

采用严格规则,a =20肯定在 b = 5之后,但a = 20具体什么时候执行,什么时候会让线程2看到?同样没有定数,所以线程2看到的a值也是不确定。


二、内存屏障是什么?

我们前面讲了volatile的指令重排规则,以及它这么规定的原因。那这样的规则,肯定是需要实现的,所以在每一次volatile的读写前后,都需要插入一些代码来作为锚点,来做某个方向的禁止重排,这样的机制我们成为内存屏障。


内存屏障是一种处理器提供的机制,由一种特殊的指令实现,能够控制处理器对内存的访问和缓存同步,以确保线程能够正确地访问共享数据。在JVM中,当一个线程访问volatile字段时,JVM会通过插入内存屏障来保证所有线程都能够正确地读取和写入该字段的值。


三、内存屏障的类型

我们前面说了,内存屏障是一种处理器提供的机制,因此显而易见的,不同的CPU提供的内存屏障是不同的。而作为JVM,JVM自己在逻辑上也定义了一些内存屏障的类型,JVM规定的内存屏障类型是针对Java程序员的。这两种内存屏障不可混为一谈,它们针对的是不同的层面,实际上JVM自己定义的屏障仅仅是一种逻辑,还要根据不同的CPU“翻译”成CPU的屏障才能得以落实(具体情况见第四章)


我们先来看看JVM规定的几种内存屏障:Load Load屏障、Store Store屏障、Load Store屏障、Store Load屏障、Acquire屏障和Release屏障


我们再来看看CPU提供的内存屏障,以x86架构为例,看X86的常用内存屏障(Memory Barrier),也叫内存栅栏(Memeory Fence)


  • Load Barrier(载入屏障):Load Barrier用于确保所有先前的读操作都已经完成,从而防止CPU在读取未更新的缓存数据。Load Barrier主要用于数据同步和保证访问序列的正确性。

  • Store Barrier(存储屏障):Store Barrier用于确保所有先前的写操作都已经完成,从而防止CPU在写入更新的缓存数据之前将数据缓存到本地缓存中。Store Barrier主要用于数据同步和保证访问序列的正确性。

  • Full Memory Barrier(全内存屏障):Full Memory Barrier用于确保所有先前的读写操作都已经完成,从而防止CPU在读取或写入未更新的缓存数据。Full Memory Barrier主要用于保证内存访问的原子性。

同样的,作为三种内存屏障的对应实现,x86平台提供了三种指令LFENCE、SFENCE、MFENCE用来禁止重排序,比如MFENCE:MFENCE指令会等待所有之前已发出的内存指令(包括load 和 store指令)都完成后才会继续执行。


我们仍以volatila的读写为例,说明JVM是怎么为volatila的读写去找合适的屏障的,还是先看这张图

869178f029f8480ebe4a216665bbdeaf.png


然后再看JVM几种屏障的解释

image.png

最后得到这样的一张图(官方图)

190d3cdc915747718117c7dc106a27fe.png

那么我们是不是可以根据这张图得出一个结论:

在volatile读的时候限制后面的指令向前排,即在volatile读后加入loadload 和 loadstore

在volatile写的时候限制前面的指令向后排,即在volatile写前加入loadstore 和 storestore


恭喜你,只猜对了一半,理论是理论,实际总是会有不同:以x86平台为例,volatile读你猜对了。而volatile写前面不需要加loadstore,因为本线程的load对外暴不暴露不会有任何影响,因此不需要消耗性能来控制它的重排,我们只需要保证写的内容不重排到后面去即可。反而是volatile写后面则需要加上storeload屏障,这里不是为了禁止重排,其实就是happens-before里提到的volatile变量规则的实现,不加这个,volatile写就不会让别的线程可见。


所以x86平台真实的内存屏障情况是:

在volatile读的时候限制后面的指令向前排,即在volatile读后加入loadload 和 loadstore

在volatile写的时候限制前面的指令向后排,即在volatile写前加入storestore,同时需要向后保证可见性,需要在volatile写之后加入storeload


四、JVM内存屏障的实现

我们上面说了,JVM 和 CPU 都有自己规定的内存屏障类型,他们之间有关联,JVM根据规则为代码在逻辑上找到合适的屏障,然后翻译成对应CPU的屏障,最终把CPU的屏障插入代码,那么你是不是以为学完了?别急,以x86为例,jvm真的会使用x86提供的内存屏障指令吗?我们借用一张图来说明:

源码来源:orderAccess_linux_x86.inline.hpp

7dbba42e9cd04d8bb1beb18e0a6a6564.png

可以看到在jdk8u中,在针对x86平台时,Load Load屏障、Load Store屏障、Acquire屏障是一样的

// 上面代码的意思就是加载栈顶的值,存储到寄存器,并非调用x86的内存屏障指令,
// 不过因为栈的特性,操作栈顶元素其实就意味着之前的读取操作都已经结束了,
// 所以实际上这种方法又叫“栈顶内存屏障“
__asm__ volatile ("movq 0(%%rsp), %0" : "=r" (local_dummy) : : "memory");

而Store Store屏障 和 Release屏障也是一样的内容。 Store Load屏障则比较独特 执行了个

// 这是一个带lock前缀的加0指令。它会进行总线锁定,保证指令执行的原子性
__asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");

纵观上述内容,我们发现JVM对x86的插入内存屏障好像并不是想象的那样有fence族指令,而是通过嵌入内存访问指令来实现的,这些指令提供了比fence族指令更细粒度的内存屏障。


当然这并不是说x86的fence族指令没用,只是JVM出于消耗和性能考虑,选用了嵌入内存访问指令的方式,而没有选用更为“正式”的CPU内存屏障


目录
相关文章
|
12天前
|
Web App开发 监控 JavaScript
监控和分析 JavaScript 内存使用情况
【10月更文挑战第30天】通过使用上述的浏览器开发者工具、性能分析工具和内存泄漏检测工具,可以有效地监控和分析JavaScript内存使用情况,及时发现和解决内存泄漏、过度内存消耗等问题,从而提高JavaScript应用程序的性能和稳定性。在实际开发中,可以根据具体的需求和场景选择合适的工具和方法来进行内存监控和分析。
|
1月前
|
编译器 C语言
动态内存分配与管理详解(附加笔试题分析)(上)
动态内存分配与管理详解(附加笔试题分析)
49 1
|
2月前
|
程序员 编译器 C++
【C++核心】C++内存分区模型分析
这篇文章详细解释了C++程序执行时内存的四个区域:代码区、全局区、栈区和堆区,以及如何在这些区域中分配和释放内存。
51 2
|
7天前
|
开发框架 监控 .NET
【Azure App Service】部署在App Service上的.NET应用内存消耗不能超过2GB的情况分析
x64 dotnet runtime is not installed on the app service by default. Since we had the app service running in x64, it was proxying the request to a 32 bit dotnet process which was throwing an OutOfMemoryException with requests >100MB. It worked on the IaaS servers because we had the x64 runtime install
|
3月前
|
存储 SQL 缓存
揭秘Java并发核心:深度剖析Java内存模型(JMM)与Volatile关键字的魔法底层,让你的多线程应用无懈可击
【8月更文挑战第4天】Java内存模型(JMM)是Java并发的核心,定义了多线程环境中变量的访问规则,确保原子性、可见性和有序性。JMM区分了主内存与工作内存,以提高性能但可能引入可见性问题。Volatile关键字确保变量的可见性和有序性,其作用于读写操作中插入内存屏障,避免缓存一致性问题。例如,在DCL单例模式中使用Volatile确保实例化过程的可见性。Volatile依赖内存屏障和缓存一致性协议,但不保证原子性,需与其他同步机制配合使用以构建安全的并发程序。
70 0
|
17天前
|
Web App开发 JavaScript 前端开发
使用 Chrome 浏览器的内存分析工具来检测 JavaScript 中的内存泄漏
【10月更文挑战第25天】利用 Chrome 浏览器的内存分析工具,可以较为准确地检测 JavaScript 中的内存泄漏问题,并帮助我们找出潜在的泄漏点,以便采取相应的解决措施。
116 9
|
21天前
|
并行计算 算法 IDE
【灵码助力Cuda算法分析】分析共享内存的矩阵乘法优化
本文介绍了如何利用通义灵码在Visual Studio 2022中对基于CUDA的共享内存矩阵乘法优化代码进行深入分析。文章从整体程序结构入手,逐步深入到线程调度、矩阵分块、循环展开等关键细节,最后通过带入具体值的方式进一步解析复杂循环逻辑,展示了通义灵码在辅助理解和优化CUDA编程中的强大功能。
|
1月前
|
存储 Java
JVM知识体系学习四:排序规范(happens-before原则)、对象创建过程、对象的内存中存储布局、对象的大小、对象头内容、对象如何定位、对象如何分配
这篇文章详细地介绍了Java对象的创建过程、内存布局、对象头的MarkWord、对象的定位方式以及对象的分配策略,并深入探讨了happens-before原则以确保多线程环境下的正确同步。
53 0
JVM知识体系学习四:排序规范(happens-before原则)、对象创建过程、对象的内存中存储布局、对象的大小、对象头内容、对象如何定位、对象如何分配
|
1月前
|
程序员 编译器 C语言
动态内存分配与管理详解(附加笔试题分析)(下)
动态内存分配与管理详解(附加笔试题分析)(下)
46 2
|
2月前
|
算法 程序员 Python
程序员必看!Python复杂度分析全攻略,让你的算法设计既快又省内存!
在编程领域,Python以简洁的语法和强大的库支持成为众多程序员的首选语言。然而,性能优化仍是挑战。本文将带你深入了解Python算法的复杂度分析,从时间与空间复杂度入手,分享四大最佳实践:选择合适算法、优化实现、利用Python特性减少空间消耗及定期评估调整,助你写出高效且节省内存的代码,轻松应对各种编程挑战。
41 1