HotSpot中执行引擎技术详解(四)——hotspot探测

基本理论

在前一篇文章里面简单提及了一些HotSpot中的“热点(hotspot)”探测,这篇文章就要来详细探讨一下在HotSpot里面使用的热点探测技术。众所周知的是,JVM在检测到一个热点的时候,就会启动JIT。这些热点将被编译为本地机器码,编译好的机器码将被放在methodOop里面的一个保留字段。下一次执行的时候,JVM检测到这个methodOop的保留字段不为null,就会将程序控制从解释执行转移到执行编译后的代码。

通常意义上,热点是指一个代码块,这块代码被频繁执行。在一些情况下,热点也会代指一条被频繁执行的路径。这对应于热点探测中收集的两种数据:

  1. 一个代码块被执行的频率;
  2. 基于控制流的代码执行路径分析:举个例子来说,对于一个条件分支,true和false的分支的执行情况是不同的,可能一条分支的执行频率非常高,而另一条分支只有在极其罕见的情况下才会被执行。从代码的意义上来说,这两条分支的代码可能在物理上解决,即它们属于同一个代码块。


    热点块和热点路径

如上图,左边的绿色块即是指热点代码块,右边的红色线条即是热点路径。

从某种意义上来说,基于控制流的代码路径分析会更加精确。可以断定的是,一个执行频繁的代码块,里面可能含有的某一段代码会处于“冷”路径上(如前面所说的另外一个罕见分支)。但是在一条“热”的路径上,所有的代码都会是“热”的,被频繁执行,虽然它们之间热度上会有区别。

HotSpot是基于块的,即当我们描述HotSpot的热点代码的时候,就是指一个代码块,如果直接理解为某一个方法,也不能算是错。

HotSpot里面,代码的执行会达到两个阈值。第一个就是熟知的触发JIT的阈值,这种时候编译都是以方法为单位的;第二个是触发栈上替换(on-stack replacement)的阈值。这个阈值可以被循环所触发。触发的代码也会被编译为本地机器码,但是其不再是以方法为单位的,它可以是一个方法内的某一段代码,比如说整个循环体。在此种情况下,JVM会维护一个字节码到编译后本地机器码的映射。读者可以参阅The Java HotSpot Server Compiler论文

热点的探测有两种基本手段。第一种是在执行的代码中间插入一些“探针”,或者说“桩”,这些探针或者桩就是一小段的代码。这些代码就是用于收集代码运行时候的信息;另外一种方式是采样,即在某个时间点收集寄存器和内存中的数据,最为常见的是收集PC中的数值,由此来断定分支跳转等信息。采样的方法,显然其开销会比较小,但是却无法收集精细的数据;而插桩则会带来更加大的开销,但是却可以精确控制所需要收集的数据。HotSpot采用的是插桩的方法。HotSpot主要收集两个指标:

  1. 方法计数,为每个方法分配一个调用计数器,它出现在方法入口;
  2. 循环计数,为每个循环分配一个计数器;

当一个方法的方法计数器或者循环计数器出发JIT阈值,就会被认为是一个热点,即会被编译为本地代码。

HotSpot实现

现在深入到HotSpot里面的去看一看它是如何插桩的。这里主要以方法调用的插桩为例。方法调用的字节码指令有invokestatic, invokevirtual, invokeinterface等,这里我将用Invokevirtual指令来作为例子。
现在的HotSpot默认采用的是模板解释器 ,该系列的第二篇已经对此有解释了。所以进去模板解释器的templateTable里面找到该指令的模板生成器——实际上就是一个方法,其定义是:

// src/share/vm/interpreter/templateTable.hpp
static void invokevirtual(int byte_no);

该方法实现是与CPU架构直接相关的,x86 64的实现是:

// src/cpu/x86/vm/templateTable_x86_64.cpp
void TemplateTable::invokevirtual(int byte_no) {
  transition(vtos, vtos);
  assert(byte_no == f2_byte, "use this argument");
  prepare_invoke(byte_no,
                 rbx,    // method or vtable index
                 noreg,  // unused itable index
                 rcx, rdx); // recv, flags

  // rbx: index
  // rcx: receiver
  // rdx: flags

  invokevirtual_helper(rbx, rcx, rdx);
}

调用计数是在invokevirtual_helper方法里面被调用,其内调用了一个profile_virtual_call方法,在其内完成了方法计数的增加:

void InterpreterMacroAssembler::profile_virtual_call(Register receiver,
                                                     Register mdp,
                                                     Register reg2,
                                                     bool receiver_can_be_null) {
  if (ProfileInterpreter) {
  // ...
    if (receiver_can_be_null) {
      // ...
      // We are making a call.  Increment the count for null receiver.
      increment_mdp_data_at(mdp, in_bytes(CounterData::count_offset()));
      //...
    }
    //...
  }
}

现在知道在哪里方法计数被增加了,那么还有一个问题没有解决,即HotSpot将这些计数保存在哪里?一个很自然的想法就是保存在methodOop里面。注意的是,从前面贴出来的代码上并不能看出什么来,因为它增加方法计数是直接增加了寄存器中的数值。

methodOop里面有一段注释,已经解释了方法计数被放在哪里:


方法计数器

methodCounters对应于methodOop中声明的_method_counters字段,该字段是MethodCounters指针。而MethodCounters的定义如下:

// src/share/vn/oops/methodCounters.hpp
class MethodCounters: public MetaspaceObj {
 friend class VMStructs;
 private:
  int               _interpreter_invocation_count; // Count of times invoked (reused as prev_event_count in tiered)
  u2                _interpreter_throwout_count; // Count of times method was exited via exception while interpreting
  u2                _number_of_breakpoints;      // fullspeed debugging support
  InvocationCounter _invocation_counter;         // Incremented before each activation of the method - used to trigger frequency-based optimizations
  InvocationCounter _backedge_counter;           // Incremented before each backedge taken - used to trigger frequencey-based optimizations
//...
}

从其继承关系上也可以看出来,它被放在metaspace里面。它里面含有好几个计数器,之前一直追溯的就是_interpreter_invocation_count计数,这个计数统计的就是整个方法被解释器调用的次数。除此以外,还有两个计数器可以关注一下:

InvocationCounter _invocation_counter;         // Incremented before each activation of the method - used to trigger frequency-based optimizations
  InvocationCounter _backedge_counter;           // Incremented before each backedge taken - used to trigger frequencey-based optimizations

这两个计数都是用于基于频率的优化。所谓基于频率的优化,其实也很好理解。显然,一个方法的调用次数并不能完全决定它是否是热点。比如说一个方法在第一分钟内调用了一万次,而后再没有被调用,而第二个方法虽然每分钟只被调用一千次,但是一直被调用。那么显然,第一个方法在第二分钟起,就不应该被认为是一个热点了,相反,第二个方法可能会一直被认为是一个热点。

InvocationCounter是一个很关键的类,它里面维护着如何断定一个方法是否属于热点——即是否触发阈值的逻辑,其定义在:

// src/share/vm/interpreter/invocationCounter.hpp
// InvocationCounters are used to trigger actions when a limit (threshold) is reached.
// For different states, different limits and actions can be defined in the initialization
// routine of InvocationCounters.
// Implementation notes: For space reasons, state & counter are both encoded in one word,
// The state is encoded using some of the least significant bits, the counter is using the
// more significant bits. The counter is incremented before a method is activated and an
// action is triggered when when count() > limit().
class InvocationCounter VALUE_OBJ_CLASS_SPEC {
 private:                             // bit no: |31  3|  2  | 1 0 |
  unsigned int _counter;              // format: [count|carry|state]
// ...
  bool reached_InvocationLimit(InvocationCounter *back_edge_count) const {
    return (_counter & count_mask) + (back_edge_count->_counter & count_mask) >=
           (unsigned int) InterpreterInvocationLimit;
  }
  bool reached_BackwardBranchLimit(InvocationCounter *back_edge_count) const {
    return (_counter & count_mask) + (back_edge_count->_counter & count_mask) >=
           (unsigned int) InterpreterBackwardBranchLimit;
  }
  // Do this just like asm interpreter does for max speed.
  bool reached_ProfileLimit(InvocationCounter *back_edge_count) const {
    return (_counter & count_mask) + (back_edge_count->_counter & count_mask) >=
           (unsigned int) InterpreterProfileLimit;
  }
  // ...
}

到这里,我们就已经清楚,HotSpot是如何完成热点探测这一件事情的了。热点探测是和JIT息息相关的一种东西,等后续谈到JIT更加详细的内容的时候,还会回到这个地方,讨论两者之间的合作。比如说方法计数的衰减问题。

推荐阅读更多精彩内容