JVM方法执行的来龙去脉

0.238字数 1196阅读 515

趁着春节放假,借着《揭秘Java虚拟机》,好好看了下Hotspot源码,对JVM执行Java方法的过程有了更深入的了解。大过年的,不发红包,发篇文章吧。

一:CallStub例程

普通的Java类被编译成字节码后,对Java方法的调用都会转换为invoke指令,而Java第一个方法是由谁调用的呢?Java main()方法的执行其实是通过JVM自己调用的。不过对于JVM来说,无论是如何执行Java方法,都是通过JavaCalls模块来实现的。

JavaCalls这个名字取得很形象,一看就知道是用来调用Java方法的。JavaCalls中有很多用来调用Java方法的函数,如call_virtual()、call_special()、call_static等,用来调用不同类型的Java方法,不过这些函数最终都是调用的call()方法:

void JavaCalls::call(JavaValue* result, methodHandle method, JavaCallArguments* args, TRAPS) {
  ......
  os::os_exception_wrapper(call_helper, result, &method, args, THREAD);
}

os::os_exception_wrapper(call_helper, result, &method, args, THREAD)中其实没啥:

void os::os_exception_wrapper(java_call_t f, JavaValue* value, methodHandle* method,
                     JavaCallArguments* args, Thread* thread) {
  f(value, method, args, thread);
}

f其实就是call()方法中传入的call_help,这里相当于调用了call_help(value, method, args, thread),因为call_help其实就是个函数指针,同样定义在JavaCalls中:

void JavaCalls::call_helper(JavaValue* result, methodHandle* m, JavaCallArguments* args, TRAPS) {
    ......
      StubRoutines::call_stub()(
        (address)&link,
        // (intptr_t*)&(result->_value), // see NOTE above (compiler problem)
        result_val_address,          // see NOTE above (compiler problem)
        result_type,
        method(),
        entry_point,
        args->parameters(),
        args->size_of_parameters(),
        CHECK
      );
    ......
}

可见call_help中最终是通过StubRoutines::call_stub()的返回值来调用java方法的;由此可知,call_stub()返回的肯定也是个函数指针之类的。我们来看看call_stub()返回的具体是啥:

  /openjdk/hotspot/src/share/vm/runtime/stubRoutines.hpp
  static CallStub call_stub() { return CAST_TO_FN_PTR(CallStub, _call_stub_entry); }

call_stub()返回了_call_stub_entry例程的地址,例程是啥,我开始时也觉得很难理解,而且“例程”这个名字也取得很奇怪。其实例程可以理解为用汇编写好的一个方法,和内联汇编差不多,被加载到内存中后,我们就可以直接通过它的首地址来调用执行它。很多读者可能也觉得很奇怪,为什么要用汇编呢?是因为汇编快吗?那C语言写的方法最后不也会被编译成汇编吗,有什么区别呢?首先,就是因为汇编快,“快”其实不太准确,C语言虽然也会被编译成汇编,最后编译成二进制指令,但是编译器生成的C语言指令会很长,有很多冗余的指令,而为了实现同样一个功能,程序员自己写的汇编会比较精简,指令少,优化多,自然也就更“快”了。

那么_call_stub_entry这个例程是何时生成的呢?答案就在generate_call_stub()中,这个方法有点长,大家有点耐心。

下面大家会看到很多类似汇编指令的代码,其实这些不是指令,而是一个个用来生成汇编指令的方法。JVM是通过MacroAssembler来生成指令的。我会将具体的执行过程通过注释的方式插入到代码中

/openjdk/hotspot/src/cpu/x86/vm/stubGenerator_x86_32.cpp

address generate_call_stub(address& return_address) {
    StubCodeMark mark(this, "StubRoutines", "call_stub");
    //汇编器会将生成的例程在内存中线性排列。所以取当前汇编器生成的上个例程最后一行汇编指令的地址,用来作为即将生成的新例程的首地址
    address start = __ pc();

    // stub code parameters / addresses
    assert(frame::entry_frame_call_wrapper_offset == 2, "adjust this code");
    bool  sse_save = false;
    const Address rsp_after_call(rbp, -4 * wordSize); // same as in generate_catch_exception()!
    const int     locals_count_in_bytes  (4*wordSize);

    //定义一些变量,用于保存一些调用方的信息,这四个参数放在被调用者堆栈中,即call_stub例程堆栈中,所以相对于call_stub例程的栈基址(rbp)为负数。(栈是向下增长),后面会用到这四个变量。
    const Address mxcsr_save    (rbp, -4 * wordSize);
    const Address saved_rbx     (rbp, -3 * wordSize);
    const Address saved_rsi     (rbp, -2 * wordSize);
    const Address saved_rdi     (rbp, -1 * wordSize);
    //传参,放在调用方堆栈中,所以相对call_stub例程的栈基址为正数,可以理解为调用方在调用call_stub例程之前,会将传参都放在自己的堆栈中,这样call_stub例程中就可以直接基于栈基址进行偏移取用了。
    const Address result        (rbp,  3 * wordSize);
    const Address result_type   (rbp,  4 * wordSize);
    const Address method        (rbp,  5 * wordSize);
    const Address entry_point   (rbp,  6 * wordSize);
    const Address parameters    (rbp,  7 * wordSize);
    const Address parameter_size(rbp,  8 * wordSize);
    const Address thread        (rbp,  9 * wordSize); // same as in     generate_catch_exception()!
    sse_save =  UseSSE > 0;

    //enter()对应的方法如下,用来保存调用方栈基址,并将call_stub栈基址更新为当前栈顶地址,c语言编译器其实在调用方法前都会插入这件事,这里JVM相对于借用了这种思想。
 ---------------------------------------------
|        void MacroAssembler::enter() {       |
|            push(rbp);                       |
|            mov(rbp, rsp);                   |
|       }                                     |
 ---------------------------------------------
    __ enter();

    //接下来计算并分配call_stub堆栈所需栈大小。
    //先将参数数量放入rcx寄存器。
    __ movptr(rcx, parameter_size);              // parameter counter
    //shl用于左移,这里将rcx中的值左移了Interpreter::logStackElementSize位,在64位平台,logStackElementSize=3;在32位平台,logStackElementSize=2;所以在64位平台上,rcx = rcx * 8, 即每个参数占用8字节;32位平台rcx = rcx *4 ,即每个参数占4个字节。
    __ shlptr(rcx, Interpreter::logStackElementSize); // convert parameter count to bytes

    // locals_count_in_bytes 在上面有定义:const int     locals_count_in_bytes  (4*wordSize);这四个字节其实就是上面用来保存调用方信息所占空间。
    __ addptr(rcx, locals_count_in_bytes);       // reserve space for register saves

    //rcx现在保存了计算好的所需栈空间,将保存栈顶地址的寄存器rsp减去rcx,即向下扩展栈。
    __ subptr(rsp, rcx);

    //引用《揭秘Java虚拟机》:为了加速内存寻址和回收,物理机器在分配堆栈空间时都会进行内存对齐,JVM也借用了这个思想。JVM中是按照两个字节,即16位进行对齐的:const int StackAlignmentInBytes = (2*wordSize);
    __ andptr(rsp, -(StackAlignmentInBytes));    // Align stack

    //将调用方的一些信息,保存到栈中分配的地址处,最后会再次还原到寄存器中
    __ movtr(saved_rdi, rdi);
    __ movptr(saved_rsi, rsi);
    __ movptr(saved_rbx, rbx);
   ......
   ......
    //接下来就要进行参数压栈了;
    Label parameters_done;
    //检查参数数量是否为0,为0则直接跳到标号parameters_done处。
    __ movl(rcx, parameter_size);  // parameter counter
    __ testl(rcx, rcx);
    __ jcc(Assembler::zero, parameters_done);

    Label loop
    //将参数首地址放到寄存器rdx中,并将rbx置0;
    __ movptr(rdx, parameters);          // parameter pointer
    __ xorptr(rbx, rbx);

    //标号loop处
    __ BIND(loop);

    //此处开始循环;从最后一个参数倒序往前进行参数压栈,初始时,rcx = parameter_size;要注意,这里的参数是指java方法所需的参数,而不是call_stub例程所需参数!
    //将(rdx + rcx * stackElementScale()- wordSize )移到 rax 中,(rdx + rcx * stackElementScale()- wordSize )指向了要压栈的参数。
    __ movptr(rax, Address(rdx, rcx, Interpreter::stackElementScale(), -wordSize));
    //再从rax中转移到(rsp + rbx * stackElementScale()) 处,expr_offset_in_bytes(0) = 0;这里是基于栈顶地址进行偏移寻址的,最后一个参数会被压到栈顶处。第一个参数会被压到rsp + (parameter_size-1)* stackElementScale()处。
    __ movptr(Address(rsp, rbx, Interpreter::stackElementScale(),
                Interpreter::expr_offset_in_bytes(0)), rax);          // store parameter
    //更新rbx
    __ increment(rbx);
    //自减rcx,当rcx不为0时,继续跳往loop处循环执行。
    __ decrement(rcx);
    __ jcc(Assembler::notZero, loop);

    //标号parameters_done处
    __ BIND(parameters_done);

   //接下来要开始调用Java方法了。
   //将调用java方法的entry_point例程所需的一些参数保存到寄存器中
    __ movptr(rbx, method);           // get Method*
    __ movptr(rax, entry_point);      // get entry_point
    __ mov(rsi, rsp);                 // set sender sp
   //跳往entry_point例程执行
    __ call(rax);

    ......
}

二:EntryPoint例程

上面最后会跳往entry_point例程执行,现在有个新的问题,entry_point例程是个啥?其实entry_point例程和call_stub例程一样,都是用汇编写的来执行java方法的工具。

我们回到JavaCalls::call_helper()中:

address entry_point = method->from_interpreted_entry();

entry_point是从当前要执行的Java方法中获取的:

  /openjdk/hotspot/src/share/vm/oops/method.hpp

  volatile address from_interpreted_entry() const{ 
      return (address)OrderAccess::load_ptr_acquire(&_from_interpreted_entry); 
  }

那么_from_interpreted_entry是何时赋值的?method.hpp中有这样一个set方法:

void set_interpreter_entry(address entry)      { 
    _i2i_entry = entry;  
    _from_interpreted_entry = entry; 
}

我们来看看是何时调用了method的这个set方法:

// Called when the method_holder is getting linked. Setup entrypoints so the method
// is ready to be called from interpreter, compiler, and vtables.
void Method::link_method(methodHandle h_method, TRAPS) {
  ......
  address entry = Interpreter::entry_for_method(h_method);
  assert(entry != NULL, "interpreter entry must be non-null");
  // Sets both _i2i_entry and _from_interpreted_entry
  set_interpreter_entry(entry);
  ......
}

根据注释都可以得知,当方法链接时,会去设置方法的entry_point,entry_point是由Interpreter::entry_for_method(h_method)得到的:

  static address    entry_for_method(methodHandle m)            { return entry_for_kind(method_kind(m)); }

首先通过method_kind()拿到方法类型,接着调用entry_for_kind():

  static address    entry_for_kind(MethodKind k){ 
      return _entry_table[k]; 
  }

这里直接返回了_entry_table数组中对应方法类型索引的entry_point地址。给数组中元素赋值专门有个方法:

void AbstractInterpreter::set_entry_for_kind(AbstractInterpreter::MethodKind kind, address entry) {
  _entry_table[kind] = entry;
}

那么何时会调用set_entry_for_kind()呢,答案就在TemplateInterpreterGenerator::generate_all()中,generate_all()会调用generate_method_entry()去生成每种方法的entry_point,所有Java方法的执行,都会通过对应类型的entry_point例程来辅助。

现在就豁然开朗了,调用Java方法时,首先通过method找到对应的entry_point例程,并传递给call_stub例程,call_stub准备好堆栈后,就开始前往entry_point处,entry_point例程就会开始执行传递给它的Java方法了。在研究JVM时,我们不要把Java方法当作方法,要把它当作一个对象来对待,下次有时间在好好研究下entry_point例程。

好了,不多说了,放鞭炮去了,新年快乐!

推荐阅读更多精彩内容

  • pdf下载地址:Java面试宝典 第一章内容介绍 20 第二章JavaSE基础 21 一、Java面向对象 21 ...
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
  • 从科研档案预测下一次职业变动; 引用和读者是否确定了重要的出版物?; MemeSequencer:用于嵌入图像宏的...
  • 如何彻底研究透一家公司 2018-02-15 实战财经 持续获取信息,请点击上方"实战财经"关注 第一部分:公司“...
  • 1 鸡年 腊月二十五的时候,已临近过年,我在初高组魔法训练营,有了相对充足的时间,属于自己,可以做自己喜欢做的事,...