Skip to content

并发与 JUC 深入 ​

Java 高级 · 底层原理

高并发系统的稳定性,根本上取决于你对"锁怎么实现、线程怎么调度、任务怎么编排"的理解深度。本文不止讲结论,而是深入 AbstractQueuedSynchronizer(AQS)的 CLH 队列、线程池的 ctl 位运算、以及虚拟线程的载体调度,让你从"加个 synchronized"进阶到"精准控制并发"。

一、AQS:JUC 的心脏 ​

ReentrantLock、Semaphore、CountDownLatch、ReadWriteLock 全都建立在一个名为 AbstractQueuedSynchronizer 的抽象类之上。它的设计极其精妙,用三个核心部件统一了所有同步器:

  1. volatile int state:同步状态。锁的可重入次数、信号量的许可数、读写锁的读计数都编码在这里。
  2. FIFO 等待队列(CLH 变种):一个双向链表,节点 Node 封装"等待的线程 + 等待状态(CANCELLED/WAITING 等)"。
  3. 两套模板方法:acquire/release(独占)与 acquireShared/releaseShared(共享),子类只实现 tryAcquire/tryRelease 等钩子。

1.1 加锁失败的链路 ​

以 ReentrantLock.lock() 为例,调用链是 lock → sync.acquire(1):

关键细节:acquire 先 tryAcquire(一次 CAS 抢锁),失败才进队列;进队后不是立即 park,而是再 tryAcquire 一次(避免不必要的挂起),仍失败才 LockSupport.park。这叫"自旋一小会儿再阻塞",兼顾了低竞争下的性能与高竞争下的 CPU 友好。

1.2 公平锁 vs 非公平锁 ​

ReentrantLock 默认非公平:

java
// 非公平锁:上来先插队 CAS,抢不到才排队
final boolean nonfairTryAcquire(int acquires) {
    if (compareAndSetState(0, acquires)) {   // 无视排队,直接抢
        setExclusiveOwnerThread(current);
        return true;
    }
    // 已持有则重入
    ...
}
// 公平锁:hasQueuedPredecessors() 检查"前面是否有人排队",有则放弃插队
protected final boolean tryAcquire(int acquires) {
    if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) { ... }
}

非公平锁吞吐更高(减少上下文切换),但可能造成"饥饿";公平锁严格按队列顺序,避免饥饿却牺牲性能。ReentrantLock 的 state 还支持条件队列 Condition:await() 把线程从 AQS 队列移到 Condition 自己的单向等待队列,signal() 再把它移回 AQS 队列重新抢锁——这就是 await/signal 必须持有锁才能调用的原因。

TIP

为什么 AQS 队列是 CLH 的变种(双向而非单向)?因为取消(cancel)节点、判断前驱状态、唤醒后继都需要双向指针;且每个节点只关心"前驱是否释放了锁"这一信号,自旋/park 都基于前驱状态,这种局部性让 CAS 冲突降到最低。

二、线程池:ctl 位运算与拒绝策略 ​

ThreadPoolExecutor 的精妙在于用一个原子整数 ctl 同时编码线程池运行状态(高 3 位)和工作线程数(低 29 位),一次 CAS 同时更新两者,避免状态与计数不一致。

java
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
// RUNNING(111) SHUTDOWN(000) STOP(001) TIDYING(010) TERMINATED(011)
private static int runStateOf(int c)  { return c & ~CAPACITY; }  // 取高3位
private static int workerCountOf(int c) { return c & CAPACITY; } // 取低29位

2.1 任务提交流程 ​

注意一个易错点:任务会先尝试填满核心线程,再入队列,队列满了才扩到最大线程数。所以如果你用无界队列,maximumPoolSize 永远用不上,线程数恒等于 corePoolSize。

2.2 四大拒绝策略 ​

  • AbortPolicy(默认):抛 RejectedExecutionException。
  • CallerRunsPolicy:由提交任务的线程自己执行——一种温和的反压,让提交方慢下来。
  • DiscardPolicy:静默丢弃,可能造成任务丢失,慎用。
  • DiscardOldestPolicy:丢弃队列头最老任务,再尝试提交当前任务。
java
// IO 密集型:核心数放大;用有界队列 + 反压策略,杜绝无界堆积 OOM
ThreadPoolExecutor pool = new ThreadPoolExecutor(
    8, 32, 60, TimeUnit.SECONDS,
    new LinkedBlockingQueue<>(1000),                 // 有界!
    new ThreadFactoryBuilder().setNameFormat("biz-%d").build(),
    new ThreadPoolExecutor.CallerRunsPolicy());      // 满时调用方兜底

2.3 异步编排:CompletableFuture 的依赖图 ​

CompletableFuture 把回调地狱变成声明式编排,底层是基于 ForkJoinPool(或你指定的线程池)的有向无环依赖图。每个 Future 持有一个 Completion 链表,前一个完成时触发后继的 thenApply/thenCompose/thenCombine。

java
CompletableFuture<User> u = CompletableFuture.supplyAsync(() -> userSvc.get(id), pool);
CompletableFuture<List<Order>> o = u.thenComposeAsync(uv -> orderSvc.list(uv.getId()), pool); // 扁平化
CompletableFuture<Void> done = o.thenAcceptAsync(orders -> log.info("{} 单", orders.size()))
                                  .exceptionally(ex -> { log.error("err", ex); return null; });
// thenCombine: 两个 Future 都完成才执行二元函数

DANGER

CompletableFuture 默认用 ForkJoinPool.commonPool(),它是全局共享且线程数有限(=核数-1)的。若把大量 IO 阻塞任务丢进去,会拖垮所有依赖公共池的代码。IO 密集任务务必传独立线程池。另外 exceptionally 只捕获前一个阶段的异常,长链建议配合 handle 做统一收口。

三、虚拟线程(JDK 21+):载体线程调度 ​

传统平台线程(Platform Thread)是 OS 线程的一对一映射,创建成本高(默认 1MB 栈),百万并发直接打爆内核。虚拟线程(Virtual Thread)是 JVM 管理的轻量实体:

  • 一个**载体线程(Carrier Thread,即平台线程)**可挂载海量虚拟线程。
  • 虚拟线程遇到阻塞 IO / LockSupport.park 时,JVM 自动**卸载(unmount)它,把载体线程让给别的虚拟线程;IO 就绪后再挂载(mount)**回来。
  • 因为"阻塞"不再阻塞载体线程,所以同步写法也能获得异步吞吐,且调试栈是可读的(不像回调嵌套)。
java
// 每个任务一个虚拟线程,海量并发也不爆内存
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 1_000_000; i++) {
        executor.submit(() -> blockingCall()); // 阻塞时自动卸载,载体线程去干别的
    }
}

WARNING

虚拟线程不适合 synchronized 包裹的密集计算或长持锁——synchronized 阻塞时无法卸载(会 pin 住载体线程)。应用 ReentrantLock 替代,或在虚拟线程里避免把阻塞操作放进 synchronized。CPU 密集计算也不适合虚拟线程(它没有减少计算量,只是省了阻塞时的栈成本)。

四、生产落地清单 ​

  • 按业务隔离线程池(下单/查询/通知分属不同池),避免相互挤占;用 Micrometer 暴露活跃线程数、队列长度、拒绝次数并告警。
  • 锁要小:只在临界区持锁,远程调用/IO 放到锁外;volatile 不替代锁(i++ 仍需原子类或锁)。
  • 善用原子类:AtomicReference/LongAdder(高并发计数比 AtomicLong 分段,减少 CAS 争用)。
  • IO 密集且阻塞多的场景优先评估虚拟线程,简化代码同时提升吞吐。

📚 官方文档与参考资料 ​

✏️ 练习与自测 ​

  1. 读 ReentrantLock.NonfairSync 源码,画出 lock() 从 tryAcquire 到 park 的完整路径,说明非公平为何吞吐更高。
  2. 给定 core=2, max=4, queue=2,依次提交 10 个耗时任务,推演线程创建、入队、扩容、拒绝的每一个时刻状态。
  3. 用 CompletableFuture 编排"查用户 → 并发查订单和地址 → 汇总",并指定独立线程池避免污染公共池。
  4. 对比虚拟线程与平台线程在 10 万并发阻塞调用下的资源占用,指出 synchronized 为何会 pin 住载体线程。

本节小结

AQS 用 "state + CLH 双向队列 + LockSupport" 统一了所有同步器,公平/非公平只差"插队与否";线程池的精髓在 "ctl 位运算编码状态与计数 + 队列缓冲 + 拒绝策略反压";异步编排靠 CompletableFuture 依赖图,未来属于虚拟线程的"阻塞即卸载"。核心心法:锁要小、池要隔离、异步要有界、虚拟线程避 synchronized。