锁与Synchronized
开篇:多线程的核心矛盾——共享资源
两个人同时编辑同一份文档,A 在第三段加了一句话,B 把第三段删了。最后文档变成什么样?谁也说不准。
这就是多线程的核心矛盾:多个线程同时读写同一份共享数据,结果不可预测。
解决方案很朴素:排队。你改完了我再改——这就是"锁"。
一、synchronized 关键字
synchronized 是 Java 内置的锁机制,也是最常用的同步手段。
1.1 三种用法
修饰实例方法:锁的是当前对象(this)
public synchronized void method() {
// 同一个对象实例的多个线程互斥
}修饰静态方法:锁的是 Class 对象
public static synchronized void method() {
// 整个类的所有实例共享同一把锁
}修饰代码块:锁的是括号里指定的对象
// 锁实例对象
synchronized (this) {
// ...
}
// 锁 Class 对象
synchronized (MyClass.class) {
// ...
}对象锁 vs 类锁:一个类只有一个 Class 对象,所以类锁全局唯一;而每个实例有自己的对象锁。如果多个线程操作的是不同的实例,对象锁之间互不干扰。
举个例子:10 个线程各自 new 一个 Runnable,在其中调用 synchronized(this) 方法——因为锁的是不同的 this 对象,所以没有互斥效果。换成 synchronized(MyClass.class) 才能真正互斥。
1.2 底层原理:Monitor
synchronized 的本质是对**监视器锁(Monitor)**的获取和释放。
同步代码块在字节码层面使用 monitorenter 和 monitorexit 指令:
- 执行
monitorenter时尝试获取 Monitor 锁,获取成功则计数器 +1 - 执行
monitorexit时计数器 -1,减到 0 则释放锁 - 如果获取失败,线程进入阻塞状态(BLOCKED),直到锁被释放
同步方法在字节码中会有 ACC_SYNCHRONIZED 标志。JVM 检测到这个标志后,会自动在方法前后加上获取/释放锁的逻辑。
Monitor 的内部结构(HotSpot 中的 ObjectMonitor)有几个关键属性:
_owner:当前持有锁的线程_EntryList:等待获取锁的线程队列_WaitSet:调用了 wait() 的线程队列_recursions:锁的重入次数_count:获取锁的次数
当线程调用 wait() 时,会释放锁并进入 _WaitSet。被 notify() 唤醒后进入 _EntryList 竞争锁。
1.3 synchronized 如何保证三大特性
原子性:通过 Monitor 的互斥性保证。线程拿到锁后,即使时间片耗尽,它也不会释放锁(只是暂停执行)。其他线程拿不到锁就无法执行临界区代码,所以被 synchronized 保护的代码块在逻辑上是原子的。
可见性:JMM 规定,对一个变量解锁之前,必须先把变量同步回主内存。所以当一个线程释放锁后,另一个线程获取锁时一定能看到最新的值。
有序性:synchronized 本身不能禁止指令重排。但由于同一时间只有一个线程执行临界区代码,单线程下的 as-if-serial 语义保证了有序性。
二、锁升级:从偏向锁到重量级锁
在 JDK 1.6 之前,synchronized 直接使用 Monitor(重量级锁),需要操作系统从用户态切换到内核态,开销很大。
JDK 1.6 引入了偏向锁和轻量级锁,让锁可以根据竞争程度逐步升级,在低竞争场景下大幅降低开销。
2.1 偏向锁:VIP 会员卡
想象一个健身房,早上总是同一个人第一个来。如果每次都要验证身份、刷卡进门,效率太低了。不如给他发一张 VIP 卡,以后看到是他就直接放行。
偏向锁就是这个思路:当一个锁总是被同一个线程获取时,JVM 在对象头中记录这个线程的 ID,后续该线程再次获取锁时只需要简单比较 ID,不需要做任何同步操作。
触发条件:第一次获取 synchronized 锁时自动开启(JVM 未禁用偏向锁的前提下)。
JDK 15 中偏向锁已被废弃(JEP 374)。原因是现代应用很少使用 Hashtable、Vector 这类内部用 synchronized 的旧集合类,偏向锁的收益越来越小,但它给 JVM 增加了大量代码复杂度,得不偿失。
2.2 轻量级锁:CAS 自旋
当第二个线程尝试获取锁时,偏向锁升级为轻量级锁。
轻量级锁的工作方式:
- 把对象头中的 Mark Word 复制到线程栈中的锁记录(Lock Record)
- 通过 CAS 操作尝试把对象头的 Mark Word 更新为指向锁记录的指针
- 如果 CAS 成功,获取锁成功
- 如果 CAS 失败,说明有其他线程在竞争,开始自旋——循环尝试获取
自旋不会让线程阻塞,而是让 CPU "空转"一小会儿,等锁释放后立即获取。好处是避免了线程切换的开销;坏处是如果锁一直不释放,CPU 白白浪费。
JDK 6 引入了适应性自旋:JVM 会根据之前自旋的成功率动态调整自旋次数。如果上次自旋很快就拿到锁了,下次就多转几圈;如果上次转了半天也没拿到,下次就少转或直接升级。
2.3 重量级锁:操作系统级别的互斥
当自旋多次失败后,锁膨胀为重量级锁。此时 JVM 会创建一个 Monitor 对象,竞争失败的线程进入阻塞状态,等待操作系统调度唤醒。
重量级锁虽然"重",但在高竞争场景下是必要的。因为自旋会持续消耗 CPU,如果竞争激烈,不如让线程挂起,把 CPU 让给有用的工作。
2.4 升级过程
在 Java 对象头的 Mark Word 中,用低位来标识锁状态:
| 锁状态 | 标志位 | 说明 |
|---|---|---|
| 无锁 | 01(偏向位=0) | 对象刚创建 |
| 偏向锁 | 01(偏向位=1) | 记录了偏向线程 ID |
| 轻量级锁 | 00 | Mark Word 指向栈中锁记录 |
| 重量级锁 | 10 | Mark Word 指向 Monitor 对象 |
注意:synchronized 的锁只能升级,不能降级(HotSpot 实现)。一旦升级到重量级锁,即使竞争消失了也不会自动降回轻量级锁。但有一个特殊情况:在 STW(Stop-the-World)暂停期间,JVM 会清理未被使用的 Monitor 对象,这个过程叫 deflation。
2.5 其他锁优化
除了锁升级,JVM 还有几种优化手段:
锁消除:JIT 编译器通过逃逸分析发现锁对象不可能被其他线程访问到,就会把锁直接去掉。比如在方法内 new Object() 然后 synchronized(obj) ——这个对象不可能逃逸到方法外,加锁没有意义。
锁粗化:如果连续对同一个对象加锁解锁(比如循环中每次迭代都加锁),JIT 会把锁的范围扩大到整个循环外面,减少加锁解锁的次数。
// 粗化前
for (int i = 0; i < 10000; i++) {
synchronized(this) { doSomething(); }
}
// 粗化后
synchronized(this) {
for (int i = 0; i < 10000; i++) { doSomething(); }
}三、ReentrantLock vs synchronized
ReentrantLock 是 java.util.concurrent.locks 包提供的显式锁,和 synchronized 功能类似但更灵活。
| 对比项 | synchronized | ReentrantLock |
|---|---|---|
| 加锁方式 | 自动获取/释放 | 手动 lock()/unlock() |
| 锁类型 | 非公平锁 | 可选公平/非公平 |
| 可中断 | 不支持 | lockInterruptibly() 支持 |
| 超时获取 | 不支持 | tryLock(timeout) 支持 |
| 条件变量 | 只有一个(wait/notify) | 可以有多个 Condition |
| 可重入 | 是 | 是 |
| 实现层级 | JVM 内置(字节码) | Java 代码(AQS) |
ReentrantLock 的基本用法:
private final Lock lock = new ReentrantLock();
public void doSomething() {
lock.lock();
try {
// 临界区代码
} finally {
lock.unlock(); // 必须在 finally 中释放!
}
}公平锁 vs 非公平锁
Lock fairLock = new ReentrantLock(true); // 公平锁
Lock unfairLock = new ReentrantLock(false); // 非公平锁(默认)- 公平锁:线程按照请求锁的顺序排队,先到先得。优点是不会饿死;缺点是吞吐量低,因为需要频繁唤醒阻塞线程。
- 非公平锁:线程可以"插队"——新来的线程先尝试 CAS 抢锁,抢不到再排队。优点是吞吐量高;缺点是可能导致某些线程长时间等不到锁。
可重入性
synchronized 和 ReentrantLock 都支持可重入。同一个线程可以多次获取同一把锁,内部用计数器记录重入次数,每 unlock 一次计数器减 1,减到 0 才真正释放锁。
public synchronized void method1() {
method2(); // 可重入:同一线程可以再次获取同一把锁
}
public synchronized void method2() {
System.out.println("method2");
}四、读写锁简介
普通的互斥锁(synchronized、ReentrantLock)不区分读和写——即使都是读操作也要排队。但读操作之间其实不需要互斥,只有写操作才需要排他。
4.1 ReadWriteLock
ReentrantReadWriteLock 提供了读锁和写锁的分离:
- 读锁(共享锁):多个读线程可以同时持有
- 写锁(排他锁):只有一个线程可以持有,且与读锁互斥
ReadWriteLock rwLock = new ReentrantReadWriteLock();
// 读操作
rwLock.readLock().lock();
try {
// 多个线程可以同时读
} finally {
rwLock.readLock().unlock();
}
// 写操作
rwLock.writeLock().lock();
try {
// 只有一个线程可以写
} finally {
rwLock.writeLock().unlock();
}读写锁支持锁降级(持有写锁的线程可以同时获取读锁,然后释放写锁),但不支持锁升级(持有读锁的线程不能直接获取写锁)。
问题:如果读操作远多于写操作,写线程可能一直抢不到锁——"写饥饿"。
4.2 StampedLock(邮戳锁)
JDK 8 引入的 StampedLock 进一步优化了读写锁。它引入了乐观读的概念:
StampedLock sl = new StampedLock();
// 乐观读:不加锁,读完后验证
long stamp = sl.tryOptimisticRead();
int value = sharedData; // 读取共享数据
if (!sl.validate(stamp)) {
// 验证失败,说明中间有写操作,升级为悲观读
stamp = sl.readLock();
try {
value = sharedData;
} finally {
sl.unlockRead(stamp);
}
}乐观读的好处:读的时候不阻塞写线程,解决了写饥饿问题。代价是读完后需要验证,如果验证失败就要重读。
StampedLock 的限制:
- 不支持重入
- 不支持 Condition
- 不能调用 interrupt()(会导致 CPU 飙升)
五、各种锁的分类
Java 中的锁可以从多个维度分类:
| 分类维度 | 类型 | 说明 |
|---|---|---|
| 乐观/悲观 | 乐观锁 | CAS,适合读多写少 |
| 悲观锁 | synchronized、ReentrantLock,适合写多 | |
| 公平/非公平 | 公平锁 | 按申请顺序获取 |
| 非公平锁 | 允许插队(默认) | |
| 可重入/不可重入 | 可重入锁 | 同一线程可以多次获取同一把锁 |
| 排他/共享 | 排他锁(写锁) | 只能一个线程持有 |
| 共享锁(读锁) | 多个线程可以同时持有 | |
| 实现方式 | 阻塞锁 | 获取失败则挂起线程 |
| 自旋锁 | 获取失败则循环重试 |
六、常见面试题精选
Q1:synchronized 和 ReentrantLock 的区别?
最核心的区别:synchronized 是 JVM 内置的,自动加锁释放锁;ReentrantLock 是 Java 代码实现的(基于 AQS),需要手动 lock/unlock。ReentrantLock 支持公平锁、可中断、超时获取、多条件变量等高级功能。
Q2:synchronized 锁的是什么?
不管哪种用法,synchronized 最终锁的都是对象。实例方法锁 this,静态方法锁 Class 对象,代码块锁括号中的对象。
Q3:synchronized 是非公平锁吗?
是的。JVM 在管理等待锁的线程时不遵循先来先服务原则。一个刚请求锁的线程可能在等待更久的线程之前获得锁。这是出于性能考虑——非公平锁的吞吐量通常更高。
Q4:为什么 JDK 15 要废弃偏向锁?
三个原因:(1) 现代应用很少使用 Hashtable/Vector 等旧集合,偏向锁收益减少;(2) 偏向锁撤销需要在 safepoint 进行,高并发时反而影响性能;(3) 偏向锁给 JVM 源码带来了巨大的复杂度,维护成本太高。
Q5:什么是可重入锁?为什么需要可重入?
可重入锁允许同一个线程多次获取同一把锁。如果锁不可重入,线程调用一个 synchronized 方法 A,A 内部又调用另一个 synchronized 方法 B(同一把锁),就会死锁。可重入机制通过内部计数器解决了这个问题。
Q6:有了 synchronized 为什么还需要 volatile?
两个原因:(1) synchronized 是重量级操作,有性能开销和阻塞问题;volatile 是轻量级的,性能更好。(2) volatile 能禁止指令重排,这是 synchronized 做不到的。最经典的例子就是 DCL 单例——不加 volatile 可能拿到未初始化完成的对象。
小结
- synchronized 是 Java 内置的锁机制,底层基于 Monitor,支持三种用法(实例方法、静态方法、代码块)
- 锁升级:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁,根据竞争程度自动升级(不可降级)
- ReentrantLock 比 synchronized 更灵活,支持公平锁、可中断、超时获取、多条件变量
- 读写锁 把读和写分离,读读不互斥;StampedLock 进一步引入乐观读
- 锁的选择:简单场景用 synchronized(JVM 会自动优化),需要高级功能用 ReentrantLock,读多写少用读写锁
附录:深入理解锁的实现
synchronized 的完整实现细节
我们前面说了 synchronized 基于 Monitor,那 Monitor 在 JVM 中到底是怎么实现的?
HotSpot 中 Monitor 是通过 C++ 的 ObjectMonitor 类实现的。它的获取锁流程大致如下:
- 通过 CAS 尝试把 Monitor 的
_owner设为当前线程 - 如果成功,获取锁成功
- 如果失败,检查
_owner是不是当前线程(可重入判断) - 如果是,
_recursions加 1(重入计数) - 如果不是,进入
_EntryList等待队列,线程被挂起
释放锁的流程:
_recursions减 1- 如果减到 0,释放锁(
_owner置空),从等待队列中唤醒一个线程 - 如果没减到 0,说明是重入的解锁,只减计数不释放
这就解释了为什么 synchronized 是可重入的——内部有计数器,同一线程每次加锁计数器加 1,解锁时减 1,减到 0 才真正释放。
Monitor 的三个区域
可以把 Monitor 想象成一栋大楼:
+------------------+
| 特殊房间 (Owner) | <-- 同时只能有一个线程在里面
+------------------+
| 走廊 (EntryList) | <-- 等待获取锁的线程排在这里
+------------------+
| 等待室 (WaitSet) | <-- 调用了 wait() 的线程在这里等通知
+------------------+- 线程想进入特殊房间,先在走廊排队
- 调度器从走廊选一个线程进入房间
- 如果线程在房间里调用了
wait(),它会被送到等待室,同时释放房间 - 其他线程调用
notify()后,等待室中的一个线程被送回走廊重新排队
锁升级的 Mark Word 变化
Java 对象头中的 Mark Word 会随着锁状态变化而变化。在 64 位 JVM 中,Mark Word 占 8 字节:
无锁状态: | hashcode(31) | age(4) | biased(1) | lock(2)=01 |
偏向锁状态: | threadId(54) | epoch(2)| age(4) | biased(1)=1| lock(2)=01 |
轻量级锁状态: | ptr_to_lock_record(62) | lock(2)=00 |
重量级锁状态: | ptr_to_monitor(62) | lock(2)=10 |可以看到,不同锁状态下 Mark Word 存储的内容完全不同:
- 无锁时存 hashcode
- 偏向锁时存线程 ID
- 轻量级锁时存指向栈中锁记录的指针
- 重量级锁时存指向 Monitor 的指针
这也解释了为什么计算了 hashcode 的对象不能使用偏向锁——Mark Word 的空间被 hashcode 占了。
重量级锁为什么慢但仍然必要
有人问:重量级锁这么慢,为什么还要用?
其实 synchronized 本身就是重量级锁,偏向锁和轻量级锁是后来为了优化低竞争场景引入的。在高竞争场景下,重量级锁是唯一正确的选择。
打个比方:你去买煎饼果子,只有一个窗口。如果只有你一个人,直接买就行(偏向锁)。如果前面有两三个人,你等一会儿就行(自旋/轻量级锁)。如果前面排了 100 人,你总不能一直站着等吧——得坐下来休息,等叫号(重量级锁/阻塞)。
在竞争激烈时:
- 轻量级锁的自旋会白白消耗 CPU
- 重量级锁让线程挂起,把 CPU 让给有用的工作
- 虽然唤醒线程有开销,但总比 CPU 空转强
ReentrantLock 的高级用法
tryLock:非阻塞获取锁
Lock lock = new ReentrantLock();
if (lock.tryLock()) {
try {
// 获取锁成功,执行操作
} finally {
lock.unlock();
}
} else {
// 获取锁失败,执行备选方案
System.out.println("没拿到锁,做别的事");
}tryLock(timeout):带超时的获取
if (lock.tryLock(5, TimeUnit.SECONDS)) {
try {
// 5 秒内获取到锁
} finally {
lock.unlock();
}
} else {
// 5 秒内没获取到
}lockInterruptibly:可中断的获取
try {
lock.lockInterruptibly();
try {
// 获取锁成功
} finally {
lock.unlock();
}
} catch (InterruptedException e) {
// 等待锁的过程中被中断
System.out.println("等锁时被中断了");
}Condition:条件变量
synchronized 只有一个等待队列(wait/notify),ReentrantLock 可以有多个。
Lock lock = new ReentrantLock();
Condition notEmpty = lock.newCondition();
Condition notFull = lock.newCondition();
// 生产者
lock.lock();
try {
while (isFull()) {
notFull.await(); // 队列满了,等待
}
produce();
notEmpty.signal(); // 通知消费者
} finally {
lock.unlock();
}
// 消费者
lock.lock();
try {
while (isEmpty()) {
notEmpty.await(); // 队列空了,等待
}
consume();
notFull.signal(); // 通知生产者
} finally {
lock.unlock();
}公平锁的实现原理
ReentrantLock 的公平锁和非公平锁在源码层面有什么区别?
非公平锁的 lock:先直接 CAS 抢锁,抢到了直接用;抢不到再进 AQS 队列排队。
// NonfairSync
final void lock() {
if (compareAndSetState(0, 1)) // 先插队尝试
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1); // 失败再排队
}公平锁的 lock:直接进 AQS 队列排队,不尝试插队。
// FairSync
final void lock() {
acquire(1); // 直接排队,不尝试插队
}
protected final boolean tryAcquire(int acquires) {
// 多了一个 hasQueuedPredecessors() 判断
if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
// ...
}关键就在 hasQueuedPredecessors():它会检查 AQS 队列中是否有线程在前面排队。如果有,即使当前锁是空闲的,也不会直接获取,而是老老实实排队。
读写锁的锁降级详解
读写锁支持锁降级(写锁降为读锁),但不支持锁升级(读锁升为写锁)。
锁降级的正确步骤:
- 获取写锁
- 修改数据
- 在释放写锁之前获取读锁
- 释放写锁
- 使用数据
- 释放读锁
为什么要在释放写锁之前获取读锁?如果先释放写锁再获取读锁,中间可能有其他线程获取了写锁并修改了数据,导致当前线程读到的数据不一致。
为什么不支持锁升级?如果允许,两个读线程同时持有读锁,然后都想升级为写锁——就死锁了(互相等对方释放读锁)。
StampedLock 的乐观读详解
StampedLock 的乐观读是如何工作的?
StampedLock sl = new StampedLock();
long stamp = sl.tryOptimisticRead(); // 获取一个"邮戳"
// 读取共享数据(不加锁)
int x = sharedX;
int y = sharedY;
// 验证邮戳:如果中间没有写操作,邮戳有效,返回 true
if (sl.validate(stamp)) {
// 读取成功,直接使用 x 和 y
} else {
// 验证失败:中间有写操作发生,升级为悲观读
stamp = sl.readLock();
try {
x = sharedX;
y = sharedY;
} finally {
sl.unlockRead(stamp);
}
}乐观读的原理:每次写操作都会改变 StampedLock 内部的版本号。tryOptimisticRead() 记录当前版本号,validate() 检查版本号是否变化。如果没变,说明中间没有写操作,读到的数据是有效的。
这种方式在读远多于写的场景下性能极好,因为读操作完全不阻塞写操作。
无锁化编程
锁虽然能解决线程安全问题,但也带来了性能开销。无锁化编程通过原子操作(CAS)来实现线程安全,避免了锁的开销。
Java 的 java.util.concurrent.atomic 包提供了丰富的原子类:
| 类 | 作用 |
|---|---|
| AtomicInteger / AtomicLong | 原子整型/长整型 |
| AtomicBoolean | 原子布尔 |
| AtomicReference | 原子引用 |
| AtomicStampedReference | 带版本号的原子引用(解决 ABA) |
| LongAdder | 高并发下的计数器(比 AtomicLong 更快) |
无锁编程的优势:
- 减少上下文切换
- 提高并发性
- 避免死锁
但无锁并非万能——在竞争激烈时,CAS 的自旋会消耗大量 CPU。所以要根据竞争程度选择合适的方案。
选择锁的决策树
是否需要同步?
├── 不需要 → 无锁(不可变对象/ThreadLocal/栈封闭)
└── 需要 →
├── 读多写少?
│ ├── 是 → ReadWriteLock / StampedLock
│ └── 否 →
│ ├── 需要高级功能(公平/可中断/超时/多条件)?
│ │ ├── 是 → ReentrantLock
│ │ └── 否 →
│ │ ├── 简单计数/标志?
│ │ │ ├── 是 → AtomicXxx / volatile
│ │ │ └── 否 → synchronized
│ └──
└──最后记住一句话:能不加锁就不加锁,必须加锁时选最轻的锁。