Java虚拟机规范(Java SE 8版)读后总结
写在前面:
因为之前读过周志明的《深入理解Java虚拟机》,并且也一直在阅读相关的博客,所以对Java虚拟机的知识有了一点浅显的了解(主要是在内存分配、垃圾回收、类加载以及内存模型方面)。但是个人感觉class文件格式以及字节码指令集等方面不太了解,所以就去图书馆借来这本书打算学习学习。由于临近期末,要应付各种课设以及实验以至于前后花了两周多的时间才把它简单地过了一遍。
Java虚拟机规范 Java SE 8版封面
全书一共7章大概300多页,之所以叫规范,也就是书中仅仅描述了抽象的Java虚拟机,而在实现具体的Java虚拟机时,本书指出了设计规范。Java虚拟机的实现必须体现书中内容,但仅在确有必要时才应该受制于这些规范。用书中的原话来说即"公有设计,私有实现"。
各章摘要
- 第1章 :简单地介绍了Java虚拟机的历史并吹捧了←_← 一下Java的平台无关性(一次编译,到处运行);
- 第2章:概览Java虚拟机整体架构;
- 第3章:介绍如何将Java语言编写的程序转换为虚拟机指令集;
- 第4章:定义class文件格式。它是一种与硬件和操作系统无关的二进制格式,用来表示编译后的类和接口;
- 第5章:定义了Java虚拟机启动以及类和接口的加载、链接和初始化的过程;
- 第6章:定义了Java虚拟机指令集;
- 第7章:提供了一张以操作码值为索引的Java虚拟机操作码助记表。
一开始我直接就捧着介绍class文件格式以及字节码指令集的部分生啃,后来发现完全记不住。所以后来我就通过阅读书上介绍的一些内容,再结合书中讲解字节码的部分再来理解,学习效率提升了不少。因此在本文中对知识总结的顺序和原书会有很大的不一样,并且本文主要是个人的学习总结,如果有想深入学习Java虚拟机规范的同学,我建议还是阅读原书比较好。
要去正确地实现一台Java虚拟机,就需要正确地读取class文件中每一条字节码指令并且能正确执行这些指令所蕴含的操作即可。
数据类型
和Java语言类似,在Java虚拟机中的数据类型也可以分为基本类型和引用类型两种,所以也存在原始值和引用值两种类型的数值。它们可用于变量赋值、参数传递、方法返回和运算操作。
原始类型与值
Java虚拟机所支持的原始数据类型包括数值类型、boolean类型、和returnAddress类型
- 数值类型分为整数类型和浮点类型,分别是char,byte,short,int,long;浮点类型即float和double,这里和Java语言中的一致。
- returnAddress翻译过来是返回地址,其实returnAddress类型的值指向一条虚拟机指令的操作码。它在虚拟机中比较典型的一个应用场景是用于jsr程序段落跳转,在try-catch异常处理以及finally代码块经常出现。和数值类的原生类型不同,returnAddress类型在Java语言之中并不存在相应的类型,而且也无法在程序运行期间修改。
- 虽然Java虚拟机定义了boolean这种数据类型,但是只对它提供了十分有限的支持。在Java虚拟机中并没有任何供boolean值专用的字节码指令,Java语言表达式所操作的boolean值,在编译之后都使用Java虚拟机中的int数据类型来代替。(Java虚拟机会把boolean数组元素中的true采用1来表示,false采用0来表示,当Java编译器把Java语言中的boolean类型值映射为Java虚拟机的int类型值时,也必须用上述表示方式)
引用类型与值
Java虚拟机中有三种引用类型:类类型、数组类型和接口类型。它们分别指向动态创建的类实例、数组实例和某个接口的类实例或数组实例。
数组类型最外面那一维元素的类型叫做数组类型的组件类型。一个数组的组件类型也可以是数组。从任意一个数组开始,如果发现其组件类型也是数组类型,那就继续取这个小数组的组件类型,不断执行这样的操作,最终一定可以遇到组件类型不是数组的情况,这时就把这种类型成为本数组的元素类型。数组的元素类型必须是原生类型、类类型或者接口类型之一。
在引用类型的值中还有一个特殊的值:null,当一个引用不指向任何对象的时候,它的值就用null来表示。一个为null的引用,起初并不具备任何实际的运行期类型,但是它可转型为任意的引用类型。引用类型的默认值就是null。Java虚拟机规范并没有规定null在虚拟机实现中应当怎样用编码来表示。
运行时数据区域
由于《深入理解Java虚拟机》一书中对Java虚拟机运行时数据区域介绍得更详细,所以这里直接贴上以前的读书笔记。
深入理解Java虚拟机---自动内存管理机制
这里再特别回顾一下几个不太熟悉的知识点:
-
栈帧:栈帧是用来存储数据和部分过程结果的数据结构,同时也用来处理动态链接、方法返回值和异常分派。栈帧又是存储在栈中(包括Java虚拟机栈和本地方法栈),它随着方法调用而创建,随着方法结束而销毁,其实也就是一个方法执行的过程也对应着栈帧的入栈和出栈的过程。无论方法是正常完成还是异常完成(抛出了在方法内未被捕获的异常)都算作方法结束。栈帧的存储空间由创建它的线程分配在Java虚拟机栈之中,每一个栈帧都有自己的本地变量表、操作数栈和指向当前方法所属的类的运行时常量池的引用。
本地变量表和操作数栈的容量在编译期确定,并通过相关方法的code属性保存及提供给栈帧使用。因此,栈帧数据结构的大小仅仅取决于Java虚拟机的实现。实现者可以在调用方法的时候给它们分配内存。
在某条线程执行的过程中的某个时间点,只有目前正在执行的那个方法的栈帧是活动的。这个栈帧称为当前栈帧,这个栈帧对应的方法称为当前方法,定义这个方法的类称作当前类。对局部变量表和操作数栈的各种操作,通常都是值对当前栈帧的局部变量表和操作数栈所进行的操作。
如果当前方法调用了其他方法,或者当前方法执行结束,那这个方法的栈帧就不再是当前栈帧了。调用新方法时,新的栈帧也会随之而创建,并且会随着程序控制权移交到新方法而成为新的当前栈帧。方法返回之际,当前栈帧会传回此方法给前一个栈帧,然后虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。
需要特别注意的是,栈帧是线程本地私有的数据,不可能在一个栈帧之中引用另外一个线程的栈帧。 -
局部变量表
每个栈帧内部都包含一组称为局部变量表的变量列表。栈帧中局部变量表的长度由编译器决定,并却存储于类或接口的二进制表示之中,即通过方法的code属性保存及提供给栈帧使用。
一个局部变量可以保存一个类型为boolean、byte、char、short、int、float、reference或returnAddress的数据。两个局部变量可以保存一个类型为long或double的数据。
局部变量使用索引来进行定位访问。首个局部变量的索引值为0。局部变量的索引值是个整数,它大于等于0,且小于局部变量表的长度。Java虚拟机使用局部变量表来完成方法调用时的参数传递。当调用类方法时,它的参数将会依次传递到局部变量表中从0开始的连续位置上。当调用实例方法时,第0个局部变量一定用来存储该实例方法所在对象的引用(即Java语言中的this关键字)。后续其他参数将会传递至局部变量表中从1开始的连续位置上。 -
操作数栈:每个栈帧内部都包含一个称为操作数栈的后进后出栈。栈帧中操作数栈的最大深度由编译期决定,并且通过方法的code属性保存及提供给栈帧使用。栈帧刚创建的时候操作数栈是空的。Java虚拟机提供一些字节码指令来从局部变量表或者对象实例的字段中复制常量或变量值到操作数栈中,也提供了一些指令用于从操作数栈取走数据、操作数据以及把操作结果重新入栈。在调用方法时,操作数栈也用来准备调用方法的参数以及接收方法返回结果。例如iadd字节码指令的作用是将两个int类型的数值相加,它要求在执行之前操作数栈的栈顶已经存在两个由前面的其他指令所放入的int类型数值。在执行iadd指令时,两个int类型数值出栈,相加求和之后求和结果重新入栈。操作数栈的每个位置上可以保存一个Java虚拟机中定义的数据类型的值,包括long和double类型。在任意时刻,操作数栈都会有一个确定的栈深度,一个long或者double类型的数据会占用两个单位的栈深度,其他数据类型则会占用一个单位的栈深度。
-
动态链接
每个栈帧内部都包含一个指向当前方法所在类型的运行时常量池的引用,以便对当前方法的代码实现动态链接。在class文件里面一个方法若要调用其他方法,或者访问成员变量,则需要通过符号引用来表示。动态链接的作用就是将这些符号引用所表示的方法转换为对实际方法的直接引用。类加载的过程中将要解析尚未被解析的符号引用,并且将对变量的访问转化为变量在程序运行时,位于存储结构中的正确偏移量。由于对其他类中的方法和变量进行了晚期绑定,所以即便那些类发生变化,也不会影响调用它们的方法。
对象的表示
Java虚拟机规范不强制规定对象的内部结构应该如何表示。在具体实现中,一般有两种对象的访问方式,分别是通过句柄访问对象以及通过直接指针访问对象。
在之前的博客里我也总结过这两种对象访问方式的优劣,想要了解的同学可以参考这篇博客
特殊方法
在Java虚拟机层面,Java编程语言的构造器是以一个名为<init>
的特殊实例初始方法的形式出现的。<init>
这个方法名称是由编译器命名的,因为它并非一个合法的Java方法名字,不可能通过程序编码的方式实现。实例初始化方法的初始化期间,通过Java虚拟机的invokespecial指令来调用,而且只能在尚未初始化的实力上调用该指令。构造器的访问权限也会约束由该构造器所衍生出来的实例初始化方法。
一个类或者接口最多可以包含不超过一个类或接口的初始化方法,类或接口就是通过这个方法完成初始化的。这个方法是一个不包含参数的、返回类型为void的方法,名为<clinit>
。
在class文件中把其他方法命名为<clinit>
是没有意义的,这些方法并不是类或接口的初始化方法,它们既不能被字节码指令调用,也不会被虚拟机自己调用。当class文件的版本号不小于51.0时,<clinit>
方法想要成为类或接口的初始化方法,必须设置ACC_STATIC标志。
异常
Java虚拟机里面的异常使用Throwable或其子类的实例来表示,抛异常的本质实际上是程序控制权转移的一种即时的、非局部的转换---从异常抛出的地方转换至异常处理的地方。
绝大多数异常的产生都是由于当前线程执行的某个操作所导致的,这种可以称为同步异常。与之相对,异步异常可以在程序执行过程中随时发生。Java虚拟机中异常的出现总是由下面三种原因之一导致的:
- athrow字节码指令被执行;
- 虚拟机同步检测到程序发生了非正常的执行情况,这时异常必将紧接着发生在非正常执行情况的字节码指令之后抛出,而不会在执行程序的过程中随时抛出。例如:程序所执行的操作可能会引发异常---当字节码指令所蕴含的操作违反了Java语言的语义,如访问一个超出数组边界范围的元素,或者是当程序在加载或者连接时出现错误;还有一种异常是使用某些资源的时候产生资源限制,比如说使用了太多的内存。
- 由于以下原因,导致了异步异常的发生: 调用了Thread或者ThreadGroup的stop方法;Java虚拟机实现发生了内部错误。
当某个线程调用了stop方法时,将会影响到其他线程,或者在特定线程组中的所有线程。因为stop方法的执行常常会导致出现数据不一致的情况,这时候其他线程中出现的异常就是异步异常,因为这些异常可能出现在线程执行过程中的任何位置。虚拟机的内部错误也被认为是一种异步异常。
虚拟机错误
- InternalError:实现虚拟机的软件错误、底层主机系统的软件错误及硬件错误都会导致Java虚拟机出现内部错误,InternalError是一个异步异常,它可能出现在程序中的任何位置;
- OutOfMemoryError:当Java虚拟机实现耗尽了所有虚拟或物理内存,并且内存自动管理子系统无法回收到创建新对象所需的足够内存空间时,虚拟机将抛出OutOfMemoryError。
- StackOverflowError:当Java虚拟机实现耗尽了线程全部的栈空间时,虚拟机将会抛出StackOverflowError。
- UnKnownError:当某种错误或异常出现,但虚拟机实现又无法确定它具体是哪种异常或错误,将会抛出UnKnownError。
由于通常虚拟机会对代码进行优化,例如指令重排。那么在异常发生的时候,有一些在异常出现位置之后的代码可能已经执行了,那这些优化过的代码必须保证它们提前执行所产生的影响对用户程序来说是不可见的。
由Java虚拟机执行的每个方法都会配有零到多个异常处理器。异常处理器描述了其在方法代码中的有效作用范围(通过字节码偏移量范围来描述)、能处理的异常类型以及处理异常的代码所在的位置。要判断某个异常处理器是否可以处理某个具体的异常,需要同时检查一场出现的位置是否在异常处理的有效作用范围内,以及出现的异常是否是异常处理器声明可以处理的异常类型或其子类型。当抛出异常时,Java虚拟机搜索当前方法包含的各个异常处理器,如果能找到可以处理该异常的异常处理器,则将代码控制权转向异常处理器中描述的处理异常的分支之中。
下面我举一个简单例子
方法中捕捉了ArithmeticException 上面这个类对应的字节码上面的字节码比较容易理解,首先简单介绍一下main方法中各条字节码指令所代表的意思:
- 0 : 将int类型的常量i压入操作数栈中,iconst_0后面跟的那个0代表常量的值为0;
- 1:将一个int类型数据由保存到本地变量表,istort_1后面的1代表的是指向当前栈帧中局部变量表的索引值;
- 2:将一个值为3的int类型的常量压入操作数栈中。在这里指的是被除数3;
- 3:从局部变量表加载一个int类型值到操作数栈中,这里指的是除数i;
- 4:对两个int类型的数据做除法;
- 5:将两数相除之后所得到的int类型数据保存到本地变量表;
- 6:假如没有发生异常的话,那么执行完goto到第14条语句,函数正常返回;
- 9:假如发生了除零异常,就执行这条指令,将异常对象保存到局部变量表中;
- 10:从局部变量表中加载刚才的那个异常对象到操作数中;
- 11:调用异常对象的printStackTrace方法
- 14:不管是正常完成还是异常完成,最终都会返回。
在字节码下方可以看到一个Exception table。那么它是什么东西呢?其实我们很容易能够理解它就是异常表,也就是前面我们提过的异常处理器。我们可以明显地观察出,其实try-catch代码块编译之后似乎没有生成任何指令。那么Java语言中的try-catch放到字节码当中对应什么东西呢?其实就是对应这个异常处理器。下面我们来解读一下异常处理器:
异常处理器在try语句块的执行过程中如果没有抛出异常,那么这个异常处理器不会起作用。异常处理器的作用范围是从字节码的第2行到第6行,也就是from-to标明的范围。假如编译好的代码里面第2~6句之间有一个类型为java.lang.ArithmeticException的异常实例被抛出,那么操作将转移至第9句继续执行,即进入catch语句块的实践步骤。假如说抛出的异常不是ArithmeticException实例,那么异常处理器就不能处理该异常,这个异常将返回给上一级的调用者。
那假如try语句块包含多个catch语句块,在编译好的代码中会出现什么样的结果呢?
在Java代码中增加一个catch代码块 上述代码编译之后的字节码如果给定的try语句块包含多个catch语句块,那么在编译好的代码中,多个catch语句块的内容将会连续排列,在异常表中也会有对应的连续排列的成员,它们的排列顺序和源码中catch语句块的出现顺序一致。main方法在执行时,如果try语句块中抛出了一个异常,这个异常将会被多个catch语句块捕获。假如第一个catch不能捕获异常(当然这里的第一个catch语句块肯定是能处理ArithmeticException,我只是举个例子),那么异常将交由第二个异常处理器来进行处理,这很容易理解。因为我在第二个catch语句块中选择的是将捕获的异常抛出,所以在字节码的第26行可以看到有一个athrow指令,在前面的学习当中我们知道它是抛出异常的意思,其实也就是对应着Java代码中的throw new Exception()
。在这里,我还要顺便介绍一下Java创建一个对象的代码在编译之后会产生怎样的字节码。
其实,刚才我所说的throw new Exception()
对应athrow字节码指令只说对了一半,它在编译之后不仅仅只产生athrow这一条字节码指令。因为它还对应着一个操作,也就是new一个Exception对象。Java语言实例化一个Exception对象将会产生三条字节码指令,即上图中19,22,23三行:
为什么会有三条指令呢?dup是做什么的?我们下面一起来学习一下
由于讨论的是创建对象,所以在代码
throw new Exception()
中我们不看throw,只看new Exception()这一部分代码。
new Exception()表达式的作用是:
- 创建并默认初始化一个Exception对象;
- 调用Exceptioon类的signature为<init>()V的构造器;
- 表达式的值为一个指向这个对象的引用
对应字节码,我们可以看到:
- new Exception()对应上面的1
- invokespecial Exception.<init>()V对应上面的2
- 那么3是怎么来的?
回归到字节码,我们可以看到new字节码指令的作用是创建指定类型的对象实例、对其进行默认初始化,并将指向该实例的一个引用压入操作数栈顶;
然后因为invokespecial会消耗操作数栈顶的引用作为传给构造器的"this"参数,所以如果我们希望在invokespecial调用后在操作数栈顶还维持有一个指向新建对象的引用,就得在invokespecial之前先“复制”一份引用----这就是dup的来源。
以上,就是对创建一个对象编译之后产生的字节码的解释
编译finally语句块
刚才我们介绍了异常处理在字节码层面的细节,但是我们还需要注意的是----由于finally能够保证不管发生任何情况,都能够执行语句块中的代码,所以在日常编码过程中我们在可能发生异常的地方(或者是不会发生异常的地方)经常使用finally来释放某些资源。
下面我们从虚拟机层面来看看如何保证finally语句块中的代码一定会执行
可以看到,其实编译器是通过在每个分支后面增加冗余代码的形式来保证finally语句块中的代码一定会被执行。这里和书上讲的有点出入,书上在讲解这一块的时候还是用jsr、jsr_w、ret等程序控制转移指令来解释的,但是javac在很早之前就不再为finally语句生成jsr和ret指令了。
如果程序在try语句块中执行了return,那么代码的行为如下:
- 如果有返回值,将返回值保存在局部变量表;
- 执行跟在后面的冗余finally语句块中的代码;
- 在finally执行完之后,将事先保存在局部变量表中的返回值压入操作数栈中之后返回。
如果在try语句中抛出异常,那么代码的行为如下:
- 将异常保存在局部变量表中
- 执行finally语句块中的代码
- 在执行完finally语句块中的代码后,重新抛出这个事先保存好的异常。
同步
Java虚拟机中的同步(synchronization)使用monitor的进入和退出来实现的。无论显式同步(有明确的monitorenter和monitorexit指令),还是隐式同步(依赖方法调用和返回指令实现)都是如此。
在Java语言中,同步用得最多的地方可能是经synchronized所修饰的同步方法。同步方法并不是用monitorenter和monitorexit来实现的,而是由方法调用指令读取运行时常量池中方法的ACC_SYNCHRONIZED标志来隐式实现的。
monitorenter和monitorexit指令用于编译同步语句块
编译器必须确保无论方法以何种方式完成(正常结束或者是异常结束),方法中调用过的每条monitorenter指令都必须有对应的monitorexit指令得到执行。为了确保在方法异常完成时,monitorenter和monitorexit指令依然可以正确配对执行,编译器会自动生成一个异常处理器,这个异常处理器宣称自己可以处理所有异常,它的代码用来执行monitorexit指令。
类加载
之前写过关于类加载的博客,这里就不赘述了。下面把之前关于类加载的博客贴出来。
深入了解Java虚拟机---虚拟机类加载机制
写在后面:由于本书介绍的是Java虚拟机规范,所以使用了大篇幅的内容来讲class文件格式以及各种Java虚拟机代码约束、Java虚拟机指令集等等。我硬着头皮看了一遍,也不指望能够记住什么。。因为东西太多了,根本记不住。其实个人觉得虚拟机规范这种东西就像字典一样,不可能完全记住,只需要在遇到问题的时候知道可能是什么地方的问题,知道在哪里能找到答案就行了。话说回来读完全书之后感觉收获还是挺多的,尤其是之前很多知识点我都只是停留在对Java语言上的理解,没有深入到Java虚拟机字节码这个层面去探究,所以有些东西是知其然不知其所以然。