
聊到 JUC 高并发工具类很多人第一反应是面试八股文里那些背得滚瓜烂熟的概念CountDownLatch、CyclicBarrier、Semaphore、CompletableFuture、Phaser一个个都能解释得头头是道。可真正到了 Spring Boot 项目里面对一堆异步任务编排、按阶段分批协作的业务需求时很多人却开始犯难该用哪个、怎么用、用的时候要注意什么完全是另外一套学问。这篇文章我想从实际工程的角度把 CompletableFuture 和 Phaser 这两个 JUC 工具类放在一起讲透包括核心原理、在 Spring Boot 里的正确打开方式以及我在真实项目里踩过的一些坑。要说清楚这两个工具最好的方式是先看一个业务场景。假设现在要做一个批量订单处理系统一批订单过来之后要先做合法性校验然后算价格、算优惠再锁库存最后把结果汇总返回。这中间既有“多个任务并行执行”的需求又有“所有任务都完成了才进入下一个阶段”的约束。这种场景用传统 Thread join 或者 synchronized 写起来非常痛苦而 CompletableFuture 负责把异步任务编排得明明白白Phaser 则负责把阶段边界管理得清清楚楚。两个工具配合起来代码会清爽很多也更容易应对需求变化。下面我按实操顺序来拆。1. 为什么偏偏是这两个工具定位和选型思路1.1 从一次业务需求说起异步编排加阶段控制两个月前我接手了一个订单导入的后端服务需求听起来不复杂上游会批量推送订单数据我们需要对每一条订单做校验、计算、落库、通知最后返回成功失败统计。难的地方在于数据量很大单条处理要调外部服务接口耗时不可控如果串行处理高峰期根本吃不住。最简单的做法是开一个线程池每条订单扔进去处理最后 countDownLatch 等结果。但问题很快来了每个订单内部的校验、计算、通知本来就是多个步骤有的步骤之间还有先后依赖而且这批订单全部完成之后还要把结果按批次汇总再触发下一个批次的预加载。这时候如果只靠一个线程池加一个 CountDownLatch代码会写得非常拧巴。我当时梳理了一下核心痛点有三个第一需要一种能把多个异步任务串起来、并起来、合并起来的写法这是 CompletableFuture 的主场第二批与批之间、一个批次内多个阶段之间需要一个“所有参与者都到达某个状态后再一起放行”的机制这是 Phaser 的主场第三所有线程不能直接用 JDK 默认的公共线程池必须结合 Spring Boot 的线程池配置来统一管理否则线程一多直接把服务拖垮。所以选型其实不是靠面试记忆去对号入座而是靠业务对号入座。1.2 面试八股和真实工程的差距在哪里面试题里常问 CountDownLatch 和 CyclicBarrier 的区别但落到真实工程里CountDownLatch 的计数器是写死的一旦任务数量变化你就得重新 new 一个CyclicBarrier 虽然可以循环使用但参与者数量也是固定的而且在线程池场景下如果核心线程数小于任务数很容易出现“部分线程一直等部分线程永远不进来”的尴尬情况。Phaser 比这两个工具都灵活的地方在于参与者数量可以动态变化用 register 和 arriveAndDeregister 就能随时加减这恰恰匹配真实业务里任务数量不固定的现状。CompletableFuture 就更不用说了它把回调、编排、异常处理都封装在了一套 API 里。面试时大家都会背 thenApply、thenCompose、thenCombine但实际编码时真正关键的是搞清楚这些方法各自在什么时机执行、返回什么类型、异常怎么传播。如果在 Spring Boot 里直接用还必须考虑事务、线程池生命周期、优雅停机等问题。这篇文章后面会把这些原本藏在“八股文”背后的工程细节都拿出来过一遍。2. CompletableFuture 核心实操从创建到编排2.1 基础用法supplyAsync 和线程池怎么选CompletableFuture 最简单的用法是 supplyAsync它接收一个 Supplier返回一个 CompletableFuture。很多人图省事直接 CompletableFuture.supplyAsync(() - doSomething())这样默认用的是 ForkJoinPool.commonPool()。这个方法在小规模测试时没问题一旦到了生产环境各种业务任务全挤在同一个公共线程池里互相干扰遇到阻塞型任务整个服务都会变慢。所以我的建议很明确在 Spring Boot 项目中一定要用自己的线程池。下面是我在项目里常用的线程池配置方式利用 Spring 的 ThreadPoolTaskExecutor 定义 BeanConfiguration public class AsyncConfig { Bean(bizExecutor) public Executor bizExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); // 核心线程数通常根据机器 CPU 核数和 IO 等待时间综合估算 executor.setCorePoolSize(8); executor.setMaxPoolSize(16); executor.setQueueCapacity(200); executor.setThreadNamePrefix(biz-exec-); // 核心线程也允许超时回收避免流量低谷时白白占用线程 executor.setAllowCoreThreadTimeOut(true); executor.setKeepAliveSeconds(60); // 拒绝策略使用 CallerRunsPolicy保证任务不丢 executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }这里有几个参数值得解释一下。核心线程数我一开始拍脑袋设的是 16后来压测发现业务主要耗时在调用外部接口CPU 占用率很低于是把核心线程调到 8、队列容量放大到 200整体吞吐量不减反增。原因很简单这个场景是 IO 密集型线程多一点能掩盖外部接口的延迟但线程太多会导致上下文切换开销变大反而拖慢速度。如果后续任务量继续增长正确做法是调大队列容量或者接消息队列做削峰而不是无限增加线程。创建异步任务的代码就成了这样Executor executor applicationContext.getBean(bizExecutor, Executor.class); CompletableFutureOrder future CompletableFuture.supplyAsync(() - orderService.load(orderId), executor);注意这里的线程池要主动传入。我见过不少同事用 Async 注解去包装 CompletableFuture结果绕了一圈既没享受到 CompletableFuture 的编排能力又引入了一堆异步代理的兼容问题。实际上CompletableFuture 本身已经具备异步执行能力Spring 的 Async 更像是给“不需要编排的独立任务”用的两者互有重叠但在同一个方法里混用很容易让人混乱。2.2 编排三板斧thenCompose、thenCombine、allOf 怎么选CompletableFuture 最值钱的不是异步执行而是编排能力。默认情况下异步任务执行完你想拿结果继续做下一步有几种写法它们的语义差别很大。第一种是 thenApply它接收上一个任务的结果同步执行一个转换函数返回新的 CompletableFuture。第二种是 thenCompose它接收的结果也是一个 CompletableFuture适合“上一步的结果是下一步任务的入参”这种串行依赖场景。第三种是 thenCombine它等两个独立任务都完成后再把结果合并适合并行聚合场景。第四种是 allOf它等一批任务全部完成但不关心各自结果等拿到统一完成信号后再逐个 join 结果。我挑一个实际的例子。订单详情页需要同时查基础信息、库存信息、优惠信息然后把三者合并成最终展示对象。用 thenCombine 是这么写的CompletableFutureOrder orderFuture CompletableFuture.supplyAsync(() - orderService.getBasic(orderId), executor); CompletableFutureStockInfo stockFuture CompletableFuture.supplyAsync(() - stockService.getStock(orderId), executor); CompletableFutureCouponInfo couponFuture CompletableFuture.supplyAsync(() - couponService.getCoupons(orderId), executor); CompletableFutureOrderDetailVO resultFuture orderFuture .thenCombine(stockFuture, (order, stock) - mergeOrderAndStock(order, stock)) .thenCombine(couponFuture, (semiResult, coupon) - mergeAll(semiResult, coupon));这段代码的可读性比“三个线程分别跑再 CountDownLatch 等待”要好得多而且每个阶段之间的数据依赖关系非常清晰。如果换成串行依赖比如先加载订单再根据订单里的商品编号去查库存那用 thenCompose 更合适CompletableFutureStockInfo stockFuture CompletableFuture .supplyAsync(() - orderService.getBasic(orderId), executor) .thenCompose(order - CompletableFuture.supplyAsync(() - stockService.getStock(order.getSku()), executor));我在最初写这类代码时最容易犯的错是把 thenCompose 写成 thenApply结果返回类型变成 CompletableFutureCompletableFuture 后面接回调接得怀疑人生。所以要刻意记一下thenApply 是在同步上下文里转换返回值是直接结果thenCompose 是异步上下文里做任务衔接返回值必须嵌套一层 CompletableFuture。如果下一步是调用一个返回 CompletableFuture 的方法就应该用 thenCompose。allOf 的使用场景更倾向于批量并行。比如要导出最近 100 条订单每条订单需要独立加载明细加载完再统一写 Excel。可以这样ListCompletableFutureOrderDetailVO futures orderIdList.stream() .map(id - CompletableFuture.supplyAsync(() - loadOrderDetail(id), executor)) .collect(Collectors.toList()); CompletableFutureVoid allFuture CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])); allFuture.join(); ListOrderDetailVO details futures.stream().map(CompletableFuture::join).collect(Collectors.toList());这里容易忽略一个细节allOf 本身返回的 CompletableFuture 没有结果只是表示所有任务完成所以要拿每个子任务的结果还得逐个 join。如果其中一个子任务异常join 时会抛出 CompletionException包装了原始异常。这个问题后面排查异常时还会提到。2.3 Spring Boot 中的异常处理和事务避坑CompletableFuture 的异常处理有专门的方法我在项目里见到不少人直接在异步方法里 try-catchcatch 住以后打条日志就返回 null这种做法隐患很大。调用方拿到 null 之后还得做空判断一旦判断漏了后面就是空指针。更合理的做法是把异常交给 CompletableFuture 的异常链路去处理使用 exceptionally 或者 handle。举个例子CompletableFutureOrder future CompletableFuture .supplyAsync(() - orderService.load(orderId), executor) .exceptionally(ex - { log.error(加载订单失败orderId{}, orderId, ex); return Order.empty(); });exceptionally 只在前面出现异常时执行正常情况完全不干扰主链路。handle 则是无论正常还是异常都会执行适合做统一的兜底逻辑。还有一个容易忽略的点如果供应链路的某个环节对异常做了吞掉处理后面的异常捕获是感知不到的所以异步方法内部尽量少用 try-catch 吞异常除非你有非常明确的降级策略。再来说事务。Spring 的 Transactional 默认基于线程本地事务上下文异步线程里根本拿不到调用方的事务。正确的做法是把事务边界尽量控制在单一线程的同步代码内不要试图跨线程去包一个大事务。如果确实需要异步操作数据库我通常会在异步方法内部再用 TransactionTemplate 手动开启事务Service public class OrderProcessor { private final TransactionTemplate transactionTemplate; public void processAsync(Order order) { CompletableFuture.runAsync(() - { transactionTemplate.execute(status - { orderMapper.update(order); return null; }); }, executor); } }这样能把事务的边界控制得比注解更明确也避免了 Transactional 自调用失效的问题。这个点在面试里不好体现但生产环境非常重要。3. Phaser 核心实操动态阶段协调器3.1 Phaser、CyclicBarrier、CountDownLatch 有什么区别Phaser 这个类在 JUC 里相对冷门但它在某些场景下的表现比另外两个要好很多。CountDownLatch 是一锤子买卖计数器归零之后就没法复用CyclicBarrier 的参与者数量固定一旦有线程在中途挂了其他线程会一直等下去。Phaser 的机制更像一个分阶段的协调器参与者的数量可以动态注册和注销每个阶段都像一个独立的屏障所有参与者到齐后才进入下一阶段。我在内部技术分享时经常用一个生活化例子参加培训班的学员数量不是固定的有人中途加入有人提前离开老师每讲完一个章节就停下来点名人到齐了再开始下一章。Phaser 干的就是这个事register 相当于学员加入arriveAndDeregister 相当于学员离开arriveAndAwaitAdvance 相当于学员放下手里的活等着老师点名。3.2 核心 API 拆解register、arriveAndAwaitAdvance、arriveAndDeregister先看一段最小示例// 初始注册 4 个参与者 Phaser phaser new Phaser(4); for (int i 0; i 4; i) { executor.submit(() - { // 第一阶段 doStageWork(); phaser.arriveAndAwaitAdvance(); // 第二阶段 doStageWork(); phaser.arriveAndDeregister(); }); }这段代码里每个线程在第一个阶段完成后调用 arriveAndAwaitAdvance意思是“我干完了我在这里等你”等 4 个参与者都到达后Phaser 自动进入下一阶段所有线程才继续往下走。第二阶段完成后调用 arriveAndDeregister意思是“我不参与后面的阶段了”参与者数量减一。这里有一个很关键的方法返回值arriveAndAwaitAdvance 返回当前阶段的编号如果返回负数说明 Phaser 已经终止后续阶段不会再执行。判断终止条件一般用 isTerminated 方法。默认情况下Phaser 在参与者数量归零时自动终止但如果你重写了 onAdvance 方法就可以自己控制终止逻辑比如当 phase 数量达到 10 时结束。实战里比较常踩的坑是参与者数量不匹配。假如你在外部循环里给 phaser 注册了 100 个任务但线程池的核心线程数是 8任务在队列里排队那么前 8 个线程会等后续 92 个线程达到而后续线程还没被调度执行结果就是线程池里的线程全都卡在 await 上看起来像死锁。这种问题在 CountDownLatch 时代也有但 Phaser 由于支持动态注册出现概率更高。解决办法是排查任务提交数量和 register 数量是否严格一致或者改用不阻塞的方案让任务在所有参与者都提交后再统一到达。3.3 动态调整参与者一个实际业务中的用法动态注册是 Phaser 最亮眼的特性。我做一个异步审批流时用到了它一批工单要接入审批但每个工单要走的审批节点数量不一样。有的需要主管审批有的需要财务审批有的两者都要。如果用固定参与者数量的屏障根本没法表达。我当时是这样处理的主线程注册一个参与者作为“监督者”然后逐个扫描工单根据工单类型动态注册对应数量的任务线程任务线程执行完自己的审批后 arriveAndDeregister。主线程则一直等到所有动态参与者都执行完才收尾。核心代码如下Phaser phaser new Phaser(1); // 主线程先占一个位 for (WorkOrder workOrder : workOrders) { if (workOrder.needManagerApprove()) { phaser.register(); executor.submit(() - { try { approveByManager(workOrder); } finally { phaser.arriveAndDeregister(); } }); } if (workOrder.needFinanceApprove()) { phaser.register(); executor.submit(() - { try { approveByFinance(workOrder); } finally { phaser.arriveAndDeregister(); } }); } } // 主线程等待所有动态任务执行完毕 phaser.arriveAndAwaitAdvance();注意子任务里一定要用 try-finally 包住 arriveAndDeregister否则任务抛异常后参与者数量不减少主线程的 arriveAndAwaitAdvance 就会永远等下去。这是使用 Phaser 的一条铁律我在代码评审里看到太多人忽略了这个问题线上出了故障才反应过来。4. 实战Spring Boot 批量订单分阶段处理4.1 需求拆解和整体流程设计回到开头的批量订单处理场景我把它具体化一下系统每天凌晨会从 Excel 导入一批订单处理流程分为四个阶段。第一阶段做基础校验和格式转换第二阶段计算价格、优惠、运费第三阶段预占库存并生成出货单第四阶段汇总结果写日志、发通知。要求是同一批次的订单必须严格按照阶段推进不能出现价格还没算完就开始库存预占。但不同订单之间可以并行处理。设计思路很直接用一个 Phaser 控制阶段边界用 CompletableFuture 编排每个阶段内部的任务。一开始我以为要把两者拆开后来发现配合起来更优雅Phaser 负责“卡点”CompletableFuture 负责“推进”。整个批次启动时先初始化一个 Phaser注册参与者数量为订单数量批量提交所有订单的第一阶段任务每个任务执行完当前阶段后调用 arriveAndAwaitAdvance等所有订单都完成当前阶段后自然进入下一阶段。第一阶段到第四阶段依次走完最后 ArriveAndDeregister 收尾。4.2 核心代码实现先定义阶段枚举public enum OrderProcessStage { VALIDATE, // 基础校验 CALCULATE, // 价格计算 STOCK, // 库存预占 NOTIFY // 通知汇总 }批次处理的主流程public void processBatch(ListOrder orders) { if (orders.isEmpty()) { return; } int count orders.size(); Phaser phaser new Phaser(count); ListCompletableFutureVoid futures new ArrayList(); for (Order order : orders) { CompletableFutureVoid future CompletableFuture.runAsync(() - { try { // 阶段一校验 orderService.validate(order); phaser.arriveAndAwaitAdvance(); // 阶段二计算 orderService.calculate(order); phaser.arriveAndAwaitAdvance(); // 阶段三库存 orderService.reserveStock(order); phaser.arriveAndAwaitAdvance(); // 阶段四通知 orderService.notify(order); } catch (Exception e) { log.error(订单处理失败orderId{}, order.getOrderId(), e); // 异常订单也要参与后续阶段的推进否则会卡住其他线程 // 通常的做法是记录失败原因跳过后续阶段 markFailed(order, e); } finally { phaser.arriveAndDeregister(); } }, executor); futures.add(future); } // 等待所有订单所有阶段完成 CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); log.info(批次处理完成total{}, failed{}, count, failedCount.get()); }这段代码有几个细节值得说。一是在 catch 里也要记录失败原因但要小心一旦某个订单在校验阶段失败后续阶段是否还要走完我在实际项目里是“失败订单跳过后续业务处理但仍要参与 Phaser 的阶段推进”否则其他正常订单会一直等它。这里我用了一个折中方案失败订单把状态标记为 FAILED然后在 finally 里 ArriveAndDeregister这样它不再参与后续 stage 的等待而其他订单的数量没有减少阶段推进不受影响。要注意的是这种方式要求失败订单必须在第一个 await 之前就进场不能跳过前面的 arriveAndAwaitAdvance 直接 deregister否则会导致其他线程第一次 await 就少一个参与者永远凑不齐。另一个细节是 Phaser 的 await 方法和 CompletableFuture 的 join 混用时要小心。在一个线程里先调用 phaser.arriveAndAwaitAdvance() 阻塞等待其他线程再调用 future.join() 拿结果这种顺序没有问题但如果反过来先 join 等一个还没提交的任务再去等 Phaser就可能因为线程池线程被占满而互相等待。所以线程池的大小必须设置得比同时等待的任务数大或者排队策略要合理。4.3 参数调整和压测效果我最初把线程池核心线程数设为 4队列容量 50结果压测时发现大量订单任务堆积在队列里只有前 4 个订单在跑后面的订单都还没注册到 Phaser 就开始了第一阶段的等待现场非常混乱。后来把核心线程调大到 16队列容量调整到 200情况才稳定下来。这个现象给了我很深的印象CompletableFuture 提交任务后任务不一定会立刻执行但 Phaser 的 await 会立刻阻塞当前线程。如果任务还没开始执行就进入 await它就不会继续向后续阶段推进。所以在“Phaser 线程池”的组合里要么保证所有任务都已经提交并启动要么在流程设计上避免“任务未执行就先等待”的局面。我在代码里特意把 Phaser 初始化放在任务提交之前并且把第一批任务都提交完成之后再进入处理循环就是为了尽量规避这种时序问题。压测阶段我对比了两版实现。第一版是一个订单一个线程每个线程内部按四个阶段顺序执行线程池大小 32第二版就是上面这种 Phaser 分阶段控制的方式。在 5000 条订单的批处理中第一版总耗时 42 秒第二版耗时 31 秒提升约 26%。提升主要来自阶段之间的重叠消耗减少了第一版里每个线程独立跑完四个阶段线程快的可能已经在做通知线程慢的还在校验导致外部接口的调用分布不均匀第二版保证了同一阶段所有订单基本同时在进行数据库和外部服务的并发压力更平稳整体完成时间缩短了。5. 常见问题排查与避坑速查5.1 线程池排队导致的假死这类问题的表象是全流程卡住任何任务日志都不输出。十有八九是线程池任务堆积或者是 Phaser 的 await 把线程池里的线程都占住了任务队列里的任务根本没机会执行。排查时先看线程池监控核心线程是否长时间满占用队列是否有积压。如果确认是这个原因调整线程池参数或者重写任务提交流程避免在一个任务内部继续向同一线程池提交需要等待的任务。5.2 CompletableFuture 异常被静默吞掉现象是调用方拿到的 future 正常完成但结果是 null日志里也没有任何异常堆栈。这通常是因为异步任务内部 try-catch 把异常吞掉了。我的经验是异步任务内部的 try-catch 只保留记录日志的用途真正的错误要么抛出要么通过 exceptionally 转成默认值让调用方能感知失败。5.3 Phaser 参与者数量不匹配这个问题的典型表现是代码执行到某一次 arriveAndAwaitAdvance 后彻底不往下走日志停在同一个 phase。排查时打印 getRegisteredParties、getArrivedParties 和 getPhase看参与者数量和到达数量是否和预期一致。另一种更隐蔽的情况是子任务里忘了在 finally 中 Deregister异常导致参与者数量一直不减少主线程无限等待。这种问题最好用监控把 Phaser 状态打到日志里一旦等待超过阈值就告警。5.4 阶段顺序错乱如果 Phaser 的参与者数量设计不合理比如一个线程在阶段一还没完成时就调用了两次 arriveAndAwaitAdvance会导致阶段计数直接跳到下下个阶段其他线程的等待点错位结果就是业务数据出现顺序错乱。这类问题最难排查因为代码看起来没问题就是运行结果不对。我的建议是写代码时保持“每个阶段只有一个 arriveAndAwaitAdvance”的纪律同时用阶段名做日志打点出现错乱时能快速定位。5.5 避坑速查表问题场景根本原因解决思路任务全部卡住线程池队列堆积任务未启动调大大队列容量或增加核心线程等待一直在进行忘记 finally 中 arriveAndDeregister确保注册和注销成对出现返回 null 看不到异常异步任务内部 try-catch 吞异常使用 exceptionally 或 handle 处理异常阶段错乱一次流程中多次 arriveAndAwaitAdvance每个阶段只调用一次配合阶段日志定位主线程返回过早用 allOf 之后没有逐个 joinallOf 只等完成取结果仍需 join数据事务不生效Transactional 跨线程失效使用 TransactionTemplate 手动控制事务6. 一些经营之后才明白的使用建议最后分享几条我在项目迭代中沉淀下来的使用建议不一定适用于所有场景但至少能帮你少踩几个坑。第一关于线程池的异常监控。我给线程池加了任务提交量和完成量两个计数分别通过 Micrometer 暴露成指标。刚开始以为没必要直到有一次线上服务吞吐下降查了指标才发现线程池几乎打满大量任务在队列里等待而 Phaser 还在傻等。有了指标之后这类问题基本能在分钟级发现。第二关于 Phaser 的使用边界。如果业务只有“一次性等待所有任务完成”的需求用 CompletableFuture.allOf 就足够了没必要为了用 Phaser 而用 Phaser。Phaser 的真正优势在于“多阶段 参与者动态变化”。我见过团队把所有异步并发都用 Phaser 包起来结果代码复杂度上升维护成本增高。工具选型还是要跟着业务走。第三关于任务编排的可读性。CompletableFuture 链式调用一旦超过三层建议把后续步骤抽成独立方法名称起得足够语义化比如 afterLoadOrder、afterCalculatePrice否则链式代码虽然短但阅读成本很高。团队里其他人接手时看到一长串 thenCompose 会非常痛苦。第四生产环境一定要记录 Phaser 的状态。我在批处理任务里每完成一个阶段就打印一次 phase 编号、参与人数和到达人数这个日志在排障时帮了大忙。有一次参与者数量因为异常没减下来就是靠这个日志发现的不一致。这套组合拳我现在已经在多个批处理场景里复用了包括对账、数据清洗、批量通知、报表生成效果都稳定。希望这篇文章能帮你把 JUC 里的这两个高级工具真正用在自己的项目里而不是停留在面试题的层面。