《Java 并发编程·线程与内存模型》1.1 从进程到线程:JVM 线程模型

从进程地址空间与栈讲起,说明 JVM 平台线程与内核线程 1:1 映射的代价,给出完整 ThreadProbe 类、2000 个线程的创建耗时、threadId 等元信息,以及 jcmd Thread.print 的逐行读法,最后列出创建线程时最常见的四个坑。

承接的问题

上一节把环境、目录和版本基线交代完了。从这一节开始,我们正式进入并发。

第一个问题看起来最基础,却决定了后面所有讨论的参照系: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 执行、执行多久,由操作系统调度器决定。

由此可以推出三个后果:

  1. 阻塞会穿透到内核。 线程在 synchronized 上等锁、在 read() 上等数据,对应的 OS 线程也处于阻塞态,不能去干别的活。
  2. 线程数量受操作系统限制。 每个线程都要占内核数据结构、栈空间和调度开销,开几万个平台线程,系统会先于 JVM 撑不住。
  3. 栈是进程地址空间的一部分。 线程越多,虚拟地址占用越大,哪怕大部分栈页从未被真正写入。

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 默认栈估算,线程数量与地址空间预留的关系如下:

平台线程数栈预留(虚拟地址)典型场景
1020 MB常规业务线程池,CPU 核数量级
200400 MBIO 较多、每任务阻塞时间长的服务
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 设置,是最可靠的检索键
#25JVM 线程编号和 threadId() 一致;本次运行 worker 的 threadId 就是 25
[26627]操作系统线程 ID(十进制)可与 ps -M、采样工具对齐
prio=5Java 优先级默认值 5,不代表内核调度优先级
os_prio=31操作系统侧优先级不同平台取值不同,不要跨机器比较
cpu=0.21ms该线程累计消耗的 CPU 时间与 elapsed 对比可判断是「等」还是「算」
elapsed=2.60s线程启动至今的墙钟时间不是任务耗时,线程池复用时也不是任务开始时间
tid=0x...HotSpot 内部 Thread* 指针调试 JVM 本身时才用,不是 OS TID
nid=26627本地线程 ID和方括号里的数字对应
waiting on conditionJVM 记录的等待原因粗粒度提示,精确状态看下一行
[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 与任务抽象

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收