第三章 - JVM,Java程序员的OS
3.2 跨平台和字节码基本原理
3.2.1 javap 命令工具
public class StringTest {
public static void main(String[] args) {
String a = "a" + "b" + 1;
String b = "ab1";
System.out.println(a == b);
}
}
上面这段代码先经过 javac -g:vars StringTest.java
之后再使用 javap -verbose StringTest
, 来使用这里的命令来论证我们之前提到过的结论。
Last modified 12 Mar, 2020; size 609 bytes
MD5 checksum 72c295fac93103d4c254971bd5a25a19
public class JavaTeZhongBing.StringTest
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #6.#24 // java/lang/Object."<init>":()V
#2 = String #25 // ab1
#3 = Fieldref #26.#27 // java/lang/System.out:Ljava/io/PrintStream;
#4 = Methodref #28.#29 // java/io/PrintStream.println:(Z)V
#5 = Class #30 // JavaTeZhongBing/StringTest
#6 = Class #31 // java/lang/Object
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LocalVariableTable
#11 = Utf8 this
#12 = Utf8 LJavaTeZhongBing/StringTest;
#13 = Utf8 main
#14 = Utf8 ([Ljava/lang/String;)V
#15 = Utf8 args
#16 = Utf8 [Ljava/lang/String;
#17 = Utf8 a
#18 = Utf8 Ljava/lang/String;
#19 = Utf8 b
#20 = Utf8 StackMapTable
#21 = Class #16 // "[Ljava/lang/String;"
#22 = Class #32 // java/lang/String
#23 = Class #33 // java/io/PrintStream
#24 = NameAndType #7:#8 // "<init>":()V
#25 = Utf8 ab1
#26 = Class #34 // java/lang/System
#27 = NameAndType #35:#36 // out:Ljava/io/PrintStream;
#28 = Class #33 // java/io/PrintStream
#29 = NameAndType #37:#38 // println:(Z)V
#30 = Utf8 JavaTeZhongBing/StringTest
#31 = Utf8 java/lang/Object
#32 = Utf8 java/lang/String
#33 = Utf8 java/io/PrintStream
#34 = Utf8 java/lang/System
#35 = Utf8 out
#36 = Utf8 Ljava/io/PrintStream;
#37 = Utf8 println
#38 = Utf8 (Z)V
{
public JavaTeZhongBing.StringTest();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this LJavaTeZhongBing/StringTest;
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: ldc #2 // String ab1
2: astore_1
3: ldc #2 // String ab1
5: astore_2
6: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
9: aload_1
10: aload_2
11: if_acmpne 18
14: iconst_1
15: goto 19
18: iconst_0
19: invokevirtual #4 // Method java/io/PrintStream.println:(Z)V
22: return
LocalVariableTable:
Start Length Slot Name Signature
0 23 0 args [Ljava/lang/String;
3 20 1 a Ljava/lang/String;
6 17 2 b Ljava/lang/String;
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */
offset_delta = 18
locals = [ class "[Ljava/lang/String;", class java/lang/String, class java/lang/String ]
stack = [ class java/io/PrintStream ]
frame_type = 255 /* full_frame */
offset_delta = 0
locals = [ class "[Ljava/lang/String;", class java/lang/String, class java/lang/String ]
stack = [ class java/io/PrintStream, int ]
}
还是一样解析:
开头的一部分是常量池,每一项的开头都是 const #数字
,这个数字是顺序递增的,通常叫做入口位置。根据入口位置找某些常量内容,常量内容分为很多种。每个常量池项最前面的一个字节,用来表示常量的类型(我们所看到的后面的备注,比如Method,Class等等,都是映射转化之后得到的,字节码之中只有一个字节来存放)。
接下来是内容,内容可以直接存放在常量池的入口,也可能由其他的一个或者几个常量池域组合而成。下面讲几个例子:
例子1:
` #1 = Methodref #6.#24 // java/lang/Object.”
入口#1,代表一个方法入口,方法入口由 #6 和 #24 组成,中间用了一个 . 分割。
` #6 = Class #31 // java/lang/Object`
` #24 = NameAndType #7:#8 // “
入口 #6,是一个class,class是一个引用,所以其引用了 #31 的常量池。
入口 #21 代表一个表示名称和类型(NameAndType),分别由入口 #7 和 入口#8组成。
` #7 = Utf8
#8 = Utf8 ()V
` #31 = Utf8 java/lang/Object`
入口#7 是一个常量池内容 <init>
,代表构造方法。
入口 #8 是一个真正的常量,值是 ()V ,其没有入口参数,所以返回值是 void。将入口 #7,#8 反推到入口 #24,就代表这个构造方法的名称,入口参数的个数为0,返回值是 void。
入口#28是一个常量,其值为java/lang/Object
,但是这个只是一个字符串,反推到#6,就要求这个字符串代表一个类,那么可以推得其代表的类是java,lang.Object
。
那么将这三部分统一起来,其代表的就是 java.lang.Object 类型的构造方法,入口参数的个数为0,返回值为 void。注意,这部分实际在 const#1 之后的备注已经表示出来了(这部分备注在字节码之中并不存在,只是 javap 工具帮助合并的)。
例子2:
` #2 = String #25 // ab1`
代表将会有一个String类型的引用入口,而引用的都是入口#22的内容。
` #25 = Utf8 ab1`
代表常量池之中会存放内容 ab1.
那么上面二者综合起来就是:一个String对象的常量,存放的值是 ab1.
例子3:
` #3 = Fieldref #26.#27 // java/lang/System.out:Ljava/io/PrintStream;`
` #4 = Methodref #28.#29 // java/io/PrintStream.println:(Z)V`
入口#3代表一个属性,这个属性引用了入口#26的类,入口#27的具体属性。
入口#4代表一个方法,引用了入口#28的类,#29 的具体方法。
#26 = Class #34 // java/lang/System
#27 = NameAndType #35:#36 // out:Ljava/io/PrintStream;
#28 = Class #33 // java/io/PrintStream
#29 = NameAndType #37:#38 // println:(Z)V
入口 #26代表一个 class,其也是一个引用,引用了入口#34的常量。
入口 #27 代表名称和类型(NameAndType),其对应入口 #35, #36
入口 #28 引用入口 #30
入口 #29 代表名称和类型,也是一个返回值+引用类型。对应入口 #37. #38。
#33 = Utf8 java/io/PrintStream
#34 = Utf8 java/lang/System
#35 = Utf8 out
#36 = Utf8 Ljava/io/PrintStream;
#37 = Utf8 println
#38 = Utf8 (Z)V
入口 #33 对应的常量池是 java/io/PrintStream
。反推到入口 #28,代表类java.lang.PrintStream
。
入口 #34 对应的常量是 java/lang/System
,反推到入口 #26,代表类java.lang.System
。
入口 #35 对应的常量池是 out ,反推到入口 #27,而入口#27 要求名称和类型,此处明显是返回的名称。
入口 #36 对应是Ljava/io/PrintStream;
, 反推到入口 #27,已经知道其要求的是名称和类型了,那么此处明显返回的是类型,也就是 out 的类型是 java.io.PrintStream
。
入口 #37 对应的是 println,反推到入口 #29, 其需要名称和类型,那么此处返回的是名称,其名称为 println
入口 #38 对应的是 (Z)V
,反推到入口#29, 可得此处返回的是类型,即代表入口参数为Z(代表boolean),返回参数为V(void)。
那么将这些综合起来,就可以知道其要执行的操作为:
入口 #3是获取到 java/lang/System 类的属性 out,其 out 的类型是 Ljava/io/PrintStream;
入口 #4 是获取到 java/io/PrintStream 类的 println 方法,方法的返回值是 void,入口的类型是 boolean。
这个常量池仅仅是操作的陈列,还没有真正的执行任务。执行任务的部分在下面:
public JavaTeZhongBing.StringTest();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this LJavaTeZhongBing/StringTest;
我们从第一行开始讲解:
descriptor
:这部分代表是其传入参数为空,有一个空的返回类型(V)。
flags
:这部分代表这个部分的属性,此处这个部分的属性为 ACC_PUBLIC。
可以看出这个是一个构造方法,虽然我们在程序之中并没显性的定义,但是 Java 会帮助我们生成一个,说明这个动作是在编译的时候完成的。
stack=1, locals=1, args_size=1
:其中的 Stack 代表栈顶的单位大小(每一个大小为一个 slot),当需要使用一个数据的时候,其首先会被放在栈顶,使用完之后会回写到本地变量或者主存之中。
笔者自己的疑问和寻找的方式:
此处原书之中讲,一个 slot 就是 4个字节宽,但是我有一些疑问:在64位的机器上面,也是一个 slot 32 位吗?查到一些资料,虽然不敢确定,但是对于其中的定义有一些自己的看法:
参考资料:https://www.cnblogs.com/wuzhiwei549/p/9162673.html
其中这一段:
局部变量表的容量以变量槽(Variable Slot)为最小单位,虚拟机规范中并没有明确指明一个Slot暂用的内存空间大小,只是很有“导向性”地说明每个Slot都应该能存放一个boolean,byte,char,short,int,float,refrence,returnAddress类型的数据,这种描述明确指出 “每个Slot占用32位长度的内存空间” 有一些差别,它允许Slot的长度随着处理器,操作系统或虚拟机的不同而发生变化。不过无论如何,即使在64位虚拟机中使用64位长度的内存空间来实现Slot,虚拟机仍要使用对齐和补白的手段让Slot在外观上看起来和32位虚拟机中得一致。
那么意味着JVM并没对于 slot 的这么一个长度和计算机的位数做一个硬性的规定,而只是规定了其中必须要放得下什么。至于是多少位和数据怎么去处理,是各个 JVM 实现者自己的规定。哪怕是一个 Slot 占有 64位,其也是要使用对齐等等手段使Slot 看起来和 32位之中一致。那么对于JVM而言,32位和 64位的slot就没有必要修改,我个人倾向于在大部分的 JVM 之中slot 的设计还是32位,也就是4字节。希望各位大佬予以指正。
Stack=1
代表栈顶的单位大小,在写入一个数据的时候,其首先会放入栈顶,使用完会写到本地变量和主存之中。这个栈的宽度是1,意味着有一个 this 将会被使用。
Locals=1
是本地变量的 slot 个数,但是并不代表着要和Stack的宽度一致。本地变量在这个方法的生命周期之内,局部变量最多的时候,需要多大的宽度来存放数据(double,long 等等会占用两个 slot)。
Args_size 代表的是入口参数的个数,不再是 slot 的个数,即便传入一个 long,这边的记录也只会是1。
` 0: aload_0`
第一个0代表虚指令之中的行号。每个方法从0开始顺序递增,但是可以跳跃,原因在于有一部分的指令还会接操作的内容。这些操作的内容可能来自于常量池,也可以标识是第几个 slot的本地变量。因此需要占用一部分的空间。
aload_0
指令是将”第一个“slot所在的本地变量推到栈顶,并且这个本地变量是引用类型的。相关的指令有:aload_[0-3](范围是:0x2a~0x2d)。如果超过4个,则会使用 aload+本地变量的位置
来完成(此时会占用多一个字节来存放),而aload\_[0-3]
则是通过具体的几个指令直接完成的。其应该是第一个 slot 位置的本地变量。
` 1: invokespecial #1 // Method java/lang/Object.”
指令之中的第2个行号,执行 invokespecial 指令,当发生构造方法调用、父类的构造方法调用、非静态的private 方法调用的时候会使用该指令。这里要从常量池之中获取一个方法,其会占用2个字节的宽度,加上指令本身就是3个字节,因此下一个的行号是4。
` 4: return`
最后一行是一个 return,虽然没写但是会在JVM 编译的时候帮助我们加上。
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this LJavaTeZhongBing/StringTest;
代表本地变量的列表,本地变量的作用域的起始位置是0,作用域的宽度是5(0~4),slot的起始位置是0,名称为 this,类型为 LJavaTeZhongBing/StringTest;
下面是main 方法的代码,会使用注释形式在其中讲解:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
//这边指的是一个传入类型为String,返回值类型为void
flags: ACC_PUBLIC, ACC_STATIC
//这个方法有public和static的属性。
Code:
stack=3, locals=3, args_size=1
//Stack的3来源是两个Stiring变量,再加上System的out也要占用一个。
//当发生对比生成boolean的时候,要将两个String的引用从栈顶pop出来,所以栈最多是3个slot。
//注意此处Locals的值和书上不同,书上是2.此处是3。差别在我这边是main方法,所以多了一个args[]这个变量
//Arg_size=1也是因为传入参数的个数为1
0: ldc #2 // String ab1
/**
* 指令的body部分,第0个字节为 ldc 指令,从常量池入口#2处拿到内容取到栈顶。
* 虽然String部分也是引用,但是其为常量,所以不使用aload而是使用ldc
*/
2: astore_1
/**
* 将栈顶的引用值放入第2个slot所在的本地变量之中。
* 注意:此处的作用和之前的 aload 刚好相反。
* aload 的作用是将本地变量之中的值放到栈顶之中,
* astore 的作用是将栈顶的slot的值放到本地变量之中。
* 依笔者个人所见。本地变量的作用类似于变量库,即不需要的变量都存放在里面。
* 而如果要放到栈之中,就是需要用来做运算的值了。
* 那么第一个slot之中放入什么呢?看下面的 LocalVariableTable
* 其中写了第一个 slot 之中的内容是 args。
*/
3: ldc #2 // String ab1
5: astore_2
6: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
/**
* 获取静态域,放到栈顶,引用常量池入口 #3来获得
* 此时的静态域是System类之中的out对象
*/
9: aload_1
/**
* 将第2个slot所在位置的本地引用变量加载到栈顶
*/
10: aload_2
11: if_acmpne 18
14: iconst_1
15: goto 19
18: iconst_0
/**
* 判定两个栈顶的引用是否一致(引用值也就是地址),对比处理的结束位置是18行
* 在 if_acmpne 之前,先将两个操作数从栈顶 pop出来,因此栈顶最多是3位
* 如果一致,则将常量1写入栈顶,对应到 boolean 为 true,并且跳转到19行
* 如果不一致,则将常量值0 写入栈顶,对应 boolean 值为 false
*/
19: invokevirtual #4 // Method java/io/PrintStream.println:(Z)V
/**
* 执行 out 对象的 println 对象,方法的参数为 boolean,返回值是 void
* 从常量池 #4 之中拿到方法的内容实体
* 此时会将栈顶的元素当做入口参数,栈顶的0或1则会转换成 boolean 值 true,false
*/
22: return
LocalVariableTable:
Start Length Slot Name Signature
0 23 0 args [Ljava/lang/String;
3 20 1 a Ljava/lang/String;
6 17 2 b Ljava/lang/String;
/**
* 本地变量列表,javac 之中需要使用 -g:vars 才会生成,使用一些工具会直接生成。
* 第一个变量的本地区域是从第0个字节开始,作用的范围是23字节
* 同理,第二个变量的本地区域从第3个字节开始,作用范围是20 字节。
*/
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */
offset_delta = 18
locals = [ class "[Ljava/lang/String;", class java/lang/String, class java/lang/String ]
stack = [ class java/io/PrintStream ]
frame_type = 255 /* full_frame */
offset_delta = 0
locals = [ class "[Ljava/lang/String;", class java/lang/String, class java/lang/String ]
stack = [ class java/io/PrintStream, int ]
}
那么我们现在看其为何输出 true 就很简单了。第一个变量a虽然代码之中写的是”a”+”b”+1,但是在常量池之中却找不到这3个值,而且指令之中也看不到对其的操作,指令之中只看到了对”ab1”的操作,因为在编译阶段JVM就已经将这些合并了。
3.2.1.2 数字游戏(笔者自添)
下面这段代码:
package JavaTeZhongBing.Chapter3;
public class IntegerTest {
public static void main() {
int a = 1, b = 1, c = 1, d = 1;
a++;
++b;
c = c++;
d = ++d;
System.out.println(a + "\t" + b + "\t" + c + "\t" + d + "\t");
}
}
注意此处因为从第五个变量开始,会使用后面加上地址的情况(之前提到过),所以将 main() 之中的参数删除掉,这样可以保证只有4个变量,其格式统一,比较容易去辨析我们此处的这个问题。
按照之前我们印象之中的 ++i 是 i先增加之后返回,i++ 是i先返回之后增加,不管如何其都是会增加,四个应该全是2吧?
输出为:
2 2 1 2
中间这个1属实让人觉得懵逼。下面就使用 javap 来看一下为什么会有这样的输出。
还是先 javac 后 javap,结果如下:
Last modified 13 Mar, 2020; size 740 bytes
MD5 checksum 6a2ea2258529f1d53d8ad4d9e290d991
public class JavaTeZhongBing.Chapter3.IntegerTest
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #11.#24 // java/lang/Object."<init>":()V
#2 = Fieldref #25.#26 // java/lang/System.out:Ljava/io/PrintStream;
#3 = Class #27 // java/lang/StringBuilder
#4 = Methodref #3.#24 // java/lang/StringBuilder."<init>":()V
#5 = Methodref #3.#28 // java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
#6 = String #29 // \t
#7 = Methodref #3.#30 // java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
#8 = Methodref #3.#31 // java/lang/StringBuilder.toString:()Ljava/lang/String;
#9 = Methodref #32.#33 // java/io/PrintStream.println:(Ljava/lang/String;)V
#10 = Class #34 // JavaTeZhongBing/Chapter3/IntegerTest
#11 = Class #35 // java/lang/Object
#12 = Utf8 <init>
#13 = Utf8 ()V
#14 = Utf8 Code
#15 = Utf8 LocalVariableTable
#16 = Utf8 this
#17 = Utf8 LJavaTeZhongBing/Chapter3/IntegerTest;
#18 = Utf8 main
#19 = Utf8 a
#20 = Utf8 I
#21 = Utf8 b
#22 = Utf8 c
#23 = Utf8 d
#24 = NameAndType #12:#13 // "<init>":()V
#25 = Class #36 // java/lang/System
#26 = NameAndType #37:#38 // out:Ljava/io/PrintStream;
#27 = Utf8 java/lang/StringBuilder
#28 = NameAndType #39:#40 // append:(I)Ljava/lang/StringBuilder;
#29 = Utf8 \t
#30 = NameAndType #39:#41 // append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
#31 = NameAndType #42:#43 // toString:()Ljava/lang/String;
#32 = Class #44 // java/io/PrintStream
#33 = NameAndType #45:#46 // println:(Ljava/lang/String;)V
#34 = Utf8 JavaTeZhongBing/Chapter3/IntegerTest
#35 = Utf8 java/lang/Object
#36 = Utf8 java/lang/System
#37 = Utf8 out
#38 = Utf8 Ljava/io/PrintStream;
#39 = Utf8 append
#40 = Utf8 (I)Ljava/lang/StringBuilder;
#41 = Utf8 (Ljava/lang/String;)Ljava/lang/StringBuilder;
#42 = Utf8 toString
#43 = Utf8 ()Ljava/lang/String;
#44 = Utf8 java/io/PrintStream
#45 = Utf8 println
#46 = Utf8 (Ljava/lang/String;)V
{
public JavaTeZhongBing.Chapter3.IntegerTest();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this LJavaTeZhongBing/Chapter3/IntegerTest;
public static void main();
descriptor: ()V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=4, args_size=0
0: iconst_1
1: istore_0
2: iconst_1
3: istore_1
4: iconst_1
5: istore_2
6: iconst_1
7: istore_3
8: iinc 0, 1
11: iinc 1, 1
14: iload_2
15: iinc 2, 1
18: istore_2
19: iinc 3, 1
22: iload_3
23: istore_3
24: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
27: new #3 // class java/lang/StringBuilder
30: dup
31: invokespecial #4 // Method java/lang/StringBuilder."<init>":()V
34: iload_0
35: invokevirtual #5 // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
38: ldc #6 // String \t
40: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
43: iload_1
44: invokevirtual #5 // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
47: ldc #6 // String \t
49: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
52: iload_2
53: invokevirtual #5 // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
56: ldc #6 // String \t
58: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
61: iload_3
62: invokevirtual #5 // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
65: ldc #6 // String \t
67: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
70: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
73: invokevirtual #9 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
76: return
LocalVariableTable:
Start Length Slot Name Signature
2 75 0 a I
4 73 1 b I
6 71 2 c I
8 69 3 d I
}
此处我们直接解释其核心指令:
public static void main();
descriptor: ()V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=4, args_size=0
0: iconst_1 //将 int 类型的常量值1推送到栈顶
1: istore_0 //将栈顶抛出的数据赋值到第1个slot所在的 int 类型的本地变量之中
2: iconst_1
3: istore_1
4: iconst_1
5: istore_2
6: iconst_1
7: istore_3
8: iinc 0, 1 //将第一个slot所在的int类型的本地变量自加1
11: iinc 1, 1 //将第二个slot所在的int类型的本地变量自加1
14: iload_2 //将第三个slot之中的int类型的本地变量放入栈顶
15: iinc 2, 1 //将第三个slot所在的int类型的本地变量加1
18: istore_2 //将栈顶的元素写入到第三个slot所在的int类型的本地变量
19: iinc 3, 1
22: iload_3
23: istore_3
LocalVariableTable:
Start Length Slot Name Signature
2 75 0 a I
4 73 1 b I
6 71 2 c I
8 69 3 d I
可以看到序号8的语句和序号11的语句是一样的,也就是如果没有赋值或者被用于其他的计算操作,一个本地变量发生 i++ 和 ++i ,其最终指令都是 iinc,也就是说 i++ 会被变成 ++i 的操作。
接下来看看第3个本地变量c的操作,首先通过 iload_2 指令将其拷贝到栈顶,然后发生 iinc 操作,再通过 istore_2 这个指令将栈顶的数据赋值给这个本地变量。因此可以认为其就像做了这个操作:
int tmp = c; c++; c = tmp;
只是这个tmp不是真正存在的本地变量,而是栈顶的一份拷贝。其自己叠加的数据并不参与其他的运算,这才是 Java 实现 i++ 的真实道理。
下面使用书中的插图做进一步的指令分析:
在进入方法之前,JVM 分配的栈大概是图中所示的样子(这部分不包括指令和指令之中指向的常量池位置):
一个 Java方法分配的时候,不仅仅分配这几部分空间,至少还需要一个 frame 的数据区。职责包含:负责指向 Class 的常量池,以便于得到指令;会记录一些内容帮助方法返回到正确的来源位置;设置PC寄存器要得到执行的指令;记录异常表,控制异常的处理权。
当 iconst_1 发生操作的时候,结构就又改变了:
当 istore_0 发生操作的时候,将栈顶数据抛出,赋值给变量a,此时的结构就像图中所示:
以此类推,到赋值操作结束之后,4个本地变量都会发生这样的赋值操作,最终结果如下图所示:
iinc 指令我们没有必要详细讲解(实现的细节也可以是利用一个栈顶数据来store,叠加1,load),总之a,b 两个变量都变成了2,当进一步做 c = c++; 操作的时候,首先发生的第一个操作是将数据拷贝到栈顶,然后将本地变量改为2,再从栈顶拷贝回来,如下图所示:
那么上面将这个问题的产生方式弄清楚了,好像整体的后进先出栈只用一个slot,为什么会有3个呢?
因为之前我们只是输出一些简单的操作指令,后面还有一条代码System.out.println 相关的指令没有输出,虽然就一行代码,但是指令很多(代码短,但是不代表指令短),下面的一些指令如下:
24: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
27: new #3 // class java/lang/StringBuilder
30: dup
31: invokespecial #4 // Method java/lang/StringBuilder."<init>":()V
34: iload_0
35: invokevirtual #5 // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
38: ldc #6 // String \t
40: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
43: iload_1
44: invokevirtual #5 // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
47: ldc #6 // String \t
49: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
52: iload_2
53: invokevirtual #5 // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
56: ldc #6 // String \t
58: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
61: iload_3
62: invokevirtual #5 // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
65: ldc #6 // String \t
67: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
70: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
73: invokevirtual #9 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
76: return
可见上面的大部分指令实际上都是 invokevirtual 指令,关键看其在操作的东西。后面的注释已经写入了其具体执行的指令,现在我们看看本地栈要使用3个slot是怎么来的。
首先,getstatic 命令将 System 类的 out 静态属性获取出来放到栈顶(因为其没有局部变量可以存放,只能放到栈顶)。然后通过 new 指令创建一个对象(此时仅仅是分配对象的空间,而不是开始初始化对象),其是通过常量池入口 #3 获得一个 StringBuilder 类型。此时栈应该如图所示:
此时发生是 dup 命令,其会拷贝一份栈顶的内容,并且写入栈顶。为何要拷贝一份并且写入栈顶?因为后续的 invokespecial 操作会将栈顶数据抛出执行,执行 StringBuilder 的构造方法(刚刚只是分配了空间,还没有对内容进行初始化,一个创建对象的操作要多条指令来完成),此时栈的情况就变成了下图:
到现在为止我们已经知道为何stack 之中的容量是3了。
接下来的指令动作是将栈顶数据抛出,执行StringBuilder 对象的构造方法对其进行初始化,此时栈的使用情况回到和图 3-7 所示一样的情况,区别在于现在的 StringBuilder 对象已经执行完的构造方法(但是不带代表所有属性都初始化完成)。
在这之后,将本地变量,常量(“\t”) 逐个 iload 和 aload 到栈顶,然后调用 invokevirtual 指令调用 StringBuilder 的 append 方法。虽然这个方法也会 pop 出来执行操作,但是这个方法会有一个 StringBuilder 的返回值,由于下一个动作也是基于这个返回值进行操作,所以这个返回值会再次被赋值到栈顶,那么其也就不需要再拷贝这个动作了。如果这个 StringBuilder 是一个自定义的本地变量,那么也就无需再一次执行 iload 操作了。
笔者注:此处的意思是其会将 StringBuilder 先弹出,然后对其进行append操作之后将返回值再次压入栈,所以不会再次需要其他的Stack 位置。
3.2.2 Java 字节码结构
Java 之所以可以跨平台,是因为其是基于字节码的。字节码从形式上说是以byte为单位存储的文件,但是就其功能而言,和跨平台的特性结合起来考虑,其是描述程序要运行的虚指令的集合,而这个虚指令的集合和任何平台无关,Java 虚拟机会将其翻译成对应的 OS 指令。
那么整个过程是怎么一回事呢?下面是具体的梳理。
-
编译一个 Java 源文件,是通过 javac 命令来将其编译成 class 文件的。javac 本身是一个引导器,其引导编译器程序的运行。
javac 命令运行时候所引导的 Java类是
com.sun.tools.javac.main.JavaCompiler
,这个类的功能是:完成 Java 源文件的解析(Parser),注解处理(Annotation process),属性标注,检查,泛型处理。一些语法糖转换等等,最终生成 class 文件。如果想要动态编译一些 Java 源码,可以使用
ToolProvider.getSystemJavaCompiler()
来得到一个编译器,得到的是javax.tools.Compiler
类的一个对象,通过其可以创建 CompilationTask 任务来进行编译,编译任务运行之后就可以得到编译之后的 Java 字节码(其为一个 byte[] 数组),接下来就交给 ClassLoader 了。 -
Java 字节码的主体结构如下:
书中将这些部分分成了一些板块:
class 文件头部:magic, minor_version, major_version
常量池区域: constant_pool_count, constant_pool[constant_pool_count -1]
当前类的一些描述信息:access_flags, this_class, super_class, interface_count, interfaces[interfaces_count]
下面是分点解释:
首先,文件头部包含的4个字节的头部验证码,这4个字节的十六进制表示分别为:0xCA, 0xFE, 0xBA, 0xBE,合起来就是 CAFEBABE 。如果文件头部不是这4个字节,通常 JVM 不认。
接下来是Class 的 minor_version 和 major_version, 分别占用 2个字节的空间。这两个信息代表 Class 使用什么版本的编译器编译的,当 JRE 的版本低于这个版本的时候,其就会报错:
Unsupported major.minor version 50
如果 minor version 是0,那么有的时候就只显示 major version。无论如何,字节码之中始终会这些信息。
平时的 JDK 1.6,JDK 1.7 之中,其版本对应关系如表所示。
Major | Minor | Java platform version |
---|---|---|
45 | 3 | 1.1 |
46 | 0 | 1.2 |
47 | 0 | 1.3 |
48 | 0 | 1.4 Example: 1.4.x |
49 | 0 | 1.5 Example: 1.5 |
50 | 0 | 1.6 |
51 | 0 | 1.7 |
52 | 0 | 1.8 |
接着讲作者所说的第二部分:常量池
在之前的 javap 代码示例之中,我们也看到了常量池的部分;javap 也一样输出了内容。那么看下字节码之中是如何存储这些常量池内容的。
Const_pool_count: 其长度为2字节,用来表示常量池入口位置的最大下标。其下标是从1开始的(javap之中也能看到其从入口 #1开始的)。
再来看看常量池之中的数据结构,每一项都有一个字节来表示其类型,然后是常量池之中的内容。常量池之中每一项的结构为:
cp_info{
u1 tag;
u1 info[];
}
其类型列表为:
每一种类型对应的都是不同宽度的内容,比如 Class 紧接着是2个字节的内容,这2个字节代表着Class的名称在常量池之中的位置(这个CLass会记录另外的常量池位置,另外的常量池之中才会真正记录Class的名称,而这里该项仅仅代表返回的名称应当转换为一个 Class)。
而其中其他的项,比如 Fieldref, methodref 等等会使用2个字节代表常量池之中的位置,再用2个字节代表 NameAndType 信息。对于属性自然是属性的名称和类型,对于方法的话就是方法的名称,以及方法的入口参数和返回值类型。
综合起来,常量池的结构如图所示:
再看看类的基本描述信息,其又包含什么内容?
首先,其会用2个字节来表示类的 Class 修饰符,这个修饰符代表的是这个类的访问方式(access_flags),如下表所示。
紧接着会有2个字节来标识类名在常量池之中的位置。再用2个字节标识父类名称在常量池之中的位置(如果没有写父类,那么就是 Object 类了,所以这两个字节始终存在)。
之前我们有介绍过 Modifier,书中这一部分也提及了:
要是希望自己写程序来解析修饰符,就使用 java.lang.reflect.Modifier
,其提供了相关的方法来判定,比如要判定修饰符是否为 public,就调用 Modifier.isPublic(int),内部实现方法就是之前提到过的按位取与:
/**
* The {@code int} value representing the {@code public}
* modifier.
*/
public static final int PUBLIC = 0x00000001;
/**
* Return {@code true} if the integer argument includes the
* {@code public} modifier, {@code false} otherwise.
*
* @param mod a set of modifiers
* @return {@code true} if {@code mod} includes the
* {@code public} modifier; {@code false} otherwise.
*/
public static boolean isPublic(int mod) {
return (mod & PUBLIC) != 0;
}
如果在运行的程序之中,想要判定一个Class或者某个属性,方法的 public 情况,就需要获取到这个标识符,程序之中有方法:
int XXX.class.getModifiers()
下面讲接口方面:在Java之中,任意一个类最多只能有一个父类,但是可以有多个接口。所以要表达接口,首先要知道接口的数量,再根据数量去循环读取每一个接口的信息。
Java 字节码之中也是用2个字节来表达接口数量,然后循环获取接口。在每一个接口的描述之中,标识了其在常量池之中的入口位置,通过这个入口就可以找到所有的接口列表了。
综合起来的话,类的基本信息如图所示:
下面讲属性(field)部分,类似的,使用2个字节来代表长度是多少,也就是代表有多少个 field。再根据这个长度开始循环,对于属性的内部结构,官方的描述如图3-16所示:
field_info{
u2 access_flags,
u2 name_index,
u2 descriptor_index,
u2 attributes_count,
attribute_info attributes[attributes_count]
}
其和class一样,使用2个字节来代表field的access_flags
,与之不同的是 field 还会增加一些 transient, volatile, static 等等的判定(也就是表的条数更多),如下:
Flag Name | Value | Interpretation |
---|---|---|
ACC_PUBLIC |
0x0001 | Declared public ; may be accessed from outside its package. |
ACC_PRIVATE |
0x0002 | Declared private ; usable only within the defining class. |
ACC_PROTECTED |
0x0004 | Declared protected ; may be accessed within subclasses. |
ACC_STATIC |
0x0008 | Declared static . |
ACC_FINAL |
0x0010 | Declared final ; never directly assigned to after object construction (JLS §17.5). |
ACC_VOLATILE |
0x0040 | Declared volatile ; cannot be cached. |
ACC_TRANSIENT |
0x0080 | Declared transient ; not written or read by a persistent object manager. |
ACC_SYNTHETIC |
0x1000 | Declared synthetic; not present in the source code. |
ACC_ENUM |
0x4000 | Declared as an element of an enum . |
紧接着的2个字节用来获取常量池的入口,对应到“属性名称”(name index),然后就是属性类型在常量池之中的入口位置。
另外,还有其他一些附加属性(attribute 列表),其通常标识 Signature, Annotation, Deprecated 等信息,这部分就不详细说了。
属性列表的图示如下:
那么,对应到每个字段,其值就是:
Access_flag: 属性修饰符
Name_index: ”属性名称“常量池入口
Descriptor_index: ”属性类型“常量池入口
Attributes_count: 有多少个 attributes,可能用于之后做遍历
attribute_info: 其每一个 attribute 的具体信息
属性之后就是 method 部分了。先上每一个方法的内部结构:
method_info{
u2 access_flags;
u2 name_index;
u2 descriptor_index;
u2 attributes_count;
attribute_info attributes[attributes_count];
}
需要使用2个字节来标识method的数量,和之前一样,依照这个数量循环取 method 列表信息。对于每个method,按照之前的方法内部结构,可以看出来其都有access_flags,方法名,但是方法名这部分要单独拎出来讲。为什么呢?因为其有可能是”构造方法“或者”static匿名块“。那么下面详细讲以下这些部分的含义:
方法有入口参数和返回值,这里方法使用了2个字节来保存常量池区域的入口位置,为了节约空间,常量池会采用一种”简写“方式,将Java代码之中的类型进行转换来存放。
比如一个”(II)V“ 的常量池表,就代表入口参数为2个int,但是返回值为 void。对于Java常量的基本类型,其在字节码之中的规范定义如下表所示。表的来源为:https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-4.html#jvms-4.3.2
BaseType Character | Type | Interpretation |
---|---|---|
B |
byte |
signed byte,代表字节,但是不是Byte的意思 |
C |
char |
Unicode character code point in the Basic Multilingual Plane, encoded with UTF-16 |
D |
double |
double-precision floating-point value |
F |
float |
single-precision floating-point value |
I |
int |
integer |
J |
long |
long integer |
L ClassName ; |
reference |
an instance of class ClassName,引用类,比如String被标识为 Ljava/lang.String |
S |
short |
signed short |
Z |
boolean |
true or false ,代表布尔值,但是同样不是 Byte的意思 |
[ |
reference |
one array dimension,数组的引用,比如double[][]被标识为[[D;String[]被标识为[Ljava.lang.String |
V |
void |
没有返回值 |
注意:如果是 Integer,其不会像 int 那样被标识为 I,因为其是一个对象,所以会以对象的形式存在,对象类型在这里会使用一个字符”L“来开头,以一个英文分号”;“结尾。所以 Integer的标识为 Ljava/lang/Integer; 因为每种类型都有自己的特征,所以多种类型组合在一起也可以被识别出来,类型就这样串联起来了。
接下来解析方法的内部信息:
我们上面的代码可以看出,其是使用 attribute 来标识的。首先用2个字节来代表attribute的个数(是不是似曾相识),然后遍历每一个 attribute 的内容。每一个 attribute 的内部结构如图所示:
attribute_info{
u2 attribute_name_index;
u4 attribute_length;
u1 info[attribute_length];
}
图中,u2代表2个字节标识名称所在的常量池位置,这里的名称是用来标识方法之中不同的body类型的。方法之中还有不同的body类型?是的,有
- 指令Code列表
- Exception 标识抛出的异常
- StackMapTable 操作数栈的位置
- LineNumberTable 记录行号对应表
- LocalVaribleTable 记录本地变量列表
属性,类,方法都有 attribute。
根据不同的 attribute_name,有不同的指令列表。下面是以其 attribute_name 为 ”Code”为例,其内部结构如下所示:
Code_attribute{
u2 attribute_name_index;
u4 attribute_length;
u2 max_stack;
u2 max_locals;
u4 code_length;
u1 code[code_length];
u2 exception_table_length;
{
u2 start_pc;
u2 end_pc;
u2 handler_pc;
u2 catch_type;
} exception_table[exception_table_length];
u2 attributes_count;
attribute_info attributes[attributes_count];
}
按照之前的惯例,其之前是名字的入口和长度,没什么其他的特殊含义。那么接着向下分析:
Max_stock 和 max_locals 是javap 指令之中的 Stacks,Locals 两个值的内容,分别用了2个字节来表达。
紧接着用4个字节代表Code的长度,然后使用一个 byte 数组来表达 Code的列表(数组的长度就是之前的 u4 code_length 代表的长度)。要解析Code,就得逐个字节进行遍历(这里的遍历指的是第几个字节刚好对应到 javap 指令之中的行号,所以前面作者说其实这个是 Code 的字节顺序)。
在循环遍历过程之中,首先读取一个字节,这个字节代表一个指令,对应到下面的指令列表之中:
https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-7.html
在得到指令之后,就得根据字节是否需要接操作数决定读取多少个字节,比如 invokespecial 指令就需要2个字节来标识常量池之中的入口位置,iconst_0 无需读取任何操作数,等等。
Code部分在执行命令这部分并没有结束,还会有后面的 Exception 列表,首先读取2个字节代表 Exception 的个数,然后循环提取 Exception。
每个Exception的表达由from,to,target,typeDescribe 几个信息来表达,分别代表异常的 try 开始位置,try 结束位置,异常跳转的目标,异常的类型(在常量池之中)。
接下来还有 attribute 列表,原因是在字节码之中,如果是以 Code 开头,那么 LineNumberTable, StackMapTable 以及包含的异常信息等等都会包含进来。
这并不代表 body 部分只有一种结构,有的编译器是先编译生成 Exception 信息,然后再声明其他。
在方法结束之后,通常还会有 Class 的 attribute 信息,比如 SourceFuke 等等信息可以存放在这里。
Java虚拟机也会为每个 OS 平台编写对应的 JRE 运行时环境,与 OS 动态链接,将这些虚指令编码翻译成对应操作系统的汇编指令信息,就可以在对应的 OS 上面调用执行了。
问:为何要有常量池?内容直接放在字节码之中不可以嘛?
答:在我们平时的编码过程之中也能看到,要尽量将重复的部分抽出,尽量不写重复的代码。那么意义就明显了:一是可以节约空间,二是 Class 文件的字节码结构更加规整。
问:字节码除了跨平台之外还有什么用途:
答:一是可以了解 Class 的内存结构,字节码增强技术,反编译技术;二是要设计一种语言或者类似的解析类的程序,这种字节码结构是可以借鉴的方法。
3.2.2.1 个人小总结(笔者自添)
首先还是将官方的资料放上来:https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-4.html
这里面是对 Class 文件的一个总体和细致表述。
那么对于这个结构:
ClassFile {
u4 magic;
u2 minor_version;
u2 major_version;
u2 constant_pool_count;
cp_info constant_pool[constant_pool_count-1];
u2 access_flags;
u2 this_class;
u2 super_class;
u2 interfaces_count;
u2 interfaces[interfaces_count];
u2 fields_count;
field_info fields[fields_count];
u2 methods_count;
method_info methods[methods_count];
u2 attributes_count;
attribute_info attributes[attributes_count];
}
我们可以有几个小的规律总结:
- u2 的地方几乎全都是常量池之中的地址
- 所有的数组都需要先声明一个长度,方便下面的属性遍历
- 对于每种小的结构,比如 field_info 这种,其实也都需要包含下面几项:
- access_flag, 使用按位来标识的方法,然后按位取与这种
- name_index和 descriptor_index 来标识其name和描述符,比如使用 (II)V 来代表其入口参数为2个int,出口参数为 void 的一个函数
- attribute用来记录其内部的属性值
3.2.3 Class字节码的加载
这部分会使用 ClassLoader,我们在之前的文章之中有提及过ClassLoader,但是没有特别深入。这里做一个详细的了解。
Class的加载,就是通过ClassLoader 实现的,Java 加载类就是靠其进行加载的。ClassLoader,说白了就是读取字节码的字节流进行加载。
ClassLoader 的继承关系:
其继承关系是从 BootstrapClassLoader 开始的(这部分我们之前提到过,其不是java而是 C++ 写的),由其最先加载类,之后是 ExtClassLoader(名称来源是因为其加载jre/lib/ext/
目录下面的 jar 包),接下来是 AppClassLoader(应用程序默认),最后是用户自己的 ClassLoader(在容器下编写的代码,都是由容器自定义创建的 ClassLoader 创建的类)。下面是分点简介:
- BootStrapClassLoader 主要用于加载一些 Java 自带的核心类(比如 java.lang.*),这些核心类的 class 通常被签名,不可以被替换掉,是由 JVM 内核实现的。在 hotspot VM 下面,这个是由 C++ 实现,有了其加载最核心的内容,才有后面的 ClassLoader 的存在。
- ExtClassLoader 是加载在
jre/lib/ext/
目录下面的 jar 包,用户也可以自己将 jar 包放在这个目录下,通过这个 ClassLoader 来加载。 - AppClassLoader 也是用户可见的 ClassLoader,其加载的是 classpath下面的内容,也就是和 classpath 相关的类。classPath即为java文件编译之后的class文件的编译目录
- 用户自定义的 classLoader 要加载的内容可能不在系统的 classpath 范围之内,甚至不是class文件或者 jar 文件,也就是加载方式完全自己定义。既然这部分讲的是自定义的 classLoader,那么其具有这种类型的特性也就不足为奇,万一我就想实现一个自己的写的 .class 类型文件呢(笑)。
比如,想要将自己写的一段 java 代码动态加载到 JVM 进程之中,就可以通过自定义的 ClassLoader 来实现(当然也有其他用途,比如加载一些远程的 jar 包)。在之后介绍字节码增强的部分也会说到这个问题。
用户自定义的 ClassLoader 一般继承于 URLClassLoader,可以继承于 ClassLoader 或者 SecureClassLoader。其本身之间也是继承关系,根据实际情况重写不同的方法即可。
进程启动时,通常会加载JVM的一些核心库,其并不会加载项目之中的所有 Class,原因是Class启动还是会占用很多空间的,比如项目之中可能会有好几百MB甚至更大的 jar 包,其都不是启动时加载的。除了加载一些 JVM 的核心库之外,通常会加载引导方法(main)所在的类,以及main所在类会使用到的类。
下面是Class本身加载过程的详细解释:
Class本身也是用来描述普通Java对象格式的一种对象,既然是对象,其加载就需要一个过程。
- 读取文件。读取文件的前提是首先找到文件,也就是找到 .class 类型的文件。其将以“类全名+ClassLoader 名称“作为唯一标志,加载于方法区内部。
- 链接。这个动作内部就是对要加载的字节码进行解析,校验,看是否符合字节码的规范结构。如果不符合就会抛错误 ClassNotFoundError (注意这个地方不是 ClassNotFoundException, 一个是异常一个是错误,区别很大)。这一步还会为了 Class 对象分配内存。(第一步只是找到文件并且读取文件,这个时候还没为Class对象分配内存)这一步可选的是常量池之中的符号引用解析为直接引用。
- 初始化。会调用这个 Class 对象自身的构造函数,对静态变量,static 块进行赋值(通过 javap 可以发现,许多静态变量的赋值会在编译时放在 static 块之中完成)
所有的类,在使用之前都必须被加载和初始化,初始化过程使用的是<clinit>
方法,确保其是线程安全的,包括 static 块也必须要执行完才可以被使用。如果由多个线程同时尝试访问该类,那么必须等待 static 块执行完成,否则都将被阻塞。