JVM的类加载机制
整体来看,JVM体系分为三个部分:类加载器(Class Loader),运行时数据区(Runtime Data Area),和执行引擎(Execution Engine),这里主要探讨一下 JVM 的类加载器
一 . 类的生命周期
先通过一个图对类加载流程有个整体认识,如下图
类从被加载到虚拟机内存中开始,直到卸载出内存为止,它的整个生命周期包括了:加载、验证、准备、解析、初始化、使用和卸载这7个阶段。其中,验证、准备和解析这三个部分统称为连接(linking)。
其中,加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班的“开始”(仅仅指的是开始,而非执行或者结束,因为这些阶段通常都是互相交叉的混合进行,通常会在一个阶段执行的过程中调用或者激活另一个阶段),而解析阶段则不一定(它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定。
1. 加载
加载是类加载的第一阶段,在加载阶段虚拟机需完成以下三件事
1、 通过一个类的全限定名来获取定义此类的二进制字节流。
2、将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
3、 在Java堆中生成一个代表这个类的java.lang.Class对象,作为方法区这些数据的访问入口。
加载阶段既可以使用系统提供的类加载器在完成,也可以由用户自定义的类加载器来完成。这一动作是放在Java虚拟机外部去实现的,以便让应用程序自己决定如何获取所需的类。虚拟机规范并没有指明二进制字节流一定要从一个.class文件获取,也就是根本没有指明二进制字节流从哪里获取、怎样获取。这种开放的形式使得Java在很多领域得到充分运用,例如:
1、从ZIP包中读取,成为JAR,WAR格式的基础
2、从网络中获取,典型的应用就是Applet
3、运行时生成,典型的就是动态代理技术
4、有其他文件生成,典型例子的就是jsp,由jsp文件生成对应的Class类
加载阶段与连接阶段的部分内容(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始。加载阶段完成后,虚拟机外部的 二进制字节流就按照虚拟机所需的格式存储在方法区之中,而且在Java堆中也创建一个java.lang.Class类的对象,这样便可以通过该对象访问方法区中的这些数据。
这里有个需要注意的地方,对于后面理解类锁和对象锁至关重要。什么是Class对象?为了方便理解可看下图:
虚拟机为每种类的数据结构管理一个独一无二的Class对象。也就是说,每个类有且只有一个Class对象。运行程序时,Java虚拟机(JVM)首先检查是否所要加载的类对应的Class对象是否已经加载。如果没有加载,JVM就会根据类名查找.class文件,并在堆中生成类的Class对象。所有的类都是在对其第一次使用时,动态加载到JVM中的(并不是启动时全部加载到内存中,而是懒加载)。当程序创建第一个对类的静态成员的引用时,就会加载这个类。使用new创建类对象的时候也会被当作对类的静态成员的引用。因此java程序程序在它开始运行之前并非被完全加载,其各个类都是在必需时才加载的。这一点与许多传统语言都不同。动态加载使能的行为,在诸如C++这样的静态加载语言中是很难或者根本不可能复制的,其中类的Class对象就负责生成类的具体实例对象。再来说一说静态方法和非静态方法同步锁的问题。
非静态同步方法用的是同一把锁——实例对象本身,当多个线程操作堆中的同一个实例对象的同步非静态方法时,持有的是该这个实例对象的锁,其他线程想要执行这个实例对象的其余同步非静态方法时,需等待持有该对象锁的线程释放对象锁。
静态同步方法用的也是同一把锁——类对象本身,当多个线程操作同一个类的同步静态方法时,线程持有的是这个类对象的锁,也就是类的Class对象的锁,其他线程想要执行同一个类的其余同步静态方法时,需等待持有该对象锁的线程释放对象锁。
注意:多线程可以同时操作一个类的非静态同步方法和静态同步方法,因为他们的锁对象不一样,不会互斥。线程部分后续会详细讨论,这里做简要说明。
2. 连接
(1). 验证
验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
Java语言本身是相对安全的语言。但是,Class文件并不一定是由Java源码编译而来,可以使用任何途径,包括用十六进制编辑器(如UltraEdit)直接编写。如果直接编写了有害的“代码”(字节流),而虚拟机在加载该Class时不进行检查的话,就有可能危害到虚拟机或程序的安全。
不同的虚拟机,对类验证的实现可能有所不同,但大致都会完成下面四个阶段的验证:文件格式验证、元数据验证、字节码验证和符号引用验证。
1、文件格式验证,是要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。如验证魔数是否0xCAFEBABE;主、次版本号是否正在当前虚拟机处理范围之内;常量池的常量中是否有不被支持的常量类型。该验证阶段的主要目的是保证输入的字节流能正确地解析并存储于方法区中,经过这个阶段的验证后,字节流才会进入内存的方法区中存储,所以后面的三个验证阶段都是基于方法区的存储结构进行的。
2、元数据验证,是对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求。可能包括的验证如:这个类是否有父类;这个类的父类是否继承了不允许被继承的类;如果这个类不是抽象类,是否实现了其父类或接口中要求实现的所有方法……
3、字节码验证,主要工作是进行数据流和控制流分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为。如果一个类方法体的字节码没有通过字节码验证,那肯定是有问题的;但如果一个方法体通过了字节码验证,也不能说明其一定就是安全的。
4、符号引用验证,发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在“解析阶段”中发生。验证符号引用中通过字符串描述的权限定名是否能找到对应的类;在指定类中是否存在符合方法字段的描述符及简单名称所描述的方法和字段;符号引用中的类、字段和方法的访问性(private、protected、public、default)是否可被当前类访问
(2). 准备
为类的静态变量分配内存,并将其初始化为默认值。准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。但有几点需要注意:
1、这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在Java堆中
2、这里所设置的初始值通常情况下是数据类型默认的零值(如0、0L、null、false等),而不是被在Java代码中被显式地赋予的值。
举个例子:public static int value=123; 在准备阶段value初始值为0 。在初始化阶段才会变为123 。但是需要注意的是,public static final int value=123; 在准备阶段value就会直接被赋值为123,当使用类名点final修饰的静态变量时,不会执行类的初始化。下面用代码来说明一下。
1 | public class Car { |
当引用类的静态变量时
1 | public class Test { |
得到的结果:
1 | 类初始化 |
结果很明显,引用静态变量,会先初始化类。
当引用类的静态常量是
1 | public class Test { |
得到的结果:
1 | 666 |
结果在引用类的final修饰的静态常量时,类不会初始化
(3). 解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
符号引用(Symbolic Reference):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。
直接引用(Direct Reference):直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是与虚拟机实现的内存布局相关的,如果有了直接引用,那么引用的目标必定已经在内存中存在。
3 . 初始化
初始化,为类的静态变量赋予正确的初始值,JVM负责对类进行初始化,主要对类变量进行初始化。这个阶段会对static变量赋值,public static int value=123; 在准备阶段value初始值为0 。在这个阶段就会被赋值为123。此外还会执行类的静态代码块 。
Java虚拟机规范中严格规定了有且只有六种情况必须对类进行初始化:
– 创建类的实例,也就是new的方式
– 访问某个类或接口的静态变量,或者对该静态变量赋值
– 调用类的静态方法
– 反射(如Class.forName(“com.lsj.Car”))
– 初始化某个类的子类,则其父类也会被初始化
– Java虚拟机启动时被标明为启动类的类(也就是包含main函数),虚拟机会首先初始化这个类
4 . 卸载
类的生命周期和Class对象是否被引用有关,当代表类的Class对象不再被引用时,Class对象就会结束生命周期,类在方法区内的数据也会被卸载。因此,一个类何时结束生命周期,取决于代表它的Class对象何时结束生命周期。由Java虚拟机自带的类加载器所加载的类,在虚拟机的生命周期中,始终不会被卸载,而用户自定义的类加载器是可以被卸载的
二 . 类加载流程
JVM的类加载机制采用的是双亲委派模型:当某个类需要被加载时,它自己的类加载器首先不会自己去加载,而是把这个工作委托自己的父加载器,直到最顶层的类加载器也无法加载此类时,才会尝试自己去加载此类。双亲委派模型如下图所示:
先来看一个例子:
1 | public class Test { |
运行结果:
1 | sun.misc.Launcher$AppClassLoader@18b4aac2 |
首先说明一下三个类加载器的作用
Bootstrap ClassLoader :
启动类加载器主要加载核心类库,加载的是%JAVA_HOME%\jre\lib下的jar包,采用native code实现,是JVM的一部分,底层由C++实现,源码在 JVM 的内核中,当 JVM 启动后,随之会启动Bootstrap ClassLoader。无法被java代码直接引用。在 JVM 启动时指定-Xbootclasspath来改变Bootstrap ClassLoader加载目录
Extention ClassLoader:
扩展类加载器加载目录%JAVA_HOME%\jre\lib\ext目录下的jar包,可通过-D java.ext.dirs属性改变加载目录
Application ClassLoader:
加载应用程序中classpath里指定的所有类
从运行结果可以看出加载Test类的类加载器是AppClassLoader,它的父加载器是ExtClassLoader,但是ExtClassLoader父加载器却不是Bootstrap ClassLoader,此外不要被这种父子关系误导,查看AppClassLoader和ExtClassLoader的源码时,你会发他们几个根本没有继承关系,也就是说应用类加载器AppClassLoader不是通过extend关键字继承ExtClassLoader,为什么AppClassLoader的getParent()方法得到的是ExtClassLoader呢?它们两个到底又是什么关系?这还得从源码中寻找答案
1 | public class Launcher { |
首先得从这个类开始,为了方面阅读,省略了部分代码,从源码可以看出ExtClassLoader和AppClassLoader是以静态内部类的形式声明的。而Launcher是创建类加载器的启动类。从Launcher构造函数中可以看出,首先会创建ExtClassLoader,然后会创建AppClassLoader,然后将AppClassLoader设置为线程上下文类加载器。这里我需要关注的是上面代码的21行,this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);这行代码的getAppClassLoader方法是创建AppClassLoader,同时将var1作为参数传了进去。通过代码发现var1其实就是前面创建的ExtClassLoader。那么就得看看getAppClassLoader方法做了什么事情了。
1 | static class AppClassLoader extends URLClassLoader { |
1 | static class ExtClassLoader extends URLClassLoader { |
上面是AppClassLoader和ExtClassLoader类的源码,在这两个类中并没有getParent()方法,这个先放一边。我们继续关注AppClassLoader的getAppClassLoader方法,在这个方法中唯一的的参数就是var0,而这个var0就是传进来的ExtClassLoader,最后这个传给了AppClassLoader的构造方法。而在构造方法中又传给了父类的构造方法中。而ExtClassLoader的构造方法中也是调用父类的构造方法,但与AppClassLoader调用父类构造方法不同的是,它的第二个参数传的是null。接下来继续看父类的源码如下
1 | public class URLClassLoader extends SecureClassLoader implements Closeable { |
在AppClassLoader构造方法中super(var1, var2, Launcher.factory);调用的是父类的构造,如上代码,在这个构造中,parent参数又传给了父类,继续刨根问底,看看SecureClassLoader的源码
1 | public class SecureClassLoader extends ClassLoader { |
SecureClassLoader的构造函数中parent参数继续传给了父类。再来看看SecureClassLoader的父类ClassLoader的源码
1 | public abstract class ClassLoader { |
通过源码可以发现ClassLoader中有三个构造函数其中两个protected修饰,而另外一个是private修饰的私有构造方法,是在SecureClassLoader的构造函数中的super(parent);代码调用的是ClassLoader的protected的有参构造方法,而在这个方法中调用了私有构造方法,在私有构造方法中,一路从传过来的parent变量被赋值给了一个静态常量。而且getParent()也在这个类中,这个方法返回的就是parent常量。
到这里一切都明白了,当它的子类调用getParent()方法时,实际上返回的是这个parent常量,这个常量是子类构造方法中传进来的。而Launcher创建类加载器时,先创建了ExtClassLoader,这个类的构造函数中parent属性传的是null,因此它调用getParent()得到的是null,创建AppClassLoader,这个类的构造是把ExtClassLoader作为parent属性传给父类,因此它调用getParent()得到的是ExtClassLoader。这几个类的类图如下
那Bootstrap ClassLoader和ExtClassLoader又是什么关系呢,我们来看一下双亲委派模型的源码
1 | protected Class<?> loadClass(String name, boolean resolve) |
这个方法在ClassLoader类中,因此parent就是子加载器创建时指定的父加载器。通过这段核心代码。很容易就能明白了双亲委派的类加载流程。首先通过parent去加载Class,如果parent为null,则由Bootstrap ClassLoader去加载Class。
三 . 自定义类加载器
jdk自带的三大类加载器都是加载特定目录下的资源文件。但有时我们有些特定的需求,比如我想从某个特定的磁盘或者是网络上加载.class文件,为了安全考虑还会对.class文件加密,然后再解密加载进 JVM 中。要想做到这些,只有自定义ClassLoader了。自定义类加载器三步曲
- 继承ClassLoader抽象类。
- 重写findClass() 方法。
- 在 findClass() 方法中调用 defineClass() 。
说明: findClass()方法中通过IO找到.class文件, findClass()会把.class文件二进制内容转换成Class对象。自定义的ClassLoader如果不指定parent,那么默认parent就是AppClassLoader。
下面来看一个例子,编写一个测试类,生成.class文件,并把.class文件放在 E:\\test 目录下(这里我写了两个)
Car测试类
1 | package com.lsj.test; |
Person测试类
1 | package com.lsj.test; |
然后编写自定义的类加载器,代码如下:
1 | public class MyClassLoader extends ClassLoader { |
最后编写测试类
1 | public class ClassLoaderTest { |
最后的测试结果如下:
1 | 类初始化 |
在测试类中分别调用了Car的 run() 方法和Person的 say() 方法,执行结果和预期一样。这里需要注意的是在调用ClassLoder的loadClass方法时,需要指定类的全包路径。
刚刚说道可以把class文件加密后再在代码中解密在进行加载。首先需要编写一个加密的工具类,这里我用的就是最简单的异或,我们都知道一个数字经过两次异或后得到的结果就是它自己。加密工具类代码如下
1 | package com.lsj.classloader; |
这里我把class文件经过异或加密后生成了.txt文件。在加载时,先把文件解密,在进行类加载。下面是自定义解密的ClassLoader类
1 | public class MyClassLoaderCode extends ClassLoader{ |
然后是测试类
1 | public class ClassLoaderTest { |
得到的结果
1 | class文件加载成对象 |
打赏一个呗