一线开发大牛带你深入探讨虚拟机运行时的java线程模型
运行时
运行时,顾名思义是指虚拟机运行的时候,它表征程序执行时的状态,本章将讨论虚拟机运行时涉及的方方面面。
线程创生纪
线程模型描述了Java虚拟机中的执行单元,是所有虚拟机组件的最终使能的对象。了解Java线程模型有助于了解虚拟机运行的概况。Java程序可以轻松创建线程,虚拟机本身也需要创建线程。解释器、JIT编译器、GC是抽象出来执行某一具体任务的组件,这些组件执行任务时都需要依托线程。所以,为了管理这些五花八门的线程,虚拟机将它们的公有特性抽象出来构成一个线程模型,如图4-1所示。
1)Thread:线程基类,定义所有线程都具有的功能。
2)JavaThread:Java线程在虚拟机层的实现。
3)NonJavaThread:相比Thread只多了一个可以遍历所有NonJavaThread的能力。
4)ServiceThread:服务线程,会处理一些杂项任务,如检查内存
过低、JVMTI事件发生。
5)JvmtiAgentThread:JVMTI的RunAgentThread()方法启动的线程。
6)CompilerThread:JIT编译器线程。
7)CodeCacheSweeperThread:清理Code Cache的线程。
8)WatcherThread:计时器(Timer)线程。
9)JfrThreadSampler:JFR数据采样线程。
10)VMThread:虚拟机线程,会创建其他线程的线程,也会执行GC、退优化等。
11)ConcurrentGCThread:与WorkerThread及其子类一样,都是为GC服务的线程。
当使用命令行工具java启动应用程序时,操作系统会定位到java启动器的main函数,java启动器调用JavaMain完成一个程序的生命周期,如代码清单4-1所示,这其中涉及各种线程的创建与销毁:
代码清单4-1 Java程序生命周期
int JNICALL JavaMain(void * _args){
...// 初始化Java虚拟机
if (!InitializeJVM(&vm, &env, &ifn)) {
JLI_ReportErrorMessage(JVM_ERROR1);
exit(1);
}
...
// 加载main函数所在的类
mainClass = LoadMainClass(env, mode, what);
CHECK_EXCEPTION_NULL_LEAVE(mainClass);
// 对GUI程序的支持
appClass = GetApplicationClass(env);
mainArgs = CreateApplicationArgs(env, argv, argc);
if (dryRun) {
ret = 0; LEAVE();
}
PostJVMInit(env, appClass, vm);
// 获取main方法id
mainID = (*env)->GetStaticMethodID(env, mainClass, "main",
"([Ljava/lang/String;)V");
// main方法调用
(*env)->CallStaticVoidMethod(env, mainClass, mainID, mainArgs);
// 启动器的返回值(非System.exit退出)
ret = (*env)->ExceptionOccurred(env) == NULL ? 0 : 1;
LEAVE();
}
InitializeJVM会调用JNI函数JNI_CreateJavaVM初始化虚拟机。
JNI_CreateJavaVM又会将初始化虚拟机的任务委派给Threads::create_vm。Threads::create_vm是虚拟机的创生纪,几乎所有HotSpot VM组件都会在这一步初始化和创建,有关初始化的问题大部分都能在这找到答案。由于Threads::create_vm代码很多无法全部给出,本节将按Threads::create_vm代码初始化顺序对4.1节进行细分,讨论一些重要的线程初始化过程,读者可以认为Threads::create_vm包含了4.1.1~4.1.6节的所有内容。
容器化支持
近几年容器技术越来越流行,作为云原生的技术基石,得到很多应用和服务的广泛应用。容器使用cgroup限制CPU、内存资源,但是Java8早期并没有对容器提供支持(Java 10提供了对Linux容器的支持,并backport[1]到Java 8,所以最新的Java 8也支持容器),所以当在容器中运行JVM时,它会忽略cgroup施加的限制,错误地“看到”宿主机的所有CPU和内存资源,可能造成一些问题。
Java 10提供了对容器的支持,使用-XX:+UseContainerSupport开启容器支持后,由Threads::create_vm调用OSContainer::init()检查虚拟机是否运行在容器中,如果是则读取容器所施加的资源限制,并据此设置默认的GC线程数、堆大小等。
Java线程
注意,由于当前操作系统线程后续会解释字节码,而Java main方法会通过字节码解释执行的,因此执行Java main的线程是Java主线程,这里创建的JavaThread数据结构也就是常说的Java主线程。
代码清单4-2 Java线程创建
jint Threads::create_vm(JavaVMInitArgs* args, bool* canTryAgain) {
...
// 创建Java主线程,附加到当前线程
JavaThread* main_thread = new JavaThread();
main_thread->set_thread_state(_thread_in_vm);
main_thread->initialize_thread_current();
main_thread->record_stack_base_and_size();
main_thread->register_thread_stack_with_NMT();
main_thread->set_active_handles(JNIHandleBlock::allocate_block()
if (!main_thread->set_as_starting_thread()) {
... // 主线程启动失败,虚拟器退出
}
// 栈保护页创建
main_thread->create_stack_guard_pages();
{ // 将Java主线程加入全局线程链表,供后续使用
MutexLocker mu(Threads_lock);Threads::add(main_thread);
}
...
}
除了要防止栈溢出破坏栈之外的数据结构,语言运行时还会保留最大栈上限所在的一片区域,即保护页(Guard Page),又叫哨兵值(Sentry)、金丝雀(Canary)。当函数返回时检查保护页的值,如果被修改,说明已经到达最大栈上限,此时要终止程序并输出错误。
Java也有栈溢出,发生时会抛出StackOverflowError,输出调用栈和代码行数。这些过程都需要额外执行很多方法,但是发生栈溢出就意味着不能继续执行方法了(因为方法执行需要栈空间)。为了解决这个问题,HotSpot VM在C++语言运行时提供的保护页(Linux的JavaThread没有)之外会使用create_stack_guard_pages()创建额外的保护页来支持栈溢出错误处理,如图4-2所示。
线程栈的最大上限处会保留三块保护页(Guard Page)支持栈溢出,分别是Reserved Page、Yellow Page、Red Page。图4-2中的主要内容分析如下。
1)Reserved Page:Reserved Page源于JEP 270[2],旨在为一些关键段(Critical Section)方法保存外栈空间,让有@
jdk.internal.vm.annotation.ReservedStackAccess注解的方法能完成执行(如lock与unlock之间的代码),防止关键段方法中的对象出现不一致的状态。当执行关键段方法时分配的栈顶触及Reserved Page,则虚拟机会将Reserved Page标记为正常栈空间,供关键段方法完成执行,然后再抛出StackOVerflowError。Reserved Page的大小由-XX:+StackReservedPages=<val>指定。
2)Yellow Page:如果执行Java代码时分配的栈顶触及YellowPage,则虚拟机会抛出StackOverflowError,然后将Yellow Page标为正常栈空间,让抛异常的代码有栈可用。Yellow Page的数量由参数-XX:StackYellowPages=<val>指定,最后Yellow Page占用的空间是page数量*page大小(page的大小一般是4KB,如果开启-XX:+UseLargePages且操作系统支持large page特性,page的大小可达到4MB)。
3)Red Page:如果执行Java代码时分配的栈顶触及Red Page,则虚拟机会创建错误日志hs_err_pid<pid>.log然后关闭虚拟机。同样,为了让创建日志的代码执行,虚拟机会将Red Page标为正常栈空间。RedPage的大小由-XX:StackRedPages=<val>指定。
4)Shadow Page:前面区域都是执行Java代码出现栈溢出的错误处理。虚拟机还可能执行native方法或者虚拟机本身需要执行的方法,这些方法的栈大小不像Java代码一样能确定(编译器能确定但是虚拟机不能),如果开启虚拟机参数-XX:+UseStackBanging,JVM会分配一块足够大的Shadow Page执行,如果RSP(栈顶指针)超出Shadow Page区则抛出StackOverflowError。
有了create_stack_guard_pages()创建的额外的保护页,即便产生StackOverflowError,虚拟机也能执行额外的代码,正确地抛出Java异常并输出调用栈以提醒用户。
虚拟机线程
紧接着使用VMThread::create()创建VMThread数据结构以及它对应的VMOperation队列,VMThread即虚拟机线程,它是一个相当重要的线程。与前面的JavaThread一样,VMThread只是一个数据结构,要想发挥“可运行”的线程的能力,需要关联一个真正的线程,这个线程就是操作系统线程(OSThread)。上一小节提到JavaThread关联的是当前指向代码的操作系统线程,而这里os::create_thread会创建一个新的OSThread然后关联到VMThread。在创建了新的OSThread后,主线程会将它设置为ALLOCATED状态然后阻塞,直到新创建的OSThread完成初始化操作并设置为INITIALIZED,如代码清单4-3所示:
代码清单4-3 VMThread创建与初始化
jint Threads::create_vm(JavaVMInitArgs* args, bool* canTryAgain) {
...
{ // 创建虚VMThread
VMThread::create();
Thread* vmthread = VMThread::vm_thread();
// 创建VMThread对应的OSThread线程,OSThread会调用
// pthread_create创建真正的内核线程
if (!os::create_thread(vmthread, os::vm_thread)) {
... // 创建失败
}
{ // 等待VMThread准备就绪,然后运行VMThread
MutexLocker ml(Notify_lock);
os::start_thread(vmthread);
while (vmthread->active_handles() == NULL) {
Notify_lock->wait();
}
}
}}
当完成这一切后OSThread会阻塞,直到主线程执行os::start_thread。这时情况再次反转,主线程阻塞在vmthread->active_handles(),OSThread继续执行,设置active_handle()并最终阻塞在VMOperation队列上,等待VMOperation任务。当主线程发现active_handles已经设置便解除阻塞,至此VMThread创建完成,如图4-3所示。
创建完VMThread的效果是VMThread阻塞在VMOperation队列上,等待其他线程投放VMOperation。VMOperation表示需要VMThread执行的各种操作,如代码清单4-4所示:
代码清单4-4 VM_Operation
class VM_Operation: public CHeapObj<mtInternal> {private:
Thread* _calling_thread; // 发起VMOperation的线程
ThreadPriority _priority; // VMOPeration优先级
long _timestamp; // 创建时间戳
VM_Operation* _next; // 下一个VMOperation
VM_Operation* _prev; // 上一个VMOperation
static const char* _names[]; // VMOperation名字
public:
virtual void doit() = 0; // 具体功能实现
virtual Mode evaluation_mode(){ return _safepoint; } // 执行模式
...
};
evaluation_mode()会返回当前VM_Operation的执行模式,即虚拟机线程以何种方式执行该操作。目前虚拟机支持四种执行模式,具体如下所示。
Safepoint:虚拟机线程需要等其他线程和发起操作的线程都进入安全点才能执行操作。
No Safepoint:虚拟机线程无须等待其他线程和发起操作的线程进入安全点就能执行该操作。
Concurrent:线程发起操作后可继续执行,虚拟机线程执行操作无须等待发起操作的线程和其他线程进入安全点。
Asynchronous Safepoint:线程发起操作后可继续执行,但是当虚拟机线程执行该操作时发起操作的线程和其他线程都会进入安全点。关于安全点会在本书第二部分讨论,简单来说,它是一个全局停顿点,或者说世界停止点,在那里除了VMThread外所有线程都暂停执行,那一刻虚拟机可以认为是单线程的。如图4-4所示,理解四种状态的关键是想象JVM只有三种线程:发起操作的线程、虚拟机线程、其他线程。
并发关乎发起线程与虚拟机线程之间的交互,安全点关乎其他线程和发起线程的交互。OpenJDK 12有多达85种VMOperation,包括垃圾回收相关操作、退优化、线程状态改变、调试输出、偏向锁偏向、堆遍历等,每一种都继承自VM_Operation类,由虚拟机线程执行。
编译器线程
Threads::create_vm()会在中后期调用
CompileBroker::compilation_init_phase1创建JIT编译器线程CompilerThread。与VMThread类似,JIT编译器线程会阻塞在各自的CompileQueue队列,当有编译任务发起时,其他线程会向CompileQueue投递一个CompileTask,然后编译线程启动并进行编译。
-XX:CICompilerCount=<val>可以限制JIT编译器线程的数量,这个参数在早期Java 8及以前是有意义的,因为该参数基于CPU数调整,如果虚拟机运行在容器中无视容器的内存限制和CPU数限制,可以通过手动设置该参数解决这个问题。
另外,如果设置了-XX:+
UseDynamicNumberOfCompilerThreads,则虚拟机可以在运行时动态伸缩JIT编译器线程数量,使用-XX:+TraceCompilerThreads能观察到动态伸缩的过程。更多关于编译的内容会在本书第二部分详细讨论。
如果开启-XX:+MethodFlushing,虚拟机还会创建CodeCacheSweeperThread代码缓存清扫线程。该线程负责清理CodeCache。因为Code Cache中存放了JIT编译后的机器代码,如果由于某些原因如退优化、分层编译,或者编译器乐观假设的条件被打破,则nmethod会被标记为made_not_entrant,随后被标记为僵尸方法,此时nmethod变得不可用,可以被清理出Code Cache区域。
服务线程
Threads::create_vm()后期会创建服务线程(ServiceThread),而服务线程会检查一系列事件是否发生,如果发生则唤醒执行,否则阻塞等待。
1)低内存探测:检查堆内存和堆外内存(Non-heap memory)的内存分配是否达到阈值。
2)JVMTI deferred事件:只有Java线程能投递JVMTI事件,如果非Java线程想要投递JVMTI事件,如CompiledMethodLoad(方法被编译并载入内存),CompiledMethodUnload(方法从内存中移除),DynamicCodeGenerated(虚拟机自身组件的,如模板解释器,动态代码生成)事件,只能先投到JvmtiDeferredQueue然后等待服务线程拉取处理。
3)GC通知:GC完成后会向服务线程投递通知。
4)jcmd命令:当使用jcmd执行一些命令时会向服务线程投递通知。
5)Table改变:当一些表发生重哈希行为时会设置标记,而服务线程能发现该标记。
6)Oop区域清理:服务线程会检查一些Oop区域是否有可清理的无效引用。
计时器线程
计时器线程(Watcher Thread)是JVM内部唯一一个具有最高优先级的线程,它可以模拟计时中断来定时执行某个周期任务(PeriodicTask)。计时器线程的具体实现比较简单,首先线程如果没有周期任务就阻塞,如果有周期任务则先睡眠指定时长,然后立刻唤醒执行周期任务。周期任务是PeriodicTask及其子类,比较常见的是更新性能计数数据(-XX:+UsePerfData,
-XX:PerfDataSamplingInterval=50)的任务和更新内存Profiling任务(-XX:+MemProfiling,-XX:MemProfilingInterval=500)。
本文给大家讲解的内容是探讨虚拟机运行时的java线程模型
下篇文章给大家讲解的是探讨虚拟机运行时的java线程启动、停止、睡眠与中断;
觉得文章不错的朋友可以转发此文关注小编;
感谢大家的支持!