返回专题首页

Java 专题

Java 并发进阶:volatile、AQS、CompletableFuture 与线程池治理

并发入门篇更多是在帮你建立“线程不是免费的、共享状态会带来风险、线程池不是随便开几个线程”的基础意识。到了进阶阶段,重点就不再是知道几个类名,而是理解它们背后的运行逻辑,以及这些逻辑在真实系统里会怎样影响稳定性。

Java 专题第 23 篇 / 26 篇11 分钟

并发入门篇更多是在帮你建立“线程不是免费的、共享状态会带来风险、线程池不是随便开几个线程”的基础意识。到了进阶阶段,重点就不再是知道几个类名,而是理解它们背后的运行逻辑,以及这些逻辑在真实系统里会怎样影响稳定性。

这一篇我们重点看四块内容:

  • volatile 和可见性;
  • AQS 和并发同步器的共同底座;
  • CompletableFuture 和异步编排;
  • 线程池治理与生产环境观测。

它们看上去分散,实际上分别对应并发世界里四个最常见的问题:共享变量怎么安全可见、线程怎么协作排队、异步流程怎么组织、资源池怎么不失控。

并发进阶真正难在哪里?

很多人学并发时,一开始会陷入 API 视角:会用 synchronized、会 new 线程池、会写 CompletableFuture 链式调用,好像就已经掌握了并发。可一旦系统开始承受压力,问题马上会换一种样子出现:

  • 明明变量改了,另一个线程却没看到;
  • 线程没有死锁,但吞吐越来越低;
  • 异步流程代码看起来很现代,异常却悄悄丢了;
  • 一个通用线程池承接所有任务,高峰时整个服务一起卡住。

这说明并发真正难的不是“会写”,而是“知道这些工具在什么边界下会失效,以及出了问题从哪层排查”。

volatile 解决的是可见性,不是万能同步

这是并发里最容易被误解的点之一。很多人第一次看到 volatile,会把它当成轻量版锁,觉得只要变量加了这个关键字,多线程访问就安全了。事实远没有这么简单。

volatile 真正做了什么?

从工程角度看,它主要带来两层保证:

  • 一个线程对变量的写入,其他线程能更及时地看到;
  • 某些危险的指令重排会被限制,从而保证可见的执行顺序。

这意味着它适合解决“状态已经被某个线程改了,但别的线程迟迟看不到”的问题。

它不能解决什么?

它不能天然保证复合操作的原子性。像下面这类典型操作:

  • 自增;
  • 先判断后更新;
  • 多步读取再写回;
  • 依赖多个共享变量的组合状态。

这些都不是单纯的可见性问题,而是读改写过程的一致性问题。单靠 volatile 不能保证安全。

什么时候适合用 volatile

比较典型的场景包括:

  • 配置刷新标记;
  • 关闭开关;
  • 双重检查中的状态可见性;
  • 只写一次、读很多次的简单状态量。

一旦涉及复杂共享状态,应该优先考虑锁、原子类、无锁数据结构或者直接重新设计共享方式。

并发问题为什么常常和 Java 内存模型一起出现?

因为多线程问题本质上不只是“代码顺序看起来怎样”,而是“不同线程最终看到的内存结果是不是一致”。Java 内存模型关心的就是这些事情:

  • 一个线程的写入什么时候对另一个线程可见;
  • 编译器和处理器在什么范围内可以重排指令;
  • 同步手段怎样建立可靠的 happens-before 关系。

这也是为什么真正的并发问题经常很反直觉。你在源码里看到的顺序,不一定就是多个线程观察到的顺序。所以并发进阶一定要开始习惯从“内存可见性和执行顺序”而不是“代码排版顺序”来理解问题。

AQS 为什么是 Java 并发工具类的重要底座?

很多人会用 ReentrantLockCountDownLatchSemaphore,但把这些工具真正串起来看时,会发现它们背后大量共享同一种设计思路,这个思路就是 AQS。

AQS 真正在做什么?

你可以把它理解成一个围绕“状态”和“排队”组织起来的同步框架。它主要解决几件事:

  • 线程获取资源失败后,怎样排队等待;
  • 资源何时被释放;
  • 后续线程怎样被唤醒;
  • 某个同步器是独占模式还是共享模式。

这也是为什么很多同步工具虽然外部语义不同,底层却能共享一套思路。因为本质上它们都在回答同一个问题:多个线程如何围绕一个有限状态安全协作。

为什么理解 AQS 有价值?

不是为了让你立刻手写同步器,而是为了帮助你理解:

  • 为什么锁竞争严重时线程会进入等待队列;
  • 为什么某些同步器适合共享获取,某些适合独占获取;
  • 为什么一个并发工具在高压下会表现出特定吞吐特征。

当你开始理解这些底层机制,再看锁、公平性、中断、超时等能力时,就不会只停留在 API 背诵层。

锁、原子类、并发容器,应该怎么选?

这是并发工程里特别现实的问题。没有一种方案适合所有场景,真正要看的是共享状态的复杂度和冲突强度。

共享状态简单,优先考虑原子类

如果只是对单个数值或单个引用做简单原子更新,原子类通常更轻、更直接。

共享状态复杂,需要完整临界区时,锁更稳

一旦逻辑涉及多步操作、多个变量之间的一致性,锁往往更容易保证正确性。不要因为“锁听起来重”就本能回避它,错误的无锁设计通常比合理的加锁更危险。

数据结构层面已有成熟实现时,优先用并发容器

ConcurrentHashMap 这类结构,本质上已经帮你处理了大量并发细节。很多时候,与其自己围绕普通容器加锁,不如先看看 JDK 是否已经提供了成熟工具。

CompletableFuture 为什么不是“语法更花哨的 Future”?

因为它真正解决的不是“异步拿结果”这一件事,而是异步任务之间的编排问题。

传统 Future 的问题

传统 Future 更像是“你先提交一个任务,之后再去等它结果”。它能表达异步执行,但很难优雅地表达:

  • 前一个任务完成后继续做什么;
  • 两个异步结果怎样合并;
  • 某个阶段失败后如何补偿或降级;
  • 异常如何沿链路传播;
  • 不同步骤使用哪个线程池。

CompletableFuture 的真正价值

它把异步流程从“阻塞等待”推进到了“可组合的任务图”。也就是说,你可以更自然地表达:

  • 串行依赖;
  • 并行聚合;
  • 成功和失败分支;
  • 超时控制;
  • 结果转换;
  • 异常恢复。

在聚合接口、批量查询、远程调用拼装、异步补偿这些场景里,这种能力非常有价值。

但它也最容易把代码写乱

因为一旦没有边界意识,链式调用就会迅速膨胀成“回调迷宫”:

  • 正常路径和异常路径搅在一起;
  • 同步 / 异步线程切换不清晰;
  • 某一步吞掉异常,后面所有逻辑都在错误状态下继续;
  • 公共线程池被大量业务异步任务挤爆。

所以 CompletableFuture 真正难的地方,不是 API 数量,而是你是否有能力控制异步链路的结构。

线程池为什么必须上升到“治理”层面?

很多系统早期用线程池没有明显问题,是因为任务量不大、调用链也短。一旦服务进入真实生产环境,线程池立刻会成为系统稳定性的关键部件。

线程池不是“有就行”

线程池背后涉及的是一整套资源调度问题:

  • 任务是 CPU 密集还是 I/O 密集;
  • 核心线程数和最大线程数是否合理;
  • 队列长度会不会掩盖问题;
  • 拒绝策略是不是与业务契合;
  • 任务是否需要隔离;
  • 线程池耗尽时系统怎样退化。

如果这些问题一个都不看,只是默认参数直接上,线程池很容易从“提效工具”变成“故障放大器”。

不同业务为什么要做线程池隔离?

这是非常常见、也非常重要的工程实践。因为不同任务对线程资源的占用模式完全不同:

  • 查询类任务大多受外部 I/O 影响;
  • 计算类任务更吃 CPU;
  • 批处理类任务持续时间长;
  • 通知类任务允许一定延迟;
  • 核心交易任务不能被低优先级任务拖死。

如果把所有任务都丢进一个公共线程池,高峰时最先出问题的通常不是某个单点功能,而是整个服务的响应能力一起下滑。

线程池治理还包括观测与预案

一个成熟系统不会只关心“线程池配置写了没”,还会关注:

  • 当前活跃线程数;
  • 队列积压长度;
  • 任务平均执行时间;
  • 拒绝次数;
  • 超时比例;
  • 线程池耗尽时的降级策略。

只有看见这些数据,线程池治理才不是纸面配置,而是可持续运营的能力。

项目里怎样把这些能力串成一套并发心智?

可以按下面这个顺序理解:

1. 先减少共享,再谈同步

共享状态越少,并发复杂度越低。能通过任务拆分、不可变对象、局部变量解决的,就不要一开始就靠复杂同步。

2. 必须共享时,先判断问题是哪一类

是可见性问题、原子性问题、还是资源竞争问题?不同问题对应不同工具,不能一把梭。

3. 异步化不是目的,边界清晰才是目的

不是所有调用都值得改成异步,也不是只要异步就会更快。异步只是把等待换成了编排复杂度,前提是你能驾驭这份复杂度。

4. 线程池要按资源池来治理,而不是按工具类来理解

线程池本质上和数据库连接池、对象池一样,都是受限资源池。真正要关心的是容量、隔离、观测和退化能力。

最常见的并发进阶误区

1. 把 volatile 当成原子性方案

这会导致很多隐蔽的数据竞争问题,尤其在计数、状态迁移、组合判断场景里特别常见。

2. 过度追求“无锁”,却没有正确性证明

很多看似高级的优化,最后只是把 bug 变得更难复现。

3. CompletableFuture 写得很长,却没有明确异常策略

这类异步链一旦出问题,排查难度通常比同步流程高得多。

4. 线程池全站一个,谁都能往里丢任务

这几乎是线上性能问题的高频诱因。

5. 只盯着代码,不看监控和运行数据

并发问题很多时候不是从源代码里一眼看出来的,而是从队列积压、耗时分布、拒绝次数和 GC 压力这些运行信号里浮现出来的。

这一篇真正想帮你建立的判断习惯

当你下次遇到并发问题时,不妨按这个顺序问自己:

1. 这是可见性、原子性还是资源竞争问题?2. 当前共享状态能不能减少,而不是直接同步?3. 如果必须同步,应该用锁、原子类还是并发容器?4. 如果要异步化,异常、超时、线程池归属有没有设计清楚?5. 线程池是否有隔离、指标和退化策略?

只要这套判断顺序稳定下来,并发进阶就不再是一些零碎 API 的集合,而会变成你构建稳定系统时的基本能力。

总结

Java 并发进阶真正要学会的,不是背更多类名,而是把共享变量、同步器、异步编排和资源治理放到一个体系里看。volatile 解决的是可见性边界,AQS 解释了很多同步工具的共同底座,CompletableFuture 负责组织复杂异步流程,而线程池治理则决定这些流程在生产环境里能不能稳定运行。只要你能从“运行机制 + 资源治理”的视角来看并发,这一块能力才算真正开始落地。