JVM执行子系统,一点一滴解析.class文件

Posted 爱奇志

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JVM执行子系统,一点一滴解析.class文件相关的知识,希望对你有一定的参考价值。

一、前言

笔者关于JVM的一共有四篇文章,前一篇讲述“JVM自动内存管理”,讲述JVM的底层结构,内存分配与内存回收。本篇讲述“JVM执行子系统”,本篇的全部目标是解析.class文件,读完本篇后,您会发现从.java文件到.class文件的映射,直至一个变量的定义,每一行代码,都是有矩可循的。

全文的结构是:第二部分,从Java两个无关性引入class文件,并对一个打印"hello world"字符串的程序的class文件进行分析,这里读者可能看不懂分析过程,没有关系,因为第二部分只是一个引子;第三部分,介绍JVM类加载机制(包括类加载概要、类加载明细);第四部分,介绍JVM执行引擎(包括介绍帧栈结构,方法调用,方法执行);第五部分,对上面示意的demo程序做“.java文件–.class文件”一一映射分析。

二、类文件结构(Class文件)

2.1 平台无关性与语言无关性

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文件的结构(当第五部分才要求完全看懂)。

2.2 从.java文件到.class文件,手把手教你阅读.class文件(从十六进制到javap)

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."<init>":()V
     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、对象、一维数组、二维数组)

三、类加载机制

介绍完class文件后,现在我们来看如何JVM类加载机制。

什么是JVM类加载机制?

虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程就是JVM的类加载机制。

Java语言是动态链接,又称运行时绑定,其类的加载、连接和初始化过程都是在程序运行期间完成的,为Java应用程序提供高度灵活性。

3.1 类加载概要

类从被加载到虚拟机内存开始,到卸载出内存为止,整个生命周期包括:加载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 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需先触发其初始化。

前面的五种方式是对一个类的主动引用,除此之外,所有引用类的方法都不会触发初始化,称为被动引用。

3.2 类加载详细

现在我们来分别解释上面七个过程,就是类加载的七个过程。

3.2.1 加载

注意:“加载”与“类加载”区别?

看起来很相似,但是不是同一个东西,加载≠类加载,是包含关系,类加载一共包括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 的开始顺序确定是一个意思。

3.2.2 验证

验证阶段是类加载机制的第二步,也是连接阶段的第一步,上面的加载阶段开始后,验证阶段就可以开始了(顺序开始),验证阶段的目的是为了确保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类加载机制中的验证阶段。

3.2.3 准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。

读者注意笔者粗体标记的两个词语

第一,关于“为类变量分配内存”,这里仅指类变量,即用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,不用等到初始化阶段。

3.2.4 解析

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

注意笔者粗体表示的几个字“常量池”“符号引用”“直接引用”

常量池表示的是解析阶段操作的范围,符号引用表示的是解析阶段操作的对象,直接引用是解析阶段的产出结果。我们先引入“符号引用”和“直接引用”(符号引用、直接引用这两个概念很重要,第四部分重点讲解析都会用到,重点注意,要搞懂,加粗)

符号引用
符号引用以一组符号来描述所引用的目标,符号可以使任何形式的字面量。
直接引用
直接引用可以使直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符 7 类符号引用进行,分别对应于常量池的 7 中常量类型。

3.2.5 初始化

初始化阶段是类加载过程的最后一步,实际上,类加载过程中,前面6个阶段都是以虚拟机主导(除了加载阶段用户应用程序可以通过类加载器参与之外),一直到初始化阶段开始执行类中的 Java 代码。

准备阶段的赋值操作与初始化阶段的赋值操作辨析(仅针对未使用final关键字修饰的变量):

准备阶段的赋值操作是系统默认值,而初始化阶段的赋值操作是程序员代码中定义的值,初始化阶段是执行类构造器()方法的过程。这里给出常用基本类型的系统默认值:

3.3 类加载器

什么是类加载器?

类加载包括三个功能,将其中的“通过一个类的全限定性类名(包名+类名,唯一确定一个类)获取描述此类的二进制字节流”这个动作是在JVM外部实现的,实现这个动作的代码模块就是“类加载器”。

3.3.1 类与类加载器

上面的这个定义告诉我们,实现类加载这个动作的代码模块就是“类加载器”,且看代码段:

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

3.3.2 双亲委派模型

一般来说,我们对类加载器分类:

从 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()方法进行加载。

3.3.3 破坏双亲委派模型

实际上,双亲委派模式并不是强制程序员使用的一种约束模型,而是JVM提供给程序员的一种建议/推荐模型,在Java世界中大部分的类加载器都遵循这个模型(双亲委派模型)。其实,双亲委派模型在Java历史有过三个大的破坏,这里对三次破坏双亲委派模型简单介绍。

双亲委派模型的第一次“被破坏”:由于JDK1.2才引入双亲委派模型,所以JDK1.2之前是对双亲委派模型的破坏。

双亲委派模型的第二次“被破坏”:由于模型自身的缺陷所导致的,引入线程上下文件类加载器(Thread Context ClassLoader),对双亲委派模型的破坏。

双亲委派模型的第三次“被破坏”:由于用户对程序的动态性的追求导致的(如OSGi的出现)对双亲委派模型的破坏。

具体的,Java设计历史上对双亲委派模型的三次破坏,这里只是简单介绍,这些都是一些历史性的东西,对于面试、开发来说没什么用,为了不占用篇幅,这里不介绍,读者任意百度,均有详细介绍。

四、字节码执行引擎

4.1 执行引擎

4.1.1 什么是执行引擎?

上一篇文章中,前言部分讲述到JVM之所以成为虚拟机,是因为它对计算机进行虚拟机,包括内存、磁盘、寄存器等方面,

那么计算机有执行引擎,直接建立在处理器、硬件、指令集、操作系统层面上的,那么JVM是否也有类似的执行引擎呢?答案是肯定的,但是JVM执行是由程序员自己实现的,因为是自己实现,所以可以自行制定指令集与执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式。

所以说,JVM是对计算机的虚拟,JVM执行引擎是对计算机执行引擎的虚拟。

4.1.2 JVM执行引擎概念模型(统一外观,不同实现)

统一外观:由Java虚拟机规范制定,统一外观指JVM执行引擎输入输出统一,输入的统一是字节码文件,处理过程统一是字节码解析的等效过程,输出的统一是执行结果。

不同实现:JVM执行引擎有两种实现方式,其一,解释执行,通过解释器执行,其二,编译执行,通过即时编译器,可以是任意一种实现方式,也可以两者兼而有之,还可以包含几个不同级别的编译器执行引擎。

4.2 运行时帧栈结构

4.2.1 引入帧栈:帧栈是什么?

帧栈,为Stack Frame,是用于支持虚拟机进行方法调用和方法执行的数据结构,

帧栈定义中,加粗的一共有三个词语,分别是“方法调用”“方法执行”“数据结构”,这里解释:

方法调用:方法与方法直接的相互调用关系,不涉及方法内部具体实现。

方法执行:方法内部具体是如何执行方法的(是如何来执行方法里面的字节码指令的)。

数据结构:帧栈类似是一种栈,先进后出,和数据结构中的栈是一样的,所以说帧栈是一种数据结构。

帧栈就是虚拟机运行时数据区的虚拟机栈(上一篇博客中,第二部分谈到JVM运行时数据区时,其包括五个部分,方法区、堆、虚拟栈、本地方法栈、程序计数器)的栈元素。帧栈存储了方法的局部变量表、操作数栈、动态连接、方法返回值和附加信息,下文中详细阐述。

注意,帧栈≠虚拟机栈,Java程序中,JVM运行时数据区只有一个虚拟机栈(Java方法调用),其中每一个线程有n个帧栈,帧栈1-帧栈n,一个帧栈对应一个方法。所以说,帧栈是虚拟机栈的栈元素。

4.2.2 帧栈的结构

Java程序中,每一个方法从调用开始至执行完成的过程,都对应一个帧栈在虚拟机入栈到出栈的过程。

帧栈的结构:

关于对上图(帧栈结构)的理解:

第一,一个Java程序有多个线程,线程1-线程N,当前正在执行的线程称为当前线程;

第二,每一个线程(以当前线程为例)中有多个方法,方法1-方法n,每一个方法对应一个帧栈,所以帧栈1-帧栈n,当前正在执行的方法对应的是当前帧栈;

第三,每一个帧栈(以当前帧栈为例)中有多个组件,包括局部变量表、操作栈、动态连接、返回地址、附加信息。

注意1,帧栈的结构是在编译时确定的,不是在运行时确定的

解释:在编译程序代码的时候, 帧栈中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并写入到方法表的Code属性中,因此一个帧栈需要分配多大的内存,不会受到程序运行时变量数据的影响,仅仅取决于具体虚拟机实现。

注意2,执行引擎运行的所有字节码指令都只针对当前帧栈操作

解释:当前帧栈是指活动线程中,位于栈顶的帧栈,且这个栈顶帧栈(当前帧栈)关联的方法称为当前方法。

4.2.3 局部变量表Local Variable Table

局部变量表是一组变量值空间,用于存放方法参数和方法内定义的局部变量。在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:

第六篇:JVM执行子系统,一点一滴解析.class文件

第六篇:JVM执行子系统,一点一滴解析.class文件

JVM执行子系统,一点一滴解析.class文件

JVM执行子系统,一点一滴解析.class文件

JVM的理解

JVM-解析常量池