导航
JVM的生命周期
- 程序正常执行结束
- 调用System.exit()方法
- 程序执行过程中出现异常而终止(没有使用try...catch捕获异常,致使异常最终被抛给JVM)
- 由于操作系统出现错误而导致JVM进程终止
类加载机制
JVM把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被JVM直接使用的Java类型,这就是JVM的类加载机制。
类的生命周期
类从被JVM加载到内存中开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载7个阶段。其中验证、准备、解析3个阶段统称为连接。具体可以参考下图:
本文主要讲述类的加载、连接和初始化3个阶段。
类的加载、连接、初始化
类的加载、连接、初始化就是类加载的全过程,而这些都是在程序的运行期完成的。
- 加载:将类的Class文件加载到内存中
- 连接:
- 验证:确保加载Class文件的正确性,没有被篡改
- 准备:为类的静态变量分配内存,并将其初始化为默认值
- 解析:将类中的符号引用替换为直接引用
- 初始化:为类中的静态变量赋予给定的初始值
类初始化阶段的为静态变量赋初始值,有两种方式:
1、显式直接为静态变量赋值
class Demo {
static String str = "hello";
}
2、在静态代码块中为静态变量赋值
class Demo {
static String str;
static {
str = "hello";
}
}
根据上述的第二种情况,我们可以推导出:执行了static代码块中的代码就可以证明该类初始化了。
类的使用方式
java程序对类的使用可以分为两种方式:主动使用和被动使用。
主动使用
- 创建类的实例
- 访问某个类、接口的静态变量,或者对该静态变量赋值
- 调用类的静态方法
- 反射
- 初始化一个类的子类
- JVM启动时被标记为启动类的类(包含main()方法)
- JDK1.7开始提供动态语言支持(很少使用)
被动使用
除了上述七种情况,其余使用java类的方式都可以视为对类的被动使用,被动使用不会导致类的初始化。注意,不初始化一个类,并不意味着不会加载这个类的Class文件。
例如通过子类访问父类的静态变量,不会初始化子类。例1:
public class Test{
public static void main(String[] args) {
System.out.println(Children.str);
}
}
class Parent {
public static String str = "hello";
static {
System.out.println("Parent init");
}
}
class Children extends Parent{
static {
System.out.println("Children init");
}
}
上面例子会打印“Parent init”,而不会打印“Children init”。这个例子从侧面说明了主动使用的第二种情况——访问某个类、接口的静态变量,或者对该静态变量赋值——初始化的是定义这个静态变量的类。
加载
类的加载指的是将类的Class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区,然后在内存中创建一个java.lang.Class对象(规范并未说明Class对象位于什么区域,例如HotSpot虚拟机将其放在方法区中),用于封装类在方法区内的数据结构。类的加载的最终产品是Class对象,而该Class对象正是反射的基础。
加载Class文件的方式
- 从本地(磁盘)直接加载
- 从网络下载加载
- 从zip、jar等归档文件中加载
- 从专有数据库中提取加载
- 将java源文件动态编译
查看类加载信息
这里我们需要用到一个JVM的参数:-XX:+TraceClassLoading(追踪类的加载信息并打印出来)。关于JVM的参数格式是有规律可循的,具体来说就是下面三种:
- -XX:+<option> 开启option(+表示true,开启的意思)
- -XX:-<option> 关闭option(-表示false,关闭的意思)
- -XX:<option>=<value> 将option的值设置为value
我使用的IDE是eclipse,在当前使用的启动类(这里是Test类)的java代码中右击->Run As->Run Configurations...,在Run Configurations窗口的VM arguments中就可以配置JVM参数。具体可以参考下图:
当然,你也可以设置默认的JVM参数,这样就不用分别为每个启用类设置JVM参数。选中菜单栏上的Window->Preferences->Java->Installed JREs,选中当前使用的JRE->Edit...,在Edit JRE窗口的Default VM arguments中就可以配置JVM参数。具体可以参考下图:
完成上面任意一种JVM参数配置,就可以在控制台中看到JVM加载的全部类信息的打印结果。
加载时机的不确定
什么时候会开始类加载过程中的第一个阶段:加载?在虚拟机规范中并没有进行强制约束,这点交由虚拟机的具体实现来自由把握。
在上面谈及被动使用时,有过这样的描述:不初始化一个类,并不意味着不会加载这个类的Class文件。这句话所表达的含义其实就暗含了加载时机的不确定性。
完成配置JVM参数配置之后,这里继续使用例1的代码,运行程序。我们可以在打印结果中看到Children类的加载信息:
[Loaded org.hu.jvm.Parent from file:/C:/Users/hunan/workspace/JVM/build/classes/]
[Loaded org.hu.jvm.Children from file:/C:/Users/hunan/workspace/JVM/build/classes/]
虽然Children类没有被初始化,但是该类依然被JVM加载了。这也就证实了类的加载时机是由虚拟机的具体实现来自由把握的。
然而虽然我们无法确定JVM什么时候会加载一个类,但是我们可以确定什么时候JVM不会加载一个类。例2:
public class Test{
public static void main(String[] args) {
System.out.println(FinalClass.str);
}
}
class FinalClass {
public static final String str = "hello";
static {
System.out.println("FinalClass init");
}
}
在打印结果中,我们不会看到FinalClass类的加载信息的,甚至将FinalClass类的Class文件删除,这个程序依然可以运行。
eclipse编译的Class文件默认存放在build文件夹下。在Quick Access输入框中输入Navigator以打开Navigator窗口,在Navigator窗口中就可以看到build文件夹以及编译的Class文件。具体可以参考下图:
把build文件夹下的FinalClass.class文件删除,再次运行程序,你会发现程序可以正常运行,没有任何报错信息。
这是因为Test类中访问了FinalClass类中的常量str,而在编译阶段就已经将此常量的值“hello”存放到Test类的常量池中。之后Test类对常量str的访问,实际上都是对自身常量池的访问。换而言之,两个类在编译成Class文件之后就不存在任何联系了。所以引用常量并不会导致定义常量的类的初始化。
初始化
上面提到类的加载时机是不确定的,但是类的初始化的机我们是可以确定的:每个类或者接口只有被Java 程序首次主动使用使用时,JVM才会初始化它。
从上面的对类初始化时机的描述中,我们可以得出这样的结论:
- 首次使用的类才会被初始化
- 主动使用的类才会被初始化
- 类的初始化只会进行一次
反编译与字节码指令
现在我们来试着反编译Class文件,看看Class文件中记录的数据。这里继续使用例2的代码。
在Navigator窗口选中要反编译的Class文件所在的文件夹,然后点击Terminal,在弹出的对话框中选择“OK”就可以看到Terminal窗口。我们可以发现,Terminal中的命令行已经定位到Class文件所在的文件夹,在命令行中输入javap -c 类名(这里是Test),就可以查看Test类的Class文件中记录的字节码指令。具体可以参考下图:
反编译Test.class后字节码指令如下图所示:
在JVM中有很多字节码指令助记符,我在文章中就讲解一些碰到的助记符含义:
- getstatic:访问静态变量
- ldc:int,float,String 类型常量值从常量池推送到栈顶
- invokevirtual:调用实例方法
接口初始化
大家应该都知道接口中的变量都是public static final的,所以当我们在启动类中引用接口中的变量时,就变成了例2所讨论的情况——启动类和接口在编译成Class文件之后就不存在任何联系了。例3:
public class Test{
public static void main(String[] args) {
System.out.println(Inter.num);
}
}
interface Inter {
int num = 1; // 完整的样子:public static final num = 1;
}
运行上面的程序,在控制台打印的类加载信息中,我们不会看到Inter接口的加载信息的。既然如此,那么问题来了——怎么样才能让接口被初始化呢?
在例2和例3中,我们都给常量赋予了一个确定的值,所以在编译期该常量会被存放到引用该常量的类的常量池中。那么如果我们给常量赋予的值在编译期无法确定呢?例4:
public class Test{
public static void main(String[] args) {
System.out.println(Inter.thread);
}
}
interface Inter {
Thread thread = new Thread() {
{ // 构造代码块,创建对象时执行
System.out.println("Inter init");
}
};
}
在上面的例子中,接口成员变量thread的值在编译期是无法确定的。只有在运行期,Thread对象才会被创建出来,当Test类访问thread时,变量thread才会被赋值,接口就会被初始化。打印结果:“Inter init”,证明接口Inter确实被初始化了。
通过例4我们可以发现之前从例2得出的推论——引用常量并不会导致定义常量的类的初始化——是不完善的,现在我们可以将这个结论完善一下:引用在编译期可以确定值的常量并不会导致定义常量的类的初始化。
接口与类初始化的不同
下面的例子是java程序对类的主动使用,相信大家应该都知道打印的结果。例5:
public class Test{
public static void main(String[] args) {
System.out.println(Children.str2);
}
}
class Parent {
static {
System.out.println("Parent init");
}
}
class Children extends Parent{
public static String str2 = "welcome";
static {
System.out.println("Children init");
}
}
打印结果:“Parent init”,“Children init”,“welcome”。这个打印结果是符合我们预期的。例5所证明的是主动使用的第五种情况——初始化一个类的子类,该类会被初始化。但这条规则不适用于接口,具体体现在下面两点:
- 在初始化一个接口的时候,并不会先初始化它的父接口
- 在初始化一个类的时候,并不会先初始化它所实现的接口
我们先来看第一种情况,例6:
public class Test{
public static void main(String[] args) throws Exception{
System.out.println(Children.num1);
}
}
interface Parent {
int num = new Random().nextInt(2);
Thread thread = new Thread() {
{
System.out.println("Parent init");
}
};
}
interface Children extends Parent{
int num1 = new Random().nextInt(2);
Thread thread1 = new Thread() {
{
System.out.println("Children init");
}
};
}
打印结果:“Children init”,1(此值以输出情况为准)。接着将main()方法中语句改为:
System.out.println(Children.num);
我们可以发现打印结果变成了这样:“Parent init”,0(此值以输出情况为准)。通过这个例子,我们可以对第一种情况进行补充:初始化一个接口时,并不要求其父接口都完成了初始化,只有在真正使用到父接口的时候(如引用接口中所定义的常量时),才会初始化父接口。
再来看第二种情况,例7:
public class Test{
public static void main(String[] args) {
System.out.println(Implemention.str);
}
}
interface Inter {
Thread thread = new Thread() {
{
System.out.println("Inter init");
}
};
}
class Implemention implements Inter {
public static String str = "hello";
static {
System.out.println("Implemention init");
}
}
打印结果:“Implemention init”,“hello”。
从例6和例7两个例子中不难发现,在对类和接口初始化的时候,类与接口、接口与接口之间的关联很弱,甚至可以说没有什么关联——类的初始化不会影响到该类继承的接口、子接口的初始化不会影响父接口。
初始化的顺序
看这样一个例子,例8:
public class Test{
public static void main(String[] args) {
Singleton singleton = Singleton.getInstance();
System.out.println(singleton.count1);
System.out.println(singleton.count2);
}
}
class Singleton {
public static int count1;
public static int count2 = 0;
public static Singleton singleton = new Singleton();
private Singleton() {
count1++;
count2++;
}
public static Singleton getInstance() {
return singleton;
}
}
运行上面的程序,打印的结果是什么呢?相信你一定能猜出来,打印结果是:1,1。你可能会觉得奇怪,这么简单的程序有什么好举例的。那么我稍微修改一下这个程序,你还能猜出打印的结果吗?例9:
public class Test{
public static void main(String[] args) {
Singleton singleton = Singleton.getInstance();
System.out.println(singleton.count1);
System.out.println(singleton.count2);
}
}
class Singleton {
public static int count1;
public static Singleton singleton = new Singleton();
private Singleton() {
count1++;
count2++;
}
public static int count2 = 0; // 将count2的位置调换到这里
public static Singleton getInstance() {
return singleton;
}
}
打印结果:1,0。怎么样,猜对了吗?为什么仅仅调换了count2的位置,程序打印结果就发生了这么大的变化呢?
在JVM完成Singleton类的加载和连接两个步骤之后,相信大家都是知道变量count1和count2的值的,它们都被赋予了对应数据类型的默认值——count1=0,count2=0。接着开始初始化,在为变量singleton赋初值的时候调用了Singleton的构造方法,此时变量count1和count2的值变成了count1=1,count2=1。在此之后,变量count2才被赋予给定的初始值0,于是最后变量count1和count2的值为count1=1,count2=0。具体可以参考下图:
通过这个例子想要说明的是:初始化是自上而下顺序执行的。
数组与被动使用
关于被动使用,还有一种情况需要着重讨论。例10:
public class Test{
public static void main(String[] args) {
ArrayClass[] arr1 = new ArrayClass[1];
System.out.println(arr1.getClass()); // 获取Class对象
System.out.println(arr1.getClass().getSuperclass()); // 获取父类Class对象
System.out.println("========================");
ArrayClass[][] arr2 = new ArrayClass[5][6];
System.out.println(arr2.getClass());
System.out.println(arr2.getClass().getSuperclass());
System.out.println("========================");
char[] arr3 = new char[128];
System.out.println(arr3.getClass());
System.out.println(arr3.getClass().getSuperclass());
}
}
class ArrayClass {
static {
System.out.println("ArrayClass init");
}
}
打印结果如下:
class [Lorg.hu.jvm.ArrayClass;
class java.lang.Object
========================
class [[Lorg.hu.jvm.ArrayClass;
class java.lang.Object
========================
class [I
class java.lang.Object
打印结果中并没有出现“ArrayC init”,说明ArrayClass类并没有被初始化。这是因为对于数组实例而言,其类型是由JVM在运行期动态生成的。对于这种动态生成的类型,其父类型是Object。JavaDoc经常将构成数组的元素称为Component,实际上就是将数组降低一个维度后的类型。
数组的类型分为两类:基本类型数组和引用类型数组。基本类型数组的类型表示为:数组维度个[ + L + 数据类型对应的大写字母,引用类型数组的类型表示为:数组维度个[ + L + 类全名。
反编译Test.class,从打印结果中可以看到出现了新的助记符:
- iconst_1:将整形常量1压入栈(对于整型常量-1~5,JVM 采用iconst_m1、iconst_0、iconst_1、iconst_2、iconst_3、iconst_4、iconst_5指令将常量压入栈中)
- astore_1:将栈顶引用类型值保存到局部变量1中(除此之外还有astore_0,astore_2,astore_3)
- bipush:将单字节(-128 ~ 127)的常量值从常量池中推至栈顶
- sipush:将一个短整型(-32768 ~ 32767)的常量值从常量池中推至栈顶
- anewarray:创建一个一维引用类型数组,并将其引用值压入栈顶
- newarray:创建一个一维基本类型数组,并将其引用值压入栈顶
- multianewarray:创建一个多维数组(基本类型和引用类型数组均使用此指令)。
参考: