
很多人写了三五年Java面试时被问synchronized的锁升级过程还是会卡壳。平时项目里锁倒是没少用但大多停留在方法上加个关键字就完事的层面。这期Thread学习第六篇就把synchronized彻底聊透——从字节码层面它到底做了什么到JVM的锁升级机制再到那些容易让人栽跟头的坑。内容偏进阶适合已经会写多线程但想搞清楚底层逻辑的同学。1. synchronized到底锁住了什么从对象头到Monitor监视器要说清楚synchronized得先回到JVM的对象内存布局。Java对象在堆内存里由三部分构成对象头Header、实例数据Instance Data、对齐填充Padding。真正和锁强相关的是对象头里的Mark Word。64位JVM下一个普通对象的Mark Word占8个字节64bit它是一块非常精打细算的动态数据结构。平时它存的是对象的hashCode、分代年龄一旦对象被synchronized盯上Mark Word的内容就会切换成锁记录指针、偏向线程ID之类的信息。同一个位置不同状态下含义完全不同——这是理解锁升级的地基。再看字节码层面。用javap -v反编译一段同步代码块你会看到monitorenter和两个monitorexit指令。为什么两个exit因为正常执行完要释放锁执行过程中抛了异常也得释放锁——所以编译器会给同步块加上异常表隐式生成一个异常路径的monitorexit。这也解释了为什么synchronized不用像ReentrantLock那样在finally里手动unlockJVM在字节码层面帮你兜底了。每个对象在JVM内部都关联着一个monitorsynchronized实际上就是去获取这个monitor的所有权。在HotSpot实现里monitor底层由C的ObjectMonitor对象承载里面有_owner持有锁的线程、_WaitSet调用wait的线程队列、_EntryList等待获取锁的线程队列等关键字段。所以synchronized的锁本质上是给对象头打个标记然后去竞争这个对象对应的ObjectMonitor。这里有个理解上的分水岭锁不是在方法上加的是在对象上加的。同一个对象被两个线程同时加锁才会产生竞争两个线程分别锁两个不同的对象哪怕锁的代码一模一样也互不相干。这个锁对象的概念直接引出下一节。2. 类锁和对象锁不是一回事修饰位置决定锁的目标很多初学者以为方法上加了synchronized就万事大吉其实锁的目标完全取决于synchronized修饰的位置。2.1 修饰实例方法锁的是this对象public synchronized void increase() { count; }这个写法等价于方法内部用this作为锁对象public void increase() { synchronized(this) { count; } }两个线程调用的是同一个实例的increase方法才会互斥。如果是两个不同实例锁的是各自的this互不影响。这在单体应用里还算安全但在某些场景会漏——比如Spring默认单例Bean只有一个实例所以锁this基本等价于全局锁但如果你自己new了两个对象去操作同一份共享数据锁就失效了。2.2 修饰静态方法锁的是Class对象public static synchronized void init() { // 类级别的初始化逻辑 }静态方法属于类所以锁对象是当前类的Class对象如OrderService.class。注意类锁和实例锁互不干扰。一个线程持有类锁时另一个线程依然可以访问这个类的synchronized实例方法。因为它们的monitor不同。2.3 修饰代码块可精确控制锁粒度synchronized(lockObject) { // 只锁这段逻辑 }这是最灵活的方式可以指定任意对象作为锁。但也最容易出问题——锁对象选不好后面全是坑。理解这个维度之后你才会明白为什么有些项目里会看到private static final Object LOCK new Object()这种写法用一个专门的、独立的、不可被外部引用的对象当锁既规避了锁this可能被外部锁干扰的问题又免去了类锁范围过大的代价。3. 锁升级全过程无锁、偏向锁、轻量级锁、重量级锁的演进逻辑HotSpot放弃早期版本所有同步都用重量级锁的方案后引入了一整套锁优化链路。整个升级方向是无锁 → 偏向锁 → 轻量级锁 → 重量级锁而且只能升级不能降级这也是面试高频考点。3.1 偏向锁只有一个线程访问时的极致优化场景假设绝大多数情况下一个同步代码块从头到尾只有同一个线程在访问。那每次都走monitor竞争流程太浪费了。偏向锁的思路是Mark Word里记录抢占到这个锁的线程ID。之后这个线程再来只要检查线程ID是不是自己是就直接执行同步代码CAS都不用做。什么时候撤销偏向锁另一个线程尝试获取锁时偏向模式就得退出。如果原持有锁的线程还活着且持锁偏向锁会升级成轻量级锁如果已不在同步块内可能被CAS重新偏向到新线程。注意JDK 15开始偏向锁被标记为废弃JDK 19正式禁用偏向锁。为什么偏向锁的撤销逻辑在大量竞争场景下反而带来了额外的暂停开销加上JVM团队发现现代应用普遍短生命周期收益已不显著。但现在面试还在考它因为锁升级的演进思想没变。3.2 轻量级锁多线程交替竞争时的自旋优化如果两个线程交替访问同步块没有同时抢偏向锁撤销后会升级为轻量级锁。此时Mark Word里记录的是指向线程栈中Lock Record锁记录的指针。这个阶段线程不直接阻塞而是通过CAS自旋去抢锁。抢不到就继续自旋一会儿再试。为什么要自旋因为线程阻塞和唤醒要进入内核态开销很大如果同步块执行非常快让线程等一会儿就能拿到锁比直接挂起划算得多。自旋不是无限的。JDK 6之后采用自适应自旋JVM根据同一锁上一次自旋锁等待时间、竞争线程数等因素动态调整自旋次数。我见过某些场景下JVM会把自旋调成很激进的状态严重消耗CPU——这是后话在优化章节再说。3.3 重量级锁真正的Monitor互斥自旋超过阈值还拿不到锁说明竞争确实激烈了。此时锁膨胀为重量级锁Mark Word变成指向ObjectMonitor的指针线程被挂起进入_EntryList队列靠操作系统互斥量实现阻塞和唤醒。这就是你在jstack看到的- waiting to lock 0x...状态的底层来源。重量级锁性能差的根源不在锁本身而在线程频繁阻塞/唤醒时的用户态到内核态切换。所以优化锁的第一步就是尽量让它别膨胀到这个阶段。3.4 逃逸分析与锁消除JIT编译时还会做逃逸分析。如果JVM判断一个对象只在单线程内使用、永远不会被其他线程访问那它上面的synchronized会被直接消除。public String concat(String a, String b) { StringBuffer sb new StringBuffer(); sb.append(a); // StringBuffer的方法都带synchronized sb.append(b); return sb.toString(); }这段代码里sb对象不向外逃逸JVM如果开启逃逸分析JDK 8默认开上面的同步等待都会被优化掉。这也是为什么局部变量用StringBuffer拼接不慢的原因。4. synchronized的实战大坑锁对象选错一切白搭理论知识说完了说几个我实际踩过的坑。这些坑的共同特征代码看起来没问题线上就是出诡异问题。4.1 String字面量做锁常量池的意外共享public class OrderService { private static final String LOCK order_lock; public void operate() { synchronized (LOCK) { ... } } }问题出在字符串常量池JVM里相同字面量的字符串是同一个对象。如果系统里有多个模块都用了order_lock这个锁对象原本互不相干的服务A和服务B会被意外地互相阻塞。更危险的是如果你用new String(order_lock)或者动态拼出来的字符串当锁每次锁对象可能不同锁又失效了。所以锁对象永远不要用字符串字面量。用专门的对象是最稳的private static final Object LOCK new Object();4.2 Integer做锁-128到127的缓存陷阱private static Integer lock 0; public void setLock(Integer value) { synchronized (lock) { lock value; // 修改共享数据 } }这个代码有个隐蔽问题如果把lock重新赋值下一次再来锁的就是另一个对象了原来锁对象上的等待线程和新的竞争根本对不上。再加上Integer在-128到127之间走缓存等于大量线程可能在同一个缓存对象上竞争。锁对象应该基本不可变最好final。4.3 锁的可见性没覆盖只锁了写没锁读private int count; public synchronized void increment() { count; } public int getCount() { return count; // 没有synchronized }读没加锁意味着读线程可能读到旧值。虽然synchronized块退出时会刷新到主存但读线程不进同步块无法保证感知到最新值。要么读也加锁要么直接用AtomicInteger或volatile辅助。4.4 锁粒度太大把无关操作也包进同步块经典反模式public synchronized void doSomething() { // 10行操作共享数据 // 50行调用RPC、查数据库、写日志、发邮件... }锁是互斥的同步块内的耗时代码越长竞争线程等得越久锁膨胀的概率越高。正确做法是缩小锁范围把共享可变数据的读写单独提出来加锁IO和耗时操作全部丢到锁外。4.5 重入别搞混synchronized是天然可重入的同一线程持锁后可以再次进入该锁保护的代码块。递归调用synchronized方法不会死锁public synchronized void process(int n) { if (n 0) { process(n - 1); // 同一线程重入没问题 } }这个机制基于monitor计数器每次monitorenter累加monitorexit递减归零才释放。这个特性让很多看起来会死锁的调用链其实安全——但注意如果涉及多个锁的交叉等待重入也救不了你。5. 锁升级与锁优化的实测心得从jstack看到的一切这部分分享一些排查锁问题时的实际操作经验。5.1 用jstack查看线程状态判断锁是否膨胀线程卡住时执行jstack -l pid看线程栈里相关行如果显示waiting to monitor说明线程在等待获取synchronized锁已经进入monitor竞争队列。如果大量线程处于RUNNABLE状态但CPU又没跑满配合top -H -p查看线程CPU占用通常说明存在自旋/轻量级锁竞争JVM在空转。这两种现象对应不同的优化方向前者要缩短临界区锁内逻辑后者要考虑减少锁竞争频率减少共享写操作或改用读写锁/分段思想。我之前遇到过一个高并发下单接口线上出现请求堆积但CPU不高的典型症状jstack一看70%线程堵在了一个synchronized方法的waiting to monitor上。根因是方法里做了一次Redis查询和一次远程库存校验整体耗时几十毫秒并发一上来锁内排队严重。优化方案很直白把远程校验挪到锁外锁内只保留对共享库存变量的更新接口RT从120ms降到30ms。5.2 锁竞争高了先从这三件事查起排查synchronized相关性能问题我习惯按顺序做三件事看锁的临界区大小锁内代码是否包含了IO、网络、序列化等耗时操作如果是先拆锁。看共享数据的访问频率是读多写少还是写多读多写少可以换ReentrantReadWriteLock甚至StampedLock但数据量不大时synchronized依然够用。看锁对象的生命周期锁对象是否被重新赋值是否被多个业务共享如果是换独立锁对象。5.3 自旋和偏向锁的JVM参数不是默认就好虽然偏向锁在JDK 15之后被废弃但很多线上还在跑JDK 8或11。如果压测中发现锁竞争激烈可以显式关闭偏向锁减少撤销的CLS停顿-XX:-UseBiasedLocking自旋参数在JDK 6之后默认自适应通常不用动。但如果你的服务器核数很多、并发很高可以观察自旋线程带来的CPU开销必要时通过-XX:PreBlockSpin控制自旋次数上限——不过这个参数在后续JDK版本里行为变化较大要结合具体版本测试不是无脑设。6. synchronized和其他同步方案的取舍不是全都要换成JUC很多人一聊到性能第一反应就是把synchronized换成ReentrantLock或换成CAS。这个观念需要纠正换方案的前提是你先搞清楚了瓶颈在哪。对比项synchronizedReentrantLockvolatileAtomicInteger锁类型JVM内置Monitor实现AQS实现需手动unlock无锁只保证可见性CAS无锁保证原子性是否可中断不可中断支持lockInterruptibly--是否公平非公平可指定公平/非公平--适用场景代码块短、竞争适中、写法最简需要超时控制、可中断、多条件队列单一变量写读、状态标志计数器、累加器、自增字段我的实践经验是临界区很短、竞争强度一般无脑用synchronized。它由JVM管不会出现忘了释放锁的问题写起来简洁。需要尝试拿锁、拿不到就走其他逻辑tryLock带超时才需要考虑ReentrantLock。单个共享变量的状态切换比如开关标志、任务状态流转优先想volatile是否够用。多个变量的一致性更新设计上应优先考虑用不可变对象或原子引用组合而不是盲目加大锁范围。另外补充一点synchronized在JDK 6之后做了大量优化很多场景下它和ReentrantLock的性能差距很小甚至因为偏向锁、锁消除等机制短临界区场景下synchronized反而有优势。纠结性能前先用压测数据说话别靠直觉重构代码。7. 写在最后的几个习惯生产环境里我怎么做锁说了这么多原理和坑最后落地成实操习惯。我在生产代码里处理synchronized相关逻辑时有几条铁律锁对象永远选不可变且专用的。要么是private static final Object LOCK new Object()要么是当前类的Class对象。绝不复用String字面量、Integer缓存值或可变的业务对象。锁的范围能小就小。写代码时先想清楚哪些变量必须互斥操作把这些变量的读写扣出来加锁其他一律不包。看到synchronized修饰整个方法体且方法里有IO逻辑基本可以判定需要优化。加锁的代码不要写复杂逻辑。锁内最好只有几行内存操作。如果锁内需要调用外部方法极容易在不知情的情况下引入慢逻辑。建议锁内不调用任何远程接口、不打印大日志、不做序列化。压测时观察锁膨胀。我用JMH做微基准测试时会分别测低竞争单线程、中等竞争4线程、高竞争32线程三档看synchronized方案和Lock方案在不同档位的表现。结论往往和直觉不同——有些场景Lock优势明显另一些场景synchronized更快。拿数据说话不迷信任何框架。synchronized看起来是个入门级关键字但深入下去它牵扯着JVM对象布局、指令级同步、操作系统内核调度。Thread学习系列走到这一篇本质上是在帮我们把并发安全这层窗户纸捅破。理解了锁的底层逻辑之后再回头看业务代码里那些看起来差不多能用的加锁写法你会发现自己能看到另一层问题。下一篇可以接着聊volatile和内存屏障这两者是理解整个Java并发模型的最后一块拼图。