JVM(6) 字节码执行引擎
编译器(javac)将Java源文件(.java文件)编译成Java字节码(.class文件)。
类加载器负责加载编译后的字节码,并加载到运行时数据区(Runtime Data Area)
通过类加载器加载的,被分配到JVM运行时数据库的字节码会被执行引擎执行。
执行引擎以指令为单位读取Java字节码。就像CPU一样,一条一条地执行机器指令。每个字节码指令都由一个1字节的操作码和附加的操作数组成。执行引擎取得一个操作码,然后根据操作数来执行任务,完成后就继续执行下一条操作码。
Java字节码是用一种人类可以读懂的语言编写的,而不是机器可以直接执行的语言。因此,执行引擎必须把字节码转换成可以直接被JVM执行的语言。通过两种方式:
- 解释器执行:一条一条地读取,解释并且执行字节码指令。因为它一条一条地解释和执行指令,所以它可以很快地解释字节码,但是执行起来会比较慢。
- 即使(Just-In-Time)编译器执行:执行引擎首先按照解释执行的方式来执行,然后在合适的时候,即使编译器把整段字节码编译成本地代码。然后,执行引擎就没有必要再去解释执行方法了,它可以通过本地代码去执行它。执行本地代码比一条一条解释执行的速度快很多。编译后的代码可以执行得很快,因为本地代码是保存在缓存里的。
“虚拟机”是一个相对于“物理机”的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、硬件、指令集和操作系统层面上,而虚拟机的执行引擎则是由自己实现的,因此可以自行制定指令集与执行引擎的结构体系,并且能够执行哪些不被硬件直接支持的指令集格式。
在不同的虚拟机实现里面,执行引擎在执行Java代码的时候可能会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备,甚至还可能会包含几个不同级别的编译器执行引擎。
但是从外观上看,所有的Java虚拟机的执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。
一、运行时栈帧结构
栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址和一些额外的附加信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的 Code 属性之中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。
一个线程中的方法调用链可能会很长,很多方法都同时处于执行状态。对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。
1.局部变量表
局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。
局部变量表的容量以变量槽(Variable Slot)为最小单位。一个Slot可以存放一个32位以内的数据类型,对于64位的数据类型,虚拟机会以高位对齐的方式为其分配两个连续的Slot空间。例如:Java 中占用 32 位以内的数据类型有 boolean、byte、char、short、int、float、reference 和 returnAddress 8 种类型。Java 语言中reference 类型可能是 32 位也可能是 64 位,而64 位的数据类型只有 long 和 double 两种,因此把 long 和 double 数据类型分割存储到两个连续的Slot中,由于局部变量建立在线程的堆栈上,是线程私有的数据,无论读写两个连续的 Slot 是否为原子操作,都不会引起数据安全问题。
虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从 0 开始至局部变量表最大的 Slot 数量。如果访问的是 32 位数据类型的变量,索引 n 就代表了使用第 n 个 Slot,如果是 64 位数据类型的变量,则说明会同时使用 n 和 n+1 两个 Slot。对于两个相邻的共同存放一个 64 位数据的两个 Slot,不允许采用任何方式单独访问其中的某一个。
在方法执行时,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,如果执行的是实例方法(非 static 的方法),那局部变量表中第 0 位索引的 Slot 默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字 “this” 来访问到这个隐含的参数。其余参数则按照参数表顺序排列,占用从 1 开始的局部变量 Slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的 Slot。
为了尽可能节省栈帧空间,局部变量中的 Slot 是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码 PC 计数器的值已经超出了某个变量的作用域,那这个变量对应的 Slot 就可以交给其他变量使用。不过,Slot重用除了节省栈帧空间以外,还会直接影响到系统的垃圾收集行为。
例1:在执行System.gc();时,placeholder变量还处在作用域之内,因此虚拟机自然不会回收placeholder变量。
public static void main(String[] args) { byte[] placeholder = new byte[64 * 1024 * 1024];
System.gc();
}
[GC (System.gc()) 68157K->66320K(251392K), 0.0008468 secs]
[Full GC (System.gc()) 66320K->66163K(251392K), 0.0050420 secs]
例2:限制了placeholder变量的作用域之后,在执行System.gc();时,placeholder变量已经不可能再被访问了,但还是没有执行垃圾收集。这是因为局部变量表中的Slot还存在有关于placeholder变量的引用。虽然离开了placeholder的作用域,但在此之后,没有任何对局部变量表的读写操作,placeholder原本所占用的Slot还没有被其他变量所复用,所以作为GC Roots一部分的局部变量表仍然保持着对它的关联。
public static void main(String[] args) {
{
byte[] placeholder = new byte[64 * 1024 * 1024];
}
System.gc();
}
例3:通过int a = 0;语句对placeholder变量对应的局部变量表Slot进行重用,因此可以正确地进行垃圾回收过程。
public static void main(String[] args) {
{
byte[] placeholder = new byte[64 * 1024 * 1024];
}
int a = 0;
System.gc();
}
[GC (System.gc()) 68157K->66256K(251392K), 0.0007356 secs]
[Full GC (System.gc()) 66256K->627K(251392K), 0.0043869 secs]
例4:通过placeholder = null;将placeholder变量对应的局部变量表Slot清空,同样也可以正确地进行垃圾回收过程。
public static void main(String[] args) {
{
byte[] placeholder = new byte[64 * 1024 * 1024];
placeholder = null;
}
System.gc();
}
[GC (System.gc()) 68157K->66240K(251392K), 0.0017206 secs]
[Full GC (System.gc()) 66240K->627K(251392K), 0.0052135 secs
局部变量不像前面介绍的类变量那样存在 “准备阶段”。类变量有两次赋初始值的过程,一次在准备阶段,赋予系统初始化;另外一次在初始化阶段,赋予程序员定义的初始值。因此,即使在初始化阶段程序没有为类变量赋值也没有关系,类变量仍然具有一个确定的初始值。
public class SlotTest { static int a; public static void main(String[] args) {
System.out.println(a);
}
}
打印:0
但局部变量就不一样,如果一个局部变量定义了但没有赋初始值是不能使用的。
public class SlotTest { public static void main(String[] args) {
int a;
System.out.println(a);
}
}
打印:
Exception in thread "main" java.lang.Error: Unresolved compilation problem:
The local variable a may not have been initialized
2.操作数栈
操作数栈也常称为操作栈,它是一个后入先出(Last In First Out,LIFO)栈。同局部变量表一样,操作数栈的最大深度也在编译的时候写入到 Code 属性的 max_stacks (局部变量表的属性是max_locals)数据项中。在方法执行的任何时候,操作数栈的深度都不会超过在 max_stacks 数据项中设定的最大值。
操作数栈的每一个元素可以是任意的 Java 数据类型,包括 long 和 double。32 位数据类型所占的栈容量为 1,64 位数据类型所占的栈容量为 2。
当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈 / 入栈操作。
例如:整数加法的字节码指令iadd在运行的时候,操作数栈中最接近栈顶的两个元素已经存入了两个int类型的数值,当执行这个指令时,会将这两个int值出栈并相加,然后将相加的结果入栈。
操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译器要严格保证这一点,在类校验阶段的数据流分析中还要再次验证这一点。再以上面的 iadd 指令为例,这个指令用于整型数加法,它执行时,最接近栈顶的两个元素的数据类型必须为 int 型,不能出现一个 long 和一个 float 使用 iadd 命令相加的情况。
在概念模型中,两个栈帧作为虚拟机栈的元素,是完全相互独立的。但在大多虚拟机的实现里都会做一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用时就可以共用一部分数据,无须进行额外的参数复制传递。
3.动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。 Class 文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化成为静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分成为动态连接。
4.方法返回地址
当一个方法开始执行后,只有两种方式可以退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口。另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是 Java 虚拟机内部产生的异常,还是代码中使用 athrow 字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。
无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的 PC 计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。
方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整 PC 计数器的值以指向方法调用指令后面的一条指令等。
5.附加信息
虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试相关的信息,这部分信息完全取决于具体的虚拟机实现。
二、方法调用
方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。
在程序运行时,进行方法调用是最普遍、最频繁的操作,但是Class 文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在 Class 文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(相当于之前说的直接引用)。这个特性给 Java 带来了更强大的动态扩展能力,但也使得 Java 方法调用过程变得相对复杂起来,需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。
1.解析(Resolution)(invokestatic)
所有方法调用中的目标方法在 Class 文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。换句话说,调用目标在程序代码写好、编译器进行编译时就必须确定下来。这类方法的调用称为解析。
在 Java 语言中符合 “编译期可知,运行期不可变” 这个要求的方法,主要包括静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写其他版本,因此它们都适合在类加载阶段进行解析。
在Java虚拟机里提供了5条方法调用字节码指令:
- invokestatic:调用静态方法。
- invokespecial:调用实例构造器<init>方法、私有方法和父类方法。
- invokevirtual:调用所有的虚方法,虚方法就是在继承时,类对象实际调用的方法是子类重写的方法;也就是编译器和jvm调用的不是同一个类的方法。
- invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。
- invokedynamic:现在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。
只要能被 invokestatic 和 invokespecial 指令调用的方法,都可以在解析阶段确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法 4 类,它们在类加载的时候就会把符号引用解析为该方法的直接引用。这些方法可以称为非虚方法,与之相反,其他方法称为虚方法(除去 final 方法)。
Java 中的非虚方法除了使用 invokestatic、invokespecial 调用的方法之外还有一种,就是被 final 修饰的方法。虽然 final 方法是使用 invokevirtual 指令来调用的,但是由于它无法被覆盖,没有其他版本,所以也无须对方法接收者进行多态选择,又或者说多态选择的结果肯定是唯一的。
例如:main方法调用静态方法。
public class StaticResolution { public static void sayHello() {
System.out.println("hello, world");
} public static void main(String[] args) {
StaticResolution.sayHello();
}
}
2.分派(Dispach)(invokestatic + invokevirtual)
Java 是一门面向对象的程序语言,因为 Java 具备面向对象的 3 个基本特征:继承、封装和多态。
分派调用过程和多态性特征的一些最基本的体现密切相关,如 “重载” 和 “重写” 在 Java 虚拟机之中的实现。
解析调用一定是个静态的过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。
分配(Dispatch)调用则可能是静态的也可能是动态的,根据分派依据的宗量数可分为单分派和多分派。
(1)静态分派(和“重载(OverLoad)”有关)
示例1:由于Human类是static类,子子类Man类和Woman类都是static类,所以不管三个sayHello方法是静态方法还是实例方法,只要发生了重载,就需要用到静态分派。即在编译期间就确定下来具体调用的是哪一个方法。
package testClass; public class StaticDispatch { static abstract class Human{ } static class Man extends Human{ } static class Woman extends Human{ } public void sayHello(Human guy) {
System.out.println("hello, guy!");
} public void sayHello(Man guy) {
System.out.println("hello, man");
} public void sayHello(Woman guy) {
System.out.println("hello, woman");
} public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
StaticDispatch sr = new StaticDispatch();
sr.sayHello(man);
sr.sayHello(woman);
}
}
输出:
hello,guy!
hello,guy!
静态分派演示
从编译出来的Class文件看重载过程:第16行开始new出一个sr对象,然后第26行和第31行使用invokevirtual指令调用的是参数类型为Human的那个参数。
16: new #11 // class testClass/StaticDispatch
19: dup
20: invokespecial #12 // Method "<init>":()V
23: astore_3
24: aload_3
25: aload_1
26: invokevirtual #13 // Method sayHello:(LtestClass/StaticDispatch$Human;)V
29: aload_3
30: aload_2
31: invokevirtual #13 // Method sayHello:(LtestClass/StaticDispatch$Human;)V
34: return
这是因为:
Human man = new Man();
"Human"称为变量的静态类型(Static Type),"Man"被称为变量的实际类型(Actual Type),静态类型和实际类型在程序中都可以发生变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译器可知的;而实际类型变化的结果在运行器才可以确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。
也就是说,由于静态分派调用过程要求对象的实际类型必须在编译期间就完全确定,
如果是sr.sayHello(man);,那么man这个对象就可能在运行期间发生实际类型的变化,因此sr对象选择参数类型为“Human”的sayHello(Human guy)方法
如果是sr.sayHello((Man)man);,那么也就在编译器间明确了man这个对象在运行期间不会发生类型的变化,这时sr对象就已经确定了选择sayHello(Man guy)方法
main() 里面的两次 sayHello() 方法调用,在方法接收者已经确定是对象 “sr” 的前提下,使用哪个重载版本,就完全取决于传入参数的数量和数据类型。代码中刻意地定义了两个静态类型相同但实际类型不同的变量,但虚拟机(准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。
静态类型是编译期可知的,因此,在编译阶段,javac 编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了 sayHello(Human) 作为调用目标,并把这个方法的符号引用写到 main() 方法里的两条 invokevirtual 指令的参数中。
示例2:
所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。
静态分派的典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。另外,编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是 “唯一的”,往往只能确定一个 “更加适合的” 版本。产生这种模糊结论的主要原因是字面量不需要定义,所以字面量没有显式的静态类型,它的静态类型只能通过语言上的规则去理解和推断。
package testClass; import java.io.Serializable; public class Overload { // 全部不注释,输出:hello, char public static void sayHello(char arg) { // 1.注释掉,输出:hello, int
System.out.println("hello, char");
} public static void sayHello(int arg) { // 2.注释掉,输出:hello, long
System.out.println("hello, int");
} public static void sayHello(long arg) { // 3.注释掉,输出:Character
System.out.println("hello, long");
} public static void sayHello(Character arg) { // 4,注释掉,输出:Serializable
System.out.println("hello, Character");
} public static void sayHello(Serializable arg) { // 5.注释掉,输出:hello, Object
System.out.println("hello, Serializable");
} public static void sayHello(Object arg) { // 6.注释掉,输出:hello, char...
System.out.println("hello, Object");
} public static void sayHello(char... arg) { // 7.不能注释掉
System.out.println("hello, char...");
} public static void main(String[] args) {
sayHello('a');
}
}
- 'a' 是一个 char 类型的数据,自然会寻找参数类型为 char 的重载方法
- 发生了一次自动类型转换,'a' 除了可以代表一个字符串,还可以代表数字 97(字符 'a' 的 Unicode 数值为十进制数字 97),因此参数类型为 int 的重载也是合适的。
- 发生了两次自动类型转换,'a' 转型为整型 97 之后,进一步转型为长整数 97L,匹配了参数类型为 long 的重载。
- 发生了一次自动装箱,'a' 被包装为它的封装类型 java.lang.Character,所以匹配到了参数类型为 Character 的重载,
- 出现 hello Serializable,是因为 java.lang.Serializable 是 java.lang.Character 类实现的一个接口,当自动装箱之后发现还是找不到装箱类,但是找到了装箱类实现了的接口类型,所以紧接着又发生一次自动转型。char 可以转型成 int,但是 Character 是绝对不会转型为 Integer 的,它只能安全地转型为它实现的接口或父类。Character 还实现了另外一个接口 java.lang.Comparable<Character>,如果同时出现两个参数分别为 Serializable 和 Comparable<Character> 的重载方法,那它们在此时的优先级是一样的。编译器无法确定要自动转型为哪种类型,会提示类型模糊,拒绝编译。程序必须在调用时显式地指定字面量的静态类型,如:sayHello((Comparable<Character>'a'),才能编译通过。
- char 装箱后转型为父类了,如果有多个父类,那将在继承关系中从下往上开始搜索,越接近上层的优先级越低。
- 可见变长参数的重载优先级是最低的,这时候字符 'a' 被当做了一个数组元素。
解析与分派这两者之间的关系并不是二选一的排他关系,它们是不同层次上去筛选、确定目标方法的过程。静态方法会在类加载期就进行解析,而静态方法显然也是可以拥有重载版本的,选择重载版本的过程也是通过静态分派完成的。例如,上面的例子中的方法都是静态的,它们在类加载器就解析完成,而由于需要重载,因此还需要静态分派。
(2)动态分派(和“重写(Override)”有关)
package testClass; public class DynamicDispatch { static abstract class Human{
protected abstract void sayHello();
} static class Man extends Human{
@Override
protected void sayHello() {
System.out.println("hello, man");
}
} static class Woman extends Human{
@Override
protected void sayHello() {
System.out.println("hello, woman");
}
} public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
man.sayHello();
woman.sayHello();
man = new Woman();
man.sayHello();
}
}
动态分配演示
虚拟机是如何知道要调用哪个方法的呢,显然这里不可能再根据静态类型决定,因为静态类型同样都是Human的两个变量man和woman在调用sayHello()方法时执行了不同的行为,并且变量man在两次调用中执行了不同的方法。导致这个现象的原因很明显,是这两个变量的实际类型不同。
Main方法的字节码描述:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: new #2 // class testClass/DynamicDispatch$Man
3: dup
4: invokespecial #3 // Method testClass/DynamicDispatch$Man."<init>":()V
7: astore_1
8: new #4 // class testClass/DynamicDispatch$Woman
11: dup
12: invokespecial #5 // Method testClass/DynamicDispatch$Woman."<init>":()V
15: astore_2
16: aload_1
17: invokevirtual #6 // Method testClass/DynamicDispatch$Human.sayHello:()V
20: aload_2
21: invokevirtual #6 // Method testClass/DynamicDispatch$Human.sayHello:()V 24: new #4 // class testClass/DynamicDispatch$Woman
27: dup
28: invokespecial #5 // Method testClass/DynamicDispatch$Woman."<init>":()V
31: astore_1
32: aload_1
33: invokevirtual #6 // Method testClass/DynamicDispatch$Human.sayHello:()V 36: return
分析一下Main方法的字节码:
- 6-9:(new)创建一个Man对象,并将Man对象的引用值压入栈顶。dup(复制栈顶数值并将复制值压入栈顶)。(invokespecial)调用Man类的构造方法。(astore_1)将栈顶引用性数值存入到第二个本地变量。
- 10-13:(new)创建一个Woman对象,并将Man对象的引用值压入栈顶。dup(复制栈顶数值并将复制值压入栈顶)。(invokespecial)调用Man类的构造方法。(astore_2)将栈顶引用性数值存入到第三个本地变量。
- 14-17:(aload_1)将第二个引用类型的本地变量推送至栈顶。(invokevirtual)调用Human类的sayHello()方法。(aload_2)将第三个引用类型的本地变量推送至栈顶。(invokevirtual)调用Human类的sayHello()方法。
- 18-21:同理。
- 22-23:同理。
- 24:从当前方法返回void
从上面的字节码可以看出,14行的aload_1是将创建的man对象的引用压到栈顶,16行的aload_2是将创建的woman对象压到栈顶,这两个对象是将要执行的sayHello()方法的所有者,称为接收者(Receiver);而15行和17行是方法调用指令,这两条调用指令单从字节码角度来看,无论是指令还是参数,都是“invokevirtual #6”,也就是说,从字节码上来看,两条方法调用指令都应该执行的是DynamicDispatch$Human.sayHello:()V,也就是Human类的sayHello()方法,这是因为Human man = new Man(); Human woman = new Woman();这两条语句决定的,编译器在编译期间执行静态分派的过程中,由于man对象的woman对象的静态类型都是Human,所以说字节码上体现出来静态分配的结果也就是两个方法调用指令的参数都指向同一个常量池中的符号引用。但是最终执行的目标方法却是不一样的,原因就在于invokevirtual指令的多态查找。
invokevirtual指令的运行时解析过程大致分为以下几个步骤(以14-17为例):
- 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。(即man对象的实际类型是Man类型,woman对象的实际类型是Woman类型)
- 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError异常(在Man类中找到了sayHello()方法,在Woman类中也找到了sayHello()方法,返回两个方法的直接引用)
- 否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。
- 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常
由于 invokevirtual 指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是Java 语言中方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
(3)单分派与多分派(静态多分派、动态单分派)
方法的接收者与方法的参数统称为方法的宗量,根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。
单分派是根据一个宗量对目标方法进行选中,多分派则根据多于一个宗量(两个)对目标方法进行选择。
例如:下面的例子给定了两个方法的接收者和两个方法的参数,分析单分派和多分派
package testClass; public class Dispatch { static class QQ{};
static class _360{}; public static class Father{
public void hardChoice(QQ arg) {
System.out.println("father choose qq");
}
public void hardChoice(_360 arg) {
System.out.println("father choose 360");
}
} public static class Son extends Father{
public void hardChoice(QQ arg) {
System.out.println("son choose qq");
}
public void hardChoice(_360 arg) {
System.out.println("son choose 360");
}
} public static void main(String[] args) {
Father father = new Father();
Father son = new Son();
father.hardChoice(new _360());
son.hardChoice(new QQ());
}
}
输出:
father choose 360
son choose qq
通过javap指令查看Class中Main方法的字节码指令:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=3, args_size=1
0: new #2 // class testClass/Dispatch$Father
3: dup
4: invokespecial #3 // Method testClass/Dispatch$Father."<init>":()V
7: astore_1
8: new #4 // class testClass/Dispatch$Son
11: dup
12: invokespecial #5 // Method testClass/Dispatch$Son."<init>":()V
15: astore_2
16: aload_1
17: new #6 // class testClass/Dispatch$_360
20: dup
21: invokespecial #7 // Method testClass/Dispatch$_360."<init>":()V
24: invokevirtual #8 // Method testClass/Dispatch$Father.hardChoice:(LtestClass/Dispatch$_360;)V 27: aload_2
28: new #9 // class testClass/Dispatch$QQ
31: dup
32: invokespecial #10 // Method testClass/Dispatch$QQ."<init>":()V
35: invokevirtual #11 // Method testClass/Dispatch$Father.hardChoice:(LtestClass/Dispatch$QQ;)V 38: return
- 首先来看编译阶段编译器选择目标方法的过程,也就是静态分派的过程,这一过程的结果体现就是编译出来的Class字节码指令。通过字节码指令可以看出,由于father对象的静态类型是Father类型的,而son对象的静态类型也是Father类型的,所以根据静态分派的原理,执行的方法应该都是“Dispatch$Father.hardChoice:”。而又根据方法参数的静态类型,father对象调用的方法里面的参数的静态类型是360类型的,而son对象调用的方法里面的参数的静态类型是QQ类型的,所以说两条invokevirtual指令的参数应该指向不同的方法的符号引用。即father.hardChoice(new _360());对应的invokevirtual指令的参数应该是“Dispatch$Father.hardChoice:(LtestClass/Dispatch$_360;)V”,而son.hardChoice(new QQ());对应的invokevirtual的参数应该是“Dispatch$Father.hardChoice:(LtestClass/Dispatch$QQ;)V”
- 然后再来看一下运行阶段虚拟机选择目标方法的过程,也就是动态分派的过程,这一过程的结果体现就是执行语句返回的结果,即“father choose 360 son choose qq”。如果按照Class字节码指令的执行参数执行,那应该结果是“father choose 360 father choose qq”,那么问题就是son.hardChoice(new QQ());这条语句执行的时候虚拟机对于实际执行的目标方法的选择。由于编译器已经决定目标方法的签名必须是hardChoice(new QQ());,也就是说,动态分派在使用时已经确定了参数的类型,所以动态分派过程只会对方法的接收者的实际类型感兴趣,因此根据Father son = new Son();可以知道实际类型是Son类型,所以实际上执行的是Son类的hardChoice(QQ arg)方法。
总结来说就是:
静态分派过程是根据方法的接收者的静态类型和方法的参数的静态类型两个宗量对目标方法进行选择,所以是单分派。
动态分派过程在使用时已经确定了方法的参数类型,因此只是根据方法的接收者的实际类型一个宗量对目标方法进行选择,所以是双分派。
Java多态中,方法“重载”使用的是静态分派,而方法“重写”使用的动态分派。
(4)虚拟机动态分派的实现
由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能的考虑,大部分实现都不会真正地进行如此频繁的搜索。
面对这种情况,最常用的 “稳定优化” 手段就是为类在方法区中建立一个虚方法表(Virtual Method Table,也称为 vtable,与此对应的,在 invokeinterface 执行时也会用到接口方法表——Interface Method Table,简称 itable),使用虚方法表索引来代替元数据查找以提高性能。
虚方法表中存放着各个方法的实际入口地址。
如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的;都指向父类的实现入口。
如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。
Son 重写了来自 Father 的全部方法,因此 Son 的方法表没有指向 Father 类型数据的箭头。但是 Son 和 Father 都没有重写来自 Object 的方法,所以它们的方法表中所有从 Object 继承来的方法都指向了 Object 的数据类型。
为了程序实现上的方便,具有相同签名的方法,在父类、子类的虚方法表中都应该具有一样的索引序号,这样当类型变换时,仅需要变更查找的方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。
方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。
3.动态类型语言支持(invokedynamic)
随着 JDK 7 的发布,字节码指令集终于迎来了第一位新成员——invokedynamic 指令。这
条新增加的指令是 JDK 7 实现 “动态类型语言” (Dynamically Typed Language)支持而进行的改进之一,也是为 JDK 8 可以实现 Lambda 表达式做技术准备。
(1)动态类型语言
动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期,例如:Python。
相对的,在编译期就进行类型检查过程的语言(如 C++ 和 Java 等)就是最常用的静态类型语言。
类型检查就是:例如:在Main方法中直接有int[][][] array = new int[1][0][-1],那么这段代码能够正常编译,但运行的时候会报 NegativeArraySizeException 异常。
在 Java 虚拟机规范中明确规定了 NegativeArraySizeException 是一个运行时异常,通俗一点来说,运行时异常就是只要代码不运行到这一行就不会有问题。
与运行时异常相对应的是连接时异常,例如很常见的 NoClassDefFoundError 便属于连接时异常,即使会导致连接时异常的代码放在一条无法执行到的分支路径上,类加载时(Java 的连接过程不再编译阶段,而在类加载阶段)也照样会抛出异常。
静态类型语言在编译期确定类型,最显著的好处是编译器可以提供严谨的类型检查,这样与类型相关的问题能在编码的时候就及时发现,利于稳定性及代码达到更大规模。而动态类型语言在运行期确定类型,这可以为开发人员提供更大的灵活性,某些在静态类型语言中需用大量 “臃肿” 代码来实现的功能,由动态类型语言来实现可能会更加清晰和简洁,清晰和简洁通常也就意味着开发效率的提升。
(2)JDK 1.7与动态类型
JDK 1.7 以前的字节码指令集中,4 条方法调用指令(invokevirtual、invokespecial、invokestatic、invokeinterface)的第一个参数都是被调用的方法的符号引用,方法的符号引用在编译时产生,而动态类型语言只有在运行期才能确定接收者类型。
这样,在 Java 虚拟机上实现的动态类型语言就不得不使用其他方式(如编译时留个占位符类型,运行时动态生成字节码实现具体类型到占位符类型的适配)来实现,这样势必让动态类型语言实现的复杂度增加,也可能带来额外的性能或者内存开销。尽管可以利用一些办法(如Call Site Caching)让这些开销尽量变小,但这种底层问题终归是应当在虚拟机层次上去解决才最合适,因此在 Java 虚拟机层面上提供动态类型的直接支持就成为了 Java 平台的发展趋势之一。
(3)java.lang.invoke包
JDK 1.7 新加入了 java.lang.invoke 包,这个包的主要目的是在之前单纯依靠符号引用来确定调用的目标方法这种方式以外,提供一种新的动态确定目标方法的机制,称为 MethodHandle。MethodHandle与C/C++中的函数指针Function Pointer类似,
举个例子,如果我们要实现一个带谓词的排序函数,在 C/C++ 中常用的做法是把谓词定义为函数,用函数指针把谓词传递到排序方法,如下:
void sort(int list[], const int size, <strong>int (*compare)(int, int))
但 Java 语言做不到这一点,即没有办法把一个函数作为参数进行传递。普遍的做法是设计一个带有 compare() 方法的 Comparator 接口,以实现了这个接口的对象作为参数,例如 Collections.sort() 就是这样定义的:
void sort(List list, Comparator c)
在拥有 Method Handle 之后,Java 语言也可以拥有类似于函数指针或者委托的方法别名的工具了。
package testClass; import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodType; public class MethofHandleTest { static class ClassA {
public void println(String s) {
System.out.println(s);
}
} private static MethodHandle getPrintlnMH(Object reveiver) throws Throwable {
// 方法的返回值类型是void类型的,方法的具体参数类型是String类型的。
// mt对象是MethodType类型的,即表示"方法类型"
MethodType mt = MethodType.methodType(void.class, String.class);
// lookup()方法是在指定的类中查找符合给定的方法名称、方法类型、并且符合调用权限的方法句柄
// 并指定方法的接受者,即指定由谁来调用这个方法。
// 结合main方法,分析reveiver.getClass()的意思是,
// 如果reveiver是System.out,那么就在System.out所在的类里寻找名称为"println"的方法,并满足mt的返回类型和参数类型
// 同理,如果reveiver是new ClassA(),那么就在ClassA这个类中寻找名称为"println"的方法,并满足mt的返回类型和参数类型
return java.lang.invoke.MethodHandles.lookup().findVirtual(reveiver.getClass(), "println", mt).bindTo(reveiver);
} public static void main(String[] args) throws Throwable {
Object obj = System.currentTimeMillis() % 2 == 0 ? System.out : new ClassA();
// 无论obj最终是哪个实现类,下面这句话都能正确地调用到System.out.println("oh yeah")或者是new ClassA().println("oh yeah")
// getPrintlnMH(Object reveiver)方法传递进去的参数就是调用方法的对象,即执行者reveiver。
getPrintlnMH(obj).invokeExact("oh yeah");
}
}
方法 getPrintlnMH() 中模拟了 invokevirtual 指令的执行过程,只不过它的分派逻辑并非固化在 Class 文件的字节码上,而是通过一个具体方法来实现。而这个方法本身的返回值(MethodHandle 对象),可以视为对最终调用方法的一个 “引用”。
仅站在 Java 语言的角度来看,MethodHandle 的使用方法和效果与 Reflection 有众多相似之处,不过,它们还是有以下这些区别:
- 从本质上讲,Reflection 和 MethodHandle 机制都是在模拟方法调用,但 Reflection 是在模拟 Java 代码层次的方法调用,而 MethodHandle 是在模拟字节码层次的方法调用。在 MethodHandles.lookup 中的 3 个方法——findStatic()、findVirtual()、findSpecial() 正是为了对应与 invokestatic、invokevirtual & invokeinterface 和 invokespecial 这几条字节码指令的执行权限校验行为,而这些底层细节在使用 Reflection API 时是不需要关心的。
- Reflection 中的 java.lang.reflect.Method 对象远比 MethodHandle 机制中的 java.lang.invoke.MethodHandle 对象所包含的信息多。前者是方法在 Java 一端的全面映像,包含了方法的签名、描述符以及方法属性表中各种属性的 Java 端表示方式,还包含执行权限等的运行期信息。而后者仅仅包含与执行该方法相关的信息。用通俗的话来将,Reflection 是重量级,而 MethodHandle 是轻量级。
- 由于 MethodHandle 是对字节码的方法指令调用的模拟,所以理论上虚拟机在这方面做的各种优化(如方法内联),在 MethodHandle 上也应当可以采用类似思路去支持(但目前实现还不完善)。而通过反射去调用方法则不行。
- MethodHandle 与 Reflection 除了上面列举的区别外,最关键的一点还在于去掉前面讨论施加的前提 “仅站在 Java 语言的角度来看”:Reflection API 的设计目标是只为 Java 语言服务的,而 MethodHandle 则设计成可服务于所有 Java 虚拟机之上的语言,其中也包括 Java 语言。
(4)invokedynamic指令
在某种程度上,invokedynamic 指令与 MethodHandle 机制的作用是一样的,都是为了解决原有 4 条 “invoke*” 指令方法分派规则固化在虚拟机之中的问题,把如何查找目标方法的决定权从虚拟机转嫁到具体用户代码之中,让用户(包含其他语言的设计者)有更高的自有度。而且,它们两者的思路也是可类比的,可以把它们想象成为了达成同一个目的,一个采用上层 Java 代码和 API 来实现,另一个用字节码和 Class 中其他属性、常量来完成。
每一处含有 invokedynamic 指令的位置都称为 “动态调用点”(Dynamic Call Site),这条指令的第一个参数不再是代表方法符号引用的 CONSTANT_Methodref_info 常量,而是变为 JDK 1.7 新加入的 CONSTANT_InvokeDynamic_info 常量,从这个新常量中可以得到 3 项信息:引导方法(Bootstrap Method,此方法存放在新增的 BootstrapMethods 属性中)、方法类型(MethodType)和名称。引导方法是有固定的参数,并且返回值是 java.lang.invoke.CallSite 对象,这个代表真正要执行的目标方法调用。根据 CONSTANT_InvokeDynamic_info 常量中提供的信息,虚拟机可以找到并且执行引导方法,从而获得一个 CallSite 对象,最终调用要执行的目标方法。
import static java.lang.invoke.MethodHandles.lookup; import java.lang.invoke.CallSite;
import java.lang.invoke.ConstantCallSite;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType; public class InvokeDynamicTest { public static void main(String[] args) throws Throwable {
INDY_BootstrapMethod().invokeExact("icyfenix");
} public static void testMethod(String s) {
System.out.println("hello String: " + s);
} public static CallSite BootstrapMethod(MethodHandles.Lookup lookup, String name, MethodType mt) throws Throwable {
return new ConstantCallSite(lookup.findStatic(InvokeDynamicTest.class,
name, mt));
} private static MethodType MT_BootstrapMethod() {
return MethodType.fromMethodDescriptorString("(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;",
null);
} private static MethodHandle MH_BootstrapMethod() throws Throwable {
return lookup().findStatic(InvokeDynamicTest.class, "BootstrapMethod", MT_BootstrapMethod());
} private static MethodHandle INDY_BootstrapMethod() throws Throwable {
CallSite cs = (CallSite) MH_BootstrapMethod().invokeWithArguments(lookup(),
"testMethod",
MethodType.fromMethodDescriptorString("(Ljava/lang/String;)V", null));
return cs.dynamicInvoker();
} }
(5)掌控方法分派规则
invokedynamic 指令与前面 4 条 “invoke*” 指令的最大差别就是它的分派逻辑不是由虚拟机决定的,而是由程序员决定。
程序员在可以掌握方法分派规则之后,能做以前无法做到的事情,例如,在Java 程序中,可以通过 “super” 关键字很方便地调用到父类中的方法,但如果要访问祖类的方法呢?
在 JDK 1.7 之前,使用纯粹的 Java 语言很难处理这个问题(直接生成字节码就很简单,如使用 ASM 等字节码工具),原因是在 Son 类的 thinking() 方法中无法获取一个实际类型是 GrandFather 的对象引用,而 invokevirtual 指令的分派逻辑就是按照方法接收者的实际类型进行分派,这个逻辑是固化在虚拟机中的,程序员无法改变。
在JDK 1.7之后,可以使用MethodHandle来解决这个问题。
下面的代码是基于JDK 1.8利用MethodHandle来实现访问祖类的方法的。
package testClass; import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodHandles.Lookup;
import java.lang.invoke.MethodType;
import java.lang.reflect.Field; public class GrandFatherMethodTest { class GrandFather{
void thinking() {
System.out.println("call me father's father");
}
} class Father extends GrandFather {
void thinking() {
System.out.println("call me father");
}
} class Son extends Father {
void thinking() {
try {
MethodType mt = MethodType.methodType(void.class);
Field IMPL_LOOKUP = MethodHandles.Lookup.class.getDeclaredField("IMPL_LOOKUP");
IMPL_LOOKUP.setAccessible(true);
MethodHandles.Lookup lkp = (Lookup) IMPL_LOOKUP.get(null);
MethodHandle mh = lkp.findSpecial(GrandFather.class, "thinking", mt, GrandFather.class);
mh.invoke(this);
} catch (Throwable e) { }
}
} public static void main(String[] args) {
new GrandFatherMethodTest().new Son().thinking(); // 输出:call me father's father
}
}
由于 invokedynamic 指令所面向的使用者并非 Java 语言,而是其他 Java 虚拟机之上的动态语言,因此仅依靠 Java 语言的编译器 Javac 没有办法生成带有 invokedynamic 指令的字节码。
另:不同的内部类中不同的方法采用不同方式的调用方式。
package testClass; public class StaticMethodTest { static class Human1{ // 静态类中的静态方法可以通过 类名.方法名 调用
public static void print() {
System.out.println("oh shit");
}
} static class Human2{ // 静态类中的实例方法只能通过 new 类名().方法名 调用
public void print() {
System.out.println("oh shit");
}
} class Human3{ // 实例类中的实例方法只能通过 new 外部类().new 类名.方法名 来调用
public void print() {
System.out.println("oh shit");
}
} public static void main(String[] args) {
Human1.print();
new Human2().print();
new StaticMethodTest().new Human3().print();
}
}
内部类方法调用
三、基于栈的字节码解释执行引擎
Java 虚拟机的执行引擎在执行 Java 代码的时候都有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种。
1.解释执行
大部分的程序代码到物理机的目标代码或虚拟机能执行的指令集之前,都需要经过图 8-4 中的各个步骤。
下面那条分支,就是传统编译原理中程序代码到目标机器代码的生成过程,而中间的那条分支,自然就是解释执行的过程。
Java 语言中,Javac 编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。因为这一部分动作是在 Java 虚拟机之外进行的(类加载与方法调用),而解释器在虚拟机的内部,所以 Java 程序的编译就是半独立的实现。
2.基于栈的指令集与基于寄存器的指令集
Java 编译器输出的指令流,基本上(注:使用 “基本上”,是因为部分字节码指令会带有参数,而纯粹基于栈的指令集架构中应当全部都是零地址指令,也即是都不存在显式的参数。Java 这样实现主要是考虑了代码的可校验性)是一种基于栈的指令集架构(Instruction Set Architecture, ISA),指令流中的指令大部分都是零地址指令,它们依赖操作数栈进行工作。
与之相对的另外一套常用的指令集架构是基于寄存器的指令集,最典型的就是 x86 的二地址指令集,说得通俗一些,就是现在我们主流 PC 机中直接支持的指令集架构,这些指令依赖寄存器进行工作。那么,基于栈的指令集与基于寄存器的指令集这两者之间有什么不同呢?
分别使用基于栈的指令集与基于寄存器的指令集计算“1+1”:
- 基于栈的指令集:iconst_1(把int类型常量1压入栈中) --iconst_1(把int类型常量1压入栈中)--iadd(把栈顶的两个值出栈并相加,然后把结果入栈) --istore_0(把栈顶的值放到局部变量表的第1个Slot中)
- 基于寄存器的指令集:mov eax, 1(把EAX寄存器的值设为1) -- add eax, 1(add指令再把这个值加1,结果就保存在EAX寄存器里面)
两种指令集的优点和缺点:
- 基于栈的指令集的优点:基于栈的指令集主要的优点就是可移植,寄存器由硬件直接提供,程序直接依赖硬件寄存器则不可避免地要受到硬件的约束。如果使用栈架构的指令集,用户程序不会直接使用寄存器,就可以由虚拟机实现来自行决定把一些访问最频繁的数据(程序计数器、栈顶缓存等)放到寄存器中以获取尽量好的性能,这样实现起来也更加简单一些。栈架构的指令集还有一些其他的优点,如代码相对更加紧凑(字节码中每个字节就对应一条指令,而多地址指令集中还需要存放参数)、编译器实现更加简单(不需要考虑空间分配的问题,所需空间都在栈上操作)等。
- 基于栈的指令集的缺点:栈架构指令集的主要缺点是执行速度相对来说会稍慢一些。所有主流物理机的指令集都是寄存器架构也从侧面印证了这一点。虽然栈架构指令集的代码非常紧凑,但是完成相同功能所需的指令数量一般会比寄存器架构多,因为出栈、入栈操作本身就产生了相当多的指令数量。更重要的是,栈实现在内存之中,频繁的栈访问也就意味着频繁的内存访问,相对于处理器来说,内存始终是执行速度的瓶颈。尽管虚拟机可以采取栈顶缓存的手段,把最常用的操作映射到寄存器中避免直接内存访问,但这也只能是优化措施而不是解决本质问题的方法。由于指令数量和内存访问的原因,所以导致了栈架构指令集的执行速度会相对较慢。
3.基于栈的解释器执行过程
以下面的Java代码为例:
public int calc() { int a = 100;
int b = 200;
int c = 300;
return (a + b) * c;
}
使用javap查看calc()方法的Class字节码:
public int calc();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=2, locals=4, args_size=1
0: bipush 100
2: istore_1
3: sipush 200
6: istore_2
7: sipush 300
10: istore_3
11: iload_1
12: iload_2
13: iadd
14: iload_3
15: imul
16: ireturn
分析执行过程:
根据Stack=2,Locals=4,可以知道calc()方法需要深度为2的操作数栈和4个Slot的局部变量表空间
用Slot0(默认this) - Slot1() - Slot2() - Slot3()表示局部变量表,用s0 s1分别表示栈顶和栈底
程序计数器:0 :将单字节的常量值(-128~127)推送至操作数栈顶,参数100, (this) - () - () - () , (100) -()
程序计数器:2 :将操作数栈顶的整型值出栈并存放到索引为1的Slot中, (this) - (100) - () - () , () -()
程序计数器:3 :将一个短整型常量值(-32768~32767)推送至操作数栈顶,参数200 (this) - (100) - () - () , (200) -()
程序计数器:6 :将操作数栈顶的整型值出栈并存放到索引为2的Slot中 (this) - (100) - (200) - () , () -()
程序计数器:7 :将一个短整型常量值(-32768~32767)推送至操作数栈顶,参数200 (this) - (100) - (200) - () , (300) -()
程序计数器:10 :将操作数栈顶的整型值出栈并存放到索引为2的Slot中 (this) - (100) - (200) - (300) ,() -()
程序计数器:11 :将局部变量表索引为1的Slot复制到操作数栈顶 (this) - (100) - (200) - (300) ,(100) -()
程序计数器:12 :将局部变量表索引为2的Slot复制到操作数栈顶 (this) - (100) - (200) - (300) ,(200) -(100)
程序计数器:13 :将栈顶两个元素出栈,并整型相加,然后把结果重新入栈 (this) - (100) - (200) - (300) ,(300) -()
程序计数器:14 :将局部变量表索引为3的Slot复制到操作数栈顶 (this) - (100) - (200) - (300) ,(300) -(300)
程序计数器:15 :将栈顶两个元素出栈,并整型相乘,然后把结果重新入栈 (this) - (100) - (200) - (300) ,(90000) -()
程序计数器:16 :结束方法,并将操作数栈栈顶的整型值返回给此方法的调用者。 返回:9000
四、字节码生成技术与动态代理的实现
在 Java 里面除了 javac 和字节码类库外,使用字节码生成的例子还有很多,如 Web 服务器中的 JSP 编译器,编译时植入的 AOP 框架,还有很常用的动态代理技术,甚至在使用反射的时候虚拟机都有可能会在运行时生成字节码来提高执行速度。
许多 Java 开发人员都使用过动态代理,即使没有直接使用过 java.lang.reflect.Proxy 或实现过 java.lang.reflect.InvocationHandler 接口,应该也用过 Spring 来做过 Bean 的组织管理。如果使用过 Spring,那大多数情况都会用过动态代理,因为如果Bean是面向接口编程,那么在Spring内部都是通过动态代理的方式来对 Bean 进行增强的。
动态代理中所谓的 “动态”,是针对使用 Java 代码实际编写了代理类的 “静态” 代理而言的,它的优势不在于省去了编写代理类那一点工作量,而是实现了可以在原始类和接口还未知的时候,就确定代理类的代理行为,当代理类与原始类脱离直接联系后,就可以很灵活地重用于不同的应用场景之中。
1.动态代理代码示例
package testClass; import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy; public class DynamicProxyTest { interface IHello{
void sayHello();
} static class Hello implements IHello {
@Override
public void sayHello() {
System.out.println("hello, world");
}
} static class DynamicProxy implements InvocationHandler { Object originalObj; Object bind(Object originalObj) {
this.originalObj = originalObj;
return Proxy.newProxyInstance(originalObj.getClass().getClassLoader(), originalObj.getClass().getInterfaces(), this);
} @Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("welcome");
return method.invoke(originalObj, args);
}
} public static void main(String[] args) { System.getProperties().put("sun.misc.ProxyGenerator.saveGeneratedFiles", "true");
IHello hello = (IHello) new DynamicProxy().bind(new Hello());
hello.sayHello();
}
} 输出:
welcome
hello, world
2.由于Main方法中,System.getProperties().put("sun.misc.ProxyGenerator.saveGeneratedFiles", "true");的作用是在磁盘中生成一个名为“$Proxy0.class”的代理类Class文件,通过eclipse中的反编译器插件查看这个.class文件反编译之后的Java源码。
可以看到,代理类为传入接口中的每一个方法,以及从java.lang.Object中继承来的equals()、hashCode()、toString()方法都生成了对应的实现,并且统一调用的InvocationHandler对象的invoke()方法(代码中的“this.h”就是父类Proxy中保存的InvocationHandler实例变量)来实现这些方法的内容,各个方法的区别不过是传入的参数和Method对象有所不同而已,所以无论调用动态代理的哪一个方法,实际上都是在执行InvocationHandler.invoke()中的代理逻辑。
package testClass; import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.lang.reflect.UndeclaredThrowableException;
import testClass.DynamicProxyTest.IHello; final class $Proxy0 extends Proxy implements IHello {
private static Method m1;
private static Method m3;
private static Method m2;
private static Method m0; public $Proxy0(InvocationHandler var1) throws {
super(var1);
} public final boolean equals(Object var1) throws {
try {
return (Boolean)super.h.invoke(this, m1, new Object[]{var1});
} catch (RuntimeException | Error var3) {
throw var3;
} catch (Throwable var4) {
throw new UndeclaredThrowableException(var4);
}
} public final void sayHello() throws {
try {
super.h.invoke(this, m3, (Object[])null);
} catch (RuntimeException | Error var2) {
throw var2;
} catch (Throwable var3) {
throw new UndeclaredThrowableException(var3);
}
} public final String toString() throws {
try {
return (String)super.h.invoke(this, m2, (Object[])null);
} catch (RuntimeException | Error var2) {
throw var2;
} catch (Throwable var3) {
throw new UndeclaredThrowableException(var3);
}
} public final int hashCode() throws {
try {
return (Integer)super.h.invoke(this, m0, (Object[])null);
} catch (RuntimeException | Error var2) {
throw var2;
} catch (Throwable var3) {
throw new UndeclaredThrowableException(var3);
}
} static {
try {
m1 = Class.forName("java.lang.Object").getMethod("equals", Class.forName("java.lang.Object"));
m3 = Class.forName("testClass.DynamicProxyTest$IHello").getMethod("sayHello");
m2 = Class.forName("java.lang.Object").getMethod("toString");
m0 = Class.forName("java.lang.Object").getMethod("hashCode");
} catch (NoSuchMethodException var2) {
throw new NoSuchMethodError(var2.getMessage());
} catch (ClassNotFoundException var3) {
throw new NoClassDefFoundError(var3.getMessage());
}
}
}
m0对应从java.lang.Object中继承来的hashCode()方法;
m1对应从java.lang.Object中继承来的equals()方法;
m2对应从java.lang.Object中继承来的toString()方法;
m3对应实现了DynamicProxyTest类中的IHello接口的Hello类中的sayHello()方法。
对于代理类中的sayHello()方法来说,super.h.invoke(this, m3, (Object[])null);是方法内的执行语句,其中super表示父类Proxy类,super.h就是父类Proxy中保存的InvocationHandler实例变量,也就是所写的测试类中的DynamicProxy实例变量,这一整句语句的意思就是实际调用的是DynamicProxy类中实现的public Object invoke(Object proxy, Method method, Object[] args)方法里面的内容。
public final void sayHello() throws {
try {
super.h.invoke(this, m3, (Object[])null);
} catch (RuntimeException | Error var2) {
throw var2;
} catch (Throwable var3) {
throw new UndeclaredThrowableException(var3);
}
}
3.回到源码,重点需要关注的是Proxy.newProxyInstance()方法,在源码中用到地方是
static class DynamicProxy implements InvocationHandler {
Object originalObj;
Object bind(Object originalObj) {
this.originalObj = originalObj;
return Proxy.newProxyInstance(originalObj.getClass().getClassLoader(), originalObj.getClass().getInterfaces(), this);
}
}
调用这个方法的地方是:通过调用语句以及输出结果可以看出,Proxy.newProxyInstance语句返回了一个实现了IHello的接口,并且代理了new Hello()实例行为的对象。
结合示例进行分析,originalObj是Hello类型的对象,于是originalObj对象可以调用Hello类的sayHello()方法。然后把originalObj对象传递进bind方法里面,然后去调用Proxy.newProxyInstance方法,参数分别是originalObj对象的类加载器,originalObj对象的接口,以及DynamicProxy实例对象。
IHello hello = (IHello) new DynamicProxy().bind(new Hello());
hello.sayHello();
4.然后开始看Proxy.newProxyInstance方法的源码:方法参数为loader是类加载器对象,interfaces是接口数组,h是InvocationHandler对象
@CallerSensitive // 生成代理类对象
public static Object newProxyInstance(ClassLoader loader, Class<?>[] interfaces, InvocationHandler h) throws IllegalArgumentException {
Objects.requireNonNull(h); // 调用处理程序不能为空 final Class<?>[] intfs = interfaces.clone(); // 拷贝接口数组对象
final SecurityManager sm = System.getSecurityManager();
if (sm != null) {
checkProxyAccess(Reflection.getCallerClass(), loader, intfs);
}
// 查找或产生指定的代理类对象
Class<?> cl = getProxyClass0(loader, intfs);
// 调用指定的调用处理程序的构造函数
try {
if (sm != null) { // 校验新代理类的权限
checkNewProxyPermission(Reflection.getCallerClass(), cl);
}
// 获取代理类构造函数,参数类型必须为InvocationHandler
final Constructor<?> cons = cl.getConstructor(constructorParams);
final InvocationHandler ih = h;
// 构造函数不是public的时候,设置当前构造函数为访问权限
if (!Modifier.isPublic(cl.getModifiers())) {
AccessController.doPrivileged(new PrivilegedAction<Void>() {
public Void run() {
cons.setAccessible(true);
return null;
}
});
}
// 调用构造函数构造代理类实例,参数为传递进来的InvocationHandler对象
return cons.newInstance(new Object[]{h});
} catch (IllegalAccessException|InstantiationException e) {
throw new InternalError(e.toString(), e);
} catch (InvocationTargetException e) {
Throwable t = e.getCause();
if (t instanceof RuntimeException) {
throw (RuntimeException) t;
} else {
throw new InternalError(t.toString(), t);
}
} catch (NoSuchMethodException e) {
throw new InternalError(e.toString(), e);
}
}
5.先看Class<?> cl = getProxyClass0(loader, intfs);中的getProxyClass0方法:根据描述可以知道,如果代理类已经存在,就返回代理类的缓存副本,否则通过ProxyClassFactory这个工厂方法创建代理类。传递进去的参数是类加载器和接口数组。这里的proxyClassCache.get方法实现中,使用的是 ConcurrentMap<Object, Supplier<V>> valuesMap = map.get(cacheKey);即ConcurrentMap这个数据结构作为缓存,有兴趣的话可以研究一下Java缓存机制。
/**
* Generate a proxy class. Must call the checkProxyAccess method
* to perform permission checks before calling this.
*/
private static Class<?> getProxyClass0(ClassLoader loader,
Class<?>... interfaces) {
if (interfaces.length > 65535) {
throw new IllegalArgumentException("interface limit exceeded");
} // If the proxy class defined by the given loader implementing
// the given interfaces exists, this will simply return the cached copy;
// otherwise, it will create the proxy class via the ProxyClassFactory
return proxyClassCache.get(loader, interfaces);
}
6.接着看ProxyClassFactory方法类,参数为类加载器即Hello类的类加载器和接口数组IHello接口。
// 根据给定的类加载器和接口数组生成、定义和返回代理类
private static final class ProxyClassFactory implements BiFunction<ClassLoader, Class<?>[], Class<?>> {
// 所有代理类的名称的前缀
private static final String proxyClassNamePrefix = "$Proxy"; // 用于生成唯一代理类名称的下一个数字
private static final AtomicLong nextUniqueNumber = new AtomicLong(); @Override
public Class<?> apply(ClassLoader loader, Class<?>[] interfaces) { Map<Class<?>, Boolean> interfaceSet = new IdentityHashMap<>(interfaces.length);
for (Class<?> intf : interfaces) { // 验证接口数组中的intf接口类对象是否被类加载器loader加载和解析
Class<?> interfaceClass = null;
try {
// 从类加载器loader中获取到名称为接口名称的类Class对象
interfaceClass = Class.forName(intf.getName(), false, loader);
} catch (ClassNotFoundException e) {
}
// 验证获取到的类Class对象是否为intf接口类对象
if (interfaceClass != intf) {
throw new IllegalArgumentException(
intf + " is not visible from class loader");
}
// 验证获取到的类Class对象是否为接口类对象
if (!interfaceClass.isInterface()) {
throw new IllegalArgumentException(
interfaceClass.getName() + " is not an interface");
}
// 验证此接口类对象是不是副本
if (interfaceSet.put(interfaceClass, Boolean.TRUE) != null) {
throw new IllegalArgumentException(
"repeated interface: " + interfaceClass.getName());
}
}
// 定义代理类的包名称
String proxyPkg = null;
int accessFlags = Modifier.PUBLIC | Modifier.FINAL; // 定义代理类的修饰符为public和final类型
// 记录一个非公共代理的接口包,
for (Class<?> intf : interfaces) {
int flags = intf.getModifiers(); // 获取修饰符
if (!Modifier.isPublic(flags)) { // 如果修饰符不是public
accessFlags = Modifier.FINAL; // 定义修饰符为final
String name = intf.getName(); // 获取接口inf的完整名,包括包名和类名和接口名
int n = name.lastIndexOf('.');
String pkg = ((n == -1) ? "" : name.substring(0, n + 1)); // 记录包名字段
if (proxyPkg == null) {
proxyPkg = pkg; // 不断更新接口数组中所有的数组的包名
} else if (!pkg.equals(proxyPkg)) { //如果接口数组中有来自不同包的非public接口,则抛出异常
throw new IllegalArgumentException(
"non-public interfaces from different packages");
}
}
} if (proxyPkg == null) {
// 如果没有非public代理接口,则使用默认的包名com.sun.proxy
proxyPkg = ReflectUtil.PROXY_PACKAGE + ".";
}
// 定义代理类Class文件的完成名称proxyName
long num = nextUniqueNumber.getAndIncrement();
String proxyName = proxyPkg + proxyClassNamePrefix + num;
// 生成二进制字节码,并保存在二进制数组proxyClassFile中。
byte[] proxyClassFile = ProxyGenerator.generateProxyClass(
proxyName, interfaces, accessFlags);
try { // 根据二进制字节码得到代理类的实例
return defineClass0(loader, proxyName, proxyClassFile, 0, proxyClassFile.length);
} catch (ClassFormatError e) {
throw new IllegalArgumentException(e.toString());
}
}
}
7.通过前面的理解,应该可以清楚动态代理模式的基本实现过程了,然而还有一些额外的问题
(1)就像在代理类Class文件反编译之后看到的,代理类中有4个方法,再来试一个:
hello.sayHello();
hello.hashCode();
返回结果是:
welcome
hello, world
welcome
(2)将hello.hashCode();删除并将return method.invoke(originalObj, args);方法改成return null;
// return method.invoke(originalObj, args);
return null;
此时输出结果为:
welcome
(3)此时将hello.hashCode();加上,得到的返回结果为
welcome
welcome
Exception in thread "main" java.lang.NullPointerException
at testClass.$Proxy0.hashCode(Unknown Source)
at testClass.DynamicProxyTest.main(DynamicProxyTest.java:42)
这是由于sayHello()方法是void类型的,没有返回值,可以返回null;而hashCode()方法时String类型的,不可以返回null,
总结一下JDK中的动态代理:
1.DynamicProxy类,实现InvocationHandler接口,必须实现invoke方法
static class DynamicProxy implements InvocationHandler {...)
2.newProxyInstance方法:
public static Object newProxyInstance(ClassLoader loader, Class<?>[] interfaces, InvocationHandler h) throws IllegalArgumentException{...}
ClassLoader loader:获取被代理的类的类加载器,可以通过这个类加载器,在程序运行时,将生成的代理类加载到JVM中。
Class<?>[] interfaces:获取被代理类的所有接口信息,以便于生成的代理类可以具有代理类接口中的所有方法。
InvocationHandler h:这个时候需要调用实现了InvocationHandler 类的一个回调方法。由于自身变实现了这个方法,所以将this传递过去。
返回值Object:返回一个代理了被代理类所有的行为,并且实现了被代理类的接口中的所有方法的对应接口类型的对应被代理类的实例对象。
3.实现InvocationHandler接口要重写的invoke方法,可以灵活得在原方法之前或者之后加上需要执行的代理方法的内容:
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
Object proxy:生成的代理对象
Method method:被代理类的方法
Object[] args:被代理类的方法的参数
返回Object:返回被代理类的方法的返回值
4.methof.invoke()中的invoke方法
public Object invoke(Object obj, Object... args) throws IllegalAccessException, IllegalArgumentException, InvocationTargetException {...}
Object obj:InvocationHandler中的字段,表示被代理类的实例对象
Object... args:被代理类的方法的参数,和3中的args完全一样
返回Object:返回被代理类的原方法执行的返回值
5.动态代理示例程序
package testClass; import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy; public class DynamicProxyTest { interface IHello{
void sayHello();
} static class Hello implements IHello {
@Override
public void sayHello() {
System.out.println("hello, world");
}
} static class DynamicProxy implements InvocationHandler { Object originalObj; Object bind(Object originalObj) {
this.originalObj = originalObj;
return Proxy.newProxyInstance(originalObj.getClass().getClassLoader(), originalObj.getClass().getInterfaces(), this);
} @Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("welcome");
Object ret = null;
try {
ret = method.invoke(originalObj, args);
} catch (Exception e) {
e.printStackTrace();
throw e;
}
return ret;
}
} public static void main(String[] args) { System.getProperties().put("sun.misc.ProxyGenerator.saveGeneratedFiles", "true");
IHello hello = (IHello) new DynamicProxy().bind(new Hello());
hello.sayHello();
hello.hashCode();
}
}
动态代理示例
五、Retrotranslator:跨越JDK版本
Retrotranslator 的作用是将 JDK 1.5 编译出来的 Class 文件转变为可以在 JDK 1.4 或 1.3 上部署的版本,它可以很好地支持自动装箱、泛型、动态注解、枚举、变长参数、遍历循环、静态导入这些语法特性,甚至还可以支持 JDK 1.5 中新增的集合改进、并发包以及对泛型、注解等的反射操作。
JVM(6) 字节码执行引擎的更多相关文章
- Java之深入JVM(6) - 字节码执行引擎(转)
本文为转载,来自 前面我们不止一次的提到,Java是一种跨平台的语言,为什么可以跨平台,因为我们编译的结果是中间代码—字节码,而不是机器码,那字节码在整个Java平台扮演着什么样的角色的呢?JDK1. ...
- JVM之字节码执行引擎
方法调用: 方法调用不同于方法执行,方法调用阶段唯一任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不执行方法内部的具体过程.方法调用有,解析调用,分派调用(有静态分派,动态分派). 方法解析 ...
- 一夜搞懂 | JVM 字节码执行引擎
前言 本文已经收录到我的 Github 个人博客,欢迎大佬们光临寒舍: 我的 GIthub 博客 学习导图 一.为什么要学习字节码执行引擎? 代码编译的结果从本地机器码转变为字节码,是存储格式发展的一 ...
- JVM学习笔记:字节码执行引擎
JVM学习笔记:字节码执行引擎 移步大神贴:http://rednaxelafx.iteye.com/blog/492667
- JVM总结(五):JVM字节码执行引擎
JVM字节码执行引擎 运行时栈帧结构 局部变量表 操作数栈 动态连接 方法返回地址 附加信息 方法调用 解析 分派 –“重载”和“重写”的实现 静态分派 动态分派 单分派和多分派 JVM动态分派的实现 ...
- 深入理解JVM虚拟机5:虚拟机字节码执行引擎
虚拟机字节码执行引擎 转自https://juejin.im/post/5abc97ff518825556a727e66 所谓的「虚拟机字节码执行引擎」其实就是 JVM 根据 Class 文件中给 ...
- JVM基础结构与字节码执行引擎
JVM基础结构 JVM内部结构如下:栈.堆. 栈 JVM中的栈主要是指线程里面的栈,里面有方法栈.native方法栈.PC寄存器等等:每个方法栈是由栈帧组成的:每个栈帧是由局部变量表.操作数栈等组成. ...
- 深入理解java虚拟机(5)---字节码执行引擎
字节码是什么东西? 以下是百度的解释: 字节码(Byte-code)是一种包含执行程序.由一序列 op 代码/数据对组成的二进制文件.字节码是一种中间码,它比机器码更抽象. 它经常被看作是包含一个执行 ...
- 【java虚拟机系列】从java虚拟机字节码执行引擎的执行过程来彻底理解java的多态性
我们知道面向对象语言的三大特点之一就是多态性,而java作为一种面向对象的语言,自然也满足多态性,我们也知道java中的多态包括重载与重写,我们也知道在C++中动态多态是通过虚函数来实现的,而虚函数是 ...
随机推荐
- Spring 梳理 - 开启并配置 Spring MVC 的方法
传统web.xm中配置两个上下文+两个context对应的xml+两个上下文bean分别手动配置 传统web.xm中配置两个上下文+两个context对应的xml+<mvc:annotation ...
- 插入排序--JavaScript描述
记录一个插入排序写法 <script> var arr = [123,34,23,6,1,4,23,324,65,122]; for (let i =1, j = i ; i < a ...
- bugku—Web_Writeup
Bugku_Web_Writeup Writeup略显粗糙~~ 部分Web题没有得到最后的flag~只是有了一个简单的思路~~ Web1: 如上,打开题目答题网址后就会弹出一张图片,看图片就可以发现是 ...
- mysql root用户登录后无法查看数据库全部表
可能是把root@localhost用户删掉了. 首先停掉mysql服务,在/etc/my.cnf中添加 skip-grant-tables,同时可以添加skip-networking选项来禁用网络功 ...
- 在Android开发中,当按下home键程序会完全退出时,解决这个BUG:
把这段代码贴到 super.onCreate(savedInstanceState); 之后 //remenber process if(!this.isTaskRoot()) { //判断该Act ...
- 设计模式----行为型模式之命令模式(Command Pattern)
下面来自head first设计模式的命令模式一章节. 定义 将"请求"封装成对象,以便使用不同的请求.队列或者日志来参数化其他对象.命令模式也支持可撤销的操作. 类图 注: 1. ...
- JDK-基于Windows环境搭建
JDK安装: 毋庸置疑你要跑java程序,肯定少不了JDK,如jemter还有还有~ 下载jdk地址1:https://pan.baidu.com/s/1FIvGNvZSy0EpCBxHCz07nA ...
- React入门学习
为了获得更好的阅读体验,请访问原地址:传送门 一.React 简介 React 是什么 React 是一个起源于 Facebook 的内部项目,因为当时 Facebook 对于市场上所有的 JavaS ...
- asp.net core 3.0 中使用 swagger
asp.net core 3.0 中使用 swagger Intro 上次更新了 asp.net core 3.0 简单的记录了一下 swagger 的使用,那个项目的 api 比较简单,都是匿名接口 ...
- MySQL学习(二)索引原理及其背后的数据结构
首先区分几个概念: 聚集索引 主索引和辅助索引(即二级索引) innodb中每个表都有一个聚簇索引(clustered index ),除此之外的表上的每个非聚簇索引都是二级索引,又叫辅助索引(sec ...