JVM- 类加载机制

本贴最后更新于 1687 天前,其中的信息可能已经时移世易

今年网上充斥着各种令人焦虑的中年危机新闻,很自然的影响到了我。思来想去,还是需要提升自己的核心竞争力。稍微出去面了一下,看看自己竞争力还够不够,发现了问题比我想象中还要严重,随便问两个问题就能把我难倒。原因在于随着时间的流逝,好多以前倒背如流的知识点,现在不给点提示,我自己压根想不起来。这背后的原因,并不能简单用没有持续学习来搪塞的,因为问到的知识点也没有多深入,刚毕业那会儿就很清楚了。这当然是自己工作中疏于总结,疏于温故知新造成的。所以我决定从近期开始,对自己的知识体系重新开始梳理。就从这篇 JVM 的类加载开始吧~~

1 类加载过程

image.png

JVM 类的生命周期如上图所示。在使用类之前,需要对类进行加载。JVM 类加载分为五个部分:加载、验证、准备、解析、初始化,其中验证、准备、解析阶段可以统称为连接阶段。

1.1 加载

加载是类加载过程的第一个阶段。这个阶段会在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的入口。 Class 对象封装了类在方法区内的数据结构,并且向 Java 程序员提供了访问方法区内的数据结构的接口。
注意这里不一定非得要从一个 Class 文件中获取,这里既可以从 zip 包中读取(比如 jar/war 包),孱可以在运行时计算生成(动态代理),也可以由其它文件生成(比如将 JSP 文件转换成对应的 Class 类。只需要提供给类加载器的字节流符合规范。

1.2 连接

1.2.1 验证

这个阶段的主要目的是确保 Class 文件的字节流满足当前虚拟机的要求,并且不会危害虚拟机本身的安全。验证主要包含四个部分:类文件的结构检查,语义检查,字节码验证,二进制兼容性的验证。

1.2.2 准备

准备阶段是正式为类变量分配内存并设置类变量的初始值阶段,即在方法区中分配这些变量使用的空间。这里说的初始值概念,指的是虚拟机规范给定的默认值,而不是代码中申明的初始值(即此时没有初始化为真正的初始值)。举个例子:

public static int money = 1000;

实际上,在准备阶段,给变量 money 分配的值为 0,而非 1000。将 port 赋值为 1000 是在后续的初始化阶段完成的。对应到字节码,就是方法执行 putstatic 指令时完成。如下所示:

package cn.xiajl.jvm.classloader;

public class MyTest27 {
    public static int MAX_MONEY = 1000;
}
// 以下为执行javap -verbose cn.xiajl.jvm.classloader.MyTest27的结果,略去无关的细节。
public class cn.xiajl.jvm.classloader.MyTest27
  interfaces: 0, fields: 1, methods: 2, attributes: 1
Constant pool:
   #5 = Utf8               MAX_MONEY
   #6 = Utf8               I
{
  public static int MAX_MONEY;
    descriptor: I
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC

  static {};
    descriptor: ()V
    flags: (0x0008) ACC_STATIC
    Code:
      stack=1, locals=0, args_size=0
         0: sipush        1000
         3: putstatic     #2                  // Field MAX_MONEY:I
         6: return
} 

需要注意的是,如果一个类变量申明为 final 类型,如下所示:

public static final int MAX_MONEY = 1000;

此时,准备阶段会直接将 MAX_MONEY 设为 1000,并且将 MAX_MONEY 生成 ConstantValue 属性,标明为常量。并且在方法中中所有引用到 MAX_MONEY 的部分,直接替换为常量 1000,如下所示:

package cn.xiajl.jvm.classloader;

public class MyTest28 {
    public static final int MAX_MONEY = 1000;

    public static void main(String[] args) {
        int currentMoney = MAX_MONEY;
        System.out.println(currentMoney);
    }
}

// 以下为执行javap -verbose cn.xiajl.jvm.classloader.MyTest28的结果,略去无关的细节。
public class cn.xiajl.jvm.classloader.MyTest28
Constant pool:
   ...
   #6 = Utf8               MAX_MONEY
   #7 = Utf8               I
   #8 = Utf8               ConstantValue
   #9 = Integer            1000
   ...
{
  public static final int MAX_MONEY;
    descriptor: I
    flags: (0x0019) ACC_PUBLIC, ACC_STATIC, ACC_FINAL
    ConstantValue: int 1000

  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: sipush        1000
         3: istore_1
         4: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
         7: iload_1
         8: invokevirtual #4                  // Method java/io/PrintStream.println:(I)V
        11: return
} 

1.2.3 解析

解析阶段是指将虚拟机中的符号引用替换为直接引用的过程。符号引用就是 class 文件中的:CONSTANT_Class_info、CONSTANT_Field_info、Constant_Method_info 等类型的常量。

  • 符号引用
    符号引用与虚拟机实际的布局无关,引用的目标不一定要加载到内存中。各种虚拟机实现的内存布局可以各不相同。但是它们能接受的符号引用必须是一致的,因为符号引用的字面量形式明确定义在 Java 虚拟机规范的 Class 文件格式中。
  • 直接引用
    直接引豕可以是指向目标的指针,相对偏移量或是一个能间接定义到目标的句柄。如果有了直接引用,那引用的目标必须已经在内存中存在。

1.3 初始化

初始化阶段是执行类构造器折方法的过程。方法是由编译器自动收集类中的类变量的赋值操作和静态语句块中的语句合并而成的。 虚拟机会保证子执行之前,父类的方法已经执行完毕,如果一个类中没有对静态变量赋值也没有静态语句块,那么编译器可以不为这个类生成方法。

注意,类的初始化只有在以下 7 种情况下对类的主动使用才会发生:

  1. 创建类的实例
  2. 访问某个类或接口的静态变量,或者对该静态变量赋值
  3. 调用类的静态方法
  4. 反射(如 Class.forName("com.test.Test"))
  5. 初始化一个类的子类
  6. Java 虚拟机启动时被标明为启动类的类(java com.test.Test)
  7. JDK1.7 开始提供的动态语言支持:java.lang.invoke.MethodHandle 实例的解析结果 REF_getStatic,REF_putStatic, REF_invokeStatic 句柄对应的类没有初始化,则初始化

尤其注意,以下 6 种情况不会发生类的初始化:

  1. 通过子类引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化。
  2. 定义对象数组,不会触发该类的初始化。
  3. 常量在编译器间会存入调用类的常量池中,本质上没有直接引用定义常量的类,不会触发定义常量所在的类。
  4. 通过类名获取 Class 对象,不会触发类的初始化。
  5. 通过 Class.forName 加载指定类时,如果指定参数 initalize 为 false 时,也不会触发类初始化,其实这个参数是告诉虚拟机,是否要对灰进行初始化。
  6. 通过 ClassLoader 默认的 loadClass 方法,也不会触发初始化动作。

1.4 使用

类实例化时,就进行类的使用阶段。此时,JVM 会为新对象分配内存,为实例变量赋默认值,为实例变量赋正确的初始值。
java 编译器为它编译的每一个类都至少生成一个实例化方法。在 java 的 class 文件中,这个实例初始化方法被称为""。针对源代码中每一个类的构造方法,java 编译器都产生一个方法。
对于这个方法,需要注意的是:对于 HotSpot 虚拟机,构造块{}中的代码会一并编译进方法中,如果有多个方法,构造块中的指令会复制成多份拷贝进每个方法中。
总结一下就是:在编译后,构造块会合并进实例化方法,对象的实例化只需要执行实例化方法即可。

举例如下,我们会看到两个实例化方法编译后的 4-13 行实际上对应的就是构造块的指令:

package cn.xiajl.jvm.classloader;

public class MyTest29 {
    private int money = 10;
    {
        money = 20;
    }

    public MyTest29(int money) {
        this.money = money;
    }

    public MyTest29() {
        this.money = 30;
    }
}

// 以下为执行javap -verbose cn.xiajl.jvm.classloader.MyTest29的结果,略去无关的细节。

public class cn.xiajl.jvm.classloader.MyTest29
  interfaces: 0, fields: 1, methods: 2, attributes: 1
Constant pool:
   #2 = Fieldref           #3.#18         // cn/xiajl/jvm/classloader/MyTest29.money:I
   #5 = Utf8               money
   #6 = Utf8               I
   #7 = Utf8               <init>
   #8 = Utf8               (I)V
  #14 = Utf8               ()V
  #17 = NameAndType        #7:#14         // "<init>":()V
  #18 = NameAndType        #5:#6          // money:I
{
  public cn.xiajl.jvm.classloader.MyTest29(int);
    descriptor: (I)V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=2, locals=2, args_size=2
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: aload_0
         5: bipush        10
         7: putfield      #2                  // Field money:I
        10: aload_0
        11: bipush        20
        13: putfield      #2                  // Field money:I
        16: aload_0
        17: iload_1
        18: putfield      #2                  // Field money:I
        21: return

  public cn.xiajl.jvm.classloader.MyTest29();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: aload_0
         5: bipush        10
         7: putfield      #2                  // Field money:I
        10: aload_0
        11: bipush        20
        13: putfield      #2                  // Field money:I
        16: aload_0
        17: bipush        30
        19: putfield      #2                  // Field money:I
        22: return
} 

2. 类加载器

虚拟机设计团队把加载动作放到了 JVM 外部实现,以便让应用程序决定如何获取所需要的类,JVM 提供了三种类加载器:

  1. Bootstrap ClassLoader
    即启动类加载器,负责加载 JAVA_HOME/lib 目录中,或通过-Xbootclasspath 参数指定路径中的,且被虚拟机认可(按文件名识别,如 rt.jar)的类。
  2. Extension ClassLoader
    即扩展类加载器,负责加载 JAVA_HOME/lib/ext 目录中的,或通过 java.ext.dirs 变量指定路径中的类库。
  3. Application ClassLoader
    即应用类加载器,负责加载用户路径(classpath)上的类库。

除了以上三种 JVM 自带的类加载器之后,程序中也可通过继承 java.lang.ClassLoader 来实现自己定义的类加载器。

2.1 双亲委派

JVM 通过双亲委派模型来进行类的加载:
image.png

当一个类加载器收到类加载请求,他首先不会尝试自己去加载这个类,而把这个请求委派给父类去完成,每个层次类加载器都是如此,因此所有的加载请求都应该传送到启动类加载器加载。只有当父类加载器反馈自己无法完成这个请求时(在它的加载路径下没有找到所需加载的 Class),子类加载器才会尝试自己去加载。

父类委托机制的优点是能够提供软件系统的安全性。因为在此机制下,用户自定义的类加载器不可能加载应由其父加载器加载的可靠类,从而防止不可靠甚至恶意的代码代替由父加载器加载的可靠代码。例如,java.lang.Object 类总是由根类加载器加载,其它自定义的类加载器都不可能加载含有恶意代码的 java.lang.Object 类。

2.2 命名空间

每个类加载器都有自己的命令空间,命令空间由该加载器及所有父加载器所加载的类组成。

在同一个命名空间中,不会出现类的完整名字(包括类的包名)相同的两个类。在不同的命名空间中,有可能会出现类的完整名字(包括类的包名)相同的两个类。

同一个命名空间内的类是相互可见的。子加载器的命名空间包含所有父加载器的命名空间,因此由子加载器加载的类能够看见父加载器加载的类。例如应用类加载器加载的类能看见启动类加载器加载的类。

由父加载器加载的类不能看见子加载器加载的类。
如果两个加载器之间没有直接或间接的父子关系,那么它们相互不可见。

2.3 线程上下文加载器

在双亲委托模型下,类加载是由下至上的,即下层的类加载器会委托上进行加载。但是对于 SPI 来说,有些接口是由 Java 核心库所提供的, 而 Java 核心库是由启动类加载器,而这些接口的实现却来自于不同的 jar 包(厂商提供),Java 的启动类加载器是不会加载其他来源的 jar 包, 这样传统的双亲委托模型就无法满足 SPI 的要求,而通过给当前线程设置上下文类加载器,就可以由设置的线程上下文类加载器来实现对于接口实现类的加载。

线程上下文类加载器是从 JDK1.2 开始引入的,类 Thread 中的 getContextClassLoader()与 setContextClassLoader(ClassLoader cl) 分别用来获取和设置上下文类加载器。

如果没有通过 setContextClassLoader(ClassLoader cl)进行设置的话,线程将继承其父线程的上下文类加载器,Java 应用运行时的初始线程的上下文类加载器是应用类加载器。在线程中运行的代码可以通过该类加载器来加载类与资源。

父 Classloader 可以使用当前线程 Thread.currentThread().getContextClassLoader()所指定的 classloader 加载的类。这就改变了父 ClassLoader 不使用子 ClassLoader 或是其他没有直接父子关系的 ClassLoader 加载的类的情况,即改变了双亲委托模型。

线程上下文类加载器的一般使用模式如下所示,即:获取-> 使用-> 还原:

// 获取 - 使用 - 还原
ClassLoader classLoader = Thread.currentThread().getContxtClassLoader();
try {
    Thread.currentThread().setContextClassLoader(targetTccl);
    myMethod();
} finally {
    Thread.currentThread().setContextClassLoader(classLoader);
} 

myThread 里面则调用了 Thread.currentThread().getContextClassLoader(), 获取当前线程的上下文类加载器做某些事情。
如果一个类由类加载器 A 加载,那么这个类的依赖类也是由相同的类加载器加载的(如果该依赖类之前没有被加载过的话)
ContextClassLoader 的作用就是为了破坏 Java 的类加载委托机制。
当高层提供了统一的接口让低层实现,同时又要在高层加载(或实例化)低层的类时,就必须通过线程上下文类加载器来帮助高层的 ClassLoader 提取并加载该类。

  • Java

    Java 是一种可以撰写跨平台应用软件的面向对象的程序设计语言,是由 Sun Microsystems 公司于 1995 年 5 月推出的。Java 技术具有卓越的通用性、高效性、平台移植性和安全性。

    3169 引用 • 8208 回帖

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...