我理解下的的虚拟机类加载机制

xiaoxiao2021-02-28  22

引言

工作中,绝大多数我们多只关心业务逻辑实现,对于实现业务的类的生命周期并没有怎么关注。在我们实现业务逻辑时,不知不觉用到最多的就是类的生命周期中的“使用”阶段。

曾经我也试着去看关于类加载的一些博客或者虚拟机相关的书籍,初看时一知半解,文字上能懂得七七八八,但是真正的含义却知甚微。经过对基础知识的积累,再次回头看,一阵顿悟。虽然有些还是不能完全理解,但确实感觉到了很大的进步,特此记录。若有理解错误之处,请指出。

类加载机制的含义

首先,关于虚拟机的类加载机制的含义。

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

这句话不长,以前看这句话时,眼前只有文字,脑子里没有画面。现在看时,仿佛看到了一幅动态图。我们知道,编写的是以.java结尾的源文件。但虚拟机并不能直接读这个源文件,这就需要编译器将其编译成字节码文件.class。

我们知道java有个著名的宣传口号:一次编译,到处运行。这是因为各种不同的虚拟机和平台都统一使用程序存储格式——字节码(ByteCode),而虚拟机执行的正是这种字节码。

下面的讲解,其实都是围绕虚拟机的类加载机制展开的。

类加载过程

从类被加载到内存,直到被卸载出内存,类的生命周期包括这7个阶段:加载、验证、准备、解析、初始化、使用、卸载。其中验证、准备、解析又被统称为连接。

首先要明确一点,“加载”只是“类加载”其中一个阶段,类加载包括了加载、验证、准备、解析以及初始化这5几个阶段,下面以这5个阶段展开。

加载

通过虚拟机类加载机制的含义可以知道,这个阶段就是“虚拟机把描述类的数据从Class文件加载到内存”。那么加载阶段具体做了些什么呢?

通过一个类的全限定名来获取定义此类的二进制字节流;将这个字节流所代表的的静态存储结构转化为方法区的运行时数据结构;在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的入口。

在我们进行传统的JDBC编程时,我们注册驱动时,会通过下面的代码实现:

Class.forName("com.mysql.jdbc.Driver");

相必大家都知道这个是用来注册mysql的驱动,这是JDBC编程的第一步,至于这里面做了啥,貌似都不怎么清楚。

具体分析下:

我们看上面第1点说到的“类的全限定名”,这里“com.mysql.jdbc.Driver”就是mysql驱动的全限定名;而“二进制字节流”可以认为是Class文件存在的一种形式;

java源文件中存在的类变量,也就是static修饰的变量,经过编译后的Class文件,现在存在于字节流中,此时转化为方法区的运行时数据结构了。我们经常听说,静态变量存储于方法区中,就是这时候处理的,是不是有点顿悟的感觉?(可能JVM又有了新的规定,但不在考虑范围内)

关于第3点,我们在利用反射获取对象信息时,(可参见:反射机制基础解析)一般这么处理过:

public static void main(String[] args) throws Exception { // 获取Class对象 Class<?> clz = Class.forName("test.MyReflectTest"); // 获取实例 Object o = clz.newInstance(); // 获取构造器 Constructor[] cons = clz.getConstructors(); // 获取方法 Method[] methods = clz.getMethods(); }

生成了一个Class对象,并且这个对象是个入口,能获取实例,构造器、方法等等。 这就是类加载的第一个阶段,我算是明白很多了。

验证

验证阶段是连接的第一步,验证也就是校验,看看是否合法。也就是为了确保加载进来的Class文件的字节流中包含的信息是否符合当前虚拟机的要求,这和我们平时写程序时,对传入的参数进行校验道理是一样的。

校验的内容有很多:文件格式、元数据、字节码、符号引用等。要想了解详情,看书吧。

准备

先看个例子,别看答案,这两种情况下将输出什么结果,猜猜吧!

package com.example; public class StaticTest { public static Test t = new Test(); // #0 public static int numOne = 0; // #1 public static int numTwo; // #2 public static void main(String[] arg) { System.out.println(StaticTest.numOne); System.out.println(StaticTest.numTwo); } } class Test { public Test() { StaticTest.numOne++; StaticTest.numTwo++; } }

稍稍改动下:#2处变动为:

public static int numTwo = 2; // #2

 想  好  答  案  了  么  ?  答  案  即  将  揭  晓  .  .  . 修改前输出的结果:

0 1

修改后输出的结果:

0 2

准备阶段之前,只进行2个动作:

将Class文件加载到内存;对Class文件进行校验。

而这一阶段主要是:为类变量(static)分配内存并设置类变量初始值。

分配内存没什么好说的,在加载阶段的第2步中,

将这个字节流所代表的的静态存储结构转化为方法区的运行时数据结构。

既然已经放到方法区,自然要给它们存储的空间吧,所以为类变量分配所需要的内存。

设置类变量初始值。这里要注意,这里设置的是虚拟机内数据类型的零值。

下表中的是基本数据类型的零值。

数据类型零值数据类型零值int0booleanfalselong0Lfloat0.0fshort(short)0double0.0dchar‘\u0000’referencenullbyte(byte)0

上面的例子中:

public static int numTwo = 2; // #2

在这个“准备阶段”,numTwo设置的初始值就是0了,而numTwo = 2这个赋值动作,在这个阶段并没有进行,而是在程序编译后,存放在一个叫< clinit>()方法的类构造器中,在后面的初始化阶段才会执行。既然要涉及到初始化阶段,等讲到了初始化时,再分析下这个例子,先挂起。

解析

这一阶段的任务是把常量池中的符号引用转换为直接引用,也就是具体的内存地址。在这一阶段,jvm会将所有的类、接口名、字段名、方法名等转换为具体的内存地址。

譬如:我们要在内存中找到一个类里面的一个叫call的方法,显然做不到,但是该阶段,由于jvm已经将call这个名字转换为指向方法区中的一块内存地址了,也就是说我们通过call这个方法名会得到具体的内存地址,也就找到了call在内存中的位置了。

主要包括解析内容有:类或接口的解析,字段的解析,类方法的解析以及接口方法的解析。

初始化

初始化,是类加载的最后一步了。虚拟机规范中有严格的规定:有且仅有5种 情况必须立即对类进行“初始化”:

使用new关键字实例化对象、读取或设置一个类的静态字段(被final修饰、已经在编译器把结果放入常量池的静态字段除外),以及调用一个类的静态方法的时候;使用java.lang.reflect包的方法进行反射调用时,若类没有进行初始化,需要先触发其初始化;当初始化一个类时,若其父类还没有进行初始化,则需要先触发其父类的初始化;执行main方法,虚拟机会先初始化其包含的那个主类;当使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行初始化,则需要先触发其初始化。

我们见到最常见的一种情形就是第1点了。

我们在“准备”阶段提到过类构造器< clinit>()方法,这一阶段的主要操作就是:执行类构造器< clinit>()方法的过程。

public static int numTwo = 2; // #2

准备阶段 赋予 numTwo 初始值为“0”,此阶段会将赋值(初始化)为“2”。

我们对前面的例子debug一下,看看debug的运行路线: 我们观察下,左边红框区域。有两个方法< init>、< clinit>,分别 是实例初始化方法 和 类与接口初始化方法。

你可以亲自debug下,会发现很有意思的情况。其实和上面讲的,对于int数据类型:numOne、numTwo,在准备阶段的值是 0;reference数据类型:t,在准备阶段的值是 null,和前面提到的吻合。

在 3个debug 处,先出现的 第三处,也就是 Test类的构造器 处(准备阶段已经执行完毕了)。我们知道main()方法是程序的入口,这个debug模式也是:右键 ==> ‘StaticTest.main’,但是main()方法中的代码却没有立即执行。换句话说, 这时候还是类加载的过程,也可以说是执行前的准备阶段。

为啥先出现的是第三处debug,前面提到“有且仅有5种”必须立即“初始化”的情况之一:当发生了new动作,读取或设置静态字段。

所以在Test类的构造器中通过 ++, 将 numOne、numTwo 的值设置为“1, 1”,然后经过第3处,numOne的值初始化为 0, 而 numTwo因为没有显示设置初始值,所以这里还是 1,结果就是“0 1”了。

而修改后由于 numTwo 显示地设置了初始值为“2”,所以结果就成了“0 2”。

我们再稍微调整下代码的顺序,其他不变;

public static int numOne = 0; // #1 public static int numTwo = 2; // #2 public static Test t = new Test(); // #0

结果是啥?应该不难判断

1 3

这里想说的是,< clinit>方法是由编译器自动收集类中的所有类变量的赋值动作(numTwo = 2)和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的。 所以这里执行的顺序是: #1 => #2 => #0。

类加载器

在上面的类生命周期的第一阶段:加载,提到:

通过一个类的全限定名来获取定义此类的二进制字节流

实现这个动作的代码模块称之为“类加载器”。

我们在比较两个类是否相等时,前提是这两个类是用同一个类加载器加载进来的,这个在逻辑上还是很好理解的。

分类

从Java虚拟机的角度来讲,分为两类类加载器:

启动类加载器(Bootstrap ClassLoader),由C++语言实现,是虚拟机自身的一部分;其他类加载器,由Java语言实现,独立于虚拟机外部,并且都 继承自抽象类java.lang.ClassLoader。

从我们开发人员角度来看,一般把类加载器分为4种:启动类加载器、扩展类加载器、应用程序类加载器和自定义类加载器。

启动类加载器(Bootstrap ClassLoader),负责将< JAVA_HOME>\lib目录下的,或者被 -Xbootclasspath参数指定的路径,并且是虚拟机识别的类库加载到虚拟机内存中;

扩展类加载器(Extension ClassLoader),负责将< JAVA_HOME>\lib\ext目录下的,或者被java.ext.dirs系统变量指定的类库;

应用程序类加载器(Application ClassLoader),由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,因此也称之为 系统类加载器;负责加载用户类路径(ClassPath)上指定的类库;一般这个就是程序中默认的类加载器。

自定义类加载器,若需要自定义一个类加载器,只需要模仿前面的类加载器,继承自抽象类java.lang.ClassLoader,并覆盖findClass方法即可。

双亲委派模式

原理

双亲委派模式要求,除了顶层的启动类加载器外,其他的类加载器都应该有自己的父类加载器。

其工作过程:

如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,依次向上。按照模型图,最后都会委派到启动类加载器那。若父类加载器在它的搜索范围内,没有找到这个类时,会向下反馈无法完成这个委派请求,此时子类加载器才会尝试自己去加载。

之所以这么做,有一个很明显的好处。我们都知道 java.lang.Object是所有类的父类,也即 超类,它放在lib\rt.jar中。无论编写的是哪一个类,都需要去加载这个超类,这时候都委派给顶层的启动类加载器去加载,因此这个Object类在各种类加载器环境中都是同一个类。若不采用双亲委派模型,那么我们自己编写这样一个Object类,放在ClassPath中,这样就会出现多个Object类,就乱了。

模型实现

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); // #1 if (c == null) { long t0 = System.nanoTime(); try { if (parent != null) { c = parent.loadClass(name, false); // #2 } else { c = findBootstrapClassOrNull(name); // #3 } } catch (ClassNotFoundException e) { // #4 // 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); // #5 // 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; } }

这段代码主要讲:

#1 先检查是够已经被加载过; #2 若没有加载则调用父加载器的loadClass()方法; #3 若父加载器为空,则默认使用启动类加载器作为父类加载器加载; #4 若父类加载失败了,抛出异常ClassNotFoundException; #5 再调用自身的findClass()方法来加载。

这些就是我所理解的虚拟机类加载机制,还有很多细节需要再研究研究,未完待续…

参考:《深入理解java虚拟机》

转载请注明原文地址: https://www.6miu.com/read-2650273.html

最新回复(0)