Java的内存模型
本文讲解Java内存模型(JMM):一个旨在解决多线程环境下可见性和有序性问题的规范。它通过定义主内存、工作内存的交互规则以及Happens-Before原则,确保Java程序在不同平台表现一致,是并发编程的理论基础。
我们来深入浅出地讲解一下Java内存模型(Java Memory Model, JMM)。这部分内容是Java并发编程的核心和基石,理解它对于编写正确、高效的并发程序至关重要。
我会从以下几个方面来解释:
- 是什么(What): JMM的定义和目标。
- 为什么需要(Why): JMM要解决什么问题。
- 核心概念(Core Concepts): 主内存与工作内存,以及并发的三大特性。
- 如何实现(How): Happens-Before原则和相关的关键字。
- 一个生动的比喻:帮助你直观理解。
- 总结
1. 是什么 (What is the JMM?)
Java内存模型(JMM)是一个抽象的概念和规范。它不是物理上存在的内存结构,而是Java虚拟机(JVM)定义的一套规则,用来屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。
核心目标: 定义程序中各种变量(实例字段、静态字段和构成数组对象的元素)的访问规则,即在多线程环境下,一个线程对共享变量的写入何时对另一个线程可见。
2. 为什么需要 (Why do we need the JMM?)
现代计算机为了提高执行效率,做了很多优化,但这些优化给程序员带来了并发编程的挑战。主要有两个问题:
问题一:CPU缓存导致的“可见性”问题
- 硬件层面:CPU的运行速度远快于主内存(RAM)。为了弥补这个速度鸿沟,CPU有多级缓存(L1, L2, L3 Cache)。线程在执行时,会把主内存中的数据拷贝一份到自己的高速缓存中(这被称为工作内存的抽象)。线程对变量的读写操作都是先在自己的工作内存中进行。
- 问题:当多个线程操作同一个共享变量时,每个线程都在自己的工作内存中操作。一个线程修改了变量,但还没来得及写回主内存,另一个线程就从主内存中读取了旧的值。这就导致了可见性问题——一个线程的修改对其他线程不可见。
问题二:指令重排序导致的“有序性”问题
- 硬件与编译器层面:为了提升性能,编译器和处理器可能会对输入的代码进行重排序(Reordering),在不改变单线程程序执行结果的前提下,优化指令的执行顺序。
- 问题:在单线程环境下,重排序没问题,因为最终结果是一致的。但在多线程环境下,一个线程的重排序可能会被另一个线程观察到,从而导致意想不到的后果。
例子:
假设有两个线程:
线程A执行:
int a = 0;
boolean flag = false;
// ...
a = 1; // (1)
flag = true; // (2)
线程B执行:
// ...
if (flag) { // (3)
int i = a * a; // (4)
}
我们期望的是,如果线程B的if(flag)为true,那么a一定等于1。但是,由于指令重排序,线程A的(1)和(2)可能会被重排,先执行flag = true;,再执行a = 1;。这时,如果线程B在flag变为true后立即执行,它看到的a可能还是0,导致i的结果为0,这就不符合预期了。
JMM就是为了解决上述的可见性和有序性问题,提供一套规则,让程序员能够预知和控制多线程环境下的内存行为。
3. 核心概念 (Core Concepts)
JMM围绕着两个核心模型和三个特性来建立规则。
a. 主内存与工作内存 (Main Memory & Working Memory)
这是JMM的抽象模型,与上面提到的CPU缓存问题相对应:
- 主内存 (Main Memory):存储所有线程共享的变量(实例变量、静态变量)。
- 工作内存 (Working Memory):每个线程私有的。线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,不能直接读写主内存。不同线程之间无法直接访问对方的工作内存,线程间变量值的传递均需要通过主内存来完成。
交互过程:
- read & load: 从主内存读取数据到工作内存。
- use & assign: 在工作内存中执行计算、赋值等操作。
- store & write: 将工作内存的数据写回主内存。
b. 并发编程的三大特性
JMM的规则和提供的关键字(如volatile, synchronized)都是为了保证这三大特性。
原子性 (Atomicity)
- 定义:一个或多个操作,要么全部执行且执行过程不会被任何因素打断,要么就都不执行。
- 例子:
int i = 10;是原子操作。但i++;不是,它包含“读取i的值”、“加1”、“写回i的值”三个步骤,可能在任何一步被打断。 - 保证方式:Java中的
synchronized关键字和java.util.concurrent.locks包下的锁可以保证代码块的原子性。
可见性 (Visibility)
- 定义:当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。
- 问题来源:CPU缓存。
- 保证方式:
volatile:通过强制从主内存读、写到主内存来保证可见性。synchronized:在解锁(unlock)前,必须把共享变量的最新值刷新到主内存。在加锁(lock)时,会清空工作内存中共享变量的值,从而使用时需要从主内存重新加载。final:被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把this引用传递出去,那么在其他线程中就能看见final字段的值。
有序性 (Ordering)
- 定义:程序执行的顺序按照代码的先后顺序执行。
- 问题来源:指令重排序。
- 保证方式:
volatile:关键字本身就包含了禁止指令重排序的语义。synchronized:一个变量在同一个锁下一次只能被一个线程访问,这保证了持有同一个锁的两个同步块只能串行地进入,从而保证了有序性。- Happens-Before 原则:JMM中最重要的有序性规则。
4. 如何实现 (How does JMM work?)
JMM最核心的规则就是 Happens-Before 原则。这个原则是判断数据是否存在竞争、线程是否安全的主要依据。
Happens-Before 原则 (先行发生原则)
如果两个操作之间存在Happens-Before关系,那么前一个操作的结果对后一个操作是可见的,并且前一个操作的执行顺序排在后一个操作之前。
JMM定义的天然Happens-Before规则(无需任何同步措施):
- 程序次序规则 (Program Order Rule):在一个线程内,按照代码书写顺序,前面的操作 Happens-Before 后面的操作。
- 监视器锁规则 (Monitor Lock Rule):对一个锁的解锁(unlock)操作 Happens-Before 于后续对这个锁的加锁(lock)操作。
- volatile变量规则 (Volatile Variable Rule):对一个
volatile变量的写操作 Happens-Before 于后续对这个变量的读操作。 - 线程启动规则 (Thread Start Rule):
Thread对象的start()方法 Happens-Before 此线程的任何一个动作。 - 线程加入规则 (Thread Join Rule):线程中的所有操作都 Happens-Before 于对此线程的
join()方法的返回。 - 线程中断规则 (Thread Interruption Rule):对线程
interrupt()方法的调用 Happens-Before 于被中断线程的代码检测到中断事件的发生。 - 传递性 (Transitivity):如果操作A Happens-Before 操作B,操作B Happens-Before 操作C,那么操作A Happens-Before 操作C。
如何使用Happens-Before?
回到之前的例子:
// 线程A
a = 1; // (1)
flag = true; // (2)
// 线程B
if (flag) { // (3)
int i = a * a; // (4)
}
如果flag用volatile修饰:volatile boolean flag = false;
- 根据 volatile变量规则,(2) happens-before (3)。
- 根据 程序次序规则,(1) happens-before (2)。
- 根据 传递性,(1) happens-before (3)。
- 因为(1) happens-before (3),所以(1)的结果对(3)可见。当(3)中
flag为true时,(1)中的a=1这个操作必然已经完成且对线程B可见。这样就保证了程序的正确性。
5. 一个生动的比喻
想象一个大办公室,里面有一个中央公告板(主内存)。每个员工都有自己的小记事本(工作内存)。
普通工作流程:员工A想更新项目进度,他先把公告板上的信息抄到自己的记事本上,在记事本上修改,然后(可能过了一段时间)再把修改后的内容更新到公告板上。在他更新之前,其他员工B看到的还是公告板上的旧信息(可见性问题)。
使用
volatile:volatile就像一条规定:“任何关于这个特定项目的信息,必须直接在公告板上读写,不允许抄到记事本上再修改”。这样,只要有人更新了,所有人立刻都能看到(保证了可见性),并且大家都是按顺序更新的(保证了有序性)。使用
synchronized:synchronized就像一间带锁的会议室。公告板放在会议室里。- 员工A要更新信息,必须先拿到会议室的钥匙(加锁)。
- 他进入会议室,在里面更新公告板(原子操作,不会被打扰)。
- 更新完毕后,他走出会议室并交还钥匙(解锁)。
- 员工B想看信息,也必须拿到钥匙才能进去。他进去后,看到的一定是A更新后的最新内容(保证了可见性)。由于一次只能一个人进去,所以操作自然是排队的(保证了有序性)。
6. 总结
- JMM是规范:它定义了Java多线程程序中内存访问的规则,旨在屏蔽底层硬件差异。
- 解决两大问题:CPU缓存导致的可见性问题和指令重排序导致的有序性问题。
- 核心模型:抽象的主内存和工作内存模型。
- 核心原则:Happens-Before原则是判断线程安全和内存可见性的关键。
- 开发者工具:Java提供了
volatile,synchronized,final以及JUC包中的工具,让开发者可以利用JMM的规则来编写正确的并发程序。
理解了JMM,你就能更深刻地理解为什么需要volatile和synchronized,以及它们是如何工作的,从而在多线程编程中游刃有余。