JAVA 虚拟机栈

2018-08-10 笔记 #Java #JVM

概念

栈帧(Stack Frame)

栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构。每个方法从调用至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。

构成

在代码编译时,栈帧需要多大的局部变量表、多深的操作数栈都是完全确定的(写入到方法表的Code属性中:虚拟机执行子系统 - 类文件构成 - 属性表集合)

在活动线程中,只有位于栈顶的栈帧才有效,称为当前栈(Current Stack Frame),与这个栈帧相关联的方法称为当前方法(Current Method)。

局部变量表(Local Variable Table)

局部变量表**用于存放方法参数和方法内部定义的局部变量。**方法表的Code属性: max_locals 数据项指明了该方法所需要分配的局部变量表的最大容量。

局部变量表的容量以Slot(Variable Slot:变量槽)为最小单位,其中64位长度的long和double类型的数据占用2个Slot,其余数据类型(boolean、byte、char、short、int、float、reference、returnAddress)占用一个Slot(一个Slot可以存放32位以内的数据类型)

虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0到局部变量表最大Slot数量。32位数据类型的变量,索引n就代表使用第n个Slot,如果是64位数据类型的变量,则说明会同时使用n和n+1两个Slot。

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

// 关于局部变量表索引与 方法是否被 static 修饰的示例
// 可以通过方法的字节码看到第一次执行store操作的索引值

// 方法 add1 被 static 修饰,则第0位可以直接用于存储参数或者局部变量
public static void add1() {
  int a = 0;
  int b = 3;
  int c = a + b;
}

// 方法 add2 未被 static 修饰,是一个实例变量,则第0位将是方法所属示例对象的引用:this
public void add2() {
  int a = 0;
  int b = 3;
  int c = a + b;
}


// 方法 add1 的字节码,第一次执行istore时,索引为0
0: iconst_0
1: istore_0
2: iconst_3
3: istore_1
4: iload_0
5: iload_1
6: iadd
7: istore_2
8: return



// 方法 add2 的字节码,第一次执行istore时,索引为1,0的位置存放着this
0: iconst_0
1: istore_1
2: iconst_3
3: istore_2
4: iload_1
5: iload_2
6: iadd
7: istore_3
8: return

Slot 复用

为了节省栈帧空间,局部变量表中的Slot是可以复用的,当方法执行位置已经(程序计数器在字节码的值)超过了某个变量,那么这个变量的Slot可以被其他变量复用。除了能节省栈帧空间,还伴随着可能会影响到系统垃圾收集的行为。


// 示例一
public static void main(String[] args) {
  // 向内存填充64M的数据
  byte[] placeholder = new byte[64 * 1024 * 1024];
  System.gc();
}

// 示例二
public static void main(String[] args) {
  // 向内存填充64M的数据
  {
    byte[] placeholder = new byte[64 * 1024 * 1024];
  }
  System.gc();
}

// 示例三
public static void main(String[] args) {
  // 向内存填充64M的数据
  {
    byte[] placeholder = new byte[64 * 1024 * 1024];
  }
  int a = 0;
  System.gc();
}

编译以上代码添加:verbose:gc 参数查看 GC 收集情况,会发现示例一和示例二的占用并没有被回收,而示例三被成功回收了。

能否被回收的原因在于:局部变量表中的 Slot 是否还存在有关于 placeholder 数组的关联。在示例三中,变量a复用了已经不再使用的 placeholder 变量的Slot,在 GC 执行时,就可以正常回收 placeholder 除去a复用的空间。

所以,在部分情况下给不在使用变量设置为null,也不能算完全没有意义。不过应当以恰当的作用域来避免使用手动设置null的方式(实际上在经过JIT的编译优化后,手动设置为null语句将被消除掉)。

局部变量表大小对调用的影响

局部变量表是存在栈帧中的,如果方法的参数列表和局部变量比较多,那么调用一次会占用更多的栈空间,将会导致在栈空间内存一定下调用次数减少。

public class TestLocalVariableTimes {

    private static int count = 0;

    public static void recursion(int a, int b, int c) {
        long l1 = 12;
        short sl = 1;
        byte b1 = 1;
        String s = "1";
        System.out.println("count=" + count);
        count++;
        recursion(1, 2, 3);
    }

    public static void recursion() {
        System.out.println("count=" + count);
        count++;
        recursion();
    }

    public static void main(String[] args) {
        recursion(1, 2, 3);
    }
}

以上代码中,第一个方法有3个参数4个变量,由于long是占用2个slot,所以占用8个Slot,而第二个方法没有参数和局部变量,则不占用Slot。可以通过 javap -verbose TestLocalVariableTimes 查看每个方法的locals值。

通过 java -Xss160K TestLocalVariableTimes 运行调用不同方法时可以发现,无参数无局部变量的方法明显比有参数的方法执行的次数多,所以局部变量表的大小对调用次数是有直接关系的。

操作数栈(Operand Stack)

操作数栈也常被称为操作栈,它是一个后入先出(Last In First Out:LIFO)栈。同局部变量表一样,操作数栈的最大深度也在编译的时候就写入到了Code属性的 max_stacks 数据项中。

操作数栈的每一个元素可以是任意的 Java 数据类型,包括 long 和 double。32位数据类型所占的栈容量为1,64位数据类型所占用栈容量为2。

**CPU 执行时是通过从寄存器取指令,而 JVM 的指令主要是从操作数栈取的,因此 JVM 是一个基于栈而不是基于寄存器的执行引擎。**当执行到一个方法时,这个方法的操作数栈是空的,方法的执行也就对应着相关指令的入栈、出栈。

基本执行逻辑

主要是对变量值的入栈、出栈、运算、入栈…

例如将两个int类型的局部变量相加再将结果保存至第三个局部变量:

// 源代码
public static void main (String[] args) {
  int a = 0;
  int b = 3;
  int c = a + b;
}

// 字节码
0: iconst_0   // 将常量加载到操作数栈中
1: istore_1   // 将上一步加载到操作数栈的常量数值存储到局部变量表中
2: iconst_3   // 同0
3: istore_2   // 同1
4: iload_1    // 将局部变量表索引为1的值加载到操作数栈中
5: iload_2    // 同4
6: iadd       // 对最接近栈顶的两个值出栈并进行求和然后将结果入栈
7: istore_3   // 将相加的结果从操作数栈存储到局部变量表
8: return     // 方法正常返回,结束

操作数栈中元素的类型必须与字节码指令的序列严格匹配,例如上面的iadd操作时,不能出现iadd操作需要的值第一个为long 第二个为 float 的情况。

参考资料




文章作者:eightpigs
创作时间:2018-08-10
更新时间: 2019-03-05
许可协议:CC by-nc-nd 4.0