窥探 Java 类加载

阅读本文你能收获到

  • 了解 JVM 什么时候会去加载一个类
  • 了解类的生命周期

加载类时机

程序执行过程中遇到以下场景时, 目的类没有被加载时会尝试加载目的类

  1. 遇到 newgetstaticputstaticinvokestatic 4个指令
  2. 使用 java.lang.reflect 包的方法对类进行反射调用
  3. 初始化一个类如果其父类未初始化,则触发父类初始化
  4. 虚拟机启动时用户指定一个要执行的主类(包含main方法的类),虚拟机会先初始化这个类
  5. 使用JDK1.7 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStatic,REF_putStatic,REF_invokeStatic的方法句柄,并且这个方法句柄对应的类没有初始化,则触发其初始化。

值得注意的是, getstaticputstatic 读取的静态字段并不包括被 final 修饰的字段, 被 final 修饰的字段会在编译期就写入常量池的静态字段里面。

类生命周期

类加载流程大致经历以下阶段,下图为流程图。

图片名称

Loading

作用: 加载一个类型的二进制字节数据并映射成程序可读 Class 对象结构 (byte流 -> class)

Loading (加载)流程大致经历以下步骤

  1. 通过一个类的全限定名来获取定义此类的二进制字节流,字节流可来源于任何地方,不局限于从Java源码编译而来;
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;
  3. 在内存众生成一个代表该类的java.lang.Class对象,作为方法区这个类的各种数据访问入口。

这个阶段的工作由类加载器控制字节流的获取方法来完成。针对数组类是由虚拟机直接创建,但是其元素类型还是需要类加载器加载。

ps : Class对象的存储并不一定是在Java堆内存中,也可能存放在方法区中(比如HotSpot虚拟机的实现)

Linking

Linking (连接)阶段包含 Verification (验证),Preparation(准备) 和 Resolution(解析)。该阶段的部分步骤可能夹在加载流程中。整个类加载的流程顺序只针对每个阶段的开始时机而言。

Verification

作用 :确保Class文件的字节流中包含的信息符合当前虚拟机的要求并且不会危害虚拟机

Verification (校验)内容大致分成 4 个部分

整个校验流程大致分为4个部分

  1. 文件格式验证

    主要用于校验字节流是否符合Class文件格式的规范,这部分可以参考 Class文件结构表 这篇文章。校验内容也包含“是否以魔术OxCAFEBABE开头”,“主,次版本号是否在当前虚拟机处理范围之内”…等等。
    字节码只有经过这个阶段的校验后才会进入方法区存储结构。

  2. 元数据验证

    主要用于校验类的元数据信息语义是否正常,保证不存在不符合Java语言规范的元数据信息。比如类是否有父类(除了java.lang.Object之外都需要),如果不是抽象类是否实现了其父类或者接口中要求自类覆盖实现的所有方法等。

  3. 字节码验证

    主要通过数据流和控制流分析,确定程序语义是否合法合理,针对类的方法体进行校验分析,保证方法在运行时不会作出危害虚拟机安全的事件。比如保证跳转指针不会跳转到方法体外的字节码指令上,保证任意时刻操作栈的数据类型和指令代码序列都能配合工作,不会出现栈顶放一个int类型却按照long类型加载到本地变量表中。

  4. 符号引用验证

    发生在虚拟机将符号引用转化为直接引用(解析阶段)的时候进行校验, 校验的内容不局限于符号引用中通过字符串描述的全限定名是否能找到对应的类,在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段等等。主要确保解析阶段中解析行为能正常执行。

Preparation

作用 :为类变量分配内存并设置类变量初始值

比如声明以下静态变量

1
2
public static int A = 1;
public static final int B = 2

在准备阶段为 A 申请内存之后初始化值为 0,其真正赋值1要在类构造器()中完成。而 B 就不一样了,在编译时 javac 就把为 B 生成 ConstantValue 属性,其值为 2(字段属性表)并在准备阶段赋值给 B .

Resolution

作用 :将常量池中符号引用替换成直接引用

符号引用 ,即一组用来描述所引用目标的字面量,其形式已经明确定义在Class文件格式中。
直接引用可以是指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。前者与虚拟机实现的内存布局无关,后者有关。如果有了直接引用,那么其目标必定存在内存中。

只要用到以下16个操作符号引用的字节码指令,在执行指令之前先对所引用的符号引用进行解析。因此虚拟机可根据需要来判读到底是在类被加载器加载时就对常量池的符号引用进行解析,还是等到一个符号引用将要被使用前才进行解析。

anewarray,checkcast,getfield,getstatic,instanceof,invokedynamic,invokeinterface,invokespecial,invokestatic,invokevirtual,ldc,ldc_w,multianewarray,new,putfield,putstatic

解析的动作主要针对类或接口,字段,类方法,接口方法,方法类型,方法句柄和调用点限定符7类,分别对应CONSTANT_Class_info,CONSTANT_Fieldref_info,CONSTANT_Methodref_info,CONSTANT_InterfaceMethodref_info,CONSTANT_MethodType_info,CONSTANT_MethodHandle_infoCONSTANT_InvokeDynamic_info.

感兴趣的朋友可参考 深入理解Java虚拟机:JVM高级特定及最佳实践一书 或 官方文档中涉及的解析内容, 篇幅较大这里不做阐述。

Initialization

作用:执行类中定义的Java代码,根据程序定义的计划区初始化类变量和其他资源。

编译器自动收集类中所有类变量赋值行为和静态语句块,合并产生 < clinit >() 方法,其方法内语句的执行顺序和源文件中出现的顺序一致。虚拟机保证这份代码在单/多线程环境中被正确且执行一次,在子类的 < clinit >() 调用之前父类的 < clinit >() 已经被调用。

值得一提的是,接口也会生成 < clinit >(),但和类不同,接口执行 < clinit >() 不需要先执行父接口的(),只有父接口中定义的变量使用时父接口才会初始化 < clinit >()。< clinit >() 是一个线程安全的操作,能保证多线程环境下被正确地加锁,同步。如果多个线程同时初始化一个类,则只有一个线程去执行并且其他线程需要阻塞等待。

Using

类的使用阶段即类的 Class 对象存在之后到被卸载之前的这段时间。 这个阶段程序可能产生大量的类的实例对象并执行对象行为。 哪怕内存中没有任何类的实例对象也不一定会被卸载, 卸载的时机见 Unloading 篇章。

Unloading

类被加载之后, Class 和 Meta 信息会被存放在 PermGen space 区域, 该区域在程序运行期间一般是永久保存的。如果想要卸载一个类, 必须满足以下三个条件。

  1. 该类所有的实例都已被 GC 回收
  2. 加载该类的 ClassLoader 实例已被 GC 回收
  3. 该类的 java.lang.Class 对象没被引用

Meta, 即元数据, 是针对程序中的方法, 字段,类,包等元素进行描述的数据。Java 中常见的 Anotation 就是元数据.

JVM规范中指出 Bootstrap Classloader 在运行期间是不可能被卸载的。 而 Extension Classloader 加载的类型在运行期间基本也不太可能被卸载, 因为程序会直接或间接范围到某些标准扩展类(javax.*开头类)。如果开发者自定义类加载器用来加载某些类, 除非这些类的上下文非常简单且还要借助虚拟机垃圾回收功能才能尝试卸载。

尽管卸载的条件很苛刻, 但是还是存在可能性。但是 GC 的时机并不是我们能控制的, 所以卸载 Class 也是不可控的。

参考文章