一、从实际问题引入 AQS
在多线程的编程世界里,我们常常会遇到这样的场景:多个线程同时访问和修改共享资源。就好比多个顾客同时抢购商店里仅剩的几件商品,谁能抢到,什么时候抢到,都充满了不确定性。在代码中,如果不加以控制,就会引发数据不一致、竞态条件等问题,严重影响程序的正确性和稳定性。
举个简单的例子,假设我们有一个银行账户类,多个线程同时进行取款操作。如果没有合理的同步机制,就可能出现账户余额被错误扣除,甚至出现负数余额的情况。代码示例如下:
public class BankAccount {
private int balance;
public BankAccount(int initialBalance) {
this.balance = initialBalance;
}
public void withdraw(int amount) {
if (balance >= amount) {
try {
Thread.sleep(100); // 模拟一些业务操作时间
} catch (InterruptedException e) {
e.printStackTrace();
}
balance -= amount;
System.out.println(Thread.currentThread().getName() + " 成功取款 " + amount + ",余额为 " + balance);
} else {
System.out.println(Thread.currentThread().getName() + " 取款失败,余额不足");
}
}
}
在这个例子中,由于withdraw方法没有进行同步控制,当两个线程同时调用时,就可能出现问题。比如线程 1 判断余额足够后,还没来得及扣除余额,线程 2 也判断余额足够,然后两个线程都进行了扣除操作,导致余额错误。
面对这些多线程资源竞争问题,我们通常会想到使用synchronized关键字、Lock接口等方式来实现线程同步。但是,当并发场景变得复杂,线程数量众多时,这些传统的同步方式可能会面临性能瓶颈和代码复杂度增加的问题。那么,有没有一种更高效、更通用的解决方案呢?这时候,AQS(AbstractQueuedSynchronizer)就登场了,它为我们提供了一种统一的框架来处理多线程同步问题 ,接下来,让我们一起深入了解 AQS 的奥秘。
二、AQS 是什么
AQS,即 AbstractQueuedSynchronizer,抽象队列同步器 ,是 Java 并发包中构建锁和同步器的基础框架,它为实现依赖于先进先出(FIFO)等待队列的阻塞锁和同步器提供了一个通用框架。在 Java 并发编程的大厦中,AQS 就如同坚实的地基,许多重要的同步工具类都基于它构建而成。
AQS 在 Java 并发编程中占据着举足轻重的地位。像我们常用的ReentrantLock(可重入锁)、CountDownLatch(倒计时器)、Semaphore(信号量)、ReentrantReadWriteLock(可重入读写锁)等,它们的底层实现都离不开 AQS。可以说,理解了 AQS,就相当于掌握了 Java 并发包的核心精髓。
以ReentrantLock为例,它是一种可重入的互斥锁,允许同一个线程多次获取同一把锁。当我们使用ReentrantLock时,线程在获取锁的过程中,AQS 会负责管理线程的排队、阻塞与唤醒等操作,确保线程安全地访问共享资源。而CountDownLatch则允许一个或多个线程等待,直到其他线程完成一组操作。这里的等待和唤醒机制,同样是基于 AQS 实现的。再看Semaphore,它通过 AQS 来维护许可数,控制同时访问特定资源的线程数量。在这些工具类中,AQS 就像一个幕后英雄,默默地处理着复杂的线程同步逻辑,让我们能够更便捷、高效地编写多线程程序 。
三、AQS 的核心构成
1. 状态变量 state
AQS 使用一个volatile修饰的整型变量state来表示同步状态 ,这个变量在不同的同步器中有着不同的含义,就像一把万能钥匙,虽然形状固定,但在不同的锁孔里能开启不同的功能。
以ReentrantLock为例,state表示锁的重入次数。当一个线程首次获取到锁时,state会被设置为 1。如果该线程再次获取同一把锁,state就会递增,变为 2,以此类推,表示线程对锁的重入。而当线程释放锁时,state会相应递减,直到state为 0 时,锁被完全释放 。
再看Semaphore(信号量),state表示剩余的许可数。假设我们创建一个信号量对象并设置许可数为 5,那么初始时state就是 5。当一个线程调用acquire方法获取许可时,如果state大于 0,该线程就能获取到许可,同时state减 1;当state为 0 时,其他线程再调用acquire方法就会被阻塞,直到有其他线程调用release方法释放许可,state增加后,才有可能获取到许可 。
state被volatile修饰,这确保了它在多线程环境下的可见性,即一个线程对state的修改,其他线程能够立即看到。并且,对state的操作大多基于CAS(Compare and Swap)算法,这保证了在多线程并发修改state时的原子性 ,避免了数据竞争和不一致的问题,就像给state穿上了一层坚固的铠甲,使其在复杂的多线程战场中能够稳定地发挥作用。
2. 同步队列(CLH 队列)
同步队列,也被称为 CLH 队列(Craig, Landin, and Hagersten 队列的变体),是 AQS 实现线程同步的关键数据结构,它就像是一个有序的排队等候区,所有等待获取锁的线程都在这里按顺序排队。
从结构上看,同步队列是一个双向链表 ,每个节点(Node)代表一个等待线程,节点包含了线程的引用(thread)、前驱节点(prev)、后继节点(next)以及等待状态(waitStatus)等信息。
当一个线程尝试获取锁失败时,它会被封装成一个节点加入到同步队列的尾部。例如,线程 A、B、C 依次尝试获取锁但都失败,那么它们会依次被加入到同步队列中,形成一个 A -> B -> C 的链表结构 。
节点的状态waitStatus有多种取值,每种取值都有着特定的含义和作用:
- CANCELLED(值为 1):表示该节点的线程由于超时或被中断等原因,已经被取消等待,处于作废状态,这样的节点会被从等待队列中移除。比如线程在等待获取锁的过程中,被其他线程中断了,它对应的节点就可能会被标记为CANCELLED 。
- SIGNAL(值为 -1):意味着后继节点的线程需要被唤醒。当一个节点的状态被设置为SIGNAL时,它的后继节点的线程处于挂起或者即将挂起的状态。当前节点的线程如果释放了锁或者放弃获取锁并且状态为SIGNAL,那么将会尝试唤醒后继节点的线程,让其有机会去竞争锁 。
- CONDITION(值为 -2):表示线程在条件队列里面等待。当线程调用Condition的await方法时,会进入条件队列等待,此时节点的状态会被设置为CONDITION。直到其他线程调用Condition的signal方法,该节点才会从条件队列转移到同步队列中,重新参与锁的竞争 。
- PROPAGATE(值为 -3):主要用于共享模式下,释放共享资源时需要通知其它节点,保证下一个acquireShared操作能够得以执行 。
在同步队列中,节点的入队、出队和取消操作都有着严格的流程:
- 入队操作:新节点入队时,首先会尝试快速插入到队列尾部。如果队列已经存在(即tail不为空),新节点会将当前tail作为前驱节点,然后通过CAS操作尝试更新tail为自己,如果更新成功,再将原来的tail的后继节点指向自己,完成快速入队。如果快速入队失败,就会进入完整的入队流程,通过循环不断尝试,直到成功入队 。
- 出队操作:当持有锁的线程释放锁时,会唤醒同步队列的头节点的后继节点(如果存在)。后继节点被唤醒后,会尝试获取锁。如果获取成功,该节点就会成为新的头节点,原来的头节点因为不再有线程关联,会被垃圾回收机制回收,实现了节点的出队 。
- 取消操作:当一个节点的线程被取消等待(例如超时或被中断),它会将自己的状态设置为CANCELLED。然后,它的前驱节点会跳过它,直接将后继节点指向它的后继节点(如果存在),将其从队列中移除,完成取消操作 。
四、AQS 的工作机制
1. 独占模式
独占模式下,同一时刻只有一个线程能够获取到同步状态,从而访问被保护的资源。以ReentrantLock为例,我们来深入剖析其在独占模式下的工作流程。
获取锁:当一个线程调用ReentrantLock的lock方法获取锁时,实际上调用的是AQS的acquire方法,这个方法会尝试获取锁。在ReentrantLock中,tryAcquire方法是获取锁的核心逻辑 :
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
- 首先检查当前状态state是否为 0,若为 0,表示锁未被持有 。此时,对于公平锁,会调用hasQueuedPredecessors方法检查同步队列中是否有前驱节点(即是否有其他线程在等待获取锁),如果没有前驱节点,说明当前线程是等待队列中最早的请求者,再通过CAS操作尝试将state从 0 设置为 1 ,若设置成功,将当前线程设置为锁的持有者,返回true,表示获取锁成功 。
- 如果state不为 0,说明锁已被持有,此时判断当前线程是否是锁的持有者(即current == getExclusiveOwnerThread())。如果是,说明是可重入锁的重入操作,将state增加acquires(通常为 1),并返回true,表示获取锁成功 。
- 如果以上条件都不满足,说明获取锁失败,返回false。
当获取锁失败时,线程会通过addWaiter方法被封装成一个节点(Node)加入到同步队列的尾部,然后进入acquireQueued方法,在这个方法中,线程会在一个循环中不断尝试获取锁 :
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;
failed = false;
return interrupted;
}
if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
- 首先获取当前节点的前驱节点p,如果p是头节点,说明当前节点是等待队列中最靠前的节点,有机会获取锁,再次调用tryAcquire方法尝试获取锁 。若获取成功,将当前节点设置为头节点(setHead(node)),并将原头节点的后继指针置空(p.next = null),以便垃圾回收 ,返回是否被中断的标志interrupted 。
- 如果获取锁失败,调用shouldParkAfterFailedAcquire方法判断是否应该阻塞当前线程 。如果应该阻塞,调用parkAndCheckInterrupt方法阻塞当前线程,并返回是否被中断的状态 。如果线程在阻塞过程中被中断,interrupted会被设置为true 。
释放锁:当线程调用ReentrantLock的unlock方法释放锁时,实际上调用的是AQS的release方法 :
public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
首先调用tryRelease方法尝试释放锁,在ReentrantLock中,tryRelease方法的实现如下:
protected final boolean tryRelease(int releases) {
int c = getState() - releases;
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
if (c == 0) {
free = true;
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}
该方法首先将state减去releases(通常为 1),如果当前线程不是锁的持有者,抛出IllegalMonitorStateException异常 。如果state减为 0,说明锁已被完全释放,将锁的持有者设置为null,并返回true,表示释放锁成功 。否则,返回false,表示锁还未被完全释放 。
其次如果tryRelease返回true,说明锁已成功释放,获取头节点h。如果头节点不为空且头节点的等待状态不为 0,调用unparkSuccessor方法唤醒头节点的后继节点对应的线程 ,使其有机会竞争锁 。unparkSuccessor方法会从尾节点开始向前查找,找到距离头节点最近的处于等待状态(waitStatus <= 0)的节点,并调用LockSupport.unpark方法唤醒该节点对应的线程 。
公平锁和非公平锁的区别及实现原理:ReentrantLock可以创建公平锁和非公平锁 。公平锁保证线程按照请求锁的顺序获取锁,即先到先得 ;非公平锁则不保证获取顺序,线程可以在锁可用时直接竞争获取,可能会出现后到的线程先获取到锁的情况 。
公平锁和非公平锁的主要区别在于获取锁时的tryAcquire方法实现。公平锁在获取锁时,会先调用hasQueuedPredecessors方法检查同步队列中是否有前驱节点 ,只有在没有前驱节点时才尝试获取锁 ,以此保证先到的线程先获取锁 。而非公平锁在获取锁时,直接尝试通过CAS操作获取锁,不会检查同步队列 ,如果获取失败,才会和公平锁一样,将线程加入同步队列等待 。例如,非公平锁的tryAcquire方法实现如下:
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
可以看到,非公平锁在state为 0 时,直接通过CAS尝试获取锁,没有检查同步队列 。这种实现方式使得非公平锁在高并发场景下,由于减少了检查同步队列的开销,性能通常比公平锁更高 。但同时,由于可能导致一些线程长时间等待,出现线程饥饿的问题 。
2. 共享模式
共享模式下,允许多个线程同时获取同步状态,访问共享资源 。以Semaphore(信号量)和CountDownLatch(倒计时器)为例,讲解共享模式下线程获取和释放共享资源的过程 。
获取共享资源:当一个线程调用Semaphore的acquire方法获取许可(共享资源)时,实际上调用的是AQS的acquireShared方法 ,这个方法会尝试获取共享资源 。在Semaphore中,tryAcquireShared方法是获取共享资源的核心逻辑 :
protected int tryAcquireShared(int acquires) {
for (;;) {
int available = getState();
int remaining = available - acquires;
if (remaining < 0 || compareAndSetState(available, remaining))
return remaining;
}
}
- 首先进入一个无限循环,获取当前的状态state(在Semaphore中,state表示剩余的许可数) 。
- 计算获取acquires个许可后剩余的许可数remaining 。
- 如果remaining小于 0,说明当前剩余许可数不足,返回remaining,表示获取失败 。
- 否则,通过CAS操作尝试将state从available更新为remaining 。如果更新成功,返回remaining,表示获取成功 ;如果更新失败,说明有其他线程同时在修改state,继续循环尝试 。
当获取共享资源失败时,线程会通过doAcquireShared方法被封装成一个节点(Node)加入到同步队列的尾部,然后进入一个循环,不断尝试获取共享资源 :
private void doAcquireShared(int arg) {
final Node node = addWaiter(Node.SHARED);
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
final Node p = node.predecessor();
if (p == head) {
int r = tryAcquireShared(arg);
if (r >= 0) {
setHeadAndPropagate(node, r);
p.next = null;
if (interrupted)
selfInterrupt();
failed = false;
return;
}
}
if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
- 首先通过addWaiter方法将当前线程封装成一个共享模式的节点加入到同步队列的尾部 。
- 然后进入一个无限循环,获取当前节点的前驱节点p 。如果p是头节点,说明当前节点是等待队列中最靠前的节点,有机会获取共享资源,调用tryAcquireShared方法尝试获取共享资源 。
- 如果获取成功(r >= 0),调用setHeadAndPropagate方法将当前节点设置为头节点,并根据情况唤醒后续节点 ,将原头节点的后继指针置空(p.next = null),以便垃圾回收 。如果线程在等待过程中被中断,调用selfInterrupt方法重新设置中断标志 ,返回 。
- 如果获取失败,调用shouldParkAfterFailedAcquire方法判断是否应该阻塞当前线程 。如果应该阻塞,调用parkAndCheckInterrupt方法阻塞当前线程,并返回是否被中断的状态 。如果线程在阻塞过程中被中断,interrupted会被设置为true 。
释放共享资源:当线程调用Semaphore的release方法释放许可(共享资源)时,实际上调用的是AQS的releaseShared方法 :
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) {
doReleaseShared();
return true;
}
return false;
}
首先调用tryReleaseShared方法尝试释放共享资源,在Semaphore中,tryReleaseShared方法的实现如下:
protected boolean tryReleaseShared(int releases) {
for (;;) {
int current = getState();
int next = current + releases;
if (next < current)
throw new Error("Maximum permit count exceeded");
if (compareAndSetState(current, next))
return true;
}
}
该方法首先进入一个无限循环,获取当前的状态state 。计算释放releases个许可后的状态next 。如果next小于current,说明释放的许可数超过了最大许可数,抛出Error异常 。否则,通过CAS操作尝试将state从current更新为next 。如果更新成功,返回true,表示释放成功 ;如果更新失败,说明有其他线程同时在修改state,继续循环尝试 。
如果tryReleaseShared返回true,说明释放共享资源成功,调用doReleaseShared方法唤醒同步队列中等待的线程 。doReleaseShared方法会从同步队列的头节点开始,找到第一个等待状态小于 0 的节点(即有效的等待节点),调用LockSupport.unpark方法唤醒该节点对应的线程 。如果唤醒后发现该节点的后继节点也处于等待状态,继续唤醒后继节点,直到没有可唤醒的节点为止 。
共享模式与独占模式的差异:
- 获取资源的方式:独占模式下,同一时刻只有一个线程能获取到同步状态,其他线程只能等待 ;共享模式下,允许多个线程同时获取同步状态,只要剩余的共享资源满足线程的需求 。
- 同步队列的处理:在独占模式中,获取锁失败的线程会被加入同步队列,当持有锁的线程释放锁时,只会唤醒同步队列中头节点的后继节点对应的线程 ;在共享模式中,获取共享资源失败的线程同样会被加入同步队列,当有线程释放共享资源时,会唤醒同步队列中多个等待的线程(只要剩余的共享资源足够) 。
- 应用场景:独占模式适用于需要严格控制资源访问,保证同一时刻只有一个线程访问资源的场景,如文件写入操作 ;共享模式适用于读多写少的场景,或者需要多个线程同时访问共享资源的场景,如数据库连接池、信号量控制并发访问数量等 。
五、AQS 在实际场景中的应用
AQS 作为 Java 并发包的核心框架,在众多实际场景中发挥着重要作用 。许多基于 AQS 实现的并发工具类,为我们解决多线程并发问题提供了强大的支持 。下面我们来介绍一些常见的基于 AQS 实现的并发工具类及其使用场景 。
1. ReentrantLock
ReentrantLock是一种可重入的互斥锁 ,它允许同一个线程多次获取同一把锁 。在需要保证同一时刻只有一个线程能够访问共享资源的场景中,ReentrantLock是一个很好的选择 。
例如,在一个多线程的银行转账系统中,为了保证账户余额的一致性,在进行转账操作时需要对账户进行加锁 。使用ReentrantLock可以实现如下:
public class BankAccount {
private double balance;
private final ReentrantLock lock = new ReentrantLock();
public BankAccount(double initialBalance) {
this.balance = initialBalance;
}
public void transfer(BankAccount target, double amount) {
lock.lock();
try {
if (this.balance >= amount) {
this.balance -= amount;
target.balance += amount;
System.out.println(Thread.currentThread().getName() + " 成功转账 " + amount + " 到目标账户");
} else {
System.out.println(Thread.currentThread().getName() + " 转账失败,余额不足");
}
} finally {
lock.unlock();
}
}
}
在这个例子中,transfer方法使用ReentrantLock来保证在同一时刻只有一个线程能够执行转账操作,避免了多线程同时操作账户余额导致的数据不一致问题 。
2. Semaphore
Semaphore是一个计数信号量,它可以用来控制同时访问特定资源的线程数量 。在资源有限的情况下,Semaphore非常有用 。
比如,在一个数据库连接池的实现中,我们需要限制同时获取数据库连接的线程数量,以避免数据库连接被耗尽 。使用Semaphore可以实现如下:
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.concurrent.Semaphore;
public class DatabaseConnectionPool {
private static final String URL = "jdbc:mysql://localhost:3306/mydb";
private static final String USER = "root";
private static final String PASSWORD = "password";
private static final int MAX_CONNECTIONS = 5;
private final Semaphore semaphore;
public DatabaseConnectionPool() {
this.semaphore = new Semaphore(MAX_CONNECTIONS);
}
public Connection getConnection() throws InterruptedException, SQLException {
semaphore.acquire();
try {
return DriverManager.getConnection(URL, USER, PASSWORD);
} catch (SQLException e) {
semaphore.release();
throw e;
}
}
public void releaseConnection(Connection connection) {
if (connection != null) {
try {
connection.close();
} catch (SQLException e) {
e.printStackTrace();
} finally {
semaphore.release();
}
}
}
}
在这个例子中,DatabaseConnectionPool使用Semaphore来控制同时获取数据库连接的线程数量,当有线程获取连接时,调用acquire方法获取许可,当线程释放连接时,调用release方法释放许可 ,这样可以确保不会有过多的线程同时获取数据库连接,避免了数据库连接资源的耗尽 。
3. CountDownLatch
CountDownLatch是一个同步辅助类,它允许一个或多个线程等待,直到其他线程完成一组操作 。在需要等待多个线程完成任务后再继续执行的场景中,CountDownLatch非常适用 。
比如,在一个分布式系统中,有多个子任务需要并发执行,当所有子任务完成后,再进行汇总操作 。使用CountDownLatch可以实现如下:
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class DistributedTask {
private static final int TASK_COUNT = 5;
public static void main(String[] args) throws InterruptedException {
ExecutorService executorService = Executors.newFixedThreadPool(TASK_COUNT);
CountDownLatch countDownLatch = new CountDownLatch(TASK_COUNT);
for (int i = 0; i < TASK_COUNT; i++) {
final int taskIndex = i;
executorService.submit(() -> {
try {
System.out.println("任务 " + taskIndex + " 开始执行");
// 模拟任务执行
Thread.sleep((long) (Math.random() * 1000));
System.out.println("任务 " + taskIndex + " 执行完成");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
countDownLatch.countDown();
}
});
}
countDownLatch.await();
System.out.println("所有任务执行完成,进行汇总操作");
executorService.shutdown();
}
}
在这个例子中,主线程创建了一个CountDownLatch对象,并将计数器初始化为任务数量 。每个子任务执行完毕后,调用countDown方法将计数器减 1 。主线程调用await方法等待,直到计数器变为 0,即所有子任务都执行完毕后,再继续执行后续的汇总操作 。
七、面试题解析
1. 经典面试题
- AQS 是什么?
- AQS 的核心原理是什么?
- AQS 中的同步队列和条件队列有什么区别?
- ReentrantLock 的公平锁和非公平锁有什么区别?
- Semaphore 是如何基于 AQS 实现的?
- CountDownLatch 的实现原理与 AQS 有什么关系?
- AQS 中独占模式和共享模式的区别是什么?
- AQS 中的状态变量 state 有什么作用?
- 如何自定义一个基于 AQS 的同步器?
2. 解题思路和答案
AQS 是什么?
- 解题思路:从 AQS 的定义、作用和在 Java 并发包中的地位进行阐述。
- 答案:AQS 即抽象队列同步器(AbstractQueuedSynchronizer) ,是 Java 并发包中构建锁和同步器的基础框架 。它提供了一种基于 FIFO 队列的机制来管理线程的竞争和等待状态 ,许多重要的同步工具类,如ReentrantLock、CountDownLatch、Semaphore等,都是基于 AQS 实现的 。AQS 就像是 Java 并发编程大厦的基石,为我们实现各种复杂的同步逻辑提供了强大的支持 。
3. AQS 的核心原理是什么?
- 解题思路:围绕 AQS 的核心构成(状态变量state和同步队列),以及在独占模式和共享模式下线程获取和释放资源的过程进行分析。
- 答案:AQS 的核心原理基于一个 FIFO 等待队列和一个同步状态(state) 。state是一个volatile修饰的整型变量,用于表示同步状态 ,不同的同步器中state有着不同的含义 ,如在ReentrantLock中表示锁的重入次数,在Semaphore中表示剩余的许可数 。同步队列是一个双向链表,当线程无法获取同步状态时,会被封装成一个节点加入到同步队列中等待 。在独占模式下,同一时刻只有一个线程能获取到同步状态,其他线程获取失败则进入同步队列等待;在共享模式下,允许多个线程同时获取同步状态,只要剩余的共享资源满足线程的需求 。线程通过CAS操作来竞争获取或释放资源,获取失败则进入同步队列等待唤醒 。
4. AQS 中的同步队列和条件队列有什么区别?
解题思路:分别介绍同步队列和条件队列的结构、作用以及节点状态的含义,对比它们在功能和使用场景上的差异。
答案:同步队列是 AQS 实现线程同步的关键数据结构,是一个双向链表 ,用于存储等待获取同步状态的线程 。当线程获取同步状态失败时,会被封装成一个节点加入到同步队列的尾部 。节点的状态有CANCELLED(线程已取消等待)、SIGNAL(后继节点的线程需要被唤醒)等 。在独占模式下,持有锁的线程释放锁时,会唤醒同步队列中头节点的后继节点对应的线程 ;在共享模式下,释放共享资源时,会唤醒多个等待的线程 。
条件队列与Condition接口相关联 ,每个Condition对象都有一个单独的条件队列 。线程调用Condition的await方法时,会从同步队列转移到条件队列并释放锁进入等待状态 ,此时节点的状态会被设置为CONDITION 。当其他线程调用Condition的signal方法时,条件队列中第一个节点被转移回同步队列,等待获取锁 。
总的来说,同步队列主要用于线程获取同步状态失败时的等待和唤醒,而条件队列用于线程在满足特定条件时的等待和唤醒 ,两者相互配合,共同实现了 AQS 强大的同步功能 。
5. ReentrantLock 的公平锁和非公平锁有什么区别?
- 解题思路:从获取锁的过程、是否保证线程获取锁的顺序以及性能等方面进行对比分析。
- 答案:公平锁保证线程按照请求锁的顺序获取锁,即先到先得 。在获取锁时,公平锁会先调用hasQueuedPredecessors方法检查同步队列中是否有前驱节点 ,只有在没有前驱节点时才尝试获取锁 ,以此保证先到的线程先获取锁 。
非公平锁则不保证获取顺序,线程可以在锁可用时直接竞争获取,可能会出现后到的线程先获取到锁的情况 。非公平锁在获取锁时,直接尝试通过CAS操作获取锁,不会检查同步队列 ,如果获取失败,才会和公平锁一样,将线程加入同步队列等待 。
在性能方面,非公平锁由于减少了检查同步队列的开销,在高并发场景下性能通常比公平锁更高 。但同时,由于可能导致一些线程长时间等待,出现线程饥饿的问题 。而公平锁虽然保证了公平性,但由于频繁的线程上下文切换,性能相对较低 。
6. Semaphore 是如何基于 AQS 实现的?
- 解题思路:分析Semaphore中与 AQS 相关的核心方法,如tryAcquireShared和tryReleaseShared,阐述它们如何利用 AQS 的同步队列和状态变量来实现信号量的功能。
- 答案:Semaphore是基于 AQS 的共享模式实现的 。在Semaphore中,AQS 的状态变量state表示剩余的许可数 。当一个线程调用acquire方法获取许可时,实际上调用的是 AQS 的acquireShared方法 ,tryAcquireShared方法是获取共享资源的核心逻辑 。它会进入一个无限循环,获取当前的state,计算获取acquires个许可后剩余的许可数remaining 。如果remaining小于 0,说明当前剩余许可数不足,返回remaining,表示获取失败 ;否则,通过CAS操作尝试将state从当前值更新为remaining 。如果更新成功,返回remaining,表示获取成功 ;如果更新失败,说明有其他线程同时在修改state,继续循环尝试 。
当线程调用release方法释放许可时,实际上调用的是 AQS 的releaseShared方法 ,tryReleaseShared方法会进入一个无限循环,获取当前的state ,计算释放releases个许可后的状态next 。如果next小于当前state,说明释放的许可数超过了最大许可数,抛出Error异常 ;否则,通过CAS操作尝试将state从当前值更新为next 。如果更新成功,返回true,表示释放成功 ;如果更新失败,说明有其他线程同时在修改state,继续循环尝试 。如果释放成功,会调用doReleaseShared方法唤醒同步队列中等待的线程 。
7. CountDownLatch 的实现原理与 AQS 有什么关系?
- 解题思路:说明CountDownLatch如何利用 AQS 的同步队列和状态变量来实现线程的等待和计数功能,重点分析await和countDown方法的实现逻辑。
- 答案:CountDownLatch也是基于 AQS 实现的 。它利用 AQS 的状态变量state来表示计数的初始值 。当一个或多个线程调用await方法时,实际上调用的是 AQS 的acquireSharedInterruptibly方法 ,线程会尝试获取共享资源 。由于CountDownLatch的设计,只有当state减为 0 时,线程才能获取到共享资源,否则线程会被加入到同步队列中等待 。
当其他线程调用countDown方法时,实际上调用的是 AQS 的releaseShared方法 ,会将state减 1 。如果state减为 0,说明所有的计数都已完成,会唤醒同步队列中等待的线程 ,让它们继续执行 。
8. AQS 中独占模式和共享模式的区别是什么?
- 解题思路:从获取资源的方式、同步队列的处理以及应用场景等方面进行详细对比。
- 答案:在获取资源的方式上,独占模式下,同一时刻只有一个线程能获取到同步状态,其他线程只能等待 ;共享模式下,允许多个线程同时获取同步状态,只要剩余的共享资源满足线程的需求 。
在同步队列的处理上,独占模式中,获取锁失败的线程会被加入同步队列,当持有锁的线程释放锁时,只会唤醒同步队列中头节点的后继节点对应的线程 ;共享模式中,获取共享资源失败的线程同样会被加入同步队列,当有线程释放共享资源时,会唤醒同步队列中多个等待的线程(只要剩余的共享资源足够) 。
在应用场景方面,独占模式适用于需要严格控制资源访问,保证同一时刻只有一个线程访问资源的场景,如文件写入操作 ;共享模式适用于读多写少的场景,或者需要多个线程同时访问共享资源的场景,如数据库连接池、信号量控制并发访问数量等 。
9. AQS 中的状态变量 state 有什么作用?
- 解题思路:结合不同的同步器,阐述state在表示同步状态、控制资源访问以及实现锁的重入等方面的作用。
- 答案:AQS 中的状态变量state是实现同步的关键 ,它用一个volatile修饰的整型变量来表示同步状态 。在不同的同步器中,state有着不同的含义和作用 。在ReentrantLock中,state表示锁的重入次数 。当一个线程首次获取到锁时,state会被设置为 1 。如果该线程再次获取同一把锁,state就会递增,变为 2,以此类推,表示线程对锁的重入 。而当线程释放锁时,state会相应递减,直到state为 0 时,锁被完全释放 。在Semaphore中,state表示剩余的许可数 。假设我们创建一个信号量对象并设置许可数为 5,那么初始时state就是 5 。当一个线程调用acquire方法获取许可时,如果state大于 0,该线程就能获取到许可,同时state减 1 ;当state为 0 时,其他线程再调用acquire方法就会被阻塞,直到有其他线程调用release方法释放许可,state增加后,才有可能获取到许可 。
9. 如何自定义一个基于 AQS 的同步器?
- 解题思路:介绍自定义同步器的步骤,包括继承 AQS 类、重写必要的抽象方法(如tryAcquire、tryRelease等),以及如何使用自定义同步器。
- 答案:自定义一个基于 AQS 的同步器,首先需要继承AbstractQueuedSynchronizer类 。然后,根据同步器的类型(独占模式或共享模式),重写相应的抽象方法 。
对于独占模式的同步器,通常需要重写tryAcquire和tryRelease方法 。tryAcquire方法用于尝试获取同步状态,实现该方法须查询并判断当前状态是否符合预期,然后再进行CAS设置状态 。例如,实现一个简单的独占锁:
import java.util.concurrent.locks.AbstractQueuedSynchronizer;
public class CustomLock {
private static class Sync extends AbstractQueuedSynchronizer {
@Override
protected boolean tryAcquire(int arg) {
if (compareAndSetState(0, 1)) {
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
return false;
}
@Override
protected boolean tryRelease(int arg) {
if (getState() == 0)
throw new IllegalMonitorStateException();
setExclusiveOwnerThread(null);
setState(0);
return true;
}
protected boolean isHeldExclusively() {
return getState() == 1;
}
}
private final Sync sync = new Sync();
public void lock() {
sync.acquire(1);
}
public void unlock() {
sync.release(1);
}
public boolean isLocked() {
return sync.isHeldExclusively();
}
}
在这个例子中,tryAcquire方法通过CAS操作尝试将state从 0 设置为 1 ,如果设置成功,将当前线程设置为锁的持有者,返回true,表示获取锁成功 ;tryRelease方法将state设置为 0,并将锁的持有者设置为null,返回true,表示释放锁成功 。
对于共享模式的同步器,需要重写tryAcquireShared和tryReleaseShared方法 。使用自定义同步器时,创建同步器的实例,然后调用其提供的方法来实现线程同步 。
八、总结
AQS 作为 Java 并发编程的核心框架,为我们解决多线程同步问题提供了强大的支持。通过本文的介绍,我们从实际问题出发,深入探讨了 AQS 的概念、核心构成、工作机制、实际应用以及面试相关内容。
AQS 的核心构成包括状态变量state和同步队列 ,它们相互配合,实现了线程的同步控制 。在独占模式和共享模式下,AQS 通过不同的逻辑来管理线程对资源的获取和释放 ,确保了多线程环境下资源访问的安全性和高效性 。
基于 AQS 实现的并发工具类,如ReentrantLock、Semaphore、CountDownLatch等,在实际开发中有着广泛的应用 ,为我们处理各种并发场景提供了便捷的解决方案 。
在面试中,AQS 相关的问题也是高频考点 ,掌握 AQS 的原理和应用,能够帮助我们更好地应对面试,展示自己在并发编程方面的能力 。