1 类文件结构
1.1 MyDemo
public class MyDemo {
private int a = 1;
private int b = 2;
public int sum() {
int c = a + b;
return c;
}
public static void main(String[] args) {
MyDemo myDemo = new MyDemo();
int sum = myDemo.sum();
System.out.println(sum);
}
}
上面的java文件编译得到的class文件,class文件是一个16进制字节码的二进制文件,我们通过winhex软件打开如下:
1.2 字节码
1.2.1 字节码表
需要说明的是,class文件只有两种数据类型: 无符号数 和 表。
- 无符号数
无符号数可以用来描述数字、索引引用、数量值或按照utf-8编码构成的字符串值。
其中无符号数属于基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节。 - 表
表是由多个无符号数或其他表构成的复合数据结构。
所有的表都以“_info”结尾,由于表没有固定长度,所以通常会在其前面加上个数说明。
类型 | 名称 | 说明 | 长度 |
---|---|---|---|
u4 | magic | 魔数,识别Class文件格式 | 4个字节 |
u2 | minor_version | 副版本号 | 2个字节 |
u2 | major_version | 主版本号 | 2个字节 |
u2 | constant_pool_count | 常量池计算器 | 2个字节 |
cp_info | constant_pool | 常量池 | n个字节 |
u2 | access_flags | 访问标志 | 2个字节 |
u2 | this_class | 类索引 | 2个字节 |
u2 | super_class | 父类索引 | 2个字节 |
u2 | interfaces_count | 接口计数器 | 2个字节 |
u2 | interfaces | 接口索引集合 | 2个字节 |
u2 | fields_count | 字段个数 | 2个字节 |
field_info | fields | 字段集合 | n个字节 |
u2 | methods_count | 方法计数器 | 2个字节 |
method_info | methods | 方法集合 | n个字节 |
u2 | attributes_count | 附加属性计数器 | 2个字节 |
attribute_info | attributes | 附加属性集合 | n个字节 |
1.2.2 魔数
在class文件中,前4个字节就是魔数,如下:
魔数是用来区分文件类型的一种标识,0XCAFEBABE (咖啡豆/咖啡宝贝)表示就是class文件。
1.2.3 版本号
魔数后面的4位就是版本号了,同样也是4个字节,其中前2个字节表示副版本号,后2个字节表示主版本号。
前面两个字节是0x0000,也就是其值为0; 后面两个字节是0x0034,也就是其值为52; 所以上面的代码就是52.0版本
来编译的,也就是jdk1.8.0。
1.3 常量池
在版本号的后面就是常量池了。
1.3.1 常量池容量计算
由于常量池的数量不固定,所以需要通过2个字节来记录常量池的大小。
其值为0X0027,用十进制表示就是39,需要注意的是,常量池中只有38个常量。
1.3.2 常量类型和结构
常量池中的每一项都是一个表,其项目类型共有14种,如下表格所示:
类型 | 标志 | 描述 |
---|---|---|
CONSTANT_utf8_info | 1 | UTF-8编码的字符串 |
CONSTANT_Integer_info | 3 | 整形字面量 |
CONSTANT_Float_info | 4 | 浮点型字面量 |
CONSTANT_Long_info | 5 | 长整型字面量 |
CONSTANT_Double_info | 6 | 双精度浮点型字面量 |
CONSTANT_Class_info | 7 | 类或接口的符号引用 |
CONSTANT_String_info | 8 | 字符串类型字面量 |
CONSTANT_Fieldref_info | 9 | 字段的符号引用 |
CONSTANT_Methodref_info | 10 | 类中方法的符号引用 |
CONSTANT_InterfaceMethodref_info | 11 | 接口中方法的符号引用 |
CONSTANT_NameAndType_info | 12 | 字段或方法的符号引用 |
CONSTANT_MethodHandle_info | 15 | 表示方法句柄 |
CONSTANT_MothodType_info | 16 | 标志方法类型 |
CONSTANT_InvokeDynamic_info | 18 | 表示一个动态方法调用点 |
1.3.3 第一个常量
可以看到,第一个常量的值为10,对应到标志位,找到常量为 CONSTANT_Methodref_info (类中方法的符号引用),它的结构为:
后面的4个字节都是它的内容,记录着2个索引值:
第一个索引值为:0X0009,也就是9,指向常量池中第9项的索引。
第二个索引值为:0X0017,也就是23,指向常量池中第23项的索引。
1.3.4 第二个常量
可以看到,第二个常量的值为9,对应到表中的标志位,找到常量为 CONSTANT_Fieldref_info(字段的符号引用),它的结构为:
后面的4个字节都是它的内容,记录着2个索引值:
第一个索引值为:0X0004,也就是4,指向常量池中第4项的索引。
第二个索引值为:0X0018,也就是24,指向常量池中第24项的索引。
1.3.5 javap编译字节码
通过javap命令就可以将class文件转化为可读的字节码指令。
javap -v MyDemo.class > MyDemo.txt
生成的字节码指令如下:
Classfile /E:/xxx/jvm/MyDemo.class
Last modified 2020-7-20; size 564 bytes
MD5 checksum 0c32d2c6a7edb1bebe91f0a2f168d28d
Compiled from "MyDemo.java"
public class cn.learn.jvm.MyDemo
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #9.#23 // java/lang/Object."<init>":()V
#2 = Fieldref #4.#24 // cn/learn/jvm/MyDemo.a:I
#3 = Fieldref #4.#25 // cn/learn/jvm/MyDemo.b:I
#4 = Class #26 // cn/learn/jvm/MyDemo
#5 = Methodref #4.#23 // cn/learn/jvm/MyDemo."<init>":()V
#6 = Methodref #4.#27 // cn/learn/jvm/MyDemo.sum:()I
#7 = Fieldref #28.#29 // java/lang/System.out:Ljava/io/PrintStream;
#8 = Methodref #30.#31 // java/io/PrintStream.println:(I)V
#9 = Class #32 // java/lang/Object
#10 = Utf8 a
#11 = Utf8 I
#12 = Utf8 b
#13 = Utf8 <init>
#14 = Utf8 ()V
#15 = Utf8 Code
#16 = Utf8 LineNumberTable
#17 = Utf8 sum
#18 = Utf8 ()I
#19 = Utf8 main
#20 = Utf8 ([Ljava/lang/String;)V
#21 = Utf8 SourceFile
#22 = Utf8 MyDemo.java
#23 = NameAndType #13:#14 // "<init>":()V
#24 = NameAndType #10:#11 // a:I
#25 = NameAndType #12:#11 // b:I
#26 = Utf8 cn/itcast/jvm/MyDemo
#27 = NameAndType #17:#18 // sum:()I
#28 = Class #33 // java/lang/System
#29 = NameAndType #34:#35 // out:Ljava/io/PrintStream;
#30 = Class #36 // java/io/PrintStream
#31 = NameAndType #37:#38 // println:(I)V
#32 = Utf8 java/lang/Object
#33 = Utf8 java/lang/System
#34 = Utf8 out
#35 = Utf8 Ljava/io/PrintStream;
#36 = Utf8 java/io/PrintStream
#37 = Utf8 println
#38 = Utf8 (I)V
{
public cn.learn.jvm.MyDemo();
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: aload_0
10: iconst_2
11: putfield #3 // Field b:I
14: return
LineNumberTable:
line 3: 0
line 5: 4
line 6: 9
public int sum();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=2, locals=2, args_size=1
0: aload_0
1: getfield #2 // Field a:I
4: aload_0
5: getfield #3 // Field b:I
8: iadd
9: istore_1
10: iload_1
11: ireturn
LineNumberTable:
line 9: 0
line 10: 10
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: new #4 // class cn/itcast/jvm/MyDemo
3: dup
4: invokespecial #5 // Method "<init>":()V
7: astore_1
8: aload_1
9: invokevirtual #6 // Method sum:()I
12: istore_2
13: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
16: iload_2
17: invokevirtual #8 // Method java/io/PrintStream.println:(I)V
20: return
LineNumberTable:
line 14: 0
line 15: 8
line 16: 13
line 17: 20
}
SourceFile: "MyDemo.java"
内容大致分为4个部分:
第一部分:显示了生成这个class的java源文件、版本信息、生成时间等。
第二部分:显示了该类中所涉及到常量池,共38个常量。
第三部分:显示该类的构造器,编译器自动插入的。
第四部分:显示了sum、main方的信息。(这个是需要我们重点关注的)
1.4 描述符
1.4.1 字段描述符
官网:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html#jvms-4.3.2
1.4.2 方法描述符
官网:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html#jvms-4.3.3
示例:
The method descriptor for the method:
Object m(int i, double d, Thread t) {…}
is:
(IDLjava/lang/Thread;)Ljava/lang/Object;
1.5 实例:字符串拼接
字符串的拼接在开发过程中使用是非常频繁的,常用的方式有三种:
- +号拼接: str+“456”
- StringBuilder拼接
- StringBuffer拼接
StringBuffer是保证线程安全的,效率是比较低的,我们更多的是使用场景是不会涉及到线程安全的问题的,所以更多的时候会选择StringBuilder,效率会高一些。那么,问题来了,StringBuilder和“+”号拼接,哪个效率高呢?
通过探究字节码可知:使用+号拼接,但是在字节码中也被编译成了StringBuilder方式。
所以,可以得出结论,字符串拼接,+号和StringBuilder是相等的,效率一样。
2 类加载机制
Java源代码经过编译器编译成字节码之后,最终都需要加载到虚拟机之后才能运行。虚拟机把描述类的数据从Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这就是虚拟机的类加载机制。
2.1 类加载时机
一个类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备、解析三个部分统称为连接(Linking)。
加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类型的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定特性(也称为动态绑定或晚期绑定)。
注意:是按部就班的开始,这些阶段通常都是互相交叉地混合进行的,会在一个阶段执行的过程中调用、激活另一个阶段。
对于初始化阶段,《Java虚拟机规范》则是严格规定了有且只有六种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):
- 遇到 new、getstatic、putstatic 或 invokestatic 这 4 条字节码指令;
- 使用 java.lang.reflect 包的方法对类进行反射调用的时候;
- 当初始化一个类的时候,发现其父类还没有进行初始化的时候,需要先触发其父类的初始化;
- 当虚拟机启动时,用户需要指定一个要执行的主类,虚拟机会先初始化这个类;
- 当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果
REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有初始化。 - 当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。
除此之外,所有引用类型的方式都不会触发初始化,称为被动引用。
比如:
- 通过子类引用父类的静态字段,不会导致子类的初始化;
- 通过数组定义来引用类,不会触发此类的初始化;
- 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化;
2.2 类加载过程
2.2.1 加载
在加载阶段,Java虚拟机需要完成以下三件事情:
- 通过一个类的全限定名来获取定义此类的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
2.2.2 验证
验证是连接阶段的第一步,这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。
- 文件格式验证
第一阶段要验证字节流是否符合 Class 文件格式的规范。验证点主要包括:
1)是否以魔数 0xCAFEBABE 开头;
2)主、次版本号是否在当前虚拟机处理范围之内;
3)常量池的常量中是否有不被支持的常量类型;
4)Class 文件中各个部分及文件本身是否有被删除的或者附加的其它信息等等。 - 元数据验证
第二阶段是对字节码描述的信息进行语义分析
1)这个类是否有父类;
2)这个类的父类是否继承了不允许被继承的类;
3)如果这个类不是抽象类,是否实现了其父类或者接口之中要求实现的所有方法;
4)类中的字段、方法是否与父类产生矛盾等等。 - 字节码验证
第三阶段是整个验证过程中最复杂的一个阶段,主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。 - 符号引用验证
1)最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段–解析阶段中发生。
2)符号引用验证可以看做是对类自身以外(常量池中的各种符号引用)的各类信息进行匹配性校验,通俗来说就是,该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源。
2.2.3 准备
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段。
2.2.4 解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
2.2.5 初始化
类初始化阶段是类加载过程中的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全是由虚拟机主导和控制的。到了初始化阶段,才真正开始执行类中定义的 Java 程序代码。
2.3 类加载器
实现"通过一个类的全限定名来获取描述该类的二进制字节流"这个动作的代码被称为“类加载器”(Class Loader)。
2.3.1 类与类加载器
比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。
2.3.2 双亲委派模型
从 Java 虚拟机的角度来讲,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用 C++ 来实现,是虚拟机自身的一部分;另一种就是所有其他的类加载器,这些类加载器都由 Java 来实现,独立于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoader 。
从 Java 开发者的角度来看,类加载器可以划分为:
- 启动类加载器(Bootstrap ClassLoader):这个类加载器负责将存放在 <java_home>\lib 目录中的类库加载到虚拟机内存中。启动类加载器无法被 Java 程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给启动类加载器,那直接使用 null 代替即可;
- 扩展类加载器(Extension ClassLoader):这个类加载器由sun.misc.Launcher$ExtClassLoader 实现,它负责加载 <java_home>\lib\ext 目录中,或者被 java.ext.dirs 系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器;
- 应用程序类加载器(Application ClassLoader):这个类加载器由 sun.misc.Launcher$App-
ClassLoader 实现。getSystemClassLoader() 方法返回的就是这个类加载器,因此也被称为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库。开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。
双亲委派模型的工作过程是:
1)如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类
2)而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此
3)因此所有的加载请求最终都应该传送到最顶层的启动类加载器中
4)只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。
这样做的好处就是 Java 类随着它的类加载器一起具备了一种带有优先级的层次关系。例如java.lang.Object,它放在 rt.jar 中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型顶端的启动类加载器来加载,因此Object 类在程序的各种类加载器环境中都是同一个类。
双亲委派模型确保在程序的各种类加载器环境中没有重复的类,对于保证Java程序的稳定运作极为重要!
3 编译优化
3.1 前端编译器
前端编译器就是将*.java文件编译成*.class文件的过程。
3.1.1 javac编译过程
javac的编译过程大致可以分为1个准备过程和3个处理过程,它们分别如下所示。
- 准备过程:初始化插入式注解处理器。
- 解析与填充符号表过程
1)词法、语法分析。将源代码的字符流转变为标记集合,构造出抽象语法树。
2)填充符号表。产生符号地址和符号信息。符号表(Symbol Table)是由一组符号地址和符号信息构成的数据结构。 - 插入式注解处理器的注解处理过程。
- 分析与字节码生成过程
1)标注检查。对语法的静态信息进行检查。
2)数据流及控制流分析。对程序动态运行过程进行检查。
3)解语法糖。将简化代码编写的语法糖还原为原有的形式。
4)字节码生成。将前面各个步骤所生成的信息转化成字节码。
3.2 后端编译优化
后端编译器是指把Class文件转换成与本地基础设施(硬件指令集、操作系统)相关的二进制机器码的过程。
- 即时编译器
在HotSpot中有三种编译器
客户端编译器”(Client Compiler)
服务端编译器”(Server Compiler)
Graal编译器(JDK10中出现) - 提前编译器
Android中的ART就是提前编译器
3.2.1 编译器的优化
3.2.2 方法内联
是指JVM在运行时将调用次数达到一定阈值的方法调用替换为方法体本身。
例如:
static class B {
int value;
final int get() {
return value;
}
}
//未内联的代码
public void foo() {
y = b.get();
// ...do stuff...
z = b.get();
sum = y + z;
}
//内联优化的代码
public void foo() {
y = b.value;
// ...do stuff...
z = b.value;
sum = y + z;
}
//可以继续优化
//冗余存储消除的代码
public void foo() {
y = b.value;
// ...do stuff...
z = y;
sum = y + z;
}
//复写传播的代码
public void foo() {
y = b.value;
// ...do stuff...
y = y;
sum = y + y;
}
//进行无用代码消除的代码
public void foo() {
y = b.value;
// ...do stuff...
sum = y + y;
}
需要注意的是,以上优化是在编译器内部完成,而不是在代码中完成的,只是用代码来说明问题。
3.2.3 逃逸分析
逃逸分析的基本原理是:
- 分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸;
- 甚至还有可能被外部线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸;
- 从不逃逸、方法逃逸到线程逃逸,称为对象由低到高的不同逃逸程度。
如果能证明一个对象不会逃逸到方法或线程之外,或者逃逸程度比较低,则可能为这个对象实例采取不同程度的优化。
3.2.4 公共子表达式消除
它的含义是:如果一个表达式E之前已经被计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就称为公共子表达式。
对于这种表达式,没有必要花时间再对它重新进行计算,只需要直接用前面计算过的表达式结果代替E。
如果这种优化仅限于程序基本块内,便可称为局部公共子表达式消除,如果这种优化的范围涵盖了多个基本块,那就称为全局公共子表达式消除。
例子:
//源码
int d = (c * b) * 12 + a + (a + b * c);
//编译器检测到cb与bc是一样的表达式,而且在计算期间b与c的值是不变
int d = E * 12 + a + (a + E);
//还可以进一步优化
int d = E * 13 + a + a;
3.2.5 数组边界检查消除
如果有一个数组foo[],在Java语言中访问数组元素foo[i]的时候系统将会自动进行上下界的范围检查,即i必须满足“i>=0&&i<foo.length”的访问条件,否则将抛出一个运行时异常:
java.lang.ArrayIndexOutOfBoundsException。
对于编译器而言,根据数据流分析来确定是否越界,如果没有越界,那么执行时就不需要再判断了。
如果在循环中,本身就是通过循环变量来控制对数组的访问,执行时也就不用再判断了,
这样就可以把整个数组的上下界检查消除掉,这可以节省很多次的条件判断操作。