深入理解 Java 内存模型(一)
从Java代码到CPU指令
- 最开始,我们编写
Java
代码,是java文件。 - 在编译(javac命令)后,从刚才的java文件会编出一个新的Java字节码文件(
*.class
)。 - JVM会执行刚才生成的字节码文件(
*.class
),并把字节码文件转换为机器指令
。 -
机器指令
可以直接在cpu
上执行,也就是最终的执行程序。
为什么需要JMM(JMM 是一组规范) ?
因为不同的厂商的JVM实现会带来不同的“
翻译
”,不同的cpu平台
的机器指令又千差万别,无法
保证并发安全的效果一致
,为此需要规范
JVM的实现。
需要各个JVM的实现
来遵守JMM规范
,以便于开发者可以利用这些规范,更方便的开发多线程程序
。其次,volatile
,synchronized
,Lock
等的原理都是JMM。因为有JMM,我们只需要同步工具栏和关键字
就可以开发并发程序。
并发编程模型的分类
在并发编程
中,我们需要处理两个关键问题:
- 线程之间如何通信及线程之间如何同步(这里的线程是指并发执行的活动实体)。
-
通信
是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制有两种:共享内存
和消息传递
。
共享内存
在共享内存
的并发模型里,线程之间共享程序的公共状态,线程之间通过写 - 读内存中的公共状态来隐式
进行通信。
消息传递
在消息传递
的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式
进行通信。
同步
是指程序用于控制不同线程之间操作发生相对顺序的机制。
在共享内存
并发模型里,同步是显式
进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行
。
在消息传递
的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式
进行的。
Java 的并发采用的是共享内存模型
,Java 线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。如果编写多线程程序的 Java 程序员不理解隐式进行的线程之间通信的工作机制,很可能会遇到各种奇怪的内存可见性
问题。
Java内存模型的抽象
可见性
在 java 中,所有实例域
、静态域
和数组元素
存储在堆内存
中,堆内存
在线程之间共享
(本文使用“共享变量”这个术语代指实例域,静态域和数组元素)。局部变量
(Local variables),方法定义参数(java 语言规范称之为 formal method parameters)和异常处理器参数(exception handler parameters)不会在线程之间共享,它们不会有内存可见性
问题,也不受内存模型的影响
。
Java 线程之间的通信
由 Java 内存模型
(本文简称为 JMM)控制,JMM 决定一个线程对共享变量的写入何时对另一个线程可见。
从抽象的角度来看,JMM 定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存
(local memory),本地内存中存储了该线程以读 / 写共享变量的副本
。本地内存是 JMM 的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。Java 内存模型的抽象示意图如下:
从上图来看,线程 A
与线程 B
之间如要通信的话,必须要经历下面 2 个步骤:
- 首先,
线程 A
把本地内存 A
中更新过的共享变量刷新到主内存中去。 - 然后,
线程 B
到主内存中
去读取线程 A 之前已更新过的共享变量。
如上图所示,本地内存 A 和 B 有主内存中共享变量 x 的副本。假设初始时,这三个内存中的 x 值都为 0。线程 A 在执行时,把更新后的 x 值(假设值为 1)临时存放在自己的本地内存 A 中。当线程 A 和线程 B 需要通信时,线程 A 首先会把自己本地内存中修改后的 x 值刷新到主内存中,此时主内存中的 x 值变为了 1。随后,线程 B 到主内存中去读取线程 A 更新后的 x 值,此时线程 B 的本地内存的 x 值也变为了 1。
从整体来看,这两个步骤实质上是线程 A 在向线程 B 发送消息,而且这个通信过程必须要经过主内存。JMM 通过控制主内存与每个线程的本地内存之间的交互,来为 java 程序员提供内存可见性保证。
因此我们把一个线程对共享变量的修改,另外一个线程能够立刻看到的现象,称为可见性。
多核时代,每颗 CPU 都有自己的缓存,这时 CPU 缓存与内存的数据一致性就没那么容易解决了,当多个线程在不同的 CPU 上执行时,这些线程操作的是不同的 CPU 缓存。也正是由于这个原因,对于共享变量,由于多个线程之间,数据通信不及时,而造成数据的不可见性。
下面我们再用一段代码来验证一下多核场景下的可见性问题。
/**
* 描述: 案例1 演示可见性带来的问题
*/
public class FieldVisibility {
int a = 1;
int b = 2;
// 按顺序执行第一个线程称作线程1,第二个为线程二
// TODO: 情况1:线程一先执行,线程二其次。结果为:a = 3, b = 3
// 情况2:线程二先执行,线程一其次。结果为:a = 1, b = 2
// 情况3:线程一执行到change方法,给a赋值为3,然后切换线程,执行线程二,调用print方法。这样结果为:a = 3, b = 2
// 情况4:还有种特殊的情况线程一调用了change方法后,线程二在调用print方法时,两个线程之间的数据并没有及时同步过来(发生了可见性问题)结果为:a = 1, b = 3
// 对于情况4这样的问题,如何解决呢?可以在变量字段前加上关键字:volatile
private void change() {
a = 3;
b = a;
}
private void print() {
System.out.println("b=" + b + ";a=" + a);
}
public static void main(String[] args) {
while (true) {
FieldVisibility test = new FieldVisibility();
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
test.change();
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
test.print();
}
}).start();
}
}
}
下面我们再演示一段代码,每执行一次 add()
方法,都会循环 10000 次 count+=1 操作。在 testAdd()
方法中我们创建了两个线程,每个线程调用一次add()
方法,我们来看看执行 testAdd()
方法得到的结果 ?
package background;
/**
* 案例 2
*/
public class Test {
private static long count = 0;
private void add() {
int index = 0;
while(index++ < 10000) count += 1;
}
public static long testAdd() {
final Test test = new Test();
// 创建两个线程,执行 add() 操作
Thread th1 = new Thread(test::add);
Thread th2 = new Thread(test::add);
// 启动两个线程
th1.start();
th2.start();
// 等待两个线程执行结束
try {
th1.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
try {
th2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
return count;
}
public static void main(String[] args) {
System.out.println(testAdd());
}
}
其执行结果是不是很意外?为什么呢?这就是可见性而造成的问题。
我们假设线程 A 和线程 B 同时开始执行,那么第一次都会将 count=0 读到各自的 CPU 缓存里,执行完 count+=1 之后,各自 CPU 缓存里的值都是 1,同时写入内存后,我们会发现内存中是 1,而不是我们期望的 2。之后由于各自的 CPU 缓存里都有了 count 的值,两个线程都是基于 CPU 缓存里的 count 值来计算,所以导致最终 count 的值都是小于 20000 的。这就是缓存的可见性问题。
为什么会有可见性问题?
如上图所示:
cpu有多级缓存,导致读的数据过期。
- 高速缓存的
容量
比主内存小,但是速度
仅次于寄存器,所以cpu和主内存之间多了Cache层。 - 线程间的对于共享变量的可见性问题不是直接由多核引起的,而是由多缓存引起的。
- 如果所有核心都
只用一个缓存
,那么也就不存在内存可见性问题
了。 - 每个核心都会将自己需要的数据
读到独占缓存中
,数据修改后也是写入到缓存中,然后等待刷入到主存
中。所以会导致有些核心读取的值是一个过期
的值。
主内存和本地内存的关系
JMM有以下规定:
-
所有的变量
都存储在主
内存中,同时每个线程
也有自己独立
的工作内存
,工作内存中的变量内容是主内存中的拷贝
- 线程
不能直接读写主内存中
的变量,而是只能操作自己工作内存
中的变量,然后再同步
到主内存中 -
主内存
是多个线程共享
的,但线程间不共享工作内存
,如果线程间需要通信
,必须借助主内存中转
来完成
所有的共享变量存在于主内存
中,每个线程有自己的本地内存
,而且线程读写共享数据
也是通过本地内存交换
的,所以才导致了可见性
问题。
happens-before
-
happens-before
规则是用来解决可见性
问题的:在时间上,动作A发生在动作B之前,B保证能看见A
,这就是happens-before
。 - 两个操作可以用
happens-before来确定他们的执行顺序:
如果一个操作happens-before
于两一个操作,那么我们说第一个操作对于第二个操作是可见的`。 -
两个线程
没有相互配合的机制,所以代码X和Y的执行结果并不能保证总被对方看到
的,这就不
具备happens-before
.
原子性
现代的操作系统都基于更轻量的线程来调度,现在我们提到的“任务切换”都是指“线程切换”。
Java 并发程序都是基于多线程的,自然也会涉及到任务切换,也许你想不到,任务切换竟然也是并发编程里诡异 Bug 的源头之一。任务切换的时机大多数是在时间片结束的时候,我们现在基本都使用高级语言编程,高级语言里一条语句往往需要多条 CPU 指令完成。
例如上面代码中的count += 1
,至少需要三条 CPU 指令。
- 指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;
- 指令 2:之后,在寄存器中执行 +1 操作;
- 指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。
操作系统做任务切换,可以发生在任何一条CPU 指令执行完,是的,是 CPU 指令,而不是高级语言里的一条语句。对于上面的三条指令来说,我们假设 count=0,如果线程 A 在指令 1 执行完后做线程切换,线程 A 和线程 B 按照下图的序列执行,那么我们会发现两个线程都执行了 count+=1 的操作,但是得到的结果不是我们期望的 2,而是 1。
我们潜意识里面觉得 count+=1 这个操作是一个不可分割的整体,就像一个原子一样,线程的切换可以发生在 count+=1 之前,也可以发生在 count+=1 之后,但就是不会发生在中间。我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性。CPU 能保证的原子操作是 CPU 指令级别的,而不是高级语言的操作符
,这是违背我们直觉的地方。因此,很多时候我们需要在高级语言层面保证操作的原子性。
synchronized修饰的代码块里,会出现线程切换么?
在同步块里,线程也可能被操作系统剥夺cpu的使用权,但是其他线程此时是拿不到锁,所以其他线程不会执行同步块的代码。
重排序
案例演示
package jmm;
import java.util.concurrent.CountDownLatch;
/**
* 描述:演示重排序的现象 (直到达到某个条件才停止,测试小概率事件)
* 这4行代码的执行顺序决定了最终x和y的执行结果
* 第一种情况:x = 0, y=1(线程1先进行赋值操作,其次线程2再开始赋值操作:a=1;x=b;b=1;y=a)
* 第二种情况:x = 1, y=0(线程2先执行赋值操作,其次线程1开始执行赋值操作:b=1;y=a;a=1;x=b)
* 第三种情况:x = 1, y=1(线程1,2先执行第一行赋值操作,其次再执行第二行赋值操作:b=1;a=1;x=b;y=a)
* 第四种情况:x = 0, y=0(此时存在一种执行顺序,那就是y=a;a=1;x=b;b=1.当出现这样的一种执行顺序时,这就意味着重排序发生 )
*/
public class OutOfOrderExecution {
private static int x = 0, y = 0;
private static int a = 0, b = 0;
public static void main(String[] args) throws InterruptedException {
int i = 0;
for (; ; ) {
i++;
x = 0;
y = 0;
a = 0;
b = 0;
CountDownLatch latch = new CountDownLatch(3);
Thread one = new Thread(new Runnable() {
@Override
public void run() {
try {
latch.countDown();
latch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
a = 1;
x = b; // x = b = 0
}
});
Thread two = new Thread(new Runnable() {
@Override
public void run() {
try {
latch.countDown();
latch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
b = 1;
y = a; // y = a = 0
}
});
one.start();
two.start();
latch.countDown();
one.join();
two.join();
String result = "第" + i + "次(" + x + "," + y + ")";
if (x == 0 && y == 0) {
System.out.println(result);
break;
} else {
System.out.println(result);
}
}
}
}
什么是重排序 ?
如图,通过重排序
,进行了指令优化。
定义:
在线程内部的两行代码的实际执行顺序
和代码在java文件中的顺序
不一致,代码指令并不是严格要求按照代码语句顺序执行的,他们的顺序被改变了,这就是重排序(如上图所示)。
为什么要重排序 ?
在执行程序时为了提高性能
,编译器和处理器常常会对指令做重排序。重排序分三种类型:
- 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序。由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
从 java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序:
上述的 1 属于编译器重排序
,2 和 3 属于处理器重排序
。这些重排序
都可能会导致多线程程序出现内存可见性
问题。对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM 的处理器重排序规则会要求 java 编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers
,intel 称之为 memory fence
)指令,通过内存屏障
指令来禁止特定类型的处理器重排序
(不是所有的处理器重排序都要禁止)。
JMM 属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台
之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证
。
JVM内存结构
参考链接
参考链接