最近为了学习 Java 虚拟机底层的工作原理,我找了一些相关的书籍来看,阅读过程当中看到了一个例子,觉得很不错,在此记录一下,主要讲述的是虚拟机如何执行 main 方法中的第一条指令的。
要理解 Java 虚拟机,首先要意识到它可能指的是如下三种不同的东西:
1.抽象规范。
2.一个具体的实现。
3.一个运行中的虚拟机实例。
知道了这点小小的区别以后,我们再以下面这个简单的源程序为例进行说明。不过要特别提醒的是,下文所描述的执行 main 方法字节码中第一条指令的步骤,仅仅是虚拟机多种实现方式中的一种,不同的虚拟机实现可能会用完全不同的方法来操作。
1 | package com.test; |
下面看一下 Java 虚拟机是如何执行 Demo 程序中 main 方法的第一条指令的。
要运行 Demo 程序,首先要以某种「依赖于实现」的方式告诉虚拟机「Demo」这个名字。之后,虚拟机将找到并读入相应的 class 文件「Demo.class」,然后它会从导入的 class 文件里的二进制数据中提取 Demo 这个类的信息并放到方法区中。通过执行保存在方法区中的字节码,虚拟机开始执行 main() 方法,在执行时,它会一直持有指向 Demo 类的常量池的指针。
注意,虚拟机开始执行 main() 方法字节码的时候,尽管 Lava 类还没被装载,但是和大多数虚拟机的实现一样,它不会等到把程序中用到的所有类都装载后才开始运行程序。恰好相反,它只在需要时才装载相应的类。
main() 方法的第一条指令告知虚拟机给列在常量池第一项的类分配足够的内存。所以虚拟机使用指向 Demo 类常量池的指针找到第一项,发现它是一个对 Lava 类的符号引用,然后它就检查方法区,看 Lava 类是否已经被装载了。
这个符号引用仅仅是一个给出了类 Lava 的全限定名「Lava」的字符串。为了能让虚拟机尽可能快地从一个名称找到类,设计者应当选择最佳的数据结构和算法。这里可以采用各种方法,如散列表、搜索树等等。同样的算法也可以用于实现 Class 类的 forName() 方法,这个方法根据给定的全限定名返回 Class 引用。
当虚拟机发现还没有装载过名为「Lava」的类时,它就开始查找并装载文件「Lava.class」,并把从读入的二进制数据中提取的 Lava 类信息放入方法区中。
紧接着,虚拟机以一个直接指向方法区 Lava 类数据的指针来替换常量池第一项(就是那个字符串「Lava」),以后就可以用这个指针来快速地访问 Lava 类了。这个替换过程称为常量池解析,即把常量池中的符号引用替换为直接引用。这是通过在方法区中搜索被引用的元素实现的,在这期间可能又需要装载其他类。在这里,我们替换掉符号引用的「直接引用」是一个本地指针。
终于,虚拟机准备为一个新的 Lava 对象分配内存。此时,它又需要方法区中的信息。还记得刚刚放到 Demo 类常量池第一项的指针吗?现在虚拟机用它来访问 Lava 类信息(此前刚放到方法区中的),找出其中记录的这样一个信息:一个 Lava 对象需要分配多少堆空间。
Java 虚拟机总能够通过存储于方法区中的类信息来确定一个对象需要多少内存,但是,某个特定对象事实上需要多少内存,是跟特定实现相关的。对象在虚拟机内部的表示是由实现的设计者来决定的。
当 Java 虚拟机确定了一个 Lava 对象的大小后,它就在堆上分配这么大的空间,并把这个对象实例的变量 speed 初始化为默认值0。假如 Lava 类的超类 Object 也有实例变量,则也会在此时被初始化为相应的默认值。
当把新生成的 Lava 对象的引用压入栈中,main() 方法的第一条指令也完成了。接下来的指令通过这个引用调用 Java 代码(该代码把 speed 变量初始化为正确值 5)。另外一条指令将用这个引用调用 Lava 对象的 flow() 方法。
总结:
通过上面的讲解,我们也可以得出类装载器的一个基本工作顺序:
1.装载:查找并装载类或接口的二进制数据。
2.连接:执行验证、准备,以及解析(可选)。
验证:确保被导入类或接口的正确性。
准备:为类变量分配内存,并将其初始化为默认值。
解析:把类或接口中的符号引用转换为直接引用。
3.初始化:把类变量初始化为正确的值。
(完)