《深入理解JAVA虚拟机笔记》运行时栈帧、方法分派、动态类型

运行时栈帧结构

Java 虚拟机以方法作为最基本的执行单元,“栈帧”(Stack Frame)则是用于支持虚拟机进行方法调用方法执行背后的数据结构,它也是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息每一个方法从调用开始至执行结束的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。

每一个栈帧都包括了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。在 Java 编译程序代码时,栈帧中需要多大的局部变量表,需要多深的操作数栈就已经被分析计算出来,并且写入到方法表的 Code 属性之中。换言之,一个栈帧需要分配多少内存,并不会受到程序运行期变量数据的影响,而仅仅取决于程序源码和具体的虚拟机实现的内存布局形式。

一个线程中的方法调用链可能会很长,以Java 程序的角度来看,同一时刻、同一条线程里面,在调用堆栈的所有方法都同时处于执行状态。而对于执行引擎来讲,在活动线程中,只有位于栈顶的方法才是在运行的、生效的,其被称为“当前栈帧(Current Stack Frame)”,与之关联的方法被称为“当前方法(Current Method)”。执行引擎所运行的所有字节码指令都只针对当前栈帧进行操作。栈帧结构如下图所示:

在这里插入图片描述

局部变量表

局部变量表(Local Variables Table)是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量在Java程序被编译为Class文件时,就在方法表的Code属性的max_locals数据项中确定了该方法所需分配的局部变量表的最大容量

局部变量表的容量以变量槽(Variable Slot)为最小单位。《Java 虚拟机规范》中并没有明确指出一个变量槽应占应用的内存空间大小,只是很有导向性地说到每个变量槽都应该能存放一个boolean、 byte、char、short、int、float、referencereturnAddress类型的数据,这8种数据类型,都可以使用32位或更小的物理内存来存储,它允许变量槽的长度可以随着处理器、操作系统或虚拟机实现的不同而发生变化,保证了即使在64位虚拟机中使用了64位的物理内存空间去实现一个变量槽,虚拟机仍要使用对齐和补白的手段让变量槽在外观上看起来与32位虚拟机中的一致。

一个变量槽可以存放一个32位以内的数据类型,Java中占用不超过32位存储空间的数据类型有boolean、byte、char、short、int、float、referencereturnAddress这8种类型。前面6种不需要多加解释,读者可以按照Java语言中对应数据类型的概念去理解它们(仅是这样理解而已,Java语言和Java虚拟机中的基本数据类型是存在本质差别的),而第7种reference类型表示对一个对象实例的引用,《Java 虚拟机规范》既没有说明它的长度,也没有明确指出这种引用应有怎样的结构。但一般来说,虚拟机实现至少都应当能通过这个引用做到两件事情,一是根据引用直接或间接地查找到对象在Java堆中的数据存放的起始地址或索引,二是根据引用直接或间接地查找到对象所属数据类型在方法区中的存储的类型信息。第8种returnAddress类型目前已经很少见了,它是为字节码指令jsr、jsr_w和ret服务的,指向了一条字节码指令的地址,某些很古老的Java虚拟机曾经使用这几条指令来实现异常处理时的跳转,但现在也已经全部改为采用异常表来代替了。

对于 64 位的数据类型,Java虚拟机会以高位对齐的方式为其分配两个连续的变量槽空间。Java 语言中明确的 64 位的数据类型只有 longdouble 两种。这里把longdouble数据类型分割存储的做法,与Java内存模型中“longdouble的非原子性协定”,允许把一次longdouble数据类型读写分割为两次 32 位读写的做法有些类似。不过,由于局部变量表是建立在线程堆栈中的,属于线程私有的数据,无论读写两个连续的变量槽是否为原子操作,都不会引起数据竞争和线程安全问题

Java虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始至局部变量表最大的变量槽数量。如果访问的是32位数据类型的变量,索引N就代表了使用第N个变量槽,如果访问的是64位数据类型的变量,则说明会同时使用第NN+1两个变量槽。对于两个相邻的共同存放一个64位数据的两个变量槽,虚拟机不允许采用任何方式单独访问其中的某一个,《Java虚拟机规范》中明确要求了如果遇到进行这种操作的字节码序列,虚拟机就应该在类加载的校验阶段中抛出异常。

当一个方法被调用时,Java虚拟机会使用局部变量表来完成参数值到参数变量列表的传递过程,即实参到形参的传递。如果执行的是实例方法(没有被static修饰的方法),那局部变量表中第0位索引的变量槽默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问这到这个隐含的参数。其余参数则按照参数表顺序排列,占用从1开始的局部变量槽,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的变量槽。

[隐式参数this][显示的参数列表][方法内的局部变量]

为了尽可能节省栈帧耗用的内存空间,局部变量表中的变量槽是可以重用的,方法体中定义的变量,其作用域不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对应的变量槽就可以交给其他变量来重用。不过,这样的设计除了节省栈帧空间以外,还会伴随有少量额外的副作用,例如在某些情况下变量槽的复用会直接影响到系统的垃圾收集行为

// 代码清单 8—1 局部变量表槽复用对垃圾收集的影响之一
public static void main(String[] args) {
   
    byte[] placeholder = new byte[64 * 1024 * 1024];
    System.gc(); // 没有回收上面的 64M 空间
}

代码清单 8-1 中的代码很简单,向内存中填充了64MB的数据,然后通知虚拟机进行垃圾收集。我们在虚拟机运行参数中加上 “-verbose:gc” 来看看垃圾收集的过程,发现在System.gc()运行后并没有回收掉这64MB的内存,下面是运行的结果:

[GC (System.gc()) 70792K->66352K(251392K),0.0007000 secs] 
[Full GC (System.gc()) 66352K->66212K(251392K),0.0041151 secs] 

代码清单 8-1 的代码没有回收掉placeholder所占的内存是能说得过去,因为在执行System.gc()时,变量placeholder还处于作用域之内,虚拟机自然不敢回收掉placeholder的内存。那我们把代码修改一下,变成代码清单 8-2 的样子。

// 代码清单 8-2 局部变量表 Slot 复用对垃圾收集的影响之二
public static void main(String[] args) {
   
    {
   
        byte[] placeholder = new byte[64 * 1024 * 1024];
    }
    System.gc(); // 没有回收上面的 64M 空间
}

下面是运行的结果:

[GC (System.gc()) 70792K->66416K(251392K),0.0007739 secs] 
[Full GC (System.gc()) 66416K->66212K(251392K),0.0044062 secs] 

加入了花括号之后,placeholder的作用域被限制在花括号以内,从代码逻辑上讲,在执行System.gc()的时候,placeholder已经不可能再被访问了,但执行这段程序,会发现运行结果如下,还是有64MB的内存没有被回收掉,这又是为什么呢?

在解释为什么之前,我们先对这段代码进行第二次修改,在调用System.gc()之前加入一行“int a=0;”,变成代码清单 8-3 的样子。

// 代码清单 8—3 局部变量表 Slot 复用对垃圾收集的影响之三
public static void main(String[] args) {
   
    {
   
        byte[] placeholder = new byte[64 * 1024 * 1024];
    }
    int a = 0;
    System.gc(); // 回收了上面的 64M 空间
}

这个修改看起来很莫名其妙,但运行一下程序,却发现这次内存真的被正确回收了。

[GC (System.gc()) 70792K->66400K(251392K), 0.0008860 secs
[Full GC (System.gc()) 66400K->675K(251392K), 0.0044742 secs]

代码清单8-1至8-3中,placeholder能否被回收的根本原因就是:局部变量表中的变量槽是否还存有关于placeholder数组对象的引用。第一次修改中,代码虽然已经离开了placeholder的作用域,但在此之后,再没有发生过任何对局部变量表的读写操作,placeholder原本所占用的变量槽还没有被其他变量所复用,所以作为 GC Roots 一部分的局部变量表仍然保持着对它的关联。这种关联没有被及时打断,绝大部分情况下影响都很轻微。但如果遇到一个方法,其后面的代码有一些耗时很长的操作,而前面又定义了占用了大量内存但实际上已经不会再使用的变量,手动将其设置为null值(用来代替那句int a = 0,把变量对应的局部变量槽清空) 便不见得是一个绝对无意义的操作,这种操作可以作为一种在极特殊情形(对象占用内存大、此方法的栈帧长时间不能被回收、方法调用次数达不到即时编译器的编译条件) 下的“奇技”来使用。

注意:在 Android 的 Dalvik 和 ART 虚拟机中,由于使用寄存器代替了局部变量表和操作数栈的角色,所以也不存在上面变量槽复用的问题。在基于寄存器的虚拟机当中,变量会被复制给65536个可用寄存器中的任何一个,直接访问这些寄存器来存取局部变量。

Java语言的一本非常著名的书籍《Practical Java》中将把 “不使用的对象应手动赋值为 null” 作为一条推荐的编码规则(笔者并不认同这条规则),但是并没有解释具体原因,很长时间里都有读者对这条规则感到疑惑。

虽然代码清单8-1至8-3的示例说明了赋null操作在某些极端情况下确实是有用的,但笔者的观点是不应当对赋null值操作有什么特别的依赖,更没有必要把它当作一个普遍的编码规则来推广。原因有两点:

  • 从编码角度讲,以恰当的变量作用域来控制变量回收时间才是最优雅的解决方法,如代码清单 8-3 那样的场景除了做实验外几乎毫无用处。
  • 更关键的是,从执行角度来讲,使用赋null操作来优化内存回收是建立在对字节码执行引擎概念模型的理解之上的,当虚拟机使用解释器执行时,通常与概念模型还会比较接近,但经过即时编译器施加了各种编译优化措施以后,两者的差异就会非常大,只保证程序执行的结果与概念一致。在实际情况中,即时编译(JIT)才是虚拟机执行代码的主要方式,赋null值的操作在经过即时编译优化后几乎是一定会被当作无效操作消除掉的,这时候将变量设置为null就是毫无意义的行为。字节码被即时编译为本地代码后,对 GC Roots 的枚举也与解释执行时期有显著差别,以前面的例子来看,经过第一次修改的代码清单8-2在经过即时编译后,System.gc()执行时就可以正确地回收内存,根本无须写成代码清单8-3的样子。

关于局部变量表,还有一点可能会对实际开发产生影响,就是局部变量不像前面介绍的类变量那样存在“准备阶段”。我们知道类的字段变量有两次赋初始值的过程,一次在准备阶段,赋予系统初始值; 另外一次在初始化阶段,赋予程序员定义的初始值因此即使在初始化阶段程序员没有为类变量赋值也没有关系,类变量仍然具有一个确定的初始值,不会产生歧义但局部变量就不一样了,如果一个局部变量定义了但没有赋初始值,那它是完全不能使用的所以不要认为Java中任何情况下都存在诸如整型变量默认为0、布尔型变量默认为false等这样的默认值规则。如代码8-4所示,这段代码在Java中其实并不能运行( 但是在其他语言,譬如C和C++中类似的代码是可以运行的),所幸编译器能在编译期间就检查到并提示出这一点,即便编译能通过或者手动生成字节码的方式制造出下面代码的效果,字节码校验的时候也会被虚拟机发现而导致类加载失败。

// 代码清单 8—4 未赋值的局部变量

public static void main(String[] args) {
   
	int a;
	System.out.println(a); 
}

操作数栈

操作数栈(Operand Stack)也常被称为操作栈,它是一个后入先出栈(Last In First Out,LIFO)。同局部变量表一样,操作数栈的最大深度也在编译的时候被写入到Code属性的max_stacks数据项之中。操作数栈的每一个元素都可以是包括longdouble在内的任意Java数据类型。32位数据类型所占的栈容量位1,64位数据类型所占的栈容量位2。Javac编译器的数据流分析工作保证了在方法执行的任何时候,操作栈数的深度都不会超过在max_stacks数据项中设定的最大值。

当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作。譬如在做算术运算的时候是通过将运算涉及的操作数栈压入栈顶后调用运算指令来进行的,又譬如在调用其他方法的时候是通过操作数栈来进行方法参数的传递。

另外在概念模型中两个不同栈帧作为不同方法的虚拟机栈的元素,是完全相互独立的。但是在大多虚拟机的实现里都会进行一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样做不仅节约了一些空间,更重要的是进行方法调用时可以直接共用一部分数据,无须进行额外的参数复制转递了,重叠的过程如下图所示:

在这里插入图片描述

动态连接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用。这种转化被称为静态解析。另外一部分将在每一次运行期间都转化为直接引用,这部分就称为动态连接。

方法返回地址

当一个方法开始执行后,只有两种方式退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者或者主调方法),方法是否有返回值以及返回值的类型将根

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

川峰

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值