深入剖析JVM执行Hello World过程
想必学习计算机的同学,编写的第一个程序都是Hello World!,至此欣喜的打开了编程世界的大门,那么对于Java程序来说,程序到底是怎么运行的呢?
先来看一下我们编写的第一个程序:
public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello World!");
}
我们通常会使用 javac 命令,先将.java文件编译为.class文件
javac HelloWorld.java
然后再使用 java 命令执行.class文件,然后就输出了“Hello World!”。
java HelloWorld
Hello World!
我们知道,在jdk安装路径bin目录下,都能找到与我们常执行的命令对应的同名exe可执行程序,比如java、javac、jhat、jinfo......
java HelloWorld 命令,就意味着使用java.exe启动JVM(Java虚拟机)解析执行我们的字节码文件(即HelloWorld.class),具体流程如下:
字节码文件结构
每一个字节码文件(包括但不限于*.class文本文件)都对应着唯一的一个类或者接口的定义信息。
根据《Java虚拟机规范》,Class文件结构定义可以用C语言(伪码)表示:
可以看到,字节码文件中包含:
- magic:魔数,固定值0xCAFEBABE,占头4个字节,唯一的作用就是确定这个文件是否能被虚拟机接受的class文件
- minor_version:副版本号,占用第5和第 6个字节
- major_version:主版本号,占用第7和第8个字节
- constant_pool*:常量池信息,主要包括字面量(如文本字符串、final修饰的常量)和符号引用(包、类和接口名、字段和方法名称描述等)两大类
- access_flags:访问标志,这个Class是类还是接口; 是否定义为public类型; 是否定义为abstract类型; 如果是类的话, 是否被声明为final;
- this_class/super_class/interfaces*:类索引、父类索引与接口索引集合,用来确定该类的继承关系
- fields*:字段信息,描述接口或者类中声明的变量
- method*:方法信息、包括实例初始化方法以及类或接口的初始化方法
- attribute*:属性信息、Class文件、 字段表、 方法表中的属性表集合,比如,你写在方法里的Java代码, 经过Javac编译器编译成字节码指令之后, 就存放在方法属性表集合中一个名为“Code”的属性里面
结构定义中的类型u1、u2、u4、u8,分别代表1个字节、 2个字节、 4个字节和8个字节的无符号数;类型cp_info、field_info、method_info、attribute_info代表对应定义的结构。
将上面二进制class文件转换为十六进制后,就可以看到,前四个字节就是固定值0xCAFEBABE,代表副版本号的第5/6个字节是0x0000,代表主版本号的第7/8个字节就是0x0034,也就是十进制的52,根据jdk版本对照表,可以看到我用的是java 8版本。
为了便于分析,我们一般也不需要人工解析二进制或十六进制Class文件,JDK提供了一个帮助我们分析class字节码文件的工具: javap (见图1),可以使用 javap -v HelloWrold 命令分析字节码中的指令,当然,也可以使用IDEA中show bytecode扩展工具( 更多IDEA工具 )帮助我们查看分析字节码文件结构:
Classfile /D:/workspace/learnProject/cxy965-jvm/target/classes/com/cxy965/HelloWorld.class
Last modified 2022-5-28; size 556 bytes
MD5 checksum d39542245044aa1cf75f2436a116e54c
Compiled from "HelloWorld.java"
public class com.cxy965.HelloWorld
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
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 // Hello World!
#4 = Methodref #24.#25 // java/io/PrintStream.println:(Ljava/lang/String;)V
#5 = Class #26 // com/cxy965/HelloWorld
#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/cxy965/HelloWorld;
#14 = Utf8 main
#15 = Utf8 ([Ljava/lang/String;)V
#16 = Utf8 args
#17 = Utf8 [Ljava/lang/String;
#18 = Utf8 SourceFile
#19 = Utf8 HelloWorld.java
#20 = NameAndType #7:#8 // "<init>":()V
#21 = Class #28 // java/lang/System
#22 = NameAndType #29:#30 // out:Ljava/io/PrintStream;
#23 = Utf8 Hello World!
#24 = Class #31 // java/io/PrintStream
#25 = NameAndType #32:#33 // println:(Ljava/lang/String;)V
#26 = Utf8 com/cxy965/HelloWorld
#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
public com.cxy965.HelloWorld();
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 3: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/cxy965/HelloWorld;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String Hello World!
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 5: 0
line 6: 8
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 args [Ljava/lang/String;
SourceFile: "HelloWorld.java"
从文件中我们可以清楚的看到,Class文件的类名,版本、常量池信息、并且包含一个构造方法、一个main方法,方法中包含了描述信息、访问标识以及一个Code属性。
以main方法为例,可以看出以下信息:
- 描述信息:返回值为void,一个String数组类型参数
- 访问标志:public,static
- Code属性:
- stack:操作数栈的深度,2
- locals:局部变量表所需的存储空间,1个变量槽
- args_size:参数数量,1,static方法从0计数,实例方法从1计数,因会有1个指向对象实例(this)的局部变量作为参数传入
- getstatic: 执行指令 操作静态方法
- ldc: 执行指令 将String常量“HelloWorld”从常量池中推送至栈顶
- invokevirtual:调用PrintStream的println方法
- return:方法返回
- LineNumberTable:Java源码行号与字节码行号(字节码的偏移量) 之间的对应关系,当抛出异常时, 堆栈中显示出错的行号信息就是从这获取的
- LocalVariableTable:栈帧中局部变量表的变量与Java源码中定义的变量之间的关系,当其他人引用这个方法时,同时会把参数名称带过去,而不是arg0,arg1
字节码指令
每一条字节码指令都是 一个字节 长度的、代表着特定操作含义的操作码(opcode)以及跟随其后的零或多个代表此操作所需参数的操作数(operand)所构成。
在Java虚拟机的指令集中,大多数的指令都包含了其所操作的数据类型信息,并且它们的操作码都有特殊的字符作为助记符来表明为哪种数据类型服务: i代表对int类型的数据操作, l代表long, s代表short, b代表byte, c代表char, f代表float, d代表double, a代表reference,而如iload指令则用于从局部变量表中加载int类型的数据到操作数栈中,而fload指令加载的则是float类型的数据。
JVM指令很多,这里就不在一一列出,读者可自行查阅。
Java虚拟机
Java虚拟机的作用就是正确解析class文件中每一条字节码指令,并且正确执行这些指令所代表的机器操作指令。
所以,Java虚拟机其实是不仅限于Java语言的,凡是编译后能生成符合虚拟机规范的class字节码文件,都可以被Java虚拟机执行,比如Groovy,JRuby等语言。
类加载机制
Java虚拟机将数据从Class文件加载到内存, 并对数据进行校验、 转换解析和初始化, 最终形成可以被虚拟机直接使用的Java类型, 这个过程被称作虚拟机的类加载机制。
- 加载:获取Class文件二进制流,将其所代表的静态存储结构转化为方法区的运行时数据结构,在内存中生成一个代表这个类的java.lang.Class对象, 作为方法区这个类的各种数据的访问入口
- 验证:校验文件格式、元数据、字节码、符号引用等的正确性。
- 准备:为类中定义的静态变量分配内存并设置类变量初始值
- 解析:将常量池内的符号引用替换为直接引用
- 初始化:初始化类变量为代码指定的值和其他资源,执行静态代码块
类加载器
在类的加载机制中的加载阶段,会获取Class文件二进制流,这个其实就是使用类加载器实现的这个功能,在图二中,我们已经了解到的类加载器有引导类加载器、扩展类加载器、系统类加载器。一般情况下,Java虚拟机默认使用系统类加载器来加载我们的应用程序。
在这些类加载器中,除引导类加载器是由虚拟机在自身内部(C++)实现的,其他的加载器都是在虚拟机外部(继承java.lang.ClassLoader)实现的,比如在jre对应的类库文件中,还有一种是我们自己通过继承ClassLoader实现的自定义加载器。
比较两个类是否“相等”, 不仅二进制流数据相同,还必须是由同一个类加载器加载的才被认为是相等的。
这么多类加载器,那么一个类,到底由哪个类加载器进行加载呢?
双亲委派模型
双亲委派模型的过程就是: 如果一个类加载器收到了类加载的请求, 它首先不会自己去尝试加载这个类, 而是把这个请求委派给父类加载器去完成, 每一个层次的类加载器都是如此, 因此所有的加载请求最终都应该传送到最顶层的启动类加载器中, 只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类) 时, 子加载器才会尝试自己去完成加载。
java.lang.ClassLoader源码如下
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
c = findClass(name);
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
if (resolve) {
resolveClass(c);
return c;
}
使用双亲委派机制的一个好处就在于,Java中的类随着它的类加载器一起具备了一种带有优先级的层次关系。比如类java.lang.Object, 它存放在rt.jar之中, 无论哪一个类加载器要加载这个类, 最终都是委派给处于模型最顶端的启动类加载器进行加载, 因此Object类在程序的各种类加载器环境中都能够保证是同一个类。 反之, 如果没有使用双亲委派模型, 都由各个类加载器自行去加载的话, 如果用户自己也编写了一个名为java.lang.Object的类, 并放在程序的ClassPath中, 那系统中就会出现多个不同的Object类, Java类型体系中最基础的行为也就无从保证, 应用程序将会变得一片混乱。
比如,我们自己创建一个包名java.lang,并在该包下面创建Object类,会发生什么情况呢?
package java.lang;
public class Object {
public static void main(String[] args) {
System.out.println("自定义Object类");
}
运行一下:
错误: 在类 java.lang.Object 中找不到 main 方法, 请将 main 方法定义为:
public static void main(String[] args)
否则 JavaFX 应用程序类必须扩展javafx.application.Application
想一想,这是为什么呢?
执行我们自定义的Object的main方法时,应用程序类加载器需要先加载该类java.lang.Object,根据双亲委派机制,它会请求父类扩展类加载器去加载,而扩展类加载器同样会请求启动类加载器去加载(前文我们已经知道,JVM启动时,会首先创建启动类加载器并加载rt.jar等类库),这时启动类加载器会发现自己已经在rt.jar中加载过该类,并将信息逐级返回给应用程序类加载器。
我们自定义的Object类并未加载,当执行main方法时,会发现根本找不到main方法,rt.jar中的Object类根本没有main方法。
那么,怎么做才能让我们创建的Object类执行不报错呢?
打破双亲委派机制
上文我们提到,我们可以自定义类加载器,只需要继承java.lang.ClassLoader类即可,在该类中有两个核心方法:
- loadClass:实现了双亲委派机制
- findClass:空的实现方法
那么要打破双亲委派,我们只需要删除loadClass中委派父类加载的逻辑,并重写findClass方法即可。
protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
byte[] data = loadByte(name);
return defineClass(name, data, 0, data.length);
} catch (Exception e) {
e.printStackTrace();
throw new ClassNotFoundException();
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
c = findClass(name);
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();