前言
本章记录一下 Java 的 ClassLoader 机制。
JVM 内存模型
之前我只是粗略的了解 JVM 的内存模型,这里来简单记录下。
JVM 内存模型主要就是指 Java程序运行时的数据区,JVM 在执行 Java 程序的过程中会将它所管理的内存划分为 5 个不同的数据区域。这些数据区域如下所示:
- 虚拟机栈( VM Stack )
- 本地方法栈( Native Method Stack )
- 程序计数栈( Program Counter Register )
- 堆( Heap )
- 方法区( Method Area )
其中,可以根据线程的访问权限将这 5 个不同的数据区域划分为两块:
- 堆、方法区属于线程共享区域。
- 栈、本地方法栈、程序计数栈属于线程私有区域(线程独占区域)。
线程共享区域
-
堆(Heap)
Java 堆是所有线程共享的一块内存区域,在 JVM 启动时被创建,是 JVM 管理的内存中最大的一块。
该区域的作用是:给对象实例和数组分配内存。
如果堆中没有足够的内存来完成内存分配,并且堆上也无法再扩展时,就会抛出 OutOfMemoryError 异常
-
方法区(Method Area)
Java 方法区又被称为 “非堆区”、“静态区”,方法区在 JVM 启动时被创建,是各个内存共享的内存区域。该区域的作用是:存储已被虚拟机加载的类信息、常量、静态变量、即是编译器编译后的代码等数据。
“运行时常量池”( Runtime Constant Pool ) 是方法区的一部分编译,期间生成的字面量和符号引用,会在类的加载阶段后进入运行时常量池。
-
字面量:类似于 Java 语言层面的常量概念,例如文本字符串,Final修饰符修饰的常量值等。
-
符号引用:包括类和接口的全限定名、字段的名称和描述符、方法的名称和描述符; 当JVM运行时,需要从运行时常量池中获得对应的符号引用,在类创建或运行时解析到具体的内存地址中。
当常量池无法再申请到内存时,会抛出 OutOfMemoryError 异常。
-
线程独占区域
-
程序计数器(Program Counter Register)
程序计数器是当前线程所执行的字节码的行号指示器,字节码解释器工作时会通过改变这个计数器的值来选取将要执行的下一条字节码指令。
字节码指令、分支结构、循环结构、跳转结构、异常处理、线程恢复等基础功能都依赖于这个计数器。
每条线程都有一个独立的程序计数器,不同线程之间的程序计数器互不影响,因此程序计数器是线程独占的。
该内存区域是 JVM 中唯一一个不会排除 OutOfMemoryError 异常的区域
-
Java 虚拟机栈(VM Stack)
VM Stack 的生命周期与线程相同,因此该区域也是线程独占的。
VM Stack 描述的实际是 Java方法执行 的内存模型,即每个方法执行时会创建一个栈帧,方法调用完成时会销毁该栈帧,栈帧内存放了方法中的局部变量、操作数栈等数据。
VM Stack 主要负责栈帧进行存储、压栈和出栈等操作。
- 局部变量表:一个局部变量(本地变量)可以存放32位以内的数据,可以保存类型为 INT、SHORT、Reference、BYTE、CHAR、FLOAT、ReturnAddress 的数据,两个本地变量可以保存类型为 LONG 和 DOUBLE 的数据。
- 操作数栈:每个栈帧内部都包含一个被称为操作数栈的LIFO栈,提供给方法中的计算过程使用。
- 指向运行时常量池的指针:每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用来支持方法调用过程中的动态链接。
- 返回地址:在方法调用完成后,程序需要返回到方法被调用的位置继续执行。注意返回时需要平衡栈帧、修改程序计数器、将返回值(如果有的话)压入调用者栈帧的操作数栈中。
如果线程请求的栈深度大于 JVM 允许的深度,则将抛出 StackOverflowError 异常。
在 VM Stack 可动态扩展的情况下(一般情况下 VM Stack 可以动态扩展),如果扩展时无法申请到足够的内存,则将抛出 OutOfMemoryError 异常。
Java 变量对比
在上文中提到了多种变量(实例变量、局部变量、静态变量), 凶残的程序员@Juejin 前辈在前文中有一个表做的很棒,这里记录一下。
ClassLoader 简介
一个完整的 Java 应用程序由若干个 Java Class 文件组成,当程序在运行时,会通过一个入口函数来调用系统的各个功能,这些功能都被存放在不同的 Class 文件中。
因此,系统在运行时经常会调用不同 Class 文件中被定义的方法,如果某个 Class 文件不存在,则系统会抛出 ClassNotFoundException 异常。
系统程序在启动时,不会一次性加载所有程序要使用的 Class 文件到内存中,而是根据程序需要,通过 Java 的类加载机制动态将需要使用的 Class 文件加载到内存中; 只有当某个 Class 文件被加载到内存后,该文件才能被其他 Class 文件调用。
这个 “类加载机制“ 就是 ClassLoader , 他的作用是动态加载 Java Class 文件到 JVM 的内存空间中,让 JVM 能够调用并执行 Class 文件中的字节码。
上面的流程图即为 ClassLoaderTest.java 是如何被动态加载到 JVM 内存空间的,类加载的过程主要由 5 步组成。
- 加载阶段 :该阶段是类加载过程的第一个阶段,会通过一个类的完全限定名称来查找类的字节码文件,并利用字节码文件来创建一个 Class 对象。
- 验证阶段 :该阶段是类加载过程的第二个阶段,其目的在于确保 Class 文件中包含的字节流信息符合当前 Java 虚拟机的要求。
- 准备阶段 : 该阶段会为类变量在方法区中分配内存空间并设定初始值( 这里 “类变量” 为static修饰符修饰的字段变量 )
- 不会分配并初始化用 final 修饰符修饰的 static 变量,因为该类变量在编译时就会被分配内存空间。
- 不会分配并初始化实例变量,因为实例变量会随对象一起分配到 Java 堆中,而不是 Java 方法区。
- 解析阶段 :该阶段会将常量池中的符号引用替换为直接引用。
- 初始化阶段 :该阶段是类加载的最后阶段,如果当前类具有父类,则对其进行初始化,同时为类变量赋予正确的值。
ClassLoader 详解
JVM ClassLoader 分类
下面记录一下 Java 中类加载器的分类,Java 中的类加载器大致分为 2 种:
-
JVM 默认类加载器
主要由 “引导类加载器”、“扩展类加载器”、“系统类加载器” 三方面组成。 -
用户自定义类加载器
用户可以编写继承 java.lang.ClassLoader类的自定义类来自定义类加载器。
引导类加载器(BootstrapClassLoader)
引导类加载器(BootstrapClassLoader)属于 JVM 的一部分,其底层代码由C++编写,不继承 java.lang.ClassLoader 类,也没有父类加载器。
引导类加载器的作用为:加载核心 Java 库。
下面来看一看 BootstrapClassLoader 类加载器会加载哪些类:
结论是:BootstrapClassLoader 类加载器会加载 %JAVA_HOME%/jre/lib/
目录下的 Java 核心库。
我们从上面这个目录中任取一个Class文件,来看一看其父类加载器是不是 BootstrapClassLoader。我们以 %JAVA_HOME%/jre/lib//rt.jar!/java/lang/Object.class
为例,来验证下。
结果很奇怪,Object.class 的父类加载器为 null ,不存在父类加载器。
这点其实也能理解,BootstrapClassLoader 底层的原生代码是 C++ 语言编写的,并不是一个 Java 类,且已经被嵌入到 JVM 内核中,自然无法在 Java 代码中获取它的引用。
扩展类加载器(ExtensionsClassLoader)
扩展类加载器(ExtensionsClassLoader)是引导类加载器(BootstrapClassLoader)的子集,其核心目的是加载标准核心Java类的扩展,以便适配平台上运行的所有应用程序。
同样的方法来看一看该类加载器会加载哪些目录下的类。
结论是:ExtensionsClassLoader 类加载器会加载 %JAVA_HOME%/jre/lib/ext/
目录下的 Java 库类。
同样的,我们去 %JAVA_HOME%/jre/lib/ext/
目录下的 Jar 包中找一个 Class 文件,来验证下其父类加载器。我们以 %JAVA_HOME%/jre/lib/ext/nashorn.jar!/jdk/nashorn/api/scripting/AbstractJSObject.class
为例。
这里我们获取到了 AbstractJSObject.class
的父类加载器,为 sun.misc.Launcher$ExtClassLoader
,因此可以作证该类确实是由 ExtensionsClassLoader
类加载器加载的。
系统类加载器(AppClassLoader)
系统类加载器又被称为 App类加载器。该加载器会加载应用程序 CLASSPATH 路径下所有的 Java 类库。
我们通过 (URLClassLoader)ClassLoader.getSystemClassLoader().getURLs()
方法可以验证这一点。
可见,AppClassLoader 类加载器加载的 Java 类库路径与我们在 CLASSPATH 环境变量中指定的路径是完全相同的。
同样来验证下当前类的父类加载器是否为 AppClassLoader。
可见,当前类的确是由 AppClassLoader 类加载器加载的。
自定义类加载器(UserDefineClassLoader)
除了上述 3 个 JVM 默认的类加载器外,用户也可以通过继承 java.lang.ClassLoader.class
的方式来实现自定义的类加载器。下文会单独记录一下这部分的知识点。
ClassLoader 核心方法
先来看一看 java.lang.ClassLoader.class
中的几个核心方法。
defineClass(String name, byte[] b, int off, int len)
该方法有四个参数,解释如下:
- String name :二进制形式的类名
- byte[] b :类的字节流,字节流可以来自 Class 文件,也可以来自网络或其他途径
- int off :类的字节流的起始偏移量
- int len :类的字节流的大小
该方法的核心作用是:将类的字节流转换为 java.lang.Class 实例对象。
defineClass()
方法会对传入的字节流做校验,校验不通过则抛出 ClassFormatError 异常。该方法返回的 Class 实例对象没有被链接,后面需要调用 resolveClass()
方法对 Class 对象进行链接。
loadClass(String name, boolean resolve)
首先来看下 loadClass()
方法,该方法用于加载指定的 Java 类。
该函数有两个参数,解释如下:
- String name :二进制形式的类名
- Boolean resolve :如果该直为 True ,则说明要处理该类,执行后续的链接操作。
-
loadClass()
方法中会先调用findLoadedClass()
方法来检查这个类是否已经被加载过,如果类已经被加载,则不会重复加载。 -
接着递归调用父类加载器的
loadClass()
方法,如果父类加载器不为 null,则调用父类加载器的loadClass(
) 方法来寻找该类并加载 ,如果父类加载器为 null,则调用 bootstrapClassLoader(引导类加载器)。 -
如果父类加载器均找不到这个类,则调用自定义的
findClass()
方法寻找并加载该类。这里的
findClass()
方法会返回一个 Class 类型的变量,方法体为空,仅会抛出一个 ClassNotFoundException 异常,说明该方法需要用户自己去实现。这里就涉及到自定义类加载器的内容了,我们后面再说。 -
方法最后会根据入参的 resolve 值选择是否对 Class 进行动态链接操作,将方法区里面的符号引用转为直接引用。
双亲委派机制
双亲委派机制的原理
通过上文,我们知道了 JVM 默认使用了三种类加载器,分别加载不同目录下的 Java 类库。当程序需要某个类时,JVM会按需将生成的 Class 文件加载到内存中,生成对应实例对象。
在分析 loadClass()
方法时,我们得知:JVM 会递归调用父类加载器的 loadClass()
方法来寻找并加载类,如果全部的父类加载器都没有找到对应类,则 JVM 会调用用户自定义的 findClass()
方法来找到对应类。
那么 JVM 会采用什么顺序来调用默认的三个类加载器以及自定义类加载器呢? 这实际上就是经典的 "双亲委派机制"。
先来看一看什么是 “双亲委派机制”。
顾名思义,该机制的实现分为两个阶段,即上图中的 “委托阶段” 与 “派发阶段”。
-
委托阶段
当一个类加载器需要加载类时,首先会去判断该类是否已经被加载,如果该类已经被加载就直接返回,如果该类未被加载,则委托给父类加载器。
父类加载器会执行相同的操作来进行判断,直到委托请求到达“引导类加载器(bootstrapClassLoader)”,此时可以确定当前类未被加载,因此需要进入派发阶段,查找并加载该类。
-
派发阶段
上面提到委托请求最终会到达 bootstrapClassLoader,此时进入派发阶段,bootstrapClassLoader 会去对应的目录下(
%JAVA_HOME%jre/lib/
)搜索该类,如果找到该类就加载它,如果没有找到就将加载请求派发给子类加载器。子类加载器会执行类似的操作,去对应目录下搜索该类,如果找到就加载该类,如果没找到就继续将请求派发给子类加载器。
最后加载请求会到达用户自定义的类加载器,此时如果类加载器在自定义目录下找到该类,就加载它; 如果还是没有找到,就抛出
ClassNotFoundException
异常并退出。
其实通过上图来看,整个双亲委派模型的流程是比较清晰的,如果还有不清楚的地方,可以参考 loadClass()
中对应的和源代码。
双亲委派机制的优势
-
避免重复加载某些类,当父加载器已经加载了某个类后,子加载器不会重复加载。
-
保证 Java 核心库的安全,例如攻击者定义了一个恶意的
java.lang.Object.class
文件,并通过网络传输到本地加载。当使用双亲委派模型加载时,由于java.lang.Object.class
类已经被加载,因此类加载器不会重复加载该类,这样保证了 Java 核心API不会被篡改。
自定义 ClassLoader 类加载器
Java 中默认提供的类加载器只能加载指定目录下的 jar 包以及 CLASSPATH 路径中的 Class 文件。
但在很多情况下,我们希望调用本地磁盘其他目录中的 Class 文件或者网络传输的二进制 Class 字节流; 此时默认提供的 ClassLoader 已经不够用了,我们需要自定义类加载器。
通过对上文 loadClass()
方法进行分析后,我们得知:loadClass()
方法内部有一个 findClass()
方法,该方法需要手工实现,当默认的三个类加载器无法找到并加载指定类时,就会调用该方法来寻找并加载类。
因此,自定义类加载器时的两个核心步骤如下:
-
自定义类加载器继承
java.lang.ClassLoader.class
。 -
自定义类加载器时重写
findClass()
方法。
下面我们来看几个自定义类加载器的 Demo,这些 Demo 均来自互联网上前辈们的文章。
自定义 ClassLoader :diskClassLoader
这个场景是需要自定义一个 ClassLoader,能加载自定义路径 /tmp/
目录下的 Class 文件。
-
待测试类 :
/tmp/test.class
由于
~/Desktop/
目录不在 CLASSPATH 中,因此JVM一定无法加载/tmp/test.class
文件。接下在我们会在
~/Desktop/
目录下编写一个 ClassLoader,使其能够加载/tmp
目录下的 class 文件。 -
自定义类加载器 :
diskClassLoader.class
该自定义类加载器会接收一个字符串作为类加载路径,此外还重写了
findClass(String name)
方法; 新的方法会接收一个字符串作为文件名,并读取文件内容; 最后调用defineClass()
方法来生成一个 Class 实例对象。接下來我们只需要编写一个类来调用自定义的类加载器即可。
-
测试类 :
testDiskClassLoader.class
该方法实际上就是调用自定义类加载器的
loadClass()
方法(其中包含了重写的findClass()
方法),然后获取返回类的实例对象,再通过反射来调用test()
方法的过程。最后,我们成功加载了默认类加载器指定路径外的 Class 文件。
自定义 ClassLoader :DecryptClassLoader.class
这个场景需要读取并加载一个加密的 Java 类。
-
待加密类 :
test.class
这个类与前文用到的相同,在 javac 编译后,我们能够直接通过 javap 来反编译生成的 class 对象。接下来我们需要编写一个加密类对该类加密。
-
加密类 :
encryptClass.class
加密的方式非常简单,即读取源 Class 文件的每一个字节,并让其与
0xFF
做异或运算( 按位取反操作 )。当加密操作结束后,已经无法通过 javap 来反编译加密后的文件了。
-
解密类 :
decryptClassLoader.class
这里重点都已经写在图里了,需要注意的就是在调用
defineClass()
时我们不能使用加密后的 Class 文件名,而要使用加密前的 Class 文件名,这样才能与解密后 Class 文件内容匹配。 -
测试类 :
testDecryptClassLoader.class
接着执行编译后的
testDecryptClassLoader.class
文件,来看一看是否能解密并调用test.test()
方法啪一个异常,非常 Nice 啊!下面来琢磨下为啥会抛异常。
defineClass() 抛出 ClassFormatError 异常的原因
其实上图这个异常已经能说明不少问题了,ClassFormatError 是在调用 defineClass()
方法时抛出的。根据我们的代码,当调用 defineClass()
方法时,传入的类名已经被修改为 test 了,并且传入的二进制字节流也是解密后的。
但是看上面这个异常,明显说的是加密后的文件 testCrypt 格式错误,无法被 defineClass()
方法识别为一个实例对象。难道说是类加载时根本没有调用我们自定义的类加载器?
想到这,其实答案就出来了,回顾双亲委派机制,类的加载是自上而下的,也就是按照( bootstrapClassLoader -> ExtensionsClassLoader -> AppClassLoader -> UserDefineClassLoader
)的顺序来调用类加载器的。
因此,JVM 会先调用 AppClassLoader 类加载器来加载我们指定的类( testCrypt.class
),如果 AppClassLoader 类加载器找不到它,才会调用 UserDefineClassLoader
类加载器来查找和加载。
AppClassLoader 能找到 testCrypt.class
吗?肯定能呀!上面的操作都是在同一个目录下编译并执行的。而 CLASSPATH
是包含当前路径的,所以 AppClassLoader 能够找到 testCrypt.class
如何解决这个问题? 给 test.class
和 testCrypt.class
换个路径就可以。具体操作如下:
-
修改
encryptClass.java
和testDecryptClassLoader.java
两个文件的路径 -
重新编译 java 文件,并将上述两个文件剪贴到对应的路径下,此时再执行测试类
testDecryptClassLoader.class
,就可以解密并调用test.test()
方法了
URLClassLoader 加载网络传输的 Class 文件
在 Java 安全中,java.net.URLClassLoader.class
这个类加载器是比较常用的,我们可以通过该类加载器来加载本地磁盘或者网络传输的 Class 文件。
这里重点来关注下如何通过 URLClassLoader 来加载网络传输的 Class 文件。
-
恶意类 :
evil.class
启动一个 Tomcat 服务,并在其 Web 根目录下生成一个恶意的 Class 文件( evil.class )
该 Class 文件用于弹出计算器,当 evil.class 被实例化时,就会触发恶意代码。
-
加载类 :
evilClassLoader.class
加载类要做的也就是加载恶意类并实例化。
恶意的类加载器从网络传输中加载了恶意的 evil.class 类并实例化,执行其中的构造方法,从而弹出计算器。
总结
本章的内容基本就到这里了。全文都是在围绕 ClassLoader ,我们看到了 JVM 内置的 3 种类加载器(bootstrapClassLoader
、extensionsClassLoader
、AppClassLoader
); 知道了如何自定义 UserDefineClassLoader
来实现不同的功能; 也实践了如何利用 URLClassLoader
来远程加载恶意类,执行其中的代码。
因此,了解 ClassLoader 机制对我们学习 Java 安全是非常重要的。