1. 从一次线上事故讲起:为什么我真的建议你死磕同步代码
大概两年前,我接手过一个保险核心系统的“疑难杂症”:每天凌晨跑批之后,总有几笔订单的分润金额对不上。最开始怀疑数据库事务,查了三天日志,最后发现罪魁祸首是一段看起来很无辜的Java代码——一个基于内存的计数器在并发更新时丢失了更新。当时负责的同事非常不理解:明明已经在方法上加了synchronized,为什么还会丢数据?
答案就藏在Java并发最底层的那套逻辑里:JMM(Java内存模型)、JVM运行时行为、以及操作系统线程调度这三者是怎么协同的。很多人面试能背出“可见性、原子性、有序性”三个词,但真遇到线上问题,还是会懵。所以我决定用一段最简单的同步代码,带你把这三个概念和整个执行链路彻底串起来,看完之后你再回去看任何并发Bug,都会有一种“啊,原来如此”的感觉。
这篇文章适合谁?不光是Java面试党,更包括所有写多线程业务代码的工程师。我不打算给你罗列一堆抽象理论,而是从一段真实可运行的代码出发,一步步拆解JMM怎么定义数据访问规则、JVM怎么用Monitor和锁升级来落地synchronized、线程调度又是怎么在操作系统和Java线程状态之间来回切换的。
2. 拆解一段同步代码:JMM 的三大承诺和一次实际执行
2.1 一个看起来毫无破绽的计数器
我们先来看一个很典型的场景:多个线程并发调用increment(),我们希望结果等于调用次数。
public class SimpleCounter { private int value = 0; // 加了synchronized,表面看应该没问题 public synchronized void increment() { value++; } public synchronized int getValue() { return value; } }这段代码正确吗?从结果上说,大部分场景下是对的,但它并没有你想的那么简单。为了弄清它在并发环境里到底怎么工作的,我们需要知道Java内存模型给每个线程规定了什么样的“可见范围”。
2.2 JMM的核心:主内存与工作内存
JMM是一套抽象规则,它规定所有共享变量都存在主内存(Main Memory)里,每个线程又有自己的工作内存(Working Memory)。线程不能直接操作主内存,必须先把变量复制到自己的工作内存,操作完再写回主内存。
可以把它类比成多人协作的文档:主内存就是服务器上的共享文档,工作内存就是你本地浏览器缓存的一份副本。你在本地疯狂编辑,但如果不保存(写回主内存),别人就看不到;反过来,如果你不刷新(读取主内存),也看不到别人刚保存的内容。
那么synchronized做了什么?它本质上是给这块共享数据的读写划定了边界:进入同步块时会先刷新工作内存,确保看到最新的值;退出同步块时强制把修改写回主内存。这就是为什么加了锁之后,上面value的并发可见性有保障。
2.3 value++ 为什么不是“一步操作”
很多人听说synchronized可以保证原子性,就以为value++在执行时是铁板一块。但JMM层面,一次自增操作实际上拆成了三步:
- 从主内存读取当前值到工作内存
- 在工作内存中把值+1
- 将新值写回主内存
如果不加锁,线程A读到value=5,还没来得及写回,线程B也读到value=5,两个线程都加1后写回,最终结果就是6而不是7,这就是经典的“丢失更新”。加了synchronized后,三个步骤被锁保护,同一时刻只有一个线程能执行完整流程,这才能保证原子性。
2.4 有序性:JMM的第三条红线
除了可见性和原子性,还有一条很多人忽视的规则——有序性。JMM允许编译器和CPU在不改变单线程语义的前提下对指令进行重排序。
举个例子,你在线程A里执行:
configReady = true; // 写一个标记线程B里看到configReady == true后,立刻去读config。结果有可能读到一个还没初始化完成的config,因为A线程的“初始化config”和“写标记”两条指令可能被重排序了。synchronized同样能约束这种乱序——同步块的进入和退出点会插入内存屏障,屏障前后的指令不能任意跨越。
我把JMM的三个要点整理成一个表,方便对照记忆:
| 特性 | 解决的问题 | synchronized的作用 | 反例 |
|---|---|---|---|
| 可见性 | 工作内存和主内存的同步 | 进入刷新,退出写回 | volatile可能不够的复合操作 |
| 原子性 | 复合操作被切割 | 互斥执行整段代码 | i++未加锁 |
| 有序性 | 指令重排导致异常 | 屏障阻止跨越同步边界重排 | 双重检查锁言的经典陷阱 |
到这里,我们只是从“内存模型”的角度把synchronized讲清楚了。但JVM是怎么在底层实现这种“同步边界”的?接下来看字节码和Monitor。
3. JVM 在背后干了什么:Monitor、锁升级与内存屏障
3.1 一眼看穿 synchronized 的字节码
用javap -verbose反编译上面的SimpleCounter类,你会看到increment()方法里有这样两条关键字节码:
monitorenter ... monitorexitmonitorenter获取Monitor锁,monitorexit释放Monitor锁。中间夹着的就是value++的字节码。如果方法是synchronized修饰的,字节码级别其实是给方法打上ACC_SYNCHRONIZED标记,并隐式地在入口和出口进行加锁解锁。
但最早的synchronized是纯通过底层操作系统级的互斥量(Mutex)实现的,也就是常说的重量级锁。线程竞争时频繁在用户态和内核态之间切换,性能非常差。现代JVM早就受不了这种直来直去的做法,于是引入了一套锁升级机制。
3.2 锁升级:从偏向锁到重量锁
JVM的锁是“轻轻地来,慢慢地重”的。我实际调试过程中,完全没感觉到锁的存在,就是因为它在不同竞争强度下会变换形态:
| 锁状态 | 适用场景 | 核心思路 |
|---|---|---|
| 偏向锁 | 同一线程反复进入同步块 | 在对象头Mark Word里记录线程ID,持有者再次进入无需CAS |
| 轻量级锁 | 少量线程交替进入,但竞争不激烈 | 通过CAS自旋抢锁,避免立刻切内核态 |
| 重量级锁 | 多线程同时竞争,自旋不划算 | 升级为操作系统Mutex,线程进入阻塞队列 |
这个过程是单向不可逆的:偏向锁可以升级为轻量级锁,轻量级锁可以升级为重量级锁,但不会自动降级。所以一个看似加了锁的方法,刚开始可能几乎无开销,只有真出冲突时才会越来越重。
我自己踩过一个坑:在某个高频接口里同时用了多个synchronized静态方法,以为每个锁只保护一小块数据,结果它们都锁在同一个Class对象上,瞬间把并发竞争拉到高峰,性能直接从几百TPS掉到几十。当时看线程堆栈,大量线程卡在monitorenter上。这就是没有理解“锁状态升级”和“锁粒度”的关系。
3.3 内存屏障:synchronized 的有序性武器
前面说有序性靠内存屏障,那具体是什么?CPU和编译器为了执行效率会打乱指令,但某些边界点需要“钉住”。JMM抽象出了四类内存屏障,synchronized的进入和退出主要依赖其中两类:
- LoadLoad屏障:之后的读操作不能越过该屏障跳到之前
- StoreStore屏障:之前的写操作不能越过该屏障跳到之后
- LoadStore屏障:之前的读不能越过到之后的写
- StoreLoad屏障:之前的写不能越过到之后的读,最贵
synchronized块内的读写之外,JVM会在monitorenter后插入一个LoadLoad/LoadStore屏障,在monitorexit前插入StoreStore屏障。这些屏障保证:只能看见进入锁之前的最新值,锁内修改不会乱序逃出锁的范围。
我经常跟组里新人说:别把synchronized简单想成一个“大门锁”。它更像是一个双向安检通道,进去的时候要刷新记忆,出来的时候要留下记录,中间的动作还不允许乱插队。
4. 线程调度过程:从 Runnable 到 Running,到底谁在切换
4.1 Java线程状态与操作系统线程状态
看Thread.getState(),你会得到六个状态:NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。但这里有个最容易被忽略的细节:RUNNABLE这个状态实际上同时涵盖了“正在CPU上运行”和“在操作系统就绪队列里排队等待被调度”两种情况。
JVM里的线程本质上是对操作系统线程(如Linux上的pthread)的一层封装。Java的RUNNABLE对应到Linux,既可能是真正的运行中(Running),也可能是可运行但还没真正跑到CPU(Ready)。JVM并没有单独把这两种情况拆成两个状态,这就是为什么你在jstack里看到很多线程是RUNNABLE,却不代表它们都在占满CPU,可能只是排队中。
4.2 未获得锁的线程去哪了?
当多个线程竞争同一个synchronized方法时,没拿到锁的线程会进入BLOCKED状态,挂在该Monitor的同步队列里。但重量级锁的实现又依赖操作系统的线程阻塞机制:JVM调用pthread_mutex_lock相关操作,让线程真正休眠,暂时放弃CPU。
这里必须理解一个概念:线程的睡眠和唤醒是开销很大的操作。因为每次切换都要保存当前线程的上下文,再加载另一个线程的上下文,如果频繁发生,系统CPU时间大量消耗在调度器上,而不是业务逻辑上。这也是为什么轻量级锁自旋一会儿,对短临界区反而更高效。
我实际做过一次实验:把同一把锁下临界区里的耗时从1微秒增加到1毫秒,然后观察TPS变化。你会发现短临界区场景下,自旋锁的收益非常明显;临界区一长,自旋就变成纯浪费CPU了。这也提醒我们,synchronized的设计是好的,用得好不好全看临界区短不短。
4.3 一次完整的同步代码执行过程
用一个“四个线程同时调increment()”的场景,完整走一遍:
- T1线程最先抢到轻量级锁,开始执行
value++。此时T2、T3、T4进入自旋,尝试通过CAS抢锁。 - 如果T1在极短时间内完成任务并释放锁,T2通过CAS抢到锁,T3、T4继续自旋。整个过程没有发生内核级线程阻塞。
- 如果T1执行时间过长,T2、T3、T4自旋超过阈值(JVM可配置),它们会申请把锁膨胀为重量级锁,然后操作系统把这三个线程挂起,状态变为
BLOCKED。 - T1释放锁后,JVM需要唤醒队列头部的线程T2,T2进入
RUNNABLE并等待操作系统的调度。T3、T4继续原地等待。 - T2执行完释放锁,继续唤醒下一个线程,直到全部执行完毕。
这个过程中,JVM处理锁升级、线程排队和唤醒,操作系统处理实际CPU时间片分配。很多并发调优的人只盯着业务代码,却忽略了这种上下游协作,往往才会出现“明明用synchronized保护了,但线上还是故障”的错觉。
5. 实战排查:同步代码拖垮性能的三种典型场景
5.1 场景一:锁粒度太大,串行化太严重
最简单的例子:初始化一个共享资源,很多人喜欢把整个初始化过程都放到synchronized方法里,包括耗时很长的远程调用。这样一来,所有并发请求都会被锁堵住。
我见过最夸张的代码是一个订单查询方法,synchronized包里塞了数据库查询、Redis访问和业务计算,吞吐量惨不忍睹。排查思路很简单:
- 先看线程栈:大量线程
BLOCKED,且锁的持有时间很长 - 再看逻辑划分:把不需要保护的读操作挪到锁外,只保留真正的临界区
- 如果读多写少,考虑用
ReadWriteLock或者StampedLock
5.2 场景二:伪共享(False Sharing)在锁内部放大问题
伪共享听起来和锁无关,但它会让“加了锁的代码”性能雪崩。简单说,CPU缓存是以缓存行为单位加载的,通常64字节。如果两个共享变量恰好落在同一个缓存行里,线程A修改变量X,会导致包含变量Y的整条缓存行在其他CPU核心上失效,线程B读Y时就得重新从内存加载。
在同步代码里,如果多个线程不在同一个锁竞争,而是操作同一缓存行内的不同变量,它们也会“同步”得异常频繁。我曾经为了测试,在一个对象里连续声明了好几个long变量,分别用不同线程写,结果性能远不如把每个变量前后用填充字段隔开。后来用@Contended注解(JVM参数-XX:-RestrictContended)解决了。不过这个坑一般只在极端高并发下才明显,普通业务基本不用管。
5.3 场景三:锁竞争导致的频繁线程阻塞唤醒
如果说前两个场景是“锁内代码太多”和“缓存行打架”,那么第三种就是纯粹的“锁竞争频率太高”。
假设一个秒杀接口,所有请求都要执行increment(),哪怕这个方法的临界区只有几行代码,但每秒几万次请求争抢同一把锁,也一样会让JVM在锁升级上反复横跳。这种时候光靠synchronized已经不够了,我常用方案有两种:
- 分段锁:把共享数据拆成多个桶,每个桶有独立的锁,降低单个锁的竞争频率
- 减少锁的使用:比如计数器场景可以试试
LongAdder,它内部采用了一种分段的“cell”策略,把线程分散到不同槽位里累加,最后汇总,能极大缓解竞争
5.4 一个排查小工具清单
如果线上出现了并发相关的问题,我习惯按这样的顺序排查:
- 先用
jstack抓线程堆栈,看大量线程停在哪个方法、哪个锁上 - 用
jstat -gcutil观察GC情况,排除GC停顿带来的假象 - 用
jmap导出堆,分析是否有大对象在同步代码里创建 - 做压测,用
-XX:+PrintFlagsFinal查看锁是否默认值,再考虑是否手动调整自旋次数(一般不建议)
这条链路基本能定位80%的同步代码性能问题。剩下的20%,往往就是JMM和线程调度这些底层概念没理解透,导致压根没定义清楚问题的方向。
6. 最后踩过的坑,和给你的三条实在建议
其实回到开头的那个线上事故,最后排查出来的原因令人意外:代码里故意加了synchronized的Getter方法,但业务代码用的是另一个没有加锁的本地缓存读取路径。也就是说,一个线程每次锁内更新数据,另一个线程却通过锁外路径读取旧数据,可见性直接失效。这个问题不是JMM的错,而是使用者对同步边界理解不透。
我个人在实际操作中的体会是:不要把synchronized当成“只要加了就是并发安全”的护身符,它只能约束“同一个锁”下的代码路径。只要你有一条路径绕开了锁,那之前的全部保证都等于零。
再分享一个经验:写并发代码前,先在自己脑子里走一遍“主内存、工作内存、Monitor队列”的旅程,把代码里每一个共享变量当作一件跨越内存边界传递的包裹,把锁当作唯一的驿站。如果两个线程拿的不是同一把钥匙,那它们就是住在两个不同酒店里,永远等不到对方。
如果你现在正准备Java面试,或者准备入手一个高并发项目,我建议你再深入看一下happens-before规则的前几条,配合这篇文章的代码去单步调试一遍,你会突然觉得JMM不再是虚无缥缈的规则,而是一套可以亲眼看到的系统行为。