Java 锁全景:该用哪把锁、各自有哪些方法
上一单元把 volatile、CAS、ABA 讲完了,但那些都是针对单个变量的。业务里天天要处理的是另一类问题:一段代码必须互斥执行,一组字段必须保持一致 —— 这种时候靠的是锁。
麻烦在于 Java 的锁不止一把。synchronized、ReentrantLock、ReentrantReadWriteLock、StampedLock,名字里都带锁,但方法名不一样、加解锁方式不一样、能用的场景也不一样。面试官问「你平时用哪个锁」,只答得出前两个,后面就聊不下去了。
这篇是第六单元的地图:先把有哪几把锁、各自有哪些方法铺开,再给一张选型判断表,最后用一个能跑的对照实验说明 Lock 接口到底解决了 synchronized 解决不了的问题。AQS 的内核、ReentrantLock 四种获取方式的坑、并发工具类与读写锁的细节,在后面三篇里。
版本说明:本文代码实测于 JDK 26,个别随版本变化的地方文中标出。JDK 14 起 AQS 被整体重写,但本篇只讲对外暴露的 API 和语义,不涉及那部分内部实现,因此不受影响。
两条路线:synchronized 关键字与 Lock 接口
Java 里管互斥的东西能分成两条完全不同的路线。
第一条是关键字路线,只有 synchronized 一个成员。它是语言层面的东西:
synchronized (lock) {
// 临界区
}
编译出来是两条字节码指令 monitorenter / monitorexit,运行时 JVM 用对象头里的 Mark Word 配合一个叫 monitor 的结构来管。关键在「JVM 管」这三个字:抢不到就等着,退出临界区自动放,编译器保证加解锁配对 —— 你写不出忘记释放的代码。JDK 6 之后 JVM 还给了它锁消除、锁粗化这些优化,所以别用「synchronized 慢」当换锁的理由。
第二条是接口路线,java.util.concurrent.locks.Lock,JDK 1.5 跟整个并发包一起加进来的:
lock.lock();
try {
// 临界区
} finally {
lock.unlock(); // 必须自己写,忘了就永久泄漏
}
加解锁要手动配对,这是它的第一笔代价。换来的是 synchronized 给不了的能力:lockInterruptibly() 可中断、tryLock(timeout) 可超时、newCondition() 一把锁挂多组等待队列。
一句话概括这个区别:synchronized 是语言关键字,行为被 JVM 定死了;Lock 是普通接口,行为由实现类决定 —— 所以同一组方法名下面,可以长出毛病各不相同的实现来。
图里红虚线那一截是个特别容易搞混的点:StampedLock 名字带 Lock,但它不实现 Lock 接口。所以它没有 lock()、没有 unlock()、没有 newCondition(),方法名和用法是另一套。按名字猜 API,这是最容易翻车的地方。
Lock 接口的六个方法
从 JDK 1.5 到现在,Lock 接口一共就六个方法,一个都没加过:
public interface Lock {
void lock();
void lockInterruptibly() throws InterruptedException;
boolean tryLock();
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
void unlock();
Condition newCondition();
}
前四个管「怎么拿到锁」,后两个管「怎么还回去、怎么等条件」。四个获取方法的区别只在抢不到的时候怎么办:
| 方法 | 抢不到时 | 返回什么 | 能不能被 interrupt 打断 |
|---|---|---|---|
lock() | 一直等 | void | 否 |
lockInterruptibly() | 一直等 | void | 能,抛 InterruptedException |
tryLock() | 不等,立刻返回 | false | 否 |
tryLock(time, unit) | 等指定时长 | 超时返回 false | 能 |
unlock() 是唯一必须成对出现的方法,finally 里漏写一次锁就永久泄漏 —— 这一点和 synchronized 正好相反,也是它唯一真正的坑。newCondition() 是这六个别的方法里最有分量的一个,它是 ReentrantLock 相比 synchronized 真正的结构性优势,单独一篇讲。
有两个配套的坑要提前记:tryLock()、tryLock(time, unit)、lockInterruptibly() 在返回 false 或抛异常时根本没拿到锁,此时绝对不能调 unlock(),调了直接抛 IllegalMonitorStateException;另外 catch 到 InterruptedException 之后要把中断标记恢复回去,否则信号被吞掉,上层再也看不见。四种获取方式各自的实测和判断在下一篇展开。
ReentrantLock:可重入是什么意思
ReentrantLock 是 Lock 接口最常用的实现。类名里的 Reentrant 就是可重入,定义一句话:同一个线程已经持有这把锁,它再进来一次不会被自己挡住,只是把计数加一。
为什么需要这个特性?因为锁保护的代码会互相调用。一个方法加了锁,它调用的另一个方法也加了同一把锁 —— 换成不可重入的锁,这里当场死锁,而且看起来毫无破绽。
「计数」是字面意思上的计数器,可以直接查出来:
import java.util.concurrent.locks.ReentrantLock;
/**
* "可重入"到底是什么意思:同一个线程再进一次,不是被挡住,是计数器加一。
* 用法: java LockApiDemo
*/
public class LockApiDemo {
static final ReentrantLock LOCK = new ReentrantLock();
public static void main(String[] args) {
LOCK.lock();
System.out.println("lock() #1 -> holdCount = " + LOCK.getHoldCount()
+ ", isLocked = " + LOCK.isLocked());
LOCK.lock();
System.out.println("lock() #2 -> holdCount = " + LOCK.getHoldCount()
+ ", isHeldByCurrentThread = " + LOCK.isHeldByCurrentThread());
LOCK.unlock();
System.out.println("unlock() #1 -> holdCount = " + LOCK.getHoldCount()
+ ", isLocked = " + LOCK.isLocked());
LOCK.unlock();
System.out.println("unlock() #2 -> holdCount = " + LOCK.getHoldCount()
+ ", isLocked = " + LOCK.isLocked()
+ ", isHeldByCurrentThread = " + LOCK.isHeldByCurrentThread());
}
}
javac LockApiDemo.java
java LockApiDemo
实测输出:
lock() #1 -> holdCount = 1, isLocked = true
lock() #2 -> holdCount = 2, isHeldByCurrentThread = true
unlock() #1 -> holdCount = 1, isLocked = true
unlock() #2 -> holdCount = 0, isLocked = false, isHeldByCurrentThread = false
盯住第三行:unlock() 调了一次之后,isLocked 还是 true。因为 lock() 进去两次,unlock() 也必须调满两次才真正放开。重入的代价就在这儿 —— 退出次数必须和进入次数对得上,少一次,别的线程永远拿不到这把锁。这类 bug 在重入的 try/finally 里嵌套一层就容易出,而且表现是「偶发卡死」,很难查。
输出里的三个查询方法本身也值得记:getHoldCount()、isLocked()、isHeldByCurrentThread()。加上 isFair()、hasQueuedThreads()、getQueueLength(),ReentrantLock 一共给了六个状态查询方法,synchronized 一个都提供不了。线上排查「到底谁把锁占着」的时候,这几个方法比日志管用。
ReentrantLock 的构造参数决定公平性:new ReentrantLock() 默认非公平,new ReentrantLock(true) 是公平锁。公平锁严格按排队顺序发放,代价是吞吐下降,真实差距在 AQS 那篇有压测数据。
读写锁家族:ReentrantReadWriteLock 与 StampedLock
前面两把锁都是独占的:同一时刻只允许一个线程进临界区。但很多场景是读多写少 —— 缓存、配置、路由表,绝大多数操作是读,读和读之间根本没有冲突,凭什么要互相等?
ReentrantReadWriteLock:读读共享
ReentrantReadWriteLock 把一把锁拆成两个 Lock 对象:
ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
Lock r = rwl.readLock();
Lock w = rwl.writeLock();
互斥规则三条:读读共享、读写互斥、写写互斥。多个读线程可以同时持有读锁;只要有一个写线程在写,读线程全被挡住。
两条容易被考倒的规则单独记:
- 降级可以,升级不行。 先拿写锁、再拿读锁、然后放掉写锁 —— 这条路径在源码注释里叫 lock downgrading,是允许的。反过来,手里拿着读锁去要写锁,永远拿不到:写锁要等所有读锁释放,而你自己就是其中一个,谁也动不了,当场死锁。
Condition只有写锁有。writeLock().newCondition()正常,readLock().newCondition()直接抛UnsupportedOperationException—— 源码注释写得很直白:ReadLocksdo not support conditions。
StampedLock:多一个「乐观读」
StampedLock(JDK 8 加入)走的是另一条路。它的核心想法是:绝大多数读根本不会和写撞上,那就干脆别加锁 —— 先读,读完再验一下刚才有没有人写过。
它有三种模式:
| 模式 | 方法 | 特点 |
|---|---|---|
| 写 | writeLock() | 独占,和读写锁的写锁一样 |
| 读 | readLock() | 悲观读,会挡住写线程 |
| 乐观读 | tryOptimisticRead() | 完全不加锁,只拿一个版本号 stamp |
乐观读有固定的使用套路:先取 stamp,把要用的字段读进局部变量,用完之后调 validate(stamp) 问一句「我拿到 stamp 之后,有没有人写过」。返回 false 就说明读到的数据已经作废,必须重来一遍。
跑一遍,一次看三件事:
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.StampedLock;
/**
* StampedLock 的三件事:
* 1. 乐观读期间写线程照样能改数据(乐观读根本没有加锁)
* 2. 数据被动过,validate(stamp) 就必须返回 false,读到的值作废
* 3. 它不实现 Lock 接口、没有 newCondition、同一个线程也拿不到第二次写锁
* 用法: java StampDemo
*/
public class StampDemo {
static final StampedLock SL = new StampedLock();
static int x = 0, y = 0;
static final CountDownLatch READ_DONE = new CountDownLatch(1); // 乐观读已经把字段读完了
static final CountDownLatch WRITE_DONE = new CountDownLatch(1); // 写线程改完了
public static void main(String[] args) throws Exception {
System.out.println("StampedLock implements Lock ? "
+ Lock.class.isAssignableFrom(StampedLock.class));
boolean hasCondition = true;
try { StampedLock.class.getMethod("newCondition"); }
catch (NoSuchMethodException e) { hasCondition = false; }
System.out.println("StampedLock has newCondition() ? " + hasCondition);
Thread writer = new Thread(() -> {
try { READ_DONE.await(); } catch (InterruptedException e) { return; }
long ws = SL.writeLock(); // 读线程只握着 stamp,写锁立刻就能拿到
try { x = 1; y = 1; } finally { SL.unlockWrite(ws); }
System.out.println("writer: writeLock() acquired while reader held the optimistic stamp");
WRITE_DONE.countDown();
}, "writer");
writer.start();
long stamp = SL.tryOptimisticRead();
int cx = x, cy = y;
System.out.println("reader: optimistic stamp taken, x=" + cx + " y=" + cy);
READ_DONE.countDown();
WRITE_DONE.await();
System.out.println("reader: validate(stamp) = " + SL.validate(stamp));
long rs = SL.readLock(); // 重试:换成悲观读
try { cx = x; cy = y; } finally { SL.unlockRead(rs); }
System.out.println("reader: after retry with readLock(), x=" + cx + " y=" + cy);
writer.join();
long s1 = SL.writeLock();
long s2 = SL.tryWriteLock(); // 同一个线程再要一次
System.out.println("reentrant ? second tryWriteLock() = " + s2);
SL.unlockWrite(s1);
}
}
javac StampDemo.java
java StampDemo
实测输出:
StampedLock implements Lock ? false
StampedLock has newCondition() ? false
reader: optimistic stamp taken, x=0 y=0
writer: writeLock() acquired while reader held the optimistic stamp
reader: validate(stamp) = false
reader: after retry with readLock(), x=1 y=1
reentrant ? second tryWriteLock() = 0
七行输出,四个结论:
- 前两行:
StampedLock implements Lock ? false,它不是Lock。所以不能当Lock用,也就没有newCondition()—— 第二行用反射印证了这一点。 - 第四行:读线程只握着 stamp 的时候,写锁立刻就能拿到。乐观读一点都不阻塞写,这是它快的根源,也是它不可靠的根源。
- 第五行:
validate(stamp) = false,数据确实被动过,刚才读到的x=0 y=0不能信,换成悲观读重试才拿到x=1 y=1。 - 第七行:
tryWriteLock()返回0表示拿不到。同一个线程已经持有写锁,再要一次同样拿不到 —— 这就是「不可重入」。顺带记住stamp = 0是StampedLock表示失败的统一约定,所有try*方法都用它。
用 StampedLock 有三条硬约束,源码注释里都写明了:不可重入、读段必须无副作用、乐观读段里不能调未知方法(因为读到的字段值可能是不一致的状态)。它比读写锁宽松的地方只有一个:tryConvertToWriteLock() 提供了有条件的模式转换 —— 已经在写模式、或读模式下没有别的读者、或乐观读模式下锁可用时,可以升上去,不像读写锁那样写死「升级不行」。
顺带一句版本
ReentrantReadWriteLock 内部用一个整数 state 同时表达读锁和写锁:JDK 8 是高 16 位记读锁数、低 16 位记写锁重入数,一个 int 刚好塞满。但在 JDK 26 的源码里,它的 Sync 已经改成继承 AbstractQueuedLongSynchronizer,state 从 32 位扩到 64 位,读写两侧各得 32 位。面试按 JDK 8 答就行,自己翻源码看到另一个类名时别以为翻错了地方 —— 这个坑 AQS 那篇展开过。
选型:什么场景用哪把锁
名字都认识了,真正要形成条件反射的是这张判断表:
| 场景 | 用哪把 | 一句话理由 |
|---|---|---|
| 普通互斥,临界区短 | synchronized | 代码最短,JVM 保证释放,还能被优化 |
| 要超时 / 要可中断 / 要多组等待队列 / 要公平 | ReentrantLock | 这四样 synchronized 一样都给不了 |
| 读多写少,读操作本身有耗时 | ReentrantReadWriteLock | 读读共享,把并发度提上去 |
| 读极多、读段短、能接受重试 | StampedLock 乐观读 | 读的时候完全不加锁,写也不被挡 |
| 只保护一个变量 | 都不用,AtomicXxx | CAS 就够了,没必要上锁 |
| 高并发计数、累加 | LongAdder | 分段累加,比 AtomicLong 快 |
给一条能落地的默认策略:先写 synchronized,不够再加条件。 「不够」的标志是具体的 —— 出现「超时后要降级」、出现「任务要能被取消」、出现「两类线程要分开等」,才换成 ReentrantLock;出现「读占绝大多数」,才轮到读写锁。
反过来,只因为「听说 ReentrantLock 性能好」就换,是没有依据的。JDK 6 之后 synchronized 的优化已经很充分,两者的真实差距在功能,不在裸性能。
还有一点反直觉:读写锁和 StampedLock 不是 ReentrantLock 的升级版。StampedLock 确实更快,但代价是不可重入、没有 Condition、读段必须无副作用 —— 它能用的场景其实很窄。缓存这类「读多写少、且读段足够简单」的结构才是它的主场,把它当成通用替代品会踩得很惨。
手把手实操:interrupt 打不断 synchronized
回到开头那个问题:Lock 接口多出来的四个方法,到底解决了什么?挑最关键的一个 —— 可中断 —— 跑个对照。
场景很常见:主线程持着锁在做一件耗时的事,工作线程来抢同一把锁。这时候用户点了取消,主线程调 worker.interrupt()。同一个场景,两种写法,结果完全不同。
import java.util.concurrent.locks.ReentrantLock;
/**
* 同一个场景:主线程持锁,工作线程来抢,主线程把它 interrupt 掉。
* 两种写法结果完全不同 —— synchronized 打不断,lockInterruptibly 能打断。
* 用法: java InterruptCompareDemo sync
* java InterruptCompareDemo lock
*
* 标签用 ASCII,保证裸 java 命令就能逐字复现输出(控制台默认 GBK 会糊掉中文)。
*/
public class InterruptCompareDemo {
static final Object MONITOR = new Object();
static final ReentrantLock LOCK = new ReentrantLock();
static volatile boolean entered = false; // 工作线程最终有没有进临界区
static volatile boolean gaveUp = false; // 工作线程有没有放弃
static volatile boolean flagAfter = false; // 进临界区之后,中断标记还在不在
public static void main(String[] args) throws Exception {
boolean useLock = args.length > 0 && args[0].equals("lock");
System.out.println("mode = " + (useLock ? "lockInterruptibly" : "synchronized"));
Thread worker = new Thread(() -> {
System.out.println("worker: try to acquire");
if (useLock) {
try {
LOCK.lockInterruptibly(); // 可中断的等锁
} catch (InterruptedException e) {
gaveUp = true;
System.out.println("worker: InterruptedException caught, give up");
return; // 没拿到锁,直接退出
}
try { entered = true; } finally { LOCK.unlock(); }
} else {
synchronized (MONITOR) { entered = true; } // 不可中断的等锁
}
flagAfter = Thread.currentThread().isInterrupted();
System.out.println("worker: entered critical section, interrupt flag = " + flagAfter);
}, "worker");
if (useLock) {
LOCK.lock();
try {
probe(worker);
System.out.println("main: release lock");
} finally { LOCK.unlock(); }
} else {
synchronized (MONITOR) {
probe(worker);
System.out.println("main: release lock");
}
}
worker.join(5000);
System.out.println("result: entered = " + entered + ", gaveUp = " + gaveUp);
}
/** 主线程此刻已经持锁;启动工作线程、等它排上队、然后中断它。 */
static void probe(Thread worker) throws Exception {
worker.start();
Thread.sleep(300);
System.out.println("main: holding lock, worker is waiting");
worker.interrupt();
System.out.println("main: interrupt() sent to worker");
Thread.sleep(500);
System.out.println("main: 500ms later, entered = " + entered);
}
}
javac InterruptCompareDemo.java
java InterruptCompareDemo sync
java InterruptCompareDemo lock
两种写法的实测输出:
mode = synchronized
worker: try to acquire
main: holding lock, worker is waiting
main: interrupt() sent to worker
main: 500ms later, entered = false
main: release lock
worker: entered critical section, interrupt flag = true
result: entered = true, gaveUp = false
mode = lockInterruptibly
worker: try to acquire
main: holding lock, worker is waiting
main: interrupt() sent to worker
worker: InterruptedException caught, give up
main: 500ms later, entered = false
main: release lock
result: entered = false, gaveUp = true
对着看两栏的区别:
synchronized 那栏:interrupt() 发出去了,500ms 之后 entered 还是 false,工作线程仍在等。中断标记倒是留着了(最后一行 interrupt flag = true),但它对一个阻塞在 monitor 上的线程没有任何作用 —— 工作线程最后是等主线程释放了锁才进去的,result 那行 entered = true 就是证据。也就是说,卡在 synchronized 上的线程取消不掉,唯一的办法是等它自己走完。
lockInterruptibly() 那栏:interrupt() 之后工作线程立刻抛出 InterruptedException,打印 give up 然后退出,entered = false。整个等锁过程被打断,线程干净收场。
这就是「可取消」的实现基础。任务被提交到线程池、用户点了取消,如果任务卡在 synchronized 上,你只能把它连着工作线程一起废掉;换成 lockInterruptibly(),它能优雅退出把线程让出来。
用的时候记两个细节:一是 catch 到之后要恢复中断标记(Thread.currentThread().interrupt()),否则中断信号被吞掉,上层再也看不到;二是这里不能调 unlock() —— 压根没拿到锁,调了直接抛 IllegalMonitorStateException。这也是为什么正确写法要把「获取锁」和「临界区」分成两段 try,只对拿到锁的那一段用 finally { unlock(); }。
面试速答
Q:Java 里有哪几种锁?
A:两条路线。关键字路线是 synchronized,JVM 内置 monitor 实现,自动释放。接口路线是 Lock(JDK 1.5),常用实现三个:ReentrantLock(独占可重入)、ReentrantReadWriteLock(读读共享、读写互斥、写写互斥)、StampedLock(多一个乐观读)。注意 StampedLock 不实现 Lock 接口,方法名和用法都是另一套,实测 Lock.class.isAssignableFrom(StampedLock.class) 返回 false。
Q:Lock 接口有哪些方法?
A:六个,JDK 1.5 至今没变过:lock()、lockInterruptibly()、tryLock()、tryLock(time, unit)、unlock()、newCondition()。前四个的区别只在抢不到时的行为:一直等 / 可中断地等 / 立即返回 false / 超时返回 false。unlock() 必须放 finally,newCondition() 是一把锁挂多组等待队列的入口。
Q:可重入是什么意思?
A:同一个线程已经持有锁,再进来一次不会被自己挡住,计数加一。ReentrantLock.getHoldCount() 能查到当前重入了几次(实测连调两次 lock() 得 2)。所以 unlock() 的次数必须和 lock() 对得上 —— 实测只 unlock() 一次时 isLocked 仍然是 true,锁没真正放开。
Q:读写锁的降级和升级?
A:降级(持有写锁时再拿读锁、然后放开写锁)允许,源码注释里叫 lock downgrading。升级(持有读锁时去要写锁)永远拿不到 —— 写锁要等所有读锁释放,而自己就是其中一个,直接死锁。另外 Condition 只有写锁有,readLock().newCondition() 抛 UnsupportedOperationException。
Q:StampedLock 的乐观读是怎么回事?
A:tryOptimisticRead() 完全不加锁,只返回一个版本号 stamp;把字段读进局部变量后调 validate(stamp),返回 false 说明期间被人写过,数据作废必须重试。好处是乐观读期间写线程完全不被阻塞(实测写锁立刻拿到),读多写少时吞吐更好;代价是读到的值可能不一致,只适合读段短、无副作用的场景。它不可重入(实测同一线程第二次 tryWriteLock() 返回 0)、不支持 Condition、也不实现 Lock。
Q:为什么有了 synchronized 还要 ReentrantLock?
A:是功能差异,不是性能差异。synchronized 只有一种获取方式:一直等,不响应中断、不能超时、一个对象只有一个等待集。ReentrantLock 多出四样:可超时(tryLock(time, unit))、可中断(lockInterruptibly())、一把锁挂多组 Condition、可选公平。实测里卡在 synchronized 上的线程根本中断不掉,换成 lockInterruptibly() 立刻抛异常退出。
Q:怎么选锁?
A:默认 synchronized。需要超时 / 可中断 / 多条件等待 / 公平这四样里的任何一样,才换 ReentrantLock;读多写少换读写锁;读极多且读段短才考虑 StampedLock 乐观读。只保护单个变量用 AtomicXxx,高并发计数用 LongAdder。
核心收获与下一步
锁的家族先记两条线:synchronized 是关键字,JVM 管,只有一种写法、自动释放;Lock 是接口,六个方法,加解锁手动配对。接口下面挂着 ReentrantLock 和 ReentrantReadWriteLock(内部分成读锁和写锁两个 Lock),而 StampedLock 不在这个接口下 —— 它是另一套带 stamp 的 API,不重入、无 Condition,但多出一个不阻塞写的乐观读。
选型判断压成一句:先 synchronized,出现「超时 / 中断 / 多条件 / 公平」才换 ReentrantLock,读多写少换读写锁,读极多才轮到 StampedLock。
Lock 接口存在的意义,用 InterruptCompareDemo 的两栏输出解释最省事:卡在 synchronized 上的线程取消不掉,lockInterruptibly() 能。这就是「灵活」两个字的落点。
下一步按顺序往下读:AQS 内核那篇拆排队到底怎么实现(state、CLH 变体队列、LockSupport 的许可机制),它解释的正是这篇里反复出现的「排队等锁」;然后是 ReentrantLock 与 Condition 那篇,讲四种获取方式各自的坑和双队列机制;最后是并发工具类与读写锁那篇,把读写锁和 StampedLock 的细节补完。读完这三篇再回头看这张家族图,每一格都能填上实测数据。
本文关键词:synchronized、ReentrantLock、ReentrantReadWriteLock、StampedLock、Lock 接口