JVM基础回顾记录(四):类文件

一、JAVA的平台无关性

我们最开始学习java的时候了解到java代码文件必须经过javac的编译,生成字节码文件才可以被JVM识别并运行,而JVM则拥有多种操作系统的版本:

图1

正是因为这些不同操作系统下的JVM版本,使得我们被编译后的字节码文件可以被不同操作系统上的JVM识别并运行,继而实现跨平台。

二、字节码文件组成

字节码文件是经过编译器编译后的class文件,所以也可以称为Class类文件,这个文件内的内容你是看不懂的,它是以某种顺序堆叠的二进制流组成的文件,以字节(byte)为基本单位,我们学习java基础时知道,单字节占有8个bit位,虽然你很难看懂,但对于JVM来说却可以很容易的解析这类文件,这里先不谈它如何解析,我们本次只说明这个文件里都放了些什么东西,接下来会结合实例来详细说明。

上面刚说过,文件内部的基本单位是字节(8bit),利用这些字节数据所处的先后顺序,来决定Class文件里不同的数据项,JVM在做字节码加载分析的时候也是按照这个顺序来进行的,这就像是一种序列化的方式,而JVM加载解析它的时候,相当于在做反序列化,这个在本节不做探讨,留到类加载章节会详细说明。

字节码文件里存储的数据类型分为两大类,一种是基本的无符号数字类型,另外一种则是类似C语言中结构体的一种东西,我们管它叫,表可能包含无符号数字,也可能包含另外一个表,也可能由无符号数字和其他表共同组成(其实也很像java里的类对吧?可能只包含基本类型的属性,也可能包含另外一个类的对象属性,虽然它跟类完全不是一回事,但为了便于理解,可以这样想)

由于字节码文件的“反序列化”过程非常的单纯,就是从前往后读,那么Class文件中的无符号数和表的堆叠顺序就显得非常重要,这其实是一种java虚拟机约定好的协议,比如Class文件读进来的二进制流,前4个字节一定是某个具体的字段,紧接着往后2个字节一定是另外一个字段,就这样,这个二进制字节流被协议切分成了具体的组,每一个组都代表着不同含义的字段。

三、前三个字节组:魔数、次版本号、主版本号

通过第二节的了解,我们知道了Class文件就是字节与字节的顺序堆叠排列,然后按照字节码约定协议进行以字节为单位分组,每一组的数据代表着不同的含义,接下来让我们看下字节码文件的头部三个组都分别代表什么吧:

图2

上图展示的就是Class文件开头几个字节被约定分组的情况,其实后面更复杂的表也是按照类似的方式做的。

无符号数按照不同数量的字节分组,形成了不同的数据类型,需要占用4个字节才能存下的字段的类型被称为u4,而只需要占用2个字节的字段类型被称为u2图2中“魔数”就可以被说成是一个u4类型的数字,而次版本号和主版本号则为u2

魔数(magic):确定当前文件是否是一个可以被jvm加载的Class文件(像mp3、pdf等文件,开头一样也会有类似的魔数)

主版本号(major_version)&次版本号(minor_version):用来记录当前Class文件的版本号,每个版本的jdk编译过的Class文件,会保有其版本号信息,学习java基础的时候都了解过,java是自上向下兼容的,比如jdk1.8编译出来的Class文件不可以被1.7版本的虚拟机加载运行,但jdk1.7编译出来的Class文件却可以被1.8版本的虚拟机加载运行,至于能不能被加载的第一道坎,就是按照这俩版本号进行判断的。

四、常量池

图2里我们至少已经知道了魔数次版本号主版本号这三个字段,往后的则被省略了。往后是什么呢?它是非常复杂且庞大的一个分组集合,被称为常量池(注意,这个常量池是指Class文件内的常量池,他们会被Class文件内一些索引项给索引到,准确的说并不是运行期的那个JVM方法区内的运行时常量池,但随着JVM的类加载,类里的这个常量池会被加载进运行时常量池,顺便说下,到了那个时候,很多符号引用也会转变为直接引用)它们位于主版本号后面,第一项是一个u2类型的数字,表示有几个常量表如图:

图3

4.1:池里常量的分类

常量池里的常量按照类型被分为了两大类:字面量和符号引用,而符号引用又往下细分了几个分类,如图:

图4

4.1.1:字面量

很简单的概念,类似于常量,下面的分节将会详细介绍,主要是类似语言层面的基本类型,比如intfloatdouble这些,都有对应的字面量常量表:CONSTANT_Integer_infoCONSTANT_Float_infoCONSTANT_Double_info

4.1.2:符号引用

符号引用都是以字符串的方式存储在常量池里的,它们通常用来描述类的全限定名、方法和字段的名称以及描述符,因此被分了三类.

类和接口的全限定名

第一类叫全限定名,例如,一个类叫:com.bilibili.test.Test,则它的全限定名为:com/bilibili/test/Test

字段、方法的简单名称和描述符

相对来说,字段和方法的描述符更为复杂,我们得通过实际的例子来说明问题。

我们知道,一个类里面有字段以及方法,字段有它自己声明的类型以及名称,而方法则更加复杂,它存在入参、返回、名称,因此要想用描述符这样的字符串来描述一个字段或者一个方法,往往需要一定的格式,让我们来看看其格式吧。

先说字段,假如我的类里有个字段是int型,名称为num,名称没什么好说的就是num,但它的类型描述符却是:I

为什么int型的数据的类型描述符是I?那double呢?下面我们就列出所有类型对应的描述符的映射关系表,后面更复杂的方法描述符也可以用到这个表:

类型 描述符
byte B
char C
double D
float F
int I
long J
short S
boolean Z
reference(引用类型) L,格式为L类型全限定名;,例如Object类型的引用变量可表示为:Ljava/lang/Object;
array(数组) [,格式为:[类型描述符,比如int[]可以表示为[I,再比如Object[]可以表示为[Ljava/lang/Object;,高维数组,只需要多加一个[即可,比如int[][]可以表示为[[I
void(方法描述符独有,用于形容方法的无参数返回类型) V
表1

ok,了解完各种类型的描述符规则,再来一遍开始那个字段描述符为I就可以很容易理解了,下面来继续了解下方法的描述符,这个就更为复杂,其格式为:

1
(参数1类型描述符 参数2类型描述符...)返回值类型描述符

下面通过几个例子来说明方法描述符,希望可以帮助你理解:

方法定义 描述符
int getId() ()I
double getPrice(Object[] o, double price) ([Ljava/lang/Object;D)D
void setId() ()V
void setId(int id) (I)V
表2

那么字段或方法的名称及描述符存在哪个地方呢?接下来要介绍的一个叫做CONSTANT_NameAndType_info的常量表就是专门存放这俩数据的,它的内部结构请参考``里对它的介绍。

常量池里的每一项都是个表,下面,我们来深层次探讨下常量池里的每一个表的结构~

4.2:CONSTANT_Utf8_info(UTF-8编码的字符串)

这个表的详细结构如下:

图5

即便是表,也是按照类似的方式堆叠的,后续几种类型也是类似。

基本上常量池里的表第一个字节都是tag,tag在图4中已经解释过了,它用来区分当前表属于什么类型的表,比如本节里tag=1,规范里tag为1的表就是CONSTANT_Utf8_info,当虚拟机知道了表为CONSTANT_Utf8_info,那么很自然的后面两个字节肯定是length,至于其内容,肯定就是length后面的字节了,然后截取length长度的字节,就是这个CONSTANT_Utf8_info所存放的实际内容。

4.3:CONSTANT_Integer_info(整型字面量)

直接上图:

图6

我们都知道int型数据占4字节,32位,现在通过上图字节码的分配可以证实,下面的字面量也大体相同。

4.4:CONSTANT_Float_info(浮点型字面量)

图7

4.5:CONSTANT_Long_info(长整型字面量)

图8

4.6:CONSTANT_Double_info(双精浮点型字面量)

图9

到这里,基本类型就介绍完了,通过对2.3.2 ~ 2.3.5的了解,可以发现,基本类型是很简单的类型,而且其实际内容符合java里基本类型所占位数。

4.7:CONSTANT_Class_info(类、接口的符号引用)

图10

这里要详细介绍下index这个属性,因为在后面的表中,这个字段出现的频率会非常高。

这个字段代表了一个内容的索引,它索的谁的引呢?答案还是常量池,举个例子吧,Class_info是一个用来描述类或者接口全限定名的表,既然是全限定名,那肯定是个字符串,那么index指向的肯定是常量池里一张CONSTANT_Utf8_info的表,现在让我们假设一个常量池,里面已经排列好了各种表数据,按照常量池索引值从1开始,因此按照规则,绘制出下图:

图11

当Class_info的index属性就是用来指向常量池中某一个表的,例如上图里index=2,则意味着常量池里索引下标为2的表的内容,就是Class_info的内容,开头说过,就是类或接口的全限定名。

4.8:CONSTANT_String_info(字符串类型字面量)

图12

4.9:CONSTANT_NameAndType_info(字段、方法的部分符号引用)

之前介绍了常量池里存放的字段、方法都是存在名称以及描述符的,本节介绍的这个结构就是用来存放这两项内容的(对字段或方法的名称及描述符不熟悉的话,建议加强理解下图4表1表2里的内容)

图13

该结构存储了某个字段或方法的名称索引描述符索引,那肯定有具体的某个字段或方法的表里会索引向它,接下来要介绍的CONSTANT_Fieldref_infoCONSTANT_Methodref_info,均有指向它的索引字段。

4.10:CONSTANT_Fieldref_info(类中引用字段的符号引用)

这个常量专门用来描述类内被引用到的属性,包含你自定义的出现在该类的属性,也包含该类里方法调用时使用的别的类的属性

图14

字段的符号引用表,除了要描述声明自己的类或接口,还需要索引到具体的CONSTANT_NameAndType_info,这个表里上面介绍过,内含一个名称和具体的描述符,下方的方法符号引用也是同样的结构,即一个字段或方法的符号引用等于:声明该字段或方法的类或接口的全限定名 + 该字段或方法的名称 + 该字段或方法的描述符

4.11:CONSTANT_Methodref_info(类中引用方法的符号引用)

这个常量专门用来描述类内被引用到的方法,比如你在A类里定义了一个叫test()的方法,这个test方法并不会在常量池存在一个CONSTANT_Methodref_info,如果你再定义一个方法test2(),让它调用test方法,这时test方法相当于被引用,这时就拥有了对应的Methodref_info.

再比如,你方法里经常会调用一些依赖包的类方法,比如最常用的System.out.println,这个过程相当于你使用了out对象println方法,此时当前类的字节码常量池便会有一个println的Methodref_info出现,同时也会有一个叫out的Fieldref_info出现。(这些均会在实战篇讲解)

图15

纠错,图里第一个info块里的内容应该是“Methodref_info的tag值为10”

4.12:CONSTANT_InterfaceMethodref_info(接口中方法的符号引用)

图16

截止到目前,常见的11种常量已经介绍完了,下面再介绍3种JDK1.7引入的新常量。

4.13:CONSTANT_MethodHandle_info(表示方法句柄)

这个常量是1.7新增的特性,即方法句柄,可以和下方MethodType结合使用,用法这里暂时不说,你可以简单理解,它是类似反射的功能,可以指定调用哪个对象的哪个方法。但不同于反射,它可以在编译期就指定好方法调用,而不是运行期,这相比反射要安全的多。

4.14:CONSTANT_MethodType_info(标识方法类型)

待补全…

4.15:CONSTANT_InvokeDynamic_info(动态方法调用点)

待补全…

五、访问标志

紧挨着常量池后面的两个字节,代表访问标志access_flags),因为其具备2个字节,所以它有16个bit位可以利用,每个位置的0或1代表不同的含义,当前只定义了8个,如下:

图17

六、继承关系

接着access_flag后面,有描述本类继承关系的几个变量,分别如下:

图18

学过java的人都知道,java类允许单继承多实现,因此图中super_class只有一个,interface却对应了一个集合,当然,它们都只是u2类型的索引而已,指向常量池里的CONSTANT_Class_info(参考4.7

七、字段表集合

我们离开了继承关系后,紧挨着的就是字段表集合。字段表,即类(或接口)里声明的变量,变量分为静态变量(类变量)以及成员变量(实例变量)。

图19

现在详细介绍下图中虚线部分,这四个u2类型的字段以及属性表attribute_info)集合代表了一个field_info表,它的这些字段解释如下:

7.1:access_flags

这个字段也叫access_flags,跟之前Class本身的access_flags所具备的意义一样,它是用来描述字段访问标志,让我们来看看它每一位代表什么意思吧:

图20

7.2:name_index & descriptor_index

name_indexdescriptor_index都是索引值,前者指向常量池里一个CONSTANT_Utf8_info,用来表示该字段的简单名称,后者也是指向常量池里一个CONSTANT_Utf8_info,但它用来表示字段的描述符(什么是简单名称,什么是描述符?请参考4.1.2

7.3:attributes_count & attribute_infos

这段信息很复杂,代表属性表,attributes_count代表后面跟几个属性表,attribute_infos代表属性表集合,属性表是很复杂的一块内容,Class文件、字段表、方法表,甚至Code属性表都可以携带自己的属性表集合,属性表的种类很繁多,在9.1节会列举一个与本节(方法表)相关的属性表ConstantValue的结构。

八、方法表集合

离开字段表后,紧挨着的就是方法表集合,它几乎跟字段表结构一致。方法表,即类(或接口)里声明的方法,同样的,方法分为静态方法(类方法)以及成员方法(实例方法)。

图21

同样的,来介绍下图中虚线部分,这四个u2类型的字段以及属性表attribute_info)集合代表了一个method_info表,它的这些字段解释如下:

8.1:access_flags

不多说了,前面遇到很多次了,它用来描述方法访问标志,让我们来看看它每一位代表什么意思吧:

图22

8.2:name_index & descriptor_index

跟字段表对应的字段作用相同,name_indexdescriptor_index都是索引值,前者指向常量池里一个CONSTANT_Utf8_info,用来表示该方法的简单名称,后者也是指向常量池里一个CONSTANT_Utf8_info,但它用来表示方法的描述符(什么是简单名称,什么是描述符?请参考4.1.2

8.3:attributes_count & attribute_infos

属性表,在7.3节提到过,这里的俩属性跟字段表里的意思一样,但在本节你可能会好奇,方法表定义了方法的名称和描述符,那么方法的代码跑哪里去了?其实方法本身的代码会被编译成字节码指令,存放在每个方法后面的某个属性表里,而这个属性表就是Code,在后续的9.2里会详细介绍,这里只需要记住它是出现在本节(方法表)这个位置的即可。

九、属性表

前面两节或多或少提到过属性表,这是很复杂也很重要的一个表结构,下面来看下它的基本组成:

图23

不管属性表多么复杂,它开始的两个属性总是attribute_name_indexattribute_length,具体含义如图所示。

本节只少量介绍几个很重要的属性表,更详细的属性表可以查阅https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-4.html#jvms-4.7

9.1:ConstantValue

先回看下7.3,该属性表仅作用在字段表上,用来表示一个static final字段的初始值,且要求该字段必须是基本类型String类型才可以用该表表示初始值,为什么?我们来看下该表的结构:

图24

因为constantvalue_index指向的一定常量池里某字面量,因此它只能是CONSTANT_Long_infoCONSTANT_Float_infoCONSTANT_Double_infoCONSTANT_Integer_infoCONSTANT_String_info里的一种,所以只有被声明为static final的基本类型和String类型的字段,才可能存在此属性表,用来代表其初始化值。

9.2:Code

这绝对是最高能的一个属性表,我们详细来解析下,从它的名字就可以看出来,它代表的是一个方法的代码属性,属于方法表(这点请牢记),这在8.3里提到过,我们写的程序代码最终会被javac编译成字节码指令,而这些指令就存储在Code属性表内,它的结构是怎样的呢?看图:

图25

这里面有很多细节,我们来逐一介绍下,就从max_stack开始吧~

9.2.1:max_stack

这个值跟操作数栈有关系,代表它的最大深度,当jvm处于运行期的时候,会根据这个值来分配栈帧操作数栈的深度。(栈帧是后续文章将会介绍的概念,运行期时每一个Code模块,都会与之对应一个栈帧结构,可通过10.2.5.1节了解其基本概念)

9.2.2:max_locals

这个值也是围绕着运行期栈帧来生效的,它代表栈帧里局部变量表所需的存储空间。什么是局部变量表?早期接触java时就知道,每个方法内产生的对象、变量我们都称之为局部变量,它们会在运行期被暂存入方法所属栈帧里的局部变量表中,方便操作数栈存取与操作,而它需要多少存储大小呢?就是靠这个值来决定的,结合9.2.1可以知道,一个java方法的局部变量表大小操作数栈最大深度都是在编译期就已经确定了的。

那么,存储大小的单位又是什么呢?是字节(byte)吗?比如max_locals为5,代表的是5byte的存储空间吗?

并不是,它是一个叫做Slot的东西,max_locals=5,代表需要分配5个Slot。下面来介绍下Slot。

Slot有32个bit位,意思就是说每个Slot占4字节,因此,如果你有一个局部变量是int型,那么它就占1个Slot空间,如果是float或者long之类的,那就需要两个Slot。

那现在我们就可以很容易推算出一个方法的局部变量表的容量到底需要多大了:总Slot量 = 所有局部变量所用的字节量 / 4

但是,这样是不对的!因为根据每个局部变量的作用域不同,Slot是可以被复用的,这点很重要,让我们来看个例子:

代码块1
1
2
3
4
5
6
7
8
9
10
11
12
13
public int slot(int a) { //入参a,为a分配一个Slot

int b = 1; //为b分配一个Slot

if (a == 1) {
int c = a + b; //为c分配一个Slot,但是注意,c的作用域只有if域
b = c;
} //离开if域,为c分配那个Slot会被完全闲置出来,因为c在之后的逻辑中再也无法使用了

int d = b; //反正c的那个Slot闲着也是浪费,不如给接下来的变量用,这里d就可以复用c的Slot

return d;
}

这段代码一共有a、b、c、d四个int型变量,理论上需要分配4个Slot存储,但实际只分配了3个,具体原因请参考代码中的注释,这种利用java语法来节省Slot的做法还是相当聪明的。

9.2.3:字节码指令

参考图25,离开了max_stackmax_locals,紧接着就进入了一个非常冗长的区域,那就是code区域,它是你写的java程序被翻译成的一个个指令码,是最重要的一部分,也是直接可以被java程序员操控的部分。

字节码指令大全:https://luisstruggle.github.io/blog/javaSE7-JVM.html

后续会以某个实例的字节码指令集来分析这些指令是如何在栈帧里运行的(虽然是运行期的东西,但还是想在这一节做下简单的说明)

十、实战

10.1:快速回顾

终于把字节码的详细内容讲完了,太多太长,上面是详细介绍,下面就通过一张图简单回顾一下字节码文件的堆叠顺序和内容种类:

图26

10.2:字节码分析实战

10.2.1:源代码

本篇会通过以下实例来具体解析字节码组成:

代码块2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package sun.juwin.test;

public class Test {

public static final int j; //声明一个类变量j,其在静态块里进行初始化

public static final int k = 5; //再声明一个类变量k,并直接初始化为5

private int i; //包含一个成员变量

static { //静态块,相当于类本身的构造器
j = 6;
}

public Test() { //包含两个重载构造器
System.out.println("Test init !"); //注意,这里调用了out对象的println方法
}

public Test(int i) {
this(); //在这里主动触发一次无参构造器
this.i = i; //给i属性赋值
}

public static int getK() { //类方法,返回类属性k的值(静态方法的入参不会隐式传入this作为参数)
return k;
}

public int sum_i(int a, int b) { //成员方法,这个方法传俩值,返回i和这俩值之和的和(成员方法的入参会隐式传入this作为参数)
return i + sum(a, b);
}

public int sum(int x, int y) { //成员方法,计算两个数的和
return x + y;
}

public int sum2(int x, int y) { //成员方法,作用跟上面的sum一样,但这个方法会多出一个Slot来存result这个局部变量
int result = x + y;
return result;
}
}

10.2.2:字节码信息

首先我们利用javac将其编译成字节码文件(我这里使用jdk11版本做的编译),然后利用javap指令,输出其字节码信息:

1
2
3
4
javac&javap指令

javac -g Test.java //编译(-g会帮忙把调试信息也编译进去,为了说明Slot,我们需要这个)
javap -v -p Test //输出字节码详细信息,-v表示输出详细信息,-p表示输出所有的字段和方法(不加这个的话public以下的字段方法无法展示)

javap这个指令是干嘛的呢?它实际上是将编译好的字节码文件反编译,然后输出字节码文件里各数据项的详细信息,相比直接去读二进制的字节码文件,javap输出的字节码详细信息更加通俗易读。

现在来看下代码块2的字节码详细信息(太冗长了,后面会拆解说明):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
Compiled from "Test.java"
public class sun.juwin.test.Test
minor version: 0
major version: 55
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #7 // sun/juwin/test/Test
super_class: #10 // java/lang/Object
interfaces: 0, fields: 3, methods: 7, attributes: 1
Constant pool:
#1 = Methodref #10.#39 // java/lang/Object."<init>":()V
#2 = Fieldref #40.#41 // java/lang/System.out:Ljava/io/PrintStream;
#3 = String #42 // Test init !
#4 = Methodref #43.#44 // java/io/PrintStream.println:(Ljava/lang/String;)V
#5 = Methodref #7.#39 // sun/juwin/test/Test."<init>":()V
#6 = Fieldref #7.#45 // sun/juwin/test/Test.i:I
#7 = Class #46 // sun/juwin/test/Test
#8 = Methodref #7.#47 // sun/juwin/test/Test.sum:(II)I
#9 = Fieldref #7.#48 // sun/juwin/test/Test.j:I
#10 = Class #49 // java/lang/Object
#11 = Utf8 j
#12 = Utf8 I
#13 = Utf8 k
#14 = Utf8 ConstantValue
#15 = Integer 5
#16 = Utf8 i
#17 = Utf8 <init>
#18 = Utf8 ()V
#19 = Utf8 Code
#20 = Utf8 LineNumberTable
#21 = Utf8 LocalVariableTable
#22 = Utf8 this
#23 = Utf8 Lsun/juwin/test/Test;
#24 = Utf8 (I)V
#25 = Utf8 getK
#26 = Utf8 ()I
#27 = Utf8 sum_i
#28 = Utf8 (II)I
#29 = Utf8 a
#30 = Utf8 b
#31 = Utf8 sum
#32 = Utf8 x
#33 = Utf8 y
#34 = Utf8 sum2
#35 = Utf8 result
#36 = Utf8 <clinit>
#37 = Utf8 SourceFile
#38 = Utf8 Test.java
#39 = NameAndType #17:#18 // "<init>":()V
#40 = Class #50 // java/lang/System
#41 = NameAndType #51:#52 // out:Ljava/io/PrintStream;
#42 = Utf8 Test init !
#43 = Class #53 // java/io/PrintStream
#44 = NameAndType #54:#55 // println:(Ljava/lang/String;)V
#45 = NameAndType #16:#12 // i:I
#46 = Utf8 sun/juwin/test/Test
#47 = NameAndType #31:#28 // sum:(II)I
#48 = NameAndType #11:#12 // j:I
#49 = Utf8 java/lang/Object
#50 = Utf8 java/lang/System
#51 = Utf8 out
#52 = Utf8 Ljava/io/PrintStream;
#53 = Utf8 java/io/PrintStream
#54 = Utf8 println
#55 = Utf8 (Ljava/lang/String;)V
{
public static final int j;
descriptor: I
flags: (0x0019) ACC_PUBLIC, ACC_STATIC, ACC_FINAL

public static final int k;
descriptor: I
flags: (0x0019) ACC_PUBLIC, ACC_STATIC, ACC_FINAL
ConstantValue: int 5

private int i;
descriptor: I
flags: (0x0002) ACC_PRIVATE

public sun.juwin.test.Test();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
7: ldc #3 // String Test init !
9: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
12: return
LineNumberTable:
line 15: 0
line 16: 4
line 17: 12
LocalVariableTable:
Start Length Slot Name Signature
0 13 0 this Lsun/juwin/test/Test;

public sun.juwin.test.Test(int);
descriptor: (I)V
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=2, args_size=2
0: aload_0
1: invokespecial #5 // Method "<init>":()V
4: aload_0
5: iload_1
6: putfield #6 // Field i:I
9: return
LineNumberTable:
line 20: 0
line 21: 4
line 22: 9
LocalVariableTable:
Start Length Slot Name Signature
0 10 0 this Lsun/juwin/test/Test;
0 10 1 i I

public static int getK();
descriptor: ()I
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=0, args_size=0
0: iconst_5
1: ireturn
LineNumberTable:
line 25: 0

public int sum_i(int, int);
descriptor: (II)I
flags: (0x0001) ACC_PUBLIC
Code:
stack=4, locals=3, args_size=3
0: aload_0
1: getfield #6 // Field i:I
4: aload_0
5: iload_1
6: iload_2
7: invokevirtual #8 // Method sum:(II)I
10: iadd
11: ireturn
LineNumberTable:
line 29: 0
LocalVariableTable:
Start Length Slot Name Signature
0 12 0 this Lsun/juwin/test/Test;
0 12 1 a I
0 12 2 b I

public int sum(int, int);
descriptor: (II)I
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=3, args_size=3
0: iload_1
1: iload_2
2: iadd
3: ireturn
LineNumberTable:
line 33: 0
LocalVariableTable:
Start Length Slot Name Signature
0 4 0 this Lsun/juwin/test/Test;
0 4 1 x I
0 4 2 y I

public int sum2(int, int);
descriptor: (II)I
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=4, args_size=3
0: iload_1
1: iload_2
2: iadd
3: istore_3
4: iload_3
5: ireturn
LineNumberTable:
line 37: 0
line 38: 4
LocalVariableTable:
Start Length Slot Name Signature
0 6 0 this Lsun/juwin/test/Test;
0 6 1 x I
0 6 2 y I
4 2 3 result I

static {};
descriptor: ()V
flags: (0x0008) ACC_STATIC
Code:
stack=1, locals=0, args_size=0
0: bipush 6
2: putstatic #9 // Field j:I
5: return
LineNumberTable:
line 12: 0
line 13: 5
}
SourceFile: "Test.java"

太长了,我们来将上面的字节码信息拆解一下进行详细分析。

10.2.3:字节码-头部

代码块4 头部
1
2
3
4
5
6
7
8
Compiled from "Test.java"
public class sun.juwin.test.Test
minor version: 0 //这是次版本号
major version: 55 //这是主版本号
flags: (0x0021) ACC_PUBLIC, ACC_SUPER //这是访问标志,将0x0021转换成二进制,再跟图17一一对应,就可以得出ACC_PUBLIC和ACC_SUPER的结论
this_class: #7 //#7是个常量池索引,其值为sun/juwin/test/Test,代表该类的全限定名
super_class: #10 //#10也是个常量池索引,其值为java/lang/Object,代表该类的父类全限定名,默认为Object,如果真的继承了其他父类,则该值为对应父类的全限定名
interfaces: 0, fields: 3, methods: 7, attributes: 1 //该类实现了0个接口,共有3个字段(i、j、k),7个方法(静态块、Test()、Test(int i)、getK、sum_i、sum、sum2),1个attributes??这个我自己没搞懂,如果指代属性表,那远不止1个,如果不是指属性表,那是什么意思呢?

10.2.3:字节码-常量池

代码块5 常量池
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
Constant pool: //下面就是大杂烩的常量池了,#xx代表一个索引,所以看得时候需要找序号的按照这个值找就行了。
//这里说一下XXXref,在4.10和4.11介绍过,它们是指在该类内被引用到的字段或方法,因此像System.out就是一个FieldRef,而println就是一个MethodRef,同样的在sum_i方法里有对sum方法的调用,因此sum方法也是一个MethodRef
#1 = Methodref #10.#39 //由于该类构造器被调用时会触发其父类构造器的调用,因此父类构造器是一个MethodRef,其值为:java/lang/Object."<init>":()V(前面为该方法所属类的全限定名,后面为该方法的名称和描述符,#39可以往下找,是个NameAndType)
#2 = Fieldref #40.#41 //一个被该类引用的属性,这个找法类似上面的MethodRef,其值为:java/lang/System.out:Ljava/io/PrintStream;即:Test()方法里的System.out属性
#3 = String #42 //String字面量,指向内容为"Test init !"的Utf8_info
#4 = Methodref #43.#44 //值为java/io/PrintStream.println:(Ljava/lang/String;)V,因为println被我们所引用,所以它也成为了该类的一个MethodRef
#5 = Methodref #7.#39 //该类的一个无参构造器,值为sun/juwin/test/Test."<init>":()V,因为我们的有参构造器引用了它,所以它也是MethodRef,下面的各类Ref以此类推
#6 = Fieldref #7.#45 //值为sun/juwin/test/Test.i:I
#7 = Class #46 //该类的全限定名,指向的值为sun/juwin/test/Test
#8 = Methodref #7.#47 //值为sun/juwin/test/Test.sum:(II)I
#9 = Fieldref #7.#48 //值为sun/juwin/test/Test.j:I
#10 = Class #49 //父类的全限定名,它已经被#1引用过一次了,值为java/lang/Object
#11 = Utf8 j //字段j的简单名称文本值,应被某个字段表索引,参考7.2
#12 = Utf8 I //字段的描述符文本值,同样应被某个字段表索引,参考7.2,只需要一个I即可,因为本类所定义的属性只有int一个类型
#13 = Utf8 k //字段k的简单名称文本值
#14 = Utf8 ConstantValue //属性表名称文本值,应被某个属性表索引,参考9.1
#15 = Integer 5 //某Integer字面量的值,本例中被属性k的ConstantValue索引,参考9.1
#16 = Utf8 i //字段i的简单名称文本值
#17 = Utf8 <init> //该类构造方法的简单名称文本值,应被某个方法表索引,参考8.2
#18 = Utf8 ()V //某方法的描述符文本值,()V代表无参无返回值(描述符规则参考4.1.2),应被某个方法表索引,参考8.2
#19 = Utf8 Code //属性表名称文本值,应被某个属性表索引,参考9.2
#20 = Utf8 LineNumberTable //属性表名称文本值,应被某个属性表索引,LineNumberTable是Code属性表的一部分,参考9.2
#21 = Utf8 LocalVariableTable //属性表名称文本值,应被某个属性表索引,LocalVariableTable是Code属性表的一部分,参考9.2
#22 = Utf8 this //字段this的简单名称(this属于隐式字段)
#23 = Utf8 Lsun/juwin/test/Test; //字段描述符文本值,所有类型为sun/juwin/test/Test类型的字段,都可以使用该值作为自己的描述符
#24 = Utf8 (I)V //某个入参为一个int型无返回值的方法描述符文本值,本例子指Test(int i)方法
#25 = Utf8 getK //getK方法的简单名称文本值
#26 = Utf8 ()I //某个无参返回int数据的方法的描述符文本值,本例可以指代getK()方法
#27 = Utf8 sum_i //sum_i方法的简单名称文本值
#28 = Utf8 (II)I //某个入参为两个int型,返回一个int型值的方法的描述符文本值,在本例中,可以用来作sum_i、sum、sum2的描述符
#29 = Utf8 a //这种位于局部变量表的变量名,都是在开启调试模式时(javac -g)编译的class文件才有,普通javac是没有的,下方b、x、y、result同理
#30 = Utf8 b
#31 = Utf8 sum //sum方法的简单名称文本值
#32 = Utf8 x
#33 = Utf8 y
#34 = Utf8 sum2 //sum2方法的简单名称文本值
#35 = Utf8 result
#36 = Utf8 <clinit> //类初始化方法的简单名称,就是静态块,静态块被认为是类本身的"构造器",如果没有静态块,那么也不会有这个方法
#37 = Utf8 SourceFile
#38 = Utf8 Test.java //本类源文件名称
#39 = NameAndType #17:#18 //已经被#1和#5的MethodRef引用,其值为:"<init>":()V
#40 = Class #50 //类限定名:java/lang/System,被#2引用
#41 = NameAndType #51:#52 //被#2引用,其值为:out:Ljava/io/PrintStream;即:System.out这个属性的名称和描述符
#42 = Utf8 Test init ! //Test()方法里的字符串文本值,被#3引用
#43 = Class #53 //类限定名:java/io/PrintStream,被#4引用
#44 = NameAndType #54:#55 //被#4引用,其值为:println:(Ljava/lang/String;)V,即:out.println这个方法的名称和描述符
#45 = NameAndType #16:#12 //被#6引用,其值为:i:I,即该实例中i这个成员变量的名称和描述符
#46 = Utf8 sun/juwin/test/Test //类全限定名的文本值,被#7引用
#47 = NameAndType #31:#28 //被#8引用,其值为:sum:(II)I,即该实例中sum方法的简单名称和描述符
#48 = NameAndType #11:#12 //被#9引用,其值为:j:I,即该实例中j这个类变量的名称和描述符
#49 = Utf8 java/lang/Object //类全限定名的文本值,被#10引用
#50 = Utf8 java/lang/System //类全限定名文本值,被#40引用
#51 = Utf8 out //属性简单名称文本值,被#41引用
#52 = Utf8 Ljava/io/PrintStream; //属性的描述符文本值,被#41引用
#53 = Utf8 java/io/PrintStream //类全限定名的文本值,被#43引用
#54 = Utf8 println //方法简单名称文本值,被#44引用
#55 = Utf8 (Ljava/lang/String;)V //方法描述符的文本值,被#44引用

10.2.4:字节码-字段表

看完了最为复杂的常量池,下面来看下字段表部分,这部分简单许多:

代码块6 字段表
1
2
3
4
5
6
7
8
9
10
11
12
public static final int j; //类变量j
descriptor: I //描述符,指向#12
flags: (0x0019) ACC_PUBLIC, ACC_STATIC, ACC_FINAL //参考7.1,将十六进制的0x0019转成二进制后看看哪些bit位是1哪些就是它的修饰符,显然是ACC_PUBLIC、ACC_STATIC、ACC_FINAL

public static final int k; //类变量k
descriptor: I
flags: (0x0019) ACC_PUBLIC, ACC_STATIC, ACC_FINAL
ConstantValue: int 5 //参考9.1,如果一个static final的常量有直接的初始值,那么必定跟一个ConstantValue的属性表

private int i; //成员变量i
descriptor: I
flags: (0x0002) ACC_PRIVATE

10.2.5:字节码-方法表

ps:想用更加简洁的方式看方法指令的话,建议使用javap -c。本节只挑选代码块2实例中的几个具有代表性的方法来说明。

下面就是我们的方法表了,内部会包含Code这个涵盖方法主要逻辑的指令码,我们这节会一点点分析这些指令是如何在操作数栈里运行的(操作数栈是运行期要学的一个结构,先做个了解,它位于栈帧内,主要负责执行Code指令码),这里要注意:栈是一种先进后出的数据结构,既然操作数栈是栈,那么必然满足这一点特性。

10.2.5.1:无参构造器-Test()

javap输出的详细信息:

代码块7 方法表-Test()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public sun.juwin.test.Test(); //Test方法,Code区域的指令集是如何在操作数栈运行的?请看图27
descriptor: ()V //方法描述符,指向#18
flags: (0x0001) ACC_PUBLIC //参考8.1
Code:
stack=2, locals=1, args_size=1 //依次为:最大栈深、逻辑Slot数量(实际上可能少于这个,因为Slot可复用)、参数size,成员方法默认传入this,所以即便Test()里没有参数,这里args_size也是1
0: aload_0 //取this
1: invokespecial #1 //调用方法:java/lang/Object."<init>":()V
4: getstatic #2 //获取类属性:java/lang/System.out:Ljava/io/PrintStream;
7: ldc #3 //ldc是指从常量池中取值,放入操作数栈,值为:Test init !
9: invokevirtual #4 //调用方法:java/io/PrintStream.println:(Ljava/lang/String;)V
12: return //返回指令,用于终止程序
LineNumberTable:
line 15: 0 //源代码15行对应指令码第0行,剩下的类推即可(这里源代码以代码块2为准)
line 16: 4
line 17: 12
LocalVariableTable:
Start Length Slot Name Signature //这里只有一个Slot,它的下标为0
0 13 0 this Lsun/juwin/test/Test;

先来看下其源代码:

代码块8
1
2
3
4
public Test() { //隐式传入this作参数
super(); //隐式调了父类构造器
System.out.println("Test init !"); //输出一段话
}

它对应的指令码参考代码块7-Code部分

当一个线程运行到此方法,会为其创建一个栈帧

栈帧的基本概念

栈帧是用于支持jvm进行方法调用方法执行数据结构,它是运行期保存在虚拟机栈中的栈元素,是线程运行的基本单元,由执行引擎触发执行其内部保存的字节码指令(这里执行引擎在执行代码时还分为解释执行编译执行)。栈帧由局部变量表操作数栈动态连接方法返回地址附加信息组成

Test()方法栈帧结构如图:

图27

现在,让我们看一下这个方法在运行时,栈帧内部是如何变化的:

栈帧初始状态:入参首先填充进局部变量表

图28

解释:首先Test方法是一个非静态方法,其方法第一个入参就是this这个reference类型的变量,因此aload_0就是将该变量推向操作数栈的栈顶。

第一步:aload_0,代表将局部变量表内第0个Slot内保存的变量入栈

图29

第二步:invokespecial,代表一次构造方法的调用,根据代码块7可知这里调用的是其父类的构造方法

图30

随着Object构造方法触发,随之又产生了一个Object构造器的栈帧,用来执行父类构造器逻辑,传入的是子类对象指针this,这点要记住。为了优化内存,jvm开发者们经常会将新开栈帧的局部变量表和上一栈帧的操作数栈共享一些数据,共享是完全按照下一栈帧接收的参数个数来的,比如下一栈帧需要5个参数,那么当前栈帧操作数栈从栈顶开始往下数5个,全部会共享进下一栈帧的局部变量表里。

还有一点需要说明一下,我们知道在一个对象的构造器被触发时,它的父类构造器首先会被触发,此时很多人认为父类跟子类一样,肯定也是被new了一次,其实不然,集合图29图30可知,在Test类的构造器被触发之前,就已经存在this指针了,那意味着类对象已经产生了,因此构造器是在对象产生后主动触发的,而父类也没有被new,仅仅是触发了一下它的构造器而已,你可以理解,一个子类对象,其实就是包含了其父类公共方法的类,不存在对象嵌套的情况(即一个子类的对象是不会包含一个父类对象的),这个在第六节会详细讲,本节做个预热即可。

最后随着新开栈帧运行结束,线程又会再次回到当前栈帧,并将当前栈帧内的操作数栈共享部分清空,若下一栈帧有返回值,则将该值存入当前栈帧的栈顶,否则不进行其他操作。说到这里也知道为什么栈帧也是栈了,先进入的栈帧最后才会执行完,越是新开的栈帧越先结束,这似乎也是栈的特性。

第三步:getstatic,用来获取某个类的静态属性,由代码块7可知,这里是从类常量池里获取到的PrintStream类型的System类的out静态属性,即System.out

提示:getstatic的执行流程应为:从常量池获取常量的描述符,然后根据描述符里指示的类和属性名,去对应的类里拿到该属性的值。

图31

第四步:ldc,由代码块7可知,这是从常量池里加载String类型字面量“Test init !”入栈

图32

第五步:invokevirtual,代表了一次虚方法的调用,本例指的是对out对象的println方法的调用

图33

结合上图来思考下println的两个参数,首先我们需要明确一点,println不是静态方法,因此它传入的第一个参数一定是“this”,即对象引用,其实任何非静态方法的访问都是如此,需要知道自己的对象是哪位,否则没意义,那么针对println这个方法的“this”,又是谁呢?答案是out,我们是用out对象调用的它,所以这里第一个入参一定是out对象,第二个入参则是“Test init !”,所以上图中共享给下游栈帧的一共有俩,已用红框标出。

第六步:return,方法执行结束,栈帧出让,就不画图了。

到这里,我们已经完成了一组指令码的执行过程解析。

10.2.5.2:sum_i(int a, int b)

再来分析一个方法的执行流程,我们把javap的信息丢出来(这里就不加注释了):

代码块9
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public int sum_i(int, int);
descriptor: (II)I
flags: (0x0001) ACC_PUBLIC
Code:
stack=4, locals=3, args_size=3
0: aload_0
1: getfield #6 // Field i:I
4: aload_0
5: iload_1
6: iload_2
7: invokevirtual #8 // Method sum:(II)I
10: iadd
11: ireturn
LineNumberTable:
line 29: 0
LocalVariableTable:
Start Length Slot Name Signature
0 12 0 this Lsun/juwin/test/Test;
0 12 1 a I
0 12 2 b I

它的源代码如下:

代码块10
1
2
3
public int sum_i(int a, int b) {
return i + sum(a, b);
}

它有3个参数,还发生过一次方法调用,最后还要将方法返回的值再加上i属性,然后将结果返回出去,我们来看下这个方法的运行过程。

老样子,来看下栈帧初始状态:入参首先填充进局部变量表

图34

这是个非静态方法,第一个参数依然是该方法对应类的某个对象的引用指针(即this),剩下两个参数就是源代码里的a和b。

第一步:aload_0,代表将局部变量表内第0个Slot内保存的变量入栈,即this入栈,因为接下来的getfield指令要用到,这跟非静态方法需要this的道理一样,获取某个非静态的属性,也需要知道是哪个对象,才能取到对应的值,这就是非静态属性和方法与静态属性方法最大的区别。

图35

第二步:getfield,这个指令用来获取某对象的某个属性值,本例中为i

图36

提示:getfield执行流程:从常量池获取属性的描述符,然后根据描述符里指示的类和属性名,去对应的类对象里拿到该属性的值。(图中第2步就是利用当前栈顶的this,来打入其内部,将其对应属性i的值取出,并设置入栈顶,替换掉原来的this)

第三步:aload_0,再次将局部变量表内第0个Slot内保存的变量入栈

图37

第四步:将入参a和b入栈

图38

图39

第五步:invokevirtual,虚方法调用,本例指的是对sum方法的调用,通过实例代码可知,sum方法拥有三个参数,即thisab,因此开栈帧时需要共享当前栈帧自栈顶往下数3个栈位作为新栈帧的入参

图40

注意,sum方法是有返回值的,返回值为a和b相加的结果,最终sum栈帧执行完毕再次回到sum_i栈帧时,会将其返回结果入栈,最终sum执行完毕后的情况如下:

图41

第六步:iadd,将栈顶两int型数值相加,并且其运算结果入栈

图42

第七步:ireturn,将现在位于栈顶的数据返回出去给上游的栈帧,就不画图了。

到目前,sum_i方法的执行流程就剖析完毕。

十一、小结

本章介绍了Class文件本身的组成以及它内部数据的堆叠方式,还介绍了运行期指令码是怎么被执行引擎所运行的,可以尝试利用这些知识,分析日常中较为复杂的类的字节码文件,熟悉字节码指令对于我们理解日常java代码的一些“特殊性质”有一定的积极作用。