為什麼Java程式使用的RAM比Heap Size大?
Java程式使用的虛擬記憶體確實比Java Heap要大很多。JVM包括很多子系統:垃圾收集器、類載入系統、JIT編譯器等等,這些子系統各自都需要一定數量的RAM才能正常工作。
當一個Java程式執行時,也不僅僅是JVM在消耗RAM,很多本地庫(Java類庫中引用的本地庫)可能需要分配原生記憶體,這些記憶體無法被JVM的Native Memory Tracking機制監控到。Java應用自身也可能通過DirectByteBuffers等類來使用堆外記憶體。
那麼,當一個Java程式執行時,有哪些部分在消耗記憶體呢?這裡我們只展示哪些可以被Native Memory Tracking監控到的部分。
一、JVM部分
Java Heap: 最明顯的部分,Java物件在這個區域分配和回收,Heap的最大值由-Xmx決定。
Garbage Collector:GC的資料結構和演演算法需要額外的記憶體對堆記憶體進行管理。這些資料結構包括:Mark Bitmap、Mark Stack(用於跟蹤存活的物件)、Remembered Sets(用於記錄region之間的引用)等等。這些資料結構中的一些是可以直接調整的,例如:-XX:MarkStackSizeMax,其他的則依賴於堆的分佈,例如:分割槽大小,-XX:G1HeapRegionSize,這個值越大Remembered Sets的值越小。不同的GC演演算法需要的額外記憶體是不同的,-XX:+UseSerialGC和-XX:+UseShenandoahGC需要較小的額外記憶體,G1和CMS則需要Heap size的10%作為額外記憶體。
Code Cache:用於存放動態生成的程式碼:JIT編譯的方法、攔截器和執行時存根。這個區域的大小由-XX:ReservedCodeCacheSize確定(預設是240M)。使用-XX-TieredCompilation關掉多層編譯,可以減少需要編譯的程式碼,從而減少Code Cache的使用。
Compiler:JIT編譯器需要一些記憶體來才能工作。這個值可以通過關閉多層編譯或減少執行編譯的執行緒數(-XX:CICompilerCount)來調整.
Class loading:類的元資料(方法的位元組碼、符號表、常量池、註解等)被存放在off-heap區域,也叫Metaspace。當前JVM程式載入了越多的類,就會使用越多的metaspace。通過設定-XX:MaxMetaspaceSize(預設是無限)或-XX:CompressedClassSpaceSize(預設是1G)可以限制元空間的大小
Symbol tables:JVM中維護了兩個重要的雜湊表:Symbol表包括類、方法、介面等語言元素的名稱、簽名、ID等,String table記錄了被interned過的字串的引用。如果Native Tracking表明String table使用了很大的記憶體,那麼說明該Java應用存在對String.intern方法的濫用。
Threads:執行緒棧也會使用RAM,棧的大小由-Xss確定。預設是1個執行緒最大有1M的執行緒棧,幸運得失事情並沒有這麼糟糕——OS使用惰性策略分配記憶體頁,實際上每個Java執行緒使用的RAM很小(一般80~200K),作者使用這個指令碼(https://github.com/apangin/jstackmem)來統計有多少RSS空間是屬於Java執行緒的。
二、堆外記憶體(Direct buffers)
Java應用可以通過ByteBuffer.allocateDirect顯式申請堆外記憶體;預設的堆外記憶體大小是-Xmx,但是這個值可被-XX:MaxDirectMemorySize覆蓋。在JDK11之前,Direct ByteBuffers被NMT(Native Memory Tracking)列舉在other部分,可以通過JMC觀察到堆外記憶體的使用情況。
除了DirectByteBuffers,MappedByteBuffers也會使用本地記憶體,MappedByteBuffers的作用是將檔案內容對映到程式的虛擬記憶體中,NMT沒有跟蹤它們,想要限制這部分的大小並不容易,可以通過pmap -x
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複製程式碼
三、本地庫(Native libraries)
由System.loadLibrary載入的JNI程式碼也會按需分配RAM,並且這部分記憶體不受JVM管理。在這裡需要關注的是Java類庫,未關閉的Java資源會導致本地記憶體洩漏,典型的例子是:ZipInputStream或DirectoryStream。
JVMTI agent,特別是jdwp除錯agent,也可能導致記憶體的過量使用(PS:去年寫memory agent程式碼造成的記憶體洩漏記憶猶新)。
四、Allocator issues
一個Java程式可以通過系統呼叫(mmap)或標準庫(malloc)方法來向OS申請記憶體。malloc自己又通過mmap來向OS申請比較大的記憶體,並通過自己的演演算法來管理這些記憶體,這可能會導致記憶體碎片,從而導致過量使用虛擬記憶體。jemalloc是另外一個記憶體分配器,它比常規的malloc分配器需要更少的footprint,因此可以在自己的C++程式碼中嘗試使用jemalloc方法。
結論
無法準確統計一個Java程式使用的虛擬記憶體,因為有太多因素需要考慮,列舉如下:
Total memory = Heap + Code Cache + Metaspace + Symbol tables +
Other JVM structures + Thread stacks +
Direct buffers + Mapped files +
Native Libraries + Malloc overhead + ...複製程式碼
***本號專注於後端技術、JVM問題排查和優化、Java面試題、個人成長和自我管理等主題,為讀者提供一線開發者的工作和成長經驗,期待你能在這裡有所收穫。