做后端这几年,我见过太多“表面能用、一上并发就崩”的代码。最典型的一个场景:早几年在某订单系统里,有人为了“优化性能”,把一个用synchronized保护的状态标记改成了普通 boolean,理由很简单——“这字段就一个线程写,其他线程只读,加锁纯属浪费”。结果呢?灰度环境一切正常,生产环境一压测,服务端直接出现诡异的数据错乱,日志里什么异常都没有,线程就是卡在某个while循环里出不来。后来定位到问题,根源就是 JVM 内存模型里的可见性问题。
JVM 架构与 Java 内存模型(JVM Architecture & Memory Model)这个东西,属于那种“平时用不到,一用到就是大事故”的知识。很多人觉得 Java 有 GC、有内存自动管理,JVM 原理不重要,写业务代码够用就行。但一旦你开始接触并发、开始优化性能、开始排查线上问题,不懂原理的代价会成倍放大。这篇内容不打算做成教科书式的原理复读,而是想把 JVM 的运行时架构和 Java 内存模型这两块核心知识,用我实际踩坑的经验串起来讲清楚,适合正在写并发代码的 Java 开发者,也适合准备面试、想系统补一下 JVM 底层的朋友。
1. JVM 运行时架构:你的代码到底跑在哪里
不先搞清楚 JVM 运行时架构,内存模型里的很多问题就会显得像天书。因为所谓可见性、原子性、有序性,最终都能从 JVM 的“内存地图”和“执行引擎”上找到物理根源。JVM 本质上是一台运行在操作系统之上的虚拟机,它负责把你写的.class字节码翻译成当前硬件平台能认的机器指令,同时帮你管好内存分配、垃圾回收、线程调度这些脏活累活。你写的 Java 代码从来不直接接触操作系统内存,它接触的是 JVM 划好的区域,而 JVM 再和操作系统申请真正的物理内存。
1.1 类加载子系统:代码进入 JVM 的第一道门
类加载子系统负责把.class文件加载进 JVM,整个流程分三阶段:加载、链接、初始化。
加载阶段会找到 class 文件的二进制字节流,把它转换成方法区里的运行时数据结构,并在堆里生成对应的java.lang.Class对象。注意,这时候代码还没真正“活过来”,只是骨架搭好了。
链接阶段又分三步。第一步是验证,检查字节码是否符合 JVM 规范,防止恶意或者损坏的字节码破坏运行时安全。这个环节非常重要,但平时开发几乎感知不到。第二步是准备,给类的静态变量分配内存,并设置默认零值。很多人在这里栽过跟头:private static int count = 10;这一行里,准备阶段给 count 分配的是 0,而不是 10,真正的 10 要等初始化阶段才赋值。第三步是解析,把常量池里的符号引用替换成直接引用,也就是把“这个名字指向谁”落实成“具体内存地址是多少”。
初始化阶段才真正执行静态变量的赋值语句和静态代码块。而且类初始化有触发条件,只有当你主动使用这个类,比如new对象、访问静态字段、调用静态方法时,它才会执行。搞清楚这个顺序有什么用?我举一个真实场景:某项目在静态代码块里初始化一个很重的连接池,但因为这个类只在某个冷门业务路径上被首次访问,结果第一次请求时响应巨慢,后面才恢复。理解了类加载时机,你就能想明白为什么冷启动会慢,也知道该用什么方式预热。
1.2 运行时数据区:JVM 的“内存地图”
运行时数据区是 JVM 启动后按规范划分出来的内存区域。我按线程私有和线程共享两个维度来拆,因为这直接决定了你写并发代码时该不该紧张。
线程私有的区域有三个。程序计数器(PC Register)保存当前线程正在执行的字节码行号,分支、循环、跳转、异常恢复都靠它。它是唯一不会抛OutOfMemoryError的区域,每个线程一份。虚拟机栈(VM Stack)描述 Java 方法执行的内存模型,每次方法调用都会创建一个栈帧,栈帧里有局部变量表、操作数栈、动态链接、方法出口。方法调用就是压栈,方法返回就是弹栈。递归没写好、栈帧深度超限,就会抛StackOverflowError。本地方法栈(Native Method Stack)给 native 方法用,和虚拟机栈类似的角色,只不过服务对象不同。
线程共享的区域有两个。堆(Heap)是所有对象实例的出生地,字符串、数组、业务实体全在这里分配,也是垃圾回收的主战场。简单说,你new出来的东西,绝大部分都住在堆里。方法区(Method Area)存放类元信息、静态变量、常量池。在较新的 JDK 里,方法区由元空间(Metaspace)实现,使用本地内存而非 JVM 堆内存,所以默认情况下,加载一万个类和十万个类,不会直接把堆挤爆,倒是本地内存有耗尽的风险。
注意:线程私有的区域天然线程安全,因为别的线程碰不到你的栈帧和 PC 寄存器;线程共享的区域是并发问题的温床,你在堆里建的对象、在方法区里放的静态变量,都可能被多线程同时读写。看到线上报
Java heap space或者频繁 Full GC,基本就是堆出了问题;而报StackOverflowError,则是某个线程的方法调用栈太深。
1.3 执行引擎:解释执行与即时编译的博弈
执行引擎负责把字节码“跑起来”。早期 JVM 主要靠解释器逐条解释,速度上不去。后来引入 JIT(Just-In-Time)编译器,把“热点代码”编译成本地机器码缓存下来,性能才有了质的提升。现代 JVM 一般用分层编译:C1 编译器做轻量快速优化,适合启动阶段;C2 编译器做深度激进优化,适合长期运行的稳定热点。
这里有一个和内存模型强相关的点:JIT 编译器在做优化时,完全站在“单个线程”的视角看问题。它对一段代码做指令重排序、做寄存器缓存、做死代码删除,唯一准则是“这个线程自己执行的结果不变”。至于另一个线程会怎么看到这些操作,编译器根本不管,也管不着。所以才会出现那种邪门的现象:写代码的人觉得明明是 A 先执行、B 后执行,换个线程观察到的顺序却是反的。
除了编译器的重排序,CPU 和内存硬件层面也存在重排序和缓存一致性延迟。多核 CPU 各自有 L1/L2 缓存,写入结果什么时候刷到主内存,其他核心什么时候能看到,都有延时。硬件层面的行为你控制不了,但 JMM 可以约束编译器,让它在这类关键点上“老实一点”。这就顺势引出了 Java 内存模型存在的必要性:它是一套 JVM 层面的规则,明确告诉编译器和运行时,哪些地方不允许随便重排,哪些操作必须保证可见性。
2. Java 内存模型(JMM):并发世界的“交通规则”
Java 内存模型不是一张内存结构图,而是一套抽象规范,定义多线程环境下共享变量的读写行为。具体来说,它管的就三件事:可见性、原子性、有序性。很多并发 bug 看起来千奇百怪,扒开看无非是这三性中的一个出了问题。
2.1 主内存与工作内存:看不见的中间层
JMM 把内存抽象成主内存和工作内存两层。主内存是所有线程共享的,存放所有共享变量;每个线程又有一份私有的工作内存,线程对变量的读和写都先发生在工作内存里,再同步回主内存。线程之间不能直接访问彼此的工作内存,只能靠主内存“中转”。
这个模型有点像团队协作:大家把想法写在各自的笔记本上,要共享时再贴到公共白板,别人从白板抄回自己的笔记本。问题是,笔记本上的内容什么时候贴到白板、别人什么时候过来抄,如果没有约定,就会出现“A 已经改了,B 还在看旧版本”的错位。
这就是可见性问题的根源。线程 A 改了共享变量,线程 B 读到的却可能是旧值,因为 A 的修改还没刷回主内存,或者 B 的工作内存里缓存了旧值。解决可见性问题的手段,就是后面要讲的volatile、synchronized、Lock,以及并发包里的各种工具类。它们本质上是 JMM 规定好的“同步协议”,保证某次修改对其他线程是及时的、确定的。
2.2 happens-before 规则:判断可见性的金标准
如果每个共享变量的每次读写都要强制同步主内存,并发性能会差到没法看。所以 JMM 用一种更聪明的设计来平衡正确性和性能:定义一组 happens-before 关系规则,只要两个操作满足这些规则之一,前一个操作的执行结果就对后一个操作可见,且前一个操作的顺序不会被重排到后一个操作之后。不满足规则的操作,JVM 不承诺任何可见性,你可以认为“什么奇怪结果都可能出现”。
我整理一份常用规则速查表,排查并发问题时就拿它逐条对照:
| 规则 | 含义 |
|---|---|
| 程序顺序规则 | 同一个线程内,前面的代码 happens-before 后面的代码 |
| 监视器锁规则 | 对一个锁的解锁 happens-before 后续所有对这个锁的加锁 |
| volatile 变量规则 | 对一个 volatile 变量的写 happens-before 后续所有对该变量的读 |
| 线程启动规则 | 调用start()happens-before 被启动线程内的所有操作 |
| 线程终止规则 | 线程内的所有操作 happens-before 其他线程检测到该线程终止 |
| 传递性 | 若 A happens-before B,且 B happens-before C,则 A happens-before C |
日常排查并发 bug 时,我几乎都是靠这些规则做判断。看到“数据对不上”“标志位不生效”这类问题,第一步不是瞎猜,而是找代码里有没有建立 happens-before 关联。如果没有,那恭喜,你大概率找到了问题根源;如果有,那问题多半出在别的地方,比如原子性破坏或死锁。
2.3 原子性、可见性、有序性:并发三恶
把 JMM 要解决的问题拆开看,就是这三个词。
原子性描述的是“一个操作或者一组操作,要么全部执行成功,要么全部不执行,中间状态不可见”。i++这一行代码看着像一步,实际上是“读取旧值→计算新值→写回”三步,两个线程同时执行就可能丢更新,这就是原子性被破坏。保证原子性的手段是synchronized、Lock、AtomicInteger等。
可见性描述的是“一个线程修改共享变量后,其他线程能不能立刻看到修改”。volatile是最轻量的可见性保证手段,它还能禁止指令重排序。
有序性描述的是“程序执行的顺序符不符合我们的直觉”。由于编译器和 CPU 都可能重排序,flag = true可能被提前到data = 42之前执行,这就会导致另一个线程看到 flag 是 true 却读不到新 data。volatile和synchronized都能在特定语义下禁止重排,保证有序。
2.4 volatile 与 synchronized 的内存语义
volatile有两层语义。第一层是可见性:对 volatile 变量的写,会立即刷新到主内存;对 volatile 变量的读,会从主内存重新读取,而不是用工作内存里的缓存。第二层是有序性:写 volatile 变量时,JMM 会在写之前插入 StoreStore 屏障、在写之后插入 StoreLoad 屏障;读 volatile 变量时,会在读之后插入 LoadLoad 和 LoadStore 屏障。这些内存屏障是 JMM 对编译器和 CPU 的硬约束,保证相关指令不会越过屏障乱跑。
volatile不保证原子性。比如volatile int count,两个线程同时执行count++,仍然会丢更新。所以它适合“一个线程写、其他线程读”的场景,不适合“多个线程同时读写”的场景。
synchronized的语义更重。它靠监视器锁实现互斥,保证同一时刻只有一个线程进入临界区。同时,进入 synchronized 块会清空工作内存,退出时会刷新工作内存,所以锁的释放 happens-before 后续的获取。换句话说,synchronized能同时保证原子性、可见性、有序性,代价是线程阻塞、上下文切换的开销。性能敏感的场景可以考虑ReentrantLock、StampedLock,但核心语义依然是围绕 JMM 展开的。
3. 改个并发就炸的经典翻车现场
这一节我讲几个真实见过的、或者亲手调过的典型问题。它们有个共同特征:单线程跑全对,并发一上就出妖,而且往往不是必现,而是概率出现——这最折磨人。
3.1 双重检查锁:不加 volatile 必踩的坑
单例模式里的双重检查锁(DCL)几乎是面试必问,但很多人只是背了结论——instance 要加 volatile。我来解释为什么。
new Singleton()在字节码层面大致是三步:分配内存、调用构造器初始化、把引用指向这块内存。问题在于,第三步“引用赋值”可能会被 JIT 或 CPU 重排到“构造器初始化”之前。此时另一个线程进来检查instance != null,一看不为空,直接 return 拿去用,实际拿到的是一个构造器还没跑完的半成品对象。可能出现字段还是默认值、状态未就绪等诡异现象。
加了volatile之后,写 volatile 变量的操作会触发内存屏障,禁止这种危险重排,保证对象完整发布后才对其他线程可见。
public class Singleton { // instance 必须加 volatile,否则 DCL 是不安全的 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; } }我见过一个同行因为这个 bug 调了一整天:偶现的 NullPointerException 或者字段值不对,日志里没有任何并发异常,就是结果不对。最后还是靠堆转储对比才发现是半初始化对象。这种坑隐蔽就隐蔽在,它不报错,只是表现不符合预期。
3.2 停止标志位:后台线程停不下来
另一种更常见的翻车是用 boolean 标志位控制线程退出。
public class FlagDemo { private static boolean running = true; // 没有 volatile public static void main(String[] args) throws InterruptedException { Thread worker = new Thread(() -> { while (running) { // 忙等待循环 } System.out.println("worker 退出"); }); worker.start(); Thread.sleep(1000); running = false; // 主线程修改标志位 worker.join(2000); System.out.println("main 结束, worker 是否存活: " + worker.isAlive()); } }这段代码在某些环境下能正常退出,在某些环境下就卡在 while 里出不来。原因就是 JMM 的可见性问题:主线程把 running 改成 false 写回主内存,但 worker 线程的工作内存里还缓存着 true,读不到最新值。更麻烦的是,有些 JIT 版本还会把while (running)优化成while (true),因为编译器看到循环体内没有修改 running,默认它不会变,直接把判断条件优化掉了。
解法就是给 running 加volatile,要么就把循环体改成能建立 happens-before 关系的写法。这里我多说一句:写完代码后最好用jstack验证一下,确保线程真的退出了再上线,别指望“应该没问题”。
3.3 i++:你以为的一步,其实是三步
再讲一个最基础的:count++不是原子操作。它在 JVM 里是“读取-修改-写入”三步。两个线程同时执行count++,可能的交错结果是两个线程都读到同一个旧值,分别加一,最后写回的还是同一个值,等于加了一次而不是两次。最终 count 会比预期小,而且小多少完全看线程调度时机,很难复现。
要解决这个问题,最简单的三个方案:synchronized包住、用AtomicInteger、或者用LongAdder。三者各有适用场景,AtomicInteger适合并发量中等、对延迟敏感的场景,底层靠 CAS 乐观重试;LongAdder适合高并发、以最终一致性为目标的统计场景,内部做了分段累加,降低了 CAS 竞争。
AtomicInteger count = new AtomicInteger(0); // 多个线程并发执行 count.incrementAndGet();理解了 i++ 不是原子的,你就明白为什么并发场景下计数器总是“莫名少一截”。这不是 JDK 的 bug,是你的操作不满足原子性。
3.4 并发容器选型错了也会炸
顺带说一个容易误伤的坑:HashMap在多线程并发写时,旧版本 JDK 里可能因为扩容时头插法导致链表成环,CPU 直接飙到 100%。虽然较新版本 JDK 改成了尾插法,缓解了成环问题,但并发写HashMap依然不线程安全,会出现数据丢失、死循环等诡异现象。并发场景优先用ConcurrentHashMap,它内部对桶数组做了细粒度分段锁或 CAS 优化,读多写少的场景性能比全局加锁好得多。这也是为什么我建议写并发代码时,默认先检查“这个容器是不是线程安全的”。
4. 工具与排查实战:线上并发 bug 怎么定位
理论再熟,线上出问题还得靠工具抓现场。我总结一套自己的排查流程,先看症状,再定方向,再抓证据,最后修复验证,每一步都有对应的工具。
4.1 常用命令工具集
| 工具 | 用途 | 常用姿势 |
|---|---|---|
jps | 列出当前机器上的 JVM 进程 | jps -l拿到进程号 |
jstack | 打印线程栈,看死锁、线程卡在哪 | jstack <pid> |
jmap | 堆内存信息、生成堆转储 | jmap -dump:format=b,file=heap.hprof <pid> |
jstat | 实时看类加载、GC 情况 | jstat -gcutil <pid> 1000 |
jvisualvm | 图形化监控 CPU、内存、线程 | 打开后连接本地进程 |
排查并发问题时我第一个用的几乎总是jstack。它能直接告诉你有多少个线程在 RUNNABLE 状态、每个线程栈顶在哪个方法。死锁时它会明确打出 “Found one Java-level deadlock”。线程空转时你能看到同一个 while 循环出现在很多线程栈里。
4.2 一次可见性问题的完整排查实录
我挑一个印象深的模拟项目场景讲。某任务调度模块里有个清理线程,设计上应该在某标志位置位后自行退出。现象是:压测时清理线程没有退出,反而越积越多,CPU 占用率异常高。这就是典型的忙等待空转。
排查流程是这样的:
- 先用
jps拿到进程号,再用jstack抓线程栈。看到大量线程栈顶都停在同一个while (!stop) { ... }上。 - 检查 stop 字段定义,结果是个普通 boolean,没有
volatile,也没有锁保护。 - 对照 JMM 规则:主线程写 stop 和清理线程读 stop 之间,没有任何 happens-before 关系,可见性没有保障。
- 修复:给 stop 加
volatile。 - 复验:重新压测,清理线程正常退出,CPU 占用回落。
整个过程半小时不到。难的不是修,是敢不敢在第一时间怀疑“可见性”。大多数人对并发 bug 的第一反应是加锁,但有时加锁反而掩盖了问题,而volatile才是对症下药。
4.3 GC 日志与堆转储的使用提醒
如果并发问题伴随内存异常,比如线程越跑越多、堆内存快速增长,就要结合 GC 日志和堆转储看。启动参数加上 GC 日志是排查的基础操作,而不是可有可无的配置:
java -Xms2g -Xmx2g \ -Xlog:gc*=info:file=gc.log:time,uptime,level,tags \ -XX:+HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath=/data/logs/heap.hprof \ -jar app.jar-XX:+HeapDumpOnOutOfMemoryError这个参数强烈建议生产环境开上,代价极小,但 OOM 时能自动留下现场,省去一大半排查时间。生成堆转储后,用 MAT 或jhat分析,看哪些对象占据了大部分内存。有一点要提醒:堆转储会暂停 JVM,堆越大暂停越久,生产高峰期不要贸然执行,最好在低峰期或应急容灾之后再做。
4.4 死锁排查的快速套路
死锁也是并发高发问题。死锁的典型症状是“程序卡住不动,但 CPU 占用为 0”,因为没有线程在推进,都在互相等锁。用jstack抓栈后,搜索 “deadlock” 关键字,JVM 会直接列出死锁的线程环。解决死锁要分两层:第一层是当前立刻止损,比如重启或手工干预;第二层是根因修复,通常是锁顺序不一致,需要统一加锁顺序,或者用tryLock加超时机制避免无限等待。
Lock lockA = new ReentrantLock(); Lock lockB = new ReentrantLock(); // 用 tryLock 替代 lock,避免死锁 if (lockA.tryLock(1, TimeUnit.SECONDS)) { try { if (lockB.tryLock(1, TimeUnit.SECONDS)) { try { // 业务逻辑 } finally { lockB.unlock(); } } } finally { lockA.unlock(); } }5. 我对 JVM 内存模型的一些实操心得
做并发相关的工作这几年,有一些碎碎念想分享给后来的开发者。
第一,别迷信“去掉锁会更快”的直觉。没有充分性能数据支撑的并发优化,十次有九次在埋雷。正确的姿势是先保证正确性,再用 profiler 一类工具证明瓶颈确实在某块,最后才动手优化,优化完还得有压测和灰度验证兜底。
第二,JMM 的知识性价比极高。面试几乎是必考,实际工程里排查并发问题也要靠它。与其死记结论,不如亲手写几个反例跑一跑,比如写一个不 volatile 的停止标志位,看看在不同 JDK 版本下是不是有时能停有时停不了。眼见为实之后,你对 happens-before 的记忆会比背一百遍都牢。
第三,写并发代码时默认所有共享变量都是“没有可见性保障”的,除非明确用了volatile、synchronized、Lock或并发包里的工具类。这个默认假设能帮你堵住绝大多数坑。很多翻车现场不是因为问题复杂,而是因为开发者默认“我写的顺序就是执行顺序”,这个默认在并发世界里不成立。
第四,排查并发问题的效率,很大程度取决于对工具熟练度。jstack和jmap这两个命令建议平时就练熟,别等线上出了事故才边查文档边慌张。最好在本地搭一个故意有 bug 的 Demo,跑起来抓栈看现象,多练几次就有手感了。
第五,也是我最想强调的一点:真香也要懂原理。Java 的自动内存管理确实让人省心,但省心不意味着可以不懂。你可以在写业务代码时靠经验避开并发坑,但一旦遇到“概率出现、偶发诡异”的问题,唯一能依赖的就是对 JVM 架构和内存模型的系统性理解。改一行并发代码之前,先问自己一句:我清楚这一改动了什么内存语义吗?如果不清楚,那炸了也不冤。