凌晨两点十七分,我被值班电话叫醒。线上订单服务的RT(响应时间)从20毫秒直接飙到5秒,监控面板一片飘红。打开终端输入top,CPU全核打满,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,获取执行结果 FutureTask<Integer> 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,队列也满,触发拒绝策略。
也就是说,不是任务一多就立刻加线程,而是先排队,排不下才加线程。很多人配置了maximumPoolSize=200,但队列用了无界队列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,容量2000,keepAliveTime设为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而不是默认的AbortPolicy?AbortPolicy直接抛异常,业务方稍不注意就感知到失败;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, BlockingQueue<Runnable> 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")); Future<Integer> 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指定进程PID,12345替换成你的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 -Hp+jstack+arthas thread -n | 用线程池复用线程,优化热点代码 |
| 内存溢出,创建线程失败 | 线程栈占用过多堆外内存 | jstack数线程数,jmap -heap看堆内存 | 限制线程池上限,检查无界队列 |
| 大量线程BLOCKED在同一锁上 | 锁竞争严重,持锁操作耗时过长 | jstack搜索BLOCKED | 缩小同步块范围,用读写锁或无锁结构 |
RejectedExecutionException | 线程池和队列都满了,拒绝策略太粗暴 | 看日志异常栈顶 | 配置有界队列 +CallerRunsPolicy |
| 服务卡死,大量线程WAITING | 无界队列任务积压,或调用了无超时的get() | jstack+ 查看线程池监控 | 设置超时,改用有界队列 |
6. 一点个人体会
写这篇的时候,我又想起那次凌晨被叫醒的经历。后来我把线上跑着的所有线程池都审查了一遍,给每个线程池都起上明明白白的名字,核心参数全部落成配置,不再散落在业务代码里随手一写。线程控制说到底是一门“定规矩”的学问:创建线程要有上限,停止线程要讲协作,任务排队要有边界,等待结果要有超时。规矩立好了,线上最大的风险就消除了一大半。
这一篇把线程生命周期、线程池参数和排查手段讲清楚了。下一篇我还准备继续聊聊锁竞争、并发容器和异步编排这些进阶话题。最后送大家一句经验:给线程池起好名字,是所有线程控制手段里成本最低、收益最高的一件事,千万别懒。