遞歸引發的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{
    …… //執行文件處理代碼
   }
     }
}

這樣一段程序在我個人自測階段也沒有發現什麼問題,但是放到雲梯上實際使用的時候問題就來了——棧溢出了。工具拋出了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、堆和棧在內存哪裏?
下圖是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
這裏寫圖片描述
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.csdn.net/chuxing/article/details/7415796

2、其他工具以及監測指標說明,可以參考文章:http://www.cnblogs.com/redcreen/archive/2011/05/09/2040977.html

轉自:http://blog.csdn.net/typing_yes_no/article/details/50961559

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章