Java 堆(每個 Java 對象在其中分配)是您在編寫 Java 應用程序時使用最頻繁的內存區域。JVM
設計用于將我們與主機的特性隔離,所以將內存當作堆來考慮再正常不過了。您一定遇到過 Java 堆 OutOfMemoryError
,
它可能是由于對象泄漏造成的,也可能是因為堆的大小不足以存儲所有數據,您也可能了解這些場景的一些調試技巧。但是隨著您的 Java
應用程序處理越來越多的數據和越來越多的并發負載,您可能就會遇到無法使用常規技巧進行修復的 OutOfMemoryError
。
在一些場景中,即使 java 堆未滿,也會拋出錯誤。當這類場景發生時,您需要理解 Java 運行時環境(Java Runtime
Environment,JRE)內部到底發生了什么。
Java 應用程序在 Java 運行時的虛擬化環境中運行,但是運行時本身是使用 C 之類的語言編寫的本機程序,它也會耗用本機資源,包括本機內存 。本機內存是可用于運行時進程的內存,它與 Java 應用程序使用的 java 堆內存不同。每種虛擬化資源(包括 Java 堆和 Java 線程)都必須存儲在本機內存中,虛擬機在運行時使用的數據也是如此。這意味著主機的硬件和操作系統施加在本機內存上的限制會影響到 Java 應用程序的性能。
本系列文章共分兩篇,討論不同平臺上的相應話題。本文是其中一篇。在這兩篇文章中,您將了解什么是本機內存,Java
運行時如何使用它,本機內存耗盡之后會發生什么情況,以及如何調試本機 OutOfMemoryError
。本文介紹
Windows 和 Linux 平臺上的這一主題,不會介紹任何特定的運行時實現。
本機進程遇到的許多限制都是由硬件造成的,而與操作系統沒有關系。每臺計算機都有一個處理器和一些隨機存取存儲器(RAM),后者也稱為物理 內存。處理器將數據流解釋為要執行的指令,它擁有一個或多個處理單元,用于執行整數和浮點運算以及更高級的計算。處理器具有許多寄存器 —— 常快速的內存元素,用作被執行的計算的工作存儲,寄存器大小決定了一次計算可使用的最大數值。
處理器通過內存總線連接到物理內存。物理地址(處理器用于索引物理 RAM 的地址)的大小限制了可以尋址的內存。例如,一個 16 位物理地址可以尋址 0x0000 到 0xFFFF 的內存地址,這個地址范圍包括 2^16 = 65536 個惟一的內存位置。如果每個地址引用一個存儲字節,那么一個 16 位物理地址將允許處理器尋址 64KB 內存。
處理器被描述為特定數量的數據位。這通常指的是寄存器大小,但是也存在例外,比如 32 位 390 指的是物理地址大小。對于桌面和服務器平臺,這個數字為 31、32 或 64;對于嵌入式設備和微處理器,這個數字可能小至 4。物理地址大小可以與寄存器帶寬一樣大,也可以比它大或小。如果在適當的操作系統上運行,大部分 64 位處理器可以運行 32 位程序。
表 1 列出了一些流行的 Linux 和 Windows 架構,以及它們的寄存器和物理地址大小:
| 架構 | 寄存器帶寬(位) | 物理地址大小(位) |
|---|---|---|
| (現代)Intel? x86 | 32 | 32 36,具有 物理地址擴展(Pentium Pro 和更高型號) |
| x86 64 | 64 | 目前為 48 位(以后將會增大) |
| PPC64 | 64 | 在 POWER 5 上為 50 位 |
| 390 31 位 | 32 | 31 |
| 390 64 位 | 64 | 64 |
如果您編寫無需操作系統,直接在處理器上運行的應用程序,您可以使用處理器可以尋址的所有內存(假設連接到了足夠的物理 RAM)。但是要使用多任務和硬件抽象等特性,幾乎所有人都會使用某種類型的操作系統來運行他們的程序。
在 Windows 和 Linux 等多任務操作系統中,有多個程序在使用系統資源。需要為每個程序分配物理內存區域來在其中運行。可以設計這樣一個操作系統:每個程序直接使用物理內存,并 且可以可靠地僅使用分配給它的內存。一些嵌入式操作系統以這種方式工作,但是這在包含多個未經過集中測試的應用程序的環境中是不切實際的,因為任何程序都 可能破壞其他程序或者操作系統本身的內存。
虛擬內存 允許多個進程共享物理內存,而且不會破壞彼此的數據。在具有虛擬內存的操作系統(比如 Windows、Linux 和許多其他操作系統)中,每個程序都擁有自己的虛擬地址空間 —— 一個邏輯地址區域,其大小由該系統上的地址大小規定(所以,桌面和服務器平臺的虛擬地址空間為 31、32 或 64 位)。進程的虛擬地址空間中的區域可被映射到物理內存、文件或任何其他可尋址存儲。當數據未使用時,操作系統可以在物理內存與一個交換區域 (Windows 上的頁面文件 或者 Linux 上的交換分區 )之間移動它,以實現對物理內存的最佳利用率。當一個程序 嘗試使用虛擬地址訪問內存時,操作系統連同片上硬件會將該虛擬地址映射到物理位置,這個位置可以是物理 RAM、一個文件或頁面文件/交換分區。如果一個內存區域被移動到交換空間,那么它將在被使用之前加載回物理內存中。圖 1 展示了虛擬內存如何將進程地址空間區域映射到共享資源:
程序的每個實例以進程 的形式運行。在 Linux 和 Windows 上,進程是一個由受操作系統控制的資源(比如文件和套接字信息)、一個典型的虛擬地址空間(在某些架構上不止一個)和至少一個執行線程構成的集合。
虛擬地址空間大小可能比處理器的物理地址大小更小。32 位 Intel x86 最初擁有的 32 位物理地址僅允許處理器尋址 4GB 存儲空間。后來,添加了一種稱為物理地址擴展(Physical Address Extension,PAE)的特性,將物理地址大小擴大到了 36 位,允許安裝或尋址至多 64GB RAM。PAE 允許操作系統將 32 位的 4GB 虛擬地址空間映射到一個較大的物理地址范圍,但是它不允許每個進程擁有 64GB 虛擬地址空間。這意味著如果您將大于 4GB 的內存放入 32 位 Intel 服務器中,您將無法將所有內存直接映射到一個單一進程中。
地址窗口擴展(Address Windowing Extension)特性允許 Windows 進程將其 32 位地址空間的一部分作為滑動窗口映射到較大的內存區域中。Linux 使用類似的技術將內存區域映射到虛擬地址空間中。這意味著盡管您無法直接引用大于 4GB 的內存,但您仍然可以使用較大的內存區域。
盡管每個進程都有其自己的地址空間,但程序通常無法使用所有這些空間。地址空間被劃分為用戶空間 和內核空間 。 內核是主要的操作系統程序,包含用于連接計算機硬件、調度程序以及提供聯網和虛擬內存等服務的邏輯。
作為計算機啟動序列的一部分,操作系統內核運行并初始化硬件。一旦內核配置了硬件及其自己的內部狀態,第一個用戶空間進程就會啟動。如果用戶 程序需要來自操作系統的服務,它可以執行一種稱為系統調用 的操作與內核程序交互,內核程序然后執行該請求。系統調用通常是讀取和寫入文件、聯網和啟動新進程等操作所必需的。
當執行系統調用時,內核需要訪問其自己的內存和調用進程的內存。因為正在執行當前線程的處理器被配置為使用地址空間映射來為當前進程映射虛擬 地址,所以大部分操作系統將每個進程地址空間的一部分映射到一個通用的內核內存區域。被映射來供內核使用的地址空間部分稱為內核空間,其余部分稱為用戶空 間,可供用戶應用程序使用。
內核空間和用戶空間之間的平衡關系因操作系統的不同而不同,甚至在運行于不同硬件架構之上的同一操作系統的各個實例間也有所不同。這種平衡通 常是可配置的,可進行調整來為用戶應用程序或內核提供更多空間。縮減內核區域可能導致一些問題,比如能夠同時登錄的用戶數量限制或能夠運行的進程數量限 制。更小的用戶空間意味著應用程序編程人員只能使用更少的內存空間。
默認情況下,32 位 Windows 擁有 2GB 用戶空間和 2GB 內核空間。在一些 Windows 版本上,通過向啟動配置添加
/3GB
開關并使用 /LARGEADDRESSAWARE
開關重新鏈接應用程序,可以將這種平衡調整為 3GB 用戶空間和 1GB 內核空間。在 32 位 Linux 上,默認設置為 3GB 用戶空間和
1GB 內核空間。一些 Linux 分發版提供了一個 hugemem
內核,支持 4GB
用戶空間。為了實現這種配置,將進行系統調用時使用的地址空間分配給內核。通過這種方式增加用戶空間會減慢系統調用,因為每次進行系統調用時,操作系統必
須在地址空間之間復制數據并重置進程地址-空間映射。圖 2 展示了 32 位 Windows 的地址-空間布局:
圖 3 顯示了 32 位 Linux 的地址-空間配置:
31 位 Linux 390 上還使用了一個獨立的內核地址空間,其中較小的 2GB 地址空間使對單個地址空間進行劃分不太合理,但是,390 架構可以同時使用多個地址空間,而且不會降低性能。
進程空間必須包含程序需要的所有內容,包括程序本身和它使用的共享庫(在 Windows 上為 DDL,在 Linux 上為 .so 文件)。共享庫不僅會占據空間,使程序無法在其中存儲數據,它們還會使地址空間碎片化,減少可作為連續內存塊分配的內存。這對于在擁有 3GB 用戶空間的 Windows x86 上運行的程序尤為明顯。DLL 在構建時設置了首選的加載地址:當加載 DLL 時,它被映射到處于特定位置的地址空間,除非該位置已經被占用,在這種情況下,它會加載到別處。Windows NT 最初設計時設置了 2GB 可用用戶空間,這對于要構建來加載接近 2GB 區域的系統庫很有用 —— 使大部分用戶區域都可供應用程序自由使用。當用戶區域擴展到 3GB 時,系統共享庫仍然加載接近 2GB 數據(約為用戶空間的一半)。盡管總體用戶空間為 3GB,但是不可能分配 3GB 大的內存塊,因為共享庫無法加載這么大的內存。
在 Windows 中使用 /3GB
開關,可以將內核空間減少一半,也就是最初設計的大小。在一些情形下,可能耗盡 1GB 內核空間,使 I/O
變得緩慢,且無法正常創建新的用戶會話。盡管 /3GB
開關可能對一些應用程序非常有用,但任何使用它的環境在部署之前都應該進行徹底的負載測試。
本機內存泄漏或過度使用本機內存將導致不同的問題,具體取決于您是耗盡了地址空間還是用完了物理內存。耗盡地址空間通常只會發生在 32 位進程上,因為最大 4GB 的內存很容易分配完。64 位進程具有數百或數千 GB 的用戶空間,即使您特意消耗空間也很難耗盡這么大的空間。如果您確實耗盡了 Java 進程的地址空間,那么 Java 運行時可能會出現一些陌生現象,本文稍后將詳細討論。當在進程地址空間比物理內存大的系統上運行時,內存泄漏或過度使用本機內存會迫使操作系統交換后備存 儲器來用作本機進程的虛擬地址空間。訪問經過交換的內存地址比讀取駐留(在物理內存中)的地址慢得多,因為操作系統必須從硬盤驅動器拉取數據。可能會分配 大量內存來用完所有物理內存和所有交換內存(頁面空間),在 Linux 上,這將觸發內核內存不足(OOM)結束程序,強制結束最消耗內存的進程。在 Windows 上,與地址空間被占滿時一樣,內存分配將會失敗。
同時,如果嘗試使用比物理內存大的虛擬內存,顯然在進程由于消耗內存太大而被結束之前就會遇到問題。系統將變得異常緩慢,因為它會將大部分時 間用于在內存與交換空間之間來回復制數據。當發生這種情況時,計算機和獨立應用程序的性能將變得非常糟糕,從而使用戶意識到出現了問題。當 JVM 的 Java 堆被交換出來時,垃圾收集器的性能會變得非常差,應用程序可能被掛起。如果一臺機器上同時使用了多個 Java 運行時,那么物理內存必須足夠分配給所有 Java 堆。
Java 運行時是一個操作系統進程,它會受到我在上一節中列出的硬件和操作系統局限性的限制。運行時環境提供的功能受一些未知的用戶代碼驅動,這使得無法預測在每 種情形中運行時環境將需要何種資源。Java 應用程序在托管 Java 環境中執行的每個操作都會潛在地影響提供該環境的運行時的需求。本節描述 Java 應用程序為什么和如何使用本機內存。
Java 堆是分配了對象的內存區域。大多數 Java SE 實現都擁有一個邏輯堆,但是一些專家級 Java 運行時擁有多個堆,比如實現 Java 實時規范(Real Time Specification for Java,RTSJ)的運行時。一個物理堆可被劃分為多個邏輯扇區,具體取決于用于管理堆內存的垃圾收集(GC)算法。這些扇區通常實現為連續的本機內存 塊,這些內存塊受 Java 內存管理器(包含垃圾收集器)控制。
堆的大小可以在 Java 命令行使用 -Xmx
和 -Xms
選項來控制(mx
表示堆的最大大小,ms
表示初始大小)。盡管邏輯堆(經常被使用的內存區域)可以根據堆上的對象數量和在 GC
上花費的時間而增大和縮小,但使用的本機內存大小保持不變,而且由 -Xmx
值(最大堆大小)指定。大部分 GC
算法依賴于被分配為連續的內存塊的堆,因此不能在堆需要擴大時分配更多本機內存。所有堆內存必須預先保留。
保留本機內存與分配本機內存不同。當本機內存被保留時,無法使用物理內存或其他存儲器作為備用內存。盡管保留地址空間塊不會耗盡物理資源,但 會阻止內存被用于其他用途。由保留從未使用的內存導致的泄漏與泄漏分配的內存一樣嚴重。
當使用的堆區域縮小時,一些垃圾收集器會回收堆的一部分(釋放堆的后備存儲空間),從而減少使用的物理內存。
對于維護 Java 堆的內存管理系統,需要更多本機內存來維護它的狀態。當進行垃圾收集時,必須分配數據結構來跟蹤空閑存儲空間和記錄進度。這些數據結構的確切大小和性質因 實現的不同而不同,但許多數據結構都與堆大小成正比。
JIT 編譯器在運行時編譯 Java 字節碼來優化本機可執行代碼。這極大地提高了 Java 運行時的速度,并且支持 Java 應用程序以與本機代碼相當的速度運行。
字節碼編譯使用本機內存(使用方式與 gcc
等靜態編譯器使用內存來運行一樣),但 JIT
編譯器的輸入(字節碼)和輸出(可執行代碼)必須也存儲在本機內存中。包含多個經過 JIT 編譯的方法的 Java
應用程序會使用比小型應用程序更多的本機內存。
Java 應用程序由一些類組成,這些類定義對象結構和方法邏輯。Java 應用程序也使用 Java 運行時類庫(比如 java.lang.String
)
中的類,也可以使用第三方庫。這些類需要存儲在內存中以備使用。
存儲類的方式取決于具體實現。Sun JDK 使用永久生成(permanent generation,PermGen)堆區域。Java 5 的 IBM 實現會為每個類加載器分配本機內存塊,并將類數據存儲在其中。現代 Java 運行時擁有類共享等技術,這些技術可能需要將共享內存區域映射到地址空間。要理解這些分配機制如何影響您 Java 運行時的本機內存占用,您需要查閱該實現的技術文檔。然而,一些普遍的事實會影響所有實現。
從最基本的層面來看,使用更多的類將需要使用更多內存。(這可能意味著您的本機內存使用量會增加,或者您必須明確地重新設置 PermGen 或共享類緩存等區域的大小,以裝入所有類)。記住,不僅您的應用程序需要加載到內存中,框架、應用服務器、第三方庫以及包含類的 Java 運行時也會按需加載并占用空間。
Java 運行時可以卸載類來回收空間,但是只有在非常嚴酷的條件下才會這樣做。不能卸載單個類,而是卸載類加載器,隨其加載的所有類都會被卸載。只有在以下情況下 才能卸載類加載器:
- Java 堆不包含對表示該類加載器的
java.lang.ClassLoader對象的引用。 - Java 堆不包含對表示類加載器加載的類的任何
java.lang.Class對象的引用。 - 在 Java 堆上,該類加載器加載的任何類的所有對象都不再存活(被引用)。
需要注意的是,Java 運行時為所有 Java 應用程序創建的 3 個默認類加載器( bootstrap
、extension
和 application
)都不可能滿足這些條件,因此,任何系統類(比如 java.lang.String
)
或通過應用程序類加載器加載的任何應用程序類都不能在運行時釋放。
即使類加載器適合進行收集,運行時也只會將收集類加載器作為 GC 周期的一部分。一些實現只會在某些 GC 周期中卸載類加載器。
也可能在運行時生成類,而不用釋放它。許多 JEE 應用程序使用 JavaServer Pages (JSP) 技術來生成 Web 頁面。使用 JSP 會為執行的每個 .jsp 頁面生成一個類,并且這些類會在加載它們的類加載器的整個生存期中一直存在 —— 這個生存期通常是 Web 應用程序的生存期。
另一種生成類的常見方法是使用 Java 反射。反射的工作方式因 Java 實現的不同而不同,但 Sun 和 IBM 實現都使用了這種方法,我馬上就會講到。
當使用 java.lang.reflect
API 時,Java 運行時必須將一個反射對象(比如 java.lang.reflect.Field
)
的方法連接到被反射到的對象或類。這可以通過使用 Java 本機接口(Java Native
Interface,JNI)訪問器來完成,這種方法需要的設置很少,但是速度緩慢。也可以在運行時為您想要反射到的每種對象類型動態構建一個類。后一種
方法在設置上更慢,但運行速度更快,非常適合于經常反射到一個特定類的應用程序。
Java 運行時在最初幾次反射到一個類時使用 JNI 方法,但當使用了若干次 JNI 方法之后,訪問器會膨脹為字節碼訪問器,這涉及到構建類并通過新的類加載器進行加載。執行多次反射可能導致創建了許多訪問器類和類加載器。保持對反射對象 的引用會導致這些類一直存活,并繼續占用空間。因為創建字節碼訪問器非常緩慢,所以 Java 運行時可以緩存這些訪問器以備以后使用。一些應用程序和框架還會緩存反射對象,這進一步增加了它們的本機內存占用。
JNI 支持本機代碼(使用 C 和 C++ 等本機編譯語言編寫的應用程序)調用 Java 方法,反之亦然。Java 運行時本身極大地依賴于 JNI 代碼來實現類庫功能,比如文件和網絡 I/O。JNI 應用程序可能通過 3 種方式增加 Java 運行時的本機內存占用:
- JNI
應用程序的本機代碼被編譯到共享庫中,或編譯為加載到進程地址空間中的可執行文件。大型本機應用程序可能僅僅加載就會占用大量進程地址空間。
- 本機代碼必須與 Java 運行時共享地址空間。任何本機代碼分配或本機代碼執行的內存映射都會耗用 Java 運行時的內存。
- 某些 JNI 函數可能在它們的常規操作中使用本機內存。
GetType ArrayElements和GetType ArrayRegion函數可以將 Java 堆數據復制到本機內存緩沖區中,以供本機代碼使用。是否復制數據依賴于運行時實現。(IBM Developer Kit for Java 5.0 和更高版本會進行本機復制)。通過這種方式訪問大量 Java 堆數據可能會使用大量本機堆。
Java 1.4 中添加的新 I/O (NIO) 類引入了一種基于通道和緩沖區來執行 I/O 的新方式。就像 Java
堆上的內存支持 I/O 緩沖區一樣,NIO 添加了對直接
ByteBuffer
的支持(使用 java.nio.ByteBuffer.allocateDirect()
方法進行分配), ByteBuffer
受本機內存而不是 Java 堆支持。直接 ByteBuffer
可以直接傳遞到本機操作系統庫函數,以執行 I/O — 這使這些函數在一些場景中要快得多,因為它們可以避免在 Java 堆與本機堆之間復制數據。
對于在何處存儲直接 ByteBuffer
數據,很容易產生混淆。應用程序仍然在 Java
堆上使用一個對象來編排 I/O 操作,但持有該數據的緩沖區將保存在本機內存中,Java 堆對象僅包含對本機堆緩沖區的引用。非直接 ByteBuffer
將其數據保存在 Java 堆上的 byte[]
數組中。圖 4 展示了直接與非直接 ByteBuffer
對象之間的區別:
圖 4. 直接與非直接 java.nio.ByteBuffer
的內存拓撲結構

直接 ByteBuffer
對象會自動清理本機緩沖區,但這個過程只能作為 Java 堆 GC
的一部分來執行,因此它們不會自動響應施加在本機堆上的壓力。GC 僅在 Java 堆被填滿,以至于無法為堆分配請求提供服務時發生,或者在 Java
應用程序中顯式請求它發生(不建議采用這種方式,因為這可能導致性能問題)。
發生垃圾收集的情形可能是,本機堆被填滿,并且一個或多個直接 ByteBuffers
適合于垃圾收集(并且可以被釋放來騰出本機堆的空間),但 Java 堆幾乎總是空的,所以不會發生垃圾收集。
應用程序中的每個線程都需要內存來存儲器堆棧 (用于在調用函數時持有局部變量并維護狀態的內存區域)。每個 Java 線程都需要堆棧空間來運行。根據實現的不同,Java 線程可以分為本機線程和 Java 堆棧。除了堆棧空間,每個線程還需要為線程本地存儲(thread-local storage)和內部數據結構提供一些本機內存。
堆棧大小因 Java 實現和架構的不同而不同。一些實現支持為 Java 線程指定堆棧大小,其范圍通常在 256KB 到 756KB 之間。
盡管每個線程使用的內存量非常小,但對于擁有數百個線程的應用程序來說,線程堆棧的總內存使用量可能非常大。如果運行的應用程序的線程數量比 可用于處理它們的處理器數量多,效率通常很低,并且可能導致糟糕的性能和更高的內存占用。
Java 運行時善于以不同的方式來處理 Java 堆的耗盡與本機堆的耗盡,但這兩種情形具有類似的癥狀。當 Java
堆耗盡時,Java 應用程序很難正常運行,因為 Java 應用程序必須通過分配對象來完成工作。只要 Java 堆被填滿,就會出現糟糕的 GC
性能并拋出表示 Java 堆被填滿的 OutOfMemoryError
。
相反,一旦 Java 運行時開始運行并且應用程序處于穩定狀態,它可以在本機堆完全耗盡之后繼續正常運行。不一定會發生奇怪的行為,因為需要分配本機內存的操作比需要分配 Java 堆的操作少得多。盡管需要本機內存的操作因 JVM 實現不同而異,但也有一些操作很常見:啟動線程、加載類以及執行某種類型的網絡和文件 I/O。
本機內存不足行為與 Java 堆內存不足行為也不太一樣,因為無法對本機堆分配進行單點控制。盡管所有 Java 堆分配都在 Java
內存管理系統控制之下,但任何本機代碼(無論其位于 JVM、Java
類庫還是應用程序代碼中)都可能執行本機內存分配,而且會失敗。嘗試進行分配的代碼然后會處理這種情況,無論設計人員的意圖是什么:它可能通過 JNI
接口拋出一個 OutOfMemoryError
,在屏幕上輸出一條消息,發生無提示失敗并在稍后再試一次,或者執行其他操
作。
缺乏可預測行為意味著無法確定本機內存是否耗盡。相反,您需要使用來自操作系統和 Java 運行時的數據執行診斷。
為了幫助您了解本機內存耗盡如何影響您正使用的 Java 實現,本文的示例代碼中包含了一些 Java 程序,用于以不同方式觸發本機堆耗盡。這些示例使用通過 C 語言編寫的本機庫來消耗所有本機地址空間,然后嘗試執行一些使用本機內存的操作。提供的示例已經過編譯,編譯它們的指令包含在示例包的頂級目錄下的 README.html 文件中。
com.ibm.jtc.demos.NativeMemoryGlutton
類提供了
gobbleMemory()
方法,它在一個循環中調用 malloc
,直到幾乎所有本
機內存都已耗盡。完成任務之后,它通過以下方式輸出分配給標準錯誤的字節數:
Allocated 1953546736 bytes of native memory before running out |
針對在 32 位 Windows 上運行的 Sun 和 IBM Java 運行時的每次演示,其輸出都已被捕獲。提供的二進制文件已在以下操作系統上進行了測試:
- Linux x86
- Linux PPC 32
- Linux 390 31
- Windows x86
使用以下 Sun Java 運行時版本捕獲輸出:
java version "1.5.0_11" |
使用的 IBM Java 運行時版本為:
java version "1.5.0" |
com.ibm.jtc.demos.StartingAThreadUnderNativeStarvation
類嘗試在耗盡進程地址空間時啟動一個線程。這是發現 Java 進程已耗盡內存的一種常用方式,因為許多應用程序都會在其整個生存期啟動線程。
當在 IBM Java 運行時上運行時,StartingAThreadUnderNativeStarvation
演示的輸出如下:
Allocated 1019394912 bytes of native memory before running out |
調用 java.lang.Thread.start()
來嘗試為一個新的操作系統線程分配內存。此嘗試會失敗并拋出 OutOfMemoryError
。JVMDUMP
行通知用戶 Java 運行時已經生成了標準的 OutOfMemoryError
調試數據。
嘗試處理第一個 OutOfMemoryError
會導致第二個錯誤 —— :OutOfMemoryError,
ENOMEM error in ZipFile.open
。當本機進程內存耗盡時通常會拋出多個 OutOfMemoryError
。Failed
to fork OS thread
可能是在耗盡本機內存時最常見的消息。
本文提供的示例會觸發一個 OutOfMemoryError
集群,這比您在自己的應用程序中看到的情況要嚴重得多。這一定程度上是因為幾乎所有本機內存都已被使用,與實際的應用程序不同,使用的內存不會在以后被釋
放。在實際應用程序中,當拋出 OutOfMemoryError
時,線程會關閉,并且可能會釋放一部分本機內存,以讓運行時處理錯誤。測試案例的這個細微特性還意味著,類庫的許多部分(比如安全系統)未被初始化,而且
它們的初始化受嘗試處理內存耗盡情形的運行時驅動。在實際應用程序中,您可能會看到顯示了很多錯誤,但您不太可能在一個位置看到所有這些錯誤。
在 Sun Java 運行時上執行相同的測試案例時,會生成以下控制臺輸出:
Allocated 1953546736 bytes of native memory before running out |
盡管堆棧軌跡和錯誤消息稍有不同,但其行為在本質上是一樣的:本機分配失敗并拋出 java.lang.OutOfMemoryError
。
此場景中拋出的 OutOfMemoryError
與由于 Java 堆耗盡而拋出的錯誤的惟一區別在于消息。
com.ibm.jtc.demos.DirectByteBufferUnderNativeStarvation
類嘗試在地址空間耗盡時分配一個直接(也就是受本機支持的)java.nio.ByteBuffer
對象。當在
IBM Java 運行時上運行時,它生成以下輸出:
Allocated 1019481472 bytes of native memory before running out |
在此場景中,拋出了 OutOfMemoryError
,它會觸發默認的錯誤文檔。OutOfMemoryError
到達主線程堆棧的頂部,并在 stderr
上輸出。
當在 Sun Java 運行時上運行時,此測試案例生成以下控制臺輸出:
Allocated 1953546760 bytes of native memory before running out |
|
當出現 java.lang.OutOfMemoryError
或看到有關內存不足的錯誤消息時,要做的第一件事是確定哪種類型的內存被耗盡。最簡單的方式是首先檢查 Java 堆是否被填滿。如果 Java
堆未導致 OutOfMemory
條件,那么您應該分析本機堆使用情況。
檢查堆使用情況的方法因 Java 實現不同而異。在 Java 5 和 6 的 IBM 實現上,當拋出 OutOfMemoryError
時會生成一個 javacore 文件來告訴您。javacore 文件通常在 Java 進程的工作目錄中生成,以 javacore.日期
.時
間
.pid
.txt 的形式命名。如果您在文本編輯器中打開該文件,可以看到以下信息:
0SECTION MEMINFO subcomponent dump routine |
這部分信息顯示在生成 javacore 時有多少空閑的 Java 堆。注意,顯示的值為十六進制格式。如果因為分配條件不滿足而拋出了 OutOfMemoryError
異常,則 GC 軌跡部分會顯示如下信息:
1STGCHTYPE GC History |
J9AllocateObject() returning NULL!
意味著 Java
堆分配例程未成功完成,并且將拋出 OutOfMemoryError
。
也可能由于垃圾收集器運行太頻繁(意味著堆被填滿了并且 Java 應用程序的運行速度將很慢或停止運行)而拋出 OutOfMemoryError
。
在這種情況下,您可能想要 Heap Space Free 值非常小,GC 軌跡將顯示以下消息之一:
1STGCHTYPE GC History |
1STGCHTYPE GC History |
當 Sun 實現耗盡 Java 堆內存時,它使用異常消息來顯示它耗盡的是 Java 堆:
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space |
IBM 和 Sun 實現都擁有一個詳細的 GC 選項,用于在每個 GC 周期生成顯示堆填充情況的跟蹤數據。此信息可使用工具(比如 IBM Monitoring and Diagnostic Tools for Java - Garbage Collection and Memory Visualizer (GCMV))來分析,以顯示 Java 堆是否在增長。
如果您確定內存耗盡情況不是由 Java 堆耗盡引起的,那么下一步就是分析您的本機內存使用情況。
Windows 提供的 PerfMon 工具可用于監控和記錄許多操作系統和進程指標,包括本機內存使用。它允許實時跟蹤計數器 ,或將其存儲在日志文件中以供離線查看。使用 Private Bytes 計數器顯示總體地址空間使用情況。如果顯示值接近于用戶空間的限制(前面已經討論過,介于 2 到 3GB 之間),您應該會看到本機內存耗盡情況。
Linux 沒有類似于 PerfMon 的工具,但是它提供了幾個替代工具。命令行工具(比如 ps
、top
和 pmap
)能夠顯示應用程序的本機內存占用情況。盡管獲取進程內存使用情況的實時快照非常有用,但通過記錄內存隨時間的
使用情況,您能夠更好地理解本機內存是如何被使用的。為此,能夠采取的一種方式是使用 GCMV。
GCMV 最初編寫用于分析冗長的 GC 日志,允許用戶在調優垃圾收集器時查看 Java 堆使用情況和 GC 性能的變化。GCMV 后來進行了擴展,支持分析其他數據源,包括 Linux 和 AIX 本機內存數據。GCMV 是作為 IBM Support Assistant (ISA) 的插件發布的。
要使用 GCMV 分析 Linux 本機內存配置文件,您首先必須使用腳本收集本機內存數據。GCMV 的 Linux
本機內存分析器通過根據時間戳隔行掃描的方式,讀取 Linux ps
命令的輸出。GCMV
提供了一個腳本來幫助以正確形式記錄收集數據。要找到該腳本:
- 下載并安裝 ISA Version 4(或更高版本),然后安裝 GCMV 工具插件。
- 啟動 ISA。
- 從菜單欄單擊 Help >> Help Contents ,打開 ISA 幫助菜單。
- 在左側窗格的 Tool:IBM Monitoring and Diagnostic Tools for Java - Garbage Collection and Memory Visualizer >> Using the Garbage Collection and Memory Visualizer >> Supported Data Types >> Native memory >> Linux native memory 下找到 Linux 本機內存說明。
圖 5 顯示了該腳本在 ISA 幫助文件中的位置。如果您的幫助文件中沒有 GCMV Tool 條目,很可能是因為您沒有安裝 GCMV 插件。
圖 5. Linux 本機內存數據捕獲腳本在 ISA 幫助對話框中的位置

GCMV 幫助文件中提供的腳本使用的 ps
命令僅適用于最新的 ps
版本。在一些舊的 Linux 分發版中,幫助文件中的命令將會生成錯誤信息。要查看您的 Linux 分發版上的行為,可以嘗試運行 ps
-o pid,vsz=VSZ,rss=RSS
。如果您的 ps
版本支持新的命令行參數語法,那么得到的輸出將類似于:
PID VSZ RSS |
如果您的 ps
版本不支持新語法,得到的輸出將類似于:
PID VSZ,rss=RSS |
如果您在一個較老的 ps
版本上運行,可以修改本機內存腳本,將
ps -p $PID -o pid,vsz=VSZ,rss=RSS |
行替換為
ps -p $PID -o pid,vsz,rss |
將幫助面板中的腳本復制到一個文件中(在本例中名為 memscript.sh),找到您想要監控的 Java 進程的進程 ID (PID)(本例中為 1234)并運行:
./memscript.sh 1234 > ps.out |
這會把本機內存日志寫入到 ps.out 中。要分析內存使用情況:
- 在 ISA 中,從 Launch Activity 下拉菜單選擇 Analyze Problem 。
- 選擇接近 Analyze Problem 面板頂部的 Tools 標簽。
- 選擇 IBM Monitoring and Diagnostic Tools for Java - Garbage Collection and Memory Visualizer .
- 單擊接近工具面板底部的 Launch 按鈕。
- 單擊 Browse 按鈕并找到日志文件。單擊 OK 啟動 GCMV。
一旦您擁有了本機內存隨時間的使用情況的配置文件,您需要確定是存在本機內存泄漏,還是在嘗試在可用空間中做太多事情。即使對于運行良好的 Java 應用程序,其本機內存占用也不是從啟動開始就一成不變的。一些 Java 運行時系統(尤其是 JIT 編譯器和類加載器)會不斷初始化,這會消耗本機內存。初始化增加的內存將高居不下,但是如果初始本機內存占用接近于地址空間的限制,那么僅這個前期階段就 足以導致本機內存耗盡。圖 6 給出了一個 Java 壓力測試示例中的 GCMV 本機內存使用情況,其中突出顯示了前期階段。
圖 6. GCMV 的 Linux 本機內存使用示例,其中顯示了前期階段

本機內存占用也可能應工作負載不同而異。如果您的應用程序創建了較多進程來處理傳入的工作負載,或者根據應用于系統的負載量按比例分配本機存
儲(比如直接 ByteBuffer
),則可能由于負載過高而耗盡本機內存。
由于 JVM 前期階段的本機內存增長而耗盡本機內存,以及內存使用隨負載增加而增加,這些都是嘗試在可用空間中做太多事情的例子。在這些場景中,您的選擇是:
-
減少本機內存使用。
縮小 Java 堆大小是一個好的開端。
-
限制本機內存使用。
如果您的本機內存隨負載增加而增加,可以采取某種方式限制負載或為負載分配的資源。
-
增加可用地址空間。
這可以通過以下方式實現:調優您的操作系統(例如,在 Windows 上使用
/3GB開關增加用戶空間,或者在 Linux 上使用龐大的內核空間),更換平臺(Linux 通常擁有比 Windows 更多的用戶空間)。
一種實際的本機內存泄漏表現為本機堆的持續增長,這些內存不會在移除負載或運行垃圾收集器時減少。內存泄漏程度因負載不同而不同,但泄漏的總 內存不會下降。泄漏的內存不可能被引用,因此它可以被交換出去,并保持被交換出去的狀態。
當遇到內存泄漏時,您的選擇很有限。您可以增加用戶空間(這樣就會有更多的空間供泄漏),但這僅能延緩最終的內存耗盡。如果您擁有足夠的物理 內存和地址空間,并且會在進程地址空間耗盡之前重啟應用程序,那么可以允許地址空間繼續泄漏。
一旦確定本機內存被耗盡,下一個邏輯問題是:是什么在使用這些內存?這個問題很難回答,因為在默認情況下,Windows 和 Linux 不會存儲關于分配給特定內存塊的代碼路徑的信息。
當嘗試理解本機內存都到哪里去了時,您的第一步是粗略估算一下,根據您的 Java 設置,將會使用多少本機內存。如果沒有對 JVM 工作機制的深入知識,很難得出精確的值,但您可以根據以下指南粗略估算一下:
- Java 堆占用的內存至少為
-Xmx值。
- 每個 Java 線程需要堆棧空間。堆棧空間因實現不同而異,但是如果使用默認設置,每個線程至多會占用 756KB 本機內存。
- 直接
ByteBuffer至少會占用提供給allocate()例程的內存值。
如果總數比您的最大用戶空間少得多,那么您很可能不安全。Java 運行時中的許多其他組件可能會分配大量內存,進而引起問題。但是,如果您的初步估算值與最大用戶空間很接近,則可能存在本機內存問題。如果您懷疑存在本機 內存泄漏,或者想要準確了解內存都到哪里去了,使用一些工具將有所幫助。
Microsoft 提供了 UMDH(用戶模式轉儲堆)和 LeakDiag 工具來在 Windows 上調試本機內存增長。這兩個工具的機制相同:記錄特定內存區域被分配給了哪個代碼路徑,并提供一種方式來定位所分配的內存不會在以后被釋放的代碼部分。我建 議您查閱文章 “Umdhtools.exe:如何使用 Umdh.exe 發現 Windows 上的內存泄漏”。在本文中,我將主要討論 UMDH 在分析存在泄漏的 JNI 應用程序時的輸出。
本文包含一個名為 LeakyJNIApp
的 Java 應用程序,它循環調用一個 JNI
方法來泄漏本機內存。UMDH 命令獲取當前的本機堆的快照,以及分配每個內存區域的代碼路徑的本機堆棧軌跡快照。通過獲取兩個快照,并使用 UMDH
來分析差異,您會得到兩個快照之間的堆增長報告。
對于 LeakyJNIApp
,差異文件包含以下信息:
// _NT_SYMBOL_PATH set by default to C:/WINDOWS/symbols |
重要的一行是 + 412192 ( 1031943 - 619751) 963 allocs
BackTrace00468
。它顯示一個 backtrace 進行了 963 次分配,而且分配的內存都沒有釋放 — 總共使用了
412192 字節內存。通過查看一個快照文件,您可以將 BackTrace00468
與有意義的代碼路徑關聯起來。在第一個快照文件中搜索 BackTrace00468
,可以找到如下信息:
000000AD bytes in 0x1 allocations (@ 0x00000031 + 0x0000001F) by: BackTrace00468 |
這顯示內存泄漏來自 Java_com_ibm_jtc_demos_LeakyJNIApp_nativeMethod
函數中的 leakyjniapp.dll 模塊。
在編寫本文時,Linux 沒有類似于 UMDH 或 LeakDiag 的工具。但在 Linux 上仍然可以采用許多方式來調試本機內存泄漏。Linux 上提供的許多內存調試器可分為以下類別:
-
預處理器級別。
這些工具需要將一個頭文件編譯到被測試的源代碼中。可以使用這些工具之一重新編譯您自己的 JNI
庫,以跟蹤您代碼中的本機內存泄漏。除非您擁有 Java 運行時本身的源代碼,否則這種方法無法在 JVM
中發現內存泄漏(甚至很難在隨后將這類工具編譯到 JVM 等大型項目中,并且編譯非常耗時)。Dmalloc 就是這類工具的一個例子。
-
鏈接程序級別。
這些工具將被測試的二進制文件鏈接到一個調試庫。再一次,盡管這對個別 JNI
庫是可行的,但不推薦將其用于整個 Java 運行時,因為運行時供應商不太可能支持您運行修改的二進制文件。Ccmalloc
是這類工具的一個例子。
-
運行時鏈接程序級別。
這些工具使用
LD_PRELOAD環境變量預先加載一個庫,這個庫將標準內存例程替換為指定的版本。這些工具不需要重新編譯或重新鏈接源代碼,但其中許多工具與 Java 運行時不太兼容。Java 運行時是一個復雜的系統,可以以非常規的方式使用內存和線程,這通常會干擾或破壞這類工具。您可以試驗一下,看看是否有一些工具適用于您的場景。 NJAMD 是這類工具的一個例子。
-
基于模擬程序。
Valgrind
memcheck工具是這類內存調試器的惟一例子。它模擬底層處理器,與 Java 運行時模擬 JVM 的方式類似。可以在 Valgrind 下運行 Java 應用程序,但是會有嚴重的性能影響(速度會減慢 10 到 30 倍),這意味著難以通過這種方式運行大型、復雜的 Java 應用程序。Valgrind 目前可在 Linux x86、AMD64、PPC 32 和 PPC 64 上使用。如果您使用 Valgrind,請在使用它之前嘗試使用最小的測試案例來將減輕性能問題(如果可能,最好移除整個 Java 運行時)。
對于能夠容忍這種性能開銷的簡單場景,Valgrind memcheck
是最簡單且用戶友好的免費工具。它能夠為泄漏內存的代碼路徑提供完整的堆棧軌跡,提供方式與 Windows 上的 UMDH 相同。
LeakyJNIApp
非常簡單,能夠在 Valgrind 下運行。當模擬的程序結束時,Valgrind memcheck
工具能夠輸出泄漏的內存的匯總信息。默認情況下,LeakyJNIApp
程序會一直運行,要使其在固定時期之后關閉,可以將運行時間(以秒為單位)作為惟一的命令行參數進行傳遞。
一些 Java 運行時以非常規的方式使用線程堆棧和處理器寄存器,這可能使一些調試工具產生混淆,這些工具要求本機程序遵從寄存器使用和堆棧結構的標準約定。當使用 Valgrind 調試存在內存泄漏的 JNI 應用程序時,您可以發現許多與內存使用相關的警告,并且一些線程堆棧看起來很奇怪,這是由 Java 運行時在內部構造其數據的方式所導致的,不用擔心。
要使用 Valgrind memcheck
工具跟蹤 LeakyJNIApp
,
(在一行上)使用以下命令:
valgrind --trace-children=yes --leak-check=full |
--trace-children=yes
選項使 Valgrind 跟蹤由 Java
啟動器啟動的任何進程。一些 Java 啟動器版本會重新執行其本身(它們從頭重新啟動其本身,再次設置環境變量來改變行為)。如果您未指定 --trace-children
,
您將不能跟蹤實際的 Java 運行時。
--leak-check=full
選項請求在代碼運行結束時輸出對泄漏的代碼區域的完整堆棧軌跡,而不只是匯總內存的狀態。
當該命令運行時,Valgrind
輸出許多警告和錯誤(在此環境中,其中大部分都是無意義的),最后按泄漏的內存量升序輸出存在泄漏的調用堆棧。在 Linux x86 上,針對 LeakyJNIApp
的 Valgrind 輸出的匯總部分結尾如下:
==20494== 8,192 bytes in 8 blocks are possibly lost in loss record 36 of 45 |
堆棧的第二行顯示內存是由 com.ibm.jtc.demos.LeakyJNIApp.nativeMethod()
方法泄漏的。
也可以使用一些專用調試應用程序來調試本機內存泄漏。隨著時間的推移,會有更多工具(包括開源和專用的)被開發出來,這對于研究當前技術的發 展現狀很有幫助。
就目前而言,使用免費工具調試 Linux 上的本機內存泄漏比在 Windows 上完成相同的事情更具挑戰性。UMDH 支持就地 調試 Windows 上本機內存泄漏,在 Linux 上,您可能需要進行一些傳統的調試,而不是依賴工具來解決問題。下面是一些建議的調試步驟:
-
提取測試案例。
生成一個獨立環境,您需要能夠在該環境中再現本機內存泄漏。這將使調試更加簡單。
-
盡可能縮小測試案例。
嘗試禁用函數來確定是哪些代碼路徑導致了本機內存泄漏。如果您擁有自己的 JNI
庫,可以嘗試一次禁用一個來確定是哪個庫導致了內存泄漏。
-
縮小 Java 堆大小。
Java 堆可能是進程的虛擬地址空間的最大使用者。通過減小 Java
堆,可以將更多空間提供給本機內存的其他使用者。
-
關聯本機進程大小。
一旦您獲得了本機內存隨時間的使用情況,可以將其與應用程序工作負載和 GC
數據比較。如果泄漏程度與負載級別成正比,則意味著泄漏是由每個事務或操作路徑上的某個實體引起的。如果當進行垃圾收集時,本機進程大小顯著減小,這意味
著您沒遇到內存泄漏,您擁有的是具有本機支持的對象組合(比如直接
ByteBuffer)。通過縮小 Java 堆大小(從而迫使垃圾收集更頻繁地發生),或者在一個對象緩存中管理對象(而不是依賴于垃圾收集器來清理對象),您可以減少本機支持對象持有的內存量。
如果您確定內存泄漏或增長來自于 Java 運行時本身,您可能需要聯系運行時供應商來進一步調試。
使用 32 位 Java 運行時很容易遇到本機內存耗盡的情況,因為地址空間相對較小。32 位操作系統提供的 2 到 4GB 用戶空間通常小于系統附帶的物理內存量,而且現代的數據密集型應用程序很容易耗盡可用空間。
如果 32 位地址空間不夠您的應用程序使用,您可以通過移動到 64 位 Java 運行時來獲得更多用戶空間。如果您運行的是 64 位操作系統,那么 64 位 Java 運行時將能夠滿足海量 Java 堆的需求,還會減少與地址空間相關的問題。表 2 列出了 64 位操作系統上目前可用的用戶空間。
| 操作系統 | 默認用戶空間大小 |
|---|---|
| Windows x86-64 | 8192GB |
| Windows Itanium | 7152GB |
| Linux x86-64 | 500GB |
| Linux PPC64 | 1648GB |
| Linux 390 64 | 4EB |
然而,移動到 64 位并不是所有本機內存問題的通用解決方案,您仍然需要足夠的物理內存來持有所有數據。如果物理內存不夠 Java 運行時使用,運行時性能將變得非常糟,因為操作系統不得不在內存與交換空間之間來回復制 Java 運行時數據。出于相同原因,移動到 64 位也不是內存泄漏永恒的解決方案,您只是提供了更多空間來供泄漏,這只會延緩您不得不重啟應用程序的時間。
無法在 64 位運行時中使用 32 位本機代碼。任何本機代碼(JNI 庫、JVM Tool Interface [JVMTI]、JVM Profiling Interface [JVMPI] 以及 JVM Debug Interface [JVMDI] 代理)都必須編譯為 64 位。64 位運行時的性能也可能比相同硬件上對應的 32 位運行時更慢。64 位運行時使用 64 位指針(本機地址引用),因此,64 位運行時上的 Java 對象會占用比 32 位運行時上包含相同數據的對象更多的空間。更大的對象意味著要使用更大的堆來持有相同的數據量,同時保持類似的 GC 性能,這使操作系統和硬件緩存效率更低。令人驚訝的是,更大的 Java 堆并不一定意味著更長的 GC 暫停時間,因為堆上的活動數據量可能不會增加,并且一些 GC 算法在使用更大的堆時效率更高。
一些現代 Java 運行時包含減輕 64 位 “對象膨脹” 和改善性能的技術。這些功能在 64 位運行時上使用更短的引用。這在 IBM 實現中稱為壓縮引用 ,而在 Sun 實現中稱為壓縮 oop 。
對 Java 運行時性能的比較研究不屬于本文討論范圍,但是如果您正在考慮移動到 64 位,盡早測試應用程序以理解其執行原理會很有幫助。由于更改地址大小會影響到 Java 堆,所以您將需要在新架構上重新調優您的 GC 設置,而不是僅僅移植現有設置。
在設計和運行大型 Java 應用程序時,理解本機內存至關重要,但是這一點通常被忽略,因為它與復雜的硬件和操作系統細節密切相關,Java 運行時的目的正是幫助我們規避這些細節。JRE 是一個本機進程,它必須在由這些紛繁復雜的細節定義的環境中工作。要從 Java 應用程序中獲得最佳的性能,您必須理解應用程序如何影響 Java 運行時的本機內存使用。
耗盡本機內存與耗盡 Java 堆很相似,但它需要不同的工具集來調試和解決。修復本機內存問題的關鍵在于理解運行您的 Java 應用程序的硬件和操作系統施加的限制,并將其與操作系統工具知識結合起來,監控本機內存使用。通過這種方法,您將能夠解決 Java 應用程序產生的一些非常棘手的問題。



浙公網安備 33010602011771號