在构建高并发系统时,锁是不可或缺的同步工具。然而,锁的实现细节往往隐藏着性能瓶颈。相信大家都遇到过这样的场景:在高并发环境下,应用程序的响应时间 резко 上升,CPU 占用率却很低,这时很有可能就是锁竞争导致的。例如,使用简单的 synchronized 关键字,虽然简单易用,但在竞争激烈的情况下,性能会急剧下降。更高级的锁,比如 ReentrantLock 提供了更多的功能,但也增加了使用的复杂度。
那么,如何才能构建既高效又易于使用的同步组件呢?答案就是:理解并运用 AbstractQueuedSynchronizer (AQS)。
AbstractQueuedSynchronizer (AQS) 的核心思想
AQS,即抽象队列同步器,是 Java 并发包 java.util.concurrent 中的核心组件。它提供了一个用于构建锁和同步器的框架,而不是一个具体的锁实现。ReentrantLock、Semaphore、CountDownLatch 等并发工具都是基于 AQS 构建的。
AQS 的核心思想是:
- State (状态):AQS 维护一个
volatile int state变量,用于表示同步状态。不同的同步器可以根据自己的需求来定义状态的含义,例如,ReentrantLock使用 state 来表示锁的持有次数,Semaphore使用 state 来表示剩余的许可证数量。 - FIFO 队列:AQS 内部维护一个 FIFO 队列,用于存放等待获取同步状态的线程。当一个线程尝试获取同步状态失败时,它会被放入队列中等待,直到有其他线程释放同步状态。
- CLH 变体:AQS 的队列实际上是 CLH 队列的一个变体。CLH 队列是一种基于链表的 FIFO 队列,它的每个节点都持有一个指向前驱节点的引用。AQS 在 CLH 队列的基础上进行了一些优化,例如使用
compareAndSet操作来保证线程安全,并引入了 head 和 tail 节点来简化队列的操作。
AQS 的工作流程
AQS 的工作流程大致如下:
- 线程尝试获取同步状态。如果获取成功,则直接返回。
- 如果获取失败,则将当前线程封装成一个节点,放入 FIFO 队列的尾部。
- 队列中的线程会不断尝试获取同步状态,直到获取成功或者被取消。
- 当一个线程释放同步状态时,它会唤醒队列中的下一个线程,让其尝试获取同步状态。
AQS 的源码解析
AQS 的源码比较复杂,但我们可以抓住几个关键点进行分析:
tryAcquire(int arg):尝试以独占模式获取同步状态。子类需要重写该方法,并根据自己的需求来实现获取同步状态的逻辑。如果获取成功,则返回 true,否则返回 false。tryRelease(int arg):尝试以独占模式释放同步状态。子类需要重写该方法,并根据自己的需求来实现释放同步状态的逻辑。如果释放成功,则返回 true,否则返回 false。tryAcquireShared(int arg):尝试以共享模式获取同步状态。子类需要重写该方法,并根据自己的需求来实现获取同步状态的逻辑。如果获取成功,则返回一个非负数,否则返回一个负数。tryReleaseShared(int arg):尝试以共享模式释放同步状态。子类需要重写该方法,并根据自己的需求来实现释放同步状态的逻辑。如果释放成功,则返回 true,否则返回 false。acquire(int arg):以独占模式获取同步状态,如果获取失败,则阻塞等待。acquireShared(int arg):以共享模式获取同步状态,如果获取失败,则阻塞等待。release(int arg):以独占模式释放同步状态。releaseShared(int arg):以共享模式释放同步状态。
下面是一个简单的 AQS 使用示例,实现一个简单的互斥锁:
import java.util.concurrent.locks.AbstractQueuedSynchronizer;
public class SimpleMutex {
private static class Sync extends AbstractQueuedSynchronizer {
// 独占模式,state 为 0 表示未锁定,1 表示已锁定
@Override
protected boolean tryAcquire(int arg) {
if (compareAndSetState(0, 1)) { // CAS 操作,尝试将 state 从 0 设置为 1
setExclusiveOwnerThread(Thread.currentThread()); // 设置当前线程为独占线程
return true;
} else {
return false;
}
}
@Override
protected boolean tryRelease(int arg) {
if (!isHeldExclusively()) { // 如果当前线程不是独占线程,则抛出异常
throw new IllegalMonitorStateException();
}
setExclusiveOwnerThread(null); // 释放独占线程
setState(0); // 将 state 设置为 0
return true;
}
@Override
protected boolean isHeldExclusively() {
return getExclusiveOwnerThread() == Thread.currentThread();
}
}
private final Sync sync = new Sync();
public void lock() {
sync.acquire(1); // 独占模式获取锁
}
public void unlock() {
sync.release(1); // 释放锁
}
public boolean isLocked() {
return sync.isHeldExclusively();
}
public static void main(String[] args) throws InterruptedException {
SimpleMutex mutex = new SimpleMutex();
Runnable task = () -> {
mutex.lock();
try {
System.out.println(Thread.currentThread().getName() + " acquired the lock");
Thread.sleep(100); // 模拟临界区操作
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
mutex.unlock();
System.out.println(Thread.currentThread().getName() + " released the lock");
}
};
Thread t1 = new Thread(task, "Thread-1");
Thread t2 = new Thread(task, "Thread-2");
t1.start();
t2.start();
t1.join();
t2.join();
}
}
实战避坑经验总结
- 避免长时间持有锁:长时间持有锁会导致其他线程阻塞等待,降低并发性能。尽量缩短临界区的代码执行时间。
- 选择合适的锁模式:根据实际需求选择独占锁或共享锁。如果多个线程可以同时读取共享资源,则可以使用共享锁,提高并发性能。
- 避免死锁:死锁是指多个线程互相等待对方释放资源,导致程序无法继续执行。可以通过避免循环等待、按顺序获取锁等方式来避免死锁。
- 合理设置公平性:AQS 支持公平锁和非公平锁。公平锁会按照线程请求锁的顺序来分配锁,而非公平锁则允许线程插队。在某些情况下,非公平锁可以提高吞吐量,但也可能导致某些线程饥饿。
- 监控 AQS 的性能: 使用 JConsole、VisualVM 等工具监控 AQS 的性能指标,例如锁的竞争情况、等待队列的长度等,以便及时发现和解决性能问题。如果发现锁竞争激烈,可以考虑使用更细粒度的锁,或者采用无锁化的数据结构和算法。 此外,在使用 Nginx 做反向代理时,也要关注连接池的大小和 upstream 的配置,避免因后端服务处理能力不足而导致请求堆积。
掌握 AbstractQueuedSynchronizer 是构建高性能并发组件的关键。通过深入理解 AQS 的原理和使用方法,我们可以更好地设计和实现各种锁和同步器,从而提高并发系统的性能和可靠性。
冠军资讯
键盘上的咸鱼