很多人写了三五年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相关性能问题,我习惯按顺序做三件事:
- 看锁的临界区大小:锁内代码是否包含了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"。这个观念需要纠正:换方案的前提是你先搞清楚了瓶颈在哪。
| 对比项 | synchronized | ReentrantLock | volatile | AtomicInteger |
|---|---|---|---|---|
| 锁类型 | 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并发模型的最后一块拼图。