看Java中對象引用如何嚴重影響垃圾收集器(1)

如果您認爲 Java 遊戲開發人員是 Java 編程世界的一級方程式賽車手,那麼您就會明白爲什麼他們會如此地重視程序的性能。遊戲開發人員幾乎每天都要面對的性能問題,往往超過了一般程序員考慮問題的範圍。哪裏可以找到這些特殊的開發人員呢?Java 遊戲社區就是一個好去處。雖然在這個站點可能沒有很多關於服務器端的應用,但是我們依然可以從中受益,看看這些“惜比特如金”的遊戲開發人員每天所面對的,我們往往能從中得到寶貴的經驗。讓我們開始遊戲吧!

對象泄漏

遊戲程序員跟其他程序員一樣――他們也需要理解Java 運行時環境的一些微妙之處,比如垃圾收集。垃圾收集可能是使您感到難於理解的較難的概念之一,因爲它並不能總是毫無遺漏地解決 Java 運行時環境中堆管理的問題。似乎有很多類似這樣的討論,它的開頭或結尾寫着:“我的問題是關於垃圾收集”。

假如您正面遭遇內存耗盡(out-of-memory)的錯誤。於是您使用檢測工具想要找到問題所在,但這是徒勞的。您很容易想到另外一個比較可信的原因:這是Java 虛擬機堆管理的問題,而不會認爲這是您自己的程序的緣故。但是,正如Java 遊戲社區的資深專家不止一次地解釋的,Java 虛擬機並不存在任何被證實的對象泄漏問題。實踐證明,垃圾收集器一般能夠精確地判斷哪些對象可被收集,並且重新收回它們的內存空間給 Java 虛擬機。所以,如果您遇到了內存耗盡的錯誤,那麼這完全可能是由您的程序造成的,也就是說您的程序中存在着“無意識的對象保留(unintentional object retention)”。

內存泄漏與無意識的對象保留

內存泄漏和無意識的對象保留的區別是什麼呢?對於用Java 語言編寫的程序來說,確實沒有區別。兩者都是指在您的程序中存在一些對象引用,但實際上您並不需要引用這些對象。一個典型的例子是向一個集合中加入一些對象以便以後使用它們,但是您卻忘了在使用完以後從集合中刪除這些對象。因爲集合可以無限制地擴大,並且從來不會變小,所以當您在集合中加入了太多的對象(或者是有很多的對象被集合中的元素所引用)時,您就會因爲堆的空間被填滿而導致內存耗盡的錯誤。垃圾收集器不能收集這些您認爲已經用完的對象,因爲對於垃圾收集器來說,應用程序仍然可以通過這個集合在任何時候訪問這些對象,所以這些對象是不可能被當作垃圾的。

對於沒有垃圾收集的語言來說,例如 C++ ,內存泄漏和無意識的對象保留是有區別的。C++ 程序跟 Java 程序一樣,可能產生無意識的對象保留。但是 C++ 程序中存在真正的內存泄漏,即應用程序無法訪問一些對象以至於被這些對象使用的內存無法釋放且返還給系統。令人欣慰的是,在 Java 程序中,這種內存泄漏是不可能出現的。所以,我們更喜歡用“無意識的對象保留”來表示這個令 Java 程序員抓破頭皮的內存問題。這樣,我們就能區別於其他使用沒有垃圾收集語言的程序員。

跟蹤被保留的對象

那麼,當發現了無意識的對象保留該怎麼辦呢?首先,需要確定哪些對象是被無意保留的,並且需要找到究竟是哪些對象在引用它們。然後,必須安排好應該在哪裏釋放它們。最容易的方法是使用能夠對堆產生快照的檢測工具來標識這些對象,比較堆的快照中對象的數目,跟蹤這些對象,找到引用這些對象的對象,然後強制進行垃圾收集。有了這樣一個檢測器,接下來的工作相對而言就比較簡單了。

等待直到系統達到一個穩定的狀態,這個狀態下大多數新產生的對象都是暫時的,符合被收集的條件;這種狀態一般在程序所有的初始化工作都完成了之後。

強制進行一次垃圾收集,並且對此時的堆做一份對象快照。

進行任何可以產生無意地保留的對象的操作。

再強制進行一次垃圾收集,然後對系統堆中的對象做第二次對象快照。

比較兩次快照,看看哪些對象的被引用數量比第一次快照時增加了。因爲您在快照之前強制進行了垃圾收集,那麼剩下的對象都應該是被應用程序所引用的對象,並且通過比較兩次快照我們可以準確地找出那些被程序保留的、新產生的對象。

根據您對應用程序本身的理解,並且根據對兩次快照的比較,判斷出哪些對象是被無意保留的。

跟蹤這些對象的引用鏈,找出究竟是哪些對象在引用這些無意地保留的對象,直到您找到了那個根對象,它就是產生問題的根源。

顯式地賦空(nulling)變量

一談到垃圾收集這個主題,總會涉及到這樣一個吸引人的討論,即顯式地賦空變量是否有助於程序的性能。賦空變量是指簡單地將 null 值顯式地賦值給這個變量,相對於讓該變量的引用失去其作用域。

清單1:局部作用域

public static String scopingExample(String string) { StringBuffer sb = new StringBuffer(); sb.append("hello ").append(string); sb.append(", nice to see you!"); return sb.toString(); }

當該方法執行時,運行時棧保留了一個對 StringBuffer 對象的引用,這個對象是在程序的第一行產生的。在這個方法的整個執行期間,棧保存的這個對象引用將會防止該對象被當作垃圾。當這個方法執行完畢,變量 sb 也就失去了它的作用域,相應地運行時棧就會刪除對該 StringBuffer 對象的引用。於是不再有對該 StringBuffer 對象的引用,現在它就可以被當作垃圾收集了。棧刪除引用的操作就等於在該方法結束時將 null 值賦給變量 sb。

錯誤的作用域

既然 Java 虛擬機可以執行等價於賦空的操作,那麼顯式地賦空變量還有什麼用呢?對於在正確的作用域中的變量來說,顯式地賦空變量的確沒用。但是讓我們來看看另外一個版本的 scopingExample 方法,這一次我們將把變量 sb 放在一個錯誤的作用域中。

清單2:靜態作用域

static StringBuffer sb = new StringBuffer(); public static String scopingExample(String string) { sb = new StringBuffer(); sb.append("hello ").append(string); sb.append(", nice to see you!"); return sb.toString(); }

現在 sb 是一個靜態變量,所以只要它所在的類還裝載在 Java 虛擬機中,它也將一直存在。該方法執行一次,一個新的 StringBuffer 將被創建並且被 sb 變量引用。在這種情況下,sb 變量以前引用的 StringBuffer 對象將會死亡,成爲垃圾收集的對象。也就是說,這個死亡的 StringBuffer 對象被程序保留的時間比它實際需要保留的時間長得多――如果再也沒有對該 scopingExample 方法的調用,它將會永遠保留下去。


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