ReentrantLock解析:用 “会议室抢用” 故事讲透设计思想与原理

57 阅读8分钟

作为 Android 开发,我们每天都在和 “线程安全” 打交道 —— 比如 UI 线程不能做耗时操作、子线程不能更新 UI,而就是解决线程竞争的核心工具。ReentrantLock作为 Java 并发包(JUC)的 “明星锁”,比synchronized更灵活(可中断、可超时、支持公平锁),但其设计思想和原理却常被 “CAS”“AQS” 等术语吓退。

今天我们用一个 “公司会议室抢用” 的趣味故事,结合代码和时序图,把ReentrantLock讲得明明白白。

一、故事背景:为什么需要 ReentrantLock?

假设公司有 1 间 “独占会议室”(共享资源),10 个员工(线程)要抢着用。如果没规矩,会出现:

  • 两个员工同时冲进会议室(线程安全问题);

  • 员工 A 刚出来,员工 C 直接插队,排队的员工 B 有意见(公平性问题);

  • 员工 A 进去后,忘了出来,其他人永远用不了(死锁风险);

  • 员工 A 进去后,想再拿份文件,却被当成 “新用户” 排队(不可重入问题)。

ReentrantLock就像一个智能会议室管理员,能解决以上所有问题。它的核心设计目标是: “独占、可重入、可公平 / 非公平、可安全释放”

二、ReentrantLock 的核心设计思想

在讲原理前,先提炼 4 个核心设计思想(对应管理员的 “工作准则”):

设计思想对应 “会议室规则”技术本质
1. 委托式设计管理员不自己做判断,委托给 “策略小组” 处理抢锁逻辑核心逻辑委托给Sync(继承 AQS)
2. 策略模式支持 “按排队顺序叫号”(公平)或 “有空就进”(非公平)FairSyncNonfairSync实现不同策略
3. 基于状态的锁管理用 “会议室占用计数” 记录状态(0 = 空,N = 被占用 N 次)state变量 + CAS 原子操作
4. 独占式等待队列抢不到的员工按顺序排队,避免拥挤AQS 的双向链表等待队列

三、原理拆解:用故事 + 代码讲透每一步

ReentrantLock的核心是内部类Sync(继承自AbstractQueuedSynchronizer,简称 AQS),所有锁操作(抢锁、释放锁)都由Sync及其子类实现。我们分 “抢锁”“释放锁”“可重入” 三个场景讲。

场景 1:抢锁(员工申请用会议室)

1.1 非公平锁:“有空就进,不管排队”(默认策略)

故事场景
员工 A(Thread1)来申请会议室,管理员看会议室空着(state=0),直接让他进;
员工 B(Thread2)来申请,发现被占,只好排队;
此时员工 A 出来了(state=0),员工 C(Thread3)刚到,没排队直接冲进会议室(插队)—— 这就是非公平。

对应代码(简化核心逻辑)
ReentrantLocklock()方法会委托给NonfairSynclock()

// ReentrantLock的lock()方法
public void lock() {
    sync.lock(); // 委托给Sync子类
}

// 非公平锁的Sync实现(NonfairSync)
static final class NonfairSync extends Sync {
    final void lock() {
        // 第一步:尝试“插队”——CAS把state从0设为1(有空就进)
        if (compareAndSetState(0, 1)) {
            // 成功:记录当前持有锁的线程(员工A)
            setExclusiveOwnerThread(Thread.currentThread());
        } else {
            // 失败:调用AQS的acquire(),进入排队流程
            acquire(1);
        }
    }

    // 核心:尝试抢锁(tryAcquire)
    protected final boolean tryAcquire(int acquires) {
        return nonfairTryAcquire(acquires);
    }
}

// 非公平抢锁的核心逻辑
final boolean nonfairTryAcquire(int acquires) {
    final Thread current = Thread.currentThread(); // 当前员工(比如Thread3)
    int c = getState(); // 获取会议室状态(0=空,1=被占)

    if (c == 0) { // 会议室空着
        // 不看排队,直接CAS抢锁(插队关键!)
        if (compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true; // 抢锁成功
        }
    }
    // 下面是“可重入”逻辑(场景3讲)
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires; // 计数+1(比如员工A再进一次)
        if (nextc < 0) throw new Error("锁重入次数溢出");
        setState(nextc); // 更新状态
        return true;
    }
    return false; // 抢锁失败,准备排队
}

关键细节

  • compareAndSetState(0,1):CAS(Compare And Swap)是 “原子操作”,相当于管理员 “瞬间检查并标记会议室占用”,避免两个员工同时抢成功;
  • 非公平的核心是 “不判断队列”:即使有员工排队,新到的员工也能直接抢空会议室。

1.2 公平锁:“按排队顺序,先到先得”

故事场景
员工 A 进会议室后,员工 B、C 依次排队;
员工 A 出来后,管理员必须叫排队的第一个(员工 B),员工 C 不能插队。

对应代码(公平锁的 tryAcquire)

static final class FairSync extends Sync {
    protected final boolean tryAcquire(int acquires) {
        final Thread current = Thread.currentThread();
        int c = getState();

        if (c == 0) { // 会议室空着
            // 公平的关键:hasQueuedPredecessors()判断“是否有排队的前驱”
            if (!hasQueuedPredecessors() && 
                compareAndSetState(0, acquires)) {
                setExclusiveOwnerThread(current);
                return true;
            }
        }
        // 可重入逻辑(同上)
        else if (current == getExclusiveOwnerThread()) {
            int nextc = c + acquires;
            if (nextc < 0) throw new Error("锁重入次数溢出");
            setState(nextc);
            return true;
        }
        return false;
    }
}

// AQS的方法:判断“当前线程是否有前驱在排队”
public final boolean hasQueuedPredecessors() {
    Node t = tail; // 队尾
    Node h = head; // 队头
    Node s;
    // 逻辑:如果队列不为空,且当前线程不是队头的下一个,就是有前驱
    return h != t && 
        ((s = h.next) == null || s.thread != Thread.currentThread());
}

公平锁 vs 非公平锁对比

维度公平锁(FairSync)非公平锁(NonfairSync)
核心逻辑抢锁前先看队列(hasQueuedPredecessors直接 CAS 抢锁,不看队列
效率低(频繁切换线程)高(减少线程唤醒开销)
场景需严格按顺序的场景(如秒杀排队)大多数场景(如普通线程安全逻辑)

1.3 抢锁失败:进入 AQS 等待队列

如果抢锁失败(比如会议室被占),会进入 AQS 的 “排队流程”(acquire(1)):

  1. addWaiter:把当前线程包装成Node(排队号),加入 AQS 的双向链表尾部;

  2. acquireQueued:让线程在队列中 “阻塞等待”(LockSupport.park()),直到被唤醒。

故事对应
员工 B 抢不到锁,管理员给她发个排队号(Node),让她坐在走廊椅子上等待(park),直到前面的人出来。

场景 2:释放锁(员工用完会议室)

故事场景
员工 A 用完会议室,告诉管理员 “我走了”,管理员检查:

  • 如果员工 A 之前重入过(比如进了 2 次),就把 “占用计数” 减 1(state 从 2→1);

  • 如果计数减到 0(完全离开),就叫下一个排队的员工(员工 B)。

对应代码(释放锁核心逻辑)

// ReentrantLock的unlock()方法
public void unlock() {
    sync.release(1); // 委托给Sync释放
}

// AQS的release()方法(Sync继承AQS)
public final boolean release(int arg) {
    // 第一步:尝试释放锁(tryRelease由Sync实现)
    if (tryRelease(arg)) {
        Node h = head; // 队头(第一个排队的员工)
        if (h != null && h.waitStatus != 0) {
            unparkSuccessor(h); // 唤醒队头的下一个线程
        }
        return true;
    }
    return false;
}

// ReentrantLock的Sync实现tryRelease
protected final boolean tryRelease(int releases) {
    // 计算释放后的状态(占用计数-1)
    int c = getState() - releases;
    // 校验:只有持有锁的线程能释放(防止别人乱开门)
    if (Thread.currentThread() != getExclusiveOwnerThread()) {
        throw new IllegalMonitorStateException();
    }

    boolean free = false;
    if (c == 0) { // 计数减到0,完全释放
        free = true;
        setExclusiveOwnerThread(null); // 清空持有者(会议室变空)
    }
    setState(c); // 更新状态(重入时只减计数,不释放)
    return free;
}

// 唤醒下一个排队的线程
private void unparkSuccessor(Node node) {
    int ws = node.waitStatus;
    if (ws < 0) {
        compareAndSetWaitStatus(node, ws, 0); // 标记为“已唤醒”
    }

    Node s = node.next; // 队头的下一个节点(第一个排队的线程)
    if (s == null || s.waitStatus > 0) {
        // 找有效的节点(跳过已取消的)
        for (Node t = tail; t != null && t != node; t = t.prev) {
            if (t.waitStatus <= 0) s = t;
        }
    }
    if (s != null) {
        LockSupport.unpark(s.thread); // 唤醒线程(叫下一个员工)
    }
}

关键细节

  • tryRelease必须返回true才会唤醒队列:只有当 “占用计数减到 0”(完全释放)时,才会叫下一个人;
  • LockSupport.unpark:相当于管理员 “叫醒” 排队的员工,让他再次尝试抢锁(此时会议室已空)。

场景 3:可重入(员工多次进会议室)

故事场景
员工 A 已经在会议室(state=1),突然想起文件没拿,又跟管理员说 “再进一次”。管理员认识他(判断当前线程是持有者),直接让他进,把 “占用计数” 改成 2(state=2);
后来员工 A 拿完文件出来,计数减为 1(没完全释放);再出来一次,计数减为 0(完全释放),会议室变空。

对应代码
就是tryAcquire中这段逻辑(前面已贴,再重点标注):

else if (current == getExclusiveOwnerThread()) {
    // 当前线程是持有者:计数+1(重入)
    int nextc = c + acquires; 
    if (nextc < 0) throw new Error("锁重入次数溢出");
    setState(nextc); // 更新状态(比如1→2)
    return true;
}

为什么需要可重入?
比如一个递归方法加了锁:

private ReentrantLock lock = new ReentrantLock();

public void recursiveMethod() {
    lock.lock();
    try {
        // 业务逻辑
        if (condition) {
            recursiveMethod(); // 递归调用,再次抢锁(重入)
        }
    } finally {
        lock.unlock();
    }
}

如果不可重入,递归时线程会自己等自己释放锁,导致死锁。

四、时序图:可视化整个调用流程

我们用公平锁场景画时序图,展示 “Thread1 抢锁成功→Thread2 抢锁失败入队→Thread1 释放→Thread2 被唤醒并抢锁成功” 的完整过程。

exported_image.png

五、总结:ReentrantLock 的 “灵魂”

  1. AQS 是基石ReentrantLock本身不实现复杂逻辑,而是委托给 AQS,利用 AQS 的 “状态管理” 和 “等待队列” 解决竞争;

  2. 可重入是便利:通过 “校验当前线程是否为持有者 + 累加 state” 实现,避免递归死锁;

  3. 公平 / 非公平是选择:根据业务场景选策略 —— 公平保证顺序,非公平保证效率;

  4. 释放锁要严谨:必须在finally中调用unlock(),防止线程异常导致锁无法释放(管理员不会忘关门)。

理解ReentrantLock后,你会发现 JUC 中很多工具(如CountDownLatchSemaphore)都基于 AQS 设计,掌握这个 “模板”,就能一通百通!