一. Java 内存模型(JMM)概述
Java内存模型(Java Memory Model,JMM):在不同操作系统中,JVM需要保证操作的变量有统一的表现。为此,在制定JVM运行标准时,规定程序运行时变量的操作规范。
二. 为什么需要 Java 内存模型(JMM)
计算机CPU和内存条: 在CPU运行时,CPU处理数据速度远高于从内存条读取速度。为了解决这个问题,在CPU中加人了寄存器和多级缓。在计算时,先将数据读取到寄存器或缓存中,然后再进行计算。但是现代CPU,大部分是多核心设计,每个核心都有自己的缓存。并且各个核心的缓存是彼此隔离的,相互之间无法共享数据。由此,便会产生多个核心共享数据不一致问题。
缓存一致协议问题:
当多个核心同时运行时,需要从主内存(内存条)中读取同一份数据,然后进行执行处。虽然从主内存(内存条)中读取数据是一致的,但在CPU对缓存中数据操作后,数据出现不一致,并且无法通知其他核心。当其他核心执行操作需依赖此数据时,就会出现问题。
缓存一致性协议,就是为了解决多核运行时,缓存不一致问题。多核CPU的缓存虽然无法直接进行数据交互,但是他们共同依赖主内存(内存条)和总线。当一个核心修改了其他核心共同依赖的数据时,通过特殊的指令,快速将寄存器或缓存中数据保存到主内存(内存条)上。同时将其他核心依赖的该数据缓存置为无效。当需要使用时,再从新从主内存中读取。这样,就可以保证多个核心的缓存共享数据始终是一致的。
x86指令集CPU的缓存一致性协议 - MESI协议
每个CPU核心对于每个缓存行(缓存细分为一个个缓存行)做了一个表,表中有四个标识符:
Modified (M 修改) 从主内存读取到缓存行中的数据已经被修改过。并且该数据只在当前这个CPU核心中使用。在未来某个时候,该CPU核心会将修改过的缓存变量写入到主内存,以替换原来的值,保证缓存变量的值和主内存的值是一致的。同时缓存行改为共享状态(S).
Exclusive (E 独占) 缓存行仅存在当前缓存中,并且和主内存中的值一致。①当其他CPU核心读取该缓存行时,则变更为共享状态(S)。②当前CPU核心修改该缓存行数据时,则变更为修改状态(M)。
Shared (S 共享) 缓存行在多个CPU核心缓存中存在,并且这些CPU核心缓存中的值和主内存的值一致。当有一个CPU核心修改了该缓存行的值时,修改值的缓存行变更为修改状态(M),其他CPU核心中缓存行副本将变更为无效状态(I)。
Invalid (I 无效) 缓存行的数据时无效的。使用时需要更新从主内存中读取。
既然有了 MESI 为何还需要内存屏障?
1. CPU和缓存之间还存在 Load Buffer和Store Buffer, 只有变量从CPU寄存器刷新L1等才会触发MESI。
2. 内存屏障 还有禁止运行重排序功能,这对于多线程开发至关重要。
指令重排序问题: 也叫乱序执行(Out-Of-Order Execution)优化。CPU在执行程序指令时,在保证单线程下程序一致性表现下,为了最大执行效率,会对指令进行重新排序,提高执行效率。虽然最终保证结果与顺序执行的结果是一致的,但并不保证程序中各个指令执行的先后顺序与输入指令中的顺序一致。在单线程下,这个没有问题。但是在多线程下,一个线程执行依赖另外一个线程中操作的变量时,就会出现问题。
内存屏障(Memory Barriers): 指令重排可能会导致多线程下内存可见性问题。Java 内存模型 要求Java编译器在生成特定指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel称之为Memory Fence),来禁止特殊情况下的指令重排。通过禁止特殊情况下的编译器重排,处理器重排,保证内存可见性。StoreLoad Barriers 是全能型 内存屏障。包含其它三个内存屏障的作用。通过StoreLoad 屏障,处理器会将缓存区的数据全部刷新到内存中(Buffer Fully Flush)。
指令 Store1; StoreLoad; Load2 保证 Store1 数据对于其它CPU核心可见,并且先于 Load2 数据及其它load数据。
正是由于计算机物理结构和设计原因等问题,会导致程序运行时,不可避免的出现数据同步问题。为了解决这个问题,JVM制定了一套标准的Java程序运行时变量读取和写入的规则,即Java内存模型(Java Memory Model,JMM),保证在不同CPU指令集,操作系统架构下程序有一致的表现。
三. JVM中 Java 内存模型 (JMM)
(一) JVM变量操作基本规范
1.定义Java中线程 工作内存 和 主内存 的关系
(1). 所有变量都存储在主内存(Main Memory)上,主内存默认对应物理内存条。
(2). 每个线程都有自己的工作内存( working memory;也称之为本地内存 (Local Memory))。工作内存映射到物理上就是 高速缓存和寄存器。
注意,本地内存和 TLAB (Thread Local Allocation Buffer,线程本地分配缓冲区) 是完全不同的概念,注意不要弄混了。TLAB (Thread Local Allocation Buffer,线程本地分配缓冲区)是 Java 中内存分配的一个概念,它是在 Java 堆中划分出来的针对每个线程的内存区域,专门在该区域为该线程创建的对象分配内存。它的主要目的是在多线程并发环境下需要进行内存分配的时候,减少线程之间对于内存分配区域的竞争,加速内存分配的速度。TLAB 本质上还是在 Java 堆中的,因此在 TLAB 区域的对象,也可以被其他线程访问。
(3). 线程运行时使用变量,先从主内存上读取到工作内存上。即工作内存上保存的是主内存的拷贝副本。
(4). 线程不能直接操作主内存的变量,只能操作工作内存中的变量,然后再同步到主内存中。
完整使用变量的流程是,先从主内存中读取变量到工作内存,然后修改工作内存变量,再回写到主内存中。
(5). 多个线程之间,工作内存是无法共享的。如果多个线程之间要共享变量,则需要通过主内存进行处理。但是共享变量同步时,无法保证在其它核心工作内存中即时生效。因此会产生可见性问题。
2. 定义Java多个线程之间通信规则
Java多个线程之间,通过主内存进行通信,即通过主内存进行变量的共享。
例如线程A和线程B共享变量:①线程A将工作内存中修改过的共享变量回写到主内存中。②线程 B从主内存中读取更新过的共享变量到自己的工作内存中。
JVM 内存模型规范,制定了变量的八种操作指令:
read(读取): 从主内存中读取数据。
load(加载): 将主内存中读取的数据加载到工作内存中。
use(使用):线程执行指令时,使用工作内存中的数据。一个变量使用必须先read(读取),再load(加载)才能use(使用)。
assign(赋值):线程执行指令后,给变量赋予新的值。
store(保存):变量赋予新的值后,保存值。将工作内存的值准备回写到主内存中。
write(写入): 将工作内存中保存的值写入到主内存中。其他线程就可以读取使用该变量。
lock(加锁): 把指定的变量在主内存上标识为线程独占的锁定状态。即该变量只能当前线程使用。
unlock(解锁): 把主内存中锁定状态的变量释放出来。锁定状态的变量只有被释放后,才能被其他线程再锁定。
JVMV保证对变量进行上述前六个操作是原子的、不可再分的。long 和 double 类型是特殊的,没做严格要求。
线程在执行上述八种操作时,满足如下规则:
1. 当线程将变量从主内存复制到工作内存时,必须按照read 和load顺序进行。
当线程将变量从工作内存中回写到主内存中时,必须按照store和write顺序进行。read 和 load 或 store 和 write 可以不连续,中间可插入其他指令。
read 和 load 或 store 和 write 必须成对的出现,
2. 不容许丢弃工作内存中 assign(赋值)操作后的变量。工作内存中的变量值修改后必须回写到主内存中。
3. 不容许工作内存中未进行 assign(赋值)操作的变量回写入主内存中。
4. 不容许在工作内存中自己创建变量。工作内存中的变量必须是从主内存中 read(读取) 和 load(加载)。
在对工作内存中的变量进行 use(使用)或 store(保存)时,必须先执行 load(加载) 和 assign(赋值) 。
5. 主内存中的变量同一时刻只允许一个线程lock(加锁)。一个线程可以重复多次lock(加锁),只有执行相同次数的unlock(解锁)就行。
lock(加锁) 和 unlock(解锁) 必须成对的出现。
6. 如果一个变量没有先lock(加锁),则不允许执行unlock(解锁) 。同时也不允许去unlock(解锁)其他线程lock(加锁)的变量。
7. 当对一个变量执行 lock(加锁) 之前,会先清空该变量在工作内存中的值。当执行引擎使用这个变量时,需要重新从主内存中read(读取) 和 load(加载) 该变量。保证lock(加锁) 后,变量是从主内存中读取的最新值。
8. 对一个变量执行 unlock(解锁) 之前,必须先把此变量工作内存中的副本回写到主内存中,即执行 store (保存) 和 write (写入)操作。
(二) JVM 多线程共享变量并发处理
JVM虽然定义了变量的操作规范,但由于数据存在多份,并且JVM中工作内存之间不能直接共享变量,在多线程运行下,共享变量不可避免的存在不一致性问题。JVM为处理多线程下共享变量不一致问题,制定了一些规则和指令,解决这些问题。
可见性:当一个线程修改了共享变量,其他线程立即知道。
原子性:线程执行一个或一系列操作变量的指令时,必须是完整的,要么不被执行,要么一次性执行完,中间不被中断。
有序性: 在本线程中观察,所有操作都是有序的。即“线程内表现为串行”(Within-Thread As-If-Serial Semantics));
在一个线程中观察另外一个线程,所有操作都是无序的。即 (“指令重排序”现象和“线程工作内存与主内存同步延迟”现象)。
as-if-serial规则: 不管怎么重排(编译器和处理器为了提高并行度),单线程程序的执行结果不能改变。在as-if-serial语义规则要求下,编译器和处理器不会对数据存在依赖关系的语句进行重新排序,因为这种重排会影响结果。如果语句之间没有数据相互依赖,则可以进行执行语句重新排序,提高执行效率。
先行发生(Happens-Before)的原则: 如果一个操作先行发生 (Happens-Before)另一个操作之前,那么第一个操作结果对第二个操作可见,而第一个操作执行顺序排在第二个之前。两个操作之间存在先行发生(Happens-Before)关系,并不意味着必须按先行发生(Happens-Before)顺序执行。如果重排序的执行结果与 先行发生(Happens-Before)顺序执行结果一致,那么JVM就允许这种重排序。
1. 多线程共享变量的读同步与可见性
线程缓存导致的可见性问题:普通情况下,一个线程修改了自己工作内存中多线程共享变量的副本后,没有立即回写到主内存上时,则对其他线程不是可见的。
Java 解决线程缓存的可见性问题方法:
volatile 关键字:volatile 在JVM底层保证每次使用volatile修饰的变量时都是重新从主内存中读取。如果修改了volatile修饰的变量,则立即回写到主内存中。普通变量修改后,也会回写到主内存中。但是普通变量无法保证立即回写到主内存中。
sychornized 关键字:同步代码块的可见性是通过lock和unlock的逻辑间接实现的。当对一个变量执行 lock(加锁) 之前,会先清空该变量在工作内存中的值。当执行引擎使用这个变量时,需要重新从主内存中read(读取) 和 load(加载) 该变量。保证lock(加锁) 后,变量是从主内存中读取的最新值。 对一个变量执行 unlock(解锁) 之前,必须先把此变量工作内存中的副本回写到主内存中,即执行 store (保存) 和 write (写入)操作。
final 关键字:被final修饰的变量,只要经过构造函数初始化后,并且在构造方法执行时没有把 this 引用传递出去,对其他线程就可见。
重排序导致的可见性问题: 正常情况下,单线程执行指令时是有序性的。但是多线程下,由于存在指令重排,无法保证有序性。
Java保证线程之间执行指令的有序性:
volatile 关键字:volatile 在语义上有禁止指令重排的含义。volatile 修饰的变量,在读取和赋值时,会添加相应的内存屏障,禁止指令重排。
synchronized关键字:synchronized 映射到底层指令 lock,而 lock 保证变量同一时刻只能被一个线程锁住。当多个线程执行同一个同步块指令时,必须串行执行。
2. 多线程共享变量写同步与原子性
当多个线程共享一个变量时,多个线程修改共享变量,就会出现多线程竞争(Race Conditions)问题。Java使用原子性操作解决多线程竞争(Race Conditions)问题。
Java 内存模型(JMM)中定义的 read(读取),load(加载),use(使用),assign(赋值),store(保存),write(写入) 操作基本数据类型变量(JVM对long,double类型操作未要求原子性,但一般JVM实现了原子性)、引用类型变量具备原子性。
volatile 修饰的任何类型变量读写具有原子性。
普通变量,volatile 修饰变量 进行复合操作时,例如 i++, volatile v++ 则不具备原子性。变量操作依赖当前变量值,无法保证原子性。
synchronized 设置同步代码块,保证一个批次的指令集执行具备原子性。映射到字节码指令就是 monitorenter 和 monitorexist。
参考来源:
JMM 规范:The JSR-133 Cookbook for Compiler Writers [译]
如果觉得这篇文章对你有小小的帮助的话,记得在右下角点个“推荐”哦,博主在此感谢!
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· 开发者必知的日志记录最佳实践
· SQL Server 2025 AI相关能力初探
· Linux系列:如何用 C#调用 C方法造成内存泄露
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本
· Manus爆火,是硬核还是营销?
· 终于写完轮子一部分:tcp代理 了,记录一下
· 别再用vector<bool>了!Google高级工程师:这可能是STL最大的设计失误
· 单元测试从入门到精通