Java类加载机制

Java学习

Java类加载机制

JVM(java虚拟机)把描述类的数据从Class文件加载到内存,并对数据进行校验、解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。

Java类

Java是编译型语言,我们编写的java文件需要被编译成class文件后才能被JVM运行,这里先了解一下java类。

给一个Main.java,内容如下:

1
2
3
4
5
public class Main{
    public static void main(String[] args){
        System.out.println("haha");
    }
}

使用javac命令将其编译为字节代码的class文件,结果如下:

image-20250120194425462

这样就生成了一个字节码文件 ,用十六进制工具打开看一下

image-20250120194651158

这里就可以看到生成的字节码,我们可以通过JDK自带的javap命令反汇编Main.class文件对应的Main类,

image-20250120194640440

JVM在执行Main之前会先解析class二进制内容,JVM执行的其实就是如上javap命令生成的字节码。

类加载的时机

(具体可参考《深入理解Java虚拟机》一书)

类加载过程如下表示:

image-20250120194632072

其中 从验证到解析 为连接, 从加载到初始化 为类加载

加载

加载过程:

  1. 通过类的全限定名(包名+类名)来获取定义此类的二进制字节流
  2. 将这个字节流所代表的静态存储结构转化为运行时的数据结果
  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口

校验

这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。从整体上看,验证阶段大致上会完成4个阶段的校验工作:文件格式、元数据、字节码、符号引用。可以通过设置参数略过。

准备

准备阶段是正式为类中定义的变量(即静态变量–被static修饰的变量)分配内存并设置类变量初始值的阶段。

这里需要注意的点:

  • 此时进行内存分配的仅包括类变量(静态变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中

也就是说类变量随着类的加载为存在于方法区

  • 其次就是这里说的初始值“通常情况”下是数据类型的零值,比如如下代码:

public satic int value = 123;在准备阶段过后的初始值为0而不是123。把value赋值为123的指令在初始化阶段才会被执行。

image-20250120194622432

但是在一些特殊情况,如果类变量同时被staticfinal修饰,比如:

public static final int value = 123;

那么在准备阶段虚拟机就会将value赋值为123,并且在定义这个变量的时候必须为其显式的赋值。

解析

解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程

  • 符号引用就是一组符号来描述目标,可以是任何字面量。
  • 直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。

初始化

执行类构造器<clinit>()方法的过程

会调用java.lang.ClassLoader加载类字节码,ClassLoader会调用JVM的native方法(defineClass0/1/2)来定义一个java.lang.Class 实例

其中包括:

  • 执行static语句块中的语句
  • 完成static属性的赋值操作
  • 当类的直接父类还没有被初始化,则先初始化其直接父类

ClassLoader类加载器分类

一切的Java类都必须经过JVM加载后才能运行,ClassLoader的主要作用就是Java类文件的加载

类加载器类型包括四种,分别如下:

  • Bootstrap ClassLoader(启动类加载器):最顶层的类加载器,主要加载核心类库。这个类加载器负责加载存放在 jre\lib 目录下的部分jar包(如rt.jar、tools.jar)或者被-Xbootclasspath 参数所指定的路径中存放的类库。
  • Extension ClassLoader(扩展类加载器):这个加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载 jre\lib\ext 目录中的,或者被 java.ext.dirs 系统变量所指定的路径中的所有类库
  • Application ClassLoader(系统类加载器):这个类加载器由 sun.misc.Launcher$AppClassLoader 实现。它负责加载用户类路径(ClassPath)上所指定的类库。需要注意的是如果应用程序中没有自定义自己的类加载器,一般情况下AppClassLoader是默认的类加载器。
  • 用户自定义加载器

在Java虚拟机角度来看,存在两种不同的类加载器:

一种是启动类加载器,这个加载器使用C++语言实现,是虚拟机自身的一部分。所以当使用getClassLoader()方法时会返回null(后面会说)

还有一种是其他所有的类加载器,这些类加载器都由Java语言实现,独立存在于虚拟机外部,并且全都继承自抽象类 java.lang.ClassLoader

获取类加载器的方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import java.lang.ClassLoader;

public class Main {
    public static void main(String[] args) throws Exception {
        // 获取 Test 类的类加载器
        ClassLoader testClassLoader = Class.forName("Test").getClassLoader();
        System.out.println("Test 类的类加载器: " + testClassLoader);

        // 获取当前类的类加载器
        ClassLoader currentClassLoader = Main.class.getClassLoader();
        System.out.println("Main 类的类加载器: " + currentClassLoader);

        // 获取系统类加载器
        ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
        System.out.println("系统类加载器: " + systemClassLoader);
    }
}
/*
Output:
Test 类的类加载器: sun.misc.Launcher$AppClassLoader@18b4aac2
Main 类的类加载器: sun.misc.Launcher$AppClassLoader@18b4aac2
系统类加载器: sun.misc.Launcher$AppClassLoader@18b4aac2

双亲委派模型

双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。注意这里的父子关系一般不是以继承关系实现,只是通过使用组合关系来复用父加载器的代码。

双亲委派模型的工作流程图

image-20250120194611445

即如果一个类加载器收到了类加载的请求,它不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一层次的加载器都是如此,直到传送到最顶层的启动类加载器,只有当父加载器反馈自己无法完成这个加载请求的时候(即它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。

双亲委派模型的优点:

1、这样就是能够实现有些类避免重复加载使用,直接先给父加载器加载,不用子加载器再次重复加载。

2、保证java核心库的类型安全。比如网络上传输了一个java.lang.Object类,通过双亲模式传递到启动类当中,然后发现其Object类早已被加载过,所以就不会加载这个网络传输过来的java.lang.Object类,保证我们的java核心API库不被篡改,出现类似用户自定义java.lang.Object类的情况。

双亲委派模型实现代码

image-20250120194557297

这段代码的逻辑就是先检查请求加载的类型是否被加载过,如果没有就调用父加载器的loadClass() 方法,若父加载器为空则默认使用启动类加载器为父加载器。假如父加载器加载失败,才调用自己的findClass()方法加载。

最后通过上述步骤我们找到了对应的类,并且接收到的resolve参数的值为true,那么就会调用resolveClass(Class)方法来处理类。

//类加载器核心方法

  • loadClass:加载指定的Java类

  • findClass:查找指定的Java类

  • findLoadedClass:查找JVM已经加载过的类

  • defineClass:定义一个Java类

  • resolveClass:链接指定的Java类

类加载的方式

  • 命令行启动应用时由JVM初始化加载
  • 通过Class.forName()方法动态加载
  • 通过ClassLoader.loadClass()方法动态加载。

通过Class.forName方法动态加载会执行类中的static块,而ClassLoader.loadClass()方法动态加载不会执行

在前面反射的学习中也是说明了可以利用classloader来加载类,在这里利用加载器获取Class对象的代码如下

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
package java_foundation;

import java.lang.ClassLoader;
import java.lang.reflect.Method;

public class Main {
    public static void main(String[] args) throws Exception{
        //加载类的Class对象:
        Class clazz = ClassLoader.getSystemClassLoader().loadClass("java_foundation.Reflection");
        Method method = clazz.getDeclaredMethod("getName");
        System.out.println("hello "+method.invoke(clazz.newInstance(),null));
    }
}
//hello fupanc

可以看到可以正常获取到类的Class对象并且调用它。

调试类加载过程

测试代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
package java_foundation;

import java.lang.ClassLoader;

public class Main {
    public static void main(String[] args) throws Exception{
        ClassLoader classLoader = ClassLoader.getSystemClassLoader();
        //加载类
        Class clazz = classLoader.loadClass("java_foundation.Reflection");
    }
}

打断点后调试:

image-20241224192840808

得到当前类加载器为系统类加载器,然后强制步入loadClass()方法,进入到了ClassLoader类的loadClass()方法。在这个laodClass()方法中会调用另外一个重载的loadClass()方法:

image-20241224193259985

简单注意一下这个参数传递,看看后面有没有用。继续跟进这个loadClass()方法,但是由于jdk自带的包中有些文件是反编译的.class文件,反编译的代码有一些不是很纯却,所以这里需要将其转换为.java文件,具体就看参考文章,这里不多说。

然后继续调试,步入后发现到达了Launcher类的loadClass()方法:

image-20241224195313579

这样就可以看出来这里的调用逻辑,不多说,再往后面看,这里的ucp.knownToNotExist(name)为false,所以会直接调用到后面的代码,如下:

image-20241224195657823

这里的super是系统类加载器,也就是说这里调用了类加载器来加载类:

image-20241224195718209

现在就到了ClassLoader类的loadClass()方法: image-20241224195859214

这里的逻辑还是比较好看,首先这里的synchronized是Java提供的同步机制,用于保证多线程访问共享资源时的线程安全,而在getClassLoadingLock(name) 方法返回一个用于加载类的锁对象,所以这的目的就是为每个类的加载过程提供独立的锁,防止多个线程同时加载同一个类。

然后现在来看方法内部的代码,首先就是看JVM中是否加载过这个类,这里为null,也就是没有加载过。然后进入了if语句:

image-20241224200449143

这里检测到了系统类加载器有父加载器Extension ClassLoader(扩展类加载器)。

然后就调用类ExtClassLoader类加载器的loadClass()方法(同样是ClassLoader类的loadClass()方法):

image-20241224200832734

但是这里的parent为null,也算符合预期,因为扩展类加载器本来就没有父加载器。

然后就会进入else语句,其实这个findBootstrapClassOrNull()方法就是看怎么进行的,跟进这个方法:

image-20241224201136248

但是这个findBootstrapClass(name)执行后还是为Null,回退到loadClass()方法:

image-20241224201643616

后面就会进入这个if条件,然后调用扩展类加载器来调用findClass()方法来查找类:

image-20241224201816068

可以看到这个方法中有defineClass()方法,但是这里并没有进入这个条件,大概可以知道这里就是在调用扩展类加载器来查找,但是并没有找到,直接退出了。

然后就退出到了前面AppClassLoader加载器的loadClass()方法:

image-20241224203005831

随后就会调用到AppClassLoader类加载器的findClass()方法:

image-20241224203408050

这里应该是因为异常退出导致c直接被设置为null。

然后跟进这里的findClass()方法:

image-20241225173356404

可以看到最后返回这个result,这个result就是我们想要寻找的类。并且这里是在这个方法内部声明的result,所以这个找类的操作必然是中间的那个赋值操作完成的,调试一下,确实是调用的defineClass()方法来定义的类:

image-20241225174244461

最后是找到了这个了类:

image-20241224203805146

然后回到loadClass()方法,最后是直接返回了这个Reflection类:

image-20241224203903282

然后一直返回,成功得到了这个Refelction类的Class对象:

image-20241224204029075

OK。调试结束。大概过程梳理下来确实比较符合双亲委派机制,先是AppClassLoader,然后是ExtClassLoader,然后是启动类加载器,启动类加载器找不到然后再委派给子加载器加载,最后在系统类加载器找到想要加载的类。

————

现在我们来看一下sun.misc.Launcher类,并且在调试过程中都是进入了这个Launcher类的,简单看一下源码,可以发现这个类里面有常用的类加载器的源码,看下面这两个代码:

1
2
3
 static class AppClassLoader extends URLClassLoader{}
 
 static class ExtClassLoader extends URLClassLoader{}

看一下URLClassLoader

1
public class URLClassLoader extends SecureClassLoader implements Closeable{}

SecureClassLoader

1
public class SecureClassLoader extends ClassLoader{}

从这些代码片段可以看出上面介绍的类加载器在使用时为什么不是继承关系,而是组合关系,叫作父加载器是为了更好学习。

继承图如下:

image-20250120194734199

URLClassLoader

URLClassLoader 继承了 ClassLoader,从名字就可以看出来,URLClassLoader 提供了加载远程资源的能力,在写漏洞利用的 payload 或者 webshell 的时候我们可以使用这个特性来加载远程的 jar 来实现远程的类方法调用。

首先声明一点,对于class文件是否要有软件包声明问题,报错的话有两种解决方法:

  • class文件不要软件包
  • 调用loadClass()方法加载时加上软件包即可,如 java_foundation.Reflection

——————

MyTest.java:

1
2
3
4
5
public class MyTest {
    public MyTest(){
        System.out.println("调用了无参构造函数");
    }
}

javac编译生成MyTest.class文件,再将其放在E盘。然后将当前项目中的所有MyTest.java文件删除,同时注意classpath中已编译的MyTest.class文件,也要删除,然后使用如下代码测试:

Main.java文件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
package java_foundation;

import java.io.File;
import java.net.URL;
import java.net.URLClassLoader;

public class Main {
    public static void main(String[] args) throws Exception{
        File filePath = new File("E:\\");
        URL url = filePath.toURI().toURL();
        System.out.println(filePath.toURI());
        System.out.println(filePath.toURI().toURL());
        URLClassLoader urlClassLoader = new URLClassLoader(new URL[]{url});
        Class clazz = urlClassLoader.loadClass("MyTest");
        System.out.println("MyTest类加载器为"+clazz.getClassLoader());
        System.out.println("MyTest类的父加载器为"+clazz.getClassLoader().getParent());
        clazz.newInstance();
    }
}

输出结果:

1
2
3
4
5
file:/E:/
file:/E:/
MyTest类加载器为java.net.URLClassLoader@7a81197d
MyTest类的父加载器为sun.misc.Launcher$AppClassLoader@14dad5dc
调用了无参构造函数

可以看到这里调用的是URLClassLoader构造器,并且这个构造器的父加载器是AppClassLoader加载器。了大概说一下这里的逻辑,先用File类来定义了一个在D盘下搜索文件的File对象。

然后调用了toURI().toURL()函数来将File对象转换为URL,因为URL是URLClassLoader用来定位资源的格式。这里创建了一个URL类型的数组,是URLClassLoader类的构造函数决定的:

image-20241224212414478

然后就是加载类那些了,不多说。

但是如果classpath中有相关远程加载类的编译代码,此时AppClassLoader就会优先加载这个本地class文件,而不会去加载E盘的文件,如下尝试;

另外的MyTest.java文件内容:

1
2
3
4
5
public class MyTest {
    public MyTest(){
        System.out.println("调用了classPath中的MyTest文件");
    }
}

然后我将其打成jar包加入到当前maven项目的外部库中,也就是引入了classpath中,再使用之前的代码里来测试,输出如下:

1
2
3
4
5
file:/E:/
file:/E:/
MyTest类加载器为sun.misc.Launcher$AppClassLoader@14dad5dc
MyTest类的父加载器为sun.misc.Launcher$ExtClassLoader@2f92e0f4
调用了classPath中的MyTest文件

可以看到确实是优先调用的classpath中的文件,并且不再调用URL中引入的文件。

但是这里同样是有解决方法的,我们可以将URLClassLoader的父类设置为启动类加载器,这样在双亲委派时就可以避免AppClassLoader加载classpath中的文件,而是URLClassLoader优先加载。

测试代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
package java_foundation;

import java.io.File;
import java.net.URL;
import java.net.URLClassLoader;

public class Main {
    public static void main(String[] args) throws Exception{
        File filePath = new File("E:\\");
        URL url = filePath.toURI().toURL();
        System.out.println(filePath.toURI());
        System.out.println(filePath.toURI().toURL());
        URLClassLoader urlClassLoader = new URLClassLoader(new URL[]{url},null);
        Class clazz = urlClassLoader.loadClass("MyTest");
        System.out.println("MyTest类加载器为"+clazz.getClassLoader());
        System.out.println("MyTest类的父加载器为"+clazz.getClassLoader().getParent());
        clazz.newInstance();
    }
}

执行结果为:

1
2
3
4
5
file:/E:/
file:/E:/
MyTest类加载器为java.net.URLClassLoader@24d46ca6
MyTest类的父加载器为null
调用了无参构造函数

可以看到这里成功外部加载了其他的class文件。这里调用的构造函数为:image-20241225002437088

可以看出来第二个参数就是指定父类的操作,在前面的学习后,这里将其设置为null就是将其设置为启动类加载器的子加载器,很好理解,然后个人理解这里调用URLClassLoader就是用来“动态构造”一个ClassLoader,大概就是这样。

同样的,还可以直接将其设置为一个URL对象,如下代码:

Main.java:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
package java_foundation;

import java.io.File;
import java.net.URL;
import java.net.URLClassLoader;

public class Main {
    public static void main(String[] args) throws Exception{
        URL url = new URL("file:/E:/");
        URLClassLoader urlClassLoader = new URLClassLoader(new URL[]{url},null);
        Class clazz = urlClassLoader.loadClass("MyTest");
        System.out.println("MyTest类加载器为"+clazz.getClassLoader());
        System.out.println("MyTest类的父加载器为"+clazz.getClassLoader().getParent());
        clazz.newInstance();
    }
}

输出结果为:

1
2
3
MyTest类加载器为java.net.URLClassLoader@24d46ca6
MyTest类的父加载器为null
调用了无参构造函数

本地加载成功,网络传输class文件是差不多的,在这里对于传参类型看源码很容易看出来,这里不多说。

但是有一个点是要说明的,如下代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
package java_foundation;

import java.io.File;
import java.net.URL;
import java.net.URLClassLoader;

public class Main {
    public static void main(String[] args) throws Exception{
        URL url = new URL("file:/D:/");
        URLClassLoader urlClassLoader = new URLClassLoader(new URL[]{url},null);
        Class clazz = urlClassLoader.loadClass("MyTest");
        System.out.println("MyTest类加载器为"+clazz.getClassLoader());
        System.out.println("MyTest类的父加载器为"+clazz.getClassLoader().getParent());
        clazz.newInstance();
    }
}

没有输出,而是报错:

1
2
3
4
5
Exception in thread "main" java.lang.ClassNotFoundException: MyTest
	at java.net.URLClassLoader.findClass(URLClassLoader.java:381)
	at java.lang.ClassLoader.loadClass(ClassLoader.java:424)
	at java.lang.ClassLoader.loadClass(ClassLoader.java:357)
	at java_foundation.Main.main(Main.java:11)

想了一下,这是因为”新定义“的URLClassLoader只是启动类的子加载器,但是它没有子加载器,这里D盘下并没有MyTest.class文件,虽然classpath中有,但是没有子类,所以是无法调用成功的,可以和前面父类的AppclassLoader的代码对比理解一下。

——————————

参考文章:

https://xz.aliyun.com/t/12669?time__1311=GqGxuDRiD%3Dit%3DGN4eeqBKqAKKhQQdFD9WoD

https://blog.csdn.net/yrk0556/article/details/105348968

https://blog.csdn.net/qq_37205350/article/details/108805628

https://blog.csdn.net/briblue/article/details/54973413

https://blog.csdn.net/TJtulong/article/details/89598598

https://blog.csdn.net/m0_45406092/article/details/108984101

https://nivi4.notion.site/Java-cedccc0611654bd99f841de3ef578e24

Licensed under CC BY-NC-SA 4.0
使用 Hugo 构建
主题 StackJimmy 设计