vlambda博客
学习文章列表

面试题:请介绍 JVM 类加载机制

本文约5900字,完整阅读大概会花费你「15分钟」左右的时间

文章导读

前言

我们在前面分析的时候,简单介绍了 Java 类加载机制,本文带大家深入分析一下。

Java 代码执行流程


面试题:请介绍 JVM 类加载机制



根据上图所示,Java 代码执行步骤如下:

  • 步骤 1:获取 Java 源代码;

  • 步骤 2:编译器把 java 文件转变成 class 文件。编译过程大致可以分为 1 个准备过程和 3 个处理过程:

    • 准备过程:初始化插入式注解处理器

    • 解析与填充符号表过程,包括:词法、语法分析等

    • 注解处理过程

    • 语义分析与字节码生成过程

  • 步骤 3:若要运行此 Java 程序,JVM 中会有一个叫类加载器(class loader)的内置程序把字节码从硬盘载入 JVM;

  • 步骤 4:JVM 中还有一个叫字节码校验器(bytecode verifier)的内置程序检测是否存在运行期错误(例如栈溢出)。若通过检测,字节码校验器就会将字节码传递给解释器(interpreter);

  • 步骤 5:解释器会对字节码进行逐行翻译,将其翻译成当前所在系统可以理解的机器码(machine code);

  • 步骤 6:将机器码交给操作系统,操作系统会以 main 方法作为入口开始执行程序。至此,一个Java程序就这样运行起来了。



下面我们就开始重点介绍 Java 的类加载机制。

类的生命周期

一个类在 JVM 里的生命周期有 7 个阶段,分别是加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸载(Unloading)。


面试题:请介绍 JVM 类加载机制


其中前五个部分(加载,验证,准备,解析,初始化)统称为类加载,下面我们就详细介绍一下这五个过程。


加载

加载(Loading)阶段是整个类加载(Class Loading)过程中的一个阶段,各位不要混淆。


加载的主要作用是将外部的 .class 文件,加载到 Java 的方法区内。


这个阶段 JVM 需要完成以下三个操作:

  1. 通过一个类的全限定名(包名 + 类名)来获取定义此类的二进制字节流;

  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;

  3. 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。


加载 class 文件有以下几种方式:

  1. ZIP 压缩包中读取,这很常见,成为日后 jarwar 格式的基础;

  2. 通过网络获取,典型场景:Web Applet

  3. 运行时计算生成,使用最多的是:动态代理技术;

  4. 由其他文件生成,典型场景是 JSP 应用;

  5. 从加密文件中获取,典型的防 Class 文件被反编译的保护措施。


验证

验证是连接阶段的第一步,这一阶段的目的是确保 class 文件里的字节流信息符合当前虚拟机的要求,不会危害虚拟机的安全。


从代码量和耗费的执行性能的角度上讲,验证阶段的工作量在虚拟机的类加载过程中占了相当大的比重。


如果输入的字节流如不符合 Class 文件格式的约束,将抛出一个 java.lang.VerifyError 异常或其子类异常。


从整体上看,验证阶段大致会完成如下四个阶段的检验动作:文件格式验证、元数据验证、字节码验证和符号引用验证。


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

    • 是否以魔数 0xCAFEBABE 开头

    • 主、次版本号是否在当前 Java 虚拟机接受范围之内

    • 常量池的常量中是否有不被支持的常量类型(检查常量 tag 标志)

  2. 元数据验证:是对字节码描述的信息进行语义分析,以保证其描述的信息符合《Java语言规范》的要求。比如:

    • 这个类的父类是否继承了不允许被继承的类(被 final 修饰的类)

    • 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法

    • 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的 final 字段,或者出现不符合规则的方法重载,例如方法参数都一致,但返回值类型却不同等)

  3. 字节码验证:这一阶段是整个验证过程中最复杂的一个阶段,主要目的是通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的;

  4. 符号引用验证:最后一个阶段的校验行为发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在 连接的第三阶段——解析阶段中发生。


准备

准备阶段是为定义的类变量(即静态变量,被 static 修饰的变量)分配内存并初始化为标准默认值(比如 null 或者 0 值)。

从概念上讲,这些变量所使用的内存都应当在方法区中进行分配,但必须注意到方法区 本身是一个逻辑上的区域,在JDK 7及之前,HotSpot使用永久代来实现方法区时,实现是完全符合这种逻辑概念的;而在JDK 8及之后,类变量则会随着Class对象一起存放在Java堆中,这时候“类变量在方法区”就完全是一种对逻辑概念的表述了。

准备阶段,有两个关键点需要注意:

  • 首先是这时候进行内存分配的仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在 Java 堆中;

  • 然后就是初始化为标准默认值。


假设一个类变量的定义如下:

public static int value = 123;

准备阶段的值会被初始化为 0,后面在类初始化阶段才会执行赋值为 123;但是下面如果使用 final 修饰静态常量,某些 JVM 的行为就不一样了。


假设上面类变量 value 的定义修改为:

public static final int value = 123;

编译时 Javac 将会为 value 生成 ConstantValue 属性,在准备阶段虚拟机就会根据 ConstantValue 的设置将 value 赋值为 123。

如果类字段的字段属性表中存在 ConstantValue 属性,那在准备阶段变量值就会被初始化为 ConstantValue 属性所指定的初始值。<<深入理解Java虚拟机>>


解析

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


介绍解析之前,我们简单了解一下符号引用直接引用

  • 符号引用是一种定义,可以是任何字面上的含义,而直接引用就是直接指向目标的指针、相对偏移量。

  • 直接引用的对象都存在于内存中,你可以把通讯录里的女友手机号码,类比为符号引用,把面对面和你吃饭的人,类比为直接引用


简单的来说就是我们编写的代码中,当一个变量引用某个对象的时候,这个引用在 .class 文件中是以符号引用来存储的(相当于做了一个索引记录)。


在解析阶段就需要将其解析并链接为直接引用(相当于指向实际对象)。如果有了直接引用,那引用的目标必定在堆中存在。


加载一个 class 时, 需要加载所有的 super 类和 super 接口。



解析阶段负责把整个类激活,串成一个可以找到彼此的网,过程不可谓不重要。


那这个阶段都做了哪些工作呢?


主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符 7 类符号引用进行。


我们来看几个经常发生的异常,就与这个阶段有关。

  • java.lang.NoSuchFieldError根据继承关系从下往上,找不到相关字段时的报错。

  • java.lang.IllegalAccessError 字段或者方法,访问权限不具备时的错误。

  • java.lang.NoSuchMethodError 找不到相关方法时的错误。


初始化

类的初始化阶段是类加载过程的最后一个步骤。初始化阶段就是执行类构造器  <clinit>() 方法的过程。

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


下面我们看一段代码,这是一道面试题,大家可以思考一下,下面的代码,会输出什么?


public class A { static int a = 0 ; static { a = 1; b = 1; } static int b = 0; public static void main(String[] args) { System.out.println(a); System.out.println(b); } }


运行结果:

1
0


a 和 b 唯一的区别就是它们的 static 代码块的位置。


这就引出一个规则:static 语句块,只能访问到定义在 static 语句块之前的变量。


所以下面的代码是无法通过编译的。


static { b = b + 1;}static int b = 0;


规则二:Java 虚拟机会保证在子类的 <clinit>() 方法执行前,父类的 <clinit>() 方法已经执行完毕。


因此在 Java 虚拟机中第一个被执行的 <clinit>() 方法的类型肯定是  java.lang.Object()。正因如此,下面的代码字段 B 的值将会是 2 而不是 1。


public class Parent { public static int A = 1; static { A = 2; } static class Sub extends Parent{ public static int B = A; } public static void main(String[] args) { System.out.println(Sub.B); }}


clinit 和 init 方法


<clinit> 是类(Class)初始化执行的方法,<init> 是对象初始化执行的方法(构造函数)。


看下面一段代码,主要是为了让大家弄明白类的初始化和对象的初始化之间的差别。

public class A { static { System.out.println("1"); } public A(){ System.out.println("2"); }}public class B extends A { static{ System.out.println("a"); } public B(){ System.out.println("b"); } public static void main(String[] args){ A ab = new B(); ab = new B(); }}


打印结果:

1
a
2
b
2
b


其中 static 字段和 static 代码块,是属于类的,在类的加载的初始化阶段就已经被执行。类信息会被存放在方法区,在同一个类加载器下,这些信息有一份就够了,所以上面的 static 代码块只会执行一次,它对应的是 <clinit> 方法。


而对象初始化就不一样了。


通常,我们在 new 一个新对象的时候,都会调用它的构造方法,就是 <init>,用来初始化对象的属性。每次新建对象的时候,都会执行。


面试题:请介绍 JVM 类加载机制


所以,上面代码的 static 代码块只会执行一次,对象的构造方法执行两次。再加上继承关系的先后原则,不难分析出正确结果。


类加载的时机

关于在什么情况下需要开始类加载过程的第一个阶段「加载」,JVM 规范中并没有进行强制约束,但是对于初始化阶段,JVM 规范规定了只有六种情况必须立即对类进行「初始化」(加载、验证、准备自然需要在此之前开始):

  1. 遇到 newgetstaticputstaticinvokestatic 这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。能够生成这四条指令的典型 Java 代码场景有:

    • 使用 new 关键字实例化对象的时候;

    • 读取或设置一个类型的静态字段(被 final 修饰、已在编译期把结果放入常量池的静态字段除外)的时候;

    • 调用一个类型的静态方法的时候;

  2. 使用 java.lang.reflect 包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化;

  3. 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化;

  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类;

  5. 当初次调用 MethodHandle 实例时,初始化该 MethodHandle 指向的方法所在的类;

  6. 当一个接口中定义了 JDK 8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。


这六种会触发类型进行初始化的场景,JVM 规范中使用了一个非常强烈的限定语:有且只有,这六种场景中的行为称为对一个类型进行主动引用。


除此之外,所有引用类型的方式都不会触发初始化,称为被动引用。


被动引用

我们举三种被动引用的例子:


实例1:通过子类引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化。


/** * 通过子类引用父类的静态字段,不会导致子类初始化 **/public class SuperClass { static { System.out.println("SuperClass init!"); }
public static int value = 123;}
public class SubClass extends SuperClass { static { System.out.println("SubClass init!"); }}
public class NotInitialization { public static void main(String[] args) { System.out.println(SubClass.value); }}


运行结果:

SuperClass init!
123


上述代码运行之后,只会输出SuperClass init!,而不会输出 SubClass init!


对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。


实例2:通过数组定义来引用类,不会触发此类的初始化

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


运行之后发现没有输出 SubClass init!,说明并没有触发类 SuperClass 的初始化阶段。


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

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


运行结果:

hello world


上述代码运行之后,也没有输出 ConstClass init!,这是因为虽然在 Java 源码中确实引用了 ConstClass 类的常量 HELLOWORLD,但其实在编译阶段通过常量传播优化,已经将此常量的值  hello world 直接存储在 NotInitialization 类的常量池中,以后 NotInitialization 对常量 ConstClass.HELLOWORLD 的引用,实际都被转化为 NotInitialization 类对自身常量池的引用了。


也就是说,实际上 NotInitialization 的 Class 文件之中并没有 ConstClass 类的符号引用入口,这两个类在编译成 Class 文件后就已不存在任何联系了。

类加载器

类加载过程可以描述为:通过一个类的全限定名来获取描述该类的二进制字节流。实现这个动作的代码被称为类加载器(Class Loader)。


系统自带的类加载器分为三种:

  • 启动类加载器(BootstrapClassLoader)

  • 扩展类加载器(ExtClassLoader)

  • 应用类加载器(AppClassLoader)


启动类加载器:它用来加载 Java 的核心类(存放 <JAVA_HOME>\lib目录,或者被 -Xbootclasspath 参数所指定的路径),是用原生 C++ 代码来实现的,是虚拟机自身的一部分。


我们在代码层面无法直接获取到启动类加载器的引用,所以不允许直接操作它,如果获取它的对象,将会返回 null。


扩展类加载器:以 Java 代码的形式实现的。负责加载 <JAVA_HOME>\lib\ext 目录中,或者被  java.ext.dirs系统变量所指定的路径中所有的类库。


应用程序类加载器:它负责在 JVM 启动时加载来自 Java 命令的 -classpath 或者 -cp 选项、java.class.path 系统属性指定的 jar 包和类路径。


在应用程序代码里可以通过 ClassLoader 的静态方法 getSystemClassLoader() 来获取应用类加载器。如果没有特别指定,即在没有使用自定义类加载器情况下,用户自定义的类都由此加载器加载。


此外还可以自定义类加载器。


如果用户自定义了类加载器,则自定义类加载器都以应用类加载器作为父加载器。


应用类加载器的父类加载器为扩展类加载器。这些类加载器是有层次关系的,启动加载器又叫根加载器,是扩展加载器的父加载器,但是直接从 ExClassLoader 里拿不到它的引用,同样会返回 null。



上图展示的各种类加载器之间的层次关系被称为类加载器的双亲委派模型(Parents Delegation Model)。


双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。不过这里类加载器之间的父子关系一般不是以继承(Inheritance)的关系来实现的,而是通常使用组合(Composition)关系来复用父加载器的代码。


双亲委派机制

当一个自定义类加载器需要加载一个类,比如 java.lang.String,它很懒,不会一上来就直接试图加载它,而是先委托自己的父加载器去加载,父加载器如果发现自己还有父加载器,会一直往前找,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。


如果启动类加载器已经加载了某个类比如 java.lang.String,所有的子加载器都不需要自己加载了。


双亲委派模型的实现:

 protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { // 首先,检查请求的类是否已经被加载过了  Class c = findLoadedClass(name); if (c == null) { try { if (parent != null) { c = parent.loadClass(name, false); } else { c = findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { // 如果父类加载器抛出ClassNotFoundException  // 说明父类加载器无法完成加载请求  } if (c == null) { // 在父类加载器无法加载时  // 再调用本身的findClass方法来进行类加载  c = findClass(name); } } if (resolve) { resolveClass(c); } return c; }


这个模型的好处在于 Java 类有了一种优先级的层次划分关系。比如 Object 类,这个毫无疑问应该交给最上层的加载器进行加载,即使是你覆盖了它,最终也是由系统默认的加载器进行加载的。


如果没有双亲委派模型,就会出现很多个不同的 Object 类,应用程序会一片混乱。