先说个现象Java并发面试题里JMM、JVM、线程调度这三个词出镜率极高但大多数人是分开背的——JMM背一堆术语、JVM背运行时数据区、线程调度背状态切换。背完还是觉得虚因为这三样东西根本不是三个独立的知识点它们在同一段代码执行的过程中本来就是咬合在一起的。这篇文章我会用一段最简单的同步代码跟着它从编译、加载、执行、加锁到线程切换的完整路径把JMM、JVM、线程调度串起来讲。你会看到count这行代码到底变成了几条字节码、两个线程抢同一把锁时对象头里发生了什么、线程状态在 jstack 里长什么样。适合正在准备 Java 面试的同学也适合业务代码里遇到并发问题但不知道从哪下手排查的同行。1. 同步代码编译加载后JVM 内存到底长什么样1.1 源码到字节码count 远比你想的要复杂先看这段代码后面所有讨论都围绕它进行public class SyncExample { private static int count 0; private static final Object lock new Object(); public static void increment() { synchronized (lock) { count; } } public static void main(String[] args) throws InterruptedException { Thread t1 new Thread(SyncExample::increment); Thread t2 new Thread(SyncExample::increment); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(count); } }如果用 javap 反编译increment()方法会看到关键内容比想象中多得多javap -c -p SyncExamplecount这一行在字节码层面其实对应四个操作getstatic // 把静态字段 count 的值压入操作数栈 iconst_1 // 把常量 1 压入操作数栈 iadd // 弹出栈顶两个数相加后再压回栈 putstatic // 把栈顶的值写回静态字段 count这才是问题的根源一行 Java 代码在 CPU 眼里不是一个原子操作而是“读-改-写”三步。两个线程同时执行时完全可能出现“A 读到 0、B 也读到 0、A 写成 1、B 也写成 1最终 count 只有 1”的结果。synchronized 能解决这个问题但它的作用范围、生效机制要放在 JVM 内存布局里才说得清楚。1.2 运行时数据区对象在堆里执行轨迹在栈上把.class文件丢给 JVM 后类加载器会完成加载、验证、准备、解析、初始化这一整套流程。SyncExample的类元信息会放进方法区JDK 8 以后叫元空间lock对象和count变量静态字段属于类对象的一部分分配在堆上而真正决定代码执行轨迹的是虚拟机栈。方法调用产生栈帧每个栈帧由四部分组成区域干什么用类比局部变量表存放方法的参数和局部变量槽位可以复用草稿纸上的编号格子操作数栈字节码指令的运算中转站桌面上临时堆放的数字动态链接指向运行时常量池中该方法的引用通往方法定义的“指针”返回地址方法退出后回到调用方的位置书签main 线程启动后main 栈帧先入栈接着increment()被调用时新栈帧入栈方法执行完栈帧出栈。这个“后进先出”的栈结构是线程私有的——每个线程有自己的 JVM 栈互不共享。也许你会问lock这个对象不是共享的吗它确实共享所有线程都能通过引用指向堆里的同一个对象。但“所有线程都能引用同一个锁对象”和“多个线程同时操作这个对象的内部状态”是两回事。后者正是需要锁保护的场景。还有个容易被忽略的组件程序计数器。它记录当前线程执行到哪一条字节码指令是线程私有且不会 OOM 的区域。线程切换出去再切回来能继续从原来位置执行全靠它记住“翻书翻到哪一页”。从这里就能看出 JVM 层面的并发基础栈和程序计数器是线程私有的堆和方法区是线程共享的。既然有共享就必然有竞争和一致性问题于是引出了 Java 内存模型。2. 代码跑起来JMM 如何协调共享变量2.1 主内存与工作内存中央仓库与手边抽屉JMMJava 内存模型是一个抽象规范它并不是 JVM 运行时数据区的真实结构。它定义了一个规则体系每个线程有自己的工作内存共享变量存在于主内存中。线程操作变量时先把变量从主内存复制到工作内存改完再写回主内存。可以这样理解工作内存就是工位上的手边抽屉主内存是中央仓库。每个人线程干活时习惯把用到的材料先拿一份到工位上在自己眼前修改。改完之后如果不主动送回中央仓库其他人永远不会知道你改了。这就是可见性问题的本质。回到代码里count在主内存中只有一个值但 t1、t2 两个线程各自的工作内存里可能各有一份count的副本。两个线程同时count后最终写回主内存的值取决于“谁最后把抽屉里的数放回仓库”于是丢失更新、值比预期小几乎是必然的。2.2 JMM 与 JVM 内存区域的关系这里容易混淆我多说一句JMM 的“主内存”并不等同于 JVM 堆“工作内存”也不等同于栈。JMM 是一个逻辑模型描述线程与内存之间的交互规则JVM 运行时数据区是物理实现层面的划分。真实场景中主内存往往对应堆中对象实例所在的内存区域工作内存则可能落在 CPU 寄存器、缓存或线程栈上。CPU 高速缓存的存在让“改完没写回”的现象在硬件层面真实发生线程 A 在核心 1 上修改了 count核心 1 的 L1 缓存还没刷新到主内存线程 B 在核心 2 上读到的是旧值。我在实际排查中见过一个经典问题一个 boolean 停止标志位主线程改成了 false子线程却死循环不退出。代码大致长这样static boolean running true; // 子线程 while (running) { // 业务逻辑 } // 主线程修改 running false问题就在于子线程工作内存里的running一直是 trueJIT 甚至可能把while (running)优化成死循环。给running加上volatile或者用synchronized包裹读写本质都是在建立“工作内存与主内存之间的同步屏障”。synchronized 在这里的作用是进入同步块时清空工作内存退出时把工作内存中的修改强制写回主内存。所以我常说synchronized 可不只是“一把锁”它还在内存语义上天然解决了可见性问题。2.3 指令重排序代码顺序不等于执行顺序JMM 的另一个关键约束是有序性。编译器、JIT、CPU 都可能为了优化而重排指令只要单线程语义不变就行。这个优化本身没错但多线程环境下重排会破坏一些看不出来的顺序依赖。比如经典的“双重检查锁单例”懒加载时如果不加volatileinstance new Singleton()的“分配内存、初始化对象、把引用赋值给变量”三步可能被重排成“分配内存、先把引用赋值、再初始化”。此时另一个线程进来判断instance ! null直接拿去用拿到的是一个尚未初始化完成的对象。JMM 通过happens-before 规则来约束重排序的影响范围如果操作 A happens-before 操作 B那么 A 的结果对 B 可见且 A 的执行顺序在 B 之前。synchronized带来的 happens-before 关系非常明确解锁 happens-before 后续对这个锁的加锁。这意味着一个线程在同步块里对共享变量的全部修改对下一个进入同一把锁的线程完全可见。这就是所谓的“锁的内存语义”它比单纯互斥更强也是很多人没理解透的点。3. synchronized 是如何一次性搞定三大并发特性的3.1 原子性临界区把复合操作变成“不可分割”回到count的四条字节码。如果两个线程在“读-改-写”的任意两步之间插入执行结果就难以预测。synchronized 的做法很简单粗暴用监视器锁Monitor把这段代码块变成互斥的临界区同一时刻只有一个线程能进来执行。锁的概念跟洗手间门锁一个道理一个人进去了另一个人只能等等里面的人出来才能进。在字节码层面synchronized块被编译成monitorenter和monitorexit两条指令。JVM 规范要求monitorenter尝试获取锁若失败则阻塞monitorexit负责释放锁。比较隐蔽的一点是如果在临界区内发生异常JVM 会隐式执行monitorexit保证锁一定能释放。编译后的代码里通常会看到多个monitorexit就是为了覆盖正常返回和异常返回两条路径。有了这个互斥保障getstatic / iconst_1 / iadd / putstatic这组操作就不会被其他线程穿插执行了。从业务视角看count变成了一个“看起来不可分割”的操作。3.2 可见性与有序性锁的前后约定原子性只是解决了“多条指令被穿插”的问题那可见性和有序性呢其实 synchronized 通过两个机制顺带解决了可见性进入同步块前JMM 要求线程清空工作内存中相关变量退出同步块时把修改刷新到主内存。所以临界区内改的东西下一个进入同一把锁的线程一定能读到最新值。有序性synchronized 隐含着“解锁操作 happens-before 下一个加锁操作”相当于在锁的边界上树了一道墙。指令重排不会被允许跨越这道墙墙内代码可以随便优化但墙的边界语义必须保持。我经常建议面试者用一个视角去理解synchronized 不是在“代码块周围”加了一个笼子而是在内存访问的时间轴上制造了一个顺序点。获得锁的瞬间前面所有线程对该锁保护变量的修改都可见释放锁的瞬间当前线程的所有修改对后续竞争者可见。理解了这一点就能解释为什么 volatile 能做的synchronized 基本都能做只是前者没有互斥性。3.3 锁的对象是什么每个 Java 对象都能当锁synchronized可以修饰实例方法、静态方法、代码块。修饰静态方法时锁的是Class对象修饰实例方法锁的是this代码块里显式指定的对象就是锁对象。Java 里每个对象都能当锁是因为每个对象头里都有一块用于存储锁状态的数据。这段数据的演化恰好就是并发从低竞争到高竞争时的关键所在。值得注意的是锁粒度不同性能差别巨大。有人习惯给一个很大的方法整体加synchronized结果整个对象都被锁住无关操作全部串行化。拿上面的代码举例increment()方法本身逻辑很短锁的持有时间极短这种场景并发量再高也不容易出大问题但如果在锁内做耗时 IO线程会排队排到怀疑人生。判断粒度是否合理就一句话临界区只保护真正需要保护的共享变量操作别把不相关的计算也关进去。4. 从对象头到线程调度两个线程争一把锁的全过程4.1 对象头里的秘密锁信息藏在这里HotSpot JVM 的普通 Java 对象在堆里的布局由三部分组成对象头、实例数据、对齐填充。对象头又包含Mark Word和类型指针。Mark Word 在 64 位 JVM 上通常占 8 字节里面混合存放了哈希码、分代年龄、锁状态标志位等信息。因为内存宝贵Mark Word 必须“一物多用”——不同锁状态下这 8 个字节的含义完全不同。这是 synchronized 能实现高效锁升级的基础JVM 不会一上来就给线程加重量级锁而是看竞争情况渐进演化。锁状态至少有以下几种锁状态存储内容竞争特征无锁对象哈希码、分代年龄没有线程访问同步块偏向锁持有锁的线程 ID只有一个线程反复进入轻量级锁指向栈中锁记录的指针多线程交替进入错峰竞争重量级锁指向 monitor监视器的指针多线程同时竞争、阻塞4.2 锁升级的完整路径偏向锁到重量级锁偏向锁是 HotSpot 的优化如果一段代码始终只有一个线程在执行JVM 会在 Mark Word 里记录这个线程的 ID后续该线程再来只需检查 ID 是否匹配无需 CAS 操作。一旦发现另一个线程也想进偏向锁立即撤销。这里有个细节JDK 6/7 里偏向锁默认启动有延迟大约是 JVM 启动后 4 秒内不启用可以通过-XX:BiasedLockingStartupDelay0关掉延迟。这个参数在低延迟服务里经常要动。当竞争出现但不激烈时JVM 升级到轻量级锁。线程在自己的栈帧里创建一个锁记录尝试用 CAS 把 Mark Word 复制到锁记录并替换对象头中的内容。如果 CAS 成功说明拿到锁如果失败说明锁被占用线程会自旋等待。自旋不是阻塞而是“空转几个 CPU 周期再试一次”适合锁持有时间很短的场景省去了线程阻塞和唤醒的开销。自旋默认次数在 JDK 6 之后是自适应的JVM 根据历史情况动态调整。如果竞争进一步加剧自旋也搞不定锁会膨胀为重量级锁。这时 JVM 会向操作系统申请pthread_mutex这类互斥量线程抢不到锁就真正进入内核态的阻塞状态。阻塞、唤醒都需要操作系统介入成本极高。所以重量级锁是最后的兜底不是常态。结合我们的代码t1、t2 两个线程几乎是同时启动竞争程度不高大概率走一遍偏向锁尝试和轻量级锁升级的路径最终若竞争激烈也会走到重量级锁。这也是为什么 HotSpot 的 synchronized 在 JDK 6 之后性能大幅提升的原因——它已经不是早年那个“每次上锁都要搞一次系统调用”的笨重实现。4.3 线程状态变化RUNNABLE、BLOCKED、WAITING 的切换逻辑两个线程在争锁时JVM 视角里的线程状态是怎么变的Thread 的getState()方法会告诉你六个状态NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。当 t1 拿到锁时t2 执行到synchronized (lock)尝试获取锁失败进入BLOCKED状态JVM 会把它放入该锁对象的监视器等待队列。一旦 t1 执行完临界区并释放锁JVM 从等待队列里挑一个线程唤醒它重新进入 RUNNABLE参与调度。整个过程涉及 JVM 级锁到操作系统线程调度的映射HotSpot 的 Java 线程是 1:1 映射到内核线程的线程的阻塞和唤醒最终由操作系统负责。如果说线程状态切换还比较抽象jstack 能让你直观看到这一切。线程等待锁时线程 dump 里会显示Thread-1 #12 prio5 os_prio0 tid0x... nid0x... waiting for monitor entry java.lang.Thread.State: BLOCKED (on object monitor) at SyncExample.increment(SyncExample.java:8) - waiting to lock 0x00000000... (a java.lang.Object)另一类常见状态是WAITING (parking)通常来自LockSupport.park()或Object.wait()说明线程正在等待某个条件成立而不是单纯等锁。我在线上排查时第一件事永远是 jstack看线程到底卡在 BLOCKED 还是 WAITING基本能判断是锁竞争激烈还是等待条件未满足。4.4 线程调度谁决定线程什么时候跑很多人以为 Java 代码里start()之后线程就“开始执行”了其实只是让线程进入可运行状态。真正决定它能不能上 CPU 的是操作系统的线程调度器。调度器按照时间片轮转、优先级等策略把 CPU 时间分给处于 RUNNABLE 状态的线程。这里有个容易被忽视的点volatile 的线程在 RUNNABLE 状态下让出 CPU 的时机它自己控制不了。编写并发代码时不能臆想“线程 A 执行完同步块后线程 B 马上就能跑”。从monitorexit到monitorenter成功中间隔了锁状态释放、等待队列唤醒、操作系统调度等多个环节存在几十到几百微秒都不奇怪。这就是为什么高并发场景下锁持有时间稍有放大吞吐量就会明显下滑。5. 并发踩坑实录从死锁到锁粒度优化的排查思路5.1 死锁排查jstack 一眼定位synchronized 很直接但隐性问题也不少。死锁是最典型的两个线程互相持有一把锁又在等对方的锁。我用 jstack 排查过很多次死锁时线程 dump 尾部会出现类似这样的内容Found one Java-level deadlock: Thread-1: waiting to lock 0x00000000a (a java.lang.Object) locked 0x00000000b (a java.lang.Object) Thread-2: waiting to lock 0x00000000b (a java.lang.Object) locked 0x00000000a (a java.lang.Object)这是 JVM 主动帮你检测出来的碰到基本不用怀疑。预防死锁的核心原则就一条多个线程拿多把锁时保证所有线程都以相同的顺序加锁。如果 A 线程先锁 lock1 再锁 lock2B 线程也先锁 lock1 再锁 lock2就不会出现循环等待。5.2 锁竞争导致的高耗时怎么快速定位除了死锁更常见的是隐式锁竞争激烈导致的接口耗时抖动。排查步骤我一般是这样先用 jps 找到进程再 jstack 连续抓几份线程 dump间隔 3-5 秒。如果某把锁上频繁出现大量线程 BLOCKED 等同一个 monitor说明锁竞争剧烈。拿到证据后看临界区里到底做了什么。常见原因是临界区做了耗时 IO比如网络请求或者数据库访问临界区代码逻辑太重比如循环里做了全量排序锁粒度太大把读操作、不相关的计算也锁进临界区对应的优化思路是“三件套”把耗时操作移出临界区用读写锁或更细粒度的锁拆小保护范围实在不行再用原子类、LongAdder 等无锁方案。很多人把synchronized和ReentrantLock对比选择时容易陷入纠结我的经验是JDK 8 以后的 synchronized 经过锁升级优化绝大部分场景它都足够好代码也更简化。只有当你需要超时等待、可中断、多个条件队列等 synchronized 无法直接支持的特性时才考虑显式锁。最近一次线上接口耗时问题就是这么定位的有个高频查询接口短暂出现 ms 级毛刺一开始怀疑 GC后来 jstack 发现大量线程堵在同一把锁上而那个锁保护的却是一段初始化配置的懒加载逻辑。把初始化提前到启动阶段并用 volatile 保存配置引用锁直接去掉毛刺立刻消失。5.3 无锁替代与 volatile 的正确理解并不是所有共享变量都必须加锁。如果共享变量的更新是单一赋值比如int赋值、引用赋值本身就具备原子性那么使用volatile保证可见性就足够了。真正需要锁或原子类的是类似count这种“读取-修改-写入”的复合操作。遇到真正的高并发计数场景synchronized和AtomicLong都能计数但吞吐量差别明显。JDK 8 的LongAdder在竞争激烈时采用了分段累加策略各个线程更新不同的段最后再 sum 汇总简直是为“高并发写、低频率读”量身定做的。我自己测过8 线程并发递增一亿次LongAdder耗时通常只有AtomicLong的三分之一左右。但也别盲目用它如果读频率跟写频率一样高分段的存在反而让你每次 sum 都要遍历所有段收益就不明显了。再补充一点volatile解决的是可见性和单次读写的原子性它不能解决复合操作的原子性。很多人以为加了 volatile 的计数就能高枕无忧这是并发编程里最常见的误解之一。你看到的现象往往很迷惑数据偶尔对、偶尔少把 count 改成 volatile 也一样。想清楚 JMM 的“读-改-写”三步走就能明白为什么需要真正的互斥。5.4 锁升级对调优参数的意义别在生产环境乱调关于锁有几个 JVM 参数经常被提起比如-XX:-UseBiasedLocking可以关闭偏向锁。何时该关偏向锁适合“同一个线程反复进入同步块”的场景但如果你的同步块被大量不同线程交替访问偏向锁的撤销反而成了额外开销。有个说法是“ JDK 15 默认禁用了偏向锁”因为现代应用线程频繁交替竞争的场景越来越多偏向锁的收益不再明显。如果你的线上应用是 JDK 8并且观察到频繁的偏向锁撤销导致的 stop-the-world 问题可以试试禁用偏向锁对比性能。不过这需要压测验证不要拿网上流传的调优模板直接往生产上贴。最后分享一个我每次排查并发问题时都会默念的思路先看可见性再看原子性最后检查锁竞争。三者的症状在现象层面很容易混淆——数据异常可能是可见性问题也可能是复合操作被穿插还可能是逻辑本身有 bug。只有回到 JMM、JVM 和执行链路的层面逐层拆才能不靠猜来解决问题。这段同步代码虽然短但它背后这条链路值得每一个写 Java 并发代码的人完整走一遍。我个人这些年在实际操作中的体会是与其背一堆结论不如把 javap 反编译、jstack 抓线程 dump、观察线程状态这三件基本功练熟。遇到并发问题它们比任何背诵的技巧都管用。