了解Java虚拟机类加载机制

Author Avatar
Ethan Hua 12月 23, 2017

前言

在了解 Java 虚拟机类加载机制之前,我们先来看看这几个问题:

  • Java类中各成员变量和方法是在什么时候被加载到内存中的?
  • 为什么能够在运行时加载代码?常见的热修复、插件化、动态代理的所依赖的基础是什么?
  • 我们在开发中经常用到的 Class 类是什么,每个类的class实例是在什么时候加载到内存中的?

这三个问题应该是我们开发过程中经常会涉及到的,如果没有了解过 Java 虚拟机类的加载机制可能对于这三个问题的答案不是那么清楚,可能只知道有这么个东西,会用,但不知其所以然,甚至可能由于你不了解类的初始化顺序从而写出错误的代码,阅读完本文后相信你会对这三个问题有一个清晰的答案。
首先我们来看这么几段具体的示例代码:
示例一

class Parent {
    public static String a = "父类--静态变量";    
    public String    b = "父类--变量";
    protected int    i    = 9;
    protected int    j    = 0;

    static {
        System.out.println( a );
        System.out.println( "父类--静态初始化块" );
    }

    {
        System.out.println( b );
        System.out.println( "父类--初始化块" );
    }

    public Parent()
    {
        System.out.println( "父类--构造器" );
        System.out.println( "i=" + i + ", j=" + j );
        j = 20;
    }
}

public class SubClass extends Parent {
    public static String subA = "子类--静态变量";
    public String subB = "子类--变量";
    static {
        System.out.println( subA );
        System.out.println( "子类--静态初始化块" );
    }

    {
        System.out.println( subB );
        System.out.println( "子类--初始化块" );
    }

    public SubClass()
    {
        System.out.println( "子类--构造器" );
        System.out.println( "i=" + i + ",j=" + j );
    }
}

public static void main(String[] args ){
       System.out.println("开始第一次初始化-----------\n");
       System.out.println(SubClass.subA+"\n");
       System.out.println("开始第一次实例化-----------\n");
       new SubClass();
    }

示例二

//通过子类引用父类的静态字段,不会导致子类初始化
public class SuperClass{
    static{
        System.out.println("SupperClass init!")
    }
}
public class SubClass extends SuperClass{
    static{
        System.out.println("SubClass init!");
    }
}

public static void main(String[] args){
    System.out.println(SubClass.value);
}

示例三

//通过数组定义来引用类,不会触发此类的初始化
//SubClass为示例二中的类

public static void main(String[] args){
    SuperClass[] sca = new SuperClass[10];
}

示例四

//常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,
//因此不会触发定义常量的类的初始化。


public class ConstClass {

    static {
        System.out.println("ConstClass init!");
    }

    public static final String HELLOWORLD = "hello world";
}
public class Test {
    public static void main(String[] args){
        System.out.println(ConstClass.HELLOWORLD);
    }
} 

示例五

public class Test {                                         

    static {                                                
        i = 0;  //  给变量复制可以正常编译通过                           
        System.out.print(i);  // 这句编译器会提示“非法向前引用”         
    }                                                       
    static int i = 1;                                       

    static int j = 1;                                       

    static{                                                 
        j = 2;                                              
    }                                                       

    public static void main(String[] args){                 
        System.out.println(Test.i);                    
        System.out.println(Test.j);                      
    }                                                       
}       

这五段代码大体上体现了 Java 类的整个加载流程机制,请在纸上记录你心中的这五个示例代码的输出,现在我们暂时不公布答案,来看下其它的东西。

Java的跨平台和Jvm的跨语言

Java虚拟机是在不同硬件架构之上的一层封装,使得同一份代码可以在不同平台上实现的虚拟机上运行,做到 跨平台,而Java虚拟机上接受运行的是一种规定的字节码存储格式 Class文件 ,除了Java语言外还有其他语言如: Kotlin 、Scale 、Clojure 、Groovy 、JRuby 、Jython 都可以通过对应的编译器将具体的代码编译生成 Class文件 做到 跨语言
Tip: Class 文件结构中的魔数值很有 “浪漫气息” ,值为:0xCAFEBABE 咖啡宝贝?大概是我们常见到的 Java 商标咖啡的来源吧。

JVM 类的加载机制

虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这就是虚拟机的类加载机制。
在 Java 语言里面,类型的加载,连接和初始化过程都是在程序运行期间完成的,所以为我们使用 Java 进行运行时组装程序提供了可能。

JVM 类的生命周期

类从被加载到虚拟机加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段。

Java类的初始化严格的五种场景

对于第一个阶段加载 Java 虚拟机规范中并没有进行强制约束,但是对于初始化阶段,虚拟机规范严格规定了有且只有5种情况必须立即对类进行 “初始化”。 (而加载、验证、准备自然要在此之前开始)

  • 遇到 new 、 getstatic 、 putstatic 、 invokestatic 这四道字节码时触发初始化,对应的 Java 代码场景是:使用 new 创建对象、读取或设置一个类的静态字段(不包括 final 修饰的字段)、调用一个类的静态方法
  • 使用 java.lang.reflect 包的方法进行反射调用的时候,如果类没有初始化即进行初始化
  • 初始化时发现父类还没有进行初始化
  • 虚拟机启动指定的主类
  • 动态语言中 MethodHandle 实例最后解析结果 REF_getStatic 等的方法句柄对应的类没有初始化时

接口初始化的场景与类区别:一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候才会初始化,不满足这5个条件中的任意一种都不会被初始化。

  • 对于示例二代码 由于引用的是父类的静态字段所以只会触发父类的初始化,但不会触发子类的初始化,所以输出为 SuperClass init!
  • 对于示例三代码 由于只是创建了类型为 SuperClass 的数组,并没有真正的 SuperClass 实例创建,真正创建的是一个该类型的数组对象,所以不会出初始化 SuperClass ,输出为空。
  • 对于示例四代码 由于引用的是一个被final定义的静态字段,(对应上面五种情况中的第一种 访问非 final静态字段才会初始化)所以不会触发定义该字段的类的初始化,所以输出为 hello world

接下来我们来依次讲一下类加载的全过程,即加载、验证、准备、解析和初始化这5个阶段的具体动作。

加载

主要完成3件事情:

  • 通过一个类的全限定名来获取定义此类的二进制字节流
  • 将这个字节流所代表的静态存储结果转化成方法区的二进制字节流
  • 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区(对于内存布局部分可以阅读我前面的文章)这个类的各种数据的访问入口

对于第一件事情,虚拟机中并没有指明二进制字节流要从哪里获取,所以我们开发者的对于此过程具有完全的可控性,如:

  • 从ZIP包中获取,如:JAR ,EAR ,WAR 格式
  • 从网络中获取,如前面提到的热修复,插件化,在运行时从服务器端获取特定文件或者二进制流。
  • 运行时计算生成 如前面提到的动态代理技术,在运行时用 ProxyGenerator.generateProxyClass 来为特定接口生成形式为 *$Proxy 的代理类的二进制字节流
  • 由其他文件生成,如 Jave web中的 Jsp
  • 从数据库中读取

同时对于类的加载,开发者既可以使用系统提供的引导类的加载器来完成,也可以由自定义的类加载器去完成。
对于第三件事情,映射到文章开头问题三了,Class 对象是对 Java 类型的一种抽象,简单来说就是提取Java中 类的一些共同特征,比如说这些类都有类名,都有对应的 hashcode,可以判断类型属于 class 、interface 、enum 还是 annotation 。这些可以封装成 Class 类的域,另外可以定义一些方法,比如获取某个方法、获取类型名等等。这样就封装了一个表示类型 (type) 的类。
例如可以通过 “类名+ .class” 或者通过”对象+ getClass() 方法“ 来获取该类型的 class 对象,然后通过 class 对象可以拿到这个类型的所有信息,包括字段,注解,方法等等…(一般反射都会用到 Class 这个类)

验证

对 Class 字节流中的信息进行当前虚拟机的安全相关的验证 主要包含四个阶段的校验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。

准备

准备阶段是正式为类中的静态变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区(对于内存布局部分可以阅读我前面的文章)中进行分配,这里初始值打着重符的含义在于 初始值为数据类型的零值而不是其真正的值,真正赋值是在类的初始化阶段完成。如: 定义一个类变量为

public static int value = 123;

变量在准备阶段后初始之为0而不是123,对于final修饰的静态变量字段,则会在准备阶段就赋值为其真正的值。 如果理解准备阶段的功能为 初始化类的静态变量内存就好理解了,指定一个具体类型的初始默认值占坑而已。

解析

因为 Java 是动态运行时连接,Class 文件中保存的是类中字段、方法的符号引用而不是具体的最终内存布局信息,所以此阶段的主要做的事是 将 class 字节流中的常量池中的符号引用解析成具体的内存地址,即直接引用。

初始化

初始化时类加载过程的最后一步,到了初始化阶段,才真正开始执行类中定义的 Java 程序代码(注意:此处的初始化并不是实例化)

在准备阶段类中静态变量已经有一个初始值赋值操作,而在初始化阶段将会实现静态变量真正的赋值操作和执行静态代码块.
其实是执行 clinit() 方法,且父类的此方法要先于子类执行,该方法时由编译器自动收集类中的所有静态变量的赋值动作和静态语句块合并产生的,执行顺序是语句在源文件出现的顺序,但是需要注意的是 静态语句块中只能访问到定义在它之前的静态变量,定义在它之后的变量,只能赋值,不能访问,如示例五 将会在编译时 提示 非法向前引用。

一个类只会进行一次初始化,之后才能被实例化,实例化主要执行构造方法和代码块及非静态变量赋值语句。
示例一的输出是:

开始第一次初始化-----------

父类--静态变量
父类--静态初始化块
子类--静态变量
子类--静态初始化块
子类--静态变量

开始第一次实例化-----------

父类--变量
父类--初始化块
父类--构造器
i=9, j=0
子类--变量
子类--初始化块
子类--构造器
i=9,j=20

顺序依次是(静态变量、静态初始化块)>(变量、初始化块)>构造器。

类加载器

虚拟机设计团队把类加载阶段中的”通过一个类的全限定名来获取描述此类的二进制字节流“ 这个动作放在 Java 虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类,实现这个动作的代码模块称为”类加载器“。

类的唯一性标志

只有由同一个类加载器加载的同一个类才具有可比较性 对应Java代码中的 equals(),isAssignableFrom() isInstance instanceof

双亲委派模型

  • 大多数 java 程序使用3种系统提供的加载器:启动类加载器、扩展类类加载器、应用程序类加载器
  • 双亲委派模型要求除了顶层的启动类加载器外,其他的类加载器都应当有自己的父类加载器,这里一般不会以继承的关系来实现,而是使用组合的关系来复用父加载器的代码
  • 其工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,只有父类加载器反馈自己无法完成这个加载请求时(它的搜索范围中没有找到所需的类),子加载器才会尝试自己去加载
  • 这样的好处是 Java 类随着它的类加载器具备了一种带有优先级的层次关系,例如类 java.lang.Object , 无论哪一个类加载器要加载这个类,最终都是委派为模型最顶端的启动类加载器进行加载,这样就保证了 Object 类在程序的各种类加载器环境中都是同一个类,反之就会出现多个不同的 Object 类(对应前面的类的唯一性标志)
  • 实现双亲委派的代码都集中在 java.lang.ClassLoader 的 loadClass 方法中,逻辑清晰易懂

到这里关于 Java 虚拟机的加载机制就介绍完了,文章开始的三个问题和五个示例的答案也很清晰了.

最后

谢谢阅读,祝大家圣诞节快乐!

Thanks:
《深入理解Java虚拟机》