05.深入理解JMM和Happens-Before

7,107 阅读12分钟

大家好,我是王有志。

JMM都问啥?

最近沉迷P5R,所以写作的进度很不理想,但不得不说高卷杏YYDS。话不多说,开始今天的主题,JMM和Happens-Before

关于它们的问题并不多,基本上只有两个:

  • JMM是什么?详细描述下JMM。
  • 说说你对JMM的理解,为什么要这样设计?

Tips:本文以JMM理论为主。

JMM是什么?

JMM即Java Memory Model,Java内存模型JSR-133 FAQ中对内存模型的解释是:

At the processor level, a memory model defines necessary and sufficient conditions for knowing that writes to memory by other processors are visible to the current processor, and writes by the current processor are visible to other processors.

处理器级别上,内存模型定义了处理器核心间对彼此写内存操作可见性的充要条件。以及:

Moreover, writes to memory can be moved earlier in a program; in this case, other threads might see a write before it actually "occurs" in the program.  All of this flexibility is by design -- by giving the compiler, runtime, or hardware the flexibility to execute operations in the optimal order, within the bounds of the memory model, we can achieve higher performance.

在内存模型允许的范围内,允许编译器、运行时或硬件以最佳顺序执行指令,以提高性能。最佳顺序是通过指令重排序得到的指令执行顺序。

我们对处理器级别的内存模型做个总结:

  • 定义了核心间的写操作的可见性
  • 约束了指令重排序

接着看对JMM的描述:

The Java Memory Model describes what behaviors are legal in multithreaded code, and how threads may interact through memory.It describes the relationship between variables in a program and the low-level details of storing and retrieving them to and from memory or registers in a real computer system.It does this in a way that can be implemented correctly using a wide variety of hardware and a wide variety of compiler optimizations.

提取这段话的关键信息:

  • JMM描述了多线程中行为的合法性,以及线程间如何通过内存进行交互
  • 屏蔽了硬件和编译器的实现差异,以达到一致的内存访问效果

我们结合内存模型来看,JMM到底是什么?

  • JVM的角度看,JMM屏蔽了不同硬件/平台底层差异,达到一致的内存访问效果
  • Java开发人员的角度看,JMM定义了线程间写操作的可见性,约束了指令重排序

那么为什么要有内存模型呢?

“诡异”的并发问题

02.关于线程你必须知道的8个问题(上)中给出了并发编程的3要素,以及无法正确实现带来的问题,接下来我们探究下底层原因。

Tips:补充一点Linux中线程调度相关内容。

Linux线程调度是基于时间片的抢占式调度,简单理解为,线程尚未执行结束,但时间片耗尽,线程挂起,Linux在等待队列中选取优先级最高的线程分配时间片,因此优先级高的线程总会被执行

上下文切换带来的原子性问题

我们以常见的自增操作count++为例。

直觉上我们认为自增操作是一气呵成,没有任何停顿。但实际上会产生3条指令:

  • 指令1:将count读入缓存;
  • 指令2:执行自增操作;
  • 指令3:将自增后的count写入内存。

那么问题来了,如果两个线程t1,t2同时对count执行自增操作,且t1执行完指令1后发生了线程切换,此时会发生什么?

原子性问题的产生.png

我们期望的结果是2,但实际上得到1。这便是线程切换带来的原子性问题。那么禁止线程切换不就解决了原子性问题吗?

虽然是这样,但禁止线程切换的代价太大了。我们知道,CPU运算速度“贼快”,而I/O操作“贼慢”。试想一下,如果你正在用steam下载P5R,但是电脑卡住了,只能等到下载后才能愉快的写BUG,你气不气?

因此,操作系统中线程执行I/O操作时会放弃CPU时间片,让给其它线程,提高CPU的利用率

P5R天下第一!!!

缓存带来的可见性问题

你可能会想上面例子中,线程t1,t2操作的不是同一个count吗?

看起来是同一个count,但其实是内存中count在不同缓存中的副本。因为,不仅是I/O和CPU有着巨大的速度差异,内存与CPU的差异也不小,为了弥补差异而在内存和CPU间添加了CPU缓存

CPU核心操作内存数据时,先拷贝数据到缓存中,然后各自操作缓存中的数据副本。

可见性问题的产生.png

我们先忽略MESI带来的影响,可以得到线程对缓存中变量的修改对其它线程来说并不是立即可见的

Tips:拓展中补充MESI协议基础内容。

指令重排序带来的有序性问题

除了以上提升运行速度的方式外,还有其它“幺蛾子”--指令重排序。我们把02.关于线程你必须知道的8个问题(上)中的例子改一下。

public static class Singleton {
	private Singleton instance;
	public Singleton getInstance() {
	    if (instance == null) {
		    synchronized(this) {
			    if (instance == null) {
				    instance = new Singleton();
			    }
		    }
	    }
	    return instance;
	}
  
	private Singleton() {
	}
}

Java中new Singleton()需要经历3步:

  1. 分配内存;
  2. 初始化Singleton对象;
  3. instance指向这块内存。

分析下这3步间的依赖性,分配内存必须最先执行,否则2和3无法进行,至于2和3无论谁先执行,都不会影响单线程下语义的正确性,它们之间不存在依赖性。

但是到了多线程场景下,情况就变得复杂了:

有序性问题的产生.png

此时线程t2拿到的instance是尚未经过初始化的实例对象,重排序导致的有序性问题就产生了

Tips:拓展中补充指令重排序

JMM都做了什么?

正式描述JMM前,JSR-133中提到了另外两种内存模型:

  • 顺序一致性内存模型
  • Happens-Before内存模型

顺序一致性内存模型禁止了编译器和处理器优化,提供了极强的内存可见性保证。它要求:

  • 执行过程中,所有读/写操作存在全序关系;
  • 线程中的操作必须按照程序的顺序来执行;
  • 操作必须原子执行且立即对所有线程可见。

顺序一致性模型的约束力太强了,显然不适合作为支持并发的编程语言的内存模型。

Happens-Before

Happens-Before描述两个操作结果间的关系,操作A happens-before 操作B(记作AhbBA \xrightarrow{hb} B),即便经过重排序,也应该有操作A的结果对操作B是可见的

Tips:Happens-Before是因果关系,AhbBA \xrightarrow{hb} B是“因”,A的结果对B可见是“果”,执行过程不关我的事。

Happens-Before的规则,我们引用《Java并发编程的艺术》中的翻译:

程序顺序规则:线程中的每个操作happens-before该线程中的任意后续操作。 监视器锁规则:锁的解锁happens-before随后这个锁的加锁。 volatile变量规则:volatile变量的写happens-before后续任意对这个volatile变量的读。 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。 start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。 join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。

以上内容出现在JSR-133第5章Happens-Before and Synchronizes-With Edges中,原文较为难读。

这些看似是废话,但是别忘了,我们面对的是多线程环境编译器,硬件的重排序

再次强调,以监视器锁规则为例,虽然只说了解锁发生在加锁前,但实际是解锁后的结果(成功/失败)发生在加锁前。

Tips:Happens-Before可以翻译为发生在...之前,Synchronizes-With可以翻译为与...同步

另外JSR-133还还提及了非volatile变量的规则:

The values that can be seen by a non-volatile read are determined by a rule known as happens-before consistency.

非volatile变量的读操作的可见性又happens-before一致性决定

Happens-Before一致性:存在对变量V的写入操作W和读取操作R,如果满足WhbRW \xrightarrow{hb} R,则操作W的结果对操作R可见(JSR 133上的定义诠释了科学家的严谨)。

JMM虽然不是照单全收Happens-Before的规则(进行了增强),不过还是可以认为:HappensBefore规则JMM规则Happens-Before规则 \approx JMM规则

那么为什么选择Happens-Before呢?实际就是易编程约束性运行效率三者权衡后的结果。

内存模型的强度.png

图中只选了今天或多或少提到过的内存模型,其中X86/ARM指的是硬件架构体系。

虽然Happens-Before是JMM的核心,但是除此之外,JMM还屏蔽了硬件间的差异;并为Java开发人员提供了3个并发原语,synchronizedvolatilefinal

拓展内容

关于内存模型和JMM的理论内容已经结束了,这里为文章中出现的概念做个补充,大部分都是硬件层面的内容,不感兴趣的话可以直接跳过了。

缓存一致性协议

缓存一致性协议(Cache Coherence Protocol),一致性用的并不是常见的Consistency。

Coherence和Consistency经常出现在并发编程,编译优化和分布式系统设计中,如果仅仅从中文翻译上理解你很容易误解,实际上两者的区别还是很大的,我们看维基百科中对一致性模型的解释:

Consistency is different from coherence, which occurs in systems that are cached or cache-less, and is consistency of data with respect to all processors. Coherence deals with maintaining a global order in which writes to a single location or single variable are seen by all processors. Consistency deals with the ordering of operations to multiple locations with respect to all processors.

很明显的,如果是Coherence,针对的是单个变量,而Consistency针对的是多个绵连。

MESI协议

MESI协议是基于失效的最常用的缓存一致性协议。MESI代表了缓存的4种状态:

  • M(Modified,已修改),缓存中数据已经被修改,且与主内存数据不同。
  • E(Exclusive,独占),数据只存在于当前核心的缓存中,且与主内存数据相同。
  • S(Shared,共享),数据存在与多个核心中,且与主内存数据相同。
  • I(Invalid,无效),缓存中数据是无效的。

Tips:除了MESI协议外还有MSI协议,MOSI协议,MOESI协议等,首字母都是描述状态的,O代表的是Owned。

MESI是硬件层面做出的保证,它保证一个变量在多个核心上的读写顺序

不同的CPU架构对MESI有不同的实现,如:X86引入了store buffer,ARM中又引入load buffer和invalid queue,读/写缓冲区和无效化队列提高了速度但是带来了另一个问题。

指令重排序

重排序可以分为3类:

  • 指令并行重排序:没有数据依赖的情况下,处理器可以自行优化指令的执行顺序;
  • 编译器优化重排序:不改变单线程语义的前提下,编译器可以重新安排语句的执行顺序;
  • 内存系统重排序:引入store/load buffer,并且异步执行,看起来指令是“乱序”执行的。

前两种重排序很好理解,但是内存系统重排序要怎么理解呢?

引入store buffer,load buffer和invalid queue,将原本同步交互的过程修改为了异步交互,虽然减少了同步阻塞,但也带来了“乱序”的可能性。

当然重排序也不是“百无禁忌”,它有两个底线:

数据依赖

两个操作依赖同一个数据,且其中包含写操作,此时两个操作之间就存在数据依赖。如果两个操作存在数据依赖性,那么在编译器或处理器重排序时,就不能修改这两个操作的顺序

as-if-serial语义

as-if-serial语义并不是说像单线程场景一样执行,而是无论如何重排序,单线程场景下的语义不能被改变(或者说执行结果不变)

推荐阅读

关于内存模型和JMM的阅读资料

虽然《Time, Clocks, and the Ordering of Events in a Distributed System》是讨论分布式领域问题的,但在并发编程领域也有着巨大的影响。

另外,我准备了《Time, Clocks, and the Ordering of Events in a Distributed System》和JSR-133的中英版总计3个PDF文件,回复【JSR133】即可。

最后说个有意思的事情,大佬们的博客都异常“朴素”。

Doug Lea的博客首页:

Doug Lea的博客.png

Lamport的博客首页:

Lamport的博客.png

结语

最近沉迷P5R,一直在偷懒~~

JMM的内容删删减减的写得很纠结,因为涉及到并发原理时,从来不是编程语言自己在战斗,从CPU到编程语言每个环节都有参与,所以很难把控每部分内容的详略。

不过好在也是把JMM的本质和由来说明白了,希望这篇对你有所帮助,欢迎各位大佬留言指正。


好了,今天就到这里了,Bye~~