《深入Java虚拟机》之虚拟机栈

Java内存区域图
Java内存区域

注意:深底色为共享内存区域

先讲解虚拟机栈

虚拟机栈

虚拟机是线程私有的,它的生命周期与线程相同,每个方法在执行的时候都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用到执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程

局部变量表

用于存放方法参数和方法内部定义的局部变量。在编译后,局部变量表的大小就确定了,具体见代码和对应的字节码:

1
2
3
4
// 对应的java代码
public static void main(String[] args){
int a = 1000;
}


其中locals=2 即代表本地局部变量有两个

局部变量表的容量以变量槽(Slot)为最小单位,虚拟机规范表示 boolean、byte、char、short、int、float、reference或returnAddress类型的数据都应该占用1个Slot。注意,只要保证slot实现的效果和32位虚拟机中的一致即可,比如,在64位虚拟机中,使用了64位物理内存空间去实现一个Slot,虚拟机上仍要使用对齐和补白让Slot看起来和32位虚拟机中的一样。

其中介绍一下reference 这个数据类型:

  • 此引用应该直接或间接地查找到对象在Java堆中的数据存放的起始地址索引
  • 此引用应该直接或间接地查找到对象所属数据类型在方法区中地存储类型信息

对于64位的数据类型(double、long),虚拟机会以高位对齐的方式为其分配两个连续的Slot空间。注意,在局部变量表里,无论是否分两次读32位数据,都不会引起数据安全问题。

局部变量表的索引值范围是从0开始直最大的Slot数量,如果是 非static 方法,下标为0的位置默认是this引用。因为方法局部变量表在编译时就确定了,所以调用方法传入数据时,其实是传入了一个引用(待确定。

同时为了尽可能节省 栈帧空间,局部变量表中的Slot是可以重用的,方法体中定义的变量,其作用域不一定会覆盖整个方法,如果该变量超出作用范围,那个变量的slot就会交给其他变量使用。这个规则也会带来一定副作用,因为超出范围后,局部变量表不会主动断开和引用的联系,就导致某个对象一直被局部变量表持有着,可以参考下面的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* 共享变量 */
final int K = 1024;
final int M = 1024 * K;

/* 情况一,回收失败 */
{
byte[] bytes = new byte[10 * M]
}
System.gc();

/* 情况二,回收成功 */
{
byte[] bytes = new byte[10 * M];
}
int a = 0;
System.gc();

《Practical Java》里面讲到“把不使用的对象设置为null” 是有一定道理的,但是不推荐这样做,理由如下:

  • 代码不整洁,应该以恰当的变量作用域来控制回收才是最优雅的
  • JIT会堆null赋值进行优化

但是遇到这样的极端条件,还是可以一试的:对象占用内存大,此方法执行时间长,方法调用次数达不到JIT优化的的编译条件

操作数栈

就是一个处理运算的中转站,如果有做过逆波兰序数的应该可以理解,将操作数压入栈,然后遇到操作符时,弹出两个操作数进行运算。

动态连接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。在类加载阶段或者第一次使用就转换为引用,这种转换被称为静态解析。另一部分要等到每一次运行期间转换为直接引用,这部分称为动态连接。

方法调用不等同于方法执行,方法调用唯一的作用就是确定被调用方法的版本(调用哪个方法),不涉及具体运行过程。Class文件的编译过程不包含传统的连接步骤,一切方法调用在Class文件里都是符号引用,而不是方法在实际运行时内存布局中的入口地址(直接引用)。

在把符号引用转换成直接引用的过程有两种:

  1. 解析,调用的方法版本一定是在编译期就确定下来的,比如静态方法、私有方法、实例构造器、父类方法四种
  2. 分派,可能是静态也可能是动态,主要是重载和重写

因为调用什么方法签名的方法在编译期就已经决定了,如下所示

1
2
3
4
// java代码
Super aSuper = new Sub();
A a = new B();
aSuper.test(a);

上述代码的字节码

字节码#6的引用点

第11行已经决定了调用Super.test(A)方法,此时称该方法为 Resolved Method,但是具体调用哪个实例的test(A),需要取决以下的规则

假设C是一个对象的类,决定哪个方法被调用的规则如下所示:

  1. 如果C声明了一个方法m并且覆盖了Resolved Method,那么方法m就会被调用
  2. 否则,如果C有一个父类,那么搜索实现了 Resolved Method 的方法的直接父类,并向上递归查找。
  3. 抛出异常

综上所是,截至JDK7,java是静态多分派,动态单分派(多分派指需要参考多个元素):方法重载要考虑调用者的类型,考虑方法的参数,这些都是在编译期就固定在字节码里面了。而动态分配要到运行时,才会去查找具体的实例。

方法返回地址

  • 正常返回,将返回值传递给上层的方法调用者
  • 异常完成出口,在方法执行过程中遇到了异常,并且这个异常没有在方法内得到处理

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

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