类加载器(ClassLoader),用来加载 Java 类到 Java 虚拟机中。一般来说,Java 虚拟机使用 Java 类的方式如下:Java 源程序(.java 文件)在经过 Java 编译器编译之后就被转换成 Java 字节代码(.class 文件)。
类加载器,负责读取 Java 字节代码,并转换成 java.lang.Class 类的一个实例。
类加载的过程
从下面这张图上来看一下类加载的过程
image.png
类加载过程分为3步
- 加载
- 连接
- 初始化
其中连接过程内部又分为3个步骤
- 验证
- 准备
- 解析
使用和卸载属于类加载完成之后的步骤,不属于类加载过程。
我们下面来看一下这3个过程分别做了什么工作
加载
加载(Loading)属于类加载过程的第一步,在这一步,虚拟机主要完成了下面这3件事
- 通过一个类的全限定名来获取其定义的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在Java堆中生成一个代表这个类的 java.lang.Class 对象,作为对方法区中这些数据的访问入口。
相对于类加载的其他阶段而言,加载阶段(准确地说,是加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,因为开发人员既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载。
虚拟机规范多上面这3点并不具体,因此是非常灵活的。比如:"通过全类名获取定义此类的二进制字节流" 并没有指明具体从哪里获取、怎样获取。比如:比较常见的就是从 ZIP 包中读取(日后出现的JAR、EAR、WAR格式的基础)、其他文件生成(典型应用就是JSP)、从远端或者网络上读取等等。
连接
连接(Linking)阶段会做3件事,做必要的验证和数据准备,以及符合引用的解析
验证
验证是连接阶段的第一步,这一阶段的目的是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
验证阶段大致会完成4个阶段的检验动作:
- 文件格式验证:验证字节流是否符合 Class 文件格式的规范。例如:是否以 0xCAFEBABE 开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型。
- 元数据验证:对字节码描述的信息进行语义分析(注意:对比 javac 编译阶段的语义分析),以保证其描述的信息符合 Java 语言规范的要求。例如:这个类是否有父类,除了 java.lang.Object 之外。
- 字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。
- 符号引用验证:确保解析动作能正确执行。
验证阶段是非常重要的,但不是必须的,它对程序运行期没有影响,如果所引用的类经过反复验证,那么可以考虑采用 -Xverifynone 参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
准备
准备阶段为类的静态变量分配内存,并将其初始化为默认值
准备阶段,是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。对于该阶段有以下几点需要注意:
- 1、这时候进行内存分配的仅包括类变量(
static
),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在 Java 堆中。
-
2、这里所设置的初始值通常情况下是数据类型默认的零值(如
0
、0L
、null
、false
等),而不是被在 Java 代码中被显式地赋予的值。假设一个类变量的定义为:
public static int value = 3
。那么静态变量value
在准备阶段过后的初始值为0
,而不是3
。因为这时候尚未开始执行任何 Java 方法,而把value
赋值为3
的public static
指令是在程序编译后,存放于类构造器<clinit>()
方法之中的,所以把value
赋值为3
的动作将在初始化阶段才会执行。这里还需要注意如下几点:
- 对基本数据类型来说,对于类变量(
static
)和全局变量,如果不显式地对其赋值而直接使用,则系统会为其赋予默认的零值,而对于局部变量来说,在使用前必须显式地为其赋值,否则编译时不通过。 - 对于同时被
static
和final
修饰的常量,必须在声明的时候就为其显式地赋值,否则编译时不通过;而只被final
修饰的常量则既可以在声明时显式地为其赋值,也可以在类初始化时显式地为其赋值,总之,在使用前必须为其显式地赋值,系统不会为其赋予默认零值。 - 对于引用数据类型 reference 来说,如数组引用、对象引用等,如果没有对其进行显式地赋值而直接使用,系统都会为其赋予默认的空值,即
null
。 - 如果在数组初始化时没有对数组中的各元素赋值,那么其中的元素将根据对应的数据类型而被赋予默认的“空”值。
- 对基本数据类型来说,对于类变量(
-
3、如果类字段的字段属性表中存在 ConstantValue 属性,即同时被
final
和static
修饰,那么在准备阶段变量value
就会被初始化为 ConstValue 属性所指定的值。假设上面的类变量
value
被定义为:public static final int value = 3
。编译时,javac
将会为value
生成 ConstantValue 属性。在准备阶段虚拟机就会根据 ConstantValue 的设置将value
赋值为 3。我们可以理解为static final
常量在编译期就将其结果放入了调用它的类的常量池中。
解析
解析步骤是把类中的符号引用转换为直接引用
解析阶段,是虚拟机将常量池内的符号引用替换为直接引用的过程。解析动作,主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符 7 类符号引用进行。
- 符号引用,就是一组符号来描述目标,可以是任何字面量。
- 直接引用,就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。
初始化
初始化可以为类的静态变量赋予正确的初始值,JVM 负责对类进行初始化,主要是对类变量进行初始化。在 Java 中对类变量进行初始值设定有两种方式:
1、声明类变量是指定初始值。
2、使用静态代码块为类变量指定初始值。
类加载器介绍
3个重要的内置类加载器
JAVA的类加载是通过类加载器完成的,JVM有3个重要的类加载器,我们通过一张图来了解一下类加载器的层次结构
类加载器层次结构
- Bootstrap ClassLoader :根类加载器,负责加载 Java 的核心类,包括%JAVA_HOME%/lib目录下的jar包和类或者或被 -Xbootclasspath参数指定的路径中的所有类。
它不是 java.lang.ClassLoader 的子类,而是由 JVM 自身使用C++语言实现的。 - Extension ClassLoader :扩展类加载器,扩展类加载器的加载路径是 JDK 目录下 jre/lib/ext ,或被 java.ext.dirs 系统变量所指定的路径下的jar包。
扩展加载器的 getParent() 方法返回 null ,实际上扩展类加载器的父类加载器是根加载器,只是根加载器并不是 Java 实现的。 - AppClassLoader(应用程序类加载器) :面向我们用户的加载器,负责加载当前应用classpath下的所有jar包和类。
代码示例
我们通过一个示例来看一下类加载器的父类加载器
public class ClassLoaderTest {
public static void main(String[] args) {
System.out.println(ClassLoaderTest.class.getName()+"'s classLoader is:" +ClassLoaderTest.class.getClassLoader());
System.out.println(ClassLoaderTest.class.getName()+"'s parent is: "+ClassLoaderTest.class.getClassLoader().getParent());
System.out.println(ClassLoaderTest.class.getName()+"'s grandParent is: "+ClassLoaderTest.class.getClassLoader().getParent().getParent());
}
}
Snipaste_2020-06-04_19-51-48.png
可以看到ExtClassLoader的父类加载器是null,null并不是代表ExtClassLoader没有父类加载器,而是Bootstrap ClassLoader,是用C++实现的。
父子类加载器关系实现的方式
这个类加载器的模型要求除了顶层的Bootstrap ClassLoader之外,其余的类加载器都应当有自己的父类加载器。子类加载器和父类加载器不是以继承(Inheritance)的关系来实现,而是通过组合(Composition)关系来复用父加载器的代码,看一下ClassLoader类的定义
image.png
jvm如何判断2个类是否相同
Java 虚拟机不仅要看类的全名是否相同,还要看加载此类的类加载器是否一样。只有两者都相同的情况,才认为两个类是相同的。即便是同样的字节代码,被不同的类加载器加载之后所得到的类,也是不同的。
比如一个 Java 类 com.example.Sample ,编译之后生成了字节代码文件 Sample.class 。两个不同的类加载器 ClassLoaderA 和 ClassLoaderB 分别读取了这个 Sample.class 文件,并定义出两个 java.lang.Class 类的实例来表示这个类。这两个实例是不相同的。对于 Java 虚拟机来说,它们是不同的类。试图对这两个类的对象进行相互赋值,会抛出运行时异常 ClassCastException 。
双亲委派模型
说到类加载和类加载器,就不得不提双亲委派模型。
每一个类都有一个对应它的类加载器。系统中的 ClassLoder 在协同工作的时候会默认使用 双亲委派模型 。
双亲委派模型工作流程
- 在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。
每个类加载器都有自己的加载缓存,当一个类被加载了以后就会放入缓存,等下次加载的时候就可以直接返回了。
2.加载的时候,首先会把该请求委派该父类加载器的 loadClass() 处理,因此所有的请求最终都应该传送到顶层的启动类加载器 BootstrapClassLoader 中。当父类加载器无法处理时,才由自己来处理。当父类加载器为null时,会使用启动类加载器 BootstrapClassLoader 作为父类加载器。
从上面类加载器的层次关系一图中可以看出,类加载过程是自底项上检查类是否被加载,自顶向下尝试加载类。
双亲委派模型源码解析
相关代码在 java.lang.ClassLoader#loadClass(java.lang.String, boolean)
方法里
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// 首先,检查类是否已被加载
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
//如果父加载器不为空,尝试用父类加载器去加载
c = parent.loadClass(name, false);
} else {
//如果父加载器为空,使用Bootstrap去加载
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
//抛出异常说明父类加载器无法完成加载请求
// from the non-null parent class loader
}
if (c == null) {
//如果仍未发现,尝试自己去加载类
long t1 = System.nanoTime();
c = findClass(name);
// 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;
}
}
代码相对来说比较简单
双亲委派模型的好处
根据双亲委派模型的定义,是优先使用父类加载器进行加载,一层一层向上找,如果实在找不到了就自己尝试加载,这样有2个好处
- 共享功能:类如果已经加载过了,可以避免类的重复加载,提高运行效率
- 隔离功能:为了安全性考虑,不同的类加载器加载同一个类,在虚拟机看来是2个不同的类,这样可以避免用户自己编写的类动态替换 Java 的一些核心类,如果内存中出现多个java.lang.Object或者java.lang.String,会不安全。
如何破坏双亲委派模型
有时候,为了一些特殊目的,要破坏双亲委派模型,我们只要自定义一个类去继承java.lang.ClassLoader,然后重写loadClass()方法,不要去使用父类加载器加载即可。
总结
本文从类加载的流程说起,详细介绍了类加载的步骤,然后讲到了双亲委派模型,了解了一下loadClass()方法,类加载在面试中算是一个比较重要,但是并不太难的知识点,深入了解类加载机制对平时的工作和面试帮助会有不少帮助。
最后,留2道思考题:
- 假设我要加载一个从远端或者网站上的一个类,应该用哪个classLoader?
- 如果一个字段同时被但是如果同时被final和static修饰,他在哪个阶段被设置值?
最后欢迎扫描二维码关注我的公众号:站在海边看远方
网友评论