Java类加载过程

类加载过程已经是老生常谈模式了,我曾在讲解tomcat的书中、在Java基础类书、JVM的书、在Spring框架类的书中、以及各种各样的博客和推文中见过,我虽然看了又忘了,但总体还是有些了解,曾经自以为这不是什么大不了的过程。但时间总会教你做人,看得越多,越觉得以前理解不足。

此笔记记录,虚拟机中Java类加载的过程,各个过程发生的时机,具体做了什么事情,例如,在方法区或者堆分配了哪些内存,创建了哪些常量等。由于Java文件会预先编译,得到class文件,虚拟机的类加载,是对class文件进行的操作,所以不可避免的涉及到class文件的解读,只有知道class文件中有什么,虚拟机才能加载对应的内容。

一、class文件介绍

​ 不可能完全解读class文件,《虚拟机规范第二版》花了一百多页写class文件,这是class的核心,如果要完全理解,可能还得去复习复习编译原理,词法分析语法分析代码生成之类的。

1.1 文件结构

文件结构定义:u1 = 1个字节,u2 = 2个字节,u4 = 4个字节,u8 = 8个字节;

ClassFile {
  u4                     magic;                                // 魔法数
  u2                     minor_version;                        // 副版本号
  u2                     major_version;					               // 主版本号
  u2                     constant_pool_count;                  // 常量池计数,从1开始计数
  cp_info                constant_pool[constant_pool_count-1]; // 常量池[常量数量]
  u2				     access_flags;     										 // 访问标志
  u2				     this_class;													 // 类索引
  u2				     super_class;													 // 父类索引
  u2				     interfaces_count; 										 // 接口计数器
  u2                     interfaces[interfaces_count]          // 接口表
  u2				     fields_count;												 // 字段计数器
  field_info             fields[fields_count];								 // 字段表
  u2                     methods_count;												 // 方法计数器
  method_info            methods[methods_count]; 							 // 方法表
  u2                     attributes_count;										 // 属性计数器
  attribute_info         attributes[attributes_count];				 // 属性表
}

根据这个表,一个class文件的16进制文件就可以读取了。此处要注意几点

  • 常量池的数量【常量池计数-1】,是因为常量池计数从1开始,而不是从0开始。其他的方法,属性之类的是从0开始
  • 字段表和属性表的区别:
    • 字段表:记录的是类级别以及实例级别(不包含父类字段,方法内字段)的字段信息,作用域(public、private)、修饰符(static)、final、字段名等等...
    • 属性表:保存的是class的属性,让虚拟机能够正确解读class文件。例如:Exception属性指出方法抛出的受检查异常、Signature属性记录范型信息(让程序员可以通过反射读到正确类型)、Deprecated属性标记类、接口、方法或字段已经过期。Java虚拟机(Java虚拟机规范 Java SE 8 版)自带23个属性(Java虚拟机规范 Java SE 7 版只描述了21个属性),并且支持自定义属性。

1.2 简单示例读取class文件

代码

/**
 * @Author: dhcao
 * @Version: 1.0
 */
public class ClassTest {

    public static final String abc = "ccc";

    private static String def = "fff";

    public String getAbcdef(){
      return abc + def;
    }
}

编译为ClassTest.class

直接使用subline或者其他软件打开此二进制文件

解读:根据class文件结构解读

  • ca fa ba be : u4 ,魔法数。看到此魔法数开头的文件就意味着这是个java编译后的class文件
  • 00 00 00 34:u2 + u2,副版本号 + 主版本号。根据以下对照表,是jdk1.8
JDK版本号 Class版本号 10进制 16进制
1.1 45.0 00 00 00 2D
1.2 46.0 00 00 00 2E
1.3 47.0 00 00 00 2F
1.4 48.0 00 00 00 30
1.5 49.0 00 00 00 31
1.6 50.0 00 00 00 32
1.7 51.0 00 00 00 33
1.8 52.0 00 00 00 34
  • 00 27:u2, 常量池计数器。十六进制27 = 十进制39,从1开始计数,代表有38项常量

  • 后续常量的解析过于复杂,不提了....

    • 常量共有14种,每种常量都有自己的结构。下面看看第一个常量0a 00 0a 00 1b
    • 0a :代表此常量为类型CONSTANT_Methodref_info(此结构意味着后续u4都属于它),它代表方法的符号引用,也就是方法名。
    • 00 0a:(0a = 10)指向常量池中声明方法的类描述符CONSTANT_Class_info的索引。
    • 00 1b:(1b = 27)指向常量池中名称以及类型描述符CONSTANT_NameAndType的索引。
    • 以上,第一个常量表示的是方法名,它的具体内容在常量表中,它由类名和方法名和类型组合而成。
  • 貌似还可以解析第二个常量 07 00 1c

    • 07:代表此常量为类型CONSTANT_Class_info(此结构意味着后续还有u2),它代表类或者接口的符号引用,也就是类名或者接口名(不仅仅指本类或本接口,其他的也一样)。
    • 00 1c:指向常量池中第28(1c = 28)项常量。

反编译该class文件javap -verbose ClassTest

第一部分:常量池部分

Constant pool:
   #1 = Methodref          #10.#27        // java/lang/Object."<init>":()V
   #2 = Class              #28            // java/lang/StringBuilder
   #3 = Methodref          #2.#27         // java/lang/StringBuilder."<init>":()V
   #4 = Class              #29            // org/relax/jvm/demo/ls/ClassTest
   #5 = String             #30            // ccc
   #6 = Methodref          #2.#31         // java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
   #7 = Fieldref           #4.#32         // org/relax/jvm/demo/ls/ClassTest.def:Ljava/lang/String;
   #8 = Methodref          #2.#33         // java/lang/StringBuilder.toString:()Ljava/lang/String;
   #9 = String             #34            // fff
  #10 = Class              #35            // java/lang/Object
  #11 = Utf8               abc
  #12 = Utf8               Ljava/lang/String;
  #13 = Utf8               ConstantValue
  #14 = Utf8               def
  #15 = Utf8               <init>
  #16 = Utf8               ()V
  #17 = Utf8               Code
  #18 = Utf8               LineNumberTable
  #19 = Utf8               LocalVariableTable
  #20 = Utf8               this
  #21 = Utf8               Lorg/relax/jvm/demo/ls/ClassTest;
  #22 = Utf8               getAbcdef
  #23 = Utf8               ()Ljava/lang/String;
  #24 = Utf8               <clinit>
  #25 = Utf8               SourceFile
  #26 = Utf8               ClassTest.java
  #27 = NameAndType        #15:#16        // "<init>":()V
  #28 = Utf8               java/lang/StringBuilder
  #29 = Utf8               org/relax/jvm/demo/ls/ClassTest
  #30 = Utf8               ccc
  #31 = NameAndType        #36:#37        // append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
  #32 = NameAndType        #14:#12        // def:Ljava/lang/String;
  #33 = NameAndType        #38:#23        // toString:()Ljava/lang/String;
  #34 = Utf8               fff
  #35 = Utf8               java/lang/Object
  #36 = Utf8               append
  #37 = Utf8               (Ljava/lang/String;)Ljava/lang/StringBuilder;
  #38 = Utf8               toString

  • 可以看到一共38项常量,从二进制class文件读取的也一样
  • 第一项常量是方法名,分别指向第10和第27项常量,组合起来的值。
  • 第二项常量是类或者接口名,他指向第28项常量。那是一个StringBuilder类名。

二、类加载步骤

类文件(class文件)是Java编译器编译之后的结果,它遵循的是编译原理。类加载,是指JVM将class文件加载到虚拟机的过程。只有将class文件加载到虚拟机,才能够使用该class。

2.1 类加载过程

将一个class文件(不管是文件还是二进制...只要是class格式)加载到虚拟机,最后移出虚拟机,通常认为有以上步骤,即类的生命周期。对于大多数时候,我们并不关注卸载过程,将“Using、使用”之前对类等处理,统称为“类加载”。所以也有描述类加载为3个主要过程(这也是虚拟机规范定义的过程):加载 --- 连接 --- 初始化

  • 说明一:区分“加载(Loading)”和“类加载”。很明显,当我们定义类加载为“加载、连接、初始化”就知道,“加载(Loading)”只是整个“类加载”过程的一部分。

  • 说明二:Resolution、解析。解析过程是连接的一部分,但是并不一定发生在“初始化”之前。虚拟机规范并没有要求“解析”一定发生在“初始化之前”,虚拟机可根据不同情况,进行不同处理。

  • 说明三:加载、验证、准备、初始化、卸载。这五个阶段是有序的,但是....其界限并非是先加载,加载完毕之后开始验证,验证完毕之后开始准备之类的。此有序,只是“开始时间有序”,即:加载开始时间一定在验证开始之前。但加载和验证可以有交集。

    我理解,加载的整个过程,应该是覆盖了“连接”。

2.1.1 Loading、加载

  • 过程定义:获取类的二进制字节流,并在方法区建立其数据结构,生成java.lang.Class对象。(这个过程可能覆盖了“连接”)

  • 关于类的二进制字节流来源

    • 可以是编译之后的.class文件
    • 可以是zip、jar、war格式包
    • 可以是运行时由动态代理生成的二进制流
    • 可以是其他能够被解析到二进制流,jsp等模板
    • 可以是远程调用获取到的二进制流
  • 类加载器

    • 是类加载的工具。负责将二进制读进虚拟机并按class规范处理,是加载类的类。
    • 同一份class二进制文件,只有被同一个类加载,才能确定唯一性。不同的类加载器加载同一份class文件,这是2个类。在使用instanseof 和equals比较,都会得到false。
    • 其他不是重点...
  • 过程一:非数组类

    • JVM使用类加载器读取一个二进制流:loadClass()方法。
  • 过程二:数组类String[] aa = xxx

    • 我们知道数组是一串连续的内存地址。从这个定义上来看,我们就知道,这不是类加载器可以控制的。数组类的创建是由JVM直接创建的。

    • 数组的每个元素(对象)依然要靠类加载器创建。也就是说,数组本身由JVM创建并分配内存,但是其元素依然依靠类加载器加载。

  • 在加载时,可能抛出以下异常:

    • ClassNotFoundException:类加载器未找到类所对应的描述

    • LinkageError:是否已被该类加载器加载过

    • ClassFormatError:格式检查失败。此过程也可算是“验证”一部分。检查以下几项

      • 前4个字节(u4)必须是魔法数cafababe。
      • 能够辨识的属性,都具备正确的长度。
      • 文件内容不能缺失,不能有多余字节。
      • 常量池必须符合约束(各个常量表的格式)。
    • unsupportedClassVersionError:检测到class版本号不被JVM支持

    • noClassDefError:class文件与类名描述不符

    • IncompatibleClassChangeError:接口被类继承时抛出

    • ClassCircularityError:类的父类是自己时

    由上可见,加载本身也含有一定程度上的校验,不可能啥都加载。所以加载发生在验证开始之前, 但并非一定是结束在验证开始之前。

2.1.2 Verifition、验证

​ 在读入了二进制流之后,验证就开始了,验证的目的是保证Class文件的字节流包含的信息符合JVM的要求,并且不会危害JVM的安全。

​ 那么需要验证哪些呢,在《虚拟机规范 Java SE 8》中,章节目录4.10详细讲解了JVM加载class文件需要进行的校验,根本目的还是保证class文件的正确性和安全性。

  • 文件格式验证(参照前文:格式检查)。

  • 元数据验证

    • 是否有父类、是否继承了final、是否实现了接口的所有方法等等。
  • 字节码验证

    • 这个实在是多,包括指令是否正确,指令是否越界,映射是否正确等等。
  • 符号引用验证

    • 对类型进行匹配性校验,是否private的方法只能被当前类访问、通过全限定名是否找到对应的类等等。
  • 静态约束:一系列用来定义文件是否编排良好的约束

2.1.3 Preparation、准备

​ 该阶段是非常重要的,在经过前面的阶段之后,一个Class文件已经加载到了JVM并验证了其正确性,那么接下来就需要对Class文件进行处理。

虚拟机规范规定:准备阶段的任务是创建类或者接口的静态字段,并用默认值初始化这些字段。这个阶段不会执行任何的虚拟机字节码指令。

​ 过程

  • 为类变量(static)分配内存。java8以后,运行时常量池分配在堆中。
  • 设置初始值

从开始接触Java我们就一直被一些看似简单实际有些意思的题目烦扰,例如:静态变量,静态块的执行顺序、父类子类的执行顺序、变量赋值时间、方法传递的是引用还是值等等乱七八糟的问题。

关于初始值:public static int value = 123; 在准备阶段,这段代码只会得到:value = 0,这是因为int型变量的初始值为0(引用类型初始值为null)。

但是:public static final int value = 123; 在准备阶段,这段代码会得到:value = 123,这是因为final定义常量,其值在编译时确定。

  • 准备阶段是给static赋初始值,为final修饰的常量赋值。

尝试分析,如何标记常量,以及为它赋值。依然是最上述的代码段

反编译该class文件javap -verbose ClassTest

第二部分:编译码

{
  public static final java.lang.String abc;
    descriptor: Ljava/lang/String;
    flags: ACC_PUBLIC, ACC_STATIC, ACC_FINAL
    ConstantValue: String ccc

  public org.relax.jvm.demo.ls.ClassTest();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 7: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lorg/relax/jvm/demo/ls/ClassTest;
  
  ....
    .....
    ......
}

以上,为javap的反编译结果第二部分。我们看源代码中:

  public static final String abc = "ccc";

编译之后:
    public static final java.lang.String abc;
    descriptor: Ljava/lang/String;
    flags: ACC_PUBLIC, ACC_STATIC, ACC_FINAL
    ConstantValue: String ccc

重点在于ConstantValue: String ccc。

属性ConstantValue:如果同时使用了static 和 final,javac编译器在编译时就在字段上著明该属性,并在类加载的准备阶段,将该属性的值,自动赋值给静态变量。

在最开始定义class格式的时候写到,文件最后定义的是属性表,ConstantValue就是属性表中的属性。

  • 作用范围:使用在字段上。
  • 如果flags中含有ACC_STATIC和ACC_FINAL,那么ConstantValue的值将直接赋值给该字段。也就是ccc直接赋值给abc。
  • 强调:虚拟机规范只要求有ACC_STATIC就可以使用ConstantValue,是sun公司的javac编译器要求同时使用ACC_STATIC和ACC_FINAL
  • 只能限于基本类型和String使用

2.1.4 Resolution、解析

​ 解析这个过程,并没有严格的规定在什么时候发生。解析的作用是将符号引用替换为直接引用的过程,只需要在使用符号之前替换这个符号就行。

  • 符号引用

    • 类和接口的全限定名(Fully Qualified Name)
    • 字段的名称和描述符(Descriptor)
    • 方法的名称和描述符
  • 直接引用

    • 直接指向目标的引用
    • 相对偏移量
    • 能间接定位到目标的句柄

​ 以上描述还有些难以理解。说实话,我也不知道怎么解释了,举个例子描述(类方法解析):

 public String getAbcdef(){

        int a = 3;
        int c = a + 4;
        return abc + def + c;
    }

执行 javap -verbose ClassTest
  
  --------------------------------------------------------------------------------
  public java.lang.String getAbcdef();
    descriptor: ()Ljava/lang/String;
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: iconst_3
         1: istore_1
         2: iload_1
         3: iconst_4
         4: iadd
         5: istore_2
         6: new           #2                  // class java/lang/StringBuilder
         9: dup
        10: invokespecial #3                  // Method java/lang/StringBuilder."<init>":()V
        13: ldc           #5                  // String ccc
        15: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        18: getstatic     #7                  // Field def:Ljava/lang/String;
        21: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        24: iload_2
        25: invokevirtual #8                  // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
        28: invokevirtual #9                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
        31: areturn
      LineNumberTable:
        line 15: 0
        line 16: 2
        line 17: 6
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      32     0  this   Lorg/relax/jvm/demo/ls/ClassTest;
            2      30     1     a   I
            6      26     2     c   I
	--------------------------------------------------------------------------------

在反编译中,一直出现的jvm指令:invokevirtual

  • 这是一条方法调用指令,调用对象的实例方法,也是常用的虚方法分派。

解析的目的:

​ 在上文 中,该指令调用的是StringBuilder的append方法(直接字符串相加),虚拟机要求,在执行该条指令(invokevirtual)之前,需要对他们所使用的符号进行解析,也就是对StringBuilder.append进行解析。解析的结果是,该指令能够正确的找到该方法的入口!

解析过程:(类方法解析)

  • 找到该类的符号引用(StringBuilder):具体过程是,该指令后对应的常量池位置 #6 ,我们找到常量池#6,得到
  #6 = Methodref          #2.#31         // java/lang/StringBuilder.append:

​ 它是一个Methodref(方法常量),这是由#2(java/lang/StringBuilder)和#31(append)组成的。

​ 而#2:

#2 = Class              #28            // java/lang/StringBuilder

​ 它是一个Class(类)。

  • 再找,该类(StringBuilder)中是否有该方法。要求返回值为String,参数为String。
  • 最后返回该方法的引用!

解析的目的是将符号引用转为能用的直接引用。主要包含以下:

  • 类或接口的解析
  • 字段解析
  • 类方法解析(上文例子)
  • 接口方法解析

2.1.5 Initializaition、初始化

​ Loading、加载阶段读入了Class文件

​ Verifition、验证阶段校验了其正确性

​ Preparation、准备阶段为其开辟了内存空间,并为static属性赋初始值

​ Resolution、解析阶段将符号引用都转为了直接引用,使得Class中的定义都有了实际意义,不再是一串字面量

​ Initializaition、初始化阶段,是类加载的最后一步

也是执行字节码的过程,也是执行<clinit>()方法的过程

  • <clinit>()方法 和 <init>()方法

    • <clinit>()方法是由编译器自动收集类中所有类变量的赋值动作(不管是不是静态)和静态语言块(static{})中的语句合并产生的,是类构造器
    • <init>()方法是实例化时执行的构造函数或者说实例构造器。
  • <clinit>() 顺序:

    编译器收集类变量的赋值和静态块的语句,是按照Class文件中的语句顺序来的,所有如下中,static 变量i 在定义赋值之前就想要输出,是不行的。

    public class ClassTest {
    
       static {
           i = 0;                    // 可以通过
           System.out.println(i);    // 编译报错
       }
       static int i = 1;
    }
    
    
  • 重要:虚拟机会保证父类的<clinit>()方法在子类之前已经执行完毕。所以第一个执行<clinit>()方法的肯定是java.lang.Object。

    这也意味着父类的静态块语句在子类静态块之前执行

  • <clinit>()方法不是必要的,如果没有任何静态块,也没有类变量的赋值动作,那么可以不生成<clinit>()方法。

  • 接口虽然无法定义static块,但是也可以赋值,所以接口也可以有<clinit>()方法。

  • 虚拟机保证在多线程环境下,同一个类加载器中<clinit>()方法只被执行一次。

三、总结

​ 类的加载过程,主要流程如上,但是更多的细节,没有描述,例如更多的Class文件细节,更多的类加载的内容,更具体的栈与堆的数据结构和分配过程。在后面的笔记中将对这些进行补充。

熟悉的面试题,现在看来也显然易见!

class Parent {
   static {
       System.out.println("父类静态块");
   }

   Parent(){
       System.out.println("父类构造函数");
   }
}

class Sub extends Parent{
    static {
        System.out.println("子类静态块");
    }

    Sub(){
        System.out.println("子类构造函数");
    }
}

// 如何输出...
class Test{
    public static void main(String[] args) {
        new Sub();
    }
}

原文地址:https://www.cnblogs.com/dhcao/p/12019859.html