深入理解AQS:Java并发编程的核心基石

在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相关的类,它们的关系的是:

  1. Sync:抽象类,继承自AQS,是ReentrantLock的核心内部类,定义了锁的基本逻辑;

  2. NonfairSync:Sync的子类,实现了非公平锁的具体逻辑;

  3. 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机制",具体工作流程如下:

  1. 线程竞争资源时,通过CAS操作修改state状态;

  2. 如果CAS操作成功(state修改为目标值),则线程获取到资源,继续执行;

  3. 如果CAS操作失败,线程会被加入CLH双向队列,进入阻塞状态;

  4. 持有资源的线程释放资源时,会修改state状态,并唤醒队列中的下一个线程;

  5. 被唤醒的线程再次尝试CAS修改state,重复上述流程。

简单来说,AQS的核心逻辑就是"抢状态→抢不到就排队→释放时唤醒下一个",这也是所有基于AQS的并发工具的共同底层逻辑。

五、总结

AQS作为Java并发编程的核心基石,它的设计思想非常经典------通过抽象出同步机制的通用逻辑,为各种并发工具提供统一的底层支撑,减少了重复开发。理解AQS,不仅能搞懂ReentrantLock、Semaphore等工具的底层原理,更能帮助我们理解并发编程的核心逻辑,在实际开发中更合理地选择和使用并发工具。

最后提醒一句:AQS的难点在于源码中的CAS操作、队列管理和线程阻塞/唤醒逻辑,建议结合本文的解析,对照JDK源码逐行阅读,才能真正吃透这一核心机制。

相关推荐
网络点点滴2 小时前
customRef的强大之处
开发语言·前端·javascript
磊 子2 小时前
类和对象—>析构+拷贝+运算符重载
开发语言·c++·算法
清风徐来QCQ2 小时前
js中的常用api
开发语言·javascript·ecmascript
leo__5202 小时前
基于Matlab和CPLEX的2变量机组组合调度程序
开发语言·matlab
csbysj20202 小时前
CSS 伪类详解
开发语言
Reisentyan2 小时前
[backend]GoLang Learn Data Day 2
开发语言·后端·golang
2301_792674863 小时前
java学习day24
java
困死,根本不会8 小时前
Kivy+Buildozer 打包 APK 踩坑:python-for-android 克隆失败
开发语言·php·kivy
咸鱼2.010 小时前
【java入门到放弃】跨域
java·开发语言