Java内存模型(JMM)是 “法律和物理规则”,而死锁、竞态条件等并发问题是 “违反规则后导致的事故”。
下面我们来详细拆解这个关系。
首先,要理解JMM不是一块真实的物理内存,而是一套抽象的规范和规则。它的存在是为了解决一个核心问题:在多核、多缓存的现代计算机体系结构下,如何保证一个线程对共享变量的修改能被其他线程正确地、及时地看到。
JMM围绕三个核心特性来定义这些规则:
原子性(Atomicity):一个或多个操作,要么全部执行且执行过程不被任何因素打断,要么就都不执行。JMM只保证基本数据类型的读取和赋值是原子性的(long
和double
除外,它们是64位,在32位系统上可能被拆分为两次操作)。像 i++
这种操作就不是原子性的。
可见性(Visibility):当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。由于CPU缓存、寄存器、指令重排序的存在,可见性并不能被默认保证。
有序性(Ordering):程序执行的顺序按照代码的先后顺序执行。但在实际执行中,编译器和处理器为了优化性能,可能会对指令进行重排序。JMM规定了哪些情况下可以重排序,哪些情况下不行。
JMM通过 volatile
, synchronized
, final
, java.util.concurrent
包中的类等工具,为开发者提供了遵守这些规则的手段。其中,Happens-Before 原则是JMM中描述可见性和有序性的核心。
现在,我们来看看具体的并发问题是如何与JMM的这些特性产生关联的。
问题描述:当多个线程访问和修改同一个共享资源时,最终的结果取决于线程的执行时序。最经典的例子就是 count++
。
与JMM的关系:
count++
实际上是三个步骤:1) 读取 count
的值;2) 将值加一;3) 将新值写回 count
。JMM本身不保证这个复合操作的原子性。因此,在多线程环境下,一个线程可能在另一个线程完成这三步中的任意一步时插入执行,导致结果错误。
count
为0,线程B也读取count
为0。线程A计算出1并写回,线程B也计算出1并写回。结果count
是1,而不是预期的2。count++
,并将新值(比如1)写回了主内存,另一个线程可能由于CPU缓存的原因,仍然读取到的是旧值0,从而导致计算错误。如何解决(利用JMM规则):
使用 synchronized
或 java.util.concurrent.atomic.AtomicInteger
。
synchronized
:它能保证代码块的原子性(只有一个线程能执行)和可见性(进入synchronized
块前会清空工作内存,从主内存加载;退出时会将修改刷新到主内存)。这满足了JMM的Happens-Before规则。AtomicInteger
:它使用CAS(Compare-And-Swap)这种硬件级别的原子操作来保证getAndIncrement()
的原子性和可见性。问题描述:一个线程修改了共享变量,但其他线程没有看到更新后的值,导致它们在旧值上继续工作。
与JMM的关系:
经典案例:
class VisibilityExample {
private boolean stop = false;
public void start() {
new Thread(() -> {
while (!stop) {
// do something
}
System.out.println("Thread stopped.");
}).start();
}
public void stop() {
this.stop = true;
}
}
另一个线程调用 stop()
方法将 stop
设置为 true
,但循环中的线程可能永远看不到这个变化,导致死循环。
如何解决(利用JMM规则):
给 stop
变量加上 volatile
关键字。
volatile
:这是JMM提供的轻量级同步机制。它保证了对该变量的写操作会立即刷新到主内存,并且任何读操作都会直接从主内存读取。同时,它还能禁止指令重排序(保证了有序性)。它确保了对volatile
变量的写操作 Happens-Before 于后续对这个变量的读操作。问题描述:代码的执行顺序与编写的顺序不一致,在单线程下通常没问题,但在多线程下可能导致意想不到的后果。
与JMM的关系:
经典案例:双重检查锁定(DCL)的单例模式(在不使用volatile
的情况下)。
class Singleton {
private static Singleton instance; // 如果没有 volatile
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) { // 1. 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 2. 第二次检查
instance = new Singleton(); // 3. 创建实例
}
}
}
return instance;
}
}
instance = new Singleton()
这行代码不是原子性的,它大致分为三步:
instance
引用指向分配的内存地址。由于指令重排序,步骤2和3的顺序可能被颠倒,变成 1 -> 3 -> 2。
instance = new Singleton()
,执行了1和3,但还没执行2。此时 instance
已经不为 null
。getInstance()
,在第一次检查时发现 instance != null
,于是直接返回 instance
。instance
是一个未完全初始化的对象,线程B使用它可能会导致程序崩溃。如何解决(利用JMM规则):
给 instance
变量加上 volatile
关键字。
volatile
会禁止第2步和第3步的重排序,确保只有在对象完全初始化之后,instance
引用才会被赋值。问题描述:两个或多个线程无限期地互相等待对方持有的资源,导致所有线程都无法继续执行。
与JMM的关系:
synchronized
或 java.util.concurrent.locks.Lock
)造成的。这些工具是用来解决JMM层面上的原子性和可见性问题的。并发问题 | 核心原因 | 与 JMM 的关系 | 如何利用 JMM 规则解决 |
---|---|---|---|
竞态条件 | 复合操作的非原子性、结果可见性延迟 | 直接违反了JMM的原子性和可见性保证。 | 使用 synchronized 或 Lock (提供原子性和可见性),或 Atomic* 类 (提供原子操作)。 |
内存可见性 | 线程工作内存与主内存数据不一致 | 直接是JMM要解决的核心问题——可见性。 | 使用 volatile 保证可见性,或使用 synchronized /Lock 在块的边界强制刷新内存。 |
指令重排序 | 编译器和处理器优化 | 直接是JMM规范的一部分——有序性。JMM允许重排序,但也提供了禁止重排序的手段。 | 使用 volatile 或 synchronized 插入内存屏障,禁止特定区域的指令重排序。 |
死锁 | 锁的获取顺序不当,资源循环等待 | 间接关系。死锁是应用层逻辑错误,通常由错误使用JMM提供的同步工具(synchronized , Lock )引起。 |
JMM本身不解决死锁。需要开发者在代码逻辑层面保证锁的顺序、使用try-finally释放锁、使用定时锁等。 |
总结:JMM定义了多线程编程中内存操作的“游戏规则”,而竞态条件、可见性问题、重排序问题是由于开发者没有正确利用 volatile
, synchronized
等工具来遵循这些规则而产生的“犯规”行为。死锁则是更高层面的“战术失误”,虽然也与这些工具有关,但根源在于逻辑设计。