死磕synchronized:从JMM到锁升级,彻底搞懂Java并发同步

📅 发布时间:2026/10/10 9:03:46
死磕synchronized:从JMM到锁升级,彻底搞懂Java并发同步
1. 从一次线上事故讲起为什么我真的建议你死磕同步代码大概两年前我接手过一个保险核心系统的“疑难杂症”每天凌晨跑批之后总有几笔订单的分润金额对不上。最开始怀疑数据库事务查了三天日志最后发现罪魁祸首是一段看起来很无辜的Java代码——一个基于内存的计数器在并发更新时丢失了更新。当时负责的同事非常不理解明明已经在方法上加了synchronized为什么还会丢数据答案就藏在Java并发最底层的那套逻辑里JMMJava内存模型、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读到value5还没来得及写回线程B也读到value5两个线程都加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也可能是可运行但还没真正跑到CPUReady。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需要唤醒队列头部的线程T2T2进入RUNNABLE并等待操作系统的调度。T3、T4继续原地等待。T2执行完释放锁继续唤醒下一个线程直到全部执行完毕。这个过程中JVM处理锁升级、线程排队和唤醒操作系统处理实际CPU时间片分配。很多并发调优的人只盯着业务代码却忽略了这种上下游协作往往才会出现“明明用synchronized保护了但线上还是故障”的错觉。5. 实战排查同步代码拖垮性能的三种典型场景5.1 场景一锁粒度太大串行化太严重最简单的例子初始化一个共享资源很多人喜欢把整个初始化过程都放到synchronized方法里包括耗时很长的远程调用。这样一来所有并发请求都会被锁堵住。我见过最夸张的代码是一个订单查询方法synchronized包里塞了数据库查询、Redis访问和业务计算吞吐量惨不忍睹。排查思路很简单先看线程栈大量线程BLOCKED且锁的持有时间很长再看逻辑划分把不需要保护的读操作挪到锁外只保留真正的临界区如果读多写少考虑用ReadWriteLock或者StampedLock5.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不再是虚无缥缈的规则而是一套可以亲眼看到的系统行为。