synchronized锁升级过程:面试必问的Java并发核心机制
前言
提起Java并发编程,synchronized绝对是面试中的"常驻嘉宾"。不管是一面、二面还是三面,总有面试官会问:"synchronized是怎么实现的?锁升级过程是怎样的?"
很多人只会背"无锁→偏向锁→轻量级锁→重量级锁"这四个词,一旦追问细节就卡壳了。今天我们就来彻底搞懂synchronized的锁升级过程,不仅要知其然,更要知其所以然。
一、为什么需要锁升级?
在说锁升级之前,先思考一个问题:为什么synchronized要设计成可升级的?
想象一个场景:
- 代码块99%的时间只有一个线程访问
- 只有1%的时间会有多线程竞争
如果一开始就直接加重量级锁(操作系统Mutex),每次加锁都需要用户态到内核态的切换,开销巨大。但大部分时间根本不需要这么重的锁!
所以JVM的设计思路是:先用最轻量的方式加锁,发现竞争激烈了再逐步升级。这样既能保证性能,又能确保线程安全。
二、synchronized的锁状态
synchronized有四种锁状态,只能升级,不能降级:
无锁 → 偏向锁 → 轻量级锁 → 重量级锁
这四种状态是通过对象头的Mark Word来标识的:
| 锁状态 | Mark Word存储内容 | 标志位 |
|---|---|---|
| 无锁 | 对象哈希码、GC年龄 | 01 |
| 偏向锁 | 偏向线程ID、偏向时间戳 | 01 |
| 轻量级锁 | 指向栈中锁记录的指针 | 00 |
| 重量级锁 | 指向互斥量(Mutex)的指针 | 10 |
三、偏向锁:无竞争时的极致优化
3.1 什么是偏向锁?
偏向锁的核心思想:"这个锁被我承包了"。
当一个线程第一次访问同步块时,会在对象头的Mark Word中记录下自己的线程ID。之后这个线程再次进入同步块时,只需要判断Mark Word中的线程ID是不是自己,连CAS都不需要做。
java
// 伪代码演示偏向锁的工作流程
if (mark_word.has_biased_lock()) {
// 已经有偏向锁了
if (mark_word.get_thread_id() == current_thread) {
// 是同一个线程,直接进入同步块
return;
} else {
// 有竞争,撤销偏向锁,升级为轻量级锁
revoke_biased_lock();
inflate_to_lightweight_lock();
}
} else {
// 无锁状态,尝试偏向当前线程
if (mark_word.cas_bias(current_thread_id)) {
// 偏向成功,记录thread_id
return;
}
}
3.2 偏向锁的撤销
偏向锁虽好,但不是所有场景都适用。以下情况会撤销偏向锁:
- 其他线程竞争:当有其他线程尝试获取同一个锁时,偏向锁被撤销
- 批量重偏向:当一个线程创建了大量对象并偏向,但另一个线程去使用这些对象时,会触发批量重偏向
- 批量撤销:如果偏向锁撤销次数过多(默认20次),JVM会认为这个类不适合偏向,批量撤销
3.3 偏向锁的开启与关闭
偏向锁在JDK 6中是默认开启的,但可能在高并发场景下反而成为负担。如果你的系统并发度很高,可以选择关闭:
bash
# 关闭偏向锁(启动参数)
-XX:-UseBiasedLocking
# 延迟开启偏向锁(项目启动初期关闭,等warm up后再开启)
-XX:BiasedLockingStartupDelay=4000 # 4秒后开启
四、轻量级锁:自适应自旋
4.1 轻量级锁的获取
当有线程竞争偏向锁时,偏向锁会升级为轻量级锁。升级过程如下:
- 线程在栈帧中创建锁记录(Lock Record)
- 将Mark Word复制到锁记录中
- 使用CAS尝试将对象头的Mark Word指向栈帧中的锁记录
java
// 轻量级锁获取伪代码
public void lock(Object obj) {
// 在当前线程的栈帧中创建锁记录
LockRecord lockRecord = new LockRecord();
lockRecord.setMarkWord(obj.getMarkWord());
// CAS尝试将对象头指向当前线程的锁记录
if (obj.getMarkWord().casSetMarkWord(lockRecord)) {
// 加锁成功!
return;
}
// CAS失败,说明有竞争,尝试自旋等待
int spinCount = 0;
while (true) {
if (obj.getMarkWord().casSetMarkWord(lockRecord)) {
return;
}
spinCount++;
if (spinCount > MAX_SPIN_COUNT) {
// 自旋次数过多,升级为重量级锁
inflate_to_heavyweight_lock();
return;
}
// CPU空转等待
for (int i = 0; i < 10; i++) {
// 自旋等待
}
}
}
4.2 轻量级锁的解锁
解锁时,使用CAS将Mark Word恢复回去:
java
public void unlock(Object obj) {
LockRecord lockRecord = findCurrentThreadLockRecord();
if (lockRecord.getMarkWord() == obj.getMarkWord()) {
// 尝试用CAS恢复Mark Word
obj.setMarkWord(lockRecord.getOriginalMarkWord());
} else {
// 说明有竞争,释放锁并唤醒等待线程
release_and_notify_waiters();
}
}
4.3 自旋锁的优化:自适应自旋
固定自旋次数有个问题:自旋次数太多会浪费CPU,自旋次数太少又会在真正需要等待时放弃。
JDK 6引入了自适应自旋(Adaptive Spinning):
- 如果同一个锁上次自旋等待成功了,下次自旋次数会增加
- 如果一个锁很少自旋成功,可能直接就不自旋了
- 这种"自适应"让JVM能够根据实际运行情况动态调整
五、重量级锁:操作系统层面的互斥
5.1 为什么需要重量级锁?
当自旋次数过多,或者竞争非常激烈时,轻量级锁会升级为重量级锁。重量级锁会让线程进入阻塞(Blocked)状态,由操作系统负责线程调度。
关键点:重量级锁的加解锁不需要CPU忙等待,而是让线程睡眠等待,避免了无意义的CPU消耗。
5.2 Monitor对象
在JVM中,重量级锁通过**Monitor(监视器锁)**实现。每个Java对象都可以关联一个Monitor:
Monitor结构:
├── Owner:记录拥有锁的线程
├── WaitSet:调用wait()方法后等待的线程队列
├── EntryList:等待获取锁的线程队列(阻塞队列)
└── Recursion:重入次数计数
5.3 重量级锁的工作流程
java
// 进入monitorenter指令对应的逻辑
public void monitorenter(Object obj) {
Monitor monitor = obj.getMonitor();
if (monitor.owner == null) {
// 没有人持有锁,当前线程获取
monitor.owner = current_thread;
monitor.recursion = 1;
} else if (monitor.owner == current_thread) {
// 重入!计数器+1
monitor.recursion++;
} else {
// 被其他线程持有,当前线程阻塞等待
current_thread.block();
monitor.entryList.add(current_thread);
}
}
// 退出monitorexit指令对应的逻辑
public void monitorexit(Object obj) {
Monitor monitor = obj.getMonitor();
if (monitor.recursion > 1) {
monitor.recursion--;
} else {
monitor.owner = null;
monitor.recursion = 0;
// 唤醒一个等待线程
if (!monitor.entryList.isEmpty()) {
Thread next = monitor.entryList.removeFirst();
next.unpark(); // 唤醒线程
}
}
}
六、锁升级的实际案例
案例:Spring Bean初始化的并发问题
java
@Service
public class UserService {
private UserDao userDao;
@Autowired
public void setUserDao(UserDao userDao) {
// 这里可能存在并发问题
this.userDao = userDao;
}
}
在Spring容器初始化时,如果有多个Bean同时引用UserService并设置依赖,可能会触发锁升级过程:
- 初始状态:无锁状态
- 第一个线程:对象头Mark Word设置为偏向锁,指向第一个线程
- 第二个线程竞争:偏向锁撤销 → 升级为轻量级锁 → 自旋等待
- 竞争加剧:轻量级锁膨胀 → 重量级锁 → 线程阻塞
性能对比
| 锁类型 | 适用场景 | 加锁开销 | 解锁开销 | CPU消耗 |
|---|---|---|---|---|
| 偏向锁 | 单线程、无竞争 | 最快(仅判断) | 最快 | 无 |
| 轻量级锁 | 少量线程、短暂竞争 | 较快(CAS) | 较快(CAS) | 少量自旋 |
| 重量级锁 | 多线程、激烈竞争 | 慢(系统调用) | 慢(系统调用) | 无(阻塞) |
七、实战建议
7.1 如何选择合适的锁策略
java
// 场景1:单线程使用,完全无竞争
// 偏向锁最优
public class SingleThreadCache {
private final Map<String, Object> cache = new HashMap<>();
public synchronized Object get(String key) {
return cache.get(key);
}
}
// 场景2:短时并发,竞争不激烈
// 轻量级锁 + 自旋优化
public class ShortLockExample {
private final AtomicInteger counter = new AtomicInteger();
public void increment() {
// 内部使用轻量级锁优化
counter.incrementAndGet();
}
}
// 场景3:高并发、长时间持锁
// 重量级锁,让线程睡眠
public class HeavyLockExample {
private final ReentrantLock lock = new ReentrantLock();
private final List<String> list = new ArrayList<>();
public void processBatch(List<String> batch) {
lock.lock();
try {
// 耗时操作
list.addAll(batch);
Thread.sleep(1000); // 模拟耗时
} finally {
lock.unlock();
}
}
}
7.2 减少锁粒度
java
// 不推荐:粗粒度锁,整个集合锁住
public class BadExample {
private final Map<String, Object> map = new ConcurrentHashMap<>();
public synchronized void putAll(Map<String, Object> newMap) {
map.putAll(newMap); // 长时间持锁
}
}
// 推荐:分段锁(ConcurrentHashMap原理)
public class GoodExample {
private final Map<String, Object> map = new ConcurrentHashMap<>();
public void putAll(Map<String, Object> newMap) {
// 分段put,每次只锁一个桶
newMap.forEach((key, value) -> {
map.put(key, value);
});
}
}
7.3 JVM参数调优建议
bash
# 高并发场景
-XX:+UsebiasedLocking=false # 关闭偏向锁
-XX:PreBlockSpin=10 # 自旋次数(JDK 6用)
-XX:+UseCondCardMark # 减少伪共享
# 追求低延迟
-XX:+UseG1GC # G1收集器
-XX:MaxGCPauseMillis=200 # 最大GC停顿
# 大内存场景
-XX:+UseZGC # ZGC低延迟收集器
八、总结
synchronized的锁升级机制是JVM的自适应优化策略 ,核心目标是用最轻量的方式处理无竞争,用最稳妥的方式处理激烈竞争:
- 偏向锁:单线程独享,同一线程反复进入同步块时几乎零开销
- 轻量级锁:少量线程竞争时,通过CAS + 自旋避免线程切换
- 重量级锁:激烈竞争时,让线程真正睡眠,由OS调度
理解这个机制,不仅能帮助我们在面试中回答好问题,更能在实际工作中写出更高性能的并发代码。
记住:锁不是洪水猛兽,选对锁策略就能鱼与熊掌兼得。