1. 先想清楚多线程和异步调用到底是什么关系1.1 同步阻塞的痛点在哪里做Java开发的人大概率都遇到过这样的场景一个接口里面依次调了三个服务——查用户信息、查订单列表、查库存状态最后把结果拼装返回。每个服务虽然只要几十毫秒但串行加起来就是几百毫秒如果中间某个服务再慢一点接口直接奔着秒级去了。这种“一个个排队等结果”的方式就是典型的同步阻塞调用。同步阻塞的本质是线程发起调用后就一直傻等在那里CPU并没有干别的活纯粹在浪费资源。这就好比你去餐厅点菜厨师做一道菜你要等二十分钟这二十分钟你什么都干不了只能坐在那里干瞪眼。更麻烦的是如果这个线程是web容器里的工作线程它被占住了其他请求就得排队等线程释放吞吐量自然上不去。我最早接触Java并行优化时第一反应就是“多开几个线程来跑”但又搞不清楚这跟“异步调用”有什么区别。后来才慢慢明白异步调用是一种调用方式多线程是承载异步调用的具体实现手段之一。异步的本质是“发起调用后不立即等待结果”而让别的线程去执行耗时操作等执行完了再通过回调、Future或者CompletableFuture拿结果。没有多线程单纯写异步代码是搞不了并行加速的——总得有另一个执行流去干那些耗时的活吧。1.2 多线程是异步调用的“载体”先打个比方。同步调用是“你自己站在打印机旁边等打印完”异步调用是“你把打印任务丢给打印店老板说打印好了给你打电话你先去干别的事”。在这个比方里打印店的店员就是另一个线程。Java里你没法让一个单线程“一边跑业务代码、一边等IO结果”能做到并行执行的只能靠多个线程。单线程也能实现某种意义上的异步比如基于事件循环的Netty模型一个线程不断轮询事件看起来也是非阻塞的。但那是另一套编程模型对于绝大多数业务系统来说最实用的方式还是在线程池里提交任务让工作线程去执行耗时逻辑。理解了这个关系以后看代码、调优就会清晰很多异步调用解决的是“调用方不阻塞”的问题多线程解决的是“并行执行多个任务”的问题两者结合就是“在不阻塞主线程的前提下并行处理耗时任务”2. Java实现多线程异步调用的几种姿势2.1 最原始的new Thread为什么没人用了很多Java新手第一次接触异步就是new一个Thread出来跑。代码很直观new Thread(() - { // 执行耗时操作 sendEmail(); }).start();这样确实“异步”了主线程不会等sendEmail执行完就继续往下走。但真正用到生产环境问题一大堆线程创建销毁开销大每次都要经历创建、执行、销毁的过程高并发下频繁new Thread对内存和CPU都是不小的压力无法统一管理线程多了以后没法统一控制并发数量、没法优雅关闭没有返回值想拿到子线程的结果得自己搞共享变量或者回调还得处理线程安全所以这个方案基本只适合写写demo和测试脚本。真实业务里早该被ExecutorService替代了。2.2 ExecutorService Future能拿到结果的异步用线程池提交任务可以获得一个Future对象通过Future.get()拿到任务执行结果ExecutorService executor Executors.newFixedThreadPool(8); FutureString userFuture executor.submit(() - getUserInfo()); FutureString orderFuture executor.submit(() - getOrderList()); String user userFuture.get(3, TimeUnit.SECONDS); String orders orderFuture.get(3, TimeUnit.SECONDS);这段代码的核心价值在于两个耗时任务在同一个时刻并行执行整体耗时从“两者之和”变成了“两者较大值”。Future.get()可以传超时时间防止一个任务卡死导致整个接口无响应。但Future的短板也很明显多个异步任务之间的编排非常麻烦。比如“A、B两个任务都完成后再执行C”用Future你得先get完A再get完B然后手动提交C代码绕来绕去可读性很差。想实现“A成功就执行BA失败就执行C”这种逻辑Future直接做不到只能在回调里手写状态判断。2.3 CompletableFuture现代Java异步编排的答案从Java 8开始CompletableFuture彻底改变了异步编程的体验。它把回调、编排、异常处理都集中到了同一个API里CompletableFutureUser userFuture CompletableFuture.supplyAsync(() - getUserInfo(), executor); CompletableFutureListOrder orderFuture CompletableFuture.supplyAsync(() - getOrderList(), executor); CompletableFutureString result userFuture .thenCombine(orderFuture, (user, orders) - buildResponse(user, orders)) .exceptionally(ex - { log.error(并行调用失败, ex); return fallbackResponse(); });thenCombine就是“两者都完成之后再合并”的编排方法exceptionally则是异常兜底。除此之外还有thenApply串行转换、thenCompose依赖式组合、allOf全部完成、anyOf任意一个完成等方法基本覆盖了业务开发中遇到的所有异步编排场景。CompletableFuture确实是Java异步编程的主流方案但有几个坑必须提前知道默认使用ForkJoinPool.commonPool()这是全局共享的线程池任务多了会互相干扰生产环境务必传入自定义线程池如果用它来包装一个方法实际上任务已经执行了回调是在任务完成后的某个时间点触发的上下文信息比如traceId可能丢失异常如果不处理默认是静默吞掉的常人容易踩坑里2.4 Spring Async企业级开发最常用的注解业务系统大多基于Spring开发那自然绕不开Async。这是最简单的一种异步方式——在方法上加一个注解Spring就会让这个方法在线程池中执行Service public class NotifyService { Async(notifyExecutor) public void sendSms(String phone, String content) { // 模拟短信发送 } }前提是要在启动类或者配置类加EnableAsync开启异步支持同时自定义一个线程池BeanConfiguration public class AsyncConfig { Bean(notifyExecutor) public Executor notifyExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(8); executor.setQueueCapacity(200); executor.setThreadNamePrefix(notify-); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }这里有个很典型的坑Async在同一个类内部调用是不会生效的。Spring通过代理实现注解功能同类内部调this.method()根本没走代理注解就被忽略了。解决办法是拆到另一个Bean里或者自己注入自身代理。选哪个方案主要看场景场景推荐方案一次性任务简单触发ExecutorService需要多个任务编排、合并结果CompletableFuture企业业务系统里的异步方法Async 自定义线程池全链路日志追踪、上下文传递需要在线程池上加装饰器稍后细说3. 核心难点线程池参数到底怎么定3.1 线程数量不是拍脑袋定的很多开发者在配置线程池时corePoolSize随便写个10maxPoolSize写个20就完事了。这种做法一旦流量上来线程池要么排队排到天荒地老要么直接拒绝任务。线程数应该根据任务类型来估算基本原则是CPU密集型任务核心线程数约等于CPU核数1。因为每个线程都在做计算线程多了反而因为上下文切换拖慢速度IO密集型任务核心线程数约等于CPU核数*2甚至可以更大。因为线程大部分时间在等待IO等待期间可以把CPU让给别的线程假设生产服务器是4核8G任务以调用外部接口为主属于IO密集型那核心线程数定在8左右合理。最大线程数不能无限大要结合JVM内存和任务的资源消耗评估一般建议是核心线程数的2倍以内。再给出一个我在实际项目里用来估算的思考框架核心其实是积累了足够的调用量压测数据后再根据指标来调整线程池参数这比死记公式靠谱得多压测到响应时间不达标时看线程池的activeCount和queueSize如果队列一直在堆说明核心线程数太小或者下游处理能力已经瓶颈逐步加大线程数观察响应时间、错误率、下游负载三个指标找到“线程数增加但吞吐量不再提升”的拐点那就是合适的线程数3.2 队列选型与拒绝策略线程池的工作流程是先上核心线程核心线程满了进队列队列满了才加非核心线程最大线程也满了就走拒绝策略。很多人没搞清楚一个细节LinkedBlockingQueue如果不传容量默认是Integer.MAX_VALUE等于队列无限大。这种情况下maxPoolSize和拒绝策略根本不会生效所有任务都在排队一旦流量突增内存就撑爆了。所以用LinkedBlockingQueue一定要显式指定容量或者用SynchronousQueue这种不存储任务的队列。拒绝策略有四种AbortPolicy默认策略直接抛RejectedExecutionException缺点是可能把整个调用链路打断CallerRunsPolicy谁提交谁执行调用方线程直接跑这个任务好处是不会丢任务坏处是调用方线程被占住接口响应变慢DiscardPolicy / DiscardOldestPolicy静默丢弃不推荐用在业务系统里任务丢了你都不知道我的经验是通知、日志、短信这类允许延迟的任务用CallerRunsPolicy比较稳。核心业务任务就不要扔线程池了改成消息队列更合适。3.3 线程池隔离别把鸡蛋放一个篮子里一个应用里如果只有一个线程池跑批任务把线程占满后正常的业务请求也一起被拖死。这正是线程池隔离要解决的问题。不同业务用不同的线程池一个池子满了只影响它自己的业务不会殃及池鱼。我在做订单系统和消息推送系统共存的那个项目时就是这么干的orderQueryExecutor订单查询任务4核心8最大队列200notifyExecutor短信邮件通知2核心4最大队列500reportExecutor报表生成2核心4最大队列500每个池子的线程名前缀都设成业务相关的标识比如order-query-排查问题时jstack一眼就能看出是哪个业务线程出了问题。4. 一套完整实操订单状态查询接口的异步改造4.1 业务场景与改造目标我这里用之前做过的一个订单状态查询接口来整体演示。原接口逻辑是查订单基本信息数据库查询约30ms查物流轨迹外部物流API约200ms查优惠明细另一个服务约150ms汇总数据拼装返回四个步骤串行执行总耗时大约380ms。这个耗时其实不算夸张但在大促期间日志、报表都在打这个接口响应时间就明显抬头了。改造目标很明确把不依赖先后顺序的三个查询并行执行把总耗时压到最慢的那个任务耗时以内。4.2 同步版本先有基准数据改造前先写一个模拟的同步实现方便后面对比public OrderDetailVO queryOrderDetail(Long orderId) { long start System.currentTimeMillis(); // 1. 查订单基本信息 Order order orderMapper.selectById(orderId); // 约30ms // 2. 查物流轨迹 TrackInfo track trackClient.query(order.getTrackNo()); // 约200ms // 3. 查优惠明细 PromotionInfo promo promoClient.query(order.getPromoIds()); // 约150ms // 4. 汇总 OrderDetailVO vo OrderDetailVO.builder() .order(order) .track(track) .promotion(promo) .build(); long cost System.currentTimeMillis() - start; log.info(queryOrderDetail syn cost {}ms, cost); return vo; }实测下来稳定在350~400ms之间。4.3 异步版本CompletableFuture并行查询改造后的逻辑就是把三个互不依赖的查询丢进线程池并行执行ExecutorService queryPool new ThreadPoolExecutor( 8, 16, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue(200), new ThreadFactoryBuilder().setNameFormat(order-query-%d).build(), new ThreadPoolExecutor.CallerRunsPolicy() ); public OrderDetailVO queryOrderDetailAsync(Long orderId) { long start System.currentTimeMillis(); CompletableFutureOrder orderFuture CompletableFuture.supplyAsync( () - orderMapper.selectById(orderId), queryPool); CompletableFutureTrackInfo trackFuture CompletableFuture.supplyAsync( () - trackClient.query(orderId), queryPool); CompletableFuturePromotionInfo promoFuture CompletableFuture.supplyAsync( () - promoClient.query(orderId), queryPool); try { CompletableFutureOrderDetailVO resultFuture orderFuture .thenCombine(trackFuture, (order, track) - { OrderDetailVO vo new OrderDetailVO(); vo.setOrder(order); vo.setTrack(track); return vo; }) .thenCombine(promoFuture, (vo, promo) - { vo.setPromotion(promo); return vo; }); OrderDetailVO vo resultFuture.get(2, TimeUnit.SECONDS); long cost System.currentTimeMillis() - start; log.info(queryOrderDetail async cost {}ms, cost); return vo; } catch (Exception e) { log.error(queryOrderDetail async error, e); // 兜底逻辑降级为同步查询 return queryOrderDetail(orderId); } }改造后实测耗时从380ms降到210ms左右其实已经是“三个任务中最慢那个拼接开销”的极限了。这个方案最值得学习的点是传入自定义线程池避免使用公共的ForkJoinPoolget()设置了2秒超时防止外部接口卡死导致线程池堆积catch住异常后降级为同步查询保证核心接口不挂4.4 改造过程中踩过的坑第一次改造时我犯过一个很低级的错误忘了给resultFuture.get()加超时时间。结果物流接口有一次故障响应花了30秒线程池里的工作线程全部被卡住后续任务全在队列里等着连锁反应直接拖垮了其他业务。加了超时之后最坏情况就是接口走到降级逻辑线程池能很快恢复。还有一个坑是关于共享数据的。一开始我在多个thenApply回调里直接修改同一个OrderDetailVO对象以为通过引用传递没问题。后来发现回调之间是并发执行的同时写同一个对象的字段存在竞态条件偶尔出现优惠信息被覆盖。解决方法是让每个回调返回一个新的不可变对象最后再组装。5. 真实项目中的典型问题与排查方法5.1 常见问题速查表问题现象可能原因解决方案异步任务不执行线程池饱和任务被拒绝检查队列容量、拒绝策略、线程池监控接口偶发超时某个外部调用超时没设限制Future.get加超时或者用orTimeout日志没有traceId子线程无法继承ThreadLocal用TaskDecorator在工作线程提交前复制上下文事务不生效Transactional方法在异步线程中调用事务和异步不能共存要么改造为事务回调内存撑爆无限队列积压任务给LinkedBlockingQueue设置容量上限数据库连接池耗尽异步任务高并发抢占连接控制线程数量必要时单独配置数据源5.2 线程池任务堆积接口大面积超时这是一个运营后台Excel导出功能引发的故障。导出的每条数据都走一个异步线程去查询汇总本来只是导出几百条后来运营一次导出了几万条线程池队列疯长内存飙升GC频繁整个服务都变慢。排查过程先看监控发现导出线程池的queueSize持续上涨活跃线程数已经打满maxPoolSize用jstack查看线程状态大量线程都卡在数据库查询的JDBC调用上再看连接池指标activeCount已经打满说明不是导出逻辑复杂而是数据库连接不够用了结论导出任务抢占了所有数据库连接影响了正常业务查询解决办法是双管齐下一是给导出任务单独配一个数据源限制最大连接数避免影响主业务二是把导出功能改成分批异步处理每批100条通过消息队列控制消费速率。这条经验说明了两个道理异步线程池必须按业务隔离数据库连接池是共享资源滥用异步任务就是在跟其他业务抢资源。5.3 异步任务里的异常为什么“消失”了有段时间我排查线上问题发现短信没发出去但日志里没有任何异常。查到最后问题出在一个Async方法上Async(notifyExecutor) public void sendSms(String phone, String content) { try { smsClient.send(phone, content); } catch (Exception e) { log.error(sms send error, e); } }这次代码本身有catch问题不大。但之前如果写成这样Async(notifyExecutor) public void sendSms(String phone, String content) { smsClient.send(phone, content); // 异常会丢失 }那异常就会静默消失。原因在于Async的异步方法返回值是void时异常不会被调用方捕获只有方法内部自己try-catch或者返回Future/FutureTask才能通过get()拿到异常。解决方案三条方法内部自己catch并记录日志使用AsyncUncaughtExceptionHandler统一处理未捕获异常涉及关键业务的异步任务建议返回CompletableFuture由调用方统一处理异常5.4 ThreadLocal在异步线程中丢失的问题在一次链路追踪改造中我在过滤器里把traceId放进了ThreadLocal结果发现异步任务日志里的traceId全是空的。原因很直接异步任务的代码运行在工作线程里ThreadLocal的值跟当前线程绑定而工作线程是一个无关的线程自然没有traceId。解决办法是在线程池提交任务前把主线程的上下文复制过去。Spring提供了TaskDecorator机制Bean public Executor asyncExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(8); executor.setMaxPoolSize(16); executor.setQueueCapacity(200); executor.setThreadNamePrefix(async-); executor.setTaskDecorator(runnable - { MapString, String context TransmittableThreadLocal.holder(); return () - { // 设置子线程上下文 TransmittableThreadLocal.holder().set(context); runnable.run(); // 清理避免线程串数据 TransmittableThreadLocal.holder().clear(); }; }); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; }如果你在项目里用了阿里的TransmittableThreadLocal这个组件本身就解决了线程间上下文传递的问题比自己手动复制稳妥得多。如果没引入这个依赖老老实实在代码里传参把必要的上下文信息作为方法参数传进异步任务里不依赖全局变量触发。我在实际项目里主要用TransmittableThreadLocal来做traceId、用户信息的异步传递。只在自己处理不了的地方比如接了第三方的连接池才会手动复制。保守的做法永远是最稳的能传参数就传参数尽量少依赖隐式的上下文传递。5.5 Transactional和Async放在一起为什么事务失效这也是个高频坑。在一个Service方法上同时标注Async和Transactional本意是“异步执行并且这个异步操作要带事务”。结果发现事务完全没生效数据写了一半异常后没有回滚。原因在于Spring的事务是基于代理实现的事务拦截器要包裹在同一个线程里才能管理连接事务。而Async让方法在另一个线程执行事务管理器根本感知不到这个新线程的连接。说白了事务和异步是两个不同维度的机制强行组合会互相打架。我的建议是拆分设计在事务方法内部只做数据操作不要在里面发起异步调用事务提交成功后再通过EventListener或发送消息触发异步流程如果一定要异步处理数据把事务边界设计成单独的Service由外部在同步代码里调用5.6 压测时我要监控哪些指标最后分享一个我从故障中总结出来的线程池监控清单。线程池不是配置完就万事大吉上线后需要持续观测activeCount活跃线程数是否长期接近maxPoolSizequeueSize队列积压量大促期间是否快速增长completedTaskCount完成任务总数间接反映吞吐量rejectedCount拒绝的任务数一旦大于零说明配置有严重问题线程池中的线程状态通过jstack定期抓取看是否有线程长时间卡在外部调用上这些指标在Spring里可以用ThreadPoolTaskExecutor Micrometer暴露出来接到监控平台上。有监控和没有监控线上故障的处理时间差距是分钟级的。个人在用了多年异步之后最大的体会是异步调用不是银弹它解决的是“等待”的问题不是“性能”的问题。一个下游接口本身要3秒你异步调用只是不让你自己傻等并不改变下游3秒的事实。如果下游实在慢要么改它要么缓存要么彻底重构——异步只是调度手段不能背性能问题的锅。再分享一个实战中的小技巧写异步代码时把“降级方案”当成必选项来做。只要有异步就一定要想清楚“如果异步任务挂了怎么办”。同步代码挂了异常直接抛给上层异步代码挂了很容易静默消失所以降级策略、兜底日志、监控指标这三件事必须在异步方案落地时同步考虑。
企业数字化 ERP 产品动态
相关推荐
配电网日前优化调度:两阶段鲁棒模型与Matlab+CCG实现 调度员最怕的不是负荷突增,而是头天晚上排好的计划,第二天早上九点被光伏出力的暴涨彻底打乱。含分布式电源的配电网日前优化调度之所以难做,难点不在优化算法本身,而在于你做的每一个决定,离实际执行的时刻还有24小时… · 2026/9/26 13:42:15
腾讯二面追问 AGENTS.md:Claude Code 与 Cursor 的配置骨架该怎么写 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/26 13:42:08
如何在 Android Studio 中配置 TaoToken 并调试 SQLite 数据库(上) /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/26 14:20:35
机器学习图书分类实战:从数据预处理到算法实现完整解析 简介:基于机器学习算法的图书分类系统源码,面向计算机专业学生、机器学习初学者与图书管理开发者,借助模式识别技术实现图书文本自动分类与推荐。项目覆盖文本清洗、特征提取、数值化表示等预处理流程,实现贝叶斯分类器对文学类与… · 2026/9/26 14:20:35
Docker与gVisor混合沙箱实战:Tool安全隔离选型与加固指南 1. 项目概述:为什么一个“Tool”需要沙箱?你有没有遇到过这样的情况:公司内部开发了一个自动化报表生成工具,部署在测试环境跑得好好的,一上线就莫名其妙把生产数据库的连接池打满;或者运维同事临时拉起一个… · 2026/9/26 14:20:35
openGauss 1.1.0教学实践:重庆大学数据库最小可运行闭环 简介:本资源是重庆大学数据库课程的全套学习资料包,面向计算机及相关专业本科生、考研备考学生及数据库初学者,系统覆盖理论学习、实验操作、试题训练与复习巩固全环节。压缩包共185个文件,以25个PDF(含课程讲义、复习… · 2026/9/26 14:20:34
从零搭建数据展示站:Flask+SQLite+WorkBuddy实战复盘 1. 从零建站这件事,为什么我选了 WorkBuddy 加 Flask 这套组合去年年底我接手了一个挺有意思的私活,帮一个做农产品批发的朋友搭一套价格数据展示站。需求说起来不复杂:把每天从几个渠道抓到的价格数据存下来,做一个能看趋势、能查… · 2026/9/26 14:20:17
多模态大模型:从CLIP到语义空间,落地挑战与实践 1. 从一条朋友圈动态说起:为什么单模态注定不够用 我有个做电商运营的朋友,前阵子跟我说了一件事。他们的客服团队每天要处理上千条咨询,其中很大一部分是"发一张商品照片问有没有这款""拍个截图问怎么退款"——纯文字客… · 2026/9/26 14:20:17
数据库课后习题答案别硬背:当测试用例集刷,效率翻倍 简介:万常选版《数据库原理与设计》课后习题答案资源,覆盖第2至6章及第9章,适合正在学习关系模型、数据库建模、关系数据理论与模式求精的本科生、自学者作为复习与自测材料。压缩包共7个文件,含3个doc参考答案、2个sql示例脚本、… · 2026/9/26 0:00:21
OpenClaw 替代品?Hermes Agent 踩坑实录:macOS 飞书接入 TaoToken 配置 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/26 0:00:40
向下兼容与向上兼容:接口设计中的兼容性策略与工程实践 一次版本升级事故,是很多团队绕不过去的坎。线上环境里,服务端明明已经上线了新版接口,老的移动端还在照着旧文档传参数。请求一到网关,校验直接拒绝,用户操作失败,客服群炸了锅,开发群里开始互… · 2026/9/26 0:00:46