JVM类加载子系统
JVM架构概述
JVM包含三大部分,分别是类加载子系统、运行时数据区、执行引擎,我们本次关注的就是其中的第一部分:类加载子系统。
完整图如下
类加载器子系统
类加载器子系统负责从文件系统或者网络中加载Class文件,Class文件在文件开头有特定的文件标识。ClassLoader只负责Class文件的加载,至于它是否可以运行,则由执行引擎Execution Engine决定。
加载的类信息存放于一块称为方法区
的内存空间。除了类信息外,方法区中还会存放运行时常量池信息,可能还包括字符串字面量和数字常量(这部分常量信息是Class文件中常量池部分的内存映射)。
类加载完整过程
例如下面的一段简单的代码,它的加载过程是怎么样的呢?
1 | public class HelloLoader { |
完整的流程图如下所示:
加载阶段
通过一个类的全限定名获取定义此类的二进制字节流
,将这个字节流所代表的静态存储结构
转化为方法区的运行时数据结构
,在内存中生成一个代表这个类的Class对象
(存在Java堆
中),作为方法区这个类的各种元数据的访问入口。
- 可以从本地系统直接加载字节码文件
- 可以从压缩包中读取如Jar、War包
- 运行时计算生成,使用最多的是字节码技术
- 从加密文件中获取,典型的防Class文件被反编译的保护措施
链接阶段
验证Verify
目的在于确保Class文件的字节流中包含信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身安全。主要包括四种验证,文件格式验证
,元数据验证
,字节码验证
,符号引用验证
。
准备Prepare
为类变量即静态变量
分配内存并且设置该类变量的默认初始值
,即零值。
1 | public class HelloPrepare { |
上面的类变量a在准备阶段会赋初始值,但不是10,而是0。这里不包含用final修饰的类变量b,因为final在编译的时候就会分配了,准备阶段会显式初始化为20。
这里不会为实例变量分配初始化,因为类变量会分配在方法区中,而实例变量是会随着对象一起分配到Java堆中。
解析Resolve
将常量池内的符号引用
转换为直接引用
的过程。符号引用就是一组符号来描述所引用的目标。符号引用的字面量形式明确定义在《Java虚拟机规范》的Class文件格式中。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等,对应常量池中的CONSTANT Class info、CONSTANT Fieldref info、CONSTANT Methodref info等。
初始化阶段
初始化阶段就是执行类构造器方法<clinit>
的过程,此方法不需定义,是Javac编译器自动收集类中的所有类变量的赋值动作
和静态代码块
中的语句合并
而来。构造器<clinit>
方法中指令按语句在源文件中出现的顺序执行。
也就是说,当我们代码中包含static变量的时候,就会有<clinit>方法。虚拟机必须保证一个类的<clinit>方法在多线程下被同步加锁。
<clinit>()不同于类的构造器<init>(),若该类具有父类,JVM会保证子类的<clinit>()执行前,父类的<clinit>()已经执行完毕。
1 | public class HelloInitialization { |
类加载器的分类
关于ClassLoader
它负责将Class的字节码形式转换成内存形式的Class对象。字节码可以来自于磁盘文件*.class,也可以是jar包里的*.class,也可以来自远程服务器提供的字节流,字节码的本质就是一个字节数组byte[],它有特定的复杂的内部格式
。
有很多字节码加密技术
就是依靠定制ClassLoader
来实现的。先使用工具对字节码文件进行加密,运行时使用定制的ClassLoader先解密文件内容再加载这些解密后的字节码。
每个Class对象的内部都有一个classLoader字段来标识自己是由哪个ClassLoader加载的。ClassLoader就像一个容器,里面装了很多已经加载的Class对象。
1 | class Class<T> { |
JVM运行并不是一次性加载所需要的全部类的,它是按需加载
,也就是延迟加载。程序在运行的过程中会逐渐遇到很多不认识的新类,这时候就会调用ClassLoader来加载这些类。加载完成后就会将Class对象存在ClassLoader里面,下次就不需要重新加载了。
比如你在调用某个类的静态方法时,首先这个类肯定是需要被加载的,但是并不会触及这个类的实例字段,那么实例字段的类别Class就可以暂时不必去加载,但是它可能会加载静态字段相关的类别,因为静态方法会访问静态字段。而实例字段的类别需要等到你实例化对象的时候才可能会加载。
JVM支持两种类型的类加载器,分别为启动类加载器(Bootstrap ClassLoader)
和自定义类加载器(User-Defined ClassLoader)
。
从概念上来讲,自定义类加载器一般指的是程序中由开发人员自定义的一类类加载器,但是Java虚拟机规范却没有这么定义,而是将所有派生于抽象类ClassLoader
的类加载器都划分为自定义类加载器。
无论类加载器的类型如何划分,在程序中我们最常见的类加载器始终只有3个,如下所示:
这里的四者之间是包含关系,不是上层和下层,也不是子系统的继承关系。
1 | public class HelloClassLoader { |
从结果可以看出启动类加载器无法直接通过代码获取
,同时目前用户代码所使用的加载器为系统类加载器。同时我们通过获取String类的类加载器,发现是null,那么说明String类型是通过启动类加载器进行加载的,也就是说Java的核心类库都是使用启动类加载器进行加载的。
获取ClassLoader的途径:
- 获取当前ClassLoader:clazz.getClassLoader()
- 获取当前线程上下文的ClassLoader:Thread.currentThread().getContextClassLoader()
- 获取系统的ClassLoader:ClassLoader.getSystemClassLoader()
- 获取调用者的ClassLoader:DriverManager.getCallerClassLoader()
启动类加载器
BootstrapClassLoader负责加载JVM运行时核心类
,这些类位于JAVA_HOME/lib/rt.jar文件中,我们常用内置库java.xxx.*都在里面,比如java.util.*、java.io.*、java.nio.*、java.lang.* 等等。这个ClassLoader比较特殊,它是由C代码实现的,我们将它称之为「根加载器」。
1 | // 获取BootstrapClassLoader能够加载的API的路径 |
扩展类加载器
ExtensionClassLoader负责加载JVM扩展类
,比如swing系列、内置的js引擎、xml解析器等等,这些库名通常以javax开头,它们的jar包位于JAVA_HOME/lib/ext/*.jar中,有很多jar包。
系统类加载器
AppClassLoader才是直接面向我们用户的加载器,它会加载classpath环境变量
里定义的路径中的jar包和目录。我们自己编写的代码以及使用的第三方jar包通常都是由它来加载的。
那些位于网络上静态文件服务器提供的jar包和class文件,Jdk内置了一个URLClassLoader,用户只需要传递规范的网络路径给构造器,就可以使用URLClassLoader来加载远程类库了。URLClassLoader不但可以加载远程类库,还可以加载本地路径的类库,取决于构造器中不同的地址形式
。ExtensionClassLoader和AppClassLoader都是URLClassLoader的子类,它们都是从本地文件系统里加载类库。
AppClassLoader可以由ClassLoader类提供的静态方法getSystemClassLoader()得到,它就是我们所说的「系统类加载器」,我们用户平时编写的类代码通常都是由它加载的。当我们的main方法执行的时候,这第一个用户类的加载器就是AppClassLoader。
用户自定义类加载器
在Java的日常应用程序开发中,类的加载几乎是由上述3种类加载器相互配合执行的,在必要时,我们还可以自定义类加载器,来定制类的加载方式。 为什么要自定义类加载器?
隔离加载类
修改类加载的方式
扩展加载源
防止源码泄漏
ClassLoader的传递性
程序在运行过程中,遇到了一个未知的类,它会选择哪个ClassLoader来加载它呢?虚拟机的策略是使用调用者Class对象的ClassLoader来加载当前未知的类
。何为调用者Class对象?就是在遇到这个未知的类时,虚拟机肯定正在运行一个方法调用(静态方法或者实例方法),这个方法挂在哪个类上面,那这个类就是调用者Class对象。前面我们提到每个Class对象里面都有一个classLoader
属性记录了当前的类是由谁来加载的。
因为ClassLoader的传递性,所有延迟加载的类都会由初始调用main方法的这个ClassLoader全全负责,它就是AppClassLoader。
双亲委派机制
这三个ClassLoader之间形成了级联的父子关系,每个ClassLoader都很懒,尽量把工作交给父亲做,父亲干不了了自己才会干。每个ClassLoader对象内部都会有一个parent属性指向它的父加载器。
1 | class ClassLoader { |
值得注意的是图中的 ExtensionClassLoader的parent指针画了虚线,这是因为它的parent的值是 null,当parent字段是null时就表示它的父加载器是「根加载器」。如果某个Class对象的classLoader属性值是null,那么就表示这个类也是「根加载器」加载的。
1 | // 伪代码 |
结合上面的源码,简单总结一下双亲委派模型的执行流程:
- 在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载(每个父类加载器都会走一遍这个流程)。
- 类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成(调用父加载器
loadClass()
方法来加载类)。这样的话,所有的请求最终都会传送到顶层的启动类加载器BootstrapClassLoader
中。 - 只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载(调用自己的
findClass()
方法来加载类)。 - 如果子类加载器也无法加载这个类,那么它会抛出一个
ClassNotFoundException
异常。
双亲委派工作原理
- 如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类加载器去执行;
- 如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器;
- 如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。
双亲委派机制举例
当我们加载jdbc.jar
用于实现数据库连接的时候,首先我们需要知道的是jdbc.jar
是基于SPI接口
进行实现的,所以在加载的时候,会进行双亲委派,最终从启动类加载器
中加载SPI核心类
,然后再加载SPI接口实现类,接着进行反向委派,通过线程上下文类加载器进行实现类jdbc.jar
的加载。
双亲委派机制优势
双亲委派模型保证了 Java 程序的稳定运行,可以避免类的重复加载(JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类),也保证了 Java 的核心 API 不被篡改。
如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为 java.lang.Object
类的话,那么程序运行的时候,系统就会出现两个不同的 Object
类。双亲委派模型可以保证加载的是 JRE 里的那个 Object
类,而不是你写的 Object
类。这是因为 AppClassLoader
在加载你的 Object
类时,会委托给 ExtClassLoader
去加载,而 ExtClassLoader
又会委托给 BootstrapClassLoader
,BootstrapClassLoader
发现自己已经加载过了 Object
类,会直接返回,不会去加载你写的 Object
类。
在JVM中表示两个Class对象是否为同一个类存在两个必要条件:
- 类的
完整类名
必须一致,包括包名。- 加载这个类的
ClassLoader
必须相同。换句话说,在JVM中,即使这两个类对象(Class对象)来源同一个Class文件,被同一个虚拟机所加载,但只要加载它们的ClassLoader实例对象不同,那么这两个类对象也是不相等的。
JVM必须知道一个类型是由启动类加载器加载的还是由用户类加载器加载的。如果一个类型是由用户类加载器加载的,那么JVM会将这个类加载器的引用作为类型信息的一部分保存在方法区中。当解析一个类型到另一个类型的引用的时候,JVM需要保证这两个类型的类加载器是相同的。
如何打破双亲委派
自定义加载器的话,需要继承 ClassLoader
。如果我们不想打破双亲委派模型,就重写 ClassLoader
类中的 findClass()
方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写 loadClass()
方法。重写 loadClass()
方法之后,我们就可以改变传统双亲委派模型的执行流程。例如,子类加载器可以在委派给父类加载器之前,先自己尝试加载这个类,或者在父类加载器返回之后,再尝试从其他地方加载这个类。具体的规则由我们自己实现,根据项目需求定制化。
我们比较熟悉的 Tomcat 服务器为了能够优先加载 Web 应用目录下的类,然后再加载其他目录下的类,就自定义了类加载器 WebAppClassLoader
来打破双亲委托机制。这也是 Tomcat 下 Web 应用之间的类实现隔离的具体原理。