类加载器(ClassLoader)

负责读取Java字节代码,并转换成java.lang.Class 类的一个实例的代码模块。类加载器除了用于加载类外,还可用于确定类在Java虚拟机中的唯一性。一个类在同一个类加载器中具有唯一性(Uniqueness),而不同类加载器中是允许同名类存在的,这里的同名是指全限定名相同。但是在整个JVM里,纵然全限定名相同,若类加载器不同,则仍然不算作是同一个类,无法通过 instanceOf 、equals 等方式的校验。

1 分类

1) Bootstrap ClassLoader 负责加载$JAVA_HOME中jre/lib/rt.jar里所有的class或Xbootclassoath选项指定的jar,由C++实现,不是ClassLoader子类。

2) Extension ClassLoader 负责加载java平台中扩展功能的一些jar包,包括$JAVA_HOME中jre/lib/*.jar 或 -Djava.ext.dirs指定目录下的jar包。

3) App ClassLoader 负责加载classpath中指定的jar包以及Djava.class.path所指定目录下的类和jar包。

4) Custom ClassLoader 通过java.lang.ClassLoader的子类自定义加载class,属于应用程序根据自身需要定义的ClassLoader,如tomcat,jboss都会根据j2ee规范自行实现ClassLoader。

2 图解

为什么类加载器要分层?

​ 1.2版本的JVM中,只有一个类加载器,就是现在的“Bootstrap”类加载器。也就是根类加载器。但是这样会出现一个问题。假如用户调用他编写的java.lang.String类。理论上该类可以访问和改变java.lang包下其他类的默认访问修饰符的属性和方法的能力。也就是说,其他的类使用String时也会调用这个类,因为只有一个类加载器,无法判定到底加载哪个。因为Java语言本身并没有阻止这种行为,所以会出现问题。

这个时候就想到,可不可以使用不同级别的类加载器来对信任级别做一个区分?比如用三种基础的类加载器做为三种不同的信任级别。最可信的级别是java核心API类。然后是安装的拓展类,最后才是在类路径中的类(属于本机的类)。所以,三种基础的类加载器由此生。

1
2
3
4
5
6
7
8
9
10
11
12
public class Demo3 {
public static void main(String[] args) {
//App ClassLoader
System.out.printLn(new Worker().getClass().getClassLoader());
//Ext ClassLoader
System.out.printLn(new Worker().getClass().getClassLoader().getParent());
//Bootstrap ClassLoader
System.out.printLn(new Worker().getClass().getClassLoader().getParent().getParent());

System.out.printLn(new String().getClass().getClassLoader());
}
}

3 JVM类加载机制的三种方式

  1. 全盘负责:当一个类加载器负责加载某个class时,该Class所依赖的和引用的其他Class也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入。

    例如:系统类加载器AppClassLoader加载入口类(含有main方法的类)时,会把main方法所依赖的类及引用的类也载入,依此类推。“全盘负责”机制也可称为当前类加载器负责机制。显然,入口类所依赖的类及引用的类的当前类加载器就是入口类的类加载器。以上步骤只是调用了CLassLoader.loadClass(name)方法,并没有真正定义类。真正加载class字节码文件生成Class对象由“双亲委派”机制完成。

  2. 父类委托:“双亲委派”是指子类加载器如果没有加载过该目标类,就先委托父类加载器加载该目标类,只有在父类加载器找不到字节码文件的情况下才从自己的类路径中查找并装载目标类。父类委托别名就叫双亲委派机制。

    “双亲委派”机制加载Class的具体过程是:

​ (1)ClassLoader先判断该Class是否已加载,如果已加载,则返回Class对象;如果没有则委托给父类加载器。

​ (2)父类加载器判断是否加载过该Class,如果已加载,则返回Class对象;如果没有则委托给祖父类加载器。

​ (3)依此类推,直到始祖类加载器(引用类加载器)。

​ (4)始祖类加载器判断是否加载过该Class,如果已加载,则返回Class对象;如果没有则尝试从其对应的类路径下寻找class字节码文件并载入。如果载入成功,则返回Class对象;如果载入失败,则委托给始祖类加载器的子类加载器。

​ (5)始祖类加载器的子类加载器尝试从其对应的类路径下寻找class字节码文件并载入。如果载入成功,则返回Class对象;如果载入失败,则委托给始祖类加载器的孙类加载器。

​ (6)依此类推,直到源ClassLoader。

​ (7)源ClassLoader尝试从其对应的类路径下寻找class字节码文件并载入。如果载入成功,则返回Class对象;如果载入失败,源ClassLoader不会再委托其子类加载器,而是抛出异常。

​ “双亲委派”机制只是Java推荐的机制,并不是强制的机制。可以继承java.lang.ClassLoader类,实现自己的类加载器。如果想保持双亲委派模型,就应该重写findClass(name)方法;如果想破坏双亲委派模型,可以重写loadClass(name)方法。

  1. 缓存机制:缓存机制将会保证所有加载过的Class都将在内存中缓存,当程序中需要使用某个Class时,类加载器先从内存的缓存区寻找该Class,只有缓存区不存在,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓存区。这就是为什么修改了Class后,必须重启JVM,程序的修改才会生效.对于一个类加载器实例来说,相同全名的类只加载一次,即 loadClass方法不会被重复调用。而这里我们JDK8使用的是直接内存,所以我们会用到直接内存进行缓存。这也就是我们的类变量为什么只会被初始化一次的由来。
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
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
//1.在虚拟机内存中查找是否已经加载过此类。。。类缓存的主要问题所在
Class<?> c = findLoadedClass(name);
if(c == null){
long t0 = System.nanoTime();
try {
if(parent != null){
//先让上一层加载器进行加载
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e){
//ClassNotFoundException thrown if class not found
//from the non-null parneet class loader
}
if(c == null){
//调用此类加载器所实现的findClass方法进行加载
c = findClass(name);
}
}
if(resolve){
//resolverClass方法是当字节码加载到内存后进行链接操作,对文件格式和字节码校验,并为static字段分配空间并初始化,符号引用转为直接引用,访问控制,方法覆盖等
resolveClass(c);
}
return c;
}
}

4 打破双亲委派

双亲委派这个模型并不是强制模型,而且会带来一些的问题。就比如java.sql.Driver这个东西。JDK只能提供一个规范接口,而不能提供实现。提供实现的是实际的数据库提供商。提供商的库总不能放JDK目录里。所以java想到了几种办法可以用来打破双亲委派。

(1)SPI :比如Java从1.6搞出了SPI就是为了优雅的解决这类问题——JDK提供接口,供应商提供服务。编程人员编码时面向接口编程,然后JDK能够自动找到合适的实现。Java 在核心类库中定义了许多接口,并且还给出了针对这些接口的调用逻辑,然而并未给出实现。开发者要做的就是定制一个实现类,在 META-INF/services 中注册实现类信息,以供核心类库使用。比如JDBC中的DriverManager

(2)OSGI:比如我们更加追求程序的动态性,比如代码热部署,代码热替换。也就是就是机器不用重启,只要部署上就能用。OSGi实现模块化热部署的关键则是它自定义的类加载器机制的实现。每一个程序模块都有一个自己的类加载器,当需要更换一个程序模块时,就把程序模块连同类加载器一起换掉以实现代码的热替换。

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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
public class MyClassLoader extends ClassLoader {
private String root;

protected Class<?> findClass(String name) throws ClassNotFoundException {
byte[] classData = loadClassData(name);
if(classData == null){
throw new ClassNotFoundException();
} else {
return defineClass(name, classData, 0, classData.length);
}
}

private byte[] loadClassData(String className){
String fileName = root + File.separatorChar + className.replace('.', File.separatorChar) + ".class";
try {
InputStream ins = new FileInputStream(fileName);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int bufferSize = 1024;
byte[] buff = new byte[bufferSize];

int length = 0;
while((length = ins.read(buffer)) != -1){
baos.write(buffer, 0, length);
}
return baos.toByteArray();
} catch (IOException e){
e.printStackTrace();
}
return null;
}

public String getRoot(){
return root;
}

public void setRoot(String root){
this.root = root;
}

public static void main(String[] args){
MyclassLoader classLoader = new MyClassLoader();
classLoader.setRoot("E:\\temp");

Class<?> testClass = null;
try {
testClass = classLoader.loadClass("com.neo.classloader.Test2");

Object object = testClass.newInstance();
System.out.printLn(object.getClass().getClassLoader());
} catch(Exception e){
e.printStackTrace();
}
}
}

自定义类加载器的核心在于对字节码文件的获取,如果是加密的字节码则需要在该类中对文件进行解密。由于这里只是演示,并未对class文件进行加密,因此没有解密的过程。这里有几点需要注意:

​ 1、这里传递的文件名需要是类的全限定性名称,即 Test 格式的,因为 defineClass 方法是按这种格式进行处理的。如果没有全限定名,那么需要做的事情就是将类的全路径加载进去,而setRoot就是前缀地址 setRoot + loadClass的路径就是文件的绝对路径。

​ 2、最好不要重写loadClass方法,因为这样容易破坏双亲委托模式。

​ 3、这类Test 类本身可以被 AppClassLoader 类加载,因此不能把 Test.class 放在类路径下。否则,由于双亲委托机制的存在,会直接导致该类由 AppClassLoader 加载,而不会通过自定义类加载器来加载。


类加载器(ClassLoader)
http://www.zivjie.cn/2023/03/11/java基础/jvm/类加载器/
作者
Francis
发布于
2023年3月11日
许可协议