到pthread_create:验证Java线程就是OS线程的源码级证据)
1. 起因教科书一句话我非要在源码里找到证据1.1 面试官的问题和我支支吾吾的答案先说个背景。有段时间我在准备面试也面别人。围绕JDK、Java线程和操作系统的关系几乎每个候选人都会背一句“Java 线程就是 OS 线程。”我自己也是这么背的。但有一次面试官追问了一句“你凭什么这么说怎么证明”我当时愣了一下。我的第一反应是“因为 JVM 在底层调了系统线程库Linux 上就是 pthread”。但面试官继续问“那你看到过 pthread_create 是哪行代码调的吗调用之前 HotSpot 做了什么你用什么系统命令能把这个线程对应上”我答不出来。面试结束之后我做了两件事第一把自己的笔记里那些“Java 线程 OS 线程”的结论全部从“常识”升级为“待验证结论”第二直接下载 OpenJDK 源码把 Thread.start() 到操作系统线程创建之间的每个环节翻出来一条一条地跟。这篇文章就是我这次验证过程的完整记录包括源码路径、关键函数、系统工具验证结果以及那些容易踩的坑。1.2 为什么“听别人说”没用需要源码和工具双重验证很多技术结论都能在网上查到但查到的只是“别人的经验”。这事儿的麻烦在于JDK 的版本在变HotSpot 的实现细节也在变。JDK 8、JDK 11、JDK 17 的底层线程启动路径并不完全一样。而你如果在网上搜“Java 线程与 OS 线程的关系”搜到的大多是简化过的总结甚至有些文章把 Java 线程 ID 和 OS 线程 ID 混为一谈。有一次我还真见过一个说法“Java 线程的 getId() 返回的就是系统线程 ID”这显然不对。Thread.getId() 返回的是 JVM 内部递增分配的编号和操作系统线程号根本不是一回事。正因为流行资料里有这些错误我才更坚持要自己扒源码。源码不会骗人系统命令也不会骗人把两者对照起来看才能得到确定的结论。要证实“Java 线程就是 OS 线程”逻辑上需要完成这么几件事从 Java 层找到线程启动的入口也就是 Thread.start() 和它背后的 native 方法进入 HotSpot 虚拟机内部找出这个 native 方法对应的 C 实现跟随调用链一路找到真正创建原生线程的函数最后在 Linux 上用 ps、top、jstack 这类工具把 JVM 里的 Java 线程和操作系统里的线程实际对上号。这一套流程走完结论才算是“证实”了而不是“听来的”。2. 第一站Thread.java 里那个 native 方法藏着整条线索2.1 源码从哪来JDK 自带的 src.zip扒 JDK 源码第一步不是上网搜而是先看你本机装好的 JDK。只要你的 JDK 是完整的发行版在安装目录下基本都会有一个src.zip。比如$JAVA_HOME/lib/src.zip或者用 IDE 的时候旁边自动关联的源码。打开它你会看到一个非常规整的源码树src/java.base/share/classes/java/lang/Thread.java src/hotspot/share/prims/jvm.cpp src/hotspot/os/linux/os_linux.cpp src/java.base/share/native/libjava/Thread.c这四个文件就是这次验证的核心。如果你想从 GitHub 上看在线版本直接搜 openjdk/jdk 仓库就行路径也是一样的。要提醒一句你在网上看到的源码很可能和本机 JDK 版本不完全一致尤其是 HotSpot 这种变动频繁的部分所以最好的方式是直接以本机src.zip为准。2.2 start0() 和 registerNatives本地方法是怎么挂上去的打开Thread.java你会看到start()方法本身并没有创建线程的能力它只是做了线程状态检查、把线程加入线程组然后调用了一个 native 方法public synchronized void start() { // ... group.add(this); start0(); // ... } private native void start0();这个start0()是private native方法关键就在这里。所有牛鬼蛇神都藏在 native 方法后面。那start0()到底怎么找到对应的 C 函数你会在 Thread.java 的静态初始化块里找到它的注册逻辑private static native void registerNatives(); static { registerNatives(); }registerNatives()也是一段 native 代码它做的事情是把 Java 层的 native 方法名和方法签名映射到 JVM 内部的 C/C 函数指针。具体的映射表写在Thread.c里。你打开src/java.base/share/native/libjava/Thread.c会发现里面有Thread.start0的 JNI 绑定指向JVM_StartThread。这里有个很实用的阅读技巧用javap -p -s查看任意类的 native 方法签名再用签名在src.zip里搜索大多数时候都能快速定位到对应的 JNI 实现入口。2.3 getId() 和 OS 线程 ID 的那个著名错位在继续往下钻之前我想先澄清一个常见的混淆点Thread.currentThread().getId()返回的不是操作系统线程 ID。JDK 里线程 ID 的分配逻辑在 Thread.java 中private long tid; private static long threadSeqNumber; private static synchronized long nextThreadID() { return threadSeqNumber; }也就是说Java 层面的线程 ID 就是一个简单的自增序号从 1 开始跟操作系统线程 ID 毫无关系。这个问题后面做系统工具验证时会特别重要因为很多人被这个“ID”带偏了以为 Java 线程 ID 能和系统线程 ID 直接对上。实际对不上的。要真正看 OS 线程 ID得靠 jstack 里输出的nid或者 JDK 19 之后引入的Thread.getNativeId()这个受限 API。3. 顺藤摸瓜JVM_StartThread 到 pthread_create 的完整链路3.1 Thread.c 只是薄薄一层“壳”很多资料一讲到 native 方法就直接跳到 HotSpot 内部了但其实中间还有一层。Thread.c里的实现非常薄核心就几行JNIEXPORT void JNICALL Java_java_lang_Thread_start0(JNIEnv *env, jobject this) { JVM_StartThread(env, this); }为什么这么薄因为 HotSpot 的 JNI 实现大量使用了JVM_*前缀函数这算是 JVM 内部的一个约定。凡是 Java 标准库里的关键能力比如启动线程、加载类、创建对象都会从 JNI 层转发到jvm.cpp里的 JVM_ 函数。所以你看到JVM_StartThread这个名字基本可以断定线程启动的重头戏在src/hotspot/share/prims/jvm.cpp里。3.2 JVM_StartThread创建 JavaThread再委托给 Thread::start打开jvm.cpp搜索JVM_StartThread你会看到这个函数的骨架大体是这样JVM_ENTRY(void, JVM_StartThread(JNIEnv* env, jobject jthread)) JavaThread* native_thread NULL; bool throw_illegal_thread_state false; if (!Threads::can_create_threads()) { THROW_MSG(vmSymbols::java_lang_OutOfMemoryError(), unable to create native thread: possibly out of memory or process/resource limits reached); } if (java_lang_Thread::thread(jthread) ! NULL) { throw_illegal_thread_state true; } else { size_t size java_lang_Thread::thread_size(jthread); native_thread JavaThread::new_thread(jt, size, exception); // ... Thread::start(native_thread); } JVM_END理解这段代码的关键是要分清两个对象JavaThreadHotSpot 对 Java 线程的抽象。它知道这个线程对应的java.lang.Thread对象是谁持有 Java 层的栈信息、线程状态、Java 帧指针等。OSTHreadHotSpot 对操作系统线程的抽象。它在 Linux 上包含一个pthread_t在 Windows 上包含一个HANDLE是真正和 OS 线程挂钩的实体。JavaThread::new_thread内部会同时分配一个OSTHread这两个对象在 JVM 内部通过指针互相引用。这一步的意义在于JVM 虽然没有立刻调用 pthread_create但从此刻起每个 Java 线程在 JVM 内部都已经有了自己的“操作系统线程身份证”只差内核真正把它创建出来。接着Thread::start(native_thread)会把接力棒交到os::create_thread和os::start_thread。这两个函数是平台相关的不同操作系统有不同实现。Linux 上的实现在os_linux.cpp里。3.3 os_linux.cpp终于看到那行 pthread_create这是整趟验证中最激动人心的一步。打开src/hotspot/os/linux/os_linux.cpp搜索pthread_create。你会看到 HotSpot 最终是这样创建线程的int ret pthread_create(tid, attr, thread_native_entry, thread);这一行代码就是“Java 线程 OS 线程”最实锤的一环。不管上面包装了多少层 JavaThread、OSTHread最终真正让操作系统内核产生一个可调度实体的事件就是这一个 pthread_create 调用。所谓操作系统线程在 Linux 上就是 pthread而 pthread_create 正是创建它的唯一入口。注意它前面的属性设置也不是随便写的。HotSpot 会根据-Xss参数和平台默认值来设置线程栈大小pthread_attr_init(attr); // 如果上层传入了明确的栈大小就设置 if (stack_size 0) { pthread_attr_setstacksize(attr, stack_size); } // 根据线程类型决定是 joinable 还是 detached if (thr_type ! JVM_THREAD_NATIVE_THREAD) { pthread_attr_setdetachstate(attr, PTHREAD_CREATE_DETACHED); }这里就能解释为什么 JVM 可以通过-Xss1m这样的参数控制 Java 线程栈大小了——本质上是把这个值传给了 pthread 的栈属性。CSDN 上经常有人疑惑“为什么-Xss能影响栈溢出深度”看到pthread_attr_setstacksize这一行答案就清楚了。3.4 线程入口从 C 世界回到 Java 世界的 thread_wrapperpthread_create 的第三个参数是线程入口函数。HotSpot 在 Linux 上用的是thread_native_entry它的核心逻辑很简单static void* thread_native_entry(void* thread_addr) { Thread* thread (Thread*) thread_addr; // ... 初始化当前线程的 TLAB、栈基址、信号处理等 ... thread-run(); // ... 退出清理 ... return 0; }对 Java 线程来说thread的真实类型是JavaThread所以thread-run()通过 C 的多态调用会进入JavaThread::run()。再往下void JavaThread::run() { // ... thread_main_inner(); } void JavaThread::thread_main_inner() { // ... JavaCalls::call_virtual(result, threadObj, SystemDictionary::Thread_klass(), vmSymbols::run_method_name(), vmSymbols::void_method_signature(), THREAD); }到这一步虚拟机的控制流重新跳回 Java 层开始执行java.lang.Thread.run()。也就是说用户写的那个run()方法里的代码最终是在 pthread_create 创建的那个操作系统线程上跑的。从start0()开始经历了 JNI、JVM_StartThread、JavaThread 创建、OSTHread 分配、pthread_create、thread_native_entry、JavaThread::run最后回到了 Java 代码——一整条闭环非常完整。我把这整条链画成文字版本方便自己回顾Thread.start() → start0() [native, Thread.c] → JVM_StartThread [jvm.cpp] → JavaThread::new_thread: 创建 JavaThread OSTHread → Thread::start → os::create_thread [os_linux.cpp] → pthread_create(tid, attr, thread_native_entry, thread) → thread_native_entry 中调用 thread-run() → JavaThread::run [thread.cpp] → thread_main_inner → JavaCalls::call_virtual(Thread.run) → 用户 run() 方法源码级的验证到这里其实已经完成了。但光看代码还不够我习惯再用系统工具从外部观察一次“眼见为实”嘛。4. 用系统工具反证nid 就是操作系统的 LWP4.1 造一个带五个线程的测试程序源码链路看完之后我在 Linux 上做了一次实际验证。思路很简单启动一个 Java 进程让它创建 5 个工作线程长睡然后分别用 jstack 和 ps/top 抓线程列表看两边能不能一一对上。测试程序长这样public class ThreadProbe { public static void main(String[] args) throws Exception { for (int i 1; i 5; i) { Thread worker new Thread(() - { System.out.println(Thread.currentThread().getName() 就绪Java线程ID Thread.currentThread().getId()); try { Thread.sleep(600_000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }, worker- i); worker.start(); } Thread.sleep(300_000); } }编译运行后进程会一直挂着。这时候用jps拿到进程 ID$ jps 10234 ThreadProbe4.2 三次抓取三方对账接着抓 jstack 的线程信息只看 worker 线程那几段$ jstack 10234 | grep -E worker-|nid worker-5 #6 prio5 os_prio0 tid0x00007f2bace9e000 nid0x4e40 sleeping[0x00007f2bae0e2000] worker-4 #5 prio5 os_prio0 tid0x00007f2bacea0000 nid0x4e3f sleeping[0x00007f2bae964000] worker-3 #4 prio5 os_prio0 tid0x00007f2bacea2000 nid0x4e3e sleeping[0x00007f2baea26000] worker-2 #3 prio5 os_prio0 tid0x00007f2bc2d28000 nid0x4e3d sleeping[0x00007f2bc4cd5000] worker-1 #2 prio5 os_prio0 tid0x00007f2bc2d2a000 nid0x4e3c sleeping[0x00007f2bc3c59000]注意这里面有两类 ID。一个是tid0x...它其实是 JVM 内部给 JavaThread 对象的数字编号不是系统线程号。另一个是nid0x...这个才是操作系统线程 ID。把十六进制转成十进制看看$ printf %d\n 0x4e3c 20028 $ printf %d\n 0x4e3d 20029 ...然后用 ps 看进程里的线程Linux 上可以这样$ ps -eLf | grep ThreadProbe | grep -v grep UID PID PPID LWP C NLWP NLWP_START ... CMD ... 10234 ... 20024 0 6 ... java ThreadProbe ... 10234 ... 20028 0 6 ... java ThreadProbe ... 10234 ... 20029 0 6 ... java ThreadProbe ... 10234 ... 20030 0 6 ... java ThreadProbe ... 10234 ... 20031 0 6 ... java ThreadProbeps -eLf里的 LWP 列就是操作系统线程的 ID也就是 Lightweight Process。把 jstack 里的 nid 转换成十进制之后和这里的 LWP 完全一致jstack nid十六进制十进制ps LWP0x4e3c20028200280x4e3d20029200290x4e3e20030200300x4e3f20031200310x4e402003220032还可以再交叉验证一下top -H -p 10234同样能看到这些 TID。也就是说在 Linux 上jstack 里显示的 nid 就是操作系统的线程 ID。Java 线程在操作系统里不是一个抽象概念而是真实存在的、有独立调度实体和 ID 的原生线程。4.3 一个最容易踩的坑Java 线程 ID、tid、nid三个概念三个东西我在这轮验证里最大的感触是很多人把 JDK 在 jstack 里打印出来的tid和nid弄混了。你看 jstack 的输出里一个线程同时有tid0x...和nid0x...乍一看长得差不多实际上完全不是一回事。tid是 JVM 内部为 JavaThread 对象分配的编号在这个地址空间里唯一但 OS 不知道它nid是 native thread ID在 Java 里叫 jlong在 Linux 上通过 gettid() 取到它就是 LWP 的 IDJava 层的Thread.getId()又是另一个独立的自增数字用于java.lang.Thread对象。这三者都是“ID”都在表示“这个线程的身份”但目标层次完全不同。如果你的调试脚本想用 Java 线程 ID 去关联系统资源那肯定对不上。正确做法是用 jstack 的 nid或者在 JDK 19 之后调用受限 APIThread.getNativeId()。Thread.getNativeId()这个 API 我特别提一下它不是普通方法调用时可能抛UnsupportedOperationException或IllegalCallerException而且在未来版本里可能还要换入口。但在调试场景下它确实能直接从 Java 层拿到 OS 线程 ID省去了对账 jstack 和 ps 的麻烦。5. 虚拟线程当“一对一”神话被打破5.1 平台线程与虚拟线程的本质差异JDK 21 正式发布了虚拟线程这一步直接把“Java 线程 OS 线程”这句话的适用范围打破了。准确说法应该是Java 平台线程和 OS 线程是一对一的但从 JDK 21 开始Java 又多了一种虚拟线程虚拟线程和 OS 线程不是一对一的。平台线程就是我们前面讲的那种线程每个线程都对应一个由pthread_create或CreateThread创建出来的原生线程。虚拟线程则完全不同它的成本很低可以创建几十万上百万个而且绝大多数虚拟线程在任意时刻都没有属于自己的 OS 线程。5.2 虚拟线程的调度机构虚拟线程的调度器在 JVM 内部默认的是一个ForkJoinPool虚拟线程会在这个池里的某个“载体线程”上运行。载体线程仍然是平台线程仍然对应 OS 线程。你可以这样理解平台线程是“一个线程一个座位”虚拟线程是“很多线程共用一个座位池”谁被调度到谁就用哪个座位。当一个虚拟线程因为 I/O、锁或LockSupport.park而阻塞时JVM 会把它从载体线程上卸载下来让载体线程去执行另一个可运行的虚拟线程。这就是为什么虚拟线程能极大提升高并发、高阻塞场景下的吞吐能力——它不让 OS 线程彻夜等待而是把 OS 线程当成稀缺资源反复复用。注意一个有意思的地方你在jstack里观察长期阻塞的虚拟线程时往往看不到它对应一个独立的系统线程 ID因为它可能根本不占着某个 OS 线程。即便偶 while 它正在载体线程上运行jstack 也只会显示载体线程的 nid。所以如果你沿用“Java 线程必有 OS 线程 ID”的眼光去调试虚拟线程会发现在某些状态下它“没有身份证”。5.3 这会影响之前的结论吗不影响。平台线程的链路没有变Thread.start()依然要走到pthread_create。虚拟线程只是给 JVM 增加了一条新的执行路径并没有取消平台线程的原有路径。我们在 JDK 8 到 JDK 21 之间做的任何平台线程排错经验依然全部有效。但从认知框架上讲以前说“Java 线程就是操作系统线程”太绝对。更严谨的表述是传统平台线程在创建时会同步创建一个操作系统线程两者生命周期绑定而虚拟线程是用户态调度的轻量级线程只在运行时借用平台线程。面试和排错时能把这两个层次分清楚会显得你对 JVM 的理解深入很多。6. 复盘读 JVM 源码的三板斧6.1 第一板斧从 native 方法反向定位实现这次验证的第一步实际上是个标准操作遇到一个 native 方法不要猜直接用javap -p -s拿到方法描述符再去src.zip里搜方法名。像start0这种命名简单的方法一招就能定位到遇到比较复杂的方法比如registerNatives搜不到你要的实现时不要慌它可能在 JVM_ 函数里搜索JVM_前缀加名字应该就能找到。Thread.c里那些 JNI 函数都有固定的命名规则Java 包名里的点换成下划线类名和方法名也换下划线。掌握了这个规则你几乎能把 JDK 里任何一个 native 方法手动映射到 C 文件。这套规则虽然偶尔会被特殊名字绕晕但 90% 的情况适用。6.2 第二板斧分清共享实现和平台实现HotSpot 的源码有清晰的职责分层src/hotspot/share跨平台共享逻辑例如Thread.java的 JVM_ 入口、JavaThread、对象模型src/hotspot/os/linuxLinux 专属实现例如os_linux.cpp、pthread交互src/hotspot/os/windows、src/hotspot/os/bsdWindows 和 macOS 的实现。排错时先确定某个逻辑到底是跨平台还是平台相关的。比如线程入口thread_native_entry是平台相关的所以你在共享代码里搜不到完整流程但你可以先看JavaThread::run()再跳到os_*目录继续追。这个习惯能让你少走很多弯路。6.3 第三板斧系统工具交叉验证源码只是理论真正让结论“落地”的一定是外部观察。我常用的组合是jstack查看 Java 层线程名、状态、nidps -eLf查看进程对应的 LWPtop -H -p PID查看进程内线程的 CPU 使用情况/proc/pid/task/tid/查看某个线程的详细信息比如线程栈地址、状态。如果你在排查 CPU 异常这种方法尤其好用top -H里看到某个 TID 飙高再去jstack里搜索十六进制 nid很快就能锁定是哪段 Java 代码在搞鬼。这个手法几乎适用于所有 JVM 线程问题定位。还有一点经验要分享不要只用一套 JDK 版本验证就下结论。JDK 8、JDK 11、JDK 17、JDK 21 的 HotSpot 内部都出现过不小的结构调整包括线程管理逻辑。所以如果你要给团队写文档最好在文章里注明验证所用的 JDK 版本。我在这次验证里用的是 OpenJDK 17链路写出来最清晰JDK 21 里内容大体类似但虚拟线程相关的新代码已经到处都是了。最后再补充一个我自己在源码阅读中反复使用的小技巧在 IDE 里直接搜索字符串比用文件树找你预想的函数要快得多。比如你想知道线程栈大小是怎么生效的直接搜pthread_attr_setstacksize立刻就把os_linux.cpp和上层调用关系串起来了。JDK 这个体量靠读文件目录是读不完的最好的方式永远是“以问题为锚点从一点射出去”。这次验证结束之后我发现自己对 Java 线程、线程池乃至 JVM 调优的理解都踏实了不少。以后再有人问我“Java 线程到底是不是 OS 线程”我不会再背诵那句话而是会给他看这条从Thread.start()到pthread_create的完整链路再让他打开top -H -p亲眼看看那几个 LWP 怎么一一对上 jstack 里的 nid。那才是真的证据。