在Java并发编程中,我们经常会用到ReentrantLock、Semaphore、CountDownLatch等并发工具,却很少深入思考这些工具背后的共同支撑------AbstractQueuedSynchronizer,简称AQS。它就像一个"万能骨架",定义了并发工具的核心逻辑,是整个java.util.concurrent包的灵魂。今天我们就从AQS的基本概念出发,结合ReentrantLock的源码的解析,再聊聊自旋锁,彻底搞懂这一并发核心机制。
一、什么是AQS?
AQS的全称是AbstractQueuedSynchronizer,即抽象队列同步器。它不是一个具体的同步工具,而是一个抽象类,提供了一套通用的机制来管理同步状态、阻塞/唤醒线程、维护等待队列。JUC下的大部分同步工具,比如Lock系列(ReentrantLock、ReentrantReadWriteLock)和并发工具类(Semaphore、CountDownLatch、CyclicBarrier),都是基于AQS实现的,相当于AQS为它们提供了"底层模板",开发者只需根据需求重写部分方法,就能实现自定义的同步工具。
AQS的核心设计思想
AQS的核心逻辑围绕两个核心组件展开:CLH同步队列和state状态属性,二者协同工作,实现线程的同步与调度。
1. CLH同步队列
CLH同步队列全称是Craig.Landin. and Haqersten lock queue,是一个FIFO(先进先出)的双向队列,主要用于存储被阻塞的线程信息。我们可以把它理解为一个"线程等待队列",当线程竞争资源失败时,就会进入这个队列等待,直到资源被释放后被唤醒,继续竞争资源。
CLH队列的两个关键特性,决定了它的调度逻辑:
-
FIFO特性:等待时间最长的线程会优先获得资源(对应公平锁的排队逻辑),保证了线程竞争的公平性;
-
双向队列:支持线程从队列两端进行插入、删除和查找操作,这为非公平锁的"插队"逻辑提供了可能。
2. state状态属性
state是AQS中的一个volatile成员变量,用于表示共享资源的状态,其具体含义由子类(如ReentrantLock、CountDownLatch)自行定义,不同的同步工具对state的解读完全不同。举两个最常见的例子:
-
对于ReentrantLock(可重入锁):state=0表示锁未被占用,state=1表示锁已被占用;由于ReentrantLock支持重入,state的值还会随着重入次数递增(比如重入2次,state=2),释放时则递减,直到state=0时完全释放锁。
-
对于CountDownLatch(倒计时器):state的初始值为N(表示需要等待N个线程完成),每有一个线程完成任务,state就减1,当state=0时,所有阻塞的线程都会被唤醒,继续执行。
简单来说,state就是AQS用来"标记资源是否可用"的核心变量,通过对state的原子操作,实现线程对资源的竞争与释放。
二、ReentrantLock底层原理:AQS的实际应用
ReentrantLock是我们最常用的并发锁之一,它的底层完全基于AQS实现,支持公平锁和非公平锁两种模式。接下来我们就通过源码解析,看看AQS是如何支撑ReentrantLock的锁机制的。
2.1 公平锁与非公平锁的核心区别
在聊源码之前,我们先明确公平锁和非公平锁的核心差异:
-
公平锁:严格按照线程请求锁的顺序分配锁,线程必须排队等待,不允许"插队",保证了所有线程的公平性;
-
非公平锁:不严格按照请求顺序,允许线程在"合适的时机"插队。这里的"合适时机"特指:当前线程请求锁时,恰好前一个持有锁的线程释放了锁,此时当前线程可以直接获取锁,无需排队。但如果锁正被占用,当前线程依然会进入CLH队列等待,并非完全随机插队。
实际开发中,非公平锁是ReentrantLock的默认模式,因为它的吞吐量更高;而公平锁适合对顺序性要求严格的场景,比如银行转账、任务调度系统等,能避免线程饥饿问题。
2.2 ReentrantLock的内部结构
ReentrantLock内部包含了3个与AQS相关的类,它们的关系的是:
-
Sync:抽象类,继承自AQS,是ReentrantLock的核心内部类,定义了锁的基本逻辑;
-
NonfairSync:Sync的子类,实现了非公平锁的具体逻辑;
-
FairSync:Sync的子类,实现了公平锁的具体逻辑。
也就是说,ReentrantLock的公平与非公平特性,本质上是通过Sync的两个子类实现的,而这两个子类的核心差异,就在于重写AQS的相关方法时,是否遵循"排队顺序"。
2.3 上锁流程:从源码看AQS的工作机制
我们分别从非公平锁和公平锁的角度,解析ReentrantLock的上锁流程,重点看AQS的核心方法如何被调用。
(1)非公平锁的上锁流程
非公平锁的上锁逻辑,核心是"先尝试插队,失败再排队",对应的源码如下:
① Sync抽象类的lock方法(统一入口):
java
/**
* 获取锁的操作,如果初始尝试获取锁失败,则进行阻塞式获取锁。
*/
final void lock() {
if (!initialTryLock()) // 如果初始尝试获取锁失败
acquire(1); // 调用AQS的方法,进入阻塞获取逻辑
}
② NonfairSync的initialTryLock方法(非公平锁的初始尝试):
java
/**
* 初始尝试获取锁。
* @return 如果成功获取锁,则返回true;否则返回false。
*/
final boolean initialTryLock() {
Thread current = Thread.currentThread();
// 1. 尝试用CAS将state从0改为1(锁未被占用时,直接获取)
if (compareAndSetState(0, 1)) {
setExclusiveOwnerThread(current); // 设置当前线程为锁的持有者
return true;
}
// 2. 如果当前线程已经持有锁(重入),state加1
else if (getExclusiveOwnerThread() == current) {
int c = getState() + 1;
if (c < 0) // 重入次数过多,抛出异常
throw new Error("Maximum lock count exceeded");
setState(c);
return true;
}
// 3. 以上两种情况都失败,返回false,进入排队逻辑
else
return false;
}
③ AQS的acquire方法(阻塞获取锁的核心):
java
/**
* 获取锁的操作。
*/
public final void acquire(int arg) {
if (!tryAcquire(arg)) // 再次尝试获取锁(非公平锁的tryAcquire实现)
acquire(null, arg, false, false, false, 0L); // 失败则进入队列阻塞
}
④ NonfairSync的tryAcquire方法(再次尝试获取锁):
java
/**
* 尝试获取锁。
* @return 如果成功获取锁,则返回true;否则返回false。
*/
protected final boolean tryAcquire(int acquires) {
// 再次尝试CAS修改state,成功则获取锁
if (getState() == 0 && compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
return false;
}
总结非公平锁的上锁逻辑:线程先尝试CAS获取锁(插队),如果锁未被占用,直接获取;如果自己已经持有锁,就重入;如果都失败,就进入CLH队列等待。
(2)公平锁的上锁流程
公平锁的核心是"先检查队列,再尝试获取锁",不允许插队,其源码与非公平锁的差异主要在initialTryLock和tryAcquire方法:
① FairSync的initialTryLock方法:
java
/**
* 初始尝试获取锁的操作。
* @return 如果成功获取锁,则返回true;否则返回false。
*/
final boolean initialTryLock() {
Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
// 关键:先判断是否有等待的线程(!hasQueuedThreads()),没有才尝试CAS
if (!hasQueuedThreads() && compareAndSetState(0, 1)) {
setExclusiveOwnerThread(current);
return true;
}
} else if (getExclusiveOwnerThread() == current) {
// 重入逻辑与非公平锁一致
if (++c < 0)
throw new Error("Maximum lock count exceeded");
setState(c);
return true;
}
return false;
}
② FairSync的tryAcquire方法:
java
/**
* 尝试获取锁的操作。
* @return 如果成功获取锁,则返回true;否则返回false。
*/
protected final boolean tryAcquire(int acquires) {
// 关键:增加了!hasQueuedPredecessors(),判断是否有前驱节点(是否有线程排队)
if (getState() == 0 && !hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
return false;
}
这里的hasQueuedPredecessors()方法是公平锁的核心,它会判断当前线程是否有前驱节点(即队列中是否有比当前线程更早等待的线程)。如果有,就不允许获取锁,必须排队;如果没有,才尝试CAS获取锁,这就保证了公平性。
2.4 解锁流程:释放锁并唤醒等待线程
解锁流程相对简单,且公平锁和非公平锁的解锁逻辑完全一致,核心是"释放锁(修改state)+ 唤醒队列中的下一个线程",源码如下:
① AQS的release方法(解锁统一入口):
java
/**
* 释放锁的操作。
* @return 如果成功释放锁,并且成功唤醒下一个等待线程,则返回true;否则返回false。
*/
public final boolean release(int arg) {
if (tryRelease(arg)) { // 尝试释放锁
signalNext(head); // 唤醒队列中的下一个等待线程
return true;
}
return false;
}
② Sync的tryRelease方法(释放锁的具体逻辑):
java
/**
* 尝试释放锁的操作。
* @return 如果成功释放锁并且锁完全释放,则返回true;否则返回false。
*/
@ReservedStackAccess
protected final boolean tryRelease(int releases) {
int c = getState() - releases; // state递减(重入锁则多次递减)
// 校验:只有持有锁的线程才能释放锁
if (getExclusiveOwnerThread() != Thread.currentThread())
throw new IllegalMonitorStateException();
boolean free = (c == 0); // 判断是否完全释放锁(state=0)
if (free)
setExclusiveOwnerThread(null); // 完全释放,清空锁持有者
setState(c); // 更新state值
return free; // 返回是否完全释放
}
③ AQS的signalNext方法(唤醒下一个线程):
java
/**
* 唤醒给定节点的后继节点(如果存在),并取消其WAITING状态
* 当一个或多个线程被取消时,这可能无法唤醒一个合适的线程,但cancelAcquire确保活性。
*/
private static void signalNext(Node h) {
Node s;
// 校验头节点和后继节点是否有效
if (h != null && (s = h.next) != null && s.status != 0) {
s.getAndUnsetStatus(WAITING); // 取消后继节点的等待状态
LockSupport.unpark(s.waiter); // 唤醒后继节点对应的线程
}
}
解锁流程总结:线程释放锁时,先将state递减,若state减至0,说明锁完全释放,此时唤醒CLH队列中的下一个线程,让其继续竞争锁。
三、自旋锁:AQS中的"轻量级等待"
在聊AQS的过程中,我们多次提到"线程阻塞",但实际上,AQS在线程进入阻塞前,会先尝试"自旋"获取锁------这就是自旋锁的核心思想。自旋锁是一种轻量级的同步机制,也是AQS中线程竞争资源的一种优化策略。
3.1 什么是自旋锁?
自旋锁的定义很简单:当一个线程尝试获取锁时,如果锁已经被其他线程占用,该线程不会立即进入阻塞状态,而是循环等待(自旋),不断判断锁是否可用,直到获取到锁才退出循环。就像一个人在打印机前等待,不离开,而是每隔一会儿就检查打印机是否空闲,这就是自旋的逻辑。
自旋锁的核心特点是:线程在等待过程中一直处于活跃状态,不放弃CPU资源,适合锁占用时间极短的场景。
3.2 自旋锁的实现方式
自旋的本质就是"无限循环",常见的实现方式有三种:
java
// 方式1:for循环
for(;;){
// 不断尝试获取锁
}
// 方式2:while循环
while(true){
// 不断尝试获取锁
}
// 方式3:do-while循环
do{
// 不断尝试获取锁
}while(/* 锁未获取到 */);
下面是一个结合ReentrantLock实现的自旋锁示例,当尝试获取锁失败时,通过递归调用自身实现自旋:
java
public AlbumInfo getAlbumInfo(Long id) {
//创建锁
Lock lock = new ReentrantLock();
try {
// 尝试获取锁,超时时间3秒
boolean tryLock = lock.tryLock(3,TimeUnit.SECONDS);
if(tryLock) { // 成功获取锁,执行业务逻辑
AlbumInfo albumInfo = getData(id);
return albumInfo;
} else { // 获取锁失败,自旋重试
return getAlbumInfo(id);
}
}catch (Exception e) {
// 异常处理
}finally {
// 确保锁释放,避免死锁
lock.unlock();
}
}
3.3 自旋锁与AQS的关联
在AQS中,自旋锁并不是一个独立的锁实现,而是线程竞争资源时的一种"优化策略":
-
对于ReentrantLock:自旋的过程就是线程不断调用lock()方法,反复尝试用CAS修改state值,直到获取锁或自旋超时;
-
对于原子操作类(如AtomicInteger):自旋的过程就是CAS操作失败后,再次尝试CAS操作,直到成功修改值。
3.4 自旋锁的优缺点
自旋锁就像一把"双刃剑",有明显的优势和局限性,实际使用时需要根据场景选择:
优点:
-
减少线程阻塞开销:对于锁竞争不激烈、锁占用时间极短的场景,自旋的消耗远小于线程阻塞-唤醒的开销(线程阻塞需要切换到内核态,唤醒需要切换回用户态,开销较大);
-
提升性能:避免了线程在内核态和用户态之间的切换,能显著提升并发程序的运行效率。
缺点:
-
浪费CPU资源:如果锁竞争激烈,或者锁占用时间较长,自旋的线程会一直占用CPU做无用功,导致其他线程无法获取CPU资源,造成系统负载升高;
-
可能导致线程饥饿:如果持有锁的线程长时间不释放锁,自旋的线程会一直循环,无法获取锁,甚至可能无限期等待。
因此,自旋锁适合短时间、低竞争的场景;如果锁竞争激烈或持有锁时间长,应关闭自旋,直接让线程进入阻塞状态。
四、面试重点:AQS底层原理总结
结合前面的内容,我们总结一下AQS的底层原理(面试高频考点):
AQS的核心是"一个状态(state)+ 一个队列(CLH双向队列)+ 一套CAS机制",具体工作流程如下:
-
线程竞争资源时,通过CAS操作修改state状态;
-
如果CAS操作成功(state修改为目标值),则线程获取到资源,继续执行;
-
如果CAS操作失败,线程会被加入CLH双向队列,进入阻塞状态;
-
持有资源的线程释放资源时,会修改state状态,并唤醒队列中的下一个线程;
-
被唤醒的线程再次尝试CAS修改state,重复上述流程。
简单来说,AQS的核心逻辑就是"抢状态→抢不到就排队→释放时唤醒下一个",这也是所有基于AQS的并发工具的共同底层逻辑。
五、总结
AQS作为Java并发编程的核心基石,它的设计思想非常经典------通过抽象出同步机制的通用逻辑,为各种并发工具提供统一的底层支撑,减少了重复开发。理解AQS,不仅能搞懂ReentrantLock、Semaphore等工具的底层原理,更能帮助我们理解并发编程的核心逻辑,在实际开发中更合理地选择和使用并发工具。
最后提醒一句:AQS的难点在于源码中的CAS操作、队列管理和线程阻塞/唤醒逻辑,建议结合本文的解析,对照JDK源码逐行阅读,才能真正吃透这一核心机制。