Talk about what the memory model happens-before talks about?

Hello everyone, I am Yi An!

Today I'm going to talk about happen-before in the Java memory model.

The Java language introduced the concept of threads at the beginning of its design to make full use of the computing power of modern processors. The multithreading mechanism not only brings powerful and flexible advantages, but also brings confusing issues such as thread safety. In this case, the Java Memory Model (JMM) provides us with a guideline to reach consensus amidst the chaos. Before understanding happen-before, let's take a look at a few appetizers related to it

What is the Java memory model?

The Java memory model is a specification used to regulate the behavior of multi-threaded access to shared memory in Java programs. It describes how the Java Virtual Machine (JVM) manages and manipulates memory, and how thread safety is guaranteed in a multithreaded environment.

Why is the Java Memory Model needed?

In multithreaded programming, since multiple threads may access shared memory at the same time, it may lead to some unexpected results. These results can include data races, memory visibility issues, and more. The Java memory model provides us with some rules and constraints to help us write thread-safe programs.

Rules and Constraints of the Java Memory Model

The Java memory model mainly includes the following rules and constraints:

  • Atomicity: The Java memory model guarantees that the read and write operations of basic data types and references are atomic, that is, no data inconsistency or interruption will occur during read and write operations. However, for 64-bit long and double types, read and write operations are not atomic, and you may need to use the synchronized keyword or atomic classes such as AtomicLong to ensure thread safety.

  • Visibility: The Java memory model ensures that after one thread modifies the value of a shared variable, another thread can immediately see the modified value. To achieve visibility, the Java memory model uses "happens-before" relationships. In simple terms, if operation A happens-before operation B, then operation B can see the result of operation A. If there is no happens-before relationship, then the execution order of operation A and operation B is undefined.

  • Orderedness: The Java memory model guarantees that the order of program execution is executed in the order in which we write the code. However, in a multi-threaded environment, the JVM can reorder operations to optimize program execution efficiency. In order to ensure the order, you can use the synchronized keyword or the volatile keyword to prohibit the JVM from reordering operations. After a brief understanding of the java memory model, let's talk about the happen-before principle

simple answer

The Happen-before relationship is a mechanism to ensure the visibility of multi-threaded operations in the Java memory model, and it is also a precise definition of the vague concept of visibility in early language specifications.

Its specific manifestations include, but are far more than, our intuition of synchronized, volatile, lock operation sequence, etc. The following are some specific manifestations:

  • Every operation performed in a thread guarantees the operations after happen-before, which guarantees the basic program sequence rules, which is the basic agreement of developers when writing programs.

  • For a volatile variable, the write operation to it is guaranteed to happen-before the subsequent read operation of the variable.

  • For the unlock operation of a lock, the lock operation is guaranteed to happen-before.

  • The object construction is completed, and the happen-before is guaranteed to start the action of the finalizer.

  • Even the completion of internal operations like threads, guaranteeing happen-before other Thread.join() threads, etc.

These happen-before relationships are transitive. If a happen-before b and b happen-before c are satisfied, then a happen-before c is also established.

I have always used happen-before instead of simply saying before and after, because it not only guarantees execution time, but also guarantees the order of memory read and write operations. Just the order of clocks does not guarantee the visibility of thread interaction.

Problem dismantling

Today's question is a common question to examine the basic concepts of the Java memory model. The answer I gave earlier chose the rules related to daily development as much as possible.

JMM is a hot spot for interviews, and it can be seen as a necessary condition for a deep understanding of Java concurrent programming, compilers, and JVM internal mechanisms, but it is also a topic that can easily confuse beginners. For learning JMM, I have some personal suggestions:

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

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

此文,我会结合例子梳理下面两点:

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

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

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

引申

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

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

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

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

换句话说:

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

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

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

所以:

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

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

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

alt

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

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

alt

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

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

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

对于一个volatile变量:

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

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

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

内存屏障(Memory Barrier)是一种机制,用于防止处理器和编译器对内存访问的重排序。内存屏障可以分为两种类型:读屏障(Load Barrier)和写屏障(Store Barrier)。

读屏障保证了在该屏障之前的读操作要先于该屏障之后的读操作执行,从而保证了读操作的顺序性。写屏障保证了在该屏障之前的写操作要先于该屏障之后的写操作执行,从而保证了写操作的顺序性。这些屏障可以用来实现内存可见性和防止数据竞争。

内存屏障有以下三种类型:

  • LoadLoad屏障(LL):它保证了在该屏障之前的所有读操作要先于该屏障之后的所有读操作执行。这样可以保证读操作的顺序性和一致性。

  • StoreStore屏障(SS):它保证了在该屏障之前的所有写操作要先于该屏障之后的所有写操作执行。这样可以保证写操作的顺序性和一致性。

  • LoadStore屏障(LS)和StoreLoad屏障(SL):它们是最重要的内存屏障类型。LS屏障保证了在该屏障之前的所有读操作要先于该屏障之后的所有写操作执行。SL屏障保证了在该屏障之前的所有写操作要先于该屏障之后的所有读操作执行。这样可以确保数据的一致性和可见性。

内存屏障的实现是由处理器和编译器共同完成的。处理器可以使用缓存一致性协议和总线锁等机制来实现内存屏障,而编译器则可以在生成汇编代码时插入内存屏障指令来保证内存访问的顺序性和一致性。

如果你对更多内存屏障的细节感兴趣,或者想了解不同体系结构的处理器模型,建议参考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为例,分析了可见性在多线程场景中的典型用例 如果本文对你有帮助的话,欢迎点赞分享,这对我继续分享&创作优质文章非常重要。感谢 !

本文由 mdnice 多平台发布

Guess you like

Origin blog.csdn.net/qq_35030548/article/details/130164940