线程底层原理与源码剖析
在上一篇文章中我们了解了线程的基础用法与生命周期。但在工业级并发编程中,仅仅停留在 API 层面是远远不够的。Java 的线程机制并不是孤立存在的,它是操作系统线程机制在 JVM 层面的一层封装。
本文将从硬件和操作系统的视角出发,一路深挖到 JVM 源码,彻底弄清楚“创建一个线程”到底发生了什么,为什么线程上下文切换如此昂贵,以及 JDK 21 引入的虚拟线程(Virtual Threads)究竟是如何在底层打破这一瓶颈的。
操作系统视角的线程
在操作系统层面,线程是 CPU 调度的最小单位。
进程与线程的本质区别
在 Linux 操作系统中,其实并没有严格意义上“线程”的概念。Linux 内核只认 Task(任务),对应的内核数据结构是 task_struct。
- 进程:拥有独立的内存地址空间(页表)、文件描述符表等资源。
- 线程:本质上是一个与其他线程共享同一个地址空间和资源的轻量级进程(Lightweight Process, LWP)。
可以这样比喻:进程是一栋独立的办公楼,拥有独立的水电系统(内存和资源);而线程是办公楼里的一个个工位,工位之间共享大楼的饮水机和厕所,但每个工位有自己的电脑和文件(独立的程序计数器 PC、栈空间和寄存器状态)。
上下文切换为什么昂贵?
当 CPU 从线程 A 切换到线程 B 时,需要进行上下文切换(Context Switch)。这是一个极度消耗资源的过程,主要包含:
- 保存现场:将线程 A 的 CPU 寄存器状态、程序计数器(PC)等保存到内存中(通常是它的内核栈)。
- 恢复现场:从内存中加载线程 B 的上下文到 CPU 寄存器。
- 缓存失效:更致命的是,由于线程切换往往伴随着不同任务的执行,CPU 内部的 L1/L2 缓存可能会因为缓存命中率下降而导致性能断崖式下跌。如果发生进程级别的切换,还会导致 TLB(Translation Lookaside Buffer,页表缓存)被刷新,使得后续的内存访问变成极其缓慢的物理内存寻址。
这就是为什么我们常说“不要创建过多的线程”。过多的线程不仅会导致内存吃紧(每个 Java 线程默认 1MB 栈空间),还会让 CPU 绝大部分的时间都浪费在上下文切换上,而不是执行真正的业务逻辑。
Java 线程映射模型
Java 中的 java.lang.Thread 是如何与操作系统的线程对应的呢?
在现代的 HotSpot JVM 实现中,Java 线程采用的是 1:1 的内核级线程模型。这意味着,每一个启动的 Java 线程,在底层都严格对应着一个操作系统的原生内核线程。Java 层面的线程调度、阻塞、唤醒,完全依赖于操作系统的调度器(如 Linux 的 CFS 调度器)。
为什么选择 1:1 模型?
早期的 JVM(如 Solaris 上的 Green Threads)曾尝试过多对一(M:1)的用户级线程模型。用户态线程的切换极其轻量,不需要陷入内核。但 M:1 模型有一个致命缺陷:一旦某个用户线程发起了阻塞式 I/O 系统调用(如读写文件),整个内核线程就会被阻塞,进而导致映射到该内核线程上的所有用户线程全部停摆。
为了简化实现并充分利用多核 CPU 的并行能力,HotSpot 最终走向了 1:1 模型。这把调度的脏活累活全权交给了操作系统,但也注定了 Java 线程变得“非常重”。
从源码看 Thread.start() 的真面目
当我们调用 new Thread().start() 时,底层到底发生了什么?我们跟随 OpenJDK 源码一探究竟。
1. Java 层面:start0()
Thread.java 的 start() 方法核心逻辑非常短:
public synchronized void start() {
if (threadStatus != 0)
throw new IllegalThreadStateException();
group.add(this);
boolean started = false;
try {
start0(); // <--- 核心是这个 native 方法
started = true;
} finally {
if (!started) group.threadStartFailed(this);
}
}
private native void start0();
一切玄机都在 native 的 start0() 中。
2. JNI 映射层:JVM_StartThread
在 JVM 源码(Thread.c)中,start0 被映射到了 JVM_StartThread 函数:
// hotspot/src/share/vm/prims/jvm.cpp
JVM_ENTRY(void, JVM_StartThread(JNIEnv* env, jobject jthread))
// 1. 获取 Java 线程对象
JavaThread *native_thread = NULL;
// 2. 核心:创建 C++ 层面的 JavaThread 对象
native_thread = new JavaThread(&thread_entry, sz);
// 3. 启动操作系统线程
Thread::start(native_thread);
JVM_END
3. 创建 OS 线程:os::create_thread
继续追踪 new JavaThread,它会调用操作系统的特定实现 os::create_thread(以 Linux 为例,位于 os_linux.cpp):
// hotspot/src/os/linux/vm/os_linux.cpp
bool os::create_thread(Thread* thread, ThreadType thr_type, size_t stack_size) {
// 准备操作系统原生线程属性(如分配栈空间)
pthread_attr_init(&attr);
pthread_attr_setstacksize(&attr, stack_size);
pthread_t tid;
// 核心系统调用:调用 glibc 的 pthread_create 创建内核线程
int ret = pthread_create(&tid, &attr, (void* (*)(void*)) thread_native_entry, thread);
return true;
}
看清楚了吗?在 Linux 下,Java 线程底层就是硬邦邦的 pthread_create。 这个系统调用会向内核申请创建一个全新的内核执行流,分配内核栈,并将其加入操作系统的调度队列中。
4. 线程启动:thread_native_entry
当内核线程创建并被调度后,会执行回调函数 thread_native_entry:
static void *thread_native_entry(Thread *thread) {
// ... 各种初始化操作
// 回调 JavaThread 的 run 方法
thread->run();
return NULL;
}
至此,java.lang.Thread 的 run() 方法终于在全新的操作系统内核线程中开始执行。这就是 Java 线程启动的完整闭环。
虚拟线程(Virtual Threads)的救赎
了解了底层 1:1 模型后,我们就能深刻体会到传统 Java 并发的痛点:线程太贵了。在微服务架构下,面对海量的并发网络请求(每个请求分配一个线程),系统往往在 CPU 还没跑满时,就已经因为线程数耗尽或内存溢出(OOM)而崩溃。
为了解决这个问题,JDK 21 正式引入了虚拟线程(Virtual Threads)。
原理剖析:M:N 模型的归来
虚拟线程抛弃了 1:1 模型,重新拥抱了 M:N 的调度模型。但这次不是 JVM 退回 Solaris 时代,而是在 JVM 层面实现了一个极其精妙的用户态调度器。
- 载体线程(Carrier Thread):底层的平台线程(操作系统线程),作为干活的“物理工人”。数量通常等于 CPU 核心数。
- 虚拟线程(Virtual Thread):完全由 JVM 管理的轻量级对象,作为包装业务逻辑的“虚拟任务”。数量可以是百万级。
虚拟线程的核心原理可以用**“挂起与恢复”(Continuation)**来概括:
- 当虚拟线程执行纯内存计算时,它会被挂载(Mount)到一个载体线程上运行。
- 当虚拟线程发起阻塞 I/O(如等待数据库响应)时,JVM 会拦截这个系统调用,将虚拟线程从载体线程上卸载(Unmount)。
- 此时,载体线程并不会被阻塞,而是立即去执行队列中的下一个虚拟线程。
- 当网络 I/O 就绪后,底层网络事件(如 epoll)会通知 JVM,JVM 再将挂起的虚拟线程重新挂载到某个空闲的载体线程上继续执行。
带来的颠覆性改变
虚拟线程并没有让单个计算变得更快,它的意义在于极大地提升了系统的吞吐量。开发者可以继续使用同步阻塞式的编程模型(写代码最容易理解),却能享受到类似于 Node.js 异步非阻塞事件驱动(Event Loop)一样的性能红利。
在底层的实现中,虚拟线程的栈空间不再由操作系统在物理内存中分配,而是以 Chunk 的形式存放在 Java 堆内存中。当发生挂起时,JVM 只需将其栈帧数据复制到堆内存保存,这个代价极低(纳秒级),相比于陷入内核态的 OS 线程切换(微秒级),简直是降维打击。
掌握了这些底层原理,我们在后续探讨线程池的设计、AQS 源码以及并发工具时,才能真正做到“知其然,更知其所以然”。