ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

深入解析AQS:Java并发同步器的核心原理与实战实现

深入解析AQS:Java并发同步器的核心原理与实战实现 1. 项目概述为什么AQS是Java并发的“定海神针”如果你写过Java并发代码用过ReentrantLock、Semaphore或者CountDownLatch那你其实已经和AQS打过交道了。AQS全称AbstractQueuedSynchronizer是java.util.concurrent.locks包下的一个核心抽象类。它本身不直接对外暴露却是构建Java中绝大多数同步组件锁、屏障等的基石。你可以把它想象成乐高积木里的基础板ReentrantLock、Semaphore这些具体的同步器就是在这块板上搭建出的不同形态的城堡、车辆。为什么我们需要花时间深入剖析这块“基础板”因为在日常开发中我们常常满足于使用Lock.lock()和Lock.unlock()直到线上出现死锁、性能瓶颈或者需要实现一个特定业务场景的同步机制时才会发现对底层原理的模糊认知成了最大的障碍。理解AQS不仅能让你在面试中游刃有余地拆解“请说说AQS原理”这类经典八股文更重要的是它能赋予你一种能力当JDK内置的锁无法满足你苛刻的业务需求时你可以基于AQS亲手打造一把量身定制的“锁”。比如实现一个连接池的流量控制器或者一个支持复杂条件等待的分布式锁本地协调器。这不仅仅是学习一个类而是掌握一套构建并发同步原语的方法论。2. AQS核心设计思想与架构拆解AQS的设计极其精妙它用一个int类型的成员变量state来表示同步状态并内置了一个FIFO先进先出的CLH队列变体来管理那些获取资源失败的线程。它的核心思想是将构建同步器时关于状态管理、线程排队、等待与唤醒的通用性、复杂性逻辑抽取出来封装在AQS中而将资源获取与释放的具体语义比如是独占还是共享判断获取是否成功的条件留给子类去实现。这就是模板方法模式的典型应用。2.1 同步状态state的奥秘state是AQS的灵魂。这个int变量在不同子类中有完全不同的语义在ReentrantLock中state0表示锁未被持有state1表示锁被一个线程持有state1表示锁被同一个线程重入。在Semaphore中state表示当前可用的许可证数量。在CountDownLatch中state表示倒计时计数器的初始值。AQS提供了getState()、setState(int newState)和compareAndSetState(int expect, int update)这三个protected final方法来操作state。注意修改state用的是CAS操作这是保证并发安全的根本。子类需要根据自身逻辑定义如何读取和修改state以判断同步状态。2.2 等待队列CLH锁队列的变体当线程尝试获取同步状态失败时AQS会将其封装成一个Node节点加入一个双向链表的队列中然后挂起这个线程。待持有同步状态的线程释放资源后会从队列中唤醒一个独占模式或多个共享模式后继节点。这个队列是CLH锁队列的一种变体。CLH锁通常用于自旋锁而AQS将其改造为用于阻塞锁。每个Node节点保存了线程引用、等待状态waitStatus如CANCELLED、SIGNAL、CONDITION等、前驱和后继指针。waitStatus是理解线程间协作的关键例如SIGNAL表示后继节点需要被唤醒。注意很多初学者会混淆AQS队列和Object.wait()相关的等待集。AQS队列是同步队列管理所有竞争同步状态的线程而Condition对象关联的则是条件队列用于实现条件等待两者相互协作但又独立。2.3 模板方法模式留给子类的钩子AQS定义了两种资源访问模式独占Exclusive和共享Share。子类必须实现以下一个或多个protected方法来定义自己的同步策略独占模式tryAcquire(int arg)尝试以独占方式获取资源。成功返回true失败返回false。tryRelease(int arg)尝试释放独占资源。成功返回true失败返回false。共享模式tryAcquireShared(int arg)尝试以共享方式获取资源。返回负数表示失败0表示成功但后续共享获取可能失败正数表示成功且后续共享获取可能成功。tryReleaseShared(int arg)尝试释放共享资源。AQS提供的public final方法如acquire(int arg)、release(int arg)等内部会调用这些子类实现的tryXXX方法。这就是“模板方法”父类AQS定义算法骨架获取资源→入队→挂起→唤醒而将算法步骤的具体实现延迟到子类。3. 从源码视角解析AQS工作流程我们以最经典的独占、不响应中断的获取流程acquire(int arg)为例拆解其源码逻辑。这个方法体现了AQS最核心的排队与等待机制。public final void acquire(int arg) { if (!tryAcquire(arg) acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }这四行代码信息量巨大我们一步步看第一步tryAcquire(arg)这是子类实现的钩子方法。线程首先尝试直接获取资源如果成功方法直接返回线程继续执行。这对应了锁获取的“快速路径”。第二步addWaiter(Node.EXCLUSIVE)如果tryAcquire失败说明资源已被占用。此时当前线程需要被放入等待队列。addWaiter方法将当前线程包装成一个独占模式的Node节点然后通过一个CAS操作快速尝试将节点插入到队列尾部。如果CAS失败并发插入导致则进入enq(node)方法这是一个自旋循环直到通过CAS成功将节点插入队尾。enq方法还负责初始化队列头节点一个不关联线程的哑元节点。第三步acquireQueued(final Node node, int arg)这是整个排队获取逻辑的核心。节点入队后并不立即挂起因为在其排队期间持有资源的线程可能已经释放了资源。因此它进入一个自旋检查自己的前驱节点p是否为头节点。如果是说明自己是队列中第一个等待的线程有资格再次尝试获取资源tryAcquire(arg)。如果获取成功则将当前节点设置为新的头节点setHead(node)并将原头节点出队方法返回。如果前驱不是头节点或者尝试获取再次失败则调用shouldParkAfterFailedAcquire(p, node)判断是否需要挂起。这个方法主要检查并更新前驱节点的waitStatus。如果前驱节点状态正常waitStatus 0会通过CAS将其设置为SIGNAL表示“当你释放资源时记得唤醒我”。如果设置成功则返回false下一轮循环会再次检查如果前驱节点状态已经是SIGNAL则返回true表示可以安心挂起了。如果需要挂起则调用parkAndCheckInterrupt()使用LockSupport.park(this)挂起当前线程。第四步selfInterrupt()如果线程在挂起期间被中断acquireQueued方法会返回true。但acquire方法是不响应中断的这是它的语义所以它只是在获取资源成功后再补上一次自我中断Thread.currentThread().interrupt()将中断状态还原。释放流程release(int arg)相对简单调用子类的tryRelease(arg)如果成功则检查头节点状态。如果头节点状态不为0通常为SIGNAL则调用unparkSuccessor(h)唤醒其后继节点中第一个未被取消的线程。唤醒的线程会在acquireQueued的自旋中解除挂起再次尝试获取资源并很可能成功。4. 实战手把手实现一个自定义同步器理解了原理最好的巩固方式就是动手。我们来实现一个简单的“二元闸门”BooleanLatch它只有两种状态打开true和关闭false。初始状态为关闭一旦打开所有等待的线程都可以通过且后续所有尝试通过的线程也直接通过。这有点像CountDownLatch但它的计数器只有1。4.1 定义同步器类与状态语义import java.util.concurrent.locks.AbstractQueuedSynchronizer; public class BooleanLatch { // 内部同步器 private static final class Sync extends AbstractQueuedSynchronizer { // 状态定义1 表示打开0 表示关闭 boolean isOpen() { return getState() 1; } // 尝试以共享方式获取通过闸门 protected int tryAcquireShared(int acquires) { // 如果状态为1打开则获取成功返回1表示后续共享获取也可能成功 // 如果状态为0关闭则获取失败返回-1 return (getState() 1) ? 1 : -1; } // 尝试以共享方式释放打开闸门 protected boolean tryReleaseShared(int releases) { // 使用CAS将状态从0设置为1。这是一个一次性操作。 // 如果当前状态已经是1CAS会失败返回false但这是允许的重复打开。 // 我们期望的语义是至少成功一次。 for (;;) { int s getState(); if (s 1) { return false; // 已经是打开状态无需重复操作 } if (compareAndSetState(s, 1)) { return true; // 成功从关闭变为打开 } // CAS失败循环重试 } } } private final Sync sync new Sync(); // 公开API public void await() throws InterruptedException { sync.acquireSharedInterruptibly(1); // 可中断的共享获取 } public void signal() { sync.releaseShared(1); // 释放共享状态打开闸门 } public boolean isOpen() { return sync.isOpen(); } }4.2 核心方法实现解析tryAcquireShared这是共享获取的逻辑。当线程调用await()时最终会调用此方法。如果闸门已打开state 1返回正数这里返回1表示获取成功线程不会阻塞。如果闸门关闭state 0返回负数-1AQS会将线程加入队列并挂起。tryReleaseShared这是打开闸门的逻辑。它使用一个无限循环进行CAS操作试图将state从0原子性地更新为1。一旦成功返回true这会触发AQS去唤醒等待队列中所有线程因为是共享模式。即使signal()被多次调用由于CAS的原子性state也只会被设置一次为1保证了“一次性打开”的语义。4.3 使用示例与测试public class BooleanLatchDemo { public static void main(String[] args) throws InterruptedException { final BooleanLatch latch new BooleanLatch(); final int workerCount 5; // 启动多个工作线程它们会在闸门前等待 for (int i 0; i workerCount; i) { final int id i; new Thread(() - { try { System.out.println(Worker id 到达闸门等待...); latch.await(); // 等待闸门打开 System.out.println(Worker id 通过闸门); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }).start(); } // 主线程等待2秒后打开闸门 Thread.sleep(2000); System.out.println(\n主线程打开闸门); latch.signal(); // 再等待一会观察所有线程通过 Thread.sleep(1000); System.out.println(\n所有工作完成。); } }运行这个示例你会看到所有工作线程先到达并等待2秒后闸门打开所有线程几乎同时通过。4.4 实现中的注意事项与避坑指南状态设计要清晰state的语义是同步器的核心。务必用文档清晰定义每个状态值的含义。在我们的BooleanLatch中0/1的语义非常明确。正确选择模式仔细思考你的同步器是独占的如锁还是共享的如信号量、闸门。BooleanLatch允许多个线程同时通过所以是共享模式。tryRelease与tryReleaseShared的返回值这个返回值表示“此次释放操作是否完全释放了资源从而可以唤醒等待线程”。在独占锁ReentrantLock中需要重入计数归零才算完全释放。在我们的BooleanLatch中只要成功从0变为1就算完全释放可以唤醒所有等待线程。注意线程中断我们示例中await()使用了可中断的acquireSharedInterruptibly。你也可以提供不可中断的版本。这取决于你设计的同步器的语义。性能考量AQS队列的入队和出队操作都涉及CAS在极高并发下可能成为热点。对于极简的同步器可以考虑先自旋几次再入队类似ReentrantLock的tryLock和自旋优化但这会显著增加实现复杂度。对于大多数应用AQS默认的性能已经足够优秀。5. AQS在JDK同步器中的应用实例分析理解了AQS和自定义同步器后再回头看JDK内置的组件会有豁然开朗的感觉。ReentrantLock独占锁。state表示重入计数。tryAcquire通过CAS尝试将state从0改为1或判断当前持有线程是否为自身以实现重入。它内部有公平锁FairSync和非公平锁NonfairSync两个AQS子类区别在于tryAcquire前是否先检查队列中有无等待者。Semaphore共享锁。state表示可用许可证数量。tryAcquireShared尝试减少许可证计数state - acquires如果结果非负则成功。tryReleaseShared增加许可证计数。CountDownLatch共享锁。state表示倒计时初始值。tryAcquireShared在state 0时成功。countDown()调用tryReleaseShared每次将state减1直到为0时唤醒所有等待线程。ReentrantReadWriteLock巧妙地将一个AQS实例Sync的state变量拆成两部分高16位表示读锁持有数低16位表示写锁重入计数。它通过不同的tryAcquire/tryAcquireShared实现来区分读写锁的语义。6. 高级话题与性能调优思考6.1 公平与非公平的抉择这是ReentrantLock给我们上的经典一课。非公平锁在tryAcquire时直接抢不管队列里有没有人在等。这可能导致“插队”现象但减少了线程挂起和唤醒的开销吞吐量通常更高。公平锁严格按照FIFO顺序获取保证了绝对的公平但可能增加上下文切换。在绝大多数场景下非公平锁是更好的默认选择除非你的业务对线程等待时间的绝对公平性有严格要求。6.2 Condition条件队列AQS内部类ConditionObject实现了Condition接口用于实现更精细的线程等待/通知机制类似于Object.wait()/notify()但更强大、更灵活。每个ConditionObject都维护一个独立的条件队列。当线程调用condition.await()时它会释放锁并将线程节点从AQS同步队列转移到该条件队列中等待。当其他线程调用condition.signal()时会将条件队列的头节点转移到同步队列中重新参与锁竞争。ReentrantLock的newCondition()方法返回的就是这个对象。6.3 调试与监控线上并发问题难以复现。可以借助以下手段线程转储Thread Dump使用jstack或kill -3命令。查看线程状态为WAITING (parking)的其堆栈信息中往往能看到AbstractQueuedSynchronizer$ConditionObject.await或LockSupport.park结合持有的锁信息是分析死锁或锁竞争的热点。JMX一些同步器如ReentrantLock可以通过JMX暴露一些监控属性如等待队列长度但AQS本身没有。自定义监控在自定义同步器中可以暴露getQueueLength()、getQueuedThreads()等方法AQS本身有protected final方法用于监控等待情况。6.4 常见陷阱与排查技巧死锁AQS本身不会导致死锁但错误使用基于AQS的锁会。经典场景是多个锁以不同顺序获取。排查时仔细分析线程转储中每个线程持有的锁和等待的锁。锁泄露忘记在finally块中释放锁。务必使用tryLock-finally unlock模式。非重入锁的重入如果你实现的是非重入锁在tryAcquire中必须检查当前线程是否已经是持有者。ReentrantLock的重入特性就是通过这个检查实现的。“惊群”效应在共享模式下如Semaphore释放大量许可证releaseShared会唤醒所有等待线程可能导致大量线程同时竞争CPU。在某些场景下可能需要更平缓的唤醒策略但这超出了标准AQS的范畴。性能瓶颈在超高并发下AQS队列的入队CAS操作可能成为瓶颈。如果同步器竞争非常激烈可能需要考虑减少锁粒度、使用无锁数据结构如ConcurrentHashMap或尝试其他并发模型如Actor模型。深入理解AQS就像是拿到了Java并发世界的底层地图。它不能直接解决所有并发问题但它提供的这套强大、灵活的同步器构建框架让你在面对复杂并发场景时多了一份“自己动手丰衣足食”的底气和能力。从读懂ReentrantLock的源码到自己写出一个可用的BooleanLatch这个过程本身就是对并发思维最好的训练。下次当你再使用Lock时脑海中能清晰地浮现出那个state变量和等待队列的运作图景这才是真正掌握了这门内功。
返回列表