线上Java业务系统出问题的时候,最磨人的往往不是报错本身,而是问题像幽灵一样时隐时现。尤其是那些“本地跑得好好的、一上线就抽风”的并发等待、事务失效、数据串号、任务静默丢失,这类问题排查起来既费时间又费头发。作为一名常年跟业务代码和线上故障打交道的Java开发者,我把实际工作中遇到的高频业务问题场景做了个复盘,从表象、排查链路到根因和解决方案,尽量完整地还原当时的处理过程。这篇文章适合正在业务系统里写代码、改Bug、处理线上故障的Java开发,也适合准备面试时需要真实案例支撑的进阶者。
1. 多线程“等待所有任务完成”却永久卡死:从jstack定位到CountDownLatch根因
1.1 一个看似无害的并发报表导出场景
有段时间我们做了一个To B的报表导出功能,单次导出需要从多个数据源拉取统计数据,最耗时的那个接口平均要跑两三秒,如果串行执行,一个导出任务要等十几秒,体验很差。最初的方案很自然就是多线程并发拉取,然后用一个计数器等待所有线程都执行完再统一打包导出。当时的代码大致长这样:
ExecutorService pool = Executors.newFixedThreadPool(4); CountDownLatch latch = new CountDownLatch(3); pool.execute(() -> { List<OrderStat> stats = orderService.queryOrderStat(); reportContext.setOrderStats(stats); latch.countDown(); }); pool.execute(() -> { List<PayStat> stats = payService.queryPayStat(); reportContext.setPayStats(stats); latch.countDown(); }); pool.execute(() -> { List<UserStat> 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以后,写并发等待的代码更推荐这种方式:
CompletableFuture<List<OrderStat>> orderFuture = CompletableFuture.supplyAsync(() -> orderService.queryOrderStat(), pool); CompletableFuture<List<PayStat>> payFuture = CompletableFuture.supplyAsync(() -> payService.queryPayStat(), pool); CompletableFuture<List<UserStat>> userFuture = CompletableFuture.supplyAsync(() -> userService.queryUserStat(), pool); CompletableFuture.allOf(orderFuture, payFuture, userFuture) .get(30, TimeUnit.SECONDS);allOf+get(timeout)天然支持超时控制,而且每个子任务的异常会包装到CompletionException里,排查时能直接看到是哪个任务、哪个方法抛的异常,比CountDownLatch的“集体失联”清晰太多。
1.4 这类问题在面试里的变体
这个场景几乎是Java并发面试的常客,市面上常见的问法包括:CountDownLatch、CyclicBarrier、Semaphore有什么区别;线程池的饱和策略有哪几种、分别适用什么场景;submit()和execute()的区别;如何实现“多个线程全部执行完再继续”的效果。面试官想要听到的不是背诵八股文,而是你真正处理过“主线程等不到子线程完成”的场景,知道countDown()必须放在finally,知道await要加超时,知道线程池不会无限帮你兜底。这些内容在后面的章节还会反复用到,因为并发问题往往不是孤立出现的。
2. Stream流式处理中的类型陷阱:一次toArray引发的线上ClassCastException
2.1 现象复现:看似理所当然的强转
如果说并发等待问题让人心梗,那Stream的类型问题就是那种“明明很小但就是让你反复怀疑人生”的Bug。有一次在升级JDK 11之后,线上突然报了一堆ClassCastException,堆栈指向一行很简洁的代码:
String[] idArr = (String[]) userService.queryAllUserIds().stream().toArray();本地测试怎么也复现不了,因为本地用的JDK 8,而线上是JDK 11。表面上看这行代码没什么问题——queryAllUserIds()返回的是List<String>,stream().toArray()得到的数组强转成String[]不是很自然吗?但真相是,Stream.toArray()无参重载返回的是Object[],不是T[]。也就是说,无论流里的元素类型是什么,无参toArray()得到的都是Object[],强转成String[]必抛ClassCastException。
2.2 真正原因:无参toArray与有参toArray的语义差异
这个行为的根源在于Java泛型的运行时擦除。Stream<T>在运行时并不知道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相关问题时,一个很有效的技巧是把复杂的链式调用拆开,每一段结果单独打日志。比如:
List<String> ids = userService.queryAllUserIds(); log.info("ids size={}, type={}", ids.size(), ids.getClass()); Stream<String> 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私有化,代理也进不去;或者同一个接口在Spring@Transactional和@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:format=b,file=heap.bin <pid>导出堆转储,再用MAT分析大对象和引用链。在业务系统里,最常见的内存泄漏基本都集中在静态集合、缓存未过期、ThreadLocal未清理这几类,分析堆转储时优先看这几种引用类型。
还有一个容易被忽略的排查维度是JVM参数差异。同一个应用,本地用默认的-Xmx,线上可能被设置了不同的堆大小和GC算法,导致线上频繁Full GC而本地毫无感觉。遇到“本地不卡线上卡”的问题时,先看JVM启动参数,再看GC日志,往往能发现是参数配置跟不上业务数据量的增长。具体可以参考一些传统JVM调优资料,但核心思路是:先定位是不是GC问题,再确定需要调整的堆空间、垃圾回收器类型和必要的GC参数,而不是一上来就盲目加内存。
一些排查经验之外的体会
代码写久了以后会发现,业务系统里真正难搞的问题,往往不是某个算法不会写,也不是某个框架不熟,而是问题发生后,面对一堆日志和现象找不到切入的路径。我的个人习惯是拿到一个线上问题先不急着改代码,先回答四件事:第一,数据有没有问题,是不是某个特殊业务数据让常规逻辑进了异常分支;第二,线程有没有问题,是不是等待、阻塞、死锁导致的假死;第三,代理有没有生效,是不是自调用把增强逻辑绕过去了;第四,资源够不够,线程池、连接池、内存这些底层资源是不是已经到瓶颈。这四步走完,大部分业务故障的定位方向都能框出来。
最后分享一个实用的小技巧:线上应用启动时加上-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/logs,这样即使真的发生内存溢出,也会自动留下堆转储文件,同时把jstack的线程栈定期快照到日志目录。这些现场信息对于事后定位问题是无价的,很多疑难杂症就是因为当时没有这些快照,只能靠猜去复现,白白浪费了大量时间。排查问题就像破案,现场保护得越好,破案效率就越高。