当前位置: 首页 > 工具软件 > JamVM > 使用案例 >

JVM 解释器优化(JAMVM)

池俊茂
2023-12-01

简介

在前面的文章中有介绍过 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 栈顶缓存

下面就来看一下 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 个等级:

  • 等级1: 不使用栈顶缓存,则栈中 1,2 操作数相加
  • 等级2:使用1个栈顶缓存,则 v1 虚拟寄存器与栈中另一个操作数相加
  • 等级3:两个操作数都使用栈顶缓存,则是 v1 和 v2 相加
#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);   \
}

Direct Threaded

JAMVM 执行字节码有 3 种方式

  • 和 miniJVM 一样的 Switch 解释器
  • 这次要讲的 Direct Threaded,也就是使用 goto 跳转到下一个指令
  • 执行 Copy JIT 之后的代码

JAMVM 中使用了大量的宏来控制解释器运行模式,在解释器方法头部有个 INTERPRETER_PROLOGUE 的宏,它有三种实现:

  • 仅仅使用 Switch 解释器时:
#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:
          .......
     }     
 }
  • 使用 Direct Threaded 但不使用 JIT 时
#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;
 }
  • 当使用 Copy JIT 时
#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 执行的优化和准备工作

当启用 Direct Threaded 的时候,JAMVM 需要在第一次调用一个方法的时候去遍历这个方法内的每一行指令,这里有以下几个目的

  • 为栈顶缓存收集每个指令需要匹配的优化等级,上文已经解释过了一共有 3 个等级
  • 为后面的 JIT 收集必要数据
  • 修改指令,将原始字节码中的一些指令替换为 JAMVM 中自定义的伪指令,用于适应 goto 跳转执行的环境,以及 JIT 后的跳转。
  • 匹配一些可以优化的代码 Case,并且替换为优化后的指令

准备栈顶缓存等级

		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;
                }

JIT

JAMVM 中的 JIT 其实并不是真正的 JIT,这里的 JIT 其实是一种内联操作,确切的说是代码拷贝,将每行字节码指令所对应的处理 label,我们叫 handler 的代码段拷贝连接起来。这样在运行的时候就省去了分发代码的过程,这样的 JIT 其实和完整的 JIT 在效率上还是有些小的差距的。但是好在良好的可移植性,因为拷贝的是现成的代码。。。。

因为这部分代码较为复杂,后面会另起一文分析。

 类似资料: