JVM结构
要正确地实现一台JVM,只需要能正确读取class文件中地每一条字节码指令,并且能正确地执行这些指令所蕴含的操作即可。
对于设计者,可以完全自主决定所有规范中不曾描述的虚拟机内部细节,例如运行时数据区的内存如何布局、选择哪种垃圾收集算法、是否对虚拟机字节码指令进行一些内部的优化操作等。
class文件格式
编译后被JVM所执行的代码使用了一种平台中立(不依赖于特定硬件与OS)的二进制格式来表示,并且经常以文件的形式存储。class文件格式精确定义了类与接口的表示形式。
数据类型
JVM当中可以操作的数据类型分为两类:原始类型和引用类型,也存在原始值和引用值两种类型的数值,它们可以用于变量赋值、参数传递、方法返回和运算操作。
JVM希望尽可能多的类型检查能够在程序运行前完成,即编译器应该在编译期间尽最大努力完成可能的类型检查,使得虚拟机在运行期间无须进行这些操作。
对于原始类型,不需要通过特殊标记或额外手段来在运行期确定它们的实际数据类型,即无需刻意将它们与引用类型分开,JVM的字节码指令本身就可以确定它的指令操作数的类型是声明,因此可以利用该特性直接确定操作数的类型。
JVM是直接支持对象的,对象可以指动态分配的某个类的实例,也可以指某个数组。虚拟机中使用reference类型来表示对某个对象的引用。
原始类型与值
原始数据类型包括:
- 数值类型:
- 整数类型
- byte:8位有符号二进制补码整数。-2^7^~2^7^-1
- short:16位有符号二进制补码整数-2^15^~2^15^-1
- int:32位有符号二进制补码整数-2^31^~2^31^-1
- long:64位有符号二进制补码帧整数-2^63^~2^63^-1
- char:16位无符号整数、指向基于多文种平面的Unicode码点,以UTF-16编码,默认值位Unicode的
null
码点。0~2^16^
- 浮点数类型:
- float:32位,单精度浮点数集合中的元素
- double:64位,双精度浮点数集合中的元素
- 包括了符号量,还包括了正负0、正负无穷大、NaN(无效的运算操作)
- 整数类型
- boolean类型。默认为false
- 没有提供boolean值专用的字节码指令,Java语言表达式操作的boolean值会在编译后使用int代替
- returnAddress类型。指向某个操作码(
opcode
)的指针,此操作码与JVM指令对应,在JVM支持的所有原始类型中,只有该类型不能直接与Java语言的数据类型对应。- 会被jsr、ret、jsr_w指令所使用。并且无法在程序运行期间修改
引用类型与值
JVM中有3种引用类型:类类型、数组类型、接口类型。这些分别指向动态创建的类实例、数组实例、实现了某个接口的类实例或数组实例。
对于数组的元素类型,其必须是原生类型、类类型或接口类型之一。
在引用类型的值中有一个特殊的值null,当一个引用不指向任何对象的时候,它的值就为null。引用类型的默认值就是null
运行时数据区
详情参见 JVM:内存管理
栈帧
栈帧是用来存储数据和部分过程结果的数据结构,同时也用来处理动态链接、方法返回值和异常分派。
栈帧随着方法调用而创建,随着方法结束而销毁。无论方法是正常完成还是异常完成都算作方法结束。
栈帧的存储空间分配在虚拟机栈当中,并且每一个栈帧有自己的局部变量表、操作数栈、和指向当前方法所属的类的运行时常量池的引用。并且栈帧还允许携带与JVM实现相关的一些附加信息,例如对程序调试提供支持。
在某条线程执行过程中的某个时间点上,只有目前正在执行的那个方法的栈帧是活动的。当方法返回时,当前栈帧会传回此方法的执行结果给前一个栈帧,然后JVM丢弃该栈帧。
局部变量表
每个栈帧内部都包含一组称为局部变量表的变量列表(其中包括基础数据类型,例如boolean等、对象的引用或returnAddress),栈帧内的局部变量表的长度由编译器决定,并且存储于类或接口的二进制表示中,即存储在方法的Code
属性当中,并提供给栈帧使用。
局部变量表需要的内存空间在编译期间完成分配,当进入一个方法时这个需要分配多大的局部变量空间时完全确定的,运行期间不会改变局部变量表的大小,64位的long
和double
会占据两个局部变量空间。
局部变量使用索引来进行访问定位,0 =< index =< length
。当调用实例方法时,index = 0
的变量一定是this引用,之后是方法的参数。
操作数栈
JVM底层字节码指令集是基于栈类型的,索引的操作码都是对操作数栈上的数据进行操作。对每一个方法调用,JVM会建立一个操作数栈,以供计算使用。栈的深度在源码编译成class文件后就已经确定,保存在方法的Code
属性中。
在开始时操作数栈是空的,JVM提供了一些字节码指令来从局部变量表或对象实例的字段中复制常量值到操作数栈中,以及从操作数栈中取走数据、操作数据以及将操作结果重新入栈。在方法调用时,也用来准备调用方法的参数以及接收方法返回的结果。
iadd
指令要求执行前操作数栈的栈顶已经存在两个由前面其他指令放入的int类型数值,执行iadd
时将两个int值出栈,相加求和并将求和结果重新入栈。
对于一部分指令可以不关注操作数的具体数据类型,将所有在运行时数据区中的数据当作裸类型数据来操作,例如swap
。它们的操作的正确性会通过class文件的校验过程来强制保障。
动态链接
包含一个指向当前方法所在类型的运行时常量池的引用,以支持对当前方法的代码实现动态链接。
在class文件中,一个方法若调用其他方法或访问成员变量,则需要通过符号引用来表示,动态链接的作用就是将这些符号引用所表示的方法转换为对实际方法的直接引用。类加载过程中将要解析尚未被解析的符号引用,并且将对变量的访问转化为变量在程序运行时,位于数据结构中的正确偏移量。
方法调用正常完成
若方法正常返回,当前栈帧承担着恢复调用者状态的责任,其状态包括调用者的局部变量表、操作数栈和正确增加过来表示执行了该方法调用指令的程序计数器等。使得调用者的代码能在被调用的方法返回并且返回值被推入调用者栈帧的操作数栈后继续正常执行。
方法调用异常完成
若在方法的执行过程中,某些指令导致了JVM抛出异常并且虚拟机抛出的异常在方法中没有办法处理,并且该方法内部没有将异常捕获,如果方法异常调用完成,一定没有方法返回值返回给调用者。
其他引用
对象的表示
JVM并不强制规定对象的内部结构应当如何表示。
句柄与直接引用。
浮点算法
特殊方法
在JVM层面上,对于对象,Java中的构造器是以应该名为<init>的特殊实例初始化方法的形式出现的,其是由编译器命名的,因为无法通过程序编码的方式实现。实例初始化方法只能在实例的初始化期间,通过JVM的invokespecial
指令调用,并且只能在尚未初始化的实例上调用该指令。
对于类或接口,则最多可以包含不超过应该类或接口的初始化方法,该方法是一个不包含参数的、返回类型为void的方法<clinit>。类或接口的初始化方法由JVM自身隐式调用,没有任何JVM字节码指令可以调用这个方法,只会在类的初始化阶段中由JVM自身调用。
当一个方法具有签名多态性则意味着这个方法满足以下全部条件:
- 通过java.lang.invoke.MethodHandle类声明。
- 只有一个类型为Object[]的形参。
- 返回值为Object。
- ACC_VARAGS和ACC_NATIVE标志被设置。
Java8中,只有java.lang.invoke.MethodHandle
的invoke
和invokeExact
是签名多态性方法。invokevirtual指令会对具有签名多态性的方法进行特殊处理。
异常
异常使用Throwable或其子类的实例来表示,抛异常本质实际上是程序控制权的一种即时的、非局部的转换,从异常排除的地方转换至处理异常的地方。
绝大部分异常的产生是由于当前线程执行的某个操作导致的,即同步异常。而异步异常可以在程序执行过程中随时发生。JVM中异常的出现总是由以下三种原因之一导致的
- athrow字节码指令被执行,即代码中写了throw。
- 虚拟机同步检测到程序发生了非正常的执行情况,这时异常必将紧接着发生非正常执行情况的字节码指令后抛出,而不会在执行程序的过程中随时抛出。
- 程序所执行的操作可能会引发异常:
- 字节码指令所蕴含的操作违反了Java语言的语义,例如访问一个超出数组边界范围的元素。
- 当程序在加载或链接时出错。
- 使用某些资源的时候产生资源限制,例如使用了太多内存。
- 程序所执行的操作可能会引发异常:
- 异步异常的发生:
- 调用了Thread或者ThreadGroup的stop方法。则此时该线程会影响到其他线程,此时其他线程中出现的异常就是异步异常,因为它可能出现在线程执行过程中的任何位置。
- JVM发生了内部错误。
抛出异常
JVM允许在异步异常抛出前额外执行一小段有限的代码,使得代码优化器能够在不违反Java语言语义的前提下检测并将异常在可以处理它们的地方抛出。
当异常抛出、程序控制权发生转移的那一刻,所有在异常抛出的位置之前的字节码指令所产生的影响都应当时是可以观察到的,而在异常抛出的位置之后的字节码指令则不应当产生执行效果。如果虚拟机执行的代码是优化过的代码,有一些在异常之后的代码可能已经执行了,则这些优化过的代码必须保证它们提前执行所产生的影响对用户程序来说是不可见的。
异常处理器
JVM执行的每个方法都会配有0-N个异常处理器,异常处理器描述了其在方法代码中的有效作用范围、能处理的异常类型以及处理异常的代码所在的位置。
要判断某个异常处理器能否处理某个具体的异常,需要同时检查异常出现的位置是否在异常处理的有效作用范围内,出现的异常是否是异常处理器声明可以处理的异常类型。当抛出异常,JVM会去搜索当前方法包含的各个异常处理器。
异常完成
如果当前方法没有找到任何的异常处理器,并且确实异常了,则当前方法的操作数栈和局部变量表都将被丢弃,随后对应的栈帧出战,恢复到该方法调用者的栈帧中,未被处理的异常将在方法调用者的栈帧中重新被抛出,并在整个调用链不断重复。如果在顶端依然没有找到合适的处理器,则整个线程终止。
字节码指令集简介
类库
公有设计、私有实现
JVM实现必须能够读取class文件并精确实现包含在其中的JVM代码的语义。
实现者可以在规范约束下对具体实现做出修改和优化,并且推荐如此做,只要优化后的class文件依然可以正确读取,并且包含在其中的语义能够得到保持,实现者就可以选择任何方式去实现这些语义。
JVM编译器
理解编译器是如何与JVM协同工作的,编译器在某些场景中专指将JVM指令集转换未特定CPU指令集的翻译器。
格式
可以使用javap生成,虚拟机汇编语言格式:
1 | <index><opcode> [<operand1>[<operand2>...]] [<comment] |
- <index>是指令操作码在数组中的下表,该数组以字节形式来存储当前方法的JVM代码。也可以认为是相对于方法起始处的字节偏移量。
- <opcode>为指令的操作数,一条指令可以有0-N个操作数。
- <comment>为行尾注释,其部分内容为javap加入,其余为作者添加。
每条指令前的<index>可以作为控制转移指令的跳转目标。
访问运行时常量池
很多数值常量、对象、字段和方法,都是通过当前类的运行时常量池进行访问的。
ldc
、ldc_w
、ldc2_w
。
接收参数
方法调用
普通实例方法调用时在运行时根据对象类型进行分派的,这类方法调用通过invokevirtual
指令实现,invokevirtual
指令都会带有一个表示索引的参数,运行时常量池在该索引处的项为某个方法的符号引用,这个符号引用可以提供方法所在对象的类型的内部二进制名称、方法名称和方法描述符。
1 | int add12and13(){ |
编译后如下:
1 | Method int add12and13() |
使用类实例
同步
JVM中的synchronization是由monitor的进入和退出来实现的,无论是显式同步还是隐式同步。