内核线程 · 1:1 · 线程栈 · NEW / RUNNABLE / BLOCKED / WAITING / TIMED_WAITING / TERMINATED · jcmd Thread.print · /proc · 上下文切换 · 主动让出 · 被抢占 · 线程数估算 · interrupt · 守护线程
线程:一个 Java 线程就是一个内核线程,状态是 JVM 贴的标签,切换是内核做的
Java 的 Thread 背后是一个真正的操作系统线程,由内核决定它什么时候上 CPU、什么时候被换下来。Thread.State 的六种状态只说明它在 Java 层面等什么:等锁、等通知、等时间到;它在 socket 上干等时,Java 照样说它是 RUNNABLE。线程被换下 CPU 叫上下文切换,一次几微秒;线程开得越多,切得越频繁。读懂这三件事,才看得懂线程转储,也才估得出线程池该开多大。
全景:一个下单请求在线程池里 · 第 1、2、4 站地图:J02 · 线程、线程状态与上下文切换(第 1 个知识点)实验:JDK 17.0.20,2 核 ARM,Linux 6.8 实测
①为什么需要:全景里有三站说不清
在全景第 4 站,这一步要解决的是:exec-17 刚读到“还剩 1 件”,还没写回,就被换下了 CPU,exec-23 趁机也读到 1。谁决定换、什么时候换、换一次要多久?同一张全景里还有两个问题:第 1 站的 Acceptor 明明在干等新连接,线程转储却说它 RUNNABLE;第 2 站的 Tomcat 默认开到 200 个线程,为什么是 200,开 2000 个会不会更快?
| 全景里的现象 | 背后的问题 | 本页在哪讲 |
|---|---|---|
| 第 4 站:读完库存被换下 CPU | 线程什么时候被切走,切一次多贵 | ④-3、⑤ 实验 3 |
| 第 1 站:干等的 Acceptor 显示 RUNNABLE | Java 的线程状态和内核里的状态不是一回事 | ④-2、⑤ 实验 1 |
| 第 2 站:线程池开多少个 | 线程的成本,和“等待时间占多少”决定线程数 | ④-1、④-4、⑤ 实验 2、4 |
| 第 6 站:47 个线程同时 BLOCKED | BLOCKED、WAITING、TIMED_WAITING 各在等什么 | ④-2 状态机实验台 |
②没有它会怎样:一个线程处理所有请求
假设订单服务只有一个线程。每个请求花 1 ms 算价,再花 20 ms 等数据库返回。等的那 20 ms,线程什么也不干,CPU 闲着,后面的请求只能排队。
- 只有两个线程时,CPU 绝大部分时间在等:每 21 ms 里只算了 1 ms。一个线程一次只能等一件事。
- 线程从 2 个加到 32 个,快了 14 倍;再加到 128 个,几乎没变,因为这时 CPU 已经忙满了。
- 缺的那一块:让“等”的时候把 CPU 让给别人。多个线程各等各的,内核把 CPU 轮流分给此刻有活干的线程。代价是每个线程都要内存,换来换去也要时间。
不懂这一点时的典型误用:接口慢了就把线程池从 200 调到 2000。如果慢在等下游,确实会快一点;如果慢在 CPU 或者锁上,线程越多切换越多、排队越长,反而更慢(⑤ 实验 1、⑥ 坑 3)。
③相邻方案:让多件事同时进行的四种做法
| 做法 | 谁来调度 | 一件事等待时 | 成本 | Java 里的例子 |
|---|---|---|---|---|
| 多进程 | 内核 | 内核换别的进程上 | 最重:各有一份内存 | 起多个 JVM |
| 平台线程(1:1) | 内核 | 内核换别的线程上 | 每个约几十微秒创建、一块栈;切换几微秒 | new Thread、线程池、Tomcat exec 线程 |
| 虚拟线程(M:N) | JVM 把很多虚拟线程放到少数平台线程上 | JVM 把它从平台线程上卸下来 | 很轻:栈在堆上,按需长 | JDK 21 Thread.ofVirtual() |
| 事件循环 | 你自己的代码 | 注册回调,线程去处理别的事件 | 最轻,但代码要写成回调或响应式 | Netty、Tomcat 的 Poller、WebFlux |
选择规则:日常 Java 后端就是平台线程加线程池;连接很多、大部分时间在等 IO 时考虑虚拟线程或事件循环。后两者都建在平台线程上,所以本页学平台线程:Java 线程、内核线程、状态和切换。虚拟线程是 J02 最后一个知识点,事件循环见 网络 IO 模型。
④原理:一个线程从 new 到结束
- CPU 核:一个核同一时刻只能执行一条指令流。2 核机器上,同一瞬间最多 2 个线程真正在跑,其他的都在等。
- 内核态:创建线程、睡眠、唤醒、切换都要进操作系统内核,一次进出就有几百纳秒以上的开销。
- /proc:Linux 把每个进程、每个线程的信息做成文件。
/proc/<pid>/task/下面一个目录就是一个线程。
④-1一个 Java 线程 = 一个内核线程
top -H、/proc 里能直接看到 exec-17。实测 一个只起了 8 个业务线程的程序,内核里有 25 个线程:多出来的是 GC、JIT 编译器、引用处理这些 JVM 自己的线程。④-2六种状态:JVM 只告诉你“在 Java 层面等什么”
先在下面这台状态机上走一遍:挑一个动作,看 Java 说它是什么状态、内核说它是什么状态、线程转储里那一行长什么样。
Java 说NEW
内核说还没有内核线程
线程转储(不出现)
切换次数主动让出 0 · 被抢占 0
从 NEW 开始。点 start()。
- 1RUNNABLE 不等于在跑。它包括在 CPU 上跑、排队等 CPU、阻塞在本地方法里三种。JVM 看不到本地代码在内核里干什么,只知道“它没在等 Java 的锁或通知”,所以一律标 RUNNABLE。实测 等新连接的 acceptor 线程:Java 说 RUNNABLE,内核说 S,
cpu=0.57ms;死循环的 price-calc:两边都是 R,cpu=3226ms。 - 2BLOCKED 只有一个来源:进 synchronized 时锁被别人拿着(包括
wait()被叫醒后要重新拿回锁)。ReentrantLock、阻塞队列等不到时是 WAITING,因为它们底下用的是LockSupport.park,不是 synchronized。全景第 6 站那 47 个线程是 BLOCKED,就是因为用了 synchronized。 - 3WAITING 和 TIMED_WAITING 只差一个超时。
wait()、join()、park()不限时;sleep(ms)、wait(ms)、parkNanos限时。线程转储括号里还会写清是(on object monitor)、(parking)还是(sleeping)。 - 4状态和“持有什么”无关。实测 exec-17 拿着库存锁在里面
sleep,它是 TIMED_WAITING,转储里有一行- locked <0x…be98>;exec-23 等同一把锁,是 BLOCKED,转储写- waiting to lock <0x…be98>。按地址就能找到谁卡住了谁。
④-3上下文切换:内核把一个线程换下 CPU,换另一个上来
/proc/<pid>/task/<tid>/status 里叫 voluntary_ctxt_switches 和 nonvoluntary_ctxt_switches;全机每秒总数看 vmstat 1 的 cs 列。- 1主动让出多:线程大部分时间在等,看是在等锁(BLOCKED 多不多)还是在等 IO。被抢占多:可运行的线程比核多,CPU 不够分。
- 2实测 两个线程用
park / unpark来回交接 20 万次,平均一次 3.4~3.9 微秒,几乎每次都是一次主动让出。这就是“叫醒另一个线程干活”的最低代价,也是为什么锁竞争激烈时吞吐会掉。
④-4线程开多少:看一个任务里“等”占多少
线程数 ≈ 核数 × (1 + 等待时间 ÷ 计算时间)
出处是《Java 并发编程实战》(Goetz)。纯计算的任务,等待为 0,线程数 ≈ 核数(常见写法 N + 1);等下游、等数据库的任务,等得越久,越要多开。实测 2 核、每个任务算 1 ms 等 20 ms:公式给出 2 × 21 = 42。32 个线程 153 ms,128 个线程 150 ms,过了 42 再加就没用了。
公式只给起点。等待时间要从监控里量(接口 P50 的耗时里有多少在等下游),最后靠压测定。Tomcat 默认 200,是给“大多数时间在等数据库”的典型 Web 应用留的余量。
④-5线程怎么停:中断是请求,守护线程不等
- 1
t.interrupt()只是把 t 的中断位置成 true。t 正在sleep、wait、join时,会抛InterruptedException并清掉中断位;在park时直接返回,中断位保持 true;在等 synchronized 或者阻塞在传统 Socket 读写时,什么也不发生,接着等。NIO 的可中断通道(InterruptibleChannel)会被关掉,抛ClosedByInterruptException。 - 2所以停一个线程靠它自己配合:循环里检查
Thread.currentThread().isInterrupted(),捕获InterruptedException后要么往上抛,要么调interrupt()把中断位补回去。Thread.stop()会在任意一行把线程打死、锁也来不及好好释放,早就废弃,JDK 20 起直接抛UnsupportedOperationException。 - 3守护线程(
setDaemon(true)):JVM 只剩守护线程时就退出,不等它们跑完,它们的finally也不执行。GC 线程、J01 全景里取消订单的order-canceller都是守护线程。
④-6为什么这样设计:1:1,把调度交给内核
1:1 平台线程HotSpot 从 JDK 1.3 前后起在各平台都用
- 线程在 native 代码、系统调用里阻塞,只卡住它自己,内核把核给别人
- 天然用上多核:内核调度器把线程分到各个核上
- JVM 不用自己写调度器,和 C 库、JNI、调试器、
top -H都对得上 - 每个线程一块栈,创建、切换都要进内核:几千个就到头了
M:N 绿色线程JDK 1.1 在 Solaris 上用过,后来放弃
- 线程由 JVM 在用户态切换,又轻又快
- 早期实现只用一个内核线程:一个绿色线程卡在系统调用里,全部一起停;也用不上多核
- JVM 要自己做调度,还得把每个阻塞的系统调用改成非阻塞
决定胜负的约束:当年的关键是阻塞的系统调用和多核,内核线程两样都白给。代价是线程重,于是有了线程池。JDK 21 的虚拟线程又回到 M:N,是因为 JVM 终于把 JDK 里的阻塞 IO 都改造成了“等待时把虚拟线程卸下来”;没改造的地方(JDK 21 里的 synchronized、本地方法)就是 pinning 的坑。
Thread t = new Thread(task, "order-canceller"); // 起名字,转储里才认得出
t.setDaemon(true); // 守护线程:JVM 退出不等它
t.start(); // 这时才建出内核线程;run() 只是普通方法调用
t.getState(); // NEW / RUNNABLE / BLOCKED / WAITING / TIMED_WAITING / TERMINATED
t.interrupt(); // 只是请求,见 ④-5
t.join(); // 等它结束
# 命令行
jcmd <pid> Thread.print # 线程转储(jstack 同样)
grep ctxt /proc/<pid>/task/*/status # 每个线程的两种切换次数
vmstat 1 # cs 列:全机每秒切换次数;r 列:可运行线程数
top -H -p <pid> # 每个线程占多少 CPU
线程栈大小(-Xss)、线程转储在各版本里的格式、调度器的时间片,这些细节放到第⑥步。
⑤亲眼看到:靶场里的线程,Java 和内核各说了什么
两个程序,JDK 17 编译成 Java 8 字节码(--release 8),只用 JDK 自带的类。States 让靶场里几个线程各停在一种状态上,同时从 /proc 读内核状态;CtxSwitch 做四个小实验。完整代码:study-labs/java/juc/thread-state-context-switch →
// exec-17 拿着库存锁在里面睡;exec-23 也要这把锁
start("exec-17", () -> { synchronized (stockLock) { sleep(60_000); } });
start("exec-23", () -> { synchronized (stockLock) { sink++; } });
// 像 Tomcat 的 Acceptor:等新连接
start("acceptor", () -> server.accept());
// 等支付回调;取消队列 take()
start("pay-waiter", () -> { synchronized (payResult) { payResult.wait(); } });
start("order-canceller", () -> cancelQueue.take());
// 线程名 → 内核线程号:/proc/self/task/*/comm
// 内核状态:/proc/self/task/<tid>/stat 里右括号后面那个字母
for (Thread t : ts)
System.out.printf("%-18s %-16s %s%n", t.getName(), t.getState(), kernelState(tid));
真实代码是 Java 8 写法(匿名内部类),这里为了短写成 Lambda。
$ java -cp … States 线程名 Java 状态 内核 order-new NEW -(内核里没有) order-done TERMINATED -(内核里没有) price-calc RUNNABLE R acceptor RUNNABLE S exec-17 TIMED_WAITING S exec-23 BLOCKED S pay-waiter WAITING S order-canceller WAITING S 这个进程在内核里一共有 25 个线程
$ jcmd <pid> Thread.print (节选) "acceptor" … cpu=0.57ms … runnable java.lang.Thread.State: RUNNABLE at sun.nio.ch.Net.accept(Native Method) "exec-17" … waiting on condition java.lang.Thread.State: TIMED_WAITING (sleeping) - locked <0x000000075121be98> "exec-23" … waiting for monitor entry java.lang.Thread.State: BLOCKED (on object monitor) - waiting to lock <0x000000075121be98> "pay-waiter" … in Object.wait() java.lang.Thread.State: WAITING (on object monitor) "order-canceller" … waiting on condition java.lang.Thread.State: WAITING (parking) at jdk.internal.misc.Unsafe.park(Native Method) "price-calc" … cpu=3226.63ms … runnable
CtxSwitch:四个实验
$ java -cp … CtxSwitch (两次运行,取第一次;第二次见 README) CPU 核数 2,JDK 17.0.20.1 == 1. 纯计算:总量相同(约 2 核 × 1 秒),分给不同个数的线程 == 线程数 耗时ms 主动让出 被抢占 2 1025 0 333 8 1028 9 1180 64 1160 47 1455 512 1393 21 1842 == 2. 200 个任务,每个算 1 ms、等 20 ms == 线程数 耗时ms 理论下限ms 2 2113 2100 8 532 525 32 153 147 128 150 120 == 3. 两个线程来回交接(park / unpark)== 交接 200000 次,共 773 ms,平均一次 3.9 微秒 两个线程合计:主动让出 179770 次,被抢占 2161 次 == 4. 新建线程 vs 线程池 == 新建、启动、等它结束一个线程:平均 82.0 微秒 同样的活交给已有线程(提交并等结果):平均 8.8 微秒
- 1实验 1:纯计算的活,2 个线程(等于核数)最快。512 个线程慢了 36%,被抢占次数涨到 5 倍多:总活不变,多出来的是切换和缓存失效。第二次运行同样是 512 个线程最慢(1399 ms)。
- 2实验 2:等待占大头的活,线程数接近公式给的 42 时最好;128 个线程的下限受 CPU 限制(200 ms 计算 ÷ 2 核 + 20 ms),已经压不下去了。
- 3实验 3 的 3.4~3.9 微秒包含 unpark 的系统调用、唤醒和一次切换;两次运行都是这个量级。实验 4:新建线程比复用贵 9~10 倍,这还没算线程栈占的内存。
⑥细节·规范·优化
注意事项:不知道就会出事故
- 现象
- CPU 打满,拿线程转储一看几十个 RUNNABLE,挨个查代码,最后发现大部分在
socketRead0、epollWait里干等,真正吃 CPU 的只有两三个。⑤ acceptor:RUNNABLE,cpu=0.57ms - 根源
- 阻塞在本地方法里的线程,Java 也标 RUNNABLE。
- 做法
- 先
top -H -p <pid>找到 CPU 高的线程号,转成十六进制,在转储里按nid=0x…找;或者隔几秒拿两份转储,看cpu=涨了多少。
- 现象
- 应用关闭时线程池
shutdownNow()停不下来,进程要等很久或被 kill -9;定时任务取消了还在跑。 - 根源
sleep、wait抛异常时把中断位清掉了。catch (InterruptedException e) {}之后,线程再也不知道有人要它停。- 做法
- 往上抛;或者
Thread.currentThread().interrupt()补回中断位再退出循环。
- 现象
- 把线程池调到几千,吞吐不升反降,
vmstat里cs几十万每秒,r列远大于核数。⑤ 实验 1:512 个线程慢 36% - 根源
- 核就那么多。可运行的线程超过核数以后,多出来的只能排队、互相抢占,还把彼此的缓存冲掉。
- 做法
- 按公式估起点,压测找拐点;慢在锁上就去减锁的范围,慢在下游就考虑异步或虚拟线程,不是加线程。
- 现象
- 平时没事,流量一高报
OutOfMemoryError: unable to create native thread,或者机器内存被线程栈吃光。⑤ 实验 4:新建一个 82~94 µs - 根源
- 每个线程都是一个内核线程,要一块栈,受
ulimit -u、/proc/sys/kernel/threads-max、容器的 pids 限制。线程数没有上限,就会撞上其中一个。 - 做法
- 用有界的线程池;真需要每个任务一个线程,用 JDK 21 的虚拟线程。
- 现象
- 应用正常退出,最后一批日志或订单状态没写进去。
- 根源
- 只剩守护线程时 JVM 直接退出,不等它们,
finally也不跑。 - 做法
- 要落盘的线程别设成守护;用 shutdown hook 或 Spring 的生命周期回调(
@PreDestroy、SmartLifecycle)先停、再刷完。
使用规范
线程池用自定义 ThreadFactory,名字带业务:order-cancel-1,不要 pool-3-thread-7。
出事时在转储里认不出是谁。
阿里巴巴 Java 开发手册的强制规则。线程池要有界,拒绝策略要想清楚(线程池页)。
坑 4。
不写空的 catch (InterruptedException);长循环里检查 isInterrupted()。
坑 2:停不下来。
设 UncaughtExceptionHandler,或者用 submit 拿 Future 时记得 get()。
线程悄悄死掉、任务悄悄没跑。
深处:切换、调度和版本
上下文切换的间接成本更大
寄存器存取只要几百纳秒。更贵的是切回来以后:L1、L2 缓存里装的是别的线程的数据,要重新从内存读,前几十微秒跑得明显慢。所以“每秒切换多少次”比“一次多贵”更值得看:几万次通常无所谓,几十万次就该查了。
Linux 6.6 起默认调度器从 CFS 换成了 EEVDF(本机 6.8 就是)。两者都按“谁欠 CPU 时间多先跑谁”来分,没有固定的时间片长度,可运行的线程越多,每个分到的那一段越短,被抢占也越多。
线程栈:-Xss 和“占地址不占内存”
栈大小默认 1 MB(Linux x64),本机 Linux ARM 64 位是 2 MB(ThreadStackSize=2040 KB)。这是先划出的地址范围,用到哪页才真正给物理内存,所以 1000 个线程不会立刻吃掉 1~2 GB,但递归很深、栈帧很大的线程会。调小 -Xss 能多开线程,调太小会 StackOverflowError。
版本差异
| JDK 8 | 转储里线程号是 nid=0x…(十六进制),没有 cpu=、elapsed= |
|---|---|
| JDK 17 | 多了 cpu=… elapsed=…,线程号仍是十六进制(实测 nid=0xe99fb) |
| JDK 21 | 线程号同时给十进制:#41 [923220](实测,全景页那次);虚拟线程不出现在 Thread.print 里,要用 jcmd <pid> Thread.dump_to_file |
| JDK 20+ | Thread.stop() 直接抛 UnsupportedOperationException(按 JDK 21 源码) |
面试官会怎么问
线程有哪几种状态?BLOCKED 和 WAITING 有什么区别?
六种。BLOCKED 只在等进 synchronized;WAITING 是等别人显式叫醒(notify、unpark、join 的线程结束)。ReentrantLock 等锁是 WAITING,因为它用 park(④-2)。
调用 start() 和直接调用 run() 有什么区别?
start() 让 JVM 建出内核线程,新线程去执行 run();直接调 run() 只是在当前线程里调一个普通方法(④-1)。
sleep 和 wait 有什么区别?
sleep 是 Thread 的静态方法,不释放锁,到点自己醒;wait 是 Object 的方法,必须在 synchronized 里调,会释放锁,被 notify 或超时后要重新抢锁,抢不到时是 BLOCKED(④-2 实验台)。
什么是上下文切换?怎么减少?
内核把一个线程换下 CPU、换另一个上来,保存和恢复寄存器等现场。减少办法:线程数别超过需要(④-4)、减少锁竞争、用无锁结构或 CAS、IO 多的场景用虚拟线程或事件循环(④-3)。
- HotSpot 源码里线程状态怎么来的:
JavaThreadState和java_lang_Thread::threadStatus的对应 - 用
perf sched、pidstat -w -t看谁在切、为什么切;async-profiler 的 wall 模式看等待时间(J03、J13) - CPU 亲和性、
taskset和容器里的 CPU 限额:JVM 以为有 8 个核,其实只能用 2 个 - synchronized 怎么加锁、锁升级,为什么 BLOCKED(J02 下一个知识点之后)
- 虚拟线程:同样的状态和切换,换成 JVM 在用户态做(J02 第 9 个知识点)
↺回到⑤:改造你的案例
1. 找出吃 CPU 的线程
让 States 带 hold 参数跑起来,用 top -H -p <pid> 找到 CPU 最高的线程号,转成十六进制,在 jcmd Thread.print 里按 nid 找到它。对一下是不是 price-calc。
用到:④-1、坑 1
2. 让 order-canceller 能停下来
把它改成循环 take() 处理取消任务,主线程过 1 秒调 interrupt()。先写一个吞掉异常的版本,看它停不下来;再改成补回中断位、退出循环。
用到:④-5、坑 2
3. 给你的接口估线程数
改 CtxSwitch 实验 2 的“算 1 ms、等 20 ms”成你项目里一个接口的真实比例(看监控),先用公式算,再跑出拐点,对比两个数。
用到:④-4、坑 3
自测
1. 用一句话说出这页的精华。
Java 线程就是内核线程,由内核调度和切换;Thread.State 只说明它在 Java 层面等什么,RUNNABLE 也可能在 IO 里睡着;切换有成本,线程数要按“等待占多少”来定。
2. 一个线程处理所有请求,疼在哪?
它等数据库的时候 CPU 闲着,别的请求也只能排队。⑤ 实验 2 里 2 个线程 2113 ms,32 个线程 153 ms。
3. 线程转储里几十个 RUNNABLE,CPU 却不高,为什么?
它们多半阻塞在本地 IO 里,比如 socket 读、epoll 等待。JVM 看不到内核里发生了什么,一律标 RUNNABLE(④-2)。看 cpu= 或者 top -H 才知道谁在真跑。
4. 预测:把 exec-17 锁里的 Thread.sleep(60_000) 换成 stockLock.wait(60_000),exec-23 会变成什么状态?
exec-23 能进锁了。wait 会释放锁,exec-23 拿到锁、执行完 sink++,变成 TERMINATED;exec-17 是 TIMED_WAITING (on object monitor)。60 秒到了,exec-17 要重新抢锁,抢到之前是 BLOCKED(④-2 第 2 条)。
5. 预测:一个 4 核容器,CPU 限额只给 1 核,JVM 却按 4 核把 ForkJoinPool 和 GC 线程开满,会看到什么?
可运行线程远多于真正能用的 CPU,被抢占次数(nonvoluntary)猛涨,响应时间抖动。纯计算任务更明显,和 ⑤ 实验 1 里 512 个线程的情形一样(④-3)。JDK 10 起 JVM 会读容器的 CPU 限额来算核数,老版本或配错时就会这样。
6. 放回全景:第 4 站 exec-17 读完库存被换下 CPU,这是哪种切换?如果服务器有 64 个核、Tomcat 只有 4 个线程,第 4、5 站还会撞吗?
如果是等 SELECT 的结果,是主动让出;如果正好时间片用完,是被抢占。两种都会让 exec-23 插进来。64 核、4 个线程时几乎不会被抢占,但 4 个线程可以同时在 4 个核上跑,照样同时读到 1、各自写回 0。撞车的根源是“读、判断、写回”不是一步,不是切换本身;切换只是让单核上也能撞。修法是全景第 6、7、8 站。