最近有个老朋友拿来一段“多线程处理数据”的代码给我看,说是一到高峰期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); Future<Integer> 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 什么时候值得用ReentrantLock
ReentrantLock真正值钱的三个场景:
第一,需要锁等待超时。比如对方服务慢,我不能无限等锁,这时用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线程写的running=false可能只更新到CPU缓存,A线程一直读取自己缓存里的旧值,根本看不到变更。
解决办法之一是把running声明为volatile:
private volatile boolean running = true;volatile的作用是保证一个线程对变量的写,能立刻被其他线程看到。但要注意,它不保证复合操作的原子性。running=false这种单一写操作是安全的,换成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的原理是,每个线程内部维护一个ThreadLocalMap,key是ThreadLocal对象,value是塞进去的数据。如果用的是线程池,线程是复用的,当前请求处理完但ThreadLocal里的value没被清掉,下一个请求就会读到上一个用户的残留数据,这不是“内存泄漏那么遥远”的事,它会直接造成业务串号。
更隐蔽的是内存泄漏路径:ThreadLocalMap里的key是弱引用,一旦把ThreadLocal对象置为null,key可能被回收,但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的亮点是回调式编排:
CompletableFuture<Price> priceFuture = CompletableFuture.supplyAsync(() -> queryPrice(id), bizExecutor); CompletableFuture<Stock> stockFuture = CompletableFuture.supplyAsync(() -> queryStock(id), bizExecutor); CompletableFuture<Discount> 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排查思路跑一遍,再回来调线程池参数,你会发现很多线程问题不是玄学,只是还没去看线程到底在哪里、做了什么、又卡在了哪里。