Java記憶體問題的一些見解

ImportNew發表於2015-08-22

在Java中,記憶體洩露和其他記憶體相關問題在效能和可擴充套件性方面表現的最為突出。我們有充分的理由去詳細地討論他們。

Java記憶體模型——或者更確切的說垃圾回收器——已經解決了許多記憶體問題。然而同時,也帶來了新的問題。特別是在有著大量並行使用者的J2EE執行環境下,記憶體越來越成為一種至關重要的資源。乍看之下,這似乎有些奇怪,因為當前記憶體已經足夠廉價,並且我們也有了64位的JVM和更先進的垃圾回收演算法。

接下來,我們將會仔細的討論一下關於Java記憶體的問題。這些問題可以分為四組:

  • 在Java中,記憶體洩露一般都是由於引用物件不再被使用而造成的。當有多個引用的物件,同時這些物件又不再需要,然而開發者又忘記清理它們,這時極容易導致記憶體洩露的發生。
  • 執行消耗太多的記憶體而導致不必要的高記憶體佔用。這在為了使用者體驗而管理大量狀態資訊的 Web 應用中很常見。隨著活躍使用者數量的增加,記憶體很快到達了上限。未繫結或低效快取配置是持續高記憶體佔用的另一來源。
  • 當使用者負載增加時,低效的物件建立容易導致效能問題。從而垃圾回收器必須不斷地清理堆記憶體。而這導致了垃圾回收器對CPU產生了不必要的高佔用。隨著CPU因垃圾回收而被阻塞,應用程式響應時間頻繁的增加,導致其一直處於中等負載之下。這種行為也成為“GC trashing”。
  • 低效的垃圾回收行為往往是由於垃圾回收器的缺失或者錯誤的配置。這些垃圾回收器將會時刻追蹤物件是否被清理。然而這種行為如何以及何時發生必須由配置或者程式設計師,或者系統架構師決定的。通常,人們只是簡單地“忘記”了正確的配置和優化垃圾回收器。我曾參加過一些關於“效能”的專題討論會,發現一個簡單的引數變化將會導致高達25%的效能提升。

在大多數情況下,記憶體問題不僅影響效能,還會影響可擴充套件性。每次請求消耗的記憶體數量越高,使用者或Session可以執行的並行事務就越少。在某些情況下記憶體問題也影響可用性。當JVM耗盡了記憶體或者即將接近記憶體極限,這個時候它將退出並報OutOfMemory錯誤。這時經理會來到你的辦公室,你就知道自己攤上大事了。

記憶體問題很難被解決通常有兩個原因: 第一,某些情況下分析很複雜,也很困難,特別是如果你缺少正確的方法來解決他們;其次,他們通常是應用程式的架構基礎。簡單的程式碼更改不會幫助解決他們。

為了使開發過程更容易,我會展示一些實際應用中常被使用的反模式。這些模式已經能夠在開發過程中避免記憶體問題。

HTTPSession作為快取

此反模式是指濫用HTTPSession物件作為資料快取。session物件的存在是為了儲存資訊,這個資訊裡面存在著一個HTTP請求。這也稱為一個Session狀態。這意味著,資料將被儲存直至它們被處理。這些方法通常存在於一些重要的web應用程式中。web應用程式除了在伺服器上儲存這些資訊外,沒有別的方法。然而,一些資訊是能夠儲存在cookie中,但是這將會帶來一些其他的影響。

在cookie中,儘可能地保持少而短的資料,這是非常重要的。有時候很容易發生這種現象,session裡儲存著成兆位元組的資料物件。這將會立即導致堆疊高佔用和記憶體短缺。同時並行使用者的數量非常有限,JVM將應對越來越多出現OutOfMemoryError錯誤的使用者。多數使用者Session也有其他效能損失。叢集場景的session複製中,這將會增加序列化和溝通工作將導致額外的效能和可伸縮性問題。

在某些專案中這些問題的解決方案是增加數量的記憶體和切換到64位jvm。他們無法抵抗住僅僅增加幾個G大小的堆疊記憶體的誘惑。然而,與其提供一個對真正問題的解決方案,不如隱藏這個現象。這個“解決方案”只是暫時的,同時還會引入了一個新的問題。越來越大的堆記憶體使它更難以找到“真正的”記憶體問題。對這種非常大的堆(大約6G)來說,大部分可用的分析工具是無法處理這些記憶體垃圾。我們在dynaTrace投入了大量的研發工作希望能夠有效地分析大量的記憶體垃圾。隨著這個問題變得越來越重要,一種新的JSR規範也提到了它。

由於應用程式架構尚未明確,導致Session快取問題經常出現,。在開發過程中,資料被輕鬆而又簡單的放入session當中。這是經常發生的,類似於一種“add and forget”方式,即沒有人能夠確保當這種資料不再需要時是被移除的。通常,當session超時時不需要的session資料應該被處理。在企業中,一些應用程式常常大量使用Session超時,這將會導致無法正常工作。此外經常使用非常高的Session超時- 24小時為使用者提供額外的“體驗”,使他們不必再次登入。

舉一個實際的例子,從session裡的資料庫列表中選擇所需要的資料。其目的是為了避免不必要的資料庫查詢。(是不是覺得有點過早優化呢?)。這將導致在session物件中為每個單獨的使用者放入幾千個位元組。雖然,快取這些資訊它是合理的,但使用者session可以肯定是一個錯誤的地方。

另外一個例子是,為了管理Session狀態而濫用Hibernate session。Hibernatesession物件只是為了快速訪問資料庫而放入HTTPsession物件中。然而,這將導致更多必要的資料被儲存。同時每個使用者的記憶體佔用也將顯著提高。

現如今,AJAX應用程式Session狀態也可以在客戶端進行管理。這使服務端程式變成無狀態的,或接近無狀態的,同時也顯然有著更好的可擴充套件性。

執行緒本地變數記憶體洩露

在Java中使用ThreadLocal變數是為了在一個特定的執行緒中繫結變數。這意味著每個執行緒都有它自己的單獨例項。這種方法一般在一個執行緒中用於處理狀態資訊,例如使用者授權。然而,一個ThreadLocal變數的生命週期與另外一個執行緒的生命週期是息息相關的。被遺忘的ThreadLocal變數很容易導致記憶體問題,尤其是在應用伺服器中。

如果忘記了設定ThreadLocal變數,尤其是在應用伺服器中,這很容易導致記憶體問題。應用伺服器利用執行緒池避免常量不斷建立和執行緒銷燬。舉個例子,一個HTTPServletRequest類在執行時得到一個空閒的已分配的執行緒,在執行完後將它回傳到執行緒池中。如果應用程式邏輯使用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);

這些文件採用按固定位元組數的方式來讀取。首先,他們被讀入中位元組陣列中,然後傳送到使用者的瀏覽器中。然而僅僅幾個並行請求將會導致堆溢位。由於讀取文件採用了極其低效的演算法,這將導致問題越來越糟糕。最初的想法只是建立1KB的初始位元組陣列。如果這個陣列滿了,則一個新的1KB陣列將被建立,同時這個老的陣列將拷貝到新的陣列中。這意味著當讀取文件時,一個新陣列將被建立,同時將讀取的每位元組都複製到新陣列中。這將導致大量的臨時物件和兩倍於實際資料大小的記憶體消耗——資料將永久被複制。

在處理大量資料時,優化處理邏輯效能是至關重要的。在這種情況下,一個簡單的負載測試會顯示這一問題。

糟糕的垃圾回收器配置

到目前為止,在所提到的情境中出現的問題基本都是由應用程式程式碼所導致的。然而,這些原因的根源是由於垃圾回收器配置錯誤,或者丟失。我常常看到使用者相信他們的應用程式伺服器的預設設定,同時也相信應用伺服器的開發者瞭解哪些是自己的程式最好的。無論如何,堆的配置很大程度上取決於應用程式和實際使用場景。根據場景來調整引數,應用程式才能更好地執行。和一批執行長期任務的應用程式相比,一個執行大量短而持久的應用程式配置起來是完全不同的。此外,實際的配置還取決於JVM使用情況。對IBM來說,什麼才能使Sun Jvm正常執行可能是一場噩夢(或至少是不理想的)。配置錯誤的垃圾收集器通常不會立即被確認為效能問題的根源(除非你監控了垃圾收集器的活動)。通常我們肉眼可見的問題都是響應過慢。同時,理解垃圾回收活動與響應時間的關係也是不明顯的。如果垃圾回收的時間與響應時間沒什麼關聯,人們通常會發現一個非常複雜的效能問題。響應時間和執行時間度量問題主要體現在應用程式——對於這種現象,在不同的地方都沒有一個明顯的模式。

下圖顯示了事務指標與垃圾收集時間在dynaTrace中的關係。我發現了一些情況,關於垃圾回收器的優化問題。人們正打算花幾周的時間去解決如何在幾分鐘內設定解決效能問題。

類載入器記憶體洩露

在談到記憶體洩漏時,大部分人主要認為是堆中的物件。除了物件,類和常量也是託管在堆中。根據JVM,它們被放入堆中特定的區域。例如Sun JVM使用所謂的永久代或PermGen。通常情況下,類被放入堆中好幾次。僅僅是因為他們已經被不同的類載入器載入。在現代化企業級應用程式中,載入類的記憶體佔用能夠達到幾百MB。

關鍵是避免無謂地增加類的大小。一個很好的例子是大量字串常量的定義——例如在GUI應用程式中。這裡所有的文字通常儲存在常量。而使用常量字串的方法原則上是一個好的設計方法,記憶體消耗不應該被忽視。在真實的情況下,在一個國際化應用程式中,所有常量都會被定義為各種語言。一個很不起眼的程式碼錯誤都會影響到已經被載入的類。最終的結果是,在應用程式的永久代中,JVM將出現OutOfMemoryError 錯誤,同時崩潰。

應用伺服器還面臨著類載入器洩漏的問題。這些洩漏的原因主要是因為類載入器不能被垃圾回收,因為類載入器中的類的一個物件仍然活著。結果,這些類並不打算釋放這些記憶體佔用。而現在,這個問題已經被J2EE 應用程式伺服器很好的解決了,它似乎更常出現在OSGI-based應用程式環境。

總結

在Java應用程式中記憶體問題通常是多方面的,這容易導致效能和可擴充套件性的問題。特別是在有著大量並行使用者的J2EE應用程式中,記憶體管理必須是應用程式體系結構的核心部分。然而垃圾回收器對於那些未使用的物件是否被清理並不關心,所以開發人員還是需要適當的記憶體管理。此外,應用程式記憶體管理設計是應用程式配置的核心部分。

你的經驗

這些都是我在現實世界應用程式中發現的反模式。如果你有額外的反模式或共同的問題,我很願意更多地瞭解他們。

關於作者

這篇文章基於我和codecentric的作者Mirko Novakovic共同研究的效能反模式系列。

其他感興趣的部落格

由於效能反模式是我的愛好,我將定期釋出關於反模式的帖子。它們將會從這些帖子裡選擇你們可能感興趣的一篇帖子。

如果你想要了解更多關於如何解決像本文中提到的記憶體問題,和其他一些java執行壞境相關的問題,你也許對我同事最近一本關於持續應用程式效能管理的白皮書感興趣。

相關文章