JVM:字节码&指令集详解

963 阅读4分钟

项目地址:github.com/zexho994/gv…

JVM指令集

在jvm中,定义了有205条指令,从0x00 -> 0xCA , 0xFE -> 0xFF。我们在方法中写的所有逻辑,最终都会转换成这些指令,而虚拟机的最重要的一个任务就是解释执行这些指令。

指令类型种类很多

比较常见的类型有:

  • load : 数据从局部变量表 -> 操作数栈中
  • store : 数据从操作数栈 -> 局部变量表中
  • references : 方法调用、类型强转、monitor锁等
  • math :运算操作相关
  • comparisons : 逻辑操作,if,while,for,goto等

指令格式

指令都是由两部分组成,操作码操作数。

  • 操作码: 用于表示是什么指令. 0x1a , 0x45 ,0x57 是操作码
  • 操作数: 有些指令需要操作局部变量表和操作数栈,需要知道索引的位置.
//匹配指令
switch opcode {
    ...
    case 0x1a:
		return iload_0
    ...
	case 0x45:
		return fstore_2
    ...
    case 0x57:
		return pop
}

超级指令

解释的速度通常比较慢,原因之一是解释器在判断指令类型的时候涉及到分支,cpu层面分支预测失败和指令缓存未命中的开销都很大。

解决办法很容易想到,把两个或者多个代码合并成一个代码,在一趟处理中可以直接获取然后执行指令,这样就减少了分支的次数,这些指令便称为超级指令、快速指令、虚拟指令。

...
case 0x59:
	return dup
...
case 0x60:
	return iadd
...
case 0x84:
	return iinc
...

例如

  • dup: 指令表示将操作数栈顶的元素复制一份。
  • iinc: 表示局部变量表+1,省略了经过操作数栈+1,然后再保存的过程。

指令流获取

在类加载过程-加载步骤。在字节码文件中,指令的数据保存在在methods的的属性表的code字段中。在类加载阶段,读取字节码文件时候会生成一份等价的对象,对象的构建就是从头到尾的把所有数据都进行读取,然后解析。每个不同组件都有不同的方法读取。

// 读取length长度的数据
func parseMethod(count uint16, reader *loader.ClassReader, pool *constant_pool.ConstantPool, k *Klass) Methods {
	methods := make([]*MethodKlass, count)
	for i := range methods {
		method := &MethodKlass{}
		//设置常量池
		method.ConstantPool = pool
		//设置访问标记
		method.accessFlag = reader.ReadUint16()
		//name常量池索引
		method.nameIdx = reader.ReadUint16()
		//描述符常量池索引
		method.descriptorIdx = reader.ReadUint16()
		//属性表数量
		method.attrCount = reader.ReadUint16()
		// 解析方法表中的属性表字段
		method.AttributesInfo = attribute.ParseAttributes(method.attrCount, reader, pool)
		// 绑定klass&method
		method.Klass = k
		// 本地方法注入字节码
		method.InjectCodeAttrIfNative()
	}
	return methods
}

func (c *AttrCode) parse(reader *loader.ClassReader) {
	c.MaxStack = reader.ReadUint16()
	c.MaxLocals = reader.ReadUint16()
	//code长度
	c.codeLen = reader.ReadUint32()
	//读取len长度的字节
	c.code = reader.ReadBytes(c.codeLen)
	c.ExceptionTable = parseExceptionTable(reader)
	c.attrCount = reader.ReadUint16()
	c.AttributesInfo = ParseAttributes(c.attrCount, reader, c.ConstantPool)
}

指令的解释过程

指令的执行是由解释器负责的,解析器的流程简单说就是:

graph LR
n1(读取操作码) --> n2(根据操作码创建指令) --> n3(根据指令确定操作数) --> n4(执行指令)

指令解释就是将这三步重复循环,其中要注意的点是栈帧中的pc和整体pc寄存器的值要保持一致。

//循环解释
func loop(thread *runtime.Thread) {
    methodReader := &base.MethodCodeReader{}
        for {
        // 更新pc寄存器
        updatePC(thread)
        // 更新方法code
        updateCodeArrt(thread, methodReader)
        //执行指令
        execInst(thread, methodReader)
        if finished(thread) {
                break
        }
    }
}

//执行
func execInst(thread *runtime.Thread, reader *base.MethodCodeReader) {
    curFrame := thread.PeekFrame()
    //获取操作码
    opcode := reader.ReadOpenCdoe()
    //创建指令
    inst := instructions.NewInstruction(opcode)
    //获取操作数
    inst.FetchOperands(reader)
    curFrame.SetFramePC(reader.MethodReaderPC())
    //执行指令
    inst.Execute(curFrame)
}

指令解释执行

获取到指令后,inst.Execute(curFrame) 执行指令。每条指令都有各自的执行逻辑

每条指令都有不同的执行逻辑。

func iload_0(frame *runtime.Frame) {
	val := frame.LocalVars().GetInt(0)
	frame.OperandStack().PushInt(val)
}

func iload_1(frame *runtime.Frame) {
	val := frame.LocalVars().GetInt(1)
	frame.OperandStack().PushInt(val)
}

i++ & ++i

int i = 1;
print(i++);  
print(++i);  

编译后字节码两者是不同的,原因与java编译器的遍历AST(javac采用逆波兰表达算法)有关。

func (i *IINC) Execute(frame *runtime.Frame) {
	idx := i.Index >> 8         //局部变量表下标
	toAdd := i.Index & 0x0011   //值
	old := frame.LocalVars().GetInt(uint(idx)) 
	frame.LocalVars().SetInt(uint(idx), int32(toAdd)+old) //设置新的值
}

i++ :先把i保存到了操作数栈,然后再对局部变量表的i+1。

++i :先对局部变量表的i+1,然后再把i保存到了操作数栈。

if..else..关键字

public static void if_test() {
    int n = 10;
    GvmOut.to("testing if");
    GvmOut.to("10 > 9 ?");
    if (n > 9) {
        GvmOut.to("yes");
    } else {
        GvmOut.to("no");
    }
}

for..关键字

public static void for_test() {
    GvmOut.to("testing for");
    for (int i = 0; i < 5; i++) {
        GvmOut.to(i);
	}
}
    

// Execute to branch if and only if val1 great or equals val2
func (icmp *IfIcmpge) Execute(frame *runtime.Frame) {
	val2 := frame.PopInt()      //弹出栈顶的int
	val1 := frame.PopInt()      //弹出栈顶的int
	goNext := icmp.Index        //获取挑战地址
	if val1 >= val2 {           //比较
		base.Branch(frame, int(goNext))  
	}

while

public static void while_test() {
    GvmOut.to("testing while");
    int x = 100;
    while (x < 105) {
        x++;
        GvmOut.to(x);
    }
}

泛型

Java里泛型,与c#比起来,更算是一种语法糖。关于语法糖,是编译时的一种“小把戏”,目的是让使用者可以有更多的手段是编写代码。

好处是效率,了解Rust的可以感受到,编写Rust的代码极其高效的,代价就是学习成本非常高,原因之一就是大量的语法糖。

在java 中:

public void foo(Person<String> p){}

public void foo(Person<Long> p){}

泛型擦除-字节码分析

public void foo(Person<String> p){}  ==> public void foo(Person<Object> p){}

public void foo(Person<Long> p){} ==> public void foo(Person<Object> p){}

下面这个类

public class Person<T>{

    T name;

    public static void main(String[] args) {
        Person<String> p = new Person<String>();
        p.name = "张三";
        GvmOut.to(p.name);
    }

    public void foo(T t){}
}

字段的签名和方法的签名都是Object对象,那为什么还需要范型,直接用Object声明不就好了吗。答案是约束,使用泛型可以保证类型符合要求。

在字节码指令中,会添加checkcast命令去检查类型是否匹配。

泛型上下边界-字节码分析

泛型常见的场景之一是使用extends,可以约束参数的范围。

public class Son<T extends Person>{

    T father;

    public static void main(String[] args) {
        Son<Person> son = new Son<>();
        son.father = new Person();
    }

    public T name(T t){
        return t;
    }
}

本质上,编译后,泛型依然被擦除了,但是不再是Object类,而是Person类。

为什么选择这种方式实现范型

向下兼容。向下兼容。向下兼容。

参考:

-《虚拟机规范(SE8)》
-《自己动手些虚拟机》