我将会通过 Java 源码,看在 Java 的底层是怎么实现多线程的,以及 Java 中实现多线程与操作系统中的多线程的异同点
Java 中的多线程
JVM 与 OS
在 OS 的学习中我们能够知道,绝大部分重要的功能不会让用户进程使用
JVM 是一个运行在 OS 上的进程,当 JVM 中需要进行多线程操作时,自己又没有这个权限,所以只能通过请求 OS 来创建线程
根本原因是因为 JVM 运行的环境处于用户态,而创建线程需要内核态进行创建
Java 底层创建线程
在 Java 中创建多线程只需要 new 一个线程,然后让该线程启动起来。
public class Main {
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
System.out.println("这里是线程 --- 1");
});
thread1.start();
}
}
这里是 Java 中线程对应的源码,如果你学习过操作系统那么你会对此感到熟悉,这不就是 OS 中线程(TCB)对应的“属性”吗?
操作系统创建线程需要设置的部分字段在 Java 中也能看见,
我们能看到 Java 底层的 start 是使用了系统调用
我们知道 JVM 的底层是调用了 C 和 C++ 的代码,所以我们需要再深入一点
我进入了 Open JDK
我们需要找到 native 包下的 Thread 文件,后缀为 .c 或者 .cpp
第一部分中我们能发现这里有一张方法表, Java 调用的方法在这里对应着 C 或 C++ 的方法,在 Java 底层就是通过 C 或 C++ 去请求 OS 内核级对线程进行操控
这段代码的作用是“建立映射关系”。它告诉 JVM:“如果在 Java 里调用了 start0,请你去找 C 代码里的 JVM_StartThread 函数执行。” 这就是 JNI (Java Native Interface) 的桥梁作用。
static JNINativeMethod methods[] = {
{"start0", "()V", (void *)&JVM_StartThread},
{"setPriority0", "(I)V", (void *)&JVM_SetThreadPriority},
{"yield0", "()V", (void *)&JVM_Yield},
{"sleepNanos0", "(J)V", (void *)&JVM_SleepNanos},
{"currentCarrierThread", "()" THD, (void *)&JVM_CurrentCarrierThread},
{"currentThread", "()" THD, (void *)&JVM_CurrentThread},
{"setCurrentThread", "(" THD ")V", (void *)&JVM_SetCurrentThread},
{"interrupt0", "()V", (void *)&JVM_Interrupt},
{"holdsLock", "(" OBJ ")Z", (void *)&JVM_HoldsLock},
{"getThreads", "()[" THD, (void *)&JVM_GetAllThreads},
{"dumpThreads", "([" THD ")[[" STE, (void *)&JVM_DumpThreads},
{"getStackTrace0", "()" OBJ, (void *)&JVM_GetStackTrace},
{"setNativeName", "(" STR ")V", (void *)&JVM_SetNativeThreadName},
{"scopedValueCache", "()[" OBJ, (void *)&JVM_ScopedValueCache},
{"setScopedValueCache", "([" OBJ ")V",(void *)&JVM_SetScopedValueCache},
{"getNextThreadIdOffset", "()J", (void *)&JVM_GetNextThreadIdOffset},
{"findScopedValueBindings", "()" OBJ, (void *)&JVM_FindScopedValueBindings},
{"ensureMaterializedForStackWalk",
"(" OBJ ")V", (void*)&JVM_EnsureMaterializedForStackWalk_func},
};
第二部分中我们能发现,创建线程的代码的过程中,JVM 请求创建线程会被这里转发给 OS
JNIEXPORT void JNICALL
Java_java_lang_Thread_registerNatives(JNIEnv *env, jclass cls)
{
(*env)->RegisterNatives(env, cls, methods, ARRAY_LENGTH(methods));
}
JNIEXPORT void JNICALL
Java_java_lang_Thread_clearInterruptEvent(JNIEnv *env, jclass cls)
{
#if defined(_WIN32)
// Need to reset the interrupt event used by Process.waitFor
ResetEvent((HANDLE) JVM_GetThreadInterruptEvent());
#endif
}
以下是更进一步的创建线程的代码
是否创建了一个新的线程?
如下图,当程序运行时 OS 会为 Java 创建对应的线程,这个变化可以在控制台中观察到
TCB
从源码中设置线程对应的代码中我们能知道,在 Java 中会为新创建的线程设置类似于 OS 中那样的属性,例如 tid,这是每个 TCB 的身份证号。
但是 Java 中设置的 tid 不会被 OS 采纳。 操作系统(OS)完全不知道、也不关心 Java 层面的 tid 是多少。
OS 有自己的分配机制。 OS 会按照内核的规则生成一个 Native Thread ID(比如 Linux 下的 LWP ID),这与 Java 的 tid 是两码事。
Java 设置 tid 是为了 JVM 内部的管理、跨平台抽象以及生命周期维护。
就像是在 JVM 中存在一张映射表,让 JVM 中的每一个线程都能对应 OS 中的每一个线程。
为什么 JVM 内部还要设置一个 TCB?
1. 两套 ID 体系的区别
Java 层面的 TID (Thread.getId())
- 来源:由 JDK 的 java.lang.Thread 类产生。
- 生成方式:通常是一个静态的自增 long 类型变量(threadSeqNumber)。每创建一个 new Thread(),这个数字加 1。
- 作用域:仅在当前 JVM 进程内部 唯一。
- 本质:它只是 Java Thread 对象的一个成员变量,用于在 Java 代码中区分不同的线程对象。
OS 层面的 TID (Native ID / LWP ID)
- 来源:由操作系统内核(Kernel)产生。
- 生成方式:取决于 OS 的调度器。在 Linux 上,通常对应轻量级进程 ID(LWP ID);在 Windows 上对应系统线程 ID。
- 作用域:在整个 操作系统 层面(或者该进程的线程表)唯一。
- 本质:它是操作系统调度 CPU 执行单元的实际标识。
2. Java 线程与 OS 线程的关系
在现代的主流 JVM(如 HotSpot)中,采用的是 1:1 线程模型。
- 当你调用 new Thread() 时,Java 层面分配了 tid(比如 tid = 5)。
- 当你调用 thread.start() 时,JVM 会通过 JNI 请求操作系统创建一个内核线程(Native Thread)。
- 操作系统创建成功后,会返回一个操作系统层面的 ID(比如 nid = 10234)。
- JVM 会在内部维护一张映射表(或在对象头中持有句柄) ,将 Java 的 Thread 对象与 OS 的 nid 关联起来。
你可以通过命令验证这一点:
使用 jstack 查看线程堆栈,你会看到类似这样的信息:
code Text
"main" #1 prio=5 os_prio=0 tid=0x00007f... nid=0x1b03 runnable ...
- tid: Java 层面的 ID(或者是内存地址的十六进制表示,视 JVM 版本而定,但逻辑上对应 Java 线程)。
- nid (Native ID) : 这才是操作系统层面的 ID(十六进制),对应 Linux 的 PID/LWP。
3. 既然 OS 不认,Java 为什么还要自己搞一套 tid?
这是关键所在,主要有以下几个原因:
A. 跨平台抽象 (Write Once, Run Anywhere)
Java 的核心承诺是跨平台。
- Linux 的线程 ID 是 int (pid_t)。
- Windows 的线程句柄是 HANDLE (指针或长整型)。
- 其他实时操作系统可能有不同的 ID 格式。
如果 Java 直接暴露 OS 的 ID,那么 Java 代码在不同系统上运行时,ID 的类型和范围就会不一致,破坏了跨平台性。Java 定义自己的 long 类型 tid,保证了无论底层是 Linux、Windows 还是 macOS,开发者看到的都是一个标准的 Java ID。
B. 生命周期不一致 (Lifecycle Mismatch)
- Java 线程对象:当你 new Thread() 时,对象就创建了,此时 必须 有一个 ID 来标识这个对象(比如放入 Map 中作为 Key)。
- OS 线程:只有当你调用 start() 之后,OS 线程才会被真正创建。
如果在 start() 之前没有 ID,那么在线程启动前的准备阶段(设置上下文、配置参数)就无法唯一标识这个线程对象。此外,当线程运行结束(Terminated),OS 线程会被回收,但 Java 的 Thread 对象可能还在堆内存中(只要有引用指向它),此时它仍然需要一个 ID 来保持身份。
C. 虚拟线程 (Virtual Threads / Project Loom)
这一点在 JDK 21+ 尤为重要。
Java 引入了 虚拟线程。虚拟线程是非常轻量级的,可能有成千上万个虚拟线程复用同一个 OS 线程(Platform Thread)。
- 在这种情况下,OS 线程 ID 只有几个(比如 8 个)。
- 但 Java 虚拟线程 ID 有几百万个。
此时,Java 的 tid 是区分这几百万个逻辑任务的唯一方式,完全脱离了 OS 的 1:1 映射。
D. 错误追踪与日志
JVM 的异常堆栈(Stack Trace)和日志需要清晰的标识。使用连续的、可读性强的 Java tid(从 1 开始递增)比使用 OS 随机分配的、大数字的 nid 对开发者更友好。
总结
- OS 甚至不知道 Java tid 的存在,它只认自己的 Native ID。
- Java 设置 tid 是为了“自成一统” :为了在 Java 虚拟机内部,给线程对象一个 跨平台的、生命周期独立的、逻辑上的 身份证。
线程的生命周期
在操作系统中我们能知道,线程的生命周期:
用 Java 代码来对线程状态与转换进行实现
public class Main {
public static void main(String[] args) throws InterruptedException {
Thread t2 = new Thread(() -> {
try {
Thread.sleep(5000);
} catch (InterruptedException ignored) {
}
});
System.out.println("1. 刚 new 出来: " + t2.getState()); // NEW
t2.start();
System.out.println("2. start 之后: " + t2.getState()); // RUNNABLE
Thread.sleep(1000); // 主线程睡一下,让子线程有机会跑
System.out.println("3. 子线程 sleep 时: " + t2.getState()); // TIMED_WAITING
}
}
其中包含了三个最主要的状态,分别是 就绪/运行/阻塞,其中的方法 getState() 的作用就是获取当前线程的状态
Java 线程池
在 Java 中存在着线程池,你可能会想“为什么要多此一举搞一个线程池出来?”这件事情就要说回OS 线程对应的规则了
OS 中,当线程处于运行态时会使用 CPU,线程产生的数据占用 CPU 中的缓存,但是由于并发的存在,CPU 会在规定时间里面换另一个线程执行,那么这一个执行到一半的线程产生的数据应该怎么办?
OS 说:你自己先带回去,等到下一次你要用 CPU 的时候再带过来,所以线程还会携带计算到中间的数值。
所谓的“数据带回去”,在计算机原理中就是保存上下文 (Context Save)。操作系统会把当前线程的程序计数器 (Program Counter) 和 寄存器 (Registers) 信息保存到内存里(TCB/PCB),等下次轮到它执行时再恢复。这个过程非常消耗 CPU 时间,这就是为什么线程切换慢的原因。
我们来做一个小实验,如果不使用线程池会产生什么结果
public class Main {
public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0; i < 10000; i++) {
new Thread(() -> {
}).start();
}
System.out.println("耗时: " + (System.currentTimeMillis() - start));
}
}
public class Main {
public static void main(String[] args) {
// 引入线程池
ExecutorService pool = Executors.newFixedThreadPool(10);
long start = System.currentTimeMillis();
for (int i = 0; i < 10000; i++) {
pool.execute(() -> {}); // 任务扔进队列,10个工人轮流干
}
System.out.println("耗时: " + (System.currentTimeMillis() - start));
pool.shutdown();
}
}
我们能发现,如果不采取线程池,所需要消耗的时间成百倍上升,那为什么线程池能够节省 CPU 的性能呢?
答案是创建和复用的基本策略不同,
方法一 每一个线程都采取直接创建,而线程创建所消耗的性能十分庞大,需要先保存上下文 CPU 变态调用对应的创建语句,最后还需要切换回当前线程。
方法二 固定创建了 10 个线程,但是一直复用了10个线程,消耗自然而然就小了
除了 CPU 开销,内存开销也是大头。一个 Java 线程运行如果需要 1MB 的栈内存空间(Stack Size)。如果你同时运行 10000 个线程,理论上需要 10GB 内存!这不 OOM 谁 OOM?而线程池通过复用 10 个线程,只占用了 10MB 内存。