资讯动态

Java多线程进阶:线程池、锁与异步编排的生产实践

发布时间:2026/10/14 22:46:05 来源:尧图企业网站定制
最近有个老朋友拿来一段“多线程处理数据”的代码给我看说是一到高峰期CPU就忽高忽低某些线程进去之后再也不出来。我扫了一遍就发现问题业务里每个请求都直接new Thread去干活好几个地方还共享同一个HashMap写状态。这种代码不是没用到线程而是把线程用成了“给自己找麻烦”的武器。深入线程这件事从来不是学会启动一个线程就完事它在JAVA进阶之线程里占据核心位置是因为并发既能让业务跑得更快也能让bug藏得比普通代码深得多。这篇不会从头解释进程和线程的定义主要围绕Runnable/Callable、线程中断、锁的选择、线程池参数、并发工具类、异步编排以及线上排查这几个硬骨头展开。适合已经会用new Thread或者ExecutorService提交任务、但遇到线程安全、任务停止、线程池爆掉时依然没有头绪的开发者。你会看到大量来自生产环境的取舍和坑也能直接照着里面的配置去改自己的代码。1. 把任务交给线程之前先想清楚Runnable和Callable的边界1.1 Runnable看似简单却承担不了“任务结果”这件事很多教程第一次讲线程都会给一个Runnable示例new Thread(() - System.out.println(hello)).start()看起来很好上手。但Runnable有一个很强的限制它的run()方法没有返回值也不能抛出受检异常。这意味着一旦你拿它表达一个真正有业务意义的动作比如“查一下用户余额”或者“调第三方接口拿价格”结果回不来异常也只能在线程内部被消化掉。实际项目里我见过大量把Runnable用到临界点的情况。代码里写pool.submit(() - { try { updateOrderStatus(orderId); } catch (Exception e) { log.error(更新订单失败, e); } });从日志看没毛病。但调用方根本不知道这个更新到底成功没有。下游如果有个接口超时这里也只是在日志里吐一条错误业务补偿无从谈起。Runnable适合“发通知、记日志、触发异步清理”这类fire-and-forget的操作不适合需要结果回传的调用。1.2 Future.get()不是万能钥匙超时和中断都要写Java早就提供了带返回值的任务抽象Callable。它的call()可以有返回值还可以声明抛出异常非常贴合真实业务ExecutorService pool Executors.newFixedThreadPool(4); FutureInteger future pool.submit(() - computeScore(orderId)); Integer score future.get(3, TimeUnit.SECONDS);Future.get()会阻塞当前线程直到任务完成。问题在于很多开发者一拿到Future就习惯性无参get然后线上遇到一次下游永久hang住调用线程也跟着全部卡死。正确姿势一定要带超时try { Integer score future.get(3, TimeUnit.SECONDS); } catch (TimeoutException e) { log.warn(打分任务超时订单{}, orderId); future.cancel(true); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } catch (ExecutionException e) { log.error(打分任务执行异常, e.getCause()); }这里有两个容易忽略的细节。第一InterruptedException不代表业务失败而是当前线程在等待时被人请求中断捕获后应该重新设置中断标志表示“我知道了我让路”。第二ExecutionException里的getCause()才是任务真实抛出的异常别只打外层壳子。1.3 任务粒度才是线程性能的真正分水岭线程数不是越多越快。CPU密集型任务线程数接近CPU核数就好否则线程切换会吃掉大量时间I/O密集型任务线程可以多一些因为一个线程在等待I/O时另一个线程能占住CPU继续干活。一个常用起步公式是CPU核数 * (1 等待时间 / 计算时间)但真实场景往往是混合型公式只能当起点最终要靠压测来调。更隐蔽的问题是任务粒度。以前我处理过100万行数据的导入如果把每一行作为一个任务丢进线程池线程池要承受几十万个Runnable和Future对象的开销光是排队和取结果就消耗很大。后来把数据按片分每片十万行线程池只提交几个大任务性能反而翻倍。提示在创建线程池之前先想清楚任务单位切得太碎和切得太粗一样危险。常见的做法是按批处理一批内循环计算而不是一批任务只算一个单元格。2. 线程不是想停就停协作式中断的正确用法2.1 stop()早就判了死刑老版本JDK有个Thread.stop()能从外部强制终止线程但它在任意位置直接抛出异常可能让对象处于半更新状态也可能让锁资源停留在不释放的状态。由于太危险它已经被标记为过时并禁用了。真正的问题在于很多讲线程的书虽然说了“不要用stop”却没有说清楚替代方案是什么。替代方案就是中断机制。Thread.interrupt()做的事情只是把目标线程的中断标志位置为true。这个标志只是一个“礼貌的请求”目标线程想不想响应、什么时候响应完全由它自己决定。如果你调用interrupt()之后发现线程没有任何反应不用惊讶因为阻塞方法才会主动抛出InterruptedException而普通计算逻辑根本不会自动停下。2.2 InterruptedException的正确处理姿势最常见的中断陷阱是把InterruptedException捕获后什么都不写。举个例子一个消费任务里循环拉数据while (!Thread.currentThread().isInterrupted()) { try { Thread.sleep(1000L); } catch (InterruptedException e) { // 什么都没写 } pollAndProcess(); }表面上看中断标志被置位后下一轮循环能通过isInterrupted()退出。但实际上InterruptedException被捕获的瞬间中断标志已经被JVM清掉了。你如果不在catch里重新设置标志下一次Thread.sleep()会立刻再次被中断然后继续循环线程池里这个任务根本没退出去。正确的处理是重新设置中断标志并且尽快退出或清理while (!Thread.currentThread().isInterrupted()) { try { Thread.sleep(1000L); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } pollAndProcess(); }这条规则几乎适用于所有阻塞操作sleep、join、BlockingQueue的take、Future.get、信号量acquire。只要捕获到InterruptedException第一件事就是Thread.currentThread().interrupt()把请求转达给上层逻辑。直接吞掉等于对外宣称“这个线程不可取消”排查问题时非常难复现。2.3 等待锁时能不能中断也是高级需求synchronized等待锁的过程中不会响应interrupt。如果A线程持锁后长时间不释放B线程在synchronized块外面等得再久也只能干等。想要让等待变得可超时、可中断就得换用Lock接口。ReentrantLock里有个lockInterruptibly()语义是“如果没被中断我就去获取锁如果已被中断就直接抛InterruptedException”。用法需要特别注意ReentrantLock lock new ReentrantLock(); boolean locked false; try { lock.lockInterruptibly(); locked true; // 临界区代码 } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { if (locked) { lock.unlock(); } }注意这里不能用finally { lock.unlock(); }一把梭因为如果lockInterruptibly()还没拿到锁就抛出中断调用unlock()反而会抛IllegalMonitorStateException。用布尔变量标记是否真正加锁再在合适的地方释放是更稳的写法。3. 锁的选择题不如从竞争模型下手3.1 别再传synchronized性能差从JDK 6开始synchronized已经做了大量优化包括偏向锁、轻量级锁、重量级锁的升级链路。低竞争场景下synchronized的成本很低JDK 15之后偏向锁因为维护成本高被默认关闭但轻量级锁和自旋优化依然在。大多数业务代码直接用synchronized足够。很多人盲目选用Lock是受早期“Lock性能比synchronized好”的说法影响。真实对比里性能差距已经非常小关键反而在功能差异。synchronized的优点是简单进方法或代码块自动加锁出异常或执行完自动释放不用手动管理。缺点也明显等待不可中断、不能设置超时、不能做公平排队控制。3.2 什么时候值得用ReentrantLockReentrantLock真正值钱的三个场景第一需要锁等待超时。比如对方服务慢我不能无限等锁这时用tryLock(2, TimeUnit.SECONDS)更合适拿不到锁就走降级逻辑。第二需要响应中断用lockInterruptibly()配合前面的中断处理。第三需要多个等待队列用Condition来实现“唤醒部分线程而不是全部”的语义。使用上有一个建议Lock写起来容易犯错因为unlock必须在finally里手动执行。如果一个类里已经为同一个锁写了好几个返回值分支尽量用try/finally把临界区包干净避免中间业务逻辑抛异常导致锁一直不释放。3.3 Atomic系列并不能包打天下无锁总比有锁好这句话在简单场景下确实成立。一个计数器如果用synchronized每次递增都要经历几十个CPU周期用CAS自旋升级为底层原子指令更轻量。Java里常见的AtomicInteger、AtomicLong、AtomicBoolean、AtomicReference都是基于CAS的。AtomicInteger count new AtomicInteger(0); count.incrementAndGet();遇到写入非常频繁的统计场景推荐LongAdder它内部把数据分到多个单元最后sum()再合并能大幅降低多线程写同一个内存位置的冲突概率。但Atomic系列有天然边界它只能保证“单个变量的原子操作”无法保证“多个变量之间的复合一致性”。比如转账场景里扣减一个账户和增加另一个账户必须同时成功这靠AtomicReference没用还是得用synchronized或更完整的锁。4. 线程池参数的坑和出路4.1 Executors默认构造器为什么是定时雷很多人在代码里写Executors.newFixedThreadPool(8)觉得这是标准答案。但翻到源码就会看到newFixedThreadPool内部用的是LinkedBlockingQueue队列容量是Integer.MAX_VALUE。假设业务在高峰期涌入十万个任务线程池只有8个线程在消费剩下九万多个任务会全部挤进队列内存占用线性上涨队列无限膨胀。另一个陷阱是newCachedThreadPool它的最大线程数是Integer.MAX_VALUE。来多少任务就建多少线程线程数会随着请求量疯狂上涨把CPU和内存直接拖垮。虽然它内部线程空闲60秒会被回收但洪峰来时根本等不到回收。建议生产环境尽量手动new ThreadPoolExecutor不要用Executors的静态方法。不是它们一无是处而是“无界排队”和“无限线程”这两个坑普通业务根本承担不起。4.2 一套可以照着抄的ThreadPoolExecutor配置手动构造线程池其实不难常用模板如下ThreadPoolExecutor pool new ThreadPoolExecutor( 4, // corePoolSize 16, // maximumPoolSize 30L, TimeUnit.SECONDS, new ArrayBlockingQueue(512), new ThreadFactory() { private final AtomicInteger seq new AtomicInteger(1); Override public Thread newThread(Runnable r) { Thread t new Thread(r, data-import-thread- seq.getAndIncrement()); t.setDaemon(false); return t; } }, new ThreadPoolExecutor.CallerRunsPolicy() );几个参数要理解不能照抄字面。corePoolSize是最小常驻线程数任务多起来时新任务先往队列里排队队满了才创建core之外的线程一直到maximumPoolSize如果队满且线程数也到了max才会触发拒绝策略。拒绝策略的选择要看业务。AbortPolicy默认会抛RejectedExecutionException适合需要明确感知“我超载了”的业务CallerRunsPolicy会让提交任务的线程自己来执行被拒绝的任务有天然的限流效果但可能让调用端变慢DiscardPolicy则直接丢弃除非任务可以丢否则别用。这里还有很多人没注意的一点如果你的maximumPoolSize远大于corePoolSize但队列又设得很大那max基本不会生效因为任务还没排到队满。想要max发挥作用就得控制队列长度。所以单独调一个参数是没有意义的要把它当成“核心数-队列-最大数-拒绝策略”的整体来做取舍。4.3 线程池里没有好名字排查会多花三倍时间我对团队代码有一个比较固执的要求线程池必须自定义ThreadFactory线程名里带上业务含义。默认线程名是pool-1-thread-1线上看dump时只能知道“你有个线程池”但完全看不出是哪个业务模块。改成pay-query-thread-5之后jstack一眼就能定位到支付查询模块排查效率完全不同。另一个容易被忽略的是shutdownNow()的效果。它会给线程池里正在跑任务的线程发interrupt但任务若没有正确处理中断线程依然不会退出。等于是“礼貌地请求关闭但对方如果你还死皮赖脸攒着不响应整体关闭就得等”。正确做法是给任务里阻塞的调用都带超时保证中断能被及时响应。线程池要多隔离。一个全局线程池被下单、对账、报表、消息推送全放在一起只要一个慢接口把线程占满所有业务都得排队。按业务拆池例如订单线程池、实时推送线程池、异步对账线程池各管各的参数和队列慢业务不会拖垮关键链路。5. 原子性、可见性、有序性并发bug的三大根源5.1 你写的并发代码第一个敌人是内存可见性没有并发编程经验的人最容易碰上“明明另一个线程改了flag循环却不停”的问题。看一段经典代码public class Worker { private boolean running true; public void stop() { running false; } public void runLoop() { while (running) { // 长时间运算 } } }如果runLoop()在A线程执行stop()在B线程调用A线程有很大概率永远退不出来。原因不是代码逻辑错了而是JVM、CPU缓存以及即时编译优化导致的可见性问题。B线程写的runningfalse可能只更新到CPU缓存A线程一直读取自己缓存里的旧值根本看不到变更。解决办法之一是把running声明为volatileprivate volatile boolean running true;volatile的作用是保证一个线程对变量的写能立刻被其他线程看到。但要注意它不保证复合操作的原子性。runningfalse这种单一写操作是安全的换成count就不行因为它不是一个原子操作。5.2 volatile只是入口真正复杂的是JMM规则volatile解决的只是可见性的一部分。Java内存模型JMM里还有一组happens-before规则决定了哪些场景下一个线程对变量的修改必然能被另一个线程看到。常见的有程序次序规则、监视器锁规则、volatile变量规则、传递性。这些规则最直接的影响在双重检查锁单例上。经典的懒加载写法public final class Singleton { private static volatile Singleton instance; public static Singleton getInstance() { if (instance null) { synchronized (Singleton.class) { if (instance null) { instance new Singleton(); } } } return instance; } }这里为什么必须加volatile因为new Singleton()在底层至少有三步分配内存、调用构造、把引用赋值给静态字段。编译器可能重排成“先赋值指针再调用构造”。另一个线程判断instance ! null就直接把这个半初始化的对象拿去用了从而读到构造还没完成的字段。volatile禁止了这种重排保证使用者看到的要么是null要么是完整初始化后的对象。如果嫌DCL复杂也可以用静态内部类或枚举单例。但DCL作为面试题和遗留代码中的常用模式搞清楚它的volatile语义非常重要。5.3 ThreadLocal是好东西但用不好就是内存泄漏入口ThreadLocal的原理是每个线程内部维护一个ThreadLocalMapkey是ThreadLocal对象value是塞进去的数据。如果用的是线程池线程是复用的当前请求处理完但ThreadLocal里的value没被清掉下一个请求就会读到上一个用户的残留数据这不是“内存泄漏那么遥远”的事它会直接造成业务串号。更隐蔽的是内存泄漏路径ThreadLocalMap里的key是弱引用一旦把ThreadLocal对象置为nullkey可能被回收但value是强引用仍然被当前线程的ThreadLocalMap引用着。线程池里的线程只要不销毁这条强引用链路就一直存在。经验做法在请求进入时set结束时在finally里remove不要依赖“线程跑完自然回收”这件事。尤其在线程池场景里线程不是每次用完就消失的。6. 并发工具类要用在刀刃上6.1 CountDownLatch等齐了再走有些业务天然是“先同时干活再一起汇总”的结构。比如批量审批要同时让4个审核服务跑主流程等全部结果回来之后再做下一步判断。CountDownLatch就是为此设计的。CountDownLatch latch new CountDownLatch(4); for (int i 0; i 4; i) { executor.submit(() - { try { doReview(batchId); } finally { latch.countDown(); } }); } latch.await(10, TimeUnit.SECONDS);这里最容易被忽略的是countDown()要放在finally里。如果任务里抛了异常latch的计数没有归零主线程就会一只等下去。等多久如果调用的是无参await()那等到天荒地老。生产代码一律建议用带超时的await版本超时之后判断一下返回值该走补偿走补偿该降级降级。6.2 CyclicBarrier一轮一轮并肩前进CyclicBarrier和CountDownLatch看着像但语义不同。CountDownLatch是一次性的主线程等子线程CyclicBarrier是多个线程之间互相等大家都到了再一起冲线而且可以反复使用。像数据同步场景每条线程负责一份分片数据所有分片都准备好之后才统一写库。写库完成后进入下一轮CyclicBarrier barrier new CyclicBarrier(3, () - log.info(本轮三片数据都已就绪)); executor.submit(() - { for (int i 0; i 10; i) { prepareSlice(i); try { barrier.await(5, TimeUnit.SECONDS); } catch (BrokenBarrierException e) { log.error(屏障被破坏线程退出); return; } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } catch (TimeoutException e) { log.warn(本轮某个切片超时继续处理当前数据); } writeSlice(i); } });使用CyclicBarrier最大的风险是“屏障破裂”某一线程等待超时或中断屏障就会broken其他所有等待线程都会抛出BrokenBarrierException。所以await一定要带超时不能无期限等下去。多线程之间的协作一旦有一个点掉队队伍要能快速感知而不是全员死等。6.3 Semaphore并发限流而不是线程池的替代品Semaphore可以把一个操作的最大并发量限制住。比如下游接口只允许同时5个请求进入超过就排队或快速失败Semaphore semaphore new Semaphore(5); executor.submit(() - { boolean acquired false; try { semaphore.acquire(1, TimeUnit.SECONDS); acquired true; callDownstream(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { if (acquired) { semaphore.release(); } } });要注意的是Semaphore并不限制线程数它限制的是“许可证持有数量”。线程池可以很大但同一时刻拿到许可的只有5个线程。所以它非常适合做“并发控制”跟线程池的参数控制是两条不同维度。求稳妥的话release一定要在finally里做否则信号量的许可会越用越少最后流控变成不可用。7. 异步编排CompletableFuture要用对而不是用花7.1 从Future.get到异步回调变化到底在哪Future提供的get()是阻塞式等待主线程把任务提交给线程池后还得等结果才能继续干别的。当你要同时做多个独立调用时比如商品详情要查价格、库存、优惠券三样东西基于Future的写法容易变成“先等价格再等库存再等优惠”串行耗时累加起来很可观。CompletableFuture的亮点是回调式编排CompletableFuturePrice priceFuture CompletableFuture.supplyAsync(() - queryPrice(id), bizExecutor); CompletableFutureStock stockFuture CompletableFuture.supplyAsync(() - queryStock(id), bizExecutor); CompletableFutureDiscount discountFuture CompletableFuture.supplyAsync(() - queryDiscount(id), bizExecutor); priceFuture .thenCombine(stockFuture, (price, stock) - combineBase(price, stock)) .thenCombine(discountFuture, (base, discount) - assembleDetail(base, discount)) .thenAccept(detail - log.info(详情组装完成: {}, detail)) .exceptionally(ex - { log.error(详情组装失败, ex); return null; });三个外部调用会并行执行任何一个阶段准备好后后续逻辑会自动触发不需要显式地开线程去等待。这比Future串行get要省线程。7.2 thenApply、exceptionally、whenComplete的正确理解thenApply用于将上一步的结果转换后继续传递。thenCompose用于“上一步返回Future需要进一步合并另一个异步操作”的情况区别在于thenCompose会拍平嵌套的Future而不是让调用方再get一层。exceptionally是异常掉头后的“兜底”操作它能在链上遇到异常时返回一个默认值让流程继续往下走。一些业务会在exceptionally里打日志并返回null但null一旦进入thenApply链可能把问题掩盖掉让调用方看到空引用。所以异常恢复策略要明确是保留一个默认值还是终止流程。whenComplete则完全不同它只是“无论成功失败我都被通知一下”适合做日志记录或指标上报。注意它不会吞掉异常如果链条前一步已经抛了异常whenComplete看到异常之后原异常还会继续传播不能被它拯救回来。这一点特别容易写错。7.3 别忘了指定Executor别再让commonPool背锅CompletableFuture如果不显式传线程池默认会用ForkJoinPool.commonPool()。commonPool是整个JVM共享的线程数通常等于CPU核数减1。只要有一个业务里的任务在commonPool里阻塞其他使用CompletableFuture的模块都会跟着延迟。流量大的时候这种“风扇效应”会放大得非常快。线上经验任何长时间运行的异步任务和阻塞型调用都要传独立线程池。比如外部接口请求放在IO线程池CPU计算放在work线程池两个池子分开调优。另外使用CompletableFuture做异步编排时ThreadLocal的传递是第二个痛点。回调默认在完成任务的线程上执行跨线程切过去之后原来的调用上下文用户身份、traceId等可能就丢了。解决思路是提交任务前先把上下文放到局部变量里回调启动时再手动恢复或者用装饰过的Task包装器统一处理。这是异步框架落地时最容易被忽略的一环。7.4 别忘记给异步链加超时CompletableFuture本身没有强制超时这是它设计上的一个痛点。JDK 9提供了orTimeout()和completeOnTimeout()但在JDK 8时代很多人要么自己写调度器去complete这个Future要么在链路上用get(timeout)兜底。我见过不少生产问题都是异步链中某个下游接口hang住整个编排任务一直不结束线程池里的线程被占满。如果你还在用JDK 8一个简单折中方案是提交异步任务时不光保存CompletableFuture同时用ScheduledExecutor模拟超时后主动把Future exceptionally完成。代码会多一点但比线上无限等待要好得多。8. 线程问题排查jstack与线程Dump怎么读8.1 用jstack抓一次现场别再猜“卡在哪里”线上一个服务偶发“请求变得极慢”很多人的第一反应是加日志、看监控但最直接的现场信息其实在JVM线程栈里。先用jps -l找到目标Java进程的pid然后执行jps -l jstack 12345 /tmp/thread_dump_$(date %Y%m%d%H%M).txt这个dump文件会包含进程里所有线程的状态、锁等待、调用栈。常见有用的场景有两种。一种是线程大量处于BLOCKED状态说明正在等待锁锁竞争很激烈。另一种是大量线程处于WAITING或TIMED_WAITING但调用栈都汇集在同一个点多半是某个Future或者队列take没有返回。在dump里搜索关键片段grep -A 20 java.lang.Thread.State: BLOCKED /tmp/dump.txt | head -100配合上下文里的“waiting to lock”和“locked”信息能看出谁锁住了谁。如果jstack输出里直接出现“Found one Java-level deadlock”的相关提示基本就是死锁发生按着提示里的两到三个线程互相等待的关系去改代码就清楚了。8.2 线程数疯涨的几个常见原因线程数增长失控原因通常逃不过这几种一是代码在业务方法里直接new Thread().start()并发一大线程就指数级增加二是线程池队列没有上限任务堆积后活跃线程爬升三是定时任务或MQ消费没有做幂等去重同一个任务被重复提交进线程池四是业务里拿线程池做大量阻塞调用一个“假异步”接口把池子瞬间占满。排查时别一看到线程多就动线程池参数。先看它是哪类线程业务线程还是GC线程、JIT编译器线程。如果所谓的线程增长其实是GC线程或RMI线程问题核心可能变成内存分配过猛或JVM运维配置调再大的线程池也是白搭。通常检查顺序建议是CPU使用率、GC与堆占用、线程总数与状态、队列长度、日志中的异常关键字。8.3 给线程使用建立可观测指标线程池参数调完不等于以后万事大吉。还是要加上指标否则下次流量翻倍时你又是两眼一抹黑。最小可用的监控指标有三个当前线程总数、活跃线程数、队列剩余容量。通过JMX获取线程总数非常简单ThreadMXBean threadBean ManagementFactory.getThreadMXBean(); int threadCount threadBean.getThreadCount(); int peakThreadCount threadBean.getPeakThreadCount();线程池本身的指标可以定时采样int active pool.getActiveCount(); int queued pool.getQueue().size(); int completed pool.getCompletedTaskCount();把这些数值上报到监控系统按历史水位设置告警。比如线程总数平时在30以内某天突然冲到200并持续不降即使还没挂也应该提前介入排查。线程问题最怕“突然爆掉”而这些指标就是提前发现爆掉之前曲线异常的眼睛。9. 我在线程实践里的几个“反常识”心得做了几年并发相关的东西有几个结论和第一反应相反但踩坑之后发现它们更可靠。第一线程池不是越大越好而是要按业务拆开。宁可每个池子小一点也要保证支付链路不会被导出报表的慢任务拖死。隔离比总量重要得多。第二不要拿异步编排去包装阻塞操作。一个CompletableFuture回调里如果又调用future.get()阻塞会顺着回调链蔓延最后占住的还是线程。真正要优化的是把阻塞调用改造成带超时的异步IO或者至少给它一个独立线程池。第三缩小锁范围永远值得做。没事别把一个几十行的方法整体加上synchronized把只有真正需要保护的几行代码独立出来加锁性能提升往往立竿见影。能用不可变对象表达的数据就用不可变对象线程之间的很多麻烦会因为“数据不能改”而消失。第四日志里把线程名打出来真的是性价比最高的排查投资。我做过一个很不起眼的改动只把日志模板里的默认占位符换成Thread.currentThread().getName()前缀线上定位卡顿问题的速度从小时级变成分钟级。线程这个主题远不止语言API它是一套对CPU、IO、任务特性和故障率的系统性思考。先把jstack排查思路跑一遍再回来调线程池参数你会发现很多线程问题不是玄学只是还没去看线程到底在哪里、做了什么、又卡在了哪里。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑