tags:
- JVM
- Java
date: 2024-04-15

要了解双亲委派,就需要先了解类加载器,可以参考[[类的生命周期及类加载器]]一文。

1. 双亲委派机制是什么?

双亲委派机制指的是:当一个类加载器收到加载类的任务时,会向上查找查找是否加载过,再由顶向下进行加载。

即,当某个类加载器收到加载任务,如果自己没有加载过此类,则会向上委派加载任务给自己的父加载器,如此重复知道委托到启动类加载器,然后再尝试加载类,如果当前类加载器无法加载,则任务下发给自己的子类加载器。过程如下图:

Pasted image 20241224191625

举例说明

假设现在用应用程序类加载器(Application ClassLoader)来加载当前项目classpath目录下的 Demo9.class文件。步骤如下:

  1. Application ClassLoader收到加载任务后,先查询自己是否加载过此类。若加载过,直接返回class对象即可。否则委托给Extension/Platform ClassLoader。
  2. Extension/Platform ClassLoader收到后,查询是否加载过此类,若加载过,直接返回class对象,否则,委托给Bootstrap ClassLoader。
  3. Bootstrap ClassLoader收到任务后,查询是否加载过此类,若加载过,返回class对象。否则,开始尝试加载,若自己的域中无此类的字节码文件,则向下尝试加载。若有,则加载后,返回class对象。
  4. Extension/Platform ClassLoader开始尝试加载,若自己的域中无此类字节码文件,则继续向下尝试加载。若有,则加载后返回class对象。
  5. Application ClassLoader开始尝试加载,若自己域中无此类字节码文件,则抛出ClassNotFoundException异常。若有,则加载后,返回class对象。

以上步骤中,1、2、3步骤为向上查找是否加载过的步骤;3、4、5步骤为向下尝试加载的步骤。

2. 为什么使用双亲委派?

主要是为了保证类加载的安全性,避免重复加载。即,防止程序代码会对JDK造成影响。如下案例:

定义一个包名为sun.util.calendar的名为ZoneInfo的类。如下:

1
2
3
4
5
6
7
8
9
10
11
package sun.util.calendar;  

/**
* @author LittleY
* @date 2024/4/15
*/
public class ZoneInfo {

private int id = 666;

}

此时,使用Application ClassLoader来加载此类。

1
2
3
4
5
6
7
8
public static void main(String[] args) throws Exception{  
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();

Class<?> aClass = systemClassLoader.loadClass("sun.util.calendar.ZoneInfo");

System.out.println(aClass);
System.out.println(aClass.getClassLoader());
}

输出如下:

1
2
class sun.util.calendar.ZoneInfo
null

可见,ZoneInfo类已经被成功加载。获取其类加载器显示null,即启动类加载器。那么就说明,加载的依然是$JAVA_HOME/jre/lib下的字节码文件,而非classpath下的字节码文件。避免了用户自己定义的类破坏JDK核心类的情况。

3. 如何做到双亲委派?

不难推断出,双亲委派机制为类加载器中实现的。所有的类加载器都继承了 ClassLoader 类(Bootstrap ClassLoader除外),以下是ClassLoader中几个核心的方法。先看总览:

1
2
3
4
5
6
7
8
9
10
11
// 类加载入口,提供双亲委派机制,内部调用findClass()
public Class<?> loadClass(String name)

// 获取字节码二进制数据以调用defineClass()
protected Class<?> findClass(String name)

// 进行类名的校验,并调用JVM底层方法将其加载到JVM内存中
protected final Class<?> defineClass(String name, byte[] b, int off, int len)

// 执行连接操作
protected final void resolveClass(Class<?> c)

3.1 loadClass(String name)的实现

要让指定类加载器加载指定的类,次方法为入口。双亲委派机制在次方法中实现,以下是部分代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {  
synchronized (getClassLoadingLock(name)) {
// 首先,检查类是否已经加载
Class<?> c = findLoadedClass(name);

//如果c != null 说明被加载过,直接跳过这一步
//若没有加载,再检查是否被父类加载器加载
if (c == null) {
long t0 = System.nanoTime();
try {
// 直接委派 调用父 类加载器进行加载
if (parent != null) {
c = parent.loadClass(name, false);
} else {
// 这个else是为了保证根类加载器的加载,因为BootstrapClassLoader是null,所以需要手动加载
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 如果未找到类,则抛出 ClassNotFoundException // 来自父 类加载器
}

if (c == null) {
// 如果仍然没有找到,则依次调用findClass
long t1 = System.nanoTime();
c = findClass(name);

// this is the defining class loader; record the stats
PerfCounter.getParentDelegationTime().addTime(t1 - t0);
PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}

3.2 findClass()实现

ClassLoader的findClass实现如下,什么都没有。 通常由子类去实现,根据名称或位置加载.class字节码,然后使用defineClass()方法去解析class字节流,返回class对象实例))将字节码转换为Class返回。

1
2
3
protected Class<?> findClass(String name) throws ClassNotFoundException {  
throw new ClassNotFoundException(name);
}

那我们就挑一个相对比较简洁易懂的子类来阅读其 findClass()方法的源码。Hutool的ResourceClassLoader

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Override  
protected Class<?> findClass(String name) throws ClassNotFoundException {
final Class<?> clazz = cacheClassMap.computeIfAbsent(name, this::defineByName);
if (clazz == null) {
return super.findClass(name);
}
return clazz;
}

/**
* 从给定资源中读取class的二进制流,然后生成类<br>
* 如果这个类资源不存在,返回{@code null}
* * @param name 类名
* @return 定义的类
*/
private Class<?> defineByName(String name) {
final Resource resource = resourceMap.get(name);
if (null != resource) {
final byte[] bytes = resource.readBytes();
return defineClass(name, bytes, 0, bytes.length);
}
return null;
}

先查看缓存中是否有指定类名的class类,若有责返回,没有就直接去尝试获取此类的二进制流,并调用defineClass()方法将类存入内存。具体流程在此代码中已经非常清晰。

3.3 defineClass()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
protected final Class<?> defineClass(String name, byte[] b, int off, int len, ProtectionDomain protectionDomain) throws ClassFormatError {  
//确定保护域,并检查: 1、未定义 java.lang.class 2、此类的签名者与包中其余类的签名者相匹配。
// balabala,可以理解为一个安全检查
protectionDomain = preDefineClass(name, protectionDomain);

// 这个方法的作用是获取给定类的来源位置,并以字符串形式返回
String source = defineClassSourceLocation(protectionDomain);

// 调用defineClass1方法,将字节数组转换为类
Class<?> c = defineClass1(this, name, b, off, len, protectionDomain, source);

// 一些安全相关的后续操作
postDefineClass(c, protectionDomain);
return c;
}

可见,defineClass()又调用了一个defineClass1方法,将类字节码文件加载到内存中,此方法为本地方法,非Java实现。

3.4 resolveClass()实现

此方法做用就是对指定的类做连接操作,调用的依然是本地方法。

1
2
3
4
5
protected final void resolveClass(Class<?> c) {  
resolveClass0(c);
}

private native void resolveClass0(Class<?> c);

由上可知,[[类的生命周期及类加载器]]一节中的自定义类加载器,就是重写了findClass()方法。

4. 双亲委派可以打破吗?

先给出答案,可以。

根据以上的分析,双亲委派在 loadClass()中实现,那么,我们重写loadClass()方法即可覆盖掉双亲委派的代码逻辑,实现打破双亲委派。如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class MyCLassLoader extends ClassLoader{  
public static final String MY_CLASS_HOME = "/Users/yang/";
public static final String CLASS_SUFFIX = ".class";

@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
if (!name.startsWith("com.littley")) {
return super.loadClass(name);
}
String path = MY_CLASS_HOME + name + CLASS_SUFFIX;
byte[] bytes = FileUtil.readBytes(path);
return defineClass(name, bytes, 0, bytes.length);
}

public static void main(String[] args) throws Exception {
MyCLassLoader myCLassLoader = new MyCLassLoader();

Class<?> aClass = myCLassLoader.loadClass("com.yang.Student");

System.out.println(aClass);
System.out.println(aClass.getClassLoader());
}
}

输出为:

1
2
class com.yang.Student
com.yang.MyCLassLoader@4dcbadb4

相关的思考

再回忆一下双亲委派机制的作用,不就是为了保证JDK的安全性吗?既然可以打破的话,那么不是可以用相同的方案加载一个自己写的 java.lang.String

事实上是无法做到的,JDK对对于java包下的类做了比较强的保护。在defineClass()方法中,有一个前置的安全检查方法 preDefineClass(),代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private ProtectionDomain preDefineClass(String name,  
ProtectionDomain pd)
{
if (!checkName(name))
throw new NoClassDefFoundError("IllegalName: " + name);

if ((name != null) && name.startsWith("java.")) {
throw new SecurityException
("Prohibited package name: " +
name.substring(0, name.lastIndexOf('.')));
}
if (pd == null) {
pd = defaultDomain;
}

if (name != null) checkCerts(name, pd.getCodeSource());

return pd;
}

是直接写死在代码中,如果全限定名为java.开头,则直接抛出异常。

通过打破双亲委派可以实现很多功能,后续单独讨论。