Java多线程实战:从线程池到CompletableFuture的并发编程指南

发布时间:2026/9/8 9:24:28
Java多线程实战:从线程池到CompletableFuture的并发编程指南 1. 为什么我劝你别在项目里裸写 new Thread1.1 线程不是越“原始”越可控但凡做过JavaEE项目的人都清楚new Thread这个写法在入门教程里出现频率极高。但真实业务里我几乎没见过哪个能撑住线上流量的代码敢这么干。原因不复杂每一次new Thread都是在机器上创建一条不受任何约束的原生线程它没有池化、没有复用、没有容量上限并发一上来线程数像无头苍蝇一样膨胀操作系统调度不过来CPU 时间片大量消耗在线程切换上业务响应反而越来越慢。我印象很深的一次线上事故就是凌晨的批次任务用new Thread并发跑数据同步结果一来流量直接创建了上千条线程内存被线程栈占满整台机器直接 OOM。事后复盘时发现代码里对“并发数”完全没有设限线程的创建和销毁也没有任何复用机制。从那时起我对团队的要求就一句话JavaEE 项目里统一起点就是线程池谁再裸写 new Thread 谁负责写复盘。1.2 一个订单推送场景把线程池参数讲明白先别急着背参数我习惯用业务场景来理解线程池。假设你要做一个订单状态变更后的推送系统每秒钟有 200 个订单需要推送通知。这个任务是典型的IO 密集型大部分时间都在等外部接口返回、等数据库查询结果CPU 本身空闲得很。我当时给团队定的线程池设计是这样ThreadPoolExecutor orderPushPool new ThreadPoolExecutor( 16, // 核心线程数 32, // 最大线程数 30L, TimeUnit.SECONDS, // 空闲线程存活时间 new ArrayBlockingQueue(200), // 等待队列 new ThreadFactory() { private final AtomicInteger count new AtomicInteger(1); Override public Thread newThread(Runnable r) { Thread t new Thread(r, order-push- count.getAndIncrement()); t.setDaemon(false); return t; } }, new ThreadPoolExecutor.CallerRunsPolicy() );为什么核心线程数定成 16因为这台机器的 CPU 是 8 核而订单推送是 IO 密集型任务线程在等待 IO 时可以让出 CPU 给别的线程。公式很朴素CPU 密集型任务建议核心线程数 CPU 核数 1IO 密集型任务建议核心线程数 2 * CPU 核数再多就得靠压测去试了而不是拍脑袋。等待队列用ArrayBlockingQueue而不是无界的LinkedBlockingQueue是防一条路走到黑。无界队列看起来省事任务全堆在内存里但积压到一定程度就是定时炸弹。CallerRunsPolicy是我比较偏爱的拒绝策略队列满了不抛弃任务而是让提交任务的线程自己来跑这个任务等于把压力回传给了上游起到了天然限流的效果。1.3 按照这个参数算一笔容量账继续用上面的推送场景算一笔账ThreadPoolExecutor 的最大处理能力不只是最大线程数 32它还包含队列的缓冲 200。也就是说在拒绝策略触发之前系统瞬时能容纳的任务量是32线程 200队列 232。如果每秒来 200 个任务每个任务平均耗时 100ms那么每个线程每秒能处理 10 个任务32 个线程每秒能处理 320 个任务其实完全够用线程根本涨不到 32队列也基本不积压。如果任务平均耗时变成 500ms每个线程每秒只能处理 2 个任务32 个线程每秒只能处理 64 个这时候每秒 200 个任务的提交速率会迅速把 200 容量的队列填满再往后就只能触发拒绝策略。这个计算过程一定要写在设计文档里否则别人看代码只知道你配了参数并不知道这些数字是怎么来的后续调整也无从下手。2. 线程池与JavaEE容器事务、请求线程和生命周期2.1 容器自身的线程池和业务线程池别混用JavaEE 应用跑在 Tomcat、Undertow 这样的容器里容器本身就有自己的工作线程来处理 HTTP 请求。很多人容易忽略的是容器线程和业务线程池的线程不是一个东西也不是你代码里创建了线程池容器就会自动把请求分流给它。有个典型的坑在 Servlet 里直接拿容器线程去调用一个基于异步回调的第三方 SDK回调线程是它自己的线程池结果容器线程已经返回了可你还想在原线程上继续做业务。这就是经典的“线程切换导致上下文丢失”问题。解决思路是明确区分请求的接收和响应由容器线程负责业务逻辑的异步执行和回调由自己的线程池负责两者之间的数据传递用CompletableFuture或回调接口来衔接而不是依赖 ThreadLocal 或者某种“同一个线程”的隐式假设。我参与过一个报表导出功能用户点击导出后请求线程去数据库查数据数据量一大查询就超时。后来把查询任务丢给业务线程池请求线程立刻返回“导出中请稍后在列表页查看”任务在线程池里慢慢跑跑完写文件再异步通知用户。这里面的关键点就是容器的请求生命周期和任务的实际执行周期必须解耦否则短请求也变长请求线程一直被占住系统吞吐自然上不去。2.2 多线程执行SQL的串行等待问题热搜词里有一条很有意思“java 多线程执行sql语句时程序等sql执行完毕后再执行下一条”。这是很多人刚接触多线程时都会遇到的问题。比如一段代码在 for 循环里逐条执行 INSERT 或 UPDATE每条 SQL 要 100ms一百条就是 10 秒用户等得心急如焚。我早期优化一个数据清洗程序时就是用固定大小的线程池把 SQL 任务切成多个并发执行但核心要求是所有 SQL 都执行完主线程才能继续往下走。这个语义可以用CountDownLatch实现但用起来比较啰嗦。后来换成CompletableFuture后清爽很多ListCompletableFutureVoid futures dataList.stream() .map(data - CompletableFuture.runAsync(() - sqlExecutor.execute(data), bizPool)) .toList(); CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); log.info(全部数据更新完成);这段代码干了三件事把每条 SQL 提交到业务线程池、用allOf聚合所有异步任务、最后join阻塞主线程直到全部完成。但我要强调一点多线程并发写 SQL 不是万灵药并发写入要特别小心数据库连接池的大小。Druid 连接池最大连接数是配置出来的如果线程池设置是 32而连接池只有 10那么剩下 22 个线程全部在等数据库连接任务整体耗时不降反升。2.3 JavaEE 事务边界为什么线程池里的事务“失效了”还有一个在 JavaEE 场景下特别阴魂不散的问题事务。Spring 的Transactional默认是基于代理和 ThreadLocal 的它绑定的是当前线程。你把一个任务丢给线程池里的另一个线程执行事务上下文不会跟着过去这个任务里做的数据库操作根本不在事务管理范围内。我见过线上一个诡异的“数据写到一半”问题订单主表更新成功明细表却只有部分成功程序没报错数据就是不一致。查到最后发现有人在异步线程池里调用了Transactional的方法那个注解本质上没生效每个操作各自提交了。所以我现在给团队定的铁律是异步线程池里的任务要么自己管理事务编程式事务要么把事务操作放到事务性消息队列或本地消息表里绝不能在子线程里依赖父线程的事务注记。3. CompletableFutureJavaEE里被低估的异步利器3.1 为什么 CountDownLatch 和 Future.get 不够用很多老项目里多线程等待任务结果用的还是CountDownLatch加Future.get()的组合。这个组合能解决“等结果”的问题但对“任务之间的依赖编排”非常不友好。比如一个接口需要并行查三个数据源都查完才组装返回用 CountDownLatch 写出来的代码哪个结果由哪个 Future 承载需要手动去对应索引代码一多就乱。CompletableFuture的价值在于它把异步任务变成了可组合的流水线和 Java8 的 Stream API 一样是一种声明式的编写方式。我写的报表聚合接口就是这样用户打开一个经营驾驶舱页面需要同时查订单量、销售额、用户数、库存量四个维度的数据再合并返回。之前串行查四个库要 2 秒改成并行后直接压到 600ms 左右。3.2 串行计算、并行依赖和结果聚合怎么用举一个比较典型的用法展示服务启动时加载配置先要从数据库加载基础配置再根据配置内容请求外部接口做二次校验最后把结果写入本地缓存。这三步有依赖关系但每一步本身都耗时较长。用 CompletableFuture 写CompletableFutureBaseConfig loadBase CompletableFuture.supplyAsync(() - loadFromDb(), executor); CompletableFutureValidatedConfig validated loadBase.thenApplyAsync(base - validateRemote(base), executor); CompletableFutureCacheResult cached validated.thenApplyAsync(v - writeLocalCache(v), executor); cached.join();thenApplyAsync的语义是“上一步完成之后把结果交给下一步在工作线程里继续执行”。这样流水线虽然还是串行的但每一步都不会阻塞调用线程调用方提交完任务之后可以去做别的事。需要并行聚合的场景用thenCombine或者allOfCompletableFutureOrderStats orderFut CompletableFuture.supplyAsync(() - orderService.stats(), executor); CompletableFutureUserStats userFut CompletableFuture.supplyAsync(() - userService.stats(), executor); CompletableFutureSalesStats salesFut CompletableFuture.supplyAsync(() - salesService.stats(), executor); DashboardVO vo CompletableFuture.allOf(orderFut, userFut, salesFut) .thenApply(v - { DashboardVO dashboard new DashboardVO(); dashboard.setOrderStats(orderFut.join()); dashboard.setUserStats(userFut.join()); dashboard.setSalesStats(salesFut.join()); return dashboard; }).join();这段代码的精髓是三个查询任务之间没有依赖所以并行提交allOf负责等到所有任务完成thenApply里再统一取结果。join()会等待并抛出不检查异常比get()少了一堆 checked exception 的胶水代码。3.3 等待多个任务结果时的三个误用场景用 CompletableFuture 也不是没有坑这里说三个我实测踩过的。第一个坑是线程池传错。supplyAsync不传线程池的话默认走 ForkJoinPool.commonPool公共池的并行度约等于 CPU 核数减一。多个业务混用公共池其中一个 IO 密集任务把池子占满其他业务也跟着遭殃。所以生产环境我强烈建议每个独立业务都显式传入自己的线程池。第二个坑是超时控制缺失。如果其中一个下游接口卡住不返回join 会一直等下去整个请求就挂死了。要给 CompletableFuture 设置超时用orTimeoutorderFut.orTimeout(3, TimeUnit.SECONDS) .exceptionally(ex - { log.error(订单统计查询超时或异常, ex); return new OrderStats(); });第三个坑是误用 getNow 造成逻辑提前执行。getNow(valueIfAbsent)的意思是“如果任务已经完成就返回结果否则返回默认值”它不会等待。很多人拿它当“立即获取”来用结果在任务还没跑完时就拿到了默认值数据自然不对。判断“等到结果”还是“有则取之”代码语义必须清楚。4. 锁、原子类和线程安全从“能用”到“好用”4.1 synchronized 能解决的问题不要硬上 Lock多线程(一)里通常已经讲了 synchronized 的基本用法但到了项目里很多人会陷入选择的纠结到底用 synchronized 还是 ReentrantLock我的判断标准很直接如果只是简单地对一段代码或一个方法加互斥synchronized 足够代码也更短如果需要可中断、超时、公平锁、多条件队列这种高级能力才考虑 ReentrantLock。我记得团队里有同学用一个 ReentrantLock 保护一个简单的 Map 读写代码写得很“漂亮”加锁解锁、try-finally、还配了 Condition但整个系统只有一个地方用到这个 Map同步压力几乎可以忽略。这种属于过度设计不仅没有带来收益还让代码的可读性下降。能用 synchronized 解决的场景优先用 synchronized这是我在 code review 里说过最多的话之一。4.2 CAS 和 AtomicInteger 的适用边界再往深一层就是 CAS比较并交换和java.util.concurrent.atomic包。CAS 最大的优势在于无锁线程没有上下文切换开销但它的适用场景非常窄只能保证一个字段的原子更新。比如统计接口调用次数的计数器用 AtomicLong 非常合适但如果你要保证多个字段之间的状态一致性CAS 就无能为力了必须回到锁或者其他的并发原语。我写过一个限流器就是用 AtomicInteger 做滑动窗口内的请求计数每次请求进来incrementAndGet()超过阈值就拒绝。同样的场景如果用 synchronized 全程加锁高并发下性能会明显下滑。但要注意 CAS 在高竞争下会出现自旋开销线程多且碰撞严重时CAS 反而可能比锁更慢。所以选型逻辑是竞争低的场景用原子类竞争高且需要多步操作的场景回归锁。下面这张对比表是我内部培训时常用来给团队做决策参考的场景推荐方式原因单字段计数器、流量统计AtomicLong / AtomicInteger无锁、性能高、语义清晰单方法或代码块互斥synchronized写法简单锁可自动释放需要超时拿锁或可中断ReentrantLock支持 tryLock 和 lockInterruptibly多字段一致性更新synchronized 或 ReentrantLockCAS 无法提供多变量原子更新读多写少的共享资源ReadWriteLock / StampedLock读读不互斥提升并发读性能4.3 并发集合选择不当程序迟早出事HashMap 在多线程下扩容会死循环这是老生常谈。但真正到项目里我看到的问题更隐蔽有人用了Collections.synchronizedMap包了一个 HashMap觉得线程安全了。包装方法确实把所有方法都加上了锁但遍历和修改之间没有原子性保证一个线程在读迭代器另一个线程在改 Map依然会抛 ConcurrentModificationException。业务代码里更推荐直接用ConcurrentHashMap它的锁粒度和同步策略设计得很精细。对于需要保持插入顺序的场景用ConcurrentLinkedQueue或者在 ConcurrentHashMap 外面自己维护顺序对于队列场景LinkedBlockingQueue和ArrayBlockingQueue分别是无界和有界的代表选型时优先有界队列理由前面已经讲过。有一个冷门但很有用的类是CopyOnWriteArrayList适合写极少、读极多的场景比如系统启动时加载一次的配置列表。它每次写操作都会复制整个数组所以写代价很高但读完全无锁如果列表规模小、更新频率极低用起来很舒服。5. 一次真实死锁排查从现象到根因的完整链路5.1 现象接口偶发超时线程池队列持续增长有一段时间我们一个发送短信的接口在下午高峰期频繁超时但是监控上看 CPU 和内存都不高数据库也没有慢 SQL。我第一反应是线程池被打满一查监控发现线程池的活跃线程数一直保持在最大值队列长度也在缓慢上涨。这说明大量线程卡住了而不是在等 CPU 或者数据库卡住的原因通常是等待某个锁、某个外部接口或者某个 IO。5.2 jstack 排查发现线程互相持锁等待排查命令其实不花哨就是jstack pid抓线程快照。抓下来以后我搜java.lang.Thread.State是BLOCKED的线程很快定位到有两条业务线程互相等待thread-pool-3 prio5 tid0x... nid0x... waiting for monitor entry [0x...] - java.lang.Object0x... (locked by thread-pool-5) - com.xxx.service.SmsService.lockA thread-pool-5 prio5 tid0x... nid0x... waiting for monitor entry [0x...] - java.lang.Object0x... (locked by thread-pool-3) - com.xxx.service.SmsService.lockB这就非常明显了线程 A 拿到 lockA 后想拿 lockB线程 B 拿到 lockB 后想拿 lockA两个线程互不相让形成死锁。jstack 里其实已经打印了Found one Java-level deadlockJava 虚拟机会在检测到死锁时给出明确提示所以排查的重点反而是代码里为什么会有这种交叉拿锁的顺序5.3 根因与修复统一加锁顺序比什么都重要后来查代码发现短信服务里有校验签名和检查黑名单两个方法一个先锁blackListLock再锁signLock另一个反过来。本来两个锁保护的是完全不同的资源同一个线程不会同时需要两个锁偏偏有一次重构在两个锁保护的区域里都加了一段“调用发送接口前检查余额”的逻辑而这个逻辑需要同时访问这两个资源交叉等待就出现了。修复方案其实不是一个技术问题而是约定问题全项目里凡是需要同时获取多个锁的代码必须严格按照同一个全局顺序获取。我们定的是按锁的字典序来排blackListLock排在signLock前面所有方法都先拿 blackListLock 再拿 signLock。只改了一处代码死锁立即消失。后来我在代码规范里加了一条强制约束任何多锁逻辑都必须写清楚加锁顺序并作为 review 的必检项。顺手补充一个排查技巧死锁不一定每次都出现需要压力测试才有机会暴露。如果压测时发现线程池线程数“长满”而且活跃度极高优先抓 jstack盯住waiting for monitor entry和Found one Java-level deadlock这两个关键字段排查速度会快很多。6. ThreadLocal 双刃剑方便背后的隐患6.1 ThreadLocal 到底在线程池里发生了什么ThreadLocal 在 JavaEE 场景里经常被拿来存放当前登录用户、请求 ID、租户信息之类的上下文。单线程场景下ThreadLocal 很好用线程用完了数据对象被回收ThreadLocal 里的值也跟着没了。但放在线程池里情况完全不一样线程池的线程是复用的一个线程处理完请求 A 之后不会被销毁ThreadLocal 里的值也就留在了线程上。等这个线程被分配给请求 B 时B 一进来就拿到了 A 的上下文数据轻则污染日志重则数据串号。我记得有一次线上工单反馈“用户 A 看到了用户 B 的订单记录”排查到最后就是 ThreadLocal 惹的祸。请求一开始第一个过滤器把用户 ID 放进了 ThreadLocal但是请求结束时没有 remove恰好这个线程又处理了下一个请求下一个请求在没有设置新值的情况下直接从 ThreadLocal 里取出了上一个用户的信息。6.2 规范做法finally 里 remove绝不偷懒ThreadLocal 的正确姿势应该是在使用它的 finally 代码块里 remove确保当前线程处理完请求后ThreadLocal 是干净的。我在团队里定了一个标准模板try { loginContext.set(userId); doBiz(); } finally { loginContext.remove(); }有一些框架比如基于过滤器链的 web 框架会在请求结束时自动清理但不要依赖框架自己业务代码创建的 ThreadLocal清理责任就应该在业务代码自己手里。还有一个容易忽略的点是如果你在代码里手动创建了子线程子线程默认是拿不到父线程的 ThreadLocal 值的。如果确实需要父子线程间传递上下文用InheritableThreadLocal但使用更复杂因为有值传递的线程池还需要重写任务包装逻辑否则依然会串上下文。这里推荐轻量级的做法用阿里开源的 transmittable-thread-local 库来做线程池上下文的显式传递比自己在任务包装类里手动传值可靠得多。6.3 ThreadLocal 内存泄漏弱引用的代价ThreadLocal 内存泄漏的问题理论书里讲了很多但实际项目里我没见过几次是因为它导致 OOM 的倒是见过不少因为不 remove 导致的长生命周期对象堆积。ThreadLocalMap 的 key 是弱引用指向 ThreadLocal 对象value 是强引用。如果 ThreadLocal 对象不可达key 会被回收但 value 还挂在线程上如果线程是线程池里的常驻线程value 就一直无法回收。所以不及时 remove 的 ThreadLocal本质上是一种内存泄漏的温床尤其是 value 指向较大的业务对象时影响更明显。解决这个问题没有魔法就是 remove。有同学问能不能用ThreadLocal.withInitial(() - new HeavyObject())来自动初始化结果用完不清理照样泄漏。我的经验是ThreadLocal 用得多不多不重要重要的是清理习惯。把清理和 try-finally 绑定成肌肉记忆比任何框架层面的兜底都靠谱。7. 主线程等待所有任务完成的三句话总结多线程(二)的内容到这里核心是一个转变从“会用线程”到“能驾驭线程”。我在实际项目中反复用到的三句话在这里总结给各位不是套路是真金白银的教训第一能用现成并发工具解决的问题不要自己去造轮子。线程池用 ThreadPoolExecutor任务编排用 CompletableFuture并发容器用 ConcurrentHashMap、LinkedBlockingQueue它们经受了无数项目的验证比你自己实现的各种“优化”靠谱得多。第二线程池的参数和并发工具的选择一定要经过容量计算不能拍脑袋。核心线程数、队列长度、最大线程数之间的关系必须是算得清楚的数字。计算的基础就是业务流量、任务耗时、CPU 核数和外部 IO 的延迟这些数据每个团队都能拿到关键是有没有意识去算。第三排查多线程问题jstack 和线程状态是最大的线索来源。线程是 RUNNABLE、BLOCKED、WAITING 还是 TIMED_WAITING直接决定了排查方向。遇到问题先抓快照再分析代码里的加锁顺序和线程池配置一步一步定位不要凭空猜测。多线程的知识体系很庞杂但真正在 JavaEE 项目里天天要用的翻来覆去就是线程池、任务编排、并发容器、锁和线程安全这几板斧。把这些吃透配合一两次真实的问题排查比背再多面试题都管用。