← 返回博客
2026-09-21 16:05:00

Java 锁全景:该用哪把锁、各自有哪些方法

Java 锁全景:该用哪把锁、各自有哪些方法

上一单元把 volatile、CAS、ABA 讲完了,但那些都是针对单个变量的。业务里天天要处理的是另一类问题:一段代码必须互斥执行,一组字段必须保持一致 —— 这种时候靠的是锁。

麻烦在于 Java 的锁不止一把。synchronizedReentrantLockReentrantReadWriteLockStampedLock,名字里都带锁,但方法名不一样、加解锁方式不一样、能用的场景也不一样。面试官问「你平时用哪个锁」,只答得出前两个,后面就聊不下去了。

这篇是第六单元的地图:先把有哪几把锁、各自有哪些方法铺开,再给一张选型判断表,最后用一个能跑的对照实验说明 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 是普通接口,行为由实现类决定 —— 所以同一组方法名下面,可以长出毛病各不相同的实现来。

Java 锁家族结构图:synchronized 是关键字路线由 JVM 管理;Lock 是接口,下面有 ReentrantLock 和 ReentrantReadWriteLock,其中读写锁内含 ReadLock 与 WriteLock;StampedLock 位于红框内,不实现 Lock 接口,是另一套 API

图里红虚线那一截是个特别容易搞混的点: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;另外 catchInterruptedException 之后要把中断标记恢复回去,否则信号被吞掉,上层再也看不见。四种获取方式各自的实测和判断在下一篇展开。

ReentrantLock:可重入是什么意思

ReentrantLockLock 接口最常用的实现。类名里的 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();

互斥规则三条:读读共享、读写互斥、写写互斥。多个读线程可以同时持有读锁;只要有一个写线程在写,读线程全被挡住。

两条容易被考倒的规则单独记:

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 有三条硬约束,源码注释里都写明了:不可重入、读段必须无副作用、乐观读段里不能调未知方法(因为读到的字段值可能是不一致的状态)。它比读写锁宽松的地方只有一个:tryConvertToWriteLock() 提供了有条件的模式转换 —— 已经在写模式、或读模式下没有别的读者、或乐观读模式下锁可用时,可以升上去,不像读写锁那样写死「升级不行」。

顺带一句版本

ReentrantReadWriteLock 内部用一个整数 state 同时表达读锁和写锁:JDK 8 是高 16 位记读锁数、低 16 位记写锁重入数,一个 int 刚好塞满。但在 JDK 26 的源码里,它的 Sync 已经改成继承 AbstractQueuedLongSynchronizerstate 从 32 位扩到 64 位,读写两侧各得 32 位。面试按 JDK 8 答就行,自己翻源码看到另一个类名时别以为翻错了地方 —— 这个坑 AQS 那篇展开过。

选型:什么场景用哪把锁

名字都认识了,真正要形成条件反射的是这张判断表:

场景用哪把一句话理由
普通互斥,临界区短synchronized代码最短,JVM 保证释放,还能被优化
要超时 / 要可中断 / 要多组等待队列 / 要公平ReentrantLock这四样 synchronized 一样都给不了
读多写少,读操作本身有耗时ReentrantReadWriteLock读读共享,把并发度提上去
读极多、读段短、能接受重试StampedLock 乐观读读的时候完全不加锁,写也不被挡
只保护一个变量都不用,AtomicXxxCAS 就够了,没必要上锁
高并发计数、累加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 / 超时返回 falseunlock() 必须放 finallynewCondition() 是一把锁挂多组等待队列的入口。

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接口,六个方法,加解锁手动配对。接口下面挂着 ReentrantLockReentrantReadWriteLock(内部分成读锁和写锁两个 Lock),而 StampedLock 不在这个接口下 —— 它是另一套带 stamp 的 API,不重入、无 Condition,但多出一个不阻塞写的乐观读。

选型判断压成一句:synchronized,出现「超时 / 中断 / 多条件 / 公平」才换 ReentrantLock,读多写少换读写锁,读极多才轮到 StampedLock

Lock 接口存在的意义,用 InterruptCompareDemo 的两栏输出解释最省事:卡在 synchronized 上的线程取消不掉,lockInterruptibly() 能。这就是「灵活」两个字的落点。

下一步按顺序往下读:AQS 内核那篇拆排队到底怎么实现(state、CLH 变体队列、LockSupport 的许可机制),它解释的正是这篇里反复出现的「排队等锁」;然后是 ReentrantLockCondition 那篇,讲四种获取方式各自的坑和双队列机制;最后是并发工具类与读写锁那篇,把读写锁和 StampedLock 的细节补完。读完这三篇再回头看这张家族图,每一格都能填上实测数据。

本文关键词:synchronized、ReentrantLock、ReentrantReadWriteLock、StampedLock、Lock 接口