类加载过程和双亲委派模型
作为JVM层面的Java面试题,类加载过程和双亲委派模型是几乎必问的内容之一。
这篇文章就来聊聊类加载过程和双亲委派模型。
# 絮
我面试曾经也不遇到不少这个题目,第一次被问到我是懵逼的,因为我确实不知道这东西,而且我也没用过。
我觉得这类面试题考察的还是程序员对Java虚拟机的深层次底层功力。
至于它在工作过程中有没有用得上,这个并不是很重要的;重要的是面试官觉得你知道了,说明你的Java底子还是很好的。
关于虚拟机的知识,我是跟着周志明的《深入理解Java虚拟机》 (opens new window)学习的,不知不觉已经是第三版了,如果你对一个.java
文件在执行过程中发生了什么、对性能调优、对底层知识感兴趣的,我建议大家阅读一下。
在这本书里面,也提到了类加载机制和双亲委派模型,我这里总结一下。
# 1、类加载过程
首先我们知道一个.java
文件它的编译过程是这样的:
上面图中描述的是宏观的过程,但是微观的过程并没有提及,而微观过程的.class
字节码是如何解释成机器码而被不同的平台执行的呢?
这就是我们本篇文章研究的 类加载过程和双亲委派模型
再进一步拆分,它其实是这样的:
且看看《深入理解Java虚拟机》 (opens new window)中对类加载过程的定义:虚拟机把描述类的数据从class文件加载到内存,并对数据进行校验、转换解析、初始化,最终形成可以被虚拟机直接使用的Java类型。
类从被加载到虚拟机内存中开始,它的整个生命周期包括:
加载 -> 验证 -> 准备 -> 解析 -> 初始化 -> 使用 -> 卸载
# 1、加载
加载分为三步:
第一步:通过一个类的全限定名来获取定义此类的二进制字节流。
第二步:将静态的存储结构转换为方法区中的运行时数据结构。
第三步:生成一个对象放入java堆中,作为对方法区的引用。
(类的加载就是将class文件中的二进制数据读取到内存中,然后将该字节流所代表的静态数据结构转化为方法区中运行的数据结构,并且在堆内存中生成一个java.lang.Class
对象作为访问方法区数据结构的入口)
对于第一步的二进制字节流,其实这个虚拟机并没有说的很具体,所以说现在很多开发人员都可以打破它,比如说 1、通过jar、zip、war 获取
2、提供网络获取,比如说applet
3、动态代理
4、JSP这种,编译后还是一个class类
# 2、验证
验证主要的目的是为了确保class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
所以说Java虚拟机并不是完全信任加载过来的字节流。
整体上验证分为四个动作:
- 文件格式验证
包括 class文件的表示(魔数),class文件的版本号,class文件的每个部分是否正确(字段表、方法表等),验证常量池(常量类型、常量类型数据结构是否正确,utf-8是否标准),,字节码(指令)验证,符号引用验证(是否能根据符号找到对应的字段、表、方法等)
只有通过了第一步的文件格式验证,字节流才会进入到内存的方法区中进行存储,所以接下来的三个验证阶段都是基于方法区的存储结构进行的,不会再直接操作字节流。
- 元数据验证
元数据验证(父类验证,继承验证,final验证),比如说是否继承了不允许被继承的类(即final修饰的类)、是否实现类抽象类的方法、重载回参、类型是否一样
- 字节码验证
这个阶段是最复杂的,主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。
如果一项不对,就会验证失败。
- 符号引用验证
这一步发生在虚拟机将符合引用转化为直接引用的时候,**目的是为了确保 解析 动作能正常执行。**比如说类是否能找到、访问类的字段、方法是否被private修饰无法访问了。
# 3、准备
准备阶段为类变量分配内存 和设置类变量初始化。
这个过程中,只对static类变量进行内存分配,这个时候只是分配内存,没有进行复制,所有的类变量都是初始化值。
如果是final的话,会直接对应到常量池中。会在准备阶段直接赋值。
public static final int value = 123;
这里变量的初始化值是0 而不是 123,123在初始化阶段才会赋值。
题外话,这里我演示一下,大家可以看看:
这是我的HelloCoder.java
文件:
package com.yudianxx.basic.字节码;
/**
* @author HaC
* @date 2021/5/15 14:26
* @webSite https://rain.baimuxym.cn
* @Description
*/
public class HelloCoder {
public static int staticValue = 123;
public static final int finalValue = 456;
public static void main(String[] args) {
System.out.println(staticValue);
System.out.println(finalValue);
}
}
使用命令打印一下字节码:
javap -verbose -private -c -s -l HelloCoder
这是字节码:
Classfile /G:/源码/springBootLogback/yudianxx-core/target/classes/com/yudianxx/basic/字节码/HelloCoder.class
Last modified 2021-5-15; size 712 bytes
MD5 checksum ac91b5504efcf6aa372cdc10cd899626
Compiled from "HelloCoder.java"
public class com.yudianxx.basic.字节码.HelloCoder
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #6.#26 // java/lang/Object."<init>":()V
#2 = Fieldref #27.#28 // java/lang/System.out:Ljava/io/PrintStream;
#3 = Fieldref #5.#29 // com/yudianxx/basic/字节码/HelloCoder.staticValue:I
#4 = Methodref #30.#31 // java/io/PrintStream.println:(I)V
#5 = Class #32 // com/yudianxx/basic/字节码/HelloCoder
#6 = Class #33 // java/lang/Object
#7 = Utf8 staticValue
#8 = Utf8 I
#9 = Utf8 finalValue
#10 = Utf8 ConstantValue
#11 = Integer 456
#12 = Utf8 <init>
#13 = Utf8 ()V
#14 = Utf8 Code
#15 = Utf8 LineNumberTable
#16 = Utf8 LocalVariableTable
#17 = Utf8 this
#18 = Utf8 Lcom/yudianxx/basic/字节码/HelloCoder;
#19 = Utf8 main
#20 = Utf8 ([Ljava/lang/String;)V
#21 = Utf8 args
#22 = Utf8 [Ljava/lang/String;
#23 = Utf8 <clinit>
#24 = Utf8 SourceFile
#25 = Utf8 HelloCoder.java
#26 = NameAndType #12:#13 // "<init>":()V
#27 = Class #34 // java/lang/System
#28 = NameAndType #35:#36 // out:Ljava/io/PrintStream;
#29 = NameAndType #7:#8 // staticValue:I
#30 = Class #37 // java/io/PrintStream
#31 = NameAndType #38:#39 // println:(I)V
#32 = Utf8 com/yudianxx/basic/字节码/HelloCoder
#33 = Utf8 java/lang/Object
#34 = Utf8 java/lang/System
#35 = Utf8 out
#36 = Utf8 Ljava/io/PrintStream;
#37 = Utf8 java/io/PrintStream
#38 = Utf8 println
#39 = Utf8 (I)V
{
public static int staticValue;
descriptor: I
flags: ACC_PUBLIC, ACC_STATIC
public static final int finalValue;
descriptor: I
flags: ACC_PUBLIC, ACC_STATIC, ACC_FINAL
ConstantValue: int 456 //ConstantValue指令
public com.yudianxx.basic.字节码.HelloCoder(); //构造方法
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 9: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/yudianxx/basic/字节码/HelloCoder;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: getstatic #3 // Field staticValue:I
6: invokevirtual #4 // Method java/io/PrintStream.println:(I)V
9: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
12: sipush 456
15: invokevirtual #4 // Method java/io/PrintStream.println:(I)V
18: return
LineNumberTable:
line 14: 0
line 15: 9
line 16: 18
LocalVariableTable:
Start Length Slot Name Signature
0 19 0 args [Ljava/lang/String;
static {}; //这里是static语法块
descriptor: ()V
flags: ACC_STATIC
Code:
stack=1, locals=0, args_size=0
0: bipush 123
2: putstatic #3 // 通过指令putstatic标志位初始化值
5: return
LineNumberTable:
line 10: 0
}
SourceFile: "HelloCoder.java"
以上字节码中,static 的值会通过putstatic
指令标志,存放于类构造器<clinit>()
方法中,final 的值则是 ConstantValue
指令
# 4、解析
解析阶段就是虚拟机将常量池内的符号引用替换为直接引用的过程。(指向目标的指针或者偏移量)
符号引用 是以一组符号来描述所引用的目标,只是定位目标,和虚拟机的内存布局无关,所以它不一定已经加载到内存中,不同的虚拟机的内存布局也可以不一样,但是符号标注都是一样的。
直接引用 直接指向目标的指针、相对偏移量,和虚拟机的内存布局是直接相关的,如果有了直接引用,那么引用的目标必定已经存在内存中了。
主要涉及到的解析有类,接口,字段,方法等。 如果权限不够就会抛出IllegalAccessError
,找不到字段就会抛出NoSuchFiledError
、找不到方法就会抛出NoSuchMethodError
# 5、初始化
类初始化就是执行类中定义的Java程序代码(或者说是字节码),从字节码层面来说,初始化阶段是执行类构造器<clinit>()
方法的过程。
包括static{ }
代码块的语句执行和static变量赋值(可以参考上面,static 修饰的在这里的初始化阶段进行赋值,而且是父类的<clinit>()
先执行,如果程序没有static,那么编译器可以不生成<clinit>()
方法)
# 6、使用
使用阶段就是使用这个class。
# 7、卸载
卸载阶段就是不在使用,将class给卸载。
# 2、双亲委派模型
类加载器的种类:
启动类加载器(Bootstrap ClassLoader),是虚拟机自身的一部分,用来加载Java_HOME/lib/目录中的,或者被 -Xbootclasspath 参数所指定的路径中并且被虚拟机识别的类库;
扩展类加载器(Extension ClassLoader):负责加载\lib\ext目录或Java. ext. dirs系统变量指定的路径中的所有类库;
应用程序类加载器(Application ClassLoader)。负责加载用户类路径(classpath)上的指定类库,我们可以直接使用这个类加载器。一般情况,如果我们没有自定义类加载器默认就是用这个加载器。
# 1、双亲委派的工作流程是什么?
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委派给父类加载器去完成,层层套娃(葫芦娃们)。因此,默认情况下,最终是送到顶层的 启动类加载器(Bootstrap ClassLoader),只有当父类(葫芦娃爷爷👴)反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
# 2、使用双亲委派模型的好处是什么?
采用双亲委派模式的是好处是Java类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关可以避免类的重复加载,当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次。
其次是考虑到安全因素,java核心api中定义类型不会被随意替换,假设通过网络传递一个名为java.lang.Integer
的类,通过双亲委托模式传递到启动类加载器,而启动类加载器在核心Java API发现这个名字的类,发现该类已被加载,并不会重新加载网络传递的过来的java.lang.Integer
,而直接返回已加载过的Integer.class
,这样便可以防止核心API库被随意篡改。
# 3、JVM什么情况下才会加载一个类?
Java虚拟机规范中并没有进行强制约束,这个可以交个虚拟机的具体实现来自己把握。
但对于初始化阶段,虚拟机规范则是严格规定来有且5种情况必须立即对类进行初始化,(这里说的初始化严格来说是包括之前的加载、验证、准备。)
1、遇到new
、getstatic
、putstatic
、invokestatic
这四个指令,如果类没有进行过初始化,则需要先触发其初始化。最常见的就是new一个对象、读取一个类的static变量。
2、反射。比如Class.forName("className")
3、当子类被加载时,如果父类还没有初始化,则需要先触发其父类的初始化。
4、JVM启动时,用户需要制定一个执行的主类,比如说执行main方法,需要先初始化这个主类
5、当使用JDK1.7动态语言支持时,如果一个java.lang.invoke.MethodHandle
实例最后的解析结果为REF_getStatic
、REF_putStatic
、REF_invokeStatic
的方法句柄,并且这个方法句柄的所对应的类还没有进行初始化,则需要先初始化。(这个我不是很清楚是什么意思)
# 4、手写一个自定义类加载器
首先要知道ClassLoader的源码实现:
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name); //首先查找.class是否被加载过
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
//如果父加载器不是null(不是Bootstrap ClassLoader),那么就执行父加载器的loadClass方法
//把类加载请求一直向上抛,直到父加载器为null(是Bootstrap ClassLoader)为止
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
}
//如果父类都加载失败就交给子加载器去加载
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();
}
}
//是否解析这个.class,主要就是将符号引用替换为直接引用的过程
if (resolve) {
resolveClass(c);
}
return c;
}
}
protected Class<?> findClass(String name) throws ClassNotFoundException {
throw new ClassNotFoundException(name);
}
1、如果不想打破双亲委派模型,那么只需要重写findClass
方法即可
2、如果想打破双亲委派模型,那么就重写整个loadClass
方法
jdk1.2开始,不提倡用户去覆盖loadClass()
方法,而应该写到findClass()
方法,这样保证自己实现的类加载器也是符合双亲委派模型的。
下面上代码。
实体类:
@Data
@AllArgsConstructor
public class HaC {
String name;
String wx;
}
这个HaC.java
编译好,复制到其他路径,方便下一步操作。
重写ClassLoader:
class MyClassLoader extends ClassLoader {
public MyClassLoader() {
}
public MyClassLoader(ClassLoader parent) {
super(parent);
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
File file = getClassFile(name);
try {
byte[] bytes = getClassBytes(file);
Class<?> c = this.defineClass(name, bytes, 0, bytes.length);
return c;
} catch (Exception e) {
e.printStackTrace();
}
return super.findClass(name);
}
private File getClassFile(String name) {
File file = new File("G:\\HaC.class" );
// File file = new File("G:\\源码\\springBootLogback\\yudianxx-core\\target\\classes\\com\\yudianxx\\basic\\类加载过程\\HaC.class" );
return file;
}
private byte[] getClassBytes(File file) throws Exception {
// 这里要读入.class的字节,因此要使用字节流
FileInputStream fis = new FileInputStream(file);
FileChannel fc = fis.getChannel();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
WritableByteChannel wbc = Channels.newChannel(baos);
ByteBuffer by = ByteBuffer.allocate(1024);
while (true) {
int i = fc.read(by);
if (i == 0 || i == -1)
break;
by.flip();
wbc.write(by);
by.clear();
}
fis.close();
return baos.toByteArray();
}
}
class TestMyClassLoader {
public static void main(String[] args) throws Exception {
MyClassLoader myClassLoader = new MyClassLoader(ClassLoader.getSystemClassLoader().getParent());
Class<?> c1 = Class.forName("com.yudianxx.basic.类加载过程.HaC", true, myClassLoader);
Constructor constructor = c1.getConstructor(String.class, String.class);//获得构造方法
Object object = constructor.newInstance("HaC", "公众号:HelloCoder"); //反射实例化对象
System.out.println(object);
System.out.println(object.getClass().getClassLoader());
}
}
输出:
HaC(name=HaC, wx=公众号:HelloCoder)
com.yudianxx.basic.类加载过程.MyClassLoader@2c13da15
如果第二行输出 sun.misc.Launcher$AppClassLoader 证明还是没有用到自己的类加载器,因为正常来说,ClassLoader的getResourceAsStream(String name)方法,默认就是从CLASSPATH下获取资源的,就是要让AppClassLoader 去处理类的加载的。
如果报这个错,解决方法如下:
1、第一种解决方法
这里 MyClassLoader myClassLoader = new MyClassLoader(ClassLoader.getSystemClassLoader().getParent());
如果没有传入构造参数,是不会利用自己的自定义加载器去加载的。
因为你的CLASSPATH下面就编译有了 HaC.class
,那么自然是由AppClassLoader这个爸爸来加载这个.class
文件了(哪轮得到儿子MyClassLoader
去加载)
ClassLoader.getSystemClassLoader()
就是 AppClassLoader,我们定义
ClassLoader.getSystemClassLoader().getParent()
越过它,让爷爷(Extension ClassLoader)去加载,爷爷找不到,再让太爷爷(Bootstrap ClassLoader)去找,它也没办法处理,最后调用findClass
方法。
2、第二种解决方法
删除CLASSPATH下的Person.class,这样它就找不到了,所以爸爸、爷爷、太爷爷这三个加载器都找不到了。