Java线程映射到操作系统线程原理浅析

前言

之前看 JVM 内存结构时,看到了《深入理解 JVM》这本书说“每个线程都有一个程序计数器,记录了当前执行字节码的位置”。但是想起来 JVM 的线程是委托 OS 实现的,或者说,Java 线程映射到了 OS 线程,那这个 PC 记录的字节码指令位置到底是什么?

OS 的线程那可是正儿八经的 C 线程,C 线程的 PC 保存二进制指令的位置(忽略乱序执行导致的不完整指令),他们是如何一一对应的?难道是每个字节码对应一个本地机器码吗?但是 Java 还有解释执行器这个东西,他是一条一条翻译的。👴属实想了好久,后来在谷歌上翻了不少时间,勉强找到一些解释。

在开始讨论这个问题之前,我们不妨先来实现一个属于自己的线程来验证。

要求

  • 1⃣️一个可以 debug 的 JDK,一个谷歌。
  • 2⃣️可以阅读 C++代码。

过程

首先,我们先试着定义自己的 Java 线程。因为我昵称是 CodeWithBuff 嘛,所以前缀就是 CWB:

/** * @author CodeWithBuff(给代码来点Buff) * @device iMacPro * @time 2021/6/29 1:27 下午 */public class CWBThread {
    private String msg;
    public CWBThread(String msg) {        this.msg = msg;    }
    public void run() {        System.out.println(msg);    }
    public void start() {        start0();    }
    private native void start0();}

复制代码

我们模仿 JVM,整个了 start0()方法,它是 native 的,所以我们需要在 C++里实现。使用它很简单:

new CWBThread("aaa").start();

复制代码

即可。

既然我们要模仿 JVM,那就把线程的创建,销毁等操作,也学 JVM 一样,丢给 OS 去做!在这里我不打算学 JVM 在 JDK 里面添加动态链接库,而是通过 JNI 的方式来实现。

在终端输入:

javac /.../CWBThread.java -h /.../[目录]

复制代码

来生成我们需要实现的本地方法的头文件,不出意外你会得到这样的.h 文件:

/* DO NOT EDIT THIS FILE - it is machine generated */#include <jni.h>/* Header for class com_codewithbuff_javathread_CWBThread */
#ifndef _Included_com_codewithbuff_javathread_CWBThread#define _Included_com_codewithbuff_javathread_CWBThread#ifdef __cplusplusextern "C" {#endif/* * Class:     com_codewithbuff_javathread_CWBThread * Method:    start0 * Signature: ()V */JNIEXPORT void JNICALL Java_com_codewithbuff_javathread_CWBThread_start0  (JNIEnv *, jobject);
#ifdef __cplusplus}#endif#endif

复制代码

然后就是实现它的方法了,我是用 CLion 编写,在此之前需要链接 jni.h 和 jni_md.h 两个文件,否则会失败。所以我的 CMakeLists 文件如下:

cmake_minimum_required(VERSION 3.19)project(JavaThreadLearn)
set(CMAKE_CXX_STANDARD 20)
# 这里改成你自己的文件位置和文件名add_executable(JavaThreadLearn src/main/cpp/com_codewithbuff_javathread_CWBThread.h src/main/cpp/cwb_thread.cpp)# 这里记得修改成你自己的JDK目录include_directories(/Library/Java/JavaVirtualMachines/jdk-15.0.2.jdk/Contents/Home/include)include_directories(/Library/Java/JavaVirtualMachines/jdk-15.0.2.jdk/Contents/Home/include/darwin)

复制代码

之后呢,我们就可以编写对应的 cpp 文件了,编写之后如下:

//// Created by joker on 2021/6/29.//#include <iostream>#include <pthread.h>#include "com_codewithbuff_javathread_CWBThread.h"using namespace std;
class CWBThreadWrapper {private:    JavaVM* javaVm;    jobject cwbThreadObject;    JNIEnv* attachToJVM();public:    CWBThreadWrapper(JNIEnv *env, jobject obj);    void callRunMethod();    ~CWBThreadWrapper();};
JNIEnv *CWBThreadWrapper::attachToJVM() {    JNIEnv *jniEnv;    if (javaVm->AttachCurrentThread((void **)&jniEnv, nullptr) != 0) {        cout << "Attach failed.\n";    }    return jniEnv;}
CWBThreadWrapper::CWBThreadWrapper(JNIEnv *env, jobject obj) {    env->GetJavaVM(&(this->javaVm));    this->cwbThreadObject = env->NewGlobalRef(obj);}
CWBThreadWrapper::~CWBThreadWrapper() {    javaVm->DetachCurrentThread();}
void CWBThreadWrapper::callRunMethod() {    JNIEnv *env = attachToJVM();    jclass clazz = env->GetObjectClass(this->cwbThreadObject);    jmethodID methodId = env->GetMethodID(clazz, "run", "()V");    if (methodId != nullptr) {        env->CallVoidMethod(this->cwbThreadObject, methodId);    } else {        cout << "Can't find run() method.\n";    }}
void *thread_entry_pointer(void *args) {    cout << "Start set thread entry pointer.\n";    CWBThreadWrapper *cwbThreadWrapper = (CWBThreadWrapper *) args;    cwbThreadWrapper->callRunMethod();    delete cwbThreadWrapper;    return nullptr;}
JNIEXPORT void JNICALL Java_com_codewithbuff_javathread_CWBThread_start0(JNIEnv *jniEnv, jobject cswThreadObject) {    CWBThreadWrapper *cwbThreadWrapper = new CWBThreadWrapper(jniEnv, cswThreadObject);    pthread_attr_t pthreadAttr;    pthread_attr_init(&pthreadAttr);    pthread_attr_setdetachstate(&pthreadAttr, PTHREAD_CREATE_DETACHED);    pthread_t pthread;    if (pthread_create(&pthread, &pthreadAttr, thread_entry_pointer, cwbThreadWrapper)) {        cout << "Create error.\n";    } else {        cout << "Start a linux thread.\n";    }}

复制代码

这代码阅读起来问题不大,就是一个简单的 JNI 本地方法调用。因为我们把线程的创建交给了 pthread 来完成,我们也不需要考虑为线程插入安全点,安全区域,解释器入口等 JVM 才有的操作,所以总代码不多,功能单一。

现在一切就绪,我们把这个 cpp 文件编译成平台相关的动态链接文件,因为我是 macOS,所以后缀是 jnilib,输入指令:

g++ -I[你的JDK的位置]/jdk-15.0.2.jdk/Contents/Home/include -I[你的JDK的位置]/jdk-15.0.2.jdk/Contents/Home/include/darwin -dynamiclib [你的cpp文件的位置]/cwb_thread.cpp -o libCWBThread.jnilib

复制代码

最后会生成一个 jnilib 的文件在你的 C++工程文件夹下面。然后我们通过 System.load()方法加载这个文件到 JVM 中去,JVM 就可以为我们的 native 的 start0()方法调用 C++方法了。

如下所示:

public class Main {
    public static void main(String[] args) {        System.load("[你的C++工程位置]/libCWBThread.jnilib");        new CWBThread("aaa").start();        new CWBThread("bbb").start();    }}

复制代码

我们可以看到这样的输出:

此时我们通过在 C++中创建一个线程,在线程中传入 CWBThread 的对象,然后通过这个对象调用它的 run()方法来实现类似 JVM 的 Thread 创建运行。

总结

到此结束了吗?那我们一开始提的问题,又怎么回答呢?

在回答问题之前,我们先来看看 JVM 架构:

类加载器我们就不说了,运行时数据区主要包括以下几个:

重点看后面的执行引擎部分。

执行引擎负责执行 class 文件,除此之外,还负责处理 Java 代码中的 JNI 本地调用,并把结果返回给 Java 程序。

这里的执行引擎包括三个:解释器,即时编译器和垃圾回收器。

  • 1️⃣解释器负责把 class 文件一行一行的解释执行,并不是翻译成机器码,而是读取每一行字节码,然后在自己的内部执行,所以 Java 线程的 PC 记录的是当前解释器解释到了哪一行(第一个疑惑——PC 记录的是啥)。
  • 2️⃣JIT 负责对热点代码生成本地码,具体过程包括:
  • 热点探测技术发现热点代码
  • 字节码=>中间码
  • 中间码优化
  • 中间码=>本地机器码

这里需要说明的是,JIT 只负责生成,不负责执行,生成之后的机器码的调用还是解释器来完成,所以实际的执行流程里有一个解释器判断当前代码有没有被编译的过程,如果被编译了,就直接执行编译过的本地码,否则自己一行一行解释执行。

  • 3️⃣垃圾回收器不是我们这节的重点,我们就不说了。

现在我们通过我们创建的这个小的自定义线程来理一理:首先,pthread 属于 C 的方法,JVM 也无非是调用 OS 的 thread create 来创建线程,OS 的底层实现也是 pthread;其次,我们通过在 pthread 创建时传入我们的 JavaThread 对象,然后调用它的 run()方法来实现线程中执行 run()的功能;最后执行完毕,线程因为是 C 线程,由 OS 负责销毁,我们啥也没干就结束了。

在这里我们忽略了 JVM 在为 JavaThread 创建 OS 线程时插入的安全点等操作,单纯考虑最简单的功能。

后来我在 StackOverflow 和知乎上找到了答案。

每个线程的执行分为两种:解释器直接执行+本地方法执行。如果是本地方法执行,解释器不做任何行为;如果是解释器执行,则解释执行当前字节码,如有必要,由 JIT 翻译成本地机器码,但是依旧是解释器执行本地代码。 Java 字节码必须通过执行引擎执行,所以即使是在 pthread 中的 run()方法,它所包含的字节码也必须由执行引擎执行。 (我的理解)执行引擎+run()是一起运行在 pthread 中的,它们俩组合运行,而不是 run()单独跑在操作系统线程中,也不是多个 run()共享一个执行引擎。执行引擎只是一个程序,相当于在 run()之前插入一些代码,然后读取字节码,(执行引擎)在 pthread 中运行(即运行自己也运行字节码)。

那些答案在强调 Java 只能以字节码+执行引擎执行,JIT 只是把字节码中的热点代码编译成本地码加快速度,但不等于 Java 字节码可以直接跑在机器上。既然 Java 字节码只能通过执行引擎运行,而 run()里面保存的是字节码,那么由 pthread 运行的 run()必然需要执行引擎介入。

顺带一提,对于方法调用,在同一线程中,只是执行引擎入口处的栈帧+字节码发生了替换而已。不同线程拥有自己的执行引擎,彼此独立;这里需要强调的是执行引擎是一个程序,不是一个实例对象,它位于线程最开始的地方,接受一个方法的栈帧+字节码作为参数,然后执行字节码(纯解释器执行或 JIT 翻译之后解释器执行本地码)。

这里还要说一下,关于 PC(程序计数器),如果执行的是 Java 代码(字节码),PC 确实是字节码位置,哪怕在 pthread 中执行,因为我们刚刚已经弄明白了一件事,那就是即使是在 pthread 中,还是跑的是字节码,所以 PC 是存在的,也确确实实记录了字节码行号;但若跑的是本地方法,那 PC 就是未定义的,因为此时本地方法的 PC 表示的是二进制指令的位置。

参考

对于OpenJDK而言,是不是每个Java线程都对应一个执行引擎线程? - ETIN的回答 - 知乎

How Java thread maps to OS thread?

Wouldn't each thread require its own copy of the JVM?

JVM Tutorial - Java Virtual Machine Architecture Explained for Beginners

What exactly is the JIT compiler inside a JVM?

Interpreter

  • 发表于:
  • 本文为 InfoQ 中文站特供稿件
  • 首发地址https://www.infoq.cn/article/7a457354e971816711c89edb8
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码关注腾讯云开发者

领取腾讯云代金券