在前面的文章中有介绍过 miniJVM 的具体实现,但是 miniJVM 的解释器部分仍然是比较简单的 Switch 指令分发,从执行效率上来说是比较落后的。
同样是轻量级的 JVM,jamvm 在性能方面就要优秀很多,最新版本 2.0.0 支持了 openjdk8,并且支持 java 8 的所有特性,包括 miniJVM 不支持的 Reference。
jamvm 已经非常古老了,Android Framework 在研发阶段还是使用的 jamvm 作为临时的 java 虚拟机,直到 Android 正式发布时才替换成了 DVM
同时 jamvm 的最新版本是实现了一些现代 JVM 的一些关键优化技术的,例如栈顶缓存,direct-threaded,代码拷贝 jit 等。
下面就逐个介绍一下 jamvm 解释器的几种关键优化技术
带注释的 Repo: https://github.com/ganyao114/jamvm-2.0.0
我们知道 Java 原生的字节码是基于栈的,寄存器被转抽象成操作数栈,以此来提供更高的平台兼容性能。 但是这里就产生了性能问题。
还是以 1 + 2 为例
JVM 基于栈解释执行:
常量 1 入栈 ----- 1读1写
常量 2 入栈 ----- 1读1写
1 出栈给临时变量 a ----- 1读1写
2 出栈给临时变量 b ----- 1读1写
a + b -------- 2读
结果 3 入栈 ----- 1写
基于栈发生了 7 次内存读,5 次内存写
如果启用栈顶缓存,那么,栈操作将被替换成解释器方法内部的本地变量的付值操作。而这个本地变量在编译器的优化下在寄存器上分配,那么本来的内存操作就成了寄存器操作。因为寄存器的速度远远大于内存。
那么使用栈顶缓存:
常量 1 给寄存器 a ----- 1 读
常量 2 给寄存器 b ------ 1读
寄存器 a + 寄存器 b
结果 3 入栈 ------ 1 写
优化之后仅有 2 次内存读,1 次内存写,以及寄存器操作
下面就来看一下 JAMVM 栈顶缓存的实现
首先确定栈顶缓存的主题,用于模拟寄存器的本地变量的定义
#ifdef USE_CACHE
union {
struct {
uintptr_t v1;
uintptr_t v2;
} i;
int64_t l;
} cache;
#endif
可以看到在联合体内使用 v1,v2 来模拟寄存器,也就是说可以缓存栈顶两个 int 长度数据,以及一个 long 型数据
除此之外,为了保证编译器能将 v1 v2 分配到真机的寄存器上,解释器的方法没有任何参数,毕竟前4个参数是要占用寄存器的
实际所要执行的方法体等参数都被放在了每个线程独立的 Env 中的栈帧中
//解释执行 Java 方法
//这个方法不带参数想必是不想让参数占用宝贵的寄存器
uintptr_t *executeJava() {
//保存解释器状态的变量定义。这些对于所有解释器变体都是通用的
uintptr_t *arg1;
//PC 指针
register CodePntr pc;
//取出当前方法栈,和其中的操作数栈与本地变量栈
ExecEnv *ee = getExecEnv();
Frame *frame = ee->last_frame;
//本地变量栈指针存在寄存器中,因为这样每次查找本地变量时可以节省一次内存寻址
register uintptr_t *lvars = frame->lvars;
//操作数栈同理
register uintptr_t *ostack = frame->ostack;
//方法所在对象
Object *this = (Object*)lvars[0];
//方法结构体
MethodBlock *new_mb, *mb = frame->mb;
//常量池
ConstantPool *cp = &(CLASS_CB(mb->class)->constant_pool);
...........
}
在开启栈顶缓存之后,每当有入栈指令时,实际会将入栈操作改成给寄存器付值操作
#ifdef USE_CACHE
//本来 PUSH_0 是将常量推入操作数栈
//现在使用栈顶缓存的话,就直接将常量付值给 cache 中的 v1 模拟寄存器
#define PUSH_0(value, ins_len) \
cache.i.v1 = value; \
DISPATCH(1, ins_len);
#else
#define PUSH_0(value, ins_len) { \
uintptr_t tos = value; \
*ostack++ = tos; \
DISPATCH(0, ins_len); \
}
#endif
这里 PUSH_0 指令被替换成给 v1 寄存器付目标值
当遇到需要使用栈内数据的指令时
例如双目运算操作
并且由于只有 v1 v2 两个缓存寄存器,所以这里运算分为 3 个等级:
#define BINARY_OP_0(OP) \
ostack -= 2; \
PUSH_0((int)ostack[0] OP (int)ostack[1], 1);
#define BINARY_OP_1(OP) \
PUSH_0((int)*--ostack OP (int)cache.i.v1, 1);
#define BINARY_OP_2(OP) \
PUSH_0((int)cache.i.v1 OP (int)cache.i.v2, 1);
#define INTDIV(OP, dividend, divisor) \
{ \
int v1 = dividend; int v2 = divisor; \
PUSH_0(v1 OP (INTDIV_OVERFLOW(v1, v2) ? 1 : v2), 1); \
}
JAMVM 执行字节码有 3 种方式
JAMVM 中使用了大量的宏来控制解释器运行模式,在解释器方法头部有个 INTERPRETER_PROLOGUE 的宏,它有三种实现:
#define INTERPRETER_PROLOGUE \
DISPATCH_PROLOGUE \
jam_printf("Unrecognised opcode %d in: %s.%s\n", \
*pc, CLASS_CB(mb->class)->name, mb->name); \
exitVM(1);
//循环分发代码
#define DISPATCH_PROLOGUE \
while(TRUE) { \
switch(*pc) { \
default:
宏展开
while(true) {
switch(*pc) {
label_op_1:
label_op_2:
label_op_3:
label_op_n:
.......
}
}
#define INTERPRETER_PROLOGUE \
unused: \
rewrite_lock: \
DISPATCH_FIRST
#ifdef PREFETCH
#define DISPATCH_FIRST \
{ \
next_handler = pc[1].handler; \
REDISPATCH \
}
#define REDISPATCH \
goto *pc->handler;
#else
#define DISPATCH_FIRST \
REDISPATCH
宏展开
unused:
rewrite_lock:
{
next_handler = pc[1].handler;
goto *pc->handler;
}
#define INTERPRETER_PROLOGUE \
GCC_HACK \
\
rewrite_lock: \
DISPATCH_FIRST \
\
unused: \
throwOOBLabel = NULL; \
throwNullLabel = NULL; \
throwArithmeticExcepLabel = NULL;
#define DISPATCH_FIRST \
goto *pc->handler;
和上面的差不多
所谓 Direct Threaded,就是在解释器方法体内部定义一堆 label,把原来的循环体 Switc Case 跳转换成 goto,这样可以节省 Case 比较的过程
当启用 Direct Threaded 的时候,JAMVM 需要在第一次调用一个方法的时候去遍历这个方法内的每一行指令,这里有以下几个目的
case OPC_LCONST_0: case OPC_DCONST_0: case OPC_DCONST_1:
case OPC_LCONST_1: case OPC_LLOAD_0: case OPC_DLOAD_0:
case OPC_LLOAD_1: case OPC_DLOAD_1: case OPC_LLOAD_2:
case OPC_DLOAD_2: case OPC_LLOAD_3: case OPC_DLOAD_3:
case OPC_LALOAD: case OPC_DALOAD: case OPC_DUP_X1:
case OPC_DUP_X2: case OPC_DUP2: case OPC_DUP2_X1:
case OPC_DUP2_X2: case OPC_SWAP: case OPC_LADD:
case OPC_LSUB: case OPC_LMUL: case OPC_LDIV:
case OPC_LREM: case OPC_LAND: case OPC_LOR:
case OPC_LXOR: case OPC_LSHL: case OPC_LSHR:
case OPC_LUSHR: case OPC_F2L: case OPC_D2L:
case OPC_LNEG: case OPC_I2L:
#ifdef USE_CACHE
//LONG 需要64位寄存器 Cache
cache = 2;
pc += 1;
break;
#endif
可以看到以上所有指令都是要将一个 long 型 或者 doubele 型数据入栈,那么这就需要使用到 2 个寄存器的缓存空间
主要需要修正 opcode 中的操作数,因为一些优化指令的更改会造成指令地址的改变,那么原始操作数中大量存在的偏移就回出错
case OPC_SIPUSH:
operand.i = READ_S2_OP(code + pc);
#ifdef USE_CACHE
if(cache < 2)
cache++;
#endif
pc += 3;
break;
//此类分支跳转的处理是为了修正优化后的 Code 地址错误,因为优化后 Code 位置有所改变,那么原来分支跳转指令的地址将不准确
case OPC_IFEQ: case OPC_IFNULL: case OPC_IFNE:
case OPC_IFNONNULL: case OPC_IFLT: case OPC_IFGE:
case OPC_IFGT: case OPC_IFLE: case OPC_IF_ACMPEQ:
case OPC_IF_ICMPEQ: case OPC_IF_ACMPNE: case OPC_IF_ICMPNE:
case OPC_IF_ICMPLT: case OPC_IF_ICMPGE: case OPC_IF_ICMPGT:
case OPC_IF_ICMPLE:
{
//得到跳转的地址
int dest = pc + READ_S2_OP(code + pc);
#ifdef USE_CACHE
/* Conflict can only occur on first pass, and dest must be backwards */
if(cache_depth[dest] > 0) {
TRACE("CONFLICT in IF target addr: %d\n", dest);
/* Reset depthes calculated from the (backwards) destination and
here. By setting depth at dest to zero (see below) and starting
from dest again, we will immediately get a conflict which will be
resolved by adding a NOP. */
memset(&cache_depth[dest + 1], DEPTH_UNKNOWN, pc - dest);
cache = cache_depth[dest];
ins_count = map[dest] - 1;
pc = dest;
} else {
cache = 0;
#endif
pc += 3;
#ifdef USE_CACHE
}
#endif
if(pass == 1) {
TRACE("IF old dest %d new dest %d\n", dest, map[dest]);
operand.pntr = &new_code[map[dest]];
} else {
#ifdef USE_CACHE
/* Branches re-cache, so the cache depth at destination is zero */
cache_depth[dest] = 0;
#endif
#ifdef INLINING
info[pc] |= FALLTHROUGH;
info[dest] |= TARGET;
#endif
}
break;
}
指令中有一些比较常见的 Case 可以匹配到,并且可以做一些精简或者优化的,因为 java 在编译成字节码的时候并没进行太多的优化,因为 JVM 才是 Java 的核心,JVM 才懂得当前所在的平台环境需要进行何种优化。
那么 JAMVM 匹配到了下面一个可优化的场景,而且是非常非常常见的场景
当在当前类 load 一个本类 Field 的时候
例如:
public class A {
private String a = "ssss";
public void test() {
//这里相当于 b = this.a
String b = a;
}
}
这里相当于 this.a, 方法参数列表第一个一般保存的是 this 指针,那么引用本类变量的字节码应该为:
ALOAD_0 //this 指针入操作栈
GETFIELD //从操作栈顶的对象中 get 指定 Field 的值
那么当 JAMVM 匹配到 ALOAD_0 指令并且下一个指令是 GETFIELD 的时候,就匹配到了一个可优化的 Case
那么优化的逻辑就是直接将这个 Field 解析出来并且把 GETFIELD 指令替换成自己的 JVM 内部伪指令 GETFIELD_THIS,并将 Field 的偏移保存到操作数中
case OPC_ALOAD_0:
{
FieldBlock *fb;
#ifdef USE_CACHE
if(cache < 2)
cache++;
#endif
/* If the next instruction is GETFIELD, this is an instance method
and the field is not 2-slots rewrite it to GETFIELD_THIS. We
can safely resolve the field because as an instance method
the class must be initialised */
//当在当前类 load 一个本类 Field 的时候,那么它的 Code 形式一般是
/**
* ALOAD_0 this 指针入操作栈
* GETFIELD 从操作栈顶的对象中 get 指定 Field 的值
* */
//那么这个就是一个普遍可以优化的指令,这里直接将这个 Field 解析出来并且把 GETFIELD 指令替换成自己的 JVM 内部伪指令 GETFIELD_THIS,并将 Field 的偏移保存到操作数中
if((code[++pc] == OPC_GETFIELD) && !(mb->access_flags & ACC_STATIC)
&& (fb = resolveField(mb->class, READ_U2_OP(code + pc)))
&& !((*fb->type == 'J') || (*fb->type == 'D'))) {
if(*fb->type == 'L' || *fb->type == '[')
opcode = OPC_GETFIELD_THIS_REF;
else
opcode = OPC_GETFIELD_THIS;
operand.i = fb->u.offset;
pc += 3;
} else
opcode = OPC_ILOAD_0;
break;
}
JAMVM 中的 JIT 其实并不是真正的 JIT,这里的 JIT 其实是一种内联操作,确切的说是代码拷贝,将每行字节码指令所对应的处理 label,我们叫 handler 的代码段拷贝连接起来。这样在运行的时候就省去了分发代码的过程,这样的 JIT 其实和完整的 JIT 在效率上还是有些小的差距的。但是好在良好的可移植性,因为拷贝的是现成的代码。。。。
因为这部分代码较为复杂,后面会另起一文分析。