深入理解 JVM 类加载机制

类的生命周期一共分为七个阶段:

JVM-类加载机制

类加载过程为加载、验证、准备、解析和初始化五个部分,其中验证、准备和解析三个部分又被称为 连接(Linking)

这些过程并不是严格的线性过程,中间会穿插执行。比如加载为完成前,连接过程可能已经开始(比如文件格式的校验);比如解析可能发生在初始化前也可能在初始化后等等。

现在对这 5 个阶段做详细分析。

1. 加载

把编译后的 Class 文件载入内存,创建一个 java.lang.Class 对象。

这个阶段要完成三件事情:

  • 使用类的 全限定名 来获取类的二进制字节流。
  • 将二进制字节流的 静态结构 进行转化,存储为方法区的 运行时结构
  • 在内存中生成 java.lang.Class 对象,作为访问的入口。经常使用 Java 反射会对 java.lang.Class 对象非常熟悉,因为可以从该对象中获取到很多类的信息,比如字段、方法等。

Class 文件的来源可以多种多样,由不同的类加载器实现不同来源加载,比如:

  • 本地的 Class 文件直接加载。比如 classpath 路径下的 .class 文件。
  • 网络加载。
  • 压缩包中加载,zip、jar 或者 war 加载。比如第三方类库打包压缩在 jar 中,部署到 tomcat 的 Class 文件打包在 war 中。
  • 运行时创建。Java 提供了一些工具类来实现,比如java.lang.reflect 包中的 Proxy 工具,可以直接在内存中创建动态代理对象。
  • 由其他文件生成,比如 JSP 文件。
JVM-类加载机制-加载

我们可以也自定义类加载器来实现从其他渠道加载 Class 文件。

载入内存后,生成 java.lang.Class 对象非常特别,因为它被存放在方法区中而不是堆中。

2. 验证

验证是连接的第一步。对 Class 文件的格式检查,确保满足 JVM 规范,避免产生虚拟机的安全问题。

因为上面说到 Class 文件不一定是 javac 编译产生的,有各种方式可以创建,对于编译时做的数组越界、对象类型转换错误等问题,类加载过程还需要再次校验。

不过验证还是挺耗性能的,如果 Class 文件已经被反复验证多次,可以使用 -Xverify:none 来缩短类加载时间。

验证主要有四种验证:文件格式验证、元数据验证、字节码验证和符号引用验证。

2.1. 文件格式验证

文件格式验证,确保符合 Class 文件规范,能被当前版本的虚拟机处理。比如:

  • 检查魔数(Magic Number)是否是 Class 文件(0xCAFEBABY)。

  • 检查主次版本对不对。

  • 检查常量池的类型和索引是否正确。

    …...

在经历过这个阶段后,字节流才会被存储到方法区中。所以文件校验发生在 加载 阶段还未结束前。经过文件格式校验后,后续的校验都是基于方法区执行的。

2.2. 元数据验证

元数据验证,字节码语义分析,看是否符合语法规范。比如:

  • 是否有父类。除了 java.lang.Object,所有的类都要有父类。

  • 是否继承了不允许被继承的类。比如被 final 修饰类不允许被继承。

  • 如果不是抽象类,是否实现了父类或接口要求实现的方法。

  • 是否类的字段和方法和父类有矛盾。比如覆盖了父类的 final 字段。

    …...

这些语法规范如果是编译的 Class 文件,在编译期间也会做校验。但因为 Class 文件来源多样,并不能确保遵循语法规范,所以这里还要再进行一次验证。

2.3. 字节码验证

字节码校验。对字节码 数据流控制流 进行分析,确定语义正确。这个是所有校验最复杂的阶段。比如:

  • 正确的操作指令。操作操作局部变量表和操作数栈的数据,指令类型要正确。

  • 正确的跳转指令。跳转到正确的字节码。

  • 正确的类型转换。比如子类可以赋值给父类,但父类不允许赋值子类。

    …...

这里举个校验的例子。

在执行字节码执行的时候,对局部变量表和操作数栈的数据进行操作时,需要使用正确的指令类型。比如局部变量表索引 1 是 int 类型,加载它需要用 iload_1 字节码,而不是 lload_1fload_1dload_1 。在元数据校验阶段就可以做强制约束,排除错误使用。

  public int add(int, int);
    descriptor: (II)I
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=3
         0: iload_1                     # 因为局部变量类型为 int,使用 iload 加载                     
         1: iload_2
         2: iadd
         3: ireturn
      LineNumberTable:
        line 32: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       4     0  this   Loblee/demo/jvm/stack/SimpleObject;
            0       4     1     a   I   # 局部变量类型为 int
            0       4     2     b   I   # 局部变量类型为 int

经过了复杂的字节码验证,其实还是无法确保运行的安全。属于著名的 "Halting Problem"

复杂的验证也十分消耗性能,HotSpot 虚拟机做了很多优化。

2.4. 符号引用验证

符号引用校验。这个发生在符号引用转换为直接引用的时候,属于 解析 阶段的验证,用来确认引用一定会被访问到。比如:

  • 符号引用的全限定名是否能找到对应的类。

  • 符号引用的字段描述符或方法描述符是否能在类中找到对应字段和方法。

  • 符号引用的类、字段和方法的作用域(public、protected、private、default)是否可以被当前类访问。

    …...

符号引用验证也说明了这几个阶段不是线性的,而是会会穿插执行。

无法通过符号引用验证的话,会抛出相关的异常,比如 java.lang.IllegalAccessErrorjava.lang.NoSuchFieldErrorjava.lang.NoSuchMethodError 等等。

3. 准备

类变量 分配内存,设置初始值。

分配的初始值具体是多少?各个数据的默认零值,有 0、0L、null、false 等。

比如下面的类变量 a:

public class SimpleObject {
    public static String a = "hello world";
    public static String b = 2;
}

准备 阶段给 a 的初始值是 null,而不是 “hello world” 字符串,给 b 的初始值是 0 而不是 2。a 和 b 的真实赋值发生在 初始化 阶段,放在了类构造器 <clinit>() 中,使用 putstatic 指令实现。

上面是类变量的处理过程,常量并非如此。如果是字符串或者基础数据类型(int、long、float 等等)的常量类型,准备阶段会直接分配具体数据。

还是 SimpleObject 对象,我们使用 final 关键字将 a 和 b 修饰为类常量:

public class SimpleObject {
    public static final String a = "hello world";
    public static final String b = 2;
}

准备阶段,会直接从运行时常量池中取出 "hello world" 字符串给 a 赋值,从常量池中取出 2 对 b 赋值(这时候 2 被加入到常量池中)。

注意,准备阶段只是处理 类变量,而不是 实例变量。实例变量的在类实例化后一同和实例对象分配在堆中。

4. 解析

解析的过程,就是将符号引用转为直接引用的过程。

符号引用中主要有:

  • 类和接口的全限定名。
  • 字段名称和描述符。
  • 方法名称和描述符。

对应在 class 文件中的常量类型为 CONSTANT_Class_infoCONSTANT_Fieldref_infoCONSTANT_Methodref_info 。编译后在 class 文件的静态常量池中,类加载后进入运行时常量池。之所以需要符号引用,是因为 javac 将代码编译成 class 文件时,是不知道这些类、字段和方法在内存中的位置的,需要有一个符号来代替。

比如我们有一个类 ObjectA ,引用了 ObjectB 作为成员变量:

public class  ObjectA {

    private ObjectB b;

    public void setB(ObjectB b) {
        this.b = b;
    }

    public ObjectB getB() {
        return b;
    }
}

使用 javap 查看这个类的符号引用:

JVM-类加载机制-符号引用

直接引用主要有:

  • 目标指针,比如 Class 对象、类变量、类方法在方法区的指针。
  • 相对偏移量,比如实例变量在堆中的相对偏移量。
  • 能间接定位到目标的 句柄

可以直接引用对应的目标已经在内存中了。直接引用和虚拟机的内存布局相关,在不同虚拟机的直接引用是不一样的。

这些在未解析前都是字符串,存在方法区的运行时常量池里(HotSpot 1.8 方法区实现做了调整,符号引用被放到了由本地内存组成的元空间中),字节码解释器在执行字节码的时候是无法直接使用符号引用的,所以需要翻译成直接引用,可以是内存中的

这个解析过程不一定在 初始化 前执行,也有可能延迟到一个符号引用需要使用到的时候,在栈帧进行动态链接。

JVM-虚拟机栈-栈帧-动态链接

初始化前的解析的被称为 静态绑定 ,栈帧里的解析被称为 动态绑定

解析完成后,符号引用对应的直接引用会被记录在运行时常量池中,后续如果重复解析,直接返回对应的直接引用。

5. 初始化

经过了加载和连接(校验、准备和解析),类已经载入内存并且分配了初始内存,开始进行初始化。

加载过程我们可以自定义类加载器(比如 ClassLoader) 来接管加载,连接完全是虚拟机自动处理的,而初始化才开始正式执行字节码。

初始化就是执行类构造器 <clinit>() 的过程。<clinit>() 由类变量赋值加上 static{} 语句块的代码合并而成,这个并不是必须的,如果没有静态变量赋值过程的话,是不会生成 <clinit>() 的。

<clinit()> 调用一些指令来实现静态变量的赋值操作, 比如使用 putstatic

准备类 SimpleObject 如下:

public class SimpleObject {
    public static String  a = "hello world";
    public static int     b = 100;
    public static ObjectC c = new ObjectC();
    public static Class   d = ObjectD.class;
    public static int     e = ObjectE.e;
    public static int     f = ObjectF.getF();
    public static int     h = 10;
    public static String  i = ObjectI.I;
    public static int     k = ObjectJ.k;

    static {
        ObjectH.h = h;
    }
}

使用 javap 查看 SimpleObject 的类构造器 <clinit>

  static {};
    descriptor: ()V
    flags: ACC_STATIC
    Code:
      stack=2, locals=0, args_size=0
         0: ldc           #2                  // String hello world
         2: putstatic     #3                  // Field a:Ljava/lang/String;
         5: bipush        100
         7: putstatic     #4                  // Field b:I
        10: new           #5                  // class oblee/demo/jvm/pre/ObjectC
        13: dup
        14: invokespecial #6                  // Method oblee/demo/jvm/pre/ObjectC."<init>":()V
        17: putstatic     #7                  // Field c:Loblee/demo/jvm/pre/ObjectC;
        20: ldc           #8                  // class oblee/demo/jvm/pre/ObjectD
        22: putstatic     #9                  // Field d:Ljava/lang/Class;
        25: getstatic     #10                 // Field oblee/demo/jvm/pre/ObjectE.e:I
        28: putstatic     #11                 // Field e:I
        31: invokestatic  #12                 // Method oblee/demo/jvm/pre/ObjectF.getF:()I
        34: putstatic     #13                 // Field f:I
        37: bipush        10
        39: putstatic     #14                 // Field h:I
        42: ldc           #16                 // String objectI
        44: putstatic     #17                 // Field i:Ljava/lang/String;
        47: getstatic     #18                 // Field oblee/demo/jvm/pre/ObjectJ.k:I
        50: putstatic     #19                 // Field k:I
        53: getstatic     #14                 // Field h:I
        56: putstatic     #20                 // Field oblee/demo/jvm/pre/ObjectH.h:I
        59: return

如果静态变量是 字符串类型,作为字面量加入了常量池,初始化时从常量池中取出赋值。比如上面的 "hello world" 直接从常量池取出赋值给 a。

如果静态变量是 基础数据类型,直接使用指令载入。比如上面的 bipush 指令。

如果静态变量是个 类实例的引用类型,但是类还未加载,会再次触发这个类的类加载流程。

初始化不是在类加载后就马上执行的,有一定的触发条件。JVM 规范没有规定什么时候开始进行类加载流程,但对类初始化有进行严格的规定,有四条字节码指令 newgetstaticputstaticinvokestatic 这 4 条指令,如果没有初始化会先触发初始化。

常见的场景有:

  • 使用 new 关键词实例化对象。上面对静态变量 c 赋值,需要对类 ObjectC 进行实例化,所以会触发 ObjectC 的初始化。
  • 读取类的静态变量。上面赋值静态变量 e, getstatic #10 ,触发 ObjectE 的初始化。
  • 设置类的静态变量。上面设置 ObjectH 的静态变量 h,putstatic #20 ,触发 ObjectH 的初始化。
  • 调用类的静态方法。上面调用 ObjectF 的静态方法 getF()invokestatic #12 ,触发 ObjectF 的初始化。

比如下面几种情况不会执行类初始化:

  • 通过子类引用父类的静态字段,只触发父类的初始化,不会触发子类的初始化。
  • 使用对象数组,不会触发该类初始化。
  • 常量被存入常量池中,没有直接引用常量的类,不会触发定义常量的类的初始化。
  • 通过类名获取 Class 对象,不会触发初始化。比如上面使用 ObjectD.class 并不会触发 ObjectD 类的初始化。
  • 使用 Class.forName() 加载类,参数 initialize 为 false 不会触发初始化。
  • 通过 ClassLoader 默认的 loadClass 方法加载类不会触发初始化。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,884评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,347评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,435评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,509评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,611评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,837评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,987评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,730评论 0 267
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,194评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,525评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,664评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,334评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,944评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,764评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,997评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,389评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,554评论 2 349

推荐阅读更多精彩内容

  • 编译原理请查看之前内容,.java文件编译过程和执行过程分析以及计算机简单认识 需要了解更多Java原创资料,请加...
    坑王老薛阅读 2,190评论 0 5
  • 概述 虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验,转换,解析和初始化,最终形成可以被虚拟机直...
    lwd45阅读 1,687评论 0 16
  • 简述:虚拟机把描述类的数据从class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接...
    卡巴拉的树阅读 1,880评论 1 6
  • 前言 什么是类加载? 虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成...
    JackieLeeQJ阅读 444评论 0 0
  • 文/雨随尘清 曾经的你 如一片秋叶 独自凋零在夏天的尾声 人们常说一叶知秋 可谁记得荒野中的你 谁懂被世界遗忘的落...
    清陋阅读 1,004评论 17 21