AQS(AbstractQueuedSynchronizer)是一个用于实现各种同步器的抽象类,是 JUC(java.util.concurrent)并发包中的核心类之一,JUC 中的许多并发工具类和接口都是基于 AQS 实现的。
它提供了一种基于队列的、高效的、可扩展的同步机制,是实现锁、信号量、倒计时器等同步器的基础.它使用了一个int成员变量state来表示同步的状态,并内置了一个队列来完成需要获取资源的线程的排队工作;
锁是面向锁的使用者的,而AQS则是面向锁的编写者的,这个FIFO的双向队列是基于CLH单向链表实现的,我们通过包含显式的("prev" 和 "next")链接以及一个"status"字段,将其用于阻塞同步器,这些字段允许节点在释放锁时向后续节点发送信号,并处理由于中断和超时导致的取消操作。
同步器:同步器指的是用于控制多线程访问共享资源的机制。同步器可以保证在同一时间只有一个线程可以访问共享资源,从而避免了多线程访问共享资源时可能出现的数据竞争和不一致性问题。Java 中的同步器包括 synchronized 关键字、ReentrantLock、Semaphore、CountDownLatch 等。
AQS的工作原理是什么?
三个组件:state,队列,exclusiveOwnerThread
state:实现锁信息的同步
CLH队列:对获取锁失败的线程进行管理
exclusiveOwnerThread:用于表示当前是哪个线程正在持有锁
AQS依赖底层的同步队列,当一个线程获取锁失败之后,那么就会将其封装为一个node节点然后到队列的末尾,并阻塞这个线程,当持有锁的线程释放之后,会将队头的节点中的线程唤醒,使其再次尝试获取锁
AQS的node节点的状态值有哪些?
- cancelled:节点引用的线程由于等待超时或者打断被取消之后,节点会变成cancelled状态
- signal:后续节点需要被唤醒时,当前节点就会变成signal状态,
- condition:表示当前节点进入了condition队列的状态
- propagate:释放共享锁的时候会头节点使用
AQS的核心设计思想:模板方法与状态管理
AQS的本质是一个抽象类,它采用模板方法模式构建了一个用于实现锁或同步器的通用框架。它的核心思想是,将同步逻辑的通用部分(如线程的排队、阻塞、唤醒)封装起来,而将具体的资源获取和释放逻辑(即同步状态的判断)抽象成protected方法,交由子类去实现。
这种设计极大地简化了同步器的开发。开发者只需要关注以下两个核心问题:
- 资源的定义:如何表示共享资源的状态?
- 状态的变更:在何种条件下线程可以获取或释放资源?
AQS内部通过以下两大组件来支撑这个框架:
- 同步状态 (State):这是一个
volatile的int类型的变量。它代表了同步资源的状态(例如,锁是否被占用、Semaphore的许可数量等)。AQS提供getState()、setState()和compareAndSetState()这三个原子操作方法来安全地读写该状态。 - FIFO双向队列 (CLH队列的变体):这是一个先进先出的队列,用于存放所有请求资源但失败而被阻塞的线程。当同步状态变为可获取时,队列头部的线程将被唤醒,尝试再次获取资源。
AQS的两种模式:独占模式与共享模式
AQS为同步器定义了两种不同的资源共享模式,子类通常只实现其中一种,但也可以同时支持两种(如ReentrantReadWriteLock)。
1. 独占模式 (Exclusive Mode)
资源在同一时刻只能被一个线程持有。这是最常见的锁模式。子类需要重写以下方法来定义独占资源的获取与释放逻辑:
protected boolean tryAcquire(int arg):尝试以独占方式获取资源。如果成功,返回true;失败则返回false。AQS的acquire()方法会调用它,如果返回false,则会将当前线程加入等待队列并挂起。protected boolean tryRelease(int arg):尝试释放独占资源。如果成功,返回true;失败则返回false。AQS的release()方法会调用它,并在成功后唤醒等待队列中的下一个线程。protected boolean isHeldExclusively():判断当前线程是否持有独占锁。这个方法主要供Condition使用。
2. 共享模式 (Shared Mode)
资源在同一时刻可以被多个线程持有。例如,Semaphore或CountDownLatch就采用了共享模式。子类需要重写以下方法:
protected int tryAcquireShared(int arg):尝试以共享方式获取资源。它的返回值有三种情况:- 负数:表示获取失败。
- 0:表示获取成功,但后续没有更多资源可供其他线程获取。
- 正数:表示获取成功,并且后续仍有资源可供其他线程获取。AQS在收到正数返回值后,会唤醒后续的等待线程(这被称为“传播”或“级联唤醒”),这是共享模式与独占模式在释放逻辑上的核心区别。
protected boolean tryReleaseShared(int arg):尝试释放共享资源。通常,这会增加可用资源的数量。如果释放后允许后续等待的线程被唤醒,则应返回true。
AQS内部核心流程分析
我们通过acquire(独占)和acquireShared(共享)的流程来理解AQS的内部工作机制。
独占模式获取 (acquire)
- 调用
tryAcquire(arg):这是子类实现的核心逻辑。 - 成功获取:如果
tryAcquire返回true,acquire方法直接返回,线程继续执行。 - 获取失败:
- 创建节点并入队:AQS会将当前线程封装成一个
Node节点,并通过CAS操作安全地将其添加到等待队列的尾部。 - 阻塞与唤醒:入队后,线程会进入一个循环。在这个循环中,它会检查自己是否是队列中的第二个节点(即仅次于头节点)。如果是,它会再次尝试调用
tryAcquire。如果失败,线程就会通过LockSupport.park(this)被挂起,等待被前一个节点在释放锁时唤醒。 - 成为头节点后:一旦被唤醒,线程会继续循环,当它最终成功获取到锁后,它会将自己设置为头节点,然后
acquire方法返回。
- 创建节点并入队:AQS会将当前线程封装成一个
共享模式释放 (releaseShared)
共享模式的释放稍显复杂,因为它可能需要唤醒多个线程。
- 调用
tryReleaseShared(arg):子类实现逻辑,通常会增加state的值。 - 唤醒后继节点:如果
tryReleaseShared返回true,AQS会唤醒队列头节点的后继节点。 - 传播唤醒 (Propagation):被唤醒的节点在成功获取共享资源后(即其
tryAcquireShared返回正数),它有责任继续唤醒它的后继节点。这个过程会像链式反应一样传播下去,直到队列中某个节点获取资源后发现不再有剩余资源(tryAcquireShared返回0),或者队列为空。
具体实现分析:ReentrantLock、CountDownLatch与ReentrantReadWriteLock
通过分析java.util.concurrent包中的具体类,我们可以更深刻地理解AQS的应用。
1. ReentrantLock (独占模式)
ReentrantLock利用AQS实现了一个可重入的独占锁。
- State的含义:
state变量用于表示锁的重入次数。当state为0时,锁未被持有。当一个线程获取锁时,state加1。同一个线程再次获取时,state会继续累加。 tryAcquire实现:- 首先检查
state是否为0。如果是,则通过CAS将state设置为1,并将锁的持有者(exclusiveOwnerThread)设置为当前线程。 - 如果
state不为0,则检查当前锁的持有者是否就是当前线程。如果是,就将state加1,实现可重入性。 - 公平与非公平的差异:
- 非公平锁 (
NonfairSync):在尝试获取锁时,会先直接尝试CAS设置state,如果成功就直接获取,不管队列中是否有等待者。这种“插队”行为提高了吞吐量,但可能导致饥饿。 - 公平锁 (
FairSync):在尝试获取锁前,会先调用hasQueuedPredecessors()方法检查等待队列中是否有比自己更早的线程。如果有,tryAcquire直接返回false,使当前线程乖乖排队。
- 非公平锁 (
- 首先检查
tryRelease实现:- 检查当前线程是否为锁的持有者。
- 如果是,将
state减1。 - 当
state减到0时,表示锁已完全释放,此时将exclusiveOwnerThread设为null。
public final boolean hasQueuedPredecessors() {
Thread first = null; Node h, s;
if ((h = head) != null && ((s = h.next) == null ||
(first = s.waiter) == null ||
s.prev == null))
first = getFirstQueuedThread(); // retry via getFirstQueuedThread
return first != null && first != Thread.currentThread();
}2. CountDownLatch (共享模式)
CountDownLatch允许多个线程等待一个或多个事件的发生。
- State的含义:
state在初始化时被设置为一个计数值。这个计数值代表需要等待的事件数量。 tryAcquireShared实现:这个方法的逻辑非常简单:检查state是否为0。如果为0,表示所有事件都已发生,返回1(表示获取成功,且可继续传播);如果不为0,则返回-1(表示获取失败)。await()方法就是基于此实现的,它会一直阻塞直到tryAcquireShared成功。tryReleaseShared实现:countDown()方法会调用此逻辑。它通过一个CAS循环来将state减1。当state减到0时,返回true,从而触发AQS唤醒所有因await()而等待的线程。
3. ReentrantReadWriteLock (独占+共享)
ReentrantReadWriteLock是一个更复杂的例子,它同时使用了两种模式。
- State的含义:AQS的
state是一个32位的int。ReentrantReadWriteLock巧妙地将其拆分为两部分:高16位用于表示读锁的数量,低16位用于表示写锁的重入次数。 - 写锁 (
WriteLock):是一个独占锁。它的tryAcquire会检查state的读锁部分和写锁部分是否都为0(或者写锁持有者是当前线程),然后才尝试获取并增加写锁计数。 - 读锁 (
ReadLock):是一个共享锁。它的tryAcquireShared会检查当前是否有线程持有写锁。如果没有,就通过CAS增加state的高16位(读锁计数)。
AQS中的条件队列 (ConditionObject)
AQS还提供了一个内部类ConditionObject,它是对Condition接口的实现,功能类似于Object.wait()和Object.notify()。
- 工作机制:每一个
ConditionObject实例都维护着一个独立的等待队列(条件队列)。 await()过程:- 当前线程必须先持有与
Condition关联的锁。 - 调用
await()时,线程被封装成一个节点并加入条件队列。 - 然后,线程会完全释放它持有的锁(例如,对于可重入锁,会将
state设为0)。 - 最后,线程被挂起。
- 当前线程必须先持有与
signal()过程:- 当前线程必须持有锁。
- 调用
signal()时,AQS会将条件队列中的第一个节点转移到锁的主等待队列中。 - 需要注意的是,
signal()并不会立即唤醒被转移的线程。这个线程需要等待持有锁的线程调用unlock()之后,才有机会被唤醒并重新竞争锁。
这种设计将条件等待与锁的等待机制解耦,实现了更灵活、更强大的线程协作能力。
总结
AQS的精髓在于其**“关注点分离”**的设计哲学。它将同步器中不变的、复杂的底层机制(线程排队、状态的原子更新、阻塞与唤醒)固化下来,让开发者可以专注于实现特定的同步语义。这种半成品式的框架设计,是Java并发包能够提供如此丰富、高效且健壮的同步工具的关键。
深入理解AQS,你不仅能更好地使用ReentrantLock、Semaphore等工具,更重要的是,当这些工具无法满足你复杂的业务需求时,你将有能力基于AQS构建出自己的、高性能的同步器。这正是一名高级工程师所应具备的核心能力。
LockSupport.park() 是理解AQS线程阻塞和唤醒机制的最后一块,也是最核心的一块拼图。如果说AQS的队列(Node)是骨架,state是灵魂,那么LockSupport.park()就是让这个骨架暂停和活动起来的肌肉。
什么是LockSupport.park()?
LockSupport是一个非常基础且底层的线程工具类,它的核心作用就是阻塞和唤醒线程。它的主要方法是 park() 和 unpark(Thread thread)。
你可以把它理解成一个更灵活、更底层的线程"等待/通知"机制,与我们熟知的Object.wait()/notify()相比,它有几个关键优势:
- 无需获取锁:调用
park()的线程不需要先获得任何对象的监视器锁(synchronized块)。同理,unpark()也不需要。这使得它可以在任何上下文中直接使用,非常灵活。 - “许可”(Permit)机制:
park/unpark的实现基于一种“许可”概念。每个线程都有一个关联的许可,许可最多只有一个(不能累积)。park():如果线程的许可是可用的,那么它会“消耗”这个许可,并立即返回,不会阻塞。如果许可是不可用的,线程将被阻塞,直到许可变为可用。unpark(Thread T):它会使线程T的许可变为可用。如果T当前正因park()而阻塞,它会被唤醒。如果T当前没有被阻塞,那么下次它调用park()时,就会直接消耗许可并立即返回。
- 精确唤醒:
unpark(Thread T)方法是针对特定线程的。它只会唤醒你指定的那一个线程,而不是像notify()那样只能唤醒等待队列中的一个随机线程,或者像notifyAll()那样唤醒所有线程,这避免了不必要的上下文切换和“惊群效应”。
正是这些特性,使得LockSupport成为了AQS理想的底层线程阻塞工具。
LockSupport.park()在AQS流程中的关键作用
在AQS中,LockSupport.park()的作用可以总结为一句话:在线程获取锁失败并加入等待队列后,让该线程高效地进入休眠状态,放弃CPU,直到轮到它时再被精确地唤醒。
我们来回顾一下之前提到的独占锁获取流程(acquire),看看park()是如何嵌入其中的:
初次尝试:线程A调用
lock(),AQS会调用子类实现的tryAcquire()。如果成功,万事大吉,流程结束。入队操作:如果
tryAcquire()失败,AQS会将线程A封装成一个Node节点,并通过CAS操作将其安全地加入到等待队列的末尾。自旋与检查:入队后,线程A不会立刻休眠,而是会进入一个
for(;;)的"自旋"循环。在循环中,它会做一件事:检查自己的前一个节点是不是头节点(head)。- 为什么检查? 因为AQS的规则是,只有头节点的下一个节点才有资格去尝试获取锁。如果前一个节点是head,就意味着持有锁的线程已经释放了锁,轮到自己了。
- 如果检查发现轮到自己了,它会再次调用
tryAcquire()。如果这次成功,线程A就成功获取锁,然后将自己设置为新的头节点,lock()方法返回。
准备休眠与执行休眠(
park):如果线程A在自旋循环中检查发现,自己的前一个节点不是头节点,或者轮到自己了但tryAcquire()再次失败(例如,在非公平模式下被一个新来的线程“插队”了),它就知道自己需要等待了。- 在调用
park()之前,它会做个准备工作:通过shouldParkAfterFailedAcquire()方法,将前一个节点的waitStatus状态位设置为SIGNAL(-1)。这相当于给前一个节点留了个“遗言”:“兄弟,你将来释放锁的时候,记得用unpark叫醒我(也就是你的后继节点)。” - 准备工作完成后,线程A就放心地调用
LockSupport.park(this),将自己挂起。此时,线程A就进入了阻塞状态,不再消耗CPU资源。
- 在调用
被唤醒 (
unpark):当持有锁的线程(假设是线程B)调用unlock()时,AQS的release()方法被触发。release()会修改state状态,然后找到队列的头节点(也就是线程B自己对应的Node)。- 它会检查头节点的
waitStatus。如果发现状态是SIGNAL,就意味着它的后继节点(也就是线程A)正在等待被唤醒。 - 于是,它就会找到后继节点A,并调用
LockSupport.unpark(A)。 unpark操作会发放许可给线程A,线程A从park()方法中被唤醒,它的for(;;)循环得以继续。然后它会再次检查自己是不是头节点的后继,尝试获取锁,这个流程就循环起来了。
总结:park的角色
- CPU资源的节约者:它是避免线程在等待锁时进行无效“忙等”(Busy Waiting)的关键。线程通过
park进入休眠,将CPU让给其他可运行的线程,大大提高了系统的整体效率。 - 精确调度的执行者:AQS的队列(CLH)构建了一个严格的FIFO顺序,但这个顺序需要一个机制来执行。
unpark(successor)的精确唤醒能力,使得AQS可以严格按照队列顺序,一个一个地唤醒线程,确保了公平性和调度的可预测性。 - AQS框架的基石:没有
LockSupport提供的这种独立于锁、基于许可的、可精确中断和唤醒的线程阻塞原语,AQS的整个设计将难以实现。它是连接上层同步逻辑和底层线程调度的桥梁。
当你看到AQS的代码,理解了state和队列之后,一定要把LockSupport.park()和unpark()看作是驱动这个队列状态机流转的引擎。
LockSupport的许可(Permit)机制,并没有在Java层面用锁去实现,而是直接利用了操作系统内核提供的线程调度原语 (Thread Scheduling Primitives)。
可以把它理解为,Java虚拟机(JVM)为每个Java线程都在其内部(通常是在C++层面)维护了一个小小的信标或标志位,这个就是“许可”。LockSupport类通过JNI(Java Native Interface)调用,直接去操作这个由JVM和操作系统管理的、与线程绑定的标志位。
让我们来一步步拆解这个过程:
1. 绕过Java锁,直达底层
LockSupport 的实现,在HotSpot虚拟机中,最终会委托给一个名为 sun.misc.Unsafe 的特殊类。Unsafe类提供了一系列非常底层的、可以直接操作内存和线程的方法,它的名字本身就在警告开发者:“不要轻易使用,否则后果自负”。
LockSupport.park() 实际上调用的是 Unsafe.park(boolean isAbsolute, long time)。 LockSupport.unpark(Thread t) 实际上调用的是 Unsafe.unpark(Object thread)。
Unsafe的这些方法是native方法,它们直接进入了JVM的C++代码世界。
2. 核心机制:操作系统提供的Futex或类似原语
在JVM的C++代码中,park/unpark的功能通常是基于操作系统提供的特定机制来实现的。在现代Linux系统上,这个机制就是大名鼎鼎的 Futex (Fast Userspace Mutex)。在其他操作系统如Windows上,则会使用类似的机制,如条件变量(Condition Variables)或事件(Events)。
我们以Linux的Futex为例,因为它非常具有代表性:
什么是Futex? Futex是一种非常高效的同步原语。它的核心思想是:绝大多数情况下,同步操作可以在用户空间(Userspace)完成,只有当真正发生竞争或需要阻塞时,才陷入内核空间(Kernelspace)。 这极大地减少了线程上下文切换的开销。
"许可"是如何用Futex实现的? 你可以想象,在JVM内部,每个Java线程对象(
java.lang.Thread)都关联着一个内存地址上的一个整型变量,我们称之为_counter或_permit,初始值为0。当线程调用
park()时:- 快速路径(用户空间):它首先会检查这个
_counter变量的值。如果_counter是1,说明许可已经存在(之前有人调用了unpark)。那么,它就会把_counter减为0(消耗许可),然后立即返回,根本不会阻塞。这个过程完全在用户态,速度极快。 - 慢速路径(内核空间):如果检查到
_counter是0,说明没有许可,线程需要阻塞。此时,它才会执行一次系统调用(syscall),陷入内核,调用类似futex_wait(&_counter, 0)的函数。这个函数告诉内核:“请把我这个线程在_counter这个地址上挂起,直到它的值不再是0为止。”
- 快速路径(用户空间):它首先会检查这个
当另一个线程调用
unpark(targetThread)时:- 用户空间操作:它会找到目标线程
targetThread关联的那个_counter变量,并将其值设为1。 - 通知内核:然后,它会执行另一次系统调用,类似
futex_wake(&_counter, 1)。这个函数告诉内核:“请去_counter这个地址上检查一下,如果那里有正在等待的线程,请唤醒一个。”
- 用户空间操作:它会找到目标线程
工作流程总结
结合上面的解释,我们来看unpark和park的先后顺序所产生的不同效果:
场景一:先
unpark,后park- 线程B调用
LockSupport.unpark(A)。 - JVM将线程A的内部
_counter设置为1。 - 线程A随后调用
LockSupport.park()。 - 线程A检查自己的
_counter,发现是1。 - 它将
_counter置为0,然后立即返回,整个过程线程A没有发生阻塞。
- 线程B调用
场景二:先
park,后unpark- 线程A调用
LockSupport.park()。 - 它检查
_counter,发现是0。 - 线程A通过系统调用陷入内核,被操作系统挂起,进入休眠状态。
- 一段时间后,线程B调用
LockSupport.unpark(A)。 - JVM将线程A的
_counter设为1,并通过系统调用通知内核唤醒在_counter地址上等待的线程。 - 操作系统收到通知,将线程A从休眠状态唤醒,变为可运行状态,等待CPU调度。当调度器再次选择线程A时,它的
park()方法就返回了。
- 线程A调用
结论
LockSupport之所以能做到无锁实现许可机制,关键在于:
- 责任下放:它不自己在Java层面用锁和条件变量去模拟一套复杂的同步逻辑,而是把“阻塞”和“唤醒”这两个最核心的操作直接交给了更底层的JVM和操作系统。
- 状态绑定:“许可”这个状态(permit)不是一个共享的Java对象,而是与每个线程一对一绑定的内部标志。操作自己线程的标志或指定线程的标志,天然地避免了复杂的竞态条件。
- 高效的内核原语:它依赖的Futex等机制本身就是为高效同步而设计的,通过“用户态快速检查,竞争时才陷入内核”的模式,避免了不必要的性能开销。
可以把LockSupport.park/unpark看作是Java暴露给我们的、用于直接操作线程调度状态的、最轻量级的接口之一。AQS正是因为它这种轻量、精确、高效的特性,才选择它作为构建所有高级同步器的基石。