我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

警惕CompletableFuture.supplyAsync默认线程池:Java并发实战复盘与自定义线程池配置

警惕CompletableFuture.supplyAsync默认线程池:Java并发实战复盘与自定义线程池配置 警惕CompletableFuture.supplyAsync的默认线程池一次Java并发实战复盘写过一阵子Java并发代码的朋友大概率都用过CompletableFuture.supplyAsync。这个方法确实方便一行代码就能把任务丢到后台异步执行配合thenApply、whenComplete这类链式调用写起来行云流水。但便利背后藏着一个很多人忽略的隐患supplyAsync没传线程池时用的不是你的业务线程池而是ForkJoinPool里的公共线程池commonPool。这个默认行为在低并发场景下没什么感觉一旦业务量上来或者任务里有IO阻塞很容易把线程池占满导致整个应用的异步链路瘫痪。今天就把这个问题彻底拆开讲清楚。我会先用一段可复现的代码演示默认线程池是怎么被打爆的然后给出自定义线程池落地的完整方案最后把线程池参数配置、队列选型、拒绝策略这些实际项目中绕不开的细节一并讲透。不管你是刚接触CompletableFuture的新手还是已经被线上问题折磨过的老手这篇内容都值得花几分钟看完。1. supplyAsync一用就出事先看默认线程池做了什么1.1 默认的ForkJoinPool.commonPool到底怎么来的先看CompletableFuture.supplyAsync的源码实现。当你只传一个Supplier参数时方法内部会调用asyncSupplyStage(ASYNC_POOL, supplier)这里的ASYNC_POOL是CompletableFuture类里的一个静态常量它在类加载时通过CommonPool工具类初始化。CommonPool核心逻辑是这样的static final int getCommonPoolParallelism() { return Math.max(2, Runtime.getRuntime().availableProcessors() - 1); }也就是说一台8核机器上commonPool默认并行度是724核机器上是23。看到问题了吗这个线程数只和CPU核心数相关完全不考虑你的业务类型是CPU密集型还是IO密集型也不考虑你有多少个服务共享这个JVM进程。更要命的是commonPool是整个JVM级别的全局共享线程池你的业务代码在用框架可能也在用第三方SDK可能也在用大家挤在同一组线程上抢资源。你无法控制谁提交了任务也无法单独调整它的队列大小更没法给不同业务设置不同的优先级。生产环境一旦出现任务堆积排查时你甚至都分不清是哪个业务把线程占满了。1.2 一个请求耗完整线程池的复现场景光说理论不直观我写了一个小Demo来模拟真实场景。假设我们有一个接口需要调用外部服务获取用户信息然后基于结果做后续处理。外部服务我们用一个Thread.sleep(1000)来模拟1000毫秒的IO等待。public class SupplyAsyncDemo { public static void main(String[] args) throws InterruptedException { // 模拟24核服务器commonPool并行度23 System.out.println(CPU核数: Runtime.getRuntime().availableProcessors()); long start System.currentTimeMillis(); // 模拟50个并发请求每个请求内部调用supplyAsync CountDownLatch latch new CountDownLatch(50); for (int i 0; i 50; i) { final int requestId i; CompletableFuture.supplyAsync(() - { // 模拟调用远程接口需要1秒 try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return 用户信息- requestId; }).thenAccept(result - { System.out.println(Thread.currentThread().getName() 处理完成: result); latch.countDown(); }); } latch.await(); long cost System.currentTimeMillis() - start; System.out.println(总耗时: cost ms); } }运行结果很有说服力。24核机器上commonPool有23个线程50个任务分两批跑第一批23个第二批23个最后4个第三批总耗时接近3秒。如果你在8核机器上跑并行度750个任务需要8批总耗时直接飙到8秒。有人可能会说3秒也还能接受那你换个场景任务里如果还有同步调用其他接口、查数据库、写Redis的操作IO等待时间从1秒变成3秒、5秒任务从50个变成500个延迟和线程堆积就会成倍恶化。最极端的情况下某个核心接口的一次抖动会耗光公共线程池的所有线程其它所有依赖异步处理的业务全部排队等待故障就像雪崩一样扩散开。这就是我坚持在项目里禁止裸用supplyAsync的原因。2. 线程池参数究竟怎么配才靠谱2.1 核心线程数、最大线程数、队列长度的联动关系既然默认线程池不靠谱那自己配一个。但线程池参数怎么定又是一个让很多人头疼的问题。网上的说法五花八门其实只要理解了ThreadPoolExecutor的调度逻辑配参数就没那么玄乎。ThreadPoolExecutor处理任务的顺序是这样的当线程数小于corePoolSize时新任务直接创建线程执行。当线程数达到corePoolSize后新任务进入阻塞队列等待。当阻塞队列满了之后继续创建新线程直到线程数达到maximumPoolSize。当线程数达到maximumPoolSize且队列也满了触发拒绝策略。这个顺序一定要刻在脑子里核心线程数先干活队列先存任务队列满了才扩到最大线程数最后才拒绝。先说核心线程数。对于IO密集型任务调用远程接口、读写数据库、访问文件系统核心线程数建议设置为CPU核数 * 2或者更高因为线程在IO等待时不占用CPU。更精确的估算公式是线程数 CPU核数 / (1 - 阻塞系数)阻塞系数通常在0.8到0.9之间。以24核机器为例如果阻塞系数取0.85合理线程数约为24 / 0.15 160。注意这个值只是估算起点具体要压测调整。以我的经验IO密集型业务先按2 * CPU核数起步压测后逐步上调同时观察响应时间和线程利用率找到一个拐点。这里线上业务回调阻塞IO的场景较多所以线程数可以给得大方一些。阻塞队列长度也没有标准答案但要牢记一个原则不要让任务无限堆积。队列太长等于变相地把请求都堆在内存里任务没执行完内存先爆了而且客户端早就超时了你在后端继续处理一个已经没有人等待的请求纯属浪费资源。一般建议队列长度结合接口的QPS和平均耗时来估算比如某个异步任务QPS是50平均耗时500毫秒那队列里最多积压50 * 0.5 25个任务队列长度给到50左右就够用。2.2 阻塞队列到底选哪种Java里最常见的阻塞队列有三种各自的适用场景差异很大队列类型是否有界特点适用场景ArrayBlockingQueue有界数组实现必须指定容量公平性可配置推荐用于业务线程池防止任务堆积LinkedBlockingQueue默认无界链表实现不指定容量时容量为Integer.MAX_VALUE慎用任务堆积可能导致内存溢出SynchronousQueue不存储元素每个任务直接交给线程没有等待队列适合需要快速响应、线程数弹性大的场景LinkedBlockingQueue是一个大坑。这个队列默认的容量是Integer.MAX_VALUE几乎等于无界。如果你用new LinkedBlockingQueue()不传容量线程池的maximumPoolSize实际上永远不会触发因为队列永远没满所有超过核心线程数的任务全部堆在队列里。生产环境出现过的OOM很多就是这么来的。SynchronousQueue比较特殊它不缓存任务来个任务必须立即有线程接手。配合一个较大的maximumPoolSize可以实现任务多就多开线程任务少线程就回收的弹性效果。但要注意这种模式下线程的创建销毁频率会很高不适合任务小而多的场景。我的默认选择是ArrayBlockingQueue容量根据业务预估。原因很简单有界队列能让背压机制生效当任务量超过线程池处理能力时队列会满然后触发拒绝策略而不是让任务无限堆积在内存里直到宕机。2.3 拒绝策略的四选一不能拍脑袋ThreadPoolExecutor提供了四种拒绝策略看名字就知道含义拒绝策略行为风险AbortPolicy直接抛RejectedExecutionException默认策略任务丢失且调用方感知不到如果没捕获CallerRunsPolicy由调用方线程执行该任务调用方线程阻塞但形成了天然背压DiscardPolicy静默丢弃任务任务静默丢失很难排查DiscardOldestPolicy丢弃队列中最旧的任务高并发下可能丢弃大量过期任务业务系统里我最推荐CallerRunsPolicy。这个策略的逻辑是线程池处理不过来时不抛异常而是把任务交回给提交任务的线程来执行。对于Web应用来说提交任务的线程通常是Tomcat的工作线程这意味着请求线程会自己处理这个异步任务接口响应会变慢但任务不会丢也不会把进程打挂。这种降级但可用的行为在流量突刺时非常实用。AbortPolicy也不是完全不能用前提是你对RejectedExecutionException做了全局捕获并且降级逻辑明确比如记录日志后返回兜底数据。如果没有任何兜底逻辑一个没有被捕获的异常会导致接口直接返回500调用方的体验很差。2.4 线程工厂和回收策略容易被忽略线程池初始化时还有两个参数经常被忽略线程工厂ThreadFactory和 keepAliveTime。线程工厂一定要自定义主要目的是给线程起一个有意义的名字。我之前排查过一个线上问题线程dump出来一看全是pool-3-thread-1这种名字根本分不清是哪个业务模块的线程。后来统一换成promotion-async-%d这种命名格式线上排障效率提升了一个档次。ThreadFactory threadFactory new ThreadFactory() { private final AtomicInteger counter new AtomicInteger(1); Override public Thread newThread(Runnable r) { Thread thread new Thread(r, promotion-async- counter.getAndIncrement()); thread.setDaemon(false); thread.setPriority(Thread.NORM_PRIORITY); return thread; } };keepAliveTime指线程数超过corePoolSize时空闲线程最多存活的时间。这个参数一般设置30到60秒配合allowCoreThreadTimeOut(true)还可以让核心线程也在空闲时回收适合任务有明显的波峰波谷的场景。我的习惯是业务线程池保留核心线程常驻非核心线程空闲60秒回收这样既保证了低延迟又不至于在流量低谷时白白占着线程栈的内存。3. 完整实战自建线程池接管异步任务3.1 定义一个规范化的线程池配置类既然是实战配置类肯定要写得规范。我建议用独立的配置类来统一管理线程池而不是散落在业务代码里各处new。下面是我在项目中常用的一种写法直接用ThreadPoolExecutor而不是Executors静态方法创建。import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.ThreadFactory; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; public class ThreadPoolConfig { /** * 核心线程数IO密集型场景按CPU核数*2起配 * 当前服务器为24核理论值48 */ private static final int CORE_POOL_SIZE 48; /** * 最大线程数根据压测结果调整暂定128 */ private static final int MAX_POOL_SIZE 128; /** * 阻塞队列容量结合QPS与平均耗时估算 * 假设峰值QPS200平均耗时500ms队列中最大积压任务约100个 */ private static final int QUEUE_CAPACITY 200; /** * 非核心线程空闲存活时间 */ private static final long KEEP_ALIVE_TIME 60L; private static final ThreadPoolExecutor ASYNC_EXECUTOR new ThreadPoolExecutor( CORE_POOL_SIZE, MAX_POOL_SIZE, KEEP_ALIVE_TIME, TimeUnit.SECONDS, new ArrayBlockingQueue(QUEUE_CAPACITY), new NamedThreadFactory(biz-async), new ThreadPoolExecutor.CallerRunsPolicy() ); public static ThreadPoolExecutor getAsyncExecutor() { return ASYNC_EXECUTOR; } static class NamedThreadFactory implements ThreadFactory { private final String prefix; private final AtomicInteger counter new AtomicInteger(1); NamedThreadFactory(String prefix) { this.prefix prefix; } Override public Thread newThread(Runnable r) { Thread thread new Thread(r, prefix - counter.getAndIncrement()); thread.setDaemon(false); return thread; } } }这里解释一下为什么用ThreadPoolExecutor直接创建而不是Executors.newFixedThreadPool。Executors框架的几个快捷方法都有各自的隐患newFixedThreadPool用的是无界LinkedBlockingQueue任务可以无限堆积newCachedThreadPool的最大线程数是Integer.MAX_VALUE极端情况下会创建大量线程导致资源耗尽newScheduledThreadPool同样使用无界队列。生产环境里规范的做法就是通过ThreadPoolExecutor的完整参数来显式控制每一个细节。3.2 用自定义Executor调用supplyAsync的正确姿势有了线程池调用方式就清晰了。CompletableFuture.supplyAsync支持传入自定义的Executor作为第二参数import java.util.concurrent.CompletableFuture; import java.util.concurrent.ThreadPoolExecutor; public class OrderService { private final ThreadPoolExecutor asyncExecutor ThreadPoolConfig.getAsyncExecutor(); /** * 异步获取订单附加信息 */ public CompletableFutureOrderDetail enrichOrderAsync(Long orderId) { return CompletableFuture.supplyAsync(() - { // 1. 远程调用订单服务获取基础信息 OrderBase orderBase remoteOrderClient.getOrderBase(orderId); // 2. 远程调用优惠券服务获取优惠信息 CouponInfo couponInfo remoteCouponClient.getCouponByOrderId(orderId); // 3. 远程调用库存服务获取当前库存状态 StockInfo stockInfo remoteStockClient.getStockStatus(orderId); OrderDetail detail new OrderDetail(); detail.setOrderBase(orderBase); detail.setCouponInfo(couponInfo); detail.setStockInfo(stockInfo); return detail; }, asyncExecutor).whenComplete((result, throwable) - { if (throwable ! null) { // 记录异常日志这里可以把异常信息发送到监控平台 log.error(异步获取订单附加信息失败, orderId: {}, orderId, throwable); } }); } }注意两个关键点。第一所有远程调用都在我们自定义的biz-async线程池中执行线程池的参数我们可以完全掌控不会影响commonPool也不会被别人的任务挤占。第二whenComplete里对异常做了兜底处理避免异常被静默吞掉。这个异常处理习惯非常重要后面会展开讲。3.3 效果验证同样的Demo换成自定义线程池回到最开始的Demo把CompletableFuture.supplyAsync的调用改成传入自定义线程池其余逻辑不变public class SupplyAsyncWithCustomPoolDemo { public static void main(String[] args) throws InterruptedException { ThreadPoolExecutor executor ThreadPoolConfig.getAsyncExecutor(); long start System.currentTimeMillis(); CountDownLatch latch new CountDownLatch(50); for (int i 0; i 50; i) { final int requestId i; CompletableFuture.supplyAsync(() - { try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return 用户信息- requestId; }, executor).thenAccept(result - { System.out.println(Thread.currentThread().getName() 处理完成: result); latch.countDown(); }); } latch.await(); long cost System.currentTimeMillis() - start; System.out.println(总耗时: cost ms); executor.shutdown(); } }因为自定义线程池的核心线程数是4850个任务基本可以同时执行总耗时降到1秒出头。当并发量继续增大到500甚至1000时核心线程、最大线程、队列、拒绝策略四层机制会逐级生效任务仍然能得到有序处理而不是全部堆积在commonPool里相互争抢。3.4 线程池监控别等出事了才想起看数据线程池参数配好了不等于一劳永逸。生产环境必须对线程池的运行状态做监控。ThreadPoolExecutor提供了几个现成的监控方法getActiveCount()查看活跃线程数getQueue().size()查看队列积压量getCompletedTaskCount()查看已完成任务数getTaskCount()查看总任务数。我习惯于在配置类里加一个定时任务把这些指标打印到日志或者推送到监控平台Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() - { ThreadPoolExecutor executor ThreadPoolConfig.getAsyncExecutor(); log.info(线程池监控 - 核心线程数: {}, 活跃线程数: {}, 最大线程数: {}, 队列积压: {}, 已完成任务数: {}, executor.getPoolSize(), executor.getActiveCount(), executor.getMaximumPoolSize(), executor.getQueue().size(), executor.getCompletedTaskCount()); }, 1, 30, TimeUnit.SECONDS);监控数据积累一段时间后你就能看到业务高峰时活跃线程数是否逼近最大线程数、队列积压是否持续上涨。如果队列经常打满且触发拒绝策略说明核心线程数或最大线程数配小了需要压测调整。如果活跃线程数长期远低于核心线程数说明配多了可以适当调低节省资源。4. 常见问题与排查技巧实录4.1 任务异常被Future吞掉日志里什么都没有这是我见过最隐蔽的问题。CompletableFuture和普通线程池执行Runnable不一样任务内部抛出的异常不会直接打印到控制台也不会导致线程中断而是被封装在返回的CompletableFuture里。如果你不调用get()或join()或者没有注册exceptionally、whenComplete回调异常就被静默吞掉了。生产环境典型的现象是某个异步操作一直没结果接口响应正常但数据不对日志里没有任何异常信息排查了半天才发现任务内部抛了NPE。解决方案是在提交任务后强制注册异常处理回调CompletableFuture.supplyAsync(() - doSomething(), customExecutor) .exceptionally(ex - { log.error(异步任务执行异常, ex); return null; });用一个容器类统一封装异步任务的提交和异常兜底避免每个业务方都写一遍重复代码。这是我踩过坑以后才养成的习惯。4.2 线程池参数“配了等于没配”的三种典型案例第一种用了无界队列导致最大线程数形同虚设。线程池配置了核心线程数10、最大线程数100但队列用的是new LinkedBlockingQueue()结果任何时刻线程数都只有10剩下90个线程永远派不上用场。排查方法很简单打印executor.getPoolSize()如果长期等于核心线程数且队列积压不为零多半是队列设成了无界。第二种allowCoreThreadTimeOut没有开启核心线程会保持存活这个倒不是问题。真正的问题在于如果核心线程数设置过大空闲时也占着内存。监控发现线程池长期空闲可以开启这个开关让空闲核心线程也能回收executor.allowCoreThreadTimeOut(true);第三种拒绝策略设置为DiscardPolicy或DiscardOldestPolicy导致任务静默丢失。线上出现过的问题流量高峰时部分订单没有生成账单排查后发现就是DiscardPolicy把任务丢了而且没有任何日志。后来改用CallerRunsPolicy并把丢弃行为记录成WARN日志这个问题才彻底控制住。4.3 父子任务嵌套导致线程池被打满假设你的线程池容量是48某个任务内部又提交了子任务用同一个线程池来执行而父任务在等待子任务的结果。当外部请求量稍大时48个线程全部阻塞在父任务上子任务排不上队所有线程都在死等队列里的子任务完成形成线程饥饿死锁。这种场景在业务代码里很常见比如一个任务里需要并发查询多个下游系统查询操作又复用了同一个线程池。解决思路有几种父任务和子任务用不同的线程池或者干脆在父任务内部不要等待子任务结果改为基于回调来驱动后续逻辑。如果非要等待必须确保线程池的maximumPoolSize大于并发父任务数量并且队列能容纳所有子任务。避免死锁最稳妥的方式还是隔离业务线程池和内部分支执行线程池分开。我之前还遇到过一种内外任务嵌套的情况现象和这个一模一样当时排查了很久才发现是任务串用线程池导致的。自那以后我在项目中明确规定commonPool不用于业务任务提交所有业务异步任务必须有自己独立的线程池并且禁止在任务内部等待同一个线程池上的其它任务。4.4 快速定位线程池问题的方法线上环境线程池出问题最快的定位方式是拿线程dump。Linux下执行jstack pid然后把输出重定向到文件里分析。jstack 12345 thread_dump.txt在dump文件里通过线程名前缀就能快速找到我们自定义的线程池线程比如biz-async-15。重点看这些线程的堆栈状态如果大量线程处于WAITING状态停在ThreadPoolExecutor.getTask()方法上说明线程空闲线程数配置偏多如果大量线程处于RUNNABLE或者阻塞在IO调用上而且队列积压也在上涨说明线程数不够或者下游系统变慢了。再配合jstat -gcutil和top -Hp查看CPU和内存状况可以判断线程池问题是否已经影响到整个服务。这类问题排查多了你会发现一个好的线程命名习惯能节约大量时间。5. 更进一步线程池隔离与Spring整合实践5.1 按业务流程拆分线程池别把所有任务丢进一个池子很多项目的初期阶段图省事搞一个全局线程池所有异步任务共用。等到业务复杂了问题就来了某个报表任务特别耗时把线程都占了导致用户下单的异步通知被延迟处理。合理的做法是按业务模块做线程池隔离。比如用户服务、订单服务、营销服务各自维护独立的线程池参数根据各自的业务特性独立设置。用户服务的IO任务多线程数配高一点营销服务的任务时效性不强队列可以长一点最大线程数可以小一点。隔离的意义在于故障隔离。一个业务的任务风暴不会拖垮另一个业务这点在微服务架构里尤其重要。配合Spring的Bean方式注册线程池还能通过Spring的依赖注入机制统一管理Configuration public class AsyncThreadPoolConfiguration { Bean(userAsyncExecutor) public ThreadPoolExecutor userAsyncExecutor() { return new ThreadPoolExecutor( 16, 32, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(100), new NamedThreadFactory(user-async), new ThreadPoolExecutor.CallerRunsPolicy() ); } Bean(orderAsyncExecutor) public ThreadPoolExecutor orderAsyncExecutor() { return new ThreadPoolExecutor( 32, 64, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(200), new NamedThreadFactory(order-async), new ThreadPoolExecutor.CallerRunsPolicy() ); } }使用的时候通过Qualifier指定具体线程池清晰明了。我见过很多代码把线程池定义为static常量直接用这种做法在测试时很难替换也让配置分散在代码各处。放到Spring容器里统一管理可维护性会好很多。5.2 线程池优雅关闭别让正在执行的任务被打断应用重启时线程池的关闭处理是个容易被忽视的细节。直接调用executor.shutdownNow()会让正在执行的任务收到中断信号如果任务里有Thread.sleep或IO阻塞可能直接抛出异常导致数据不一致。更稳妥的关闭流程是先调用shutdown()停止接收新任务然后等待一段时间让已提交的任务自然完成。如果超过了最大等待时间还有任务没完成再考虑shutdownNow()强制终止。public void close() { asyncExecutor.shutdown(); try { if (!asyncExecutor.awaitTermination(30, TimeUnit.SECONDS)) { asyncExecutor.shutdownNow(); } } catch (InterruptedException e) { asyncExecutor.shutdownNow(); Thread.currentThread().interrupt(); } }如果是Spring应用可以通过实现DisposableBean或者注册PreDestroy回调来执行这段逻辑。我遇到过因为没做优雅关闭上线发布时一批异步任务被强制中断造成部分用户通知漏发的情况。后面加了这段关闭逻辑类似问题再没出现过。5.3 把Context传给子线程避免跨线程丢数据使用线程池后一个常见痛点是ThreadLocal里的上下文信息传不进去。比如你从请求入口放了traceId进ThreadLocal到异步任务里想打印这个traceId发现是null整个链路追踪直接断掉。Java原生的ThreadLocal是线程隔离的子线程拿不到父线程的值。好在有TransmittableThreadLocal阿里巴巴开源这个库它能在提交任务时自动把父线程的上下文快照传给子线程任务执行完再恢复实现链路追踪信息跨线程传递。// 使用TTL包装线程池 ExecutorService ttlExecutor TtlExecutors.getTtlExecutorService(originalExecutor);如果你还不想引入额外的依赖也可以自己写包装类在提交Runnable时把当前ThreadLocal的值拷贝一份包装成新的Runnable执行。但TransmittableThreadLocal的实现更完善对ExecutorService、ForkJoinPool、CompletableFuture都有适配建议直接使用。5.4 业务代码里的异步改造建议用CompletableFuture做异步改造时很多人一上来就到处用supplyAsync结果代码复杂度飙升维护起来很痛苦。我的建议是先梳理业务链路找出真正的性能瓶颈比如那些串行调用多个远程接口的逻辑先改成并行调用而那些本身执行很快的本地计算没必要异步化异步带来的线程切换和代码复杂度反而得不偿失。异步改造时还要特别注意事务边界。事务放在异步任务内部执行主线程就无法感知到事务的提交结果主线程里的操作和异步任务里的操作如果依赖同一个事务会产生各种诡异问题。所以异步任务最好只做不依赖主事务的结果处理比如发通知、写日志、刷新缓存不要把核心的数据一致性逻辑放到异步里。另外异步改造过程中要控制好回调的层级。thenApply、thenCompose、thenCombine这些方法连起来写逻辑确实很简洁但调试的时候看堆栈很痛苦。建议把链条控制在4到5层以内每层之间用有意义的变量名做中间结果传递到了排查问题的时候你会感谢当时的自己。6. 写在最后我对Java并发编程的个人体会做Java开发这些年线程池的坑踩了不止一次每次复盘都会发现问题根因大多不是技术本身而是对默认行为的不了解、对参数配置的随意。CompletableFuture.supplyAsync是个好工具但默认的commonPool绝对不是生产环境的选择。项目里凡是涉及异步任务的我现在的习惯是先明确任务类型是CPU密集型还是IO密集型再按业务维度配置独立的ThreadPoolExecutor配合有界队列和合理的拒绝策略同时把线程池监控和异常兜底机制同步做好。这套流程走下来线程池相关的线上问题出现概率大幅下降。最后再分享一个小技巧如果你评估下来某个异步任务确实不重要允许丢失也请用DiscardPolicy加上日志而不是静默丢弃。任何任务被丢弃都应该有迹可循这是排查线上问题最后的底牌。
返回列表