计算机系统应用教程网站

网站首页 > 技术文章 正文

JVM类加载机制—类的生命周期

btikc 2024-09-08 11:59:11 技术文章 20 ℃ 0 评论

虚拟机把描述类的数据文件(字节码)加载到内存,并对数据进行验证、准备、解析以及类初始化,最终形成可以被虚拟机直接使用的java类型(java.lang.Class对象),这就是java虚拟机的类加载机制。——《 深入理解java虚拟机》

类的生命周期

从类被加载进内存开始直到卸载出内存为止,类的生命周期包括 装载、验证、准备、解析、初始化、使用和卸载 7个过程,其中 验证、准备、解析 三个过程统称为链接。

这7个过程会按顺序开始,但无需等到上一个过程结束下一个过程才能开始运行,实际上他们通常都是相互交叉混合着运行。

在Java中,类的加载和链接过程都是在程序运行期间完成的。另外,Java可以动态扩展的语言特性就是依赖运行期间动态加载、动态链接这个特点实现的。

接下来我们详细了解下类的整个生命周期

一、装载(加载)

在加载阶段,虚拟机完成3件事:

  1. 通过一个类的全限定名来获取定义此类的二进制字节流。
  2. 二进制字节流除了从Class文件中获取,还可以从哪些地方获取?
从ZIP或jar包中读取
从网络中获取
运行时计算生成(Java动态代理技术)
由其他文件生成(由JSP文件生成对应的Class类)
从数据库中读取
复制代码
  1. 加二进制字节流存储在方法区中(按照虚拟机所需的格式存储)。
  2. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。Class对象比较特殊,存放在方法区中(HotSpot虚拟机)

二、验证

验证的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。 一般包括两个方面:

  1. 格式语义校验:
例如:
1.是否以0xCAFEBASE开头
2.主、次版本号是否在当前虚拟机处理范围内
复制代码
  1. 代码逻辑校验

三、准备

准备阶段正式为静态变量分配内存并设置初始值,这些静态变量在方法区中分配内存。

注意:

  1. 准备阶段,JVM只会为静态变量(static修饰)分配内存,不包括实例变量,实例变量将会在对象实例化时随对象一起分配在Java堆中。
准备阶段,只会为value分配内存,不会为name分配内存
public static int value = 123;
private String name = "Tom";
复制代码
  1. 设置初始值:
  • static修饰的变量(无final):零值或null

准备阶段,未执行任何Java方法,而value赋值为123指令是程序编译后,存放于类构造器方法中,在初始化阶段才会执行,因此准备阶段,会设置零值。

准备阶段,会设置零值
public static int value = 123;
复制代码
  • static final修饰的常量:实际值
常量,准备阶段会设置实际值123
public static final int value = 123;
复制代码

注意:static final修饰的基本数据类型或者String会在javac编译时生成ConstantValue属性,在类加载的准备阶段直接把ConstantValue的值赋给该字段。可以理解为在编译期即把结果放入了常量池中。所以当A类调用B类的static final字段(基本数据类型或者String),不会触发B类的加载。

四、解析

解析阶段是虚拟机将常量池中的符号引用转化为直接引用的过程(在某些情况下可以在初始化阶段之后开始) 解析动作主要针对类或接口、字段、类方法、接口方法四类符号引用进行,分别对应于常量池中的 CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info 四种常量类型。

1、类或接口的解析:判断所要转化成的直接引用是对数组类型,还是普通的对象类型的引用,从而进行不同的解析。

2、字段解析:对字段进行解析时,会 先在本类 中查找是否包含有简单名称和字段描述符都与目标相匹配的字段,如果有,则查找结束;如果没有,则会按照继承关系递归搜索该类所实现的各个接口和它们的父接口,还没有,则按照继承关系递归搜索其父类,直至查找结束.

3、类方法解析:对类方法的解析与对字段解析的搜索步骤差不多,只是多了判断该方法所处的是类还是接口的步骤,而且对类方法的匹配搜索,是先搜索父类,再搜索接口。

4、接口方法解析:与类方法解析步骤类似,只是接口不会有父类,因此,只递归向上搜索父接口就行了。

看如下例子:

class Super{
	public static int m = 11;
	static{
		System.out.println("执行了super类静态语句块");
	}
}
 
class Father extends Super{
	public static int m = 33;
	static{
		System.out.println("执行了父类静态语句块");
	}
}
 
class Child extends Father{
	static{
		System.out.println("执行了子类静态语句块");
	}
}
 
public class StaticTest{
	public static void main(String[] args){
		System.out.println(Child.m);
	}
}
复制代码

输出结果:

执行了super类静态语句块
执行了父类静态语句块
33
复制代码

为什子类的static块不会执行?

static块是在初始化阶段执行的,而static变量发生在静态解析阶段,也即是初始化之前,此时已经将字段的符号引用转化为了内存引用,也便将它与对应的类关联在了一起,由于在子类中没有查找到与m相匹配的字段,那么m便不会与子类关联在一起,因此并不会触发子类的初始化。
复制代码

同理,如果注释掉Father类中对m定义的那一行,则输出结果如下:

执行了super类静态语句块
11
复制代码

五、初始化

初始化是类加载过程的最后一步,到了此阶段,才真正开始执行类中定义的Java程序代码。 在准备阶段,类变量已经被赋过一次系统要求的初始值,而在初始化阶段,则是根据程序员通过程序指定的主观计划去初始化类变量和其他资源 ,或者可以从另一个角度来表达:初始化阶段是执行类构造器 <clinit>() 方法的过程。

这里简单说明下()方法的执行规则:

  1. <clinit>() 方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序是由语句在 源文件中出现的顺序所决定 的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句中 可以赋值,但是不能访问
  2. <clinit>() 方法与实例构造器 <init>() 方法(类的构造函数)不同,它不需要显式地调用父类构造器, 虚拟机会保证在子类的 <clinit>() 方法执行之前,父类的 <clinit>() 方法已经执行完毕。因此,在虚拟机中第一个被执行的 <clinit>() 方法的类肯定是java.lang.Object 。
  3. <clinit>() 方法对于类或接口来说并不是必须的,如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成 <clinit>() 方法。
  4. 接口中不能使用静态语句块,但仍然有类变量(final static)初始化的赋值操作,因此接口与类一样会生成 <clinit>() 方法。但是接口与类不同的是: 执行接口的 <clinit>() 方法不需要先执行父接口的 <clinit>() 方法,只有当父接口中定义的变量被使用时,父接口才会被初始化 。另外,接口的实现类在初始化时也一样不会执行接口的 <clinit>() 方法。
  5. 虚拟机会保证一个类的 <clinit>() 方法在多线程环境中被正确地加锁和同步 ,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的 <clinit>() 方法,其他线程都需要阻塞等待,直到活动线程执行 <clinit>() 方法完毕。如果在一个类的 <clinit>() 方法中有耗时很长的操作,那就可能造成多个线程阻塞,在实际应用中这种阻塞往往是很隐蔽的。

下面给出一个简单的例子,以便更清晰地说明如上规则:

class Father{
	public static int a = 1;
	static{
		a = 2;
	}
}
 
class Child extends Father{
	public static int b = a;
}
 
public class ClinitTest{
	public static void main(String[] args){
		System.out.println(Child.b);
	}
}
复制代码

输出结果:

2
复制代码

看下运行代码后的步骤:

  1. 准备阶段:为类变量分配内存并设置类变量初始值,这样a和b均被赋值为默认值0
  2. 初始化阶段:而后再在调用 <clinit>() 方法时给他们赋予程序中指定的值
我们调用Child.b时,触发Child的<clinit>()方法,根据规则2,在此之前,要先执行完其父类Father的<clinit>()方法,
又根据规则1,在执行<clinit>()方法时,需要按static语句或static变量赋值操作等在代码中出现的顺序来执行相关的
static语句,因此当触发执行Father的<clinit>()方法时,会先将a赋值为1,再执行static语句块中语句,将a赋值为2,
而后再执行Child类的<clinit>()方法,这样便会将b的赋值为2.
如果我们颠倒一下Father类中“public static int a = 1;”语句和“static语句块”的顺序,程序执行后,则会打印出1。
很明显是根据规则1,执行Father的<clinit>()方法时,根据顺序先执行了static语句块中的内容,
后执行了“public static int a = 1;”语句。
另外,在颠倒二者的顺序之后,如果在static语句块中对a进行访问(比如将a赋给某个变量),
在编译时将会报错,因为根据规则1,它只能对a进行赋值,而不能访问。
复制代码

六、使用

使用阶段包括主动引用和被动引用,主动引用会引起类的初始化,而被动引用不会引起类的初始化。

主动引用

jvm有严格的规定, 当且仅当 以下五种情况,也就是 主动引用 ,才会触发类的初始化,除此之外,所有引用类的方法都不会触发初始化!

  1. 遇到new,getstatic,putstatic,invokestatic这4条字节码指令时,假如类还没进行初始化, 则马上对其进行初始化工作。 其实就是3种情况:用new实例化一个类时、读取或者设置类的静态字段时(不包括被final修饰的静态字段, 因为他们已经被塞进常量池了)、以及执行静态方法的时候。
  2. 使用java.lang.reflect.*的方法对类进行反射调用的时候, 如果类还没有进行过初始化,马上对其进行。
  3. 初始化一个类的时候,如果他的父亲还没有被初始化,则先去初始化其父亲。
  4. 当jvm启动时,用户需要指定一个要执行的主类(包含static void main(String[] args)的那个类), 则jvm会先去初始化这个类。
  5. 用Class.forName(String className);来加载类的时候,也会执行初始化动作。
  • 注意:ClassLoader的loadClass(String className);方法只会加载并编译某类,并不会对其执行初始化。

被动引用

  • 引用父类的静态字段,只会引起父类的初始化,而不会引起子类的初始化。
  • 定义类数组,不会引起类的初始化。
  • 引用类的static final常量,不会引起类的初始化(如果只有static修饰,还是会引起该类初始化的)。

被动引用的示例代码:

class InitClass{
	static {
		System.out.println("初始化InitClass");
	}
	public static String a = null;
	public final static String b = "b";
	public static void method(){}
}
 
class SubInitClass extends InitClass{
	static {
		System.out.println("初始化SubInitClass");
	}
}
 
public class Test4 {
 
	public static void main(String[] args) throws Exception{
	//	String a = SubInitClass.a;// 引用父类的静态字段,只会引起父类初始化,而不会引起子类的初始化
	//	String b = InitClass.b;// 使用类的final常量不会引起类的初始化
		SubInitClass[] sc = new SubInitClass[10];// 定义类数组不会引起类的初始化
	}
}
复制代码

当使用阶段完成之后,java类就进入了卸载阶段。

七、卸载

在类使用完之后,如果满足下面的情况,类就会被卸载:

  • 该类所有的实例都已经被回收,也就是java堆中不存在该类的任何实例。
  • 加载该类的ClassLoader已经被回收。
  • 该类对应的java.lang.Class对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法。

如果以上三个条件全部满足,jvm就会在方法区垃圾回收的时候对类进行卸载,类的卸载过程其实就是在方法区中清空类信息,java类的整个生命周期就结束了。

总结

整个类加载过程中,除了在加载阶段用户应用程序可以自定义类加载器参与之外,其余所有的动作完全由虚拟机主导和控制。到了初始化才开始执行类中定义的Java程序代码(亦及字节码),但这里的执行代码只是个开端,它仅限于 <clinit>() 方法。类加载过程中主要是将Class文件(准确地讲,应该是类的二进制字节流)加载到虚拟机内存中,真正执行字节码的操作,在加载完成后才真正开始。

end:如果你觉得本文对你有帮助的话,记得关注点赞转发,你的支持就是我更新动力。

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表