JVM(一) 类加载

导航

JVM的生命周期

类加载机制

类的生命周期

类的加载、连接、初始化

类的使用方式

主动使用

被动使用

加载

加载Class文件的方式

查看类加载信息

加载时机的不确定

初始化

反编译与字节码指令

接口初始化

接口与类初始化的不同

初始化的顺序

数组与被动使用


JVM的生命周期

  • 程序正常执行结束
  • 调用System.exit()方法
  • 程序执行过程中出现异常而终止(没有使用try...catch捕获异常,致使异常最终被抛给JVM)
  • 由于操作系统出现错误而导致JVM进程终止

 

类加载机制

JVM把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被JVM直接使用的Java类型,这就是JVM的类加载机制。

 

类的生命周期

类从被JVM加载到内存中开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载7个阶段。其中验证、准备、解析3个阶段统称为连接。具体可以参考下图:

本文主要讲述类的加载、连接和初始化3个阶段。

 

类的加载、连接、初始化

类的加载、连接、初始化就是类加载的全过程,而这些都是在程序的运行期完成的。

  • 加载:将类的Class文件加载到内存中
  • 连接
    1. 验证:确保加载Class文件的正确性,没有被篡改
    2. 准备:为类的静态变量分配内存,并将其初始化为默认值
    3. 解析:将类中的符号引用替换为直接引用
  • 初始化:为类中的静态变量赋予给定的初始值

类初始化阶段的为静态变量赋初始值,有两种方式:

1、显式直接为静态变量赋值

class Demo {
    static String str = "hello";
}

2、在静态代码块中为静态变量赋值

class Demo {
	static String str;
	
	static {
		str = "hello";
	}
}

根据上述的第二种情况,我们可以推导出:执行了static代码块中的代码就可以证明该类初始化了

 

类的使用方式

java程序对类的使用可以分为两种方式:主动使用和被动使用。

主动使用

  1. 创建类的实例
  2. 访问某个类、接口的静态变量,或者对该静态变量赋值
  3. 调用类的静态方法
  4. 反射
  5. 初始化一个类的子类
  6. JVM启动时被标记为启动类的类(包含main()方法)
  7. 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才会初始化它

从上面的对类初始化时机的描述中,我们可以得出这样的结论:

  1. 首次使用的类才会被初始化
  2. 主动使用的类才会被初始化
  3. 类的初始化只会进行一次

反编译与字节码指令

现在我们来试着反编译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所证明的是主动使用的第五种情况——初始化一个类的子类,该类会被初始化。但这条规则不适用于接口,具体体现在下面两点:

  1. 在初始化一个接口的时候,并不会先初始化它的父接口
  2. 在初始化一个类的时候,并不会先初始化它所实现的接口

我们先来看第一种情况,例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:创建一个多维数组(基本类型和引用类型数组均使用此指令)。

 

参考:

https://juejin.im/post/5a810b0e5188257a5c606a85

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值