You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

11 KiB

第29讲 | Java内存模型中的happen-before是什么

Java语言在设计之初就引入了线程的概念以充分利用现代处理器的计算能力这既带来了强大、灵活的多线程机制也带来了线程安全等令人混淆的问题而Java内存模型Java Memory ModelJMM为我们提供了一个在纷乱之中达成一致的指导准则。

今天我要问你的问题是Java内存模型中的happen-before是什么

典型回答

Happen-before关系是Java内存模型中保证多线程操作可见性的机制也是对早期语言规范中含糊的可见性概念的一个精确定义。

它的具体表现形式包括但远不止是我们直觉中的synchronized、volatile、lock操作顺序等方面例如

  • 线程内执行的每个操作都保证happen-before后面的操作这就保证了基本的程序顺序规则这是开发者在书写程序时的基本约定。

  • 对于volatile变量对它的写操作保证happen-before在随后对该变量的读取操作。

  • 对于一个锁的解锁操作保证happen-before加锁操作。

  • 对象构建完成保证happen-before于finalizer的开始动作。

  • 甚至是类似线程内部操作的完成保证happen-before其他Thread.join()的线程等。

这些happen-before关系是存在着传递性的如果满足a happen-before b和b happen-before c那么a happen-before c也成立。

前面我一直用happen-before而不是简单说前后是因为它不仅仅是对执行时间的保证也包括对内存读、写操作顺序的保证。仅仅是时钟顺序上的先后并不能保证线程交互的可见性。

考点分析

今天的问题是一个常见的考察Java内存模型基本概念的问题我前面给出的回答尽量选择了和日常开发相关的规则。

JMM是面试的热点可以看作是深入理解Java并发编程、编译器和JVM内部机制的必要条件但这同时也是个容易让初学者无所适从的主题。对于学习JMM我有一些个人建议

  • 明确目的克制住技术的诱惑。除非你是编译器或者JVM工程师否则我建议不要一头扎进各种CPU体系结构纠结于不同的缓存、流水线、执行单元等。这些东西虽然很酷但其复杂性是超乎想象的很可能会无谓增加学习难度也未必有实践价值。

  • 克制住对“秘籍”的诱惑。有些时候,某些编程方式看起来能起到特定效果,但分不清是实现差异导致的“表现”,还是“规范”要求的行为,就不要依赖于这种“表现”去编程,尽量遵循语言规范进行,这样我们的应用行为才能更加可靠、可预计。

在这一讲中,兼顾面试和编程实践,我会结合例子梳理下面两点:

  • 为什么需要JMM它试图解决什么问题

  • JMM是如何解决可见性等各种问题的类似volatile体现在具体用例中有什么效果

注意专栏中Java内存模型就是特指JSR-133中重新定义的JMM规范。在特定的上下文里也许会与JVMJava内存结构等混淆并不存在绝对的对错但一定要清楚面试官的本意有的面试官也会特意考察是否清楚这两种概念的区别。

知识扩展

为什么需要JMM它试图解决什么问题

Java是最早尝试提供内存模型的语言这是简化多线程编程、保证程序可移植性的一个飞跃。早期类似C、C++等语言并不存在内存模型的概念C++ 11中也引入了标准内存模型其行为依赖于处理器本身的内存一致性模型但不同的处理器可能差异很大所以一段C++程序在处理器A上运行正常并不能保证其在处理器B上也是一致的。

即使如此最初的Java语言规范仍然是存在着缺陷的当时的目标是希望Java程序可以充分利用现代硬件的计算能力同时保持“书写一次到处执行”的能力。

但是显然问题的复杂度被低估了随着Java被运行在越来越多的平台上人们发现过于泛泛的内存模型定义存在很多模棱两可之处对synchronized或volatile等类似指令重排序时的行为并没有提供清晰规范。这里说的指令重排序既可以是编译器优化行为,也可能是源自于现代处理器的乱序执行等。

换句话说:

  • 既不能保证一些多线程程序的正确性例如最著名的就是双检锁Double-Checked LockingDCL的失效问题具体可以参考我在第14讲对单例模式的说明双检锁可能导致未完整初始化的对象被访问理论上这叫并发编程中的安全发布Safe Publication失败。

  • 也不能保证同一段程序在不同的处理器架构上表现一致,例如有的处理器支持缓存一致性,有的不支持,各自都有自己的内存排序模型。

所以Java迫切需要一个完善的JMM能够让普通Java开发者和编译器、JVM工程师能够清晰地达成共识。换句话说,可以相对简单并准确地判断出,多线程程序什么样的执行序列是符合规范的。

所以:

  • 对于编译器、JVM开发者关注点可能是如何使用类似内存屏障Memory-Barrier之类技术保证执行结果符合JMM的推断。

  • 对于Java应用开发者则可能更加关注volatile、synchronized等语义如何利用类似happen-before的规则写出可靠的多线程应用而不是利用一些“秘籍”去糊弄编译器、JVM。

我画了一个简单的角色层次图不同工程师分工合作其实所处的层面是有区别的。JMM为Java工程师隔离了不同处理器内存排序的区别这也是为什么我通常不建议过早深入处理器体系结构某种意义上来说这样本就违背了JMM的初衷。

JMM是怎么解决可见性等问题的呢

在这里,我有必要简要介绍一下典型的问题场景。

我在第25讲里介绍了JVM内部的运行时数据区但是真正程序执行实际是要跑在具体的处理器内核上。你可以简单理解为把本地变量等数据从内存加载到缓存、寄存器然后运算结束写回主内存。你可以从下面示意图看这两种模型的对应。

看上去很美好但是当多线程共享变量时情况就复杂了。试想如果处理器对某个共享变量进行了修改可能只是体现在该内核的缓存里这是个本地状态而运行在其他内核上的线程可能还是加载的旧状态这很可能导致一致性的问题。从理论上来说多线程共享引入了复杂的数据依赖性不管编译器、处理器怎么做重排序都必须尊重数据依赖性的要求否则就打破了正确性这就是JMM所要解决的问题。

JMM内部的实现通常是依赖于所谓的内存屏障通过禁止某些重排序的方式提供内存可见性保证也就是实现了各种happen-before规则。与此同时更多复杂度在于需要尽量确保各种编译器、各种体系结构的处理器都能够提供一致的行为。

我以volatile为例看看如何利用内存屏障实现JMM定义的可见性

对于一个volatile变量

  • 对该变量的写操作之后,编译器会插入一个写屏障

  • 对该变量的读操作之前,编译器会插入一个读屏障

内存屏障能够在类似变量读、写操作之后保证其他线程对volatile变量的修改对当前线程可见或者本地修改对其他线程提供可见性。换句话说线程写入写屏障会通过类似强迫刷出处理器缓存的方式让其他线程能够拿到最新数值。

如果你对更多内存屏障的细节感兴趣或者想了解不同体系结构的处理器模型建议参考JSR-133相关文档我个人认为这些都是和特定硬件相关的内存屏障之类只是实现JMM规范的技术手段并不是规范的要求。

从应用开发者的角度JMM提供的可见性体现在类似volatile上具体行为是什么样呢

我这里循序渐进的举两个例子。

首先前几天有同学问我一个问题请看下面的代码片段希望达到的效果是当condition被赋值为false时线程A能够从循环中退出。

// Thread A
while (condition) {
}

// Thread B
condition = false;

这里就需要condition被定义为volatile变量不然其数值变化往往并不能被线程A感知进而无法退出。当然也可以在while中添加能够直接或间接起到类似效果的代码。

第二我想举Brian Goetz提供的一个经典用例使用volatile作为守卫对象实现某种程度上轻量级的同步请看代码片段

Map configOptions;
char[] configText;
volatile boolean initialized = false;
 
// Thread A
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;
 
// Thread B
while (!initialized)
  sleep();
// use configOptions

JSR-133重新定义的JMM模型能够保证线程B获取的configOptions是更新后的数值。

也就是说volatile变量的可见性发生了增强能够起到守护其上下文的作用。线程A对volatile变量的赋值会强制将该变量自己和当时其他变量的状态都刷出缓存为线程B提供可见性。当然这也是以一定的性能开销作为代价的但毕竟带来了更加简单的多线程行为。

我们经常会说volatile比synchronized之类更加轻量但轻量也仅仅是相对的volatile的读、写仍然要比普通的读写要开销更大所以如果你是在性能高度敏感的场景除非你确定需要它的语义不然慎用。

今天我从happen-before关系开始帮你理解了什么是Java内存模型。为了更方便理解我作了简化从不同工程师的角色划分等角度阐述了问题的由来以及JMM是如何通过类似内存屏障等技术实现的。最后我以volatile为例分析了可见性在多线程场景中的典型用例。

一课一练

关于今天我们讨论的题目你做到心中有数了吗今天留给你的思考题是给定一段代码如何验证所有符合JMM执行可能有什么工具可以辅助吗

请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。

你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。