目录
一、前言
二、类文件结构(Class文件)
2.1 平台无关性与语言无关性
2.2 从.java文件到.class文件,手把手教你阅读.class文件(从十六进制到javap)
三、类加载机制
3.1 类加载概要
3.2 类加载详细
3.2.1 加载
3.2.2 验证
3.2.3 准备
3.2.4 解析
3.2.5 初始化
3.3 类加载器
3.3.1 类与类加载器
3.3.2 双亲委派模型
3.3.3 破坏双亲委派模型
四、字节码执行引擎
4.1 执行引擎
4.1.1 什么是执行引擎?
4.1.2 JVM执行引擎概念模型(统一外观,不同实现)
4.2 运行时帧栈结构
4.2.1 引入帧栈:帧栈是什么?
4.2.2 帧栈的结构
4.2.3 局部变量表Local Variable Table
4.2.4 操作数栈Operand Stack
4.2.5 动态连接
4.2.6 方法返回地址
4.2.7 附加信息
4.3 方法调用
4.3.1 解析
4.3.2 分派Dispatch,Java重载和重写的底层实现
4.3.3 动态类型语言支持
4.4 方法执行——基于栈的字节码解释执行引擎
4.4.1 解释执行
4.4.2 基于栈的指令集与基于寄存器的指令集(对比JVM和计算机)
4.4.3 基于栈的解释器执行过程
五、实践:把自己当成虚拟机,一点一滴解析.class文件(.java文件--.class文件映射分析)
5.1 局部变量表回收的例子
5.2 方法调用解析例子
六、小结
笔者关于JVM的一共有四篇文章,前一篇讲述“JVM自动内存管理”,讲述JVM的底层结构,内存分配与内存回收。本篇讲述“JVM执行子系统”,本篇的全部目标是解析.class文件,读完本篇后,您会发现从.java文件到.class文件的映射,直至一个变量的定义,每一行代码,都是有矩可循的。
全文的结构是:第二部分,从Java两个无关性引入class文件,并对一个打印"hello world"字符串的程序的class文件进行分析,这里读者可能看不懂分析过程,没有关系,因为第二部分只是一个引子;第三部分,介绍JVM类加载机制(包括类加载概要、类加载明细);第四部分,介绍JVM执行引擎(包括介绍帧栈结构,方法调用,方法执行);第五部分,对上面示意的demo程序做“.java文件--.class文件”一一映射分析。第六部分小结。
Java这里有两个无关性:平台无关性、语言无关性,是两个不同的东西,不要搞混了,虽然底层实现都是虚拟机和字节码存储格式.
(1)平台无关性(JVM可以运行在任何操作系统上)
Java最值得令人称道之处就是其“一次编写,到处运行(Write Once,Run Anywhere)”,这句宣传指的是Java平台无关性,值得注意的是,平台无关性并不是JVM所特有,而是所有虚拟机的诉求,很多其他虚拟机都在不断实现平台无关性。
平台无关性是指虚拟机(这里是JVM)可以运行在不同平台上,这些虚拟机都可以载入和执行同一种平台无关的字节码(byteCode),从而实现程序的“一次编写,到处运行(Write Once,Run Anywhere)”,由此可知,各种不同平台虚拟机与所有平台都统一使用的程序存储格式——字节码(ByteCode)是构成平台无关性的基石。
(2)语言无关性(JVM上可以运行任何语言)
语言无关性是JVM的又一特性,实际上,我们熟知的JVM不与任何语言关联(也不与Java语言相关),只与Class文件(.class格式文件)这种特定的文件格式关联,即其他任何语言编写的程序,只要经过对应的编译器生成class文件,都可以在JVM上运行,这就是JVM的语言无关性(实现这种语言无关性的基础也是虚拟机和字节码存储格式),如图所示:
JVM识别的是Class文件,而不是由Java编译生成的Class文件(其他语言写代码只要编译生成class文件也可以在JVM上运行,不一定要Java语言,JVM与Class文件绑定,不与Java语言绑定)
这里注意,Class文件中包含了Java虚拟机指令集和符号表以及若干其他辅助信息,所以,基于安全考虑,Java虚拟机规范要求在Class文件中使用许多强制性的语法和结构性约束(当然,这是后话,这里不讲述)。
小结:辨析平台无关性和语言无关性
定义(区别) | 底层实现 | |
平台无关性 | 平台无关性针对的对象是包括JVM在内的所有虚拟机,平台无关性是指虚拟机可以运行在不同平台上,且都可以载入和执行同一种平台无关的字节码,从而实现程序的“一次编写,到处运行(Write Once,Run Anywhere)” 注意:我们平时说的“一次编译,到处运行”是指JVM平台无关性,不是其语言无关性,但是其语言无关性也是一个很重要的性质。 |
虚拟机和字节码存储格式 |
语言无关性 | 语言无关性针对的对象是JVM(局限于本文),是指JVM可以运行Class文件,而不在乎这个Class文件是由什么语言编译而成的。 |
无论是平台无关性,语言无关性,底层支持都是字节码存储,都是生成的class文件,所以,本文的全部精力和所有目的就是真正读懂class文件。我们先来看一个.class文件(打印hello world的class文件),不在于看懂,而是要熟悉class文件的结构(当第五部分才要求完全看懂)。
as we all know,计算机认识二进制0和1,那么如何让程序员书写的代码被计算机识别,这就需要一种中介转换机制,将代码按既定规则转换为计算机可以识别的0和1,这种中介转换机制就是虚拟机,我们这里就是Java虚拟机,而能够与Java虚拟机沟通,能够被Java虚拟机识别的就是Class文件。
对于java程序,.class文件是由.java文件编译生成,而.java文件就是程序员书写的代码,所以,这个帮助我们实现两个无关性的.class文件实际上就是根据程序的书写的代码编译生成的(不只是Java语言,其他语言也是这样)。
(1)十六进制阅读class文件
现在我们来解析.class结构文件,所以要辛苦读者阅读.class文件的二进制的格式了(我们用十六进制表示以方便阅读),有点像我们学计算机网络的时候阅读ip和tcp首部20字节,虽然.class文件不只20字节,但是阅读起来都是一样的。
我们使用最基本的命令行方式:我们写出一段打印hello world的程序,
public class Test {
public static void main(String[] args){
System.out.println("hello world");
}
}
这里笔者使用UltraEdit打开Test.class文件,可以用十六进制打开,如图,这是整个Test.class的二进制文件:
因为是十六进制数字,每一个十六进制数字是4位,则两个数字是8位,就是一个字节。
如上图所示,前面四个字节(序号为 0 1 2 3)是magic number,为魔法数字,固定位CAFEBABE,
第5-8个字节(序号为 4 5 6 7)存放是的Test.class文件的版本号,其中,第5、6个字节是次版本号Minor Version,第7、8个字节是主版本号Major Version,这里次版本号为0,主版本号为0x0034,换为十进制是52,根据版本对应关系,这里使用的是jdk8,正是如此。
接下来的两个字节(序号为8 9)表示常量区容量,是0x0022,十进制是34,表示常量区容量是34-1=33,这是因为常量区序号从1开始,所以表示常量区有33个常量,分别是1-33,当这个数字为0x0000时,表示“不引用任何一个常量池项目”。
(2)class文件解析工具——javap助力,再也不用阅读16进制
接下来我们来分析这33个常量,用十六进制太麻烦了,使用jdk自动的javap.exe (阅读起来就像是汇编程序或者那种编译原理的味道),如图:
现在我们从上到下,一步步解释上图:
首先,minor version次版本号为0,major version主版本号为52;
Constant pool 常量池:
第一个常量:Methodref表示类中方法的符号引用,关于对java/lang/Object."
":()V 的理解:java.lang.Object表示命名空间,init为方法名,表示该命名空间中的方法,:后面表示方法签名(参数列表+返回值),()表示参数为空,V表示void,返回值为空。所以,整句的意思是:调用 java.lang.Object 的 init 方法。
第二个常量:Fieldref表示字段的符号引用,关于对java/lang/System.out:Ljava/io/PrintStream的理解:
java.lang.System.out表示命名空间,这个字段就是java.io.PrintStream,即打印流字段。
第三个常量:String表示字符串变量,hello world即是该字符串
第四个常量:Methodref 类中方法的符号引用,关于对java/io/PrintStream.println:(Ljava/lang/String;)V的理解:
java.io.PrintStream表示命名空间,println表示方法名,:后面表示参数和返回值,Ljava.lang.String表示实际参数,V表示void,返回值为空,所以整句的意思是:调用java.io.PrintStream打印流里面的println()方法,参入的实参是一个String字符串,返回值为空。
第五个常量:Class表示类或接口的符号引用, mypackage/Test 表示类的全限定性路径,包名+类名,这里是mypackage.Test类。
第六个变量:Class表示类或接口的符号引用, java/lang/Object 表示类的全限度性路径,包名+类名,这里是java.lang.Object类。
注意:以第一个常量为例,后面有#6.#20,这是什么意思呢,其实意思就是转到序号为6、20的常量,先看#6,#6的值是#27,所以定位到27,值为java.lang.Object,另一方面再看#20,#20值为#7 #8,#7值为
#8值为()V,其实,通过这种查看方式查看到的结果和直接看 // 注释后面的内容是一样的,所以刚才笔者解释Constant pool 常量池的时候,是直接拿后面的注释解释的。
这里分为两段,一段是public mypackage.Test(); 第二段是 public static void main(java.lang.String[]);其实就是两个方法,咦,这个程序中明明只有一个main方法,为什么现在有两个方法呢,且看Test.class:
原来,Test类有一个默认无参构造函数,加上main函数就两个函数了,这就是为什么有两个函数的原因,读者记住了,后面还会出现这个默认无参构造,这里解释了,后面就不再解释了。
先看第一个方法,Test默认无参构造函数,public mypackage.Test();
descriptor:表示该方法的描述(实参+返回值),这里 ()V 表示实参为空,返回值为空;
flags:表示访问标记,和java程序中关键字对应,ACC_PUBLIC对应public关键字,表示mypackage.Test()是公共方法;
Code:表示是代码段,
stack=1, locals=1, args_size=1 表示操作数栈数目为1,本地变量表容量为1(表示局部变量表中只有一个变量,这个变量的的具体信息是下面的LocalVariableTable:清单),参数数目为1(注意:这里看不懂操作数栈和本地变量表不要紧,本文后面都有介绍)
0: aload_0
1: invokespecial #1 // Method java/lang/Object."
4: return
这三句中 0: 1: 4:表示的都是地址偏移量,冒号后面表示的是助记符,#后面表示的常量池序号(这里是1),//后面表示的是注释,现在来一个个解释助记符。
aload_0的解释:分为三部分,a表示类型,为this,当前类对象,load表示操作数栈入栈操作,0表示slot序号,整句意思是将局部变量表第0个slot中的this对象复制到操作数栈顶。
关于阅读class文件中的load操作 store操作(注意:单独的load store 不会在.class文件中,出现在class文件中的一般是 “类型简称+load+_+slot序号”)
load操作:将局部变量表中指定的某个元素复制到操作数栈栈顶,即操作数栈入栈操作,操作数栈元素个数+1,局部变量表元素个数不变。
store操作:将操作数栈栈顶元素出栈并存放入指定的局部变量slot中,局部变量表要记录,即操作数栈出栈操作,操作数栈元素个数-1,局部变量表中元素个数+1。
aload_0:a表示类型,为引用类型referenceType,load表示操作数栈入栈操作,0表示slot序号,整句意思是将局部变量表第0个slot中的引用类型值复制到操作数栈顶。
iload_0:i表示类型,为基本类型int,load表示操作数栈入栈操作,0表示slot序号,整句意思是将局部变量表第0个slot中int整型值复制到操作数栈顶。
lload_1:l表示类型,为基本类型long,load表示操作数栈入栈操作,1表示slot序号,整句意思是将局部变量表第1个slot中的long长整型值复制到操作数栈顶。
fload_2:f表示类型,为基本类型float,load表示操作数栈入栈操作,2表示slot序号,整句意思是将局部变量表第2个slot中的float单精度浮点型值复制到操作数栈顶。
dload_3:d表示类型,为基本类型double,load表示操作数栈入栈操作,3表示slot序号,整句意思是将局部变量表第3个slot中的double双精度浮点型值复制到操作数栈顶。
astore_0:a表示类型,为引用类型referenceType或返回地址returnAddress,store表示操作数栈出栈操作,0表示序号,整句意思是将操作栈栈顶的引用类型值出栈并存放到第0个局部变量Slot中。
istore_0:i表示类型,为int整型类型,store表示操作数栈出栈操作,0表示序号,整句意思是将操作栈栈顶的int整型值出栈并存放到第0个局部变量Slot中。
lstore_1:l表示类型,为long长整型类型,store表示操作数栈出栈操作,1表示序号,整句意思是将操作栈栈顶的long长整型值出栈并存放到第1个局部变量Slot中。
fstore_2:f表示类型,为float单精度浮点型类型,store表示操作数栈出栈操作,2表示序号,整句意思是将操作栈栈顶的float单精度浮点型值出栈并存放到第2个局部变量Slot中。
dstore_3:d表示类型,为double双精度浮点型类型,store表示操作数栈出栈操作,3表示序号,整句意思是将操作栈栈顶的double双精度浮点型值出栈并存放到第3个局部变量Slot中。
invokespecial:这是一条方法调用字节码,调用构造函数、私有方法、父类方法都是用invokespecial,这里调用构造函数,所以使用invokespecial
return:表示构造器返回,因为构造函数时没有返回值的,所有只要一个return。(如果返回一个整型,为ireturn,返回一个长整型,为ireturn,不同的jdk可能稍微有一点不一样,但描述的意思是一样的,读者理解就好)
LineNumberTable: 表示行号表,存放方法的行号信息,就是 line 3: 0 表示的意思是.java代码中第三行对应的是Code代码块中偏移地址为0这句。
LocalVariableTable:表示局部变量表,存放方法的局部变量信息(方法参数+方法内定义的局部变量)
Start Length Slot Name Signature
0 5 0 this LTest;
这里的局部变量表只有一行数据,表示只有一个变量,这和上面的locals=1(局部变量表容量为1)是对应的。
Start 这里为0,一般都是0开始的,略;
Length 这里5表示长度,即局部变量表的长度;
Slot 表示变量槽,局部变量表的容量以 Variable Slot(变量槽)为最小单位,每个变量槽都可以存储 32 位长度的内存空间,这里的0表示的是序号,第0个Slot;
Name 表示局部变量名,这是是this;
Signature 表示局部变量类型,L前缀表示对象,这里表示的是Test类对象。
这里有一个小疑问,LocalVariableTable是局部变量表,里面是存放变量的,是存放一个方法(这里是Test类默认无参构造函数)的参数与方法内定义的局部变量的,但是这个Test默认无参构造函数中,既没有参数,里面又没有定义局部变量,为什么生成的class文件中,这个方法里面还有一个类型为mypackage.Test、名称为this的变量呢?
因为JVM中,非static方法第0位索引的slot默认是用于传递方法所属对象实例的引用,所以class文件中就有一个this变量,如果看不懂加粗的这句话,且看本文4.2.3 局部变量表Local Variable Table,有相关解释。
先看第二个方法,public static void main(java.lang.String[]);
descriptor:表示其描述(实参+返回值),这里 ()V 表示实参为空,返回值为空;
flags:表示访问标记,和java程序中关键字对应,ACC_PUBLIC, ACC_STATIC分别对应public static关键字,表示main方法是公共的、静态的方法。
Code: 表示代码段
stack=2, locals=1, args_size=1 表示操作数栈容量为2,本地变量容量为1(就是slot0),参数数目为1(就是args)
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String hello world
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
0: getstatic #2 偏移地址为0的位置, 从类中获取静态字段
3: ldc #3 偏移地址为3的位置,把常量池中的项压入操作数栈
5: invokevirtual #4 偏移地址为5的位置,调用虚方法(即普通Java方法),使用invokevirtual
8: return 偏移地址为8的位置,结束
LineNumberTable: 表示行号表
line 5: 0 表示.java文件中行号为5 ,表示的Code代码段偏移地址为0的这句。
line 6: 8 表示.java文件中行号为6,表示的Code代码段偏移地址为8的这句。
LocalVariableTable:表示局部变量表,这个方法中局部变量只有一个,就是args,类型是java.lang.String[]
Start Length Slot Name Signature
0 9 0 args [Ljava/lang/String;
这里的局部变量表只有一行数据,表示只有一个变量,这和上面的locals=1(局部变量表容量为1)是对应的。
start 这里为0,一般都是0开始的,略;
length 这里9表示长度,即局部变量表的长度;
slot 表示变量槽variable slot,是局部变量表中最小单位,这里的0表示的是序号,第0个Slot;
name 表示局部变量名,这里是args;
signature 表示局部变量类型,这里是[Ljava/lang/String; [表示数组,L表示对象,所以总体表示String数组
这里是没有问题的,LocalVariableTable局部变量表,里面是存放方法的参数与方法内定义的局部变量,main方法的参数值String数组类型的args参数。
该程序向我们演示了将hello world字符串打印到控制台的全过程,可以看到仅仅一个打印hello world的程序,其底层也是有很多步骤的。
附上相关记录方式,供读者使用
字段表集合:(8种基本类型、void、对象、一维数组、二维数组)
标识字符 | 解释 | 备注 |
B | 基本类型byte | 虽然byte boolean都是b开头,但是用B表示byte,用Z表示boolean |
Z | 基本类型boolean | |
S | 基本类型short | 都是见名达意的东西,不必解释 |
I | 表示基本类型int | |
L | 表示基本类型long | |
C | 表示基本类型char | |
F | 表示基本类型float | |
D | 表示基本类型double | |
V | 表示特殊类型void | |
L | 表示对象类型 | Ljava/lang/String 表示String对象,Lmypackage/Test表示自定义类Test对象 |
[ | 表示数组类型,一维数组 | [I 表示整型一维数组int[],[Ljava/lang/String 表示String[]字符串一维数组,[Lmypackage/Test表示自定义类Test一维数组 |
[[ | 表示数组类型,二维数组 | [[I 表示整型二维数组int[][],[[Ljava/lang/String 表示String[][]字符串二维数组,[[Lmypackage/Test表示自定义类Test二维数组 |
对于这个打印“hello world”的程序,不要求读者看懂,第五部分我们才实践看懂class文件,放在这里的目的是为了读者大致了解class文件的结构。
介绍完class文件后,现在我们来看如何JVM类加载机制。
什么是JVM类加载机制?
虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程就是JVM的类加载机制。
Java语言是动态链接,又称运行时绑定,其类的加载、连接和初始化过程都是在程序运行期间完成的,为Java应用程序提供高度灵活性。
类从被加载到虚拟机内存开始,到卸载出内存为止,整个生命周期包括:加载Loading 验证Vertification 准备Preparation 解析Resolution 初始化lnitialization 使用Using 卸载Unloading 7个阶段,如图。
在如上的七个过程中,验证Vertification 准备Preparation 解析Resolution 三个过程统称为连接过程Linking,因为Java语言是动态连接(即运行时连接或运行时绑定),就是要等到运行时才能确定某一个对象的具体类型,就是我们面向接口编程的时候(如Object obj=new String(),编译时只能确定编译类型为Object类,只有等到运行时,在Java堆中分配内存,新建String对象,然后用obj引用指向这个String类对象,才能确定的obj是一个String类型对象引用)
加载、验证、准备、初始化、卸载这5个阶段的开始顺序是确定的(注意:这里所说的是开始顺序确定,不是指一个阶段结束后下一个阶段才开始,即开始顺序是:先开始加载,再开始验证,再开始准备,再开始初始化,再开始卸载,但是并不是加载阶段全部完成后再开始验证阶段,不是这个意思,后面会讲)。而解析阶段就不一定,在某种情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定。
什么时候需要开始类加载过程第一阶段——加载,实际上,没有硬性约束。但是,对于类加载的初始化Initialization阶段,虚拟机规范则严格规定了有且只有5种情况必须对类进行“初始化”:
a.遇到 new、getstatic、putstatic 或 invokestatic 这 4 条字节码指令时没初始化触发初始化。使用场景:使用 new 关键字实例化对象、读取一个类的静态字段(被 final 修饰、已在编译期把结果放入常量池的静态字段除外)、调用一个类的静态方法。
b.使用 java.lang.reflect 包的方法对类进行反射调用的时候。
c.当初始化一个类的时候,如果发现其父类还没有进行初始化,则需先触发其父类的初始化。
d.当虚拟机启动时,用户需指定一个要加载的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类。
e.当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需先触发其初始化。
前面的五种方式是对一个类的主动引用,除此之外,所有引用类的方法都不会触发初始化,称为被动引用。
现在我们来分别解释上面七个过程,就是类加载的七个过程。
注意:“加载”与“类加载”区别?
看起来很相似,但是不是同一个东西,加载≠类加载,是包含关系,类加载一共包括7个阶段,加载Loading 验证Vertification 准备Preparation 解析Resolution 初始化lnitialization 使用Using 卸载Unloading,加载是其中的第一个阶段。
作为类加载的第一个阶段,加载过程中,JVM需要完成三件事:
第一,通过一个类的全限定性类名(包名+类名,唯一确定一个类)获取定义此类的二进制字节流;
第二,将这个字节流所代表的静态存储结构转换为方法区的运行时数据结构;
第三,在内存中生成一个代表这个类的java.lang.String对象,作为方法区这个类的各个数据的访问入口。
对于非数组类的加载阶段,程序员可控性是最强的,因为加载阶段既可以使用系统提供的引导类加载器的完成,也可以由程序员自定义的类加载器完成,程序员可以通过定义自己的类加载器去控制字节流的获取方式(重写一个类加载器的loadClass()方法)。
对于数组类的加载阶段,由于数组类本身不通过类加载器的创建,它由JVM直接创建,但数组类与类加载器仍然有很密切的关系,因为数组类的元素类型最终是要靠类加载器去创建的,数组创建过程如下:
a.如果数组的组件类型是引用类型,那就递归采用类加载加载。
b.如果数组的组件类型不是引用类型,Java 虚拟机会把数组标记为引导类加载器关联。
c.数组类的可见性与他的组件类型的可见性一致,如果组件类型不是引用类型,那数组类的可见性将默认为 public。
加载阶段完成后,虚拟机外部的二进制文件流就按照虚拟机所需的格式存储在方法区之中,方法区中的数据存储格式由虚拟机实现自行定义,虚拟机规范未规定此区域的具体数据结构。然后在内存中实例化一个java.lang.Class类的对象(并没有明确规定是在Java堆中,对于HotSpot虚拟机而言,Class对象比较特殊,它虽然是对象,但是存放在方法区里面),这个对象将作为程序访问方法区中的这些类型数据的外部接口。
注意:关于加载阶段的时机,加载阶段和连接阶段(验证+准备+解析)的部分内容(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未全部完成,连接阶段可能已经开始,但是这些夹在加载阶段之中进行的动作,仍然属于连接阶段的内容,这两个阶段的开始时间仍然保持着固定的先后顺序,这和3.1 的开始顺序确定是一个意思。
验证阶段是类加载机制的第二步,也是连接阶段的第一步,上面的加载阶段开始后,验证阶段就可以开始了(顺序开始),验证阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
Java本身是一个比较安全的语言(比如数组越界、强制类型转换,如果出现错误,尽可能在编译时定位并阻止),但是,JVM识别的是Class文件,而不是由Java编译生成的Class文件(其他语言写代码只要编译生成class文件也可以在JVM上运行,不一定要Java语言,JVM与Class文件绑定,不与Java语言绑定)。虚拟机如果不检查输入的字节流,对其完全信任的话,很可能会因为载入了有害的字节流而导致系统崩溃,所以验证是虚拟机对自身保护的一项重要工作。
验证阶段是非常重要的,这个阶段是否严谨,直接决定了Java虚拟机是否能承受恶意代码的攻击,从执行性能的角度上讲,验证阶段的工作量在虚拟机的类加载子系统中占了相当大的一部分。从整体上来看,验证阶段大致上会完成下面4个阶段的检验工作:文件格式验证、元数据验证、字节码验证、符号引用验证。
1、文件格式验证(验证第一阶段)
文件格式验证主要是验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。这一阶段包括的验证点包括(但不局限于):
(1)是否以魔数magic number(就是class文件前四个字节CAFEBABE)开头;
(2)主版本号、次版本号是否在当前虚拟机处理范围之内(即验证class文件第5-8个字节,序号为4-7的字节内容);
(3)常量池的常量是否有不被支持的常量类型(检查常量tag标志);
(4)指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量;
(5)CONTENT_Utf8_info 型的常量中是否有不符合UTF8编码的数据;
(6)Class文件中各个部分及文件本身是否有被删除的或附加的其他信息。
2、元数据验证(验证第二阶段)
元数据验证是对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求,这个阶段可能包括的验证点有(包括但不局限于):
(1)这个类是否有父类(除了java.lang.Object类之外,所有类都应该有父类);
(2)这个类的父类是否继承了不允许被继承的类(被关键字final修饰的类);
(3)如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法;
(4)类中的字段、方法是否与父类产生矛盾(如覆盖了父类的final修饰的字段,或者出现不符合规则的方法重载)
这个阶段(元数据校验阶段)主要目的是对类的元数据信息进行语义校验,保证不存在不符合Java语言规范的元数据信息。
3、字节码验证(验证第三阶段)
字节码校验是整个校验过程中最复杂的阶段,主要目的是通过对数据流和控制流分析,确定程序语义是合法的、符合逻辑的。
元数据验证(验证第二阶段)和字节码验证(验证第三阶段)的区别?
前者是对元数据信息中的数据类型做检验,保证不存在不符合Java语言规范的元数据信息;
后者是对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件。
字节码检验的验证点有(包括但不局限于):
(1)保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,不会出现类似这样的情况:在操作栈放置一个int类型的数据,使用时却按long类型载入本地变量表中;
(2)保证跳转指令不会跳转到方法体以外的字节码指令上;
(3)保证方法体中的类型转换是有效的(子类对象赋值给父类数据类型是安全的,反过来不合法的)
4、符号引用验证(验证第四阶段)
最后一个验证阶段发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段中发生(这还是符合顺序开始的原则)。符号引用验证可以看做是对类自身以外(常量池中的各种符号引用)的信息进行匹配性验证,符号引用检验的验证点有(包括但不局限于):
(1)符号引用中通过字符创描述的全限定名是否能找到对应的类
(2)在指定类中是否存在符方法的字段描述符以及简单名称所描述的方法和字段
(3)符号引用中的类、字段、方法的访问性(private、protected、public、default)是否可被当前类访问
以上四个验证阶段是JVM在编译java程序的验证,只要有一个验证或验证下面的项目无法通过,就会编译失败,提示程序员修改程序,这样一来,就把尽可能多的错误在编译时确定,这就是JVM类加载机制中的验证阶段。
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。
读者注意笔者粗体标记的两个词语
第一,关于“为类变量分配内存”,这里仅指类变量,即用static关键字修饰的变量,不包括实例变量(没有static关键字修饰);
第二,关于“设置类变量初始值”,比如程序中定义一个int型类变量:
public static int value=1;
因为int型变量默认值为0,这里对其初始值赋值为1,实际上,变量value在准备阶段完成之后的值是默认值0,一定要等到初始化阶段,value才会被成功赋值为1.
注意:一种特殊情况,如果类变量(static关键字修饰的变量)被final关键字修饰,变量赋值后就不可被修改,只读,
public static final int value=1;
那么在准备阶段变量value的值就是1,不用等到初始化阶段。
解析阶段阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
注意笔者粗体表示的几个字“常量池”“符号引用”“直接引用”
常量池表示的是解析阶段操作的范围,符号引用表示的是解析阶段操作的对象,直接引用是解析阶段的产出结果。我们先引入“符号引用”和“直接引用”(符号引用、直接引用这两个概念很重要,第四部分重点讲解析都会用到,重点注意,要搞懂,加粗)
符号引用
符号引用以一组符号来描述所引用的目标,符号可以使任何形式的字面量。
直接引用
直接引用可以使直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符 7 类符号引用进行,分别对应于常量池的 7 中常量类型。
符号引用 | 常量池中常量类型 |
类或接口 | CONSTANT_Class_info |
字段 | CONSTANT_Fieldref_info |
类方法 | CONSTANT_Methodref_info |
接口方法 | CONSTANT_InterfaceMethodref_info |
方法类型 | CONSTANT_MethodType_info |
方法句柄 | CONSTANT_MethodHandle_info |
调用点限定符 | CONSTANT_InvokeDynamic_info |
初始化阶段是类加载过程的最后一步,实际上,类加载过程中,前面6个阶段都是以虚拟机主导(除了加载阶段用户应用程序可以通过类加载器参与之外),一直到初始化阶段开始执行类中的 Java 代码。
准备阶段的赋值操作与初始化阶段的赋值操作辨析(仅针对未使用final关键字修饰的变量):
准备阶段的赋值操作是系统默认值,而初始化阶段的赋值操作是程序员代码中定义的值,初始化阶段是执行类构造器
基本类型/数据类型 | 系统默认值/零值 |
boolean | false |
char | '\u0000'(null) |
byte | (byte)0 |
short | (short)0 |
int | 0 |
long | 0L |
float | 0.0f |
double | 0.0d |
reference对象引用 | null |
什么是类加载器?
类加载包括三个功能,将其中的“通过一个类的全限定性类名(包名+类名,唯一确定一个类)获取描述此类的二进制字节流”这个动作是在JVM外部实现的,实现这个动作的代码模块就是“类加载器”。
上面的这个定义告诉我们,实现类加载这个动作的代码模块就是“类加载器”,且看代码段:
package mypackage;
import java.io.InputStream;
public class ClassLoaderTest {
public static void main(String[] args) throws Exception {
ClassLoader classLoader = new ClassLoader() { //新建一个类加载器对象,classLoader引用指向该对象
@Override
public Class> loadClass(String name) throws ClassNotFoundException {
try {
// name 为mypackage.ClassLoaderTest filename 为ClassLoaderTest.class
String filename = name.substring(name.lastIndexOf(".") + 1) + ".class";
// 获取资源,表示加载一个名为ClassLoaderTest.class的类
InputStream inputStream = getClass().getResourceAsStream(filename);
if (null == inputStream)
return super.loadClass(name);
byte[] bytes = new byte[inputStream.available()];
inputStream.read(bytes);
return defineClass(name, bytes, 0, bytes.length);
} catch (Exception e) {
throw new ClassNotFoundException(name);
}
}
};
Object object = classLoader.loadClass("mypackage.ClassLoaderTest").newInstance();
System.out.println(object.getClass()); // class mypackage.ClassLoaderTest
System.out.println(object instanceof mypackage.ClassLoaderTest); //false
//为什么使用instanceof检查返回为false
// 因为jvm中存在两个ClassLoaderTest类,一个是由系统应用程序类加载器加载的,另外一个是程序员自定义类加载器加载的
// 虽然都来自同一个Class文件,但是两个独立不同的类,所以使用instanceof 检查返回为false
}
}
运行结果为:
class mypackage.ClassLoaderTest
false
对于这个程序,为什么使用instanceof检查返回为false?
因为jvm中存在两个ClassLoaderTest类,一个是由系统应用程序类加载器加载的,另外一个是程序员自定义类加载器加载的,虽然都来自同一个Class文件,但是两个独立不同的类,所以使用instanceof 检查返回为false
一般来说,我们对类加载器分类:
从 Java 虚拟机角度讲,按照类加载器是否存在JVM之内,分为两种类加载器:一种是启动类加载器(C++ 实现,是虚拟机的一部分);另一种是其他所有类的加载器(Java 实现,独立于虚拟机外部且全继承自 java.lang.ClassLoader)
从程序员的角度讲,按功能分类,分为三种类加载器:启动类加载器、扩展类加载器、引用程序类加载器,
启动类加载器——加载 lib 下或被 -Xbootclasspath 路径下的类
扩展类加载器——加载 lib/ext 或者被 java.ext.dirs 系统变量所指定的路径下的类
应用程序类加载器——ClassLoader负责,加载用户路径上所指定的类库。
我们的应用程序都是由这3种类加载器互相配合进行加载的,如图:
上图展示的类加载器之间的层次关系,称为类加载器的双亲委派模型,双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。这里类加载器之间的父子关系一般不会以继承的关系实现,而是都使用组合关系来复用父加载器的代码。
双亲委派模型的工作过程是:如果一个类加载器收到类加载请求,它首先不会自己尝试去加载这个类,而是把请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都会被传送到顶层的启动类加载器中,只有父类加载器反馈自己无法完成这个加载请求(它的范围内没有找到所需的类)时,子加载器才会尝试去加载。
双亲委派模型的好处:Java类随着它的类加载器一起具备了一种带有优先级的层次关系,这对于保证Java程序的正常运行很重要,实现起来却很简单,实现双亲委派的代码都集中在java.lang.ClassLoader的loadClass()方法中,这里给出这个loadClass():
protected Class> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
c = findClass(name);
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
先检查是否已被加载过,若没有加载则调用父加载器loadClass()方法,若父加载器为空则默认使用启动类加载器作为父加载器。
如果父类加载失败,抛出ClassNotFoundException异常后,再调用自己的findClass()方法进行加载。
实际上,双亲委派模式并不是强制程序员使用的一种约束模型,而是JVM提供给程序员的一种建议/推荐模型,在Java世界中大部分的类加载器都遵循这个模型(双亲委派模型)。其实,双亲委派模型在Java历史有过三个大的破坏,这里对三次破坏双亲委派模型简单介绍。
双亲委派模型的第一次“被破坏”:由于JDK1.2才引入双亲委派模型,所以JDK1.2之前是对双亲委派模型的破坏。
双亲委派模型的第二次“被破坏”:由于模型自身的缺陷所导致的,引入线程上下文件类加载器(Thread Context ClassLoader),对双亲委派模型的破坏。
双亲委派模型的第三次“被破坏”:由于用户对程序的动态性的追求导致的(如OSGi的出现)对双亲委派模型的破坏。
具体的,Java设计历史上对双亲委派模型的三次破坏,这里只是简单介绍,这些都是一些历史性的东西,对于面试、开发来说没什么用,为了不占用篇幅,这里不介绍,读者任意百度,均有详细介绍。
上一篇文章中,前言部分讲述到JVM之所以成为虚拟机,是因为它对计算机进行虚拟机,包括内存、磁盘、寄存器等方面,
那么计算机有执行引擎,直接建立在处理器、硬件、指令集、操作系统层面上的,那么JVM是否也有类似的执行引擎呢?答案是肯定的,但是JVM执行是由程序员自己实现的,因为是自己实现,所以可以自行制定指令集与执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式。
所以说,JVM是对计算机的虚拟,JVM执行引擎是对计算机执行引擎的虚拟。
统一外观:由Java虚拟机规范制定,统一外观指JVM执行引擎输入输出统一,输入的统一是字节码文件,处理过程统一是字节码解析的等效过程,输出的统一是执行结果。
不同实现:JVM执行引擎有两种实现方式,其一,解释执行,通过解释器执行,其二,编译执行,通过即时编译器,可以是任意一种实现方式,也可以两者兼而有之,还可以包含几个不同级别的编译器执行引擎。
帧栈,为Stack Frame,是用于支持虚拟机进行方法调用和方法执行的数据结构,
帧栈定义中,加粗的一共有三个词语,分别是“方法调用”“方法执行”“数据结构”,这里解释:
方法调用:方法与方法直接的相互调用关系,不涉及方法内部具体实现。
方法执行:方法内部具体是如何执行方法的(是如何来执行方法里面的字节码指令的)。
数据结构:帧栈类似是一种栈,先进后出,和数据结构中的栈是一样的,所以说帧栈是一种数据结构。
帧栈就是虚拟机运行时数据区的虚拟机栈(上一篇博客中,第二部分谈到JVM运行时数据区时,其包括五个部分,方法区、堆、虚拟栈、本地方法栈、程序计数器)的栈元素。帧栈存储了方法的局部变量表、操作数栈、动态连接、方法返回值和附加信息,下文中详细阐述。
注意,帧栈≠虚拟机栈,Java程序中,JVM运行时数据区只有一个虚拟机栈(Java方法调用),其中每一个线程有n个帧栈,帧栈1-帧栈n,一个帧栈对应一个方法。所以说,帧栈是虚拟机栈的栈元素。
Java程序中,每一个方法从调用开始至执行完成的过程,都对应一个帧栈在虚拟机入栈到出栈的过程。
帧栈的结构:
关于对上图(帧栈结构)的理解:
第一,一个Java程序有多个线程,线程1-线程N,当前正在执行的线程称为当前线程;
第二,每一个线程(以当前线程为例)中有多个方法,方法1-方法n,每一个方法对应一个帧栈,所以帧栈1-帧栈n,当前正在执行的方法对应的是当前帧栈;
第三,每一个帧栈(以当前帧栈为例)中有多个组件,包括局部变量表、操作栈、动态连接、返回地址、附加信息。
注意1,帧栈的结构是在编译时确定的,不是在运行时确定的
解释:在编译程序代码的时候, 帧栈中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并写入到方法表的Code属性中,因此一个帧栈需要分配多大的内存,不会受到程序运行时变量数据的影响,仅仅取决于具体虚拟机实现。
注意2,执行引擎运行的所有字节码指令都只针对当前帧栈操作
解释:当前帧栈是指活动线程中,位于栈顶的帧栈,且这个栈顶帧栈(当前帧栈)关联的方法称为当前方法。
局部变量表是一组变量值空间,用于存放方法参数和方法内定义的局部变量。在Java程序编译Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的局部变量的最大容量。
注意,粗体标记有两个“方法参数”、“方法内定义的局部变量”,表示局部变量表中变量来源于两部分,方法参数和方法内定义的局部变量,这里读者要记住,后面会用到,特别是第五部分的实践分析。
关于slot,全称Variable slot,译为变量槽,是局部变量表中的最小单位,可以存放一个32位以内的数据类型,每一个slot存放一个boolean byte char short int float reference returnAddress,前6种基本类型,不解释(其实基本类型有8种,但是long和double是64位的,所以不在这里面),
注意,关于32位和64位的问题?
虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0到局部变量表最大slot的数量。slot是局部变量表中的最小单位,要求每一个slot存放一个boolean byte char short int float reference returnAddress,但是数据类型有32位和64位之分,如果访问的是32位数据类型的变量(如上面8种 6种基本类型或reference returnAddress),索引n代表使用的是第n个索引,如果访问的是64数据类型的变量(如long double),则说明会同时使用n和n+1两个slot。
非static方法第0位索引的slot默认是用于传递方法所属对象实例的引用(这里给出解释):
在方法执行时,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,如果执行的是实例方法(非static方法),那局部变量表中第0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问到这个隐含的参数。其余参数则按照参数表顺序排列,占用从1开始的局部变量Slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的Slot。
附:关于局部变量的回收,Slot复用对垃圾收集的影响
代码1:
关于对上面GC日志的理解,[Tenured: 0K->1608K(10240K),0.0025840 secs]2338K->1608K(19456K)
Tenured表示是老年代(后面的Metaspace是元数据区,这里略),0K->1608K(10240K) 表示的含义:GC前该内存区域已使用的容量-->GC后该内存区域已使用的容量(该内存区域总容量),解释为,Full GC操作前,老年代什么都没有,当然是0K,GC操作后(因为是Fulll GC,所以都到老年代来了)使用了1608K,还是大于1024K,说明虽然是System.gc操作,但是没有在老年代中被回收;
0.0025840 secs是GC操作时间,这里也没什么用;
方括号之外,2338K->1608K(19456K) 表示的含义是:GC前Java堆已使用的容量-->GC后Java堆已使用的容量(Java堆总容量),理解为,应该是刚开始占用2338K,GC操作后,然后占用1608K;
代码2:
关于对上面GC日志的理解,[Tenured: 0K->584K(10240K), 0.0026270 secs] 2338K->584K(19456K)
Tenured表示是老年代(后面的Metaspace是元数据区,这里略),0K->584K(10240K) 表示的含义:GC前该内存区域已使用的容量-->GC后该内存区域已使用的容量(该内存区域总容量),解释为,Full GC操作前,老年代什么都没有,当然是0K,GC操作后(因为是Fulll GC,所以都到老年代来了)使用了584K,还是小于1024K,说明System.gc操作老年代回收了(1608K - 584K =1024K 就是回收了,这个placeholder的空间 1MB=1024K=1024*1024B);
方括号之外,2338K->584K(19456K) 表示的含义是:GC前Java堆已使用的容量-->GC后Java堆已使用的容量(Java堆总容量),理解为,应该是刚开始使用2338K,GC操作后仅占用584K;
代码3(手动赋值为null):
达到的效果是和代码2一样的,不再解释了。
对于局部变量表中的两种变量(参数和方法内定义的局部变量),参数自然是由函数调用方的传递实参过来,但是方法内定义的局部变量就一定要程序员自己赋值了,否则编译报错。
附:为什么方法内定义的局部变量一定要赋值?因为(方法内的)局部变量没有准备阶段。
从本文第三部分类加载机制可以知道,一个类变量(static关键字修饰的变量)有两次赋值,一次是准备阶段的赋值为系统默认值(基本类型和对象引用都有),一次是初始化阶段执行程序员编写的代码对其赋值,正是因为存在准备阶段,所以对于类变量来说,即使在定义时没有赋值也不会发生编译错误(变量就用准备阶段得到的系统默认值运行即可)。但是,局部变量没有准备阶段,定义的局部变量不会赋值为系统默认值,故局部变量一定要在定义时赋值,否则jvm在编译时会指出程序的错误。如
public static void main(String[] args){
int a; //局部变量没有赋值
System.out.println(a);
}
操作数栈,又称操作栈,操作数栈的结构是在编译时确定的,操作数栈的最大深度也是在编译时写入到Code属性中max_statcks数据项中。
关于数据类型的位数问题:操作数栈的每一个元素可以是任意的Java数据类型,包括long和double,如果数据类型是32位,占栈容量为1,如果数据类型是64位,占栈容量为2。
此外,在方法执行的任何时候,操作数栈的深度都不会超过max_statcks数据项中设定的最大值。
当一个方法刚开始执行的时候,其帧栈中操作数栈是空的,在方法执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,即入栈/出栈操作。
举个例子,整型方法的字节码指令iadd在运行的时候操作数栈中最接近栈顶的两个元素已经存入了两个int型的数值,当执行这个指令时,会将两个int值出渣并相加,然后将相加的结果入栈。这里给出示例图:
PS:关于两数相加的实现,笔者认为这个图已经解释的很清楚了,包括.java文件--.class文件行号对应关系、操作数栈、局部变量表/slot 都有了,并给出了每一条指令码执行后的操作数栈和slot情况,所以并没有将每一条指令码执行后的操作数栈和局部方法表一个个画出来了,一个个画出来比较麻烦且比较占用篇幅。
此外,在帧栈概念模型中(4.2.2 帧栈的结构),两个帧栈作为虚拟机栈的元素,是完全相互独立的。但在大多数虚拟机的实现里都会做一些优化处理,令两个帧栈出现一部分重叠。下面的帧栈的部分操作数栈和上面帧栈的部分局部变量表重叠在一起,这样进行方法调用时可以共享一部分数据,无须进行额外的参数复制传递。如图,表示上一个帧栈的局部变量表和下一个帧栈的操作数栈有重叠区域:
Java虚拟机栈的解释执行引擎称为“基于栈的执行引擎”,其中所指的“栈”就是操作数栈。
(1)为什么是动态连接?
动态连接,即运行时连接,编译时不能确定程序的具体类型,要等到运行时才能确定程序的具体类型。
(2)Java如何实现动态连接?
每个帧栈都包含一个指向运行时常量池中该帧栈所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。Class文件的常量池中存有大量的符号引用,字节码的方法调用指令就以常量池中指向方法的符号引用为参数。
这些符号引用一部分会在类加载阶段或第一次使用的时候就转化为直接引用,这种转化为静态解析(复习:刚才介绍类加载明细的时候,就介绍了解析:符号引用转换为直接引用)。另外一部分将在每一次运行期间转化为直接引用,称为动态连接。
两种方式退出方法——正常完成出口,异常完全出口。
正常完成出口:执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(只要返回类型不为void),这种退出方式称为正常完成出口。
异常完成出口:如果方法在执行过程中遇到异常,并且这个异常没有在方法体内得到处理(即没有使用try...catch...捕获处理),无论是Java虚拟机内部产生的异常,还是代码中使用的athrow字节码指令产生的异常,只要本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方式称为异常完成出口。注意一点:一个方法使用异常完成出口的方法退出,是不会给它的上层调用给返回任何值的。
无论是哪种退出方式(正常完成出口、异常完成出口),在方法退出后,都需要返回到方法调用的位置,程序才能继续执行,方法返回时可能需要在帧栈中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出后,调用者的PC计数器的值可以作为返回地址,帧栈中很可能会保存这种计数器值。而方法异常退出是,返回地址是要通过异常处理器来确定的,帧栈中一般不会保留这部分信息。
方法退出时相关操作:恢复上层方法的局部变量表和操作数栈,把返回值(非void返回值)压入调用者帧栈的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令。
帧栈中除了上面四部分信息(局部变量表、操作数栈、动态连接、方法返回地址),还包括一些虚拟机规范里没有描述的附加信息,如调试相关信息,这部分信息完全取决于具体虚拟机的实现,没有固定要求。
方法调用不是方法执行,方法调用描述的是方法之间的调用逻辑,方法执行描述的是方法内存业务逻辑,方法调用阶段的任务就是确定调用哪个方法(这并不涉及方法的内部实现)。在JVM中,Class文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在Class文件中存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(直接引用),所以整个Java方法调用过程,需要在类加载时期,甚至到运行时期才能确定目标方法的直接引用。
(1)解析引入,什么是解析?(解析就是调用一类“编译时可知,运行时不可变”的方法)
所有方法调用其他目标方法在Class文件中都是一个常量池中的符号引用,在类加载的解析阶段(上文介绍过的,整个生命周期是加载 验证 准备 解析 初始化 使用 卸载 ,解析阶段是类加载第四个阶段,连接过程第三阶段),会将其中的一部分符号引用转换为直接引用,这种解析能够成立的前提是:方法在程序真正运行前就有一个确定的可调用版本,并且这个方法的调用版本在运行期间不可改变。即,调用目标(被调用的方法)在程序代码写好,编译时进行编译就已经确定了,这类方法的特点称为“编译时可知,运行时不可变”,这类方法的调用称为解析。
(2)“编译时可知,运行时不可变”的方法有哪些?静态方法和私有方法
对于上面提到的“编译时可知,运行时不可变”的方法,主要包括静态方法和私有方法两大类,前者与类直接关联,后者不可被其他类访问,所以决定了它们(静态方法和私有方法)都不可能通过继承或别的方式重写其他版本,所以它们都是“编译时可知,运行时不可变”的方法,所以它们都适合在类加载阶段进行解析。
(3)JVM调用“编译时可知,运行时不可变”方法的字节码实现。
invokestatic | 调用静态方法 |
invokespecial | 调用实例构造器 |
invokevirtual | 调用所有虚方法(是没有实现的方法,没有方法体的方法,不是空实现的方法,方法体为空的方法) |
invokeinterface | 调用接口方法,会在运行时再确定一个实现此接口的对象 |
invokedynamic | 先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,其他4条调用指令,分派逻辑是固化在JVM内部的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。 |
只要能被invokestatic 或invokespecial指令调用的方法,都可以在解析阶段确定唯一的调用版本(这里的调用版本指的是具体调用哪个方法),由上表可知,能被invokestatic 或invokespecial指令调用的方法有:静态方法、私有方法、实例构造器、父类方法,四类方法在类加载时期就会把符号引用解析成为该方法的直接引用,这四类方法称为非虚方法,其他方法(不在四类方法之内)称为虚方法(final修饰的方法除外)。让我们用程序演示关于四类方法的调用:
package mypackage;
class Super{
protected void function3(){
System.out.println("This is super function:function3");
}
}
public class Test extends Super{
public static void function1(){
System.out.println("This is static function:function1");
}
private void function2(){
System.out.println("This is private function:function2");
}
public Test(){
super.function3();
}
public static void main(String[] args){
function1();
Test test=new Test();
test.function2();
}
}
class文件:
特殊:final修饰的方法不在四类方法之内,虽然是使用invokevirtual调用但是也是非虚方法,因为final方法无法被子类重写,所以不可能有其他版本,所以接收不需要进行多态选择。
从解析到分派
解析过程是一个静态过程,编译期间完全确定,类加载的解析阶段会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期完成,而分派调用这可能是是静态的,也可能是动态的,根据分派的宗量数(方法宗量=方法接收者+方法参数)分为单分派、多分派。所以一共有四个分派方式,静态单分派 静态多分派 动态单分派 动态多分派。JVM的分派调用过程将为我们揭示Java重载和重写的底层实现。
什么是动态类型语言?什么是静态类型语言?
按类型检查时机不同来划分,
动态类型语言:运行期进行类型检查过程的语言,运行时确定引用指向的对象的具体类型,如Python JavaScript Erlang;
静态类型语言:编译器进行类型检查过程的语言,编译时确定引用指向的对象的具体类型,如C++ Java。
注意:早期的Java是静态类型语言,但是现在已经加上了动态类型语言的支持,Java动态类型语言的支持就是本节重点。
(1)静态分派(方法重载)
我们说过,JVM的分派调用过程将为我们揭示Java重载和重写的底层实现。我们通过方法重载来解释静态分派,复习一下方法重载与方法重写。
首先引用方法签名,方法签名是方法的唯一性标识(即同一个类中,如果两个方法的方法签名相同,则认为是同一个方法,如果两个方法的方法签名不同,则认为是不同方法),方法签名=方法名+形参列表(注意,方法签名不包括访问权限、不包括返回值,更不包括final static synchronized这些关键字)。好了,我们现在用方法签名来解释重载、重写。
方法重载:同一类中,方法名相同,形参列表不同(形参个数、位置、类型)称为方法重载;
方法重写:父子继承或实现中,子类方法与父类方法的方法签名完全相同,称为子类对父类方法重写。
代码:
package mypackage;
public class Test {
static abstract class Human {
}
static class Man extends Human {
}
static class Woman extends Human {
}
public void test_function(Human human) {
System.out.println("function human");
}
public void test_function(Man man) {
System.out.println("function man");
}
public void test_function(Woman woman) {
System.out.println("function woman");
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
Test test = new Test();
test.test_function(man);
test.test_function(woman);
}
}
输出:
function human
function human
在Test类中,test_function三个重载,方法名相同,形参列表不同,参数类型分别为Human Man Woman,Human是抽象类不能实例化对象,所以新建一个Man对象和一个Woman对象,使用面向接口编程,编译类型都为Human类,即
Human man=new Man(); //编译时类型是父类Human 运行时类型是子类Man
Human woman=new Woman(); //编译时类型是父类Human 运行时类型是子类Woman
两个引用所指向的是Man Woman,但是在运行时,为什么会调用参数类型为Human的方法,这是一个问题,理由:
test.test_function((Man)man);
test.test_function((Woman)woman);
原因是因为虚拟机(准确的说是编译器)在重载的时候是通过参数的静态类型而不是实际类型作为判定依据的(这句粗体是上面程序为什么调用参数类型为Human方法的解释,也静态分派的原则),并且静态类型是编译时可知的,因此,在编译阶段,Javac编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了test_function(Human)作为调用目标,并把这个方法的符号引用写到main()方法的两条invokevirtual指令的参数中。
关于解析与分派:
解析与分派两者之间并不是二选一的排他关系,它们是在不同层次上筛选、确定目标方法的过程。如四类方法(静态方法、私有方法、构造器、父类方法)都会在类加载时期进行解析,而其中三类方法(静态方法、私有方法、构造器)也可以拥有重载版本,选择重载版本的过程也是通过静态分派来完成的。
(2)动态分派(方法重写)
如果说静态分派是方法重载的JVM底层实现,那么,动态分派是方法重写的JVM底层实现,且看代码:
package mypackage;
public class Test {
static abstract class Human {
protected abstract void test_function(); //protected子类可见
}
static class Man extends Human {
@Override
protected void test_function() {
System.out.println("test_function:Man");
}
}
static class Woman extends Human {
@Override
protected void test_function() {
System.out.println("test_function:Woman");
}
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
man.test_function();
woman.test_function();
man = new Woman(); //让man引用指向Woman对象,然后再调用test_function()方法
man.test_function();
}
}
输出:
test_function:Man
test_function:Woman
test_function:Woman
输出的结果是符合我们的预期的(如果读者的设计模式的思想很好,对于“面向接口编程而不是面向实现编程”理解的很好的话),即当引用指向Man类型对象时,调用的是Man类中的test_function(),当引用指向Woman类型对象时,调用的是Woman类中的test_function()方法,即方法调用在运行时与确定实际类型绑定。这只是宏观代码层面上的解释,对于JVM的学习,我们要看底层的指令码的实现,且看class文件。
我们一句句来解释(对于上图中所有命令行的显示):
虽然说现在所讲的是动态分派,但是我们还是对整个命令行的class文件进行全面解释:
对于main方法:descriptor:(main)方法描述 ([Ljava/lang/String;)V 表示参数为String数组,返回值为void
flags: ACC_PUBLIC, ACC_STATIC 表示该方法 使用public static 修饰
Code: 表示代码段部分 stack=2, locals=3, args_size=1 表示操作数栈元素最大个数为2,局部变量表/slot个数为3,参数个数为1 (则局部变量个数=locals - args_size =3 - 1 =2个)
然后看代码部分,第一句代码 Human man =new Man() 对应四条指令,如上图方框和线条指示:
new 表示创建一个新对象,Man类型对象
dup 表示复制栈顶部一个字长内容
invokespecial 为方法调用指令,表示根据编译时类型来调用实例方法
astore_1 表示将引用类型(或returnAddress类型)值存入局部变量表/slot,这里是指将man引用存入slot序号为1位置
第二句代码 Human woman=new Woman() 对应接下来四条指令,和第一句代码一样,唯一不同的是astore_2 将women引用存入slot序号为2的位置
第三句代码 man.test_function()
aload_1 表示入栈操作,将slot序号为1的引用类型值(这里是man引用),压入操作数栈中,此时操作数栈元素个数为1个
invokevirtual 为方法调用指令,调用Java方法,这里调用test_function()
第四句代码 woman.test_function() 和第三句代码解释是一样的,不同的是aload_2 将slot序号为2的引用类型值(指women引用),压入操作数栈中,此时操作数栈元素个数为2个
第五句代码 man=new Woman() 解释和第一句代码( Human man =new Man())是一样的,略过
第六句代码 man.test_function() 解释和第三句代码(man.test_function())是一样的,略过
然后就是一条 return 指令 ,结束方法
然后是LineNumberTable,这是行号表,.java文件和.class文件的行号对应关系,上图已经指示了,略过。
LocalVariableTable: 局部变量表(存放参数和方法内定义的局部变量),locals指出局部变量表为3,一共3个slot 序号为0 1 2
第0个slot:String数组类型的args参数([ 表示数组,L表示类对象)
第1个slot:Human类型的man引用(L表示类对象)
第2个slot:Human类型的woman引用(L表示类对象)
(3)单分派和多分派
引入方法宗量:方法宗量=方法接收者+方法参数,根据分派基于多少种宗量,将分派分为单分派和多分派两种,单分派是根据一个宗量对目标方法进行选择,多分派是根据多个宗量(多于一个宗量)对目标方法进行选择。
单分派和多分派的定义读起来比较拗口,从字面看起来比较抽象,但是从代码示例看起来就容易多了。
package mypackage;
public class Test {
static class QQ{}
static class _360{} //命名:字母数字下划线,数字不开头,所以_360
public static class Father{
public void hardChoice(QQ arg){
System.out.println("father choose qq");
}
public void hardChoice(_360 arg){
System.out.println("father choose _360");
}
}
public static class Son extends Father{
public void hardChoice(QQ arg){
System.out.println("son choose qq");
}
public void hardChoice(_360 arg){
System.out.println("son choose _360");
}
}
public static void main(String[] args){
Father father=new Father(); //father引用指向Father对象
Father son=new Son();//son引用 编译类型是Father 运行类型是Son
father.hardChoice(new _360()); //调用Father类的参数为_360类型的方法 打印 father choose _360
son.hardChoice(new QQ()); //调用Son类的参数为QQ类型的方法 打印 son choose qq
}
}
输出:
father choose _360
son choose qq
我们先来看看编译阶段编译器的选择过程,也就是静态分派的过程。此时选择目标方法依据有两点:一是静态类型是Father还是Son,二是方法参数是QQ还是360,这次选择结果的最终产物是产生了两条invokevirtual指令,两条指令的参数分别是常量池中指向Father.hardChoice(360)及Father.hardChoice(QQ)方法的符号引用。因为根据这两个宗量(方法接受者:这里Father还是Son,方法参数:QQ还是_360)进行选择,所以Java语言的静态分派属于多分派类型。
再看看运行阶段虚拟机的选择,也就是动态分派过程,在执行son.hardChoice(new QQ())这句代码的时候,更准确地说,在执行这句代码所对应的invokevirtual指令时,由于编译期已经决定目标方法的签名必须为hardChoice(QQ),虚拟机此时不会关心传递过来的参数QQ的具体类型,因为此时参数的静态类型、实际类型都不会对方法的选择构成影响,唯一能够影响虚拟机的选择的只有方法的接受者的具体类型是Father类型还是Son类型。因为根据这一个宗量(方法的接受者,这里:Father还是Son)进行选择,所以Java语言的动态分派属于单分派类型。
分派方式 | 编辑/运行 | 宏观代码层面 | 宗量 |
静态分派 | 编译阶段编译器的选择 | 同一个类中方法重载 | 参数列表 |
动态分派 | 运行阶段虚拟机的选择 | 父子继承中方法重写 | 方法接受者(方法调用者) |
(4)虚拟机动态分派的实现
动态分派和父子继承中的方法重写联系在一起。由于动态分派是一个非常频繁的操作,JVM中对于动态分配有相关的实现机制——虚方法表(invokevirtual调用有虚方法表,invokeinterface调用有接口方法表),这里我们来看虚方法表,用QQ和_360示例:
对于上图的理解是,在父子继承的方法重写(父类方法或属性只有为protected public子类才可见)中,
即虚拟机动态分派的实现是:对于子类没有重写的父类方法,那子类的虚方法里面的入口地址和父类相同的入口地址是一致的,都指向父类的实现入口;对于子类重写的父类方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。如上图所示,Father类和Son类各有一个方法表,就是上图中的两个圆角矩形,Son类继承Father类,而Java中所有类(包括Father类和Son类)都继承于Object类,所以继承关系是,Object -- Father -- Son,对于Object类的很多方法(clone() equals()等),Father和Son类都没有实现,所以Father方法表和Son方法表的这些方法都指向java.lang.Object,就是上图中间的椭圆形,但是对于hardChoice(QQ)和hardChoice(_360),两个类(Father和Son)都有实现,所有这两个方法各个指向其实现 Father类 Son类,就是上图下面的两个椭圆形。
JVM是如何调用方法的内容讲解完毕,本节来谈论JVM是如何执行方法的(是如何来执行方法里面的字节码指令的)。
Java的执行方式:解释执行和编译执行两种
附:编译执行和解释执行
编译执行:将源代码一次性转换成目标代码的过程,类似英语中的全文翻译,执行编译过程的程序叫做编译器。
解释执行:将源代码逐条转换成目标代码同时逐条运行的过程,类似英语中的同声传译,执行解释过程的程序叫做解释器。
编译执行 | 解释执行 | |
定义 | 将源代码一次性转换成目标代码的过程 | 将源代码逐条转换成目标代码同时逐条运行的过程 |
高级语言 | 静态语言,如C/C++、Java(注意:Java有解释执行和编译执行两种执行方法) | 脚本语言(Python、JavaScript、PHP) |
区别 | 1、一次性转换 2、不保留源代码 |
1、逐条转换 2、保留源代码 |
关于Java的编译执行与解释执行共存:
Java在编译时期,通过将源代码编译成.class ,配合JVM这种跨平台的抽象,屏蔽了底层计算机操作系统和硬件的区别,实现了“一次编译,到处运行” 。 而在运行时期,目前主流的JVM 都是混合模式(-Xmixed),即解释运行 和编译运行配合使用。解释器的优势在于不用等待,编译器则在实际运行当中效率更高。在Java虚拟机运行时,解释器和即时编译器能够相互协作,各自取长补短,从而提高运行效率。
JVM是对计算机的虚拟,但是两者使用的指令集却不相同,JVM使用的是基于栈的指令集,计算机使用的是基于寄存器的指令集.
(1)基于栈的指令集
Java编译器输出的指令流,是一种基于栈的指令集架构,即操作核心是操作数栈的入栈、出栈操作,这一点我们在分析class文件也应该注意到了。
基于栈的指令集的优缺点:
优点:
a.可移植,使用栈架构的指令集,用户程序不需要直接使用底层寄存器,可以由虚拟机实现来自行决定把一些访问最频繁的数据(程序计数器、栈顶缓存)放到寄存器中以获取最好的性能,实现起来更加简单。
b.代码更加紧凑,编译器实现更加简单。
缺点:运行速度比基于寄存器的指令集慢,由于涉及到大量的入栈、出栈操作,产生了相当多的指令数量,且每一次入栈、出栈操作内存,内存的速度跟不上处理器(即CPU)的速度,所以造成JVM速度较慢。
(2)基于寄存器的指令集
JVM是对计算机的虚拟机,但是和计算机的指令集模式不同,我们的计算机使用的是基于寄存器的指令集。
基于寄存器的指令集的优缺点:
优点:直接操作硬件寄存器,速度快;
缺点:因为直接操作硬件寄存器,不可避免的造成受到硬件在不同程度上的约束,即可移植性差。
基于栈的解释器执行过程就是我们上文和下文对class文件分析过程。这里我们不举例子了,略过。
值得注意的有两点:
第一,在HotSpot虚拟机中,有很多以“fast”开头的非标准字节码指令用于合并、替换输入的字节码以提升解释执行性能,而即时编译器的优化手段更加丰富,所以,同一程序,对于生成的.class文件可能不同;
第二,整个.class文件,我们看到的是栈结构指令集的一般运行过程,即整个运算过程的中间变量都以操作数的出栈、入栈为信息交换途径。
对于我们本文主要内容——JVM执行子系统,笔者认为我们只要能“根据.java文件一步步读懂.class文件”,再加上一些基础理论知识(如本文所述),这个学习就基本上八九不离十了,至于一些JVM里面特殊的、偏门的东西,本文没有涉及,因为笔者觉得没有太大必要,如果读者有兴趣,可以网上查找,都有相应的。
这里我们把自己当成虚拟机,对.java文件--.class文件映射分析,上文中有提到过(打印hello world 加上 动态分派三个例子),现在读者看完了相关的理论知识,所以读起.class就容易很多了,这里再加上几个 .java--.class 的示例,其实就是上文的几个程序,再来看看一下吧!
只上两个例子了,其实大多是一样的,读者只要能阅读就好了。
附一个javap指令集:https://blog.csdn.net/qq_36963950/article/details/104086591
本文介绍JVM执行子系统,对于JVM来说,保证其语言无关性的内在实心就是.class文件,所以本文的重点就是全面解析.class文件。第二部分介绍Class文件,第三部分介绍JVM类加载机制(包括类加载概要、类加载明细),第四部分介绍JVM执行引擎(包括介绍帧栈结构,方法调用,方法执行),第五部分对上面示意的demo程序都做“.java文件--.class文件”一一映射分析。
天天打码,天天进步!
JVM(一)——JVM自动内存管理 https://blog.csdn.net/qq_36963950/article/details/103997117
JVM(二)——JVM执行子系统,针丝千缕解析.class文件 https://blog.csdn.net/qq_36963950/article/details/104058823
JVM(三)——JVM优化(编译时优化+运行时优化)与JVM性能调优https://blog.csdn.net/qq_36963950/article/details/104087310
JVM(四)——JVM高效并发,一点一滴解析多线程并发的底层实 https://blog.csdn.net/qq_36963950/article/details/104103203