详解JVM内存管理与垃圾回收机制4 – References

释放双眼,带上耳机,听听看~!

Java通过new关键字来创建对象时,JVM在堆中开辟空间存放对象实例数据,这时,定义的局部变量仍存储在栈中,它包含指向堆中对象的指针 ( 即对象在堆内存的起始地址索引 ),而不是对象本身,这个指针在Java中,被称为引用。来看下面的Java方法,它持有一个由String解析而来的Integer对象。

这里可能会造成大家的误解:指针 = 引用,但实际上它们并不完全相同

当我们一提到指针,就很容易把指针与C语言中的指针划等号,实际上,"指针"是一个概念上的东西,不同的语言有不同的实现,因此在讨论问题时,最好明确前提。

从概念上来说:指针就是一个值,而这个值是某块内存的地址,通过这个值,就可以找到这块内存 (可参考维基百科)。

而C语言中的指针,可以指向内存中的任何地方,也可以参与运算,甚至还可以指向指针,以及指向指向指针的指针。因此,更多时候它只反映了指针寻址的特性。

Java中的引用也是一个值,但这个值不是随便某块内存的地址,而是某个值所在内存的地址(对象首地址),它关心的是这个值(或者对象),而不是地址。当值搬家以后(内存整理),这个引用也会跟着改变。因此,Java中引用可以认为是一个封装后的指针,它屏蔽了指针的复杂性。

更多关于引用与指针的区别,可自行查阅,特别是知乎上的一些讨论也非常有意思


1
2
3
4
5
1public static void foo(String bar) {
2    Integer baz = new Integer(bar);
3}
4
5

在调用foo方法时,JVM的内存是如何变化的?理解这个变化过程,会让你更好的理解引用,对后面理解垃圾回收也有一定的裨益。首先使用javap -v命令来查看foo方法的字节码,javap命令可以将字节码翻译成易读的JVM指令。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
1  public static void foo(java.lang.String);
2    descriptor: (Ljava/lang/String;)V
3    flags: ACC_PUBLIC, ACC_STATIC
4    Code:
5      // 堆栈深度,局部变量表中元素个数,参数个数(如果是非static方法,参数个数=实际参数个数+1)
6      stack=3, locals=2, args_size=1
7         // 创建执行类型的对象实例,对其默认初始化(null),并将指向该实例的一个引用压入操作数栈顶
8         0: new  #2   // class java/lang/Integer
9         // 复制栈顶的值,并压入到栈顶
10         3: dup
11         // 将第0个Slot中为reference类型的本地变量推送到操作数栈顶  
12         4: aload_0
13         // 栈顶的reference数据是Integer类型,调用其构造方法
14         // 这里会创建新的栈帧,并成为当前栈帧,直到Integer的构造方法调用完成后,完成当前栈帧的出栈操作
15         5: invokespecial #3   // Method java/lang/Integer."<init>":(Ljava/lang/String;)V
16         // 操作数栈出栈,将栈顶的引用保存到局部变量中,即第1个Slot
17         8: astore_1
18         9: return
19      // 描述指令与源代码行号之间的关系
20      LineNumberTable:
21        line 5: 0
22        line 6: 9
23      // 描述局部变量表
24      LocalVariableTable:
25        Start  Length  Slot  Name   Signature
26            0      10     0   bar   Ljava/lang/String;
27            9       1     1   baz   Ljava/lang/Integer;
28
29

每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息,当方法调用结束,随着函数栈帧的销毁,局部变量表、操作数栈等也随之消失。更多关于关于虚拟机栈以及栈帧的相关内容,请参考详解JVM内存管理与垃圾回收机制1 – 内存管理

如果你可以轻松理解以上指令代码,可以跳过下一小节,直接进入第二部分,下面介绍代码运行时的栈帧结构。

运行时栈帧结构

1.1 局部变量表

局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量,它的容量是以变量槽 ( Variable Slot )为最小单位,但JVM规范中并未规定一个Slot应占用多大空间,只是要求每个Slot都应该能容纳一个boolean、byte、char、short、int、float、reference和retureAddress类型的数据。而对于64位数据类型long和double,虚拟机会以高位对齐的方式为其分配两个连续的Slot空间。虚拟机为方法中的每个局部变量都分配了一个索引,通过索引可以访问局部变量的指定元素,局部变量的索引从0开始。因为long和double类型的数据占用两个Slot,所以这两种数据类型值采用两个Slot索引中较小的索引来定位。

这里需要着重强调的是:reference类型表示对一个对象实例的引用,JVM规范既没有说明它的长度,也没有指明其应有怎样的结构,但一般来说,虚拟机至少都需要通过这个引用做到两点:

  • 直接或间接地查找到对象在堆中数据的起始地址
  • 直接或间接地找到对象所属数据类型在方法区中存储的类型信息

在方法执行时,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,如果执行的是实例方法(非static的方法),那局部变量表中第0个Slot默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问到这个隐含的参数。其余参数则按照参数表顺序排列,占用从1开始的局部变量Slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的Slot。而如果是static方法,那么参数列表直接从第0个Slot顺序排列,这个也比较好理解,类方法嘛,也不存在对象实例的引用,也就不需要浪费一个Slot。

如果想了解更多关于局部变量表的内容,可以参考 ( 强烈建议大家阅读 ) :
深入理解Java虚拟机 第8章第2节
JVM jsr和ret指令始终理解不了?returnAddress又怎么理解呢?

1.2 操作数栈

操作数栈也常称为操作栈,是一个后入先出 (LIFO) 栈,操作数栈的元素可以是任意Java数据类型,包括long和double。当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作。操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译器要严格保证这一点,在类校验阶段的数据流分析中还要再次验证这一点。比如执行iadd指令时,接近栈顶的两个元素数据类型必须时整型的。

1.3 方法返回地址

当一个方法开始执行后,只有两个方式可以退出这个方法:

  • 正常退出:执行引擎遇到方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者
  • 异常退出:在方法执行过程中遇到异常,且异常没有在方法体内得到处理

无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。

方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。

运行时栈帧内存变化过程

接下来以调用foo("123")为例,介绍整个方法调用过程中栈帧的内存变化过程。

2.1 new指令

new指令用于创建一个对象,其格式: new indexbyte1 indexbyte2,无符号数indexbyte1和indexbyte2用于构建一个指向当前类的运行时常量池索引值,构建方式:(indexbyte1<<8)|indexbyte2,该索引指向运行时常量池中的一个类或接口的符号引用,且这个类或者接口应当是已经解析过的,比如这个例子new #2中的#2代表:class java/lang/Integer。

当new指令执行完成后,一个以此类的新实例将会被分配在堆中,并且它所有的实例变量都会初始化为相应类型的初始值,一个代表该对象实例的reference类型数据objectref将入栈到操作数栈中。这里Integer属于引用类型,因此它的初始值默认为null值。

执行new指令后的内存结构示意图

2.2 dup指令

dup指令用于复制操作数栈顶的值,并插入到栈顶。

执行dup指令后的内存结构示意图

2.3 aload指令

aload_n指令用于从局部变量表加载一个reference类型值到操作数栈。这个实例中,由于后面调用Integer构造方法时需要传递一个字符串参数,因此在调用之前肯定要把数据压入栈顶。指令中的n代表栈帧中局部变量表的索引值,通过n定位到的局部变量必须是reference类型,成为objectref,指令执行后,objectref将会压入到操作数栈栈顶。

执行aload指令后的内存结构示意图

2.4 invokespecial指令

invokespecial指令专门用于调用父类方法、私有方法和实例初始化方法。其格式与new指令类似,都是通过两个无符号数计算出方法在常量池中的索引以便得到该方法所在类或者接口的符号引用。

前面也介绍过,JVM在执行static方法时传递的参数即方法中定义的参数,字节码中args_size=1也表明参数个数确实是1。如果将foo方法改成非static方法,这时候args_size=2,即两个参数,除了方法定义的参数,还包含一个对象引用this参数。因此在执行invokespecial指令,需要消耗操作数栈顶的引用作为this参数传递给Integer类的构造方法。所以,当执行invokespecial指令后还需要在操作数栈顶维持有一个指向新建对象的引用,就得在invokespecial之前复制一份引用。这就是为什么要执行dup命令的原因,因此,网上关于一些为什么要执行dup命令原因的解释是错误的。

执行invokespecial指令后的内存结构示意图

2.5 astore指令

astore_n指令将一个reference类型的数据保存到局部变量表中,与aload指令一样,n也表示指向当前栈帧局部变量表的索引值,而操作数栈栈顶的objectref必须是reference类型的数据。执行此命令后,数据将从操作数栈中出栈,然后保存到n所指向的局部变量表中。

执行astore指令后的内存结构示意图

总结

本文主要分析了运行时栈帧内存结构以及其内存中数据的变化过程,希望通过这个简单的示例,让大家对Java中的引用有一个更直观和深刻的理解。对于文中涉及的指令相关介绍,主要参考了Java虚拟机规范等相关内容。如果大家想要查找某些JVM指令的作用,建议直接查阅虚拟机规范,相比于网上的内容更准确也更节约时间。而关于运行时栈帧结构的内容主要参考深入理解Java虚拟机一书,建议大家阅读。

下一小节将介绍引用的4种类型及其应用。

给TA打赏
共{{data.count}}人
人已打赏
安全技术

网站制作需要素材的实用网站

2021-12-21 16:36:11

安全技术

从零搭建自己的SpringBoot后台框架(二十三)

2022-1-12 12:36:11

个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索