JavaSec - Java 基础 - JVM与类的加载

JavaSec - Java 基础 - JVM与类的加载

本篇主要记录一些在正式开始Java安全学习之前的一些基础知识的学习,说是基础,其实更多的是一些Java的底层概念的理解以及学习文章的分享,至于语法部分,请参考各种教程网站会更为高效

了解JVM (Java Virtual Machine)

参考:深入理解Java虚拟机到底是什么 相信大家都是使用一款顺手的IDE来写Java,在配合五花八门的插件写完代码之后,点击运行来观察效果。但是大家是否了解计算机到底是如何来执行Java代码的呢?

我们先拿C语言来进行对比举例,我们写完C语言代码之后,进行编译,然后就可以生成可执行文件,接着执行可执行文件,就相当于创建一个进程,并将可执行文件加载到进程的地址空间中,从而执行指令。

那么Java又是怎样呢? 我们以最基本的HelloWorld来举例:

我们首先创建一个HelloWorld.java文件,来写入下列Java代码:

public class HelloWorld {
    public static void main(String[] args) {
        System.out.println("Hello world!");
    }
}

和C语言源文件一样,HelloWorld.java并不能被直接执行,而是需要进行编译

javac HelloWorld.java

从而得到HelloWorld.class文件,这时候就可以使用Java命令来执行了

java HelloWorld

看上去和C语言的流程是一样的,但是区别在于,C语言最后编译得到的是可以直接由计算机开启进程来运行的可执行文件,而Java的class文件是无法被直接执行的,这时候就需要请JVM (Java Virtual Machine)登场了。

我们先简单地给出一个定义,到底什么是一个Java虚拟机?Java虚拟机和我们平时用到的虚拟机是一样,都是通过仿真实际计算机上的各种硬件的功能来实现的,例如处理器、堆栈、寄存器以及对应的指令系统,同时JVM会屏蔽与操作系统平台相关的信息,以此来实现跨平台使用。JVM实现的指令系统就是字节码,即.class文件。

这时候我们回顾刚才举的例子,最后是使用 java 命令来执行 .class文件,这里实际的效果就是 启动了一个JVM进程 ,然后由JVM来加载 .class文件(字节码)来进行翻译成CPU能够识别的指令来交给CPU执行。

Figure 1: Java编译运行

Figure 1: Java编译运行

接下来我们就开始介绍,JVM具体是如何处理 .class 文件

类文件结构

在正式分析类的加载机制之前,我想先来总结一下关于 类(class) 这个文件结构的相关知识,本人在学习的过程中,也有很多的收获。读者也可以自行跳过本章,直接阅读JVM的类加载机制。

首先我们来介绍JVM的两大特性: 平台无关性语言无关性

平台无关性 体现在JVM可以在任何硬件体系结构或者操作系统上运行,其也是响应Java诞生之出那个响亮的口号““一次编写,到处运行 (Write Once,Run Anywhere)”,随着虚拟机以及大量建立在虚拟机上的语言的蓬勃发展,越来越多的程序语言通过使用特殊的存储格式来存储编译的程序,来摆脱操作系统和指令集的限制,后面我们会具体说到,JVM使用的就是 class 这个特殊的字节码结构;

语言无关性 就更加反常识了,JVM不仅能运行Java程序,其他语言例如Kotlin、Clojure、Groovy、JRuby、JPython、Scala都可以在JVM上运行,它们都有一个共同的特点,可以被编译器转化为 "Class文件" 。因此,我们可以说JVM完全不关心不限制原本的编程语言是什么,其核心就是 "Class" 这种字节码存储格式,以及JVM所能提供的字节码指令。

Figure 2: JVM的语言无关性

Figure 2: JVM的语言无关性

Class类文件的结构

接下来我们具体来看一下Class类文件的结构,首先我们需要知道的是:任何一个Class文件都对应着唯一的一个类或接口的定义信息(即使是私有类也不例外),我们可以看一个例子

public class HelloWorld {
    private class TestPrivate{
        public TestPrivate() {

        }
    }
    public static void main(String[] args) {
        System.out.println("Hello world!");
    }

}

我们还是使用 javac 来对上main的java文件进行编译,生成的class文件就有两个:

HelloWorld$TestPrivate.class
HelloWorld.class

Class文件是一组以 8个字节 为基础单位的二进制流,所有的数据都紧密的排列,没有任何分隔符,如果需要存储8个字节以上的数据项时,使用大端对齐(高位在前)的方式进行存储。

Class文件中采用一种类似于C语言结构题的伪结构来存储所有的数据,其中包含两种数据类型:

  1. 无符号数
    • 最基本的数据类型,通过u1, u2, u4, u8来表示1, 2, 4, 8个字节的无符号数,可以用来表示数字、索引引用、数量值或者按照UTF-8编码构成字符串 值。
    • 表示多个无符号数或者与表组成的复合数据类型,表的命名总是习惯以 "_info" 结尾,具体的例子我们都会在后面看到
    • 因此我们也可以说整一个Class文件本质上就是一张 "表",存储了复合的数据结构

Figure 3: Class文件格式

Figure 3: Class文件格式

我们观察上图可以发现,整一个Class文件就是由多个无符号数以及表组成的一张表,同时,在以 "_info" 结尾的表的数据项之前,都有一个以 "_count" 结尾来命名的无符号数来表示表的大小。

魔数(Magic Number)与Class文件的版本

魔数,Magic Number这个词我们乍一看很别扭,但是如果练习过文件上传漏洞的师傅们肯定已经接触过了,它就是一个文件的文件头,例如GIF或者JPEG都有其专属的文件头,在文件上传中我们可以修改文件头来进行绕过。

每个Class文件的头4个字节被称为魔数(Magic Number),以此来判断一个Class文件是否能够被虚拟机所接受。

Class文件的魔数就是 0xCAFEBABE 象征着著名咖啡品牌Peet’s Coffee深受欢迎的Baristas咖啡。

紧接着的4个字节存储的是Class文件的版本号:第5和第6个字节(前四个为魔数)是次版本号(Minor Version),第7和第8个字节是主版本号(Major Version)。

Figure 4: Class魔数

Figure 4: Class魔数

Figure 5: Class文件的魔数十六进制实例

Figure 5: Class文件的魔数十六进制实例

我们可以使用之前的HelloWorld.class的实例来查看这个结构,可以看到4个字节的魔数为 0xcafebabe, 而主版本号为0x0034,也就是52,根据下面的对应表可以得知使用的JDK 8

Figure 6: Class文件JDK版本对应表

Figure 6: Class文件JDK版本对应表

我们之前提到过,Class类文件的排列非常紧凑,没有分隔符,紧接着主、次版本号之后存储的依次就是常量池、访问标志、类索引、父类索引与接口索引集合、字段表集合、方法表集合和属性表集合,这些内容我们在这里就不做展开介绍了,有兴趣的师傅可以读一读《深入理解Java虚拟机:JVM高级特性与最佳实践》的6.3节。

类加载的触发时机

在我们正式开始讲JVM的类加载机制之前,我们先来搞清楚什么时候会触发类加载机制。

总的来说,类加载的原则是:延迟加载,即能不加载就不加载;同样的类加载的时机就是,第一次需要使用这个类的信息时,常见的情况有:

  1. 访问调用静态成员
    • 访问类的静态方法(除了有final修饰时)
      • public static final int a =123;为常量,不需要加载
      • public static final int a = math.PI; 编译时不确定常量的时,会加载
    • 访问类的静态变量
  2. 第一次new对象的时候
    • 创建顶层父类的实例
    • 创建子类的实例时,优先加载其父类
    • 一个类被加载时,如果类中的静态代码块、静态方法或静态变量饮用到了另一个类,则这个类也会被加载
  3. JVM启动时,定义了main的类,启动main方法时该类会被加载
  4. 反射

虚拟机类加载机制

之前我们简单介绍了一些Class文件的结构以及触发类加载机制的时机,那么接下来我们要介绍的就是JVM具体是如何加载和使用这些Class文件。之前我们提到过,每一个类或者接口都需要一个单独的Class文件,从他们被加载到虚拟机内存中开始,到卸载出内存为止,一共要经历七个阶段,分别是:加载 (Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化 (Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备、解析三个部分统称 为连接(Linking)。

Figure 7: Class文件加载的生命周期

Figure 7: Class文件加载的生命周期

这里有两点要注意的是:

  1. 每一个阶段并不是等上一个阶段完成之后才开始的,而是在一个阶段的执行过程中就会调用另一个阶段,从而互相交叉混合进行的
  2. 同时,解析这个阶段的调用时间并不固定,其可以如何所示在准备(Preparation)时被调用,也可以等待初始化(Initilization)之后再开始

接下来我们将简单介绍一下每个阶段都发生了什么,主要参考《深入理解Java虚拟机:JVM高级特性与最佳实践》的7.3节,感兴趣的读者可以直接去阅读书本获取更多详细信息,也可以跳过本节,直接阅读关于 ClassLoader 类加载器的内容。

加载(Loading)
  1. 通过一个类的全限定名(唯一类名)来获取定义此类的二进制字节流。
    • 并不一定是一个Class文件,可以是从ZIP中读取(JAR、EAR、WAR),从网络中获取,Web Applet,从数据库中等等
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
验证(Veritification)
作为连接(Linking)的第一步,这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚 拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。

我们知道Java相较于C/C++来说是相对安全的,纯粹的Java无法做到一些危险的行为(例如访问数组边界以外的数据),但是我们之前提到过,Class文件是一个字节流,可以由其他的语言编译而来,同时字节流指令的功能是比Java多的,因此验证阶段主要是保证字节码的安全性,是必要的措施。

验证阶段大致可以分为四个阶段的检验动作:

  1. 文件格式验证(例如:是否以魔数0xCAFEBABE开头,主、次版本号是否在当前Java虚拟机接受范围之内)
  2. 元数据验证(例如:这个类是否有父类,这个类的父类是否继承了不允许被继承的类(被final修饰的类))
  3. 字节码验证(例如:保证任何跳转指令都不会跳转到方法体以外的字节码指令上)
  4. 符号引用验证(例如:符号引用中通过字符串描述的全限定名是否能找到对应的类)

具体的验证过程大家可以参考相关文档,这里也不做展开了

准备(Preparation)
“准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段”
  • 要强调的是,准备阶段仅仅为一个类的静态变量所分配内存,以及根据其类型设置初始值,任何额外的赋值都会在类被初始化之后才会进行,例如以下静态变量,在准备阶段过后会被赋予初始值0而不是123,因为此时尚未执行任何Java方法(构造器没有被调用),123的赋值要等到累的初始化阶段才会进行。

    public static int value = 123;
    
  • 这里有一种特殊情况,即字段属性表中存在ConstantValue属性,即用 static final 表示的常量,在准备阶段就会赋予其最终的赋值,即123

    public static final int value = 123;
    
解析(Resolution)
解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程,分为符号引用( Symbolic References)和直接引用( Direct References),主要完成以下内容的解析
  1. 类或接口的解析
  2. 字段解析
  3. 方法解析
  4. 接口方法解析
初始化(Initialization)
初始化是JVM真正开始执行由程序员便携的Java程序代码的阶段,此时JVM的主导权由JVM本身过渡到了应用程序。

初始化阶段 顾名思义就是来给之前在 准备阶段 进行初始零值赋值之后的变量进行初始化;

这时,Javac编译器将会自动构造一个方法: <clinit>(),其主要由编译器自动收集类的定义中所有的 静态变量赋值 以及 静态语句块(static{}块) 结合而成的。

我们可以观察一下下面两组代码,分别是有静态变量赋值的和没有静态变量赋值的情况, .class 中就会有 <clinit>() 的区别

  public class HelloWorld {
    public HelloWorld() {
    }
    public static void main(String[] args) {
        HelloWorld helloWorld = new HelloWorld();
        System.out.println("Hello world!");
    }
  
}

Figure 8: 没有&lt;clinit&gt;()的情况

Figure 8: 没有<clinit>()的情况

public class HelloWorld {
    public static int i;
    public HelloWorld() {
    }
    static{
        i = 4;
    }
    public static void main(String[] args) {
        HelloWorld helloWorld = new HelloWorld();
        System.out.println("Hello world!");
    }

}

Figure 9: 静态变量赋值生成&lt;clinit&gt;()

Figure 9: 静态变量赋值生成<clinit>()

我们在 .class 的字节流中,还可以观察到有一个方法 <init>(),其“是实例构造器方法,对非静态变量解析初始化,在 new 一个对象时调用对象类的 constructor 方法时才会执行 <init>() 方法”

实例化一个类有四种途径:

  1. 调用new操作符;
  2. 调用 Class 或java.lang.reflect.Constructor 对象的 newInstance() 方法;
  3. 调用任何现有对象的 clone() 方法;
  4. 通过 java.io.ObjectInputStream 类的getObject() 方法反序列化。

值得注意的是Java编译器会为它的每一个类都至少生成一个实例初始化方法 <init>() 方法。

类加载器

类加载器是一段代码,用来帮助应用程序决定如何获取所需的类,即如何加载到JVM当中去,因此类加载器是在JVM外部实现的。

类与类加载器

类加载器可以用来帮助Java类确定其唯一性:因为一个Java类的唯一性是由 加载它的类加载器 以及 这个类本身 来决定的。

这将体现在针对Class对象的 equals(), isAssignableFrom(), isInstance() 方法的判断结果中。也就是说,要判断两个类是否源于同一个Class文件,只有在他们被同一个类加载器加载的情况下,才有意义。

双亲委派模型 The parent-delegation model

在我们介绍双亲委派模型之前,我们先来说说Java中的类加载器。我们从两个角度来认识这些类加载器:

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

  1. 启动类加载器(BootstrapClassLoader)
    • 由C++实现,作为虚拟机自身的一部分
  2. 其他所有的加载器
    • 由Java语言实现,独立存在于虚拟机外部
    • 全部继承抽象类 java.lang.ClassLoader

站在Java开发人员的角度,我们可以更加细致地去划分加载器的种类,在JDK 8以及之前的Java版本中,有一个三层加载器的架构:

  1. 启动类加载器(Bootstrap Class Loader)

    • 只负责加载存放在 <JAVA_HOME>/lib 目录中的类

    • 亦或者是 java 命令的参数 -Xbootclasspath 所制定的路径中存放的类

    • 同时需要是JVM能够识别的类库,如rt.jar, tools.jar等(如果名字不符合即使放在目录下也无法被加载)

      Figure 10: &lt;JAVA_HOME&gt;/lib

      Figure 10: <JAVA_HOME>/lib

  2. 扩展类加载器(Extension Class Loader)

    • 由Java代码实现的,实现类为:sun.misc.Launcher$ExtClassLoader

    • 负责加载<JAVA_HOME>/lib/ext中的类库

    • 或者由 java.ext.dirs 系统变量所指定的路径中的所有类库

    • 主要目的是帮助Java系统扩展类库

  3. 应用程序类加载器(Application Class Loader)

    • 程序默认的类加载器,又叫“系统类加载器”
    • 负责加载用户类路径(ClassPath)上的所有的类库,ClassPath默认为 java 命令执行的目录的路径

Figure 11: 类加载器双亲委派模型

Figure 11: 类加载器双亲委派模型

接下来我们来介绍 双亲委派模型 The parent-delegation model, 我们先来正确理解一下这个模型的名字,双亲委派模型 The parent-delegation model,这里的“双亲”重点在‘亲’,表示父类,而不在于‘双’(没有两个的意思),其基本原则是,“除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器”。

双亲委派模型的工作过程是:

  1. 当一个类加载器收到了类加载的请求,它会把这个请求委派(delegate)给 父类(双亲)加载器 去完成,而不是自己先尝试加载
  2. 每一个层次的类加载器都是如此,因此所有的类加载都应该被层层委托直到最顶层的 启动类加载器(Bootstrap Class Loader)
  3. 只有当父类加载器无法完成这个请求时,子加载器才会尝试自己去完成

这个工作流程乍一听还是比较反直觉的,我们一般可能会觉得能在底层子类完成就不会往上传递,但是双亲委派模型的有点就在于,这样做更容易保证类的唯一性。还记得我们之前提到过一个类的唯一性是需要拥有同样的 类加载器 以及 类本身 来决定的。比如类 java.lang.Object,其存放于 rt.jar 当中,无论在什么条件下,任何类加载器想要加载这个类,都会直接委派给最顶端的 启动类加载器(Bootstrap Class Loader) 来完成,就会减少很多的混乱。

我们可以看看双亲委派模型的实现代码,也是简单易懂的:

protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
{
    synchronized (getClassLoadingLock(name)) {
        // First, check if the class has already been loaded;首先,检查类是否已经被加载了
        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 parent class loader
            }

            if (c == null) {
                // If still not found, then invoke findClass in order;如果还是没有办法加载,就尝试类本身的findClass方法
                // to find the class.
                long t1 = System.nanoTime();
                c = findClass(name);

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

ClassLoader 详解

有了基本的概念之后,我们接着就要结合源码来具体看看我们之前提到的三层ClassLoader的内容,本章节主要参考:一看你就懂,超详细java中的ClassLoader详解

sum.misc.launcher

扩展类加载器(Extension Class Loader) 以及 应用程序类加载器(Application Class Loader) 是定义在 sum.misc.launcher 类中的两个静态类(不需要实例化就可以调用),此类也是JVM的一个入口应用。

下面是其精简化的源码

public class Launcher {
    private static Launcher launcher = new Launcher();
    private static String bootClassPath =
        System.getProperty("sun.boot.class.path");

    public static Launcher getLauncher() {
        return launcher;
    }

    private ClassLoader loader;

    public Launcher() {
        // Create the extension class loader
        ClassLoader extcl;
        try {
            extcl = ExtClassLoader.getExtClassLoader();
        } catch (IOException e) {
            throw new InternalError(
                "Could not create extension class loader", e);
        }

        // Now create the class loader to use to launch the application
        try {
            loader = AppClassLoader.getAppClassLoader(extcl);
        } catch (IOException e) {
            throw new InternalError(
                "Could not create application class loader", e);
        }

        Thread.currentThread().setContextClassLoader(loader);
    }

    /*
     * Returns the class loader used to launch the main application.
     */
    public ClassLoader getClassLoader() {
        return loader;
    }
    /*
     * The class loader used for loading installed extensions.
     */
    static class ExtClassLoader extends URLClassLoader {}

/**
     * The class loader used for loading from java.class.path.
     * runs in a restricted security context.
     */
    static class AppClassLoader extends URLClassLoader {}

我们可以观察到在 Launcher() 构造器中:

  1. 定义了 extcl 以及 loader 两个变量来获取 Extension 以及 Application 两种类加载器
  2. 同时启动类加载器(Bootstrap Class Loader)也暗藏其中,类中的静态变量 bootClassPath 所包含的内容就是 Bootstrap 类加载器所加载的部分

使用下面的代码我们可以查看 sun.boot.class.path 中所包含的文件目录正是我们之前提到的Bootstrap ClassLoader所加载的jar包目录

for (String path : System.getProperty("sun.boot.class.path").split(":")) {
    System.out.println(path);
}

Figure 12: BootstrapClassLoader所加载的核心jar包路径

Figure 12: BootstrapClassLoader所加载的核心jar包路径

ExtClassLoader

我们接着来看 ExtClassLoader 的源码,同样是精简部分

/*
     * The class loader used for loading installed extensions.
     */
    static class ExtClassLoader extends URLClassLoader {

        static {
            ClassLoader.registerAsParallelCapable();
        }

        /**
         * create an ExtClassLoader. The ExtClassLoader is created
         * within a context that limits which files it can read
         */
        public static ExtClassLoader getExtClassLoader() throws IOException
        {
            final File[] dirs = getExtDirs();

            try {
                // Prior implementations of this doPrivileged() block supplied
                // aa synthesized ACC via a call to the private method
                // ExtClassLoader.getContext().

                return AccessController.doPrivileged(
                    new PrivilegedExceptionAction<ExtClassLoader>() {
                        public ExtClassLoader run() throws IOException {
                            int len = dirs.length;
                            for (int i = 0; i < len; i++) {
                                MetaIndex.registerDirectory(dirs[i]);
                            }
                            return new ExtClassLoader(dirs);
                        }
                    });
            } catch (java.security.PrivilegedActionException e) {
                throw (IOException) e.getException();
            }
        }

        private static File[] getExtDirs() {
            String s = System.getProperty("java.ext.dirs");
            File[] dirs;
            if (s != null) {
                StringTokenizer st =
                    new StringTokenizer(s, File.pathSeparator);
                int count = st.countTokens();
                dirs = new File[count];
                for (int i = 0; i < count; i++) {
                    dirs[i] = new File(st.nextToken());
                }
            } else {
                dirs = new File[0];
            }
            return dirs;
        }

......
    }
for (String path : System.getProperty("java.ext.dirs").split(":")) {
    System.out.println(path);
}

Figure 13: java.ext.dirs目录

Figure 13: java.ext.dirs目录

我们可以看到函数 getExtDirs() 得到了Extension ClassLoader所需要加载的目录,即 java.ext.dirs,这也正是我们之前提到过的Extension ClassLoader所加载的目录

AppClassLoader

最后是AppClassLoader的精简源码:

/**
     * The class loader used for loading from java.class.path.
     * runs in a restricted security context.
     */
    static class AppClassLoader extends URLClassLoader {


        public static ClassLoader getAppClassLoader(final ClassLoader extcl)
            throws IOException
        {
            final String s = System.getProperty("java.class.path");
            final File[] path = (s == null) ? new File[0] : getClassPath(s);


            return AccessController.doPrivileged(
                new PrivilegedAction<AppClassLoader>() {
                    public AppClassLoader run() {
                    URL[] urls =
                        (s == null) ? new URL[0] : pathToURLs(path);
                    return new AppClassLoader(urls, extcl);
                }
            });
        }

        ......
    }

还是关注目录,即 java.class.path

使用IDEA得到的结果如下

Figure 14: java.class.path目录

Figure 14: java.class.path目录

总结一下,我们使用BootstrapClassLoader, ExtClassLoader以及AppClassLoader在本质上其实就是通过查阅相应的环境属性来加载文件

  • sun.boot.class.path
  • java.ext.dirs
  • java.class.path

父类加载器

我们在了解了每个加载器的作用范围之后,接下来就结合源码以及之前学习过的双亲委派模型来聊聊这三个加载器之间的关系

继续使用上一个HelloWorld.java,并且试图使用 getClassLoader 来获取对应的类的加载器,以及 getParent() 来获取父类加载器

public class ClassLoaderTest {
    public static void main(String[] args) {
        ClassLoader cl = HelloWorld.class.getClassLoader();
        System.out.println("HelloWorld's ClassLoader is :" + cl.toString());
        System.out.println("The parent ClassLoader is :" + cl.getParent().toString());
        System.out.println("The parent's parent ClassLoader is :" + cl.getParent().getParent().toString());
    }
}

结果:

HelloWorld's ClassLoader is :sun.misc.Launcher$AppClassLoader@18b4aac2
The parent ClassLoader is :sun.misc.Launcher$ExtClassLoader@4554617c
Exception in thread "main" java.lang.NullPointerException
    at ClassLoaderTest.main(ClassLoaderTest.java:6)

从上面的记过我们可以看出HelloWorld对应的类加载器为 AppClassLoader 这符合我们的认识,即AppClassLoader是默认的类加载器,同时其父类加载器为Extension ClassLoader.

但是,按照我们在双亲委派模型那一节中所学习的,Extension ClassLoader的父类加载器应该是Bootstrap ClassLoader可以为什么这里却报了Null呢?

Null是 getParent() 的结果,因此我们自然而然的就想去看看 getParent() 的源码,其是定义在 ClassLoader 类中的方法:

public final ClassLoader getParent() {
    if (parent == null)
        return null;
    SecurityManager sm = System.getSecurityManager();
    if (sm != null) {
        // Check access to the parent class loader
        // If the caller's class loader is same as this class loader,
        // permission check is performed.
        checkClassLoaderPermission(parent, Reflection.getCallerClass());
    }
    return parent;
}

而用于判断的 parent 变量,则是在ClassLoader对象的三个构造器中被赋值的

public abstract class ClassLoader {

private final ClassLoader parent;

private ClassLoader(Void unused, ClassLoader parent) {
    this.parent = parent;
    ...
}
protected ClassLoader(ClassLoader parent) {
    this(checkCreateClassLoader(), parent);
}
protected ClassLoader() {
    this(checkCreateClassLoader(), getSystemClassLoader());
}
}

我们可以观察到在这三种构造器中,对于 parent 的赋值有两种情况

  1. 在构造器方法中指定需要该参数
  2. 使用 getSystemClassLoader() 默认赋值,即默认使用 AppClassLoader

第一种情况我们已经在之前的 Launcher 类的源码中见过了,即其在初始化 AppClassLoader时,主动地定义了ExtClassLoader为其的父类加载器

ClassLoader extcl;
extcl = ExtClassLoader.getExtClassLoader();
loader = AppClassLoader.getAppClassLoader(extcl);
        public static ClassLoader getAppClassLoader(final ClassLoader extcl)
            throws IOException
        {
......
                    return new AppClassLoader(urls, extcl);
                }
AppClassLoader(URL[] urls, ClassLoader parent) {
            super(urls, parent, factory);
            ucp = SharedSecrets.getJavaNetAccess().getURLClassPath(this);
            ucp.initLookupCache(this);
        }

相反的,在ExtClassLoader的初始化中,却没有为 parent 变量进行赋值,而使用的是 Null,因此我们可以知道使用 getParent() 来获取 ExtClassLoader的父类加载器为什么是Null了

public ExtClassLoader(File[] dirs) throws IOException {
            super(getExtURLs(dirs), null, factory);
            SharedSecrets.getJavaNetAccess().
                getURLClassPath(this).initLookupCache(this);
        }

URLClassLoader&ClassLoader:父类加载器不是继承关系

细心的师傅可能会看到 AppClassLoader 以及 ExtClassLoader 在Launcher中的定义都继承自 URLClassLoader

这里单独把这个继承关系拎出来讲也是为了避免把父类加载器和传统的继承关系进行混淆

Figure 15: ClassLoader继承关系

Figure 15: ClassLoader继承关系

知道了这个继承关系之后,我们也可以更好的理解为什么ExtClassLoader的父类加载器Bootstrap ClassLoader为什么是用Null来表示了:因为在顶层父类ClassLoader中的 getClassLoader() 方法的注释中就说明了,如果是null就表示bootstrap classLoader.

/*
     * Returns the class loader for the class.  Some implementations may use
     * null to represent the bootstrap class loader. This method will return
     * null in such implementations if this class was loaded by the bootstrap
     * class loader.
*/    
    public ClassLoader getClassLoader() {
        ClassLoader cl = getClassLoader0();
        if (cl == null)
            return null;
        SecurityManager sm = System.getSecurityManager();
        if (sm != null) {
            ClassLoader.checkClassLoaderPermission(cl, Reflection.getCallerClass());
        }
        return cl;
    }

回归类的加载:loadClass()

相信在上述这么多内容的介绍之后,大家已经对于类加载器的分类,三层结构的关系,类加载器的作用范围都有了比较基础的认识。

我们接下来重新回到最为重要的双亲委派模型,来总结一下加载一个类的过程

protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
{
    synchronized (getClassLoadingLock(name)) {
        // First, check if the class has already been loaded;首先,检查类是否已经被加载了
        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 parent class loader
            }

            if (c == null) {
                // If still not found, then invoke findClass in order;如果还是没有办法加载,就尝试类本身的findClass方法
                // to find the class.
                long t1 = System.nanoTime();
                c = findClass(name);

                // this is the defining class loader; record the stats
                sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                sun.misc.PerfCounter.getFindClasses().increment();
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}
  1. 执行 findLoadedClass() 来检查类是否已经加载过了
  2. 首先尝试委派父类加载器来对类进行加载
    1. 显示定义父类加载器,如AppClassLoader的父类加载器ExtClassLoader
    2. 默认Bootstrap ClassLoader (null)
  3. 如果父类加载器没有加载成功(c=null),则通过类本身重写的 findClass() 查找
  4. 最后如果 resolve 不为null,就使用 resolveClass() 方法来链接生成最终的Class
  5. 最后返回被JVM加载之后的 java.lang.Class类对象

自定义ClassLoader

我们之前介绍了三层的类加载器,分别是Bootstrap, Extension以及Application ClassLoader,他们都是用来加载指定的目录下的Class字节流的,可以说是一种 静态加载 模式

接下来我们来看看用户如何自定义一个类加载器,从而 动态 地加载Class字节流,比如从特殊的目录或者从网络上进行加载。

自定义一个ClassLoader有三个步骤

  1. 编写一个新的类来继承(extends) 抽象类ClassLoader
  2. 重写 findClass() 方法
  3. findClass() 方法中调用 defineClass() 方法来将class二进制内容转换成Class对象

比如我们需要加载一个放置在 Home 目录下的类文件 Test.java,然后使用javac命令将其编译得到Test.class文件

public class Test {

    public void say(){
        System.out.println("Say Hello");
    }

}

然后我们来编写一个自定义类加载器 HomeClassLoader 来对根目录下的这个类进行加载

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;


public class HomeClassLoader extends ClassLoader {

    private String homePath;

    public HomeClassLoader(String path) {
        homePath = path;
    }
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {

        String fileName = getFileName(name);

        File file = new File(homePath, fileName);

        try {
            FileInputStream is = new FileInputStream(file);

            ByteArrayOutputStream bos = new ByteArrayOutputStream();
            int len = 0;
            try {
                while ((len = is.read()) != -1) {
                    bos.write(len);
                }
            } catch (IOException e) {
                e.printStackTrace();
            }

            byte[] data = bos.toByteArray();
            is.close();
            bos.close();

            return defineClass(name, data, 0, data.length);

        } catch (IOException e) {
            e.printStackTrace();
        }
        return super.findClass(name);
    }
    private String getFileName(String name) {
        int index = name.lastIndexOf('.');
        if (index == -1) {
            return name + ".class";
        } else {
            return name.substring(index + 1) + ".class";
        }
    }

}

可以看到我们在这里重写了 findClass() 方法,同时在其中调用了 defineClass() 方法来将字节流转换成Class类

最后我们可以测试一下使用 HomeClassLoader 来加载项目工程之外的根目录下的 Test.class

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;

public class ClassLoaderTest {

    public static void main(String[] args) {
        //创建自定义classloader对象。
        HomeClassLoader homeLoader = new HomeClassLoader("/Users/YourName/");
        try {
            //加载class文件
            Class c = homeLoader.loadClass("Test");

            if (c != null) {
                try {
                    Object obj = c.newInstance();
                    Method method = c.getDeclaredMethod("say", null);
                    //通过反射调用Test类的say方法
                    method.invoke(obj, null);
                } catch (InstantiationException | IllegalAccessException | NoSuchMethodException | SecurityException |
                         IllegalArgumentException | InvocationTargetException e) {
                    e.printStackTrace();
                }
            }
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }

    }

}

成功执行,关于反射的内容我们将会在新的文章中介绍

总结

本文较为冗长,是Java Sec系列的第一篇文章,主要是介绍了JVM的一些内容,包括:

  1. Java文件的编译执行
  2. JVM的平台无关性与语言无关性
  3. 了解了Class文件的文件结构与特性
  4. 类加载器的分类与作用
  5. JVM类加载机制的核心:双亲委派模型
  6. ClassLoader结构与源码解析
  7. 自定义类的构造

学习下来的感觉:虽然一直说Java很安全,但是Class文件的的字节码指令更为全面的功能却增加了风险,同时用户自定义的加载类可能也是可以找到漏洞的地方。

之后在新的文章中,我们会一起学习JVM特别是ClassLoader中的一些漏洞利用以及相关的安全知识。

Reference

深入理解Java虚拟机到底是什么

java什么时候会触发类加载_java中类的加载,及执行顺序

java中类何时被加载_java类在何时被加载

一看你就懂,超详细java中的ClassLoader详解

ClassLoader(类加载机制)

《深入理解Java虚拟机:JVM高级特性与最佳实践》

Licensed under CC BY-NC-SA 4.0
Last updated on Sep 10, 2022 10:03 CST
comments powered by Disqus
Cogito, ergo sum
Built with Hugo
Theme Stack designed by Jimmy