Java并发(二)之AQS:CLH 同步队列及同步状态(锁)的获取

在上一篇Java并发(一)之AQS简介提到AQS 内部维护着一个 FIFO 队列,该队列就是 CLH 同步队列。

1. 简介

CLH 同步队列是一个 FIFO 双向队列,AQS 依赖它来完成同步状态的管理:

  • 当前线程如果获取同步状态失败时,AQS则会将当前线程已经等待状态等信息构造成一个节点(Node)并将其加入到CLH同步队列,同时会阻塞当前线程
  • 当同步状态释放时,会把首节点唤醒(公平锁),使其再次尝试获取同步状态。

2. Node

在 CLH 同步队列中,一个节点(Node),表示一个线程,它保存着线程的引用(thread)、状态(waitStatus)、前驱节点(prev)、后继节点(next)。其定义如下:

Node 是 AbstractQueuedSynchronizer 的内部静态类。

static final class Node {

    // 共享
    static final Node SHARED = new Node();
    // 独占
    static final Node EXCLUSIVE = null;

    /**
     * 因为超时或者中断,节点会被设置为取消状态,被取消的节点时不会参与到竞争中的,他会一直保持取消状态不会转变为其他状态
     */
    static final int CANCELLED =  1;
    /**
     * 后继节点的线程处于等待状态,而当前节点的线程如果释放了同步状态或者被取消,将会通知后继节点,使后继节点的线程得以运行
     * (说白了就是处于等待被唤醒的线程(或是节点)只要前继结点释放锁,就会通知标识为SIGNAL状态的后继结点的线程执行)
     */
    static final int SIGNAL    = -1;
    /**
     * 节点在等待队列中,节点线程等待在Condition上,当其他线程对Condition调用了signal()后,该节点将会从等待队列中转移到同步队列中,加入到同步状态的获取中
     */
    static final int CONDITION = -2;
    /**
     * 表示下一次共享式同步状态获取,将会无条件地传播下去
     */
    static final int PROPAGATE = -3;

    /** 等待状态 */
    volatile int waitStatus;

    /** 前驱节点,当节点添加到同步队列时被设置(尾部添加) */
    volatile Node prev;

    /** 后继节点 */
    volatile Node next;

    /** 等待队列中的后续节点。如果当前节点是共享的,那么字段将是一个 SHARED 常量,也就是说节点类型(独占和共享)和等待队列中的后续节点共用同一个字段 */
    Node nextWaiter;
    
    /** 获取同步状态的线程 */
    volatile Thread thread;

    final boolean isShared() {
        return nextWaiter == SHARED;
    }

    final Node predecessor() throws NullPointerException {
        Node p = prev;
        if (p == null)
            throw new NullPointerException();
        else
            return p;
    }

    Node() { // Used to establish initial head or SHARED marker
    }

    Node(Thread thread, Node mode) { // Used by addWaiter
        this.nextWaiter = mode;
        this.thread = thread;
    }

    Node(Thread thread, int waitStatus) { // Used by Condition
        this.waitStatus = waitStatus;
        this.thread = thread;
    }
    
}
  • waitStatus 字段,等待状态,用来控制线程的阻塞和唤醒,并且可以避免不必要的调用LockSupport的 #park(…) 和 #unpark(…) 方法。。目前有 4 种:CANCELLED SIGNAL CONDITION PROPAGATE 。
    实际上,有第 5 种,INITAL ,值为 0 ,初始状态。

  • 撸友请认真看下每个等待状态代表的含义,它不仅仅指的是 Node 自己的线程的等待状态,也可以是下一个节点的线程的等待状态。
    CLH 同步队列,结构图如下:
    在这里插入图片描述

    • prev 和 next 字段,是 AbstractQueuedSynchronizer 的字段,分别指向同步队列的头和尾。
    • head 和 tail 字段,分别指向 Node 节点的前一个和后一个 Node 节点,从而实现链式双向队列。再配合上 prev 和 next 字段,快速定位到同步队列的头尾。
  • thread 字段,Node 节点对应的线程 Thread 。

  • nextWaiter 字段,Node 节点获取同步状态的模型( Mode )。#tryAcquire(int args) 和 #tryAcquireShared(int args) 方法,分别是独占式和共享式获取同步状态。在获取失败时,它们都会调用 #addWaiter(Node mode) 方法入队。而 nextWaiter 就是用来表示是哪种模式:

    • SHARED 静态 + 不可变字段,枚举共享模式。
    • EXCLUSIVE 静态 + 不可变字段,枚举独占模式。
    • isShared() 方法,判断是否为共享式获取同步状态。
  • predecessor() 方法,获得 Node 节点的前一个 Node 节点。在方法的内部,Node p = prev 的本地拷贝,是为了避免并发情况下,prev 判断完 == null 时,恰好被修改,从而保证线程安全。

  • 构造方法有 3 个,分别是:

    • Node() 方法:用于 SHARED 的创建。
    • Node(Thread thread, Node mode) 方法:用于 #addWaiter(Node mode) 方法。
      从 mode 方法参数中,我们也可以看出它代表获取同步状态的模式。
      在本文中,我们会看到这个构造方法的使用。
    • Node(Thread thread, int waitStatus) 方法,用于 #addConditionWaiter() 方法。
      在本文中,不会使用,所以解释暂时省略。

3. 入列

学了数据结构的我们,CLH 队列入列是再简单不过了:

tail 指向新节点。
新节点的 prev 指向当前最后的节点。
当前最后一个节点的 next 指向当前节点。
结构如图:在这里插入图片描述
3.1 acquire(int)

此方法是独占模式下线程获取共享资源的顶层入口。如果获取到资源,线程直接返回,否则进入等待队列,直到获取到资源为止,且整个过程忽略中断的影响。这也正是lock()的语义,当然不仅仅只限于lock()。获取到资源后,线程就可以去执行其临界区代码了。下面是acquire()的源码:

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

函数流程如下:

  • tryAcquire()尝试直接去获取资源,如果成功则直接返回;
  • addWaiter()将该线程加入等待队列的尾部,并标记为独占模式;
  • acquireQueued()使线程在等待队列中获取资源,一直获取到资源后才返回。如果在整个等待过程中被中断过,则返回true,否则返回false。
  • 如果线程在等待过程中被中断过,它是不响应的。只是获取资源后才再进行自我中断selfInterrupt(),将中断补上。

3.1.1 tryAcquire(int)

此方法尝试去获取独占资源。如果获取成功,则直接返回true,否则直接返回false。这也正是tryLock()的语义,还是那句话,当然不仅仅只限于tryLock()。如下是tryAcquire()的源码:

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

什么?直接throw异常?说好的功能呢?好吧,还记得概述里讲的AQS只是一个框架,具体资源的获取/释放方式交由自定义同步器去实现吗?就是这里了!!!AQS这里只定义了一个接口,具体资源的获取交由自定义同步器去实现了(通过state的get/set/CAS)!!!至于能不能重入,能不能加塞,那就看具体的自定义同步器怎么去设计了!!!当然,自定义同步器在进行资源访问时要考虑线程安全的影响。

这里之所以没有定义成abstract,是因为独占模式下只用实现tryAcquire-tryRelease,而共享模式下只用实现tryAcquireShared-tryReleaseShared。如果都定义成abstract,那么每个模式也要去实现另一模式下的接口。说到底,Doug Lea还是站在咱们开发者的角度,尽量减少不必要的工作量。
  
3.1.2 addWaiter(Node)
此方法用于将当前线程加入到等待队列的队尾,并返回当前线程所在的结点。(需要考虑并发的情况。它通过 CAS 的方式,来保证正确的添加 Node)还是上源码吧:

private Node addWaiter(Node mode) {
      // 新建节点
      Node node = new Node(Thread.currentThread(), mode);
      // 记录原尾节点
      Node pred = tail;
     // 快速尝试,添加新节点为尾节点
      if (pred != null) {
          // 设置新 Node 节点的尾节点为原尾节点
          node.prev = pred;
         // CAS 设置新的尾节点
        if (compareAndSetTail(pred, node)) {
             // 成功,原尾节点的下一个节点为新节点
             pred.next = node;
             return node;
         }
     }
     
     enq(node);// 失败,多次尝试,直到成功,因为该方法中会自旋
     return node;
 }

第 3 行:创建新节点 node 。在创建的构造方法,mode 方法参数,传递获取同步状态的模式。
第 5 行:记录原尾节点 tail 。

在下面的代码,会分成 2 部分:

  • 第 6 至 16 行:快速尝试,添加新节点为尾节点。
  • 第 18 行:添加失败,多次尝试,直到成功添加。
  • 第 7 行:当原尾节点非空,才执行快速尝试的逻辑。在下面的 #enq(Node node) 方法中,我们会看到,首节点未初始化的时,head 和 tail 都为空。
  • 第 9 行:设置新节点的尾节点为原尾节点。
  • 第 11 行:调用 #compareAndSetTail(Node expect, Node update) 方法,使用 Unsafe 来 CAS 设置尾节点 tail 为新节点。代码如下:
private static final Unsafe unsafe = Unsafe.getUnsafe();

private static final long tailOffset = unsafe.objectFieldOffset (AbstractQueuedSynchronizer.class.getDeclaredField("tail"));  // 这块代码,实际在 static 代码块,此处为了方便理解,做了简化。

private final boolean compareAndSetTail(Node expect, Node update) {
    return unsafe.compareAndSwapObject(this, tailOffset, expect, update);
}

如果对 Unsafe 不了解,请 Google 之

  • 第 13 行:添加成功,最终,将原尾节点的下一个节点为新节点。
  • 第 14 行:返回新节点。
  • 如果添加失败,因为存在多线程并发的情况,此时需要执行【第 18 行】的代码。

3.1.2.1 enq(Node)
此方法用于将node加入队尾。多次尝试,直到成功添加。源码如下:

  private Node enq(final Node node) {
      // 多次尝试(cas自旋),直到成功加入队尾
      for (;;) {
          // 记录原尾节点
          Node t = tail;
          // 原尾节点不存在,创建首尾节点都为 new Node()
          if (t == null) {
              if (compareAndSetHead(new Node()))
                  tail = head;
         // 原尾节点存在,添加新节点为尾节点
         } else {
             //设置为尾节点
             node.prev = t;
             // CAS 设置新的尾节点
             if (compareAndSetTail(t, node)) {
                 // 成功,原尾节点的下一个节点为新节点
                 t.next = node;
                 return t;
             }
         }
     }
 }
  • 第 3 行:“死”循环,多次尝试,直到成功添加为止【第 18 行】。
  • 第 5 行:记录原尾节点 t 。? 和 #addWaiter(Node node) 方法的【第 5 行】相同。
  • 第 10 至 19 行:原尾节点存在,添加新节点为尾节点。? 和 #addWaiter(Node node) 方法的【第 7 至 16 行】相同。
  • 第 6 至 9 行:原尾节点不存在,创建首尾节点都为 new Node() 。注意,此时修改的首尾节点是重新创建( new Node() )的,而不是新节点!

这里,笔者的理解是,通过这样的方式,初始化好同步队列的首尾。另外,在 AbstractQueuedSynchronizer 的设计中,head 字段,是一个“占位节点”(暂时没想到特别好的比喻),代表最后一个获得到同步状态的节点(线程),实际它已经出列,所以它的 Node.next 才是真正的队首。当然,同步队列的初始时,new Node() 也是满足这个条件,因为有新的 Node 进队列,目前就已经有线程获得到同步状态。
#compareAndSetHead(Node update) 方法,使用 Unsafe 来 CAS 设置尾节点 head 为新节点。代码如下:

private static final Unsafe unsafe = Unsafe.getUnsafe();

private static final long headOffset = unsafe.objectFieldOffset (AbstractQueuedSynchronizer.class.getDeclaredField("head"));  // 这块代码,实际在 static 代码块,此处为了方便理解,做了简化。

private final boolean compareAndSetHead(Node update) {
    return unsafe.compareAndSwapObject(this, headOffset, null, update);
}

注意,第三个方法参数为 null ,代表需要原 head 为空才可以设置。? 和 #compareAndSetTail(Node expect, Node update) 方法,类似。

3.1.3 acquireQueued(Node, int)

OK,通过tryAcquire()和addWaiter(),该线程获取资源失败,已经被放入等待队列尾部了。聪明的你立刻应该能想到该线程下一部该干什么了吧:进入等待状态休息,直到其他线程彻底释放资源后唤醒自己,自己再拿到资源,然后就可以去干自己想干的事了。没错,就是这样!是不是跟医院排队拿号有点相似~~acquireQueued()就是干这件事:在等待队列中排队拿号(中间没其它事干可以休息),直到拿到号后再返回。这个函数非常关键,还是上源码吧:

final boolean acquireQueued(final Node node, int arg) {
      boolean failed = true;//标记是否成功拿到资源
      try {
          boolean interrupted = false;//标记等待过程中是否被中断过
          
          //又是一个“自旋”!
          for (;;) {
              final Node p = node.predecessor();//拿到前驱
              //如果前驱是head,即该结点已成老二,那么便有资格去尝试获取资源(可能是老大释放完资源唤醒自己的,当然也可能被interrupt了)。
            if (p == head && tryAcquire(arg)) {
                 setHead(node);//拿到资源后,将head指向该结点。所以head所指的标杆结点,就是当前获取到资源的那个结点或null。
                 p.next = null; // setHead中node.prev已置为null,此处再将head.next置为null,就是为了方便GC回收以前的head结点。也就意味着之前拿完资源的结点出队了!
                 failed = false;
                 return interrupted;//返回等待过程中是否被中断过
             }
             
             //如果自己可以休息了,就进入waiting状态,直到被unpark()
             if (shouldParkAfterFailedAcquire(p, node) &&
                 parkAndCheckInterrupt())
                interrupted = true;//如果等待过程中被中断过,哪怕只有那么一次,就将interrupted标记为true
         }
     } finally {
         if (failed)
             cancelAcquire(node);
     }
 }

看看shouldParkAfterFailedAcquire()和parkAndCheckInterrupt()具体干些什么

3.1.3.1 shouldParkAfterFailedAcquire(Node, Node)

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
      int ws = pred.waitStatus;//拿到前驱的状态
      if (ws == Node.SIGNAL)
          //如果已经告诉前驱拿完号后通知自己一下,那就可以安心休息了
          return true;
      if (ws > 0) {
          /*
          * 如果前驱放弃了,那就一直往前找,直到找到最近一个正常等待的状态,并排在它的后边。
           * 注意:那些放弃的结点,由于被自己“加塞”到它们前边,它们相当于形成一个无引用链,稍后就会被保安大叔赶走了(GC回收)!
          */
         do {
             node.prev = pred = pred.prev;
         } while (pred.waitStatus > 0);
         pred.next = node;
     } else {
          //如果前驱正常,那就把前驱的状态设置成SIGNAL,告诉它拿完号后通知自己一下。有可能失败,人家说不定刚刚释放完呢!
         compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
     }
     return false;
 }

整个流程中,如果前驱结点的状态不是SIGNAL,那么自己就不能安心去休息,需要去找个安心的休息点,同时可以再尝试下看有没有机会轮到自己拿号。

3.1.3.2 parkAndCheckInterrupt()

如果线程找好安全休息点后,那就可以安心去休息了。此方法就是让线程去休息,真正进入等待状态

private final boolean parkAndCheckInterrupt() {
     LockSupport.park(this);//调用park()使线程进入waiting状态
     return Thread.interrupted();//如果被唤醒,查看自己是不是被中断的。
 }

park()会让当前线程进入waiting状态。在此状态下,有两种途径可以唤醒该线程:1)被unpark();2)被interrupt()。(再说一句,如果线程状态转换不熟,可以参考本人写的Thread详解)。需要注意的是,Thread.interrupted()会清除当前线程的中断标记位

再来总结下它的流程吧:

  1. 调用自定义同步器的tryAcquire()尝试直接去获取资源,如果成功则直接返回;
  2. 没成功,则addWaiter()将该线程加入等待队列的尾部,并标记为独占模式;
  3. acquireQueued()使线程在等待队列中休息,有机会时(轮到自己,会被unpark())会去尝试获取资源。获取到资源后才返回。如果在整个等待过程中被中断过,则返回true,否则返回false。
  4. 如果线程在等待过程中被中断过,它是不响应的。只是获取资源后才再进行自我中断selfInterrupt(),将中断补上。

由于此函数是重中之重,我再用流程图总结一下:
在这里插入图片描述

4. 出列

CLH 同步队列遵循 FIFO,首节点的线程释放同步状态后,将会唤醒它的下一个节点(Node.next)。而后继节点将会在获取同步状态成功时,将自己设置为首节点( head )。

这个过程非常简单,head 执行该节点并断开原首节点的 next 和当前节点的 prev 即可。注意,在这个过程是不需要使用 CAS 来保证的,因为只有一个线程,能够成功获取到同步状态。如图:
在这里插入图片描述
setHead(Node node) 方法,实现上述的出列逻辑。代码如下

private void setHead(Node node) {
    head = node;
    node.thread = null;
    node.prev = null;
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值