细说 AQS(自旋、CLH)

在这里插入图片描述


前言

阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费CPU时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。

在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失。


一、基本概念

1.1、自旋

多核机器,能够让两个或以上的线程同时并行执行,让后面那个请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就会释放锁。而为了让当前线程“稍等一下”,我们需让当前线程进行自旋(循环直到条件满足),如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销。

  • 使用场景:锁竞争不激烈、锁持有时间短的场景, 替代阻塞;
  • 表现:不阻塞,一直循环,直到满足条件;
  • 优点:无需上下文切换,也就避免了操作系统的进程调度;
  • 缺点:
    • 锁饥饿:竞争激烈的情况下,没有公平性,可能存在一个线程一直获取不到。
    • 性能问题:竞争激烈的情况下,自旋锁锁状态中心化,锁状态变更会导致多个 CPU 的高速缓存的频繁同步,从而拖慢 CPU 效率。
/**
 *                      +-----------+
 *                      |   临界区   |
 *                      |  (线程1) |
 *      (线程2自旋) <——+ +-----------+ <——+ (线程3自旋)
 *                |  |               |  |
 *                +——>               +——>
 */
class Spin {
    /**
     * 中心化锁状态
     */
    AtomicReference<Thread> spin = new AtomicReference<>();

    /**
     * 循环直到条件满足
     */
    public void lock() {
        while (!spin.compareAndSet(null, Thread.currentThread()));
    }

    public void unlock() {
        spin.compareAndSet(Thread.currentThread(), null);
    }
}

1.2、CLH

C、L、H 三个人一起针对自旋改进的锁结构,故取三人名字首字母命名。

  • 改进点:
    • 锁饥饿:增加队列(FIFO)保证公平性
    • 性能: 锁状态去中心化,让每个线程在不同的状态变量中自旋,这样当一个线程释放它的锁时,只能使其后续线程的高速缓存失效;
    • 缩小了影响范围,从而减少了 CPU 的开销。
  • 实现:队列采用伪链表实现:没有实际指针,只是针对前一个节点的值进行判断,不需要遍历整个队列。
  • 缺点:
    • 1、自旋的缺点,即锁持有时间很长时,CPU一直在空转;
    • 2、功能单一,不支持复杂的功能,比如前驱节点线程因超时而取消获取锁时,节点里的锁状态将不会修改,导致后续节点永远自旋下去。
/**
 *   +-------+  +-----------+             +-----------+             +-----------+            +-----------+
 *   | 临界区 |  |Thread1    |             |Thread2    |             |Thread3    |            |Thread4    |
 *   |Thread1|  |       True|-----<---+---|       True|-----<---+---|       True|-----<---+--|       True|
 *   +------+   +-----------+  | 自旋 |    +-----------+  | 自旋 |   +-----------+  | 自旋 |   +-----------+
 *                   Node      +-->--+         Node      +-->--+            Node   +-->--+        tail
 */
class CLH {

    /**
     * 去中心化锁状态
     * 当前线程修改当前锁状态,供后面一个线程读取。
     * 2个线程写读同一个变量,用volatile修饰保证内存可见性。
     */
    private static class Node{
        volatile boolean locked;
    }

    /**
     * 存储当前线程的锁节点状态,用于后继节点读取。
     */
    ThreadLocal<Node> node = ThreadLocal.withInitial(Node::new);

    /**
     * 尾节点指向哨兵
     */
    AtomicReference<Node> tail = new AtomicReference<>(new Node());

    public void lock() {
        final Node currentThreadNode = this.node.get();
        currentThreadNode.locked = true;
        final Node preNode = tail.getAndSet(currentThreadNode);
        while (!preNode.locked); // 对前一个线程的锁状态自旋
    }

    /**
     *    死循环:1、Thread3 修改 locked = false 后,
     *           2、Thread4还未读取到该状态时(譬如操作系统分时调度暂停该线程运行),Thread3再次执行 lock() 后,导致死循环。
     *                          Tail
     *      +-----------+   +-----------+
     *      |Thread3    |   |Thread4    |
     *   +--|       True|<--|       True|<---+
     *   |  +-----------+   +-----------+    |
     *   |___________________________________|
     */
    public void unlock() {
        final Node currentThreadNode = this.node.get();
        currentThreadNode.locked = false; // 下一个线程的 lock() 会成功读取该状态,终止循环(自旋)
        node.set(new Node()); // 避免死循环,同时为下一次当前线程上锁做准备。
    }
}

1.3、AQS

AbstractQueuedSynchronizer简称 AQS,详见 java.util.concurrent.locks.AbstractQueuedSynchronizer,广泛使用在 SemaphoreCountDownLatchReentrantLockReentrantReadWriteLockThreadPoolExecutor.Worker等并发场景下。

AQS 核心思想:如果被请求的共享资源空闲,那么就将当前请求资源的线程设置为有效的工作线程,将共享资源设置为锁定状态;如果共享资源被占用,就需要一定的阻塞等待唤醒机制来保证锁分配。这个机制主要用的是CLH队列的变体实现的,将暂时获取不到锁的线程加入到队列中。

  • 改进点:
    • 自旋缺点:锁持有时间长时,阻塞获取锁的线程,避免CPU空转。
    • 功能单一:用显示队列,即Node增加显示前驱(prev)后继(next)节点,扩展节点状态([boolean]locked -> [int]status)。
    • 举例: 队列一个线程取消获取锁时,会修改节点状态。当前线程释放锁时,会根据节点状态唤醒后继正常等待的节点,从而解除阻塞。
  • 节点状态:
    •      SIGNAL	    表示该节点正常等待
      
    •      PROPAGATE	应将 releaseShared 传播到其他节点
      
    •      CONDITION	该节点位于条件队列,不能用于同步队列节点
      
    •      CANCELLED	由于超时、中断或其他原因,该节点被取消
      
/**
 *                                  +-----------+
 *                                  |   state   |
 *                                  |   临界区   |
 *                    (----------->| (线程1)  |<--------)
 *                  (              +-----------+           )
 *                (               (        )               )
 *               (              (           )               )
 *              (             (               )              )
 *        +-----------+   +-----------+   +-----------+   +-----------+
 *        |status     |   |status     |   |status     |   |status     |
 *        |Thread1    |   |Thread2    |   |Thread3    |   |Thread4    |
 *        |prev       |<--|prev       |<--|prev       |<--|prev       |
 *        |       next|-->|       next|-->|       next|-->|       next|
 *        +-----------+   +-----------+   +-----------+   +-----------+
 *             head            Node            Node            tail
 *
 */
class AQS {
    static class Node {
        volatile Node prev;       // initially attached via casTail
        volatile Node next;       // visibly nonnull when signallable
        Thread waiter;            // visibly nonnull when enqueued
        volatile int status;      // written by owner, atomic bit ops by others
    }

    /**
     * 也是一个哨兵模式,并且懒加载
     */
    private transient volatile Node head;

    /**
     * 实例化后,通过 casTail 方法来修改
     */
    private transient volatile Node tail;

    // 共享变量,使用volatile修饰保证线程可见性
    private volatile int state;

    /** tries once to CAS a new dummy node for head
     * 详见 AbstractQueuedSynchronizer.tryInitializeHead()
     * */
    private void tryInitializeHead() {
        Node h = new Node(); // 哨兵模式
        if (CAS(this, null, h))
            tail = h;
    }
}
### AQSCLH队列的实现细节 AQS(AbstractQueuedSynchronizer)是Java并发包中的一个核心组件,用于构建锁和其他同步器的基础框架。它内部使用了一个FIFO的CLH队列来管理线程的等待和唤醒过程[^1]。 #### 1. CLH队列的基本概念 CLH(Craig, Landin, and Hagersten)队列是一种基于链表的队列结构,最初设计用于自旋锁。在AQS中,CLH队列被改造为阻塞队列,用于管理线程的排队和唤醒。每个节点代表一个等待获取资源的线程。当线程尝试获取锁失败时,会创建一个新的节点并将其加入队列末尾[^4]。 ```java 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; } } enq(node); return node; } ``` 上述代码展示了如何将一个线程包装成`Node`对象,并将其插入到CLH队列中。如果当前队列尾部不为空,则尝试通过CAS操作快速将新节点添加到队列末尾;否则调用`enq()`方法进行完整初始化。 #### 2. AQS的核心方法 AQS的核心逻辑围绕以下几个方法展开: - **`tryAcquire()`**:由子类实现,用于尝试获取锁。如果返回`false`,则调用`addWaiter()`将线程加入队列。 - **`acquireQueued()`**:将线程放入等待状态,直到成功获取锁或被中断。 - **`isHeldExclusively()`**:判断当前线程是否独占持有锁,主要用于条件变量的支持[^3]。 #### 3. 条件变量的支持 AQS还支持条件变量(Condition),通过`ConditionObject`类实现。线程可以通过`await()`方法进入等待状态,并通过`signal()`方法唤醒其他线程。这一机制类似于`Object`类中的`wait()`和`notify()`方法[^2]。 ```java public class ConditionAwaitSignalDemo { public static void main(String[] args) { ReentrantLock lock = new ReentrantLock(); Condition condition = lock.newCondition(); new Thread(() -> { try { System.out.println(Thread.currentThread().getName() + " come in."); lock.lock(); condition.await(); } catch (InterruptedException e) { e.printStackTrace(); } finally { lock.unlock(); } System.out.println(Thread.currentThread().getName() + " 换醒."); }, "Thread A").start(); new Thread(() -> { try { lock.lock(); condition.signal(); System.out.println(Thread.currentThread().getName() + " 通知."); } finally { lock.unlock(); } }, "Thread B").start(); } } ``` 上述代码演示了`Condition`接口的使用方式。`Thread A`调用`await()`进入等待状态,而`Thread B`通过`signal()`唤醒等待中的线程[^2]。 #### 4. 队列的延迟初始化 CLH队列在AQS中的实现具有延迟初始化的特点。只有当第一个线程尝试获取锁失败时,才会创建队列的第一个节点(即头节点)。头节点是一个特殊的哑节点,其`thread`字段为`null`。 ### 总结 AQS通过CLH队列实现了线程的有序等待和唤醒机制。它的设计灵活且高效,能够满足多种同步场景的需求。无论是独占锁还是共享锁,AQS都能通过统一的队列管理机制提供支持。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值