《深入理解Java虚拟机:JVM高级特性与最佳实践》读书笔记(三)

JVM

Posted by 朱坤乾 on May 30, 2019

类文件结构

无关性

无关性的体现有两个方面:

  1、平台无关性:可在不同的操作系统和机器指令集上执行,可在不同厂商的虚拟机平台上执行。

  2、语言无关性:用不同编程语言写出的代码编译生成的文件都可以运行。

实现思想:

  面向接口,定义虚拟机和编译器之间的接口规范。也就是编译后文件的存储格式——字节码(ByteCode)。

任意一种编程语言,只要生成符合存储格式规范的Class文件,就可以被任意虚拟机执行。(只要你能编译成class文件就能被我JVM执行)

Class文件结构

Class文件结构是在《Java虚拟机规范》中定义的。

Class文件的存储结构类似于C语言的struct。

Class文件是一组以8位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在Class文件之中,中间没有添加任何分隔符,这使得整个Class文件中存储的内容几乎全部是程序运行的必要数据,没有空隙存在。 当遇到需要占用8位字节以上空间的数据项时,则会按照高位在前[1]的方式分割成若干个8位字节进行存储。

Class文件中只有两种类型:无符号数和表。表是无符号数和表表的集合。

Magic Number : 每个Class文件的头4个字节称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件。 固定为 0xCAFEBABE

第5和第6个字节是次版本号(Minor Version),第7和第8个字节是主版本号(Major Version)

常量池(constant_pool) 主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)。

  字面量比较接近于Java语言层面的常量概念,如文本字符串、 声明为final的常量值等。

  符号引用则属于编译原理方面的概念,包括了下面三类常量:类和接口的全限定名(Fully Qualified Name),字段的名称和描述符(Descriptor),方法的名称和描述符

  访问标志(access_flags) 用于识别一些类或者接口层次的访问信息,包括:这个Class是类还是接口;是否定义为public类型;是否定义为abstract类型;如果是类的话,是否被声明为final等。 类索引(this_class)和父类索引(super_class) 接口索引集合(interfaces) 字段表(field_info)用于描述接口或者类中声明的变量。

  方法表集合 (methods)

  属性表(attribute_info) 在Class文件、 字段表、 方法表都可以携带自己的属性表集合,以用于描述某些场景专有的信息。

Code属性

首先,这是一个属性,位于属性表里面。

Java程序方法体中的代码经过Javac编译器处理后,最终变为字节码指令存储在Code属性内。

Code属性是Class文件中最重要的一个属性,如果把一个Java程序中的信息分为代码(Code,方法体里面的Java代码)和元数据(Metadata,包括类、 字段、 方法定义及其他信息)两部分,那么在整个Class文件中,Code属性用于描述代码,所有的其他数据项目都用于描述元数据。

字节码指令

Java虚拟机的指令由一个字节长度的、 代表着某种特定操作含义的数字(称为操作码,Opcode)以及跟随其后的零至多个代表此操作所需参数(称为操作数,Operands)而构成。

由于Java虚拟机采用面向操作数栈而不是寄存器的架构(这两种架构的区别和影响将在第8章中探讨),所以大多数的指令都不包含操作数,只有一个操作码。

单字节的优势:短小精悍,存储小,传输快。缺点:解析慢,扩展少。

在Java虚拟机的指令集中,大多数的指令都包含了其操作所对应的数据类型信息。 如:iload,fload,dload。

大多数对于boolean、 byte、 short和char类型数据的操作,实际上都是使用相应的int类型作为运算类型

操作类型:加载和存储指令 ,运算指令 ,类型转换指令 ,对象创建与访问指令 ,操作数栈管理指令 ,控制转移指令 ,方法调用和返回指令 ,异常处理指令 ,同步指令 。

一个字节码指令的例子:

void onlyMe(Foo f){
synchronized(f){
doSomething();
}
}

编译后产生的字节码

Method void onlyMe(Foo)
0 aload_1//将对象f入栈
1 dup//复制栈顶元素(即f的引用)
2 astore_2//将栈顶元素存储到局部变量表Slot 2中
3 monitorenter//以栈顶元素(即f)作为锁,开始同步
4 aload_0//将局部变量Slot 0(即this指针)的元素入栈
5 invokevirtual#5//调用doSomething()方法
8 aload_2//将局部变量Slow 2的元素(即f)入栈
9 monitorexit//退出同步
10 goto 18//方法正常结束,跳转到18返回
13 astore_3//从这步开始是异常路径,见下面异常表的Taget 13
14 aload_2//将局部变量Slow 2的元素(即f)入栈
15 monitorexit//退出同步
16 aload_3//将局部变量Slow 3的元素(即异常对象)入栈
17 athrow//把异常对象重新抛出给onlyMe()方法的调用者
18 return//方法正常返回
Exception table:
FromTo Target Type
4 10 13 any
13 16 13 any

是不是很像汇编? 这样理解也应该可以:字节码就是虚拟机上的汇编语言。

再总结一下

class文件格式,是在JVM虚拟机和各种语言编译器之间的接口。

只要符合这个接口规范,任何语言的任何编译器,编出来的class文件,都可以在任何JVM虚拟机上运行。

Class文件分两部分:1、元数据,2、字节码指令集。

这些内容与硬件、 操作系统及具体的Java虚拟机实现之间是完全独立的,虚拟机实现者可能更愿意把它们看做是程序在各种Java平台实现之间互相安全地交互的手段。

虚拟机实现的方式主要有以下两种:

将输入的Java虚拟机代码在加载或执行时翻译成另外一种虚拟机的指令集。     – 好处:适应于各种硬件环境。

将输入的Java虚拟机代码在加载或执行时翻译成宿主机CPU的本地指令集(即JIT代码生成技术)。 – 好处:提升热点函数的执行速度。 HotSpot的命名即来于此。

好的文章: http://imushan.com/categories/Java/

虚拟机类加载机制

1、概述

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

2、类加载顺序

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载(Loading)、 验证(Verification)、 准备(Preparation)、 解析(Resolution)、 初始化 (Initialization)、 使用(Using)和卸载(Unloading)7个阶段。 其中验证、 准备、 解析3个部分统称为连接(Linking)

3、类加载时机

Java虚拟机规范只规定了“初始化”阶段的时机,其他的没有约束。

对于初始化阶段,虚拟机规范则是严格规定了有且只有5种情况必须立即对类进行“初始化” :

	1)遇到new、 getstatic、 putstatic或invokestatic这4条字节码指令时
	2)使用java.lang.reflect包的方法对类进行反射调用的时候
	3)当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化
	4)当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类
	5)如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、 REF_putStatic、 REF_invokeStatic的方法句柄
	这5种场景中的行为称为对一个类进行主动引用,当发生主动引用,并且类没有被初始化时,才初始化。

其他的都成为被动引用,不会进行类的初始化,比如:

	1)通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。
	2)数组初始化时,只会触发数组类的初始化,不会触发引用类型的初始化。数组类是JVM为引用类自动创建的一个类型,具体为L+引用类全路径,比如:"Lcom.xxxx.xxx"
	3)直接访问静态字段,在编译阶段通过常量传播优化,已经将此常量的值存储到了常量池中,不会触发类初始化
	4)接口不会触发父类,只会在父类真正被使用时才会初始化。

4、类加载具体过程

4.1、加载

在加载阶段,虚拟机需要完成以下3件事情:

1)通过一个类的全限定名来获取定义此类的二进制字节流。

2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。

3)在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

JVM对二进制的来源没有指定,所以,可以来源于 JAR,WAR,等等。

4.2、验证

目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

如果验证到输入的字节流不符合Class文件格式的约束,虚拟机就应抛出一个java.lang.VerifyError异常或其子类异常 。

验证阶段大致上会完成下面4个阶段的检验动作:文件格式验证、元数据验证、 字节码验证、 符号引用验证。

	1.文件格式验证: 第一阶段要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。
	2.元数据验证: 第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求。即对数据类型进行验证
	3.字节码验证: 通过数据流和控制流分析,确定程序语义是合法的、 符合逻辑的。即对方法逻辑进行验证。
	4.符号引用验证: 发生在“解析”阶段,验证内容包括符号名,限定符等。

4.3、准备

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

注意:

1、变量指的是静态变量。

2、赋值表示的是置为JVM原始的默认值,一般是0,而不是代码指定的默认值。 比如 private static int i = 3; 这里的默认值也是0.

4.3、解析

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

符号引用:就是在class文件格式里讲的引用序号。

直接引用:就是之前讲的指针引用或者句柄引用,可以直接关联到JVM内存的。

4.4、初始化

初始化阶段是执行类构造器<clinit>()方法的过程。

<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的 。

编译器收集的顺序是由语句在源文件中出现的顺序所决定的 ,父类中定义的静态语句块要优先于子类的变量赋值操作 。

5、类加载器

对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性, 因为,每一个类加载器,都拥有一个独立的类名称空间。 所以,需要一个机制来确定用哪个类加载器来加载类。

5.1、类加载器的分类

启动类加载器(Bootstrap ClassLoader)

使用C++语言实现 ,是虚拟机自身的一部分 。

负责将存放在<JAVA_HOME>\lib目录中的,或者被-Xbootclasspath参数所指定的路径中的,并且是虚拟机识别的类库加载到虚拟机内存中。 启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器,那直接使用null代替即可

扩展类加载器( Extension ClassLoader )

由sun.misc.Launcher$ExtClassLoader实现,它负责加载<JAVA_HOME>\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。

应用程序类加载器(Application ClassLoader)

由sun.misc.Launcher $AppClassLoader实现。 由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般也称它为系统类加载器。

它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

自己定义的类加载器

虚拟机字节码执行引擎

1、概述

“虚拟机”是一个相对于“物理机”的概念,这两种机器都有代码执行能力,

其区别是物理机的执行引擎是直接建立在处理器、 硬件、 指令集和操作系统层面上的,

而虚拟机的执行引擎则是由自己实现的,因此可以自行制定指令集与执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式。

在Java虚拟机规范中制定了虚拟机字节码执行引擎的概念模型,这个概念模型成为各种虚拟机执行引擎的统一外观(Facade)。

在不同的虚拟机实现里面,执行引擎在执行Java代码的时候可能会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择[1],也可能两者兼备,甚至还可能会包含几个不同级别的编译器执行引擎。

但从外观上看起来,所有的Java虚拟机的执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果

2、运行时栈帧结构

2.1 什么是栈帧

栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)[1]的栈元素。 每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。

每一个栈帧都包括了局部变量表、 操作数栈、 动态连接、 方法返回地址和一些额外的附加信息。

在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的Code属性之中[2],

因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。

一个线程中的方法调用链可能会很长,很多方法都同时处于执行状态。

对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),与这个栈帧相关联的方法称为当前方法(Current Method)。

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

3、方法调用

注:这里的调用只包含调用,不包含执行。

3.1、解析(Resolution)

确定方法调用目标的过程,就是解析的过程。

JVM调用字节码指令有5种:

invokestatic:调用静态方法。

invokespecial:调用实例构造器<init>方法、 私有方法和父类方法。

invokevirtual:调用所有的虚方法。

invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。

invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,在此之前的4条调用指令,分派逻辑是固化在Java虚拟机内部的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。

解析调用一定是个静态的过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。

3.2、分派(Dispatch)

分派(Dispatch)调用则可能是静态的也可能是动态的,根据分派依据的宗量数[1]可分为单分派和多分派。

静态分派

所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。

静态分派的典型应用是方法重载。

静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。

举例:

class A ; 

class B extern A;

void call(A a);

void call(B b);

A a = new B();  //  这里明确两个名词:  对象a的静态类型(A),动态类型(B)。

call(a);  // 会调用void call(A a);  因为重载是在编译时决定的,重载决定的要素是静态类型,编译期未运行,不可能是运行态(动态)。

另外,对于原始数据类型,静态分派时会进行自动转换,转换顺序为:char->int->long->float->double ->装箱->Object->可变参数列表(最后)

动态分派

和多态性的另外一个重要体现[3]——重写(Override)有着很密切的关联。

显然的,静态分派的优先级要高于动态分派,因为静态分派是在编译期就已经确定了的。

总结一句:今天的Java语言是一门静态多分派、 动态单分派的语言。单分派是指已经确定了类型,只是看选父类还是子类了。 3.3、动态类型语言

动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期 Java1.7引入了invokedynamic 、MethodHandle ,但仍然不支持动态语言,详略。

4、方法执行

Java虚拟机的执行引擎在执行Java代码的时候都有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择。

在本章中,我们先来探讨一下在解释执行时,虚拟机执行引擎是如何工作的。

4.1 解释执行

Javac编译器完成了程序代码经过词法分析、 语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。

因为这一部分动作是在Java虚拟机之外进行的,而解释器在虚拟机的内部,所以Java程序的编译就是半独立的实现。

4.2 基于栈的指令集与基于寄存器的指令集

Java编译器输出的指令流,基本上[1]是一种基于栈的指令集架构(Instruction Set Architecture,ISA),指令流中的指令大部分都是零地址指令,它们依赖操作数栈进行工作。 与之相对的另外一套常用的指令集架构是基于寄存器的指令集,最典型的就是x86的二地址指令集,说得通俗一些,就是现在我们主流PC机中直接支持的指令集架构,这些指令依赖寄存器进行工作。

那么,基于栈的指令集与基于寄存器的指令集这两者之间有什么不同呢?

基于栈的:用内存做栈,存临时对象,计算时要压栈出栈。

基于寄存器的:用硬件提供的寄存器存临时对象,计算式直接使用。

对于“1+1”这个计算:

基于栈的命令:

	iconst_1
	iconst_1
	iadd
	istore_0

基于寄存器的命令:

	mov eax,1
	add eax,1

优缺点:

基于栈的指令集主要的优点就是可移植, 缺点是执行速度相对来说会稍慢一些。 寄存器则相反。所有主流物理机的指令集都是寄存器架构 。