Loading...
墨滴

阿东lazy

2021/08/18  阅读:34  主题:蔷薇紫

深入理解jvm - 类加载过程

深入理解jvm - 类加载过程

前言

​ 在最早的文章中,我们虽然讨论过了类加载器的过程,但是并没有讲述内部的细节,本文将会根据类加载器的过程,详细说一下整个类加载的过程中每一个步骤都干什么事情。

类加载过程图
类加载过程图

​ 类加载的过程如下:加载,验证,准备,初始化,解析,使用,卸载。重点需要关注的步骤是前面的五个步骤,这些细节算是八股文的内容,所以这篇文章以简单的总结和归纳为主。

概述

​ 本篇主要讲述类加载的加载过程,在类加载的过程当中包含了前五个步骤和详细的细节。

类加载的过程

​ 下们拆分这五个步骤,讲讲每一个步骤都做了哪些事情:

加载

​ 第一步是加载,加载做的事情就是什么时候jvm需要去找到.class这个对象,我们都知道.class对象如果方法区中存在的话,java是不会去加载第二次的,那么类什么时候会进行初始化呢?

​ 我们最容易想到的就是new的时候,所以可以肯定在new的时候会作为触发条件。接着我们有时候使用public static String mind = "xxx"这种常量的时候,有时候会构建常量类并且直接引用,这时候肯定也是需要先把对应的类加载过来的时候才可以使用的,最后既然我们使用其他类的静态字段会触发,那么使用其他类的静态方法肯定也是会触发类加载条件的。上面这三个条件,是我们最容易想到的三个,下面会稍微复杂一点点点触发加载动作的条件。

​ 除了new之外,我们还知道一种方式是通过java的反射机制,其实就是拿到.class文件对应的类加载器去生成一个类,反射工具就是来简化这一个动作的,所以这里可以猜到,如果反射需要加载的类还不在方法,那肯定也要先把要加载的类加载进来才行。

​ 我们从继承和实现两个角度去考虑什么时候会加载,从继承的角度看,如果父类没有被加载,那么父类也是要被加载进来的,至于为什么必须使用父类,这个问题类构造器可以作为解答,我们都知道在构造器的方法会执行一条super()的隐式方法,至于为什么要执行super()则是因为所有的类的父类都是Object。也是由于jvm的类加载器的设计所决定,双亲委派机制决定了所有的子类加载前需要加载父类。(注意,仅仅是加载,是否需要初始化下文会提到)

​ 最后我们再来看下由于jdk版本带来的改进。首先是jdk7动态语言的支持,所有涉及new或者使用静态属性指令的类都会触发加载。而jdk8因为引入了接口的default方法(默认方法)让接口也可以完成“抽象类”的事情,所以如果有子类实现了拥有默认方法的接口,也是需要进行加载的。

​ 下面我们总结上面关于加载的“初始化”条件:

  • New、静态字段引用、静态方法引用
  • 继承的父类,如果使用的是父类定义的字段或者方法时候会加载父类,但是不会加载子类。但是如果是但是如果是调用子类的,父类一定会被加载。
  • 反射机制生成的类需要加载(否则无法进行反射)。
  • jdk7动态语言涉及new和static的相关指令
  • jdk8实现了带有默认方法的接口的类。

最后,看一下书中给的一段加载“初始化”的代码,结果有点出乎意料哦:

package org.fenixsoft.classloading;
/**
* 被动使用类字段演示一:
* 通过子类引用父类的静态字段,不会导致子类初始化 **/

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! */

​ 下面再看下如果只调用子类的静态方法会发生什么事情:

static class superClass{
    static {
        System.out.println("super load");
    }
}

static class SubClass extends superClass{
    static {
        System.out.println("sub load");
    }

    public static void test(){
        System.out.println("sdsad");
    }
}

public static void main(String[] args) {
 // write your code here
    SubClass.test();
}/**运行结果
 
*/

验证

​ 验证是紧接着类加载之后的步骤,验证主要的做的事情是验证当前的class文件是否可以被虚拟机接受,这一步是至关重要的一步,决定了虚拟机是否安全,所以虚拟机规范里面用了N多页的内容讲述,当然这里的验证内容也是挑重点介绍。:

​ 首先是验证文件格式,比如魔数,主次版本,常量池和索引,验证这些内容目的是防止有人篡改class文件结构。

​ 验证完格式紧接着是验证具体的数据,比如类是否具备父类,以及验证定义的字段和属性等是否符合java的语法。这一节也叫做元数据验证,可以简单理解为对于语法等验证。

​ 之后是字节码验证,也是最复杂的步骤,因为程序的运行依赖程序计数器扫描字节码指令完成,所以字节码验证主要的内容就是验证操作的“原子性”,比如Int操作不会变为long操作,同时保证栈帧的方法正常运行。

​ 最后是符号引用的验证,验证是否能通过符号引用找到类的全限定名称,验证字段是否具备可访问性等。

​ 总的来说,验证阶段分为下面等部分:

  • 文件格式验证
  • 元数据验证
  • 字节码验证
  • 符号引用验证

准备

​ 容易误解的一个阶段,这个阶段需要注意的事情就是准备阶段初始化的静态变量是 final类型的静态常量。另外准备阶段会对类变量进行初始化,但是不会出现类的实例化,也就是说此时生成的仅仅是一个栈中的引用,可以通过引用在初始化阶段快速构建对象但是仅仅是做了一个准备而已,另外需要注意由于jdk7其实内部已经偷偷将常量池移动到了堆当中,所以这些变量都是在堆中生成的。

​ 下面是关于静态变量的初始化细节:

private static final int count1 = 123;
private static int count2 = 55;

​ 这里直接说结果,count在这个阶段的值是123,而count2是0。通过这个细节也可以说明为什么很多书中建议尽量使用final字段,因为它能将“初始化”的步骤提前。积少成多的情况下有不错的性能提升。

解析

​ 解析的核心就是把符号引用变为直接引用,什么是符号引用,什么是直接引用呢?书中用了一大段内容描述,这里用一个案例来表示就明白了(请看下面的代码),obj就可以被认为是一个符号引用,符号引用可以是任何没有歧义的“占位符”(当然和关键字冲突是不行的),而直接引用就是将这个占位符指向一个堆中的实例,有了直接引用也证明实例在内存中已经开辟了空间,所以直接引用一定是一个指针并指向堆中一个实际地址:

public void test(){
  Object obj = null;
  obj = new Object();
}

这里有个唯一的例外:invokedy namic指令。这是java为了支持动态语言的特性而出现的一个指令,除开这个指令的所有其他指令都是可以认为解析这一步骤中已经实现了“静态化”,即指针具体指向的地址已经确定。

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

​ 关于解析的细节,这里简要概括一下,当然这部分只需要了解四个主要步骤即可:

1. 类/接口解析
    不为数组,解析全限定名类加载器加载
    为数组,解析全限定名各数组元素,并生成对应数组维度的访问对象
2. 字段解析
    本类查找解析
    接口父类检查
    父类检查
    抛出nosuckField
3. 方法解析
    如果在class中发现class_index 索引为接口,会抛出异常
    本类查找
    父类查找
    父类与接口查找
    No such method
    返回直接引用进行权限验证
4. 接口方法解析
    与方法解析类似,注意解析到object类为止

初始化

​ 注意这个步骤才是程序员真正认知意义上的初始化,也就是学习java基础的时候学到的初始化的顺序,同时也是真正执行java代码的阶段,所以这个阶段用“分配资源”这个词可能更加合适。

​ 之前也提到过,准备阶段会有一个类变量的构建,可以认为是.class对象被加载到方法区,而初始化则是真正将方法区的这个引用构建到堆上。

​ 这里不得不提<clinit >()这个方法,此方法是在编译时候由java生成的,简单理解可以认为是一个类的构造器的入口,所有的类初始化必须调用这个方法,同时如果发现父类没初始化,则需要执行父类的<clinit >(),最后如果是接口则在使用接口的常量的时候会调用<clinit >(),另外接口的实现类在初始化时也一样不会执行接口的<clinit >()方法。

类加载的细节

​ 了解了类加载的过程,这一节来补充一些类加载的细节:

类加载基本条件

  • 加载/验证/准备三者顺序是确定的,原子化操作

Jvm只保证顺序 一致,但是不保证这三者的连贯性,意味着他们之间可以穿插其他的操作

  • 解析可能在初始化的前后

这是为了满足动态绑定的特性而设置的

  • 加载验证,准备并不是同步完成的,会存在交叉允许的情况

顺序确定,但是并不同步。

什么是被动引用?

  • 子类引用父类定义字段只会触发父类初始化

  • 数组初始化是newarray, 并不是合法对象初始化

  • 经过final修饰的常量池元素

总结

​ 本文主要围绕了类加载的过程这一个要点进行了复习,可以看到类加载的过程还是相对比较好理解的,需要特别关注的内容一个是准备阶段和初始化阶段,这里也有可能是一个踩坑点。

写在最后

​ 下一篇文章将会继续深入类加载器和双亲委派机制,当然在系列很早的文章也有提到过,下一节将是对于类加载器内容的深入和扩展,以及jdk9模块化对于类加载器的影响。

阿东lazy

2021/08/18  阅读:34  主题:蔷薇紫

作者介绍

阿东lazy