JVM执行子系统类加载机制及主动引用被动引用

1.什么是类加载

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

2.类加载的过程

类从被加载到虚拟机内存中开始,到卸载出内存为止,整个生命周期包含:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸载(Unloading)七个阶段。其中验证、准备、解析三个部分统称为连接。七个阶段执行顺序如下:

加载
验证
准备
解析
初始化
使用
卸载

其中解析阶段在某些情况下可以在初始化阶段之后再开始,上图所示的执行顺序指的是开始执行的顺序,下一次阶段的开始并不一定要在上一阶段执行完成之后才开始,也就是说上一个阶段没执行完下一个阶段可能就开始执行了。
先定义两个概念:主动引用被动引用,主动引用会造成所引用的类的初始化操作,被动引用不会造成被引用的类的初始化。

  • 主动引用
    虚拟机规范严格规定了有且只有四种情况必须对类进行初始化,这四种会触发类进行初始化的场景的行为成为对一个类进行主动引用。
    四种场景如下:
    • 遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发初始化。一般场景是使用new实例化对象时、读取或设置一个类的静态字段时候(例外情况:被final修饰的静态字段已在编译期把结果放入常量池中,因此读取时候不会触发类的初始化)、调用一个类的静态方法时候。
    • 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发初始化。
    • 初始化一个类时候发现其父类还没有初始化,则需要先触发其父类的初始化。
    • 当虚拟机启动时,用户指定的要执行的主类(包含main()的类),虚拟机会先初始化这个主类。
  • 被动引用
    • 通过子类引用父类的静态字段,不会导致子类初始化
    • 通过定义数组来引用类,不会导致此类初始化
    • 被final修饰的静态常量,在编译期会被存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。

被动引用测试代码如下:

package com.glt.classloading;

public class SuperClass {

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

    public static int val = 3;

    public static final String str = "hello world2";

}

package com.glt.classloading;

public class SubClass extends SuperClass {
    static {
        System.out.println("SubClass init!");
    }
}

package com.glt.classloading;

/**
 * 被动引用1
 *  对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过子类调用父类的静态字段,只会初始化父类,不会初始化子类。
 * VM args:
 * -XX:+TraceClassLoading
 */
public class NotInit1 {
    public static void main(String[] args) {
        System.out.println(SubClass.val);
        /**
         * 输出结果为 “SuperClass  init !”
         * 但是通过-XX:+TraceClassLoading参数能看到虚拟机触发了子类的加载。
         *
         */
    }
}
package com.glt.classloading;

/**
 * 被动引用2
 *  通过数组定义来引用类,不会触发类的初始化
 * VM args:
 * -XX:+TraceClassLoading
 */
public class NotInit2 {
    public static void main(String[] args) {
        SuperClass[] sus = new SuperClass[10];
        /**
         * 输出结果中没有“SuperClass  init !”,说明父类没有被初始化
         */
    }
}
package com.glt.classloading;

/**
 * 被动引用3
 */
public class NotInit3 {
    public static void main(String[] args) {
        System.out.println(SuperClass.str);
    }
}

2.1.加载

加载阶段虚拟机主要完成三件事:

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

在第一步中只说了最终要获取到二进制流,确没有说明要从说明地方获取,因此获取二进制流的阶段可以有多种方式:

  • 从包中获取,如jar、war、ear等;
  • 从网络中获取,如applet;
  • 运行时动态生成,如动态代理;
  • 由其他文件生成,如jsp应用;
  • 从数据库读取等;

2.2.验证

验证阶段在虚拟机的类加载子系统中是比较重要的,不同的虚拟机有不同的实现方式,大致分为以下四种:文件格式验证、元数据验证、字节码验证、符号引用验证。

2.2.1文件格式验证

主要是验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。
主要是验证描述文件中的内容是否规范,如:

  • 是否以魔数开头;
  • 主次版本号是否在当前虚拟机的处理范围;
  • 常量池中是否有不支持的常量类型;
  • 只想常量的各种索引值是否有不符合utf8编码的数据;
  • Class文件中各个部件及文件本身是否有被删除的或附加的其他信息等。

2.2.2.元数据验证

这个阶段主要是对字节码描述的信息进行语义分析,主要验证点如下:

  • 这个类是否有父类,出了java.lang.Object之外,所有的类都有父类;
  • 这个父类是否继承了不允许继承的类(被final修饰的类);
  • 如果是抽象类,是否实现了父类或者接口中的方法;
  • 类中的字段、方法是否与父类产生了矛盾(是否覆盖了父类的final字段,或者不符合规则的重载等);

2.2.3.字节码验证

这个阶段是验证过程最复杂的阶段,主要进行数据流和控制流分析,上一阶段对数据类型做完校验后,这阶段将对类的方法体进行校验分析,保证方法在运行时不会做出危害虚拟机安全的行为,如:

  • 保证任何时刻操作数栈的数据类型与指令代码序列都能配合工作,不会出现int类型数据使用时却按照long类型加载入本地变量表中;
  • 保证跳转指令不会跳转到方法体以外的字节码指令上;
  • 保证方法体中的类型转换是有效的,如父类对象赋值给子类数据类型等;

2.2.4.符号引用验证

符号引用的验证发生在虚拟机将符号引用转化为直接引用的时候,转化的动作是发生在解析阶段中的。符号引用验证可以看做是对类自身以外(常量池中的各种符号引用)的信息进行匹配性的校验,通常校验以下内容:

  • 符号引用中通过字符串描述的权限定名是否能找到对应的类;
  • 在指定类中是否存在合法方法的字段描述符及简单名称所描述的方法和字段;
  • 符号引用中的类、字段和方法的访问性(private、protected、public、default)是否可被当前类访问;

符号引用验证的目的是去报解析动作能正常执行,如果无法通过符号引用验证,将会抛出异常,如:java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。

2.3.准备

此阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中进行分配,这使用分配内存的仅包括类变量(static修改的变量)不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在java堆中。设置初始值通常指的是设置初始零值,如:

public static int val = 123;

准备阶段设置初始值之后初始值为0,而不是123。但也有例外情况,如果字段被final修饰则准备阶段设置的初始值就是123。常用的初始零值如下:

数据类型 零值
int 0
long 0L
short (short)0
char ‘\u0000’
byte (byte)0
boolean false
float 0.0f
double 0.0d
reference null

2.4.解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。

  • 符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义的定位到目标即可。符号引用于虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。
  • 直接引用可以是直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用与虚拟机实现的内存布局相关,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不同。如果有了直接引用,那引用的目标必定已经在内存中存在。

解析动作主要针对以下几部分进行:类或接口的解析、字段的解析、类方法的解析、接口方法的解析。

2.5.初始化

初始化阶段是类加载过程的最后一步,在前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其他动作都是由虚拟机主导和控制,到了初始化阶段才真正开始执行类中定义的java程序代码。
在准备阶段,变量已经赋过一次系统要求的初始值(初始零值和final修饰的初始值),而在初始化阶段则是根据程序员通过程序制定的主观计划去初始化类变量和其他资源。

3.类加载器介绍

类加载阶段中执行“通过一个类的权限定名来获取描述此类的二进制字节流”这个动作的代码模块就叫做类加载器 。
类加载器大致分为三种:

  • 启动类加载器
    这个类加载器负责将存放在JAVA_HOME\lib目录中的,或者被-Xbootclasspath参数做指定的路径中的,并且是虚拟机识别(按照文件名识别,如rt.jar,名字不符合规范的类库即是在lib目录中也不会被加载)的类库加载到虚拟机内存中。此类加载器无法被java程序直接使用。
  • 扩展类加载器
    这个类加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载JAVA_HOME\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有的类库,开发者可以直接使用扩展类加载器。
  • 应用程序类加载
    这个类加载器由sun.misc.Launcher$AppClassLoader来实现,这个类加载器一般也被称为系统类加载器。它负责加载用户类路径(classPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序的默认类加载。

三个类加载之间的关系如下:

自定义类加载器
User ClassLoader
应用程序类加载器
Application ClassLoader
自定义类加载器
User ClassLoader
扩展类加载器
Extension ClassLoader
启动类加载器
Bootstrap ClassLoader

类加载器之间的这种关系层次,被称为类加载器的双亲委派模型(Parents Delegation Model)。双亲委派模型要求出了顶层的启动类加载器外,其余的类加载都应该有自己的父类加载器,这里类加载器之前的关系不会使用继承(Inheritance)的关系来实现,一般都是使用组合(Composition)的关系来复用父类加载器的代码。
双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有父加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去加载。
因为双亲委派模型使用组合模式,并且优先请求父加载器去加载类,所以像java.lang.Object无论是哪个加载器请求加载的,最终都会委派到启动类加载器加载,因此系统中各种类加载加载的这个类最终都指向同一个类。


参考资料:《深入理解JAVA虚拟机》

发布了61 篇原创文章 · 获赞 85 · 访问量 17万+

猜你喜欢

转载自blog.csdn.net/bluuusea/article/details/93381511