JVM首页投稿(暂停使用,暂停投稿)Android知识

Java虚拟机规范(Java SE 8版)读后总结

2017-06-25  本文已影响3958人  EakonZhao

写在前面:
因为之前读过周志明的《深入理解Java虚拟机》,并且也一直在阅读相关的博客,所以对Java虚拟机的知识有了一点浅显的了解(主要是在内存分配、垃圾回收、类加载以及内存模型方面)。但是个人感觉class文件格式以及字节码指令集等方面不太了解,所以就去图书馆借来这本书打算学习学习。由于临近期末,要应付各种课设以及实验以至于前后花了两周多的时间才把它简单地过了一遍。


Java虚拟机规范 Java SE 8版封面

全书一共7章大概300多页,之所以叫规范,也就是书中仅仅描述了抽象的Java虚拟机,而在实现具体的Java虚拟机时,本书指出了设计规范。Java虚拟机的实现必须体现书中内容,但仅在确有必要时才应该受制于这些规范。用书中的原话来说即"公有设计,私有实现"。

各章摘要

  • 第1章 :简单地介绍了Java虚拟机的历史并吹捧了←_← 一下Java的平台无关性(一次编译,到处运行);

一开始我直接就捧着介绍class文件格式以及字节码指令集的部分生啃,后来发现完全记不住。所以后来我就通过阅读书上介绍的一些内容,再结合书中讲解字节码的部分再来理解,学习效率提升了不少。因此在本文中对知识总结的顺序和原书会有很大的不一样,并且本文主要是个人的学习总结,如果有想深入学习Java虚拟机规范的同学,我建议还是阅读原书比较好。

要去正确地实现一台Java虚拟机,就需要正确地读取class文件中每一条字节码指令并且能正确执行这些指令所蕴含的操作即可。

数据类型

和Java语言类似,在Java虚拟机中的数据类型也可以分为基本类型引用类型两种,所以也存在原始值和引用值两种类型的数值。它们可用于变量赋值、参数传递、方法返回和运算操作。

原始类型与值

Java虚拟机所支持的原始数据类型包括数值类型boolean类型、和returnAddress类型

引用类型与值

Java虚拟机中有三种引用类型:类类型、数组类型和接口类型。它们分别指向动态创建的类实例、数组实例和某个接口的类实例或数组实例。
数组类型最外面那一维元素的类型叫做数组类型的组件类型。一个数组的组件类型也可以是数组。从任意一个数组开始,如果发现其组件类型也是数组类型,那就继续取这个小数组的组件类型,不断执行这样的操作,最终一定可以遇到组件类型不是数组的情况,这时就把这种类型成为本数组的元素类型。数组的元素类型必须是原生类型、类类型或者接口类型之一。
在引用类型的值中还有一个特殊的值:null,当一个引用不指向任何对象的时候,它的值就用null来表示。一个为null的引用,起初并不具备任何实际的运行期类型,但是它可转型为任意的引用类型。引用类型的默认值就是null。Java虚拟机规范并没有规定null在虚拟机实现中应当怎样用编码来表示。

运行时数据区域

由于《深入理解Java虚拟机》一书中对Java虚拟机运行时数据区域介绍得更详细,所以这里直接贴上以前的读书笔记。
深入理解Java虚拟机---自动内存管理机制

栈帧结构

这里再特别回顾一下几个不太熟悉的知识点:

对象的表示

Java虚拟机规范不强制规定对象的内部结构应该如何表示。在具体实现中,一般有两种对象的访问方式,分别是通过句柄访问对象以及通过直接指针访问对象。
在之前的博客里我也总结过这两种对象访问方式的优劣,想要了解的同学可以参考这篇博客

特殊方法

在Java虚拟机层面,Java编程语言的构造器是以一个名为<init>的特殊实例初始方法的形式出现的。<init>这个方法名称是由编译器命名的,因为它并非一个合法的Java方法名字,不可能通过程序编码的方式实现。实例初始化方法的初始化期间,通过Java虚拟机的invokespecial指令来调用,而且只能在尚未初始化的实力上调用该指令。构造器的访问权限也会约束由该构造器所衍生出来的实例初始化方法。

一个类或者接口最多可以包含不超过一个类或接口的初始化方法,类或接口就是通过这个方法完成初始化的。这个方法是一个不包含参数的、返回类型为void的方法,名为<clinit>

在class文件中把其他方法命名为<clinit>是没有意义的,这些方法并不是类或接口的初始化方法,它们既不能被字节码指令调用,也不会被虚拟机自己调用。当class文件的版本号不小于51.0时,<clinit>方法想要成为类或接口的初始化方法,必须设置ACC_STATIC标志。

异常

Java虚拟机里面的异常使用Throwable或其子类的实例来表示,抛异常的本质实际上是程序控制权转移的一种即时的、非局部的转换---从异常抛出的地方转换至异常处理的地方。
绝大多数异常的产生都是由于当前线程执行的某个操作所导致的,这种可以称为同步异常。与之相对,异步异常可以在程序执行过程中随时发生。Java虚拟机中异常的出现总是由下面三种原因之一导致的:

  • athrow字节码指令被执行;

当某个线程调用了stop方法时,将会影响到其他线程,或者在特定线程组中的所有线程。因为stop方法的执行常常会导致出现数据不一致的情况,这时候其他线程中出现的异常就是异步异常,因为这些异常可能出现在线程执行过程中的任何位置。虚拟机的内部错误也被认为是一种异步异常。

虚拟机错误
  • InternalError:实现虚拟机的软件错误、底层主机系统的软件错误及硬件错误都会导致Java虚拟机出现内部错误,InternalError是一个异步异常,它可能出现在程序中的任何位置;

由于通常虚拟机会对代码进行优化,例如指令重排。那么在异常发生的时候,有一些在异常出现位置之后的代码可能已经执行了,那这些优化过的代码必须保证它们提前执行所产生的影响对用户程序来说是不可见的。

由Java虚拟机执行的每个方法都会配有零到多个异常处理器。异常处理器描述了其在方法代码中的有效作用范围(通过字节码偏移量范围来描述)、能处理的异常类型以及处理异常的代码所在的位置。要判断某个异常处理器是否可以处理某个具体的异常,需要同时检查一场出现的位置是否在异常处理的有效作用范围内,以及出现的异常是否是异常处理器声明可以处理的异常类型或其子类型。当抛出异常时,Java虚拟机搜索当前方法包含的各个异常处理器,如果能找到可以处理该异常的异常处理器,则将代码控制权转向异常处理器中描述的处理异常的分支之中。

下面我举一个简单例子

方法中捕捉了ArithmeticException 上面这个类对应的字节码

上面的字节码比较容易理解,首先简单介绍一下main方法中各条字节码指令所代表的意思:

  • 0 : 将int类型的常量i压入操作数栈中,iconst_0后面跟的那个0代表常量的值为0;

在字节码下方可以看到一个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三行:

实例化Exception对象对应的字节码
为什么会有三条指令呢?dup是做什么的?我们下面一起来学习一下
由于讨论的是创建对象,所以在代码throw new Exception()中我们不看throw,只看new Exception()这一部分代码。

new Exception()表达式的作用是:

  • 创建并默认初始化一个Exception对象;

对应字节码,我们可以看到:

  • new Exception()对应上面的1

回归到字节码,我们可以看到new字节码指令的作用是创建指定类型的对象实例、对其进行默认初始化,并将指向该实例的一个引用压入操作数栈顶;
然后因为invokespecial会消耗操作数栈顶的引用作为传给构造器的"this"参数,所以如果我们希望在invokespecial调用后在操作数栈顶还维持有一个指向新建对象的引用,就得在invokespecial之前先“复制”一份引用----这就是dup的来源。
以上,就是对创建一个对象编译之后产生的字节码的解释

编译finally语句块

刚才我们介绍了异常处理在字节码层面的细节,但是我们还需要注意的是----由于finally能够保证不管发生任何情况,都能够执行语句块中的代码,所以在日常编码过程中我们在可能发生异常的地方(或者是不会发生异常的地方)经常使用finally来释放某些资源。
下面我们从虚拟机层面来看看如何保证finally语句块中的代码一定会执行

增加了finally语句块 上述Java代码编译之后产生的字节码

可以看到,其实编译器是通过在每个分支后面增加冗余代码的形式来保证finally语句块中的代码一定会被执行。这里和书上讲的有点出入,书上在讲解这一块的时候还是用jsr、jsr_w、ret等程序控制转移指令来解释的,但是javac在很早之前就不再为finally语句生成jsr和ret指令了。

如果程序在try语句块中执行了return,那么代码的行为如下:

  • 如果有返回值,将返回值保存在局部变量表;

如果在try语句中抛出异常,那么代码的行为如下:

  • 将异常保存在局部变量表中

同步

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虚拟机字节码这个层面去探究,所以有些东西是知其然不知其所以然。

上一篇下一篇

猜你喜欢

热点阅读