你好,这里是codetrend专栏“高并发编程基础”。
AQS(java.util.concurrent.locks.AbstractQueuedSynchronizer)是 Java 并发包中的一个抽象类,它提供了构建同步器的基础框架。
AQS提供了一个实现阻塞锁和相关同步器(信号量、事件等)的框架,该框架依赖于先进先出(FIFO)的等待队列。这个类被设计为大多数依赖于单个原子int值表示状态的同步器的有用基础。子类必须定义改变该状态的受保护方法,并定义该状态在获取或释放此对象方面的含义。有了这些,该类中的其他方法将执行所有排队和阻塞机制。子类可以维护其他状态字段,但只有使用getState、setState和compareAndSetState方法对原子更新的int值进行操作时才与同步相关联。
Java中的大部分同步类(Semaphore、ReentrantLock、CountDownLatch等)都是基于AQS实现的。AQS 的使用需要了解其内部的同步状态和等待队列,并根据具体的需求来实现自定义的同步器。虽然直接使用 AQS 可能有些复杂,但它提供了一个强大且灵活的基础框架,可以用于实现各种高级的同步器和并发工具。
CLH locks queue
AQS 使用一个 FIFO(先进先出)的等待队列来管理线程的获取和释放资源的竞争关系。
等待队列是“CLH”(Craig、Landin 和 Hagersten)锁队列的一种变体。CLH 锁通常用于自旋锁。我们改用它们来实现阻塞同步器,通过包括显式的“prev”和“next”链接以及一个“status”字段,允许节点在释放锁时向后继节点发出信号,并处理由中断和超时引起的取消操作。状态字段包含跟踪线程是否需要信号(使用 LockSupport.unpark)的位。
要将节点入队到 CLH 锁中,您需要将其原子地作为新的尾部拼接进去。要出队,您设置头字段,这样下一个合适的等待者就成为第一个。
+------+ prev +-------+ +------+
| head | <---- | first | <---- | tail |
+------+ +-------+ +------+
将节点插入 CLH 队列只需要对“tail”执行一次原子操作,因此从未排队到已排队有一个简单的划分点。前继节点的“next”链接由入队线程在成功的 CAS 操作之后设置。尽管非原子性的,但这足以确保任何被阻塞的线程在合适的时候会被前继节点发出信号(尽管在取消的情况下,可能需要使用 cleanQueue 方法中的信号进行辅助)。发出信号部分基于类似 Dekker 方案的机制,其中待等待的线程先指示等待状态,然后重试获取,并在阻塞之前重新检查状态。发出信号者在解除阻塞时原子性地清除等待状态。
在获取锁时的出队操作涉及将节点的“prev”节点分离(置空),然后更新“head”。其他线程通过检查“prev”而不是头来检查节点是否已被出队或曾经出队。如果需要,我们通过自旋等待来强制执行置空然后设置的顺序。正因为如此,该锁算法本身并不严格“无锁”,因为获取锁的线程可能需要等待前一个获取操作取得进展。当与独占锁一起使用时,这种进展是必需的。但是共享模式可能(不常见)需要在设置头字段之前自旋等待,以确保正确传播。(历史注释:与此类的以前版本相比,这样做可以简化和提高效率。)
CAS (Compare And Swap)
AQS 的核心思想是通过维护一个状态变量来控制资源的获取和释放。当一个线程需要获取资源时,它会尝试通过 CAS(Compare and Swap)操作来获取资源,如果失败则会进入等待队列并阻塞。当其他线程释放资源时,AQS 会选择一个或多个等待队列中的线程唤醒,并让其中的线程再次尝试获取资源。
CAS全称 Compare And Swap(比较与交换),是一种无锁算法。在不使用锁(没有线程被阻塞)的情况下实现多线程之间的变量同步。java.util.concurrent包中的原子类就是通过CAS来实现了乐观锁。
CAS算法涉及到三个操作数:
- 需要读写的内存值 V。
- 进行比较的值 A。
- 要写入的新值 B。
当且仅当 V 的值等于 A 时,CAS通过原子方式用新值B来更新V的值(“比较+更新”整体是一个原子操作),否则不会执行任何操作。一般情况下,“更新”是一个不断重试的操作。
tryAcquire 和 tryRelease
AQS 的子类可以通过重写一些特定的方法来定义自己的同步逻辑。其中最重要的方法是 tryAcquire 和 tryRelease,它们用于实现资源的获取和释放逻辑。其他方法如 acquire、release、await 等都是基于这两个方法进行的封装。
同时需要通过使用 getState()、setState(int) 和/或 compareAndSetState(int, int) 检查和/或修改同步状态。
java.util.concurrent.locks.ReentrantLock.Sync
中的tryRelease和tryAcquire实现:
@ReservedStackAccess
protected final boolean tryRelease(int releases) {
int c = getState() - releases;
if (getExclusiveOwnerThread() != Thread.currentThread())
throw new IllegalMonitorStateException();
boolean free = (c == 0);
if (free)
setExclusiveOwnerThread(null);
setState(c);
return free;
}
/**
* Acquire for non-reentrant cases after initialTryLock prescreen
*/
protected final boolean tryAcquire(int acquires) {
if (getState() == 0 && compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
return false;
}
AQS 实现
为了将此类用作同步器的基础,根据需要通过使用 getState()、setState(int) 和/或 compareAndSetState(int, int) 检查和/或修改同步状态来重新定义以下方法:
tryAcquire(int)
tryRelease(int)
tryAcquireShared(int)
tryReleaseShared(int)
isHeldExclusively()
默认情况下,这些方法都会抛出 UnsupportedOperationException。这些方法的实现必须在内部是线程安全的,并且通常应该很短,不应该阻塞。定义这些方法是使用此类的唯一受支持的方式。所有其他方法都声明为 final,因为它们无法独立变化。
还可以发现继承自 AbstractOwnableSynchronizer
的方法有助于跟踪拥有排他同步器的线程。鼓励使用它们,这使得监视和诊断工具可以帮助用户确定哪些线程持有锁。
尽管此类基于内部 FIFO 队列,但它不会自动强制执行 FIFO 获取策略。排他同步的核心形式如下:
// Acquire:
while (!tryAcquire(arg)) {
// 如果线程未排队,则将其排队;
// 可能阻塞当前线程;
}
// Release:
if (tryRelease(arg)) {
// 解除阻塞第一个排队的线程;
}
// (共享模式类似,但可能涉及级联信号。)
由于获取中的检查在排队之前调用,因此新获取的线程可能会超越被阻塞和排队的其他线程。但是,如果需要,您可以定义 tryAcquire 和/或 tryAcquireShared 来通过内部调用一个或多个检查方法来禁用插队,从而提供公平的 FIFO 获取顺序。特别是,大多数公平同步器可以定义 tryAcquire 以返回 false,如果 hasQueuedPredecessors()(专门为公平同步器设计的方法)返回 true,则可以实现这一点。还有其他变化是可能的。
默认插队(也称为贪婪、放弃和避免车队)策略通常具有最高的吞吐量和可扩展性。尽管这不能保证公平或无饥饿,但先前排队的线程允许在后排队的线程之前重新竞争,并且每个重新竞争都有一个没有偏见的机会成功反对进入的线程。此外,虽然获取不会像通常意义上的自旋那样“旋转”,但它们可能在阻塞之前执行多次 tryAcquire 调用并夹杂着其他计算。这在仅短暂持有排他同步时给出了自旋的大多数好处,而在不持有该同步时减少了大部分负面影响。如果需要,您可以在调用获取方法之前增加“快速路径”检查,可能会预先检查 hasContended() 和/或 hasQueuedThreads(),以仅在同步器不太可能发生竞争时才执行。
此类通过将其使用范围专门化为可以依赖 int 状态、获取和释放参数以及内部 FIFO 等待队列的同步器,提供了一种高效且可扩展的同步基础。当这些不足时,您可以使用原子类、自定义的队列类和 LockSupport 阻塞支持从更低级别构建同步器。
想要实现一个同步器还是具有一定的复杂度,下面使用JDK17中提供的默认实现来演示AQS的使用。
Semaphore
Semaphore(信号量)是一种同步工具,用于控制同时访问特定资源的线程数量。它维护了一个计数器,该计数器表示可以同时访问资源的线程数量。当线程需要访问资源时,它必须首先获取信号量的许可,如果许可数大于0,则线程可以继续执行,否则线程将被阻塞,直到获得许可。
在Java中,Semaphore类位于java.util.concurrent包中。以下是Semaphore的一些常用方法:
acquire()
:获取一个许可,如果没有许可则阻塞线程。acquire(int permits)
:获取指定数量的许可,如果没有足够的许可则阻塞线程。release()
:释放一个许可。release(int permits)
:释放指定数量的许可。availablePermits()
:返回当前可用的许可数量。
下面是一个使用Semaphore的示例代码:
import java.util.concurrent.Semaphore;
public class SemaphoreExample {
public static void main(String[] args) {
// 创建一个Semaphore实例,初始许可数量为3
Semaphore semaphore = new Semaphore(3);
// 创建5个线程并发访问资源
for (int i = 1; i <= 5; i++) {
final int threadId = i;
Thread thread = new Thread(() -> {
try {
System.out.println("Thread " + threadId + " is waiting for a permit.");
semaphore.acquire(); // 获取许可
System.out.println("Thread " + threadId + " acquires a permit.");
// 模拟线程执行一段时间
Thread.sleep(2000);
System.out.println("Thread " + threadId + " releases the permit.");
semaphore.release(); // 释放许可
} catch (InterruptedException e) {
e.printStackTrace();
}
});
thread.start();
}
}
}
在上面的代码中,我们创建了一个初始许可数量为3的Semaphore实例。然后,我们创建了5个线程,每个线程都尝试获取一个许可,如果没有足够的许可,则线程将被阻塞。在模拟线程执行一段时间后,线程会释放许可。
运行以上代码输出结果类似如下:
Thread 1 is waiting for a permit.
Thread 1 acquires a permit.
Thread 2 is waiting for a permit.
Thread 2 acquires a permit.
Thread 3 is waiting for a permit.
Thread 3 acquires a permit.
Thread 2 releases the permit.
Thread 4 is waiting for a permit.
Thread 1 releases the permit.
Thread 5 is waiting for a permit.
Thread 3 releases the permit.
Thread 4 acquires a permit.
Thread 5 acquires a permit.
Thread 4 releases the permit.
Thread 5 releases the permit.
从输出结果可以看出,最多同时有3个线程获取到许可,其他线程需要等待。当一个线程释放许可后,等待的线程中的一个会获取到许可并继续执行。
ReentrantLock
ReentrantLock(可重入锁)是Java中的一种同步机制,它与synchronized相似,但具有更高的灵活性和功能。与synchronized不同,ReentrantLock可以实现公平锁和非公平锁,并且可以使用tryLock()方法尝试获取锁而不阻塞线程。
在Java中,ReentrantLock类位于java.util.concurrent.locks包中。以下是ReentrantLock的一些常用方法:
lock()
:获取锁,如果锁已经被其他线程占用,则阻塞当前线程。lockInterruptibly()
:获取锁,如果锁已经被其他线程占用,则阻塞当前线程,但可以响应中断。tryLock()
:尝试获取锁,如果锁当前没有被其他线程占用,则获取锁并返回true
,否则立即返回false
。unlock()
:释放锁。
import java.util.concurrent.locks.ReentrantLock;
public class ReentrantLockExample {
private static ReentrantLock lock = new ReentrantLock(); // 创建一个ReentrantLock实例
public static void main(String[] args) {
// 创建两个线程并发访问资源
Thread thread1 = new Thread(() -> {
lock.lock(); // 获取锁
try {
System.out.println("Thread 1 is doing some work.");
Thread.sleep(2000); // 模拟线程执行一段时间
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock(); // 释放锁
}
});
thread1.start();
Thread thread2 = new Thread(() -> {
try {
lock.lockInterruptibly(); // 可以响应中断地获取锁
System.out.println("Thread 2 is doing some work.");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock(); // 释放锁
}
});
thread2.start();
}
}
在上面的代码中,我们创建了一个ReentrantLock实例,并使用该实例来保护共享资源。然后,我们创建了两个线程,每个线程都尝试获取锁并执行一些操作,最后释放锁。
Thread 1 is doing some work.
Thread 2 is doing some work.
从输出结果可以看出,两个线程都成功获取到了锁并执行了自己的任务。需要注意的是,如果我们将lock()
方法替换为lockInterruptibly()
方法,则第二个线程可以响应中断地获取锁,这意味着如果第二个线程在等待锁时被中断,则它会立即退出等待并抛出InterruptedException
异常。
CountDownLatch
CountDownLatch(倒计时器)是Java中的一种同步工具,它可以让一个或多个线程等待其他线程执行完特定操作后再继续执行。它维护了一个计数器,该计数器初始化为一个正整数,并且可以在任何时候被修改。每当一个线程完成特定操作时,计数器的值就会减少1。当计数器的值变为0时,所有等待线程都会被唤醒。
在Java中,CountDownLatch类位于java.util.concurrent包中。以下是CountDownLatch的一些常用方法:
CountDownLatch(int count)
:创建一个CountDownLatch实例,并指定计数器的初始值。await()
:等待计数器的值变为0。await(long timeout, TimeUnit unit)
:等待计数器的值变为0,但最多等待指定时间。countDown()
:将计数器的值减少1。
下面是一个使用CountDownLatch的示例代码:
import java.util.concurrent.CountDownLatch;
public class CountDownLatchExample {
public static void main(String[] args) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(3); // 创建一个CountDownLatch实例,初始计数器值为3
// 创建3个线程并发执行某个任务
for (int i = 1; i <= 3; i++) {
final int threadId = i;
Thread thread = new Thread(() -> {
System.out.println("Thread " + threadId + " is doing some work.");
try {
Thread.sleep(2000); // 模拟线程执行一段时间
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Thread " + threadId + " has finished its work.");
latch.countDown(); // 计数器减1
});
thread.start();
}
latch.await(); // 等待计数器的值变为0
System.out.println("All threads have finished their work.");
}
}
在上面的代码中,我们创建了一个CountDownLatch实例,并将计数器的初始值设置为3。然后,我们创建了3个线程并发执行某个任务,在每个线程完成任务后,计数器的值就会减少1。最后,我们使用await()
方法等待计数器的值变为0,并输出所有线程都完成任务的消息。
运行以上代码将看到输出结果类似如下:
Thread 1 is doing some work.
Thread 2 is doing some work.
Thread 3 is doing some work.
Thread 1 has finished its work.
Thread 2 has finished its work.
Thread 3 has finished its work.
All threads have finished their work.
从输出结果可以看出,三个线程并发执行任务,每个线程完成任务后计数器的值就会减少1,当计数器的值变为0时,主线程才会继续执行。
关于作者
来自全栈程序员nine的探索与实践,持续迭代中。
欢迎关注和点赞~