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

 2023-01-08
原文作者:小白白白_ 原文地址:https://juejin.cn/post/6979235636519649293

前言

之前看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 __cplusplus
    extern "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();
        }
    }

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

202301011609164401.png

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

总结

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

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

202301011609171742.png

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

202301011609182743.png

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

202301011609189714.png

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

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

  • 1️⃣解释器负责把class文件一行一行的解释执行, 并不是翻译成机器码 ,而是读取每一行字节码,然后在自己的内部执行,所以Java线程的 PC记录的是当前解释器解释到了哪一行 (第一个疑惑——PC记录的是啥)。

  • 2️⃣JIT负责对热点代码生成本地码,具体过程包括:

    1. 热点探测技术发现热点代码
    2. 字节码=>中间码
    3. 中间码优化
    4. 中间码=>本地机器码

    这里需要说明的是,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