1. 类加载的过程
1.1 加载(Loading)
这一步是读取到类文件产生的二进制流(findClass()
),并将字节流所代表的静态存储结构转换为方法区的运行时数据结构(defineClass()
)。初步校验cafe babe
魔法数(二进制中前四个字节为0xCAFEBABE
用来标识该文件是 Java 文件,这是很多软件的做法,比如zip压缩文件
)、常量池、文件长度、是否有父类等,然后在 Java堆中创建对应类的java.lang.Class
实例,类中存储的各部分信息也需要对应放入运行时数据区中(例如静态变量、类信息等放入方法区)。
这里我们可能会有一个疑问,为什么 JVM 允许还没有进行验证、准备和解析的类信息放入方法区呢? 答案是加载阶段和链接阶段的部分动作(比如一部分字节码文件格式验证动作)是交叉进行的,也就是说加载阶段还没完成,链接阶段可能已经开始了。但这些夹杂在加载阶段的动作(验证文件格式等)仍然属于链接操作。
1.2 链接(Linking)
1.2.1 验证:确保被加载的类的正确性
验证是连接阶段的第一步,这一阶段的目的是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证阶段大致会完成4
个阶段的检验动作:
- 文件格式验证:验证字节流是否符合 Class 文件格式的规范;例如:是否以
0xCAFEBABE
开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型。 - 元数据验证:对字节码描述的信息进行语义分析(注意:对比
javac
编译阶段的语义分析),以保证其描述的信息符合 Java 语言规范的要求;例如:这个类是否有父类,除了java.lang.Object
之外。 - 字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。
- 符号引用验证:确保解析动作能正确执行。
验证阶段是非常重要的,但不是必须的,它对程序运行期没有影响,如果所引用的类经过反复验证,那么可以考虑采用-Xverifynone
参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
1.2.2 准备:为类的静态变量分配内存,并将其初始化为默认值
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。对于该阶段有以下两点需要注意:
- 这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在 Java 堆中。
- 这里所设置的初始值通常情况下是数据类型默认的零值(如
0
、0L
、null
、false
等),而不是被在 Java 代码中被显式地赋予的值。
注:如果类字段的字段属性表中存在 ConstantValue 属性,即同时被和修饰,那么在准备阶段变量
value
就会被初始化为 ConstValue 属性所指定的值。 例如,假设这里有一个类变量public static int value = 666;
,在准备阶段时初始值是0
而不是666
,在初始化阶段才会被真正赋值为666
。假设是一个静态类变量public static final int value = 666;
,则在准备阶段 JVM 就已经赋值为666
了。
1.2.3 解析:把类中的符号引用转换为直接引用(重要)
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7
类符号引用进行。
-『符号引用』的作用是在编译的过程中,JVM 并不知道引用的具体地址,所以用符号引用进行代替,而在解析阶段将会将这个符号引用转换为真正的内存地址。
-『直接引用』可以理解为指向类、变量、方法的指针,指向实例的指针和一个间接定位到对象的对象句柄。
1.3 初始化(Initialzation)
初始化,为类的静态变量赋予正确的初始值,JVM 负责对类进行初始化,主要对类变量进行初始化。在 Java 中对类变量进行初始值设定有两种方式:
- 声明类变量是指定初始值;
- 使用静态代码块为类变量指定初始值;
JVM 初始化步骤:
- 假如这个类还没有被加载和连接,则程序先加载并连接该类。
- 假如该类的直接父类还没有被初始化,则先初始化其直接父类。
- 假如类中有初始化语句,则系统依次执行这些初始化语句。
类初始化时机:只有当对类的主动使用的时候才会导致类的初始化。
2. 在 JVM 中如何判断两个 Class 对象是否为同一个类?
两个 Class 对象属于同一个类需要满足以下三个条件:
- 同一个 .class 文件;
- 被同一个虚拟机加载;
- 被同一个类加载器加载;
比较两个类是否 相等,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那么这两个类就必定不相等。
3. ClassLoader 类结构分析
ClassLoader 的主要责任就是加载类。ClassLoader 通过一个类的名字定位并且把这个 class 文件加载进 JVM 的内存里,生成可以表示这个类的结构。ClassLoader 是JDK 为我们提供的一个基础的类加载器,它本身是一个抽象类。
defineClass()
defineClass()
用于将byte
字节流解析成 JVM 能够识别的 Class 对象。有了这个方法意味着我们不仅可以通过.class
文件实例化对象,还可以通过其他方式实例化对象,例如通过网络接收到一个类的字节码。
注意,如果直接调用这个方法生成类的 Class 对象,这个类的 Class 对象还没有
resolve
,JVM 会在这个对象真正实例化时才调用resolveClass()
进行链接。
findClass()
findClass()
通常和defineClass()
一起使用,我们需要直接覆盖 ClassLoader 父类的findClass()
方法来实现类的加载规则,从而取得要加载类的字节码。
protected Class findClass(String name) throws ClassNotFoundException {
throw new ClassNotFoundException(name);
}
如果你不想重新定义加载类的规则,也没有复杂的处理逻辑,只想在运行时能够加载自己制定的一个类,那么你可以用this.getClass().getClassLoader().loadClass("class")
调用 ClassLoader 的loadClass()
方法来获取这个类的 Class 对象,这个loadClass()
还有重载方法,你同样可以决定在什么时候解析这个类。
loadClass()
loadClass()
用于接受一个全类名,然后返回一个 Class 类型的对象。(该方法的源码蕴含了著名的双亲委派模型)
resolveClass()
resolveClass()
用于对 Class 进行链接,也就是把单一的 Class 加入到有继承关系的类树中。如果你想在类被加载到 JVM 中时就被链接(Link),那么可以在调用defineClass()
之后紧接着调用一个resolveClass()
方法,当然你也可以选择让 JVM 来解决什么时候才链接这个类(通常是真正被实例化的时候)。
ClassLoader 是个抽象类,它还有很多子类,如果我们要实现自己的 ClassLoader,一般都会继承URLClassLoader这个子类,因为这个类已经帮我们实现了大部分工作。
例如,我们来看一下java.net.URLClassLoader.findClass()
方法的实现:
// 入参为 Class 的 binary name,如 java.lang.String
protected Class findClass(final String name) throws ClassNotFoundException {
// 以上代码省略
// 通过 binary name 生成包路径,如 java.lang.String -> java/lang/String.class
String path = name.replace('.', '/').concat(".class");
// 根据包路径,找到该 Class 的文件资源
Resource res = ucp.getResource(path, false);
if (res != null) {
try {
// 调用 defineClass 生成 java.lang.Class 对象
return defineClass(name, res);
} catch (IOException e) {
throw new ClassNotFoundException(name, e);
}
} else {
return null;
}
// 以下代码省略
}
4. 深入理解双亲委派模型
4.1 JVM 的三个类加载器
一个 Java 类是依靠类加载器被加载进 JVM 的,在 JVM 中有自带的三个类加载器:
- Bootstrap ClassLoader(启动类加载器):最顶层的加载类,主要加载 核心类库,
%JRE_HOME%\lib
下的rt.jar
、resources.jar
、charsets.jar
和class
等。 - Extention ClassLoader(扩展类加载器):用于加载目录
%JRE_HOME%\lib\ext
目录下的jar
包和class
文件。 - Appclass Loader(应用程序加载器):加载当前应用的
classpath
的所有类。4.2 双亲委派模型的工作过程
- 当前 ClassLoader 首先从自己已经加载的类中查询是否此类已经加载,如果已经加载则直接返回原来已经加载的类。(每个类加载器都有自己的加载缓存,当一个类被加载了以后就会放入缓存,等下次加载的时候就可以直接返回了。)
- 当前 ClassLoader 的缓存中没有找到被加载的类的时候,委托父类加载器去加载,父类加载器采用同样的策略,首先查看自己的缓存,然后委托父类的父类去加载,一直到 Bootstrap ClassLoader。(当所有的父类加载器都没有加载的时候,再由当前的类加载器加载,并将其放入它自己的缓存中,以便下次有加载请求的时候直接返回。)
4.3 双亲委派模型带来了什么好处
双亲委派模型保证了 Java 程序的稳定运行,可以避免类的重复加载,也保证了 Java 的核心 API 不被篡改。如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题。比如我们编写一个称为java.lang.Object
类的话,那么程序运行的时候,系统就会出现多个不同的Object
类。
4.4 loadClass() 方法解析
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) {
// 父加载器不为空则调用父加载器的 loadClass() 方法
c = parent.loadClass(name, false);
} else {
// 父加载器为空则调用 Bootstrap ClassLoader
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 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();
// 父加载器都没有在缓存中找到该类,则调用 findClass() 方法
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() 方法对类进行链接
resolveClass(c);
}
return c;
}
}
类加载的入口是从 loadClass() 方法开始的,而类加载器的双亲委派模型也是在这里实现的。在加载类期间,调用线程会一直持有一把锁。接着的逻辑就是类的双亲委派模型机制的实现,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回。否则把类加载的任务交给父类加载器执行,直到父类加载器为空,此时会返回通过 JDK 提供的系统启动类加载器加载的类。如果最后发现该类仍然没有被加载过,则由当前类加载器调用 findClass() 方法继续加载该类。
ClassLoader 不提供 findClass() 方法的具体实现,要求我们根据自己的需要来重写该方法。我们可以看看 URLClassLoader 对 findClass() 方法的实现,发现类加载的工作又被代理给了 defineClass() 方法。
defineClass() 方法主要是把字节数组转化成类的实例。同时 defineClass() 方法是 final 和 native 的,具体是由虚拟机实现。
4.5 实现自己的类加载器
4.5.1 什么情况下需要自定义类加载器
- 隔离加载类。在某些框架内进行中间件与应用的模块隔离,把类加载器到不同的环境。比如,阿里内某容器框架通过自定义类加载器确保应用中依赖的
jar
包不会影响到中间件运行时使用的jar
包。 - 修改类加载方式。类的加载模型并非强制,除了 Bootstrap 外,其他的加载并非一定要引入,或者根据实际情况在某个时间点进行按需的动态加载。
- 扩展加载源。比如从数据库、网络,甚至是电视机顶盒进行加载。(下面👇我们会编写一个从网络加载类的例子)
- 防止源码泄露。Java 代码容易被编译和篡改,可以进行编译加密。那么类加载器也需要自定义,还原加密的字节码。
4.5.2 怎么自定义 ClassLoader
实现一个自定义的类加载器比较简单:继承 ClassLoader,重写findClass()
方法,调用defineClass()
方法,就差不多行了。(在线 IDE 项目的实现方法,见02-字节码的加载和运行)。
最好不要直接重写 loadClass() 方法,因为 loadClass() 这个方法是实现双亲委托模型逻辑的地方,擅自修改这个方法会导致模型被破坏,容易造成问题。当然,如果是刻意要破坏双亲委托模型就另说。
4.6 JVM 能否对同一个类加载两次?(怎么破坏双亲委派模型?)
由于双亲委派模型机制,通常情况下可以保证一个类只被加载一次。
但其实在某些情况下,我们可能需要加载两个不同的类,但是不巧的是这两个类的名字完全一样,这时候双亲委托模型就无法满足我们的要求了,我们就要重写 loadClass 方法破坏双亲委托模型,让同一个类被不同的 ClassLoader 加载多次。