内核线程 · 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 显示 RUNNABLEJava 的线程状态和内核里的状态不是一回事④-2、⑤ 实验 1
第 2 站:线程池开多少个线程的成本,和“等待时间占多少”决定线程数④-1、④-4、⑤ 实验 2、4
第 6 站:47 个线程同时 BLOCKEDBLOCKED、WAITING、TIMED_WAITING 各在等什么④-2 状态机实验台
需求地图上这一项的深度是“懂机制”:能说清 Java 线程和内核线程的关系,看到一份线程转储能讲出每个线程在等什么,知道上下文切换从哪来、怎么数,能按“计算多少、等待多少”估出线程数。后面的锁、线程池、ThreadLocal、虚拟线程都建在这一页上。

②没有它会怎样:一个线程处理所有请求

假设订单服务只有一个线程。每个请求花 1 ms 算价,再花 20 ms 等数据库返回。等的那 20 ms,线程什么也不干,CPU 闲着,后面的请求只能排队。

实测 JDK 17,2 核。200 个任务,每个“算 1 ms + 等 20 ms”,交给固定大小的线程池。条形按对数刻度。

2 个线程2113 ms
8 个线程532 ms
32 个线程153 ms
128 个线程150 ms

不懂这一点时的典型误用:接口慢了就把线程池从 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 线程 = 一个内核线程

Thread 对象 在 Java 堆里 name = "exec-17" getState() JavaThread HotSpot 里的 C++ 结构 记着线程状态、 持有的锁、中断位 pthread + 栈 栈默认 1 MB(x64) 本机 ARM 是 2 MB 先占地址,用到才给内存 内核任务 tid 931043 /proc/self/task/931043 调度器决定它何时上 CPU ← Java 能看到的 操作系统能看到的 → start() 时才建出后三层:这一步要进内核,本机实测一个线程从新建到结束约 82~94 微秒;交给已有线程做同样的事约 9 微秒。
HotSpot 在 Linux 上是 1:1:一个 Java 线程对应一个内核线程,内核还用 Java 线程名的前 15 个字符给它命名,所以 top -H、/proc 里能直接看到 exec-17。实测 一个只起了 8 个业务线程的程序,内核里有 25 个线程:多出来的是 GC、JIT 编译器、引用处理这些 JVM 自己的线程。

④-2六种状态:JVM 只告诉你“在 Java 层面等什么”

先在下面这台状态机上走一遍:挑一个动作,看 Java 说它是什么状态、内核说它是什么状态、线程转储里那一行长什么样。

NEWnew 了还没 start RUNNABLE(Java 只有这一个标签) 排队等 CPU内核 R:可运行,在就绪队列里 在 CPU 上执行内核 R:正在某个核上跑 阻塞在本地 IO内核 S:睡着,等数据到accept()、read()、epoll_wait() BLOCKED等着进 synchronized WAITING等别人叫醒,不限时 Object.wait() park():队列 take、锁等待 TIMED_WAITINGsleep、带超时的 wait / park TERMINATEDrun() 返回了 右边这几种,内核里都是 S(睡着)

Java 说NEW

内核说还没有内核线程

线程转储(不出现)

切换次数主动让出 0 · 被抢占 0

从 NEW 开始。点 start()。

动作按钮只列出当前状态下能发生的事。试试在 BLOCKED、阻塞在 IO、WAITING 时分别点 interrupt()。

④-3上下文切换:内核把一个线程换下 CPU,换另一个上来

核 0 核 1 exec-17:SELECT qty,读到 1 exec-23:也读到 1,发 UPDATE exec-17:接着写回 qty = 0 price-calc:一直在算,时间片到了也没人跟它抢(就绪队列空) 被抢占 时间片到 主动让出 等数据库 每次切换(中间的小块)内核做的事:把 exec-17 的寄存器、程序计数器、栈指针存进它的内核栈 → 挑下一个线程 → 恢复 exec-23 的那一份。 同一进程的线程共用地址空间,不用换页表。直接开销几微秒;间接开销是新线程的数据不在 CPU 缓存里,前一段跑得慢。
线程号、时间是示意,两种切换是真的。主动让出(voluntary):线程自己要等,sleep、等锁、等 IO、park;被抢占(nonvoluntary):时间片用完,或者有更该跑的线程醒了。Linux 给每个线程分别计数,在 /proc/<pid>/task/<tid>/status 里叫 voluntary_ctxt_switches 和 nonvoluntary_ctxt_switches;全机每秒总数看 vmstat 1 的 cs 列。

④-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线程怎么停:中断是请求,守护线程不等

④-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 的坑。

API最少要会的几行
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 →

1States.java 节选
// 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 微秒
到这里已经能交差了:你能读懂一份线程转储里每个线程在等什么,知道 RUNNABLE 不一定在跑,能用 /proc 和 vmstat 数切换次数,能按公式给线程池估个起点。
已知还没处理的细节:转储里 RUNNABLE 的误读(⑥ 坑 1)、吞掉中断(⑥ 坑 2)、线程越多越慢(⑥ 坑 3)、每请求新建线程和 OOM(⑥ 坑 4)、守护线程丢数据(⑥ 坑 5)。

⑥细节·规范·优化

第一圈

注意事项:不知道就会出事故

坑 1把 RUNNABLE 当成“在干活”← ④-2 RUNNABLE 含 IO 阻塞
现象
CPU 打满,拿线程转储一看几十个 RUNNABLE,挨个查代码,最后发现大部分在 socketRead0、epollWait 里干等,真正吃 CPU 的只有两三个。⑤ acceptor:RUNNABLE,cpu=0.57ms
根源
阻塞在本地方法里的线程,Java 也标 RUNNABLE。
做法
先 top -H -p <pid> 找到 CPU 高的线程号,转成十六进制,在转储里按 nid=0x… 找;或者隔几秒拿两份转储,看 cpu= 涨了多少。
坑 2捕获 InterruptedException 后什么也不做← ④-5 中断是请求
现象
应用关闭时线程池 shutdownNow() 停不下来,进程要等很久或被 kill -9;定时任务取消了还在跑。
根源
sleep、wait 抛异常时把中断位清掉了。catch (InterruptedException e) {} 之后,线程再也不知道有人要它停。
做法
往上抛;或者 Thread.currentThread().interrupt() 补回中断位再退出循环。
坑 3线程越多越快?← ④-3 切换有成本← ④-4 线程数公式
现象
把线程池调到几千,吞吐不升反降,vmstat 里 cs 几十万每秒,r 列远大于核数。⑤ 实验 1:512 个线程慢 36%
根源
核就那么多。可运行的线程超过核数以后,多出来的只能排队、互相抢占,还把彼此的缓存冲掉。
做法
按公式估起点,压测找拐点;慢在锁上就去减锁的范围,慢在下游就考虑异步或虚拟线程,不是加线程。
坑 4每个请求 new 一个线程← ④-1 1:1,创建要进内核
现象
平时没事,流量一高报 OutOfMemoryError: unable to create native thread,或者机器内存被线程栈吃光。⑤ 实验 4:新建一个 82~94 µs
根源
每个线程都是一个内核线程,要一块栈,受 ulimit -u、/proc/sys/kernel/threads-max、容器的 pids 限制。线程数没有上限,就会撞上其中一个。
做法
用有界的线程池;真需要每个任务一个线程,用 JDK 21 的虚拟线程。
坑 5守护线程里做收尾的活← ④-5 守护线程
现象
应用正常退出,最后一批日志或订单状态没写进去。
根源
只剩守护线程时 JVM 直接退出,不等它们,finally 也不跑。
做法
要落盘的线程别设成守护;用 shutdown hook 或 Spring 的生命周期回调(@PreDestroy、SmartLifecycle)先停、再刷完。
第二圈

使用规范

线程都要有名字

线程池用自定义 ThreadFactory,名字带业务:order-cancel-1,不要 pool-3-thread-7。

出事时在转储里认不出是谁。

不直接 new Thread,用线程池

阿里巴巴 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)。

下一圈

↺回到⑤:改造你的案例

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 站。