承接的问题
上一节把环境、目录和版本基线交代完了。从这一节开始,我们正式进入并发。
第一个问题看起来最基础,却决定了后面所有讨论的参照系:Java 里的一个线程,到底是什么?
这个问题的答案直接影响到三件事。第一,为什么线程池不能无限开大;第二,为什么阻塞一个线程会让操作系统也跟着阻塞;第三,为什么第 14 章的虚拟线程能用同样的接口撑起十万级并发,而平台线程不行。
这一节不做抽象类比,而是把一个线程创建出来,打印它的元信息,再用 jcmd 从 JVM 外部看它。你看到的每个数字,都来自本机实跑。
进程给线程留了什么
进程是资源分配的单位,线程是 CPU 调度的单位。这句话的工程含义是:同一个进程里的线程共享大部分资源,但每个线程必须有自己独立的一份执行现场。
| 资源 | 进程内是否共享 | 说明 |
|---|---|---|
| 堆(heap) | 共享 | 所有对象都在这里,也是竞态的发源地 |
| 元空间(metaspace) | 共享 | 类元数据、常量池 |
| 代码缓存(code cache) | 共享 | JIT 编译后的机器码 |
| 打开的文件描述符 | 共享 | 一个线程关了文件,别的线程也读不到 |
| 进程号 PID | 共享 | 线程没有独立 PID(Linux 的 TID 是另一回事) |
| 程序计数器 PC | 私有 | 记录当前执行到哪条指令 |
| Java 虚拟机栈 | 私有 | 栈帧、局部变量表、操作数栈 |
| 本地方法栈 | 私有 | native 调用的现场 |
| 线程状态、优先级、daemon 标志 | 私有 | 由 Thread 对象承载 |
私有部分决定了「创建一个线程」不是零成本:JVM 必须为它准备栈空间,操作系统必须为它准备调度实体。
即使是一个只打印 Hello, World 的程序,JVM 启动后也不止一个线程。参考点、终结器、信号分发、GC、JIT 编译线程都在后台运行。后面用 jcmd 看到的线程列表里,main 只是其中一行。
平台线程与内核线程是 1:1 映射
Thread.ofPlatform() 创建的线程,在 HotSpot 里的路径大致是:
java.lang.Thread → JavaThread(JVM 内部结构) → OSThread → pthread → 内核调度实体
这是一条 1:1 的映射:一个 Java 平台线程对应一个操作系统线程。JVM 不做用户态调度,也不做绿色线程那套「多个 Java 线程复用一个内核线程」的把戏。线程什么时候被 CPU 执行、执行多久,由操作系统调度器决定。
由此可以推出三个后果:
- 阻塞会穿透到内核。 线程在
synchronized上等锁、在read()上等数据,对应的 OS 线程也处于阻塞态,不能去干别的活。 - 线程数量受操作系统限制。 每个线程都要占内核数据结构、栈空间和调度开销,开几万个平台线程,系统会先于 JVM 撑不住。
- 栈是进程地址空间的一部分。 线程越多,虚拟地址占用越大,哪怕大部分栈页从未被真正写入。
JDK 25 的虚拟线程是另一套模型:多个虚拟线程复用到少量载体线程上,由 JVM 调度,遇到阻塞时卸载。它不在这一节展开,第 14 章会给出创建十万个虚拟线程的实测。这里只需要记住:这一节说的所有成本,都是平台线程的成本。
创建一个平台线程要付多少代价
先看栈。JDK 在 macOS/aarch64 上的默认线程栈不是很多人以为的 1 MB,本机实测是 2048 KB:
export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
$JAVA_HOME/bin/java -XX:+PrintFlagsFinal -version 2>&1 | grep ThreadStackSize
intx ThreadStackSize = 2048 {pd product} {default}
ThreadStackSize 的单位是 KB,所以默认每个平台线程预留 2 MB 栈空间。2000 个线程就是约 4 GB 虚拟地址预留。注意是「预留」不是「已用」:栈页按需提交,实际 RSS 远小于这个数。但在 32 位进程或容器地址空间受限的环境里,这个数字会直接变成上限。
再看创建耗时。下面这个类创建 2000 个平台线程,每个线程睡 500 ms,主线程只测量「创建并启动」这一段:
import java.util.ArrayList;
import java.util.List;
public final class ThreadProbe {
public static void main(String[] args) throws Exception {
int count = args.length > 0 ? Integer.parseInt(args[0]) : 2000;
Thread worker = Thread.ofPlatform()
.name("conc-worker-1")
.start(() -> {
try {
Thread.sleep(30_000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
Thread.sleep(300);
System.out.println("pid=" + ProcessHandle.current().pid());
System.out.println("threadId=" + worker.threadId());
System.out.println("name=" + worker.getName());
System.out.println("daemon=" + worker.isDaemon());
System.out.println("priority=" + worker.getPriority());
System.out.println("state=" + worker.getState());
System.out.println("virtual=" + worker.isVirtual());
long begin = System.nanoTime();
List<Thread> threads = new ArrayList<>(count);
for (int i = 0; i < count; i++) {
threads.add(Thread.ofPlatform()
.name("conc-p-" + i)
.start(() -> {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}));
}
long elapsedMs = (System.nanoTime() - begin) / 1_000_000;
System.out.println("created=" + count + " elapsedMs=" + elapsedMs);
System.out.println("worker.state.after=" + worker.getState());
Thread.sleep(8_000);
for (Thread t : threads) {
t.join();
}
worker.join();
}
}
本机输出(Apple M1 Pro,10 核,32 GB,Temurin 25.0.5+8):
pid=63382
threadId=25
name=conc-worker-1
daemon=false
priority=5
state=TIMED_WAITING
virtual=false
created=2000 elapsedMs=316
worker.state.after=TIMED_WAITING
316 ms 创建 2000 个线程,平均每个约 0.16 ms。同一台机器重复运行时这个数字在 300–450 ms 之间波动,取决于当时有没有别的进程在抢 CPU。它说明的是数量级,不是精确基准。
0.2 ms 看起来不贵,但这是「创建」的钱,不含调度和上下文切换。2000 个线程同时跑起来之后,10 个核要轮转 2000 个可运行线程,每个线程真正拿到 CPU 的时间被切得很碎。如果任务是 CPU 密集的,多出来的线程只会增加切换开销。
按 2 MB 默认栈估算,线程数量与地址空间预留的关系如下:
| 平台线程数 | 栈预留(虚拟地址) | 典型场景 |
|---|---|---|
| 10 | 20 MB | 常规业务线程池,CPU 核数量级 |
| 200 | 400 MB | IO 较多、每任务阻塞时间长的服务 |
| 2000 | 约 4 GB | 已经是危险区,需要重新评估模型 |
| 10000 | 约 20 GB | 平台线程基本不可行,改用虚拟线程 |
这张表解释了一个常见现象:本地用 200 个线程压测没问题,上到 2000 个就开始出现 OutOfMemoryError: unable to create native thread。报错名字里写的是 native thread,根因却常常是地址空间或内核线程上限,而不是 Java 堆不够。
所以「一个请求开一个线程」的问题不在于创建本身,而在于线程数量增长后,调度、栈占用和锁竞争一起恶化。第 10 章会用线程池把创建成本和并发度分开控制。
threadId 与线程转储
Thread.getId() 在 JDK 25 已经弃用,替代品是 threadId(),返回 long。这不是改名游戏:getId() 的契约历史上不够清晰,新方法明确了一点,线程 ID 在线程生命周期内唯一,但线程终止后可以被复用。
这意味着两件事:
- 不能把
threadId()当作业务主键。今天日志里的 25 号线程,明天可能是另一个线程。 - 排查问题时,
threadId()的价值在于和线程转储对齐,而不是持久化。
从外部看线程,用 jcmd <pid> Thread.print。上面程序运行到 worker 睡眠时,在另一个终端执行:
export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
$JAVA_HOME/bin/jcmd <pid> Thread.print
本机输出片段(省略了应用帧):
"conc-worker-1" #25 [26627] prio=5 os_prio=31 cpu=0.21ms elapsed=2.60s tid=0x0000000900890000 nid=26627 waiting on condition [0x000000016fd52000]
java.lang.Thread.State: TIMED_WAITING (sleeping)
at java.lang.Thread.sleepNanos0(java.base@25.0.5/Native Method)
at java.lang.Thread.sleepNanos(java.base@25.0.5/Thread.java:509)
at java.lang.Thread.sleep(java.base@25.0.5/Thread.java:540)
这一行信息密度很高,逐段读:
| 片段 | 含义 | 排障时的用法 |
|---|---|---|
"conc-worker-1" | 线程名 | 由 Thread.Builder.name() 或 ThreadFactory 设置,是最可靠的检索键 |
#25 | JVM 线程编号 | 和 threadId() 一致;本次运行 worker 的 threadId 就是 25 |
[26627] | 操作系统线程 ID(十进制) | 可与 ps -M、采样工具对齐 |
prio=5 | Java 优先级 | 默认值 5,不代表内核调度优先级 |
os_prio=31 | 操作系统侧优先级 | 不同平台取值不同,不要跨机器比较 |
cpu=0.21ms | 该线程累计消耗的 CPU 时间 | 与 elapsed 对比可判断是「等」还是「算」 |
elapsed=2.60s | 线程启动至今的墙钟时间 | 不是任务耗时,线程池复用时也不是任务开始时间 |
tid=0x... | HotSpot 内部 Thread* 指针 | 调试 JVM 本身时才用,不是 OS TID |
nid=26627 | 本地线程 ID | 和方括号里的数字对应 |
waiting on condition | JVM 记录的等待原因 | 粗粒度提示,精确状态看下一行 |
[0x...] | 栈地址区间起点 | 分析栈溢出、栈片段时用 |
Thread.State: TIMED_WAITING (sleeping) | Java 线程状态及子原因 | 最权威的一行,六种状态见 1.3 |
读转储时先看 java.lang.Thread.State,再看栈顶帧。状态告诉你线程「在等什么类别的东西」,栈顶帧告诉你「等在哪一行代码」。只有两者一起看,才能判断是正常的空闲等待,还是卡死。
常见的误读是把 waiting on condition 当成「一定有问题」。线程池里的空闲工作线程本来就停在条件等待上,这是健康的。真正要关注的是:业务线程停在 BLOCKED 且栈顶指向同一把锁,或者大量线程停在同一个 WAITING 上。
实际排查时,通常先把转储落到文件,再做检索,而不是在滚动输出里找:
$JAVA_HOME/bin/jcmd <pid> Thread.print > /tmp/thread-dump.txt
grep -c '^"' /tmp/thread-dump.txt
grep -A 12 '"loan-worker-1"' /tmp/thread-dump.txt
$JAVA_HOME/bin/jcmd <pid> Thread.print -l > /tmp/thread-dump-locks.txt
grep -c '^"' 数的是线程数。-l 会额外打印 java.util.concurrent 锁的持有者,排查 ReentrantLock 卡死时比默认输出有用。注意 -l 的开销更大,线上只在需要时用。
在 libraryd-conc 里落地
在项目里,线程的创建不应该散落在业务代码中。conc-lab 里可以放一个最小的启动类,用来观察线程元信息和未捕获异常:
package com.plume.conc.lab;
import com.plume.conc.core.Book;
import com.plume.conc.core.BookRepository;
import com.plume.conc.core.InMemoryBookRepository;
public final class LoanWorkerBootstrap {
public static void main(String[] args) throws Exception {
BookRepository repository = new InMemoryBookRepository();
Thread worker = Thread.ofPlatform()
.name("loan-worker-1")
.uncaughtExceptionHandler((thread, error) ->
System.err.println("worker failed: " + thread.getName()
+ " -> " + error.getClass().getSimpleName()
+ ": " + error.getMessage()))
.start(() -> {
Book book = repository.findByIsbn("978-7-111-00001")
.orElseThrow(() -> new IllegalStateException("book missing"));
System.out.println("loaded " + book.title()
+ " stock=" + book.stock()
+ " on " + Thread.currentThread().getName());
});
worker.join();
System.out.println("worker threadId=" + worker.threadId()
+ " state=" + worker.getState()
+ " virtual=" + worker.isVirtual());
}
}
这段代码做了三件事:给线程起了可检索的名字、挂了未捕获异常处理器、在结束后打印终态。第 2 节会解释为什么 uncaughtExceptionHandler 在裸线程上有效,而在 submit() 提交的任务上不生效。
这个类适合在本地跑一次,然后立刻用 jcmd 连上去看。把 Thread.sleep 加在任务里,就能观察到 TIMED_WAITING;去掉 sleep 换成死循环,就能看到 RUNNABLE。线程转储不是背出来的,是对着真实进程看出来的。
常见错误与边界
| 错误写法 | 后果 | 正确做法 |
|---|---|---|
每个请求 new Thread(...) | 线程数随流量线性增长,调度与栈开销压垮进程 | 用线程池(第 10 章)或虚拟线程(第 14 章) |
为了省内存把 -Xss 调到 128k | 稍深的递归或框架调用就 StackOverflowError | 保持默认 2 MB,按实测调整,不要凭感觉压 |
为了「保险」把 -Xss 调到 64m | 每个线程预留 64 MB 虚拟地址,线程数一多就耗尽地址空间 | 默认值通常够用,需要大栈时单独给那个线程的调用路径做验证 |
用 threadId() 做业务幂等键 | 线程终止后 ID 可能复用,键会串 | 用业务 ID 或 UUID |
| 只看线程名判断健康 | 重名线程、池复用都会误导 | 名字 + threadId + 栈顶帧一起看 |
还有一类边界不属于「错误」,而是「不该用平台线程」:高并发、IO 阻塞为主、每个任务大部分时间在等下游响应的场景,平台线程的数量会被下游延迟卡死。这时应该考虑虚拟线程,而不是继续加机器。反过来,CPU 密集型任务开超过核数的平台线程没有收益,只会增加上下文切换。
第 14 章会给出虚拟线程的调度模型和 pinning 边界。在那之前,先把平台线程的成本记牢,否则「虚拟线程为什么快」只会变成一句口号。
小结
- 平台线程与内核线程是 1:1 映射,阻塞会穿透到操作系统,线程数量受 OS 限制。
- 本机默认
ThreadStackSize是 2048 KB,2000 个线程约预留 4 GB 虚拟地址,但 RSS 远小于此。 - 本机创建 2000 个平台线程约 316 ms,数量级是每个 0.16 ms;这个数字不含调度和切换成本。
Thread.getId()已弃用,用threadId();它只适合和转储对齐,不适合做业务键。- 读
jcmd Thread.print先看java.lang.Thread.State,再看栈顶帧,最后才看waiting on condition这类提示。
阅读导航:上一节 目录与学习路径 | 下一节 1.2 Thread、Runnable、Callable 与任务抽象
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。