图解栈帧,别再死记硬背
1、虚拟机栈与栈帧
Java 的 JVM 划分为堆、栈、方法区等模块,这里的栈指的就是虚拟机栈;那什么是栈帧?虚拟机栈和栈帧又有什么关系呢?先来看一段代码:
这段代码演示了一个错误递归调用的方式,很显然当 main 方法执行的时候,程序会抛出 java.lang.StackOverflowError 异常,这个异常大家都知道叫栈溢出,那为什么会抛出这个异常呢?
之所以会抛出 StackOverflowError 异常,这就和栈帧有关了。把上面的代码稍微改进一下,统计方法调用多少次后会抛出 StackOverflowError 异常。改进后的代码:
多次执行结果均接近于 10535,可以推导出:每个线程的栈的大小是固定的,每次方法调用时就会往栈里面存入东西,在无限递归的场景下,一直存一直存就出现了内存溢出的情况。
为了验证每个线程分配的的栈内存的大小是固定的,我们可以通过修改 VM options -Xss 参数,设置每个线程分配的的栈内存的大小为 128k(注意这个值不能太小,否则虚拟机启动就会抛出异常)
将线程分配的的栈内存空间调小之后,再次执行上述代码,发现程序大概执行了 970 次左右就会抛出 StackOverflowError 异常,这样就确信栈的线程分配的的栈内存空间大小是一个固定值了。
有了这些铺垫,后面的内容才会思路清晰,就可以很好的解释什么是栈帧?虚拟机栈和栈帧又有什么关系呢?
2、什么是栈帧
虚拟机为什么会划分一块虚拟机栈内存呢?其实虚拟机栈的内存空间是给线程使用的,每个线程启动后,虚拟机为其分配一块栈内存空间;每个线程分配的虚拟机栈内存区域由多个栈帧(Frame)组成,栈帧对应着每个方法调用时所占用的内存(线程运行时,其实就是执行我们编写的源代码编译后的字节码嘛、说到底就是一个个的方法调用);每个栈帧的由局部变量表、操作数栈、动态链接、方法返回值地址等组成。
虚拟机栈与栈帧的关系如下:
StackOverflowError 异常原因如下:每个线程分配的栈内存空间就好比一根用来串珠子的绳子,绳子的长度是固定的,并且只能从穿入的那一端出入,珠子就好比线程运行过程中需要执行的方法,珠子有大有小,就好比方法因为其局部变量等原因,内存大小不一。每当调用一个方法,就需要穿入一颗珠子,方法执行完毕,珠子就会取出来。而上述例子发生 StackOverflowError 异常的原因,就是方法一直在循环调用没有返回,导致线程的分配的栈内存达到上限抛出了 StackOverflowError 异常。
3、IDEA 中如何 DEBUG 栈帧
IDEA 是主流的 Java 代码编写工具,学会如何在 IDEA 中 DEBUG 栈帧,是一项必备的小技能。(其实我相信大部分人都会用,但是它们并不一定知道这就是栈帧);简单的示例代码如下所示:
在三个方法的如下所示位置分别加上断点,并且以 DEBUG 方式启动,使用 F7 步进(Step into)的方式进行 DEBUG
初始执行的是 main 方法,main 方法的参数是一 String 数组,参数名称为 args,此时可以看到 Variables 变量表中有一个 args={String[0]@483},数组对象的大小为 0,因为我们并未设置启动相关参数。
F7 步进(Step into)进入 method1,此时线程栈栈帧表 Frames 中有两个栈帧,method1 的栈帧中有一个局部变量 x,这个变量时从 main 方法中传递过来的。
F7 步进(Step into)进入 method2,此时线程栈栈帧表 Frames 中有三个栈帧,method2 的栈帧中有一个局部变量 o,这个局部变量时在 method2 中实例化的 Object 对象
F7 步进(Step into)method2 结束,此时 Frames 中只有 method1 和 main 方法两个栈帧,method2 方法由于运行结束方法返回后,就会弹栈(出栈)。继续 F7 步进(Step into)到 System.out.println(y);可以看到如下局部变量表,新增了 o 和 y。
F7 步进(Step into)method1 结束,此时 Frames 中只有 main 方法一个栈帧,method1 方法由于运行结束方法返回后,就会弹栈(出栈)。
F7 步进(Step into)main 结束,此时 Frames 中所有的栈帧都随着方法方法而弹栈(出栈)。整个程序随着主线程的运行结束而结束。
其实上述的过程就是 DEBUG 一个线程在虚拟机栈中分配的栈内存中栈帧的出入栈情况。当时大多数情况下,方法调用情况和内部逻辑会比上述情况复杂的多,并且会有多线程的场景,在多线程情况下需要将断点设置成 Thread 模式。右键单击断点,选择 Thread -> Done 即可。
多个线程进行 DEBUG,则可以在启动的进程窗口下 Threads 中切换线程。
4、图解方法调用时栈帧变化
示例代码
图解方法调用时栈帧的变化,涉及到 JVM 层面的知识点,其中包括方法区、堆、虚拟机栈、栈帧、程序计数器,其大致作用如下所示:
方法区方法区是虚拟机中一块线程共享的内存区域,用于存储类信息、常量池、静态变量、编译后的字节码等信息。在我们这个例子中,JVM 层面执行的是字节码指令,而这些指令就是存储在方法区中。
堆堆是虚拟机中最大的一块线程共享的内存区域,堆是 Java 内存管理的核心区域,所有的对象实例和数组都在堆中分配内存。
虚拟机栈虚拟机栈是线程私有的内存区域。虚拟机栈的内存空间是给线程使用的,每个线程启动后,虚拟机为其分配一块栈内存空间,虚拟机栈中存在多个栈帧。
栈帧每个线程分配的虚拟机栈内存区域由多个栈帧(Frame)组成,栈帧对应着每个方法调用时所占用的内存;每个栈帧的由局部变量表、操作数栈、动态链接、方法返回值地址等组成。
程序计数器程序计数器是一块内存很小的线程私有的内存空间,每个线程都有自己的程序计数器。任何时间一个线程都只有一个方法在执行,程序计数器会记录当前执行方法中的 JVM 指令地址,用于控制程序的正确执行。程序的分支、跳转、循环、异常以及线程切换都需要依靠程序计数器来完成。
第一步执行 main 函数:
执行 main 函数,此时虚拟机栈中会为 main 线程分配一块栈内存供 main 线程运行(main 线程栈),此时 main 线程栈中会压入一个 main 函数栈帧,main 函数拥有一个 String[] args 局部变量,因此局部变量表中 args 指向一个堆中的 String 数组(局部变量表会在方法运行之前就创建完成,分配好内存)。
第二步 method1 函数准备工作:
main 函数中只有一句代码,调用了 method1 函数,此时程序计数器指向该方法(实际上指向的是 JVM 字节码指令的地址),并且此时 main 线程栈中会压入一个 method1 函数栈帧,method1 函数中有三个局部变量,分别是 x、o、y,此时只有 x 的值由方法传递已知,因此 x=1;除此之外 method1 栈帧的返回地址指向方法区中 method1
第三步 method2 函数准备工作:
method1 栈帧创建完成之后,程序计数器会依次指向 method1 函数中的字节码指令,此时局部变量表中的局部变量将会被赋值,执行到 method1 中的第一行代码的字节码指令时,调用了 method2 函数,此是 main 线程栈中会压入一个 method2 函数栈帧
第四步执行 method2 函数中字节码指令:method2 函数中只有一句代码,在堆内存中创建了一个 Object 对象,并且将对象地址赋值给 o 引用(这句代码在在程序计数器中应该是三条字节码指令,演示为源代码看起来更加方便)
第四步执行 method1 函数中字节码指令:method2 执行结束后,main 线程栈中 method2 栈帧会弹出,此时 method1 局部变量表中的局部变量 o 接收 method2 栈帧中返回地址指向的返回值
紧接着执行 int y = x * x,此时 method1 栈帧中局部变量表 y 被赋值为 1,最后执行 System.out.println(y)不在演示。
method1 函数中字节码执行结束后,method1 栈帧弹出,最后 main 函数中字节码执行结束,main 线程栈中栈帧全部弹出,整个 main 线程执行结束,Java 进程终止。
看到这里了要个三连不过分吧!您轻而易举的三连,是对我最大的鼓励和帮助。
版权声明: 本文为 InfoQ 作者【李子捌】的原创文章。
原文链接:【http://xie.infoq.cn/article/6b7026f6d930eca0efa021371】。
本文遵守【CC-BY 4.0】协议,转载请保留原文出处及本版权声明。
评论