第 11 章 并发工具箱:线程池、CompletableFuture 与并发容器(goroutine 的 Java 替身们)

你在 Go 里写并发从来不眨眼:go handle(req) 然后该干嘛干嘛。接手 Java 服务后,你也想这么干——于是写了个 new Thread(() -> handle(req)).start() 放在接口里,跑压测时 OOM 了。同事说”你该用线程池”。你写 Executors.newFixedThreadPool(50),压测还是崩——但这次不是 OOM,是吞吐量卡死在 250 QPS。这一章解开这两道封印:线程池怎么配才不会错,以及 Java 里”goroutine + channel”组合的那些替身。


11.1 线程池:为什么需要它(呼应 00 章)

第一道封印你已经在前面的章节撞过了:Java 的 Thread 是 1:1 的 OS 线程,每来一个任务 new 一个线程,一万并发直接 OOM。第 00 章和第 10 章都把根因钉死了——线程贵,是 OS 资源,不是 runtime 资源。

问题 1: 既然线程这么贵,Java 为什么不学 Go 搞一套用户态调度,让 goroutine 那种”随便开”的写法也能成立?

答案是:Java 21 才给出标准答案(虚拟线程,第 12 章讲)。在那之前,Java 生态的通用解法就一个词——池化。

线程池的核心思想就一句话:预先创建固定数量的线程,任务来了不新建线程,而是排进队列等空闲线程来消费。 用 Go 的话说,这是”复用一组 worker”。这个思想和数据库连接池、goroutine 复用是同一类——把昂贵的资源预先建好、循环使用,避免反复创建销毁的开销。

对比一下两种写法:

// 错误:每来一个请求 new 一个线程,1 万个请求 = 1 万个 OS 线程 → OOM
new Thread(() -> handle(req)).start();
 
// 正确:任务交给线程池,50 个线程慢慢消费
executor.submit(() -> handle(req));

handle(req) 里的开销从”创建/销毁一个线程”变成了”把一个 Runnable 塞进队列”。前者是毫秒级 syscall,后者是微秒级内存操作。差距是一个数量级往上。

第一层收益:省掉线程创建/销毁的开销。 JVM 每创建一个 Thread 都要向 OS 申请内核线程、分配栈、建线程元数据;销毁要回收。池化之后这些只发生一次。

第二层收益:内存可控。 一个线程默认 1MB 栈(-Xss1m,虚拟地址,但线程对象 + 内核结构实打实占着)。100 个线程顶多 100MB 预留,1 万个线程就是 10GB 预留,直接把堆外内存挤爆。池化把线程数锁死在一个你能算出来的数字上。

第三层收益:背压的入口。 队列满了怎么办?拒绝策略怎么选?这些”限流”的能力,恰恰来自线程池——它是 Java 并发里最基础的”并发度闸门”。没有它,你的服务要么被无脑新建的线程拖垮,要么被无限堆积的任务拖垮。

从 Go 的视角看,线程池解决的是”Java 没有廉价 goroutine”这个问题。Go 里你几乎不需要线程池,因为 goroutine 极廉价(2KB 栈 + 用户态调度,阻塞了 runtime 自动让出 P)。Java 里线程贵,所以并发的”单位”从”一个任务一个执行流”退化成”一个任务 = 队列里的一个待办项,由少数线程轮转执行”。这是两种范式的根本差异:Go 的”并发”是”开 goroutine”,Java 的”并发”是”提交任务给线程池”。

记住这一节的第一句,后面所有参数配置你都会懂:线程是稀缺资源,线程池是管理稀缺资源的闸门。


11.2 ThreadPoolExecutor:七个参数决定一切

Executors 工厂方法的底层都是 ThreadPoolExecutor。想配对,必须懂它的七个参数。先给准确签名——这很重要,因为网上的”七个参数”说法经常被简化错:

public ThreadPoolExecutor(
    int corePoolSize,                   // 核心线程数(常驻)
    int maximumPoolSize,                // 最大线程数
    long keepAliveTime,                 // 非核心线程空闲存活时间
    TimeUnit unit,                      // keepAliveTime 的单位
    BlockingQueue<Runnable> workQueue,  // 任务队列
    ThreadFactory threadFactory,        // 线程创建工厂
    RejectedExecutionHandler handler)   // 拒绝策略

构造器就是这七个。还有第八个”隐藏开关” allowCoreThreadTimeOut(boolean),它是方法不是构造参数——作用是让核心线程在空闲超过 keepAliveTime 后也被回收(默认核心线程永不回收)。我把它放进七个参数的语境里一起讲,因为它直接影响”常驻线程到底驻不驻”。

逐个拆,每个都配真实场景:

① corePoolSize:核心线程数。 线程池创建后,提交任务时如果当前线程数 < corePoolSize,会立即新建一个线程执行,即使现在已经有空闲的核心线程(除非开了 allowCoreThreadTimeOut)。核心线程默认常驻不销毁——像饭店的固定员工,不管有没有客人都在。

② maximumPoolSize:最大线程数。 线程数的天花板。当队列满了、核心线程又都在忙,会新建”非核心线程”直到达到这个数。超过就触发拒绝策略。

③ keepAliveTime + unit:非核心线程的空闲存活时间。 非核心线程(超过 corePoolSize 的那部分)空闲超过这个时间就被回收。核心线程不受影响(除非 allowCoreThreadTimeOut(true))。饭店旺季雇的临时工,淡季闲着超过 N 天就辞退。

④ workQueue:任务队列(BlockingQueue)。 核心线程全忙时,新任务进队列等。这是整章最容易被配错的地方,11.4 单独讲。

⑤ threadFactory:线程创建工厂。 最容易被忽略,但排错时能救命——给线程起名字。默认工厂创建的线程叫 pool-1-thread-1,线上出事你 jstack 一看,一堆 pool-N-thread-M,根本分不清是哪个业务池的。

ThreadFactory tf = new ThreadFactory() {
    private final AtomicInteger seq = new AtomicInteger();
    public Thread newThread(Runnable r) {
        Thread t = new Thread(r, "order-pool-" + seq.incrementAndGet()); // 有名字,好排查
        t.setDaemon(false);
        return t;
    }
};
// 更省事:用 Guava 的 ThreadFactoryBuilder 或 JDK 的 Thread.ofPlatform().name(...).factory()

⑥ handler:拒绝策略。 队列满且线程数到 max 时,新任务怎么处理?四种,见下文。

⑦ allowCoreThreadTimeOut(方法开关):核心线程是否也超时回收。 默认 false——核心线程永不销毁,哪怕闲一整天。设 true 后,连核心线程空久了也回收,适合流量极度波峰波谷、且想省内存的场景。代价是波峰来时要重新建线程(有冷启动开销)。

任务提交的完整流程(必背)

这是面试和实战都高频的逻辑,用一张图钉死:

提交任务 T
├─ 当前线程数 < corePoolSize?
│     └─ 是 → 立即创建核心线程执行 T
├─ 否则,workQueue 没满?
│     └─ 是 → T 入队等待
├─ 否则,当前线程数 < maximumPoolSize?
│     └─ 是 → 创建非核心线程执行 T
└─ 否则 → 触发拒绝策略 handler

走查一遍(core=10,max=20,队列容量=30):

  • 任务 1~10 来:线程数 < 10,立即建 10 个核心线程执行。
  • 任务 11~40 来:核心线程全忙,队列没满(容量 30),进队列等待。队列里有 30 个。
  • 任务 41~50 来:队列满了(30),线程数 10 < 20,建 10 个非核心线程执行(总数到 20)。
  • 任务 51 来:队列满、线程数 = max=20,触发拒绝策略。

注意这个拐点:前 40 个任务里,只有 10 个在跑,30 个在排队——队列容量直接决定了”能缓冲多少突发”。如果下游是阻塞 5 秒的调用,这 30 个排队任务要 15 秒才消化完(10 线程 × 5s / 30 ≈),期间再来任务就拒绝。所以队列容量不是随便填的,它等于”你愿意为突发流量容忍多少秒的积压”。想缓冲更多就把容量调大,但调太大又回到 OOM 风险——这是整个配置里最吃直觉的旋钮。

【思考】这个流程里最反直觉的是什么?

参考答案

直接答案:队列满了之后才会创建非核心线程。

绝大多数人(包括很多写了几年 Java 的人)的直觉是:“核心线程忙不过来,就加线程到 maxPoolSize,最后才排队。” 错。真实顺序是先排队、后加线程。

为什么这个顺序反直觉却合理: 设计者的假设是”核心线程数已经能满足常规负载,突发任务应该排队而不是直接扩线程”——扩线程(新建 OS 线程)是更重的操作,能排队就排队。

这带来一个致命的配置陷阱: 如果你把 workQueue 设成一个无界队列(比如 LinkedBlockingQueue 不传 capacity,默认 Integer.MAX_VALUE),那么队列永远不满,流程永远走不到”创建非核心线程”这一步。结果就是:

  • maximumPoolSize 成了摆设,永远不会生效
  • 核心线程永远只有 corePoolSize 个
  • 所有超出核心线程处理能力的任务无限堆积在队列里 → 内存一路涨 → 最终 OOM
// Executors.newFixedThreadPool(50) 的真实构造,队列是无界的!
new ThreadPoolExecutor(50, 50, 0L, TimeUnit.MILLISECONDS,
    new LinkedBlockingQueue<>());  // 容量 Integer.MAX_VALUE,永不触发拒绝,也不会扩线程

更深一层:这个”先排队后扩线程”的顺序,解释了为什么线程池配错八成是队列的锅(11.4 展开)。想让 maxPoolSize 真正起作用,队列必须有界——队列满了,才会溢出到非核心线程。这跟 Go 的思路刚好相反:Go 里你用带缓冲 channel 做信号量时,缓冲满了 send 直接阻塞或走 select 的 default,是”满了就挡住调用方”,而不是”满了就悄悄建更多 goroutine”。Java 这里是”满了就扩线程”,扩到天花板才拒绝。两种哲学的限流点不同。

代码锚点(一个会悄悄 OOM 的配置):

ExecutorService pool = new ThreadPoolExecutor(
    50, 200, 60L, TimeUnit.SECONDS,
    new LinkedBlockingQueue<>());  // 错误:无界队列,max=200 永不生效,队列涨到 OOM

四种拒绝策略

队列满、线程也到 max 时,第七个参数 handler 决定命运。四种内置实现:

策略行为适用
AbortPolicy(默认)直接抛 RejectedExecutionException要显式失败暴露问题,但接口会 500
CallerRunsPolicy让提交任务的线程自己执行这个任务生产环境最常用,自带反压
DiscardPolicy静默丢弃,不抛异常可丢的任务(如埋点),但会丢数据
DiscardOldestPolicy丢弃队列里最老的任务,重试提交当前任务抢最新数据,如行情快照

【思考】CallerRunsPolicy 为什么在生产环境最友好?

参考答案

直接答案:因为它让”提交任务的线程自己跑这个任务”,从而天然形成背压(back-pressure),既不会丢数据,也不会让接口直接 500。

展开讲 AbortPolicy 的问题: 默认抛 RejectedExecutionException。如果提交任务的是 Tomcat 的 worker 线程(处理 HTTP 请求的线程),异常会顺着请求链路冒泡,最终变成接口 500。高并发下,线程池一旦被打满,大量请求一起 500——雪崩。

CallerRunsPolicy 的精妙处: 它的逻辑是”既然池子满了、队列也满了,那提交任务的人你自己把这个任务干了”。在 Web 服务里,“提交任务的人”通常就是 Tomcat 的 worker 线程。于是:

  • 这个 Tomcat 线程不再接收新请求(它正在同步执行那个被拒的任务)
  • 它占着,Tomcat 的 acceptor 能接收的连接数就少一个
  • 连接堆积到一定程度,Tomcat 也接不动了,最前端的客户端开始排队/超时

这形成了一条从线程池→Web 容器→客户端的完整反压链。 系统不是”崩溃”,而是” gracefully 降级”——吞吐下降,但不丢数据、不狂报 500、不 OOM。

真实场景: 订单服务用线程池并发调三个下游。某天下游某个服务慢了,线程池被打满,队列也满。用 AbortPolicy → 接口大面积 500,运维被报警轰炸,但下游恢复后系统立刻恢复正常(只是期间失败了)。用 CallerRunsPolicy → 接口响应变慢(因为 Tomcat 线程被借去跑任务),但很少 500,下游恢复后吞吐自己爬回来。大多数业务宁愿”慢”也不愿”失败”,所以 CallerRunsPolicy 是默认首选。

更深一层:这其实是”限流该发生在哪一层”的设计问题。Go 里你用带缓冲 channel 做信号量,缓冲满时 select { case sem <- struct{}{}: ... default: 限流 } 也是把背压显式表达出来。Java 的 CallerRunsPolicy 把背压”藏”在拒绝策略里,调用方不知不觉就被限速了——更省心,但需要你理解它在干嘛,否则看到响应变慢会一脸懵。

// 生产推荐:CallerRunsPolicy + 有界队列 + 命名线程
ExecutorService pool = new ThreadPoolExecutor(
    core, max, 60L, TimeUnit.SECONDS,
    new ArrayBlockingQueue<>(capacity),
    namedFactory,
    new ThreadPoolExecutor.CallerRunsPolicy());  // 满了 caller 自己跑,天然限流

11.3 Executors 工厂方法:四个方法,三个是坑

Executors 类给了几个”一行创建”的快捷方法。方便,但《阿里巴巴 Java 开发手册》明确禁止用它创建线程池——必须手动 new ThreadPoolExecutor。为什么?因为前三个方法都把参数藏成了陷阱。

逐一拆:

① newFixedThreadPool(n):坑(队列无界)

public static ExecutorService newFixedThreadPool(int nThreads) {
    return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS,
        new LinkedBlockingQueue<>());  // 容量 Integer.MAX_VALUE,无界!
}

core = max = n,队列无界。任务积压时无限堆积 → OOM。第 00 章那个”50 线程吞吐卡死 250 QPS”的案例,如果下游更慢、流量更大,就会从”吞吐卡死”升级成”队列暴涨 OOM”。

② newSingleThreadExecutor():坑(队列无界)

// core = max = 1,队列还是无界
new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>())

只有一个线程,队列无界。一个任务阻塞,后面全排队;任务持续涌入 → OOM。而且单线程意味着”串行”,你以为自己并发了其实没并发。

③ newCachedThreadPool():坑(线程数无上限)

public static ExecutorService newCachedThreadPool() {
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS,
        new SynchronousQueue<>());  // 容量 0,来了就交,没线程就建
}

核心 0、最大 Integer.MAX_VALUE、队列 SynchronousQueue(容量 0,不存储任务,直接交接)。结果:每来一个任务,没空闲线程就建一个新线程,线程数直奔 Integer.MAX_VALUE。短时间高并发 → 几千几万个线程 → OOM 或上下文切换雪崩。

④ newScheduledThreadPool(n):相对安全(定时任务专用)

用于周期任务/延迟任务,底层 DelayedWorkQueue。相对安全是因为它的语义就是”定时”,但队列同样无界——如果任务提交速度长期超过执行速度,也会堆积。能用,但要监控队列长度。

结论(必须醒目)

《阿里巴巴 Java 开发手册》禁止用 Executors 创建线程池,必须手动 new ThreadPoolExecutor 并显式指定队列容量和拒绝策略。 原因就是上面三个坑:无界队列 OOM、无上限线程数 OOM、单线程假并发。手工创建让你被迫面对每一个参数,把”隐藏的炸弹”变成”显式的旋钮”。

生产可用配置模板

int core = Runtime.getRuntime().availableProcessors();  // CPU 核数
ExecutorService bizPool = new ThreadPoolExecutor(
    core,                      // corePoolSize:常驻核数
    core * 2,                  // maximumPoolSize:突发可扩到 2 倍
    60L, TimeUnit.SECONDS,    // 非核心线程空闲 60s 回收
    new ArrayBlockingQueue<>(1000),  // 有界队列,满了才扩线程、再满才拒绝
    new ThreadFactory() {      // 命名线程,排错能认出
        private final AtomicInteger seq = new AtomicInteger();
        public Thread newThread(Runnable r) {
            return new Thread(r, "biz-pool-" + seq.incrementAndGet());
        }
    },
    new ThreadPoolExecutor.CallerRunsPolicy());  // 满了 caller 自己跑,天然限流

配好之后还得能看见它。 ThreadPoolExecutor 自带几个监控方法,是排错和容量评估的眼睛,别等 OOM 了才想起看:

ThreadPoolExecutor tp = (ThreadPoolExecutor) bizPool;
int active = tp.getActiveCount();          // 正在干活的线程数
int poolSize = tp.getPoolSize();           // 当前线程总数(含空闲)
int queueSize = tp.getQueue().size();      // 队列积压长度(最关键的预警指标)
long completed = tp.getCompletedTaskCount(); // 累计完成任务数

线上要把 queueSize 和 active / maximumPoolSize 的比值做成监控告警:队列持续逼近 capacity,说明消费速度跟不上提交速度(配小了或下游慢了);拒绝次数(getRejectedExecutionCount())大于 0,说明已经触发限流。这跟 Go 里你用 expvar 暴露信号量当前占用数是一个思路——并发资源必须可观测,否则就是黑盒。

【思考】IO 密集型和 CPU 密集型,corePoolSize 怎么设?

参考答案

直接答案:CPU 密集型 ≈ CPU 核数;IO 密集型可以远大于核数(因为线程大部分时间在等 IO,不占 CPU)。但任何公式都只是起点,最终必须靠压测调。

CPU 密集型(计算、压缩、加解密): 线程一直在用 CPU,多于核数的线程只会互相抢 CPU、增加上下文切换。所以 corePoolSize ≈ 核数(或 核数 + 1 留一点余量)。比如 8 核机器配 8~9。

IO 密集型(调下游 HTTP/DB/Redis、读文件): 线程大部分时间阻塞在等网络/磁盘响应(不占 CPU),此时那核 CPU 是空闲的,可以让别的线程上。所以核心线程数可以远大于核数。估算公式(经典经验值):

corePoolSize ≈ 核数 × (1 + 平均等待时间 / 平均计算时间)

例:8 核,单次任务计算 10ms、等 IO 90ms → 8 × (1 + 90/10) = 8 × 10 = 80。也就是配 80 个线程能把 CPU 喂满。

更实在的推算(承接 11.9 题 1): 用”需要的并发数 = QPS × 单请求耗时”反推。服务 QPS 1000,每个请求并发调 3 个下游、每个平均 100ms,那需要的并发 ≈ 1000 × 0.1 = 100,考虑波动配 150~200。

为什么强调压测: 公式给的是”理想上限”,真实系统有连接池上限、下游吞吐量上限、GC 抖动。配大了线程空转浪费内存、抢连接池;配小了吞吐上不去。所以正确做法是:先用公式给初值,再压测调——看 CPU 利用率(IO 型应该接近打满)、看队列长度(持续逼近 capacity 说明配小了)、看拒绝次数(>0 说明配小了或下游真扛不住)。

Go 对照:Go 不需要操心这个,因为 goroutine 廉价、阻塞被 runtime 接管。但 Go 里如果你要”限制并发度”,你会用 semaphore.Weighted(信号量)或 errgroup + 带缓冲 channel,本质是给并发数设一个硬上限。这跟 Java 线程池的 corePoolSize 是同一个”限流闸门”概念,只是形态不同:Go 是信号量计数,Java 是”线程数 + 有界队列 + 拒绝策略”三件套。

// Go:限制最多 N 个并发
sem := semaphore.NewWeighted(int64(N))
g, ctx := errgroup.WithContext(ctx)
for _, item := range items {
    sem.Acquire(ctx, 1)
    g.Go(func() error {
        defer sem.Release(1)
        return doWork(item)
    })
}
g.Wait()

11.4 阻塞队列选型:为什么线程池配错八成是队列的锅

回到 11.2 那个反直觉的发现:队列满了才创建非核心线程。所以队列选什么,直接决定 maxPoolSize 会不会生效、OOM 会不会来。一张表看全:

队列有界?特点线程池场景
LinkedBlockingQueue默认无界(可传 capacity 变有界)链表,可选有界构造FixedThreadPool 的坑源
ArrayBlockingQueue有界(必须指定容量)数组,固定容量推荐,行为可预测
SynchronousQueue容量 0不存储,直接交接CachedThreadPool(来了就交,没线程就建)
PriorityBlockingQueue无界优先级排序定时/优先级任务
DelayedWorkQueue无界按延迟时间出队ScheduledThreadPool
LinkedTransferQueue无界更高吞吐的 transfer 语义特殊场景

【思考】为什么 ArrayBlockingQueue 比 LinkedBlockingQueue 更适合做线程池队列?

参考答案

直接答案:三点——有界防 OOM、数组比链表缓存局部性好、明确容量让行为可预测。

① 有界,防 OOM(最关键)。 ArrayBlockingQueue 构造必须传 capacity,容量写死,不可能无限增长。LinkedBlockingQueue 默认 Integer.MAX_VALUE 无界——你忘了传 capacity,就是埋了个 OOM 雷。newFixedThreadPool 用的就是默认无界的 LinkedBlockingQueue,这是它被列为”坑”的直接原因。想让 LinkedBlockingQueue 有界,必须显式 new LinkedBlockingQueue<>(capacity),很多人会忘。

② 数组比链表缓存局部性好。 ArrayBlockingQueue 底层是循环数组,节点在连续内存;LinkedBlockingQueue 底层是链表,节点分散在堆上。前者遍历/入队出队对 CPU 缓存更友好,高并发下吞吐略高、GC 压力更小(没有一个个 Node 对象)。

③ 行为可预测。 数组容量固定,你一眼能算出”队列最多积压多少任务”,配合监控能设报警阈值。链表无界时,你根本不知道队列会涨到多大——直到 OOM 才知道。

什么时候用 LinkedBlockingQueue? 几乎只有一种情况:你真的需要无界且接受”靠拒绝策略兜底”的语义(比如配合 SynchronousQueue 不合适、又要高吞吐的单生产者单消费者场景)。但线程池场景下,无界本身就是风险,所以默认选 ArrayBlockingQueue。

更深一层:队列选型本质是”限流发生在哪”的决策。ArrayBlockingQueue(capacity) 把限流点设在”队列容量”——满了就触发拒绝/扩线程,系统行为可预测;无界队列把限流点推迟到”内存耗尽”,那是灾难级的兜底。Go 里你用带缓冲 channel 也会显式写 make(chan T, N),没人写无缓冲还指望它”自动扩容”——Java 这边同理,capacity 必须显式、必须有数。

// 推荐:有界、容量明确、行为可预测
new ArrayBlockingQueue<>(1000);

11.5 CompletableFuture:Java 版”goroutine + channel + errgroup”

第 00 章说过,goroutine 是”开 goroutine 等结果”。Java 8 之前对应的是 Future——但它有硬伤:future.get() 是阻塞的,无法组合、无法回调、无法异常恢复。你要等三个下游结果,只能一个个 get() 串行等,或者自己起线程 + 共享变量,丑且易错。

CompletableFuture(Java 8)是 Future 的升级版,实现了 CompletionStage 接口,支持链式组合和回调。它干的事,本质是 Go 里 goroutine + channel + errgroup 那一套。

核心能力逐一对照

创建异步任务:

// 不传 executor → 用 ForkJoinPool.commonPool()(坑,见后)
CompletableFuture<User> f = CompletableFuture.supplyAsync(() -> fetchUser(id));
// 传自己的 executor(生产必须)
CompletableFuture<User> f2 = CompletableFuture.supplyAsync(() -> fetchUser(id), bizPool);

Go 对照:go func() { ... }() 开一个 goroutine。

链式转换(map / 消费 / flatMap):

CompletableFuture<Integer> len = f.thenApply(u -> u.getName().length());   // map
f.thenAccept(u -> log.info("got {}", u));                                  // 消费,无返回
f.thenCompose(u -> fetchOrder(u.getId()));                                 // flatMap,避免嵌套 Future

thenCompose 对应 Go 里”在 goroutine 里再开一个 goroutine 并把结果串起来”——它把 CompletableFuture<CompletableFuture<T>> 拍平成 CompletableFuture<T>。

组合(等全部 / 等任意一个 / 两个都完成):

CompletableFuture<Void> all = CompletableFuture.allOf(f1, f2, f3);   // 等全部完成
CompletableFuture<Object> any = CompletableFuture.anyOf(f1, f2);    // 等任意一个完成
CompletableFuture<Result> combined = f1.thenCombine(f2, (a, b) -> merge(a, b));  // 两个都完成再合并

Go 对照:allOf ≈ errgroup.Wait();anyOf ≈ select 里第一个到的 case。

异常处理:

f.exceptionally(ex -> fallbackUser());                  // 出错给默认值
f.handle((u, ex) -> ex != null ? fallbackUser() : u);  // 无论成败都处理

Go 对照:if err != nil { ... }。

回调在哪个线程跑(高频疑问): thenApply / thenAccept / thenCompose 这些回调,默认在”完成前一个 future 的那个线程”上执行——如果前一个 future 是 supplyAsync(() -> ..., bizPool) 完成的,回调就跑在 bizPool 的某条线程上;如果前一个 future 是你在主线程手动 complete(value) 的,回调就跑在主线程。这跟 Go 不同:Go 里 goroutine 的后续逻辑永远在那个 goroutine 里继续跑,执行线程归属清晰。Java 这里因为 future 可以被任意线程完成,回调线程归属就”跟完成者走”,容易让你误以为”回调一定在业务池里”。如果你要求回调必须在特定池执行,用 thenApplyAsync(fn, executor) 显式指定,别依赖默认行为。

超时(Java 9+):

f.orTimeout(500, TimeUnit.MILLISECONDS);                  // 超时抛 TimeoutException
f.completeOnTimeout(defaultUser, 500, TimeUnit.MILLISECONDS);  // 超时给默认值

完整实战:一个接口并发调三个下游

CompletableFuture<User> f1 = getUserAsync(id, bizPool);
CompletableFuture<Order> f2 = getOrderAsync(id, bizPool);
CompletableFuture<Score> f3 = getScoreAsync(id, bizPool);
 
// 三个都完成后组装结果
CompletableFuture<Void> all = CompletableFuture.allOf(f1, f2, f3);
CompletableFuture<Result> result = all.thenApply(v ->
    assemble(f1.join(), f2.join(), f3.join()));  // join() 不抛受检异常
 
Result r = result.join();  // 或 result.get(),见下方【思考】

对比 Go 写法(并排):

// Go:errgroup 并发调三个 + 等全部
g, ctx := errgroup.WithContext(ctx)
var user User
var order Order
var score Score
g.Go(func() error { var err error; user, err = getUser(ctx, id); return err })
g.Go(func() error { var err error; order, err = getOrder(ctx, id); return err })
g.Go(func() error { var err error; score, err = getScore(ctx, id); return err })
if err := g.Wait(); err != nil { return err }
return assemble(user, order, score)

关键差异(真实坑): Go 的 errgroup.WithContext 在任意一个 goroutine 失败时会 cancel 掉 ctx,其他 goroutine 检测到 ctx 取消就提前退出——自动取消。而 CompletableFuture.allOf 默认不取消其他任务:一个失败,另外两个还在跑(可能还在占用下游连接)。要手动取消,见 11.9 题 4。

【思考】CompletableFuture 的 join() 和 get() 有什么区别?

参考答案

直接答案:join() 不抛受检异常(只抛 CompletionException 运行时异常),get() 抛受检的 InterruptedException 和 ExecutionException。在 lambda 里你没法 throw 受检异常,所以用 join()。

逐条对比:

get()join()
抛出异常InterruptedException、ExecutionException(都是受检)CompletionException(运行时,包着真正的异常)
在 lambda / 流式代码里必须 try-catch 或声明 throws,写不了直接写,不报错
中断(Thread.interrupt())抛 InterruptedException抛 CompletionException 包 CancellationException

为什么这点重要: 你经常把 CompletableFuture 组合塞进 thenApply/thenCompose 这类 lambda 里。lambda 的方法签名(比如 Function.apply)没有 throws,所以你在里面写 future.get() 编译器直接报错——除非你包一层 try-catch。而 join() 不抛受检异常,写出来干净。

join() 也会被中断吗? 会。如果等待期间线程被 interrupt(),join() 抛 CompletionException,里面包着 CancellationException(不是 InterruptedException)。区别只是:你 catch 的是运行时异常,且真实原因藏在 getCause() 里。

一个常见误解: 有人以为 join() 比 get() “更安全”。不是。join() 只是把受检异常变成运行时异常方便写,它该阻塞还是阻塞、该抛还是抛。阻塞语义两者完全一样——调用线程一直等到结果(或超时/异常)。所以”要不要阻塞”是另一回事,跟选 join 还是 get 无关。

// lambda 里只能写 join()
CompletableFuture<Result> r = f1.thenCompose(u ->
    fetchDetail(u.id).join());   // 若写 .get() 编译不过
 
// 普通方法里两者都行,get() 让你显式处理中断
try {
    Result r = f1.get(500, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();  // 恢复中断标志
} catch (ExecutionException e) {
    log.error("任务失败", e.getCause());
}

最大的坑(呼应 01 章 1.10):commonPool 被阻塞占满

supplyAsync(() -> ...) 不传 executor 时,用的是 ForkJoinPool.commonPool(),默认线程数 = Runtime.getRuntime().availableProcessors() - 1(通常是 7 个左右)。

事故案例: 服务里到处用 CompletableFuture.supplyAsync(blockingIO)(默认 commonPool),某天一个慢 SQL 把 commonPool 的 7 个线程全占满。于是所有用 commonPool 的异步逻辑——包括框架自己的、parallelStream 的、别的业务的——全部排队卡死。整个服务像被点了穴。

根因: commonPool 是 JVM 级全局共享池。parallelStream(第 01 章 1.10 讲过)和 CompletableFuture 默认都吃它。一个地方阻塞,全 JVM 的异步逻辑陪葬。

修复: 永远显式传自己的 Executor。

// 错误:吃全局 commonPool,拖垮所有用它的代码
CompletableFuture.supplyAsync(() -> slowBlockingCall());
 
// 正确:用独立业务池,故障隔离
CompletableFuture.supplyAsync(() -> slowBlockingCall(), bizPool);

【思考】什么时候用 CompletableFuture,什么时候用虚拟线程(第 12 章)?

参考答案

直接答案:Java 21+ 的虚拟线程让”顺序写异步代码”成为可能,很多 CompletableFuture 的场景可以用虚拟线程替代(代码更直白、像写同步代码)。但 CompletableFuture 仍有不可替代的价值:需要细粒度的组合/超时/取消控制、需要和非虚拟线程的代码交互时。

虚拟线程适合的场景: 你只是想”别让阻塞 IO 占着 OS 线程”。把 executor 换成虚拟线程的 Executors.newVirtualThreadPerTaskExecutor(),原来的同步阻塞代码(user = getUser(id); order = getOrder(id);)直接顺序写,JVM 在阻塞时自动让出载体线程。代码从”回调地狱”变回”直白的顺序代码”,可读性暴涨。

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    Future<User> u = executor.submit(() -> getUser(id));
    Future<Order> o = executor.submit(() -> getOrder(id));
    return assemble(u.get(), o.get());  // 顺序写,但并发执行,阻塞自动让出
}

CompletableFuture 仍不可替代的场景:

  1. 细粒度组合:thenCombine/thenCompose/anyOf 这种”两个结果合并""二选一”的编排,虚拟线程没有一等公民语法(得自己用 channel 思想或 Future 拼)。
  2. 超时/取消精细化:orTimeout/completeOnTimeout/cancel(true) 是现成的;虚拟线程要取消得靠 Thread.interrupt() + 业务检查 Thread.currentThread().isInterrupted()。
  3. 与非虚拟线程互操作:老框架、老库跑在平台线程上,你要把异步结果喂给它,CompletableFuture 的 thenApply 回调更自然。
  4. 纯 CPU 计算流水线:CompletionStage 的链式转换对纯内存计算很顺手。

选择框架(实用):

  • 代码目的是”并发发 IO 请求、最后汇总” → 优先虚拟线程(Java 21+),代码最干净。
  • 代码目的是”对多个异步结果做复杂编排、要超时和取消” → 用 CompletableFuture。
  • 还在 Java 8/11 老项目 → 没有虚拟线程,只能用 CompletableFuture(或 RxJava/WebFlux)。

更深一层:虚拟线程和 CompletableFuture 不是替代关系,是”不同抽象层”。虚拟线程解决”阻塞不浪费 OS 线程”,CompletableFuture 解决”异步结果的组合”。虚拟线程让”并发发请求”变简单,但”把多个请求结果拼起来”的编排逻辑,CompletableFuture 表达得更直接。第 12 章会详细讲虚拟线程的边界——它也不是银弹。


11.6 并发容器:HashMap 在并发下会怎样

你是 Go 程序员,知道 map 并发写会 panic(“fatal error: concurrent map writes”)。Java 这边,HashMap 并发写不会 panic——它更隐蔽,是数据错乱甚至死循环。两种语言对待并发错误的哲学完全不同:Go fail fast(直接崩,你立刻知道),Java fail silent(静默错,你后知后觉)。

血泪史:HashMap 的并发之殇

JDK 7:并发 put 且触发扩容时,采用头插法转移链表,多线程交错会导致链表成环 → 后续 get 在这个桶上死循环,CPU 100%,服务假死。这是当年无数次线上事故的根源。

JDK 8:改成尾插法,解决了死循环。但并发 put 仍有数据丢失(两个线程同时算到同一个桶、同时写入,后者覆盖前者)和 size 不准。

关键认知:HashMap 永远不要用在并发写场景。 哪怕”只是读多写少”,只要有一个写路径没加锁,就可能出事。

并发容器选型表

非线程安全并发版原理
HashMapConcurrentHashMapJDK7 分段锁 / JDK8 CAS + 桶级 synchronized
ArrayListCopyOnWriteArrayList写时复制整个数组
HashSetCopyOnWriteArraySet / ConcurrentHashMap.newKeySet()同上
TreeMapConcurrentSkipListMap跳表(有序)
LinkedBlockingQueue 等本身就是并发安全内置锁/ CAS
Collections.synchronizedMapConcurrentHashMap整表锁 vs 细粒度锁

ConcurrentHashMap 的关键点(极实用)

① get() 无锁。 靠 volatile 读保证可见性,读操作不加锁,高并发读极快。put() 只锁当前桶(JDK 8 用 synchronized 锁单个桶——别惊讶,JVM 早把 synchronized 优化到很轻,锁单桶几乎无竞争)。

② JDK 7 vs JDK 8 原理差异(面试高频):

  • JDK 7:默认 16 个 Segment,每个 Segment 是一把独立的 ReentrantLock,不同 Segment 并发写互不阻塞(分段锁)。
  • JDK 8:取消 Segment,直接对**数组的每个桶(bucket)**用 CAS + synchronized 加锁。锁粒度从”段”细化到”桶”,并发度更高,且省掉了 Segment 的内存开销。

③ size() 是 O(n) 近似。 高并发下 size() 要遍历所有桶求和,且求和过程中还在变,所以返回的是近似值。要精确用 mappingCount()(也是近似,但语义更明确:返回 long,不会溢出 int)。

④ computeIfAbsent 的坑: JDK 8 里在 computeIfAbsent 的 lambda 里再调 computeIfAbsent(对同一 map)可能递归加锁 → 死锁。JDK 9 修复了部分场景,但不要在里面做复杂嵌套调用仍是铁律。

⑥ 迭代器是弱一致(weakly consistent)的。 遍历 ConcurrentHashMap 时,迭代器反映的是”遍历开始时的快照 + 遍历期间已完成的修改”,不会抛 ConcurrentModificationException,但可能看不到遍历过程中新插入的条目、也可能看到被删除的条目。所以不能依赖遍历时的 size 精确,也别在遍历里做”强一致”的业务判断。Go 的 sync.Map 遍历(Range)同样不保证看到最新写入,两语言在这点上一脉相承——并发容器的遍历天然是近似的。

⑦ 批量复合操作不是原子的。 putAll、clear 整体不是原子(虽然单个 put 是)。需要”检查再更新”的复合语义用 compute/merge(内部带锁),别自己 if (!contains) put。

⑧ 不支持 null key/value(跟 HashMap 不同!)

【思考】为什么 ConcurrentHashMap 不支持 null?

参考答案

直接答案:并发下 get(key) 返回 null 无法区分”key 不存在”还是”value 就是 null”,这个歧义在并发语义下无法用”特殊值”解决——不像单线程里你可以用 containsKey 再 get。这是 Doug Lea 的明确设计决定。

展开歧义: 在单线程里,map.get(k) 返回 null,你可以接着 map.containsKey(k) 判断到底是”没这个 key”还是”value 是 null”。但并发下这套两步法失效——containsKey 和 get 之间,另一个线程可能刚 put(k, null) 或 remove(k),你拿到的两个结果不一致,判断无意义。

HashMap 为什么允许 null? 单线程语义下歧义可解,null 作为 value 是合法需求(比如”缓存了,但值是空”)。Doug Lea 认为并发集合里这个便利不值得用”返回 null 的二义性”去换,于是直接禁止:你 put(k, null) 抛 NullPointerException,从根上消灭歧义。

更深一层:这反映了并发 API 的设计取舍——宁可编译期/运行期报错,也不引入并发下无法无歧义解释的行为。Go 的 sync.Map 没有直接禁 null,但 Go 里 nil 语义相对温和,且 Go 社区习惯用 ok 双返回值(v, ok := m.Load(k))明确区分”没找到”和”找到但值是 nil”——这跟 containsKey + get 的两步法异曲同工,只是 Go 用返回值而非两次调用规避了竞态。ConcurrentHashMap 选择”彻底禁止 null”而非提供 ok 语义,是另一套代价权衡。

代码锚点:

ConcurrentHashMap<String, String> m = new ConcurrentHashMap<>();
m.put("a", null);   // NullPointerException!并发集合不让用 null
// HashMap 里这行是合法的,但并发写会出事——所以并发场景别用 HashMap

CopyOnWriteArrayList:读多写少专用

写时复制整个数组,读无锁。适合”读极多写极少”(如监听器列表、配置白名单)。

坑有两个:

  1. 写开销大:每次 add/set 都复制整个底层数组。写频繁时,复制成本 + GC 压力爆表。
  2. 数据有短暂不一致:读操作拿到的是”开始读时的那个旧副本”,写完成前读不到新数据(最终一致,但非实时)。所以不能用在”写完后必须立刻读到”的场景。

Go 对照:sync.Map vs ConcurrentHashMap

维度Go sync.MapJava ConcurrentHashMap
设计目标读多写少、key 集合稳定通用高并发读写
原理读走无锁快路径(atomic),写走互斥慢路径 + 冗余 mapCAS + 桶锁
并发写错误panic(fail fast)数据错乱/死循环(fail silent)
替代map + sync.RWMutex(更通用)Collections.synchronizedMap(整表锁,慢)

关键对照(哲学差异): Go 的 map 并发写直接 panic(fatal error: concurrent map writes),程序立刻挂——你马上能定位。Java 的 HashMap 并发写是数据错乱或死循环,程序不挂、接口可能还返回、但数据悄悄错了——更难发现。一个 fail fast,一个 fail silent,没有谁绝对更好:Go 让你”早死早超生”,Java 让你”带着病跑”。作为接手 Java 服务的你,要主动用 ConcurrentHashMap 把”静默错”变成”正确”,而不是指望它 panic 提醒你。


11.7 实战:三个真实的并发故障

每个案例按 现象 → 假设 → 验证(工具)→ 根因 → 修复 → 教训。

案例一(线程池配错导致 OOM)

现象: 订单服务压测,流量一上来,几分钟内 OutOfMemoryError: Java heap space,进程死亡。

假设: 任务创建速度远超消费速度,内存被堆积的任务撑爆。

验证: 用 Arthas 连上进程,dashboard 看线程和内存,thread 看线程池线程都在忙,memory 看堆里 LinkedBlockingQueue 的 Node 对象占了大头。再看代码:

ExecutorService pool = Executors.newFixedThreadPool(50);  // 无界队列!

根因: newFixedThreadPool(50) 的队列是无界 LinkedBlockingQueue。下游调用平均阻塞 5 秒,50 个线程吞吐只有 50 / 5 = 10 QPS,但流量是 500 QPS。每秒多出的 490 个任务全堆进队列,队列无上限 → 堆被 Node 对象吃满 → OOM。

修复: 有界队列 + 合理 max + CallerRunsPolicy + 监控。

ExecutorService pool = new ThreadPoolExecutor(
    50, 200, 60L, TimeUnit.SECONDS,
    new ArrayBlockingQueue<>(1000),
    namedFactory,
    new ThreadPoolExecutor.CallerRunsPolicy());

教训: 凡是 Executors 工厂方法创建的池子,先怀疑队列无界。队列必须显式指定容量,并监控 queue.size()。

最后别忘关池。 应用关闭时要优雅停池,否则线程不回收、任务可能丢失。shutdown() 拒绝新任务、等已提交任务跑完;shutdownNow() 尝试中断正在跑的线程并丢弃队列里未开始的。Spring 里用 @PreDestroy 或 Bean 的 destroyMethod 调它,别让池子随进程裸退。

pool.shutdown();                       // 不再收新任务,跑完手里的
if (!pool.awaitTermination(30, SECONDS)) {
    pool.shutdownNow();               // 30 秒还没结束就强停
}

案例二(commonPool 被阻塞占满)

现象: 服务每隔几天”假死”一次,所有异步接口超时,但 CPU 不高、GC 正常、下游也没挂。jstack 看到一堆线程卡在 ForkJoinPool.commonPool() 相关的方法上。

假设: 某个慢操作占满了公共线程池,导致所有依赖 commonPool 的逻辑排队。

验证: Arthas thread 看 commonPool 的 worker 全 RUNNABLE 但停在阻塞 IO(如 socketRead0);全局搜 supplyAsync( 和 parallelStream(,发现大量代码没传 executor。确认某个慢 SQL 路径把 7 个 commonPool 线程占满。

根因: CompletableFuture.supplyAsync(blockingIO) 默认吃 ForkJoinPool.commonPool()(只有 7 个线程)。这个池 JVM 全局共享,慢 SQL 占满后,所有用 commonPool 的异步逻辑(含框架、parallelStream、其他业务)全部排队。

修复: 每个业务独立线程池,绝不依赖 commonPool。

// 所有 supplyAsync 都显式传 executor
CompletableFuture.supplyAsync(() -> queryFromDb(id), dbPool);
// parallelStream 也传:orders.parallelStream().forEach(...) 改成自定义 ForkJoinPool 或顺序 + 线程池

教训: CompletableFuture.supplyAsync 和 parallelStream 共用 commonPool,这是个全局共享的脆弱点。生产代码一律自建 Executor。

案例三(HashMap 并发数据错乱)

现象: 一个本地缓存用 HashMap 存用户权限,高并发下偶尔取到 null 或脏数据,导致误判”无权限”拒绝请求,但重启后正常。

假设: 缓存读到了不一致的状态,可能是并发写 HashMap 导致。

验证: jstack 无死锁(排除 JDK7 死循环),但复现压测时偶发 get 返回 null。代码里 permissions 是 HashMap,多个请求同时 put(刷新缓存)和 get。

根因: HashMap 并发写,两个线程同时算到同一桶并写入,后者覆盖前者 → 数据丢失;或扩容期间读到一个半成品结构 → 偶发 null。JDK 8 已无死循环,但数据丢失依旧。

修复: 换成 ConcurrentHashMap。

private final Map<Long, Permission> permissions = new ConcurrentHashMap<>();

教训: 任何可能被多线程读写(哪怕”只读多写少”)的 Map,用 ConcurrentHashMap。HashMap 是单线程结构,进并发就是埋雷。


11.8 本章核心结论

  1. 线程是昂贵的 OS 资源,线程池是管理它的闸门——核心思想是”复用固定线程 + 任务排队”,跟 goroutine 廉价到随便开的范式根本不同。
  2. 提交流务是”先核心线程、再队列、再非核心线程、最后拒绝”——队列满了才扩线程,所以无界队列会让 maximumPoolSize 永远不生效。
  3. Executors 四个工厂方法三个是坑(无界队列 / 无上限线程 / 单线程),阿里手册禁止用,必须手动 new ThreadPoolExecutor 并指定队列容量 + 拒绝策略。
  4. 拒绝策略首选 CallerRunsPolicy——让提交任务的线程自己跑,天然形成背压,不丢数据、不狂报 500。
  5. CompletableFuture.supplyAsync 不传 executor 吃全局 commonPool(默认核数-1 个线程),一处阻塞全 JVM 异步逻辑陪葬——永远显式传自己的 Executor。
  6. 并发 Map 用 ConcurrentHashMap,且它不支持 null key/value(并发下 null 有歧义);HashMap 并发写是数据错乱甚至死循环,不会 panic。
  7. Go 的 map 并发写 panic(fail fast),Java 的 HashMap 并发写静默错(fail silent)——两种哲学,Java 这边要靠你主动选对容器来避免”带着病跑”。
  8. IO 密集型线程池 corePoolSize 远大于 CPU 核数,但最终容量必须压测定,公式是起点不是答案。

11.9 深度思考题

题 1:你的服务 QPS 1000,每个请求要并发调 3 个下游(每个平均 100ms),怎么配线程池?

参考答案

完整容量推算: 需要的并发度 ≈ QPS × 单请求平均耗时。

每个请求并发调 3 个下游、每个平均 100ms,那”占用一个线程”的平均时长是 100ms(因为是并发的,取最慢那个,约 100ms)。所以持续需要的并发线程数 ≈ 1000 × 0.1s = 100 个。

配置建议:

  • corePoolSize:配 100~150(覆盖常规负载,留余量)
  • maximumPoolSize:配 200(应对波峰,比如下游抖动导致耗时翻倍时)
  • workQueue:ArrayBlockingQueue(500~1000)(有界,缓冲波峰,满了才扩到 max、再满才拒绝)
  • handler:CallerRunsPolicy(波峰时 caller 自己跑,限流而非失败)
  • threadFactory:命名线程便于排查

为什么不是 50: 50 个线程、每个任务 100ms,吞吐上限 50 / 0.1 = 500 QPS,到不了 1000。这正是第 00 章”250 QPS 卡死”的同款——线程数没按”QPS × 耗时”配。

为什么强调压测: 公式是理想值。真实下游耗时是分布不是定值(P99 可能是 500ms),连接池、GC、网络都会吃掉并发度。配完要压测看:队列是否持续逼近 capacity(配小了)、拒绝次数是否 > 0(配小了或下游真扛不住)、CPU 是否打满(IO 型应该接近满)。结论:先用公式给初值,再用压测收敛。

Go 对照:Go 里你不会配”线程池”,而是用 errgroup + semaphore.Weighted(N) 把并发数锁在 N,或带缓冲 channel 当信号量(发请求前 <-sem,发完 sem <- struct{}{})。需要的 N 也是 QPS × 耗时 推算。形态不同,限流本质一样。

题 2:为什么 Executors.newFixedThreadPool 比手动 new ThreadPoolExecutor 更危险?

参考答案

直接答案:因为它用无界队列 + 隐藏了所有关键参数,让你在”看起来正确”的配置下悄悄 OOM,且你很难在代码审查时一眼发现。

展开:

Executors.newFixedThreadPool(50) 实际是:

new ThreadPoolExecutor(50, 50, 0L, TimeUnit.MILLISECONDS,
    new LinkedBlockingQueue<>());  // 无界!Integer.MAX_VALUE

危险来自三点叠加:

  1. 无界队列:任务积压无限增长,永不触发拒绝、永不扩线程,直奔 OOM。手动创建时你被迫显式写 new ArrayBlockingQueue<>(capacity),容量被你”看见”,OOM 风险在写代码时就暴露了。

  2. 参数被藏起来了:core=max=50、队列无界、拒绝策略是默认 AbortPolicy——这些你都没显式决定。新手以为”给了 50 线程就安全”,不知道队列是无界的、不知道拒绝会抛 500。手动创建强制你逐个面对参数,把”隐藏炸弹”变成”显式旋钮”。

  3. 审查成本高:代码里写 Executors.newFixedThreadPool(50) 看着很正常,reviewer 一眼扫过不会警觉;写 new ThreadPoolExecutor(50, 200, 60L, SECONDS, new ArrayBlockingQueue<>(1000), tf, new CallerRunsPolicy()) 反而让每个决策摊在阳光下,reviewer 立刻能问”队列为什么 1000?max 为什么 200?”。

更深一层:Executors 工厂方法不是”错”,是”太方便以至于容易用错”。它把”线程池配置的复杂性”封装掉了,但并发的正确性恰恰藏在那些被封装的参数里。阿里手册禁止它,本质是说:线程池配置是你必须亲自做的决策,不该被一行语法替你决定。 这跟”不要随便用 parallelStream”是同一类教训——把并发度藏起来,就是把风险藏起来。

题 3:synchronized 块里用 Thread.sleep(1000) 会怎样?

参考答案

直接答案:抱着锁睡 1 秒。sleep() 不会释放锁,这 1 秒内所有想进同一把锁的线程全部 BLOCKED,临界区被无意义拉长,吞吐塌方。

public synchronized void handle() {
    doLocalWork();          // 毫秒级
    Thread.sleep(1000);     // 错误:抱着锁睡 1 秒,其他线程全卡在门口
    doMoreWork();
}

展开后果: sleep() 只暂停当前线程,锁还握在手里。假设线程池 50 个线程,5 个同时进这个方法 → 5 把锁各睡 1 秒 → 这 1 秒内最多 5 个请求推进,其余 45 个全 BLOCKED。如果这是 HTTP 入口,吞吐直接掉到 5 / 1s = 5 QPS。

和 wait() 的关键区别: wait() 会释放锁再睡,醒来重新抢;sleep() 不释放。很多人混淆。Go 里没有这个坑——goroutine 没有”持锁睡觉”的概念,你 time.Sleep 时 Mutex 要么没 Lock 要么已经 Unlock。

更通用的铁律: 任何锁的临界区里,绝对不要做”慢操作”(sleep、网络 IO、磁盘 IO、长循环)。锁的持有时间按微秒算。第 10 章的”短信雪崩”本质同一类——同步 HTTP 调用抱锁几十秒。

Go 对照:Go 的 sync.Mutex 也没”睡觉释放锁”的语义(你要 mu.Unlock() 才释放),所以 time.Sleep 抱锁同样是 bug。但 Go 的 goroutine 阻塞在 channel 上(<-ch)不持锁,而 Java 的”阻塞在 IO”若发生在 synchronized 块内就抱着锁——这是两个生态锁语义的微妙差异,转 Java 时格外要小心”把慢操作移出临界区”。

修复: 把 sleep / IO 移出 synchronized 块;若必须等,用 wait()/notify 或 LockSupport 配合释放锁的等待。

题 4:CompletableFuture.allOf 在其中一个失败时,其他的还会继续跑吗?怎么让它取消?

参考答案

直接答案:默认会继续跑。一个任务失败,allOf 返回的 future 也失败,但另外两个任务不会被取消**,它们还在占用线程和下游连接跑着。要取消,得手动对每个 future 调 cancel(true),或自己遍历取消。**

展开(这是 Java 编排异步的易漏点):

CompletableFuture<User> f1 = getUserAsync(id, bizPool);
CompletableFuture<Order> f2 = getOrderAsync(id, bizPool);
CompletableFuture<Score> f3 = getScoreAsync(id, bizPool);
CompletableFuture<Void> all = CompletableFuture.allOf(f1, f2, f3);
all.join();  // f1 失败 → all 失败,但 f2、f3 还在跑!

allOf 只是”等所有完成”,它不持有”取消其他”的职责。一个失败,另外两个跑完也白跑(结果没被用),还占着下游资源。

手动取消:

try {
    all.join();
} catch (Exception e) {
    f1.cancel(true);   // 中断执行中的任务(若任务是可中断的 IO/Thread.sleep)
    f2.cancel(true);
    f3.cancel(true);
    throw e;
}

注意 cancel(true) 发的是中断信号,任务里得响应 Thread.currentThread().isInterrupted() 才能真正停;纯计算任务可能要跑完。cancel(false) 则只标记不中断,等它自己结束。

对比 Go 的 errgroup.WithContext: errgroup 在任意一个 goroutine 返回非 nil error 时,会自动 cancel(ctx),其他 goroutine 通过 ctx.Done() 检测到后提前退出——自动取消,不用你手动遍历。这是 errgroup 相对 allOf 的一大优势:goroutine 协作取消是语言级的(ctx),而 CompletableFuture 的取消是手动的(你得自己调 cancel)。

g, ctx := errgroup.WithContext(ctx)
g.Go(func() error { return getUser(ctx, id) })   // 失败 → ctx 取消
g.Go(func() error {
    select {
    case <-ctx.Done(): return ctx.Err()   // 检测到取消,提前退出
    case r := <-getOrder(id): return r.err
    }
})

更深一层:这个差异来自”取消语义的语言支持”。Go 的 context.Context 是一等公民,取消能沿调用链传播;Java 的取消靠 Thread.interrupt() + 业务检查,是”建议式”的。所以 Java 里编排多个异步任务时,要主动设计”一个失败取消其他”的逻辑,不能指望 allOf 帮你做。这也是为什么很多人说”Java 的异步编排比 Go 的 errgroup 啰嗦”——取消不是免费的。

题 5(开放题,无标准答案):如果让你设计一个”Java 版 goroutine”,你会给 JVM 加什么?

这道承上启下,先自己推一遍,第 12 章逐条对照 goroutine 验证。

给你方向:核心是用户态调度(M:N)+ 载体线程(carrier thread)+ 阻塞时自动让出。难点是 synchronized 和 native 阻塞怎么处理——一个虚拟线程卡在 synchronized 上是占着载体线程还是让出?卡在 JNI 的 C 代码里(OS 根本不知道它是虚拟线程)还能让出吗?这恰恰是 Project Loom 踩了十年的坑。先写出你的取舍清单,第 12 章我们对照看 Java 官方怎么解的。


下一章预告

第 12 章《虚拟线程与结构化并发》,Java 21 的”goroutine 答案”。这一章你受的苦——线程池七个参数、队列容量、commonPool 陷阱、CallerRunsPolicy 限流——本质上都是”线程贵”逼出来的补丁。虚拟线程让 JVM 扮演 Go runtime 的角色:你写 executor.submit(() -> blockingIO()),JVM 在阻塞时自动把载体线程让出去。但虚拟线程不是银弹,它有和 goroutine 几个致命差别的坑(比如 synchronized 内阻塞不会让出、native 阻塞不会让出),踩了就是线上事故。第 12 章逐条对照 goroutine,告诉你虚拟线程什么时候不能用、怎么和结构化并发(StructuredTaskScope)配合。