类加载过程已经是老生常谈模式了,我曾在讲解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