1、程序说明:
事 情是这样的,用户的需求是希望将某个路径作为参数传递给工具,然后工具可以遍历该目录下的所有子目录和文件,将所有数据文件进行解析转换。对于这样一个需 求,最常规的思路是做一个递归函数,遇到文件时处理文件,遇到目录时则进行递归,这样很快就可以把某个路径下的所有子目录和文件都遍历一遍,程序也会显得 简洁明了。代码一般如下:
private void recursion (Path path) { FileStatus[] children = fs.listStatus (path); for(FileStatus child : children){ if(child.isDir()){ recursion(child.getPath()); } else{ …… //执行文件处理代码 } } } |
这样一段程序在我个人自测阶段也没有发现什么问题,但是放到云梯上实际使用的时候问题就来了——栈溢出了。工具抛出了StackOverflowError异常。
当用户告知出现这个问题的时候,一刹那间我曾经通读过的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(StackOverflowError 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、堆和栈在内存哪里?
以 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是向上生长的。
好文章推介: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.StackOverFlowError。
如果是堆内存没有可用的空间存储生成的对象,JVM会抛出java.lang.OutOfMemoryError。
(4)空间大小不同
栈的内存要远远小于堆内存,如果使用递归的话,那么你的栈很快就会充满。如果递归没有及时跳出,很可能发生StackOverFlowError问题。
你可以通过-Xss选项设置栈内存的大小。-Xms选项可以设置堆的开始时的大小,-Xmx选项可以设置堆的最大值。
4、对于一个方法,内存调用是这样的
(图来源:知乎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.youkuaiyun.com/chuxing/article/details/7415796
2、其他工具以及监测指标说明,可以参考文章:http://www.cnblogs.com/redcreen/archive/2011/05/09/2040977.html