Java內存問題的一些見解
在Java中,內存泄露和其他內存相關問題在性能和可擴展性方面表現的最為突出。我們有充分的理由去詳細地討論他們。
Java內存模型——或者更確切的說垃圾回收器——已經解決了許多內存問題。然而同時,也帶來了新的問題。特別是在有著大量并行用戶的J2EE運行 環(huán)境下,內存越來越成為一種至關重要的資源。乍看之下,這似乎有些奇怪,因為當前內存已經足夠廉價,并且我們也有了64位的JVM和更先進的垃圾回收算 法。
接下來,我們將會仔細的討論一下關于Java內存的問題。這些問題可以分為四組:
-
在Java中,內存泄露一般都是由于引用對象不再被使用而造成的。當有多個引用的對象,同時這些對象又不再需要,然而開發(fā)者又忘記清理它們,這時極容易導致內存泄露的發(fā)生。
-
執(zhí)行消耗太多的內存而導致不必要的高內存占用。這在為了用戶體驗而管理大量狀態(tài)信息的 Web 應用中很常見。隨著活躍用戶數量的增加,內存很快到達了上限。未綁定或低效緩存配置是持續(xù)高內存占用的另一來源。
-
當用戶負載增加時,低效的對象創(chuàng)建容易導致性能問題。從而垃圾回收器必須不斷地清理堆內存。而這導致了垃圾回收器對CPU產生了不必要的高占用。 隨著CPU因垃圾回收而被阻塞,應用程序響應時間頻繁的增加,導致其一直處于中等負載之下。這種行為也成為“GC trashing”。
-
低效的垃圾回收行為往往是由于垃圾回收器的缺失或者錯誤的配置。這些垃圾回收器將會時刻追蹤對象是否被清理。然而這種行為如何以及何時發(fā)生必須由配置或者程序員,或者系統架構師決定的。通常,人們只是簡單地“忘記”了正確的配置和優(yōu)化垃圾回收器。我曾參加過一些關于“性能”的專題討論會,發(fā)現一個簡單的參數變化將會導致高達25%的性能提升。
在大多數情況下,內存問題不僅影響性能,還會影響可擴展性。每次請求消耗的內存數量越高,用戶或Session可以執(zhí)行的并行事務就越少。在某些情 況下內存問題也影響可用性。當JVM耗盡了內存或者即將接近內存極限,這個時候它將退出并報OutOfMemory錯誤。這時經理會來到你的辦公室,你就 知道自己攤上大事了。
內存問題很難被解決通常有兩個原因: 第一,某些情況下分析很復雜,也很困難,特別是如果你缺少正確的方法來解決他們;其次,他們通常是應用程序的架構基礎。簡單的代碼更改不會幫助解決他們。
為了使開發(fā)過程更容易,我會展示一些實際應用中常被使用的反模式。這些模式已經能夠在開發(fā)過程中避免內存問題。
HTTPSession作為緩存
此反模式是指濫用HTTPSession對象作為數據緩存。session對象的存在是為了存儲信息,這個信息里面存在著一個HTTP請求。這也稱 為一個Session狀態(tài)。這意味著,數據將被保存直至它們被處理。這些方法通常存在于一些重要的web應用程序中。web應用程序除了在服務器上存儲這 些信息外,沒有別的方法。然而,一些信息是能夠存儲在cookie中,但是這將會帶來一些其他的影響。
在cookie中,盡可能地保持少而短的數據,這是非常重要的。有時候很容易發(fā)生這種現象,session里存儲著成兆字節(jié)的數據對象。這將會立即 導致堆棧高占用和內存短缺。同時并行用戶的數量非常有限,JVM將應對越來越多出現OutOfMemoryError錯誤的用戶。多數用戶Session 也有其他性能損失。集群場景的session復制中,這將會增加序列化和溝通工作將導致額外的性能和可伸縮性問題。
在某些項目中這些問題的解決方案是增加數量的內存和切換到64位jvm。他們無法抵抗住僅僅增加幾個G大小的堆棧內存的誘惑。然而,與其提供一個對 真正問題的解決方案,不如隱藏這個現象。這個“解決方案”只是暫時的,同時還會引入了一個新的問題。越來越大的堆內存使它更難以找到“真正的”內存問題。 對這種非常大的堆(大約6G)來說,大部分可用的分析工具是無法處理這些內存垃圾。我們在dynaTrace投入了大量的研發(fā)工作希望能夠有效地分析大量 的內存垃圾。隨著這個問題變得越來越重要,一種新的JSR規(guī)范也提到了它。
由于應用程序架構尚未明確,導致Session緩存問題經常出現,。在開發(fā)過程中,數據被輕松而又簡單的放入session當中。這是經常發(fā)生的, 類似于一種“add and forget”方式,即沒有人能夠確保當這種數據不再需要時是被移除的。通常,當session超時時不需要的 session數據應該被處理。在企業(yè)中,一些應用程序常常大量使用Session超時,這將會導致無法正常工作。此外經常使用非常高的Session超 時- 24小時為用戶提供額外的“體驗”,使他們不必再次登錄。
舉一個實際的例子,從session里的數據庫列表中選擇所需要的數據。其目的是為了避免不必要的數據庫查詢。(是不是覺得有點過早優(yōu)化呢?)。這 將導致在session對象中為每個單獨的用戶放入幾千個字節(jié)。雖然,緩存這些信息它是合理的,但用戶session可以肯定是一個錯誤的地方。
另外一個例子是,為了管理Session狀態(tài)而濫用Hibernate session。Hibernatesession對象只是為了快速訪問數據庫而放入HTTPsession對象中。然而,這將導致更多必要的數據被存儲。同時每個用戶的內存占用也將顯著提高。
現如今,AJAX應用程序Session狀態(tài)也可以在客戶端進行管理。這使服務端程序變成無狀態(tài)的,或接近無狀態(tài)的,同時也顯然有著更好的可擴展性。
線程本地變量內存泄露
在Java中使用ThreadLocal變 量是為了在一個特定的線程中綁定變量。這意味著每個線程都有它自己的單獨實例。這種方法一般在一個線程中用于處理狀態(tài)信息,例如用戶授權。然而,一個 ThreadLocal變量的生命周期與另外一個線程的生命周期是息息相關的。被遺忘的ThreadLocal變量很容易導致內存問題,尤其是在應用服務 器中。
如果忘記了設置ThreadLocal變量,尤其是在應用服務器中,這很容易導致內存問題。應用服務器利用線程池避免常量不斷創(chuàng)建和線程銷毀。舉個 例子,一個HTTPServletRequest類在運行時得到一個空閑的已分配的線程,在執(zhí)行完后將它回傳到線程池中。如果應用程序邏輯使用 ThreadLocal變量和忘記了顯式地移除它們,這時,內存是不會被釋放的。
根據線程池大小——在程序系統中這些線程池可以是幾百個線程。同時,由ThreadLocal變量引用的對象的大小,這可能導致一些問題。例如,在 最壞的情況下,一個200個線程的線程池和一個5M大小的線程池將會導致1 GB的不必要的內存占用。這將立即導致強烈的垃圾回收反應,同時導致糟糕的響 應時間和潛在的OutOfMemoryError錯誤。
一個實際的例子就是在JBossWS 1.2.0版本中出現的一個bug(在JBossWS1.2.1版本已經被修復)—— “DOMUtils doesn’t clear thread locals”。此問題就是ThreadLocal變量導致的,它引用了一個14MB的 解析文檔。
大型臨時對象
大型臨時對象在最壞的情況下也能導致outofmemoryerror錯誤或者至少強烈的GC反應。例如,如果非常大的文檔(XML、PDF、圖 片…)必須閱讀和處理時。在一個特定的情況下,應用程序幾分鐘都沒有響應或性能非常有限,幾乎沒有可用的。其中根本原因是垃圾回收反應過于強烈。下面對讀 取PDF文檔的一段代碼作了詳細分析:
- byte tmpData[] = new byte [1024];
- int offs = 0;
- do{
- int readLen = bis.read (tmpData, offs, tmpData.length - offs);
- if (readLen == -1)
- break;
- offs+= readLen;
- if (oofs == tmpData.length){
- byte newres[] = new byte[tmpData.length + 1024];
- System.arraycopy(tmpData, 0, newres, 0, tmpData.length);
- tmpData = newres;
- }
- } while (true);
這些文檔采用按固定字節(jié)數的方式來讀取。首先,他們被讀入中字節(jié)數組中,然后發(fā)送到用戶的瀏覽器中。然而僅僅幾個并行請求將會導致堆溢出。由于讀取 文檔采用了極其低效的算法,這將導致問題越來越糟糕。最初的想法只是創(chuàng)建1KB的初始字節(jié)數組。如果這個數組滿了,則一個新的1KB數組將被創(chuàng)建,同時這 個老的數組將拷貝到新的數組中。這意味著當讀取文檔時,一個新數組將被創(chuàng)建,同時將讀取的每字節(jié)都復制到新數組中。這將導致大量的臨時對象和兩倍于實際數 據大小的內存消耗——數據將永久被復制。
在處理大量數據時,優(yōu)化處理邏輯性能是至關重要的。在這種情況下,一個簡單的負載測試會顯示這一問題。
糟糕的垃圾回收器配置
到目前為止,在所提到的情境中出現的問題基本都是由應用程序代碼所導致的。然而,這些原因的根源是由于垃圾回收器配置錯誤,或者丟失。我常??吹接?戶相信他們的應用程序服務器的默認設置,同時也相信應用服務器的開發(fā)者了解哪些是自己的程序最好的。無論如何,堆的配置很大程度上取決于應用程序和實際使 用場景。根據場景來調整參數,應用程序才能更好地執(zhí)行。和一批執(zhí)行長期任務的應用程序相比,一個執(zhí)行大量短而持久的應用程序配置起來是完全不同的。此外, 實際的配置還取決于JVM使用情況。對IBM來說,什么才能使Sun Jvm正常運行可能是一場噩夢(或至少是不理想的)。配置錯誤的垃圾收集器通常不會 立即被確認為性能問題的根源(除非你監(jiān)控了垃圾收集器的活動)。通常我們肉眼可見的問題都是響應過慢。同時,理解垃圾回收活動與響應時間的關系也是不明顯 的。如果垃圾回收的時間與響應時間沒什么關聯,人們通常會發(fā)現一個非常復雜的性能問題。響應時間和執(zhí)行時間度量問題主要體現在應用程序——對于這種現象, 在不同的地方都沒有一個明顯的模式。
下圖顯示了事務指標與垃圾收集時間在dynaTrace中的關系。我發(fā)現了一些情況,關于垃圾回收器的優(yōu)化問題。人們正打算花幾周的時間去解決如何在幾分鐘內設置解決性能問題。
類加載器內存泄露
在談到內存泄漏時,大部分人主要認為是堆中的對象。除了對象,類和常量也是托管在堆中。根據JVM,它們被放入堆中特定的區(qū)域。例如Sun JVM 使用所謂的永久代或PermGen。通常情況下,類被放入堆中好幾次。僅僅是因為他們已經被不同的類加載器加載。在現代化企業(yè)級應用程序中,加載類的內存 占用能夠達到幾百MB。
關鍵是避免無謂地增加類的大小。一個很好的例子是大量字符串常量的定義——例如在GUI應用程序中。這里所有的文本通常存儲在常量。而使用常量字符 串的方法原則上是一個好的設計方法,內存消耗不應該被忽視。在真實的情況下,在一個國際化應用程序中,所有常量都會被定義為各種語言。一個很不起眼的代碼 錯誤都會影響到已經被加載的類。最終的結果是,在應用程序的永久代中,JVM將出現OutOfMemoryError 錯誤,同時崩潰。
應用服務器還面臨著類加載器泄漏的問題。這些泄漏的原因主要是因為類加載器不能被垃圾回收,因為類加載器中的類的一個對象仍然活著。結果,這些類并 不打算釋放這些內存占用。而現在,這個問題已經被J2EE 應用程序服務器很好的解決了,它似乎更常出現在OSGI-based應用程序環(huán)境。
總結
在Java應用程序中內存問題通常是多方面的,這容易導致性能和可擴展性的問題。特別是在有著大量并行用戶的J2EE應用程序中,內存管理必須是應 用程序體系結構的核心部分。然而垃圾回收器對于那些未使用的對象是否被清理并不關心,所以開發(fā)人員還是需要適當的內存管理。此外,應用程序內存管理設計是 應用程序配置的核心部分。
你的經驗
這些都是我在現實世界應用程序中發(fā)現的反模式。如果你有額外的反模式或共同的問題,我很愿意更多地了解他們。
關于作者
這篇文章基于我和codecentric的作者Mirko Novakovic共同研究的性能反模式系列。
其他感興趣的博客
由于性能反模式是我的愛好,我將定期發(fā)布關于反模式的帖子。它們將會從這些帖子里選擇你們可能感興趣的一篇帖子。
如果你想要了解更多關于如何解決像本文中提到的內存問題,和其他一些java運行壞境相關的問題,你也許對我同事最近一本關于持續(xù)應用程序性能管理的白皮書感興趣。