文章目录
许多小伙伴在刚刚接触到
Java内存模型
和
Java内存结构
,总会傻傻搞不清,其实这两个玩意并不是同一个东西,但是它们之间是存在联系的,这篇文章就来扯一扯Java内存模型,并顺带解释一下这两玩意儿的区别!
一、Java内存模型(JMM)
1. 并发编程模型的两个关键问题
- 线程间如何通信?即:线程之间以何种机制来交换信息
- 线程间如何同步?即:线程以何种机制来控制不同线程间操作发生的相对顺序
有两种并发模型可以解决这两个问题:
- 消息传递并发模型
- 共享内存并发模型
这两种模型之间的区别如下表所示:
并发模型 | 如何通信 | 如何同步 |
---|---|---|
消息传递 | 线程之间没有公共状态,线程之间必须通过发送消息来显式进行通信。 | 发送消息天然同步,因为消息的发送必须在消息的接收之前,因此同步是隐式进行的。 |
共享内存 | 线程之间共享程序的公共状态,通过写-读内存中的公共状态进行隐式通信。 | 必须显式指定某个方法或某段代码需要在线程之间互斥执行。 |
Java 的并发采用的是共享内存模型,Java 线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。
2. Java 内存模型的抽象结构
2.1 运行时内存的划分(Java 内存结构)
先谈一下运行时数据区,下面这张图展示的是 Java 虚拟机内存空间包括的运行时数据区域:
对于每一个线程来说,栈都是私有的,而堆是共有的。
在 Java 中,所有共享变量(「实例域」、「静态域」和「数组元素」)都存储在堆内存中,堆内存在线程之间共享。
而在栈中的变量(「局部变量」、「方法定义参数」、「异常处理器参数」)不会在线程之间共享,也就不会有内存可见性(下文会说到)的问题,也不受内存模型的影响。
所以,内存可见性是针对共享变量的。
2.2 既然堆是共享的,为什么在堆中会有内存不可见问题?
这是因为现代计算机为了高效,往往会在高速缓存区中缓存共享变量,因为 CPU 访问缓存区比访问内存要快得多。
Java 线程之间的通信由 Java 内存模型(简称 JMM)控制,从抽象的角度来说,JMM 定义了线程和主内存之间的抽象关系。JMM 的抽象示意图如图所示:
从图中可以看出:
- 所有的共享变量都存在主内存中。
- 每个线程都保存了一份该线程使用到的共享变量的副本。
- 如果线程A与线程B之间要通信的话,必须经历下面 2 个步骤:
- 线程 A 将本地内存 A 中更新过的共享变量刷新到主内存中去。
- 线程 B 到主内存中去读取线程 A 之前已经更新过的共享变量。
所以,线程 A 无法直接访问线程 B 的工作内存,线程间通信必须经过主内存。
注意,根据 JMM 的规定,线程对共享变量的所有操作都必须在自己的本地内存中进行,不能直接从主内存中读取。
所以线程 B 并不是直接去主内存中读取共享变量的值,而是先在本地内存 B 中找到这个共享变量,发现这个共享变量已经被更新了,然后去主内存中读取这个共享变量的新值,并拷贝到本地内存 B 中,最后线程 B 再读取本地内存 B 中的新值。
那么怎么知道这个共享变量被其他线程更新了呢?这就是 JMM 的功劳了,也是 JMM 存在的必要性之一。JMM 通过控制主内存与每个线程的本地内存之间的交互,来提供内存可见性保证。
底层原理:在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议——每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。
2.3 JMM 与 Java 内存区域的区别与联系
上面分别提到了 JMM 和 Java 运行时内存区域,这两者既有差别又有联系:
-
区别
两者是不同的概念层次。JMM 是抽象的,它是用来描述一组规则,通过这个规则来控制各个变量的访问方式,围绕原子性、有序性、可见性等展开的。而 Java 运行时内存的划分是具体的,是 JVM 运行 Java 程序时,必要的内存划分。
-
联系
都存在私有数据区域和共享数据区域。一般来说,JMM 中的主内存属于共享数据区域,它包含了堆和方法区;同样,JMM 中的本地内存属于私有数据区域,包含了程序计数器、虚拟机栈、本地方法栈。
二、重排序与 happens-before
1. 什么是重排序?
计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令重新排序。
为什么指令重排序可以提高性能?
简单地说,每一个指令都会包含多个步骤,每个步骤可能使用不同的硬件。因此,流水线技术产生了,它的原理是指令1还没有执行完,就可以开始执行指令2,而不用等到指令1执行结束之后再执行指令2,这样就大大提高了效率。
但是,流水线技术最害怕中断,恢复中断的代价是比较大的,所以我们要想尽办法不让流水线中断。指令重排就是减少中断的一种技术。
分析一下下面这个代码的执行情况:
a = b + c;
d = e - f ;
先加载 b、c(注意,即有可能先加载 b,也有可能先加载 c),但是在执行 add(b,c)
的时候,需要等待 b、c 装载结束才能继续执行,也就是增加了停顿,那么后面的指令也会依次有停顿,这降低了计算机的执行效率。
为了减少这个停顿,我们可以先加载 e 和 f,然后再去加载 add(b,c)
,这样做对程序(串行)是没有影响的,但却减少了停顿。既然 add(b,c)
需要停顿,那还不如去做一些有意义的事情。
综上所述,指令重排对于提高 CPU 处理性能十分必要。虽然由此带来了乱序的问题,但是这点牺牲是值得的。
指令重排一般分为以下三种:
-
编译器优化重排
编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
-
指令并行重排
现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序。
-
内存系统重排
由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。
指令重排可以保证串行语义一致,但是没有义务保证多线程间的语义也一致。所以在多线程下,指令重排序可能会导致一些问题。
2. 顺序一致性模型与 JMM 的保证
顺序一致性模型是一个理论参考模型,内存模型在设计的时候都会以顺序一致性内存模型作为参考。
2.1 数据竞争与顺序一致性
当程序未正确同步的时候,就可能存在数据竞争。
如果程序中包含了数据竞争,那么运行的结果往往充满了不确定性,比如读发生在了写之前,可能就会读到错误的值;如果一个线程程序能够正确同步,那么就不存在数据竞争。
Java 内存模型(JMM)对于正确同步的多线程程序的内存一致性做了以下保证:
如果程序是正确同步的,程序的执行将具有顺序一致性。 即程序的执行结果和该程序在顺序一致性模型中执行的结果相同。
这里的同步是指广义上的同步,包括了使用 volatile
、final
、synchronized
等关键字来实现多线程下的同步。
2.2 顺序一致性模型
顺序一致性内存模型是一个理想化的理论参考模型,它为程序员提供了极强的内存可见性保证。
顺序一致性模型有两大特性:
- 一个线程中的所有操作必须按照程序的顺序(即 Java 代码的顺序)来执行。
- 不管程序是否同步,所有线程都只能看到一个单一的操作执行顺序。即在顺序一致性模型中,每个操作必须是原子性的,且立刻对所有线程可见。
为了理解这两个特性,我们举个例子,假设有两个线程 A 和 B 并发执行,线程 A 有3个操作,他们在程序中的顺序是 A1→A2→A3
,线程 B 也有3个操作,B1→B2→B3
。
假设正确使用了同步,A 线程的3个操作执行后释放锁,B 线程获取同一个锁。那么在顺序一致性模型中的执行效果如下所示:
假设没有使用同步,那么在顺序一致性模型中的执行效果如下所示:
之所以可以得到这个保证,是因为顺序一致性模型中的每个操作必须立即对任意线程可见。
但是 JMM 没有这样的保证。未同步程序在 JMM 中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。
比如,在当前线程把写过的数据缓存在本地内存中,在没有刷新到主内存之前,这个写操作仅对当前线程可见;从其他线程的角度来观察,这个写操作根本没有被当前线程所执行。只有当前线程把本地内存中写过的数据刷新到主内存之后,这个写操作才对其他线程可见。在这种情况下,当前线程和其他线程看到的执行顺序是不一样的。
2.3 JMM 中同步程序的顺序一致性效果
在顺序一致性模型中,所有操作完全按照程序的顺序串行执行。但是 JMM 中,临界区内(同步块或同步方法中)的代码可以发生重排序(但不允许临界区内的代码“逃逸”到临界区之外,因为会破坏锁的内存语义)。
虽然线程 A 在临界区做了重排序,但是因为锁的特性,线程 B 无法观察到线程 A 在临界区的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。
同时,JMM 会在退出临界区和进入临界区做特殊的处理,使得在临界区内程序获得与顺序一致性模型相同的内存视图。
由此可见,JMM 的具体实现方针是:在不改变(正确同步的)程序执行结果的前提下,尽量为编译器和处理器的优化打开方便之门。
2.4 JMM 中未同步程序的顺序一致性效果
对于未同步的多线程程序,JMM 只提供最小安全性:线程读取到的值,要么是之前某个线程写入的值,要么是默认值,不会无中生有。
为了实现这个安全性,JVM 在堆上分配对象时,首先会对内存空间清零,然后才会在上面分配对象(这两个操作是同步的)。
JMM 没有保证未同步程序的执行结果与该程序在顺序一致性中执行结果一致。因为如果要保证执行结果一致,那么 JMM 需要禁止大量的优化,对程序的执行性能会产生很大的影响。
未同步程序在 JMM 和顺序一致性内存模型中的执行特性有如下差异:
- 顺序一致性保证单线程内的操作会按程序的顺序执行;JMM 不保证单线程内的操作会按程序的顺序执行。(因为重排序,但是 JMM 保证单线程下的重排序不影响执行结果)
- 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而 JMM 不保证所有线程能看到一致的操作执行顺序。(因为 JMM 不保证所有操作立即可见)
- 顺序一致性模型保证对所有的内存读/写操作都具有原子性,而 JMM 不保证对64位的 long 型和 double 型变量的写操作具有原子性。
3. happens-before
3.1 什么是 happens-before?
JMM 考虑了这两种需求,并且找到了平衡点:
- 对编译器和处理器来说,只要不改变程序的执行结果(单线程程序和正确同步了的多线程程序),编译器和处理器怎么优化都行。
- 而对于程序员,JMM 提供了 happens-before 规则(JSR-133规范),满足了程序员的需求——简单易懂,并且提供了足够强的内存可见性保证。换言之,程序员只要遵循 happens-before 规则,那他写的程序就能保证在 JMM 中具有强的内存可见性。
JMM 使用 happens-before 的概念来定制两个操作之间的执行顺序。这两个操作可以在一个线程以内,也可以是不同的线程之间。因此,JMM 可以通过 happens-before 关系向程序员提供跨线程的内存可见性保证。
happens-before 关系的定义如下:
- 如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
- 两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么 JMM 也允许这样的重排序。
总之,如果操作 A happens-before 操作 B,那么操作 A 在内存上所做的操作对操作 B 都是可见的,不管它们在不在一个线程。
3.2 happens-before 规则
在 Java 中,有以下天然的 happens-before 规则:
- 程序顺序规则:一个线程中的每一个操作,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()
操作成功返回。
举例:
int a = 1; // A操作
int b = 2; // B操作
int sum = a + b;// C 操作
System.out.println(sum);
根据以上介绍的 happens-before 规则,假如只有一个线程,那么不难得出:
1> A happens-before B
2> B happens-before C
3> A happens-before C
注意,真正在执行指令的时候,其实 JVM 有可能对操作A & B进行重排序,因为无论先执行 A 还是 B,他们都对对方是可见的,并且不影响执行结果。如果这里发生了重排序,这在视觉上违背了 happens-before 原则,但是 JMM 是允许这样的重排序的。
所以,我们只关心 happens-before 规则,不用关心 JVM 到底是怎样执行的。只要确定操作 A happens-before 操作 B 就行了。
重排序有两类,JMM 对这两类重排序有不同的策略:
- 会改变程序执行结果的重排序,比如
A -> C
,JMM 要求编译器和处理器都禁止这种重排序。 - 不会改变程序执行结果的重排序,比如
A -> B
,JMM 对编译器和处理器不做要求,允许这种重排序。