DAY5:你必须知道的java虚拟机之类篇——类文件的加载(1)

742 阅读9分钟

过完年有点翻水水,几天不敲代码手指头都不听使唤了,不知道大家有没有和我一样的感觉~~

通过上面几个篇幅的讲解,我们已经完成了虚拟机运行时数据区域、对象的创建过程、class文件的文件结构的学习,并完成了一个完整的class文件的全解析过程。接下来相继会围绕类的加载过程、常用的gc收集器等内容来展开。

本章提要

本章分为两小节主要的内容是围绕类加载的过程来展开,从类加载的各个阶段的职责,到双亲委派模型,并通过重写loadclss()和findclass()来实战加深印象。

类加载的过程

类的加载一共是7个过程,分别是加载、验证、准备、解析、初始化、使用和卸载,不知道大家还记不记得对象创建的7个过程呢?这两个可以放一起来记。

由于java语言的动态绑定的性质,所以解析阶段可能在初始化之前,也可能发生在初始化之后进行。

《Java虚拟机规范》中对初始化阶段的把控有严格的规定,一共是6中情况下,类必须提前完成初始化,不然就会报错。

1. 遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。

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

3. 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化(接口是没有这一点要求的,只有在使用到父类接口的时候才会触发父类接口的初始化)

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

5.当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。

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

对于这六种会触发类型进行初始化的场景,《Java虚拟机规范》中使用了一个非常强烈的限定语 ——“有且只有”,这六种场景中的行为称为对一个类型进行主动引用。除此之外,所有引用类型的方 式都不会触发初始化,称为被动引用。

来看下被动引用的3中场景

新建3个类备用

public class SuperClass {
  static {
    System.out.println("父类静态块执行");
  }
  public static int value = 123;
}

public class SubClass extends SuperClass{
  static {
    System.out.println("子类静态块执行");
  }
}

public class ConstClass {

  static {
    System.out.println("常量类静态块执行");
  }
  public static final String HELLOWORLD = "hello world";
}

然后在main中进行调用,通过静态块中的代码是否有打印可以判断出该类是否被初始化

  public static void main(String[] args) {
    //引用父类的静态字段不会导致子类初始化
    System.out.println(SubClass.value);

    //通过数组定义来引用类,不会触发此类的初始化
    SuperClass[] sca = new SuperClass[10];

    //常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化
    System.out.println(ConstClass.HELLOWORLD);
  }

调用结果如下:

父类静态块执行
123
hello world

可以看到自始至终只有父类也就是SuperClass的静态快内容输出了,所以说以上三种场景的引用都是被动引用,不会触发当前类的初始化。

(1) 加载

加载阶段主要完成三件事,之后我们要说的类加载器也就是来完成这个过程而诞生的。

1.通过一个类的全限定名来获取定义此类的二进制字节流。

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

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

加载阶段数组的加载稍微有些特别,因为数组本身是由jvm创建出来的,不是通过类加载器加载出来的,所以还是有几点是需要注意的。

1.如果数组的组件类型是引用类型,那么就通过传统的类加载过程来加载这个组件类型

2.如果数组内部是基本数据类型类似int[] 那么jvm会把数组标记为与引导类加载器关联,也就是BootStrap

3.数组的可访问性和组件的可访问性一致,基本数据类型的可访问性默认是public

(2) 验证

java语言本身是相对安全的编程语言,纯代码是很难作出诸如访问数组边界以外的数据,但是字节码文件不同,字节码文件作为平台无关性的基础,不止是java,别的语言也都是能转换成字节码文件的,所以java无法完成的不代表字节码无法做到,所以为了保护jvm的安全,验证字节码文件是必须的。

1.文件格式验证

文件格式验证要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。验证的项目可以参考之前写的类文件结构

2.元数据验证

元数据验证的主要目的是对类的元数据信息进行语义校验,保证不存在与《Java语言规范》定义相 悖的元数据信息。

3.字节码验证

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

4.符号引用验证

最后一个阶段的校验行为发生在虚拟机将符号引用转化为直接引用[3]的时候,这个转化动作将在 连接的第三阶段——解析阶段中发生。符号引用验证可以看作是对类自身以外(常量池中的各种符号 引用)的各类信息进行匹配性校验,通俗来说就是,该类是否缺少或者被禁止访问它依赖的某些外部 类、方法、字段等资源。主要目的是确保解析行为能正常执行,如果无法通过符号引用验证,Java虚拟机将会抛出一个java.lang.IncompatibleClassChangeError的子类异常,典型的如: java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等

(3) 准备

准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初 始值的阶段。这里的初始值指的是变量的默认值

这里需要注意的一点是,准备阶段只针对变量进行默认值的设定,如果是常量,那么将会从常量池直接读取数值。

//是一个静态变量,准备阶段value = 0
public static int value = 123;

//是一个静态常量,所以准备阶段读取常量池的数值,value = 123
public static final int value = 123;

(4) 解析

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

解析过程就之前写的类文件结构解析的过程,大家可以回去回顾一下字节码文件是如何一步一步地完成解析的。

(5) 初始化

初始化阶段就是执行类构造器clinit()方法的过程。 clinit()方法由是什么呢?

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

2.clinit()方法与类的构造函数(即在虚拟机视角中的实例构造器init()方法)不同,它不需要显式地调用父类构造器,Java虚拟机会保证在子类的clinit()方法执行前,父类的clinit()方法已经执行完毕。因此在Java虚拟机中第一个被执行的clinit()方法的类型肯定是java.lang.Object。

3.由于父类的clinit()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值 操作

4.接口也有变量赋值的操作所以clinit()方法也是会生成的,但是就像前面说过的,接口的初始化和类的初始化不同,接口的初始化不要求父类的clinit()方法先执行。

5.java虚拟机在多线程环境下,多个线程同时去初始化一个类,只有一个线程来执行clinit()方法,当一个类完成初始化之后,之后的线程就再去执行初始化机会判断已经存在来初始化的类,就会直接去取已经被初始化之后的那个类,而不会初始化两次

这里用一个例子来证明一下

public class ThreadsInit {

  static CountDownLatch countDownLatch = new CountDownLatch(6);

  static class Parent {

    static {
      try {
        System.out.println(System.currentTimeMillis() + "---Parent类被初始化");
        TimeUnit.SECONDS.sleep(6);
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
    }
  }

  public static void main(String[] args) throws InterruptedException {
    for (int i = 0; i < 6; i++) {
      new Thread(() -> {
        new Parent();
        countDownLatch.countDown();
      }).start();
    }
    countDownLatch.await();
    System.out.println("main函数结束");
  }
}

输出结果:

1614149302325---Parent类被初始化
main函数结束

发现parent类始终只会初始化一次。


这一小节主要是介绍了类加载的过程具体是哪几个,每一个过程的职责是什么,下一小节会围绕类加载这一过程展开,着重在类加载器、双亲委派模型和“破坏”双亲委派模型这三个点。

大家看完了别忘了点点赞👍呀~~新的一年祝大家牛转乾坤,喜事连连。