走进 java AQS——第二期共享模式核心方法源码解读

162 阅读7分钟

各位读者大家好呀!今天小卡给大家带来的是AQS的共享加锁过程源码解读~

7a02ce7526a0ab529e4885d9714a0d6c.jpg

1. AQS 共享模式的核心流程

在共享模式中,多个线程可以同时获得共享资源。整个过程包括以下几个核心步骤:

  1. 尝试获取资源
    • 每个线程首先调用 tryAcquireShared(int arg) 方法尝试获取资源。这个方法由具体的同步器实现(如 ReadLockSemaphore)进行定义。
    • tryAcquireShared 返回的值表示当前资源的状态:
      • 如果返回值大于或等于 0,表示资源获取成功,当前线程可以继续执行。
      • 如果返回值小于 0,表示资源暂不可用,当前线程需要进入等待状态。
  1. 入队和挂起
    • 如果线程未成功获取资源(tryAcquireShared 返回值小于 0),AQS 将该线程加入到等待队列(CLH 队列)中。
    • 随后调用 doAcquireShared(int arg) 方法,线程会在这里被阻塞挂起,直到有其他线程释放资源并唤醒它。
    • 挂起操作通过 LockSupport.park() 实现,线程在队列中等待被唤醒。
  1. 共享模式的传播唤醒
    • 一旦某个线程成功获取共享资源,它会调用 setHeadAndPropagate(Node node, int propagate) 方法。
    • 这个方法的主要任务是设置新的头节点,并尝试唤醒后续等待共享资源的节点,形成链式唤醒过程。
    • 这种“传播唤醒”机制确保多个等待的线程能够连续获得资源,直到资源耗尽或没有共享节点为止。
  1. 重复获取资源的过程
    • 如果当前线程被唤醒,将重新尝试获取资源。
    • doAcquireShared 方法中,每个线程会不断调用 tryAcquireShared,直到资源可用并成功获取。

2. 共享模式的关键方法源码解读

//获取共享锁
public final void acquireShared(int arg) {
//首先尝试获取共享锁,成功返回当前获取共享锁的线程数,
//失败返回小于零的整形,让添加到同步队列中,排队获取锁。
if (tryAcquireShared(arg) < 0)
	doAcquireShared(arg);
}

//这里就是将节点添加到同步队列后,的处理逻辑。
//这个方法和AcquireQueue()大部分逻辑都差不多,在这里主要提一下不同的地方。
private void doAcquireShared(int arg) {
	//这里插入节点是设置的共享模式
	final Node node = addWaiter(Node.SHARED);
	boolean failed = true;
	try {
		boolean interrupted = false;
		for (;;) {
			//其实在这里有个问题上一期是没有提到的
			//Q1:为什么,进入同步队列后没有直接被挂起,而是再次尝试获取锁,然后被挂起?
			final Node p = node.predecessor();
			if (p == head) {
				int r = tryAcquireShared(arg);
				if (r >= 0) {
					//这个方法比较关键。
					//因为这个方法中涉及到了共享加锁的关键也就是传播唤醒。
					//什么是传播唤醒?因为共享的获取资源是同时多个线程获取某一资源,
					//所以在唤醒线程时,应该传播的把后继连续的等待共享的获取资源的线程,
					//都一一唤醒直到后继节点,不再是希望共享的获取资源。
					//那么是怎么做到传播唤醒的呢?但某一个共享的获取资源的线程被一个
					//互斥的获取的线程唤醒后,当这个共享的获取资源的线程尝试获取成功后
					//会唤醒它的直接后继为共享的获取资源的线程,然后一直这样传播唤醒,
					//直到直接后继为非共享的获取资源的线程。
					setHeadAndPropagate(node, r);
					p.next = null; // help GC
					if (interrupted)
						selfInterrupt();
					failed = false;
					return;
				}
			}
			if (shouldParkAfterFailedAcquire(p, node) &&
				parkAndCheckInterrupt())
				interrupted = true;
		}
	} finally {
		if (failed)
			cancelAcquire(node);
	}
}

 pivate void setHeadAndPropagate(Node node, int propagate) {
	    //这里设置当前节点为头节点,上一期有讲过,这里就不在赘述了。
        Node h = head; // Record old head for check below
        setHead(node);
        /*
         * Try to signal next queued node if:
         *   Propagation was indicated by caller,
         *     or was recorded (as h.waitStatus either before
         *     or after setHead) by a previous operation
         *     (note: this uses sign-check of waitStatus because
         *      PROPAGATE status may transition to SIGNAL.)
         * and
         *   The next node is waiting in shared mode,
         *     or we don't know, because it appears null
         *
         * The conservatism in both of these checks may cause
         * unnecessary wake-ups, but only when there are multiple
         * racing acquires/releases, so most need signals now or soon
         * anyway.
         */
	    //
        if (propagate > 0 || h == null || h.waitStatus < 0 ||
            (h = head) == null || h.waitStatus < 0) {
            Node s = node.next;
			//后继节点为共享的获取资源的线程节点
            if (s == null || s.isShared())
                doReleaseShared();
        }
    }

//唤醒后继节点
private void doReleaseShared() {
        /*
         * Ensure that a release propagates, even if there are other
         * in-progress acquires/releases.  This proceeds in the usual
         * way of trying to unparkSuccessor of head if it needs
         * signal. But if it does not, status is set to PROPAGATE to
         * ensure that upon release, propagation continues.
         * Additionally, we must loop in case a new node is added
         * while we are doing this. Also, unlike other uses of
         * unparkSuccessor, we need to know if CAS to reset status
         * fails, if so rechecking.
         */
        for (;;) {
            Node h = head;
            if (h != null && h != tail) {
                int ws = h.waitStatus;
                if (ws == Node.SIGNAL) {
                    if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
                        continue;            // loop to recheck cases
                    unparkSuccessor(h);
                }
                else if (ws == 0 &&
                         !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                    continue;                // loop on failed CAS
            }
            if (h == head)                   // loop if head changed
                break;
        }
    }

3. Q1:为什么,进入同步队列后没有直接被挂起,而是再次尝试获取锁,然后被挂起?

这里的逻辑采用了先尝试获取资源,再决定是否挂起的策略,主要是为了提高性能,减少不必要的挂起和唤醒操作。这种设计在高并发场景中能有效避免频繁的上下文切换。具体来说,原因可以分为以下几点:

3.1. 避免不必要的挂起

AQS 中,挂起线程的操作(例如 park())是一个相对较重的操作,会导致线程进入等待状态,需要操作系统调度来恢复运行,这会带来性能上的开销。因此,在挂起线程之前,尽量通过一次 tryAcquireShared 检查来确定是否真的需要挂起。

假设此时资源刚刚被释放,而此线程还没有被唤醒,那么它可以通过再一次尝试获取共享资源来避免不必要的挂起。如果直接挂起,线程可能会因为错过资源释放而进行无意义的等待,进而造成系统资源的浪费。

3.1.1. 示例

例如:

  • T1 是当前持有资源的线程,T2T3 是等待获取共享资源的线程。
  • T1 释放资源后,如果 T2 再次尝试获取资源,可能会直接成功获取到资源,从而避免挂起。
  • 如果没有这次尝试,T2 会直接挂起,导致系统需额外调度唤醒 T2 和后续线程。

3.2. 提高响应性,避免“惊群效应”

在共享模式中,多个线程可以同时获取共享锁。如果每个线程都直接挂起,将导致队列中的线程只能一个个被唤醒,这可能导致“惊群效应”——每次资源释放都会引发大量线程争抢,增加系统开销和资源争用。

先尝试获取资源后,再决定是否挂起,有助于让成功获取到资源的线程立即执行,提升了响应性。同时,后续传播唤醒的过程也变得更为自然,系统不必频繁处理大量线程的调度与唤醒,从而减少惊群效应的开销。

3.3. 保证公平性,防止饥饿

共享资源的获取机制设计为每次成功获取资源的线程会唤醒自己的直接后继共享节点,形成一个“传播唤醒”链。这一机制在公平队列中能够保证资源按照到达顺序分配给等待的线程,避免了线程饥饿。

在每个线程挂起之前,系统通过 tryAcquireShared 来检查当前资源是否可以被获取。这种机制确保了先到的线程能够更快获得资源,从而避免了长时间等待或饥饿的情况。

3.4. 保证链式传播唤醒

在共享模式下,tryAcquireShared 允许多个线程同时获取资源,因此一旦某个线程成功获取资源,它将调用 setHeadAndPropagate 方法继续唤醒后续节点,实现链式唤醒。这种链式传播机制确保多个线程能连续获得共享资源,而不是每次只唤醒一个线程。

挂起之前的再尝试操作,使得第一个成功获取资源的线程可以进入链式传播过程,唤醒后续节点。这种设计避免了在挂起之后再唤醒多个节点带来的复杂调度开销,使得线程可以在当前操作中自行完成链式唤醒的任务。

好的本期内容就讲到这里,如果读者们在阅读中有什么不同地意见欢迎到评论区进行指正哈~