美文网首页Android进阶之路
深入理解JVM类加载机制

深入理解JVM类加载机制

作者: Joker_Wan | 来源:发表于2020-04-28 19:43 被阅读0次

1.类加载机制

类加载机制,指虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型。

2.类加载过程

一个 class 文件被加载到内存中需要经过 3 大步:加载、链接、初始化。其中链接又可以细分为3小步:验证、准备、解析,如图所示:


2.1 加载

2.1.1 什么是加载

加载是指 Java 虚拟机查找 .class 文件并生成字节流,然后根据字节流创建 java.lang.Class 对象的过程。

加载分为3步:

  1. 通过一个类的全限定名获取此类的二进制字节流
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口

2.1.2 加载时机

当程序运行时,JVM 并不会一次性的将项目经过编译之后的 .class 文件全部加载到内存中。Java 虚拟机规范中并没有严格规定什么时候加载,不同的虚拟机实现会有不同实现。不过以下两种情况一般会对 class 进行装载操作。

  • 隐式装载:在程序运行过程中,当碰到通过 new 等方式生成对象时,系统会隐式调用 ClassLoader 去装载对应的 class 到内存中;

  • 显示装载:在编写源代码时,主动调用 Class.forName() 等方法也会进行 class 装载操作,这种方式通常称为显示装载。

2.2 链接

2.2.1 验证

验证作为链接的第一步,用于确保类或接口的二进制表示结构上是正确的,从而确保字节流包含的信息对虚拟机来说是安全的。主要包含以下几个方面的验证:

  1. 文件格式验证
    检验字节流是否符合 class 文件格式的规范,是否能被当前版本的虚拟机处理。

  2. 元数据验证
    主要对字节码描述的信息进行语义分析,以保证其提供的信息符合Java语言规范的要求。

  1. 字节码验证
    主要是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。在第二阶段对元数据信息中的数据类型做完校验后,字节码验证将对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件。

  2. 符号引用验证
    最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段解析阶段发生。符号引用是对类自身以外(常量池中的各种符号引用)的信息进行匹配校验。验证阶段非常重要,但不一定必要,如果所有代码已经被反复使用和验证过,那么可以通过虚拟机参数-Xverify: none来关闭验证,加速类加载时间。

案例分析:
使用如下代码来验证部分情况

public class Dog {

    public static void main(String[] args) {
        new Dog().print();
    }

    public void print() {
        System.out.print("superCode is " + super.hashCode());
        System.out.print("thisCode is " + hashCode());
    }

    public int hashCode() {
        return 666;
    }
}

使用 javac 编译 Dog.java 生成 Dog.class 字节码文件javac Dog.java,接着使用16进制编辑器打开 Dog.class 文件如下:

正常情况下,使用 java Dog 执行结果如下:


如果使用 16 进制编辑器修改 class 文件中的魔数,如下所示:


将“CAFE BABE”修改为“CAFE BBBE”,重新运行则会报如下错误:

class 文件中魔数后的“0034”为版本号,如果将其修改为“0035”


重新运行会报如下错误:


版本号“0034”之后的“0035”是常量池计数器,表示常量池中有 53 个常量。如果将这个值进行修改也有可能造成运行时错误,比如我将“0035”改为“0031”:


重新运行会报如下错误:


由此可见,JVM 会检查各种对 class 字节码文件的篡改行为,但是依然无法百分百保证 class 文件的安全性。比如我还是用 Dog.java 举例,在 Dog.java 中的 print 方法中,分别打印出父类的自身类的 hashCode 值,分别是:2133927002 和 666。我们可以在 class 字节码的基础上进行篡改,将父类的 hashCode 也返回 666。

通过 javap -v Dog 命令可以查看Dog.class 中常量池的具体信息,下图截取了部分常量池内容:

图中第一个红框框指向了父类 Object的hashCode 方法,图中第二个红框框 处指向了 Dog 的 hashCode 方法。在我之前的博客中从字节码角度剖析Java类文件已经分析了CONSTANT_Methodref_info表的结构如下:

其中 class_index 就是指向方法的所属类(图中为 16,转化为 16 进制为 0X10),因此只需要使用 16 进制编辑器将指向 Object 的 class_index 改为执行 Dog 的 class_index(图中为 2,转化为 16 进制为 0X02) 即可。具体修改如下:


我们将修改后的 Dog.class 文件拖到 Android Studio 中 查看反编译后的 Dog.java


通过反编译后的代码可以看见,打印获取superCode的代码被我们由之前的super.hashCode()替换成了this.hashCode(),使用 java Dog 执行结果如下:

可以看出,虽然在 Java 源文件中调用的是 super.hashCode() ,但是经过在字节码层面篡改之后,Dog.class 文件成功通过 JVM 的校验,并成功执行最终打印出我们想要的结果。

上面的案例说明即使没有 Java 源文件,还是可以对编译之后的 class 字节码文件进行篡改。所以我们在项目中经常会使用混淆,甚至是使用一些三方的加固软件,来保证我们所编写的代码的安全性。

2.2.2 准备

准备阶段的任务是为类或者接口的静态字段分配空间,并且默认初始化这些字段。这个阶段不会执行任何的虚拟机字节码指令,在初始化阶段才会显示的初始化这些字段。

比如:

public static int value = 666;

value在准备阶段的初始值为0而不是666,只有到了初始化阶段,value才会为666。并且此阶段进行内存分配的仅包括类变量,而不包括实例变量(实例变量将会在对象实例化时随着对象一起分配在 Java 堆中)。

Java中所有基础类型的零值:

有一种情况比较特殊--静态常量,比如:

public static final int value = 666;

以上代码会在准备阶段就为 value 分配内存,并设置为 666。

2.2.3 解析

解析阶段是把常量池内的符号引用替换成直接引用(具体的内存地址)的过程。
在这一阶段,JVM 会将常量池中的类、接口名、字段名、方法名等转换为具体的内存地址。

比如上面 Dog.java 中编译之后 main 方法的字节码如下:


在 main 方法中通过 invokevirtual 指令调用了 print 方法,com/jokerwan/commonadapter/Dog.print:()V就是一个符号引用,当main 方法执行到此处时,会将符号引用com/jokerwan/commonadapter/Dog.print:()V解析成直接引用(方法真正的内存地址)。

2.3 初始化

初始化是类加载的最后一步,在准备阶段,变量都被赋予了初始值,但是到了初始化阶段,所有变量还要按照用户编写的代码重新初始化。换一个角度,初始化阶段是执行类构造器<clinit>()方法的过程。

<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static语句块)中的语句合并生成的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块中可以赋值,但是不能访问。

<clinit>()方法对于类来说不是必须的,如果一个类中既没有静态语句块也没有静态变量赋值动作,那么编译器都不会为类生成<clinit>()方法。

虚拟机会保证一个类的<clinit>()方法在多线程环境中能被正确的枷锁、同步。如果多个线程初始化一个类,那么只有一个线程会去执行<clinit>()方法,其它线程都需要等待。

2.3.1 初始化时机

对于装载阶段,JVM 并没有规范何时具体执行。但是对于初始化,JVM 规范中严格规定了 class 初始化的时机,主要有以下几种情况会触发 class 的初始化:

  1. 虚拟机启动时,初始化包含 main 方法的主类;

  2. 遇到 new 指令创建对象实例时,如果目标对象类没有被初始化则进行初始化操作;

  3. 当遇到访问静态方法或者静态字段的指令时,如果目标对象类没有被初始化则进行初始化操作;

  4. 子类的初始化过程如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化;

  5. 使用反射 API 进行反射调用时,如果类没有进行过初始化则需要先触发其初始化;

  6. 第一次调用 java.lang.invoke.MethodHandle 实例时,需要初始化 MethodHandle 指向方法所在的类。

2.3.2 初始化变量

在初始化阶段,只会初始化与类相关的静态赋值语句和静态语句,也就是有 static 关键字修饰的信息,而没有 static 修饰的语句块在实例化对象的时候才会执行。

比如:

public static int value = 666;

在准备阶段 value 被分配内存并设置为 0,在初始化阶段 value 就会被设置为 666。

2.3.3 被动引用

上述触发 class 的初始化的6种情况,JVM 中被称为主动引用,除此 6 种情况之外所有引用类的方式都被称为被动引用。被动引用并不会触发 class 的初始化。

最典型的案例就是在子类中调用父类的静态变量,比如有以下两个类:

public class Parent {
    public static int value = 666;

    static {
        System.out.println("this is Parent");
    }
}
public class Child extends Parent {
    static {
        System.out.println("this is Child");
    }
}

可以看出 Child 继承自 Parent 类,如果直接使用 Child 来访问 Parent 中的 value 值,则不会初始化 Child 类,代码如下:

public class Test {

    public static void main(String[] args) {
        Child.value = 999;
    }
}

执行上面代码,打印结果如下:


对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过子类 Child 来引用父类 Parent 中定义的静态字段,只会触发父类 Parent 的初始化而不会触发子类 Child 的初始化。至于是否要触发子类的加载和验证,在虚拟机规范中并未明确规定,可以通过 XX:+TraceClassLoading 参数来查看。

java -XX:+TraceClassLoading Test

2.3.4 class 初始化和对象的创建顺序

关于 class 的初始化还有一点经常会在面试中被提及,那就是对象的初始化顺序。当我们在代码中使用 new 创建一个类的实例对象时,类中的静态代码块、非静态代码块、构造函数之间的执行顺序是怎样的。

案例代码如下:

public class Parent {

    static {
        System.out.println("Parent static block");
    }

    {
        System.out.println("Parent non-static block");
    }

    public Parent() {
        System.out.println("Parent constructor");
    }
}
public class Child extends Parent {
    static {
        System.out.println("Child static block");
    }

    {
        System.out.println("Child non-static block");
    }

    public Child() {
        System.out.println("Child constructor");
    }
}
public class Test {

    public static void main(String[] args) {
        Parent parent = new Child();
        System.out.println("-------------------------------");
        parent = new Child();
    }
}

在 main 方法中执行了 2 次 new Child() 的操作,执行上述代码结果如下:


由上图我们可以看出对象的初始化顺序:

静态变量/静态代码块 -> 普通代码块 -> 构造函数

  1. 父类静态变量和静态代码块;

  2. 子类静态变量和静态代码块;

  3. 父类普通成员变量和普通代码块;

  4. 父类的构造函数;

  5. 子类普通成员变量和普通代码块;

  6. 子类的构造函数。

相关文章

网友评论

    本文标题:深入理解JVM类加载机制

    本文链接:https://www.haomeiwen.com/subject/zjwbwhtx.html