之前一直把 Java内存模型
和 JVM内存结构
搞混。 其实两者是不同的东西。
Java内存模型(Java Memory Model,JMM) 定义了 java 运行时如何与硬件内存进行交互,比如规定了一个线程如何看到其他内存修改后共享变量的值。一些高级特性也建立在JMM的基础上,比如volatile 关键字。
而 JVM 内存结构, 定义了 JVM 内存是如何划分的。
下面我们介绍JMM。并从三个方面介绍: 为什么,是什么,解决了什么
JMM
为什么要有Java内存模型
我们都知道一台基本的计算机有 CPU 和 内存 两个东西。
CPU负责计算, 内存负责存储运行的程序和数据。
每次CPU计算时它会通过地址总线(Address Bus)向内存发送一个地址信号,指定要读取内存单元的地址。 然后进行等待。
之后内存将数据通过数据总线返回给CPU, CPU会将数据加载到寄存器中处理。然后将结果存储回内存。
但随着计算机性能的发展,CPU的计算速度越来越快。这个等待的时间(即内存读写的速度)相比计算的时间变得越来越长,CPU就无法及时获得需要的数据,导致性能下降。
为了加快运行的速度,加了一个CPU 高速缓存。每次需要读取数据的时候,先从内存读取到CPU缓存中,CPU再从CPU缓存中读取。由于CPU高速缓存则直接集成在CPU内部,与CPU之间的距离更近,因此访问速度大大加快了。
实际上会分为 一级缓存、二级缓存、和三级缓存,并且会有多核CPU。如下图
在多核CPU的情况下,每个CPU都有自己的高速缓存,而它们又共享同一块主内存。 就会发生缓存一致性问题。
比如, 初始化 a = 0;
线程a执行 a = a + 1;
线程b执行 a = a + 1;
多核CPU下可能会发生下面这种情况:
- CPU1 执行线程a, 从内存读取 a 为0, 存到高速缓存中。
- CPU2 执行线程b, 从内存读取 a 为0, 存到高速缓存中。
- CPU1 进行运算,得出结果 1,写回内存 i 的值为 1。
- CPU2 进行运算,得出结果 1,写回内存 i 的值为 1。
而在我们看来,正确结果应该是2.
错误的原因就是: 两个CPU的高速缓存是相互独立的,无法感知相互数据变化。
这就造成了缓存一致性问题。
怎么在硬件层面上解决这个问题? 比如 MESI 协议
。
该协议定义了四种缓存数据状态:
- 修改(Modified):该数据在高速缓存中修改了,还没同步到主内存,数据只存在于本缓存,与主内存不一致
- 独占(Exclusive): 我刚从内存中读取出来,别人没读过,数据只存在于本缓存中,与主内存一致
- 共享(Shared): 很多人同时从内存读该数据,数据存在于很多缓存中,与主内存一致
- 失效(Invalid):有人对该数据操作了,这不是最新的,数据无效
在该协议下,上面执行的运算就变成了这样:
- CPU1 执行线程a, 从内存读取 a 为0, 存到高速缓存中。
- CPU2 执行线程b, 从内存读取 a 为0, 存到高速缓存中。
- CPU1 进行运算,得出结果 1,写回内存 i 的值为 1。通过消息的方式告诉其他持有 i 变量的 CPU 缓存,将这个缓存的状态值为 Invalid。
- CPU2 进行运算,从缓存中取值,但该值已经是Invalid,重新从内存中获取。读取 a为 1, 放入高速缓存
- CPU2 进行运算,得出结果 2,写回内存 i 的值为 2。
从上面的例子,我们可以知道 MESI 缓存一致性协议,本质上是定义了一些内存状态,然后通过消息的方式通知其他 CPU 高速缓存,从而解决了数据一致性的问题。
说了这么多, 到底为什么要有Java 内存模型呢?
原因之一就是上面提到的缓存一致性问题:在不同的 CPU 中,会使用不同的缓存一致性协议。例如 MESI 协议用于奔腾系列的 CPU 中,而 MOSEI 协议则用于 AMD 系列 CPU 中,Intel 的 core i7 处理器使用 MESIF 协议。
当然还有其他原因,比如指令重排序导致的可见性问题等。
那么怎么统一呢? 要知道,Java 的最大特点是 Write Once, Run Anywhere
什么是JAVA内存模型
为了解决这个问题,Java封装了一套规范,这套规范就是Java内存模型。JMM定义了一组规则,以确保多线程环境下的内存可见性、有序性和数据同步等。
Java内存模型 想 屏蔽各种硬件和操作系统的访问差异,保证了Java程序在各种平台下对内存的访问都能得到一致效果。
JMM的抽象示意图如图所示
实际上,这个本地内存并不真实存在,只是JMM的抽象概念,它既可能在缓存,也可能在寄存器等。
从图上可以看出,线程A无法直接访问线程B的工作内存,线程间通信必须经过主内存。
JMM通过控制主内存与每个线程的本地内存之间的交互,来提供内存可见性保证。
可见性指: 指一个线程修改了共享变量的值后,其他线程是否能够立即看到这个修改的值。
JAVA内存模型解决了什么
Java内存模型封装了底层的实现后提供给开发人员一些关键字,比如volatile、Synchronized、final等,从而不需要关心底层的编译器优化、缓存一致性的问题。
线程之间的可见性问题
可以使用volatile关键字来保证多线程操作时变量的可见性
比如现在:
int a = 0;
volatile boolean flag = false;
public void writer() {
a = 1;
flag = true;
}
public void reader() {
if (flag) {
System.out.println(a);
}
}
a 变量被声明为 volatile,这意味着所有的读写操作都是从主存中进行的,而不是从缓存中进行的。a 被修改后可以立即同步到主内存。
假设在时间线上,线程A先执行方法writer方法,线程B后执行reader方法。那必然会有下图。
而如果flag变量没有用volatile修饰,在步骤 2,线程A的本地内存里面的变量就不会立即更新到主内存,那随后线程B也同样不会去主内存拿最新的值,仍然使用线程B本地内存缓存的变量的值a = 0,flag = false。
指令的的有序性问题
说到有序性,就得说到重排序问题。
什么是重排序呢?
可以看以下代码, 线程a 执行 a = 1, x = b;
a = 1;
x = b;
处理器或者编译器可能会优化,进行重排序,先执行 x = b, 再执行 a = 1。 为什么呢?
- 前面提到,CPU取数是需要等待的:等待内存加载这个数。所以在等待的过程中,为了做更多的事,先执行一些不依赖这些数据的指令,从而在等待时间内执行更多的指令,这样可以提高执行效率。
- 同时,对于不会改变程序执行结果的重排序,JMM是允许的
但是在多线程下,这种重排序就会带来一些问题。
比如我们继续看之前的代码
private int a = 0;
private boolean flag = false;
public void writer() {
a = 1; //1
flag = true; //2
}
public void reader() {
if (flag) { //3
int i = a * a; //4
}
}
依然是A线程执行writer(), B线程执行reader()。
那么由于指令重排,线程1执行writer方法时,代码顺序可能会变为:2 -> 1。也就是先执行了flag的赋值操作,再执行了a的赋值操作。此时线程2的读线程执行if(flag)时,由于flag已经被赋值为true,就会执行a的读取操作(语句4)。
假如这时候线程A还没执行到 a = 1。 那么 语句4就会获得 a的旧值 0, 从而导致 int i = 0 * 0 = 0 的错误结果。
怎么解决呢?
可以利用 volatile 除保障内存可见性外的第二个效果: 禁止重排序。
如之前一样,我们给 flag变量定义一个关键字:volatile ,就可以了。
JMM 是怎么给 volatile 实现进制重排序的呢? 答案是内存屏障。
在 flag 变量 写之前, 插入一个 StoreStore 屏障, 这个屏障会把 "普通写"的 a = 1强制刷新到内存,从而实现禁止重排序。
对于内存屏障更详细可以看:http://concurrent.redspider.group/article/02/8.html
又比如 synchronized 关键字, 利用锁 实现 synchronized 代码块里的禁止重排序。
多线程环境下的原子性问题
JMM还提供了 synchronized 来保证原子性问题。 说到这个关键字,能说的东西比较多。有空再进行补充
总结
- Java 为了解决多平台运行的问题,同时为了解决Java中多线程程序运行过程中可能出现的内存可见性、有序性和原子性等问题,得到了 Java 语言层面上的内存模型JMM。
- JMM是抽象的,围绕原子性、有序性、可见性等展开的。
参考:
http://concurrent.redspider.group/article/02/6.html
https://www.cnblogs.com/chanshuyi/p/deep-insight-of-java-memo...
https://zhuanlan.zhihu.com/p/29881777
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。