ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java线程控制实战:线程失控症状、线程池参数与排查

Java线程控制实战:线程失控症状、线程池参数与排查 凌晨两点十七分我被值班电话叫醒。线上订单服务的RT响应时间从20毫秒直接飙到5秒监控面板一片飘红。打开终端输入topCPU全核打满jstack一看好家伙三千多个线程卡在同一个锁上。这个场景做后端的朋友大概率不陌生代码能跑线程却失控了。所谓“线程控制”不是简单地new Thread()再start()完事而是线程的创建时机、数量上限、生命周期、协作方式、异常处理每一个环节都要处于可控状态。这是一篇系列文章的第一篇只聊最基础也最要命的部分线程为什么会失控、如何正确停止线程、线程池的核心参数怎么定、以及线上线程出问题后怎么一步步排查。适合那些“写过并发代码但一上线就抓瞎”的工程师也适合刚接触Java并发、想建立系统认知的初学者。1. 线程失控的典型症状与根因1.1 线程失控的四种典型症状先说症状因为绝大多数人第一次意识到“线程失控”不是通过代码评审而是通过告警电话。我见过、也经历过太多次症状基本逃不出下面四种。第一种CPU被打满。线程数一多操作系统要把CPU时间片轮流分给每个线程线程切换本身就要消耗CPU。你会发现top命令里%sy系统CPU占用特别高甚至超过%us用户态CPU占用这就是典型的上下文切换开销过大。我见过一台4核机器上起了2000多个线程每个线程都只是少量计算结果CPU先撑不住了。第二种内存溢出。很多人不知道线程栈是要占内存的。Java里每个线程默认的栈大小-Xss通常是1MB左右2000个线程就是2GB堆外内存还没算程序本身的堆内存。JVM的线程数量是有上限的一旦突破直接抛OutOfMemoryError: unable to create new native thread这时候服务基本处于半瘫痪状态重启都费劲。第三种响应时间飙升。线程池里的工作线程全部被慢任务占满新任务只能排到队列里等待用户请求迟迟得不到处理。表现就是接口RT一点点涨上去从几百毫秒涨到几秒像温水中煮青蛙。第四种诡异的拒绝异常。线程池的队列和最大线程数都满了新任务进不来会抛出RejectedExecutionException。如果你没有统一兜底逻辑这个异常会直接打到调用方业务报错率瞬间上扬。1.2 为什么会失控聊到底层机制线程不是代码里的普通对象它背后是操作系统级的资源。每创建一个线程JVM就要向操作系统申请一块线程栈内存同时线程会参与系统调度光上下文切换损耗就够喝一壶的。也就是说线程既是“干活的人”也是“消耗资源的人”。问题的根源多半是下面这几类代码while (true) { new Thread(() - { // 模拟一次耗时任务 Thread.sleep(5000); }).start(); }这段代码粗看没什么但线上经常就是这种写法。每来一个请求就new Thread任务处理得慢新的线程还在不断创建线程数只增不减最后系统崩溃。控制线程的核心就是给“创建线程”和“重复利用线程”立规矩。1.3 到底多少线程算失控这是个问频率极高的问题。我不能给你一个绝对数字但可以给你一个判断维度4核8G的普通Java服务线程池里的线程数控制在几十到一百左右通常很稳整个JVM所有线程加起来到几百个也可以接受到了上千个就要警觉如果上万系统基本已经在崩溃边缘。用jstack输出一次线程快照数一数有多少WAITING状态线程在等任务、多少RUNNABLE线程在真实干活这个比例比绝对数字更有参考价值。后面在第5节我会具体讲怎么看。2. 线程生命周期基本功创建、运行与优雅停止2.1 线程创建方式的取舍Java里创建线程无非三种方式但选型逻辑很多人没想清楚。实现Runnable最常用适合不需要返回结果的异步任务。好处是任务和线程解耦可以配合线程池复用不浪费线程资源。我平时90%的异步任务都用这种方式。实现Callable FutureTask适合需要返回计算结果或捕获任务异常的场景。比如批量调用外部接口汇总结果FutureTask.get()可以拿结果也能拿到执行异常。继承Thread我很少用。除非你要重写run()之外的方法否则继承会失去灵活性而且Java是单继承没必要为一个普通任务牺牲扩展空间。下面是一个最基础的示例public class ThreadCreationDemo { public static void main(String[] args) throws Exception { // 方式一Runnable无返回值 Thread t1 new Thread(() - System.out.println(Runnable 运行中), worker-1); t1.start(); // 方式二Callable FutureTask获取执行结果 FutureTaskInteger task new FutureTask(() - { Thread.sleep(1000); return 42; }); Thread t2 new Thread(task, worker-2); t2.start(); Integer result task.get(); // 阻塞等待结果 System.out.println(Callable 结果: result); } }2.2 停止线程的正确姿势这是“线程控制”里最容易被搞砸的一块。先说结论永远不要用Thread.stop()。这个方法已被标记为废弃官方文档明确说了它有严重安全隐患。它会直接终止目标线程不管这个线程正拿着什么锁、正在写什么共享数据可能导致数据写到一半程序状态变得不可预测。正确做法是协作式中断发一个“信号”给目标线程目标线程自己决定在合适的时机安全退出。Java的interrupt()就是干这个的。// 错误示范强行杀掉线程 // Thread t new Thread(() - { ... }); // t.stop(); // 绝对不要这么做 // 正确示范使用中断协作机制 public class StopThreadDemo { public static void main(String[] args) throws InterruptedException { Thread worker new Thread(() - { while (!Thread.currentThread().isInterrupted()) { System.out.println(worker 正在工作...); try { Thread.sleep(500); } catch (InterruptedException e) { System.out.println(收到中断信号准备退出); Thread.currentThread().interrupt(); // 关键恢复中断标志 break; } } }, worker-thread); worker.start(); Thread.sleep(2500); worker.interrupt(); // 发送中断信号优雅停止 } }注意上面这段代码的细节线程在sleep()期间收到interrupt()会抛出InterruptedException同时清除中断标志。如果你在catch里不做Thread.currentThread().interrupt()恢复标志那么外层循环的isInterrupted()永远是false线程根本退不出来。这个小坑我见过很多人踩。如果不用中断信号也可以用volatile布尔标志位public class VolatileStopDemo { private static volatile boolean running true; public static void main(String[] args) throws InterruptedException { Thread worker new Thread(() - { while (running) { System.out.println(running...); Thread.sleep(500); } System.out.println(线程已退出); }); worker.start(); Thread.sleep(2000); running false; // 修改标志位让线程退出 } }volatile标志位的局限是如果线程阻塞在sleep()或wait()里标志位的变化无法让它立刻醒来需要等阻塞结束才能检查。所以最稳的方式还是interrupt()它能打断大部分阻塞状态。2.3 sleep、join、yield的坑这三兄弟看着简单但在线上都出过名场面。Thread.sleep()是让出CPU但不释放锁。如果线程在持有锁的代码块里sleep(5000)其他等这把锁的线程全得陪着等。所以写代码时尽量不要在synchronized块里做长时间休眠这会硬生生拖垮并发度。join()是当前线程等待目标线程结束。比如主线程等子线程把配置加载完再继续。有个坏习惯是直接join()不带时间参数如果子线程内部死循环主线程就永远卡着。正确姿势是用join(3000)这种带超时的版本。yield()是主动让出CPU给其他同优先级线程。实际开发中我基本没主动用过因为它依赖调度器的善意行为不可预测盲目调用反而增加不必要开销。不如把优先级、调度这些事交给操作系统和线程池。3. 线程池线程数量控制的主战场3.1 为什么不能裸new Thread裸new Thread最直接的问题有两个一是线程创建和销毁成本高每次都要跟操作系统申请资源二是线程数量没有上限任务一多就无限膨胀直到压垮系统。线程池就是解决这两个问题的标准方案它做三件事复用已有线程、缓冲任务、限制最大并发数。3.2 线程池七个参数一次讲透ThreadPoolExecutor有七个核心参数很多人背过但没真正理解。我逐个拆开讲。参数名含义一句人话解释corePoolSize核心线程数池子里平时常驻的“正式工”maximumPoolSize最大线程数忙起来最多能扩到的“临时工上限”keepAliveTime空闲存活时间临时工没事干多久就被辞退workQueue任务队列排队等待干活的“待办清单”threadFactory线程工厂给线程起名、设置属性的地方handler拒绝策略池子和队列都满了新任务怎么办这里必须强调一个绝大多数人理解反的执行逻辑陷阱。ThreadPoolExecutor提交任务时顺序是线程数小于corePoolSize就直接新建核心线程执行任务。线程数达到corePoolSize后新任务先丢进workQueue排队等待。队列也满了才会继续扩充线程到maximumPoolSize。线程数到了maximumPoolSize队列也满触发拒绝策略。也就是说不是任务一多就立刻加线程而是先排队排不下才加线程。很多人配置了maximumPoolSize200但队列用了无界队列LinkedBlockingQueue结果队列永远不会满maximumPoolSize形同虚设实际并发数永远只有corePoolSize那么多。我踩过这个坑压测时以为能撑200并发实际只能跑20问题就在优先级搞反了。3.3 核心参数怎么定这个问题没有银弹但有靠谱的经验公式。CPU密集型任务核心线程数约等于CPU核数1。因为CPU密集任务基本一直在算线程数再多反而因为上下文切换变慢。Runtime.getRuntime().availableProcessors() 1是常见取值。IO密集型任务核心线程数可以设大一些常见经验值是CPU核数×2。更精确的公式是CPU核数 × 目标CPU利用率 × (1 等待时间 / 计算时间)但这个公式需要你提前测出任务里IO等待和纯计算的比例线上很难精确测量一般用“CPU核数×2起步压测再调”的思路。举一个具体场景。一台4核8G的机器跑一个订单推送服务每次推送要调用外部接口IO开销大平均200毫秒纯计算时间只要20毫秒。按经验corePoolSize设为16左右比较合理maximumPoolSize设在24到32之间workQueue用有界队列LinkedBlockingQueue容量2000keepAliveTime设为60秒。import java.util.concurrent.*; public class OrderPushPoolDemo { public static void main(String[] args) { ThreadPoolExecutor executor new ThreadPoolExecutor( 16, // corePoolSize 32, // maximumPoolSize 60L, TimeUnit.SECONDS, // 空闲线程60秒回收 new LinkedBlockingQueue(2000), // 有界队列避免任务无限堆积 new ThreadFactory() { private final java.util.concurrent.atomic.AtomicInteger counter new java.util.concurrent.atomic.AtomicInteger(1); Override public Thread newThread(Runnable r) { return new Thread(r, order-push- counter.getAndIncrement()); } }, new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略让提交任务的线程自己执行 ); // 模拟提交任务 for (int i 0; i 100; i) { executor.execute(() - { System.out.println(Thread.currentThread().getName() 开始推送); try { Thread.sleep(200); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); } executor.shutdown(); } }为什么这里选CallerRunsPolicy而不是默认的AbortPolicyAbortPolicy直接抛异常业务方稍不注意就感知到失败CallerRunsPolicy让提交任务的线程自己执行这个任务相当于在极端负载下把压力回传给调用方既不会丢任务也起到了一个很自然的限流作用。任务队列2000如果连排队都排不进去那确实说明服务已经顶满了让调用方线程亲自参与执行反而是最温和的降级。3.4 线程池命名与监控改造排查过线上问题的人都懂当jstack打印出一堆Thread-12、pool-3-thread-5这种毫无辨识度的线程名想定位问题线程来自哪个业务模块简直像大海捞针。所以我强烈建议线程池创建时一定要用自定义ThreadFactory给线程取名。在3.3的示例里我用了order-push-前缀这样线上看到线程名一眼就知道是订单推送线程池的线程省去好几小时的排查时间。另外可以重写ThreadPoolExecutor的beforeExecute和afterExecute方法做线程池监控public class MonitorableThreadPool extends ThreadPoolExecutor { public MonitorableThreadPool(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueueRunnable workQueue, ThreadFactory threadFactory) { super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, threadFactory); } Override protected void beforeExecute(Thread t, Runnable r) { super.beforeExecute(t, r); // 记录任务开始时间比如存到ThreadLocal或内存队列 } Override protected void afterExecute(Runnable r, Throwable t) { super.afterExecute(r, t); // 计算任务耗时记录活跃线程数、队列长度等指标 } }有这套监控你能提前发现线程池队列积压、线程饥饿等风险而不是等告警响了才后知后觉。4. 线程协作控制让线程按剧本走4.1 CountDownLatch一个主线程等N个子任务实际业务里经常有这种场景一个接口需要并发调用多个上游服务全部返回后再聚合结果给前端。最笨的写法是一个一个串行调用耗时就是所有服务耗时的总和。并发化改造后就需要一个机制让主线程等所有子线程都干完再继续。CountDownLatch就是干这个的。它的原理很简单初始化一个计数器比如3每个子任务完成后调用countDown()把计数减1主线程调用await()阻塞等待直到计数变成0。import java.util.concurrent.*; public class CountDownLatchDemo { public static void main(String[] args) throws InterruptedException { int taskCount 3; CountDownLatch latch new CountDownLatch(taskCount); ThreadPoolExecutor executor new ThreadPoolExecutor(3, 3, 0L, TimeUnit.SECONDS, new LinkedBlockingQueue(100), r - new Thread(r, batch-task- System.nanoTime())); for (int i 0; i taskCount; i) { final int taskId i; executor.execute(() - { try { Thread.sleep(1000); System.out.println(子任务 taskId 完成); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { latch.countDown(); // 无论异常与否都要减一 } }); } System.out.println(主线程等待所有子任务完成...); latch.await(); // 也可以带超时latch.await(5, TimeUnit.SECONDS) System.out.println(所有子任务完成主线程继续); executor.shutdown(); } }注意countDown()一定要放在finally里否则子任务一旦抛异常计数器永远减不到0主线程就会永久卡死。每次写CountDownLatch我都会下意识检查这一点。4.2 Semaphore一个控制并发量的闸门Semaphore也是并发包里非常好用的工具它的作用是限制同时执行某个操作的线程数。我举一个实际场景某个定时任务要从数据库读取大量数据但数据库连接池最多允许10个连接。如果并发开50个线程去读数据库连接会瞬间耗尽。用Semaphore(10)做闸门同一时间最多只有10个线程能拿到许可进入数据库读取区。类比一下就是停车场门口显示剩余车位有车位就放车进没车位就等在门口。acquire()是申请车位release()是离开时释放车位。import java.util.concurrent.*; public class SemaphoreDemo { public static void main(String[] args) { Semaphore dbSemaphore new Semaphore(10); // 最多10个线程同时访问数据库 ThreadPoolExecutor executor new ThreadPoolExecutor(20, 20, 0L, TimeUnit.SECONDS, new LinkedBlockingQueue(100), r - new Thread(r, db-worker- System.nanoTime())); for (int i 0; i 50; i) { final int taskId i; executor.execute(() - { try { dbSemaphore.acquire(); // 获取许可没有就阻塞等待 try { System.out.println(任务 taskId 正在读数据库); Thread.sleep(500); } finally { dbSemaphore.release(); // 释放许可 } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); } executor.shutdown(); } }Semaphore的常见坑是许可泄漏如果acquire()之后代码抛异常没有执行release()许可数量就会永久减少。所以release()必须放在finally块里。还有如果要在分布式环境下限制并发量单机Semaphore就不够了要引入分布式锁或中间件限流这是后话。4.3 超时控制避免线程永远吊住线程协作里最容易被忽略的是超时。Future.get()不带超时参数等于把自己交给对方命运。如果被调用的任务永远不会结束你的线程也跟着永久阻塞线程池里的线程被这种任务慢慢耗尽整个服务就假死了。正确姿势是get(timeout)import java.util.concurrent.*; public class FutureTimeoutDemo { public static void main(String[] args) { ThreadPoolExecutor executor new ThreadPoolExecutor(2, 2, 0L, TimeUnit.SECONDS, new LinkedBlockingQueue(100), r - new Thread(r, timeout-demo)); FutureInteger future executor.submit(() - { // 模拟一个可能卡死的任务 Thread.sleep(10000); return 1; }); try { Integer result future.get(3, TimeUnit.SECONDS); System.out.println(拿到结果: result); } catch (TimeoutException e) { System.out.println(任务超时取消它); future.cancel(true); // 传入true表示即使任务在运行也发送中断信号 } catch (Exception e) { e.printStackTrace(); } finally { executor.shutdown(); } } }用CompletableFuture的话可以直接调用orTimeout给异步链路上保险CompletableFuture.supplyAsync(() - { try { Thread.sleep(5000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return data; }).orTimeout(3, TimeUnit.SECONDS) .whenComplete((result, throwable) - { if (throwable ! null) { System.out.println(任务超时或异常: throwable.getMessage()); } else { System.out.println(任务完成: result); } });超时控制的核心思想很简单任何可能无限期等待的调用都要套一层时间边界。5. 线程失控排查实录一套组合拳5.1 第一板斧top找到CPU异常的线程如果你怀疑线程有问题先看系统层面的表现。登录服务器执行top -Hp 12345-H表示按线程维度展示-p指定进程PID12345替换成你的Java进程PID。看到结果后按CPU占用排序最高的就是“嫌疑线程”记下它的线程号TID。线程号是十进制的但jstack里显示的是十六进制需要转换printf %x\n 12346把十进制线程号转成十六进制再去jstack输出里搜索就能定位这个线程对应的代码栈。5.2 第二板斧jstack看线程栈执行jstack 12345 thread_dump_1.log然后打开日志重点看线程状态RUNNABLE正在执行但如果大量RUNNABLE线程堆在同一个地方说明这里有CPU密集逻辑或自旋等待。BLOCKED被锁阻塞大量BLOCKED线程说明锁竞争严重。WAITING等待某个条件比如park、wait。大量WAITING线程如果都在等待同一个锁就是典型的“线程池满任务积压”信号。有一种误判要特别提醒线程池空闲时核心线程也会停留在WAITING状态等待队列中的新任务。所以看到WAITING线程别急着慌先看是不是大量线程同时BLOCKED或密集RUNNABLE。5.3 第三板斧arthas一把梭如果没有专门的监控平台arthas是我排查线程问题的亲密伙伴。几个高频命令# 查看当前最忙的3个线程 thread -n 3 # 查看阻塞其他线程的线程 thread -b # 按状态统计线程分布 thread --state WAITINGthread -n 3直接列出CPU消耗最高的线程连线程栈都帮你打好省去手动配对十六进制线程号的麻烦。5.4 真实案例一次线程池误配置事故的复盘有一次压测我负责的批量任务生成服务CPU打满线程数一路狂涨。监控面板上线程数从几百涨到两千然后服务响应变得极慢最后完全拒绝新任务。排查过程top -Hp看到大量线程CPU占用集中在某个正则匹配代码上。jstack一拉发现这些线程都停在一个Pattern.matcher()调用上。再一看代码里每来一个任务就Pattern.compile()一次没有复用预编译的正则对象。线程数暴涨的原因是线程池用了无界队列。任务积压越多线程池只会不断排队虽然maximumPoolSize设了50但队列永远不满核心线程只有10个任务排队排到天荒地老新的外部请求也进不来RT自然飙升。处理方案分两步先把线程池队列改成有界队列并配置CallerRunsPolicy让系统在过载时降级而不是无限积压。再把正则改成静态预编译对象避免每次匹配都重复编译。这两步下去CPU占用从100%降到20%左右服务恢复稳定。这个案例里暴露的问题其实有两个一个是线程池配置不符合“有界明确拒绝策略”的安全准则一个是业务代码里的性能隐患被高并发放大。线程控制的意义就在这它不一定能解决所有性能问题但能防止问题失控。5.5 常见问题速查表现象可能原因排查命令处理建议CPU打满线程数很多线程频繁创建/销毁、大量计算、死循环top -Hpjstackarthas thread -n用线程池复用线程优化热点代码内存溢出创建线程失败线程栈占用过多堆外内存jstack数线程数jmap -heap看堆内存限制线程池上限检查无界队列大量线程BLOCKED在同一锁上锁竞争严重持锁操作耗时过长jstack搜索BLOCKED缩小同步块范围用读写锁或无锁结构RejectedExecutionException线程池和队列都满了拒绝策略太粗暴看日志异常栈顶配置有界队列 CallerRunsPolicy服务卡死大量线程WAITING无界队列任务积压或调用了无超时的get()jstack 查看线程池监控设置超时改用有界队列6. 一点个人体会写这篇的时候我又想起那次凌晨被叫醒的经历。后来我把线上跑着的所有线程池都审查了一遍给每个线程池都起上明明白白的名字核心参数全部落成配置不再散落在业务代码里随手一写。线程控制说到底是一门“定规矩”的学问创建线程要有上限停止线程要讲协作任务排队要有边界等待结果要有超时。规矩立好了线上最大的风险就消除了一大半。这一篇把线程生命周期、线程池参数和排查手段讲清楚了。下一篇我还准备继续聊聊锁竞争、并发容器和异步编排这些进阶话题。最后送大家一句经验给线程池起好名字是所有线程控制手段里成本最低、收益最高的一件事千万别懒。
返回列表