欢迎光临
我们一直在努力

AQS 深度剖析:ReentrantLock 公平锁如何通过 CLH 队列保证顺序

AQS 深度剖析:ReentrantLock 公平锁如何通过 CLH 队列保证顺序

一、ReentrantLock 核心概念

1.1 ReentrantLock 概述

ReentrantLock 是 Java 并发包中基于 AQS(AbstractQueuedSynchronizer)框架实现的可重入互斥锁,支持公平锁与非公平锁两种模式。

可重入性:同一线程可以多次获取同一把锁,通过计数器实现,解锁需要相同次数的释放。

1.2 与 Synchronized 的对比

特性维度ReentrantLockSynchronized
实现机制 基于 AQS 框架 基于 JVM 监视器模式
灵活性 支持中断、超时、尝试获取锁 不灵活,只能阻塞等待
锁释放 必须显式调用 unlock() 自动释放监视器
锁策略 支持公平锁与非公平锁 仅非公平锁
条件队列 可关联多个 Condition 队列 单个等待队列
性能特点 高竞争下性能更好 低竞争下性能更优
可重入性 支持可重入 支持可重入

二、AQS 核心架构与 CLH 队列

2.1 AQS 设计哲学

AQS 采用模板方法模式,提供构建锁和同步器的框架,核心思想是:

  • 通过一个 volatile 的 state 表示同步状态
  • 使用 CLH 变体队列管理等待线程
  • 提供 acquire/release 模板方法

2.2 CLH 队列变体

AQS 中的 CLH 队列是对原始 CLH 锁的改进:

// AQS 内部节点类
static final class Node {
// 等待状态
static final int CANCELLED = 1; // 节点已取消
static final int SIGNAL = 1; // 后继节点需要唤醒
static final int CONDITION = 2; // 在条件队列中等待
static final int PROPAGATE = 3; // 共享模式下传播

volatile int waitStatus; // 节点状态
volatile Node prev; // 前驱节点
volatile Node next; // 后继节点
volatile Thread thread; // 等待线程
Node nextWaiter; // 共享模式或条件队列链接
}

CLH 队列特点:

  • 双向链表:便于节点的移除和遍历
  • 虚拟头节点:简化边界条件处理
  • 状态标识:支持多种等待状态

三、公平锁顺序保证机制

3.1 公平锁的核心逻辑

公平锁的核心在于 “先来先服务” 原则,通过以下两点保证:

  • 获取锁时检查队列:判断是否有更早的等待者
  • 严格的 FIFO 出队顺序:只有队首节点能尝试获取锁
  • // 公平锁实现
    static final class FairSync extends Sync {
    protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();

    // 锁未被占用
    if (c == 0) {
    // 关键:检查是否有前驱节点
    if (!hasQueuedPredecessors() &&
    compareAndSetState(0, acquires)) {
    setExclusiveOwnerThread(current);
    return true;
    }
    }
    // 重入逻辑
    else if (current == getExclusiveOwnerThread()) {
    int nextc = c + acquires;
    if (nextc < 0) throw new Error("Maximum lock count exceeded");
    setState(nextc);
    return true;
    }
    return false;
    }
    }

    3.2 hasQueuedPredecessors() 方法详解

    这是公平锁顺序保证的核心方法:

    public final boolean hasQueuedPredecessors() {
    Node t = tail; // 尾节点
    Node h = head; // 头节点
    Node s;

    // 返回 true 表示有前驱节点,应该排队等待
    return h != t && // 队列不为空
    ((s = h.next) == null || // 头节点的后继为空(并发初始化情况)
    s.thread != Thread.currentThread()); // 后继节点的线程不是当前线程
    }

    方法逻辑分析:

  • h != t:队列不为空(有等待线程)
  • s == null:并发初始化时,头节点的 next 可能暂时为空
  • s.thread != Thread.currentThread():有更早的等待线程
  • 3.3 完整获取锁流程

    // 公平锁的 lock() 方法调用链
    public void lock() {
    sync.lock(); // 调用 FairSync.lock()
    }

    // FairSync.lock() -> AQS.acquire(1)
    public final void acquire(int arg) {
    if (!tryAcquire(arg) && // 尝试获取锁
    acquireQueued( // 获取失败,进入队列
    addWaiter(Node.EXCLUSIVE), arg)) // 创建节点并入队
    selfInterrupt(); // 恢复中断状态
    }

    步骤 1:尝试获取锁

    通过 tryAcquire() 检查:

    • 锁是否空闲
    • 是否有前驱节点在等待
    步骤 2:创建节点并入队

    private Node addWaiter(Node mode) {
    Node node = new Node(Thread.currentThread(), mode);
    Node pred = tail;

    // 快速路径:尝试直接添加到队尾
    if (pred != null) {
    node.prev = pred;
    if (compareAndSetTail(pred, node)) {
    pred.next = node;
    return node;
    }
    }

    // 慢速路径:队列为空或 CAS 失败
    enq(node);
    return node;
    }

    private Node enq(final Node node) {
    for (;;) { // 自旋直到成功
    Node t = tail;
    if (t == null) { // 队列为空,初始化
    if (compareAndSetHead(new Node())) // 创建虚拟头节点
    tail = head; // 头尾指向同一个虚拟节点
    } else {
    node.prev = t;
    if (compareAndSetTail(t, node)) { // CAS 设置尾节点
    t.next = node;
    return t;
    }
    }
    }
    }

    步骤 3:队列中等待

    final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true;
    try {
    boolean interrupted = false;
    for (;;) { // 自旋等待
    final Node p = node.predecessor(); // 获取前驱节点

    // 关键:只有前驱是头节点时才尝试获取锁
    if (p == head && tryAcquire(arg)) {
    setHead(node); // 设置当前节点为头节点
    p.next = null; // 断开旧头节点,帮助 GC
    failed = false;
    return interrupted;
    }

    // 检查是否需要阻塞
    if (shouldParkAfterFailedAcquire(p, node) &&
    parkAndCheckInterrupt())
    interrupted = true;
    }
    } finally {
    if (failed)
    cancelAcquire(node); // 取消获取
    }
    }

    shouldParkAfterFailedAcquire() 方法:

    • 检查并更新前驱节点的状态
    • 确保前驱节点的状态为 SIGNAL,表示释放锁时会唤醒后继节点

    3.4 锁释放流程

    // unlock() 调用链
    public void unlock() {
    sync.release(1);
    }

    public final boolean release(int arg) {
    if (tryRelease(arg)) { // 尝试释放锁
    Node h = head;
    if (h != null && h.waitStatus != 0)
    unparkSuccessor(h); // 唤醒后继节点
    return true;
    }
    return false;
    }

    unparkSuccessor() 唤醒后继

    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) {
    s = null;
    // 从尾向前遍历,找到最靠近头部的有效节点
    for (Node t = tail; t != null && t != node; t = t.prev)
    if (t.waitStatus <= 0)
    s = t;
    }

    if (s != null)
    LockSupport.unpark(s.thread); // 唤醒线程
    }

    四、队列状态变化示例

    4.1 初始状态

    head → [dummy] ← tail
    waitStatus=0

    4.2 线程 A 入队

    head → [dummy] ↔ [Thread-A] ← tail

    prev/next 链接

    4.3 线程 B 入队

    head → [dummy] ↔ [Thread-A] ↔ [Thread-B] ← tail

    4.4 线程 A 获取锁

    head → [Thread-A] ↔ [Thread-B] ← tail
    (原 dummy 节点被断开)

    4.5 线程 A 释放锁,唤醒 B

    head → [Thread-B] ← tail

    五、关键设计要点分析

    5.1 顺序保证机制

    机制实现方式作用
    入队顺序 新线程总是插入队尾 保证到达顺序
    出队顺序 只有头节点的后继能尝试获取锁 保证 FIFO 顺序
    严格检查 hasQueuedPredecessors() 检查 防止插队
    有序唤醒 只唤醒第一个有效等待节点 避免争抢

    5.2 避免饥饿的设计

    // 公平锁防止饥饿的核心代码
    if (c == 0) {
    // 即使锁空闲,也要检查是否有等待者
    if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) {
    // 只有无等待者才能获取
    }
    }

    设计哲学:公平锁牺牲了部分吞吐量来保证绝对的公平性,避免了线程饥饿问题。

    5.3 双向队列的优势

    优势说明
    高效取消 从任意位置移除节点都高效
    反向遍历 处理取消节点时可从后向前遍历
    条件队列 便于与条件队列集成

    5.4 虚拟头节点设计

    // 初始化队列时创建虚拟节点
    if (t == null) { // 队列为空
    if (compareAndSetHead(new Node())) // 创建虚拟节点
    tail = head;
    }

    虚拟头节点作用:

    • 简化边界条件处理
    • 避免空指针异常
    • 提供稳定的同步状态

    六、公平锁与非公平锁对比

    6.1 实现差异

    // 非公平锁实现
    static final class NonfairSync extends Sync {
    protected final boolean tryAcquire(int acquires) {
    return nonfairTryAcquire(acquires);
    }
    }

    final boolean nonfairTryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
    // 直接尝试 CAS,不检查队列!
    if (compareAndSetState(0, acquires)) {
    setExclusiveOwnerThread(current);
    return true;
    }
    }
    // 重入逻辑相同…
    }

    6.2 性能对比

    场景公平锁非公平锁
    低竞争 性能稍差 性能更好
    高竞争 吞吐量较低 吞吐量更高
    响应时间 更可预测 可能波动较大
    饥饿风险 可能存在

    6.3 适用场景

    使用公平锁的场景:

    • 需要严格的先来先服务顺序
    • 线程等待时间差异较大
    • 防止线程饥饿是首要考虑

    使用非公平锁的场景:

    • 追求更高的吞吐量
    • 线程等待时间较短
    • 能够容忍一定的顺序不公平

    七、常见问题解答

    Q1:为什么 CLH 队列使用双向链表?

    A:双向链表的优势:

  • 便于节点移除:线程超时、中断时可以从队列中间移除
  • 支持反向遍历:unparkSuccessor() 中需要从尾部向前查找有效节点
  • 条件队列支持:便于与条件队列的集成
  • Q2:公平锁如何避免死锁?

    A:公平锁本身不会引入死锁,死锁通常由以下原因引起:

  • 锁顺序不一致:多个线程以不同顺序获取多个锁
  • 缺少超时机制:无限期等待
  • // 避免死锁的建议
    ReentrantLock lock1 = new ReentrantLock(true);
    ReentrantLock lock2 = new ReentrantLock(true);

    // 统一获取顺序
    public void safeMethod() {
    lock1.lock();
    try {
    lock2.lock();
    try {
    // 业务逻辑
    } finally {
    lock2.unlock();
    }
    } finally {
    lock1.unlock();
    }
    }

    Q3:公平锁的性能瓶颈是什么?

    A:主要瓶颈包括:

  • 频繁的队列检查:每次获取锁都要检查队列
  • 上下文切换:线程频繁阻塞和唤醒
  • 缓存失效:多个线程在锁上竞争导致的缓存行失效
  • Q4:如何选择公平锁与非公平锁?

    决策矩阵:

    考虑因素公平锁非公平锁
    顺序要求 高 ✓
    吞吐量需求 高 ✓
    防止饥饿 必须 ✓ 不重要
    实现复杂度 较高 较低 ✓

    八、最佳实践与性能优化

    8.1 使用模式

    public class FairLockExample {
    private final ReentrantLock fairLock = new ReentrantLock(true);

    public void criticalSection() {
    fairLock.lock(); // 获取公平锁
    try {
    // 访问共享资源
    performOperation();
    } finally {
    fairLock.unlock(); // 必须释放锁
    }
    }

    // 带超时的获取
    public boolean tryCriticalSection(long timeout, TimeUnit unit)
    throws InterruptedException {
    if (fairLock.tryLock(timeout, unit)) {
    try {
    performOperation();
    return true;
    } finally {
    fairLock.unlock();
    }
    }
    return false;
    }
    }

    8.2 监控与调试

    // 监控队列长度
    public void monitorQueue() {
    ReentrantLock lock = new ReentrantLock(true);

    // 获取等待队列长度
    int queueLength = lock.getQueueLength();
    System.out.println("等待线程数: " + queueLength);

    // 检查是否有线程在等待
    boolean hasQueuedThreads = lock.hasQueuedThreads();

    // 获取等待线程
    Collection<Thread> queuedThreads = lock.getQueuedThreads();
    }

    8.3 性能调优建议

  • 减少锁粒度:尽量缩小临界区范围
  • 避免锁嵌套:减少锁的持有时间
  • 使用读写锁:读多写少场景使用 ReentrantReadWriteLock
  • 考虑无锁算法:对于简单操作,考虑 Atomic 变量
  • 九、源码级别调试技巧

    9.1 关键断点设置

    // 调试断点位置:
    // 1. FairSync.tryAcquire() – 公平获取逻辑
    // 2. AbstractQueuedSynchronizer.hasQueuedPredecessors() – 队列检查
    // 3. AbstractQueuedSynchronizer.addWaiter() – 入队逻辑
    // 4. AbstractQueuedSynchronizer.unparkSuccessor() – 唤醒逻辑

    9.2 线程转储分析

    jstack <pid> > thread_dump.txt

    // 查找锁信息:
    // "locked <0x000000076ad27fc0> (a java.util.concurrent.locks.ReentrantLock$FairSync)"
    // "waiting on <0x000000076ad27fc0> (a java.util.concurrent.locks.ReentrantLock$FairSync)"

    十、总结

    ReentrantLock 公平锁通过 AQS 的 CLH 队列变种实现了严格的 FIFO 顺序保证,其核心机制包括:

  • 严格的队列检查:通过 hasQueuedPredecessors() 防止插队
  • 有序的节点管理:新节点入队尾,只有队首节点能获取锁
  • 精确的唤醒机制:只唤醒第一个有效等待节点
  • 完善的重入支持:通过计数器支持锁重入
  • 公平锁的设计体现了 “公平性优先” 的原则,虽然可能降低吞吐量,但确保了:

    • 绝对的先来先服务顺序
    • 无线程饥饿问题
    • 可预测的等待时间

    在实际应用中,应根据具体场景选择锁策略:

    • 公平锁:对顺序敏感、防止饥饿的场景
    • 非公平锁:追求高吞吐、低延迟的场景

    理解 AQS 和 CLH 队列的实现原理,不仅有助于更好地使用 ReentrantLock,也为理解 Java 并发框架的其他组件(如 CountDownLatch、Semaphore 等)奠定了基础。

    赞(0)
    未经允许不得转载:171主机测评 » AQS 深度剖析:ReentrantLock 公平锁如何通过 CLH 队列保证顺序
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址