前两篇提到,HotSpot为了提高执行Java字节码的速度,采用了一种分阶段指令执行模型。一种是常见,但是效率比较低的解释模式。HotSpot采用了线索解释和模板解释器两项技术来提高字节码指令的执行效率;另外一种,则是鼎鼎大名的JIT。采用JIT,平均下来每条字节码所需的本地机器指令数量大为减少。Java的JIT是建立在局部性原理上的,即一些方法的执行会非常频繁,这些方法也被称为“热点(HotSpot)”。这些方法被编译成本地代码之后,这些代码会被缓存起来,当下一次运行的时候就可以直接使用了,也就是所谓的"code cache"(代码缓存)。然而,如同其余缓存,这些代码缓存也面临着缓存命中,缓存失效等问题。
注:这篇博客不会讨论涉及硬件方面的缓存涉及,比如说将缓存的代码如何分布在L1缓存,在L1上如何执行替换等。虽然这方面对虚拟机的性能影响也很大,不过本篇博客只是介绍一下HotSpot在软件层面上使用的缓存机制。对硬件方面有兴趣的读者可以自己去查阅资料,博主也是仅仅了解一点点。
基础理论
Method-base
在JVM的JIT和代码缓存实现里面,可以分成两种:
- 一种是基于方法的代码JIT编译与缓存(Method-base)。顾名思义,就是在判断是否需要进行JIT编译的时候,是以方法作为单位的。比如热点追踪里面,追踪的是方法调用次数、频率等。HotSpot的方法计数器就是如此;
- 另一种是基于“踪迹”的代码JIT编译与缓存(Trace-base)。这种做法,实际上是追踪代码路径执行的频率。举个例子来说,如果一个条件语句有两个分支,那么它会分别统计两个分支的执行次数。Android的Dalvik VM,Firefox里面的TraceMonkey,Lua2.0;
void method1(){
//统计这一个方法的执行次数
}
void method2(){
//统计该方法执行次数
// ...其余代码
while(someCondition){
//..统计这个循环的执行次数
}
// ...其余代码
}
这两种模式,都可以抽象理解为一种按块编译的模式。可以将一个方法理解为块,也可以将一个循环或者某个分支理解为一个块。因此,JIT或者代码缓存,都是以块为基本单位的。这里可以看到一个JIT编译的块的大小并不是固定的。因为方法长度,或者一个循环内部代码长度是不可预测的。因此在代码缓存的时候,不能采用固定大小缓存的方式。
代码缓存置换策略
另外一个要考虑的是代码缓存的置换策略。缓存空间的大小是有限的,当代码缓存里面剩余空闲空间不足以容纳下一个JIT产生的本地代码的时候,就需要考虑将一些已有的缓存置换出去,腾出空间以容纳新的JIT本地代码。这个管理有点类似于虚拟内存的页管理。置换常用的算法有:
- 最近最少使用:该算法的最大缺点是需要很多额外的开销来判断哪个是最近最少使用的块;
- 满时清除:当缓存一满,就立刻清空,从头开始;
- 抢先清除:在缓存尚未满,即将满的时候,就执行清空;
- FIFO:这个算法比较大的优点是利用了程序的时间局部性,因为最近才加入缓存的代码总是最可能在接下来继续被使用,而最先加入缓存的本地代码,很可能接下来都不再被使用了。这个算法有一个变种,叫做粗粒度的FIFO。它实际上是将整个缓存空间划分成固定的几个块,如将缓存空间划分成八块。以这些粗粒度的块来作为置换的基本单位;
代码缓存映射
一个代码缓存系统至少要解决一个问题:给定一个字节码指令,能够找到对应的编译好的代码缓存的起始PC,跳转过去执行本地代码。这个问题通常是使用一个映射表——一个维护了字节码到机器码的映射关系的表——来解决的。
但是这仅仅是缓存系统要解决的一个问题,还有一个更加棘手的问题:给定一个机器指令,要找到对应的字节码指令。我们现在仔细考察一下代码执行过程中及其可能出现的问题:抛出异常。如果整个代码都是被解释执行的,那么问题不大。但是如果在执行JIT产生的机器代码的过程中,抛出异常了,怎么办呢?这就要有一个回退机制,能够退回到解释模式下,处理异常。一般来说,可以额外维护一个映射表,该映射表维护了机器码到字节码的映射,每次发生异常的时候则查找这个表来找到对应的字节码,于是便会知道是哪条字节码执行的时候出现了异常。Java的异常机制要复杂一点,因为异常可以被catch住。所以知道字节码的时候,需要进一步根据字节码PC找到对应的异常处理例程执行。
HotSpot里面的实现
代码缓存——CodeCache
HotSpot里面对应于代码缓存的结构,是CodeCache。CodeCache里面含有极多的静态方法,用于管理代码缓存。最主要的方法有:
// hotspot/src/share/vm/code/codeCache.hpp
static CodeBlob* allocate(int size, bool is_critical = false); // allocates a new CodeBlob
static void free(CodeBlob* cb); // frees a CodeBlob
一个用于分配缓存空间,一个用于释放缓存空间。它们都是在一个堆上执行操作的。这个堆就是对应于CodeCache里面的静态变量_heap:
static CodeHeap * _heap;
CodeHeap是一个堆结构,其关键性质是其内维护了一个空闲块的链表。注意的是,这里谈论的块和前面谈到的JIT编译的块是两个概念。这里的块是指内存的大小,两者之间并无严格的对应关系。
// hotspot/src/share/vm/memory/heap.hpp
class CodeHeap : public CHeapObj<mtCode> {
//...
FreeBlock* _freelist;
size_t _freelist_segments; // No. of segments in freelist
//...
}
这个堆的管理方式就如同一般的空闲链表支撑的堆的管理方式。查找空闲块的时候,它采用的最佳适应,这意味着每次都需要整个空闲链表来查找——这是一个很大的开销;在释放空间的时候,还会执行空闲块的合并。这里不赘述,读者可以去找与数据结构、算法相关的资料来学习。
代码缓存实体——nmethod
我们先来看前面提到的一个问题:如何找到对应的JIT编译产生的机器代码?HotSpot的method-base特性,极大的简化了这个问题。实际上它只需要维护一个从方法到JIT代码的映射。不过在HotSpot里面并没有使用显示的一张表来维护这种关系,而是在methodOop里面维护了这种映射:
//...hotspot/src/share/vm/oops/method.hpp
// Entry point for calling from compiled code, to compiled code if it exists
// or else the interpreter.
volatile address _from_compiled_entry; // Cache of: _code ? _code->entry_point() : _adapter->c2i_entry()
// The entry point for calling both from and to compiled code is
// "_code->entry_point()". Because of tiered compilation and de-opt, this
// field can come and go. It can transition from NULL to not-null at any
// time (whenever a compile completes). It can transition from not-null to
// NULL only at safepoints (because of a de-opt).
nmethod* volatile _code;
- _from_compiled_entry: 是执行JIT代码的入口,与之对应的是_from_interpreted_entry,它是解释模式的入口;
- _code:指向的就是JIT编译后的代码。从源码的注释里面可以直接看到,该字段在不为NULL的时候,才代表已经被JIT编译了。而且,注释也表明了,它可以在NULL和non_null之间转换;
所以在调用一个方法的时候,JVM必然能够获得这个方法的methodOop实例,只需要检测这个_code字段,就可以断定应该解释执行,还是应该编译执行。
_code是一个nmethod的指针。nmethod就是这篇文章要讨论一个核心结构,它代表了一个JIT产生的Java方法。它里面有两个核心字段:
//hotspot/src/share/vm/code/nmethod.hpp
ExceptionCache *_exception_cache;
PcDescCache _pc_desc_cache;
ExceptionCache维护了异常处理信息,真正的异常处理类实际上是ExceptionHandlerTable;PcDescCache维护了PC信息,它的重要性在于,它维护了物理机器PC到字节码指令之间的映射。还记得在理论部分提出的问题:当执行本地代码的时候,发生异常怎么处理?其核心就是利用这两个结构。通过PC映射到字节码指令,而后找到对应的ExceptionHandler进行处理。
nmethod还有一个地方需要认真处理,即nmethod的“状态”。一般概念上,一个东西同一时刻只能处于一种状态中,但是nmethod的“状态”比较特殊。nmethod可能处于以下几种状态中:
- active:正处于调用栈中,也可以被称为是正在被使用;
- not-entrant:处于该状态,表名该nmethod将不能再被Java线程调度,但是如果此时的nmethod还处于调用栈中,那么这个nmethod也是active的;
- zombie:僵尸状态,故名思议就是没人用了。zombie和active是互斥的;
- for-reclamation:可以被回收的状态,处于这个状态的nmethod可能会被sweeper线程回收,释放其空间;
nmethod的这些状态,主要是在缓存清理的时候被使用到。
缓存清理——NMethodSweeper
CodCache满了以后的管理,是在CompileBroker里面实现的。这个类是一个Broker模式。它承担了JIT中的很多调度性质的工作,例如缓存满了之后的处理、编译请求队列管理。我们现在只关注:
//hotspot/src/share/vm/compiler/compileBroker.hpp
static void handle_full_code_cache();
其实现里面关键的句子是:
if (UseCodeCacheFlushing) {
// Since code cache is full, immediately stop new compiles
if (CompileBroker::set_should_compile_new_jobs(CompileBroker::stop_compilation)) {
NMethodSweeper::log_sweep("disable_compiler");
}
// Switch to 'vm_state'. This ensures that possibly_sweep() can be called
// without having to consider the state in which the current thread is.
ThreadInVMfromUnknown in_vm;
NMethodSweeper::possibly_sweep();
} else {
disable_compilation_forever();
}
// Print warning only once
if (should_print_compiler_warning()) {
warning("CodeCache is full. Compiler has been disabled.");
warning("Try increasing the code cache size using -XX:ReservedCodeCacheSize=");
codecache_print(/* detailed= */ true);
}
这里也就是可以看出来,HotSpot如果在没有设置允许缓存满了的时候刷新缓存的话,默认是不再进行任何的JIT工作。这是一个极大的性能损失。普遍来说,会打印出日志“CodeCache is full. Compiler has been disabled”。
从这段代码里面也可以看出来,刷新缓存的关键在于NMethodSweeper::possibly_sweep();
。
NMethodSweeper置换nmethod(即清除代码缓存)有两个步骤:
- 标记active的方法。所谓的active的方法,是指这个方法处于线程的调用栈中。这个步骤必须要在safepoint中进行。这是一个不难理解的步骤,如果一个方法还处于调用栈中,那么就意味着这个方法已经被执行了。如果这个时候被置换出去了,那么JVM就会出错——原本这个位置放置的是可执行的代码,而被清楚掉之后,JVM如果还继续往下执行,那么会发生情况,只能看天意了;
- 清理nmethod。这个是在
sweep_code_cache()
中完成的。为了回收这块内存:- 首先将nmethod标记为not-entrant。处于这种状态下的nmethod将无法再被Java线程调用,但是它们可能是active的;
- 这时候需要等待下一次的标记,这是为了避免回收active的nmethod。如果标记发现它们不是active的,那么就可以将其标记为zombie;
- 所有的内联缓存(inline cache),如果引用了这个nmethod,也需要被清理掉。这主要是为了避免方法内联之后,因为被内联的方法已经被清理掉了,而Inline cache却以为还存在继续被使用;
-
回收nmethod的内存;
显而易见的,只有多次标记完成之后,才有可能真正回收一块内存。标记次数与回收次数的比值收到缓存空间大小的影响。在源码中对此有说明:
// hotspot/src/share/vm/runtime/sweeper.cpp
// Small ReservedCodeCacheSizes: (e.g., < 16M) We invoke the sweeper every time, since
// the result of the division is 0. This
// keeps the used code cache size small
// (important for embedded Java)
// Large ReservedCodeCacheSize : (e.g., 256M + code cache is 10% full). The formula
// computes: (256 / 16) - 1 = 15
// As a result, we invoke the sweeper after
// 15 invocations of 'mark_active_nmethods.
// Large ReservedCodeCacheSize: (e.g., 256M + code Cache is 90% full). The formula
// computes: (256 / 16) - 10 = 6.
其中的清理的调用链是:
- NMethodSweeper::possibly_sweep()
- NMethodSweeper::sweep_code_cache()
- NMethodSweeper::process_nmethod(nmethod *nm)
- NMethodSweeper::release_nmethod(nmethod *nm)
- nmethod::flush()
- CodeCache::free(CodeBlob* cb)
- nmethod::flush()
- NMethodSweeper::release_nmethod(nmethod *nm)
- NMethodSweeper::process_nmethod(nmethod *nm)
- NMethodSweeper::sweep_code_cache()
这里有很多的细节,但是并不属于博主这篇文章打算探讨的内容。回顾前面我们谈论到的缓存置换策略,现在细细一看,那么也很容易发现HotSpot使用的的策略就是满时清理。这有点出乎意料,在博主真的深入源码读这一段逻辑之前,我以为HotSpot会使用一些更加高效率的策略。后来一想,HotSpot使用的很多技术其实都不是最优的。比如说method-base总体上是不如trace-base,但是实现简单。此处也是类似,虽然性能有损失,但是好处是实现简单。
另外要提及的一点是,JVM默认缓存清理是被关闭的。也就是缓存满了就满了,HotSpot将不再编译任何代码。这是一个极大的性能损失(重要的事情多说几遍)。
还有一个问题是,因为active可能存在缓存空间的任何一个地方——回收nmethod的时候并没有执行压缩,因此使用的空闲链表技术,难免会带来内存碎片的问题。
影响因素
我总结了一下我认为对性能影响比较大的几个地方。
- 缓存空间大小:代码缓存空间会占据一部分内存。如果代码缓存空间过大,势必会影响其余部分的内存使用。但是如果缓存空间过小,那么就意味着缓存命中率会进一步下降,而且导致频繁的缓存换入换出。这两个地方都会极大影响应用的性能。一个比较有意思的例子是,早期谷歌在其安卓系统上就陷入了这种困境。早期的安卓系统面临着代码缓存疯狂增长的问题,因此他们甚至建议在内存受限的设备上禁用掉代码缓存功能。不过这个操作会付出巨大的代价,其系统的性能会明显下降,用户对此都能明显感知到。(博主也不知道后面的版本有没有解决这个问题,以及怎么解决了这个问题)
- 缓存置换算法:很显然,这个问题就有点像是操作系统里面的的内存页面置换算法。最为理想的情况下,是将现有缓存里面将来最不可能被使用的那部分缓存置换出去。不过,也正如页面置换算法所面临的困境一般,我们只能根据过去的表现去评估未来哪部分缓存就不会再被使用。所以,对于表现良好的缓存置换算法来说,能够提高缓存命中率,能够及时清理掉不再是“热点”的代码。而糟糕的缓存置换算法,则刚好相反。HotSpot使用的置换算法有点让我失望,不知道它将来会不会有改进;
- 缓存单元:即块。基本上来说,HotSpot的JIT都是以方法为单位的。显然的是,方法长度是不可预见的,有些方法长,有些方法短。那么这些块占据的缓存空间大小也是不确定的。这会带来额外的缓存空间管理的困难。因为无论是写入还是置换,都需要计算块的大小。前面提到的内存碎片问题,在块大小不均匀的时候,会更加严重。还有另外的一个问题是,HotSpot是拒绝对大的方法进行JIT编译的,其默认设置是8000字节。-XX:HugeMethodLimit可以修改这个限制。但是在产品环境下,这个选项是无效的,但是可以使用-XX:-DontCompileHugeMethods来关闭对大方法的限制。
8000在面向对象语言里面应该算是一个很大的数值了。普遍而言,面向对象语言充满了各种小方法。我大概估算了一下(纯粹的感觉估算),8000字节的方法,应该能够支撑2000行左右代码。
后记
其实还有一个问题,我觉得有必要回答一下的。就是我认为,代码缓存对性能的影响,应该要远远大于垃圾回收器的影响。不过现在,业界的开发者对这方面重视不足。我觉得原因还是出在,垃圾回收有众多的选项给开发者选择,可调整的内容比较多。而相比之下,缓存管理能够被开发者所影响的东西就很少。甚至说,基本上是无能为力的。
一些研究者研究过JVM的代码缓存命中,不足70%。所以,实际上在这方面,提升的空间还是很大的。不过短期内应该是看不到VM开放更多选项给应用开发者使用。jdk9已经暴露了JIT的接口,博主没有搞过,也不清楚里面有没有暴露一下JIT代码缓存管理的接口。
我想到现在流行的微服务架构,在代码方面还是有极其鲜明的特征的。能否说通过修改JVM的源码,来提高缓存性能。比如说可以将一些RPC框架的关键节点、QPS极高的服务代码常驻在代码缓存里面。这算是一个猜想。可惜的是国内真的做JVM定制的公司其实没有几个,也就是阿里做得比较好。
JVM选项
这里列出一些和代码缓存相关的JVM选项。我想读了这篇文章前面的内容,应该很容易理解这些JVM选项的意义。
- -XX:InitialCodeCacheSize:设置代码缓存的初始大小,这个参数在intel处理器下,在client编译器模式下是160KB,而在server编译器模式下是2496KB;
- -XX:ReservedCodeCacheSize:设置代码缓存的大小;
- -XX:+UseCodeCacheFlushing:当代码缓存满了的时候,让JVM换出一部分缓存以容纳新编译的代码。在默认情况下,这个选项是关闭的。这意味着,在代码缓存满了的时候,JVM会切换到纯解释器模式,这对于性能来说,可以说是毁灭性的影响;
- -XX:NmethodSweepCheckInterval:设置清理缓存的时间间隔;
- -XX:+DontCompileHugeMethods:默认不对大方法进行JIT编译;
- -XX:HugeMethodLimit: 默认值是8000,遗憾的是,在产品环境下,该值不允许被修改;
从我写了前面两篇到现在已经过去快一个月了,第三篇终于姗姗来迟。这倒不是我要弃坑——虽然这个坑的确很大,而是因为最近真的比较忙。而且写这篇的过程中遇到很多的问题,让我觉得第三篇其实不应该是这篇。我接下来的第四篇要分析一下HotSpot的热点探测技术。按照道理来说,它应该处于本篇前面。