JVM 简介—JVM 的执行子系统
1.Class 文件结构
(1)Java 跨平台的基础
字节码是各种不同平台虚拟机与所有平台都能统一使用的程序存储格式,所以字节码(ByteCode)是构成平台无关性的基石,是语言无关性的基础。
Java 虚拟机不和包括 Java 在内的任何语言绑定,Java 虚拟机只与 Class 文件这种特定的二进制文件格式所关联。Class 文件中包含了 Java 虚拟机指令集和符号表以及若干其他辅助信息。
(2)Class 文件的本质
Class 文件本质是一组以 8 位字节为基础单位的二进制流,或者说整个 Class 文件本质上就是一张表,类似于 C 语言结构体的伪结构来存储数据。
只有两种数据类型:无符号数和表。无符号数属于基本的数据类型,u1、u2、u4、u8,表是由多个无符号数或其他表作为数据项构成的复合数据类型。
任何一个 Class 文件都对应着唯一一个类或接口的定义信息。但反过来说,类或接口并不一定都得定义在 Class 文件里。比如类或接口也可以动态生成,直接送入类加载器中。所以 Class 文件不一定以磁盘文件的形式存在。
2.Class 文件格式概述
各个数据项严格按顺序紧凑排列在 Class 文件中,中间没有任何分隔符。这使 Class 文件中存储的内容几乎全是程序运行的必要数据,没有空隙。
Class 文件格式采用一种类似于 C 语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:无符号数和表。
无符号数属于基本的数据类型,以 u1、u2、u4、u8 来分别代表 1 个、2 个、4 个和 8 个字节的无符号数,无符号数可用来描述数字、索引引用、数量值或按 UTF-8 构成字符串值。
表是由多个无符号数或其他表作为数据项构成的复合数据类型,所有表都习惯性地以"_info"结尾,表用于描述有层次关系的复合结构的数据,整个 Class 文件本质上就是一张表。
3.Class 文件格式详解
Class 的结构不像 XML 等描述语言,由于它没有任何分隔符号,所以在其中的数据项,无论是顺序还是数量,都是被严格限定的。哪个字节代表什么含义,长度是多少,先后顺序如何,都不允许改变。
按顺序分别包括:
(1)魔数与 Class 文件的版本
每个 Class 文件的头 4 个字节称为魔数(Magic Number),它的作用是确定这个文件是否为一个能被虚拟机接受的 Class 文件。其实不仅仅是 Class 文件,很多文件格式标准中都有使用魔数来标识身份的习惯。
使用魔数而不是扩展名来进行识别主要是基于安全方面的考虑,因为文件扩展名可以随意地改动。文件格式的制定者可以自由地选择魔数值,只要这个魔数值还没有被广泛采用过同时又不会引起混淆即可。
紧接着魔数的 4 个字节存储的是 Class 文件的版本号,第 5 和第 6 个字节是次版本号(Minor Version),第 7 和第 8 个字节是主版本号(Major Version)。
Java 的版本号是从 45 开始的,JDK 1.1 之后的每个 JDK 大版本发布主版本号向上加 1。高版本 JDK 能兼容低版本 Class 文件,但不能运行以后版本的 Class 文件。即使文件格式未发生变化,JVM 也拒绝执行超过其版本号的 Class 文件。
(2)常量池
紧接着次、主版本号之后的是常量池入口,常量池可以比喻为 Class 文件里的资源仓库,常量池是 Class 文件结构中与其他项目关联最多的数据项目。
常量池通常是占用 Class 文件空间最大的数据项目之一,也是 Class 文件中第一个出现的属于表类型的数据项目。
常量池中常量的数量是不固定的,所以在常量池的入口需放置一项 u2 类型的数据,代表常量池容量计数值。与 Java 中语言习惯不一样的是,这个容量计数是从 1 而不是 0 开始的。
常量池中主要存放两大类常量:字面量和符号引用。字面量比较接近于 Java 语言层面的常量概念,如声明为 final 的常量值等。符号引用则属于编译原理方面的概念,包括了三类常量:类和接口的全限定名(Fully Qualified Name)、字段的名称和描述符(Descriptor)、方法的名称和描述符。
如何观看字节码,可以通过使用 javap 工具的-verbose 参数输出 Class 文件字节码:
(3)访问标志
在常量池结束之后,紧接着的 2 个字节代表访问标志。这个访问标志用于识别一些类或者接口层次的访问信息,包括:这个 Class 是类还是接口、是否定义为 public 类型、是否定义为 abstract 类型、如果是类则是否被声明为 final 等。
(4)类索引、父类索引与接口索引集合
Class 文件由这三项数据来确定这个类的继承关系。类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名。由于 Java 语言不允许多重继承,所以父类索引只有一个。除了 java.lang.Object 之外,所有的 Java 类都有父类。因此除了 java.lang.Object 外,所有 Java 类的父类索引都不为 0。接口索引集合就用来描述这个类按顺序实现了哪些接口。
(5)字段表集合
字段表用于描述接口或者类中声明的变量,字段(field)包括类级变量以及实例级变量。
字段叫什么名字、字段被定义为什么数据类型,这些都是无法固定的,只能引用常量池中的常量来描述。
字段表集合中不会列出从父类或者父接口中继承而来的字段,但有可能列出原本 Java 代码之中不存在的字段。比如在内部类中为了保持对外部类的访问性,编译器就会自动添加指向外部类实例的字段。
(6)方法表集合
方法表用于描述方法的定义。但方法里的 Java 代码经过编译器编译成字节码指令后,存放在属性表集合中的方法属性表集合中一个名为 Code 的属性里。
与字段表集合相类似的,如果父类方法在子类中没有被重写(Override),方法表集合中就不会出现来自父类的方法信息。
但同样的,有可能会出现由编译器自动添加的方法,最典型的便是类构造器<clinit>方法和实例构造器<init>方法。
(7)属性表集合
存储 Class 文件、字段表、方法表都可以携带自己的属性表集合,用于描述某些场景专有的信息,如方法的代码就存储在 Code 属性表中。
4.字节码指令
Java 虚拟机的指令由一个字节长度的、代表某种特定操作含义的数字,以及跟随其后的零至多个代表此操作所需要的参数而构成。其中的数字称为操作码 Opcode,需要的参数称为操作数 Operands。
由于限制了 Java 虚拟机操作码的长度为一个字节(即 0~255),所以这意味着指令集的操作码总数不可能超过 256 条。
大多数的指令都包含了其操作所对应的数据类型信息,例如:iload 指令用于从局部变量表中加载 int 型的数据到操作数栈中,而 fload 指令加载的则是 float 类型的数据到操作数栈中。
大部分的指令都没有支持整数类型 byte、char 和 short,甚至没有任何指令支持 boolean 类型。
大多数对于 boolean、byte、short 和 char 类型数据的操作,都是使用相应的 int 类型作为运算类型。
(1)加载和存储指令
用于将数据在栈帧中的局部变量表和操作数栈之间来回传输,这类指令包括如下内容:
一.将一个局部变量加载到操作数栈
iload、iload_<n>、lload、lload_<n>、fload、fload_<n>、dload、dload_<n>、aload、aload_<n>。
二.将一个数值从操作数栈存储到局部变量表
istore、istore_<n>、lstore、lstore_<n>、fstore、fstore_<n>、dstore、dstore_<n>、astore、astore_<n>。
三.将一个常量加载到操作数栈
bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_m1、iconst_<i>、lconst_<l>、fconst_<f>、dconst_<d>。
(2)运算指令
对两个操作数栈上的值进行某种特定运算,并把结果重新存入操作栈顶。加法指令:iadd、ladd、fadd、dadd;减法指令:isub、lsub、fsub、dsub;乘法指令:imul、lmul、fmul、dmul 等等。
(3)类型转换指令
可以将两种不同的数值类型进行相互转换。Java 虚拟机支持以下数值类型的宽化类型转换,也就是小范围类型向大范围类型的安全转换:int 类型到 long、float 或者 double 类型,long 类型到 float、double 类型,float 类型到 double 类型。处理窄化类型转换(Narrowing Numeric Conversions)时,必须显式地使用转换指令来完成,这些转换指令包括:i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l 和 d2f。
(4)创建类实例指令
new。
(5)创建数组指令
newarray、anewarray、multianewarray。
(6)访问字段指令
getfield、putfield、getstatic、putstatic。
(7)数组存取相关指令
把一个数组元素加载到操作数栈的指令:baload、caload、saload、iaload、laload、faload、daload、aaload。将一个操作数栈的值存储到数组元素中的指令:bastore、castore、sastore、iastore、fastore、dastore、aastore。取数组长度的指令:arraylength。
(8)检查类实例类型的指令
instanceof、checkcast。
(9)操作数栈管理指令
如同操作一个普通数据结构中的堆栈那样,Java 虚拟机提供了一些用于直接操作操作数栈的指令。
将操作数栈的栈顶一个或两个元素出栈:pop、pop2。复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶:dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2。将栈最顶端的两个数值互换:swap。
(10)控制转移指令
控制转移指令可以让 Java 虚拟机有条件或无条件地:从指定的位置指令而不是控制转移指令的下一条指令继续执行程序,可以认为控制转移指令就是在有条件或无条件地修改 PC 寄存器的值。
控制转移指令之条件分支:ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull、if_icmpeq、if_icmpne、if_icmplt、if_icmpgt、if_icmple、if_icmpge 等。控制转移指令之复合条件分支:tableswitch、lookupswitch。控制转移指令之无条件分支:goto、goto_w、jsr、jsr_w、ret。
(11)方法调用指令
invokevirtual 指令用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),这也是 Java 语言中最常见的方法分派方式。
invokeinterface 指令用于调用接口方法,会在运行时搜索一个实现了该接口方法的对象,找出适合的方法来调用。
invokespecial 指令用于调用一些要特殊处理的实例方法,包括实例初始化方法、私有方法和父类方法。
invokestatic 指令用于调用类静态方法。
invokedynamic 指令用于在运行时动态解析出方法并执行该方法。
前面 4 条调用指令的分派逻辑都固化在 JVM 内部,而 invokedynamic 指令的分派逻辑是由用户所设定的引导方法决定的,方法调用指令与数据类型无关。
(12)方法返回指令
根据返回值的类型区分,包括 ireturn、lreturn、freturn、dreturn 和 areturn。另外还有一条 return 指令供声明为 void 的方法、实例初始化方法以及类和接口的类初始化方法使用。
(13)异常处理指令
在 Java 程序中显式抛出异常的操作(throw 语句)都由 athrow 指令来实现。
(14)同步指令
有 monitorenter 和 monitorexit 两条指令支持 synchronized 关键字的语义。
5.类的生命周期和初始化
(1)类的生命周期
类从被加载到虚拟机内存中开始,到卸载出内存为止。类的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载 7 个阶段。其中验证、准备、解析 3 个部分统称为连接。
(2)会触发类进行初始化的主动引用
初始化阶段规定了有且只有 6 种情况必须立即对类进行初始化,当然对类进行初始化之前,加载、验证、准备自然也需要完成。
一.遇到 new、getstatic、putstatic 或 invokestatic 这 4 条字节码指令时
如果类没有进行初始化,则需要先触发其初始化。生成这 4 条指令的最常见的 Java 代码场景是:
场景 1:使用 new 关键字实例化对象
场景 2:读取或设置一个类的静态字段
场景 3:调用一个类的静态方法
二.用 java.lang.reflect 包的方法对类进行反射调用
如果类没有进行过初始化,则需要先触发其初始化。
三.初始化一个类时发现父类还没有进行过初始化
那么就需要先触发其父类的初始化。
四.当虚拟机启动时用户需要指定一个要执行的主类
比如包含 main()方法的那个类,虚拟机就会先初始化这个主类。
五.如果某些方法句柄所对应的类没有进行过初始化
如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果是 REF_getStatic、REF_putStatic、REF_invokeStatic 等方法句柄,且这个方法句柄所对应的类没有进行过初始化,则要先触发其初始化。
六.当接口中定义了被 default 关键字修饰的方法时
如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。
注意:以上六种场景的行为称为对一个类型进行主动引用。除此之外,所有引用类型的方式都不会触发初始化,称为被动引用。
(3)不会触发类进行初始化的被动引用
一.通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。
二.通过数组定义来引用类不会触发此类的初始化。
三.常量在编译阶段会存入调用类的常量池中(编译阶段常量传播优化),本质上没直接引用到定义常量的类,故不会触发定义常量的类的初始化。
6.类加载的全过程
Java 虚拟机中类加载的全过程,即加载、验证、准备、解析和初始化这 5 个阶段所执行的具体动作:
(1)加载阶段
虚拟机需要完成以下 3 件事情:
一.通过一个类的全限定名来获取定义这个类的二进制字节流。
二.将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
三.在内存中生成一个代表这个类的 java.lang.Class 对象,代表这个类的对象会作为方法区这个类的各种数据的访问入口。
(2)验证阶段
这是连接阶段的第一步,主要有如下两个目的。
目的一:确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求。
目的二:不会危害虚拟机自身的安全。
从整体上看,验证阶段大致上会完成下面 4 个阶段的检验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。
(3)准备阶段
这是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。这个阶段中有两个容易混淆的概念需要强调一下。
首先,此时进行内存分配的只是类变量(被 static 修饰),不包括实例变量。实例变量将会在对象实例化时随着对象一起分配在 Java 堆中。
其次,这里所说的初始值"通常情况"下是数据类型的零值。假设一个类变量的定义为:public static int value=123,那变量 value 在准备阶段过后的初始值为 0 而不是 123,因为这时候尚未开始执行任何 Java 方法。而把 value 赋值为 123 的 putstatic 指令是程序被编译后,存放于类构造器<clinit>()方法之中,所以把 value 赋值为 123 的动作将在初始化阶段才会执行。
假设上面类变量 value 的定义变为:public static final int value=123。编译时 Javac 将会为 value 生成 ConstantValue 属性,在准备阶段虚拟机就会根据 ConstantValue 的设置将 value 赋值为 123。
(4)解析阶段
这是虚拟机将常量池内的符号引用替换为直接引用的过程,比如有:类或接口的解析、字段解析、方法解析、接口方法解析等。
(5)类初始化阶段
这是类加载过程的最后一步。在前面的类加载过程中:除了在加载阶段用户应用程序可以通过自定义类加载器参与外,其余动作完全由虚拟机主导和控制。
到了初始化阶段,才真正开始执行类中定义的 Java 程序代码。在准备阶段,变量已经赋过一次系统要求的初始值。而在初始化阶段,则根据初始化方法去初始化类变量和其他资源,或者说初始化阶段是执行类构造器<clinit>()方法的过程。
<clinit>()方法是由编译器自动收集类中所有类变量的赋值动作,以及静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的。
<clinit>()方法对于类或接口来说并不是必需的。如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。
JVM 会保证一个类的<clinit>()方法在多线程环境中被正确加锁、同步。如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕为止。所以如果在一个类的<clinit>()方法中有一些耗时很长的操作,那么就有可能会导致多个线程阻塞。
7.类加载器
(1)类加载器的定义及其用途
类加载的 5 个阶段中:除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。
一.类加载器的定义
类加载的阶段通过一个类的全限定名来获取描述该类的二进制字节流,这个动作放到 Java 虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类,实现这个动作的代码被称为类加载器(Class Loader)。
二.类加载器的用途
类加载器的用途有:热加载、代码保护和加解密、类层次划分、OSGi 等。
(2)类加载器与类是否相等
对于任意一个类,都必须由加载它的类加载器和这个类本身,一同确立其在 Java 虚拟机中的唯一性。每一个类加载器,都拥有一个独立的类名称空间。这句话可以表达得更通俗一些:比较两个类是否"相等",只有在这两个类是由同一个类加载器加载的前提下才有意义。否则即使这两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。
这里所指的"相等",包括代表类的 Class 对象的 equals()方法、isAssignableFrom()方法、isInstance()方法的返回结果,也包括使用 instanceof 关键字做对象所属关系判定等情况。
(3)如何重写类的加载方法
在自定义 ClassLoader 的子类时,常见的会有两种做法。一种是重写 loadClass()方法,另一种是重写 findClass()方法。
其实这两种方法本质上差不多,毕竟 loadClass()也会调用 findClass(),但是从逻辑上讲最好不要直接修改 loadClass()的内部逻辑,建议只在 findClass()里重写自定义类的加载方法。
loadClass()这个方法是实现双亲委托模型逻辑的地方,擅自修改这个方法会导致模型被破坏,容易造成问题。因此最好在双亲委托模型框架内进行小范围改动,不破坏原有的结构,同时也避免了重写 loadClass()方法的过程中必须写双亲委托的重复代码。从代码的复用性来看,不直接修改 loadClass()方法始终是比较好的选择。
(4)自定义类加载器对业务类进行加密解密
下面自定义类加载对类进行加密和解密,比如对 DemoUser 加密和解密。
如果要想在下面的 DemoRun 类里使用 DemoUser,那么就需要在自定义的类加载器里进行解密:
对加密过的 Class 二进制流进行解密,虚拟机才能加载 DemoUser 这个类:
8.双亲委派模型
(1)JVM 角度的两种类加载器
从 Java 虚拟机的角度来看,只存在两种类加载器。第一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用 C++语言实现,是虚拟机自身的一部分。第二种是所有其他的类加载器,这些类加载器都由 Java 语言实现,独立于虚拟机外部,并且全都继承自抽象类 java.lang.ClassLoader。
(2)开发者角度的三层类加载器
从 Java 开发人员的角度来看,类加载器应该划分得更细致些。Java 一直保持着三层类加载器、双亲委派的类加载架构。三层类加载器如下所示:
(3)自定义加载器的作用
一.增加除了磁盘位置外的 Class 文件来源
二.实现类的隔离
三.实现类的重载
(4)双亲委派模型
一.双亲委派模型各个类加载器之间的关系
双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。
这里类加载器之间的父子关系不会以继承的关系来实现,而是使用组合关系(委托)来复用父加载器的代码。
二.双亲委派模型的工作过程
如果一个类加载器收到了类加载请求,它首先不会自己尝试加载这个类,而是把这个请求委派给父类加载器去完成,每个层次的类加载器都如此。
因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求时(即搜索不到所需的类),才会让子加载器尝试自己去完成加载。
三.双亲委派模型的好处
使用双亲委派模型来组织类加载器之间的关系,有个显而易见的好处是:Java 类随着它的类加载器一起具备了一种带有优先级的层次关系。
例如类 java.lang.Object,它存放在 rt.jar 之中。无论哪个类加载器要加载这个类,最终都委派给启动类加载器进行加载。因此 Object 类在程序的各种类加载器环境中都是同一个类。
相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话。如果用户自己编写了一个 java.lang.Object 类并放在程序的 ClassPath 中,那系统中将会出现多个不同的 Object 类,应用程序将会变得一片混乱。
四.双亲委派模型的实现
双亲委派模型的实现就放在 loadClass()方法里,所以自定义的类加载器最好不要覆盖 loadClass(),而是覆盖 findClass()。
五.双亲委派模型被破坏的情形
双亲委派很好解决了各个类加载器协作时基础类的一致性问题,越基础的类由越上层的加载器进行加载。
基础类型之所以被称为基础,是因为它们总是作为被用户代码继承、调用的 API,但如果有基础类又要调用回用户的代码,该如何处理。
为了解决该困境,Java 引入了线程上下文类加载器。线程上下文类加载器可通过 Thread.setContextClassLoader()方法设置。如果创建线程时还未设置,它将会从父线程中继承一个。如果在应用程序的全局范围内都没有设置过的话,那这个线程上下文类加载器默认是应用程序类加载器。
有了线程上下文加载器,JNDI 服务就可使用它去加载所需的 SPI 服务代码。这是一种父类加载器去请求子类加载器完成类加载的行为,这种行为实际上是打通了双亲委派模型的层次结构来逆向使用类加载器,这种行为已经违背了双亲委派模型的一般性原则。
Java 中涉及 SPI 的加载基本上都是采用线程上下文类加载器来完成,例如:JNDI、JDBC、JCE、JAXB 等。
9.栈桢详解
(1)运行时的栈帧结构
Java 虚拟机以方法作为最基本的执行单元。栈帧是用于支持虚拟机进行方法调用和方法执行背后的数据结构,栈帧也是虚拟机运行时数据区中的虚拟机栈的栈元素。
栈帧存储了方法的局部变量表、操作数栈、动态连接和返回地址等信息。每个方法从调用开始至调用结束,都对应一个栈帧从入栈到出栈的过程。
编译 Java 程序源码时,栈帧中需要多大的局部变量表、多深的操作数栈,就已经被分析计算出来,并且会写入到方法表的 Code 属性中。
所以一个栈帧要分配多少内存,并不会受程序运行期变量数据的影响,而仅仅取决于程序源码和具体的虚拟机实现的栈内存布局形式。
(2)栈桢的内容详解
一.局部变量表
局部变量表用于存放方法参数和方法内部定义的局部变量。局部变量表的容量以变量槽为最小单位。局部变量表建立在线程堆栈中,属于线程私有的数据。
读写两个连续变量槽是否为原子操作,都不会有数据竞争和线程安全问题。Java 虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从 0 开始至局部变量表最大的变量槽数量。
当一个方法被调用时,Java 虚拟机会使用局部变量表来完成参数值到参数变量列表的传递过程,即实参到形参的传递。
为了节省栈帧的内存空间,局部变量表中的变量槽是可以重用的。方法体中定义的变量,其作用域并不一定会覆盖整个方法。如果当前字节码程序计数器的值已经超出了某个变量的作用域,那这个变量对应的变量槽就可以交给其他变量来重用。不过,这种变量槽的重用在某些情况下会影响到系统的垃圾收集行为。
类的字段变量有两次赋初始值的过程:一次在准备阶段赋予系统初始值,一次是在初始化阶段赋予程序定义的初始值。因此即使在初始化阶段程序没有为类变量赋值也没有关系,类变量仍然具有一个确定的初始值,不会产生歧义。
但局部变量就与类的字段变量不同了;如果一个局部变量定义了但没有赋初始值,那它是不能使用的。
二.操作数栈
操作数栈也被称为操作栈,它是一个后进先出栈。同局部变量表一样,操作数栈的最大深度也在编译时被写入到 Code 属性。
Java 虚拟机的解释执行引擎被称为基于栈的执行引擎,这里的栈指的就是操作数栈。
当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的。在方法的执行过程中,会有各种字节码指令在操作数栈中出栈和入栈。
比如在运行整数加法的字节码指令 iadd 这条指令时,会要求操作数栈中最接近栈顶的两个元素先存入两个 int 型的数值,然后把这两个 int 值出栈进行相加,接着将相加的结果重新入栈。
三.动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。
我们知道 Class 文件的常量池中存在大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。
这些符号引用中:一部分会在类加载阶段或第一次使用时就被转化为直接引用,这种转化被称为静态解析。另外一部分将在每一次运行期间都转化为直接引用,这部分转化就称为动态连接。
四.方法返回地址
当一个方法开始执行后,只有两种方式退出这个方法:第一种方式是执行引擎遇到任意方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者。第二种方式是方法执行的过程中遇到异常且没有妥善处理,这时候不会有返回值传递给上层的方法调用者。
方法退出的过程等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈、把返回值(如果有的话)压入调用者栈帧的操作数栈中、调整程序计数器的值以指向方法调用指令后面的一条指令等。
11.方法调用详解
(1)方法调用介绍
这里的详情总结成 3 句话就是:
一.解析和静态分派都是在编译期间就能知道具体调用的是哪一个方法。
二.动态分派在运行时才知道具体调用哪个方法。
三.动态分派通过虚拟机的虚方法表机制来实现。
在 Idea 这些工具上,使用 command 点一下就能跳到对应的方法了。如果跳到的是具体的方法那就是静态分派,如果跳到的是接口方法那就是动态分派。
方法调用并不等同于方法中的代码被执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),因此方法调用阶段暂时还未涉及方法内部的具体运行过程。
在程序运行时,进行方法调用是最普遍、最频繁的操作之一。Class 文件的编译过程中不包含传统程序语言编译的连接步骤。一切方法调用在 Class 文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(直接引用)。
这个特性给 Java 带来强大的动态扩展能力,但也让方法调用过程变复杂。某些调用要在类加载期间,甚至运行期间才能确定目标方法的直接引用。
(2)解析调用
调用编译时就已经确定的目标方法,称为解析调用。
所有方法调用的目标方法在 Class 文件里都是常量池中的一个符号引用。在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用。
这种在解析阶段能转化为直接引用的符号引用,对应的方法具有的特点:
特点一:在程序运行前就有一个可确定的调用版本。
特点二:该方法的调用版本在运行期是不可改变的。
符合编译期可知 + 运行期不可变的方法主要是静态方法和私有方法。前者与类型直接关联,后者在外部不可被访问。这两方法的特点决定它们都不可能通过继承或别的方式重写其他版本,因此它们都适合在类加载阶段进行解析。
被 invokestatic 和 invokespecial 指令调用的方法,也可以在解析阶段中确定唯一的调用版本。这些方法有静态方法、私有方法、实例构造器、父类方法。
被 final 修饰的方法,在类加载时会把符号引用解析为该方法的直接引用。
以上这些方法统称为非虚方法,与之相反,其他方法被称为虚方法。所以非虚方法(把符号引用解析为该方法的直接引用)有 5 种,分别是:静态方法、私有方法、实例构造器、父类方法、被 final 修饰的方法。
解析调用是一个静态过程,在编译期间就完全确定,在类加载的解析阶段会把涉及的符号引用全部转为明确的直接引用。
(3)静态分派
静态分派多见于方法的重载(也就是方法名相同,但是参数不同)。
Human 称为变量的静态类型,或者叫做的外观类型,后面的 Man 和 Woman 则称为变量的实际类型。
代码中定义了两个静态类型相同但实际类型不同的变量,但编译器在重载时是通过参数的静态类型而不是实际类型作为判定依据的,并且静态类型是编译期可知的。因此在编译阶段,编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了 sayHello(Human)作为调用目标。
所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译阶段,确定静态分派的动作不是由虚拟机来执行的。
(4)动态分派
动态分派多见于方法的重写(方法名和参数都相同,继承相同父类)。
静态类型同样都是 Human 的两个变量 man 和 woman,在调用 sayHello()方法时执行了不同的行为,并且变量 man 在两次调用中执行了不同的方法。导致这个现象的原因很明显,是这两个变量的实际类型不同。
动态分派的实现:
动态分派是执行非常频繁的动作,Java 虚拟机会为类型在方法区中建立一个虚方法表,虚方法表中存放着各个方法的实际入口地址。
如果某个方法在子类中没有被重写,那子类的虚方法表里的地址入口和父类相同方法的地址入口是一致的。如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。
12.基于栈的字节码解释执行引擎
JVM 的执行引擎在执行 Java 代码时有解释执行和编译执行两种选择。解释执行就是通过解释器来执行,编译执行就是通过即时编译器产生本地代码来执行。
Java 语言经常被人们定义为解释执行的语言。在 Java1.0 时代时,这种定义还算比较准确。但当主流的虚拟机中都包含了即时编译器后,Class 文件中的代码到底会被解释执行还是编译执行,就成了只有虚拟机自己才能准确判断的事了。
Javac 编译器输出的字节码指令流,基本上是一种基于栈的指令集架构,字节码指令流里的指令大部分是零地址指令,它们依赖操作数栈工作。
基于栈的解释器执行过程:
这段代码在虚拟机中的执行情况如下:
文章转载自:东阳马生架构
评论