递归引发的jvm栈溢出的理解--堆和栈的概念整理


最近一段时间,在登月项目中接触到一个涉及数据对比的工具,需要对hdfs(Hadoop分布式文件系统)上的一些原始数据进行按行解析,并重新保存成可被hive(基于Hadoop的一个数据仓库工具)识别的数据文件。作为一个复杂度不高的应用MR并行计算框架的工具,设计制作过程还是很顺利的,两三天的功夫编码完成,自测也通过了,然而上线使用后,却发生了一个意想不到的bug

1、程序说明:

事 情是这样的,用户的需求是希望将某个路径作为参数传递给工具,然后工具可以遍历该目录下的所有子目录和文件,将所有数据文件进行解析转换。对于这样一个需 求,最常规的思路是做一个递归函数,遇到文件时处理文件,遇到目录时则进行递归,这样很快就可以把某个路径下的所有子目录和文件都遍历一遍,程序也会显得 简洁明了。代码一般如下:

private void recursion (Path path) {

   FileStatus[] children = fs.listStatus (path);

   for(FileStatus child : children){

if(child.isDir()){

   recursion(child.getPath());

   }

else{

    …… //执行文件处理代码

   }

     }

}

这样一段程序在我个人自测阶段也没有发现什么问题,但是放到云梯上实际使用的时候问题就来了——栈溢出了。工具抛出了*Error异常。

当用户告知出现这个问题的时候,一刹那间我曾经通读过的DistCP的源码立即在我脑海中闪现了出来,曾经不理解为何要那么写的一段代码,此时此刻我终于恍然大悟。这个问题的根源在于hdfs文件系统的目录层次太深了,因此每一层递归累积起来终于将jvm的栈空间撑爆了。自测阶段之所以没有暴露出问题,完全是因为云梯线上的目录文件树在一个小集群中很难模拟。

解决这个问题是非常简单的,只需要将递归算法替换成迭代算法就可以了。修改后的代码如下:

Stackpathstack = new Stack();

for(pathstack.push(fs.getFileStatus(path));  !pathstack.empty();){

           FileStatus cur = pathstack.pop();

           FileStatus[] children = fs.listStatus(cur.getPath());

           for(int i = 0; i < children.length; i++) {

            final FileStatus child = children[i];

            if (child.isDir()) {

             pathstack.push(child);

                        }

                  else {

                        …… //执行文件处理代码

                          }

            }

   }

问题虽然解决了,但对jvm堆栈方面技术需要重新审视深究,于是我顺便查了些资料学习了一下。众所周知,堆是有序完全二叉树,栈是一种先进后出的线性表,栈的特点是速度快,jvm的压栈和出栈操作都是非常高效的(相对来说,堆的二叉树遍历是要比先进后出线性表要慢的)。

“每一个Java应用都唯一对应一个JVM实例,每一个实例唯一对应一个堆。应用程序在运行中所创建的所有类实例或数组都放在这个堆中,并由应用所有的线程共享.跟C/C++不同,Java中分配堆内存是自动初始化的。Java中所有对象的存储空间都是在堆中分配的,但是这个对象的引用却是在栈中分配,也就是说在建立一个对象时从两个地方都分配内存,在堆中分配的内存实际建立这个对象,而在堆栈中分配的内存只是一个指向这个堆对象的指针(引用)而已。”

“JVM堆中存的是对象。JVM栈中存的是基本数据类型和JVM堆中对象的引用。一个对象的大小是不可估计的,或者说是可以动态变化的,但是在JVM栈中,一个对象只对应了一个4btye的引用。”

关于这一点,我制作了下面这段程序来进行证实:

public class StackLevel {

private int level = 1;

public void stackLevel(){

level++;

stackLevel();

}

public static void main(String[]args) throws Throwable{

StackLevel sl = new StackLevel();

try{

sl.stackLevel();

}catch(*Error e){

System.out.println(sl.level);

}

}

}

这段代码执行下来,可以看到默认情况下递归深度是10827(java version "1.6.0_65",系统不同值略有不同)。在stackLevel函数中,增加一个变量(Stringbuf = “”;)之后,再执行一遍,得到的深度为9925。随意的给字符串buf赋值,无论字符串长度是多少,9925这个深度都不会发生变化。而如果再增加一个变量申明,则递归深度又会再次变小。从而证明了栈只存放指针,而堆负责存储这件事情。

对于我们一般的本地化应用来说,遍历目录这样的简单任务,用递归还是最高效的,这不仅是因为算法设计上较为清晰简单,更因为递归利用了栈的高效,程序整体运行速度会比较快。但是对于hdfs这样的文件系统来说,目录的层次深度可能会多达数十甚至数百层,这样一来,使用递归必然会使得函数空间层层堆叠直到导致栈溢出,这也是为什么DistCP中使用了Stack类来规避递归的原因(而我最开始看到这一段的时候只是觉得这样的算法费事不讨巧,完全没有理解其深层次的原因)。此外,对于MR编程框架来说,计算量的增加或者说计算速度的增加到是可以通过增加slot数来进行弥补的,所以,如果真遇到大量需要遍历的应用,合理切分到多个slot中去执行才是提高效率的正道。

有趣的是,不同的语言对递归深度都有不同的解释,我尝试了python和C这两种语言,python代码如下:

global level

level = 1

def stackLevel():

global level

level += 1

stackLevel()

try:

stackLevel()

except RuntimeError:

print level

得到的结果是1000,且无论在函数stackLevel中增加多少个变量,其递归深度都始终是1000。对于python来说递归深度是一个可以设置的值,其默认就是1000,可以通过(sys.setrecursionlimit(递归深度值))来进行设置。但是这个设置只不过是起到一个保护的作用,当设置的深度非常大,以至于超过进程内存空间时,python依然会报出“Segmentation fault: 11”(11是SIGSEGV信号,无法捕获)。

在C里面,递归深度则可以到一个很大的数值,不过最终也会被SIGSEGV信号中断。

由这个问题再引申一下,我对递归的使用更加感觉需要审慎,尤其类似大数据项目的测试中,每一个递归都应该仔细考量其规模,因为大数据的文件系统往往在深 度、广度上都不是普通文件系统可以比拟的,单机上不会出现的问题,到了大数据上都有可能成为问题。再进一步,有了这次的这个经验,更提醒我在将来的coding中,使用递归前也要预估一下可能带来的后果,防止类似的bug重现。
原文:沧海拾贝——一个递归引发的思考(淘测试))
————————————————————————————————————————————————————————
整理:
一、首先纠正自己对堆和栈的概念理解:
1、堆是堆(heap),栈是栈(stack)。
2、堆和栈在内存哪里?
下图是linux 中一个进程的虚拟内存分布:图中0号地址在最下边,越往上内存地址越大。
以 32位地址操作系统为例,一个进程可拥有的虚拟内存地址范围为0-2^32(4G)。分为两部分,一部分留给kernel使用(kernel virtual memory),剩下的是进程本身使用, 即图中的process virtual memory。普通Java 程序使用的就是process virtual memory.
上图中最顶端的一部分内存叫做user stack. 这就是题目问的 stack. 中间有个 runtime heap。就是题目中的heap. 他们的名字和数据结构里的stack 和 heap 几乎没有关系。stack 是向下生长的; heap是向上生长的。
(来源:知乎雷博https://www.zhihu.com/question/29833675/answer/45811216)
递归引发的jvm栈溢出的理解--堆和栈的概念整理
好文章推介:JVM内存模型    什么是堆和栈,它们在哪儿?


3、堆和栈的区别(来源:Java中的堆和栈的区别,英文原文地址:http://javarevisited.blogspot.com.au/2013/01/difference-between-stack-and-heap-java.html.)
(1)作用不同:
栈内存用来存储局部变量和方法调用。
而堆内存用来存储Java中的对象。无论是成员变量,局部变量,还是类变量,它们指向的对象都存储在堆内存中。
(2)独享与共享性
栈内存归属于单个线程,每个线程都会有一个栈内存,其存储的变量只能在其所属线程中可见,即栈内存可以理解成线程的私有内存。
堆内存中的对象对所有线程可见。堆内存中的对象可以被所有线程访问。它通常由某种自动内存管理机制所管理,这种机制通常叫做“垃圾回收”(garbage collection,GC)。
(3)不同异常提示
如果栈内存没有可用的空间存储方法调用和局部变量,JVM会抛出java.lang.*Error。
如果是堆内存没有可用的空间存储生成的对象,JVM会抛出java.lang.OutOfMemoryError。
(4)空间大小不同
栈的内存要远远小于堆内存,如果使用递归的话,那么你的栈很快就会充满。如果递归没有及时跳出,很可能发生*Error问题。
你可以通过-Xss选项设置栈内存的大小。-Xms选项可以设置堆的开始时的大小,-Xmx选项可以设置堆的最大值。

4、对于一个方法,内存调用是这样的
递归引发的jvm栈溢出的理解--堆和栈的概念整理
(图来源:知乎Gilgamesh,地址:https://www.zhihu.com/question/29833675/answer/45831313)


我对图的理解:
(1)首先在栈中存储局部变量i和y
(line1和Line2)
(2)然后是最开始文章中说的:“应用程序在运行中所创建的所有类实例或数组都放在堆中,并由应用所有的线程共享。Java中所有对象的存储空间都是在堆中分配的,但是这个对象的引用却是在栈中分配”"在建立一个对象时从两个地方都分配内存,在堆中分配的内存实际建立这个对象,而在堆栈中分配的内存只是一个指向这个堆对象的指针(引用)而已。”这个就是line3边上小图的意思,在堆中创建了一个cls1的对象,然后在栈中放了一个引用。
(4)最后line4:当超过变量的作用域后,Java会自动释放掉为该变量所分配的内存空间,栈里面就被清空了,而堆中的内容还在,直到被GC清理掉。可能这个就是Java特别消耗内存的原因?

5、至此,再看递归引起的栈溢出,就是如果一个程序结束了,那么栈中的变量是会被清空的,但是递归中的变量值并没有被清空,每调用一次,函数的参数、局部变量等信息就压一次栈当递归的层级比较深时,最终导致栈溢出。

三、监控JVM内存溢出的工具与方法:
1、Android中内存溢出的监测工具DDMS
DDMS 的全称是Dalvik Debug Monitor Service,详细使用参考文章:http://blog.csdn.net/chuxing/article/details/7415796

2、其他工具以及监测指标说明,可以参考文章:http://www.cnblogs.com/redcreen/archive/2011/05/09/2040977.html