如果你在搜索引擎里输入“java内存模型”,看到的结果很可能分成两拨:一拨是Java虚拟机运行时数据区,讲堆、栈、方法区;另一拨是主内存与工作内存、volatile、happens-before、内存屏障。两拨内容都挂着“java内存模型”的名头,但本质不是一回事。我自己学JMM的第一感受就是:必须先做概念清障,否则后面全是浆糊。
这篇内容是我的一篇学习记录,适合正在面试前突击并发基础的同学,也适合写多线程代码总是“看着没问题、跑起来随机出错”的人。我会把容易混淆的概念掰开,再结合几个经典案例把规则落到代码里,最后整理一份常见问题速查表。目标是让你看完以后,能用自己的话把“java内存模型到底是什么”讲清楚,而不是被八股文牵着鼻子走。
1. 一次概念清障:JMM不是JVM内存布局
1.1 我最初的理解错在哪
最开始我把“java内存模型”等同于“JVM内存结构”,后来发现完全不是。JVM内存结构指的是虚拟机运行时把内存分成程序计数器、虚拟机栈、本地方法栈、堆、方法区(Java 8之后多数实现用元空间代替永久代)。这是和“对象放哪、方法栈帧在哪”相关的话题。而JMM关心的是:在多线程环境下,一个线程对共享变量的写入,什么时候对另一个线程可见;编译器、CPU为了性能做的指令重排,边界在哪里。
换句话说,JVM内存结构管的是“数据怎么在内存区域里放”,JMM管的是“多线程访问共享数据时,你能看到什么、看不到什么”。一个偏运行时布局,一个偏并发语义。
1.2 JMM到底在解决什么问题
Java内存模型是一套规范,不是某个实际存在的物理内存区域。它描述的是Java程序中的变量(成员变量、静态变量、数组元素,但不包括局部变量和方法参数)在不同线程之间的一致性规则。
为什么需要这套规则?因为现代CPU有缓存和指令重排。同一个变量可能被多个CPU核读取,如果一个线程修改了变量但还没写回主内存,另一个线程读到的就是旧值。指令重排又会让代码的执行顺序“看起来”变了,尤其是写操作和读操作交替出现时,问题更容易暴露。
我常用一个类比:公司里有一个公共公告栏(主内存),每个员工手里还有一块私人白板(工作内存/CPU缓存)。员工看通知先看自己的白板,白板没有才去公告栏拿最新版。问题是,员工在白板上改了通知内容,可能并不会立刻贴回公告栏。另一个员工过来看,看到的是白板上的旧通知。JMM就是规定“什么时候必须同步公告栏”“什么时候不能只改白板”的规则集。
1.3 别把“JMM”和“JVM内存结构”混在一起背
很多搜索热词里“jvm内存模型”“java8jvm内存模型中是否还有方法区”指向的是同一个困惑。如果你准备面试,这两块都要知道,但不要混在一起背。
JVM运行时数据区是分区域的,比如堆是共享的,栈是线程私有的。JMM则把共享内存抽象成主内存,把每个线程的操作抽象成工作内存。本质上,JMM的这个抽象是用来对应CPU缓存、寄存器这些东西的,不是在描述JVM自己怎么分配内存。
顺带说一句Java 8的变化:规范里的“方法区”仍然存在,它只是逻辑上的概念,落地实现从永久代换成了元空间。更准确地说,Java 8移除的是永久代,不是方法区。元空间使用本地内存,不再容易触发永久代溢出,这是另一个话题了。但如果你听到“Java 8没有方法区”这种说法,至少要知道它省略了“实现方式变了”这个前提。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. JMM的核心:可见性、有序性、原子性
2.1 三个性质分别是什么意思
并发Bug的根源可以收敛到三个词:可见性、有序性、原子性。
可见性是指一个线程修改共享变量后,其他线程能不能立即看到这个修改。造成不可见的原因是CPU缓存和指令重排。有序性是指程序执行结果不能因为指令重排而变乱。原子性是指一组操作要么全部执行成功,要么全部不执行。
JMM对原子性其实没有提供太多的直接保证,它更聚焦在可见性和有序性上。原子性通常交给synchronized、Lock、Atomic类去解决。刚开始学的时候,很容易把“volatile可以保证可见性”误解成“volatile可以保证原子性”,这是最大的坑之一。
举个例子,两个线程同时执行 count++。count++ 并不是一条原子操作,它至少包含“读取 count、计算 count+1、写回 count”三步。volatile能保证每一步操作的即时可见,但保证不了“读取和写回之间,变量没有被别人改过”。所以最终结果可能小于20000。
2.2 Happens-Before原则:JMM的“先行发生”规则
JMM定义了一组关系,叫happens-before。如果A操作happens-before B操作,那么A的所有操作结果对B可见,且A的执行顺序排在B之前。核心规则有这些:
| 规则 | 含义 |
|---|---|
| 程序次序规则 | 同一个线程内,按代码书写顺序,前面的操作happens-before后面的操作 |
| 管程锁定规则 | unlock操作happens-before后面对同一个锁的lock操作 |
| volatile变量规则 | 对一个volatile变量的写操作happens-before后面对这个变量的读操作 |
| 线程启动规则 | Thread.start() happens-before该线程中的每一个动作 |
| 线程终止规则 | 线程中的所有操作happens-before其他线程检测到该线程终止 |
| 线程中断规则 | interrupt()调用happens-before被中断线程检测到中断事件 |
| 对象终结规则 | 一个对象的初始化完成happens-before它的finalize()开始 |
| 传递性 | 如果A happens-before B,B happens-before C,则A happens-before C |
这些规则看起来抽象,但实际开发中一直在用。比如你在线程A里设置一个标志位为true,然后启动线程B,B读到这个标志位就一定能看到true,因为线程启动规则保证了这一点。又比如你用过synchronized,天然满足管程锁定规则,所以临界区里的共享变量修改对下一个进入临界区的线程可见。
2.3 从代码看happens-before
假设这样一段代码:
java复制int a = 0;
boolean ready = false;
// 线程1
a = 1;
ready = true;
// 线程2
if (ready) {
System.out.println(a);
}
如果没有额外的同步手段,线程2可能看到ready为true,但a还是0。为什么?虽然线程1里a=1写在ready=true之前,但这两条语句没有happens-before关系(不在同一个线程的顺序之外,编译器/CPU可能重排)。所以线程2看到的可能是一个处于中间状态的世界。
如果给ready加上volatile:
java复制volatile boolean ready = false;
根据volatile变量规则,线程1对ready的写happens-before线程2对ready的读。同时依靠传递性,a=1这个发生在ready=true之前,而ready=true happens-before线程2读到ready,那么线程2如果能读到ready为true,就一定能看到a已经变成1。这就是volatile不仅能保证标志位可见,还能顺带保证它之前的其他变量写入也被刷新的原因。
3. 内存屏障与并发关键字的底层语义
3.1 volatile到底做了什么
JMM层面说volatile有两条语义:保证可见性、禁止指令重排。在底层实现上,volatile读写会插入内存屏障,或者由JIT在生成汇编时加上lock前缀指令。
简单理解,内存屏障是一种指令,它限制前后指令的重排。JMM内部有四种屏障:LoadLoad、StoreStore、LoadStore、StoreLoad。
- StoreStore屏障:确保屏障前的普通写操作先刷新到主内存,屏障后的写操作才能开始。
- StoreLoad屏障:确保屏障前的写操作先刷新,屏障后的读操作才能开始。
- LoadLoad屏障:确保屏障前的读操作先完成,屏障后的读操作才能开始。
- LoadStore屏障:确保屏障前的读操作先完成,屏障后的写操作才能开始。
volatile读后面会加LoadLoad屏障和LoadStore屏障,确保后续普通读不能越过volatile读往前排;volatile写前面会加StoreStore屏障,确保前面的普通写已经完成,后面会加StoreLoad屏障,确保后续读写不能越过volatile写往前排。
如果你不写底层,不需要背这些屏障名字,但理解屏障的存在能帮你更直观地明白“为什么volatile会带来性能损耗”。它阻止的每一件事,都是CPU和编译器原本为了优化能干出来的事。
3.2 synchronized不只是加锁
synchronized的底层涉及monitor,也就是监视器锁。一个线程进入synchronized代码块时,需要获得锁,离开时释放锁。按照管程锁定规则,释放锁的操作happens-before后续获取同一把锁的操作。这意味着锁不只是在互斥,它还在“传递数据”。
我用过一个很常见的场景:多个线程处理同一个列表,每次进入临界区后都能看到前一个线程写入的最新数据。这不是巧合,是synchronized的内存语义保证的。进入临界区时相当于从主内存重新读取共享变量,退出临界区时相当于把工作内存中的修改刷回主内存。
顺带提一句,synchronized和ReentrantLock一个很大的差异,是ReentrantLock底层用到了AQS和CAS,而AQS里的共享变量也用volatile和Unsafe提供了内存屏障支持。但两者最终都能实现“锁语义 + 内存可见性”的效果。
3.3 final字段的可见性
很多人忽略final。JMM里有一条final字段的特殊规则:在构造函数中正确初始化final字段后,其他线程无需同步就能看到这个final字段的正确值,条件是“对象引用没有被重排序到构造函数完成之前”。
更直白地说,只要构造函数没有让this引用逸出(比如在构造期间启动新线程、注册监听器、把this传给别的对象),final字段就是安全的。但如果普通字段呢?就还要靠锁或volatile。
我记得一个实际教训:写不可变对象时,以为字段都是final就万事大吉,结果对象在构造函数里开启了子线程。子线程读到的对象也许还是半成品。这不是final的问题,是对象逸出问题。安全发布对象和final字段要配合使用。
4. 三个经典案例把理论变成实战
4.1 循环等flag,为什么可能死循环
先看一个经典例子:
java复制public class VisibilityTest {
private static boolean flag = false;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
while (!flag) {
// 空转
}
System.out.println("exit");
});
t.start();
Thread.sleep(1000);
flag = true;
t.join();
}
}
在无同步的情况下,主线程把flag改成true,子线程的循环可能一直看不到,程序不会退出。原因是子线程的while循环可能一直在读工作内存里的旧值,或者JIT把while循环优化成了一个死循环。
解决办法之一是给flag加volatile。这样主线程的写操作会刷新主内存,子线程每一次读都会重新从主内存拿最新值,同时volatile变量规则保证了可见性。这个例子很基础,但每次跑出来都挺震撼的:代码明明看着改了flag,程序就是不退出。
如果循环里有println之类会产生锁或内存屏障的操作,可能无意中就把可见性问题掩盖了,这也是多线程Bug“加一行日志就正常”的原因之一。
4.2 count++为什么需要AtomicInteger
再看一个原子性问题:
java复制private static int count = 0;
private static final int LOOP = 10000;
public static void main(String[] args) throws Exception {
Thread t1 = new Thread(VisibilityTest::increment);
Thread t2 = new Thread(VisibilityTest::increment);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
即使main方法里先等一下让两个线程同时跑,最后count也大概率小于20000。因为count++的读、加、写三步之间可能被插队。
把count声明成volatile也解决不了,因为volatile不能保证“读-改-写”的原子性。需要用synchronized包裹,或者用AtomicInteger。AtomicInteger底层依赖CAS,CAS本身是一条原子指令,并且配合volatile保证可见性,所以计数结果才对。
这个案例告诉我们:分析并发bug时,先看操作是否原子,再看变量是否可见。可见性解决“看不看得到”,原子性解决“会不会被打断”。两者缺一不可。
4.3 单例双重检查为什么必须加volatile
双重检查锁是并发面试常客:
java复制class Singleton {
private static volatile Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
instance如果不加volatile,会有极小概率拿到“半初始化”的对象。原因在于 instance = new Singleton() 这条代码不是原子操作。它大体是三步:分配内存、调用构造方法初始化对象、把引用赋值给instance。JIT和CPU可能把第2步和第3步重排,让instance先指向一块还没初始化完成的内存。此时另一个线程进来发现instance不为null,直接使用,结果字段都是默认值。
加volatile之后,写volatile变量会插入StoreStore屏障,禁止初始化过程和赋值操作重排。读volatile变量也会保证看到的是已经完整初始化的对象。这正是JMM在实战中最出名的一个应用。
5. 学习路上常见的坑:面试题与误区
5.1 JMM和JVM内存结构,一句话分清
面试官如果问“Java内存模型”,不要一上来就答堆、栈、方法区。要先确认对方问的是Java内存模型(JMM)还是JVM运行时内存结构。如果问的是JMM,重点讲主内存和工作内存、可见性、Happens-Before、volatile语义。如果问的是JVM内存结构,重点讲线程私有区域和线程共享区域。
如果对方问“Java 8的JVM内存模型中还有方法区吗”,这里就有一点陷阱色彩。规范层面方法区仍然存在,HotSpot虚拟机在Java 8用元空间实现了方法区,永久代被移除。元空间属于本地内存,不再占用堆内存。说“方法区没了”不准确,说“永久代被移除”才准确。
5.2 volatile不是万能药
volatile的三大常见误区:
| 误区 | 正确认识 |
|---|---|
| volatile能保证原子性 | 不能,可见性和有序性不等于原子性 |
| volatile可以替代synchronized | 不能,复合操作仍然需要锁 |
| volatile的性能一定差 | 不一定,但确实会阻止更多优化 |
实际编码中,volatile最合适的场景是:一个变量由单个线程写,其他线程读。比如状态开关、运行标志、发布对象引用。如果是多个线程同时写同一个变量,或者写操作依赖当前值,就要考虑锁或原子类。
5.3 OOM和JMM有什么关系
搜索热词里有“gc+java内存模型优化”“OutOfMemoryError”这些,其实它们和JMM不是一回事。OutOfMemoryError是JVM在堆、元空间、直接内存等区域无法分配内存时抛出的异常。这是JVM内存管理的问题,和GC调优、内存泄漏有关。JMM负责的是并发语义正确性,不负责内存够不够用。
如果把这两个混在一起,面试时也很容易翻车。比如面试官问“如何减少OOM”,你需要提到调整堆参数、检查大对象、优化递归深度、排查内存泄漏。而提到JMM时,你应该讲并发时数据的一致性。两者都重要,但请放在不同的话题下去讲。
5.4 其他高频题:happens-before、内存屏障、双检锁
我整理了一份自测清单,能流利答上来,基本就算过关了:
- 为什么会出现可见性问题?CPU缓存和指令重排怎么影响?
- 什么是happens-before?列举几条规则并举例。
- volatile的读写屏障分别插在哪里?为什么禁止重排?
- synchronized怎么保证可见性?它和锁的关系是什么?
- final字段一定线程安全吗?什么情况下不安全?
- 双检锁单例为什么要用volatile?
- 什么是安全发布?有哪些方式?
- AtomicInteger底层如何保证原子性和可见性?
这些问题都能在JMM框架内找到答案。回答时尽量结合代码和场景,不要只背名词。
6. 后续学习路线与实操建议
6.1 建议的学习路径
我踩过的弯路是:一开始直接冲到底层汇编,结果屏障符号看得云里雾里。后来调整了顺序,效果好很多。
第一步,先把Java并发包用起来。至少写几个synchronized、volatile、锁、ConcurrentHashMap的demo,感受一下不同工具的行为差异。第二步,回过头来精读JMM相关章节,比如《深入理解Java虚拟机》第十二章和《Java并发编程实战》第三章。第三步,去看JLS中对内存模型的官方描述,虽然有点绕,但那是唯一真正的“标准答案”。第四步,如果还有余力,再看HSDIS插件反汇编,观察lock指令和屏障的插入位置。
6.2 用工具验证,不靠感觉
学习JMM最怕的是“我以为”。推荐两个工具:jcstress是一个并发压力测试框架,能帮你找并发Bug;hsdis是HotSpot反汇编插件,能看JIT最终生成的汇编指令。
我自己用jcstress跑过几个小样例,真的能复现出“在不正确的同步下,结果随机出错”的现象。这种东西看再多书不如亲眼看一次:同样一份代码,加不加volatile,运行结果差别巨大。看到结果的那一瞬间,很多概念就真正连起来了。
如果你的JDK环境还没配置好,先把Java环境变量配好,用OpenJDK或者Oracle JDK都行,版本建议以JDK 8或JDK 17为起点,这两个版本在并发模型上保持了很好的延续性。环境配置属于基本功,卡在这里会严重干扰后续学习节奏。
6.3 一点个人体会
老实说,JMM是我学过最容易“看完就忘”的内容。今天记住了happens-before,明天写并发代码时还是会不自觉地把所有变量都写成volatile。后来我改变策略:每学一个规则,就写一个能证明“没有这条规则会怎样”的Demo。比如故意去掉volatile,用jcstress跑出错误结果,再看着正确结果对比,印象立刻深了。
还有,分享一个小技巧:排查并发问题的时候,先别急着用工具,拿一支笔,把线程A每一步读写了哪些变量画出来,再把线程B的读写画出来,标出哪一步有happens-before关系。大多数问题在纸面上就能现出原形。
学习java内存模型,最终目的不是为了应付八股文,而是为了在写并发代码时,能准确判断“这句话会不会被编译器重排”“这个变量另一个线程能不能看到”“这个锁到底保证了什么”。把这些想清楚,并发编程就不再是玄学。
