JMM(Java Memory Model,Java内存模型) 是Java中定义多线程环境下内存可见性、指令执行顺序以及线程交互规则的规范。它解决了并发编程中因硬件优化(如CPU缓存、指令重排序)导致的不可预测行为,确保开发者能够编写正确且高效的多线程程序。
想要学习JMM,先要了解一下什么是CPU缓存以及指令重排序的概念。
什么是CPU缓存?
CPU缓存是位于CPU和主内存(RAM)之间的高速存储器,用于减少CPU访问数据的延迟。由于CPU速度远快于内存,缓存通过存储频繁访问的数据副本,避免CPU因等待内存响应而闲置。
为什么要弄一个 CPU 高速缓存呢? 类比我们开发网站后台系统使用的缓存(比如 Redis)是为了解决程序处理速度和访问常规关系型数据库速度不对等的问题。 CPU 缓存则是为了解决 CPU 处理速度和内存处理速度不对等的问题。
我们甚至可以把 内存看作外存的高速缓存,程序运行的时候我们把外存的数据复制到内存,由于内存的处理速度远远高于外存,这样提高了处理速度。
总结:CPU Cache 缓存的是内存数据用于解决 CPU 处理速度和内存不匹配的问题,内存缓存的是硬盘数据用于解决硬盘访问速度过慢的问题。
层级结构
-
L1缓存:速度最快,容量最小(通常几十KB),每个CPU核心独享,分为指令缓存和数据缓存。
-
L2缓存:速度次之,容量较大(几百KB到几MB),通常每个核心独享或共享。
-
L3缓存:速度较慢,容量最大(几MB到几十MB),由同一CPU的所有核心共享。
缓存一致性问题
多核环境下,多个核心的缓存可能持有同一数据的副本,修改数据会导致不一致。缓存一致性协议(如MESI)通过状态标记(Modified、Exclusive、Shared、Invalid)确保数据一致性。
什么是指令重排序?
指令重排序是编译器或CPU为了优化性能,在不改变单线程程序语义的前提下,重新排列指令执行顺序的技术。
常见指令重排序有两种情况:
- 编译器优化重排:编译器(包括 JVM、JIT 编译器等)在不改变单线程程序语义的前提下,重新安排语句的执行顺序。
- 指令并行重排:现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。
内存可见性与有序性问题
-
单线程:重排序对结果无影响,因依赖关系被保留。
-
多线程:若共享变量未正确同步,重排序可能导致其他线程观察到非预期的执行顺序,引发逻辑错误。
解决方案
-
内存屏障(Memory Barrier):强制限制指令重排序,确保屏障前后的指令按顺序执行。
-
原子操作与同步机制:如锁、信号量或高级语言中的
volatile
(Java)、atomic
(C++)关键字,防止重排序并保证可见性。
为什么需要JMM?
硬件层面的优化(如CPU缓存、指令重排序)会引发以下问题:
-
可见性问题:一个线程修改共享变量后,其他线程可能无法立即看到最新值。
-
有序性问题:代码的实际执行顺序可能与编写顺序不一致。
-
原子性问题:非原子操作(如
i++
)在多线程中可能被中断,导致数据不一致。
JMM通过定义一套规则,屏蔽底层硬件差异,为开发者提供统一的并发编程语义。
JMM的核心概念
1. 主内存(Main Memory)与工作内存(Working Memory)
-
主内存:所有线程共享的内存区域,存储变量本身。
-
工作内存:每个线程私有的内存区域,存储线程操作变量的副本。
-
交互规则:
-
线程对变量的读写必须通过工作内存与主内存交互。
-
线程不能直接操作主内存中的变量,也不能访问其他线程的工作内存。
-
什么是Happens-Before规则?
简单来讲,就是定义多线程操作的执行顺序约束,确保某些操作的可见性和有序性。
为什么需要 happens-before 原则? happens-before 原则的诞生是为了程序员和编译器、处理器之间的平衡。程序员追求的是易于理解和编程的强内存模型,遵守既定规则编码即可。编译器和处理器追求的是较少约束的弱内存模型,让它们尽己所能地去优化性能,让性能最大化。happens-before 原则的设计思想其实非常简单:
- 为了对编译器和处理器的约束尽可能少,只要不改变程序的执行结果(单线程程序和正确执行的多线程程序),编译器和处理器怎么进行重排序优化都行。
- 对于会改变程序执行结果的重排序,JMM 要求编译器和处理器必须禁止这种重排序。
常见规则包括:
-
程序顺序规则:单线程中代码的执行顺序与编写顺序一致。
-
锁规则:解锁操作先于后续的加锁操作。
-
volatile
规则:对volatile
变量的写操作先于后续的读操作。 -
线程启动规则:
Thread.start()
前的操作对线程中的所有操作可见。 -
传递性:若A先于B,B先于C,则A先于C。
JMM如何解决并发问题?
1. 可见性
-
volatile
关键字:强制线程每次访问变量时从主内存读取,修改后立即同步回主内存。 -
锁机制(
synchronized
):线程进入同步块时清空工作内存,退出时将变量刷新到主内存。
2. 有序性
-
禁止重排序:通过Happens-Before规则和内存屏障限制编译器与CPU的指令优化。
3. 原子性
-
锁与原子类:通过锁或
java.util.concurrent.atomic
包的原子类(如AtomicInteger
)保证操作的原子性。