类加载过程详解
类的生命周期
加载、验证、准备、解析、初始化、使用、卸载七个阶段,其中验证、准备、解析这三个阶段可以统称为连接。
类加载过程
系统加载 Class 类型的文件主要三步:加载->连接->初始化。连接过程又可分为三步:验证->准备->解析。
加载
类加载过程的第一步主要完成下面3件事情:
1.通过全类名获取定义此类的二进制字节流
2.将字节流所代表的的静态存储结构转换为方法区的运行时数据结构
3.在内存中生成一个代表该类的Class对象,作为方法区这些数据的访问入口
虚拟机规范上面这3点并不具体,因此是非常灵活的,并没有指明从哪里获取、怎么获取。
加载这一步主要是通过我们后面要讲到的 类加载器 完成的。类加载器有很多种,当我们想要加载一个类的时候,具体是哪个类加载器加载由 双亲委派模型 决定(不过,我们也能打破由双亲委派模型)。
每个Java类都有一个引用指向加载它的ClassLoader
。不过,数组类不是通过ClassLoader
创建的,而是JVM在需要的时候自动创建的,数组类通过getClassLoader()
方法获取ClassLoader
的时候和该数组的元素类型的ClassLoader
是一致的。
一个非数组类的加载阶段(加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,这一步我们可以去完成还可以自定义类加载器去控制字节流的获取方式(重写一个类加载器的loadClass()
方法 )。
加载阶段与连接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未结束,连接阶段可以已经开始了。
验证
验证是连接阶段的第一步,这个阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。
验证阶段这一步在整个类加载过程中耗费的资源还是相对比较多的,但是很有必要,可以有效防止恶意代码的执行。任何时候,程序的安全都是第一位。
不过,验证阶段也不是必须要执行的阶段。如果程序运行的全部代码(包括自己编写的、第三方包中的、从外部加载的、动态生成的等所有代码) 都已经被反复使用和验证过,在生产环境的实施阶段就可以考虑使用-Xverify:none
参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
验证主要由四个检验阶段组成:
1.文件格式验证(Class文件格式检查)
验证字节流是否符合Class文件格式的规范,例如:是否以0XCAFEBABE开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型。
2.元数据验证(字节码语义检查)
对字节码描述的信息进行语义分析,以确保其描述的信息符合《Java语言规范》的要求,例如:这个类是否有父类(除了Java.lang.Object之外所有类都有父类)、这个类是否被继承了不允许继承的类(被final修饰的类)等。
3.字节码验证(程序语义检查)
通过数据流分析和控制流分析,确保程序语义是合法的、符合逻辑的,例如:函数的参数类型是否正确、对象的类型转换是否合理(像父类对象赋值给子类对象类型 就是非法的)。
4.符号引用验证(类的正确性检查)
验证类的正确性。例如:该类要使用其他类、方法、字段是否存在、是否拥有正确的访问权限。
文件格式验证这一阶段是基于该类的二进制字节流进行的,主要目的是保证输入的字节流能正确的解析并存储于方法区之内,格式上符合描述一个Java类型信息的要求。除了这一阶段之外,其余三个验证阶段都是基于方法区的存储结构上进行的,不会再直接读取、操作字节流了。
方法区属于是JVM运行时数据区域的一块逻辑区域,是各个线程共享的内存区域。当虚拟机要使用一个类时,它需要读取并解析Class文件获取相关信息,再将信息存入方法区。方法区会存储已被虚拟机加载的 类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
符号引用验证发生在类加载过程中的解析阶段,具体来说是JVM将符号引用庄华伟直接引用的时候(解析阶段会介绍符号引用和直接引用)
符号引用验证的主要目的是确保解析阶段能正常执行,如果无法通过符号引用验证,JVM会抛出异常。
准备
准备阶段是正式结为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。对于该阶段有以下几点注意:
1.这时候进行内存分配的仅包括类变量(即静态变量,被static关键字修饰的变量,只与类相关,因此被称为类变量),而不包括实例变量。实例变量会在对象实例化时随着对象一块分配在Java堆中。
2.从概念上讲,类变量所使用的内存都应当在方法区进行分配。不过需要注意的一点,JDK7之前,HotSpot使用永久代来实现方法区的时候,实现是完全符合这种逻辑概念的。而在JDK7之后,HotSpot已经把原来放在永久代的字符串常量池、静态变量等移到到堆中,这个时候类变量则会随着Class对象一起存放Java堆中。
3.这里所设置的初始值“通常情况”下是数据类型默认的零值(如0、0L、null、false等),比如我们定义了 public static int value =111
,那么value变量在准备阶段的初始值就是0而不是111(初始化阶段才会赋值)。特殊情况:比如给value变量加上了final关键字public static final int value = 111
,那么准备阶段value的值就被赋值为111。
解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符 7 类符号引用进行。
举个例子:在程序执行方法时,系统需要明确知道这个方法所在的位置。Java 虚拟机为每个类都准备了一张方法表来存放类中所有的方法。当需要调用一个类的方法的时候,只要知道这个方法在方法表中的偏移量就可以直接调用该方法了。通过解析操作符号引用就可以直接转变为目标方法在类中方法表的位置,从而使得方法可以被调用。
综上,解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,也就是得到类或者字段、方法在内存中的指针或者偏移量。
初始化
初始化阶段是执行初始化方法<clinit>()
方法的过程,是类加载的最后一步,这一步JVM才开始真正执行类中定义的Java程序代码(字节码)。
说明:<clinit>()
方法时编译之后自动生成的。
对于<clinit>()
方法的调用,虚拟机会自己确保其在多线程环境中的安全性。因为<clinit>()
方法是带锁的线程安全,所以在多线程环境下进行类初始化的话可能会引起多个线程阻塞,并且这种阻塞很难被发现。
对于初始化阶段,虚拟机严格规范了有且只有 6 种情况下,必须对类进行初始化(只有主动去使用类才会初始化类):
1.当遇到new
、getstatic
、putstatic
、invokestatic
这4条字节码指令时,比如new
一个类,读取一个静态字段(未被 final 修饰)、或调用一个类的静态方法时。
- 当 jvm 执行
new
指令时会初始化类。即当程序创建一个类的实例对象。 - 当 jvm 执行
getstatic
指令时会初始化类。即程序访问类的静态变量(不是静态常量,常量会被加载到运行时常量池)。 - 当 jvm 执行
putstatic
指令时会初始化类。即程序给类的静态变量赋值。 - 当 jvm 执行
invokestatic
指令时会初始化类。即程序调用类的静态方法。
2.使用 java.lang.reflect
包的方法对类进行反射调用时如 Class.forname("...")
, newInstance()
等等。如果类没初始化,需要触发其初始化。
3.初始化一个类,如果其父类还未初始化,则先触发该父类的初始化。
4.当虚拟机启动时,用户需要定义一个要执行的主类 (包含 main
方法的那个类),虚拟机会先初始化这个类。
5.MethodHandle
和 VarHandle
可以看作是轻量级的反射调用机制,而要想使用这 2 个调用,就必须先使用 findStaticVarHandle
来初始化要调用的类。
6.当一个接口中定义了JDK8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。
类卸载
卸载类即该类的 Class 对象被 GC。
卸载类需要满足 3 个要求:
- 该类的所有的实例对象都已被 GC,也就是说堆不存在该类的实例对象。
- 该类没有在其他任何地方被引用
- 该类的类加载器的实例已被 GC
所以,在 JVM 生命周期内,由 jvm 自带的类加载器加载的类是不会被卸载的。但是由我们自定义的类加载器加载的类是可能被卸载的。
只要想通一点就好,JDK自带的BootstrapClassLoader
,ExtClassLoader
, AppClassLoader
负责加载JDK提供的类,所以它们(类加载器的实例)肯定不会被回收。而我们自定义的类加载器的实例是可能被回收的,所以使用我们自定义的加载器加载的类是可以被卸载掉的。
类加载器详情
类加载器
类加载器介绍
类加载器是一个负责加载类的对象,用于实现类加载过程中的加载这一步。
每个 Java 类都有一个引用指向加载它的 ClassLoader
。
数组类不是通过 ClassLoader
创建的(数组类没有对应的二进制字节流),是由 JVM 直接生成的。
简单来说,类加载器的主要作用就是加载 Java 类的字节码( .class 文件)到 JVM 中(在内存中生成一个代表该类的 Class 对象)。 字节码可以是 Java 源程序(.java
文件)经过 javac
编译得来,也可以是通过工具动态生成或者通过网络下载得来。
其实除了加载类之外,类加载器还可以加载 Java 应用所需的资源如文本、图像、配置文件、视频等等文件资源。本文只讨论其核心功能:加载类。
类加载器加载规则
JVM 启动的时候,并不会一次性加载所有的类,而是根据需要去动态加载。也就是说,大部分类在具体用到的时候才会去加载,这样对内存更加友好。
对于已经加载的类会被放在 ClassLoader
中。在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。也就是说,对于一个类加载器来说,相同二进制名称的类只会被加载一次。
类加载器总结
JVM 中内置了三个重要的 ClassLoader
:
- BootstrapClassLoader(启动类加载器):最顶层的加载类,由 C++实现,通常表示为 null,并且没有父级,主要用来加载 JDK 内部的核心类库(
%JAVA_HOME%/lib
目录下的rt.jar
、resources.jar
、charsets.jar
等 jar 包和类)以及被-Xbootclasspath
参数指定的路径下的所有类。 - ExtensionClassLoader(扩展类加载器):主要负责加载
%JRE_HOME%/lib/ext
目录下的 jar 包和类以及被java.ext.dirs
系统变量所指定的路径下的所有类。 - AppClassLoader(应用程序类加载器):面向我们用户的加载器,负责加载当前应用 classpath 下的所有 jar 包和类。
🌈 拓展一下:
**rt.jar**:rt 代表“RunTime”,`rt.jar`是 Java 基础类库,包含 Java doc 里面看到的所有的类的类文件。也就是说,我们常用内置库 `java.xxx.*`都在里面,比如`java.util.*`、`java.io.*`、`java.nio.*`、`java.lang.*`、`java.sql.*`、`java.math.*`。
Java 9 引入了模块系统,并且略微更改了上述的类加载器。扩展类加载器被改名为平台类加载器(platform class loader)。Java SE 中除了少数几个关键模块,比如说 `java.base` 是由启动类加载器加载之外,其他的模块均由平台类加载器所加载。
除了这三种类加载器之外,用户还可以加入自定义的类加载器来进行拓展,以满足自己的特殊需求。就比如说,我们可以对 Java 类的字节码( .class
文件)进行加密,加载时再利用自定义的类加载器对其解密。
除了BootstrapClassLoader
是JVM自身的一部分之外,其他所有的类加载器都是在JVM外部实现的,并且全都继承自ClassLoader
抽象类。这样做的好处是用户可以自定义类加载器,以便让应用程序自己决定如何获取所需的类。
每个ClassLoader
可以通过getParent
获取其父ClassLoader
,如果获取到ClassLoader
为null
的话,那么该类是通过BootstrapClassLoader
加载的。
为什么获取到 ClassLoader
为null
就是 BootstrapClassLoader
加载的呢?
这是因为BootstrapClassLoader
由C++实现,由于这个C++实现的类加载器在Java中是没有与之对应的类的,所以拿到的结果是null。
自定义类加载器
我们前面也说说了,除了BootstrapClassLoader
其他类加载器均由Java实现且全部继承自java.lang.ClassLoader
。如果我们要自定义自己的类加载器,很明显需要继承ClassLoader
抽象类。
ClassLoader类有两个关键的方法:
-
proected Class loadClass(String name, boolean resolve)
:加载指定二进制名称的类,实现了双亲委派机制。name
为类的二进制名称,resolve
如果为true,在加载时调用resolveClass(Class<?> c)
方法解析该类。 -
protected Class findClass(String name)
:根据类的二进制名称来查找类,默认实现是空方法。
如果我们不想打破双亲委派模型,就重写 ClassLoader 类中的 findClass() 方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写 loadClass()方法。
双亲委派模型
双亲委派模型介绍
ClassLoader
类使用委派模型来搜索类和资源。- 双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。
ClassLoader
实例会在试图亲自查找类或资源之前,将搜索类或资源任务委托给其父类加载器。
注意 ⚠️:
双亲委派模型并不是一种强制性的约束,只是JDK官方推荐的一种方式。如果我们因为某些特殊需求需要打破双亲委派模型,也是可以的。
类加载器之间的父子关系一般不是以继承的关系来实现的,而是通过使用组合关系来复用加载器的代码。
在面向对象编程中,有一种非常经典的设计原则:组合优先继承,多用组合少用继承
。
双亲委派模型的执行流程
双亲委派模型的实现很简单,逻辑清楚,都集中在java.lang.ClassLoader
的loadClass()
中
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
//首先,检查该类是否已经加载过
Class c = findLoadedClass(name);
if (c == null) {
//如果 c 为 null,则说明该类没有被加载过
long t0 = System.nanoTime();
try {
if (parent != null) {
//当父类的加载器不为空,则通过父类的loadClass来加载该类
c = parent.loadClass(name, false);
} else {
//当父类的加载器为空,则调用启动类加载器来加载该类
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
//非空父类的类加载器无法找到相应的类,则抛出异常
}
if (c == null) {
//当父类加载器无法加载时,则调用findClass方法来加载该类
//用户可通过覆写该方法,来自定义类加载器
//这样就可以保证自定义的类加载器也符合双亲委派模式。
long t1 = System.nanoTime();
c = findClass(name);
//用于统计类加载器相关的信息
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
//对类进行link操作
resolveClass(c);
}
return c;
}
}
每当一个类加载器接收到加载请求时,它会先将请求转发给父类加载器。在父类加载器没有找到所请求的类的情况下,该类加载器才会尝试去加载。
简述双亲委派模型的执行流程:
- 在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载(每个父类加载器都会走上一遍这个流程)。
- 类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成(调用父加载器
loadClass()
方法来加载类)。这样的话,所有的请求最终都会传送到顶层的启动类加载器BootstrapClassLoader
中。 - 只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载(调用自己的
findClass()
方法来加载类)。 - 如果子类加载器也无法加载这个类,那么它会抛出一个
ClassNotFoundEcveption
异常。
🌈 拓展一下:
JVM 判定两个 Java 类是否相同的具体规则:JVM 不仅要看类的全名是否相同,还要看加载此类的类加载器是否一样。只有两者都相同的情况,才认为两个类是相同的。即使两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相同。
双亲委派模型的好处
双亲委派模型保证了Java程序的稳定运行,可以避免类的重复加载(JVM区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类),也保证了Java的核心API不被篡改。
如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题;
比如我们编写一个称为java.lang.Object
类的话,那么程序运行的时候,系统就会出现两个不同的Object
类。双亲委派模型可以保证加载的是JRE里的那个Object
类,而不是你写的Object
类。这是因为AppClassLoader
在加载你的Object
类时,会委托给ExtClassLoader
去加载,而ExtClassLoader
又会委托给BootstrapClassLoader
,BootstrapClassLoader
发现自己已经加载过了Object
类,会直接返回,不会去加载你写的Object
类。
打破双亲委派模型方法
自定义加载器的话,需要继承ClassLoader
。如果我们不想打破双亲委派模型,就重写ClassLoader
类中的findClass()
方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。但是如果想打不双亲委派模型则需要重写loadClass()
方法。
为何要重写loadClass()
方法打破双亲委派模型呢?双亲委派模型的执行流程:
类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器完成(调用父加载器loadClass()
方法来加载类)。
重写loadClass()
方法之后,我们就可以改变传统的双亲委派模型的执行流程。例如,子类加载器可以在委派给父类加载器之前,先自己尝试加载这个类,或者在父类加载器返回之后,在尝试从其他地方加载这个类。具体的规则由我们自己实现,根据项目需求定制化。
我们比较熟悉的Tomcat服务器为了能够优先加载Web应用目录下的类,然后再加载其他目录下的类,就自定义了类加载器WebAppClassLoader
来打破双亲委派机制。这也是Tomcat下Web应用之间的类实现隔离的具体原理。
Tomcat的类加载器的层次结构:
Tomcat这四个自定义的类加载器对应的目录如下:
CommonClassLoader
对应<Tomcat>/common/*
CatalinaClassLoader
对应<Tomcat >/server/*
SharedClassLoader
对应 <Tomcat >/shared/*
WebAppClassloader
对应 <Tomcat >/webapps/<app>/WEB-INF/*
从图中的委派关系可以看出:
-
CommonClassLoader
作为SharedClassLoader
和CatalinaClassLoader
的父类加载器。CommonClassLoader
能加载的类都可以被CatalinaClassLoader
和SharedClassLoader
使用。因此,CommonClassLoader
是为了实现公共类库(可以被所有Web应用和Tomcat内部组件使用的类库)的共享和隔离。 -
CatalinaClassLoader
和SharedClassLoader
能加载的类则与对方相互隔离。CatalinaClassLoader
用于加载Tomcat自身的类,为了隔离Tomcat本身的类和Web应用的类。SharedClassLoader
作为WebAppClassLoader
的父加载器,专门来加载Web应用之间共享的类比如Spring、Mybatis。 -
每个Web应用都会创建一个单独的
WebAppClassLoader
,并在启动Web应用的线程里设置线程上下文类加载器为WebAppClassLoader
。各个WebAppClassLoader
实例之间相互隔离,进而实现Web应用间的类隔。
线程上下文类加载器
线程上下文加载器,一种类加载器传递机制。因为该类加载器保存在线程私有数据里,只要是同一个线程,一旦设置了线程上下文加载器,在线程后续执行过程中就能把这个类加载器取出来用。因此Tomcat为每个Web应用创建一个WebAppClassLoader类加载器,并在启动Web应用的线程里设置线程上下文加载器,这样Spring在启动时就将线程上下文加载器取出来,用来加载Bean。Spring取线程上下文加载的代码如下:
cl = Thread.currentThread().getContextClassLoader();
在StandardContext的启动方法,会将当前线程的上下文加载器设置为WebAppClassLoader。
Tomcat打破双亲委派机制实现隔离Web应用的方法:
https://www.yingsoo.com/news/servers/39188.html
单纯依靠自定义类加载器没办法满足某些场景的要求,例如,有些情况下,高层的类加载器需要加载低层的加载器才能加载的类。
比如,SPI中,SPI的接口(如 java.sql.Driver)是由Java核心库提供的,由 BootstrapClassLoader 加载。
而SPI的实现(如 com.mysql.cj.jdbc.Driver)是由第三方供应商提供的,它们是由应用程序类加载器或者自定义类加载器来加载的。默认情况下,一个类及其依赖类由同一个类加载器加载。所以,加载SPI的接口的类加载器(BootstrapClassLoader )也会用来加载SPI的实现。按照双亲委派模型,BootstrapClassLoader是无法找到SPI的实现类的,因为它无法委托给子类加载器去尝试加载。
再比如,假设我们的项目中有 Spring 的 jar包,由于它是Web应用之间共享的,因此会由 SharedClassLoader加载(Web服务器是Tomcat)。
我们项目中有一些用到了Spring的业务类,比如实现了Spring提供的接口、用到了Spring提供的注解。所以,加载Spring的类加载器(也就是SharedClassLoader )也会用来加载这些业务类。但是业务类在Web应用目录下,不在 SharedClassLoader的加载路径下,所以 SharedClassLoader 无法找到业务类,也就无法加载它们。
如何解决这个问题?这个时候需要用到 线程上下文类加载器( ThreadContextClassLoader ) 。
拿Spring这个例子来说,当Spring需要加载业务类的时候,它不是用自己的类加载器,而是用当前线程的上下文类加载器。
记得上面说的嘛?每个Web应用都会创建一个单独WebAppClassLoader
,并在启动Web应用的线程里设置线程上下文类加载器为WebAppClassLoader
。这样就可以让高层的类加载器(SharedClassLoader
)借助子类加载器(WebAppClassLoader
) 来加载业务类,破坏了Java的类加载委托机制,让应用逆向使用类加载器。
线程上线文类加载器的原理:
是将一个类加载器保存在线程私有数据里,跟线程绑定,然后再需要的时候取出来使用。这个类加载器通常是由应用程序或者容器(如 Tomcat)设置的。
Java.lang.Thread
中的getContextClassLoader()
和 setContextClassLoader(ClassLoader cl)
分别用来获取和设置线程的上下文类加载器。如果没有通过setContextClassLoader(ClassLoader cl)
进行设置的话,线程将继续继承其父线程的上下文类加载器。
Spring获取线程上下文类加载器的代码如下:
cl = Thread.currentThread().getContextClassLoader();