本文是何适 JVM 修仙系列第 3 篇,文末有本系列文章汇总。
上一篇介绍 Java 虚拟机结构时讲到 Java 栈同 Java 方法的调用密切相关,那么这篇就来探究下 Java 栈到底和方法的调用有什么关系。Java 栈分如下几部分介绍:
- 栈帧
- 局部变量表
- 操作数栈
- 帧数据
1. 栈帧
线程被创建的时候 Java 栈被创建,Java 栈中保存着栈帧。线程中方法被调用时,对应着一个栈帧被压入 Java 栈;方法返回时,对应的栈帧从 Java 栈中弹出;方法中抛出异常时,对应的栈帧也会将栈帧弹出。
举例说明:方法 1 中调用方法 2,方法 2 中调用方法 3,方法 3 中调用方法 4,当执行到方法 4 时,栈内存结构如下图:
举例模拟栈溢出:
因为每次方法调用都会生成一个栈帧,当函数调用数量很大,生成的栈帧内存超过 Java 栈总内存时,就会栈溢出 StackOverflowError。
-Xss参数指定最大栈内存,也就是函数调用的最大深度。
以下代码用递归调用来模拟这种情况:
public class StackTest { public static int count = 0; public static void test() { count++; test(); } public static void main(String[] args) { try { test(); } catch (Throwable e) {// 使用Throwable捕获,StackOverflowError是Error,不是Exception System.out.println("调用深度count=" + count); e.printStackTrace(); } } }
设置参数-Xss128K 执行以上代码,结果如下:
调用深度count=1088 java.lang.StackOverflowError at test.test1.StackTest.test(StackTest.java:6) at test.test1.StackTest.test(StackTest.java:7) at test.test1.StackTest.test(StackTest.java:7) at test.test1.StackTest.test(StackTest.java:7) ···
在 test()方法执行了 1088 次之后,栈溢出。你可以尝试调整-Xss 参数查看不同的结果,我将-Xss 参数改为 256K 之后,可以执行 2736 次。
栈帧中包含局部变量表、操作数栈、帧数据。
2. 局部变量表
局部变量表用于保存函数参数和局部变量。
局部变量表中的变量只有在当前函数调用中有效,当函数调用结束后,栈帧销毁,局部变量表也就销毁了。
局部变量数量多,会使得栈帧更占内存,导致函数嵌套调用次数减少。局部变量表中的槽位是可以重用的,从而节省内存。如果一个局部变量过了作用域,那么它的槽位就可以被其他局部变量复用。
举例:局部变量占用栈内存
上文中的 test()方法没有局部变量,可以执行 2736 次。现在 test()方法中加入 10 个局部变量,同样 256K 的栈内存,可以递归 905 次 test()方法。
public class StackTest { public static int count = 0; public static void test() { long a=1,b=2,c=3,d=4,e=5,f=6,g=7,h=8,i=9,j=10; count++; test(); } public static void main(String[] args) { try { test(); } catch (Throwable e) {// 使用Throwable捕获,StackOverflowError是Error,不是Exception System.out.println("调用深度count=" + count); e.printStackTrace(); } } }
执行结果如下:
调用深度count=905 java.lang.StackOverflowError at test.test1.StackTest.test(StackTest.java:6) at test.test1.StackTest.test(StackTest.java:8) at test.test1.StackTest.test(StackTest.java:8) ...
2.2 局部变量对垃圾回收的影响
你可以先不看注释,自己分析下局部变量会不会被回收,然后核对下注释。
栈帧中的局部变量槽位是可以复用的。如果局部变量 a 过了作用域,那么之后声明的局部变量 b 就可以复用 a 的槽位。
public class LocalVarGCTest { public static void main(String[] args) { localVarGC5(); } /** * 数组被a引用,不能被回收 * [GC (System.gc()) 12206K->10968K(123904K), 0.0046455 secs] */ public static void localVarGC1() { byte[] a = new byte[10 * 1024 * 1024]; System.gc(); } /** * 数组没有引用,可以被回收 * [GC (System.gc()) 12206K->704K(123904K), 0.0007060 secs] */ public static void localVarGC2() { byte[] a = new byte[10 * 1024 * 1024]; a = null; System.gc(); } /** * 虽然a变量已经失效,但是因为方法还没结束, * a变量依然存在于局部变量表中,并指向数组,所以byte数组不能被回收 * [GC (System.gc()) 12206K->11000K(123904K), 0.0044869 secs] */ public static void localVarGC3() { { byte[] a = new byte[10 * 1024 * 1024]; } System.gc(); } /** * a变量失效,c变量复用了a的槽位,使得byte数组的引用失效,所以byte数组会被回收 * [GC (System.gc()) 12206K->744K(123904K), 0.0008775 secs] */ public static void localVarGC4() { { byte[] a = new byte[10 * 1024 * 1024]; } int c = 10; System.gc(); } /** * localVarGC1()方法中byte数组没有被回收 * localVarGC1()执行完后,localVarGC1()的栈帧被销毁,byte数组也就没有了引用 * localVarGC5()GC时可以将byte数组回收 * * [GC (System.gc()) 12206K->10896K(123904K), 0.0041066 secs] * [Full GC (System.gc()) 10780K->540K(123904K), 0.0029822 secs] */ public static void localVarGC5() { localVarGC1(); System.gc(); } }
3 操作数栈
操作数栈用于保存计算过程的中间结果,作为计算过程中变量临时的存储空间。
4 帧数据
帧数据区用于支持常量池解析、方法返回和异常处理。
常量池解析:帧数据区保存着访问常量池的指针,当 JVM 执行到需要常量池数据的指令时,它都会通过帧数据区中指向常量池的指针来访问它。
方法返回:如果是通过 return 正常结束,则当前栈帧从 Java 栈中弹出,恢复发起调用的方法的栈。如果方法有返回值,JVM 会把返回值压入到发起调用方法的操作数栈。
异常处理:帧数据区保存着一个异常处理表,遇到异常就会查找异常处理表来处理,如果异常处理表中没有找到处理方法,则结束当前函数调用,抛出异常。
举例:异常处理表(Exception table)
from | to | target | type |
---|---|---|---|
4 | 16 | 19 | any |
19 | 21 | 19 | any |
表示在字节码偏移量 4-16 字节可能抛出任意异常,如果遇到异常,则跳转到字节码偏移量 19 处执行。
5. 总结
参考资料
- 《深入理解 Java 虚拟机(第 2 版) : JVM 高级特性与最佳实践》
- 《实战 Java 虚拟机 : JVM 故障诊断与性能优化》