(給ImportNew加星標,提高Java技能)
編譯:唐尤華
本文來自 StackOverflow 的一個問答:Java using much more memory than heap size (or size correctly Docker memory limit)
題主發現 Java 行程佔用記憶體遠超過堆記憶體設定的大小,於是提出了下麵的問題:
有誰能解釋為什麼 Java 行程佔用記憶體遠超過堆記憶體大小?如何正確計算 Docker 記憶體限制?有沒有辦法減少 Java 行程的堆外記憶體(off-heap memeory)佔用?
“下麵是熱心網友的答覆”
Java 行程使用的虛擬記憶體遠遠超過 Java 堆大小。要知道 JVM 包括許多子系統,垃圾回收器、類裝載器、JIT 編譯器等等。所有這些子系統執行都需要佔用記憶體。
JVM 不是記憶體唯一的消費者,Java Class Library 在內的所有 Native Library 也會佔用記憶體。對於記憶體跟蹤工具來說這些開銷甚至無法跟蹤。Java 應用程式本身還可以透過直接 `ByteBuffers` 使用堆外記憶體。
1. 究竟 Java 行程中有哪些元件會佔用記憶體?
透過 Native Memory Tracking 可以觀察到有以下 JVM 元件。
1.1 Java 堆
最顯而易見的就是 Java 堆,它是 Java 物件存在的地方。它會佔用 `-Xmx` 引數指定大小的記憶體。
1.2 垃圾回收器
GC 需要額外的記憶體進行堆管理,主要用於 GC 自身的結構與演演算法。這些結構包括 Mark Bitmap、Mark Stack(遍歷物件關係圖)、Remembered Set(記錄 region 之間取用)等等。其中一些可以直接調優,例如 `-XX: MarkStackSizeMax` 選項,另一些依賴於堆佈局。其中 G1 region (`-XX:G1HeapRegionSize`)佔用記憶體較大,Remembered Set 佔用記憶體較小。
GC 的記憶體開銷因演演算法而異,其中 `-XX:+UseSerialGC` 與 `-XX:+UseShenandoahGC` 的開銷最小,而 G1 或 CMS 則會輕鬆佔用大約10%的堆記憶體。
1.3 程式碼快取
程式碼快取包含動態生成的程式碼,JIT 編譯生成的方法、直譯器以及執行時 stub 程式碼。程式碼大小受 `-XX:ReservedCodeCacheSize` 選項限制(預設為240M)。關閉 `-XX:-TieredCompilation` 可以減少已編譯程式碼的數量,從而減小程式碼快取。
1.4 編譯器
JIT 編譯器本身工作時也需要記憶體。可以透過關閉 Tiered Compilation 或者 `-XX:CICompilerCount` 減少編譯使用的執行緒數。
1.5 類載入
類的元資料儲存在 Metaspace 堆外區域中,包括方法位元組碼、符號、常量池、註解等。載入的類越多,使用的元資料就越多。可以透過 `-XX:MaxMetaspaceSize`(預設無上限)和 `-XX:CompressedClassSpaceSize`(預設1G)選項控制元資料總大小。
1.6 符號表
JVM 有兩個主要的 hashtable:符號表包含名稱、簽名、識別符號等,String 表包含對 interned String 取用。如果 Native Memory Tracking 顯示 String 表使用了大量記憶體,這可能意味著應用程式呼叫 String.intern 過於頻繁。
1.7 執行緒
執行緒堆疊也會申請記憶體。堆疊大小由 `-Xss` 選項指定,預設每個執行緒1M,幸運的是情況並非那麼糟糕。作業系統會以延遲分配的方式分配記憶體頁面,比如在第一次使用時分配,因此實際使用的記憶體要低得多,通常每個執行緒堆疊佔用80至200KB。我編寫了一個[指令碼][1]評估有多少 RSS 屬於 Java 執行緒堆疊。
[1]:https://github.com/apangin/jstackmem
還有其他 JVM 部件會佔用本地記憶體,但它們在總記憶體消耗中通常比例不大。
2. Direct Buffer
應用程式可以透過 ByteBuffer.allocateDirect 呼叫直接請求非堆記憶體。預設的非堆記憶體大小限制由 `-Xmx` 選項指定,但也可以使用 `-XX:MaxDirectMemorySize` 改寫配置。Direct ByteBuffer 包含在 Native Memory Tracking 輸出的 Other 區域,在 JDK 11 之前包含在 Internal 區域。
透過 JMX 可以在 JConsole 或 Java Mission Control 中直接看到 Direct Memory 的使用量:
除了 Direct ByteBuffer,還有 `MappedByteBuffer` 對映到行程虛擬記憶體中的檔案。雖然 Native Memory Tracking 不對它跟蹤,但是 `MappedByteBuffer` 也會佔用物理記憶體,而且沒有一種簡單的方法限制它申請的記憶體大小。可以透過檢視行程記憶體對映瞭解實際的記憶體使用情況:`pmap-x `。
```shell
Address Kbytes RSS Dirty Mode Mapping
...
00007f2b3e557000 39592 32956 0 r--s- some-file-17405-Index.db
00007f2b40c01000 39600 33092 0 r--s- some-file-17404-Index.db
^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^
```
3. Native Library
`System.Loadlibrary` 載入的 JNI 程式碼可以不受 JVM 控制分配堆外記憶體,標準 Java Class Library 也是如此。尤其是未關閉的 Java 資源可能造成本地記憶體洩漏。典型的例子是 `ZipInputStream` 和 `DirectoryStream`。
JVMTI 代理,尤其是 jdwp 除錯代理,也會造成記憶體消耗過多。
[這個回答][2]描述瞭如何使用 [async-profiler][3] 分析本地記憶體分配。
[2]:https://stackoverflow.com/a/53598622/3448419
[3]:https://github.com/jvm-profiling-tools/async-profiler/
4. Allocator 問題
行程通常透過 mmap 系統呼叫直接從作業系統分配記憶體,或者使用標準的 libc allocator —— malloc 分配本機記憶體。反過來,malloc 會呼叫 mmap 向作業系統申請大塊記憶體,然後根據自己的分配演演算法管理記憶體塊。問題在於這種演演算法會造成碎片化以及[過度使用虛擬記憶體][4]。
[4]:https://www.ibm.com/developerworks/community/blogs/kevgrig/entry/linux_glibc_2_10_rhel_6_malloc_may_show_excessive_virtual_memory_usage?lang=en
[jemalloc][5] 是 libc malloc 的一個更智慧的替代選項,使用 jemalloc 佔用記憶體會變得更小。
[5]:http://jemalloc.net/
5. 總結
因為有太多的因素需要考慮,沒有一種可靠的方法可以用來評估一個 Java 行程所有的記憶體使用量。
```
總記憶體 = 堆 + 程式碼快取 + Metaspace + 符號表 +
其他 JVM 結構 + 執行緒堆疊 +
Direct Buffer + 對映檔案 +
Native Library + Malloc 開銷 + ...
```
雖然可以透過設定 JVM 引數縮小或限制類似程式碼快取這樣的區域,但是其他許多區域根本不受 JVM 控制。
設定 Docker 限制的一種可能的方法是觀察行程“正常”狀態下的實際記憶體使用情況。有一些工具和技術可以用來研究 Java 記憶體消耗問題,[Native Memory Tracking][6]、[pmap][7]、[jemalloc][5]、[async-profiler][3]。
[6]:https://docs.oracle.com/javase/8/docs/technotes/guides/troubleshoot/tooldescr007.html
[7]:http://man7.org/linux/man-pages/man1/pmap.1.html
朋友會在“發現-看一看”看到你“在看”的內容