走进 java AQS——第一期互斥加锁及解锁过程源码解读

271 阅读12分钟

亲爱的各位读者大家好呀!本期小卡将代领大家走进java JUC包下的 AQS(队列同步器)。先简单的介绍一下AQS吧!

AQS(Abstract Queue Synchronizer)是Java中一个用于实现同步器的抽象类,位于java.util.concurrent包中。它提供了构建复杂同步工具(如锁、信号量和屏障)的基础框架。AQS的核心功能包括:

  1. 状态管理:使用一个整型变量表示同步状态(如锁的占用情况)。

  2. 线程排队:当多个线程请求同步资源时,AQS会将这些线程组织成一个FIFO队列,确保公平性。

  3. 独占和共享模式

    • 独占模式:允许只有一个线程访问(如ReentrantLock)。
    • 共享模式:允许多个线程同时访问(如Semaphore)。

开发者可以通过继承AQS并重写相关方法(如tryAcquiretryRelease等)来实现自定义的同步行为。总的来说,AQS是Java并发编程中非常重要的组件,为高效的多线程同步提供了灵活的解决方案。

好的,那么我们下面正式进入源码解读环节。

节点结构

static final class Node {
    /** 标记节点正在以共享模式等待 */
    static final Node SHARED = new Node();
    /** 标记节点正在以独占模式等待 */
    static final Node EXCLUSIVE = null;

    /** 表示线程已取消的 waitStatus 值 */
    static final int CANCELLED =  1;
    /** 表示后继节点的线程需要被唤醒的 waitStatus 值 */
    static final int SIGNAL    = -1;
    /** 表示线程正在等待条件的 waitStatus 值 */
    static final int CONDITION = -2;
    /**
     * 表示下一次 acquireShared 应无条件传播的 waitStatus 值
     */
    static final int PROPAGATE = -3;

    /**
     * 状态字段,只取以下值:
     *   SIGNAL:     表示该节点的后继节点已被(或将要)阻塞(通过 park),
     *               因此当前节点在释放或取消时必须唤醒其后继节点。
     *               为了避免竞态,acquire 方法必须首先表明它们需要一个信号,
     *               然后重试原子获取操作,如果失败则阻塞。
     *   CANCELLED:  此节点因超时或中断而被取消。
     *               节点一旦进入此状态就不会再离开,特别是具有取消节点的线程不会再次阻塞。
     *   CONDITION:  此节点当前在条件队列中。
     *               它不会被用作同步队列节点,直到被转移,此时状态将被设置为 0。
     *               (在此使用此值与其他用途无关,但简化了机制)。
     *   PROPAGATE:  一个 releaseShared 应该被传播到其他节点。
     *               此值仅在 head 节点中设置,以确保即使有其他操作介入,传播仍将继续。
     *   0:          无上述情况
     *
     * 值的顺序是按数值排列,以简化使用。
     * 非负值表示节点不需要发送信号。因此,大部分代码不需要检查特定值,只需检查符号。
     *
     * 对于普通的同步节点,该字段初始化为 0,对于条件节点则初始化为 CONDITION。
     * 该字段通过 CAS 修改(或者在可能的情况下,使用无条件的 volatile 写操作)。
     */
    volatile int waitStatus;

    /**
     * 指向前驱节点的链接,当前节点/线程依赖该节点来检查 waitStatus。
     * 在入队期间分配,并在出队时置空(为了垃圾回收)。
     * 此外,在取消前驱节点时,我们会在查找非取消节点时进行跳过,
     * 因为头节点永远不会被取消:节点只有在成功获取后才能成为头节点。
     * 一个被取消的线程永远不会成功获取,并且线程只能取消自身,而不会取消其他节点。
     */
    volatile Node prev;

    /**
     * 指向后继节点的链接,当前节点/线程在释放时会唤醒它。
     * 在入队期间分配,在绕过取消的前驱节点时进行调整,并在出队时置空(为了垃圾回收)。
     * 入队操作在连接之前不会分配前驱的 next 字段,因此看到一个空的 next 字段并不一定意味着该节点在队列末尾。
     * 然而,如果 next 字段为空,我们可以从尾部向前扫描 prev 以进行双重检查。
     * 取消的节点的 next 字段被设置为指向自身而不是 null,以简化 isOnSyncQueue 的实现。
     */
    volatile Node next;

    /**
     * 入队该节点的线程。在构造时初始化,并在使用后置空。
     */
    volatile Thread thread;

    /**
     * 指向等待条件的下一个节点的链接,或特殊值 SHARED。
     * 因为条件队列只在独占模式下访问,所以我们只需要一个简单的链式队列来保存正在等待条件的节点。
     * 它们随后被转移到队列中以重新获取。
     * 由于条件只支持独占模式,我们可以通过使用特殊值来指示共享模式,从而节省一个字段。
     */
    Node nextWaiter;

    /**
     * 如果节点正在以共享模式等待,则返回 true。
     */
    final boolean isShared() {
        return nextWaiter == SHARED;
    }

    /**
     * 返回前驱节点,如果为空则抛出 NullPointerException。
     * 当前驱节点不可能为空时使用。空检查可以省略,但存在是为了帮助虚拟机。
     *
     * @return 此节点的前驱节点
     */
    final Node predecessor() throws NullPointerException {
        Node p = prev;
        if (p == null)
            throw new NullPointerException();
        else
            return p;
    }

    Node() {    // 用于建立初始头节点或共享标记
    }

    Node(Thread thread, Node mode) {     // 由 addWaiter 使用
        this.nextWaiter = mode;
        this.thread = thread;
    }

    Node(Thread thread, int waitStatus) { // 由 Condition 使用
        this.waitStatus = waitStatus;
        this.thread = thread;
    }
}

互斥加锁过程

//获取锁
public final void acquire(int arg) {
//首先尝试获取锁(非阻塞式)
if (!tryAcquire(arg) && 
	//同步获取,将节点添加到同步队列中Node.EXCLUSIVE标识为独占式。
	acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) 
	selfInterrupt();
}

//向同步队列中添加节点
private Node addWaiter(Node mode) {
	//mode 为null表示独占式获取锁,此时waitState为0(INiTAL)
	Node node = new Node(Thread.currentThread(), mode); 
	Node pred = tail; 
	//然后尝试通过cas快速将节点添加到同步队列的尾部,如果失败就开启自旋添加
	if (pred != null) {
		node.prev = pred;
		if (compareAndSetTail(pred, node)) {
			pred.next = node;
			return node;
		}
	}
	//自旋添加。
	enq(node); 
	return node;
}

//自旋从尾部添加节点
private Node enq(final Node node) {
	//不断自旋cas尝试往同步队列的尾部添加一个新节点,直到添加成功。
	for (;;) { 
		Node t = tail;
		// 这里是在初始时同步队列为空时添加哑节点
		// Q1:设置一个哑结点的作用是什么呢?
		if (t == null) { 
			if (compareAndSetHead(new Node()))
				tail = head;
		} else {
			node.prev = t;
			if (compareAndSetTail(t, node)) {
				t.next = node;
				return t;
			}
		}
	}
}

//这里就是将节点添加到同步队列后,的处理逻辑。
final boolean acquireQueued(final Node node, int arg) {
	//这个字段决定了再最后的finally代码块中是否需要将节点设置为取消状态。
	boolean failed = true;
	try {
		boolean interrupted = false;
		//在队列中通过这个循环在 尝试获取锁->被挂起->被唤醒这几个状态中来回切换知道获取锁成功。
		for (;;) {
			//获取前置节点
			final Node p = node.predecessor();
			//当前置节点为头结点,并且尝试加锁成功
			if (p == head && tryAcquire(arg)) {
				//设置当前节点为头结点。
				setHead(node);
				p.next = null; // help GC
				failed = false;
				return interrupted;
			}
			//检查当前节点在获取锁失败后是否需要被挂,然后挂起并检查中断。
			if (shouldParkAfterFailedAcquire(p, node) &&
				parkAndCheckInterrupt())
				interrupted = true;
		}
	} finally {
		//判断是否需要将节点状态设置为取消。
		if (failed)
			cancelAcquire(node);
	}
}

//将传入节点设置为头结点
private void setHead(Node node) {
    //将当前节点设置为头结点
    head = node;
    //将节点中对于当前线程的引用清空,便于后续线程的回收
    node.thread = null;
    //将节点中对于前置节点的引用清空,便于后续前直节点的回收
    node.prev = null;
}

//判断是否能够安全挂起
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
	int ws = pred.waitStatus;
	//前置节点waitState == Node.SIGNAL(-1)是才能唤醒后继节点
	if (ws == Node.SIGNAL)
		/*
         * 前置节点状态为SIGNAL
         * 因此可以安全地进入休眠状态(park)。
         */
		return true;
	if (ws > 0) {
		/*
         * 前驱节点已被取消。跳过并移除前驱节点并
         */
		do {
			node.prev = pred = pred.prev;
		} while (pred.waitStatus > 0);
		pred.next = node;
	} else {
		/*
         * waitStatus 必须为 0 或 PROPAGATE。
         * 表示我们需要一个信号,但还不进入休眠状态。
         * 调用者需要重试,以确保在进入休眠前无法获取锁。
		 * Q2:围绕这里需要重试但是前面第一个if(ws==Node.SIGNAL)不做重试的原因。
		 * 在后文会有解答我们先在这埋个点
         */
		compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
	}
	return false;
}

//挂起并判断但前线程是否被中断
private final boolean parkAndCheckInterrupt() {
	LockSupport.park(this);
	return Thread.interrupted();
}

互斥解锁过程

//解锁的代码
public final boolean release(int arg) {
    //在尝试解锁成功后通过头结点去唤醒后续节点抢占锁
    if (tryRelease(arg)) {
	    Node h = head;
	    if (h != null && h.waitStatus != 0)
		    unparkSuccessor(h);
	    return true;
    }
    return false;
}

//唤醒后继没有被取消的第一个节点
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);
}

 Q1:设置一个哑结点的作用是什么呢?

笔者不知道大家在阅读互斥加锁源码的过程中,有没有想过为什么AQS采用的是头结点去唤醒后继节点,而不是直接让加锁的线程去唤醒头结点,就是说当一个线程释放掉锁的时候,其实这里有两种情况,就是在非公平锁的情况下,可能释放锁的线程在同步队列中,也可能这个线程压根没有被添加到同步队列中,也就是这个线程一次抢锁就成功的情况,如果是这样的话要笔者来设计那肯定就是直接让解锁的线程来唤醒,同步队列的头部节点,也就是在同步队列中头结点锁持有的线程加锁成功,哎就直接把他移除同步队列,然后把后继节点设置为头结点。那么这样的话,加锁的线程的在释放锁的时候就只需要去唤醒同步队列的头结点就行了对吧。咋一看确实是没有毛病但是还是存在一些问题的?这个问题留到后面的系列再来向读者们解答,读者们也可以自己开动脑筋思考一下。那么我们回到Q1,既然AQS采用的是头节点唤醒后继节点(我什么要这样设计我们姑且不论),那么就存在一个问题:初始的时候队列为空,那么添加到同步队列的第一个线程该有谁来唤醒呢?没错就是通过这个哑结点。其实同步队列中的头结点都可以说是哑节点,因为他内部持有的线程在该节点加锁成功时就已经别清空了,那么这个节点就已经不具备实际意义了,它的职责目前来看就只有一个那就唤醒后继节点。

Q2:围绕这里需要重试但是前面第一个if(ws==Node.SIGNAL)不做重试的原因?

//这里就是将节点添加到同步队列后,的处理逻辑。
final boolean acquireQueued(final Node node, int arg) {
	//这个字段决定了再最后的finally代码块中是否需要将节点设置为取消状态。
	boolean failed = true;
	try {
		boolean interrupted = false;
		//在队列中通过这个循环在 尝试获取锁->被挂起->被唤醒这几个状态中来回切换知道获取锁成功。
		for (;;) {
			//获取前置节点
			final Node p = node.predecessor();
			//当前置节点为头结点,并且尝试加锁成功
			if (p == head && tryAcquire(arg)) {
				//设置当前节点为头结点。
				setHead(node);
				p.next = null; // help GC
				failed = false;
				return interrupted;
			}
			//检查当前节点在获取锁失败后是否需要被挂,然后挂起并检查中断。
			if (shouldParkAfterFailedAcquire(p, node) &&
				parkAndCheckInterrupt())
				interrupted = true;
		}
	} finally {
		//判断是否需要将节点状态设置为取消。
		if (failed)
			cancelAcquire(node);
	}
}

//判断是否能够安全挂起
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
	int ws = pred.waitStatus;
	//前置节点waitState == Node.SIGNAL(-1)是才能唤醒后继节点
	if (ws == Node.SIGNAL)
		/*
         * 前置节点状态为SIGNAL
         * 因此可以安全地进入休眠状态(park)。
         */
		return true;
	if (ws > 0) {
		/*
         * 前驱节点已被取消。跳过并移除前驱节点并
         */
		do {
			node.prev = pred = pred.prev;
		} while (pred.waitStatus > 0);
		pred.next = node;
	} else {
		/*
         * waitStatus 必须为 0 或 PROPAGATE。
         * 表示我们需要一个信号,但还不进入休眠状态。
         * 调用者需要重试,以确保在进入休眠前无法获取锁。
		 * Q2:围绕这里需要重试但是前面第一个if(ws==Node.SIGNAL)不做重试的原因。
		 * 在后文会有解答我们先在这埋个点
         */
		compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
	}
	return false;
}

这个问题笔者有查过一些资料,不过说的都比较笼统,所以笔者在这里给出一些自己的看法吧?我们首先来看看在后面的两个条件判断中到底都做了些什么操作,在ws>0时也就是前驱节点被取消的情况下主要做的是跳过并移除前驱节点, 来保证当前线程所在的节点在后续能够被唤醒,else中主要做的就是确保前置节点具有唤醒后继节点的条件,也就是waitState!=0;二这两操作看起来就比较耗时对吧,那么要是此时前置节点刚好释放锁,那么他就会来唤醒当前线程,可是当前线程并没有被挂起呀,那么唤醒失败,然后当前线程继续执行后面的逻辑被挂起了,那么极端情况下就有可能导致同步队列中的线程再也不会被唤醒了,所以这是一个巨大的隐患,必须要避免,所以才有去做重试。但是按理来说第一个条件判断也有可能发什么类似的情况呀,哪里为什么不需要重试呢,在这里笔者认为第一个原因是第一个条件判断它的执行速度很快,所以出现类似上文所述情况的概率极低,第二点则是,要是在第一个条件判断也重试那代码就没法写了呀。

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