JVM字节码
一、简单字节码分析
JavaByte由单个字节(byte)的指令组成,理论上最多支持256个操作码,实际上Java只使用了200左右的操作码,还有一些操作码留给调试操作。
根据指令的性质,主要分为四大类:
1、栈操作指令,包括与局部变量交互的指令;JVM就类似一个计算机,计算机的运行有基于栈的、有基于寄存器的,而JVM就是基于栈的操作,因此会存在栈操作指令。
2、程序流程控制指令,例如代码中的 if、for 等
3、对象操作指令,包括方法调用指令;Java本身是面向对象的,其会调用对象的方法和属性,因此会存在对象操作指令。
4、算术运算以及类型转换指令
其中栈操作指令是虚拟机本身结构需要的,而另外三个是和Java语言对应的。
如下一段简单的代码:
public class HelloByteCode { public static void main(String[] args) { HelloByteCode obj = new HelloByteCode(); } }
查看其字节码
# 编译 javac HelloByteCode.java # 查看内部字节码内容 javap -c HelloByteCode.class # 内容 Compiled from "HelloByteCode.java" public class HelloByteCode { public HelloByteCode(); Code: 0: aload_0 1: invokespecial #1 // Method java/lang/Object."":()V 4: return public static void main(java.lang.String[]); Code: 0: new #2 // class HelloByteCode 3: dup 4: invokespecial #3 // Method "":()V 7: astore_1 8: return }
可以分析一下上面的字节码文件,首先收两个方法,一个是类的无参构造,一个是main方法,在无参构造中,第一行,是从局部变量表下表为0的位置获取数据,load表示获取,a表示引用类型;第二行表示调用初始化方法,后面的 1 表示是对常量池中 1 所表示的常量,后面的注释说明了其调用的是其父类Object的初始化方法;第三行是返回。在main方法中,第一行表示使用常量池中2表示的常量new一个对象,注释表示了new哪个类型的对象;第二行表示压栈,即将new出来的对象压入操作数栈;第三行表示初始化,使用的是常量池中3表示的常量;第四行表示将初始化完成的对象放入局部变量表;最后返回。
每一行的前面的数字表示操作指令偏移量,例如在构造方法中,初始偏移量为0.第二个指令的偏移量为1,说明aload指令占了一个字节码,而第三行的偏移量为4,说明初始化所占的字节码为3,除了一个字节的操作指令外,还可以有2个字节的操作数,样例中只是用了一个字节。
上面的代码是不带常量池信息的,如果要展示常量池信息,可以在使用javap命令时加上-verbose
# javap -c -verbose HelloByteCode.class Classfile ...../java-training-camp/HelloByteCode.class Last modified 2022-4-30; size 288 bytes MD5 checksum e2660631542bd407feefe48622ed4ae1 Compiled from "HelloByteCode.java" public class HelloByteCode minor version: 0 major version: 52 flags: ACC_PUBLIC, ACC_SUPER Constant pool: #1 = Methodref #4.#13 // java/lang/Object."":()V #2 = Class #14 // HelloByteCode #3 = Methodref #2.#13 // HelloByteCode."":()V #4 = Class #15 // java/lang/Object #5 = Utf8#6 = Utf8 ()V #7 = Utf8 Code #8 = Utf8 LineNumberTable #9 = Utf8 main #10 = Utf8 ([Ljava/lang/String;)V #11 = Utf8 SourceFile #12 = Utf8 HelloByteCode.java #13 = NameAndType #5:#6 // " ":()V #14 = Utf8 HelloByteCode #15 = Utf8 java/lang/Object { public HelloByteCode(); descriptor: ()V flags: ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokespecial #1 // Method java/lang/Object."":()V 4: return LineNumberTable: line 1: 0 public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=2, args_size=1 0: new #2 // class HelloByteCode 3: dup 4: invokespecial #3 // Method "":()V 7: astore_1 8: return LineNumberTable: line 3: 0 line 4: 8 } SourceFile: "HelloByteCode.java"
可以看到在无参构造函数中初始化的常量1,是由常量4和常量13组成的,常量4是Object类,常量13是由常量5和常量6组成,其中注释也说明了,其是由方法名和返回参数构成的,常量5表示init方法,常量6表示返回参数,大写的V表示void。这些合起来就是常量池1的注释,调用Object类中返回值为void的init方法。而在无参构造的第二行中,invokespecial表示调用常量池1的常量,合起来,就是调用Object类中返回值为void的init方法。
在上面的无参构造方法中,还使用LineNumberTable表示了行号,其说明偏移量为0的指令,在Java代码中的第一行,也就是生命该类的行号,在main方法中,其说明了new指令在代码的第3行,返回在代码的第4行。
可以看到,一个class中,包含了描述信息、常量池信息、方法内容等:
描述信息:包括类的最后修改时间、类的大小、MD5加密、编译来源、版本号(上面的版本号是52,对应JDK8,如果是51,对应JDK7,50对应JDK6)、修饰符、是否有父类等。
常量池信息:描述了常量信息
方法:描述了方法的修饰符、返回值、入参,还有Code码,code中有总体信息和详细信息,总体信息例如stack(执行该方法需要栈的总深度)、locals(执行该段字节码需要局部变量表的长度)、args_size(执行该段字节码需要的参数个数),详细信息就是上面描述的具体的操作指令。
但是上面的Code码中虽然标注了有基本本地变量,但是并没有标注本地变量是哪些,其实本地变量是什么,并不会对程序的运行产生影响,如果想要展示本地变量是什么时,可以在打包时加上参数 -g 来打包即可。在下面的字节码文件中们可以看到每一个本地变量的起始位置、长度、槽位、名称、类型。默认情况下,这些信息是丢失的,如果使用常规的工具进行反编译,例如Eclipse、Idea等,反编译出来的结果一般是y1、y2、y3这样的本地变量。
# javac -g HelloByteCode.java # javap -verbose HelloByteCode.class Classfile ....../HelloByteCode.class Last modified 2022-4-30; size 415 bytes MD5 checksum 44dd68d97fffda0bd16a524fb32b983a Compiled from "HelloByteCode.java" public class HelloByteCode minor version: 0 major version: 52 flags: ACC_PUBLIC, ACC_SUPER Constant pool: #1 = Methodref #4.#19 // java/lang/Object."":()V #2 = Class #20 // HelloByteCode #3 = Methodref #2.#19 // HelloByteCode."":()V #4 = Class #21 // java/lang/Object #5 = Utf8#6 = Utf8 ()V #7 = Utf8 Code #8 = Utf8 LineNumberTable #9 = Utf8 LocalVariableTable #10 = Utf8 this #11 = Utf8 LHelloByteCode; #12 = Utf8 main #13 = Utf8 ([Ljava/lang/String;)V #14 = Utf8 args #15 = Utf8 [Ljava/lang/String; #16 = Utf8 obj #17 = Utf8 SourceFile #18 = Utf8 HelloByteCode.java #19 = NameAndType #5:#6 // " ":()V #20 = Utf8 HelloByteCode #21 = Utf8 java/lang/Object { public HelloByteCode(); descriptor: ()V flags: ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokespecial #1 // Method java/lang/Object."":()V 4: return LineNumberTable: line 1: 0 LocalVariableTable: Start Length Slot Name Signature 0 5 0 this LHelloByteCode; public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=2, args_size=1 0: new #2 // class HelloByteCode 3: dup 4: invokespecial #3 // Method "":()V 7: astore_1 8: return LineNumberTable: line 3: 0 line 4: 8 LocalVariableTable: Start Length Slot Name Signature 0 9 0 args [Ljava/lang/String; 8 1 1 obj LHelloByteCode; } SourceFile: "HelloByteCode.java"
上面的是字节码文件,而实际上计算机运行使用的是二进制文件,在运行时,JVM将助记符变为16进制运行的,以main方法为例,在0的位置是new,在3的位置是dup,然后new后面可以有两个字节的操作数,但是字节码中new后面只有一个2,那么将2号下标职位02,1号下标补0,同理,初始化指令后面也可以有两个操作数,但是字节码后面只有一个3,因此在下标6的位置置位03,前面补位0,最终就形成了一个类似于16进制的code码,而这些操作指令都是助记符,其对应都有相应的16进制,例如new对应bb,也就是187,同理,其余的助记符都有其对应的16进制,可以使用一些支持16进制的文本编辑器打开字节码文件,就可以看到其真实对应的16进制内容。
二、四则运算字节码
public class MovingAverage { private int count = 0; private double sum = 0.0D; public void submit(double value) { this.count++; this.sum += value; } public double getAvg(){ if(this.count == 0){ return sum; } return this.sum/this.count; } } public class LocalVariableTest { public static void main(String[] args) { MovingAverage ma = new MovingAverage(); int num1 = 1; int num2 = 2; ma.submit(num1); ma.submit(num2); double avg = ma.getAvg(); } }
对上面的代码进行编译,然后查看LocalVariableTest的字节码文件
# javac -g MovingAverage.java # javac -g LocalVariableTest.java # javap -verbose LocalVariableTest.class Classfile ....../LocalVariableTest.class Last modified 2022-4-30; size 614 bytes MD5 checksum eb6bb9c0d6a1a6f0027bd30f3f6b1a6d Compiled from "LocalVariableTest.java" public class LocalVariableTest minor version: 0 major version: 52 flags: ACC_PUBLIC, ACC_SUPER Constant pool: #1 = Methodref #7.#28 // java/lang/Object."":()V #2 = Class #29 // MovingAverage #3 = Methodref #2.#28 // MovingAverage."":()V #4 = Methodref #2.#30 // MovingAverage.submit:(D)V #5 = Methodref #2.#31 // MovingAverage.getAvg:()D #6 = Class #32 // LocalVariableTest #7 = Class #33 // java/lang/Object #8 = Utf8#9 = Utf8 ()V #10 = Utf8 Code #11 = Utf8 LineNumberTable #12 = Utf8 LocalVariableTable #13 = Utf8 this #14 = Utf8 LLocalVariableTest; #15 = Utf8 main #16 = Utf8 ([Ljava/lang/String;)V #17 = Utf8 args #18 = Utf8 [Ljava/lang/String; #19 = Utf8 ma #20 = Utf8 LMovingAverage; #21 = Utf8 num1 #22 = Utf8 I #23 = Utf8 num2 #24 = Utf8 avg #25 = Utf8 D #26 = Utf8 SourceFile #27 = Utf8 LocalVariableTest.java #28 = NameAndType #8:#9 // " ":()V #29 = Utf8 MovingAverage #30 = NameAndType #34:#35 // submit:(D)V #31 = NameAndType #36:#37 // getAvg:()D #32 = Utf8 LocalVariableTest #33 = Utf8 java/lang/Object #34 = Utf8 submit #35 = Utf8 (D)V #36 = Utf8 getAvg #37 = Utf8 ()D { public LocalVariableTest(); descriptor: ()V flags: ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokespecial #1 // Method java/lang/Object."":()V 4: return LineNumberTable: line 1: 0 LocalVariableTable: Start Length Slot Name Signature 0 5 0 this LLocalVariableTest; public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=3, locals=6, args_size=1 0: new #2 // class MovingAverage 3: dup 4: invokespecial #3 // Method MovingAverage."":()V 7: astore_1 8: iconst_1 9: istore_2 10: iconst_2 11: istore_3 12: aload_1 13: iload_2 14: i2d 15: invokevirtual #4 // Method MovingAverage.submit:(D)V 18: aload_1 19: iload_3 20: i2d 21: invokevirtual #4 // Method MovingAverage.submit:(D)V 24: aload_1 25: invokevirtual #5 // Method MovingAverage.getAvg:()D 28: dstore 4 30: return LineNumberTable: line 3: 0 line 4: 8 line 5: 10 line 6: 12 line 7: 18 line 8: 24 line 9: 30 LocalVariableTable: Start Length Slot Name Signature 0 31 0 args [Ljava/lang/String; 8 23 1 ma LMovingAverage; 10 21 2 num1 I 12 19 3 num2 I 30 1 4 avg D } SourceFile: "LocalVariableTest.java"
在main方法中:
偏移量为7的操作指令为astore_1,store表示将数据加载到操作数栈中,a表示是一个引用类型,1表示本地变量1对应的内容,联合起来就是将类型为MovingAverage的引用变量ma压入本地变量表
偏移量为8的操作指令为iconst_1,const表示将数据压栈,i表示int类型,1表示数值1,合起来就是将常量1压栈
偏移量为9的操作指令为istrore_2,表示将其写入本地变量的下标2中,可以看到下面的本地变量表中,slot为2的位置存储的就是num1,值为1
......
偏移量为12的操作指令为aload_1,表示将本地变量表中slot为1的内容进行压栈
偏移量为14的操作指令为i2d,表示将int类型转为double类型
......
对于字节码的算术操作和类型转换对比图如下所示:
在上图中可以看到,i表示int、l表示long、f表示float、d表示double,另外就是a表示引用类型,在Java代码中,基本的数据类型为boolean、byte、short、int、long、double、float、char,但是在虚拟机的字节码层面,只有int、double、float、long四种,是因为java虚拟机中int是最小的处理单位,因此其将栈语言进行了简化,会将boolean、byte、short、char都转化为int表示;在上面的字节码中,可以看到很多有下划线带数字的指令,这是因为对于大多数函数来说,其需要使用的操作数栈的深度、本地变量表的个数都是比较小的,因此可以将本来需要三个字节(一个字节的操作指令和两个字节的操作数)来实现的一条指令直接使用一个字节表示的指令,节省了很大的空间,如果操作数栈比较深或者本地变量个数较多,则会直接使用类似istore、iconst这样的指令,后面跟上具体的操作数。
三、循环字节码
public class ForLoopTest { private static int[] numbers = {1,6,8}; public static void main(String[] args) { MovingAverage ma = new MovingAverage(); for(int number : numbers){ ma.submit(number); } double avg = ma.getAvg(); } }
字节码
# javap -verbose ForLoopTest.class ......public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=3, locals=6, args_size=1 0: new #2 // class MovingAverage 3: dup 4: invokespecial #3 // Method MovingAverage."":()V 7: astore_1 8: getstatic #4 // Field numbers:[I 11: astore_2 12: aload_2 13: arraylength 14: istore_3 15: iconst_0 16: istore 4 18: iload 4 20: iload_3 21: if_icmpge 43 24: aload_2 25: iload 4 27: iaload 28: istore 5 30: aload_1 31: iload 5 33: i2d 34: invokevirtual #5 // Method MovingAverage.submit:(D)V 37: iinc 4, 1 40: goto 18 43: aload_1 44: invokevirtual #6 // Method MovingAverage.getAvg:()D 47: dstore_2 48: return LineNumberTable: line 4: 0 line 5: 8 line 6: 30 line 5: 37 line 8: 43 line 9: 48 LocalVariableTable: Start Length Slot Name Signature 30 7 5 number I 0 49 0 args [Ljava/lang/String; 8 41 1 ma LMovingAverage; 48 1 2 avg D StackMapTable: number_of_entries = 2 frame_type = 255 /* full_frame */ offset_delta = 18 locals = [ class "[Ljava/lang/String;", class MovingAverage, class "[I", int, int ] stack = [] frame_type = 248 /* chop */ offset_delta = 24 ......
在main方法中出现了if_icmpge,这就是对应的for循环的判断,其中if表示判断、i表示int类型、cmp表示比较、ge表示大于或等于,该指令的意思就是前面的变量如果大于等于给定的数,就会执行偏移量为43的指令,可以看到,goto指令在43指令之前,说明其跳出了循环,如果没有跳出,则一直执行,执行到43后,goto指令表示跳转到偏移量为18的操作指令进行执行。
四、关于方法调用
在上面的字节码中,看到有invokevirtual,其表示方法调用,实际在JVM字节码中共有五种方法调用指令。
Invokestatic:顾名思义,这个指令用于调用某个类的静态方法,这是方法调用指令中最快的一个。
Invokespecial :用来调用构造函数,但也可以用于调用同一个类中的 private 方法, 以及可见的超类方法。
invokevirtual :如果是具体类型的目标对象,invokevirtual 用于调用公共、受保护和package 级的私有方法。
invokeinterface :当通过接口引用来调用方法时,将会编译为 invokeinterface 指令。
invokedynamic : JDK7 新增加的指令,是实现“动态类型语言”(Dynamically TypedLanguage)支持而进行的升级改进,同时也是 JDK8 以后支持 lambda 表达式的实现基础。