
线上Java业务系统出问题的时候最磨人的往往不是报错本身而是问题像幽灵一样时隐时现。尤其是那些“本地跑得好好的、一上线就抽风”的并发等待、事务失效、数据串号、任务静默丢失这类问题排查起来既费时间又费头发。作为一名常年跟业务代码和线上故障打交道的Java开发者我把实际工作中遇到的高频业务问题场景做了个复盘从表象、排查链路到根因和解决方案尽量完整地还原当时的处理过程。这篇文章适合正在业务系统里写代码、改Bug、处理线上故障的Java开发也适合准备面试时需要真实案例支撑的进阶者。1. 多线程“等待所有任务完成”却永久卡死从jstack定位到CountDownLatch根因1.1 一个看似无害的并发报表导出场景有段时间我们做了一个To B的报表导出功能单次导出需要从多个数据源拉取统计数据最耗时的那个接口平均要跑两三秒如果串行执行一个导出任务要等十几秒体验很差。最初的方案很自然就是多线程并发拉取然后用一个计数器等待所有线程都执行完再统一打包导出。当时的代码大致长这样:ExecutorService pool Executors.newFixedThreadPool(4); CountDownLatch latch new CountDownLatch(3); pool.execute(() - { ListOrderStat stats orderService.queryOrderStat(); reportContext.setOrderStats(stats); latch.countDown(); }); pool.execute(() - { ListPayStat stats payService.queryPayStat(); reportContext.setPayStats(stats); latch.countDown(); }); pool.execute(() - { ListUserStat stats userService.queryUserStat(); reportContext.setUserStats(stats); latch.countDown(); }); latch.await(); // 拿到全部数据后做合并导出...线上跑了一段时间突然有运营反馈说某个报表任务一直卡在“生成中”后台日志也没有异常就像整个任务凭空停住了一样。第一反应是怀疑数据库压力大导致某个查询hang住了但是看监控数据库连接、慢查询都正常服务CPU和内存也没有异常。1.2 排查链路jstack一眼看出谁在等谁遇到这种“进程还活着但逻辑不往下走”的问题第一件事不是猜而是拿现场。我习惯第一时间执行jstack pid把线程栈打出来截取关键片段:pool-3-thread-1 waiting on [0x00000000d4f2e000] at java.lang.Object.wait(Native Method) - waiting on 0x00000007813b48f8 (a java.util.concurrent.CountDownLatch$Sync) at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquireSharedInterruptibly at java.util.concurrent.CountDownLatch.await(CountDownLatch.java:230)主线程停在了CountDownLatch.await()上而线程池里的worker线程状态却是WAITING在LinkedBlockingQueue.take()上。看到这里心里基本有数了:worker线程并没有在执行我们提交的任务而是全部空闲地等在队列上。这说明我们提交的3个任务里至少有一个没有被调度到或者已经执行完了但countDown()没执行。回顾代码Executors.newFixedThreadPool(4)队列用的是无界的LinkedBlockingQueue理论上4个线程处理3个任务不会排队才对。继续往下看jstack发现线程池里只有2个worker线程而不是4个——原来这个服务里还有其他地方也用静态公共线程池之前有人提交过阻塞任务把线程占满了后面提交的任务全部排队我们的3个任务里至少有1个在队列里根本没开始执行而主线程已经提前await了。1.3 根因修复与正确的并发等待姿势这个案例暴露出两个问题第一业务线程池和公共线程池被混用资源互相挤占第二CountDownLatch的countDown()没有保证在finally里执行一旦任务中抛出异常或线程被占满主线程就会一直等下去。正确的修复策略如下ExecutorService pool new ThreadPoolExecutor( 4, 4, 0L, TimeUnit.MILLISECONDS, new ArrayBlockingQueue(100), new NamedThreadFactory(report-export), new ThreadPoolExecutor.CallerRunsPolicy() ); CountDownLatch latch new CountDownLatch(3); Future? f1 pool.submit(() - { try { reportContext.setOrderStats(orderService.queryOrderStat()); } finally { latch.countDown(); } }); // f2、f3类似... // 核心await要加超时绝不能无限等 if (!latch.await(30, TimeUnit.SECONDS)) { log.error(报表导出等待超时, context{}, reportContext); // 超时后主动取消还在执行的任务 f1.cancel(true); f2.cancel(true); f3.cancel(true); }有几个点值得展开。第一个是线程池必须独立命名。很多公司会提供一个全局的ExecutorService静态实例大家图省事都往里面丢任务一旦某个任务里写了死循环或者锁竞争整个公司的业务线程都会被拖垮。我之前踩过一次之后所有的线程池都改成独立创建并且用ThreadFactory设置清晰的名字例如report-export-thread-1。这样下次jstack时一眼就能看出阻塞发生在哪个业务模块的线程池里。第二个是**await()一定要带超时**。无参的await()会无限等待线上故障往往就是某个子任务静默失败后主任务挂死整个流程卡住。加超时之后即使子任务出问题主线程也能及时止损通过取消、补偿或者报警机制把问题抛出来而不是让用户一直等。第三个是优先用CompletableFuture替代手动CountDownLatch。尤其是JDK 8以后写并发等待的代码更推荐这种方式CompletableFutureListOrderStat orderFuture CompletableFuture.supplyAsync(() - orderService.queryOrderStat(), pool); CompletableFutureListPayStat payFuture CompletableFuture.supplyAsync(() - payService.queryPayStat(), pool); CompletableFutureListUserStat userFuture CompletableFuture.supplyAsync(() - userService.queryUserStat(), pool); CompletableFuture.allOf(orderFuture, payFuture, userFuture) .get(30, TimeUnit.SECONDS);allOfget(timeout)天然支持超时控制而且每个子任务的异常会包装到CompletionException里排查时能直接看到是哪个任务、哪个方法抛的异常比CountDownLatch的“集体失联”清晰太多。1.4 这类问题在面试里的变体这个场景几乎是Java并发面试的常客市面上常见的问法包括CountDownLatch、CyclicBarrier、Semaphore有什么区别线程池的饱和策略有哪几种、分别适用什么场景submit()和execute()的区别如何实现“多个线程全部执行完再继续”的效果。面试官想要听到的不是背诵八股文而是你真正处理过“主线程等不到子线程完成”的场景知道countDown()必须放在finally知道await要加超时知道线程池不会无限帮你兜底。这些内容在后面的章节还会反复用到因为并发问题往往不是孤立出现的。2. Stream流式处理中的类型陷阱一次toArray引发的线上ClassCastException2.1 现象复现看似理所当然的强转如果说并发等待问题让人心梗那Stream的类型问题就是那种“明明很小但就是让你反复怀疑人生”的Bug。有一次在升级JDK 11之后线上突然报了一堆ClassCastException堆栈指向一行很简洁的代码String[] idArr (String[]) userService.queryAllUserIds().stream().toArray();本地测试怎么也复现不了因为本地用的JDK 8而线上是JDK 11。表面上看这行代码没什么问题——queryAllUserIds()返回的是ListStringstream().toArray()得到的数组强转成String[]不是很自然吗但真相是Stream.toArray()无参重载返回的是Object[]不是T[]。也就是说无论流里的元素类型是什么无参toArray()得到的都是Object[]强转成String[]必抛ClassCastException。2.2 真正原因无参toArray与有参toArray的语义差异这个行为的根源在于Java泛型的运行时擦除。StreamT在运行时并不知道T的具体类型所以无参toArray()只能制造一个Object[]。要给到正确类型的数组必须传入一个“数组构造器”也就是有参版本String[] idArr userService.queryAllUserIds() .stream() .toArray(String[]::new);那为什么本地JDK 8不报错因为JDK 8里很多集合的stream()走的是Collection.stream()内部ArrayPipeline的toArray()在某些实现下恰好返回了运行时类型正确的数组但这是底层实现细节不是语言规范保证的。JDK 11修掉了这个“过于好心”的行为统一返回Object[]于是原本能跑的代码就暴露出了问题。这件事给我的教训是永远不要依赖集合内部实现的“巧合行为”规范怎么写就怎么用。2.3 流式处理的三个附加雷区toArray只是Stream问题的一个入口类似的“业务代码没问题但线上抽风”的Stream场景还有很多顺手总结一下常见雷区。第一个雷区是并行流修改共享状态。用parallelStream()处理列表时如果在流里修改外部的一个HashMap或者ArrayList会出现数据丢失、死循环甚至CPU飙满。因为并行流底层是ForkJoinPool多个线程同时读写非线程安全的集合后果无法预料。并行流只适合无状态、无共享变量的场景。第二个雷区是**peek()只是个调试工具不是forEach()**。peek()是一个中间操作它不保证每个元素都会被消费尤其是流没有终止操作时peek()可能什么都不做。有人习惯在peek()里打印日志结果发现日志时有时无原因就在这。真正要看每个元素的处理情况用forEach()或者map()去显式处理。第三个雷区是在流里做远程调用或IO操作。比如list.stream().map(orderService::queryDetail).collect(Collectors.toList())如果list有几百上千个元素这个流会串行发起几百次数据库查询或HTTP调用性能极差。正确的做法是批量查询或者使用CompletableFuture并发分批处理后再汇总。2.4 排查流问题时的日志与断点技巧排查Stream相关问题时一个很有效的技巧是把复杂的链式调用拆开每一段结果单独打日志。比如ListString ids userService.queryAllUserIds(); log.info(ids size{}, type{}, ids.size(), ids.getClass()); StreamString stream ids.stream(); String[] arr stream.toArray(String[]::new);这样一旦某一步的类型、数量、内容不对劲日志会立刻暴露问题。另外一个实用工具是IntelliJ IDEA的Trace Current Stream Chain功能能在调试时直观看到每一步map/filter的结果定位“哪个元素在哪一步被过滤掉了”、“哪些元素没有走到终止操作”非常方便。遇到流里的NullPointerException不要只看堆栈行号要用调试器逐步观察是哪个元素、哪个中间操作抛的异常往往能发现是数据源里混入了null值而流的管道表达式掩盖了这个源头。3. “为什么我的Spring事务/切面没生效”——动态代理失效的业务故障复盘3.1 典型表象事务不回滚、切面不执行下面这个场景来自一个真实的支付对账模块。当时我们有一个ReconcileService里面有个process()方法整体加了Transactional平时通过接口调用一切正常。后来新增了一个内部定时任务直接调用同一个Service的另一个方法doReconcile()doReconcile()内部又调用了process()。结果线上出现对账数据部分成功、部分失败数据库里留下了半截脏数据事务完全没有回滚。更诡异的是这个模块还有一个自定义注解AuditLog用来记录操作日志切面在外部调用时正常工作但定时任务里调用时日志就是写不进去。这两个问题本质上是一个问题通过this.method()调用同一个类里的另一个方法走的是当前对象而不是Spring生成的代理对象事务和切面都建立在代理对象上绕过了代理自然就失效了。3.2 根因链路this调用绕过了代理对象Spring的Transactional、Aspect等能力的底层是AOP代理。Spring容器启动时扫描到这些注解会为Bean生成一个代理对象。我们注入的实际上是这个代理而对业务代码来说它并不感知自己是个代理方法内部的this引用指向的是原始对象不是代理对象。以JDK动态代理为例代理对象持有InvocationHandler每次调用方法都会经过invoke()在这里完成事务开启、切面逻辑等增强处理。而this.process()这种内部调用是直接调用原始对象的真实方法完全不经过代理事务注解自然形同虚设。CGLIB代理的逻辑类似只是通过子类继承实现但对于this调用同样无效。3.3 三种正确解法与适用场景解决这个问题的方案有三个按推荐度排序。第一个是把被调用的方法拆到另一个独立的Bean里。这是最推荐的做法既能避免自调用问题也让类的职责更清晰。比如单独建一个ReconcileProcessor把process()放进去ReconcileService和定时任务都注入ReconcileProcessor来调用。事务和切面都正常生效测试也方便缺点是要多建一个类但收益远大于成本。第二个是注入代理对象到自身。有些项目不想拆类可以在类里注入自己Service public class ReconcileService { Autowired private ReconcileService self; public void doReconcile() { // ... self.process(); } }用self.process()触发代理增强。注意不能直接Autowired自己然后循环依赖Spring Boot 2.6以后默认禁止循环依赖这种写法有些团队会严格限制需要根据项目情况评估。第三个是用AopContext获取当前代理((ReconcileService) AopContext.currentProxy()).process();但需要配置EnableAspectJAutoProxy(exposeProxy true)而且代码里出现这种写法阅读体验比较差我和团队也更倾向第一种方案。3.4 顺着这个思路排查“接口偶发失败”的问题这类代理失效还有一个变种方法加了final修饰CGLIB无法拦截方法做了private私有化代理也进不去或者同一个接口在SpringTransactional和Async同时出现时代理顺序不对导致某个增强没执行。排查这类问题时最直接的手段是看Bean的运行时类型在启动日志里搜ReconcileService如果显示的是ReconcileService$$EnhancerBySpringCGLIB$$xxxx说明代理创建成功如果显示的是普通的ReconcileService说明这个类压根没被代理那问题大概率出在配置注解或扫描路径上。4. 异步线程池里的无声故障任务丢失、上下文错乱与拒绝策略4.1 案例背景异步写入与定时任务我们对接过一个第三方数据同步场景业务方提交一批数据后系统异步落库、异步推送通知。某天业务反馈“偶尔有数据没同步到目标系统”但主流程没有任何报错。查数据库发现部分数据确实只写了主库、没写目标库而且日志里也没有同步失败的记录就像这个任务被“吃”掉了一样。这类“任务被吃掉”的问题十有八九出在线程池的拒绝策略上。ThreadPoolExecutor默认的拒绝策略是AbortPolicy线程池满了之后直接抛RejectedExecutionException。但很多业务代码在提交任务时会用try-catch包住异常甚至有的开发图省事用了Executors.newFixedThreadPool()底层是无界队列任务永远不会被拒绝但队列会无限增长直到堆内存溢出。还有一种情况是用了默认拒绝策略但没打日志异常被吞了业务上完全没有感知。4.2 线程池“看似健康”背后的三个指标盲区排查线程池问题时只盯线程数和活跃线程数是不够的有三个盲区特别容易漏掉。第一个盲区是队列积压量。很多监控系统只采集了activeCount和poolSize没采集queue.size()。如果核心线程数设置太小任务会大量堆积在队列里活跃线程看起来不高但任务的等待时间越来越长。线上A/B两个任务共用同一个线程池时A任务的耗时飙升B任务也用同一个池排队慢这种互相拖累最烦人。第二个盲区是线程池的线程名字不可读。默认线程名是pool-1-thread-1这种一旦出了问题jstack里根本分不清是哪个业务模块的线程。用ThreadFactory把线程名改成sync-push-thread-1这类有业务含义的名字排查时能省下一大堆时间。第三个盲区是拒绝策略不是“抛个异常”那么简单。AbortPolicy直接抛异常很多业务方没捕获就丢了DiscardPolicy静默丢弃DiscardOldestPolicy丢弃最老的任务CallerRunsPolicy让提交线程自己执行。不同业务场景适合的策略完全不同比如实时性要求高的适合AbortPolicy加报警允许兜底处理的适合CallerRunsPolicy。4.3 修复方案一份可抄作业的自定义线程池配置基于上面的经验我现在基本上不用Executors的快捷方法了都是手工创建ThreadPoolExecutor示例如下ThreadPoolExecutor syncPushPool new ThreadPoolExecutor( 4, 8, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(200), new ThreadFactoryBuilder().setNameFormat(sync-push-%d).build(), new ThreadPoolExecutor.CallerRunsPolicy() );参数方面核心线程数一般按“CPU核数 * (1 平均等待时间 / 平均计算时间)”粗略估算比如IO密集型的同步推送任务等待时间远大于计算时间核心线程可以设到CPU核数的2到4倍。最大线程数不要设置得过高防止突发流量打满线程后直接把CPU拖垮。队列用有界队列容量根据业务峰值估算比如正常情况下任务堆积量是50到100队列就设200留出一定余量。异常处理方面我通常会把任务体本身包一层try-catch在catch里记录完整的上下文信息包括任务ID、提交时间、异常堆栈并发送到告警通道。这样即使某个任务失败了也能第一时间发现并手工补偿而不是看着数据“人间蒸发”。4.4 MDC上下文传递与异常上报还有一个和异步线程池很容易一起踩的坑日志上下文丢失。业务里经常用MDC.put(traceId, xxx)来串联整条调用链但线程池里的线程是复用的不处理MDC的话异步任务里的traceId是空的排起错来简直噩梦。解决方式有两种要么在提交任务时把MDC的Map捕获出来在任务执行时重新put进去要么用一个自定义的TaskDecorator在Runnable包装时自动复制MDC上下文。Spring的ThreadPoolTaskExecutor支持直接配置TaskDecorator用起来很干净ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setTaskDecorator(MdcTaskDecorator::new);这段代码几乎是我所有项目里异步线程池的标配了。5. 环境类问题的排查工具箱版本、依赖与运行时5.1 从“同一个代码本地没问题线上出问题”说起有那么一类线上故障代码拷到本地完全正常一上测试环境或生产就翻车这类问题往往不是业务逻辑的锅而是环境差异。我遇到过最典型的案例是JDK版本差异导致的时间解析异常项目在本地用JDK 8开发LocalDate.parse(2024-02-29)一切正常上了生产JDK 11后某些历史日期解析直接抛DateTimeParseException——因为不同JDK版本对ISO日期格式的容忍度和Unicode支持有差异。排查这类问题第一步永远是对齐所有环境的JDK版本、JVM参数、编码配置不要想当然认为“都一样”。5.2 Maven依赖冲突的定位三步法环境类问题里依赖冲突是另一座大山。最经典的表现是启动时或者运行时突然报NoSuchMethodError、ClassNotFoundException但代码编译完全正常。原因一般是同一个类被多个不同版本的jar包引入而运行时实际加载的是其中某一个版本恰好缺少业务需要的方法。定位依赖冲突我通常会按三步走。第一步用mvn dependency:tree或者IDEA的Diagrams功能查看依赖树找出同一个groupId和artifactId的多个版本。第二步用mvn dependency:tree -Dverbose或者dependency:analyze找出哪些依赖之间互相传递了冲突版本。第三步是如果冲突已经很隐蔽直接在运行时用arthas的sc命令查看某个类的实际加载来源比如执行sc -d com.example.BizService就能看到这个类是从哪个jar包加载的。确认之后在pom里用exclusions排除掉不需要的传递依赖或者用dependencyManagement统一版本。5.3 JVM层面的辅助排查工具另一个值得常备的工具是Arthas。业务问题查到最后往往需要深入到JVM层看线程、看内存、看类加载、看方法调用参数。Arthas的dashboard命令能实时观察线程CPU占用和内存情况thread -n 3能定位CPU最高的几个线程快速找到死循环或者频繁GC的代码位置watch命令能打印某个方法入参和返回值这比加日志重发版本的效率高太多。如果遇到的是内存问题比如进程持续占用高内存、老年代不停增长可以用jmap -dump:formatb,fileheap.bin pid导出堆转储再用MAT分析大对象和引用链。在业务系统里最常见的内存泄漏基本都集中在静态集合、缓存未过期、ThreadLocal未清理这几类分析堆转储时优先看这几种引用类型。还有一个容易被忽略的排查维度是JVM参数差异。同一个应用本地用默认的-Xmx线上可能被设置了不同的堆大小和GC算法导致线上频繁Full GC而本地毫无感觉。遇到“本地不卡线上卡”的问题时先看JVM启动参数再看GC日志往往能发现是参数配置跟不上业务数据量的增长。具体可以参考一些传统JVM调优资料但核心思路是先定位是不是GC问题再确定需要调整的堆空间、垃圾回收器类型和必要的GC参数而不是一上来就盲目加内存。一些排查经验之外的体会代码写久了以后会发现业务系统里真正难搞的问题往往不是某个算法不会写也不是某个框架不熟而是问题发生后面对一堆日志和现象找不到切入的路径。我的个人习惯是拿到一个线上问题先不急着改代码先回答四件事第一数据有没有问题是不是某个特殊业务数据让常规逻辑进了异常分支第二线程有没有问题是不是等待、阻塞、死锁导致的假死第三代理有没有生效是不是自调用把增强逻辑绕过去了第四资源够不够线程池、连接池、内存这些底层资源是不是已经到瓶颈。这四步走完大部分业务故障的定位方向都能框出来。最后分享一个实用的小技巧线上应用启动时加上-XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/logs这样即使真的发生内存溢出也会自动留下堆转储文件同时把jstack的线程栈定期快照到日志目录。这些现场信息对于事后定位问题是无价的很多疑难杂症就是因为当时没有这些快照只能靠猜去复现白白浪费了大量时间。排查问题就像破案现场保护得越好破案效率就越高。