深入探讨虚拟机运行时的java线程栈帧、Java/JVM沟通

1496 篇文章 10 订阅
1494 篇文章 14 订阅

栈帧

线程是程序执行的代名词,而程序执行过程中一个至关重要的东西是栈帧。它可以存放局部变量、方法参数等数据。先进先出(FIFO)的数据结构也原生地为函数调用和递归函数调用提供了可能。在4.1.2节中讨论了线程栈顶部的一些保护页和相关机制,本节将关注4.1.2节中用于描述方法调用的栈帧(frame),如代码清单4-14所示:

代码清单4-14 frame结构

class frame {
private:
intptr_t* _sp; // 栈顶指针
address _pc; // 指向下一条指令的指针(RIP)
CodeBlob* _cb; // 持有pc的代码块
deopt_state _deopt_state; // 退优化状态(未退优化、退优化、未知)
public:
...
#include CPU_HEADER(frame) // 巧妙地用#include包含CPU架构相关的代码
};

HotSpot VM将CPU无关的代码放置于runtime/frame中,然后用头文件包含指令#include巧妙地根据不同的CPU架构包含不同的代码,CPU_HEADER将会在编译的时候被替换为指定的架构,如x86是
hotspot/cpu/x86/frame_x86.hpp。与前面线程一样,frame只是一个数据结构,不管有没有这个数据结构,C++代码在执行的时候都是存在栈帧的,所以,虚拟机没有创造frame,它只是借用frame这个数据结构来描述栈帧。除了frame外还有vframe,如代码清单4-15所示,vframe是对frame的进一步封装,表示Java层面的虚拟栈帧。除了frame提供的信息外还能通过vframe访问到栈帧所属线程和Callee-saved寄存器。

代码清单4-15 vframe布局

class vframe: public ResourceObj {
protected:
frame _fr; // 物理栈帧
RegisterMap _reg_map; // callee-saved寄存器
JavaThread* _thread; // 栈帧所属线程
public:
...
};

Callee-saved表示被调用者保存的寄存器,如果方法调用者希望调用了方法后某些寄存器还能保持原来的值,就需要被调用者在使用它们前提前保存。与之类似的概念是Caller-saved,即调用者在调用前自行保存这些寄存器的值,而被调用者可以自由使用它。两者的区别就是如果某个寄存器需要在一个调用后使用,那么是调用者保存它的值(Caller-saved)还是被调用者保存它的值(Callee-saved)。

vframe的Callee-saved寄存器保存了调用者的栈底指针(RBP/EBP),也正是因为它保存了该指针,才能放心地通过当前vframe获取前一个vframe,如此继续,直至迭代完所有栈帧。即便如此,vframe仍显得细节过多,所以虚拟机会在vframe上抽象出javaVFrame用于表示Java栈帧。javaVFrame栈帧还可以细分为解释器栈帧(interpretedVFrame)和编译代码栈帧(compiledVFrame)。这些新的栈帧几乎没有增加数据结构,只是相比frame而言更方便。

后面在第10章会讲到,垃圾回收器从GC Root出发寻找存活对象。

很多地方都属于GC Root,其中之一便是线程栈。垃圾回收器遍历线程上的每个frame(所有frame构成一个线程栈),然后调用frame::oops_do()寻找frame中的所有对象引用,并以它们为起始进行对象标记,如代码清单4-16所示:

代码清单4-16 frame::oops_do

void frmae::oops_do(...) { oops_do_internal(...); }
void frame::oops_do_internal(...) {
if (is_interpreted_frame()) { // 解释器栈帧
oops_interpreted_do(f, map, use_interpreter_oop_map_cache);
} else if (is_entry_frame()) { // call_stub调用起始栈帧
oops_entry_do(f, map);
} else if (CodeCache::contains(pc())) { // 编译后代码栈帧
oops_code_blob_do(f, cf, map);
} else {
ShouldNotReachHere();
}
}

不同类型的栈帧存放引用的位置不同,如解释器栈中monitor区域存在引用、参数区域存在引用、编译后的代码中OopMapSet和参数区存在引用,所以frame::do_oops()需要区分它们并找到所有引用。

Java/JVM沟通

Java代码没有能力创造线程,它必须通过JNI的形式请求虚拟机来创造,而某些情况下JVM也需要调用Java方法,两者需要一种方式来沟通,这种方式便是JNI和JavaCalls,它们是JVM和Java沟通的桥梁,如图4-6所示。

有时Java标准库不提供或者没有及时提供平台特定的一些功能,有时有些库可能使用其他语言编写,但Java代码希望调用它们,还有时用户希望使用汇编或者其他的低级语言实现一些时间敏感的逻辑。为了满足这些需求,Java设计了JNI(Java Native Interface)。当Java方法被native关键字修饰时(native方法),该方法通过JNI进入虚拟机内部,调用对应的虚拟机中函数(JNI函数)。

JNI

开发者通常使用Class<?>.getDeclaredFields()获取某类的所有(父类除外)字段。在具体实现中,它调用native修饰的方法getDeclaredFields0,该方法又通过JNI调用虚拟机内部的JNI函数
JVM_GetClassDeclaredFields。那么虚拟机如何知道native方法getDeclaredFields0对应的JNI函数JVM_GetClassDeclaredFields呢?答案是使用Class<?>.registerNatives。

当类加载时,虚拟机调用静态代码块的Class<?>.registerNatives方法,如代码清单4-17所示,该方法会告诉虚拟机两者的对应关系。后续如果调用getDeclaredFields0,虚拟机可以根据之前注册的关系找到
JVM_GetClassDeclaredFields。

代码清单 4-17 Class.getDeclaredFields()JNI实现

static JNINativeMethod methods[] = {
{"getDeclaredFields0",
"(Z)[" FLD,
(void *)&JVM_GetClassDeclaredFields}, ...
};
JNIEXPORT void JNICALL
Java_java_lang_Class_registerNatives(JNIEnv *env, jclass cls){
methods[1].fnPtr = (void *)(*env)->GetSuperclass;
(*env)->RegisterNatives(env, cls, methods,
sizeof(methods)/sizeof(JNINativeMethod));
}

HotSpot VM将一些JNI函数放入一个数组(methods),然后用registerNatives统一注册。在JDK源码中,有很多类(如java.lang.Class,java.lang.Object, java.lang.System)都有这个注册函数,它们都是在一个类的静态代码块里面调用registerNatives。这也意味着如果类没有经历初始化阶段(即<clinit>没有调用,参见第2章),部分未经注册的JNI函数是不能使用的。在第2章提到,Java方法在虚拟机中的表示是Method,Method里面有很多入口,而所谓注册,就是设置native方法的入口,如代码清单4-18所示,只是这个入口位置比较奇怪,不在Method中而是在其后。

代码清单4-18 native方法入口

void Method::set_native_function(...) {
// native_function_addr会返回Method之后的位置
address* native_function = native_function_addr();
address current = *native_function;
// 如果已经注册过就返回
if (current == function) return;
// 否则将native方法入口地址写到Method之后的位置
*native_function = function;
}

set_native_function会将JNI函数地址写到Method后的native_function_addr,如图4-7所示。

如果是普通Java方法,Method就存放一切需要的入口地址,比如解释器入口地址、JIT编译后的入口地址,此时Method后面没有附加内容。但是如果Java方法是native,其对应的JNI函数地址会放到Method后面的第一个附加槽(不属于Method数据结构的部分),这个“将JNI函数地址放入第一个槽”就是registerNative()要完成的。这样之后registerNative注册的native方法就能在类初始化后被调用了。

实际上,在日常开发中可以用javah生成C++函数,即JNI函数,它和Java的native方法“自动”对应,无须用到registerNative。这是因为虚拟机也会根据native方法名称和参数类型按照一定的规范查找JNI函数。根据用户编写的native方法找到对应的JNI函数是一个复杂的过程,虚拟机会经历一个很长的查找链。

1)解释器遇到native方法,调用
InterpreterRuntime::prepare_native_call准备。2)prepare_native_call检查Method是否存在附加槽(是否已经有native入口),如果存在直接返回;如果不存在则用NativeLookup::lookup继续后面的查找过程。

3)NativeLookup::lookup调用Java代码ClassLoader.findNative。

4)findNative在synchronized块内寻找所有动态链接库,然后又调用一个native方法回到JVM层,这个native方法最终调用JVM_FindLibraryEntry。

5)JVM_FindLibraryEntry代理操作系统相关的动态链接APIos::dll_lookup。

6)os::dll_lookup平台相关,在Linux/OS X上调用dlsym(),在Windows上调用GetProcAddress。

查找native方法对应的JNI函数涉及多个状态层的转换,甚至还包含synchronized代码块,如图4-8所示。

如果使用registerNative提前注册,类初始化阶段会完成这些准备工作,否则上述开销将会推迟到运行时。

Method后面第二个槽signature_handler会在紧接着JNI入口设置后设置,它的作用和第2章提到的i2c/c2i适配器的作用一样:消除Java解释器栈和C栈调用约定的不同,将位于解释器栈中的参数适配到JNI函数使用的C栈。

如图4-9所示,假如有一个native方法,签名是(JID)I。当调用它时,signature handler会根据《Java虚拟机规范》的描述解析方法签名字符串,得到参数是this指针(long)、long、int和double,返回值为int。它会将解释器栈上这些参数放到C栈上,然后根据调用约定(x64 gcc遵循System V AMD64 ABI),将C栈上一些参数再尽可能放入寄存器。由于该调用约定可以将至多6个整数放入通用寄存器,8个浮点数放入xmm寄存器,因此本例中的4个参数都会放入寄存器。

图4-9 JNI参数处理器

除此之外,参数-XX:+UseFastSignatureHandlers(默认开启)还会启用一个优化:对于不超过13个参数的native方法,signature handler会走快速路径。所谓快速路径是指JVM计算方法签名字符串得到一个64位整数方法指纹(Method Fingerprint)值,后续signature handler将不需要每次都解析签名字符串得到参数个数和类型,而是直接用方法指纹值。

同时快速路径也不会将参数放到C栈再取一些放入寄存器而是一步到位,直接放入寄存器或者C栈(如果寄存器放不下)。

上面的讨论说明如果native方法参数不超过13个,则有较高性能提升,如果参数个数在调用约定允许的寄存器范围内,可以让native调用性能到达最佳。

JavaCalls

前面提到过Java线程设置了入口后使用JavaCalls执行Java方法Thread.run()。在虚拟机中,Java代码通过JNI调用JVM方法,而JVM反过来通过JavaCalls模块调用Java方法。

JavaCalls模块可细分为call_virtual调用Java虚方法、call_static调用Java静态方法等。虚方法调用会根据对象类型进行方法决议,所以需要获取对象引用再查找实际要调用的方法,而静态方法调用直接查找要调用的方法即可。无论如何,这些方法都是先找到要调用的方法的methodHandle,然后传给如代码清单4-19所示的JavaCalls::call_helper()做实际调用:

代码清单4-19 JavaCalls方法调用

void JavaCalls::call_helper(...) {
...
// 调用函数指针_call_stub_entry,把实际的函数调用工作转交给它
{ JavaCallWrapper link(method, receiver, result, CHECK);
{ HandleMark hm(thread);
StubRoutines::call_stub()(
(address)&link,
result_val_address,
result_type,
method(),
entry_point,
args->parameters(),
args->size_of_parameters(),
CHECK
);
...
}
}
}

严格来说,call_helper还没有做方法调用,它只是检查方法是否需要编译,验证参数是否正确等,最终它会跳转到函数指针_call_stub_entry处,把方法调用这件事又转交给_call_stub_entry。

_call_stub_entry由generate_call_stub()生成,这是一个运行时代码生成的过程,会在本书后面数次遇到,简单来说是虚拟机在初始化阶段为这些stub生成一段固定的机器代码,并放入内存,后续可以跳转到这段内存,将数据当作代码执行。

_call_stub_entry会调用Java方法,而调用Java方法前需要建立栈帧,所以它也会负责栈帧的创建。这个栈帧里面保存了一些重要的数据,包括Java方法的参数和返回地址。当一切准备就绪,就可以调用Java方法了。需要注意的是,Java方法不是机器代码,不能被CPU直接执行,这里说的调用Java方法更确切来说是跳转到解释器入口entry_point处,由解释器解释执行Java方法,如图4-10所示。

entry_point即第2章类链接阶段设置的,实际上entry_point到解释器真正解释Java代码还有一小段距离,这里为了便于理解可以将它看作解释器入口。entry_point也是一段机器代码,也是通过运行时代码生成技术在虚拟机初始化时动态生成的,关于它的生成将会在第5章讨论。

本文给大家讲解的内容是探讨虚拟机运行时的java线程栈帧、Java/JVM沟通

  1. 下篇文章给大家讲解的是探讨虚拟机运行时的Unsafe类;
  2. 觉得文章不错的朋友可以转发此文关注小编;
  3. 感谢大家的支持!
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值