JVM笔记:Java虚拟机的字节码指令详解

3,022 阅读44分钟

1.字节码

Java能发展到现在,其“一次编译,多处运行”的功能功不可没,这里最主要的功劳就是JVM和字节码了,在不同平台和操作系统上根据JVM规范的定制JVM可以运行相同字节码(.Class文件),并得到相同的结果。之所以被称之为字节码,是因为字节码文件由十六进制值组成,而JVM以两个十六进制值为一组,即以字节为单位进行读取。在Java中一般是用javac命令编译源代码为字节码文件,将java文件编译后生成.class文件交由Java虚拟机去执行,在android上,class文件被包装成.dex文件交由DVM执行。

通过学习Java字节码指令可以对代码的底层运行结构有所了解,能更深层次了解代码背后的实现原理,例如字符串的相加的实现原理就是通过StringBuilderappend进行相加。用过字节码的视角看它的执行步骤,对Java代码的也能有更深的了解,知其然,也要知其所以然。

通过学习字节码知识还可以实现字节码插桩功能,例如用ASM 、AspectJ等工具对字节码层面的代码进行操作,实现一些Java代码不好操作的功能。

1. 字节码的格式

下面举个简单的例子,分析其字节码的结构

public class Main {
    public static void main(String[] args) {
        System.out.println("HelloWorld");
    }
}

Main.class的字节码

上图中纯数字字母就是字节码,右边的是具体代码执行的字节码指令。

上面看似一堆乱码,但是JVM对字节码是有规范的,下面一点一点分析其代码结构

1.1魔数(Magic Number)

魔数唯一的作用是确定这个文件是否为一个能被虚拟机接收的Class文件。很多文件存储标准中都使用魔数来进行身份识别,譬如gif和jpeg文件头中都有魔数。魔数的定义可以随意,只要这个魔数还没有被广泛采用同时又不容易引起混淆即可。

这里字节码中的魔数为0xCafeBabe(咖啡宝贝),这个魔数值在Java还被称作Oak语言的时候就已经确定下来了,据原开发成员所说是为了寻找一些好玩的、容易记忆的东西,选择0xCafeBabe是因为它象征着著名咖啡品牌Peet`s Coffee中深受喜欢的Baristas咖啡,咖啡同样也是Java的logo标志。

1.2版本号(Version Number)

紧接着魔数的四个字节(00 00 00 33)存储的是Class文件的版本号。前两个是次版本号(Minor Version),转化为十进制为0;后两个为主版本号(Major Version),转化为十进制为52,序号52对应的主版本号为1.8,所以编译该文件的Java版本号为1.8.0。高版本的JDK能向下兼容以前的版本的Class文件,但不能运行以后版本的Class文件,及时文件格式并未发生变化,虚拟机也必须拒绝执行超过其版本号的Class文件。

1.3常量池(Constant Pool)

这部分内容前面做了一个简要的笔记,感兴趣的可以去看看。

紧接着版本号之后的是常量池入口,常量池可以理解为Class文件之中的资源仓库,它是Class文件结构中与其他项目关联最多的数据结构,也是占用Class文件控件最大的数据项目之一,同事也是在Class文件中第一个出现的表类型数据项目。

常量池的前两个字节(00 22)代表的是常量池容量计数器,与Java中语言习惯不一样的是,这个容量计数是从1开始的,这里的22转换成十进制后为34,去除一个下标计数即表示常量池中有33个常量,这一点从字节码中的Constant pool也可以看到,最后一个是#33 = Utf8 (Ljava/lang/String;)V

容量计数器后存储的是常量池的数据。 常量池中存储两类常量:字面量与符号引用。字面量为代码中声明为Final的常量值(例如字符串),符号引用如类和接口的全局限定名、字段的名称和描述符、方法的名称和描述符,当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建时或者运行时解析、翻译到内存地址中。如下图。

常量池的每一项常量都是一个表,在JDK71.7之前共有11中结构不同的表结构数据,在JDK1.7之后为了更好底支持动态语言调用,又额外增加了三种(CONSTANT_MethodHandle_info、CONSTANT_MethodType_info和CONSTANT_InvokeDynamic_info),总计14中,表结构如下图

常量池数据表

上图中tag是标志位,用于区分常量类型,length表示这个UTF-8编码的字符串长度是多少节,它后面紧更着的长度为length字节的连续数据是一个使用UTF-8缩略编码表示的字符串。上图的u1,u2,u4,u8表示比特数量,分别为1,2,4,8个byte。

UTF-8缩略编码与普通UTF-8编码的区别是:从\u0001\u007f之间的字符(相当于1-127的ASCII码)的缩略编码使用一个字节表示,从\u0080\u07ff之间的所有字符的缩略编码用两个字节表示,从\u0800\uffff之间的所有字符的缩略编码就按照普通UTF-8编码规则使用三个字节表示,这么做的主要目的还是为了节省空间。

由于Class文件中方法、字段等都需要引用CONSTANT_Utf8_info型常量来描述名称,所以CONSTANT_Utf8_info型常量的最大长度就是Java中的方法、字段名的最大长度。这里的最大长度就是length的最大值,即u2类型能表达的最大值65535,所以Java程序中如果定义了超过64K英文字符的变量或发放名,将会无法编译。

回到上面那个例子,00 22后面跟着的是 0A 0006 0014,第一个字节0A转化为十进制为10,表示的常量类型为CONSTANT_Methodref_info,这从常量表中可以看到这个类型后面会两个u2来表示index,分别表示CONSTANT_Class_infoCONSTANT_NameAndType_info。所以0006和0014转化为10进制分别是6和20。这里可能不知道这些数字指代什么意思,下面展示的是编译后的字节码指令就可以清楚了。

Constant pool:
   #1 = Methodref          #6.#20         // java/lang/Object."<init>":()V
   #2 = Fieldref           #21.#22        // java/lang/System.out:Ljava/io/PrintStream;
   #3 = String             #23            // HelloWorld
   #4 = Methodref          #24.#25        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #5 = Class              #26            // com/verzqli/snake/Main
   #6 = Class              #27            // java/lang/Object
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               LocalVariableTable
  #12 = Utf8               this
  #13 = Utf8               Lcom/verzqli/snake/Main;
  #14 = Utf8               main
  #15 = Utf8               ([Ljava/lang/String;)V
  #16 = Utf8               args
  #17 = Utf8               [Ljava/lang/String;
  #18 = Utf8               SourceFile
  #19 = Utf8               Main.java
  #20 = NameAndType        #7:#8          // "<init>":()V
  #21 = Class              #28            // java/lang/System
  #22 = NameAndType        #29:#30        // out:Ljava/io/PrintStream;
  #23 = Utf8               HelloWorld
  #24 = Class              #31            // java/io/PrintStream
  #25 = NameAndType        #32:#33        // println:(Ljava/lang/String;)V
  #26 = Utf8               com/verzqli/snake/Main
  #27 = Utf8               java/lang/Object
  #28 = Utf8               java/lang/System
  #29 = Utf8               out
  #30 = Utf8               Ljava/io/PrintStream;
  #31 = Utf8               java/io/PrintStream
  #32 = Utf8               println
  #33 = Utf8               (Ljava/lang/String;)V

从上面可以看到Constant pool中一共有33个常量,第一个常量类型为Methodref,他其实指代的是这个Main类,它是最基础的Object类,然后这里它有两个索引分别指向6和20,分别是Class和NameAndType类型,和上面十六进制字节码描述的一样。

1.4访问标志(Access Flags)

在常量池结束后,紧接着的两个字节代表访问标志,这个标志用于识别一些类或者接口层次的访问信息,包括:这个Class是类还是接口;是否定义为public类型;是否定义为abstract类型,如果是类的话,是否被声明为final等,具体的标志位以及标志的含义见下表。

标志名称 标志值 含义
ACC_PUBLIC 0x0001 标识是否为public类型
ACC_FINAL 0x0010 标识是否被声明为final,只有类可设置
ACC_SUPER 0x0020 用于兼容早期编译器,新编译器都设置改标志,以在使用invokespecial指令时对子类方法做特殊处理
ACC_SYNTHETIC 0x1000 标识这个类并非由用户代码产生,而是由编译器产生
ACC_INTERFACE 0x0200 标识是否为一个接口,接口默认同事设置ACC_ABSTRACT
ACC_ABSTRACT 0x0400 标识是否为一个抽象类,不可与ACC_FINAL同时设置
ACC_ANNOTATION 0x2000 标识这是否是一个注解类
ACC_ENUM 0x4000 标识这是否是一个枚举

ACCESS_FLAGS中一共有16个标志位可用,当前只定义了其中8个(上面显示了比8个多,是因为ACC_PRIVATE,ACC_PROTECTED,ACC_STATIC,ACC_VOLATILE,ACC_TRANSTENT并不是修饰类的,这里写出来是让大家知道还有这么些标志符),对于没有使用到的标志位要求一律为0。Java不会穷举上面所有标志的组合,而是同|运算来组合表示,至于这些标志位是如何表示各种状态,可以看这篇文章,讲的很清楚。

我们继续回到例子

标志
例子中只是一个简单的Main类,所以他的标志是ACC_PUBLIC和ACC_SUPER,其他标志都不存在,所以它的访问标志为0x0001|0x0020=0x0021。

1.5 类索引、父类索引、接口索引

类索引和父类索引都是一个u2类型的数据,接口索引是一组u2类型的数据的集合,Class文件中由着三项数据来确定这个类的继承关系。这三者按顺序排列在访问标志之后,本文例子中他们分别是:0005,0006,0000,也就是类索引为5,父类索引为6,接口索引集合大小为0 ,查询上面字节码指令的常量池可以一一对应(5对应com/verzqli/snake/Main,6对应java/lang/Object)。

类索引确定这个类的全限定名,父类索引确定这个类的父类全限定 名,因为Java不允许多重继承,所以父类索引只有一个,除了Object外,所有的类都有其父类,也就是其父类索引不为0.接口索引即可用来描述这个类实现了哪些接口,这些被实现的接口按implements(如果这个类本身就是一个接口,则应当是extends语句)后的接口顺序从左到右排列在接口索引集合中。

1.6 字段表集合(Field Info)

字段表用于描述类和接口中声明的变量,包含类级别的变量以及实例变量。但是不包含方法内部声明的局部变量。在Java中描述一个字段可能包含一下信息:

  • 字段的作用域(public,private,protected修饰符)
  • 是实例变量还是类变量(static修饰符)
  • 是否可变(final修饰符)
  • 并发可见 (vlolatile修饰符,是否强制从主内存中读写)
  • 是否可悲序列化(transient修饰符)
  • 字段数据基本类型(基本类型、对象、数组)
  • 字段名称 上述信息中,每个修饰符都是bool值,要么有要么没有,很适合用和访问标志一样的标志位来表示。而字段名称,字段数据类型只能引用常量池中的常量来描述。其中字段修饰符的访问标志和含义如下表。
标志名称 标志值 含义
ACC_PUBLIC 0x0001 标识是否为private类型
ACC_PRIVATE 0x0002 标识是否为private类型
ACC_PROTECTED 0x0004 标识是否为protectes类型
ACC_STATIC 0x0008 标识是否为静态类型
ACC_FINAL 0x0010 标识是否被声明为final,只有类可设置
ACC_VOLATILE 0x0040 标识是否被声明volatile
ACC_TRANSIENT 0x0080 标识是否被声明transient
ACC_SYNTHETIC 0x1000 标识这个类并非由用户代码产生,而是由编译器产生
ACC_ENUM 0x4000 标识这是否是一个枚举

字段表的结构分为两部分,第一部分为两个字节,描述字段个数(fields_count);第二部分是每个字段的详细信息(fields_info),按顺序排列分别是访问标志(access_flags)、字段名称索引(name_index)、字段的描述符索引(descriptor_index)、属性表计数器(attribute_count)和属性信息列表(attributes)。除了最后未知的属性信息,其他都是u2的数据类型。

继续看例子,这个例子选的有点尴尬,忘记往里面放一个变量,所以在类索引后面的第一个u2 数据为0000 表示字段个数为0,所以后续的数据也没有了。只能假设一组数据来看看字段表的结构

字节码 00 01 00 02 00 03 00 07 00 00
描述 字段表个数 访问标志 字段名称索引 字段的描述符索引 属性个数
内容 1 ACC_PRIVATE 3 7 0

字段表集合中不会列出从超类或者父类接口中继承而来的字段,但有可能列出原本Java代码之中不存在的字段,譬如在内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段。另外,在Java中字段是无法重载的,对于字节码来讲,只有两个字段的描述符不一致,该字段才是合法的。

为了便于理解,这里对上面提到的一些名词进行一下解释

  • 全限定名:本文中的Main类的全限定名为com/verzqlisnake/Main,仅仅把包名中的.替换成/即可为了使连续的多个全限定名补偿混淆,一般在使用时最后会假如一个;,表示全限定名结束。
  • 简单名词:值得是没有类型和参数修饰的方法或字段名称,例如public void fun()private int a的简单名称就为funa
  • 方法和字段的描述符:描述符的作用是用来描述字段的数据类型或方法的参数列表(数量、类型和顺序)和返回值。描述符包含基本数据类型和无返回值的void,主要表示为下表中形式。
描述字符 含义
描述 字段表个数
I 基本类型int
S 基本类型short
J 基本类型long,这里注意不是L,L是最后一个
F 基本类型float
D 基本类型double
B 基本类型byte
C 基本类型char
Z 基本类型boolean
V 特殊类型void
L 对象类型,例如Ljava/lang/String

对于数组类型,每一位度使用一个前置的[来描述,例如String[]数组将被记录为[Ljava/lang/String,String[][]数组被记录为[[Ljava/lang/String ;int[]数组被记录为[I

用描述符来描述方法时,要先按照参数列表,后返回值的顺序描述,参数列表按照参数的严格顺序放在一组小括号()之中。例如方法void fun()的描述符为()V,String.toString()的描述符为()Ljava/lang/Stringpublic void multi(int i,String j,float[] c)的描述符为(ILjava/lang/String;[F)V

1.7 方法表集合(Field Info)

方法表的结构和字段表的结构几乎完全一致,存储的格式和描述也非常相似。方法表的结构和字段表一样,包含两部分。第一部分为方法计数器,第二部分为每个方法的详细信息,依次包含了访问标志(access_flags)、方法名称索引(name_index)、方法的描述符索引(descriptor_index)、属性表计数器(attribute_count)和属性信息列表(attributes)。这些数据的含义也和字段表非常相似,仅在访问标志和属性表集合的可选项中有所区别。

类型 名称 数量
u2 access_flags 1
u2 name_index 1
u2 descriptor_index 1
u2 attribute_count 1
attribute_info attribute_info attribute_count

因为volatiletransient关键字不能修饰方法,所以方法标的访问标志中也就没有这两项标志,与之对应的,synchronized、native、strictfp、abstract可以修饰方法,所以方发表的访问标志中增加了这几类标志,如下表

标志名称 标志值 含义
ACC_PUBLIC 0x0001 标识方法是否为private
ACC_PRIVATE 0x0002 标识方法是否为private
ACC_PROTECTED 0x0004 标识方法是否为protectes
ACC_STATIC 0x0008 标识方法是否为静态
ACC_FINAL 0x0010 标识方法是否被声明为final
ACC_SYNCHRONIZED 0x0020 标识方法是否被声明synchronized
ACC_BRIDGE 0x0040 标识方法是否由编译器产生的桥接方法
ACC_VARARGS 0x0080 标识这个类是否接受不定参数
ACC_NATIVE 0x0100 标识方法是否为native
ACC_ABSTRACT 0x0400 标识方法是否为abstract
ACC_STRICTFP 0x0800 标识方法是否为strictfp
ACC_SYNTHETIC 0x1000 标识方法是否由编译器自动产生的

继续分析本文例子,方法表数据在字段表之后的数据 0002 0001 0007 0008 0001 0009

字节码 00 02 00 01 00 07 00 08 00 01 0009
描述 方法表个数 访问标志 方法名称索引 方法的描述符索引 属性表计数器 属性名称索引
内容 1 ACC_PUBLIC 7 8 1 9

从上表可以看到方法表中有两个方法,分别是编译器添加的实例构造器<init>和代码中的main()方法。第一个方法的访问标志为ACC_PUBLIC,方法名称索引为7(对应<init>),方法描述符索引为8(对应()V),符合前面的常量池中的数据。

   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code

接着属性表计数器的值为1,表示此方法的属性表集合有一箱属性,属性名称索引为9,对应常量池中为Code,说明此属性是方法的字节码描述。

方法重写 : 如果父类方法在子类中没有被重写(Override),方法表集合中就不会出现来自父类的方法信息。但同样的,有可能会出现由编译器自动添加的方法,最典型的便是类构造器<clinit>方法和实例构造器<init>方法。 方法重载:在Java中药重载(OverLoad)一个方法,除了要与原方法遇有相同的简单名词外,还需要要有一个与原方法完全不同的特征签名。特征签名是一个方法中各个参数在常量池中的字段符号引用的集合,返回值并不会包含在前面中,因此无法仅仅依靠返回值不同来重载一个方法。 但是在Class文件中,特征签名的范围更大一些,只要描述符不是完全一致的两个方法也是可以共存的。也就是说,如果两个方法有相同的名称和特征签名,但返回值不同,那么也是可以合法共存于同一个Class文件的,也就是说Java语法不支持,但是Class文件支持。

1.8 属性表集合(attribute Info)

属性表在前面的讲解中已经出现过数次,在Class文件、字段表、方法表都可以携带自己的属性表集合,已用于描述某些场景专有的信息 与Class文件中其他的数据项目要求严格的顺序、长度和内容不同,属性表集合的限制稍微宽松了一些,不在要求各个属性表具有严格的顺序,只要不与已有的属性名重复,任何人实现的编译器都可以想属性表中写入自己定义的属性信息:Java虚拟机运行时会忽略掉它不认识的属性,具体的预定义属性入下表。

属性名称 使用位置 含义
Code 方法表 Java代码编译成的字节码指令
ConstantValue 字段表 final关键字定义的常量池
Deprecated 类,方法,字段表 被声明为deprecated的方法和字段
Exceptions 方法表 方法抛出的异常
EnclosingMethod 类文件 仅当一个类为局部类或者匿名类是才能拥有这个属性,这个属性用于标识这个类所在的外围方法
InnerClass 类文件 内部类列表
LineNumberTable Code属性 Java源码的行号与字节码指令的对应关系
LocalVariableTable Code属性 方法的局部变量描述
StackMapTable Code属性 JDK1.6中新增的属性,供新的类型检查检验器检查和处理目标方法的局部变量和操作数有所需要的类是否匹配
Signature 类,方法表,字段表 JDK1.5中新增的属性,用于支持泛型情况下的方法签名。任何类,接口,初始化方法或成员的泛型前面如果包含了类型变量(Type Variables)或参数化类型(Parameterized Type),则signature属性会为它记录泛型前面信息,由于Java的泛型采用擦除法实现,在为了便面类型信息被擦除后导致签名混乱,需要这个属性记录泛型中的相关信息。
SourceFile 类文件 记录源文件名称
SourceDebugExtension 类文件 JDK1.6中新增的属性,用于存储额外的调试信息
Synthetic 类,方法表,字段表 标志方法或字段为编译器自动生成的
LocalVariableTypeTable JDK1.5中新增的属性,使用特征签名代替描述符,是为了引入泛型语法之后能描述泛型参数化类型而添加
RuntimeVisibleAnnotations 类,方法表,字段表 JDK1.5中新增的属性,为动态注解提供支持 ,用于指明那些注解是运行时(运行时就是进行反射调用)可见的
RuntimeInvisibleAnnotations 表,方法表,字段表 JDK1.5中新增的属性,和上面刚好相反,用于指明哪些注解是运行时不可见的
RuntimeVisibleParameterAnnotation 方法表 JDK1.5中新增的属性,作用与RuntimeVisibleAnnotations属性类似,只不过作用对象为方法
RuntimeInvisibleParameterAnnotation 方法表 JDK1.5中新增的属性,作用与RuntimeInvisibleAnnotations属性类似,作用对象哪个为方法参数
AnnotationDefault 方法表 JDK1.5中新增的属性,用于记录注解类元素的默认值
BootstrapMethods 类文件 JDK1.7中新增的属性,用于保存invokeddynamic指令引用的引导方式限定符

对于每个属性,它的名称需要从常量池中应用一个CONSTANT_Utf8_info类型的常量来标书,而属性值的结构则是完全子墩医德,只需要通过一个u4的长度属性去说明属性值做占用的位数即可,其符合规则的结构如下图。

类型 名称 数量
u2 attribute_name_index 1
u4 attribute_length 1
u1 infoattribute_length

因为属性表中的属性包含二十多种,下面只对几个属性做一个简要描述。

  • 1.8.1 Code 属性

Java程序方法体中的代码经过Javac编译器处理后,最终变为字节码指令存储在Code属性内,Code属性出现在方法表的属性集合之中,但并未所有的方法表都必须存在这个属性:接口或者抽象类中的方法就不存在Code属性。如果方法表有Code属性,那么它的结构将如下表所示。

类型 名称 数量
u2 attribute_name_index 1
u4 attribute_length 1
u2 max_stack 1
u2 max_locals 1
u4 code_length 1
u1 code code_length
u2 exception_table_length 1
exception_info exception_table exception_length
u2 attributes_count 1
attribute_info attributes attributes_count

attribute_name_index:一项指向CONSTANT_Utf8_info型常量的索引,常量值固定为“Code”,他代表了该属性的名称。 attribute_length: 属性值得长度,由于属性名称索引和长度一共为6字节,所以属性值长度固定为整个属性表长度减去6个字节。 max_stack:操作数栈深度的最大值,装虚拟机运行的时候需要根据这个值来分配栈帧中的操作栈深度,没有定义好回归的递归发生的栈溢出就是超过了这个值。 max_locals:局部变量表所需的存储空间。这里的单位是Slot,Slot是虚拟机为局部变量表分配内存所使用得最小单位。对于byte、char、float、int、short、boolean、returnAddress这些长度不超过32位的整型数据,每个局部变量占用一个Slot。像double和float两种64位的数据类型需要两个Slot来存放位置。**方法参数(实例方法中隐藏的this)、显示异常处理器的参数(就是try-catch语句中catch锁定义的异常)、放大提中定义的局部变量都需要使用局部变量表来存放。**因为Slot可以重用,所以这个最大值并不是所有的Slot之和,当代码执行超过一个局部变量的作用于时,这个局部变量所占用的Slot可以被其他局部变量使用,所以该值主要根据变量的所用域来计算大小。 code_length:字节码长度。虽然是u4长度,但是虚拟机规定了一个方法中的字节码指令条数不超过u2(65535)条,超过的话编译器会拒绝编译。 code:存储编译后生成的字节码指令。每个字节码指令是一个u1类型的单字节。当虚拟机督导一个字节码时,可以找到这个字节码代码的指令,并可以知道这个指令后面是否需要跟随参数以及参数的意思。一个u1数据的取值范围为0x00~0xff,也就是一共可以表达256条指令,目前,Java虚拟机以及定义了其中200多条编码值对应的指令含义,具体指令可以看虚拟机字节码指令表。 因为异常表对于Code属性不是必须存在的,后面几个类型也没有太大的重要性,这里就暂时略过。

  • 1.8.2 Exceptions属性

    这里的Exceptions属性是在方法表中与Code属性评级的一项属性,Exceptions属性的作用是列举出方法中可能抛出的受查异常(Checked Exceptions),也就是方法描述时在throws关键词后面列举的异常,其结构如下图。
类型 名称 数量
u2 attribute_name_index 1
u2 attribute_lrngth 1
u2 number_of_exception 1
u2 exception_index_table number_of_exceptions

number_of_exception:表示方法可能抛出此项值数值的受查异常,每一种受查异常exception_index_table表示。 exception_index_table:表示一个指向常量池中CONSTANT_Class_indo型常量的索引,所以,代表了该种受查异常的类型。

  • 1.8.3 SourceFile属性

SourceFile属性用于记录生成这个Class文件的源码文件名称。可以使用Javac的-g:none和-g:source选项来关闭或者生成这项信息。对于大多数类来说,类名和文件名是一致的,但是例如内部类等一些特殊情况就会不一样。如果不生成这个属性,当抛出异常时,堆栈中将不会显示出错代码所属的文件名,其结构入下表:

类型 名称 数量
u2 attribute_name_index 1
u4 attribute_length 1
u2 sourcefile_index 1

sourcefile_index:指向常量池中的CONSTANT_Utf8_indo型常量,常量值是源码文件的文件名。

  • 1.8.3 InnerClass属性

InnerClass属性用于记录内部类与宿主之间的关联,如果一个类中定义了内部类,那编译器将会为他以及它所包含的内部类生成InnerClasses属性,其表结构如下图:

类型 名称 数量
u2 attribute_name_index 1
u4 attribute_length 1
u2 number_of_classes 1
inner_classes_info inner_classes number_of_classes

number_of_classes:表示内部类信息的个数。每一个内部类的信息都由一inner_classes_info表进行描述,改表结果如下:

类型 名称 数量
u2 inner_class_info_index 1
u2 outer_class_info_index 1
u2 inner_name_index 1
u2 inner_class_access_flags 1

inner_class_info_index:指向常量池中的CONSTANT_Class_indo型常量的索引,表示内部类的符号引用。 outer_class_info_index:指向常量池中的CONSTANT_Class_indo型常量的索引,表示宿主类的符号引用。 inner_class_access_flags:内部类的访问标志,类似于类的access_flags

  • 1.8.4 ConstantValue属性

    ConstantValue属性的作用是通知虚拟机自动为静态变量赋值。只有被static关键字修饰的变量(类变量)才可以使用这项属性,例如int a=1static int a=1,虚拟机对这两种变量的赋值方式和时刻都有所不同。对于前者的赋值是在实例构造器方法中进行的,换而言之就是一个类的构造的方法没有被执行前,该类的成员变量是还没赋值的;而对于后者,则有两种方式可以选择:在类构造器方法中或者使用ConstantValue属性。目前Javac编译器的选择是如果同时使用finalstatic来修饰一个变量,并且这个变量的数据类型是基本类型或者字符串类型时,就生成ConstantValue属性来初始化,如果这个变量没有被final修饰,或者并非基本类型变量或字符串,则会选择在<clinit>方法中进行初始化

    <clinit>:类构造器。在jvm第一次加载class文件时调用,因为是类级别的,所以只加载一次,是编译器自动收集类中所有类变量(static修饰的变量)和静态语句块(static{}),中的语句合并产生的,编译器收集的顺序,是由程序员在写在源文件中的代码的顺序决定的。 <init>:实例构造器方法,在实例创建出来的时候调用,包括调用new操作符;调用Class或java.lang.reflect.Constructor对象的newInstance()方法;调用任何现有对象的clone()方法;通过java.io.ObjectInputStream类的getObject()方法反序列化。

<clinit>方法和类的构造函数不同,它不需要显示调用父类的构造方法,虚拟机会保证子类的<clinit>方法执行之前,父类的此方法已经执行完毕,因此虚拟机中第一个被执行的方法的类肯定是java.lang.Object。言而言之就是先需要<clinit>完成类级别的变量和代码块的加载,再进行对象级别的加载信息,所以经常看的面试题子类和父类哪个语句先被执行就是这些决定的。

public class Main {
     static final int a=1;
}
字节码:
  static final int a;
    descriptor: I
    flags: ACC_STATIC, ACC_FINAL
    ConstantValue: int 1
未添加final
public class Main {
      static int a=1;
}
字节码:
  public com.verzqli.snake.Main();
    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
      LineNumberTable:
        line 12: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcom/verzqli/snake/Main;
  //可以看到  这里的初始化放在了Main的类构造器中
  static {};
    descriptor: ()V
    flags: ACC_STATIC
    Code:
      stack=1, locals=0, args_size=0
         0: iconst_1
         1: putstatic     #2                  // Field a:I
         4: return
      LineNumberTable:
        line 13: 0
}

public class Main {
        int a=1;
}
字节码:
  //可以看到  这里的初始化放在了Main的实例构造器中
  public com.verzqli.snake.Main();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: aload_0
         5: iconst_1
         6: putfield      #2                  // Field a:I
         9: return
}

2. 字节码指令

字节码指令是一个字节长度的,代表着某种特点操作含义的数字,总数不超过256条(全部字节码指令汇编),。对于大部分与数据类型相关的字节码指令,它们的操作码助记符中都有特殊字符来表明专门为那种数据类型服务,如下表:

描述字符 含义
i 基本类型int
s 基本类型short
l 基本类型long,这里注意不是L,L是最后一个
f 基本类型float
d 基本类型double
b 基本类型byte
c 基本类型char
b 基本类型boolean
a 对象类型引用reference

这里有一个注意的点,这对于不是整数类型的byte、char、short、boolean。编译器会在编译器或运行期将byte和short类型的数据带符号扩展(Sign-extend)为相应的int类型数据,将boolean和char类型数据零位扩展(Zero-extend)为相应的int数据。同样在处理上诉类型的数组数据是,也会转换为使用int类型的字节码指令来处理。

2.1 加载和存储指令。

加载和存储指令用于将数据在栈帧中的局部变量表和操作数栈之间来回传输。

<类型>load_<下标>:将一个局部变量加载到操作数栈。例如iload_1,将一个int类型局部变量(下标为1,0一般为this)从局部变量表加载到操作栈,其他的也都类似,例如:dload_2,fload_3。 <类型>store_<下标>:将一个数值从操作数栈栈顶存储到局部变量表。例如istore_3,将一个int类型的数值从操作数栈栈顶存储到局部变量3中,后缀为3,证明局部变量表中已经存在了两个值。 <类型>const_<具体的值>:将一个常量加载到操作数栈。例如iconst_3,将常量3加载到操作数栈。 wide扩展:当上述的下标志超过3时,就不用下划线的方式了,而是使用istore 6,load的写法也是一样。 bipush、sipush、ldc :当上述的const指令后面的值变得很大时,该指令也会改变。

  • 当 int 取值 -1~5 时,JVM 采用 iconst 指令将常量压入栈中。
  • 当 int 取值 -128~127 时,JVM 采用 bipush 指令将常量压入栈中。
  • 当 int 取值 -32768~32767 时,JVM 采用 sipush 指令将常量压入栈中。
  • 当 int 取值 -2147483648~2147483647 时,JVM 采用 ldc 指令将常量压入栈中。

看例子:

 public void save() {
       int a = 1;
       int b = 6;
       int c = 128;
       int d = 32768 ;
       float f = 2.0f;
   }
字节码:
   Code:
     stack=1, locals=6, args_size=1
        0: iconst_1               //将常量1入栈,
        1: istore_1               //将栈顶的1存入局部变量表,下标为1,因为0存储了整个类的this
        2: bipush        6       //将常量6入栈,同时也是以wide扩展的形式
        4: istore_2               //将栈顶的6存入局部变量表,下标为2
        5: sipush        128    //将常量128入栈,
        8: istore_3               //将栈顶的128存入局部变量表,下标为3 ,后面一样的意思
        9: ldc           #2        // int 32768
       11: istore        4
       13: fconst_2
       14: fstore        5
       16: return

2.2 运算指令。

运算主要分为两种:对征信数据进行运算的指令和对浮点型数据运算的指令,和前面说的一样,对于byte、char、short、和 boolean类型的算数质量都使用int类型的指令替代。整数和浮点数的运算指令在移除和被领出的时候也有各自不同的表现行为。具体的指令也是在运算指令前加上对应的类型即可,例如加法指令:iadd,ladd,fadd,dadd。

  • 加法指令:(i,l,f,d)add
  • 减法指令:(i,l,f,d)sub
  • 乘法法指令:(i,l,f,d)mul
  • 除法指令:(i,l,f,d)div
  • 求余指令:(i,l,f,d)rem
  • 取反指令:(i,l,f,d)neg
  • 位移指令: ishl、ishr、iushr、lshl、lshr、lushr
  • 按位或指令:ior、lor
  • 按位与指令:iand、land
  • 按位异或指令: ixor、lxor
  • 局部变量自增: iinc(例如for循环中i++)
  • 比较指令: dcmpg、dcmpl、fcmpg、fcmpl、lcmp

上面的指令没必要强记,需要的时候查找一下即可,看多了也自然就熟悉了。至于浮点数运算的精度损失之类的这里就不多做赘述了。

2.3 类型转换指令。

类型转换指令可以将两种不同的数值类型进行相互转换,这些转换一般用于实现用户代码中的显示类型转换操作。

Java虚拟机直接支持宽化数据类型转换(小范围数据转换为大数据类型),不需要显示的转换指令,例如int转换long,float和double。举例:int a=10;long b =a

Java虚拟机转换窄化数据类型转换时,必须显示的调用转化指令。举例:long b=10;int a = (long)b

类型转换的字节码指令其实就比较简单了,<前类型>2<后类型>,例如i2l,l2i,i2f,i2d。当然这里举的都是基本数据类型,如果是对象,当类似宽化数据类型时就直接使用,当类似窄化数据类型时,需要checkcast指令。

public class Main {
    public static void main(String[] args) {
        int a = 1;
        long b = a;
        Parent Parent = new Parent();
        Son son = (Son) Parent;
    }
}
字节码:
  Code:
      stack=2, locals=6, args_size=1
         0: iconst_1
         1: istore_1
         2: iload_1
         3: i2l
         4: lstore_2
         5: new           #2                  // class com/verzqli/snake/Parent
         8: dup
         9: invokespecial #3                  // Method com/verzqli/snake/Parent."<init>":()V
        12: astore        4
        14: aload         4
        16: checkcast     #4                  // class com/verzqli/snake/Son
        19: astore        5
        21: return

注意上面这个转换时错误的,父类是不能转化为子类的,编译期正常,但是运行是会报错的,这就是checkcast指令的原因。

2.4 对象创建和访问指令

虽然累实例和数组都是对象,但Java苏尼基对类实例和数组的创建与操作使用了不同的字节码指令。对象创建后,就可以通过对象访问指令获取对象实例或者数组实例中的字段或者数组元素,这些指令如下。

  • new:创建类实例的指令
  • newarray、anewarray、multianewarray:创建数组的指令
  • getfield、putfield、getstatic、putstatic:访问类字段(static字段,被称为类变量)和实例字段(非static字段,)。
  • (b、c、s、i、l、f、d、a)aload:很明显,就是基础数据类型加上aload,将一个数组元素加载到操作数栈。
  • (b、c、s、i、l、f、d、a)astore:同上面一样的原理,将操作数栈栈顶的值存储到数组元素中。
  • arraylength:取数组长度
  • instanceof、checkcast:检查类实例类型的指令。

2.4 操作数栈管理指令

如同操作一个普通数据结构中的堆栈那样,Java虚拟机提供了一些直接操作操作数栈的指令。

  • pop、pop2:将操作数栈栈顶的一个或两个元素出栈。
  • dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2:服战栈顶一个或两个数值并将期值复制一份或两份后重新压入栈顶。
  • swap:将栈顶两个数互换。

2.5 方法调用和返回指令。

方法调用的指令只要包含下面这5条

  • invokespecial:用于调用一些需要特殊处理的实例方法,包括实例初始化方法、私有方法和父类方法。
  • invokestatic:用于调用static方法。
  • invokeinterface:用于调用接口方法,他会在运行时搜索一个实现了这个接口方法的对象,找出合适的方法进行调用。
  • invokevirtual:用于调用对象的实例方法,根据对象的实际类型进行分派。
  • invokedynamic:用于在运行时动态解析出调用点限定符所引用的方法,并执行该方法。前面4条指令的分派逻辑都固话在Java虚拟机内部,而此条指令的分派逻辑是由用户设定的引导方法决定的。
  • (i,l,f,d, 空)return:根据前面的类型来确定返回的数据类型,为空时表示void

2.5 异常处理指令。

在Java程序中显示抛出异常的操作(throw语句)都由athrow指令来实现。但是处理异常(catch语句)不是由字节码指令来实现的,而是采用异常表来完成的,如下例子。

public class Main {
   public static void main(String[] args) throws Exception{
       try {
           Main a=new Main();
       }catch (Exception e){
           e.printStackTrace();
       }
   }
}
字节码:
public static void main(java.lang.String[]) throws java.lang.Exception;
   descriptor: ([Ljava/lang/String;)V
   flags: ACC_PUBLIC, ACC_STATIC
   Code:
     stack=2, locals=2, args_size=1
        0: new           #2                  // class com/verzqli/snake/Main
        3: dup
        4: invokespecial #3                  // Method "<init>":()V
        7: astore_1
        8: goto          16
       11: astore_1
       12: aload_1
       13: invokevirtual #5                  // Method java/lang/Exception.printStackTrace:()V
       16: return

2.6 同步指令

Java虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用Monitor 实现的。 正常情况下Java运行是同步的,无需使用字节码控制。虚拟机可以从方法常量池的方法表结构中的ACC_SYNCHRONIZE访问标志得知一个方法是否声明为同步方法。当方法调用时,调用指令将会检查方法的ACC_SYNCHRONIZE访问表示是否被设置,如果设置了,执行线程就要求先持有Monitor,然后才能执行方法,最后当方法完成时释放Monitor。在方法执行期间,执行线程持有了Monitor,其他任何一个线程都无法在获取到同一个Monitor。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理次异常,那么这个同步方法所持有的Monitor将在异常抛出到同步方法之外时自动释放。 同步一段指令集序列通常是由synchronized语句块来表示的,Java虚拟机指令集中有monitorentermonitorexit两条指令来支持synchronized关键字。如下例子

public class Main {
    public void main() {
        synchronized (Main.class) {
            System.out.println("synchronized");
        }
        function();
    }

    private void function() {
        System.out.printf("function");
    }
}

字节码:
 Code:
      stack=3, locals=3, args_size=1
         0: ldc           #2                  // class com/verzqli/snake/Main  将Main引用入栈
         2: dup                                // 复制栈顶引用 Main
         3: astore_1                        // 将栈顶应用存入到局部变量astore1中
         4: monitorenter                  // 将栈顶元素(Main)作为锁,开始同步
        5: getstatic     #3                 // Field java/lang/System.out:Ljava/io/PrintStream;
         8: ldc           #4                   // String synchronized ldc指令在运行时创建这个字符串
        10: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        13: aload_1                         // 将局部变量表的astore1入栈(Main)
        14: monitorexit                    //退出同步
        15: goto          23                  // 方法正常结束,跳转到23
        18: astore_2                        //这里是出现异常走的路径,将栈顶元素存入局部变量表
        19: aload_1                          // 将局部变量表的astore1入栈(Main)
        20: monitorexit                      //退出同步
        21: aload_2                          //将前面存入局部变量的异常astore2入栈
        22: athrow                            //  把异常对象长线抛出给main方法的调用者
        23: aload_0                          // 将类this入栈,以便下面调用类的方法
        24: invokespecial #6                  // Method function:()V
        27: return

编译器必须确保无论方法通过何种方式完成,方法中调用过的每条monitorenter指令都必须执行其对应的monitorexit指令,无论这个方法是正常结束还是异常结束。

3 实例

前面说了一堆,空看理论既枯燥又难懂,理论就图一乐,真懂还得看例子。

例一:

相信面试过的人基本地看过这个面试题,然后还扯过值传递还是引用传递这个问题,下面从字节码的角度来分析这个问题。

public class Main {
    String str="newStr";
    String[] array={"newArray1","newArray2"};

  public static void main(String[] args) {
      Main main=new Main();
      main.change(main.str, main.array);
      System.out.println(main.str);
      System.out.println(Arrays.toString(main.array));
  }

  private   void change(String str, String[] array) {
      str="newStrEdit";
      array[0]="newArray1Edit";
  }
}
输出结果:
newStr
[newArray1Edit, newArray2]
字节码:

private void change(java.lang.String, java.lang.String[]);
  descriptor: (Ljava/lang/String;[Ljava/lang/String;)V
  flags: ACC_PRIVATE
  Code:
    stack=3, locals=3, args_size=3
       0: ldc           #14                 // String newStrEdit
       2: astore_1
       3: aload_2
       4: iconst_0
       5: ldc           #15                 // String newArray1Edit
       7: aastore
       8: return

}

这里main方法的字节码内容可以忽略,主要看这个change方法,下面用图来表示。

图一

这是刚进入这个方法的情况,这时候还没有执行方法的内容,局部变量表存了三个值,第一个是this指代这个类,在普通方法内之所以可以拿到外部的全局变量就是因为方法内部的局部变量表的第一个就是类的this,当获取外部变量时,先将这个this入栈aload_0,然后就可以获取到这个类所有的成员变量(也就是外部全局变量)了。 因为这个方法传进来了两个值,这里局部变量表存储的是这两个对象的引用,也就是在堆上的内存地址。

图二
上面执行了str = "newStrEdit";这条语句,先ldc指令创建了newStrEdit(0xaaa)字符串入栈,然后astore_1指令将栈顶的值保存再局部变量1中,覆盖了原来的地址,所以这里对局部变量表的修改完全没有影响外面的值。
图三
上面执行array[0] = "newArrar1Edit";这条语句,将array的地址入栈,再将要修改的数组下标0入栈,最后创建newArray1Edit字符串入栈。最后调用aastore指令将栈顶的引用型数值(newArray1Edit)、数组下标(0)、数组引用(0xfff)依次出栈,最后将数值存入对应的数组元素中,这里可以看到对这个数组的操作一直都是这个0xfff地址,这个地址和外面的array指向的是同一个数组对象,所以这里修改了,外界的那个array也就同样修改了内容。

例二:

看过前面那个例子应该对局部变量表是什么有所了解,下面这个例子就不绘制上面那个图了。这个例子也是一个常见的面试题,判断try-catch-finally-return的执行顺序。

finally是一个最终都会执行的代码块,finally里面的return会覆盖try和catch里面的return,同时在finally里面修改局部变量不会影响try和catch里面的局部变量值,除非trycatch里面返回的值是一个引用类型。

 public static void main(String[] args) {
        Main a=new Main();
        System.out.println("args = [" + a.testFinally() + "]");;
    }

    public   int testFinally(){
        int i=0;
        try{
            i=2;
            return i;
        }catch(Exception e){
            i=4;
            return i;
        }finally{
            i=6;
        }
字节码:
public int testFinally();
    descriptor: ()I
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=5, args_size=1
         0: iconst_0				// 常量0入栈
         1: istore_1				// 赋值给内存变量1(i) i=0
         2: iconst_2				// 常量2入栈
         3: istore_1				// 赋值给内存变量1(i) i=2
         4: iload_1				    // 内存变量1(i)入栈
         5: istore_2			    // 将数据存储在内存变量2 这里原因下面说明
         6: bipush        6    		// 常量6入栈
         8: istore_1				// 保存再内存变量1
         9: iload_2					// 加载内存变量2
        10: ireturn					// 返回上一句加载的内存变量2(i) i=2
        11: astore_2				// 看最下面的异常表,如果2-6发生异常,就从11开始,下面就是发生异常后进入catch的内容
        12: iconst_4				// 常量4入栈
        13: istore_1				// 保存在局部变量1
        14: iload_1					// 加载局部变量1
        15: istore_3				// 将局部变量1内容保存到局部变量3,原因和上面5一样
        16: bipush        6			// 常量6入栈 (进入了catch最后也会执行finally,所以这里会重新再执行一遍finally)
        18: istore_1				// 保存在局部变量1
        19: iload_3					// 加载局部变量3并返回
        20: ireturn					//上面类似的语句,不过是catch-finally的路径
        21: astore        4			// finally 生成的冗余代码,这里发生的异常会抛出去
        23: bipush        6
        25: istore_1
        26: aload         4
        28: athrow
      Exception table:
         from    to  target type
             2     6    11   Class java/lang/Exception  //如果2-6发生指定的Exception异常(try),就从11开始
             2     6    21   any 						//如果2-6发生任何其他异常(finally),就从21开始
            11    16    21   any						//如果11-16发生任何其他异常(catch),就从21开始
            21    23    21   any						//其实这里有点不太能理解为什么会循环,如果有知道的大佬可以解答一下

在Java1.4之后 Javac编译器 已经不再为 finally 语句生成 jsr 和 ret 指令了, 当异常处理存在finally语句块时,编译器会自动在每一段可能的分支路径之后都将finally语句块的内容冗余生成一遍来实现finally语义。(21~28)。但我们Java代码中,finally语句块是在最后的,编译器在生成字节码时候,其实将finally语句块的执行指令移到了ireturn指令之前,指令重排序了。所以,从字节码层面,我们解释了,为什么finally语句总会执行!

如果try中有return,会在return之前执行finally中的代码,但是会保存一个副本变量(第五和第十五行)。finally修改原来的变量,但tryreturn返回的是副本变量,所以如果是赋值操作,即使执行了finally中的代码,变量也不一定会改变,需要看变量是基本类型还是引用类型。 但是如果在finally里面添加一个return,那么第9行和第19行加载的就是finally块里修改的值(iload_1),再在最后添加一个iload_1ireturn,感兴趣的可以自己去看一下字节码。

例三:

还是上面那个类似的例子,这里做一下改变

 public static void main(String[] args) {
        Main a = new Main();
        System.out.println("args = [" + a.testFinally1() + "]");
        System.out.println("args = [" + a.testFinally2() + "]");
    }

    public StringBuilder testFinally1() {
        StringBuilder a = new StringBuilder("start");
        try {
            a.append("try");
            return a;
        } catch (Exception e) {
            a.append("catch");
            return a;
        } finally {
            a.append("finally");
        }
    }

    public String testFinally2() {
        StringBuilder a = new StringBuilder("start");
        try {
            a.append("try");
            return a.toString();
        } catch (Exception e) {
            a.append("catch");
            return a.toString();
        } finally {
            a.append("finally");
        }
    }

输出结果:
args = [starttryfinally]
args = [starttry]

这里就不列举全局字节码了,两个方法有点多,大家可以自己尝试去看一下。这里做一下说明为什么第一个返回的结果没有finally。 首先这个方法的局部变量表1里面存储了一个StringBuilder地址,执行到try~finally这一部分没什么区别,都是复制了一份变量1的地址到变量3,注意,这两个地址是一样的。 那为什么第二个返回方法少了finally呢,那是因为s.toString()方法这个看起来是在return后面,但其实这个方法属于这个try代码块,分为两步,先调用toString()生成了一个新的字符串starttry然后返回,所以这里的字节码逻辑就如下:

      17: aload_1
      18: invokevirtual #12                 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      21: astore_2
      22: aload_1
      23: ldc           #18                 // String finally
      25: invokevirtual #8                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      28: pop
      29: aload_2
      30: areturn

可以很清楚的看到 调用append方法拼接“start”和“try”后,先调用了toString()方法然后将值存入局部变量2。这时候finally没有和上面那样复制一份变量,而是继续使用局部变量1的引用来继续append,最后的结果也存入了局部变量1中,最后返回的是局部变量2中的值starttry,但是要注意此时局部变量1中指向的StringBuilder的值却是starttryfinally,所以这也就是方法1中返回的值。

4.如何快捷查看字节码

如果是ide的话,应该都可以,通过``Setting->Tools->External Tools进入 然后创建一个自定义的tools。

设置
调用

如上图,新建一个External Tools,第一行输入你电脑的javap.exe地址,第二行是你想要的命令符,第三行是显示位置,设置好后要对着代码右键即可一键查看字节码指令,方便快捷。

5.Tips(后续有就继续更新)

5.1 对象被new指令创建后为什么会执行一个dup(将栈顶的数据复制一份并压入栈)?

对象被new之后还需要调用invokespecial <init>来初始化,这里需要拿到一份new指令分配的内存地址,然后栈中还存在的一份地址是供这个对象给其他地方调用的,否则栈中如果不存在这个引用之后,任何地方都访问不到这个类了,所以就算这个类没有被任何地方调用,栈中还是会存在一份它的引用。

6. 总结

本来只是想写点字节码指令的笔记,结果越记越多,本文大部分理论知识来自于《深入理解Java虚拟机--周志明》,写得多了,错误在所难免,如果有发现的还望指出,谢谢。