☰
死磕synchronized:从JMM到锁升级,彻底搞懂Java并发同步
2026/10/10 9:03:10 网站建设 项目流程

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. 从主内存读取当前值到工作内存
  2. 在工作内存中把值+1
  3. 将新值写回主内存

如果不加锁,线程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 ... monitorexit

monitorenter获取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()”的场景,完整走一遍:

  1. T1线程最先抢到轻量级锁,开始执行value++。此时T2、T3、T4进入自旋,尝试通过CAS抢锁。
  2. 如果T1在极短时间内完成任务并释放锁,T2通过CAS抢到锁,T3、T4继续自旋。整个过程没有发生内核级线程阻塞。
  3. 如果T1执行时间过长,T2、T3、T4自旋超过阈值(JVM可配置),它们会申请把锁膨胀为重量级锁,然后操作系统把这三个线程挂起,状态变为BLOCKED。
  4. T1释放锁后,JVM需要唤醒队列头部的线程T2,T2进入RUNNABLE并等待操作系统的调度。T3、T4继续原地等待。
  5. 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 一个排查小工具清单

如果线上出现了并发相关的问题,我习惯按这样的顺序排查:

  1. 先用jstack抓线程堆栈,看大量线程停在哪个方法、哪个锁上
  2. 用jstat -gcutil观察GC情况,排除GC停顿带来的假象
  3. 用jmap导出堆,分析是否有大对象在同步代码里创建
  4. 做压测,用-XX:+PrintFlagsFinal查看锁是否默认值,再考虑是否手动调整自旋次数(一般不建议)

这条链路基本能定位80%的同步代码性能问题。剩下的20%,往往就是JMM和线程调度这些底层概念没理解透,导致压根没定义清楚问题的方向。

6. 最后踩过的坑,和给你的三条实在建议

其实回到开头的那个线上事故,最后排查出来的原因令人意外:代码里故意加了synchronized的Getter方法,但业务代码用的是另一个没有加锁的本地缓存读取路径。也就是说,一个线程每次锁内更新数据,另一个线程却通过锁外路径读取旧数据,可见性直接失效。这个问题不是JMM的错,而是使用者对同步边界理解不透。

我个人在实际操作中的体会是:不要把synchronized当成“只要加了就是并发安全”的护身符,它只能约束“同一个锁”下的代码路径。只要你有一条路径绕开了锁,那之前的全部保证都等于零。

再分享一个经验:写并发代码前,先在自己脑子里走一遍“主内存、工作内存、Monitor队列”的旅程,把代码里每一个共享变量当作一件跨越内存边界传递的包裹,把锁当作唯一的驿站。如果两个线程拿的不是同一把钥匙,那它们就是住在两个不同酒店里,永远等不到对方。

如果你现在正准备Java面试,或者准备入手一个高并发项目,我建议你再深入看一下happens-before规则的前几条,配合这篇文章的代码去单步调试一遍,你会突然觉得JMM不再是虚无缥缈的规则,而是一套可以亲眼看到的系统行为。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询