1.为什么要理解进程与线程的关系?

A process will contain at least one thread, which is created to execute the point of entry of the application.

Usually this entry point is the main() function of the application.

引用《Learning Concurrency in Kotlin》书中的一段话,它阐述了进程与线程的关系。

为什么要理解进程与线程的关系呢?因为理解了进程与线程的关系有利于我们定位问题。

比如:当生产环境出现高CPU占用时,我们通常需要先找到高CPU占用的进程,再找到该进程下高CPU占用的线程。

2.一个Java进程,会产生几个线程?

我们回到Java本身,我们引出这样1个问题:执行"1个Java进程,会产生几个线程呢?"

我们有2种方式观测:

  • 从JVM层观测:通过JDK自带的工具(如:jps、jstack等),通过IDE的调试工具(如:IDEA的debug视图)。
  • 从操作系统层观测:通过操作系统工具(如:ps、top等)

3.从JVM层观测

3.1.测试代码

首先,我们先用一段简单的代码——这段代码只有1个main函数,main函数中打印了Hello world...

image-20230203165720496

我们在IDEA中做个测试——在14行处打个断点,测试结果可以看到有该Java进程产生了5个线程:

  • main线程
  • Attach Listener线程
  • Finalizer线程
  • Reference Handler线程
  • Signal Dispatcher线程

image-20230203165533977

3.2.测试代码改进

为了方便观测,我们修改一下测试代码——通过Thread.getAllStackTraces()在main函数中打印出该Java进程包含的线程:

  • 通过Thread.getAllStackTraces(),可以让我们在命令行中,也能方便地观察测试结果。
  • Thread.sleep(Integer.MAX_VALUE)可以持续阻塞住该进程,方便观察。

image-20230204135454957

这里补充说明一下Thread.getAllStackTraces()方法:

  • getAllStackTraces()java.lang.Thread类的静态方法
  • 首先,此方法调用java.lang.Thread类的native方法getThreads(),获得JVM上当前进程包含的所有线程对象threads
  • 然后,此方法调用java.lang.Thread类的native方法dumpThreads(),将所有线程对象threads转换为StackTraceElement[][]
  • 最后,此方法将StackTraceElement[][]转换为Map,该Map的key就是该线程包含的1个线程对象

image-20230204135619621

4.从操作系统层观测

  • 首先,我们在Linux上运行起来2.2中的测试代码,

  • 然后,通过ps -ef | grep java命令查看该Java进程信息——该Java进程的进程号是6526:

image-20230204142512012

  • 然后,通过top -H -p 6526查看该Java进程下的线程信息——该Java进程下包含了15个线程

image-20230204142900818

5.不一致的观测结果,说明了什么?

从JVM层观测,我们看到被测的这个Java进程包含了5个线程:

  • main线程
  • Attach Listener线程
  • Finalizer线程
  • Reference Handler线程
  • Signal Dispatcher线程

从操作系统层观测,我们看到同1个被测的Java进程包含了15个线程:

  • PID:6527
  • PID:6531
  • PID:6544

通过上述不一致的观测结果,我们可以产生两个推论:

  • 推论1此线程非彼线程——JVM层的线程应该是Java线程,操作系统层的线程是真正的系统线程。
  • 推论2此线程与彼线程之间应该存在某种映射关系——JVM层的线程,与操作系统层的系统线程之间,应该存在某种映射关系。

6.验证推论

在JVM层观测得到的4个线程,其中的main线程比较单纯(就是Java测试代码中main函数所在的主线程),相比其它线程(如:Finalizer线程)更单纯,因此我们可以跟踪JVM源码中的main函数:

STEP1.通过执行测试代码进入JVM的main函数

image-20230204144631761

STEP2.进入JLI_Launch函数

image-20230204144827592

  • 此时,操作系统中的该Java进程(Java进程ID=6627)下包含1个系统线程(系统线程ID=6627):

image-20230204145133920

image-20230204145221020

STEP3.执行LoadJavaVM函数

image-20230204145333363

  • 此时,操作系统中的该Java进程(Java进程ID=6627)下依然只包含1个系统线程(系统线程ID=6627):

STEP4.执行JVMInit函数

此时,可以看到1个函数名ContinueInNewThread,疑似JVM准备创建新的系统线程。

image-20230204145550135

STEP5.执行ContinueInNewThread函数

此函数会进一步调用ContinueInNewThread0()函数,疑似JVM在这个函数中会创建新的系统线程。

image-20230204145738721

STEP6.执行ContinueInNewThread0函数

  • 在此,我们终于看到了JVM调用了Linux系统APIpthread系列函数来创建系统线程

image-20230204145917391

  • 我们执行到1013行,pthread_create函数,再来观察操作系统,此时产生了1个新的系统线程(线程ID=6840)

image-20230204150109863

image-20230204150126981

STEP7.在新的系统线程中会回调到JavaMain函数

  • JVM通过pthread创建新的系统线程后,该系统线程会执行JavaMain函数

image-20230204150329012

  • JavaMain函数中,会继续执行InitializeJVM函数。

image-20230204150518536

STEP8.执行InitializeJVM函数

  • 此函数会进一步执行libjvm.so中的CreateJavaVM函数

image-20230204150918046

  • CreateJavaVM函数调用栈很深,我们直接执行这个函数,查看此时操作系统中,Java进程下的系统线程,此时JVM已经在操作系统中创建了13个新的系统线程:

image-20230204151107186

STEP9.执行回到JavaMain函数

  • JVM执行LoadMainClass函数和GetStaticMethodID函数,获得Java侧的main函数所在的类和main函数Id。

image-20230204151238640

  • JVM通过JNI接口,调用Java代码中的main函数,Java代码打印出4个Java进程下包含的Java线程

说明:在不同IDE下,Run模式和Debug模式打印的Java线程是有差异的。

image-20230204151535458

  • 此时,操作系统层,这个Java进程包含的系统线程依然为15个:

image-20230204151107186

7.结论

通过6中跟踪JVM的main函数执行过程,可知:

  • JVM在执行被测Java代码的java xxxx命令时,在操作系统层产生了1个java进程JVM会在它的main函数中,通过系统API创建系统线程(如:Linux下的pthread库)
  • JVM在该java进程内,第1个创建的系统线程是用来执行Java侧代码的main函数的。
    • 在这个系统线程内,JVM先初始化JVM,此时会产生新的系统线程。
    • 在这个系统线程内,JVM再通过JNI接口得到Java侧代码的main函数以及main函数所在的类,并执行Java侧代码的main函数。

因此,证明了我们的推论1:

  • 推论1此线程非彼线程——JVM层的线程应该是Java线程,操作系统层的线程是真正的系统线程。

8.下一步

对于推论2,需要进一步分析JVM的main函数执行流程,本文篇幅有限,且听下回分解。

笔者先附上JVM的main函数执行流程,便于展开下一篇论述:

JVM的main函数流程