别只盯着 AQS 锁了!G1 与 ZGC 才是并发卡顿的“隐形杀手”
前言
上周三凌晨两点,我被电话吵醒了。
线上核心接口响应时间突然飙升,从 50ms 涨到了 2s。
监控面板上,CPU 使用率只有 30%,内存也没爆。
乍一看,这简直是“灵异事件”。
我连上服务器,先查了线程dump。
发现大量线程卡在ReentrantLock的acquire方法上。
很多人第一反应是死锁,或者锁竞争太激烈。
但仔细看堆栈,它们其实是在等 GC 释放资源。
这就是典型的“锁竞争”与“垃圾回收”的恶性耦合。
你以为是锁的问题,其实是 GC 在背后拖了后腿。
今天咱们不聊虚的,直接拆解 AQS 队列机制,顺便把 G1 和 ZGC 的底裤都扒干净。
让你明白,为什么有时候加锁反而让系统更慢。
一、底层原理
1.1 核心机制
先说 AQS(AbstractQueuedSynchronizer)。
它其实是 JDK 并发包的“地基”。
你用的ReentrantLock、CountDownLatch,底层全是它。
它的核心就两件事:状态管理 + 队列排队。
状态用一个volatile int表示,比如 0 代表没锁,1 代表有锁。
队列是个 FIFO 的 CLH 变体队列。
怎么理解这个队列?
想象早高峰的地铁站。
想进闸机(获取锁)的人,如果闸机被占了(state=1),你就得去排队(入队)。
排在前面的人进去了,闸机释放,后面的人才能动。
AQS 里,这个排队过程是通过Node节点挂起线程实现的。
一旦获取锁失败,线程就进入WAITING状态,不占 CPU。
等到前一个节点释放锁,会唤醒后继节点。
这个唤醒过程,就是LockSupport.unpark。
这里有个关键点:GC 什么时候介入?
当线程被挂起时,它持有的对象引用可能变成垃圾。
如果此时触发 Full GC,STW(Stop-The-World)会让所有线程暂停。
包括那些正在排队等锁的线程。
这就导致锁释放延迟,排队队伍越来越长。
G1 和 ZGC 就是为了解决这个“停顿”而生的。
G1 把堆内存切分成一个个 Region。
它不像 CMS 那样整个堆一起扫,而是优先回收垃圾最多的 Region。
这就叫“可预测的停顿模型”。
你可以设定最大停顿时间,比如 200ms。
G1 会尽量在这个时间内干完活。
ZGC 更狠,它直接上了读屏障。
对象引用被读取时,如果指向的对象被移动了,CPU 会帮忙修正地址。
这意味着 ZGC 几乎不需要 STW。
哪怕堆内存有 100GB,停顿时间也能控制在 1ms 以内。
这就极大减少了锁竞争线程被“冻住”的概率。
下面这张图,把 AQS 队列和 GC 停顿的关系画出来了。
graph TD A[线程尝试获取锁] --> B{状态 state 是否为 0?} B -- 是 --> C[CAS 修改 state 为 1] C --> D[获取锁成功] B -- 否 --> E[封装 Node 节点入队] E --> F[LockSupport.park 挂起线程] F --> G[等待 GC 或 锁释放] G --> H{触发 GC 停顿?} H -- 是 --> I[所有线程暂停 STW] I --> J[锁释放延迟] J --> K[队列积压 响应变慢] H -- 否 --> L[前驱节点释放锁] L --> M[unpark 后继线程] M --> N[重新尝试 CAS] N --> B style A fill:#f9f,stroke:#333 style K fill:#f96,stroke:#333 style I fill:#f00,stroke:#333,color:#fff1.2 与同类方案的对比
光说 AQS 不够,咱们看看它和另外两种同步机制的区别。
synchronized是 JVM 层面的,AQS 是 JDK 层面的。
StampedLock是 AQS 的变种,支持乐观读。
| 特性 | synchronized | AQS (ReentrantLock) | StampedLock |
|---|---|---|---|
| 实现层级 | JVM 内置 | JDK 代码实现 | JDK 代码实现 |
| 锁公平性 | 非公平 | 可配置 (默认非公平) | 非公平 |
| 条件变量 | wait/notify | Condition (更灵活) | 不支持 |
| 性能表现 | JDK6 后优化好 | 高竞争下略优 | 读多写少场景极佳 |
| GC 友好度 | 依赖 JVM 优化 | 依赖底层 GC 策略 | 依赖底层 GC 策略 |
你会发现,AQS 的优势在于“灵活”。
它能让你自定义同步逻辑。
但代价是,你必须自己管理锁的粒度。
如果锁住的范围太大,GC 扫描根对象时,压力会倍增。
二、快速上手
咱们先写个最简单的 AQS 自定义锁。
别小看这个,很多中间件的消息队列锁定,就是这么写的。
目标是:实现一个能在 1 秒内自动超时释放的锁。
import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.AbstractQueuedSynchronizer; /** * 自定义超时锁演示 * 模拟生产环境中防止线程无限等待的场景 */ public class TimeoutLock { // 内部类,继承 AQS,负责管理锁状态 private static class Sync extends AbstractQueuedSynchronizer { // 尝试获取锁,返回 true 表示成功 @Override protected boolean tryAcquire(int arg) { // 核心逻辑:CAS 修改 state,0 变 1 if (compareAndSetState(0, 1)) { // 设置独占线程 setExclusiveOwnerThread(Thread.currentThread()); return true; } return false; } // 尝试释放锁 @Override protected boolean tryRelease(int arg) { // 必须检查是不是当前线程在释放,防止误操作 if (getExclusiveOwnerThread() != Thread.currentThread()) { throw new IllegalMonitorStateException("非法释放锁"); } // 重置状态 setState(0); setExclusiveOwnerThread(null); return true; } } private final Sync sync = new Sync(); /** * 获取锁,带超时时间 * @param timeout 等待时长 * @param unit 时间单位 * @return 是否获取成功 */ public boolean tryLock(long timeout, TimeUnit unit) { // 调用 AQS 的框架方法,它会自动处理排队和挂起 return sync.tryAcquireNanos(1, unit.toNanos(timeout)); } public void unlock() { sync.release(1); } }这段代码只有 50 行,但把 AQS 的骨架都搭好了。
tryAcquire和tryRelease是模板方法。
子类只需要实现具体的业务逻辑。
AQS 框架会帮你处理排队、挂起、唤醒这些脏活累活。
三、核心 API / 深水区
3.1 核心方法速查
玩 AQS,这几个方法必须滚瓜烂熟。
| 方法名 | 作用 | 适用场景 |
|---|---|---|
tryAcquire(int arg) | 尝试同步获取 | 实现独占锁 |
tryRelease(int arg) | 尝试同步释放 | 实现独占锁 |
tryAcquireShared(int arg) | 尝试共享获取 | 实现读写锁、信号量 |
tryReleaseShared(int arg) | 尝试共享释放 | 实现读写锁、信号量 |
isHeldExclusively() | 判断当前线程是否持有锁 | 辅助 Condition 实现 |
3.2 生产级配置
在生产环境用 G1 或 ZGC,参数配置直接决定生死。
别用默认配置,默认是给 Demo 用的。
G1 推荐配置:
# 堆内存设置 -Xms4g -Xmx4g # 最大停顿时间目标,G1 会尽力满足 -XX:MaxGCPauseMillis=200 # 启动混合收集,回收老年代 -XX:+UseG1GC # 日志,方便排查 STW 时间 -Xlog:gc*:file=/tmp/gc.log:time,uptime,level,tagsZGC 推荐配置:
# JDK 15+ 直接启用 -XX:+UseZGC # 开启分代回收 (JDK 19+),性能更好 -XX:+ZGenerational # 堆内存可以设大点,ZGC 扛得住 -Xms8g -Xmx8g3.3 高级定制
如果你发现 G1 的停顿还是不可控,可以调整InitiatingHeapOccupancyPercent。
默认是 45%,意思是堆占用超过 45% 就开始并发标记。
调低这个值,比如 30%,会让 GC 更早介入。
虽然 CPU 会稍微高一点,但能避免突发的大停顿。
这就好比扫地,别等屋子全脏了再扫,每天扫一点,心里不慌。
四、实战演练
咱们来模拟一个真实场景。
高并发下,多个线程竞争同一个资源,同时后台触发 GC。
看看 AQS 队列会怎么变化。
import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; public class GcLockStressTest { // 自定义锁 private static final TimeoutLock lock = new TimeoutLock(); // 计数器,模拟业务处理 private static final AtomicInteger successCount = new AtomicInteger(0); // 失败计数器,模拟超时 private static final AtomicInteger timeoutCount = new AtomicInteger(0); public static void main(String[] args) throws InterruptedException { // 创建线程池,模拟 50 个并发用户 ExecutorService executor = Executors.newFixedThreadPool(50); System.out.println("开始压力测试,模拟 GC 干扰场景..."); for (int i = 0; i < 50; i++) { executor.submit(() -> { try { // 尝试获取锁,最多等 500 毫秒 // 如果 GC 停顿超过这个时间,这里就会超时 if (lock.tryLock(500, TimeUnit.MILLISECONDS)) { try { // 模拟业务逻辑耗时 10ms Thread.sleep(10); successCount.incrementAndGet(); } finally { lock.unlock(); } } else { // 获取锁失败,记录日志 timeoutCount.incrementAndGet(); System.out.println("线程 " + Thread.currentThread().getName() + " 获取锁超时"); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); } // 主线程休眠 2 秒,让子线程跑一会儿 Thread.sleep(2000); // 手动触发 GC,模拟生产环境的大对象分配 System.gc(); System.out.println("已触发 System.gc(),观察锁竞争情况"); Thread.sleep(3000); executor.shutdown(); System.out.println("=== 测试结束 ==="); System.out.println("成功处理: " + successCount.get()); System.out.println("超时失败: " + timeoutCount.get()); } }运行这段代码,你会发现。
如果不用 ZGC,timeoutCount可能会飙升。
因为System.gc()触发的 STW 可能长达几百毫秒。
所有排队线程都被冻住,等 GC 结束,超时时间早过了。
换成 ZGC 后,timeoutCount会显著下降。
因为 GC 停顿被压缩到了毫秒级,锁释放足够快。
五、避坑指南与最佳实践
踩过的坑,都给你总结在这儿了。
💡技巧 1:锁粒度要细
别把整个方法都锁住。
只锁共享变量访问的那几行代码。
锁住的时间越短,GC 造成的影响就越小。
⚠️警告 1:慎用System.gc()
除非你非常清楚自己在做什么。
否则千万别在生产环境手动调这个。
它相当于强制物业把整栋楼断电打扫。
✅推荐 1:监控 GC 停顿时间
用 Prometheus + Grafana 监控JVM_GC_Pause_Time。
如果这个指标和接口延迟曲线高度重合。
那不用怀疑,就是 GC 在搞鬼。
💡技巧 2:大对象直接进老年代
G1 里,大对象会直接进老年代。
如果老年代回收慢,停顿就长。
尽量优化代码,减少大对象创建。
比如别在循环里new大数组。
⚠️警告 2:ZGC 的 CPU 开销
ZGC 虽然停顿短,但平时 CPU 占用比 G1 高。
大概高 10% 左右。
如果机器 CPU 本来就紧缺,得慎重。
✅推荐 2:混合使用锁与无锁
能不用锁就不用锁。
比如用AtomicInteger代替synchronized计数。
无锁并发,GC 怎么扫都跟你没关系。
六、综合实战演示
最后,咱们写个完整的示例。
结合 AQS 和 ZGC 配置,做一个高可用的资源池。
import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; /** * 高并发资源池示例 * 结合 AQS 锁与 GC 优化策略 */ public class ResilientResourcePool { private final TimeoutLock lock = new TimeoutLock(); private final AtomicInteger availableResources = new AtomicInteger(10); private static final int MAX_RETRIES = 3; /** * 获取资源,带重试机制 * 防止因 GC 停顿导致的单次超时 */ public boolean acquireResource(long timeoutMs) { for (int i = 0; i < MAX_RETRIES; i++) { try { // 尝试获取锁 if (lock.tryLock(timeoutMs, TimeUnit.MILLISECONDS)) { try { // 检查资源是否充足 if (availableResources.get() > 0) { availableResources.decrementAndGet(); return true; } } finally { lock.unlock(); } } } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } // 如果因为 GC 停顿超时,稍微等一会儿再试 if (i < MAX_RETRIES - 1) { try { Thread.sleep(50); } catch (InterruptedException ex) { Thread.currentThread().interrupt(); } } } return false; } /** * 释放资源 */ public void releaseResource() { // 简单模拟,实际生产需加锁保证原子性 availableResources.incrementAndGet(); } public static void main(String[] args) { // 模拟 JVM 启动参数:-XX:+UseZGC -Xms2g -Xmx2g System.out.println("当前 GC 策略: " + java.lang.management.ManagementFactory.getGarbageCollectorMXBeans()); ResilientResourcePool pool = new ResilientResourcePool(); ExecutorService executor = Executors.newCachedThreadPool(); for (int i = 0; i < 20; i++) { executor.submit(() -> { if (pool.acquireResource(1000)) { System.out.println(Thread.currentThread().getName() + " 获取资源成功"); pool.releaseResource(); } else { System.out.println(Thread.currentThread().getName() + " 获取资源失败"); } }); } executor.shutdown(); } }这段代码里,acquireResource加了重试。
哪怕第一次因为 GC 停顿超时,第二次还有机会。
这就是生产级代码该有的韧性。
七、总结
AQS 是并发控制的骨架,GC 是内存管理的血液。
两者在运行时紧密交织。
锁竞争会让线程挂起,GC 停顿会让挂起的线程无法醒来。
想解决卡顿,不能只调锁,得盯着 GC 日志看。
G1 适合大堆、可预测停顿。
ZGC 适合超大堆、极低延迟。
选对工具,配合合理的锁粒度。
你的系统才能在并发高压下,依然稳如老狗。