一文带你深扒ClassLoader内核,揭开它的神秘面纱!

本文深入剖析了Java的ClassLoader,揭示了其工作原理,包括类加载、链接(验证、准备、解析)和初始化的过程。文章详细介绍了双亲委派模型,探讨了自定义类加载器的场景,如加密解密和网络加载,并提供了相关代码示例。此外,还提到了类加载器在安全性、模块隔离和防止源码泄露等方面的应用。
摘要由CSDN通过智能技术生成

  • 「MoreThanJava」 宣扬的是 「学习,不止 CODE」
  • 如果觉得 「不错」 的朋友,欢迎 「关注 + 留言 + 分享」,文末有完整的获取链接,您的支持是我前进的最大的动力!

前言

ClassLoader 可以说是 Java 最为神秘的功能之一了,好像大家都知道怎么回事儿 (双亲委派模型好像都都能说得出来…),又都说不清楚具体是怎么一回事 (为什么需要需要有什么实际用途就很模糊了…)

今天,我们就来深度扒一扒,揭开它神秘的面纱!

Part 1. 类加载是做什么的?

首先,我们知道,Java 为了实现 「一次编译,到处运行」 的目标,采用了一种特别的方案:先 编译与任何具体及其环境及操作系统环境无关的中间代码(也就是 .class 字节码文件),然后交由各个平台特定的 Java 解释器(也就是 JVM)来负责 解释 运行。

ClassLoader (顾名思义就是类加载器) 就是那个把字节码交给 JVM 的搬运工 (加载进内存)。它负责将 字节码形式 的 Class 转换成 JVM 中 内存形式 的 Class 对象。

字节码可以是来自于磁盘上的 .class 文件,也可以是 jar 包里的 *.class,甚至是来自远程服务器提供的字节流。字节码的本质其实就是一个有特定复杂格式的字节数组 byte[] (从后面解析 ClassLoader 类中的方法时更能体会)

另外,类加载器不光可以把 Class 加载到 JVM 之中并解析成 JVM 统一要求的对象格式,还有一个重要的作用就是 审查每个类应该由谁加载

而且,这些 Java 类不会一次全部加载到内存,而是在应用程序需要时加载,这也是需要类加载器的地方。

Part 2. ClassLoader 类结构分析

以下就是 ClassLoader 的主要方法了:

  • defineClass() 用于将 byte 字节流解析成 JVM 能够识别的 Class 对象。有了这个方法意味着我们不仅可以通过 .class 文件实例化对象,还可以通过其他方式实例化对象,例如通过网络接收到一个类的字节码。

    (注意,如果直接调用这个方法生成类的 Class 对象,这个类的 Class 对象还没有 resolve,JVM 会在这个对象真正实例化时才调用 resolveClass() 进行链接)

  • findClass() 通常和 defineClass() 一起使用,我们需要直接覆盖 ClassLoader 父类的 findClass() 方法来实现类的加载规则,从而取得要加载类的字节码。(以下是 ClassLoader 源码)

    protected Class<?> findClass(String name) throws ClassNotFoundException {
         
      throw new ClassNotFoundException(name);
    }
    

    如果你不想重新定义加载类的规则,也没有复杂的处理逻辑,只想在运行时能够加载自己制定的一个类,那么你可以用 this.getClass().getClassLoader().loadClass("class") 调用 ClassLoader 的 loadClass() 方法来获取这个类的 Class 对象,这个 loadClass() 还有重载方法,你同样可以决定再什么时候解析这个类。

  • loadClass() 用于接受一个全类名,然后返回一个 Class 类型的对象。(该方法源码蕴含了著名的双亲委派模型)

  • resolveClass() 用于对 Class 进行 链接,也就是把单一的 Class 加入到有继承关系的类树中。如果你想在类被加载到 JVM 中时就被链接(Link),那么可以在调用 defineClass() 之后紧接着调用一个 resolveClass() 方法,当然你也可以选择让 JVM 来解决什么时候才链接这个类(通常是真正被实实例化的时候)。

ClassLoader 是个抽象类,它还有很多子类,如果我们要实现自己的 ClassLoader,一般都会继承 URLClassLoader 这个子类,因为这个类已经帮我们实现了大部分工作。

例如,我们来看一下 java.net.URLClassLoader.findClass() 方法的实现:

// 入参为 Class 的 binary name,如 java.lang.String
protected Class<?> findClass(final String name) throws ClassNotFoundException {
   
    // 以上代码省略
  
    // 通过 binary name 生成包路径,如 java.lang.String -> java/lang/String.class
    String path = name.replace('.', '/').concat(".class");
    // 根据包路径,找到该 Class 的文件资源
    Resource res = ucp.getResource(path, false);
    if (res != null) {
   
        try {
   
           // 调用 defineClass 生成 java.lang.Class 对象
            return defineClass(name, res);
        } catch (IOException e) {
   
            throw new ClassNotFoundException(name, e);
        }
    } else {
   
        return null;
    }
  
    // 以下代码省略
}

Part 3. Java 类加载流程详解

以下就是 ClassLoader 加载一个 class 文件到 JVM 时需要经过的步骤。

事实上,我们每一次在 IDEA 中点击运行时,IDE 都会默认替我们执行以下的命令:

  • javac Xxxx.java ➡️ 找到源文件中的 public class,再找 public class 引用的其他类,Java 编译器会根据每一个类生成一个字节码文件;
  • java Xxxx ➡️ 找到文件中的唯一主类 public class,并根据 public static 关键字找到跟主类关联可执行的 main 方法 (这也是为什么 main 方法需要被定义为 public static void 的原因了——我们需要在类没有加载时访问),开始执行。

在真正的运行 main 方法之前,JVM 需要 加载、链接 以及 初始化 上述的 Xxxx 类。

第一步:加载(Loading)

这一步是读取到类文件产生的二进制流(findClass()),并转换为特定的数据结构(defineClass()),初步校验 cafe babe 魔法数 (二进制中前四个字节为 0xCAFEBABE 用来标识该文件是 Java 文件,这是很多软件的做法,比如 zip压缩文件、常量池、文件长度、是否有父类等,然后在 Java 中创建对应类的 java.lang.Class 实例,类中存储的各部分信息也需要对应放入 运行时数据区 中(例如静态变量、类信息等放入方法区)。

以下是一个 Class 文件具有的基本结构的简单图示:

如果对 Class 文件更多细节感兴趣的可以进一步阅读:https://juejin.im/post/6844904199617003528

这里我们可能会有一个疑问,为什么 JVM 允许还没有进行验证、准备和解析的类信息放入方法区呢?

答案是加载阶段和链接阶段的部分动作(比如一部分字节码文件格式验证动作)是 交叉进行 的,也就是说 加载阶段还没完成,链接阶段可能已经开始了。但这些夹杂在加载阶段的动作(验证文件格式等)仍然属于链接操作。

第二步:链接(Linking)

Link 阶段包括验证、准备、解析三个步骤。下面👇我们来详细说说。

验证:确保被加载的类的正确性

验证是连接阶段的第一步,这一阶段的目的是 为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证阶段大致会完成 4 个阶段的检验动作:

  • 文件格式验证: 验证字节流是否符合 Class 文件格式的规范;例如:是否以 0xCAFEBABE 开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型。
  • 元数据验证: 对字节码描述的信息进行语义分析(注意:对比 javac 编译阶段的语义分析),以保证其描述的信息符合 Java 语言规范的要求;例如:这个类是否有父类,除了 java.lang.Object 之外。
  • 字节码验证: 通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。
  • 符号引用验证: 确保解析动作能正确执行。

验证阶段是非常重要的,但不是必须的,它对程序运行期没有影响,如果所引用的类经过反复验证,那么可以考虑采用 -Xverifynone 参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

准备:为类的静态变量分配内存,并将其初始化为默认值

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在 方法区 中分配。对于该阶段有以下几点需要注意:

  • 1️⃣ 这时候进行内存分配的 仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在 Java 堆中。

  • 2️⃣ 这里所设置的 初始值通常情况下是数据类型默认的零值(如 00Lnullfalse等),而不是被在 Java 代码中被显式地赋予的值。

  • 3️⃣ 如果类字段的字段属性表中存在 ConstantValue 属性,即 同时被 finalstatic 修饰,那么在准备阶段变量 value 就会被初始化为 ConstValue 属性所指定的值。

➡️ 例如,假设这里有一个类变量 public static int value = 666;,在准备阶段时初始值是 0 而不是 666,在 初始化阶段 才会被真正赋值为 666

➡️ 假设是一个静态类变量 public static final int value = 666;,则再准备阶段 JVM 就已经赋值为 666 了。

解析:把类中的符号引用转换为直接引用(重要)

解析阶段是虚拟机将常量池内的 符号引用 替换为 直接引用 的过程,解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符 7 类符号引用进行。

➡️ 符号引用 的作用是在编译的过程中,JVM 并不知道引用的具体地址,所以用符号引用进行代替,而在解析阶段将会将这个符号引用转换为真正的内存地址。

➡️ 直接引用 可以理解为指向 类、变量、方法 的指针,指向 实例 的指针和一个 间接定位 到对象的对象句柄。

为了理解👆上面两种概念的区别,来看一个实际的例子吧:

public class Tester {
   

    public static void main(String[] args) {
   
        String str = "关注【我没有三颗心脏】,关注更多精彩";
        System.out.println(str);
    }
}

我们先在该类同级目录下运行 javac Tester 编译成 .class 文件然后再利用 javap -verbose Tester 查看类的详细信息 (为了节省篇幅只截取了 main 方法反编译后的代码)

// 上面是类的详细信息省略...
{
   
	// .....
  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=1
         0: ldc           #7                  // String 关注【我没有三颗心脏】,关注更多精彩
         2: astore_1
         3: getstatic     #9                  // Field java/lang/System.out:Ljava/io/PrintStream;
         6: aload_1
         7: invokevirtual #15                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        10: return
      LineNumberTable:
        line 4: 0
        line 5: 3
        line 6: 10
}
SourceFile: "Tester.java"

可以看到,上面👆定义的 str 变量在编译阶段会被解析称为 符号引用,符号引用的标志是 astore_<n>,这里就是 astore_1

store_1的含义是将操作数栈顶的 关注【我没有三颗心脏】,关注更多精彩 保存回索引为 1 的局部变量表中,此时访问变量 str 就会读取局部变量表索引值为 1 中的数据。所以局部变量 str 就是一个符号引用。

再来看另外一个例子:

public class Tester {
   

    public static void main(String[] args) {
   
        System.out.println("关注【我没有三颗心脏】,关注更多精彩");
    }
}

这一段代码反编译之后得到如下的代码:

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值