队列同步器AQS源码解读

队列同步器AQS源码解读,第1张

队列同步器AQS源码解读

队列同步器AQS源码解读

一:队列同步器AQS(AbstractQueuedSynchronizer)

1、AQS成员变量:2、FIFO等待队列:3、Node成员变量:4、tryAcquire()方法5、Acquire()方法6、addWaiter()方法7、acquireQueued()方法8、tryRelease()、release()方法

一:队列同步器AQS(AbstractQueuedSynchronizer)

首先膜拜一下大神Doug Lea

队列同步器 AbstractQueuedSynchronizer,是用来构建锁或者其他同步组件的基础框架,它使用了一个int成员变量表示同步状态,通过内置的FIFO队列来完成资源获取线程的排队工作。同步器的设计是基于模板方法模式的,也就是说,使用者需要继承同步器并重写指定的方法,随后将同步器组合在自定义同步组件的实现中,并调用同步器提供的模板方法,而这些模板方法将会调用使用者重写的方法。

1、AQS成员变量:


这个判断共享资源是否正在被占用的标记位state为什么是int类型而不是boolean类型呢?

这是由于AQS是支持独占和共享两种加锁模式的,那么在共享模式下,state可以用来记录线程占用的数量。

这篇文章只讲独占模式

2、FIFO等待队列:

3、Node成员变量:

4、tryAcquire()方法

尝试获取锁(修改标记位state),如果没有获取锁也没关系,立即返回。

参数为一个 int 值,代表对state的修改。返回值是boolean,代表是否成功获得锁。

需要子类重写实现
例:

在上层应用调用tryacquire()方法成功时则获得锁,此时可以对共享资源进行 *** 作,使用完再进行释放。如果获取锁失败,此时,如果上层应用不想等待锁,那么可以直接进行相应的处理。如果选择等待锁,那么就可以直接调用acquire()方法,而不用自己去进行这个复杂的排队处理。
十分的灵活。

5、Acquire()方法

获取锁(修改标记位state),愿意进入队列等待,直到获取锁。

如果 tryAcquire 为 true(线程通过tryAcquire已经成功获取锁),!tryAcquire则为false,直接跳出 if 判断条件。

如果 tryAcquire 为 false,!tryAcquire则为 true,那么此时会进行后续 *** 作:acquireQueued(addWaiter(Node.EXCLUSIVE), arg),进行排队等待锁。

6、addWaiter()方法

将当前线程封装成Node加入等待队列的队尾。

我们可以发现插入尾结点的 *** 作 compareAndSetTail 是一个CAS方法。

观察发现:如果当前尾结点pred为空,或者是第一次尝试CAS *** 作失败时。那么就会进入完整的入队方法 enq()。

在这个方法中,会不断的进行自旋,直到成功的将当前节点插入到队尾。

将当前线程封装成Node加入到了等待队列之后呢,不能就这样不管了。还要尝试让队列动起来,因为这是一个先进先出的队列嘛,需要让队伍不断地前进,让队伍后面的节点能够尽快的获取锁。这部分也是最难的地方

7、acquireQueued()方法

这个方法配合 release() 方法是对线程进行挂起响应 *** 作。以此来实现队列的先入先出,十分的巧妙。

方法的主干还是一个自旋的 *** 作,首先获取当前节点的前置节点,如果当前节点的前置节点为head且当前节点成功地获取到锁,那么就把这个节点node设为头结点,node出队,返回 false(那么对应前面的acquire()方法,不满足 if 判断条件,就不会执行 selfInterrupt()方法(中断当前线程))。

很多情况下当前节点的前置节点不是 head,或者说是头结点但是尝试获取锁失败,那么就会执行这一段,判断当前线程是否被挂起。

为什么要把线程挂起呢?直接自旋直到当前节点获取锁然后在直接返回不就行了?
逻辑上来说是可以的,但是自旋是一个CPU *** 作,大量的自旋一定会出现性能问题。所以我们需要将那些还没轮到它出队的那个线程挂起,再在适合的时间把它们唤醒,这样就能避免大量的自旋 *** 作。

Java的挂起和中断机制详见这篇文章:

如果 waitStatus = SIGNAL,那说明前置节点也在等待拿锁,所以当前节点是可以挂起休息的。那么就直接返回 true
如果 waitStatus > 0,那么说明状态只可能是 CANCELLED,所以可以将其从队列中删除。返回 false,进行下一轮的判断。
如果 waitStatus 是其他状态,既然当前节点已经加入等待队列,那么前置节点就应该做好准备来等待锁,所以通过CAS将前置节点的 waitStatus 置为SIGNAL。返回 false,进行下一轮的判断。

如果 shouldParkAfterFailedAcquire() 返回true,表示当前线程需要被挂起,则执行真正的挂起。

通过对 acquireQueued() 这个方法的分析,我们总结出:
如果当前线程所在的节点处于头结点的后面一个,那么将会不断的去尝试拿锁,直到拿锁成功,否则进行判断是否需要挂起。那么如何判断是不是需要挂起?判断条件是这样的,如果当前线程所在的节点之前除了head还有其他节点,并且 waitStatus = SIGNAL,那么当前节点就需要被挂起。这样就能保证head的之后只有一个节点在通过CAS来获取锁。队列里的其他线程都已经被挂起或者正在被挂起。这样就能最大限度的避免无用的自旋消耗CPU。

但到这里,事情还没有结束,大量的线程被挂起,那么它们什么时候才能被唤醒呢?

8、tryRelease()、release()方法

我们先来猜测一下什么是合适的时机:应该是在一个线程使用完了共享资源并且要释放锁的的时候,这个时候才应该去唤醒其它正在等待锁的线程。

那么释放锁的时候到底发生什么事了呢?
下面来看一下具体实现:

假如尝试释放锁成功,那么下一步就要唤醒等待队列里的其他节点。

首先我们来看一下head结点,这里的head节点其实就是acquireQueued()方法中的幸运儿,它成功获得了锁,并且被置为head,到这里需要release的时候,它的使命已经完成了。这个时候head只是作为一个占位的虚节点,所以首先要将它的 waitStatus 置为0这个默认值,才不会影响其它函数的一些判断。

然后程序就会从队列的尾节点开始搜索,找到除了head节点之外最靠前的,并且 waitStatus 是 <= 0 的节点。对其进行LockSupport.unpark(s.thread);操作,即唤醒该挂起的线程。

之前被挂起的线程一旦被唤醒,那么它将会继续执行acquireQueued()这个方法进行自旋尝试获取锁。这样就形成了一个良好的闭环,拿锁、挂起、释放、唤醒都能够有条不紊的进行。

本篇文章摘自B站寒食君的视频讲解:并发编程的意义是什么?月薪30K必知必会的Java AQS机制

欢迎分享,转载请注明来源:内存溢出

原文地址: http://outofmemory.cn/zaji/5712443.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-12-17
下一篇 2022-12-17

发表评论

登录后才能评论

评论列表(0条)

保存