阅读 885

终于搞懂了JUC中的AQS😼

AQS提供了一个框架来实现阻塞锁和依赖于先进先出(FIFO)等待队列的相关同步器(信号量、事件等),是实现 ReentrantLock、CountDownLatch、Semaphore、FutureTask 等类的基础核心,对应于java.util.concurrent.locks.AbstractQueuedSynchronizer。

AQS同时支持独占锁共享锁。通常,实现AbstractQueuedSynchronizer的子类只支持其中一种模式,但这两种模式都可以发挥作用,例如在ReadWriteLock中。只支持排他模式或只支持共享模式的子类不需要定义支持未使用模式的方法。

一、😸AQS基础之 CLH锁

CLH锁也是一种基于链表的可扩展、高性能、公平(提供先来先服务)的自旋锁,申请线程只在本地变量上自旋,它不断轮询前驱的状态,如果发现前驱释放了锁就结束自旋。由于是 Craig、Landin 和 Hagersten三位大佬的发明,因此命名为CLH锁。

1.1 数据结构:

locked:volatile 修饰的boolean变量表示加锁状态,true代表持有锁成功或正在等待加锁,false表示锁被释放。volatile修饰为了保证此变量对不同的线程可见。
tailNode:尾节点 currentThreadNode:当前节点

1.2 核心思想:

1.2.1 加锁过程

获取尾节点,如果尾节点是null,则表示当前线程是第一个过来抢锁的,可以直接加锁成功;如果不为空,则将当前节点设置为尾节点,并对当前节点的前驱结点的locked进行自旋,如果发现其前驱结点的locked字段变为了false,则给当前节点加锁成功。

1.2.2 释放锁过程

释放锁的过程主要是将当前节点locked标志位置为false的过程。也分情况,如果当前释放锁的线程节点是尾节点,则说明没有其他线程在等待队列中,直接将尾节点设置为null即可,否则需要将当前节点的locked标志位设置为false,来通知等待队列中线程锁已被释放。

二、😽AQS实现原理

2.1 AQS核心结构及解释

2.1.1 双向队列节点

对应实现为java.util.concurrent.locks.AbstractQueuedSynchronizer.Node Node

2.1.2 核心属性

1)state

private volatile int state;
复制代码

对应于java.util.concurrent.locks.AbstractQueuedSynchronizer#state。表示当前锁的状态,在不同的功能实现中代表不同的含义。比如在独占并且不可重入的锁实现中:0代表当前锁未被占用,1代表锁被占用;而在独占并且可重入的锁实现中:0代表当前锁未被占用,而大于0则表示被占用,且表示当前持有锁的线程重入的次数。可以通过getStatesetStatecompareAndSetState来检查或修改同步状态。

2)head

private transient volatile Node head;
复制代码

等待队列的头节点,是懒加载的

3)tail

private transient volatile Node tail;
复制代码

等待队列的尾节点,也是延迟初始化的,仅当调用java.util.concurrent.locks.AbstractQueuedSynchronizer#enq方法时被修改。也就是插入一个新节点时。

2)exclusiveOwnerThread

private transient Thread exclusiveOwnerThread;
复制代码

此属性继承自java.util.concurrent.locks.AbstractOwnableSynchronizer,代表当前持有独占锁的线程。

2.1.3 扩展方法

AbstractQueuedSynchronizer采用模板方法,将排队、阻塞等操作统一包装起来,仅暴漏核心方法根据需要实现功能覆写对应的方法即可,所有其他方法都声明为final,因为它们不能被独立更改。这些核心方法的实现需要是线程安全的,

排它锁对应相关方法:

    protected boolean tryAcquire(int arg) {
        throw new UnsupportedOperationException();
    }

    protected boolean tryRelease(int arg) {
        throw new UnsupportedOperationException();
    }

复制代码

共享锁对应相关方法:

    protected int tryAcquireShared(int arg) {
        throw new UnsupportedOperationException();
    }

    protected boolean tryReleaseShared(int arg) {
        throw new UnsupportedOperationException();
    }

复制代码

其他方法
该线程是否正在独占资源。只有用到condition才需要去实现它

    protected boolean isHeldExclusively() {
        throw new UnsupportedOperationException();
    }
复制代码

2.2 ReentrantLock为例分析加锁解锁实现

ReentrantLock特点:独占可重入支持公平和非公平锁

ReentrantLock内部java.util.concurrent.locks.ReentrantLock.Sync类继承了AQS,并对AQS中的tryReleaseisHeldExclusively进行了重写。而因为ReentrantLock分为公平锁和不公平锁,所以从Sync又派生出了FairSyncNonfairSync并且对AQS中的tryAcquire进行了重写,分别表示公平锁和非公平锁的实现。
Sync

2.2.3 公平式获取锁

整体大致步骤如下:
加锁步骤
入口:java.util.concurrent.locks.ReentrantLock.FairSync#lock

        final void lock() {
            acquire(1);
        }
复制代码

在lock方法中直接调用AQS的acquire(1);

    public final void acquire(int arg) {
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }
复制代码

在acquire中首先调用tryAcquire尝试获取锁,如果tryAcquire返回true,则此方法直接返回,代表加锁成功,否则需要执行addWaiter(Node.EXCLUSIVE)为当前线程创建队列节点并入队,然后调用acquireQueued(final Node node, int arg)再次尝试获取锁,或挂起当前线程等待被唤醒,被唤醒以后继续尝试获取锁,直到加锁成功。

查看tryAcquire具体实现:

以上其实还不算是AQS的核心,tryAquire方法说白了就是最终设置锁状态。AQS的核心还是在acquireQueued方法中,如下是acquireQueued方法的整体流程图: acquireQueued 流程

然后查看addWaiter具体实现分析: 查看enq()具体实现分析: 查看acquireQueued具体实现分析: 查看shouldParkAfterFailedAcquire的具体实现分析: shouldParkAfterFailedAcquire所做的操作能够保证,如果等待队列中有节点的waitStatus是 Node.SIGNAL,那么一定有线程处于挂起状态,需要被唤醒。

2.2.4 释放锁

释放锁相当就要简单一点,首先需要检查释放锁的线程是否就是持有锁的线程,如果都没持有锁,何来的释放呢?检查成功以后,就需要更改当前锁的状态;成功释放锁以后,最关键的一步就是需要唤醒阻塞队列中的等待中的线程了。解锁过程着重看AQS#releaseAQS#unparkSuccessor

AQS#release 看到这里成功释放锁之后,如何判断是否需要唤起阻塞队列中的等待线程的。首先获取到当前阻塞队列的头节点h,然后分析判断语句:h != null && h.waitStatus != 0。
1)h != null
这里首先判断了头节点不为空,假如说头节点都为空了,那么其实相当于队列就是空的,根本没有线程在等待,所以不需要唤起。
2)h.waitStatus != 0 走到这里,说明头节点是不为空的,但是,假如头节点的waitStatus是0也不满足。什么情况下waitStatus是0呢?这里需要往上翻加锁代码,查看AQS#enq,也就是说刚开始的头节点都是新建的,新建的Node,其waitStatus本身就是0,只有当有其他线程挂起时,才会将头部节点的waitStatus更新为-1,所以这里如果是0,一样可以断定当前是没有线程处于挂起状态。

AQS#unparkSuccessor 这里关键的就是找到排队中最靠前的非取消状态的线程节点,将其对应的线程唤醒,但是看代码是从队列的尾部往前查找的,这是为啥?为啥不直接从头部节点往后遍历呢?

1)原因1 (这个原因参考的美团技术文章,但是我想了想,好像并不是因为这个) 原文是说新增节点入队的情况,其中两行代码如下:

    node.prev = pred;  
	if (compareAndSetTail(pred, node)) {
			pred.next = node;
			return node;
		}
复制代码

第一行代码和第三行代码并不是原子性的,所以会产生,新加入的node节点前驱已经指向了旧的尾节点,但是旧的尾节点的后继节点并没有指向新加入的node节点(pred.next = node;还没执行),所以导致从前向后遍历,可能根本遍历不到新加入的这个node节点。

但是我个人觉得,就算遍历不到也没事,因为如果说上述代码的第三行还没执行,一定可以说明新加入的这个node还没走到shouldParkAfterFailedAcquire,所以本身就没被挂起,所以遍历不到无所谓了。

2)原因2:
产生CANCELLED状态节点的时候,先断开的是Next指针,Prev指针并未断开,因此也是必须要从后往前遍历才能够遍历完全部的Node。

三、😻图解加锁解锁过程

3.1 AQS初始状态

AQS初始状态

3.1 加锁

线程1加锁不释放 AQS初始状态 线程2尝试加锁 线程2尝试加锁 线程3尝试加锁 这里直接就给出最终的结果 线程3尝试加锁

3.2 解锁

线程1释放锁

线程1释放完锁以后,线程2被唤醒然后进入到acquireQueued方法的for循环中,并且此时正好线程2的node节点的前驱节点就是head,所以能够满足第一个if条件,加锁成功以后,将头节点转向自己,此时表示当前持有锁的线程节点就是节点2了。 线程1释放锁

参考文章

一行一行源码分析清楚AbstractQueuedSynchronizer
算法:CLH锁的原理及实现&并发锁核心类AQS
从ReentrantLock的实现看AQS的原理及应用

文章分类
后端
文章标签