ReentrantLock与AQS(一)

前言:本文的目标是借助对ReentrantLock#lock()与unlock()的分析,理解AQS的等待队列模型。文章中涉及到了CAS理论以及LockSupport的使用,读者可提前了解这两部分知识再阅读本文章,效果更佳。

我们先大概了解一下这两部分知识;

CAS理论

在了解AQS之前需要先熟悉下CASCompare-And-Swap,是一条CPU并发原语,用于判断内存中某个位置的值是否没有被修改过,如果是则更改为新的值,这个过程是原子的。

具体的操作流程

  • 先去读一次内存地址中的旧值,保存下来。
  • 在准备写之前再去读一次内存地址中的当前值,判断当前值是否和旧值相等。
  • 如果相等,则把旧值修改为期望值。
  • 如果不相等,代表在修改之前此值被其它线程修改过,则重新执行以上操作。

CAS的ABA问题

CAS算法实现的一个重要前提需要取出内存中某时刻的数据并在当下时刻比较并替换,那么在这个时间差类会导致数据的变化,比如线程M从内存位置W取出值A,这时N也取出A,N操作后A变成了B,然后N将B又变回A,这时线程M在CAS操作时发现内存中仍然是A,然后M执行成功,M虽然执行成功,但实际上就出现了ABA问题。

解决方案:

通过记录每次操作数据的版本号,在修改值时,比较当前版本号和当前值是否和旧版本号旧值相等,相等则修改。Java1.5开始,JDK的Atomic包里提供了一个类AtomicStampedRefernce来解决ABA问题。这个类的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查标志stamped是否为预期标志,如果全部一致,则继续。

只能保证一个共享变量的原子操作

对于多个变量的原子操作,Java1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作。

CAS的循环时间长开销大问题

自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销,如果JVM能支持处理器提供的pause指令,那么效率会有一定的提升。

pause指令的两个作用:
  1. 它可以延迟流水线执行指令,使CPU不会消耗过多的执行资源
  2. 它可以避免在退出循环的时候因内存顺序冲突而引起CPU流水线被清空

LockSupport

LockSupportconcurrent包中一个工具类,不支持构造,提供了一堆static方法,这里我们只看park()unpark()

  • park()

    调用后会去获取许可,如果许可获取失败,则线程会被阻塞。(许可在第一次调用的时候默认是占用状态)

  • unpark()

    释放一个许可,并通知等待获取许可的线程去获取许可。

LockSupport提供了强大的灵活性,相比于Objectwait/notify有两大优势:

  1. LockSupport不需要在同步代码块里 。所以线程间也不需要维护一个共享的同步对象了,实现了线程间的解耦。
  2. unpark函数可以先于park调用,所以不需要担心线程间的执行的先后顺序。

多次调用unpark()和调用一次unpark()方法效果一样,因为都是直接将_counter赋值为1(即同时只能有1个许可可用),而不是加1。简单说就是:线程A连续调用两次LockSupport.unpark(B)方法唤醒线程B,然后线程B调用两次LockSupport.park()方法, 线程B依旧会被阻塞。因为两次unpark调用效果跟一次调用一样,只能让线程B的第一次调用park方法不被阻塞,第二次调用依旧会阻塞。


AbstractQueuedSynchronizer简介

AbstractQueuedSynchronizer(抽象队列同步器)简称为AQS,是除了java自带的synchronized关键字之外的锁机制,同时也是Java并发包中加锁和释放锁的核心组件。

实现了AQS的锁有:自旋锁、互斥锁、读锁写锁、条件产量、信号量、栅栏都是AQS的衍生物

AbstractQueuedSynchronizer核心思想

如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并将共享资源设置为锁定状态,如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制在AQS中是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。

CLH(Craig, Landin, and Hagersten locks)

CLH队列是一个虚拟的双向队列,虚拟的双向队列即不存在队列实例,仅存在节点之间的关联关系。
AQS是将每一条请求共享资源的线程封装成一个CLH锁队列的一个结点(Node),来实现锁的分配。

用大白话来说,AQS就是基于CLH队列,用volatile修饰共享变量state,线程通过CAS去改变状态符,成功则获取锁成功,失败则进入等待队列,等待被唤醒。

在这里插入图片描述

AQS定义

  • AQS是一个抽象类(abstract ),继承 AbstractOwnableSynchronizer 类,实现了 Serializable 接口。

  • 内部维护着FIFO(先入先出)的双向虚拟队列,队列中的每一个元素都是一个Node

  • 类中还定义有三个参数 headtailstate

    • head :等待队列的头节点,只能通过setHead方法修改,如果head存在,能保证waitStatus状态不为CANCELLED。(volatiletransient修饰)
    • tail :等待队列的尾节点,只能通过enq方法来添加新的等待节点。(volatiletransient修饰)
    • state :表示锁的状态,0 表示未锁定,1表示锁定,大于1表示此锁被同一个线程连续获取多次,解锁时需要根据state的大小释放相同次数的锁,这个值可以用来实现锁的*【可重入性】*。(volatile修饰)

    内部类Node定义

    • 储存了等待获取同步状态的线程,以及当前节点的等待状态值、前驱节点、后继节点,下一个condition队列的等待节点。

      字段名类型说明修饰符
      prevNode前驱节点volatile
      nextNode后继节点volatile
      threadThread获取同步状态的线程volatile
      nextWaiterNode下一个condition队列等待节点
      waitStatusint节点的等待状态volatile

      通过对nextWaiter的状态来判断当前锁是否是共享模式

      状态值类型说明备注
      SHAREDNode节点正在共享模式下等待的标记值不为空,表示有下一个condition队列等待节点,此时是共享锁
      EXCLUSIVENode节点正在以独占模式等待的标记值为空,此时是独占锁

      waitStatus可选值

      状态码状态码说明备注
      CANCELLED1取消状态因为超时或者或者被中断,节点会被设置成取消状态。被取消的节点不会参与锁竞争,状态也不会再改变
      SIGNAL-1等待状态表示后继节点处于等待状态,如果当前节点释放了锁或者被取消,会通知后继节点去运行
      CONDITION-2处于condition队列表示节点处于condition队列中,正在等待被唤醒
      PROPAGATE-3无条件传播下一次acquireShared应该无条件传播
      None of the above0默认状态不属于以上任何状态

      ps:这篇文章主要讨论SIGNAL(-1)、CANCELLED(1)、None(0)这三种状态。

AQS类图

在这里插入图片描述

父类AbstractOwnableSynchronizer定义

一种同步器,可以由一个线程独占。该类提供了创建锁和相关同步器的基础,这些同步器可能包含所有权的概念。AbstractOwnableSynchronizer类本身并不管理或使用这些信息。但是,子类和工具可以使用适当维护的值来帮助控制和监视访问并提供诊断。

字段名类型说明修饰符
exclusiveOwnerThreadThread独占模式下锁的拥有者线程transient

AQS的基本结构

在这里插入图片描述

AQS加锁方式

AQS实现了两套加锁的模式,分别为独占锁和共享锁。以ReentrantLock为例,来观察这两种锁是实现,首先是加锁的方法lock()

ReentrantLock#lock()
public void lock() {
 sync.lock();
}
sync的定义
private final Sync sync;

abstract static class Sync extends AbstractQueuedSynchronizer {
   ...
   abstract void lock();
   ...
}

可以看到syncAbstractQueuedSynchronizer的实现,它是怎么设置公平锁和非公平锁的呢

构造方法
/**
* 创建一个ReentrantLock实例,这个方法等同于调用ReentrantLock(false)
*/
public ReentrantLock() {
     sync = new NonfairSync();
 }

/**
* 创建一个ReentrantLock实例,这个方法等同于调用ReentrantLock(false)
* 通过参数fair构造锁的公平策略
* true 公平锁
* false 非公平锁
*/
public ReentrantLock(boolean fair) {
     sync = fair ? new FairSync() : new NonfairSync();
 }

通过ReentrantLock的构造函数设置了锁的公平策略,我们看看公平锁和非公平锁分别是怎么实现的。

// FairSync 公平锁的实现
final void lock() {
 acquire(1);
}

// NonfairSync 非公平锁的实现
final void lock() {
 if (compareAndSetState(0, 1))//修改state的状态
     setExclusiveOwnerThread(Thread.currentThread());//调用的是AQS父类AOS的方法,把独占锁的线程改成当前线程
 else
     acquire(1);//调用AQS的acquire()方法
}

可以看到,非公平锁的实现仅仅是多了一个步骤:通过CAS的方式尝试改变state的状态,修改成功后设置当前线程以独占的方式获取了锁,修改失败执行的逻辑和公平锁一样。

从这里可以看出非公平模式下调用lock()方法的时候,先通过CAS抢占了一波锁(非公平策略第一次抢锁),抢占失败才调用acquire()去尝试排队,这就是公平锁和非公平锁的本质区别


为什么修改成功就可以获取到锁呢

ReetrantLock中用state表示锁的拥有者线程重复获取该锁的次数,如下图:

简单的 lock工作模型

在这里插入图片描述

state在无锁状态下的值为0,如果大于0则代表有线程在占用此锁,如果大于1则代表该线程重入了此锁。在获取锁的时候,将state CAS设置为1,并记录排他的所有者线程ownerThreadownerThread只会在0->1及1->0两次状态转换中修改);否则,state必然大于0,则尝试再获取一次锁。ownerThread将在state大于0时,用于判断重入性。

​ 总结:

  • 排他性:如果线程T1已经持有锁L,则不允许除T1外的任何线程T持有该锁L
  • 重入性:如果线程T1已经持有锁L,则允许线程T1多次获取锁L,更确切的说,获取一次后,可多次进入锁.

​ 二者结合,描述了ReentrantLock的一个性质:允许ownerThread重入,不允许其他线程进入或重入。


接着我们看看 acquire()方法

AbstractQueuedSynchronizer#acquire()
public final void acquire(int arg) {
 if (!tryAcquire(arg) &&//尝试获取锁,成功返回true
     acquireQueued(addWaiter(Node.EXCLUSIVE), arg))//获取失败,将线程封装成Node对象,模式为EXCLUSIVE(独占模式),并插入等待队列。
     selfInterrupt();//复现线程中断
}

tryAcquire方法在AQS中并没有直接实现,而是采用模板方法的设计模式,交给子类去实现。

​ 首先,通过tryAcquire()尝试获取锁。按照AQS的约定,tryAcquire()返回true表示获取成功,可直接返回;否则获取失败。如果获取失败,则向等待队列中添加一个独占模式的节点,并通过acquireQueued()阻塞的等待该节点被调用(即当前线程被唤醒)。如果是因为被中断而唤醒的,则复现中断信号。


我们看看公平锁和非公平锁是怎样实现 tryAcquire()的

FairSync#tryAcquire()
protected final boolean tryAcquire(int acquires) {
 final Thread current = Thread.currentThread();//获取当前线程
 int c = getState();// 获取state状态,0表示未锁定,大于1表示重入
 if (c == 0) {//表示没有线程获取到锁
     if (!hasQueuedPredecessors() &&//判断有无线程排在当前线程前面
         compareAndSetState(0, acquires)) {//没有比当前线程等待更久的线程了,通过CAS的方式修改state
         setExclusiveOwnerThread(current);//修改成功,调用AOS中的方法,把独占锁的线程改成当前线程
         return true;
     }
 }
 else if (current == getExclusiveOwnerThread()) {// 表示有线程获取到了锁,判断是否是当前线程获取到了锁,此处就是【可重入性】的实现
     int nextc = c + acquires;//获取锁的次数增加
     if (nextc < 0)
         throw new Error("Maximum lock count exceeded");
     // 直接修改state
     setState(nextc);
     return true;
 }
 return false;
}

公平锁在新线程通过CAS获取锁之前,会先判断队列中有无线程在排队,如果有,则不参与竞争,加入等待队列阻塞,直到被唤醒。

在这里插入图片描述

FairSync#hasQueuedPredecessors()
public final boolean hasQueuedPredecessors() {
        Node t = tail; // Read fields in reverse initialization order
        Node h = head;
        Node s;
        return h != t &&
            ((s = h.next) == null || s.thread != Thread.currentThread());
    }

有个有趣的地方: 既然h != t 还能h.next==null ? ,我这里能想到的一处地方,在于头节点初始化时先CAS的head,第二步才将tail=head,大概只有在这两步的间隙,这个条件才会成立吧!

在这里插入图片描述


接着来看来看非公平锁的实现

NonfairSync#tryAcquire()
protected final boolean tryAcquire(int acquires) {
         return nonfairTryAcquire(acquires);
     }

final boolean nonfairTryAcquire(int acquires) {
         final Thread current = Thread.currentThread();//获取当前线程
         int c = getState();// 获取state状态,0表示未锁定,大于1表示重入
         if (c == 0) {//表示没有线程获取到锁
             if (compareAndSetState(0, acquires)) {//通过CAS的方式修改state,可以看到在非公平锁的实现中,并没有判断前面是否有线程在排队,而是直接调用CAS去抢占锁
                 setExclusiveOwnerThread(current);//修改成功,调用AOS中的方法,把独占锁的线程改成当前线程
                 return true;
             }
         }
         else if (current == getExclusiveOwnerThread()) {// 表示有线程获取到了锁,判断是否是当前线程获取到了锁,此处就是【可重入性】的实现
             int nextc = c + acquires;//获取锁的次数增加
             if (nextc < 0) // overflow
                 throw new Error("Maximum lock count exceeded");
              // 直接修改state
             setState(nextc);
             return true;
         }
         return false;
     }

可以看到,在获取到当前线程后,先是重复了NonfairSync#lock()state=0时的状态转换;然后进行了排它性判断,如果当前线程为锁有者线程,则执行重入,state加1(acquires=1),表示锁有者线程重复获取该锁的次数增加1。否则返回false,表示获取锁失败,进入等待队列。

此外非公平策略在入队前的第二次枪锁,并没和公平策略一样判断当前有无线程排队的时间更久,而是直接参与锁竞争。

在这里插入图片描述

这里来看两个问题


为什么在当前线程等于ownerThread,也就是锁重入的时候不需要做同步处理呢

因为state是被volatile修饰的,基于内存屏障的概念,在第二次调用锁进行重入时,第一次调用锁写入的stateownerThread一定是可见的。

为什么要用state表示重入次数

如果state只是用0和1表示有锁和无锁的状态,没有记录重入的次数的话,在释放锁的时候,会一次性把ownerThread多次重入的锁都释放掉,而此时锁中的逻辑代码还没有执行完成,从而造成混乱。

来看看两种流程的图解:

在这里插入图片描述

这里可以看出非公平锁和公平锁设计的区别之处,非公平锁不会管线程排队的先后次序,会直接让新线程去抢占一次锁。


下面是锁竞争失败执行的代码

acquireQueued(addWaiter(Node.EXCLUSIVE), arg)

这里分为两步,我们先看看addWaiter()方法

AbstractQueuedSynchronizer#addWaiter()
/**
* 为当前线程和给定模式创建并排队节点,给的的模式分为:
* Node.EXCLUSIVE:独占模式
* Node.SHARED:共享模式
*/
private Node addWaiter(Node mode) {
     Node node = new Node(Thread.currentThread(), mode);// 创建Node节点
     // 尝试快速添加尾结点,失败就执行enq方法
     Node pred = tail;// 保存旧尾节点的值
     if (pred != null) {// 如果不为空,则把新节点的前驱节点引用设置为尾节点
         node.prev = pred;
          // CAS的方式设置尾结点
         if (compareAndSetTail(pred, node)) {//更新尾节点
             pred.next = node;//把旧尾节点的后继节点引用设置尾新节点
             return node;//返回新节点
         }
     }
     enq(node);//快速添加失败,执行enq()
     return node;//返回新节点
 }


注意:此处为快速添加,成功的条件为

  • tail!= null,即队列已经完成初始化(队列初始化完成会更新头节点和尾节点)。
  • CAS更新尾节点的值成功(没有其它的线程在尝试更新尾节点值)。

如果快速添加失败,则会调用enq(),通过自旋的方式去添加节点,直到添加成功。

/**
  * 通过自旋的方式添加进队列直到成功,必要时进行初始化。
  * @param node 要插入的节点
  * @param node 节点的前驱节点
  */
  private Node enq(final Node node) {
        for (;;) {
            // 自旋
            Node t = tail;// 保存旧尾节点的值
            if (t == null) { // Must initialize
                // 尾结点为空,队列还没有进行初始化
                if (compareAndSetHead(new Node()))//CAS的方式更新头节点
                    tail = head;//更新尾节点
            } else {//尾结点不为空,队列已经完成初始化
                node.prev = t;//把新节点的前驱节点引用设置为旧尾节点
                if (compareAndSetTail(t, node)) {//CAS的方式更新尾节点	
                    t.next = node;//把旧尾节点的后继节点引用设置为新节点
                    return t;//返回旧尾节点,也就是新节点的前驱节点
                }
            }
        }
    }

注意:**addWaiter()enq()**返回值有所不同,前者是返回添加的新节点,后者是返回旧的尾节点。不过在此逻辑中没有对返回旧尾节点的值做处理。


总结一下加入队列的流程,首先通过addWaiter()尝试向等待队列中快速添加一个独占模式的节点,并返回该节点。如果快速添加失败,则执行enq(),此时队列可能处于以下两种状态:

  • 状态一:尾节点为空,队列未初始化。
  • 状态二:队列已经完成初始化,但是尾节点未更新完成,即队列处于一种在pre方向一致,next方向不一致的状态。

我们先来看看状态一,在空队列的情况下,内部结构为

在这里插入图片描述

则尾节点为空即代表队列未初始化,此时需要通过CAS先更新头节点,在把尾节点指向和头节点相同的引用,更新后的结构为

在这里插入图片描述

该结构下队列已经完成初始化

队列刚完成初始化时,存在一个dummy node,即上图中的newNode节点。插入节点时,tail后移指向新节点,head不变仍然指向dummy node。直到调用AQS#acquireQueued()时,head才会后移,消除了dummy node,后面分析。


接着我们来分析一下状态二,为什么会队列会处于在pre方向一致,next方向不一致的状态

来看一个例子,假设线程已经完成初始化,现在有两个线程T1、T2往队列中插入节点B、C;

在这里插入图片描述

T1先执行node.prev = pred
在这里插入图片描述

T1尝试通过CAS更新tail的值,由于T2处于等待状态,T1更新成功

在这里插入图片描述

T2执行node.prev = pred,并尝试通过CAS更新tail;

在这里插入图片描述

可以看到,此时的队列处于一种弱一致性的状态,B节点的next并未指向C,而是指向NULL,即队列在prev方向一致,next方向不一致。假如此时有线程尝试从头遍历队列,寻找节点C,会出现找不到的情况。记住该状态,分析ReentrantLock#unlock()时会用到。

T2继续执行,,此时队列处于稳定状态;

在这里插入图片描述

队列刚完成初始化时,存在一个dummy node。插入节点时,tail后移指向新节点,head不变仍然指向dummy node。直到调用AQS#acquireQueued()时,head才会后移,消除了dummy node,后面分析。


插入新节点node后,通过AQS#acquireQueued()阻塞的等待该节点被调用(即当前线程被唤醒)

AbstractQueuedSynchronizer#acquireQueued()
/**
* Acquires in exclusive uninterruptible mode for thread already in
* queue. Used by condition wait methods as well as acquire.
* 对已经在队列中的线程以不间断的方式进行获取
*/
final boolean acquireQueued(final Node node, int arg) {
 // 操作是否成功,如果是ture并且发生了异常,需要后续在finally取消正在进行的锁争抢
 boolean failed = true;
 try {
     boolean interrupted = false;//线程的中断状态
     for (;;) {
     	// 自旋
     	// 获取当前节点的前驱节点
         final Node p = node.predecessor();
          //如果刚入队的尚未被挂起的节点的前置节点是头节点,那么此节点线程有必要尝试一下获取锁,因为head很可能是 刚初始化的 dummy head,或者 会预设head很快释放锁(tryAcquire方法在前文中详细讲解过)
         if (p == head && tryAcquire(arg)) {
             setHead(node);// 把当前节点设置成头节点,这一步消除了dummy node(因为前驱节点已经释放了锁,或者为dummy node,所以前驱节点不用再留在队列)
             p.next = null; //旧的头节点的后继引用链置空,方便GC回收
             failed = false;//操作状态改为未失败   
             return interrupted;//返回当前线程的中断状态
         }
         if (shouldParkAfterFailedAcquire(p, node) &&
             parkAndCheckInterrupt())
             // 如果前驱节点不是头节点或者没有获取锁
             // shouldParkAfterFailedAcquire方法用于判断当前线程是否需要被阻塞
             // parkAndCheckInterrupt方法用于阻塞线程并且检测线程是否被中断
             // 没抢到锁的线程需要被阻塞,避免一直去争抢锁,浪费CPU资源
             interrupted = true;
     }
 } finally {
     if (failed)
     	// 自旋异常退出,取消正在进行锁争抢
         cancelAcquire(node);
 }
}

PS:if (p == head && tryAcquire(arg)),注意这一行的判断,当前驱节点是头节点时,存在几种情况

  • 队列刚初始化完成,头节点为dummy node

    此时需要将head标记位后移,消除dummy node,并且让当前节点线程去获取锁。

    这里获取锁存在失败的可能,有两种情况:

    • 当队列未初始化且锁未被获取过时,第一个获取锁成功的线程T1不会进入等待队列,即头节点线程不一定是获取到锁的线程。

    • 在非公平策略下,获取锁不一定成功(回顾NonfairSync#tryAcquire())。

  • dummy node已经消除,头节点此时仍占有锁

    那么当前节点线程尝试去获取锁一定是失败的,此时需要根据前驱节点(这里指的是头节点)的waitStatus去判断是否要阻塞当前线程,避免无意义的资源消耗。

  • dummy node已经消除,头节点已经释放锁

    此时当前节点线程尝试去获取锁可能会成功(因为非公平策略下新来的线程会先去争抢这把锁,所以这里即使头节点释放了锁,也存在获取失败的可能),如果成功则将head标记位后移,并清除旧头节点的引用链(方便GC回收);如果失败则根据前驱节点的状态判断是否阻塞当前节点线程。

该方法是lock过程的核心难点,需要结合AQS#addWaiter()理解AQS内部基于等待队列的同步模型。

AQS的核心为状态依赖,可概括为两条规则:

  • 当状态还没有满足的时候,节点会进入等待队列。
  • 特别的,获取成功的节点成为队列的头结点。

初始化队列后的第一次更新头结点,直接setHead消除了dummy node。消除之后,实际节点代替了dummy node的作用,但与dummy node不同的是,该节点是持有锁的.

我们通过图来概括一下等待队列的工作流程

在这里插入图片描述

获取锁失败的节点会调用AQS#shouldParkAfterFailedAcquire()判断是否需要阻塞等待,如果需要,则通过AQS#parkAndCheckInterrupt()阻塞等待,直到被唤醒或被中断。

AbstractQueuedSynchronizer#shouldParkAfterFailedAcquire()
/**
* 检查并更新获取失败的节点的状态
* 如果线程应该阻塞,则返回true。 这是所有获取循环中的主要信号控制。 
* 要求pred == node.prev。
*
* @param pred 前驱节点
* @param node 当前节点
* @return {@code true} 是否需要阻塞
*/
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
 // 获取前驱节点的等待状态
 int ws = pred.waitStatus;
 if (ws == Node.SIGNAL)
     /*
      * SIGNAL表示后继节点处于等待状态,如果当前节点释放了锁或者被取消,会通知后继节点去运行
      * 所以作为后继节点,node直接返回true,表示需要被阻塞
      */
     return true;
 if (ws > 0) {
     /*
      * 前驱节点被取消了,需要从队列中移除,并且循环找到下一个不是取消状态的节点
      */
     do {
         node.prev = pred = pred.prev;
     } while (pred.waitStatus > 0);
     pred.next = node;
 } else {
     /*
      * 通过CAS将前驱节点的status设置成SIGNAL
      */
     compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
 }
 return false;
}

注意:这里的ws指的是新加入节点前置节点的状态

我们记录下三种情况

  • ws == Node.SIGNAL

此情况下如果前置节点释放了锁,会通知后继节点去获取锁,所以后继节点也就是当前节点这里需要先被阻塞,return true

  • ws > 0 即 ws == Node.CANCELLED

此情况下代表前置节点处于取消状态,需要从队列中移除该节点。那么什么时候节点会处于取消状态呢,在上面AQS#acquireQueued()方法中,如果自旋出现了异常,则需要取消当前正在进行的锁争抢,在finally调用了AQS#cancelAcquire(),该方法中将节点状态设置为CANCELLED

  • 其它状态

如果ws不满足以上两种情况,则会强制把waitStatus修改为SIGNAL,这里不需要阻塞当前节点,因为会在下一次循环中进入ws == Node.SIGNAL状态,再将该节点阻塞。

此处不需要检查前继节点是否为null。因为等待队列的头结点要么是dummy node,满足dummy.waitStatus==0;要么是刚替换的real node,满足real.waitStatus==0;要么是后继节点已经阻塞的节点,满足real.waitStatus==SIGNAL==-1。则最晚遍历到头结点时,一定会退出循环,不会出现pred为null的情况。

回到AQS#**acquireQueued()**后,重新检查前继节点是否为头节点,并作出相应处理。

经过多次循环执行AQS#**shouldParkAfterFailedAcquire()**后,等待队列趋于稳定。最终的稳定状态为:

  • 除了头节点,剩余节点都会返回true,表示需要阻塞等待
  • 除了尾节点,剩余节点都满足waitStatus==SIGNAL,表示释放后需要唤醒后继节点
    在这里插入图片描述

AbstractQueuedSynchronizer#parkAndCheckInterrupt()
/**
  * 用于阻塞线程并且检测线程是否被中断
  */
private final boolean parkAndCheckInterrupt() {
	// 阻塞当前线程
    LockSupport.park(this);
    // 检测当前线程是否被中断(该方法会清除中断标识位)
    return Thread.interrupted();
}

AQS#parkAndCheckInterrupt()借助LockSupport.park()实现阻塞等待。最后调用Thread.interrupted()检查是否被中断,并清除中断状态,并返回中断标志。

如果是被中断的,则需要在外层AQS#acquireQueued()中重新设置中断标志interrupted,并在下一次循环中返回。然后在更外层的AQS#acquire()中调用AQS.selfInterrupt()重放中断。

为什么不能直接在AQS#parkAndCheckInterrupt()返回后中断?因为返回中转标志能提供更大的灵活性,外界可以自行决定是即时重放、稍后重放还是压根不重放。Condition在得知AQS#acquireQueued()是被中断的之后,便没有直接复现中断,而是根据REINTERRUPT配置决定是否重放。


AbstractQueuedSynchronizer#cancelAcquire

如果在执行AQS#acquire()的过程中抛出任何异常,则取消任务:

    private void cancelAcquire(Node node) {
        ...
        node.waitStatus = Node.CANCELLED;
        ...
    }
    ...

因此,如果只考虑ReentrantLock#lock()方法的话,那么被标记为CACELLED状态的节点一定在获取锁时抛出了异常,AQS.shouldParkAfterFailedAcquire()中清理了这部分CACELLED节点。

超时版ReentrantLock#tryLock()中,还可以由于超时触发取消。


lock小结

ReentrantLock#lock()收敛后,AQS内部的等待队列如图:

在这里插入图片描述


AQS释放锁

我们先开看张图,了解一下释放锁的流程

在这里插入图片描述

接下来看看代码中是怎么实现的,首先外部调用unlock()函数释放锁

ReenteantLock#unlock()
  public void unlock() {
        sync.release(1);//释放一个state数
    }

ReentrantLock#unlock()lock()是对偶的,获取锁的方式以1个单位进行,同样的,释放锁也是以1单位进行。(注:这里的释放锁指的是修改锁占用次数,也就是state的值,请不要和上文LockSupport中的park()unpark()混为一谈;LockSupport中的_counter代表的是许可的值,且只能有一个许可,而state表示的是锁被重入的次数)

AQS#release()
/**
 * 释放锁,并返回锁释放的结果
 * ture:完全释放;false:被重入状态
 */
public final boolean release(int arg) {
        if (tryRelease(arg)) {//尝试释放锁,并判断锁是否被完全释放
            Node h = head;//记录当前的头节点
            if (h != null && h.waitStatus != 0)//如果头节点不等于空并且状态值不等于初始状态值
                unparkSuccessor(h);//尝试唤醒后继节点
            return true;
        }
        return false;
    }

什么情况下会出现h == null,h.waitStatus == 0的情况呢,有两种可能

  • 队列初始化未完成(回忆AQS#enq()),只有一个dummy node,没有后继节点
  • 后续节点还未被阻塞,不需要唤醒(回忆AQS#acquireQueued()
ReenteantLock#tryRelease()
/**
 * 释放锁,并返回锁释放的结果
 * ture:完全释放;false:被重入状态
 */
protected final boolean tryRelease(int releases) {
            int c = getState() - releases;//state的值-1
            if (Thread.currentThread() != getExclusiveOwnerThread())//如果当前线程不是获取到锁的线程则抛出异常
                throw new IllegalMonitorStateException();//此处可检查是否在未lock的情况下进行unlock,或者重复执行了unlock
            boolean free = false;
            if (c == 0) {//state=0表示锁被完全释放
                free = true;
                setExclusiveOwnerThread(null);//清除记录当前占用锁线程的标记
            }
            setState(c);//更新state的值
            return free;//返回锁是否完全释放的值
        }

判断锁是否被当前线程锁持有这一步很重要,如果存在某线程持有锁,则可以检查unlock是否被ownerThread触发,也就是说只有拥有锁的线程才有资格释放锁;如果不存在线程持有锁,则ownerThread==null,可以检查是否在未lock的情况下进行unlock,或者重复执行了unlock

因此,使用ReentrantLock时,try-finally要这么写:

Lock lock = new ReentrantLock();
lock.lock();
try {
// do sth
} finally {
lock.unlcok();
}

确保在调用lock()成功之后,才能调用unlock()

if (c == 0),这一行其实是判断是否要进行1->0的状态转换,如果是,则可以完全释放锁,将ownerThread置为null。最后无论是否完全释放都需要更新state的值。

还记得前面说的吗,AQS的核心为状态依赖。对于独占锁来说,state字段表示锁重入的次数,即释放锁的过程中会有两种状态:

  • state = 0,锁完全释放,此时需要进行无锁到有锁的状态转换,如果有后继节点等待获取锁,则唤醒后继节点。
  • state > 0,锁处于重入状态,即未完全释放,此时只需要更新锁重入的次数,不需要唤醒后继节点。
可见性问题

了抓住核心功能,前面一直忽略了一个很重要的问题——可见性。忽略可见性问题的话,阅读源码基本没有影响,但自己实现同步器时将带来噩梦。

以此处为例,是应该先执行

   if (c == 0) {
                free = true;
                setExclusiveOwnerThread(null);
            }

还是先执行 setState(c)呢,或者是无所谓?

为保障可见性,必须先执行if (c == 0)里面的代码,再执行 setState(c)。因为exclusiveOwnerThread的可见性要借助于volatile变量state

    ...
    private transient Thread exclusiveOwnerThread;
    ...
    private volatile int state;
    ...

配套的,也必须先读state,再读exclusiveOwnerThread:

    abstract static class Sync extends AbstractQueuedSynchronizer {
        ...
        final boolean nonfairTryAcquire(int acquires) {
            ...
            int c = getState(); // 先读state
            if (c == 0) {
                ...
            }
            else if (current == getExclusiveOwnerThread()) {    // 再读exclusiveOwnerThread
                ...
            }
            return false;
        }
        ...
    }

核心是三条Happens-Before规则:

  • 程序顺序规则:如果程序中操作A在操作B之前,那么在线程中操作A将在操作B之前执行。
  • 传递性:如果操作A在操作B之前执行,并且操作B在操作C之前执行,那么操作A必须在操作C之前执行。
  • volatile变量规则:对volatile变量的写入操作必须在对该变量的读操作之前执行。

具体来说,先写exclusiveOwnerThread再写state先读state再读exclusiveOwnerThread的方案,保证了在读state之后,发生在写state之前的写exclusiveOwnerThread操作发生在读state之后的读exclusiveOwnerThread操作一定是可见的

程序顺序规则、传递性两条基本规则,经常与监视器锁规则、volatile变量规则显示的搭配,一定要掌握

相对的,线程启动规则、线程结束规则、中断规则、终结器规则则通常被隐式的使用。


AQS#unparkSuccessor()
/**
 * 唤醒后继节点
 * @param node 此时的node为头节点,即刚刚释放锁的节点
 */
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) {//如果后继节点为空或后继节点的状态为CANCELLED,这种情况下,表示队列处于未稳定的状态,节点链之间有被取消的节点,此时需要从后往前找到一个未被取消的节点。
        s = null;
        //从尾节点开始遍历,找到离头节点最近的未被取消的节点,因为队列在pre方向上是一致的,在next方向上可能不一致,所以此处从尾节点开始遍历
        for (Node t = tail; t != null && t != node; t = t.prev)
            if (t.waitStatus <= 0)
                s = t;
    }
    if (s != null)//如果后继节点不为空,则唤醒后继节点
        LockSupport.unpark(s.thread);
}

我们先回顾一下lock()状态的收敛图

在这里插入图片描述

​ 可以看到,队列中节点的waitStatus要么为0,要么为SIGNAL==-1。当node.waitStatus==SIGNAL时,表示node的后继节点s已被阻塞或正在被阻塞。则需要唤醒s,并且需要把当前节点,也就是头节点的waitStatus置0,防止后继节点s被重复唤醒。

注意,此时头节点waitStatus一定不为CANCELLED==1,因为如果lock()方法没有执行成功,就无法通过unlock()方法调用AQS#unparkSuccessor()。

​ 我们需要根据后继节点s的状态来判断此时队列是否处于稳定状:

  • s == null,后继节点为空,此时有两种情况;

    • 队列中只有一个节点,也就是头节点。

在这里插入图片描述

此时没有后继节点需要唤醒,同时也不需要把释放锁的头节点(释放锁的节点不一定是头节点)移除队列。

  • 在插入节点(AQS#addWaiter())的过程中,旧的尾节点next为null未指向新节点。
    在这里插入图片描述

  • s.waitStatus > 0,即后继节点的waitStatus=CANCELLED,表示后继节点可能在锁争抢的过程中发生了异常,被设置成了取消状态。

    在这里插入图片描述

这几种情况都需要从尾节点向前遍历,找到node后最靠近node的未取消的节点,如果存在该节点s(s!=null),就唤醒s.thread以竞争锁。


一致性问题

这里为什么不能从node向后遍历去寻找呢?从尾节点向前遍历不是更慢吗?因为AQS中的等待队列基于一个弱一致性双向链表实现,允许某些时刻下,队列在prev方向一致,next方向不一致

理想情况下,队列每时每刻都处于一致的状态(强一致性模型),从node向后遍历找第一个未取消节点是更高效的做法。然而,维护一致性通常需要牺牲部分性能,为了进一步的提升性能,脑洞大开的神牛们想出了各种高性能的弱一致性模型。尽管模型允许了更多弱一致状态,但所有弱一致状态都在控制之下,不会出现一致性问题。

在lock过程的中,有两个地方出现了这个弱一致状态:

  • AQS#enq()插入新节点(包括AQS#addWaiter())的过程中,旧的尾节点next为null未指向新节点。

在这里插入图片描述

  • AQS.shouldParkAfterFailedAcquire()移除CACELLED节点的过程中,中间节点指向已被移除的CACELLED节点。

    在这里插入图片描述

因此,从node开始,沿着next方向向后遍历是行不通的。只能从tail开始,沿着prev方向向前遍历,直到找到未取消的节点(s != null),或遍历完node的所有后继子孙(s == null

当然,s == null也可能表示node恰好是尾节点,该状态是强一致的,但仍然可以复用该段代码。


unlock小结

ReentrantLock#unlock()收敛后,AQS内部的等待队列如图:

在这里插入图片描述

可能有些小伙伴看完有些晕,我们来看一下简易版的AQS工作流程图(策略为非公平策略)

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

PS:为了保持作图的简洁,以上流程省略掉了waitStatus的状态变换

整理一下AQS的几个要点:

  • 除了头节点,队列中的其它节点都会阻塞等待
  • 除了尾节点,队列中的其它节点都满足waitStatus==SIGNAL,表示释放后需要唤醒后继节点
  • 更新头节点的操作只发生于队列初始化或者队列中的节点抢占锁成功的时候
  • 更新尾节点的操作只发生于队列初始化或者新插入节点成功的时候。

总结:

这篇文章我们通过ReentrantLock#lock()与unlock()方法,得以一窥AQS的等待队列模式。实际上,ReentrantLock还有一些重要的特性和API,如ReentrantLock#lockInterruptibly()、ReentrantLock#newCondition()。在后续文章中会先后解析这两个API,加深对AQS的理解。

  • 3
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值