侧边栏壁纸
  • 累计撰写 140 篇文章
  • 累计创建 21 个标签
  • 累计收到 3 条评论

目 录CONTENT

文章目录

从进程线程到Java虚拟线程

YaFuX
2023-10-03 / 0 评论 / 0 点赞 / 0 阅读 / 0 字
温馨提示:
部分素材来自网络,若不小心影响到您的利益,请联系我们删除。

前言

上一篇已经把计算机启动、Kernel、用户态、内核态和 System Call 串了起来。

操作系统真正开始运行以后,下一个最自然的问题就是:

操作系统到底在管理谁?CPU 又是在调度谁?

我们平时会同时听到很多词:

程序
进程
线程
Linux Task
Java Thread
Platform Thread
Fiber
Coroutine
Virtual Thread
Carrier Thread

它们看起来都和“让代码跑起来”有关,但如果只背一句:

进程是资源分配的基本单位,线程是 CPU 调度的基本单位。

很多细节依然解释不了:

  • 一个 .jar 文件还没运行时,算进程吗?
  • 同一个程序为什么可以启动多个进程?
  • 一个进程里的线程到底共享什么,又独立拥有什么?
  • Linux 为什么经常不刻意区分“进程”和“线程”,而统一叫 task
  • Java 的 Thread 到底是不是 Linux 线程?
  • 为什么传统 Java 线程通常被叫作“重量级线程”?
  • 以前常说的 Fiber、Coroutine 和今天 Java 的 Virtual Thread 是不是一回事?
  • Virtual Thread 是不是比普通线程执行得更快?
  • 为什么它特别适合高并发 I/O,却不适合拿来提升纯 CPU 计算速度?

这篇文章不再把这些概念分开背,而是从一段程序真正运行起来开始,把它们放到同一条执行链上。

程序文件
进程 Process
Platform Thread
Linux Task / OS Thread
CPU Core
Virtual Thread
JDK Scheduler

一、程序、进程和线程是什么关系?

1. 程序为什么不等于进程?

程序首先是一份静态内容

它可能是:

  • 本地 ELF 可执行文件;
  • Windows PE 文件;
  • Shell / Python 脚本;
  • Java 的 .class / .jar
  • 以及程序运行时需要的资源和配置。

例如磁盘上有:

app.jar

只要你没有启动它,它只是存储设备中的一组字节。

它不会:

  • 占用 CPU 执行;
  • 拥有正在运行的线程;
  • 拥有某个运行时 PID;
  • 拥有当前进程的虚拟地址空间。

当执行:

java -jar app.jar

操作系统先启动 java 这个可执行程序,创建对应的运行实例;HotSpot 再加载你的 Class 和字节码。

这时候才进入“进程”的世界。

2. 进程是程序的一次运行实例

进程可以先理解成:

一段程序被操作系统真正运行起来以后形成的运行实例,以及与这次运行关联的资源和执行上下文。

一个进程通常涉及:

  • 虚拟地址空间;
  • 已映射的代码和数据;
  • 打开的文件描述符;
  • 信号处理状态;
  • 线程集合;
  • 身份和权限;
  • 内核用于跟踪它的数据结构。

同一个程序完全可以产生多个进程。

例如:

同一个 server 可执行文件
        │
        ├── Process A
        ├── Process B
        └── Process C

它们运行的是相同代码,但拥有不同的运行时身份和资源环境。

所以:

Program = 静态代码/文件
Process = 某次运行实例

两者不是同一个维度。

3. 线程是进程中的执行流

一个进程不一定只有一条执行路径。

例如一个服务进程内部同时可能存在:

请求处理
GC
JIT 编译
定时任务
日志写入
网络事件处理

这些执行活动可以由不同线程承担。

可以先画成:

Process
Thread A
Thread B
Thread C
自己的执行栈
自己的执行栈
自己的执行栈
共享地址空间
共享部分文件/进程资源

同一进程中的线程通常:

共享:

  • 进程的虚拟地址空间;
  • Heap;
  • 静态数据;
  • 很多进程级资源。

同时每个线程又需要保存自己的:

  • 执行栈;
  • 寄存器上下文;
  • 指令执行位置;
  • 调度状态;
  • Thread Local 状态等。

因此比“线程没有自己的内存”更准确的说法是:

线程共享进程地址空间,但仍然拥有自己执行所必需的私有上下文和栈。

二、Linux 为什么把进程和线程都叫 Task?

学到这里会出现一个很有意思的问题。

从应用开发角度,我们一直在区分:

Process
Thread

但进入 Linux Kernel 后,经常会看到另一个词:

Task

1. Linux 的核心对象是 task_struct

Linux 内核使用 struct task_struct 描述可被管理和调度的 Task。

这也是理解 Linux 线程模型最重要的一步:

在 Linux 内核眼里,线程和进程并不是两套完全不同的调度对象。它们都属于 Task。

可以抽象成:

task_struct
pid
scheduling_state
mm
files
signal
credentials
cpu_context
...
ProcessTask
ThreadTask

这里的继承关系只是为了表达概念,Linux 源码中并不存在 ProcessTaskThreadTask 这两个 C++ 类。

很多教材会把操作系统描述进程的数据结构统称为:

PCB
Process Control Block

这个概念可以帮助理解,但在 Linux 源码里真正会看到的是:

struct task_struct

因此写 Linux 文章时,与其一直说“Linux 的 PCB”,不如直接把 task_struct 这个真实名字记住。

2. Linux 怎么区分“一个进程”和“同进程中的线程”?

关键不在于:

线程是不是一种完全不同的数据结构。

而在于:

这些 Task 共享了哪些资源,以及它们是否属于同一个 Thread Group。

Linux 中同一线程组内的线程拥有不同的 TID,但共享同一个 TGID。

可以先建立:

TGID ≈ 用户通常理解的 Process ID
TID  = 内核中具体 Task / Thread 的 ID

例如:

Process / TGID 1000
Task TID 1000
Thread Group Leader
Task TID 1001
Task TID 1002
Shared mm_struct

这也是为什么 Linux 经常给人一种感觉:

“线程就是共享资源的进程。”

这个方向是对的,但更精确的说法是:

Linux 使用统一的 Task 模型;所谓线程,是一组通过特定共享关系组合在同一个 Thread Group 中的 Task。

3. clone 为什么是理解 Linux 线程的关键?

Linux 的 clone() / clone3() 提供了非常细粒度的控制:

创建新 Task 时,可以决定它与调用方共享什么。

例如:

CLONE_VM      → 共享虚拟地址空间
CLONE_FILES   → 共享文件描述符表
CLONE_SIGHAND → 共享信号处理配置
CLONE_THREAD  → 加入同一个 Thread Group

于是:

共享较少
共享 VM / Signal / Thread Group 等
创建新 Task
共享哪些资源?
更像传统 Process
更像 POSIX Thread

这比“Linux 用一种特殊的轻量级进程实现线程”更接近真实内核模型。

三、Java Thread 是怎么落到 Linux 上的?

1. Platform Thread 为什么叫平台线程?

从 Java 21 开始,java.lang.Thread 明确分成两类:

Platform Thread
Virtual Thread

传统 Java 开发里使用了二十多年的:

new Thread(task).start();

默认创建的是 Platform Thread。

在主流 HotSpot + Linux 环境下,可以把它理解成:

java.lang.Thread
Platform Thread
HotSpot Native Thread
POSIX pthread
Linux Task / OS Thread
Linux Scheduler
CPU Core

也就是典型的 1:1 模型

1 个 Java Platform Thread
          ↕
1 个 OS Thread

Java 并不是自己直接决定这个线程什么时候占用 CPU。

真正的 CPU 时间仍然由操作系统调度器安排。

2. Thread.start() 之后为什么需要操作系统参与?

执行:

Thread t = new Thread(task);
t.start();

并不是简单地在 Java Heap 里多创建一个对象。

Thread 对象当然存在于 JVM 管理的 Java 世界中,但 Platform Thread 真正开始并发执行时,还需要底层创建对应的 Native / OS Thread。

所以逻辑上会经历:

CPUSchedulerLinux KernelHotSpotJava ApplicationCPUSchedulerLinux KernelHotSpotJava ApplicationThread.start()创建底层线程资源OS Thread / Task 可运行加入可运行任务调度执行执行 JIT 后的 Java 机器码

这里再次说明:

Java Thread 是语言 / JVM 层抽象,OS Thread 是操作系统层执行实体,两者不能混成一个概念。

四、为什么传统线程比较“贵”?

“Platform Thread 很重”这句话经常被解释成:

一个线程固定占 1MB,所以机器只能启动一万个线程。

这种说法太绝对。

1. 线程成本不只有 Stack

一个 OS Thread 会带来多种成本:

  • Native Stack 虚拟地址空间;
  • Kernel Task 数据结构;
  • 调度状态;
  • TLS;
  • Guard Page;
  • 线程相关 Runtime 数据;
  • 大量线程产生的调度和 Context Switch 成本。

线程栈大小还受到:

  • JVM 参数;
  • libc / pthread 默认值;
  • OS 限制;
  • 架构;
  • 容器限制;

等因素影响。

所以:

Platform Thread = 固定 1MB

不是 Java 规范。

同样:

OS Thread 最多只能 10000 个

也不是固定规则。

真正上限来自多个资源限制共同作用。

2. 大量线程为什么会带来调度压力?

假设机器只有 8 个可运行 CPU Core,却创建 100000 个 Runnable Platform Thread。

真正同时执行的依然只有少数线程。

剩下的需要:

Runnable Queue
      ↓
Scheduler 选择
      ↓
切换执行上下文
      ↓
运行一段
      ↓
再次切换

如果大量线程都在争 CPU,那么增加线程数量不会凭空增加算力,反而可能带来:

  • Run Queue 更长;
  • Cache Locality 下降;
  • Context Switch 增多;
  • Stack / Kernel Resource 增加;
  • 延迟抖动。

所以线程数量从来不是:

越多越快。

五、从 Fiber 到 Java Virtual Thread

旧的 Java 并发资料经常会讨论:

Fiber
Coroutine
Quasar
Project Loom

如果只看早期资料,很容易以为 Java 仍然没有真正的用户态轻量线程。

这个结论已经过时了。

1. Quasar 更适合当作历史背景

在 Java 原生 Virtual Thread 出现以前,Quasar 等第三方项目尝试在 JVM 上实现 Fiber / Continuation 风格的轻量执行单元。

它们在理解历史演进时很有价值,但今天再写 Java 新项目时,讨论轻量线程首先应该看:

JDK Virtual Thread

而不是把 Quasar 当成主方案。

2. Java 21 正式交付 Virtual Threads

JDK 21 的 JEP 444 正式把 Virtual Threads 交付到 Java Platform。

现在可以直接写:

Thread.startVirtualThread(() -> {
    System.out.println("hello virtual thread");
});

也可以:

Thread.ofVirtual().start(task);

或者:

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    executor.submit(task1);
    executor.submit(task2);
}

注意它仍然是:

java.lang.Thread

并不是 Java 另外发明了一套完全不兼容的 Fiber API。

3. Virtual Thread 不是“线程里面再切小线程”

这是很常见的直觉模型,但不够准确。

Virtual Thread 真正的核心是:

JDK 自己调度大量 Virtual Thread,并在需要真正运行 Java 代码时,把它们 Mount 到少量 Platform Thread 上。

这些 Platform Thread 被称为:

Carrier Thread

关系更接近:

Virtual Thread 1
Virtual Thread 2
Virtual Thread 3
Virtual Thread ...
JDK Virtual Thread Scheduler
Carrier Platform Thread A
Carrier Platform Thread B
OS Thread A
OS Thread B
CPU Core
CPU Core

也就是典型的:

M 个 Virtual Thread
        ↓
N 个 Platform / Carrier Thread
        ↓
N 个 OS Thread

这是 M:N 调度。

六、Virtual Thread 为什么特别适合 I/O?

1. Platform Thread 阻塞时发生什么?

传统线程执行:

socket.read(...);

如果数据暂时没到,Platform Thread 可能进入等待。

在 Thread-Per-Request 模型中,如果有大量请求都阻塞在:

数据库
HTTP
Socket
文件 I/O

就可能需要很多 Platform Thread 来维持并发请求数量。

2. Virtual Thread 阻塞时可以卸载 Carrier

Virtual Thread 的关键能力是:

Virtual Thread BI/OCarrier ThreadVirtual Thread AVirtual Thread BI/OCarrier ThreadVirtual Thread AMount 并执行Blocking I/OUnmount承载其他 Virtual ThreadI/O Ready重新 Mount 后继续执行

于是:

业务上仍然可以写阻塞式代码

但:

等待 I/O 的 Virtual Thread
不必一直占着一个 OS Thread

这就是它提升高并发吞吐量的核心。

3. Virtual Thread 不是更快的 CPU 线程

假设任务只是:

for (;;) {
    做纯计算();
}

Virtual Thread 仍然必须 Mount 到 Carrier Thread,最后依然需要真实 CPU Core 执行。

如果机器只有 8 个 Core:

100000 个 Virtual Thread

不会变出:

100000 个 CPU Core

所以 Virtual Thread 的核心价值是:

Scale,不是让单个计算任务跑得更快。

它尤其适合:

  • 高并发请求;
  • 大量网络等待;
  • JDBC / HTTP / RPC 等阻塞式 I/O;
  • Thread-Per-Task / Thread-Per-Request 风格。

而长时间 CPU-Bound 计算不会因为换成 Virtual Thread 自动加速。

七、实验:5000 个阻塞任务差多少?

下面做一个很简单的实验。

实验目的不是证明:

Virtual Thread 的 CPU 执行速度比 Platform Thread 快。

而是验证:

大量任务都在等待时,Virtual Thread 可以用更少的底层线程维持更高并发度。

本文实验环境:

OpenJDK 21.0.11
Linux x86_64

代码:

import java.util.concurrent.*;

public class VirtualThreadDemo {

    static final int TASKS = 5000;
    static final long SLEEP_MS = 100;

    static long run(ExecutorService executor) throws Exception {
        long start = System.nanoTime();

        try (executor) {
            for (int i = 0; i < TASKS; i++) {
                executor.submit(() -> {
                    try {
                        Thread.sleep(SLEEP_MS);
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                });
            }
        }

        return TimeUnit.NANOSECONDS.toMillis(
                System.nanoTime() - start
        );
    }

    public static void main(String[] args) throws Exception {
        long platform = run(
                Executors.newFixedThreadPool(200)
        );

        long virtual = run(
                Executors.newVirtualThreadPerTaskExecutor()
        );

        System.out.println("platform(200) = " + platform + " ms");
        System.out.println("virtual       = " + virtual + " ms");
    }
}

编译运行:

javac VirtualThreadDemo.java
java VirtualThreadDemo

本文实际运行一次得到:

platform(200) = 2525 ms
virtual        = 131 ms

为什么固定 200 个 Platform Thread 大约需要 2.5 秒?

因为 5000 个任务,每个都等待约 100ms:

5000 / 200 = 25 批
25 × 100ms ≈ 2500ms

而 Virtual Thread 可以让大量“正在等待”的任务暂时卸载 Carrier,因此大量 sleep 可以同时处于等待状态。

但这里一定要注意:

这个实验验证的是阻塞型任务的并发扩展能力,不是 CPU 运算性能。

换成 CPU-Bound 计算后,结果完全可能不同。

八、Virtual Thread 使用时要改掉哪些旧习惯?

1. 不要给 Virtual Thread 建传统线程池

Platform Thread 比较贵,所以过去习惯:

创建固定数量线程
    ↓
大量任务排队复用线程

Virtual Thread 的思路反过来:

一个并发 Task
    ↓
一个 Virtual Thread

官方明确建议:

Virtual Thread 不应该被池化复用。

使用:

Executors.newVirtualThreadPerTaskExecutor()

不是在创建一个“固定大小 Virtual Thread Pool”。

它是每个 Task 创建一个新的 Virtual Thread。

2. 不要把 Virtual Thread 当成限流器

以前线程池的数量常常同时承担:

并发限制

Virtual Thread 很便宜以后,这两个概念应该拆开。

例如数据库最多允许 100 个并发查询,可以使用:

Semaphore
连接池容量
业务限流

控制资源并发,而不是为了限流强行把 Virtual Thread 池化。

3. ThreadLocal 要更谨慎

如果 JVM 里只有 200 个池线程:

200 份 ThreadLocal 大对象

可能还能接受。

如果未来出现:

500000 个 Virtual Thread

每个线程都缓存一个昂贵对象,就完全是另一种内存规模。

所以 ThreadLocal 更适合保存真正的线程上下文,而不是把它当作“每线程对象缓存仓库”。

4. synchronized 的 Pinning 规则已经发生过变化

JDK 21 刚交付 Virtual Thread 时,一个重要限制是:

Virtual Thread
    ↓
进入 synchronized
    ↓
发生 Blocking Operation
    ↓
可能 Pin Carrier Thread

这会削弱扩展性。

但从 JDK 24 开始,JEP 491 改进了 Monitor 实现,使 Virtual Thread 在绝大多数 synchronized 场景中也能够在阻塞时 Unmount,不再因为持有 Java Monitor 就长期 Pin Carrier。

所以今天再看到:

Virtual Thread 绝对不能和 synchronized 一起使用。

已经属于版本过时的结论。

当前仍然需要关注 Native / Foreign Function 等可能导致 Pinning 的场景。

九、Process、Platform Thread、Virtual Thread 怎么串起来?

现在把这几个概念一次性放到同一张图上:

Program / JAR / Executable
Process
Process Address Space / Java Heap
Platform Thread
Platform Thread
Virtual Thread A
Virtual Thread B
Virtual Thread C
JDK Scheduler
Linux Task / OS Thread
Linux Task / OS Thread
Linux Scheduler
CPU Core

其中:

Process

负责提供运行环境和资源边界。

Platform Thread

通常 1:1 对应 OS Thread,由 Linux Scheduler 调度。

Virtual Thread

由 JDK Scheduler 管理,在需要运行时 Mount 到 Platform / Carrier Thread。

最终真正执行机器指令的仍然只有:

CPU Core

十、常见误区

1. 进程和线程

误区 1:程序加载进内存以后才叫程序,运行后才直接“变成”进程。

不够准确。程序是静态代码 / 数据,进程是程序的一次运行实例以及对应的 OS 运行环境,不能只靠“在不在内存”区分。

误区 2:进程有独立内存,线程完全没有自己的内存。

错误。线程共享进程地址空间,但依然有自己的执行栈、寄存器上下文和线程状态。

误区 3:Linux 的线程就是 fork 出来的普通进程。

过度简化。Linux 统一使用 Task 模型,线程通常通过 clone 语义共享地址空间、文件、信号处理并加入同一 Thread Group;POSIX Thread 通常由 pthread / NPTL 封装这些底层机制。

误区 4:Linux PCB 就是一个叫 PCB 的固定结构体。

不是。PCB 是操作系统理论中的通用概念;Linux 真正的核心 Task 描述结构是 struct task_struct

2. Java Thread

误区 5:每个 Java Thread 一定固定占 1MB。

错误。Platform Thread 的 Stack 和资源消耗受 JVM、OS、架构和配置影响,不是 Java 语言固定常量。

误区 6:操作系统最多只能启动一万个线程。

错误。线程数量没有这种跨机器固定上限,会受到虚拟内存、Stack、PID / Task 限制、容器和内核资源等共同影响。

3. Fiber 与 Virtual Thread

误区 7:Java 现在还要依赖 Quasar 才能使用 Fiber。

过时。Java 21 已正式交付 Virtual Threads。

误区 8:Virtual Thread 就是一个 Platform Thread 里面固定切成很多小线程。

不准确。Virtual Thread 由 JDK Scheduler 调度,可以在生命周期中 Mount 到不同 Carrier Thread,不和某一个 Platform Thread 永久绑定。

误区 9:Virtual Thread 比 Platform Thread 计算速度快。

错误。它提高的是大量阻塞任务的可扩展性和吞吐量,不会让 CPU 执行同一段计算指令突然更快。

误区 10:Virtual Thread 越多,CPU 并行度越高。

错误。真正的 CPU 并行度最终仍受物理 / 逻辑处理器数量约束。

十一、总结

现在可以用三句话区分最核心的几个概念:

Process
= 一次程序运行实例 + OS 资源与隔离边界

Platform Thread
= 通常 1:1 对应 OS Thread 的 Java 执行线程

Virtual Thread
= 由 JDK 调度、按需 Mount 到 Carrier Thread 的轻量 Java Thread

Linux 又进一步把进程和线程统一成:

Task

通过是否共享:

  • Address Space;
  • File Table;
  • Signal State;
  • Thread Group;

等资源来形成不同的执行关系。

Java 21 之后,过去很多“Fiber 只是未来方向”的资料已经发生根本变化:

Java Virtual Thread

已经是正式平台能力。

但它解决的问题也必须理解准确:

Virtual Thread 没有增加 CPU 算力,它减少的是“一个业务并发任务长期绑定一个昂贵 OS Thread”的必要性。

所以它真正改变的是高并发服务的编程模型:

过去:
任务很多 → OS Thread 太贵 → 池化 + 异步化

现在:
任务很多 → 每个 Task 一个 Virtual Thread
          → 阻塞时释放 Carrier
          → 保留同步代码的可读性
          → 仍获得很高的并发扩展能力

进程决定资源和隔离边界,OS Thread 决定操作系统如何调度执行,而 Virtual Thread 则让 Java 可以在有限的 OS Thread 之上承载数量巨大的并发任务。

0

评论区