zl程序教程

您现在的位置是:首页 >  后端

当前栏目

Java并发编程之AQS详解编程语言

2023-06-13 09:11:51 时间
一、关于AQS

队列同步器 AbstractQueuedSynchronizer 简称AQS,是用来构建锁或其他同步组件的基础框架(ReentrantLock、ReentrantReadWriteLock、Condition、LockSupport等等),它使用了一个int成员变量表示同步的状态,通过内置的FIFO队列来完成资源获取线程的工作,并发包作者(Doug Lea)期望它能够成为实现大部分同步需求的基础。

Java并发编程之AQS详解编程语言

而AQS却正是J.U.C的核心,也就是并发类中的核心。在AQS中,它的底层是采用的双向链表,是队列的一种实现,上图中Sync queue就是同步队列,其中包含head节点(用于后期的调度)和tail节点。上面还有个Condition queue,它不是必须的,是一个单向列表,只有当程序中使用到Condition的时候,才会存在这个单向列表并且可能会有多个Condition queue。

AQS的具体设计:

使用Node实现FIFO队列,可以用于构建锁或者其他同步装置的框架 利用一个int成员变量表示同步状态(在AQS类中,存在一个state成员变量,基于AQS有一个同步组件ReentrantLock,在这个组件里,state表示获取锁的线程数,假如state == 0表示没有线程获取锁,state == 1表示已有线程获取锁,state 1表示重入锁的数量) 使用继承的方式,子类通过继承同步器并实现它的抽象方法(acquire()和release())来管理同步状态 可以同时实现排它锁和共享锁模式(独占、共享)。它的所有子类中要么实现并使用了独占功能的API,要么使用了共享锁的功能,而不会同时使用两套API。即便是它最有名的子类ReentrantReadWirteLock,也是通过两个内部类读锁和写锁分别使用两套API实现的。AQS在功能上,有独占控制和共享控制两种功能。

基于以上的设计,AQS具体实现的大致思路是首先内部维护了一个CLH队列来管理锁,线程会首先尝试获取锁,如果失败就将当前线程以及等待状态的信息包成一个Node节点加入到之前所说的同步队列(Sync queue)里,接着会不断的循环尝试获取锁,它的条件是当前节点为head的直接后继才会尝试,如果失败则会阻塞自己,直到自己被唤醒,而它持有锁的线程释放锁的时候,会唤醒队列中的后继线程。

上面说到CLH,在CLH队列中一个节点表示一个线程,用来保存获取同步状态的线程引用(thread)、等待状态(waitStatus)、前驱节点(prev)、后继节点(next),内部代码如下:

static final class Node { 

 /** 共享节点 */ 

 static final Node SHARED = new Node(); 

 /** 独占节点*/ 

 static final Node EXCLUSIVE = null; 

 /** 由于在同步队列中等待的线程等待超时或被中断,需要从同步队列中取消等待,节点进入该状态将不会变化 */ 

 static final int CANCELLED = 1; 

 /** 后继节点的线程处于等待状态,而当前节点的线程如果释放了同步状态或被取消,将会通知后继节点,使后继几点线程得以继续运行 */ 

 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; 

 /** 获取同步状态的线程*/ 

 volatile Thread thread; 

 /** 等待队列中的后继节点*/ 

 Node nextWaiter; 


Node(Thread thread, int waitStatus) { // Used by Condition this.waitStatus = waitStatus; this.thread = thread; }

CHL同步队列结构图如下:

Java并发编程之AQS详解编程语言

CHL入列:

CHL 队列的入队操作调用 addWaiter() 方法,结合上图和下面的代码,可以看出CHL入队只是tail指向新节点,新节点的prev指向当前最后的节点,当前的最后一个节点的next指向当前节点。然采用 CAS 自旋设置尾结点,且总是添加到尾节点。如果失败,还会调用enq()方法设置尾节点。

 // 尾节点 

 private transient volatile Node head; 

 // 头节点 

 private transient volatile Node tail; 

 private Node addWaiter(Node mode) { 

 // 新建 Node 

 Node node = new Node(Thread.currentThread(), mode); 

 // 尝试快速添加尾结点 

 Node pred = tail; 

 if (pred != null) { 

 // 如果尾部节点存在,那么新加入到队列尾部节点的前驱节点为pred 

 node.prev = pred; 

 // CAS设置尾结点,如果失败则自旋重试 

 if (compareAndSetTail(pred, node)) { 

 pred.next = node;// pred的后续节点为先加入的尾部节点 

 return node; 

 // 多次重试 

 enq(node); 

 return node; 

 private Node enq(final Node node) { 

 // 多次重试 

 for (;;) { 

 Node t = tail; 

 // 如果t不存在则,设置为头节点 

 if (t == null) { // Must initialize 

 if (compareAndSetHead(new Node())) 

 tail = head; 

 } else { 

 // 如果存在则设置为尾结点 

 node.prev = t; 

 if (compareAndSetTail(t, node)) { 

 t.next = node; 

 return t; 

 }

上面代码,两个方法都是通过一个CAS的方法compareAndSetTail(Node expect,Node update)方法来确保节点能够被线程安全的添加到同步队列的尾部。在enq(final Node node)方法中,AQS通过“死循环”来保证节点的正确添加,在死循环中,只有通过CAS将节点成功的设置为尾节点后,当前线程才能从该方法返回,否则当前线程还将不断得尝试。否则会一直执行下去。

Java并发编程之AQS详解编程语言

出列:

AQS队列遵循FIFO,首节点是获取同步状态成功的节点,当首节点的线程在释放同步状态时,将会唤醒后继节点,而后继节点将会在获取同步状态成功时将自己设置为首节点,设置首节点是通过获取同步状态成功的线程来完成的,由于只有一个线程能够成功获取到同步状态,因此设置头节点的方法并不需要折佣CAS来保证,它只需将头节点设置为原首节点的后继节点,并断开首节点的next引用即可。过程如下图:

Java并发编程之AQS详解编程语言

二、独占式

独占式即同一时刻,只能用有一个线程成功获取同步状态。

独占式同步状态的获取

通过调用AQS的acquire(int arg)方法可以获取同步状态,该方法对中断不敏感,也就是由于线程获取同步状态失败后进入同步队列中,后续对线程进行中断操作时,线程不会从同步队列中移出。代码如下:

 public final void acquire(int arg) { 

 if (!tryAcquire(arg) 

 acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) 

 selfInterrupt(); 

方法定义如下:

tryAcquire:tryAcquire()方法尝试直接去获取资源,如果成功则返回true,失败则返回false;该方法需要保证线程安全的获取同步状态。 addWaiter:如果tryAcquire() 方法同步状态获取失败,则调用该方法将当前节点加入到CLH同步队列尾部。 acquireQueued:acquireQueued()方法使线程在等待队列中获取资源(自旋),一直获取到资源后才返回。如果在整个等待过程中被中断过,则返回true,否则返回false。 如果线程在等待过程中被中断过,它是不响应的。只是获取资源后才再进行自我中断selfInterrupt(),将中断补上。

当前线程(Node)进入到同步队列后,进入了一个自旋的过程,每个节点都在自省的观察,当条件满足,获取到了同步状态时,就可以从这个自旋中退出,否则继续自旋并且阻塞节点的线程,代码如下:

 final boolean acquireQueued(final Node node, int arg) { 

 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; 

 //检查和更新未能获取的节点的状态。 

 //如果线程应该阻塞,返回true。 这是主要信号 

 //控制所有获取循环。 需要pred == node.prev 

 if (shouldParkAfterFailedAcquire(p, node) 

 parkAndCheckInterrupt()) 

 interrupted = true; 

 } finally { 

 //如果需要中断,则把节点从队列中移除 

 if (failed) 

 cancelAcquire(node); 

 }

从以上代码可以知道,只有当线程的前驱节点是头节点才能继续获取同步状态,理由如下:

维护同步队列的FIFO原则。 头节点是成功获取到同步状态的节点,而头节点的线程释放了同步状态后,将会唤醒后继节点,后继节点的线程被唤醒后需要检查自己的前驱节点是否是头节点。

acquire(int arg)方法调用流程,如图:

Java并发编程之AQS详解编程语言

独占式获取响应中断

此段内容摘自:【死磕Java并发】—–J.U.C之AQS:同步状态的获取与释放

AQS提供了acquire(int arg)方法以供独占式获取同步状态,但是该方法对中断不响应,对线程进行中断操作后,该线程会依然位于CLH同步队列中等待着获取同步状态。为了响应中断,AQS提供了acquireInterruptibly(int arg)方法,该方法在等待获取同步状态时,如果当前线程被中断了,会立刻响应中断抛出异常InterruptedException。

 public final void acquireInterruptibly(int arg) 

 throws InterruptedException { 

 if (Thread.interrupted()) 

 throw new InterruptedException(); 

 if (!tryAcquire(arg)) 

 doAcquireInterruptibly(arg); 

 }

首先校验该线程是否已经中断了,如果是则抛出InterruptedException,否则执行tryAcquire(int arg)方法获取同步状态,如果获取成功,则直接返回,否则执行doAcquireInterruptibly(int arg)。doAcquireInterruptibly(int arg)定义如下:

 private void doAcquireInterruptibly(int arg) 

 throws InterruptedException { 

 final Node node = addWaiter(Node.EXCLUSIVE); 

 boolean failed = true; 

 try { 

 for (;;) { 

 final Node p = node.predecessor(); 

 if (p == head tryAcquire(arg)) { 

 setHead(node); 

 p.next = null; // help GC 

 failed = false; 

 return; 

 if (shouldParkAfterFailedAcquire(p, node) 

 parkAndCheckInterrupt()) 

 throw new InterruptedException(); 

 } finally { 

 if (failed) 

 cancelAcquire(node); 

doAcquireInterruptibly(int arg)方法与acquire(int arg)方法仅有两个差别。1.方法声明抛出InterruptedException异常,2.在中断方法处不再是使用interrupted标志,而是直接抛出InterruptedException异常。

独占式超时获取同步状态

AQS除了提供上面两个方法外,还提供了一个增强版的方法:tryAcquireNanos(int arg,long nanos)。该方法为acquireInterruptibly方法的进一步增强,它除了响应中断外,还有超时控制。即如果当前线程没有在指定时间内获取同步状态,则会返回false,否则返回true。如下:

 public final boolean tryAcquireNanos(int arg, long nanosTimeout) 

 throws InterruptedException { 

 if (Thread.interrupted()) 

 throw new InterruptedException(); 

 return tryAcquire(arg) || 

 doAcquireNanos(arg, nanosTimeout); 

通过调用同步器的doAcquireNanos(int arg,long nanosTimeOut)方法可以超时获取同步状态,即在指定的时间段内获取同步状态,如果获取到同步状态则返回true,否则,返回false。

 private boolean doAcquireNanos(int arg, long nanosTimeout) 

 throws InterruptedException { 

 if (nanosTimeout = 0L) 

 return false; 

 // 超时时间 

 final long deadline = System.nanoTime() + nanosTimeout; 

 // 新增节点 

 final Node node = addWaiter(Node.EXCLUSIVE); 

 boolean failed = true; 

 try { 

 // 自旋 

 for (;;) { 

 final Node p = node.predecessor(); 

 // 获取同步状态 

 if (p == head tryAcquire(arg)) { 

 setHead(node); 

 p.next = null; // help GC 

 failed = false; 

 return true; 

 // 重新计算还需要休眠的时间 

 nanosTimeout = deadline - System.nanoTime(); 

 // 如果超时,则返回false 

 if (nanosTimeout = 0L) 

 return false; 

 // 如果没有超时,则等待上面计算的休眠时间 

 if (shouldParkAfterFailedAcquire(p, node) 

 nanosTimeout spinForTimeoutThreshold) 

 LockSupport.parkNanos(this, nanosTimeout); 

 if (Thread.interrupted()) 

 throw new InterruptedException(); 

 } finally { 

 if (failed) 

 cancelAcquire(node); 

doAcquireNanos(int arg,long nanosTimeOut)方法针对超时获取,首先记录唤醒时间deadline (deadline = System.nanoTime() + nanosTimeout)。如果获取失败,则需要计算出需要睡眠的时间间隔nanosTimeout,nanosTimeout计算公式为:nanosTimeout =deadline System.nanoTime(),如果nanosTimeout小于0,则表示已经超时,直接返回false。反之,如果大于spinForTimeoutThreshold(1000纳秒),则需要休眠nanosTimeout 。如果nanosTimeout = spinForTimeoutThreshold ,就不需要休眠了,可以直接进入快速自旋的过程。原因在于 spinForTimeoutThreshold 已经非常端,短到时间等待无法做到十分精确,如果这时再次进行超时等待,相反会让nanosTimeout 的超时从整体上面表现得不是那么精确,所以在超时非常短的场景中,AQS会进行无条件的快速自旋。

流程如下:

Java并发编程之AQS详解编程语言

三、共享式

共享式与独占式的最主要区别在于同一时刻可以有多个线程获取同步状态。以读写文件为例,如果一个程序在对文件进行读操作,那么这一时刻对于文件的写操作均被阻塞,而读操作能够同时进行,写操作要求对资源独占式访问。读操作则是共享式访问。

 public final void acquireShared(int arg) { 

 if (tryAcquireShared(arg) 0) 

 doAcquireShared(arg); 

上面代码中的acquireShared(int arg)方法中,AQS调用tryAcquireShared(int arg)方法,尝试获取同步状态,此方法返回值为int类型,如果获取失败(就是返回值小于0),则调用doAcquireShared()方法,此方法以自旋的方式来继续获取同步状态,如上所说,如果获取同步状态的值大于等于0,便获取成功。doAcquireShared方法实现如下:

 private void doAcquireShared(int arg) { 

 // 加入队列尾部 

 final Node node = addWaiter(Node.SHARED); 

 // 是否成功标志 

 boolean failed = true; 

 try { 

 //等待过程中是否被中断过的标志 

 boolean interrupted = false; 

 for (;;) { 

 // 前驱 

 final Node p = node.predecessor(); 

 // 如果到head的下一个,因为head是拿到资源的线程,此时node被唤醒,很可能是head用完资源来唤醒自己的 

 if (p == head) { 

 int r = tryAcquireShared(arg);// 尝试获取同步状态 

 if (r = 0) {// 成功 

 // 将head指向自己,还有剩余资源可以再唤醒之后的线程 

 setHeadAndPropagate(node, r); 

 p.next = null; // help GC 

 // 如果等待过程中被打断过,此时将中断补上。 

 if (interrupted) 

 selfInterrupt(); 

 failed = false; 

 return; 

 // 判断状态,寻找安全点,进入waiting状态,等着被unpark()或interrupt() 

 if (shouldParkAfterFailedAcquire(p, node) 

 parkAndCheckInterrupt()) 

 interrupted = true; 

 } finally { 

 if (failed) 

 cancelAcquire(node); 

这貌似和acquireQueued()方法特别类似,流程并没有特别大的区别,只是这里把中断的selfInterrupt()方法放到doAcquireShared(int arg)方法里去了。首先在doAcquireShared()方法里,会先去尝试获取同步状态,如果返回值大于等于0,则表示获取成功,那么便会从自旋过程中退出。

独占式获取同步状态之后,直接返回中断的状态然后,结束流程,而共享式则还需调用setHeadAndPropagate方法传播唤醒的动作。

private void setHeadAndPropagate(Node node, int propagate) { 

 Node h = head; // 保存当前头节点 

 setHead(node); // 把当前节点设为头节点 

 * 这里有三种情况执行唤醒操作:1.propagate 0,代表后继节点需要被唤醒 

 * 2. h节点的waitStatus 0或者 h=null 

 * 3. 新的头结点为空 或者 新的头结点的waitStatus 0 

 if (propagate 0 || h == null || h.waitStatus 0 || 

 (h = head) == null || h.waitStatus 0) { 

 // 找到当前节点的后继节点s 

 Node s = node.next; 

 if (s == null || s.isShared()) 

 // s=null 或者 s是共享模式,调用doReleaseShared方法唤醒后继线程 

 doReleaseShared(); 

 }

与独占式一样,共享式获取也是需要释放同步状态的,这里便通过调用releaseShared()方法来释放同步状态,代码如下:

 public final boolean releaseShared(int arg) { 

 if (tryReleaseShared(arg)) { 

 doReleaseShared(); 

 return true; 

 return false; 

 }

该方法在释放同步状态之后,将会唤醒后续处于等待状态的节点,对于能够支持多个线程访问的并发组件(比如Semaphore),它和独占式主要区别在于tryReleaseShared(int arg)方法确保同步状态(或者资源数)线程安全释放,一般通过CAS来保证的。因为释放同步状态的操作会同时来自多个线程。

参考资料:

方腾飞Java并发编程的艺术 【死磕Java并发】—–J.U.C之AQS:同步状态的获取与释放 AQS框架源码分析 Java并发之AQS详解

版权声明:尊重博主原创文章,转载请注明出处:IT虾米网

原创文章,作者:Maggie-Hunter,如若转载,请注明出处:https://blog.ytso.com/19408.html

cjava