理解类装载器

共 7143字,需浏览 15分钟

 ·

2020-08-04 10:18

阅读文本大概需要3分钟。

0x00. 类的生命周期

类装载器是 Java 中的一项创新,它使得 Java 虚拟机可以在执行的过程中再把一个 Java 类读入虚拟机,提高了程序的灵活性。在Java中,类的信息是被保存在方法区中的。在介绍类装载器之前,我们先了解一下 Java 中类的生命周期。Java 中一个类的生命周期可以划分为以下 6 个步骤:

  1. 装载,通过类加载器,把一个类的二进制读入到虚拟机中,并最终生成一个 Class 实例对象;

  2. 链接,把二进制数据合并到虚拟机的运行时状态中去,这一步又可以分为以下三个部分:

    1. 验证,确保二进制的格式正确;

    2. 准备,在方法区中为该类分配它所需要的内存;

    3. 解析,把常量池中的符号引用转化为值引用(这一步也可以在变量被使用到时再进行,即懒加载)

  3. 初始化:

    • 如果该类的父类尚未初始化,则先初始化其父类;

    • 如果该类存在一个初始化方法(),则执行此方法(初始化方法由编译器生成,程序员不可手动在 Java 源代码中添加);

  4. 对象的创建,如果程序中发现如下关键字newnewInstanceclonegetObject,则意味着需要在堆内存中创建一个对象,创建对象时会调用到()方法(对应类的构造方法),初始化方法执行前必须先调用父类的初始化方法;

  5. 对象的终结,如果一个对象不再被引用,则会在垃圾收集程序执行时被垃圾收集器收集,一个对象在被垃圾收集程序收集的时候会显式的调用其void finalize()方法(如果定义了该方法的话);

  6. 类的卸载,如果一个类不再使用,则也会被垃圾收集器收集。只有用户自定义的 ClassLoader 所装载的类才会被卸载,BootStrapClassLoader 所装载的类不会被卸载。

其中,以上的 1 ~ 3 步可以统称为类的初始化,类的初始化只可能在以下 5 种情况中发生(类初始化只会执行一次,如果类已经初始化了并且没有被卸载,则下次使用时不需要再进行初始化):

  1. 遇到 newgetstaticputstaticinvokestatic 关键字的时候;

  2. 使用 java.lang.reflect 包对类进行反射调用的时候;

  3. 当初始化一个类时,如果其父类尚未初始化,则会先初始化其父类;

  4. 当虚拟机启动时,包含 main() 方法的那个类会被初始化;

  5. 当使用 JDK1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例的最后解析结果是 REF_getStaticREF_putStaticREF_invokeStatic 的方法句柄的时候;

0x01. 类加载器基本概念

这篇文章所要讨论的类装载器对应的是类的生命周期中的第一步:装载。

顾名思义,类装载器的作用就是把一个Java的字节码数据加载到JVM中,并且生成一个java.lang.Class类的实例。每个这样的实例用来表示一个 Java 类,通过此实例的 newInstance()方法就可以创建出该类的一个对象。

我们可以通过java.lang.ClassLoader类来对一个字节码数据进行加载,该类主要包含以下和类加载相关的方法:

方法说明
getParent()返回该类加载器的父类加载器
loadClass(String name)加载名称为 name的类,返回的结果是 java.lang.Class类的实例
findClass(String name)查找名称为 name的类,返回的结果是 java.lang.Class类的实例
findLoadedClass(String name)查找名称为 name的已经被加载过的类,返回的结果是 java.lang.Class类的实例
defineClass(String name, byte[] b, int off, int len)把字节数组 b中的内容转换成 Java 类,返回的结果是 java.lang.Class类的实例。这个方法被声明为 final的
resolveClass(Class c)链接指定的 Java 类

其中最核心的就是方法是defineClass方法,它负责把一连串的字节码二进制数据转化为一个Class类的实例,而不论的这些字节码来自于什么地方。


0x02. JVM提供的类加载器

系统类加载器是由 JVM 提供的、可以直接使用的类加载器,JVM中的系统类加载器有如下三个:

  1. 引导类加载器(bootstrap class loader):它用来加载 Java 的核心库,该加载器由C++实现,不继承自 java.lang.ClassLoader。它负责加载Java的基础类,主要是 %JRE_HOME/lib/ 目录下的rt.jarresources.jarcharsets.jar和class等,如果想要使用引导类加载器来加载我们自己的jar包,可以使用如下的方式来实现

    我们可以在运行时使用如下参数:

    -Xbootclasspath:完全取代系统Java classpath.最好不用。
    -Xbootclasspath/a: 在系统class加载后加载。一般用这个。
    -Xbootclasspath/p: 在系统class加载前加载,注意使用,和系统类冲突就不好了.
    win32 java -Xbootclasspath/a: some.jar;some2.jar; -jar test.jar
    unix java -Xbootclasspath/a: some.jar:some2.jar: -jar test.jar
    win32系统每个jar用分号隔开,unix系统下用冒号隔开
  2. 扩展类加载器(extensions class loader):它用来加载 Java 的扩展库,主要是 %JRE_HOME/lib/ext 目录下的jar和class文件,你可以把需要加载的jar都扔到%JRE_HOME%/lib/ext下面,这个目录下的jar包会在Bootstrap Classloader工作完后由Extension Classloader来加载。

  3. 系统类加载器(system class loader):它根据 Java 应用的类路径(CLASSPATH)来加载 Java 类。一般来说,Java 应用的类都是由它来完成加载的,可以通过 ClassLoader.getSystemClassLoader() 来获取它。如果想要让指定的jar被加载,只需要在MANIFEST.MF中添加如下代码:Class-Path: lib/demo.jar lib/demo1.jar,就可以把指定的jar添加到CLASSPATH中了。

其中,Bootstrap ClassLoader由JVM启动,然后初始化sun.misc.Launcher ,sun.misc.Launcher初始化Extension ClassLoader、App ClassLoader。除了Bootstrap ClassLoader,其余的类加载器本身也由其它的类加载器进行加载,所以某个类加载器的父类加载器就是加载了这个类加载器的那个加载器。在JVM提供的加载器中,系统类加载器的父类加载器是扩展类加载器,扩展类加载器的父类加载器是引导类加载器。


0x03. 自定义类加载器

如果JVM提供的类加载器无法满足我们的需求,那我们就需要实现自己的类加载器。

自定义类加载器十分简单,只需要通过调用ClassLoader类的Class java.lang.ClassLoader.defineClass(String name, byte[] b, int off, int len)方法即可,不过由于该方法是final并且protected的,所以我们必须要继承ClassLoader类并且使用super.xxx()的格式来调用。下面是一个demo

import java.io.File;
import java.io.FileInputStream;
import java.lang.reflect.Method;

public class MyClassloader {

    public static void main(String[] args) throws Exception {
        ClassLoaderSub classLoaderSub = new ClassLoaderSub();
        Class clazz = classLoaderSub.getClassByFile("C:\\Users\\admin\\Desktop\\A.class");
        System.out.println("类的名字:" + clazz.getName());
        System.out.println("类的加载器:" + clazz.getClassLoader());
        Object obj = clazz.newInstance();
        Method method = clazz.getMethod("test");
        method.invoke(obj, null);
    }

}

class ClassLoaderSub extends ClassLoader {

    /**
     * 调用父类的 defineClass 来生成 Class 实例
     * @param name
     * @param b
     * @param off
     * @param len
     * @return
     */

    public Class defineClassByName(String name, byte[] b, int off, int len) {
        Class clazz = super.defineClass(name, b, off, len);
        return clazz;
    }

    /**
     * 读入字节码文件并把其转换为字节数组
     * 
     * @param fileName
     * @return
     * @throws Exception
     */

    @SuppressWarnings("finally")
    public Class getClassByFile(String fileName) throws Exception {
        File classFile = new File(fileName);
        byte bytes[] = new byte[1024 * 100];
        FileInputStream fis = null;
        Class clazz = null;
        try {
            fis = new FileInputStream(classFile);
            int j = 0;
            while (true) {
                int i = fis.read(bytes);
                if (i == -1)
                    break;
                j += i;
            }
            clazz = defineClassByName(null, bytes, 0, j);
        } finally {
            fis.close();
            return clazz;
        }
    }
}

A.class的内容很简单,编译前的源码如下:

public class A {

    public void test({
        System.out.println("我被加载成功并且方法执行了!");
    }

}

执行main()方法,打印出以下内容:

类的名字:A
类的加载器:ClassLoaderSub@15db9742
我被加载成功并且方法执行了!

以上代码的主要功能就是把 A.class的字节码读入到JVM中并且创建一个对应该字节码所对应的类的Class实例。然后根据该类来创建一个该类的对象并且调用其test()方法,方法成功执行。自定义的类加载器的核心组件就是defineClass方法,这个需要重点理解。

4.类加载器的树状组织结构

如果把JVM类加载器和自定义类加载器结合起来看的话,那么会构成一个继承的层次结构。我们已经知道,JVM的三个类加载器有继承关系,那么加上自定义类加载器之后继承关系会变成什么样呢,下面这张图很清晰的描述了这种结构


由于这种目录结构,JVM提出了类加载器的双亲委派机制,即

  • 如果某个类加载器需要加载一个类,那么此类加载器会调用它的父类加载器来加载这个类(如果某个类加载器的父类加载器为 null,那么就直接调用bootstrap class loader来进行类加载操作),一直向上直到bootstrap class loader被调用了,那么bootstrap class loader不会再调用父类加载器(也没有可以调用的),而是会自己对该类进行加载;

  • 如果bootstrap class loader的类加载操作失败了,那么就会调用其子类加载器进行加载;如果还是失败,就继续向下调用,直到成功为止。如果一直无法成功,则会抛出找不到类的异常。

双亲委派机制保证了JVM的安全性,因为恶意程序无法把自己伪造成JVM所信任的类。例如,我伪造了一个java.lang.Object类,想让JVM把它加载进去,但是由于双亲委派机制的存在,JVM默认会使用bootstrap class loader来加载java.lang.Object类,而因为bootstrap class loader默认会加载%JRE_HOME/lib/下的 java.lang.Object 文件,所以我的攻击自然失效。

那么,如果我更换一种攻击方式呢。我想让启动类加载器加载一个由我书写的名为java.lang.Attack的带有攻击代码类,那么我的攻击能成功吗?答案是不能。因为对于不同的类加载器所加载的类,它们将属于不同的运行时包。运行时包这个词在《Java虚拟机规范第2版》中第一次出现,如果两个类是由不同的类加载器进行加载的,那么他们就不可以进行相互访问。更典型的,如果我使用了两个类加载器加载了同一个类,那么这两个类是不一样的,如果让这两个类之中的某一个类的对象由另一个类来进行强制类型转换,会产生异常。

5. 关于Class.forName()方法:

Class.forName() 是一个静态方法,同样可以用来加载类。该方法有两种形式:Class.forName(String name, boolean initialize, ClassLoader loader)和 Class.forName(String className)。第一种形式的参数 name表示的是类的全名,initialize表示是否是初始化类,loader表示加载时使用的类加载器;第二种形式则相当于设置了参数 initialize的值为 true,loader的值为当前类的类加载器。

Class.forName()方法本身已经包含了类的加载过程。除此之外,Class.forName()还包括了第0节中的第2、3步操作,也就是说Class.forName()方法不仅会加载一个类,还会初始化这个类。这个方法一般被用于加载数据库的驱动,我们可以打开MySQL的驱动com.mysql.jdbc.Driver的源码看一下,可以发现如下代码:

static {
    try {
        java.sql.DriverManager.registerDriver(new Driver());
    } catch (SQLException E) {
        throw new RuntimeException("Can't register driver!");
    }
}

如上static代码块中的代码仅会在类初始化时才能执行,所以只能使用Class.forName()方法才能加载数据库的驱动。如果单纯的使用ClassLoader来加载数据库驱动,因为缺失了类初始化的操作,所以驱动加载将会失败。

本文链接:http://www.nosuchfield.com/2017/10/15/Spring-Boot-Starters/



往期精彩



01 Sentinel如何进行流量监控

02 Nacos源码编译

03 基于Apache Curator框架的ZooKeeper使用详解

04 spring boot项目整合xxl-job

05 互联网支付系统整体架构详解

关注我

每天进步一点点

喜欢!在看☟
浏览 10
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报