javap - 查看class文件字节码命令
前言:
代码编译的结果从本地机器码转变为字节码,是存储格式发展的一小步,却是编程语言发展的一大步。
所有Java虚拟机的执行引擎都是一致的:输入字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。
1.运行时栈帧(Stack Frame)结构
栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。
栈帧存储了方法的局部变量表,操作数栈,动态连接和方法返回地址等信息。
每个方法从调用开始至执行完成的过程,就对应着一个栈帧在虚拟机栈里从入栈到出栈的过程。
在编译程序代码时,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的Code属性之中,因此一个栈帧分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。
一个线程中的方法调用链可能会很长,很多方法都同时处于执行状态。对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),与这个栈帧相关联的方法称为当前方法(Current Method)。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。
1.1局部变量表
局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量。
容量是一变量槽(Variable Slot)为最小单位,每个Slot都应该能存放一个boolean,byte,char,short,int,float,reference或returnAddress类型的数据。
一个slot可以存放一个32位以内的数据类型。
reference表示对一个对象实例的引用:
一.此引用中直接或间接地查找到对象在Java堆中的数据存放的起始地址索引
二.此引用直接或间接地查找到对象所属数据类型在方法区中的存储的类型信息,否则无法实现Java语言规范中定义的语法约束。
对于64位的数据类型,虚拟机会以高位对齐的方式为其分配两个连续的slot空间。64位数据类型只有long和double,由此可以看出,一次long和double数据类型读写分割为2次32位的读写。但是由于局部变量表建立在线程的堆栈上,是线程私有的数据,无论读写两个连续slot是否为原子操作,都不会引起数据安全问题。
虚拟机通过索引定位的方式使用局部变量表,索引值得范围从0开始至局部变量表最大的slot数量。如果访问的是32位数据类型的变量,索引n就代表使用第n个slot,如果是64位,就会说明同时使用n和n+1两个slot。
在方法执行时,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,如果执行的是实例方法,局部变量表中第0位索引的slot默认是用于传递方法所属对象实例的引用,在方法中可以通过this关键字来访问这个隐藏的参数。其余参数则按照参数表顺序排列,从1开始占用slot,参数表分配完后,再根据方法体内部定义的变量顺序和作用域分配区域的slot。
为了尽可能节省栈帧空间,局部变量表中的slot是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,这个变量对应的slot就可以交给其他变量使用。
但是也会有一些其他副作用,直接影响到系统的垃圾回收。
代码中变量b占用的空间并没有被回收,因为执行gc时,变量b还处于作用域内,自然不会回收。
修改后仍然没有回收
再次修改
变量b能够被回收的根本原因是:局部变量表中的slot是否还存有关于b数组对象的引用。第一次修改显然已经离开了b的作用域,但是之后并没有对局部变量表的读写操作,b所占用的slot还没有被其他变量所复用,所以作为GC Roots一部分的局部变量表仍然保持着对它的关联。
所以一般建议对占用了大量内存,实际上已经不再使用的变量,手动将其设置为null值。
这个方法可以解决那种,对象占用内存大,方法的栈帧又长时间不能被回收的情况。虽然赋null值有效,但也不应该当做普遍的编码规则推广。
注意:
类变量(static修饰的静态变量)会有两次赋值操作
第一次:“准备阶段”,赋予系统初始值
第二次:“初始化阶段”,根据程序定义的值去赋值
即使在初始化阶段没有给类变量赋值也没关系,如:
即使没有赋值,在准备阶段已经给初始化了系统初始值0
但是局部变量就不一样了,会直接报错
即使通过手动生成字节码的方式制造上面代码的效果,字节码校验时也会被虚拟机发现导致类加载失败。
1.2操作数栈(Operand Stack)
常称操作栈,是一个后入先出(Last In First Out)栈。操作数栈的每个元素可以是任意的Java数据类型,包括long和double。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2.同局部变量表一样,操作数栈的最大深度会在编译时写入到Code属性的max_stacks。方法执行的任何时候,最大深度都不会超过这个值。
方法开始执行时,操作数栈是空的,方法执行过程中会有各种字节码指令往操作数栈中写入和提取内容,即出/入栈操作
在概念模型中,两个栈帧作为虚拟机栈的元素,是完全相互独立的,但在大多数虚拟机的实现里会做一些优化处理,让两个栈帧出现一部分重叠。下面部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用时可以共用一部分数据,无需进行额外的参数复制传递。
Java虚拟机的解释执行引擎称为“基于栈的执行引擎“,栈就是操作数栈。
1.3动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用。1.4方法返回地址
当一个方法执行后,只有两种方式可以退出这个方法。
一:遇到方法返回的字节码指令,可能会有返回值传递给上层调用者(调用当前方法的方法)。这种方式称为正常完成出口(Normal Method Invocation Completion)
二:出现异常,异常没有在方法体内处理,无论是Java虚拟机内部产生异常,还是在代码中使用athrow字节码指令产生异常。只要在方法中没有搜索到匹配的异常处理器,就会导致方法退出。称为异常完成出口(Abrupt Method Invocation Completion).异常退出不会给它的上层调用者产生任何返回值。
无论何种方式退出,方法退出后都需要返回到被调用的位置,程序才能继续执行。正常退出时,调用者的PC计数器的值可以作为返回地址。而异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。
方法退出就是将当前栈帧出栈,可能执行的操作有:
- 恢复上层方法的局部变量表和操作数栈
- 把返回值压入调用者栈帧的操作数栈
- 调整PC计数器的值以指向方法调用指令后面的一条指令等
1.5方法调用
不是方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),在程序运行时,方法调用时最普遍,最频繁的操作。解析
所有方法调用中的目标方法在Class文件里面都是一个常量池中的符号引用。在类加载解析阶段,会将其中的一部分符号引用转化为直接引用。前提是:编译器可知,运行期不可变。主要包括静态方法(与类直接关联)和私有方法(外部不可访问)。这两种方法决定了它们不可能通过继承或别的方式重写其它版本因此适合在类加载阶段进行解析。Java虚拟机提供的方法调用指令:
- invokestatic:调用静态方法
- invokespecial:调用实例构造器<init>方法,私有方法和父类方法
- invokevirtual:调用虚方法
- invokeinterface:调用接口方法,在运行时再确定一个实现此接口的对象
- invokedynamic:运行时动态解析
静态方法,私有方法,实例构造器,父类方法。它们在 类加载时就把符号引用解析为该方法的直接引用。这些方法叫做 非虚方法。
final修饰的方法也是非虚方法,虽然由一个invokevirtual指令调用的,由于无法覆盖,所以也是唯一确定的,不可修改的。分派
解释 多态特征的一些基本体现,如"重载"和"重写"。
1.静态分派
Human 称为变量的静态类型(Static Type)
Man 变量的实际类型(Actual Type)
静态类型仅仅在使用时发生变化,变量本身的静态类型不会被改变。最终的静态类型是在编译期可知。
实际类型变化的结果在运行期才可确定,编译期在编译程序的时候并不知道对象的实际类型是什么。
输出:
hello,guy!
hello,guy!
使用哪个重载完全取决于传入参数的数量和数据类型。
man和woman是静态类型相同,实际类型不同的变量。虚拟机(编译器)在重载时是通过参数的静态类型来进行判断的。并且静态类型是编译器可知的。
在编译阶段,javac编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了sayHello(Human)作为调用目标,并将方法符号引用写到两条invokevirtual指令中。
依赖静态类型来定位方法执行版本的分派动作称为静态分派,如重载,静态分派发生在编译阶段,因此确定静态分派实际上不是由虚拟机来执行。
编译器虽然能确定方法的重载版本,但往往也只能确定一个“更加合适的”版本。原因就是字面量不需要定义,所以字面量没有显示的静态类型。
- 输出 hello char
- 如果注释掉char:输出int。因为'a'也代表Unicode数值为97
- 注释int:输出long。'a'转为97后进一步转成长整数97L。
- 注释long: 输出Character。发生自动装箱,'a'被包装为封装类型java.lang.Character
- 注释Character:输出Serializable。java.lang.Serializable是Character类实现的一个接口。Character还实现了java.lang.Comparable<Character>接口。如果重载包含着两种参数,就会编译出错,需要明确指定字面量的静态类型。
- 继续注释掉Serializable:输出object,char装箱后转型为父类了,多个父类,就由下往上搜索。
- 注释object:输出char...。变长参数的重载优先级是最低的,此时字符'a'被当做了一个数组元素
解析与分派是不同层次上的筛选,并不冲突。如,静态方法会在类加载时就解析,同时静态方法也可以有重载,选择重载也是由静态分派完成。
2.动态分派
与重写(Override)有着密切的关联。
public class DynamicDispatch { static abstract class Human { protected abstract void sayHello(); } static class Man extends Human { @Override protected void sayHello() { System.out.println("man"); } } static class Woman extends Human { @Override protected void sayHello() { System.out.println("woman"); } } public static void main(String[] args) { Human man = new Man(); Human woman = new Woman(); man.sayHello(); woman.sayHello(); man = new Woman(); man.sayHello(); } }
0~15字节码是准备动作,作用是建立man和woman的内存空间,调用实例构造器,将这两个实例的引用存放在局部变量表第1,2个slot之中。
16~21是关键部分,16,20分别把创建的两个对象引用压入栈顶。这两个对象是将要执行的sayHello()方法的所有者,称为接收者(Receiver)。17,21是方法调用指令,可以看出,指令都是invokevirtual,参数都是常量池中第22项的常量。但最终执行的目标方法并不同。
invokevirtual指令运行时解析过程:
- 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。
- 如果在类型C中找到与常量中的描述符合,简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.illegalAccessError异常。
- 否则,按照继承关系从下往上依次对C的各个父类进行第二步的搜索和验证过程
- 如果始终没有找到适合的方法,则抛出java.lang.AbstractMethodError异常
由于invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是Java语言中方法重写的本质。这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。3.单分派与多分派
方法的 接收者与 方法的参数统称为方法的 宗量。根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。Java是静态多分派,动态单分派。public class Dispatch { static class QQ { } static class _360 { } public static class Father { public void hardChoice(QQ arg) { System.out.println("father qq"); } public void hardChoice(_360 arg) { System.out.println("father 360"); } } public static class Son extends Father { public void hardChoice(QQ arg) { System.out.println("son qq"); } public void hardChoice(_360 arg) { System.out.println("son 360"); } } public static void main(String[] args) { Father father = new Father(); Son son = new Son(); father.hardChoice(new _360()); son.hardChoice(new QQ()); } }
在编译过程中,选择目标方法的依据有两点:一,静态类型是Father还是Son。二:方法参数是QQ还是360。因为是根据两个宗量选择,所以Java语言的静态分派属于多分派类型。
虚拟机运行阶段的选择,也就是动态分派的过程。在执行son.hardChoice(QQ)时,虚拟机不会关心参数,因为参数类型QQ在编译时已经确定了,唯一可以影响虚拟机选择的只有方法的调用者是Father还是Son。因为只有一个宗量,所以Java语言的动态分派属于单分派类型。
动态类型语言支持
动态类型语言
动态类型语言的关键特征是它的 类型检查的主体过程是在运行期而不是编译期,满足这个特征的语言有很多,如:JavaScript,PHP,Python,Ruby等。在编译期就进行类型检查过程的语言如:Java,C++就是最常用的静态类型语言
编译正常,但是在运行时会抛出异常。运行时异常,通俗说就是只要代码不运行到这一行,就不会有问题。与运行时异常对应的是连接时异常,如NoClassDefFoundError。即使会导致连接时异常的代码放在一条无法执行到的分支路径上,类加载(Java的连接过程不在编译阶段,而在类加载阶段)时也会抛出异常。变量无类型而变量值才有类型,也是动态类型语言的一个重要特征。getPringlnMH()方法中模拟了invokevirtual指令的执行过程,只不过他的分派逻辑并非固化在Class文件的字节码上,而是通过一个具体方法来实现。这个方法本身的返回值(MethodHandler对象),可以视为对最终调用方法的一个"引用"。以此为基础,就可以写出类似下面这样的函数声明:public class MethodHandleTest { static class ClassA { public void println(String s) { System.out.println(s); } } public static void main(String[] args) throws Throwable { Object obj = System.currentTimeMillis() % 2 == 0 ? System.out : new ClassA(); getPringlnMH(obj).invokeExact("zhangsan"); } private static MethodHandle getPringlnMH(Object reveiver) throws NoSuchMethodException, IllegalAccessException { // MethodType:代表"方法類型",包含了方法的返回值(第一个参数)和具体参数(第二个及以后的参数) MethodType mt = MethodType.methodType(void.class, String.class); // lookup(),在指定类中找到符合给定的 方法名称,方法类型,并且符合调用权限的方法句柄 // 同时通过bindTo()方法来指定方法调用的接收者(即方法调用者) return MethodHandles.lookup().findVirtual(reveiver.getClass(), "println", mt).bindTo(reveiver); } }
void sort(List list,MethodHandle compare)MethodHandle的使用方法和效果与Refection有众多相似之处。不过还是有一下区别:
- 从本质上讲,Reflection和MethodHandle机制都是在模拟方法调用,但Reflection是在模拟Java代码层次的方法调用,而MethodHandle是在模拟字节码层次的方法调用。在MethodHandles.lookup中的3个方法 -- findStatic(),findValue(),findSpecial()正是为了对应invokestatic,invokevirtual&invokeinterface和invokespecial这几条字节码指令的执行权限校验行为,而这些底层细节在使用Reflection API时是不需要关心的
- Reflection中的java.lang.reflect.Method对象远比java.lang.invoke.MethodHandle对象包含的信息多。前者是方法在JAVA端的全面映象,包含了方法的签名,描述符以及方法属性表中各种属性的JAVA端表示方式,还包含执行权限等运行期信息。而后者仅仅包含与执行该方法相关的信息。Reflection是重量级,而MethodHandle是轻量级
- 由于MethodHandle是对字节码的方法指令调用的模拟,所以理论上虚拟机在这方变做的各种优化(如方法内联),在MethodHandle上也应当可以采用类似思路去支持。而通过反射去调用方法则不行。
方法内联:
在编译时用引用函数的函数体直接替换该引用,可以节省方法在调用时,根据引用去寻找目标方法的查询时间。适用于函数体小,调用频繁的一些函数。由于直接用函数体替换的引用,所以代码在编译后占用的空间会增加,也就是以空间换取时间。上述代码在JVM运行一段时间后, 会被替换成:public int sum4(int param1, int param2, int param3, int param4) { return sum2(param1, param2) + sum2(param3, param4); } public int sum2(int param1, int param2) { return param1 + param2; }
public int sum4(int param1, int param2, int param3, int param4) { return param1 + param2 + param3 + param4; }