1. 从“会背八股”到“能聊半小时”synchronized优化到底在聊什么很多人一提synchronized张嘴就是“线程安全、加锁、重量级、性能差”然后就没了。但真正被问到“你知不知道synchronized是怎么优化的”的时候才发现自己对它的理解只停留在会用锁的层面。这篇就把synchronized从底层数据结构到JIT优化、再到工程落地的完整链路拆开讲清楚读完可以直接拿去面试聊也可以回头看看自己项目里哪些锁其实写得很浪费。先说清楚一个容易被误解的前提synchronized不是一开始就重的。JDK 1.6之后JVM对synchronized做了一整轮大的优化引入了偏向锁、轻量级锁、锁消除、锁粗化、自旋等一系列机制。换句话说现在你写的每一行synchronized代码实际运行时的行为可能和教科书上“重量级锁”的描述相去甚远。搞明白这些底层优化才算真正理解为什么synchronized依然是并发编程里最稳妥、最值得优先考虑的同步手段。这篇文章适合的人群很明确准备Java面试的中高级开发、正在排查线上锁竞争问题的同学、以及想把自己代码里并发性能再抠一把的人。我会从对象头里锁的存储结构开始讲把锁升级的完整路径、JIT层的无锁化优化、工程落地手法、以及面试官最爱追问的几个细节全部串起来。看完之后你至少能就“synchronized怎么优化”这个话题和面试官来回拉扯半小时。2. 理解优化之前先搞清楚synchronized到底锁的是什么2.1 对象头里的Mark Word才是锁的“老家”很多人在聊synchronized优化的时候卡壳根源在于不知道锁状态存在哪里。锁不是凭空挂在某个全局表里的它其实就放在Java对象的对象头Object Header中。HotSpot虚拟机的对象头由两部分组成第一部分叫Mark Word占64位64位JVM下第二部分是类型指针指向类的元数据。Mark Word里存储的东西非常“复用”——它要根据对象当前的状态来决定这64位存什么。默认情况下存的是对象的hashCode、分代年龄、偏向锁标志位biased_lock和锁标志位lock加起来正好压进64位。关键是一旦对象处于被锁定的状态Mark Word就不再存hashCode和分代年龄了而是把整块空间腾出来存指向锁记录的指针、指向Monitor监视器锁的指针或者直接存持有偏向锁的线程ID。这就是JVM做锁优化的地基所有的偏向锁、轻量级锁、重量级锁切换本质上都是在这64位里做文章。提示面试时问到“synchronized锁的信息存在哪里”能脱口而出“对象头的Mark Word”只是基本分接着把Mark Word在不同锁状态下的位布局说出来才是能聊下去的关键。具体到位布局我通常用一张表帮助记忆64位虚拟机下大概是这样的锁状态Mark Word内容64位无锁unused:25 identityHashCode:31 unused:1 age:4 biased_lock:1 lock:2偏向锁threadId:54 epoch:2 unused:1 age:4 biased_lock:1 lock:2轻量级锁指向栈中锁记录的指针:62 lock:2重量级锁指向Monitor的指针:62 lock:2GC标记空/特定标记位这块内容看着枯燥但理解它之后很多“为什么”就能串起来。比如为什么偏向锁撤销要等安全点因为要修改对象头的位信息必须保证所有线程都停下来才能安全地替换Mark Word里的内容。这就是优化的成本和代价。2.2 Monitor监视器重量级锁背后的“业务系统”聊到重量级锁绕不开Monitor。每个Java对象都可以关联一个Monitor对象HotSpot里用ObjectMonitor来实现。当你用synchronized给对象加重量级锁时本质上就是让这个线程去抢ObjectMonitor的所有权。ObjectMonitor里有几个关键字段_owner当前持有锁的线程、_WaitSet调用了wait进入等待态的线程队列、_EntryList还在排队等锁的线程队列、_recursions重入计数器。重点说一下_recursions它就是synchronized可重入机制的底层来源。同一线程再次进入同步块时不用重新抢锁只需要把_recursions加1退出时减1直到减到0才真正释放锁。顺便解释一个常被问到的细节synchronized加在static方法和实例方法上的Monitor对象不同。static方法锁的是Class对象实例方法锁的是当前this对象。在实际工程里如果不注意这个区别很容易写出“以为加了锁实际没锁住”的代码。典型的坑就是两个线程分别调用同一个类的static synchronized方法和实例synchronized方法它们锁的对象不一样等于没锁。另外wait和notify这套机制也建立在Monitor之上。调用wait的线程会释放Monitor的所有权然后进入_WaitSet而调用notify只是把_WaitSet里的一个线程搬到_EntryList中它还要继续参与锁竞争。理解了这层你就能解释为什么wait之后线程不一定会立即执行因为“被唤醒”和“拿到锁”是两回事。3. 锁升级全过程偏向锁到重量级锁一次加锁背后的四次变身synchronized优化最核心的链条就是锁升级无锁 → 偏向锁 → 轻量级锁 → 重量级锁。注意这个方向是单向的锁只能升级不能降级GC里偏向锁可以批量撤销但流程上依然是“升级路径”。搞懂每一级锁的触发条件、行为特征和代价是面试聊半小时的主力弹药。3.1 偏向锁假设没有竞争干脆不抢偏向锁的设计思想很“投机”如果一个线程拿到了锁并且在随后相当长一段时间内没有其他线程来竞争那下次这个线程再进入同步块时就不用重复执行CAS抢锁了直接检查Mark Word里的线程ID是不是自己是就进去。偏向锁的获取流程大概是第一个线程尝试CAS把Mark Word中偏向锁的线程ID改成自己成功后就拥有了偏向锁。之后这个线程再次进入时只需要对比线程ID完全无开销。这个优化主要针对“一个线程反复进入同一同步块”的场景实际业务里非常常见。比如单线程初始化的缓存、只有一个线程调用的加锁方法用偏向锁就能省掉很多CAS开销。但偏向锁有个绕不开的痛一旦有其他线程来竞争偏向锁要被撤销。撤销不是简单地把锁状态改一下而是需要等待全局安全点SafePoint暂停所有用户线程然后判断原持有者是否还活着如果原线程不在了直接把对象头改成无锁或重新偏向如果还在就升级成轻量级锁。面试里经常问“偏向锁在JDK 15之后怎么了”。JDK 15默认废弃了偏向锁JDK 17里彻底不可用。原因就是偏向锁的撤销成本太高尤其在大量线程竞争的场景下安全点暂停带来的延迟比省下来的CAS开销更大。我在JDK 8生产环境里就遇到过偏向锁导致Full GC周期性暂停的案例后面细说。3.2 轻量级锁与自旋用CPU换阻塞轻量级锁是为了应对“多个线程交替执行同步块但实际没有真正竞争”的场景。它不会让抢不到锁的线程进入阻塞而是让它在用户态通过自旋循环等待始终不放弃CPU。获取轻量级锁的过程是这样的线程进入同步块时JVM先在当前线程的栈帧中创建一块名为Lock Record的空间然后通过CAS尝试把对象头Mark Word里的锁记录指针指向这个Lock Record。如果CAS成功当前线程就持有锁了如果失败说明有竞争锁会膨胀成重量级锁进入阻塞等待。轻量级锁里的自旋不是盲目的而是“自适应自旋”。JVM会根据上一次在同一个锁上自旋等待的成功率动态调整自旋次数。如果上次自旋拿到了锁这次就多转几圈如果上次转半天也没拿到那就少转几圈甚至直接放弃自旋。这个细节是面试加分项——你知道光是“自旋”这个动作JVM都做了很多智能调优而不是写死一个数字。我在实际项目中实测过一个高并发下的计数器接口同步块执行时间在几十微秒级别大部分锁竞争都是毫秒内结束的自旋就能扛住几乎不会升级到重量级锁。但如果同步块里有IO操作、远程调用这类耗时的东西自旋纯属浪费CPU必须及时阻塞。3.3 重量级锁走到了操作系统层一旦自旋失败或者竞争过于激烈轻量级锁会膨胀为重量级锁此时Mark Word指向的是ObjectMonitor。重量级锁依赖操作系统的互斥量Mutex来实现线程阻塞和唤醒涉及用户态和内核态切换代价极高。所以JVM才在前面设计了偏向锁和轻量级锁两级中间态目的就是把大部分场景拦在重量级锁之前。重量级锁还伴随一个“惊群效应”的经典问题释放锁时如果一次唤醒所有在_EntryList里排队的线程这些线程只会有一个抢到锁其余的全部要再次进入阻塞白白消耗CPU和上下文切换时间。HotSpot里对此做了优化比如锁释放时只唤醒一部分等待者或采用更合理的队列唤醒策略但本质上重量级锁的代价依然摆在那里。注意面试官问“怎么让synchronized性能更好”时正确的回答不是“不用synchronized换ReentrantLock”而是先讲清楚锁升级机制然后说“尽量让代码停留在偏向锁或轻量级锁阶段避免膨胀到重量级锁”。这才答到了点子上。4. 编译器帮你省锁锁消除与锁粗化的底层逻辑聊完锁升级还得补一块JIT编译期的优化锁消除和锁粗化。这部分是很多开发者不知道的“隐形优化”理解它之后你会发现有些代码写得很随意但JVM悄悄帮你把性能捞回来了。4.1 锁消除锁一个根本不会被共享的对象等于白锁锁消除发生在JIT编译阶段依赖逃逸分析。如果JVM判断一个对象不会逃逸出当前线程也就是说不存在其他线程访问它的可能那么对这个对象的一切加锁操作都会被直接消除。最典型的例子是StringBuffer。StringBuffer的append方法是synchronized的但如果你在一个方法内部创建StringBuffer并拼字符串这个对象根本没有对外暴露那么JVM会把所有append的加锁都优化掉。我在自己机器上用JMH实测过在方法内部使用StringBuffer拼接短字符串和直接用StringBuilder性能几乎一致原因就是锁被消除了。锁消除对整个行业的意义在于你并不需要为了性能而刻意回避synchronized只要代码本来就符合加锁条件JVM编译时会帮你判断。真正需要关心的是那些“看起来需要加锁、实际对象根本没共享”的代码比如在循环里面new一个不逃逸的容器并加锁——这种代码看着傻但性能上真的不吃亏因为锁会被消除。不过锁消除依赖逃逸分析触发而逃逸分析本身也不是每个方法都能做的。如果方法体过大、分支过于复杂JVM可能放弃分析锁就不会被消除。所以最佳实践依然是不要为了“防万一”去加无意义的锁锁应该加在真正需要保护的共享资源上。4.2 锁粗化把多次加锁合并成一次锁粗化的思路和锁消除正好相反如果JVM检测到同一线程在短时间内反复对同一个对象加锁、释放锁它会把范围扩大把这几次加解锁合并成一次大的加锁区域。这样做的原因是即使偏向锁和轻量级锁的开销不大但一次次的加锁解锁也是有成本的尤其是循环体内的加锁。一个典型的粗化场景for (int i 0; i 1000; i) { synchronized (lock) { // 只做一步操作 } }JIT可能会将它优化成一次加锁包住整个循环。但这里我要强调一个非常关键的工程问题锁粗化是JIT在运行期做的优化不是你在代码里随意扩大锁范围的借口。如果你在代码里刻意用一个巨大的synchronized块包住大量耗时操作JIT的粗化反而不会盲目扩大——它也要评估同步块的执行时间。而且你在代码层面扩大锁范围会直接影响代码可读性和锁竞争时长这是编译期优化救不回来的。我见过一些同事为了提高单线程性能手动把几个小同步块合并成一个大同步块结果恰恰相反一个线程长时间持锁其他线程等待时间从微秒级涨到毫秒级吞吐量反而下降。锁粗化是JVM的兜底手段不是开发者偷懒的理由。4.3 自旋与偏向锁在JIT层面的配合自旋和偏向锁并不仅存在于锁升级路径上JIT还会根据运行时的锁竞争统计动态调整锁的行为。比如一个锁在运行早期总是只有单个线程访问JVM可能更倾向于让它保持在偏向锁状态如果竞争加剧JVM会提高自旋阈值帮助线程尽快拿到锁避免陷入重量级锁的阻塞唤醒。这背后依赖JVM运行时统计和基于剖析的优化PGO。所以在真实场景里你很难通过静态代码判断一个同步块最终会停留哪一级锁一定要靠测量。这就引出一个结论与其猜锁优化行为不如用工具看锁竞争的真实数据下面第6节会给出完整的排查方法。5. 工程实战里的synchronized性能优化手法底层原理讲完了回到项目里真刀真枪干。面试官问完原理之后一定会追问“你项目里怎么优化synchronized的”。常见的优化思路大概分四类降低锁粒度、减少持锁时间、锁分离、以及用无锁结构替代。下面逐个说附上我踩过的坑。5.1 降低锁粒度与分段锁思想降低锁粒度的核心是“能让不同线程操作不同的数据就别让他们抢同一把锁”。最经典的案例就是ConcurrentHashMap的JDK 7实现它用Segment数组分段每个Segment是一把独立的锁不同线程操作不同Segment时完全无竞争。JDK 8改成CAS synchronized锁链头节点本质还是“把锁细分到桶级别”。在实际业务里降低锁粒度最常见的落地方式是把一个大锁拆成多个小锁。比如一个订单系统原来的代码可能是public synchronized void processOrder(Order order) { // 处理订单的大量逻辑 }这个锁把整个订单处理都串行化了。优化后可以按订单ID取模分片private final Object[] lockArray new Object[16]; public void processOrder(Order order) { Object lock lockArray[order.getId() % lockArray.length]; synchronized (lock) { // 只锁同一个分片内的订单 } }这样不同分片的订单可以并行处理。但注意一个坑分片数固定后如果某个分片的订单量特别大这个分片就会成为热点。所以我通常建议分片数要远大于CPU核数并且尽量让分片键的哈希足够分散。5.2 减少持锁时间把重活挪到锁外面这是个看起来简单、做起来容易被忽视的点。很多代码喜欢把整个方法体锁住包括读数据库、调外部接口、计算复杂逻辑这些耗时操作全部在锁内执行。正确的姿势是只锁住真正需要同步的核心资源操作比如更新共享变量、操作共享集合其他操作都放锁外。我处理过一个真实的性能故障一个秒杀扣减库存接口QPS一上来就掉到几十。排查发现代码里是synchronized包裹了从Redis预扣减到数据库落库的全流程其中一次数据库慢查询耗时接近200ms所有请求全堵在这把锁上。优化方案很简单先在锁外预扣Redis库存只有真正写数据库的短操作放在锁内性能直接翻了十几倍。这类优化的核心心法就是把持锁时间压缩到最小锁内不要有任何IO操作、网络调用、耗时计算。面试时把这个案例讲出来比背一百遍“减少锁粒度”有用得多。5.3 锁分离与读写分离的取舍锁分离的极端形态就是读写锁。读操作之间其实不互斥只有写写、读写才互斥因此用ReadWriteLock可以让读并发度翻倍。但这引出一个很常见的纠结到底是选synchronized还是ReentrantReadWriteLock我的判断标准很简单如果读多写少且读操作占用时间长读写锁值得如果读写比例均衡读写锁的额外复杂度反而不值得。还有一点synchronized配合volatile能在某些场景下替代读写锁。比如一个配置项读的频率远高于写读操作只需要读取变量写操作偶尔更新那么把变量声明为volatile写操作加synchronized保证原子性读操作不加锁直接读就是一个非常高性能的读写分离方案。这比引入ReentrantReadWriteLock要轻量得多也是面试官想听到的“灵活运用”式回答。5.4 synchronized与Lock在实际编码中的选择虽然这篇主题是synchronized但工程上绕不开和Lock的对比。我的经验是能用synchronized就用synchronized。它的锁升级优化在大多数场景下足够好而且代码简洁不会出现忘记释放锁的问题。ReentrantLock的优势在于可中断、可超时、公平锁、多条件队列。当需要tryLock超时获取、或者需要多个等待队列时才值得换Lock。如果追求极致并发优先考虑原子类、ConcurrentHashMap、LongAdder等无锁或分段结构而不是在锁的类型里纠结。另外一个容易踩的坑是synchronized在异常时的行为。synchronized会在异常时自动释放锁这是它相比手动Lock的一个优势。但如果你在同步块内catch住了异常并吞掉锁依然正常释放此时可能掩盖了并发逻辑的错误导致后续线程读到不一致的数据。所以用synchronized时也要保证同步块内的代码足够健壮不能把异常吞得无声无息。6. 线上锁竞争排查与调优实录6.1 从线程Dump看锁状态和等待队列线上排查锁问题最基础的手段是拿到线程Dump。用jstack导出线程快照后搜索关键字“Monitor”和“waiting to lock”能直接看到哪些线程在等哪把锁、谁持有锁、锁的类型是什么。一个典型的重量级锁竞争片段看起来像这样http-nio-8080-exec-12 #27 prio5 os_prio0 tid0x00007f8d3c44a800 nid0x2a49 waiting for monitor entry [0x00007f8d3a7f0000] java.lang.Thread.State: BLOCKED (on object monitor) at com.example.OrderService.processOrder(OrderService.java:45) - waiting to lock 0x00000007812b3de0 (a java.lang.Object)看到“waiting for monitor entry”和“BLOCKED (on object monitor)”就说明线程卡在重量级锁的等待队列里。如果大量线程都停在同一个锁上说明这把锁已经膨胀为重量级锁并且竞争非常激烈。这时候先去代码里找对应行看锁范围是不是太大了。另一个实用命令是jstat -gcutil观察Full GC频率。为什么锁和GC会扯上关系因为偏向锁撤销要停全场线程在JDK 8环境下如果偏向锁频繁撤销会表现为GC停顿明显变长。我之前有套JDK 8的服务周期性出现几百毫秒的停顿排查了很久最后发现是热锁导致偏向锁反复撤销。临时解决办法是在JVM启动参数里加-XX:-UseBiasedLocking关闭偏向锁停顿立刻缓解。当然这是特定历史版本和特定场景下的取舍新版本JDK里偏向锁已经默认关闭甚至移除这个坑也会慢慢消失。6.2 用Arthas和JMH测量锁竞争的真实代价排查锁竞争我还特别推荐用Arthas的monitor命令它可以统计某个方法的调用次数、耗时和失败率。如果你怀疑某个synchronized方法是瓶颈直接monitor上去看平均耗时和最大耗时如果最大耗时远高于平均耗时大概率就是锁竞争导致的偶发等待。另一个必须养成的习惯是写JMH基准测试来验证锁优化效果。比如你想比较某个同步块改成分段锁之后性能提升多少不要凭感觉跑一个JMH用例设置不同的线程数4、8、16、32测量吞吐量和平均耗时。这能直接回答“优化到底有没有用”这个问题。我给团队做的锁优化最后都是用JMH数据说话而不是“我觉得快了”。提示压测锁性能时要特别注意逃生分析对结果的影响。如果测试代码里的锁对象没逃逸锁直接被消除了测出来的数据会看起来“特别好”但线上根本不是这个场景。JMH里可以用Blackhole消费对象来防止过度优化。6.3 面试官常追的5个synchronized优化追问准备面试的读者最后帮你系统梳理一下这个领域的高频追问每一个都能展开聊几分钟偏向锁为什么在JDK 15后被废弃答偏向锁撤销需要安全点高竞争场景下安全点暂停的成本超过了收益。这句话要配上具体场景解释才算答完整。什么是锁消除依赖什么技术答JIT编译期的逃逸分析对象不逃逸则去掉锁。锁粗化和在代码里手动扩大锁范围有什么区别答锁粗化是JIT根据运行统计自动合并手动扩大锁范围会延长持锁时间两者不是一回事。自旋锁一定会升级为重量级锁吗答不一定自旋成功就留在轻量级锁状态自旋失败才膨胀。自适应自旋会根据历史成功率调整尝试次数。JVM关闭偏向锁用哪个参数线上值不值得关闭答-XX:-UseBiasedLockingJDK 8高竞争场景下可以关但更建议先压测再决定。把这些问题串起来其实刚好对应这篇文章的几个章节。底层存储 → 锁升级 → JIT优化 → 工程实践 → 排查工具每个方向都有话可说半小时的面试时间就这么撑起来了。7. 最后说点个人体会研究synchronized优化这一年多我最深的感受是这玩意儿表面上看是个语法关键字背地里却串联起了JVM对象布局、操作系统线程调度、JIT编译优化、甚至GC停顿几大核心知识。真正吃透它靠的不是背题而是拿着jstack和JMH去线上和本地反复验证。我在几个项目里都验证过只要锁粒度合理、持锁时间短、尽量停留在轻量级锁层面synchronized在绝大多数业务场景下完全不虚任何锁组件。最后再分享一个压箱底的小技巧看锁竞争问题别只盯着业务代码记得看一眼同一个锁保护的共享数据量。如果共享数据很大或者锁内操作触发了GC分配那么锁竞争会叠加GC暂停表现为“明明锁竞争不激烈但RT忽高忽低”。这种情况先优化数据结构和使用方式再考虑锁本身往往见效更快。希望这篇能帮你在面试和实战中都多一分从容。
