StackOverflowError这个错误常出现在较深的方法调用以及递归方法中,平时很少会遇到。我们以一道经典的递归算法题为例,求1到n的和。为了查看在发生栈溢出时方法一共递归了多少次,我们在方法中打印当前n的值。
public class RecursionAlgorithmMain {
private static volatile int value = 0;
static int sigma(int n) {
value = n;
System.out.println("current 'n' value is " + n);
return n + sigma(n + 1);
}
public static void main(String[] args) throws IOException {
new Thread(() -> sigma(1)).start();
System.in.read();
System.out.println(value);
}
}
在默认栈大小情况下,程序抛出栈溢出错误并终止线程时,方法递归调用了6524次:
在默认栈大小的情况下,多次运行代码,得出的结果是相差不大的。在发生StackOverflowError时,进程并没有结束,因为一个线程的StackOverflowError并不影响整个进程。
现在我们将配置JVM的启动参数-Xss(栈大小),以调整虚拟机栈的大小为256k。如果你是使用idea运行本例代码,可直接在VM options配置加上-Xss256K。如果你是使用java命令运行,可在java命令后面加上-Xss256k。
运行
这次一共调用了1669次,这与调整栈大小之前似乎存在着某种关系,用栈大小调整之前程序发生栈溢出时方法的调用次数除以栈大小调整后的,结果约是3。这是不是说明栈的大小默认为1024K左右呢。当然,以这个测试结果来说明其实并不严谨。
我们可以通过打印虚拟机参数查看默认的栈大小。使用jinfo[1]命令行工具可查看某个Java进程当前虚拟机栈的大小,这是jdk提供的工具,不需要额外下载安装。使用jinfo查看Java进程线程栈大小如下:
其实,不显式设置-Xss或-XX:ThreadStackSize时,在Linux x64上ThreadStackSize的默认值就是1024KB,给Java线程创建栈会用这个参数指定z的大小。如果把-Xss或者-XX:ThreadStackSize设为0,就是使用“系统默认值”。而在Linux x64上HotSpot VM给Java栈定义的“系统默认”大小也是1MB。
除了可以使用jinfo命令行工具查看之外,我们还可以通过NAT工具查看。使用NAT还能查看方法区的大小。以使用Java命令启动Java进程为例,在Java命令后面加上开启NAT的配置参数NativeMemoryTracking,如下:
进程启动后,可通过jcmd命令行工具查看该进程的内存占用信息
我们能看到当前进程Java堆分配的大小、用于存储类元数据信息使用的内存、线程栈总共占用的内存等。图中有每个参数的详细说明,这里不再详细说明。从线程栈信息来看,被查看的进程当前线程数为63,使用内存为63696K,也就是每个线程栈占用1M内存。
NAT工具也用于排查内存泄露问题,当项目中依赖了一些使用直接内存的第三方jar包时,可能会因为使用不当而造成内存泄露。如堆内存没有用满,但top命令查看内存使用率却接近百分百,这种情况就很有可能是程序使用堆外直接内存造成的。-Xss参数在多线程项目中常用于JVM调优。假设项目中开启1024个线程,那么使用默认栈大小的情况下,虚拟机栈将会占用1G的内存,而如果将栈大小调整为256K,虚拟机将只花费256M内存用于1024个栈的分配。
最后,我们也可以在HotSpot源码中找到关于栈大小的设置。以64位Linux操作系统为例,默认栈大小为1M,编译线程的栈大小为4M,如代码清单所示:
栈也有最小值,在不同的操作系统及CPU环境下,栈的最小值也不一样。如在64位的Linxu系统下,使用java命令启动一个jar包并将-Xss配置为128K,进程将会异常终止,并提示创建Java虚拟机失败,要求栈最小值为228K。
虚拟机栈的最小值在虚拟机启动时解析完全局参数之后调用os::init_2方法设置。虚拟机栈的最小值受当前系统是32位还是64位的影响,也受系统页大小影响。在64位Linxu操作系统下,HotSopt所允许设置的栈的最小值为228K[6],如代码清单:
在Java中,Java线程与操作系统一对一绑定,Java虚拟机栈也与操作系统线程栈映射,操作系统线程在Java线程创建时创建。前面介绍-Xss配置虚拟机栈的大小便是指定操作系统线程栈的大小。
我们以Java命令启动一个Java程序就是启动一个JVM进程。程序中main方法是Java程序的入口,JVM会为main方法的执行分配一个线程,叫main线程。我们编写的Java代码都会在线程中执行,而在Java中创建Thread对象并调用start方法时,JVM会为其创建一个Java线程,并创建一个操作系统线程,将操作系统线程绑定到Java线程上。HotSpot虚拟机线程start流程如下:
虽然Java是一门面向对象的语言,但程序运行依然是基于方法的调用,每个方法对应一个栈桢,方法的调用对应栈桢的入栈和出栈。Java类中每个方法的代码经过编译处理后最终变为字节码指令存储在Code属性中。栈与栈桢的关系如图下所示:
在调用Thread对象的start方法时,该线程对应的虚拟机栈的第一个栈桢是run方法。run方法中每调用一个方法就对应一个栈桢的入栈,一个方法只有执行结束才会出栈。方法执行结束包括方法抛出异常结束、return命令返回。栈的大小是固定的,默认栈大小是1M,可通过-Xss参数配置。因此,从run方法开始,如果调用链路过深,如递归方法,在栈没有足够的空间容纳下一个栈桢的入栈时,就会出现StackOverflowError错误,同时当前栈被销毁,当前线程结束。HotSpot虚拟机的实现源码如代码清单所示。
局部变量表与操作数栈
在了解线程、栈与栈桢的关系后,我们还要重点关注栈桢中的局部变量表与操作数栈,这两个数据结构是字节码指令执行所依赖的。
- 局部变量表
局部变量表存储方法中声明的变量、方法参数,如果是非静态方法还会存放this引用。局部变量表的大小是固定的,在编译时就已经确定。这也是我们在操作字节码时需要注意的一点,我们需要计算方法的局部变量表需要多大,如果设置过大就会造成内存资源的浪费。
局部变量表的结构是一个数组,数组的单位是Slot(变量槽),Slot的大小是多少个字节由虚拟机决定。在32位的HotSpot虚拟机中,一个Slot槽的大小是4个字节,而在64位的HotSpot虚拟机中,一个Slot槽的大小是8个字节,在开启指针压缩的情况下,一个Slot槽的大小是4个字节。局部变量表的结构如图所示。
-
操作数栈
操作数栈与局部变量表一样,大小也是固定的,也是在编译期确定,单位也是Slot。但与局部变量不一样的是,它并不是由多少个局部变量决定栈的深度的,与需要传递最多参数的方法调用有很大关系。因此,操作数栈的深度相对来说比较难确定。操作数栈用于存储执行字节码指令所需要的参数。比如获取对象自身的字段,需要先将this引用压入栈顶,再执行getfield字节码指令;比如执行new指令后,栈顶会存放该new指令返回的对象的引用。操作数栈的结构如图所示。
局部变量表与操作数栈大小的设置,也会影响到栈桢的大小,从而影响栈所能容纳的栈桢的最大数量。以前面栈溢出的例子说明,默认1M大小的栈大概能调用六千次的递归求和方法,而如果递归方法中再写得复杂些,也会导致调用次数的下降。使用ASM框架操作字节码时,要注意合理设置这个结构的大小。
做个试验,我们将递归方法写的复杂一些:
public class RecursionAlgorithmMain {
private static volatile int value = 0;
static int sigma(int n) {
int i = 0, j = i, a = i, b = i, c = i, r = i, g = 0;
int[] arr = new int[]{i, j, a, b, c, r, g};
value = n;
System.out.println("current 'n' value is " + n);
return n + sigma(n + 1);
}
public static void main(String[] args) throws IOException {
new Thread(() -> sigma(1)).start();
System.in.read();
System.out.println(value);
}
}
再次运行后,会发下只调用5000多次,因为栈帧大了,在总容量不变的情况下,能容纳的栈帧数量减少了,即方法调用次数减少了。
基于栈的指令集架构
在汇编语言中,除直接内存操作的指令外,其它指令的执行都依赖寄存器,如跳转指令、循环指令、加减法指令等。汇编指令集是由硬件直接支持的,不同架构的CPU提供的汇编指令集也会不一样。以一个经典的++i面试题为例,使用c语言编写的实现如下。
int m = ++i;
反汇编后对应的32位x86 CPU的汇编指令如下。
这三条指令的意思是,先将[ebp-44h]指向的内存块的值加1,dword ptr相当于c语言中的类型声明。接着将自增后[ebp-44h]指向的内存块的值放入eax寄存器,最后将eax寄存器的值放到[ebp-4ch]指向的内存块,也就是赋值给变量m。由于i和m是在栈上分配的内存,因此[ebp-44h]对应i的内存地址,[ebp-4ch]对应m的内存地址。
汇编指令不能直接操作将一块内存的值赋值给另一块内存,必须要通过寄存器。32位x86 CPU包括8个通用寄存器,EAX、EBX、ECX、EDX、ESP、EBP、ESI、EDI,其中EBP、ESP用做指针寄存器,存放堆栈内存储单元的偏移量。这些看不懂没关系,这也不是java程序员的重点。我也不懂。
上述++i的例子使用java代码实现如下。
public class AddByteCode {
/**
* ++i问题
*
* @param args
*/
public static void main(String[] args) {
int a = 10;
int result = ++a;
System.out.println(result);
}
}
使用javap命令输出这段代码的字节码如下。
字节码指令前面的编号我们暂时理解为行号。在本例中,行号0到7的字节码指令完成的工作是将变量a自增后的值赋值给result变量。下面将详细分析这几条指令的执行过程:
-
bipush指令是将立即数10放入到操作数栈顶。
-
istore_1指令是将操作数栈顶的元素从操作数栈出弹出,并存放到局部变量表中索引为1的Slot,也就是赋值给变量a。
-
iinc这条字节码指令比较特别,它可以直接操作局部变量表的变量,而不需要经过操作数栈。该指令是将局部变量表中索引为1的Slot所存储的整数值自增1,也就是将局部变量a自增1。
-
iload_1指令是将自增后的变量a放入操作数栈的栈顶。
-
最后,istore_2指令是将当前操作数栈顶的元素从操作数栈弹出,并存放到局部变量表中索引为2的Slot,也就是给result变量赋值。
从++i的例子中,我们可以看出,字节码是依赖操作数栈工作的。在虚拟机上执行的字节码指令虽然最终也是编译为机器码执行,但编写字节码指令时并不需要我们考虑使用哪些寄存器的问题,这些交由JVM去实现。
使用汇编指令编写代码,我们需要考虑CPU的架构,有多少个寄存器可选,了解硬件,需要关心每条指令操作多少个字节,在使用寄存器之前需要考虑是否要备份寄存器的当前值,指令执行完之后是否需要恢复寄存器的值。而使用依赖栈工作的字节码指令编写代码,我们只需要关心每条字节码指令需要多少个参数,按顺序将参数push到操作数栈顶。如果指令执行完有返回值,操作数栈顶就是返回值。
总结
本文我们从栈溢出的例子出发,了解了栈与线程的关系、栈与栈桢的关系,同时也介绍在多线程项目中如何通过配置-Xss参数调优,降低进程占用的内存,以及如何通过NAT工具查看进程使用的内存情况。在理解栈桢之后又重点分析局部变量表与操作数栈,回顾栈溢出的例子,理解一个栈桢的大小也与这两者有很大的关系。最后通过++i的例子列举了汇编指令与字节码指令在架构上的不同,简单分析字节码解释执行的过程。本文介绍的栈、栈桢、局部变量表与操作数栈是后续学习Java字节码的基础知识。