Synchronized 锁升级:从偏向锁到重量级锁的性能进化之路

Synchronized 锁升级:从偏向锁到重量级锁的性能进化之路 大家好我是程序员小策。你一定遇到过这种场景你写了一个工具类里面有个synchronized方法本地跑得一切正常QPS 才几百。上了生产环境之后监控发现这个方法的响应时间偶尔会飙到几十毫秒甚至上百毫秒。你觉得是数据库慢了排查了一圈发现不是。最后定位到原因这个synchronized在高并发下触发了锁竞争从几乎无开销变成了每次都要找操作系统申请互斥量。而这行代码背后藏着的就是今天要聊的主题——synchronized 的锁升级机制。一、为什么 synchronized 曾经是性能杀手先说一个朴素认知大部分开发者知道synchronized是 Java 内置的锁机制用来保证线程安全。在 JDK 1.5 之前它确实是个重量级锁——每次加锁都要向操作系统申请互斥量Mutex涉及用户态和内核态切换开销很大。那时候如果你要优化性能第一反应就是把 synchronized 换成 ReentrantLock。但问题是大多数情况下锁的竞争并不激烈。想象一个计数器方法99% 的时间只有一个线程在调用它。为了这 1% 的极端情况你每次调用都要付出向操作系统申请锁的代价这不合理。所以 JDK 6 做了一件大事给 synchronized 加了智能升级机制——根据竞争情况自动选择合适的锁级别。锁升级Lock Escalationsynchronized 根据锁的竞争程度自动从低开销的偏向锁升级到轻量级锁再升级到重量级锁的过程。这个过程是单向的不可逆。二、把锁升级想象成电影院的入场检查你一定去过电影院吧想象一下电影院的入场流程——它根据人流情况动态调整检查严格程度偏向锁Biased Locking——就像你是这家影院的 VIP 会员。你第一次来的时候前台记住你的脸记录线程 ID。以后你再来直接刷脸进场不用查票、不用排队。前提是你总是同一个时间段来看电影单线程反复访问同步块。如果突然有一天你的朋友也拿着同一张票来了另一个线程尝试获取锁VIP 待遇就失效了——需要升级到更严格的检查方式。轻量级锁Lightweight Locking——就像普通观众排队检票。没有 VIP 特权了大家都在入口处排队。但如果人不多竞争不激烈检票员看一眼票就放行不用叫保安不需要操作系统介入。这个过程用的是 CAS 自旋在用户态就能完成比找操作系统快得多。重量级锁Heavyweight Locking——就像春节档爆满检票口挤爆了。人太多了光靠检票员已经搞不定。这时候必须叫保安过来维持秩序向操作系统申请 Mutex让大家排好队一个一个进。虽然安全但速度慢了很多——因为涉及到用户态和内核态的切换。翻译回技术语言电影院要素锁状态技术实现开销VIP 刷脸进场偏向锁Mark Word 记录线程 ID几乎为零普通排队检票轻量级锁CAS 自旋 Lock Record很小用户态保安维持秩序重量级锁操作系统 Mutex大内核态切换当然实际上锁升级不是电影院经理拍脑袋决定的而是 JVM 根据客观指标是否有多个线程竞争自动触发的。三、代码实现观察锁升级的全过程下面这段代码可以帮你直观看到不同锁状态下的性能差异importjava.util.concurrent.CountDownLatch;importjava.util.concurrent.atomic.AtomicLong;importorg.openjdk.jol.info.ClassLayout;publicclassLockEscalationDemo{publicstaticvoidmain(String[]args)throwsInterruptedException{// 创建一个对象用于加锁ObjectlocknewObject();// 打印对象的原始布局Mark Word 信息System.out.println( 初始状态无锁);printObjectLayout(lock);// 场景1单线程多次进入同步块 → 偏向锁System.out.println(\n 单线程访问 → 偏向锁 );for(inti0;i2;i){synchronized(lock){if(i1){// 第二次进入时打印printObjectLayout(lock);}}}// 场景2两个线程交替访问 → 升级为轻量级锁System.out.println(\n 两个线程交替访问 → 轻量级锁 );Threadthread1newThread(()-{synchronized(lock){System.out.println(Thread-1 持有锁:);printObjectLayout(lock);try{Thread.sleep(100);}catch(InterruptedExceptione){}}});thread1.start();thread1.join();// 等待 thread1 完成// 主线程再次获取锁synchronized(lock){System.out.println(Main 线程获取锁后:);printObjectLayout(lock);}// 场景3多线程激烈竞争 → 升级为重量级锁System.out.println(\n 多线程竞争 → 重量级锁 );intthreadCount10;CountDownLatchstartLatchnewCountDownLatch(1);CountDownLatchdoneLatchnewCountDownLatch(threadCount);for(inti0;ithreadCount;i){newThread(()-{try{startLatch.await();// 所有线程同时开始synchronized(lock){// 故意自旋一下增加竞争for(intj0;j10000;j){}}}catch(InterruptedExceptione){e.printStackTrace();}finally{doneLatch.countDown();}}).start();}startLatch.countDown();// 放开所有线程doneLatch.await();// 等待所有线程完成System.out.println(竞争结束后:);printObjectLayout(lock);}/** * 打印对象的内存布局Mark Word 信息 * 需要依赖 JOL 库: org.openjdk:jol-core */privatestaticvoidprintObjectLayout(Objectobj){StringlayoutClassLayout.parseInstance(obj).toPrintable();// 提取关键信息对象头中的锁标志位String[]lineslayout.split(\n);for(Stringline:lines){if(line.contains(object header)||line.contains(value)){System.out.println(line.trim());}}}/** * 性能对比测试不同锁状态下的耗时 */publicstaticvoidperformanceComparison()throwsInterruptedException{intiterations100_000_000;// 1亿次操作ObjectlocknewObject();AtomicLongcounternewAtomicLong(0);// 测试无锁情况下的基准耗时longstartSystem.nanoTime();for(inti0;iiterations;i){counter.incrementAndGet();}longnoLockTime(System.nanoTime()-start)/1_000_000;System.out.println(无锁耗时: noLockTimems);// 测试偏向锁单线程startSystem.nanoTime();for(inti0;iiterations;i){synchronized(lock){counter.incrementAndGet();}}longbiasedTime(System.nanoTime()-start)/1_000_000;System.out.println(偏向锁耗时单线程: biasedTimems);System.out.println(偏向锁 vs 无锁: String.format(%.2f,(double)biasedTime/noLockTime)x);// 测试重量级锁多线程竞争CountDownLatchlatchnewCountDownLatch(4);startSystem.nanoTime();for(intt0;t4;t){newThread(()-{for(inti0;iiterations/4;i){synchronized(lock){counter.incrementAndGet();}}latch.countDown();}).start();}latch.await();longheavyweightTime(System.nanoTime()-start)/1_000_000;System.out.println(重量级锁耗时4线程竞争: heavyweightTimems);System.out.println(重量级锁 vs 偏向锁: String.format(%.2f,(double)heavyweightTime/biasedTime)x);}}代码关键点解释为什么要用 JOL 库打印对象布局Java 对象的锁状态信息存储在**对象头Object Header**的 Mark Word 中。JOLJava Object Layout工具可以直接打印出 Mark Word 的二进制内容让我们看到锁状态的变化。Mark Word 的结构64 位 JVM|--------------------|------------------|------| | unused (25bit) | epoch (unused) | age | |--------------------|------------------|------| | biased_lock (1bit) | lock (2bit) | |--------------------|------------------|------|lock 字段的值决定了锁状态01 biased_lock1 →偏向锁00→轻量级锁10→重量级锁11→GC 标记运行结果示例简化版 初始状态无锁 object header: 0x0000000000000001 (non-biasable; age: 0) 单线程访问 → 偏向锁 object header: 0x00007f62XXXXXXXX (biased: 0x0000XXXX; age: 0) // 可以看到 Mark Word 中存储了线程 ID 两个线程交替访问 → 轻量级锁 Thread-1 持有锁: object header: 0x000000XXXXXXXX (thin lock: 0x0000XXXX) // 指向栈中 Lock Record 的指针 Main 线程获取锁后: object header: 0x000000XXXXXXXX (thin lock: 0x0000XXXX) 多线程竞争 → 重量级锁 竞争结束后: object header: 0x000000XXXXXXXX (fat lock: 0x0000XXXX) // 指向操作系统 Mutex 对象的指针性能对比结果参考值无锁耗时: 45ms 偏向锁耗时单线程: 48ms ← 接近无锁 偏向锁 vs 无锁: 1.07x ← 开销极小 重量级锁耗时4线程竞争: 2856ms ← 慢了约 60 倍 重量级锁 vs 偏向锁: 59.50x ← 性能差距巨大可以看到偏向锁的开销几乎可以忽略不计而一旦升级到重量级锁性能会断崖式下降。四、看起来很完美了对吧但这几个坑你可能踩过坑一锁升级是不可逆的很多人以为锁会降级回去——比如竞争结束了重量级锁会不会变回轻量级锁答案是不会。锁升级是单向的无锁 → 偏向锁 → 轻量级锁 → 重量级锁。一旦升级到某个级别就不会再降回来除非 GC 回收对象后重新创建。这意味着什么如果你的系统偶尔出现一次高并发 spike比如整点定时任务导致锁升级到了重量级即使 spike 过去了后续所有对这个锁的操作都会继续走重量级路径直到这个对象被 GC 回收。解法避免长时间持有重量级锁的对象尽量缩小 synchronized 块的范围如果知道会有偶发的高并发考虑一开始就用其他并发原语如 AtomicLong坑二偏向锁的延迟开启JVM 默认会在启动后4 秒才开启偏向锁优化可以通过-XX:BiasedLockingStartupDelay0关闭延迟。这是为了避免启动期间大量短命对象浪费偏向锁的初始化开销。但这也意味着如果你的应用刚启动就遇到高并发前几秒钟内的 synchronized 都是走重量级路径的。解法如果应用启动时间敏感设置-XX:BiasedLockingStartupDelay0或者接受这几秒的性能损失大多数场景影响不大坑三hashCode() 会导致偏向锁失效如果一个对象调用了hashCode()方法或者被用作 HashMap 的 key它的偏向锁就会立即撤销因为 hashCode 需要存储在 Mark Word 中而偏向锁也要占用 Mark Word 存储线程 ID——两者冲突了。ObjectlocknewObject();lock.hashCode();// ← 这一行会导致偏向锁失效synchronized(lock){// 这里不会走偏向锁而是直接升级到轻量级或重量级}解法不要对会被加锁的对象调用hashCode()如果必须用 hashCode考虑改用其他并发控制方式坑四批量撤销与批量重偏向当同一个类的大量对象都发生了偏向锁撤销比如遍历 HashMap 时JVM 会触发批量撤销——把这个类的所有新建对象都设为不可偏向。这是一个连坐机制可能导致后续新创建的对象也无法享受偏向锁优化。JVM 还有一个批量重偏向机制如果撤销次数达到阈值默认 20 次会对该类的下一个对象重新启用偏向锁。但这个机制比较隐蔽很难主动利用。五、从单机到大规模集群锁升级还够用吗到目前为止我们讨论的都是单个 JVM 进程内的 synchronized 行为。但在分布式环境下呢synchronized 只能保护单个 JVM 内的资源。如果你有 10 个服务实例每个实例都有自己的 synchronized 锁它们之间无法协调。这就好比每家电影院都有自己的检票系统但你买了 A 影院的票去 B 影院看电影——B 影院的系统根本不认你的票。分布式环境下的替代方案Redis 分布式锁基于 Redis 的 SETNX 或 Redlock 算法实现跨 JVM 的互斥Zookeeper 分布式锁基于临时顺序节点强一致性保证数据库悲观锁SELECT … FOR UPDATE简单粗暴但有效当然这些方案的代价都比 synchronized 大得多网络开销、序列化开销所以在能用 synchronized 解决的场景下不要过度设计。另外值得一提的是JVM 的 JIT 编译器也会对锁做进一步优化锁消除Lock Elimination如果 JIT 发现某个锁对象永远不会逃逸出当前方法通过逃逸分析它会直接移除 synchronized 块锁粗化Lock Coarsening如果在一个循环里频繁加锁解锁JIT 会把锁提到循环外面只加一次锁// JIT 可能会把这段代码优化成只在循环外加一次锁for(inti0;i100;i){synchronized(lock){list.add(i);}}// 优化后等效于synchronized(lock){for(inti0;i100;i){list.add(i);}}这就是为什么有时候你写了 synchronized 但性能并没有明显下降——JIT 已经偷偷帮你优化了。六、三种锁状态对比一览表维度偏向锁轻量级锁重量级锁适用场景单线程反复访问同步块低竞争2 个线程交替访问高竞争多线程同时抢锁实现原理Mark Word 存储线程 IDCAS 自旋 Lock Record操作系统 Mutex是否阻塞线程不阻塞自旋等待短暂阻塞挂起让出 CPU性能开销几乎为零接近无锁很小纳秒级大微秒到毫秒级用户态/内核态用户态用户态用户态 ↔ 内核态切换能否升级到下一级能 → 轻量级锁能 → 重量级锁不能最终形态典型触发条件同一线程重复进入另一个线程尝试获取锁CAS 自旋失败超过阈值适用案例单例模式的 getInstance()简单计数器、配置读取高并发库存扣减、订单创建一句话总结选型策略如果确定只有单线程访问 →偏向锁就够了synchronized 自动处理如果有少量竞争2-3 个线程→轻量级锁兜底还是 synchronized 自动处理如果竞争激烈高并发场景→考虑用 ReentrantLock 或 Atomic 类替代 synchronized注意作为开发者你不需要手动选择锁级别——synchronized 会自动完成升级。你需要做的是理解这个机制以便在性能排查时能快速定位问题。七、面试官还会追问什么面试追问 1为什么偏向锁会提升性能它的本质是什么→ 回答方向偏向锁的本质是省掉了 CAS 操作。普通的轻量级锁每次进入同步块都要执行一次 CAS 来交换 Mark Word 和 Lock Record而偏向锁只需要比对一下线程 ID 就行了——这是一次简单的内存读操作比 CAS 快很多。适合同一个线程反复进入同步块的场景比如循环调用 synchronized 方法。面试追问 2轻量级锁的自旋会一直转下去吗有没有限制→ 回答方向不会无限自旋。JDK 6 引入了自适应自旋——自旋次数不是固定的而是根据上一次在同一个锁上的自旋时间和锁拥有者的状态动态调整。如果上次自旋成功获得了锁这次会允许自旋更长时间如果上次失败了这次会减少自旋次数甚至直接跳过自旋升级到重量级锁。可以通过-XX:PreBlockSpin参数调整初始自旋次数默认 10。面试追问 3锁升级过程中如果正在执行同步块的线程突然崩溃了怎么办→ 回答方向这是个很好的问题。答案是不用担心。偏向锁如果持有偏向锁的线程死了其他线程来竞争时会发现偏向锁无效直接升级为轻量级锁轻量级锁Lock Record 在栈帧里线程崩溃后栈帧销毁锁自然释放重量级锁操作系统会回收 Mutex其他线程可以获得锁面试追问 4如何判断线上系统的 synchronized 是否存在性能问题→ 回答方向几个排查手段jstack 查看线程状态大量线程处于 BLOCKEDon object monitor说明锁竞争严重Arthas 的thread命令查看线程 CPU 时间和阻塞时间占比JFRJava Flight Recorder录制 JVM 事件分析锁竞争热点-XX:PrintGCDetails 日志观察 safepoint 时间重量级锁会导致 safepoint 增加面试追问 5synchronized 和 ReentrantLock 在锁升级方面有什么区别→ 回答方向synchronized 有自动的锁升级机制偏向→轻量→重量而 ReentrantLock始终是基于 AQS 的重量级实现除非用 tryLock 做非阻塞尝试。这也是为什么在低竞争场景下 synchronized 往往比 ReentrantLock 更快的原因——它能享受偏向锁和轻量级锁的优化。八、总结synchronized 不是要么全有要么全无它会根据实际情况看菜下饭。读完这篇你应该能解释清楚 synchronized 的四种锁状态无锁、偏向、轻量、重量以及升级触发条件用电影院检票的类比向同事讲明白锁升级的设计思想在性能排查时识别出本不该这么慢的 synchronized 方法——大概率是升级到了重量级锁在面试时说出偏向锁省掉 CAS 操作、“自适应自旋”、“锁升级不可逆”——而不只是背JDK 6 做了优化