JVM基础02-class文件
一、class文件结构
介绍之前,请下载一个Bytecode工具,例如byte code viewer或者Java Bytecode Editor,我用的是后者Java Bytecode Editor
1.1 平台无关性
Java 是与平台无关的语言,这得益于 Java 源代码编译后生成的存储字节码的文件,即 Class 文件,以及 Java 虚拟机的实现。
不仅使用 Java 编译器可以把 Java 代码编译成存储字节码的 Class 文件,使用 JRuby 等其他语言的编译器也可以把程序代码编译成 Class 文件,虚拟机并不关心 Class 的来源是什么语言,只要它符合一定的结构,就可以在 Java 中运行。
Java 语言中的各种变量、关键字和运算符的语义最终都是由多条字节码命令组合而成的,因此字节码命令所能提供的语义描述能力肯定会比 Java 语言本身更强大,这便为其他语言实现一些有别于 Java 的语言特性提供了基础,而且这也正是在类加载时要进行安全验证的原因
1.2 类文件结构
Class 文件是一组以8位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在 Class 文件中,中间没有添加任何分隔符,这使得整个 Class 文件中存储的内容几乎全部都是程序运行的必要数据。
根据 Java 虚拟机规范的规定,Class 文件格式采用一种类似于 C 语言结构体的伪结构来存储,这种伪结构中只有两种数据类型:无符号数和表。
无符号数属于基本数据类型,以 u1、u2、u4、u8 来分别代表 1、2、4、8 个字节的无符号数。
表是由多个无符号数或其他表作为数据项构成的符合数据类型,所有的表都习惯性地以“_info”结尾。
整个 Class 文件本质上就是一张表,它由如下所示的数据项构成。
从表中可以看出,无论是无符号数还是表,当需要描述同一类型但数量不定的多个数据时,经常会使用一个前置的容量计数器加若干个连续的该数据项的形式,称这一系列连续的一个类型的数据为某一类型的集合,比如,fields_count 个 field_info 表数据构成了字段表集合。这里需要说明的是:Class 文件中的数据项,都是严格按照上表中的顺序和数量被严格限定的,每个字节代表的含义,长度,先后顺序等都不允许改变。
首先编译一个Java类,
public class Demo extends Object { public static int type = 20; private static final int TABLE=1; private String username; private String password; private int age; private int[] sizes; private String[] arrays; public String hello(String name, String password) throws Exception{ if(name == null){ throw new Exception("usrname is invalid"); } if(password == null){ throw new IllegalArgumentException("password can't be null."); } return "ok"; } //get and set... }
使用一个ByteCode工具,或者使用命令javap等等观察...
下面列出了 Class 文件中各个数据项的具体含义:
1. magic 与 version
每个 Class 文件的头 4 个字节称为魔数(magic),它的唯一作用是判断该文件是否为一个能被虚拟机接受的 Class 文件。它的值固定为 0xCAFEBABE。紧接着 magic 的 4 个字节存储的是 Class 文件的次版本号和主版本号,高版本的 JDK 能向下兼容低版本的 Class 文件,但不能运行更高版本的 Class 文件。
2. constant_pool
major_version 之后是常量池(constant_pool)的入口,它是 Class 文件中与其他项目关联最多的数据类型,也是占用 Class 文件空间最大的数据项目之一。
常量池中主要存放两大类常量:字面量和符号引用。字面量比较接近于 Java 层面的常量概念,如文本字符串、被声明为 final 的常量值等。而符号引用总结起来则包括了下面三类常量:
- 类和接口的全限定名(即带有包名的 Class 名,如:org.lxh.test.TestClass)
- 字段的名称和描述符(private、static 等描述符)
- 方法的名称和描述符(private、static 等描述符)
虚拟机在加载 Class 文件时才会进行动态连接,也就是说,Class 文件中不会保存各个方法和字段的最终内存布局信息,因此,这些字段和方法的符号引用不经过转换是无法直接被虚拟机使用的。当虚拟机运行时,需要从常量池中获得对应的符号引用,再在类加载过程中的解析阶段将其替换为直接引用,并翻译到具体的内存地址中。
这里说明下符号引用和直接引用的区别与关联:
- 符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到了内存中。
- 直接引用:直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是与虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那说明引用的目标必定已经存在于内存之中了。
常量池中的每一项常量都是一个表,共有 11 种(JDK1.7 之前)结构各不相同的表结构数据,没中表开始的第一位是一个 u1 类型的标志位(1-12,缺少 2),代表当前这个常量属于的常量类型。11 种常量类型所代表的具体含义如下表所示:
这 11 种常量类型各自均有自己的结构。在 CONSTANT_Class_info 型常量的结构中有一项 name_index 属性,该常属性中存放一个索引值,指向常量池中一个 CONSTANT_Utf8_info 类型的常量,该常量中即保存了该类的全限定名字符串。
而 CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info 型常量的结构中都有一项index属性,存放该字段或方法所属的类或接口的描述符 CONSTANT_Class_info 的索引项。另外,最终保存的诸如 Class 名、字段名、方法名、修饰符等字符串都是一个 CONSTANT_Utf8_info 类型的常量,也因此,Java 中方法和字段名的最大长度也即是CONSTANT_Utf8_info 型常量的最大长度,在 CONSTANT_Utf8_info 型常量的结构中有一项 length 属性,它是 u2 类型的,即占用 2 个字节,那么它的最大的 length 即为 65535。
因此,Java 程序中如果定义了超过 64KB 英文字符的变量或方法名,将会无法编译。
下表给出了常量池中 11 种数据类型的结构:
常量 |
项目 |
类型 |
描述 |
CONSTANT_Utf8_info |
tag |
u1 |
值为1 |
length |
u2 |
UF-8编码的字符串占用的字节数 |
|
bytes |
u1 |
长度为length的UTF-8编码的字符串 |
|
CONSTANT_Integer_info |
tag |
u1 |
值为3 |
bytes |
u4 |
按照高位在前存储的int值 |
|
CONSTANT_Float_info |
tag |
u1 |
值为4 |
bytes |
u4 |
按照高位在前存储的float值 |
|
CONSTANT_Long_info |
tag |
u1 |
值为5 |
bytes |
u8 |
按照高位在前存储的long值 |
|
CONSTANT_Double_info |
tag |
u1 |
值为6 |
bytes |
u8 |
按照高位在前存储的double值 |
|
CONSTANT_Class_info |
tag |
u1 |
值为7 |
index |
u2 |
指向全限定名常量项的索引 |
|
CONSTANT_String_info |
tag |
u1 |
值为8 |
index |
u2 |
指向字符串字面量的索引 |
|
CONSTANT_Fieldref_info |
tag |
u1 |
值为9 |
index |
u2 |
指向声明字段的类或接口描述符CONSTANT_Class_info的索引项 |
|
index |
u2 |
指向字段名称及类型描述符CONSTANT_NameAndType_info的索引项 |
|
CONSTANT_Methodref_info |
tag |
u1 |
值为10 |
index |
u2 |
指向声明方法的类描述符CONSTANT_Class_info的索引项 |
|
index |
u2 |
指向方法名称及类型描述符CONSTANT_NameAndType_info的索引项 |
|
CONSTANT_InrerfaceMethodref_info |
tag |
u1 |
值为11 |
index |
u2 |
指向声明方法的接口描述符CONSTANT_Class_info的索引项 |
|
index |
u2 |
指向方法名称及类型描述符CONSTANT_NameAndType_info的索引项 |
|
CONSTANT_NameAndType_info |
tag |
u1 |
值为12 |
index |
u2 |
指向字段或方法名称常量项目的索引 |
|
index |
u2 |
指向该字段或方法描述符常量项的索引 |
3. access_flag
在常量池结束之后,紧接着的 2 个字节代表访问标志(access_flag),这个标志用于识别一些类或接口层次的访问信息,包括:这个 Class 是类还是接口,是否定义为 public 类型,abstract 类型,如果是类的话,是否声明为 final,等等。每种访问信息都由一个十六进制的标志值表示,如果同时具有多种访问信息,则得到的标志值为这几种访问信息的标志值的逻辑或。
4. this_class、super_class、interfaces
类索引(this_class)和父类索引(super_class)都是一个 u2 类型的数据,而接口索引集合(interfaces)则是一组 u2 类型的数据集合,Class 文件中由这三项数据来确定这个类的继承关系。类索引、父类索引和接口索引集合都按照顺序排列在访问标志之后,类索引和父类索引两个 u2 类型的索引值表示,它们各自指向一个类型为 COMNSTANT_Class_info 的类描述符常量,通过该常量中的索引值找到定义在 COMNSTANT_Utf8_info 类型的常量中的全限定名字符串。而接口索引集合就用来描述这个类实现了哪些接口,这些被实现的接口将按 implements 语句(如果这个类本身是个接口,则应当是 extend 语句)后的接口顺序从左到右排列在接口的索引集合中。
5. fields
字段表(field_info)用于描述接口或类中声明的变量。字段包括了类级变量或实例级变量,但不包括在方法内声明的变量。字段的名字、数据类型、修饰符等都是无法固定的,只能引用常量池中的常量来描述。下面是字段表的最种格式:
其中的 access_flags 与类中的 access_flags类似,是表示数据类型的修饰符,如 public、static、volatile 等。
后面的 name_index 和 descriptor_index 都是对常量池的引用,分别代表字段的简单名称及字段和方法的描述符。这里简单解释下“简单名称”、“描述符”和“全限定名”这三种特殊字符串的概念。
前面有所提及,全限定名即指一个事物的完整的名称,如在 org.lxh.test 包下的 TestClass 类的全限定名为:org/lxh/test/TestClass
,即把包名中的“.”改为“/”,为了使连续的多个全限定名之间不产生混淆,在使用时最后一般会加入一个“,”来表示全限定名结束。
简单名称则是指没有类型或参数修饰的方法或字段名称,如果一个类中有这样一个方法 boolean get(int name)和一个变量 private final static int m,则他们的简单名称则分别为 get()和 m。
而描述符的作用则是用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序等)和返回值的。根据描述符规则,详细的描述符标示字的含义如下表所示:
对于数组类型:每一维度将使用一个前置的 “[” 字符来描述,如一个整数数组 “int [][]” 将为记录为 “[[I” ,而一个 String 类型的数组 “String[]” 将被记录为 “[Ljava/lang/String” 。
用方法描述符描述方法时,按照先参数后返回值的顺序描述,参数要按照严格的顺序放在一组小括号内,如方法 int getIndex(String name,char[] tgc,int start,int end,char target) 的描述符为 “(Ljava/lang/String[CIIC)I”。
字段表包含的固定数据项目到 descriptor_index 为止就结束了,但是在它之后还紧跟着一个属性表集合用于存储一些额外的信息。比如,如果在类中有如下字段的声明:static final int m = 2;那就可能会存在一项名为ConstantValue 的属性,它指向常量 2。关于 attribute_info 的详细内容,在后面关于属性表的项目中会有详细介绍。
最后需要注意一点:字段表集合中不会列出从父类或接口中继承而来的字段,但有可能列出原本 Java 代码中不存在的字段。比如在内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段。
6. methods
方法表(method_info)的结构与属性表的结构相同,不过多赘述。
方法里的 Java 代码,经过编译器编译成字节码指令后,存放在方法属性表集合中一个名为“Code”的属性里,关于属性表的项目,同样会在后面详细介绍。
与字段表集合相对应,如果父类方法在子类中没有被覆写,方法表集合中就不会出现来自父类的方法信息。但同样,有可能会出现由编译器自动添加的方法,最典型的便是类构造器 “” 方法和实例构造器 “” 方法。
在 Java 语言中,要重载一个方法,除了要与原方法具有相同的简单名称外,还要求必须拥有一个与原方法不同的特征签名,特征签名就是一个方法中各个参数在常量池中的字段符号引用的集合,也就是因为返回值不会包含在特征签名之中,因此 Java 语言里无法仅仅依靠返回值的不同来对一个已有方法进行重载。
7. Attributes
属性表(attribute_info)在前面已经出现过多系,在 Class 文件、字段表、方法表中都可以携带自己的属性表集合,以用于描述某些场景专有的信息。
属性表集合的限制没有那么严格,不再要求各个属性表具有严格的顺序,并且只要不与已有的属性名重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息,但 Java 虚拟机运行时会忽略掉它不认识的属性。Java 虚拟机规范中预定义了 9 项虚拟机应当能识别的属性(JDK1.5 后又增加了一些新的特性,因此不止下面 9 项,但下面 9 项是最基本也是必要,出现频率最高的),如下表所示:
对于每个属性,它的名称都需要从常量池中引用一个 CONSTANT_Utf8_info 类型的常量来表示,每个属性值的结构是完全可以自定义的,只需说明属性值所占用的位数长度即可。一个符合规则的属性表至少应具有 “attribute_name_info”、“attribute_length” 和至少一项信息属性。
8. Code 属性
前面已经说过,Java 程序方法体中的代码讲过 javac 编译后,生成的字节码指令便会存储在 Code 属性中,但并非所有的方法表都必须存在这个属性,比如接口或抽象类中的方法就不存在 Code 属性。如果方法表有 Code 属性存在,那么它的结构将如下表所示:
attribute_name_index 是一项指向 CONSTANT_Utf8_info 型常量的索引,常量值固定为 “Code”,它代表了该属性的名称。attribute_length 指示了属性值的长度,由于属性名称索引与属性长度一共是 6 个字节,所以属性值的长度固定为整个属性表的长度减去 6 个字节。
max_stack 代表了操作数栈深度的最大值,max_locals 代表了局部变量表所需的存储空间,它的单位是Slot,并不是在方法中用到了多少个局部变量,就把这些局部变量所占 Slot 之和作为 max_locals 的值,原因是局部变量表中的 Slot 可以重用。
code_length 和 code 用来存储 Java 源程序编译后生成的字节码指令。code 用于存储字节码指令的一系列字节流,它是 u1 类型的单字节,因此取值范围为 0x00 到 0xFF,那么一共可以表达 256 条指令,目前,Java 虚拟机规范已经定义了其中 200 条编码值对应的指令含义。code_length 虽然是一个 u4 类型的长度值,理论上可以达到 2^32-1,但是虚拟机规范中限制了一个方法不允许超过 65535 条字节码指令,如果超过了这个限制,Javac 编译器将会拒绝编译。
字节码指令之后是这个方法的显式异常处理表集合(exception_table),它对于 Code 属性来说并不是必须存在的。它的格式如下表所示:
它包含四个字段,这些字段的含义为:如果字节码从第 start_pc 行到第 end_pc 行之间(不含 end_pc 行)出现了类型为 catch_type 或其子类的异常(catch_type为指向一个 CONSTANT_Class_info 型常量的索引),则转到第 handler_pc 行继续处理,当 catch_pc 的值为 0 时,代表人和的异常情况都要转到 handler_pc 处进行处理。异常表实际上是 Java 代码的一部分,编译器使用异常表而不是简单的跳转命令来实现 Java 异常即 finally 处理机制,也因此,finally 中的内容会在 try 或 catch 中的 return 语句之前执行,并且在 try 或 catch 跳转到 finally 之前,会将其内部需要返回的变量的值复制一份副本到最后一个本地表量表的 Slot中,也因此便有了http://blog.csdn.net/ns_code/article/details/17485221这篇文章中出现的情况。
9.其他属性
Code 属性是 Class 文件中最重要的一个属性,如果把一个 Java 程序中的信息分为代码和元数据两部分,那么在整个 Class 文件里,Code 属性用于描述代码,所有的其他数据项目都用于描述元数据。
(1) Exception 属性
这里的 Exception 属性的作用是列举出方法中可能抛出的受查异常,也就是方法描述时在 throws 关键字后面列举的异常。它的结构很简单,只有 attribute_name_index、attribute_length、number_of_exceptions、exception_index_table 四项,从字面上便很容易理解,这里不再详述。
(2) LineNumberTable 属性
它用于描述 Java 源码行号与字节码行号之间的对应关系。
(3) LocalVariableTable 属性
它用于描述栈帧中局部变量表中的变量与 Java 源码中定义的变量之间的对应关系。
(4) SourceFile 属性
它用于记录生成这个 Class 文件的源码文件名称。
(5) ConstantValue 属性
ConstantValue 属性的作用是通知虚拟机自动为静态变量赋值,只有被 static 修饰的变量才可以使用这项属性。在 Java 中,对非 static 类型的变量(也就是实例变量)的赋值是在实例构造器方法中进行的;而对于类变量(static 变量),则有两种方式可以选择:在类构造其中赋值,或使用 ConstantValue 属性赋值。
目前 Sun Javac 编译器的选择是:
如果同时使用 final 和 static 修饰一个变量(即全局常量),并且这个变量的数据类型是基本类型或 String 的话,就生成 ConstantValue 属性来进行初始化(编译时 Javac 将会为该常量生成 ConstantValue 属性,在类加载的准备阶段虚拟机便会根据 ConstantValue 为常量设置相应的值);
如果该变量没有被 final 修饰,或者并非基本类型及字符串,则选择在方法中进行初始化。
虽然有 final 关键字才更符合”ConstantValue“的含义,但在虚拟机规范中并没有强制要求字段必须用 final 修饰,只要求了字段必须用 static 修饰,对 final 关键字的要求是 Javac 编译器自己加入的限制。因此,在实际的程序中,只有同时被 final 和 static 修饰的字段才有 ConstantValue 属性。而且 ConstantValue 的属性值只限于基本类型和 String,很明显这是因为它从常量池中也只能够引用到基本类型和 String 类型的字面量。
总结: final、static、static final 修饰的字段赋值的区别:
-
- static 修饰的字段在类加载过程中的准备阶段被初始化为 0 或 null 等默认值,而后在初始化阶段(触发类构造器)才会被赋予代码中设定的值,如果没有设定值,那么它的值就为默认值。
- final 修饰的字段在运行时被初始化(可以直接赋值,也可以在实例构造器中赋值),一旦赋值便不可更改;
- static final 修饰的字段在 Javac 时生成 ConstantValue 属性,在类加载的准备阶段根据ConstantValue的值为该字段赋值,它没有默认值,必须显式地赋值,否则 Javac 时会报错。可以理解为在编译期即把结果放入了常量池中。
(6) InnerClasses 属性:该属性用于记录内部类与宿主类之间的关联。如果一个类中定义了内部类,那么编译器将会为它及它所包含的内部类生成 InnerClasses 属性。
(7) Deprecated 属性和 Synthetic 属性:该属性用于表示某个类、字段和方法,已经被程序作者定为不再推荐使用,它可以通过在代码中使用 @Deprecated 注释进行设置。
(8) Synthetic 属性: 该属性代表此字段或方法并不是 Java 源代码直接生成的,而是由编译器自行添加的,如 this 字段和实例构造器、类构造器等。
二、类初始化机制
什么时候会初始化?
类初始化是类加载过程的最后一个阶段,到初始化阶段,才真正开始执行类中的 Java 程序代码。虚拟机规范严格规定了有且只有四种情况必须立即对类进行初始化:
- 遇到 new、getstatic、putstatic、invokestatic 这四条字节码指令时,如果类还没有进行过初始化,则需要先触发其初始化。生成这四条指令最常见的 Java 代码场景是:使用 new 关键字实例化对象时、读取或设置一个类的静态字段(static)时(被 static 修饰又被 final 修饰的,已在编译期把结果放入常量池的静态字段除外)、以及调用一个类的静态方法时。
- 使用 Java.lang.refect 包的方法对类进行反射调用时,如果类还没有进行过初始化,则需要先触发其初始化。
- 当初始化一个类的时候,如果发现其父类还没有进行初始化,则需要先触发其父类的初始化。
- 当虚拟机启动时,用户需要指定一个要执行的主类,虚拟机会先执行该主类。
主动引用和被动引用
虚拟机规定只有这四种情况才会触发类的初始化,称为对一个类进行主动引用,除此之外所有引用类的方式都不会触发其初始化,称为被动引用。下面举一些例子来说明被动引用。
1. 通过子类引用父类中的静态字段,这时对子类的引用为被动引用,因此不会初始化子类,只会初始化父类:
class Father{ public static int m = 33; static{ System.out.println("父类被初始化"); } } class Child extends Father{ static{ System.out.println("子类被初始化"); } } public class StaticTest{ public static void main(String[] args){ System.out.println(Child.m); // 调用的是父类的静态属性 } }
执行后输出的结果如下:
父类被初始化
33
说明:对于静态字段,只有直接定义这个字段的类才会被初始化,因此,通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。
2. 常量在编译阶段会存入调用它的类的常量池中,本质上没有直接引用到定义该常量的类,因此不会触发定义常量的类的初始化:
class Const{ public static final String NAME = "我是常量"; static{ System.out.println("初始化Const类"); } } public class FinalTest{ public static void main(String[] args){ System.out.println(Const.NAME); } }
执行后输出的结果如下:
我是常量
说明:虽然程序中引用了 const 类的常量 NAME,但是在编译阶段将此常量的值“我是常量”存储到了调用它的类 FinalTest 的常量池中,对常量 Const.NAME 的引用实际上转化为了 FinalTest 类对自身常量池的引用。也就是说,实际上 FinalTest 的 Class 文件之中并没有 Const 类的符号引用入口,这两个类在编译成 Class 文件后就不存在任何联系了。
3. 通过数组定义来引用类,不会触发类的初始化:
class Const{ static{ System.out.println("初始化Const类"); } } public class ArrayTest{ public static void main(String[] args){ Const[] con = new Const[5]; } }
执行后不输出任何信息,说明 Const 类并没有被初始化。
说明:但这段代码里触发了另一个名为“LLConst”的类的初始化,它是一个由虚拟机自动生成的、直接继承于java.lang.Object 的子类,创建动作由字节码指令 newarray 触发,很明显,这是一个对数组引用类型的初初始化,而该数组中的元素仅仅包含一个对 Const 类的引用,并没有对其进行初始化。如果我们加入对 con 数组中各个 Const 类元素的实例化代码,便会触发 Const 类的初始化,如下:
class Const{ static{ System.out.println("初始化Const类"); } } public class ArrayTest{ public static void main(String[] args){ Const[] con = new Const[5]; for(Const a:con) a = new Const(); } }
这样便会得到如下输出结果:
初始化Const类
根据四条规则的第一条,这里的 new 触发了 Const 类。
最后看一下接口的初始化过程与类初始化过程的不同。
接口也有初始化过程,上面的代码中我们都是用静态语句块来输出初始化信息的,而在接口中不能使用“static{}”语句块,但编译器仍然会为接口生成类构造器,用于初始化接口中定义的成员变量(实际上是 static final 修饰的全局常量)。
二者在初始化时最主要的区别是:
当一个类在初始化时,要求其父类全部已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量),才会初始化该父接口。
这点也与类初始化的情况很不同,回过头来看第 2 个例子就知道,调用类中的 static final 常量时并不会 触发该类的初始化,但是调用接口中的 static final 常量时便会触发该接口的初始化。
三、类加载机制
3.1 类加载过程
上图展示了类从被加载到虚拟机开始,一直到卸载出内存,它的整个生命周期。
其中类加载的过程式前5个:
加载-》验证-》准备-》解析-》初始化。
其中加载,验证,准备和初始化这四个阶段发生的顺序是确定的,而解析阶段则不一定,它在某些情况下可以在初始化之后开始,这是为了支持Java语言的动态绑定(运行时绑定) 。
注意:上面说的顺序确定是指开始顺序,而不是结束顺序。即上述阶段是交叉混合进行,通常在一个阶段执行的过程就调用下一个阶段。
什么是绑定?
绑定的意思是把一个方法的调用与方法所在的类(即方法主体)关联起来,对Java而言,分为静态和动态
静态绑定: 在程序运行前绑定,针对Java,可以理解为编译期间的绑定。Java中的方法被final static private和构造方法是前期绑定的
动态绑定: 运行期绑定。在运行时根据具体对象的类型进行绑定。在Java中,几乎所有方法都是后期绑定的。
下面说明每个阶段的工作
3.2 加载
加载时类加载过程的第一个阶段,在加载阶段,虚拟机需要完成以下三件事情:
- 通过一个类的全限定名来获取其定义的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在 Java 堆中生成一个代表这个类的 java.lang.Class 对象,作为对方法区中这些数据的访问入口。
注意,这里第 1 条中的二进制字节流并不只是单纯地从 Class 文件中获取,比如它还可以从 Jar 包中获取、从网络中获取(最典型的应用便是 Applet)、由其他文件生成(JSP 应用)等等。
相对于类加载的其他阶段而言,加载阶段(准确地说,是加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,因为开发人员既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载。开发人员可以利用这一些轻松实现Java类的动态热部署.
加载阶段完成后,虚拟机外部的 二进制字节流就按照虚拟机所需的格式存储在方法区之中,而且在 Java 堆中也创建一个 java.lang.Class 类的对象,这样便可以通过该对象访问方法区中的这些数据。
说到加载,不得不提到类加载器,下面就具体讲述下类加载器。
类加载器虽然只用于实现类的加载动作,但它在 Java 程序中起到的作用却远远不限于类的加载阶段。对于任意一个类,都需要由它的类加载器和这个类本身一同确定其在就 Java 虚拟机中的唯一性,也就是说,即使两个类来源于同一个 Class 文件,只要加载它们的类加载器不同,那这两个类就必定不相等。
这里的“相等”包括了代表类的 Class 对象的 equals()、isAssignableFrom()、isInstance()等方法的返回结果,也包括了使用 instanceof 关键字对对象所属关系的判定结果。
站在 Java 虚拟机的角度来讲,只存在两种不同的类加载器:
- 启动类加载器:它使用 C++ 实现(这里仅限于 Hotspot,也就是 JDK1.5 之后默认的虚拟机,有很多其他的虚拟机是用 Java 语言实现的),是虚拟机自身的一部分。
- 所有其他的类加载器:这些类加载器都由 Java 语言实现,独立于虚拟机之外,并且全部继承自抽象类 java.lang.ClassLoader,这些类加载器需要由启动类加载器加载到内存中之后才能去加载其他的类。
而站在 Java 开发人员的角度来看,类加载器可以大致划分为以下三类:
- 启动类加载器:Bootstrap ClassLoader,跟上面相同。它负责加载存放在
JDK\jre\li
(JDK 代表 JDK 的安装目录,下同)下,或被-Xbootclasspath
参数指定的路径中的,并且能被虚拟机识别的类库(如 rt.jar,所有的java.*
开头的类均被 Bootstrap ClassLoader 加载)。启动类加载器是无法被 Java 程序直接引用的。 - 扩展类加载器:Extension ClassLoader,该加载器由
sun.misc.Launcher$ExtClassLoader
实现,它负责加载JDK\jre\lib\ext
目录中,或者由 java.ext.dirs 系统变量指定的路径中的所有类库(如javax.*
开头的类),开发者可以直接使用扩展类加载器。 - 应用程序类加载器:Application ClassLoader,该类加载器由 sun.misc.Launcher$AppClassLoader 来实现,它负责加载用户类路径(ClassPath)所指定的类,开发者可以直接使用该类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
应用程序都是由这三种类加载器互相配合进行加载的,如果有必要,我们还可以加入自定义的类加载器。因为 JVM 自带的 ClassLoader 只是懂得从本地文件系统加载标准的 java class 文件,因此如果编写了自己的 ClassLoader,便可以做到如下几点:
-
在执行非置信代码之前,自动验证数字签名。
-
动态地创建符合用户特定需要的定制化构建类。
- 从特定的场所取得 java class,例如数据库中和网络中。
事实上当使用 Applet 的时候,就用到了特定的 ClassLoader,因为这时需要从网络上加载 java class,并且要检查相关的安全信息,应用服务器也大都使用了自定义的 ClassLoader 技术。
这种层次关系称为类加载器的双亲委派模型。我们把每一层上面的类加载器叫做当前层类加载器的父加载器,当然,它们之间的父子关系并不是通过继承关系来实现的,而是使用组合关系来复用父加载器中的代码。该模型在 JDK1.2 期间被引入并广泛应用于之后几乎所有的 Java 程序中,但它并不是一个强制性的约束模型,而是 Java 设计者们推荐给开发者的一种类的加载器实现方式。
双亲委派模型的工作流程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上,因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范围中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类。
使用双亲委派模型来组织类加载器之间的关系,有一个很明显的好处,就是 Java 类随着它的类加载器(说白了,就是它所在的目录)一起具备了一种带有优先级的层次关系,这对于保证 Java 程序的稳定运作很重要。例如,类java.lang.Object 类存放在JDK\jre\lib
下的 rt.jar 之中,因此无论是哪个类加载器要加载此类,最终都会委派给启动类加载器进行加载,这边保证了 Object 类在程序中的各种类加载器中都是同一个类。
3.3 验证
验证的目的是为了确保Class文件中的字节流信息符合当前虚拟机的要求,而且不会危害虚拟机自身的安全。不同的虚拟机对类验证的实现可能会有所不同。但大致上都有以下4个阶段的流程:
(1) 文件格式的验证: 验证字节流是否符合Class文件格式的规范,符合当前版本虚拟机要求,保证输入的字节流能被正确的解析并且存储于方法区内。 经过该阶段的验证后,字节流才会进入内存的方法区中进行存储,后面的三个验证都是基于方法区的存储结构进行的,因此这部分很关键。
(2) 元数据验证:对类的元数据信息进行语法校验,就是对类中各数据类型进行语法校验,保证不存在不符合Java语法的元数据信息。
(3) 字节码验证: 数据流和控制流的分析,对类方法体进行校验分析,以保证被校验的类方法不会危害虚拟机安全。
(4) 符号引用验证:最后一个阶段的验证,它发生在虚拟机将符号引用转化为直接引用的阶段,主要是对类自身以外的信息(常量池中的各种符号引用)进行匹配性的校验。
3.4 准备
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。对于该阶段有以下几点需要注意:
- 这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在 Java 堆中。
- 这里所设置的初始值通常情况下是数据类型默认的零值(如 0、0L、null、false 等),而不是被在 Java 代码中被显式地赋予的值。
假设一个类变量的定义为:
public static int value = 3;
那么变量 value 在准备阶段过后的初始值为 0,而不是 3,因为这时候尚未开始执行任何 Java 方法,而把 value 赋值为 3 的 putstatic 指令是在程序编译后,存放于类构造器 ()方法之中的,所以把 value 赋值为 3 的动作将在初始化阶段才会执行。
下表列出了 Java 中所有基本数据类型以及 reference 类型的默认零值:
这里还需要注意如下几点:
- 对基本数据类型来说,对于类变量(static)和全局变量,如果不显式地对其赋值而直接使用,则系统会为其赋予默认的零值,而对于局部变量来说,在使用前必须显式地为其赋值,否则编译时不通过。
- 对于同时被 static 和 final 修饰的常量,必须在声明的时候就为其显式地赋值,否则编译时不通过;而只被 final 修饰的常量则既可以在声明时显式地为其赋值,也可以在类初始化时显式地为其赋值,总之,在使用前必须为其显式地赋值,系统不会为其赋予默认零值。
- 对于引用数据类型 reference 来说,如数组引用、对象引用等,如果没有对其进行显式地赋值而直接使用,系统都会为其赋予默认的零值,即null。
- 如果在数组初始化时没有对数组中的各元素赋值,那么其中的元素将根据对应的数据类型而被赋予默认的零值。
如果类字段的字段属性表中存在 ConstantValue 属性,即同时被 final 和 static 修饰,那么在准备阶段变量 value 就会被初始化为 ConstValue 属性所指定的值。
假设上面的类变量 value 被定义为:
public static final int value = 3;
编译时 Javac 将会为 value 生成 ConstantValue 属性,在准备阶段虚拟机就会根据 ConstantValue 的设置将 value 赋值为 3。回忆上一篇博文中对象被动引用的第 2 个例子,便是这种情况。我们可以理解为 static final 常量在编译期就将其结果放入了调用它的类的常量池中。
3.5 解析
解析阶段是虚拟机将常量池中的符号引用转化为直接引用的过程。在 Class 类文件结构一文中已经比较过了符号引用和直接引用的区别和关联,这里不再赘述。前面说解析阶段可能开始于初始化之前,也可能在初始化之后开始,虚拟机会根据需要来判断,到底是在类被加载器加载时就对常量池中的符号引用进行解析(初始化之前),还是等到一个符号引用将要被使用前才去解析它(初始化之后)。
对同一个符号引用进行多次解析请求时很常见的事情,虚拟机实现可能会对第一次解析的结果进行缓存(在运行时常量池中记录直接引用,并把常量标示为已解析状态),从而避免解析动作重复进行。
解析动作主要针对类或接口、字段、类方法、接口方法四类符号引用进行,分别对应于常量池中的 CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info 四种常量类型。
1、类或接口的解析:判断所要转化成的直接引用是对数组类型,还是普通的对象类型的引用,从而进行不同的解析。
2、字段解析:对字段进行解析时,会先在本类中查找是否包含有简单名称和字段描述符都与目标相匹配的字段,如果有,则查找结束;如果没有,则会按照继承关系从上往下递归搜索该类所实现的各个接口和它们的父接口,还没有,则按照继承关系从上往下递归搜索其父类,直至查找结束,查找流程如下图所示:
从下面一段代码的执行结果中很容易看出来字段解析的搜索顺序:
class Super{ public static int m = 11; static{ System.out.println("执行了super类静态语句块"); } } class Father extends Super{ public static int m = 33; static{ System.out.println("执行了父类静态语句块"); } } class Child extends Father{ static{ System.out.println("执行了子类静态语句块"); } } public class StaticTest{ public static void main(String[] args){ System.out.println(Child.m); } }
执行结果如下:
执行了super类静态语句块
执行了父类静态语句块
33
如果注释掉 Father 类中对 m 定义的那一行,则输出结果如下:
执行了super类静态语句块
11
另外,很明显这就是上篇博文中的第 1 个例子的情况,这里我们便可以分析如下:static 变量发生在静态解析阶段,也即是初始化之前,此时已经将字段的符号引用转化为了内存引用,也便将它与对应的类关联在了一起,由于在子类中没有查找到与 m 相匹配的字段,那么 m 便不会与子类关联在一起,因此并不会触发子类的初始化。
最后需要注意:理论上是按照上述顺序进行搜索解析,但在实际应用中,虚拟机的编译器实现可能要比上述规范要求的更严格一些。如果有一个同名字段同时出现在该类的接口和父类中,或同时在自己或父类的接口中出现,编译器可能会拒绝编译。如果对上面的代码做些修改,将 Super 改为接口,并将 Child 类继承 Father 类且实现 Super 接口,那么在编译时会报出如下错误:
StaticTest.java:24: 对 m 的引用不明确,Father 中的 变量 m 和 Super 中的 变量 m
都匹配
System.out.println(Child.m);
^
1 错误
3、类方法解析:对类方法的解析与对字段解析的搜索步骤差不多,只是多了判断该方法所处的是类还是接口的步骤,而且对类方法的匹配搜索,是先搜索父类,再搜索接口。
4、接口方法解析:与类方法解析步骤类似,知识接口不会有父类,因此,只递归向上搜索父接口就行了。
3.6 初始化
初始化是类加载过程的最后一步,到了此阶段,才真正开始执行类中定义的 Java 程序代码。
在准备阶段,类变量已经被赋过一次系统要求的初始值,而在初始化阶段,则是根据程序员通过程序指定的主观计划去初始化类变量和其他资源,或者可以从另一个角度来表达:初始化阶段是执行类构造器()方法的过程。
这里简单说明下()方法的执行规则:
1、()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句中可以赋值,但是不能访问。
2、()方法与实例构造器()方法(类的构造函数)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的()方法执行之前,父类的()方法已经执行完毕。因此,在虚拟机中第一个被执行的()方法的类肯定是java.lang.Object。
3、()方法对于类或接口来说并不是必须的,如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成()方法。
4、接口中不能使用静态语句块,但仍然有类变量(final static)初始化的赋值操作,因此接口与类一样会生成()方法。但是接口与类不同的是:执行接口的()方法不需要先执行父接口的()方法,只有当父接口中定义的变量被使用时,父接口才会被初始化。另外,接口的实现类在初始化时也一样不会执行接口的()方法。
5、虚拟机会保证一个类的()方法在多线程环境中被正确地加锁和同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的()方法,其他线程都需要阻塞等待,直到活动线程执行()方法完毕。如果在一个类的()方法中有耗时很长的操作,那就可能造成多个线程阻塞,在实际应用中这种阻塞往往是很隐蔽的。
下面给出一个简单的例子,以便更清晰地说明如上规则:
class Father{ public static int a = 1; static{ a = 2; } } class Child extends Father{ public static int b = a; } public class ClinitTest{ public static void main(String[] args){ System.out.println(Child.b); } }
执行上面的代码,会打印出 2,也就是说 b 的值被赋为了 2。
我们来看得到该结果的步骤。
首先在准备阶段为类变量分配内存并设置类变量初始值,这样 A 和 B 均被赋值为默认值 0,而后再在调用()方法时给他们赋予程序中指定的值。
当我们调用 Child.b 时,触发 Child 的()方法,根据规则 2,在此之前,要先执行完其父类Father的()方法,又根据规则1,在执行()方法时,需要按 static 语句或 static 变量赋值操作等在代码中出现的顺序来执行相关的 static 语句,因此当触发执行 Father的()方法时,会先将 a 赋值为 1,再执行 static 语句块中语句,将 a 赋值为 2,而后再执行 Child 类的()方法,这样便会将 b 的赋值为 2。
如果我们颠倒一下 Father 类中“public static int a = 1;”语句和“static语句块”的顺序,程序执行后,则会打印出1。很明显是根据规则 1,执行 Father 的()方法时,根据顺序先执行了 static 语句块中的内容,后执行了“public static int a = 1;”语句。
另外,在颠倒二者的顺序之后,如果在 static 语句块中对 a 进行访问(比如将 a 赋给某个变量),在编译时将会报错,因为根据规则 1,它只能对 a 进行赋值,而不能访问。