☰
synchronized锁升级全解析:从对象头到Monitor再到实战避坑
2026/10/4 10:48:11 网站建设 项目流程

很多人写了三五年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里手动unlock,JVM在字节码层面帮你兜底了。

每个对象在JVM内部都关联着一个monitor,synchronized实际上就是去获取这个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相关性能问题,我习惯按顺序做三件事:

  1. 看锁的临界区大小:锁内代码是否包含了IO、网络、序列化等耗时操作?如果是,先拆锁。
  2. 看共享数据的访问频率:是读多写少还是写多?读多写少可以换ReentrantReadWriteLock甚至StampedLock,但数据量不大时,synchronized依然够用。
  3. 看锁对象的生命周期:锁对象是否被重新赋值?是否被多个业务共享?如果是,换独立锁对象。

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并发模型的最后一块拼图。

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

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

立即咨询