AQS实现原理
AQS实现原理
概要
在并发编程中,锁是一种常用的保证线程安全的方法。Java 中常用的锁主要有两类,一种是 Synchronized 修饰的锁,被称为 Java 内置锁或监视器锁。另一种就是在 JDK1.5版本之后的juc(java.util.concurrent) 包中的各类同步器。这些同步器都是基于 AQS来构建的,而 AQS 类的核心数据结构是一种名为 CLH 锁的变体。接下来对AQS简单介绍下。
一、什么是AQS
AQS 的全称为 AbstractQueuedSynchronizer ,翻译过来的意思就是抽象队列同步器。这个类在 java.util.concurrent.locks 包下面。
AQS是一个用来构建锁和同步器的框架。使用 AQS 能简单且高效地构造出应用广泛的同步器,比如我们提到的 ReentrantLock,Semaphore,ReentrantReadWriteLock,SynchronousQueue,FutureTask 等等皆是基于 AQS 的。
public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer implements java.io.Serializable { }
使用AQS构造出的同步器:
二、AQS的核心思想
AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是基于CLH锁的变体实现的,即将暂时获取不到锁的线程加入到队列中。
AQS的核心原理图:
三、自旋锁
AQS 类的核心数据结构是一种名为 CLH 锁的变体。在掌握这种数据结构之前,我们先简单介绍一下自旋锁和基本的CLH锁。
1. 什么自旋锁?
在许多应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。现在绝大多数的个人电脑或服务器都是多处理器系统,能让两个或两个以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一会儿”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只须让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。
因此,可以这样描述:自旋锁是一种在多线程环境中用于同步访问共享资源的锁机制。它的工作原理是让线程在等待获取锁时进行自旋,即在原地循环等待锁释放,而不是将线程挂起或进入阻塞状态。自旋锁通常适用于锁的持有时间较短的场景,因为自旋会占用 CPU 资源。
2. 自旋锁的实现
下面是一个自旋锁实现例子,基于 AtomicBoolean 进行状态检查和更新:
1 import java.util.concurrent.atomic.AtomicBoolean; 2 3 public class SpinLock { 4 /** 5 * 定义一个AtomicBoolean来表示锁的状态,true表示锁已被占用,false表示锁空闲 6 */ 7 private final AtomicBoolean lock = new AtomicBoolean(false); 8 9 /** 10 * 加锁方法 11 */ 12 public void lock() { 13 // 使用CAS操作,尝试获取锁,如果获取失败则自旋等待 14 while (!lock.compareAndSet(false, true)) { 15 // 自旋等待,直到锁被释放 16 } 17 } 18 19 /** 20 * 解锁方法 21 */ 22 public void unlock() { 23 // 释放锁,将状态设置为false 24 lock.set(false); 25 } 26 27 public static void main(String[] args) { 28 SpinLock spinLock = new SpinLock(); 29 30 // 创建多个线程来演示自旋锁的使用 31 Runnable task = () -> { 32 System.out.println(Thread.currentThread().getName() + " 尝试获取锁..."); 33 spinLock.lock(); 34 try { 35 System.out.println(Thread.currentThread().getName() + " 获得锁,正在执行任务..."); 36 // 模拟任务执行 37 Thread.sleep(100); 38 } catch (InterruptedException e) { 39 Thread.currentThread().interrupt(); 40 } finally { 41 System.out.println(Thread.currentThread().getName() + " 释放锁"); 42 spinLock.unlock(); 43 } 44 }; 45 46 // 启动多个线程 47 for (int i = 0; i < 3; i++) { 48 new Thread(task).start(); 49 } 50 } 51 } 52 53 // 执行结果 54 Thread-0 尝试获取锁... 55 Thread-0 获得锁,正在执行任务... 56 Thread-1 尝试获取锁... 57 Thread-2 尝试获取锁... 58 Thread-0 释放锁 59 Thread-1 获得锁,正在执行任务... 60 Thread-1 释放锁 61 Thread-2 获得锁,正在执行任务... 62 Thread-2 释放锁
说明:自旋锁的锁持续时间是从线程调用 lock() 方法成功获取锁,到 unlock() 方法释放锁为止。在上面例子中,这部分时间主要是 Thread.sleep(100); 的模拟任务执行时间,即约 100 毫秒。
3. 自旋锁优缺点
优点:自旋锁实现简单,避免了操作系统进程调度和线程上下文切换的开销。
缺点:
1)锁饥饿问题
在锁竞争激烈的情况下,可能存在一个线程一直被其他线程”插队“而一直获取不到锁的情况。
2)性能问题
在实际的多处理器上运行的自旋锁在锁竞争激烈时性能较差。
自旋锁的性能和理想情况相距甚远。这是因为自旋锁锁状态中心化,在竞争激烈的情况下,锁状态变更会导致多个 CPU 的高速缓存的频繁同步(总线风暴),从而拖慢 CPU 效率。
说明:锁状态中心化是指在自旋锁的实现中,所有等待获取锁的线程都会围绕同一个共享的锁状态变量进行检查和更新。这种共享变量会在多线程竞争时,成为获取锁的唯一争夺焦点,造成集中访问,称之为“中心化”。
4. 总结
自旋锁适用于锁竞争不激烈、锁持有时间短的场景。
四、CLH锁
CLH队列是由Craig, Landin 和 Hagersten这三个人设计的队列。它是一种基于链表的可扩展、高性能、公平的自旋锁,申请线程只在本地变量上自旋,它不断轮询前驱的状态,如果发现前驱释放了锁就结束自旋。
CLH 锁使用隐式单向链表来管理等待的线程。在实现上,每个线程会在进入锁时通过 CAS 操作来创建一个代表自己的节点,并加入到链表末尾,然后在自己的节点上自旋等待前驱节点的状态发生变化。CLH 锁通过这种方式保证锁的公平性,遵循 FIFO(先进先出)顺序。
说明:
- 每一个 CLH 节点有两个属性:所代表的线程和标识是否持有锁的状态变量。
- 这里之所以说是隐式单向链表,是因为在 CLH 锁的设计中,链表并没有通过显式的节点链接来形成链表结构,而是通过线程本地变量的引用和全局的尾指针来实现队列中的前驱和后继关系。
1. CLH锁原理
1)全局的尾指针
通过这个尾指针来构建等待线程的队列,能确保线程先到先得的公平性。此外这个尾节点指针是原子引用类型,避免了多线程并发操作的线程安全性问题。
2)线程本地变量的引用
等待锁的每个线程在自己的某个变量上自旋等待,这个变量将由前一个线程写入。由于某个线程获取锁操作时总是通过尾节点指针获取到前一线程写入的变量,而尾节点指针又是原子引用类型,因此确保了这个变量获取出来总是线程安全的。
2. 在自旋锁上进行改进
CLH 锁是对自旋锁的一种改进,有效的解决了以上的两个缺点。
1)首先它将线程组织成一个队列,保证先请求的线程先获得锁,避免了饥饿问题。
2)其次锁状态去中心化,让每个线程在不同的状态变量中自旋,这样当一个线程释放它的锁时,只能使其后续线程的高速缓存失效,缩小了影响范围,从而减少了 CPU 的开销。
3. 锁的获取与释放
1) 获取锁
线程尝试获取锁时,会创建一个新的节点并将其插入到链表的末尾。具体操作是将当前节点的前驱节点设为 Tail 所指向的节点,然后将 Tail 更新为当前节点。
线程会检查其前驱节点的状态,如果前驱节点已经释放锁(locked = false),则当前线程停止自旋并尝试获取锁;否则线程会自旋等待前驱节点的状态改变。
2)释放锁
当持有锁的线程完成操作并释放锁时,它会将自己节点的 locked 状态标记为 false。释放锁的线程退出后,前驱节点会观察到 locked 状态的变化,然后自行决定获取锁。
CLH 锁从获取到释放锁的全过程,如下图:
4. CLH锁的实现
基本的 CLH 锁的Java 实现代码如下:
1 import java.util.concurrent.atomic.AtomicReference; 2 3 public class CLHLock { 4 // 节点类,用于标识当前线程的状态 5 private static class Node { 6 // 默认状态为 "locked" 7 private volatile boolean locked = true; 8 } 9 10 // 指向尾部节点的引用,表示最新的节点。尾指针使用原子引用类型,保证线程安全。 11 private final AtomicReference<Node> tail; 12 // 每个线程都有自己专属的节点 13 private final ThreadLocal<Node> currentNode; 14 // 前一个节点,用于检查前驱节点状态 15 private final ThreadLocal<Node> previousNode; 16 17 public CLHLock() { 18 // 初始化 tail 为 null,因为队列开始为空 19 tail = new AtomicReference<>(null); 20 currentNode = ThreadLocal.withInitial(Node::new); 21 previousNode = new ThreadLocal<>(); 22 } 23 24 // 加锁方法 25 public void lock() { 26 // 获取当前线程的节点 27 Node node = currentNode.get(); 28 // 原子操作:将该节点设置为队尾,并返回之前的队尾节点 29 Node pred = tail.getAndSet(node); 30 // 将前驱节点保存到 previousNode 中 31 previousNode.set(pred); 32 // 如果前驱节点不为空,并且它的锁状态为 locked,当前线程自旋等待 33 if (pred != null) { 34 while (pred.locked) { 35 // 自旋等待前驱节点释放锁 36 } 37 } 38 } 39 40 // 解锁方法 41 public void unlock() { 42 // 获取当前线程的节点 43 Node node = currentNode.get(); 44 // 将当前节点的锁状态设置为 false,表示已释放锁 45 node.locked = false; 46 // 将当前节点置为新的前驱节点 47 currentNode.set(new Node()); 48 } 49 }
这里有几个值得注意的地方:
1)CLH 锁是一个链表队列,为什么 Node 节点没有指向前驱或后继指针呢?
原因:CLH 锁是一种隐式的链表队列,没有显式的维护前驱或后继指针。因为每个等待获取锁的线程只需要轮询前一个节点的状态就够了,而不需要遍历整个队列。在这种情况下,只需要使用一个局部变量保存前驱节点,而不需要显式的维护前驱或后继指针。CLH锁的设计中是通过线程本地变量的引用和全局的尾指针tail来实现队列中的前驱和后继关系。
2) 释放锁时为什么需要currentNode.set(new Node())这行代码?
需要这行代码生成新的 Node 节点,确保每个线程的状态和行为独立,从而避免由于节点状态的混用( Node 节点复用)导致的竞争和死锁问题。
举个例子:T1是T2的前驱节点,T1 持有锁,T2 自旋等待。当 T1 释放锁(设置为 false),但此时 T2 尚未抢占到锁。此时如果 T1 再次调用 lock()请求获取锁,会将状态设为 True,同时自旋等待 T2 释放锁。而 T2 也自旋等待前驱节点状态变为 False,这样就造成了死锁。如下图:
5. CLH锁的优缺点
1)优点
- 避免了惊群效应。假设100个线程在等待锁,锁释放之后,只有下一个排队线程(即前驱节点的后继线程)检测到前驱节点的状态变化并获取锁。避免同时唤醒大量线程,浪费CPU资源。
- 性能优异,获取和释放锁开销小。CLH 的锁状态不再是单一的原子变量,而是分散在每个节点的状态中,降低了自旋锁在竞争激烈时频繁同步的开销。在释放锁的开销也因为不需要使用 CAS 指令而降低了。
- 公平锁。先入队的线程会先得到锁。
2)缺点
- 因为有自旋操作,当锁持有时间长时会带来较大的 CPU 开销。
- CLH 锁功能单一,不改造不能支持复杂的功能。
五、CLH锁的变体
针对 CLH 的缺点,AQS 对 CLH 队列锁进行了一定的改造。
1)针对第一个缺点,AQS 将自旋操作改为阻塞线程操作。
2)针对第二个缺点,AQS 对 CLH 锁进行改造和扩展,原作者 Doug Lea 称之为“CLH 锁的变体”。
AQS 中的对 CLH 锁数据结构的改进主要包括三方面:扩展每个节点的状态、显式的维护前驱节点和后继节点以及诸如出队节点显式设为 null 等辅助 GC 的优化。
1. 扩展每个节点的状态
1 static final class Node { 2 // 等待状态字段 3 static final int CANCELLED = 1; // 线程已取消 4 static final int SIGNAL = -1; // 线程需要被唤醒 5 static final int CONDITION = -2; // 线程在条件队列中等待 6 static final int PROPAGATE = -3; // 用于传播信号的状态 7 8 volatile int waitStatus; // 当前节点的等待状态 9 Node prev; // 前驱节点 10 Node next; // 后继节点 11 12 // 构造函数 13 Node() { 14 // 没有明确设置 waitStatus,但它会在构造 Node 对象时自动初始化为 0 15 } 16 }
在复杂的多线程环境中,简单的锁状态无法满足所有需求。扩展状态可以支持更多的同步机制,如读写锁、可重入锁、条件变量、共享锁和独占锁等,以应对不同的场景。
2. 显式的维护前驱节点和后继节点
AQS用阻塞等待替换了自旋操作,线程会阻塞等待锁的释放,不能主动感知到前驱节点状态变化的信息。AQS 中显式的维护前驱节点和后继节点,需要释放锁的节点会显式通知下一个节点解除阻塞,如下图所示,T1 释放锁后主动唤醒 T2,使 T2 检测到锁已释放,获取锁成功。
3. 辅助 GC
JVM 的垃圾回收机制使开发者无需手动释放对象。但在 AQS 中需要在释放锁时显式的设置为 null,避免引用的残留,辅助垃圾回收。
六、AQS是如何实现的?
1. 状态管理
AQS使用一个 int 成员变量state来表示同步状态,通过内置的FIFO队列来完成获取资源线程的排队工作。AQS使用CAS对该同步状态进行原子操作实现对其值的修改。
private transient volatile Node head; private transient volatile Node tail; // 共享变量,使用volatile修饰保证线程可见性 private volatile int state;
另外,状态信息 state 可以通过 protected 类型的getState()、setState()和compareAndSetState() 进行操作。并且,这几个方法都是 final 修饰的,在子类中无法被重写。
1 //返回同步状态的当前值 2 protected final int getState() { 3 return state; 4 } 5 // 设置同步状态的值 6 protected final void setState(int newState) { 7 state = newState; 8 } 9 // 如果当前同步状态的值等于expect(期望值),原子地(CAS操作)将同步状态值设置为给定值update 10 protected final boolean compareAndSetState(int expect, int update) { 11 return unsafe.compareAndSwapInt(this, stateOffset, expect, update); 12 }
以可重入的互斥锁 ReentrantLock 为例,它内部维护一个 state 变量表示锁的占用状态。初始值为 0,表示未锁定。当线程 A 调用 lock() 时,尝试独占锁并将 state 加 1。如果成功,线程 A 获取到锁;如果失败,线程 A 会被加入等待队列。成功获取锁后,线程 A 可以重复获取该锁(state 会累加),这就是可重入性的体现:同一线程可以多次获取同一锁而不被阻塞。但线程必须释放与获取的次数相同的锁,才能使 state 回到 0,让锁恢复到未锁定状态。只有这样,其他等待的线程才能有机会获取该锁。
ReentrantLock的实现中,线程A尝试获取锁的过程如下图:
六、 AQS的核心方法
AQS 的设计是基于模板方法模式的,它有一些方法必须要子类去实现的,它们主要有:
1 /** 2 * 尝试获取独占锁。 3 * 子类需要实现该方法来定义获取锁的具体逻辑。 4 * 5 * @param arg 可能用于获取锁的附加参数 6 * @return 如果成功获取锁,则返回 true;否则返回 false 7 */ 8 protected boolean tryAcquire(int arg) { 9 throw new UnsupportedOperationException(); 10 } 11 12 /** 13 * 尝试释放独占锁。 14 * 子类需要实现该方法来定义释放锁的具体逻辑。 15 * 16 * @param arg 可能用于释放锁的附加参数 17 * @return 如果成功释放锁,则返回 true;否则返回 false 18 */ 19 protected boolean tryRelease(int arg) { 20 throw new UnsupportedOperationException(); 21 } 22 23 /** 24 * 尝试获取共享锁。 25 * 子类需要实现该方法来定义获取共享锁的具体逻辑。 26 * 27 * @param arg 可能用于获取共享锁的附加参数 28 * @return 如果成功获取共享锁,则返回一个大于等于 0 的值; 29 * 如果无法获取,则返回一个负值。 30 */ 31 protected int tryAcquireShared(int arg) { 32 throw new UnsupportedOperationException(); 33 } 34 35 /** 36 * 尝试释放共享锁。 37 * 子类需要实现该方法来定义释放共享锁的具体逻辑。 38 * 39 * @param arg 可能用于释放共享锁的附加参数 40 * @return 如果成功释放共享锁,则返回一个大于等于 0 的值; 41 * 如果无法释放,则返回一个负值。 42 */ 43 protected boolean tryReleaseShared(int arg) { 44 throw new UnsupportedOperationException(); 45 } 46 47 /** 48 * 判断当前锁是否由当前线程独占持有。 49 * 子类需要实现该方法来提供锁的独占状态的具体实现。 50 * 51 * @return 如果当前线程独占持有锁,则返回 true;否则返回 false 52 */ 53 protected boolean isHeldExclusively() { 54 throw new UnsupportedOperationException(); 55 }
这里不使用抽象方法的目的是:避免强迫子类中把所有的抽象方法都实现一遍,这样子类只需要实现自己关心的抽象方法即可,比如 信号 Semaphore 只需要实现 tryAcquire 方法而不用实现其余不需要用到的模版方法。
1. 获取锁
1)acquire方法
在获取锁时,线程会调用 acquire() 方法。
使用tryAcquire() 尝试直接获取锁,如果成功了直接返回。如果线程获取锁失败了,这个方法会将当前线程包装成一个 Node 对象,并尝试将其添加到队列中被放入等待队列,acquireQueued会把放入队列中的线程不断去获取锁,直到获取成功或者不再需要获取(中断)。
1 public final void acquire(int arg) { 2 // 尝试直接获取锁,如果成功,返回 3 if (!tryAcquire(arg) && 4 // 如果获取失败,加入等待队列并尝试获取 5 acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) 6 // 在失败后,如果需要,设置当前线程的中断状态 7 selfInterrupt(); 8 }
注意:该方法以独占模式获取(资源),忽略中断,即线程在aquire过程中,中断此线程是无效的。
由上述源码可以知道,当一个线程调用acquire时,调用方法流程如下:
tryAcquire是需要由子类去实现的,主要用于尝试直接获取资源(如锁)的操作。如果获取成功,它返回 true,否则返回 false。该方法通常是非阻塞的,即在调用时不会导致线程进入等待状态。它会立即尝试获取锁并返回结果。不同的锁实现(如 ReentrantLock、Semaphore 等)会根据自己的需求实现 tryAcquire 方法。例如,可能会根据当前锁的状态(如是否已经被占用)来决定是否允许获取。
2)addWaiter方法
将当前线程构造成节点添加到等待队列(sync 队列)中
1 // 添加等待者 2 private Node addWaiter(Node mode) { 3 // 新生成一个节点,默认为独占模式 4 Node node = new Node(Thread.currentThread(), mode); 5 // 保存尾节点 6 Node pred = tail; 7 // 尾节点不为空,即已经被初始化 8 if (pred != null) { 9 // 将node结点的prev域连接到尾节点 10 node.prev = pred; 11 // 比较pred是否为尾节点,是则将尾节点设置为node 12 if (compareAndSetTail(pred, node)) { 13 // 设置尾节点的next域为node 14 pred.next = node; 15 // 返回新生成的节点 16 return node; 17 } 18 } 19 // 尾节点为空(即还没有被初始化过),或者是compareAndSetTail操作失败,则入队列 20 enq(node); 21 return node; 22 }
3)enq方法
enq方法会使用无限循环来确保节点的成功插入。
1 /** 2 * 将给定节点入队列 3 * 该方法保证节点能够成功入队,使用自旋的方式处理并发情况。 4 * 5 * @param node 要入队的节点 6 * @return 返回入队之前的尾节点 7 */ 8 private Node enq(final Node node) { 9 // 无限循环,确保节点能够成功入队列 10 for (;;) { 11 // 保存当前尾节点 12 Node t = tail; 13 // 如果尾节点为空,说明队列尚未初始化 14 if (t == null) { 15 // 尝试将头节点设置为新节点,成功则初始化队列 16 if (compareAndSetHead(new Node())) 17 // 头节点和尾节点指向同一个新节点 18 tail = head; 19 } else { // 尾节点不为空,队列已经初始化 20 // 将新节点的前驱指向当前尾节点 21 node.prev = t; 22 // 尝试将尾节点更新为新节点 23 if (compareAndSetTail(t, node)) { 24 // 设置旧尾节点的后继为新节点 25 t.next = node; 26 // 返回旧尾节点 27 return t; 28 } 29 } 30 } 31 }
4)acquireQueued 方法
acquireQueued方法是用于处理锁获取的具体实现方法,主要用于在失败后将线程加入到等待队列(sync队列)中,直到锁可用为止。
1 // sync队列中的节点在独占且忽略中断的模式下获取(资源) 2 final boolean acquireQueued(final Node node, int arg) { 3 // 标志: 用于跟踪当前获取锁的操作是否成功 true-获取失败 false-获取成功 4 boolean failed = true; 5 try { 6 // 中断标志 7 boolean interrupted = false; 8 for (;;) { // 无限循环 9 // 获取node节点的前驱节点 10 final Node p = node.predecessor(); 11 // 前驱为头节点并且成功获得锁 12 if (p == head && tryAcquire(arg)) { 13 setHead(node); // 设置头节点 14 p.next = null; // help GC 15 failed = false; // 设置标志 16 return interrupted; 17 } 18 19 // 当获取(资源)失败后,检查并且更新结点状态 20 if (shouldParkAfterFailedAcquire(p, node) && 21 // 进行park操作并且返回该线程是否被中断 22 parkAndCheckInterrupt()) 23 interrupted = true; 24 } 25 } finally { 26 if (failed) 27 cancelAcquire(node); // 获取锁的操作失败,执行取消操作以清理资源 28 } 29 }
首先获取当前节点的前驱节点,如果前驱节点是头节点并且能够获取(资源),代表该当前节点能够占有锁,设置头节点为当前节点,并且返回。
否则,调用shouldParkAfterFailedAcquire和parkAndCheckInterrupt方法。
5)shouldParkAfterFailedAcquire方法
在 shouldParkAfterFailedAcquire 方法中,可以看到通过节点的状态管理和前驱节点的检查,如何决定当前线程是否应该进入阻塞状态。这个机制是实现阻塞队列功能的核心,确保了资源的高效管理和线程之间的良好协作。
1 // 当获取(资源)失败后,检查并且更新节点状态 2 private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) { 3 // 获取前驱结点的状态 4 int ws = pred.waitStatus; 5 if (ws == Node.SIGNAL) // 状态为SIGNAL,为-1 6 // 可以进行park操作 7 return true; 8 if (ws > 0) { // 表示状态为CANCELLED,为1 9 // 前驱节点被取消。跳过前驱并指示重试。 10 do { 11 node.prev = pred = pred.prev; 12 } while (pred.waitStatus > 0); // 找到前驱节点中最近的状态不为CANCELLED的节点 13 // 赋值前驱节点的next域 14 pred.next = node; 15 } else { // 为PROPAGATE -3 或者是0 表示无状态(为CONDITION -2时,表示此节点在condition queue中) 16 // waitStatus 必须为 0 或 PROPAGATE。指示需要信号,但尚不进行 park 操作。调用者需要重试以确保在挂起之前无法获取。 17 // 比较并设置前驱节点的状态为SIGNAL 18 compareAndSetWaitStatus(pred, ws, Node.SIGNAL); 19 } 20 // 不能进行park操作 21 return false; 22 }
只有当该节点的前驱结点的状态为SIGNAL时,才可以对该节点所封装的线程进行park操作。否则,将不能进行park操作。
这里要注意节点的状态管理和前驱节点的检查是如何进行的:
- SIGNAL(-1): 如果前驱节点的状态是SIGNAL,这表示它已经准备好释放锁,当前节点可以安全地进入阻塞状态(调用park),并等待唤醒后获取锁。
- CANCELLED(1):如果前驱节点的状态为CANCELLED,则意味着这个节点已经被取消,不再参与锁的获取逻辑。此时,方法会跳过这个节点,继续查找前驱节点直到找到状态不为CANCELLED的节点,并将当前节点的prev指向这个有效的前驱节点。
- 0 或 PROPAGATE:如果前驱节点的状态为0或PROPAGATE,方法会尝试将前驱节点的状态更新为SIGNAL,以表明后继节点需要获取锁,但当前节点尚未阻塞。此时,调用者将需要重试以确保在挂起之前无法获取。
6)parkAndCheckInterrupt方法
parkAndCheckInterrupt方法里的逻辑是首先执行park操作,即禁用当前线程,然后返回该线程是否已经被中断。
1 //进行park操作并且返回该线程是否被中断 2 private final boolean parkAndCheckInterrupt() { 3 // 在许可可用之前禁用当前线程,并且设置了blocker 4 LockSupport.park(this); 5 return Thread.interrupted(); // 当前线程是否已被中断,并清除中断标记位 6 }
7)cancelAcquire方法
该方法完成的功能就是取消当前线程对资源的获取,即设置该结点的状态为CANCELLED。
1 // 取消继续获取(资源) 2 private void cancelAcquire(Node node) { 3 // 如果节点为空,直接返回 4 if (node == null) 5 return; 6 7 // 将节点的线程设置为 null 8 node.thread = null; 9 10 // 跳过被取消的前驱节点 11 Node pred = node.prev; 12 // 找到第一个状态不为 CANCELLED 的前驱节点 13 while (pred.waitStatus > 0) 14 node.prev = pred = pred.prev; 15 16 // 获取前驱节点的下一个节点 17 Node predNext = pred.next; 18 19 // 将节点的状态设置为 CANCELLED 20 node.waitStatus = Node.CANCELLED; 21 22 // 如果当前节点是尾节点,更新尾节点为前驱节点 23 if (node == tail && compareAndSetTail(node, pred)) { 24 // 将前驱节点的 next 设置为 null 25 compareAndSetNext(pred, predNext, null); 26 } else { 27 // 如果前驱节点需要信号,则尝试设置其下一个节点 28 int ws; 29 if (pred != head && 30 ((ws = pred.waitStatus) == Node.SIGNAL || 31 (ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) && 32 pred.thread != null) { 33 // 保存后继节点 34 Node next = node.next; 35 // 如果后继节点不为空且状态小于等于 0,更新前驱节点的 next 36 if (next != null && next.waitStatus <= 0) 37 compareAndSetNext(pred, predNext, next); 38 } else { 39 unparkSuccessor(node); // 唤醒等待队列中当前节点的后继节点 40 } 41 42 node.next = node; // 帮助垃圾回收 43 } 44 }
8)unparkSuccessor方法
unparkSuccessor 方法的主要作用是唤醒在等待队列中排在当前节点后面的第一个节点,即释放后继节点。
这个方法确保在当前节点完成操作后,能够有效地将控制权转移给下一个需要执行的线程,从而提高资源的利用率和系统的并发性能。
1 // 释放后继节点 2 private void unparkSuccessor(Node node) { 3 // 获取node节点的等待状态 4 int ws = node.waitStatus; 5 if (ws < 0) // 状态值小于0,为SIGNAL -1 或 CONDITION -2 或 PROPAGATE -3 6 // 比较并且设置节点等待状态,设置为0 7 compareAndSetWaitStatus(node, ws, 0); 8 9 // 获取node节点的下一个节点 10 Node s = node.next; 11 if (s == null || s.waitStatus > 0) { // 下一个结点为空或者下一个节点的等待状态大于0,即为CANCELLED 12 // s赋值为空 13 s = null; 14 // 从尾结点开始从后往前开始遍历 15 for (Node t = tail; t != null && t != node; t = t.prev) 16 if (t.waitStatus <= 0) // 找到等待状态小于等于0的节点,找到最前的状态小于等于0的节点 17 // 保存节点 18 s = t; 19 } 20 if (s != null) // 该节点不为空,释放许可,唤醒线程 21 LockSupport.unpark(s.thread); 22 }
需要注意的地方:
由于没有针对双向链表节点的类似 compareAndSet 的原子性无锁插入指令,因此后驱节点(next)的设置通常是一个简单的赋值操作,而不是一个原子操作。在释放锁时,如果当前节点的后驱节点不可用时,将从利用队尾指针 Tail 从尾部遍历到直到找到当前节点正确的后驱节点。
利用tail指针从尾部遍历的原因:
1)当前节点的状态不明确
当前节点释放锁时,可能会有多个线程在等待队列中处于阻塞状态。由于线程状态的更新存在延迟或竞态条件,后继节点的状态可能未及时更新,或者可能已被其他线程修改(例如被取消或发生超时)。因此,后继节点的状态可能为 null 或 CANCELLED 等无效状态。为了避免误唤醒无效线程,AQS会从队列的尾部开始遍历,以确保唤醒的是状态有效且已准备好的线程。
2)从尾部遍历的有效性
从 tail 开始遍历可以确保找到队列中的最后一个有效节点,并且这些节点是在线程已知的状态下存在的。因为 tail 是最近一次成功插入的节点,其后继节点的状态更有可能是稳定和有效的。
对于cancelAcquire与unparkSuccessor方法,如下示意图可以清晰的表示:
2. 释放锁
release方法
1 public final boolean release(int arg) { 2 // 尝试释放锁并更新状态,arg 通常表示释放的状态或次数 3 if (tryRelease(arg)) { 4 // 如果释放成功,获取当前队列的头节点 5 Node h = head; 6 // 头节点不为空并且头节点状态不为0 7 if (h != null && h.waitStatus != 0) 8 // 唤醒头节点的后继结点 9 unparkSuccessor(h); 10 return true; 11 } 12 return false; 13 }
其中,tryRelease的默认实现是抛出异常,需要具体的子类实现,如果tryRelease成功,那么如果头节点不为空并且头节点的状态不为0,则唤醒头节点的后继结点。
七、条件队列(conditon queue)
1. 条件队列的作用
条件队列同样是一个FIFO的队列,节点的类型直接复用的同步队列的节点类型 - AQS的静态内部类AbstractQueuedSynchronizer.Node。
一个Condition对象的队列中,每个节点包含的线程就是在该Condition对象上等待的线程,那么如果一个锁对象获取了多个Condition对象,就可能会有不同的线程在不同的Condition对象上等待。
2. ConditonObject
ConditionObject 是 AQS 的内部类,用于实现条件队列,也就是在同步机制之上提供的等待/通知功能。
部分源码如下:
1 public class ConditionObject implements Condition, java.io.Serializable { 2 // 链表头部,表示条件队列的头节点 3 private transient Node firstWaiter; // 条件队列的头 4 5 // 链表尾部,表示条件队列的尾节点 6 private transient Node lastWaiter; // 条件队列的尾 7 8 // 等待当前条件的方法 9 public final void await() throws InterruptedException { 10 if (Thread.interrupted()) 11 throw new InterruptedException(); 12 Node node = addConditionWaiter(); 13 //... 14 } 15 16 // 添加当前线程到条件队列 17 private Node addConditionWaiter() { 18 //... 19 } 20 21 // 唤醒一个等待的线程 22 public final void signal() { 23 //... 24 } 25 // 唤醒所有等待的线程 26 public final void signalAll() { 27 //... 28 } 29 30 //... 31 }
ConditionObject 使用 LockSupport.park() 和 unpark() 实现条件等待的阻塞与唤醒,但加入了条件变量的控制逻辑。当一个线程调用 await() 时,它会进入条件队列并调用 LockSupport.park() 来阻塞自己。当某个线程持有锁并调用 signal() 或 signalAll() 时,它会将条件队列中的线程移到同步队列(也就是重新开始竞争锁),然后使用LockSupport.unpark() 唤醒线程,使其能够继续执行,直到它成功获取锁并继续执行后续逻辑。
下面是具体的源码分析:
1)await方法
await方法用于将当前线程构造成节点并加入等待队列中,并释放锁,然后当前线程会进入等待状态,等待唤醒。
调用该方法的线程也一定是成功获取了锁的线程,也就是同步队列中的首结点,如果一个没有获得锁的线程调用此方法,那么可能会抛出异常!
1 public final void await() throws InterruptedException { 2 //如果当前线程被中断直接抛出异常 3 if (Thread.interrupted()) 4 throw new InterruptedException(); 5 //将节点加入到条件队列 6 Node node = addConditionWaiter(); 7 //释放到之前获取的所有锁资源。有可能会有重入锁 8 long savedState = fullyRelease(node); 9 int interruptMode = 0; 10 11 //检查当前节点的状态如果是-2,就会在while循环里进行条件等待(在addConditionWaiter()方法中已经将当前节点的状态设置为了-2,所以说这里肯定会进入到while循环中) , 12 //也判断了当前节点在不在同步队列中(node.prev == null 说明当前节点不在同步队列中)同步队列是有头节点的,而条件队列没有 13 while (!isOnSyncQueue(node)) { 14 //挂起当前线程 15 LockSupport.park(this); 16 //醒来之后检查自己是否被中断,这里也有可能会调出循环 17 if ((interruptMode = checkInterruptWhileWaiting(node)) != 0) 18 break; 19 } 20 //走到这里说明节点已经条件满足被加入到了同步队列中或者中断了 21 //这个方法就跟独占锁调用同样的获取锁方法,从这里可以看出条件队列只能用于独占锁 22 if (acquireQueued(node, savedState) && interruptMode != THROW_IE) 23 interruptMode = REINTERRUPT; 24 //走到这里说明已经成功获取到了独占锁,接下来就做些收尾工作 25 //删除条件队列中被取消的节点 26 if (node.nextWaiter != null) // clean up if cancelled 27 unlinkCancelledWaiters(); 28 if (interruptMode != 0) 29 reportInterruptAfterWait(interruptMode); 30 }
2)isOnSyncQueue方法
1 final boolean isOnSyncQueue(Node node) { 2 // 如果节点的状态为 CONDITION 或者前驱节点为空,表示节点不在同步队列中 3 if (node.waitStatus == Node.CONDITION || node.prev == null) 4 return false; 5 // 如果节点有后继节点,则说明它已经在同步队列中 6 if (node.next != null) 7 return true; 8 9 // 通过从队尾向前遍历确认节点是否在同步队列中 10 // 通常该节点位于队列尾部附近,因此遍历距离很短 11 return findNodeFromTail(node); 12 }
3)addConditonWaiter方法
添加节点到条件队列 :同步队列的首节点并不会直接加入等待队列,而是通过addConditionWaite方法把当前线程构造成一个新的节点并将其加入等待队列中。
1 /** 2 * 位于ConditionObject中的方法 3 * 当前线程封装成Node.CONDITION类型的Node节点链接到条件队列尾部 4 * 5 * @return 新添加的结点 6 */ 7 private Node addConditionWaiter() { 8 //获取条件队列尾结点t 9 Node t = lastWaiter; 10 /*1 如果t的状态不为Node.CONDITION,即不是等待状态了遍历整个条件队列链表,清除所有不在等待状态的结点*/ 11 if (t != null && t.waitStatus != Node.CONDITION) { 12 //遍历整个条件队列链表,清除所有不在等待状态的结点 13 unlinkCancelledWaiters(); 14 //获取最新的尾结点 15 t = lastWaiter; 16 } 17 /*2 将当前线程包装成Node.CONDITION类型的Node加入条件队列尾部 18 * 这里不需要CAS,因为此时线程已经获得了锁,不存在并发的情况 19 * 从这里也能看出来,条件队列仅仅是一条通过nextWaiter维持后继关系的单链表,同时不存在类似于同步队列的哨兵结点 20 * */ 21 Node node = new Node(Thread.currentThread(), Node.CONDITION); 22 if (t == null) 23 //lastwaiter如果等于null,说明目前队列为空,所以将firstWaiter也指向node节点 24 firstWaiter = node; 25 else 26 //不为空的话 将lastWaiter的下一个节点指向node 27 t.nextWaiter = node; 28 //最后一个节点就是node 29 lastWaiter = node; 30 //返回新加结点 31 return node; 32 }
4)signal方法
通知单个线程
1 /** 2 * Conditon中的方法 3 * 将等待时间最长的结点移动到同步队列,然后unpark唤醒 4 * 5 * @throws IllegalMonitorStateException 如果当前调用线程不是获取锁的线程,则抛出异常 6 */ 7 public final void signal() { 8 /*1 首先调用isHeldExclusively检查当前调用线程是否是持有锁的线程 9 * isHeldExclusively方法需要我们重写 10 * */ 11 if (!isHeldExclusively()) 12 throw new IllegalMonitorStateException(); 13 //获取头结点 14 Node first = firstWaiter; 15 /*2 如果不为null,调用doSignal方法将等待时间最长的一个结点从条件队列转移至同步队列尾部,然后根据条件可能会尝试唤醒该节点对应的线程。*/ 16 if (first != null) 17 doSignal(first); 18 } 19 20 21 /** 22 * AQS中的方法 23 * 检测当前线程是否是持有独占锁的线程,该方法AQS没有提供实现(抛出UnsupportedOperationException异常) 24 * 通常需要我们自己重写,一般重写如下! 25 * 26 * @return true 是;false 否 27 */ 28 protected final boolean isHeldExclusively() { 29 //比较获取锁的线程和当前线程 30 return getExclusiveOwnerThread() == Thread.currentThread(); 31 }
5)doSignal() 移除等待时间最长的结点
doSignal方法将在do while中从头结点开始向后遍历整个条件队列,从条件队列中移除等待时间最长的结点,并将其加入到同步队列,在此期间会清理一些遍历时遇到的已经取消等待的结点。
等待时间最长的节点:由于队列是 FIFO(先进先出)的,因此最先进入条件队列的节点就是等待时间最长的节点。这里之所以没有说成是条件队列的头节点,是因为存在节点状态可能是已取消的情况,实际等待时间最长的有效节点不一定是当前的队列头节点。
1 /** 2 * Conditon中的方法 3 * 从头结点开始向后遍历,从条件队列中移除等待时间最长的结点,并将其加入到同步队列 4 * 在此期间会清理一些遍历时遇到的已经取消等待的结点。 5 * 6 * @param first 条件队列头结点 7 */ 8 private void doSignal(Node first) { 9 //从头结点开始向后遍历,唤醒等待时间最长的结点,并清理一些已经取消等待的结点 10 do { 11 //更新条件队列的头节点 12 if ((firstWaiter = first.nextWaiter) == null) 13 lastWaiter = null; 14 //将当前节点的nextWaiter设为null,断开与下一个节点的链接,这样就将first出队列了 15 first.nextWaiter = null; 16 /*循环条件 17 * 1 调用transferForSignal转移结点,如果转移失败(结点已经取消等待了); 18 * 2 则将first赋值为它的后继,并且如果不为null; 19 * 满足上面两个条件,则继续循环 20 * */ 21 } while (!transferForSignal(first) && 22 (first = firstWaiter) != null); 23 }
这段代码的作用是遍历条件队列,依次将每个节点从条件队列中移除,并将其转移到同步队列中。直到条件队列为空或者成功地将节点转移到同步队列。
6)transferForSignal() 转移节点
transferForSignal 是 AbstractQueuedSynchronizer(AQS) 的内部私有方法。它是 ConditionObject 中方法实现的一部分,用于帮助完成条件队列节点向同步队列的转移操作。在实际应用中,ConditionObject 中的 await 和 signal 方法通过 transferForSignal 完成了线程等待和唤醒的流程。
源码如下:
1 final boolean transferForSignal(Node node) { 2 //CAS将当前节点的ws从-2设置为0,这里就是和上面的中断两联系,如果transferAfterCancelledWait方法先将状态改变了, 导致这步CAS操作失败 3 if (!compareAndSetWaitStatus(node, Node.CONDITION, 0)) 4 return false; 5 //将该节点添加到同步队列尾部,这里返回的p节点是当前节点的前置节点; 6 Node p = enq(node); 7 int ws = p.waitStatus; 8 9 //如果前置节点被取消或者修改状态失败则直接唤醒当前节点 10 //此时当前节点已经处于同步队列中,唤醒会进行获取锁操作(acquireQueued())或者正确的挂起操作 11 if (ws > 0 || !compareAndSetWaitStatus(p, ws, Node.SIGNAL)) 12 LockSupport.unpark(node.thread); 13 return true; 14 }
7)signalAll() 通知全部线程
signalAll方法,相当于对等待队列中的每个节点均执行一次signal方法,效果就是将等待队列中所有节点全部移动到同步队列中,并尝试唤醒每个结点的线程,让他们竞争锁。
1 /** 2 * Conditon中的方法 3 * 将次Condition中的所有等待状态的结点 从条件队列移动到同步队列中。 4 * 5 * @throws IllegalMonitorStateException 如果当前调用线程不是获取锁的线程,则抛出异常 6 */ 7 public final void signalAll() { 8 /*1 首先调用isHeldExclusively检查当前调用线程是否是持有锁的线程 9 * isHeldExclusively方法需要我们重写 10 * */ 11 if (!isHeldExclusively()) 12 throw new IllegalMonitorStateException(); 13 //获取头结点 14 Node first = firstWaiter; 15 /*2 如果不为null,调用doSignalAll方法将条件队列中的所有等待状态的结点转移至同步队列尾部, 16 然后根据条件可能会尝试唤醒该结点对应的线程,相当于清空了条件队列。*/ 17 if (first != null) 18 doSignalAll(first); 19 }
8)doSignalAll() 移除并尝试转移全部结点
移除并尝试转移条件队列的所有结点,实际上会将条件队列清空。对每个节点调用transferForSignal方法。
1 /** 2 * Conditon中的方法 3 * 移除并尝试转移条件队列的所有结点,实际上会将条件队列清空 4 * 5 * @param first 条件队列头结点 6 */ 7 private void doSignalAll(Node first) { 8 //头结点尾结点都指向null 9 lastWaiter = firstWaiter = null; 10 /*do while 循环转移结点*/ 11 do { 12 //next保存当前结点的后继 13 Node next = first.nextWaiter; 14 //当前结点的后继引用置空 15 first.nextWaiter = null; 16 //调用transferForSignal尝试转移结点,就算失败也没关系,因为transferForSignal一定会对所有的结点都尝试转移 17 //可以看出来,这里的转移是一个一个的转移的 18 transferForSignal(first); 19 //first指向后继 20 first = next; 21 } while (first != null); 22 }
3. 条件队列和同步队列的区别
1)同步队列
AQS 的核心概念是同步队列(Sync Queue),它用于管理获取锁失败的线程。它通过一个 FIFO 队列来维护等待获取锁的线程。当线程调用 acquire 方法尝试获取锁失败时,会被放入同步队列中排队,直到锁可用为止。
控制的是线程是否能获取锁,只有满足条件的线程才能成功获取锁,当锁不可用时线程会进入同步队列,直到锁被释放。
2)条件队列(ConditionObject)
条件队列是一个独立的、可选的等待队列,用来管理因特定条件未满足而需要等待的线程。
负责线程在等待特定条件时的挂起和唤醒,线程会通过 await 方法进入条件队列等待,直到条件被满足,才从条件队列中转入同步队列竞争锁。
八、AQS 对资源的共享方式
AQS 定义两种资源共享方式:
1. Exclusive(独占,只有一个线程能执行,如ReentrantLock)
2. Share(共享,多个线程可同时执行,如Semaphore/CountDownLatch)。
一般来说,自定义同步器的共享方式要么是独占,要么是共享,他们也只需实现共享资源 state 的获取与释放方式:tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared中的一种即可。但 AQS 也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock(读写锁), 它允许多个线程同时对某一资源进行读。至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在上层已经帮我们实现好了。
九、总结
1. AQS中提供了同步队列的实现,用于实现锁的获取和释放,没有获取到锁的线程将进入同步队列排队等待,是实现线程同步的基础。
2. AQS内部还提供了条件队列的实现,条件队列用于实现线程之间的主动等待、唤醒机制,是实现线程有序可控同步的不可缺少的部分。
3. 一个锁对应一个同步队列,对应多个条件变量,每个条件变量有自己的一个条件队列,这样就可以实现按照业务需求让不同的线程在不同的条件队列上等待,相对于Synchronized的只有一个条件队列,功能更加强大。
4. 条件队列的使用是一个额外的功能,而不是 AQS 的核心职责。AQS 的设计目标是实现对锁的同步管理,条件队列则是在 ReentrantLock 这种特定同步器中扩展的功能。在 AQS 中,只需关注同步队列的基本 acquire/release 操作,保持其核心功能的高内聚,而条件队列的管理交给 ConditionObject 来实现。这种设计符合单一职责原则,保证 AQS 的简洁性和可维护性。
5. 构建AQS的基础:
- volatile修饰符——保证数据可见性。
- CAS(UNSAFE)——保证State变量读-写-改操作的原子性与可见性。
- LockSupport#park、LockSupport#unpark,LockSupport(UNSAFE)- 线程挂起与唤醒。
参考链接:
https://javaguide.cn/java/concurrent/aqs.html
https://www.infoq.cn/article/bvpvyvxjkm8zstspti0l
https://pdai.tech/md/java/thread/java-thread-x-lock-AbstractQueuedSynchronizer.html
https://juejin.cn/post/7068482899929989134