接下来,我们要做的事情是尽可能的熟悉字节码指令,这会让我们对java代码的理解会有本质上的飞跃。要想熟悉字节码指令,需要先知道字节码到底是怎么运行的
字节码的运行机制
一个简单的例子
还是之前的例子,java代码为:
public class Hello {
public static void main(String[] args) {
System.out.println("Hello, World");
}
}
将它编译之后生成class文件,使用xxd Hello.class
命令得到二进制码
00000000: cafe babe 0000 0034 001d 0a00 0600 0f09 .......4........
00000010: 0010 0011 0800 120a 0013 0014 0700 1507 ................
00000020: 0016 0100 063c 696e 6974 3e01 0003 2829 .....<init>...()
00000030: 5601 0004 436f 6465 0100 0f4c 696e 654e V...Code...LineN
00000040: 756d 6265 7254 6162 6c65 0100 046d 6169 umberTable...mai
00000050: 6e01 0016 285b 4c6a 6176 612f 6c61 6e67 n...([Ljava/lang
00000060: 2f53 7472 696e 673b 2956 0100 0a53 6f75 /String;)V...Sou
00000070: 7263 6546 696c 6501 000a 4865 6c6c 6f2e rceFile...Hello.
00000080: 6a61 7661 0c00 0700 0807 0017 0c00 1800 java............
00000090: 1901 000c 4865 6c6c 6f2c 2057 6f72 6c64 ....Hello, World
000000a0: 0700 1a0c 001b 001c 0100 0548 656c 6c6f ...........Hello
000000b0: 0100 106a 6176 612f 6c61 6e67 2f4f 626a ...java/lang/Obj
000000c0: 6563 7401 0010 6a61 7661 2f6c 616e 672f ect...java/lang/
000000d0: 5379 7374 656d 0100 036f 7574 0100 154c System...out...L
000000e0: 6a61 7661 2f69 6f2f 5072 696e 7453 7472 java/io/PrintStr
000000f0: 6561 6d3b 0100 136a 6176 612f 696f 2f50 eam;...java/io/P
00000100: 7269 6e74 5374 7265 616d 0100 0770 7269 rintStream...pri
00000110: 6e74 6c6e 0100 1528 4c6a 6176 612f 6c61 ntln...(Ljava/la
00000120: 6e67 2f53 7472 696e 673b 2956 0021 0005 ng/String;)V.!..
00000130: 0006 0000 0000 0002 0001 0007 0008 0001 ................
00000140: 0009 0000 001d 0001 0001 0000 0005 2ab7 ..............*.
00000150: 0001 b100 0000 0100 0a00 0000 0600 0100 ................
00000160: 0000 0100 0900 0b00 0c00 0100 0900 0000 ................
00000170: 2500 0200 0100 0000 09b2 0002 1203 b600 %...............
00000180: 04b1 0000 0001 000a 0000 000a 0002 0000 ................
00000190: 0003 0008 0004 0001 000d 0000 0002 000e ................
在使用 javap -c -s Hello
指令之后将会得到以下的字节码
Compiled from "Hello.java"
public class Hello extend Object{
public Hello();
descriptor: ()V
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
Code:
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String Hello, World
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
}
基于栈的执行引擎
虚拟机从实现上可以分为两种,以基于栈的实现方式,以基于寄存器的实现方式。
当然两种方式各有优劣,我们目前的JVM是基于栈的实现方式。
我们把目光聚焦到一个简单方法上
public void f(int a,int b){
int c = a + b;
}
首先,一个方法对应了一个叫做帧栈的东西,就可以理解为一个奇怪的数据结构。
这个数据结构呢,包含三个东西
- 指向运行时常量池的引用
- 局部变量表(想象成固定长度的数组)
- 操作数栈(想象成后进先出的堆栈)
看起来可能很抽象,我们打个比方
上面的方法体编译之后就会变成
0: iload_1 // 将 a 压入操作数栈
1: iload_2 // 将 b 压入操作数栈
2: iadd // 将栈顶两个值出栈,相加,然后将结果放回栈顶
3: istore_3 // 将栈顶值存入局部变量表中第 3 个 slot 中
把我们自己身处在这个方法体里面,当执行到iload_1了,那么就把a塞到操作数栈,当执行iload_2就把b塞到操作数栈
当执行iadd时,这个指令本身需要两个参数执行,那么就自然要到操作数栈先后搞两个参数出来,我们称为出栈
拿到参数,送进iadd内之后有返回值了,把这个返回值塞回操作数栈,类似于iadd这种操作符,都会把得到的结果塞回栈顶,之后的istore_3指令,把产生出来的结果保存到3局部变量表的位置上。
局部变量表永远都是预先分配好的,例如int c = a + b;当在编译的时候就会被认为c是个局部变量,就是需要在局部变量表存在着的。
所以字节码运行永远都是局部变量与操作数栈不断load,store的过程
执行过程
// Compiled from "Hello.java"
1. public class Hello {
2. public Hello();
3. descriptor: ()V
4. Code:
5. 0: aload_0
6. 1: invokespecial #1 // Method java/lang/Object."<init>":()V
7. 4: return
8.
9. public static void main(java.lang.String[]);
10. descriptor: ([Ljava/lang/String;)V
11. Code:
12. 0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
13. 3: ldc #3 // String Hello, World
14. 5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
15. 8: return
16.}
3 ~ 7 行:可以看到虽然没有写 Hello 类的构造器函数,编译器会自动加上一个默认构造器函数
5 行:aload_0 这个操作码是 aload_x 格式操作码中的一个。它们用来把对象引用加载到操作数栈。 x 表示正在被访问的局部变量数组的位置。在这里的 0 代表什么呢?我们知道非静态的函数都有第一个默认参数,那就是 this,这里的 aload_0 就是把 this 入栈
6 行:invokespecial #1,invokespecial 指令调用实例初始化方法、私有方法、父类方法,#1 指的是常量池中的第一个,这里是方法引用java/lang/Object.“”😦)V,也即构造器函数
7 行:return,这个操作码属于 ireturn、lreturn、freturn、dreturn、areturn 和 return 操作码组中的一员,其中 i 表示 int,返回整数,同类的还有 l 表示 long,f 表示 float,d 表示 double,a 表示 对象引用。没有前缀类型字母的 return 表示返回 void
到此为止,默认构造器函数就讲完了,接下来,我们来看 9 ~ 14 行的 main 函数
12 行:getstatic #2,getstatic 获取指定类的静态域,并将其值压入栈顶,#2 代表常量池中的第 2 个,这里表示的是java/lang/System.out:Ljava/io/PrintStream;,其实就是java.lang.System 类的静态变量 out(类型是 PrintStream)
13 行:ldc #3、,ldc 用来将常量从运行时常量池压栈到操作数栈,#3 代表常量池的第三个(字符串 Hello, World)
14 行:invokevirtual #4,invokevirutal 指令调用一个对象的实例方法,#4 表示 PrintStream.println(String) 函数引用,并把栈顶两个元素出栈
15行:return 返回void
从字节码角度看java语句背后的原理
既然我们希望换一个角度看看java语言,那么我们就按照java知识脉络的走势进行描述。
任何一本java入门书,知识的传递必然是循序渐进的。下面把一些主干的知识点拎出来(不完整的,只是为了后面的字节码对应上)
- 基本数据类型
- 算数运算与逻辑运算符
- 流程控制
- 面向对象编程
- 类初始化
- 类方法调用
- 异常处理机制
- 集合框架与泛型
- lambda表达式与流式处理api
- io
- 多线程
- 反射
那么我们在理解字节码的时候,也可以按照这样的顺序进行一一对应
2.1 指令基础
在这部分,将演示大部分的指令的使用,指令合集参考附录
2.1.1 加载与存储指令
加载(load)和存储(store)相关的指令是使用得最频繁的指令,分为load类、store类、常量加载这三种。
- load类指令是将局部变量表中的变量加载到操作数栈,比如iload_0将局部变量表中下标为0的int型变量加载到操作数栈上,根据不同的数据变量类型还有lload、fload、dload、aload这些指令,分别表示加载局部变量表中long、float、double、引用类型的变量。
- store类指令是将栈顶的数据存储到局部变量表中,比如istore_0将操作数栈顶的元素存储到局部变量表中下标为0的位置,这个位置的元素类型为int,根据不同的数据变量类型还有lstore、fstore、dstore、astore这些指令。
- 常量加载相关的指令,常见的有const类、push类、ldc类。const、push类指令是将常量值直接加载到操作数栈顶,比如iconst_0是将整数0加载到操作数栈上,bipush 100是将int型常量100加载到操作数栈上。ldc指令是从常量池加载对应的常量到操作数栈顶,比如ldc#10是将常量池中下标为10的常量数据加载到操作数栈上。
为什么同是int型常量,加载需要分这么多类型呢?这是为了使字节码更加紧凑,int型常量值根据值n的范围,使用的指令按照如下的规则。
- 若n在[-1,5]范围内,使用iconst_n的方式,操作数和操作码加一起只占一个字节。比如iconst_2对应的十六进制为0x05。-1比较特殊,对应的指令是iconst_m1(0x02)。
- 若n在[-128,127]范围内,使用bipush n的方式,操作数和操作码一起只占两个字节。比如n值为100(0x64)时,bipush 100对应十六进制为0x1064。
- 若n在[-32768,32767]范围内,使用sipush n的方式,操作数和操作码一起只占三个字节,比如n值为1024(0x0400)时,对应的字节码为sipush1024(0x110400)。
- 若n在其他范围内,则使用ldc的方式,这个范围的整数值被放在常量池中,比如n值为40000时,40000被存储到常量池中,加载的指令为ldc#i,i为常量池的索引值。
如下代码
public class TestByteCode {
private int int_2_2_1 = 1;
public void test2_1_1_1(int i,int j){
int k = int_2_2_1 + i +j;
System.out.println(k);
}
public void test2_1_1_2(){
int i = 1;
int j = 2;
int k = int_2_2_1 + i +j;
System.out.println(k);
}
public static void main(String[] args) {
}
}
可以看到局部变量表有4个slot,1,2,3分别是我们制造的i,j,k
Code数据为
0 aload_0
1 getfield #2 <com/zifang/util/core/TestByteCode.int_2_2_1>
4 iload_1
5 iadd
6 iload_2
7 iadd
8 istore_3
9 getstatic #3 <java/lang/System.out>
12 iload_3
13 invokevirtual #4 <java/io/PrintStream.println>
16 return
0 行将this压入操作数栈
1行调用getfield指令获得int_2_2_1变量的数据,压入操作数栈
4行,从局部变量表内1的位置(就是i)的值压入操作数栈
5行,调用iadd将 int_2_2_1 与 i的值进行累加,再压入操作数栈
6行,从iload_2将局部变量表内位置为2的值(j)压入栈
7行,累加,累加值压入栈
8行,调用istore_3指令,将最终的值存入位置为3的(k的位置)的局部变量表
9行,获得System.out的类ref压入栈
12行,从k上把数据压入栈
13行,执行invokevirtual进行调用
16行,执行空方法返回
为了说明const_*指令,制造了test2_1_1_2方法
各位可以自行分析
2.1.2 操作数栈指令
常见的操作数栈指令有pop、dup和swap。pop指令用于将栈顶的值出栈,一个常见的场景是调用了有返回值的方法,但是没有使用这个返回值,比如下面的代码。
public int test2_1_2_1(){
int i = 1;
int j = 2;
int k = int_2_2_1 + i +j;
return k;
}
public static void main(String[] args) {
new TestByteCode().test2_1_2_1();
}
mian方法的字节码为:
0 new #5 <com/zifang/util/core/TestByteCode>
3 dup
4 invokespecial #6 <com/zifang/util/core/TestByteCode.<init>>
7 invokevirtual #7 <com/zifang/util/core/TestByteCode.test2_1_2_1>
10 pop
11 return
其他指令大同小异,都是针对操作数栈本身的一些操作。这儿偷个懒,以后再全量的补充上。
2.2 运算和类型转换指令
java里面可以对数据进行加减乘除,也可以做与或非操作,如下操作与对应的字节码
我们java内如果有这样的语句: 1+1.0f,2是会转型变成float格式的,本质上是因为fadd指令只能接受两个float值,因此在字节码层面会使用i2f指令将int转换为float
fconst_1 // 将 1.0 入栈
iconst_1 // 将 1 入栈
i2f // 将栈顶的 1 的int 转为 float
fadd // 两个 float值相加
而boolean、char、byte、short虽然是不同的数据类型,但是在JVM层面它们都被当作int来处理,不需要显式转为int,字节码指令上也没有对应转换的指令。
这里有些指令比较特殊,自增指令(和自减指令套路一样):iinc,接受两个参量,a,b,a指局部变量表的位置,b是累加值。因此你会发现这个指令是直接操作局部变量表,栈顶的信息是没有动的。因此会出现很多奇奇怪怪的问题。
- i++实例
public static void test_2_2_1() {
int i = 0;
for (int j = 0; j < 50; j++) {
i = i++;
}
System.out.println(i);
}
查看字节码,获得到最核心的字节码
10 iload_0
11 iinc 0 by 1
14 istore_0
可以看到iload_0 把i=0的数据放到了操作数栈,然后自增,直接操作了局部变量表,这个时候局部变量表上的i已经变成1了,但是后面的istore_0又把操作数栈上i=0赋值给局部变量表,导致i=0,因此无论遍历多少次,都是0。
- ++i实例
还是一样的套路,制造一个方法出来
public static void test_2_2_2() {
int i = 0;
for (int j = 0; j < 50; j++) {
i = ++i;
}
System.out.println(i);
}
把最核心的字节码捞出来
10 iinc 0 by 1
13 iload_0
14 istore_0
你会看到,这回的字节码顺序和之前的不一样了,先是针对局部变量表,将数据自增,完成后load到操作数栈,然后再保存回操作数栈,修改是生效的。
2.3 流程控制
控制转移指令根据条件进行分支跳转,我们常见的 if-then-else、三目表达式、for 循环、异常处理等都属于这个范畴。对应的指令集包括:
- 条件分支:ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull、if_icmpeq、 if_icmpne、if_icmplt, if_icmpgt、if_icmple、if_icmpge、if_acmpeq 和 if_acmpne。
- 复合条件分支:tableswitch、lookupswitch
- 无条件分支:goto、goto_w、jsr、jsr_w、ret
2.3.1 if判断分支
我们制造这样的代码:
public int test2_3_1(int n){
if(n > 0){
return 1;
} else {
return 0;
}
}
得到的字节码为:
0 iload_1
1 ifle 6 (+5)
4 iconst_1
5 ireturn
6 iconst_0
7 ireturn
这里起到关键性作用的就是ifile指令。它的作用是将操作数栈顶元素出栈跟0进行比较,如果小于等于0则跳转到特定的字节码处,如果大于0则继续执行接下来的字节码。
1行里面,和栈顶的数据(iload1,从局部变量表1的位置上拿到n值)与0进行比较,晓宇0则跳到6行
其他类似指令套路一样,大家可以自行尝试一把。其他指令参考下表
2.3.2 for循环
我们制造如下java代码:
public void test_2_3_2(int[] c){
for(int i = 0; i < c.length; i++){
System.out.println(i);
}
}
反编译之后生成:
0 iconst_0
1 istore_2
2 iload_2
3 aload_1
4 arraylength
5 if_icmpge 21 (+16)
8 getstatic #3 <java/lang/System.out>
11 iload_2
12 invokevirtual #4 <java/io/PrintStream.println>
15 iinc 2 by 1
18 goto 2 (-16)
21 return
局部变量表为
0行:iconst_0,0压入栈(相当于i = 0的赋值操作)
1行:把0的数据压到index2的局部变量表slot上(i的坑位)
2行:iload2指令,把局部变量表的i值压入操作数栈
3行:aload_1指令把c数组压栈
4行:针对数据获得长度,压栈(c已经不在栈上了)
5行:if_icmpge指令比较栈顶的两个值(数组长度与i数据),即代码里面的 i < c.length是否成立,成立则继续执行,不成立则直接跳转21
8-12相当于 System.out.print(i);
15行进行自增,然后跳转到2行,再次进行比较
这样的逻辑路线组成了我们的for循环语句
2.3.3 for-each循环原理
java里面针对for循环做个改进,使用了语法糖来表达循环语义,例如
public void test_2_3_2_1_1(){
int[] numbers = new int[]{1, 2, 3};
for (int number : numbers) {
System.out.println(number);
}
}
public void test_2_3_2_1_2(){
List<String> a = new ArrayList<>();
a.add("a");
a.add("b");
a.add("c");
for (String item : a) {
System.out.println(item);
}
}
test_2_3_2_1_1方法编译后得到的字节码是
0 iconst_3
1 newarray 10 (int)
3 dup
4 iconst_0
5 iconst_1
6 iastore
7 dup
8 iconst_1
9 iconst_2
10 iastore
11 dup
12 iconst_2
13 iconst_3
14 iastore
15 astore_1
16 aload_1
17 astore_2
18 aload_2
19 arraylength
20 istore_3
21 iconst_0
22 istore 4
24 iload 4
26 iload_3
27 if_icmpge 50 (+23)
30 aload_2
31 iload 4
33 iaload
34 istore 5
36 getstatic #3 <java/lang/System.out>
39 iload 5
41 invokevirtual #4 <java/io/PrintStream.println>
44 iinc 4 by 1
47 goto 24 (-23)
50 return
局部变量表为:
你会发现复杂了很多,但是看上去事实上和for循环没什么两样,各位可以使用已经知道的知识进行推演。
使用反编译工具获得到test_2_3_2_1_1方法的源码:
public void test_2_3_2_1_1() {
int[] numbers = new int[]{1, 2, 3};
int[] var2 = numbers;
int var3 = numbers.length;
for(int var4 = 0; var4 < var3; ++var4) {
int number = var2[var4];
System.out.println(number);
}
}
你会发现,本质上就是for循环,编译器在执行生成字节码的时候,会针对语法糖进行解糖操作。
用同样的方式一步一步分析test_2_3_2_1_2方法获得到反编译出来的源码:
public void test_2_3_2_1_2() {
List<String> a = new ArrayList();
a.add("a");
a.add("b");
a.add("c");
Iterator var2 = a.iterator();
while(var2.hasNext()) {
String item = (String)var2.next();
System.out.println(item);
}
}
你会发现它使用了迭代器模式进行的功能循环。
迭代器接口位于Collection接口之上,意味着只要是我们java里面的集合类,都可以使用for-each循环带来的便捷。
2.3.3 swith-case分支
2.3.3.1 switch 跳转
同样的套路制造一个java代码
public int test_2_3_3_1_1(int i) {
switch (i) {
case 100: return 0;
case 101: return 1;
case 104: return 4;
default: return -1;
}
}
用javap反编译字节码:
0 iload_1
1 tableswitch 100 to 104 100: 36 (+35)
101: 38 (+37)
102: 42 (+41)
103: 42 (+41)
104: 40 (+39)
default: 42 (+41)
36 iconst_0
37 ireturn
38 iconst_1
39 ireturn
40 iconst_4
41 ireturn
42 iconst_m1
43 ireturn
可以看到,多了102与103,这部分是虚拟机自己帮忙填补上去的,指向default语句分支。这样可以实现 O(1) 时间复杂度的查找,通过游标就可以一次找到。
那么如果case里面的数值相差比较大,断层了怎么办呢?
public int test_2_3_3_1_2(int i) {
switch (i) {
case 1: return 0;
case 10: return 1;
case 100: return 4;
default: return -1;
}
}
以下对应的字节码
0 iload_1
1 lookupswitch 3
1: 36 (+35)
10: 38 (+37)
100: 40 (+39)
default: 42 (+41)
36 iconst_0
37 ireturn
38 iconst_1
39 ireturn
40 iconst_4
41 ireturn
42 iconst_m1
43 ireturn
你会发现这个时候使用的是lookupswitch指令。它的键值都是经过排序的,在查找上可以采用二分查找的方式,时间复杂度为 O(log n)
所谓稀疏是指javac下会对tableswitch 和 lookupswitch 进行代价的估算,算法的原因可能会导致少数选项下不会特别的区分开来。
2.3.3.2 String-switch
public int test_2_3_3_2(String name) {
switch (name) {
case "吃饭1":
return 100;
case "吃饭2":
return 200;
default:
return -1;
}
}
0 aload_1
1 astore_2
2 iconst_m1
3 istore_3
4 aload_2
5 invokevirtual #16 <java/lang/String.hashCode>
8 lookupswitch 2
21885863: 36 (+28)
21885864: 50 (+42)
default: 61 (+53)
36 aload_2
37 ldc #17 <吃饭1>
39 invokevirtual #18 <java/lang/String.equals>
42 ifeq 61 (+19)
45 iconst_0
46 istore_3
47 goto 61 (+14)
50 aload_2
51 ldc #19 <吃饭2>
53 invokevirtual #18 <java/lang/String.equals>
56 ifeq 61 (+5)
59 iconst_1
60 istore_3
61 iload_3
62 lookupswitch 2
0: 88 (+26)
1: 91 (+29)
default: 95 (+33)
88 bipush 100
90 ireturn
91 sipush 200
94 ireturn
95 iconst_m1
96 ireturn
反编译之后是这样的:
public int test_2_3_3_2(String name) {
byte var3 = -1;
switch(name.hashCode()) {
case 21885863:
if (name.equals("吃饭1")) {
var3 = 0;
}
break;
case 21885864:
if (name.equals("吃饭2")) {
var3 = 1;
}
}
switch(var3) {
case 0:
return 100;
case 1:
return 200;
default:
return -1;
}
}
可以看到,编译的时候会将case里面的数据拿出来hash化,为了防止hash冲突,在case下使用if真正确定逻辑的分支
2.4 面向对象编程
2.4.1 对象初始化指令
简单的说,涉及到对象初始化的指令有三个
new
<init>
<clinit>
而对象初始化又可以分为对象初始化与类的静态初始化
2.4.1.1 对象初始化
例如以下代码
A a = new A();
反编译字节码之后会有
0: new #2 // class A
3: dup
4: invokespecial #3 // Method A."<init>":()V
7: astore_1
一个对象创建的语句肯定是new,dup,invokespecial的三连语句。
new 只会创建一个object的实例,但是必须经过invokespecial指令间接调用<init>
之后才能承认这个Object是ok的。而new 完先塞到栈顶,在执行invokespecial之后把栈顶的初始化好了的object出栈。因此会导致astore_1没有东西存了,所以这个时候需要把栈顶的数据dup一下。
dup 是duplicate的缩写,意思就是复制,双倍。将栈顶的东西复制一遍,再压进去。
invokespecial就可以从栈顶拿到刚刚的Object了。
为啥要dup一下呢? 一开始是new指令在堆上分配了内存并向操作数栈压入了指向这段内存的引用,之后dup指令又备份了一份,那么操作数栈顶就有两个,再后是调用invokespecial #18指令进行初始化,此时会消耗一个引用作为传给构造器的“this”参数,那么还剩下一个引用,会被astore_1指令存储到局部变量表中。
2.4.1.2 类的静态初始化
<clinit>
是类的静态初始化,比<init>
早一点,不会直接被调用。
它在下面这个四个指令触发调用:new, getstatic, putstatic , invokestatic
。
也就是说,初始化一个类实例、访问一个静态变量或者一个静态方法,类的静态初始化方法就会被触发。
例如:
public class Initializer {
static int a;
static int b;
static {
a = 1;
b = 2;
}
}
// 部分字节码如下
static {};
0: iconst_1
1: putstatic #2 // Field a:I
4: iconst_2
5: putstatic #3 // Field b:I
8: return
2.4.1.3 常见面试题从字节码角度解决
- A a = new B(); 输出结果及正确的顺序
public class A {
static {
System.out.println("A init");
}
public A() {
System.out.println("A Instance");
}
}
public class B extends A {
static {
System.out.println("B init");
}
public B() {
System.out.println("B Instance");
}
}
public B();
0: aload_0
1: invokespecial #1 // Method A."<init>":()V
4: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
7: ldc #3 // String B Instance
9: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
12: return
被动驱动的逻辑先行,B初始化驱动B的类静态初始化,B的静态初始化会驱动父类的类的静态初始化。
子类初始化必须先调用父类的初始化方法,因此就会有:
a)A的静态初始化
b)B的类的静态初始化
c) A类的实例初始化
d)B类的实例初始化
- B[] arr = new B[10]的输出
bipush 10
anewarray 'B'
astore 1
可以看到没有使用任何和初始化相关的的指令自然没有进行初始化的必要。
2.4.2 方法调用指令
之前已经接触过了invokespecial,和他一样麻烦的的还有4个,这tm的。
- invokestatic:用于调用静态方法
- invokespecial:用于调用私有实例方法、构造器,以及使用 super 关键字调用父类的实例方法或构造器,和所实现接口的默认方法
- invokevirtual:用于调用非私有实例方法
- invokeinterface:用于调用接口方法
- invokedynamic:用于调用动态方法
技术存在是有目的性的,肯定是为了解决实实在在的需求。为什么一个简单的方法调用会分裂成5兄弟呢?
你看java里面只要在编译器阶段就能确定了行为的,那就可以说是静态绑定。需要在运行时根据调用者的类型动态识别的叫动态绑定
——这个术语太难了,以前我是不能理解的,你运行期怎么干的,我看代码也能猜出来的,JVM没理由不知道啊。
咱们换个说法——你看,构造器不能被覆盖吧,对这就是静态绑定的,static方法不能被覆盖吧,对,这也是静态绑定的——其他的,统统都是动态的!什么多态啊,子类重写父类啊,什么的,统统都是静态绑定的!
那么好了,invokestatic 和 invokespecial 这俩就是负责,调用静态绑定方法,和构造器(包括private方法,不能被覆盖的静态绑定方法)
剩下的都是调用动态绑定的方法的。
解析与分派的认识----todo
1.方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期间是不可变的,即“编译时可知,运行不可以变”,这类目标的方法的调用称之为解析
2.解析调用一定是个静态的过程,在编译期就完全确定,在类加载的解析阶段就将涉及的符号引用全部转变为可以确定的直接引用,不会延迟到运行期再去完成。而分派(Dispatch)调用则可能是静态的也可能是动的。于是分派方式就有静态分派和动态分派。
静态分派的最直接的解释是在重载的时候是通过参数的静态类型而不是实际类型作为判断依据的。因此在编译阶段,Javac编译器会根据参数的静态类型决定使用哪个重载版本。
显然这里不可能根据静态类型来决定调用那个方法。导致这个现象很明显的原因是因为这两个变量的实际类型不一样,jvm根据实际类型来分派方法执行版本。
2.4.2.1 invokevirtual
用来调用 public、protected、package 访问级别的方法。如下:
public class Color {
public void printColorName() {
System.out.println("Color name from parent");
}
}
public class Red extends Color {
@Override
public void printColorName() {
System.out.println("Color name is Red");
}
}
public class Yellow extends Color {
@Override
public void printColorName() {
System.out.println("Color name is Yellow");
}
}
public class InvokeVirtualTest {
private static Color yellowColor = new Yellow();
private static Color redColor = new Red();
public static void main(String[] args) {
yellowColor.printColorName();
redColor.printColorName();
}
}
输出
Color name is Yellow
Color name is Red
以下是字节码
0: getstatic #2 // Field yellowColor:LColor;
3: invokevirtual #3 // Method Color.printColorName:()V
6: getstatic #4 // Field redColor:LColor;
9: invokevirtual #3 // Method Color.printColorName:()V
可以看到 3 和 9 行指令完全一样,都是Color.printColorName
,并没有被编译器改写为Yellow.printColorName
和Red.printColorName
。它们最终调用的目标方法却不同,invokevirtual 会根据对象的实际类型进行分派(虚方法分派),在编译期间不能确定最终会调用子类还是父类的方法。
2.4.2.2 invokeinterface
看名字就知道专门调用接口方法的,同样是调用动态绑定方法的。
那它与invokevirtual有什么区别,为什么不用invokevirtual来实现接口方法的调用呢?
这里需要引入一个新的概念:Java方法分派
在讨论多态(一般叫运行时多态)的时候,不可避免地要和重载(Overload)进行对比
- 虚拟机会在类的方法区建立一个虚拟方法表的数据结构(virtual method table,vtable)。
方法表会在类的连接阶段初始化,方法表存储的是该类方法入口的一个映射,如果子类继承了父类,但是某个父类的方法没有被子类重写,那么在子类的方法表里边该方法指向的是父类的方法的入口,子类并不会重新生成一个方法,然后让方法表去指向这个生成的,这样做是没有意义的。还有一点,如果子类重写了父类的方法,那么子类这个被重写的方法的索引和父类的该方法的索引是一致。这样做的目的是为了快速查找,比如说在子类里边找不到一个方法索引为1的方法,那么jvm会直接去父类查找方法索引为1的方法,不需要重新在父类里边遍历。 - 针对于invokeinterface指令来说,虚拟机会建立一个叫做接口方法表的数据结构(interface method table,itable)
在需要调用某个接口方法时,虚拟机会在itable的offset table中查找到对应的方法表位置和方法位置,随后在method table中查找具体的方法实现。invokevirtual的实现依赖于Java的单继承特性,子类的虚方法表保留了父类虚方法表的顺序,但是因为Java的多接口实现,这一特性无法使用。
vtable、itable机制是实现Java多态的基础。
- 子类会继承父类的vtable。因为Java类都会继承Object类,Object中有5个方法可以被继承,所以一个空Java类的vtable的大小也等于5。
- 被final和static修饰的方法不会出现在vtable中,因为没有办法被继承重写,同理可以知道private修饰的方法也不会出现在vtable中。
- 接口方法的调用使用invokeinterface指令,Java使用itable来支持多接口实现,itable由offset table和method table两部分组成。在调用接口方法时,会先在offset table中查找method table的偏移量位置,随后在method table查找具体的接口实现。
2.4.2.3 invokeDynamic
jdk7引入,那个时候自己不用,然后groovy,JRuby,Kotlin之类的就开始开花了,璀璨的不得了。JDK8的时候lamda表达式终于开始用上这个指令了。他相当于编译时谁都不知道执行什么,真正的逻辑被下放到用户的代码里头。
这里会有一个非常重要的核心类:MethodHandle。
MethodHandle 又被称为方法句柄或方法指针, 是java.lang.invoke
包中的 一个类,它的出现使得 Java 可以像其它语言一样把函数当做参数进行传递。这个东西看其里很像Method反射,但是比起Method更为轻量级,也能享受到jit带来的提效。
下面以一个实际的例子来看 MethodHandle 的用法
public class Foo {
public void print(String s) {
System.out.println("hello, " + s);
}
public static void main(String[] args) throws Throwable {
Foo foo = new Foo();
MethodType methodType = MethodType.methodType(void.class, String.class);
MethodHandle methodHandle = MethodHandles.lookup().findVirtual(Foo.class, "print", methodType);
methodHandle.invokeExact(foo, "world");
}
}
// 运行输出
hello, world
使用 MethodHandle 的方法的步骤是:
- 创建 MethodType 对象。MethodType 用来表示方法签名,每个 MethodHandle 都有一个 MethodType 实例,用来指定方法的返回值类型和各个参数类型
- 调用 MethodHandles.lookup 静态方法返回
MethodHandles.Lookup
对象,这个对象是查找的上下文,根据方法的不同类型通过 findStatic、findSpecial、findVirtual 等方法查找方法签名为 MethodType 的方法句柄 - 拿到方法句柄以后就可以执行了。通过传入目标方法的参数,使用
invoke
或者invokeExact
就可以进行方法的调用。
例如kotlin或者是groovy,都有点类似于脚本语言,而脚本语言和静态语言的区别就是,入参随便放,能不能执行要去执行之后才知道
这里我们制造一个Test.groovy出来
def add(a, b) {
new Exception().printStackTrace()
return a + b
}
这里groovy在将代码翻译成.class就遇到了问题。a,b我不知道是个啥啊?我怎么造方法呢?那就用invoke_dynamic,使用invoke_dynamic指令之后,就可以将代码翻译成这样:
public static void main(String[] args) throws Throwable {
MethodHandles.Lookup lookup = MethodHandles.lookup();
MethodType mt = MethodType.methodType(Object.class,Object.class, Object.class);
CallSite callSite = IndyInterface.bootstrap(lookup, "invoke", mt,"add", 0);
MethodHandle mh = callSite.getTarget();
mh.invokeExact(obj, "hello", "world");
}
这里的IndyInterface是groovy提供的切入口,那么释放给groovy的权利就很大了,直接可以控制具体怎么执行,下发到groovy提供的jar里面。
当然,invoke_dynamic指令不单是在不同类语言中大放异彩,在java8引入的lamda表达式内同样可以看到其风采。
2.4.3 多态原理
*_解析_
所有方法调用中的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中一部分符号引用转化为直接引用,这种解析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且运行期不可变。
java虚拟机中提供了5条方法调用字节码指令,分别如下:
- invokestatic:调用静态方法
- invokespecial:调用实力构造器方法,私有方法和父类方法
- invokevirtual:调用所有的虚方法
- invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象
- invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,在此之前的四条调用指令,分派逻辑是固化在java虚拟机内部的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的
只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段确定唯一的调用版本,符合这个条件的静态方法,私有方法,实例构造器,父类方法四类,它们在类加载的时候就会把符号引用解析为该方法的直接引用。这些方法可以称为非虚方法,与之相反,其他方法称为虚方法(除去final方法)。final虽然使用invokevirtual调用,但是仍然是非虚方法。非虚方法是不可以被重写的。
解析调用一定是个静态的过程,在编译期间就完全确定。而分派调用可能是静态可能是动态的。
*_分派_
1、静态分派
静态分派的典型应用是重载,重载根据参数的静态类型而不是实际类型作为判定依据。而静态类型在编译期可知,所以静态分派发生在编译阶段。
另外虽然编译器能确定方法的重载版本,但是在很多情况下重载版本不唯一,往往只能确定一个更加合适的版本。主要原因是字面量作为参数传入是没有显式的静态类型的。只能选择一个最贴近该字面型类型的方法。实际工作中要避免出现。
关于分派与解析的关系:并不是排他关系,而是在不同层次上去筛选,确定目标方法的过程。前面说过,静态方法会在类加载时就进行解析,而静态方法显然也是可以拥有重载版本的,选择重载版本也是通过静态分派来完成的。
2、动态分派
动态分派的典型应用是重写,运行期根据方法接收者的实际类型来选择方法。invokevirtual指令的工作过程是,优先寻找当前类中是否有该方法,如有直接选择该方法,若没有找到,则在父类中寻找,直到找到为止。
靠的是父类或接口定义的引用变量可以指向子类或具体实现类的实例对象,而程序调用的方法在运行期才动态绑定,就是引用变量所指向的具体实例对象的方法,也就是内存里正在运行的那个对象的方法,而不是引用变量的类型中定义的方法。
多态可分为:
1.编译多态:主要是体现在重载,系统在编译时就能确定调用重载函数的哪个版本。
2.运行多态:主要体现在OO设计的继承性上,子类的对象也是父类的对象,即上溯造型,所以子类对象可以作为父类对象使用,父类的对象变量可以指向子类对象。因此通过一个父类发出的方法调用可能执行的是方法在父类中的实现,也可能是某个子类中的实现,它是由运行时刻具体的对象类型决定的。
方法的重写Overriding和重载Overloading是Java多态性的不同表现.
重写是父类与子类之间多态性的一种表现,重载是一类中多态性的表现
2.5 异常处理机制
我们制造一个异常处理的代码:
public void test2_5_1_exception(){
throw new RuntimeException();
}
public void test2_5_1_handler(Exception e){
System.out.println("捕获到异常");
}
public void test2_5_1(){
try {
test2_5_1_exception();
} catch (RuntimeException e) {
test2_5_1_handler(e);
}
}
得到的字节码为:
0 aload_0
1 invokevirtual #23 <com/zifang/util/core/TestByteCode.test2_5_1_exception>
4 goto 13 (+9)
7 astore_1
8 aload_0
9 aload_1
10 invokevirtual #24 <com/zifang/util/core/TestByteCode.test2_5_1_handler>
13 return
这里还有异常表信息,为:
在编译后字节码中,每个方法都附带一个异常表(Exception table),异常表里的每一行表示一个异常处理器,由 from 指针、to 指针、target 指针、所捕获的异常类型 type 组成。这些指针的值是字节码索引,用于定位字节码 其含义是在[from, to)
字节码范围内,抛出了异常类型为type
的异常,就会跳转到target
表示的字节码处。 比如,上面的例子异常表表示:在0
到4
中间(不包含 4)如果抛出了RuntimeException
的异常,就跳转到7
执行。
有很多个catch的时候
public void test2_5_2(){
try {
test2_5_1_exception();
} catch (NullPointerException e) {
test2_5_1_handler(e);
}catch (RuntimeException e){
test2_5_1_handler(e);
}
}
可以看到多一个异常,会在异常表(Exception table 里面多一条记录)。
当程序出现异常时,Java 虚拟机会从上至下遍历异常表中所有的条目。当触发异常的字节码索引值在某个异常条目的[from, to)
范围内,则会判断抛出的异常与该条目想捕获的异常是否匹配。
- 如果匹配,Java 虚拟机会将控制流跳转到 target 指向的字节码;如果不匹配则继续遍历异常表
- 如果遍历完所有的异常表,还未匹配到异常处理器,那么该异常将蔓延到调用方(caller)中重复上述的操作。最坏的情况下虚拟机需要遍历该线程 Java 栈上所有方法的异常表
如果增加finally
public void test2_5_3(){
try {
test2_5_1_exception();
} catch (NullPointerException e) {
test2_5_1_handler(e);
}finally {
test2_5_finally();
}
}
public void test2_5_finally(){
System.out.println("finally语句块");
}
获得到字节码与异常表为:
0 aload_0
1 invokevirtual #24 <com/zifang/util/core/TestByteCode.test2_5_1_exception>
4 aload_0
5 invokevirtual #27 <com/zifang/util/core/TestByteCode.test2_5_finally>
8 goto 31 (+23)
11 astore_1
12 aload_0
13 aload_1
14 invokevirtual #25 <com/zifang/util/core/TestByteCode.test2_5_1_handler>
17 aload_0
18 invokevirtual #27 <com/zifang/util/core/TestByteCode.test2_5_finally>
21 goto 31 (+10)
24 astore_2
25 aload_0
26 invokevirtual #27 <com/zifang/util/core/TestByteCode.test2_5_finally>
29 aload_2
30 athrow
31 return
可以看到,字节码中包含了三份 finally 语句块,都在程序正常 return 和异常 throw 之前。其中两处在 try 和 catch 调用 return 之前,一处是在异常 throw 之前。
Java 采用方式是复制 finally 代码块的内容,分别放在 try catch 代码块所有正常 return 和 异常 throw 之前。
因此人肉翻译一下上面的字节码的等效代码:
public void foo() {
try {
test2_5_1_exception();
test2_5_finally();
} catch (NullPointerException e) {
try {
test2_5_1_handler(e);
} catch (Throwable e2) {
test2_5_finally();
throw e2;
}
} catch (Throwable e) {
test2_5_finally();
throw e;
}
}
好有了这个基础之后,试分析:
public int test2_5_3_1() {
try {
int a = 1 / 0;
return 0;
} catch (Exception e) {
int b = 1 / 0;
return 1;
} finally {
return 2;
}
}
public int test2_5_3_2() {
int i = 100;
try {
return i;
} finally {
++i;
}
}
public int test2_5_3_3() {
int i = 100;
try {
return i;
} finally {
i++;
}
}
public String test2_5_3_4() {
String s = "hello";
try {
return s;
} finally {
s = "xyz";
}
}
这几个方法都会返回什么?
2.6 泛型原理
制造如下的代码
public class Pair<T> {
public T first;
public T second;
public Pair(T first, T second) {
this.first = first;
this.second = second;
}
}
public void foo(Pair<String> pair) {
String left = pair.left;
}
获得到foo的字节码为:
0: aload_1
1: getfield #2 // Field left:Ljava/lang/Object;
4: checkcast #4 // class java/lang/String
7: astore_2
8: return
可以看到left字段的字段类型为Object,并不是String。
checkcast指令用来检查对象是否符合给定类型,如果不符合条件,则抛出java.lang.ClassCastException异常。
将上面的代码翻译过来就是:
public class Pair<T> {
public Object first;
public Object second;
public Pair(T first, T second) {
this.first = first;
this.second = second;
}
}
public void foo(Pair pair) {
String left = (String)pair.left;
}
理解泛型概念的最重要的是理解类型擦除。
例如在一个类内定义:
public void print(List<String> list) { }
public void print(List<Integer> list) { }
这样是会在编译期报错的,真正的代码内都已经变成了print(List list),而JVM不允许相同签名的方法在一个类中同时存在,所以上面代码编译会失败。
2.7 lambda表达式原理
2.7.1 匿名内部类实现方式
public void test2_7_1() {
Runnable r1 = new Runnable() {
public void run() {
System.out.println("hello, inner class");
}
};
r1.run();
}
生成的字节码是:
0 new #31 <com/zifang/util/core/TestByteCode$1>
3 dup
4 aload_0
5 invokespecial #32 <com/zifang/util/core/TestByteCode$1.<init>>
8 astore_1
9 aload_1
10 invokeinterface #33 <java/lang/Runnable.run> count 1
15 return
人肉一把看出执行的代码为
class TestByteCode$1 implements Runnable {
public TestByteCode$1(TestByteCode var) {
}
@Override
public void run() {
System.out.println("hello, inner class");
}
public class TestByteCode {
public void test2_7_1(){
Runnable r1 = new TestByteCode$1(this);
r1.run();
}
}
相当于匿名内部类会生成出一个class类,将自身传递到这个类内,然后由外部类调用。
2.7.2 lambda 表达式实现的方式
public void test2_7_2(){
Runnable r1 = () -> System.out.println("hello, inner class");
r1.run();
}
使用java -p -v (不然lambda$test2_7_2$0
不会出现) 反编译得到字节码为
public void test2_7_2();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=2, args_size=1
0: invokedynamic #34, 0 // InvokeDynamic #0:run:()Ljava/lang/Runnable;
5: astore_1
6: aload_1
7: invokeinterface #33, 1 // InterfaceMethod java/lang/Runnable.run:()V
12: return
LineNumberTable:
line 197: 0
line 198: 6
line 199: 12
LocalVariableTable:
Start Length Slot Name Signature
0 13 0 this Lcom/zifang/util/core/TestByteCode;
6 7 1 r1 Ljava/lang/Runnable;
private static void lambda$test2_7_2$0();
descriptor: ()V
flags: ACC_PRIVATE, ACC_STATIC, ACC_SYNTHETIC
Code:
stack=2, locals=0, args_size=0
0: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #44 // String hello, inner class
5: invokevirtual #15 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 197: 0
BootstrapMethods:
0: #161 invokestatic java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
Method arguments:
#162 ()V
#163 invokestatic com/zifang/util/core/TestByteCode.lambda$test2_7_2$0:()V
#162 ()V
可以看到多了一个lambda$test2_7_2$0的static方法,人肉一把:
private static void lambda$main$0() {
System.out.println("hello, inner class");
}
当前常量池为:
Constant pool:
#34 = InvokeDynamic #0:#164 // #0:run:()Ljava/lang/Runnable;
#164 = NameAndType #197:#200 // run:()Ljava/lang/Runnable;
BootstrapMethods:
0: #161 invokestatic java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
Method arguments:
#162 ()V
#163 invokestatic com/zifang/util/core/TestByteCode.lambda$test2_7_2$0:()V
#162 ()V
其中#0
是一个特殊的查找,对应 BootstrapMethods 中的 0 行,可以看到这是一个对静态方法 LambdaMetafactory.metafactory() 的调用,它的返回值是 java.lang.invoke.CallSite 对象,这个对象代表了真正执行的目标方法调用。
// LambdaMetafactory#metafactory 方法
public static CallSite metafactory(MethodHandles.Lookup caller,
String invokedName,
MethodType invokedType,
MethodType samMethodType,
MethodHandle implMethod,
MethodType instantiatedMethodType)
throws LambdaConversionException {
AbstractValidatingLambdaMetafactory mf;
mf = new InnerClassLambdaMetafactory(caller, invokedType,
invokedName, samMethodType,
implMethod, instantiatedMethodType,
false, EMPTY_CLASS_ARRAY, EMPTY_MT_ARRAY);
mf.validateMetafactoryArgs();
return mf.buildCallSite();
}
- caller:表示JVM提供的查找上下文。
- invokedName:表示调用函数名,在本例中invokedName为“run”。
- samMethodType:表示函数式接口定义的方法签名(参数类型和返回值类型),本例中run方法的签名为“()void”。
- implMethod:表示编译时生成的Lambda表达式对应的静态方法invokestatic TestByteCode.lambda$test2_7_2$0
- instantiatedMethodType:一般和samMethodType是一样或是它的一个特例,在本例中是“()void”。
这里最重要也是最复杂的是:InnerClassLambdaMetafactory方法调用
public InnerClassLambdaMetafactory(MethodHandles.Lookup caller,
MethodType invokedType,
String samMethodName,
MethodType samMethodType,
MethodHandle implMethod,
MethodType instantiatedMethodType,
boolean isSerializable,
Class<?>[] markerInterfaces,
MethodType[] additionalBridges)
throws LambdaConversionException {
super(caller, invokedType, samMethodName, samMethodType,
implMethod, instantiatedMethodType,
isSerializable, markerInterfaces, additionalBridges);
implMethodClassName = implDefiningClass.getName().replace('.', '/');
implMethodName = implInfo.getName();
implMethodDesc = implMethodType.toMethodDescriptorString();
implMethodReturnClass = (implKind == MethodHandleInfo.REF_newInvokeSpecial)
? implDefiningClass
: implMethodType.returnType();
constructorType = invokedType.changeReturnType(Void.TYPE);
lambdaClassName = targetClass.getName().replace('.', '/') + "$$Lambda$" + counter.incrementAndGet();
cw = new ClassWriter(ClassWriter.COMPUTE_MAXS);
int parameterCount = invokedType.parameterCount();
if (parameterCount > 0) {
argNames = new String[parameterCount];
argDescs = new String[parameterCount];
for (int i = 0; i < parameterCount; i++) {
argNames[i] = "arg$" + (i + 1);
argDescs[i] = BytecodeDescriptor.unparse(invokedType.parameterType(i));
}
} else {
argNames = argDescs = EMPTY_STRING_ARRAY;
}
}
在这个方法内部默默地生成了内部类,类名的规则是ClassName$ L a m b d a Lambda Lambdan。其中ClassName是Lambda所在的类名,后面的数字n按生成的顺序依次递增。这个类在底层是使用asm的方式生成的,类的样子是:
final class TestByteCode$$Lambda$1 implements Runnable {
@Override
public void run() {
TestByteCode.lambda$test2_7_2$0();
}
}
因此整体而言,dynamic的执行顺序是:
- lambda 表达式声明的地方会生成一个 invokedynamic 指令,同时编译器生成一个对应的引导方法(Bootstrap Method)
- 第一次执行 invokedynamic 指令时,会调用对应的引导方法(Bootstrap Method),该引导方法会调用 LambdaMetafactory.metafactory 方法动态生成内部类
- 引导方法会返回一个动态调用 CallSite 对象,这个 CallSite 会链接最终调用的实现了 Runnable 接口的内部类
- lambda 表达式中的内容会被编译成静态方法,前面动态生成的内部类会直接调用该静态方法
- 真正执行 lambda 调用的还是用 invokeinterface 指令
public class TestByteCode{
public void test2_7_2(){
Runnable r1 = () -> System.out.println("hello, inner class");
r1.run();
}
}
public class TestByteCode{
public void test2_7_2(){
// 这里的代码等价于 一大堆 CallSite的类似于method的方法句柄调用
call TestByteCode$$Lambda$1 # run()
}
}
private static void lambda$main$0() {
System.out.println("hello, inner class");
}
final class TestByteCode$$Lambda$1 implements Runnable {
@Override
public void run() {
TestByteCode.lambda$test2_7_2$0();
}
}
2.8 synchronized实现原理
Synchronized 太重要了,是并发编程里面根本绕不开的坎。
当我们使用Synchronized关键字包裹一个Object的时候:
private Object lock = new Object();
public void foo() {
synchronized (lock) {
bar();
}
}
public void bar() { }
反编译后:
public void foo();
Code:
0: aload_0
1: getfield #3 // Field lock:Ljava/lang/Object;
4: dup
5: astore_1
6: monitorenter
7: aload_0
8: invokevirtual #4 // Method bar:()V
11: aload_1
12: monitorexit
13: goto 21
16: astore_2
17: aload_1
18: monitorexit
19: aload_2
20: athrow
21: return
Exception table:
from to target type
7 13 16 any
16 19 16 any
- 0 ~ 5:将 lock 对象入栈,使用 dup 指令复制栈顶元素,并将它存入局部变量表位置 1 的地方,现在栈上还剩下一个 lock 对象
- 6:以栈顶元素 lock 做为锁,使用 monitorenter 开始同步
- 7 ~ 8:调用 bar() 方法
- 11 ~ 12:将 lock 对象入栈,调用 monitorexit 释放锁
当我们使用synchronized修饰一个方法时:
synchronized public void testMe() {
}
// 对应字节码
public synchronized void testMe();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
JVM 不会使用特殊的字节码来调用同步方法,当 JVM 解析方法的符号引用时,它会判断方法是不是同步的(检查方法 ACC_SYNCHRONIZED 是否被设置)。如果是,执行线程会先尝试获取锁。如果是实例方法,JVM 会尝试获取实例对象的锁,如果是类方法,JVM 会尝试获取类锁。在同步方法完成以后,不管是正常返回还是异常返回,都会释放锁。
2.10 字节码与反射
针对同一个方法反射执行超过一定的上限之后,将会使用asm制造出一个新的类来调用。详情以后吧,我累了。
小结
没有小结,我累了
附录. 字节码指令集
事实上我们接下去的学习都是围绕着字节码指令,字节码指令可以被分割成很多个具有类似含义的组别,之后的学习如果追求速度,基本上可以组别单独拿一个指令出来玩一玩。在这里我们把所有的指令都展示一下