字节码的指令集:https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-6.html#jvms-6.1
例如:
aload_<n>指令,从局部变量表中加载第n个槽的引用到当前方法栈帧的栈顶。
栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,他是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态链接和方法返回地址等信息。每一个方法从运行开始到执行结束,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
在编译程序时,栈帧需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到了方发表的Code属性里,因此一个栈帧需要分配多大的内存,不会受运行时变量数据的影响,仅仅取决于具体的虚拟机的实现。
一个方法的调用链会很长,很多方法都处于执行的状态,对于执行引擎来说,只有位于栈顶的栈帧才是有效的,成为当前栈帧,与这个栈帧关联的方法称为当前方法。执行引擎运行的所有字节码都是操作于当前栈帧的。
局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序编译为Class文件时,就已经在方法的Code属性的max_locals数据项确定了该方法所需要分配的局部变量表的最大容量。
局部变量表的容量是以变量槽slot为最小单位,每个slot都可以存放一个32位以内的数据类型,对于64位的数据类型,虚拟机会以高位对齐的方式为其分配两个连续的alot空间,Java中明确的64位的数据类型只有long和double。
变量表是通过索引定位的方式来使用,索引值的范围是0开始到局部变量表最大的slot数量。如果访问的是32位数据类型,索引n就代表第n个slot;如果访问的是64位数据类型,索引n就代表第n和n+1两个slot。对于存储64位数据类型的连续两个slot,虚拟机是严格禁止单独访问其中一个的。对于64位数据类型的操作,是有特殊指令的,类似于dload,就是从局部变量表加载double类型数据。
在方法执行时,局部变量表的前n个slot是固定的,对于非static方法,第0个位置是this引用,第1到n个slot是方法的入参。对于static方法,没有this引用,第0到n个slot是方法的入参。
并且为了节省栈帧空间,局部变量表是可以重复使用的,因为每个局部变量的作用域不一定是覆盖整个方法,当字节码PC计数器的值已经超过了局部变量的作用域,那这个变量对应的slot位置可以交给其他变量使用。局部变量表的重复使用一方面是节省栈帧空间,一方面是对GC也有一定的影响。我们知道GC时的roots是包括的当前方法的局部变量表的,例如有以下代码:
public static void main(String[] args){
byte[] bs = new byte[1024*1024*64];
System.gc();
}
实际上是不会回收这64Mb空间的,因为在gc时,bs还在作用域内,所以是无法被释放的。
假如改成一下代码:
public static void main(String[] args){
{
byte[] bs = new byte[1024*1024*64];
}
System.gc();
}
在gc时,bs已经不在作用域内了,实际上此时依旧没有被回收,具体的原因是因为,虽然bs已经不在作用域内了,但是局部变量的第0个slot依旧保存着bs的引用,导致gc时,roots是可以直接标记到bs的。
我们再改一下:
public static void main(String[] args){
{
byte[] bs = new byte[1024*1024*64];
}
int i = 0 ;
System.gc();
}
此时就可以回收bs了,这是因为在创建变量i时,i覆盖了局部变量原先第0个slot的bs变量,在gc时,导致bs无法被标记。
所以,在一些代码编程规范里,要求我们在变量使用完后,赋值null,是有一定意义的。但是,从字节码的理论上来看,是有意义的,实际上Java执行远比我们看到的字节码要复杂得多,通过JIT优化之后,即使不加入i=0这行代码,bs也是可以被回收的。
操作数栈是一个栈结构,同局部变量表一样,在编译时就已经确定了操作数栈的最大深度,写入到了Code属性的max_stacks数据项中。在方法开始执行时,操作数栈是空的,在方法执行时,会有各种字节码指令往操作数栈写入或者弹出内容。
方法调用在字节码中有五个指令:
- invokestatic:调用静态方法
- invokespecial:调用构造器、私有方法和父类方法
- invokevirtual:调用虚方法
- invokinterface:调用接口方法,运行时确定实现类
- invokedynamic:在运行时动态解析调用点限定符所引用的方法,然后执行。
我们都知道Java的3个基本特征:继承、封装和多态。多态的表现形式一般是重写和重载。对于重写和重载,在虚拟机中就是静态分派和动态分派来实现的。
静态分派是在编译阶段,根据参数的静态类型决定使用哪个重载版本了,静态分派的典型应用就是重载,对于编译器如何决定使用那个重载方法,是有一套最优必配规则的:
基础类型的匹配规则:如果没有匹配到精确类型的参数,则优先匹配存储长度大于且是最接近实参的存储长度的。
例如:同时有以int和long为入参的方法重载,当实参为short类型时,调用的是int为入参的方法。
引用类型的匹配规则:如果找不到重载方法的形参的引用类型与实参一致,则实参优先匹配在继承树结构上,离实参类型最近的形参。
简单来说就是:如果一个方法可以接受传递给另一个方法的任何参数,那么第一个方法相对不合适。
动态分派典型应用就是重写,invokevirtual指令运行时解析过程如下:
- 找到操作数栈栈顶的第一个元素所指向的对象的实际类型,记作C。
- 如果在C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过,则直接返回该方法的直接引用,解析过程结束。如果不用过,则返回illegalAccessError异常。
- 否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证。
- 如果一直没有找到合适方法,则抛出AbstractMethodError异常。
动态分派的本质就是在进行invokevirtual指令时,会把常量池中方法符号引用解析到了不同的直接引用上。
因为动态分派是非常频繁的操作,为了提高性能,大部分的虚拟机不会频繁的进行搜索,最常见的优化手段是在方法区中建立一个虚方法表,与此对应,在进行invokeinterfcae时,也有对应的接口方法表。
虚方法表存放着各个方法的实际入口地址,如果某个方法在子类没有被重写,那么子类的虚方法表里面的地址入口和父类相同方法的地址入口是完全一样的。如果子类重写了这个方法,子类的方法表中的地址会替换为指向子类实现版本的入口地址。
为了程序实现方便,具有相同签名的方法,在父类和子类的虚方法表中有一样的索引序号。所以任何一个类的虚方法表的前几个索引都是Object中的方法。
当然,除了虚方法表以外还有其他的优化方式,例如方法内联等。
以上是学字节码的必备知识。