0. 問題
最近業(yè)務(wù)方反饋我們的一個 Java 寫的 agent 內(nèi)存占用過高:

業(yè)務(wù)方是通過 top 命令查看 VIRT 數(shù)值過高,但是通常來說我們都是采用 RES 衡量內(nèi)存占用……
按照我們的理解,這個數(shù)值僅僅表示進(jìn)程占用的虛擬地址空間,并不是實際的物理內(nèi)存占用,但是具體到 Java 程序,其中的細(xì)節(jié)跟業(yè)務(wù)方又說不清楚。
所以本文的目的就是盡可能理清 Java 程序內(nèi)存與操作系統(tǒng)內(nèi)存管理之間的關(guān)系,看看有沒有可能在不影響程序的情況下降低“虛擬內(nèi)存地址”占用。
1. 基礎(chǔ)知識
對于寫 Java 程序的同學(xué)來說,本來就是為了“逃避”內(nèi)存管理,所以對內(nèi)存管理的知識了解不多。
對于操作系統(tǒng)層面的理解通常僅限于:

對于 Java 層面的理解通常僅限于:


這兩層怎么對應(yīng)?中間層做了哪些工作?都是完全不清楚的!
幸好 JDK8 提供了 Native Memory Tracking,所以我們選擇自上而下地探索內(nèi)存管理。
2. 使用 NMT 查看 Java 程序內(nèi)存使用
JDK8 提供的 NMT 用于追蹤 JVM 內(nèi)存使用,也就是統(tǒng)計 malloc / mmap 的調(diào)用情況。
我們找了一臺自己的機(jī)器測試,JVM 啟動參數(shù)如下:
nohup $JAVA_HOME/bin/java -server \
-XX:+UseG1GC \
-XX:ConcGCThreads=1 \
-XX:ParallelGCThreads=4 \
-Xmx2g \
-Xms2g \
-XX:+ExplicitGCInvokesConcurrent \
-XX:MaxDirectMemorySize=256m \
-XX:MaxMetaspaceSize=64m \
-XX:NativeMemoryTracking=detail \
啟動之后通過 jcmd 查看詳細(xì)信息:
Total: reserved=3590817KB, committed=2295813KB
- Java Heap (reserved=2097152KB, committed=2097152KB)
(mmap: reserved=2097152KB, committed=2097152KB)
- Class (reserved=1061169KB, committed=13489KB)
(classes #2295)
(malloc=305KB #2057)
(mmap: reserved=1060864KB, committed=13184KB)
- Thread (reserved=42328KB, committed=42328KB)
(thread #42)
(stack: reserved=42148KB, committed=42148KB)
(malloc=132KB #212)
(arena=48KB #82)
- Code (reserved=250310KB, committed=7082KB)
(malloc=710KB #1820)
(mmap: reserved=249600KB, committed=6372KB)
- GC (reserved=125871KB, committed=125871KB)
(malloc=15279KB #12893)
(mmap: reserved=110592KB, committed=110592KB)
- Compiler (reserved=146KB, committed=146KB)
(malloc=15KB #77)
(arena=131KB #3)
- Internal (reserved=3896KB, committed=3896KB)
(malloc=3864KB #6264)
(mmap: reserved=32KB, committed=32KB)
- Symbol (reserved=3662KB, committed=3662KB)
(malloc=2471KB #15155)
(arena=1191KB #1)
- Native Memory Tracking (reserved=767KB, committed=767KB)
(malloc=130KB #2060)
(tracking overhead=636KB)
- Arena Chunk (reserved=1420KB, committed=1420KB)
(malloc=1420KB)
- Unknown (reserved=4096KB, committed=0KB)
(mmap: reserved=4096KB, committed=0KB)
Virtual memory map:
(調(diào)用棧信息太多,不復(fù)制)
Java Heap 的分配通過 mmap,而不是 malloc,也就說這塊內(nèi)存不受 glibc 管理。
NMT 的調(diào)用棧打印出了詳細(xì)的虛擬內(nèi)存地址,跟 /proc/[PID]/smaps 中的地址核對一下:
// NMT 中 Java Heap 分配的調(diào)用棧信息:
[0x0000000080000000 - 0x0000000100000000] reserved 2097152KB for Java Heap from
[0x00007f7041a9c472] ReservedSpace::initialize(unsigned long, unsigned long, bool, char*, unsigned long, bool)+0xc2
[0x00007f7041a9ce4e] ReservedHeapSpace::ReservedHeapSpace(unsigned long, unsigned long, bool, char*)+0x6e
[0x00007f7041a6a3ab] Universe::reserve_heap(unsigned long, unsigned long)+0x8b
[0x00007f70415829d0] G1CollectedHeap::initialize()+0x130
[0x0000000080000000 - 0x0000000100000000] committed 2097152KB from
[0x00007f70415a5a6f] G1PageBasedVirtualSpace::commit_internal(unsigned long, unsigned long)+0xbf
[0x00007f70415a5cfc] G1PageBasedVirtualSpace::commit(unsigned long, unsigned long)+0x11c
[0x00007f70415a8940] G1RegionsLargerThanCommitSizeMapper::commit_regions(unsigned int, unsigned long)+0x40
[0x00007f704160ae27] HeapRegionManager::commit_regions(unsigned int, unsigned long)+0x77
[0x0000000100000000 - 0x0000000140000000] reserved 1048576KB for Class from
[0x00007f7041a9c472] ReservedSpace::initialize(unsigned long, unsigned long, bool, char*, unsigned long, bool)+0xc2
[0x00007f7041a9c6ab] ReservedSpace::ReservedSpace(unsigned long, unsigned long, bool, char*, unsigned long)+0x1b
[0x00007f7041881860] Metaspace::allocate_metaspace_compressed_klass_ptrs(char*, unsigned char*)+0x40
[0x00007f7041883d3f] Metaspace::global_initialize()+0x4cf
[0x0000000100000000 - 0x00000001001a0000] committed 1664KB from
[0x00007f7041a9bee9] VirtualSpace::expand_by(unsigned long, bool)+0x199
[0x00007f704187fcc6] VirtualSpaceList::expand_node_by(VirtualSpaceNode*, unsigned long, unsigned long)+0x76
[0x00007f7041882ae0] VirtualSpaceList::expand_by(unsigned long, unsigned long)+0xf0
[0x00007f7041882c73] VirtualSpaceList::get_new_chunk(unsigned long, unsigned long, unsigned long)+0xb3
// /proc/[PID]/smaps 中對應(yīng)的地址空間
80000000-1001a0000 rw-p 00000000 00:00 0
Size: 2098816 kB
Rss: 673288 kB
Pss: 673288 kB
Shared_Clean: 0 kB
Shared_Dirty: 0 kB
Private_Clean: 0 kB
Private_Dirty: 673288 kB
Referenced: 673288 kB
Anonymous: 673288 kB
AnonHugePages: 671744 kB
Swap: 0 kB
KernelPageSize: 4 kB
MMUPageSize: 4 kB
VmFlags: rd wr mr mw me ac
1001a0000-140000000 ---p 00000000 00:00 0
Size: 1046912 kB
Rss: 0 kB
Pss: 0 kB
Shared_Clean: 0 kB
Shared_Dirty: 0 kB
Private_Clean: 0 kB
Private_Dirty: 0 kB
Referenced: 0 kB
Anonymous: 0 kB
AnonHugePages: 0 kB
Swap: 0 kB
KernelPageSize: 4 kB
MMUPageSize: 4 kB
VmFlags: mr mw me nr
可以看出 Java Heap 與 Metaspace 緊挨著分配,兩塊一共占用了 3GB 的 Size(虛擬內(nèi)存地址空間),而表征物理內(nèi)存占用的 Rss 卻只有 673288KB。也就是說,mmap 只是給進(jìn)程分配一個線性區(qū)域(虛擬內(nèi)存),并沒有分配物理內(nèi)存,只有當(dāng)進(jìn)程訪問這塊內(nèi)存時,操作系統(tǒng)才會分配具體的內(nèi)存頁給進(jìn)程,這就是 Linux 內(nèi)存管理的延遲分配策略。
這時可能會聯(lián)系到 -XX:+AlwaysPreTouch 這個啟動參數(shù),其所用就是按照內(nèi)存頁粒度訪問一遍 Java Heap:
// hotspot/src/share/vm/runtime/os.cpp
void os::pretouch_memory(char* start, char* end) {
for (volatile char *p = start; p < end; p += os::vm_page_size()) {
*p = 0;
}
}
對應(yīng)到操作系統(tǒng)層面,就是為了抵消延遲分配策略,在進(jìn)程啟動時強(qiáng)制分配好 Java Heap 的物理內(nèi)存,雖然增加了啟動延時,但是可以減少進(jìn)程運(yùn)行時由于分配內(nèi)存造成的延時。
以上可以看出 Java Heap 區(qū)域在啟動時可以強(qiáng)制占用 Xmx 設(shè)置的物理內(nèi)存空間,但是并不會多余占用虛擬內(nèi)存地址。
但是 Metaspace 分配的虛擬內(nèi)存地址已經(jīng)超過 -XX:MaxMetaspaceSize 設(shè)置的上限了,而且還有 Class、Thread、Code、GC、Compiler、Internal、Symbol、Arena Chunk 等內(nèi)存,實際上 Java 程序占用的物理內(nèi)存是會超出 Xmx 等設(shè)置的上限的,而虛擬內(nèi)存地址空間可能會超出更多。
我們可以算一下該進(jìn)程當(dāng)前除了 Java Heap 和 Metaspace 多占用了多少物理內(nèi)存:
cat smaps.txt | grep Rss | cut -d: -f2 | tr -d " " | cut -f1 -dk | sort -n | awk '{ sum += $1 } END { print sum }'
775932
總共占用物理內(nèi)存 775932KB,這個數(shù)值與 top 命令查看的 RES 一致,減去 Java Heap 和 Metaspace 占用的物理內(nèi)存 673288KB,額外占用了 102644KB,這包含代碼、鏈接庫、線程、GC數(shù)據(jù)結(jié)構(gòu)、JIT編譯器等占用的內(nèi)存。
再算一下虛擬內(nèi)存 6447068KB,這個數(shù)值與 top 命令查看的 VIRT 一致,而通過 NMT 統(tǒng)計的 reserved 只有 3590817KB,代碼和鏈接庫不應(yīng)該額外占用這么多,其中的偏差應(yīng)該在于 glibc malloc 管理的內(nèi)存。(NMT 中的 arena 實際上也是通過 malloc 分配的內(nèi)存)
3. 查看 malloc 分配的地址空間
在查看 NMT 中 malloc 分配的地址空間之前,我們先復(fù)習(xí)一下內(nèi)存分配的相關(guān)知識:
操作系統(tǒng)層面提供了兩個分配內(nèi)存的函數(shù) —— brk 和 mmap。brk 用于操作堆內(nèi)存(進(jìn)程的堆內(nèi)存,而不是 Java Heap);mmap 將文件或者其它對象映射進(jìn)內(nèi)存,JVM 就是直接調(diào)用該函數(shù)申請 Java Heap。
glibc 的 malloc 內(nèi)存分配器處在用戶進(jìn)程和內(nèi)核之間,也是通過這兩個函數(shù)向內(nèi)核申請內(nèi)存,在此基礎(chǔ)上還提供了動態(tài)內(nèi)存管理的功能。為了保持高效的分配,分配器一般都會預(yù)先分配一塊大于用戶請求的內(nèi)存,并通過某種算法管理這塊內(nèi)存;用戶釋放掉的內(nèi)存也并不是立即就返回給操作系統(tǒng),相反,分配器會管理這些被釋放掉的空閑空間,以應(yīng)對用戶以后的內(nèi)存分配請求。也就是說,分配器不但要管理已分配的內(nèi)存塊,還需要管理空閑的內(nèi)存塊,當(dāng)響應(yīng)用戶分配要求時,分配器會首先在空閑空間中尋找一塊合適的內(nèi)存給用戶,在空閑空間中找不到的情況下才分配一塊新的內(nèi)存。
回到 NMT 的信息,發(fā)現(xiàn)存在 42 個 Thread(包含了process reaper),這個數(shù)字比預(yù)想的大很多,由于線程棧默認(rèn)為 1MB,所以這就占用了 (42 - 1) * (1024 + 4) = 42148KB。線程棧的大小可以通過 -Xss 參數(shù)控制,但這不會造成 VIRT 數(shù)值過高,還是得尋找 malloc 分配的虛擬內(nèi)存地址空間。
很遺憾在 NMT 中關(guān)于 Thread 中的 malloc 操作雖然打印了調(diào)用堆棧,但是沒有打印地址空間。
[0x00007f7041a4baa5] Thread::allocate(unsigned long, bool, MemoryType)+0x2f5
[0x00007f704170490f] JVM_StartThread+0x23f
[0x00007f702d017a94]
(malloc=32KB #11)
根據(jù)調(diào)用棧找到 hotspot 代碼中的具體實現(xiàn):
// hotspot/src/share/vm/memory/allocation.inline.hpp
// Explicit C-heap memory management
// allocate using malloc; will fail if no memory available
inline char* AllocateHeap(size_t size, MEMFLAGS flags,
const NativeCallStack& stack,
AllocFailType alloc_failmode = AllocFailStrategy::EXIT_OOM) {
char* p = (char*) os::malloc(size, flags, stack);
#ifdef ASSERT
if (PrintMallocFree) trace_heap_malloc(size, "AllocateHeap", p);
#endif
if (p == NULL && alloc_failmode == AllocFailStrategy::EXIT_OOM) {
vm_exit_out_of_memory(size, OOM_MALLOC_ERROR, "AllocateHeap");
}
return p;
}
實際上調(diào)用的就是 glibc 的 malloc 函數(shù)。
沒有打印 malloc 分配的地址空間,那就只能硬著頭皮挨個地址段對比了。。。 但是由于 Java 程序的地址空間太多,完全沒有頭緒,所以還是先寫個簡單的 C 程序模擬一下。
測試代碼來自《Understanding glibc malloc》
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>
void* threadFunc(void* arg) {
printf("Before malloc in thread 1\n");
getchar();
char* addr = (char*) malloc(1000);
printf("After malloc and before free in thread 1\n");
getchar();
free(addr);
printf("After free in thread 1\n");
getchar();
}
int main() {
pthread_t t1;
void* s;
int ret;
char* addr;
printf("Welcome to per thread arena example::%d\n",getpid());
printf("Before malloc in main thread\n");
getchar();
addr = (char*) malloc(1000);
printf("After malloc and before free in main thread\n");
getchar();
free(addr);
printf("After free in main thread\n");
getchar();
ret = pthread_create(&t1, NULL, threadFunc, NULL);
if(ret)
{
printf("Thread creation error\n");
return -1;
}
ret = pthread_join(t1, &s);
if(ret)
{
printf("Thread join error\n");
return -1;
}
return 0;
}
當(dāng)打印出 'After malloc and before free in thread 1' 之后,發(fā)現(xiàn) /proc/[PID]/smaps 多了一些地址空間:
7fc460000000-7fc460021000 rw-p 00000000 00:00 0
Size: 132 kB
Rss: 4 kB
Pss: 4 kB
Shared_Clean: 0 kB
Shared_Dirty: 0 kB
Private_Clean: 0 kB
Private_Dirty: 4 kB
Referenced: 4 kB
Anonymous: 4 kB
AnonHugePages: 0 kB
Swap: 0 kB
KernelPageSize: 4 kB
MMUPageSize: 4 kB
VmFlags: rd wr mr mw me nr
7fc460021000-7fc464000000 ---p 00000000 00:00 0
Size: 65404 kB
Rss: 0 kB
Pss: 0 kB
Shared_Clean: 0 kB
Shared_Dirty: 0 kB
Private_Clean: 0 kB
Private_Dirty: 0 kB
Referenced: 0 kB
Anonymous: 0 kB
AnonHugePages: 0 kB
Swap: 0 kB
KernelPageSize: 4 kB
MMUPageSize: 4 kB
VmFlags: mr mw me nr
7fc467a45000-7fc468448000 rw-p 00000000 00:00 0
Size: 10252 kB
Rss: 20 kB
Pss: 20 kB
Shared_Clean: 0 kB
Shared_Dirty: 0 kB
Private_Clean: 0 kB
Private_Dirty: 20 kB
Referenced: 20 kB
Anonymous: 20 kB
AnonHugePages: 0 kB
Swap: 0 kB
KernelPageSize: 4 kB
MMUPageSize: 4 kB
VmFlags: rd wr mr mw me ac
7fc467a45000-7fc468448000 是線程棧的空間,通過ulimit 查看 stack size 默認(rèn)值為 10240,數(shù)值上是匹配的。
7fc460000000-7fc460021000 和 7fc460021000-7fc464000000 合起來的大小為 65536,這個與 glibc malloc 分配器中 arena 的默認(rèn)大小匹配。(arena 是分配器對分配區(qū)的封裝,這里只要理解為一個內(nèi)存分配區(qū)域即可,也就是一段地址空間,其中具體細(xì)節(jié)詳見——《Glibc 內(nèi)存管理剖析--Ptmalloc2源碼分析》)
HEAP_MAX_SIZE = (2 * DEFAULT_MMAP_THRESHOLD_MAX)
32-bit DEFAULT_MMAP_THRESHOLD_MAX = (512 * 1024)
64-bit DEFAULT_MMAP_THRESHOLD_MAX = (4 * 1024 * 1024 * sizeof(long))
也就是說在線程中調(diào)用 malloc 分配內(nèi)存,默認(rèn)會先分配 65536KB (HEAP_MAX_SIZE)大小的虛擬內(nèi)存地址空間。
4. malloc 多線程優(yōu)化的副作用
回過頭來看 Java 程序的地址空間:
7f6ff4000000-7f6ff4021000 rw-p 00000000 00:00 0
Size: 132 kB
Rss: 4 kB
Pss: 4 kB
Shared_Clean: 0 kB
Shared_Dirty: 0 kB
Private_Clean: 0 kB
Private_Dirty: 4 kB
Referenced: 4 kB
Anonymous: 4 kB
AnonHugePages: 0 kB
Swap: 0 kB
KernelPageSize: 4 kB
MMUPageSize: 4 kB
VmFlags: rd wr mr mw me nr
7f6ff4021000-7f6ff8000000 ---p 00000000 00:00 0
Size: 65404 kB
Rss: 0 kB
Pss: 0 kB
Shared_Clean: 0 kB
Shared_Dirty: 0 kB
Private_Clean: 0 kB
Private_Dirty: 0 kB
Referenced: 0 kB
Anonymous: 0 kB
AnonHugePages: 0 kB
Swap: 0 kB
KernelPageSize: 4 kB
MMUPageSize: 4 kB
VmFlags: mr mw me nr
也存在這樣的 65536 KB 大小的地址空間,粗略統(tǒng)計一下有 41 個這樣的 arena,僅僅這些 arena 就占了 2686976KB 的地址空間,難怪 VIRT 數(shù)值這么高,但是表征物理內(nèi)存占用的 Rss 卻是特別小,因為 Java 層面的對象都是管理在 Java Heap 中,而不是線程分配的這塊區(qū)域。
41 這個數(shù)字有點兒奇怪,跟線程數(shù)是匹配上,這不是巧合,測試機(jī)器的 glibc 版本為 2.12,其中 malloc 的具體實現(xiàn)是 Wolfram Gloger 的 ptmalloc2,其中對多線程內(nèi)存分配進(jìn)行了優(yōu)化:由于多個線程會并發(fā)申請內(nèi)存,如果只有一個 arena,那么并發(fā)請求需要加鎖,就會影響性能,所以 ptmalloc2 對每個線程分配了單獨(dú)的 arena。
但是由于線程可能太多,所以對 arena 的數(shù)量進(jìn)行了限制,32位機(jī)器的默認(rèn)值為CPU核數(shù)的兩倍,64位機(jī)器的默認(rèn)值為CPU核心數(shù)的8倍,對于24核的機(jī)器,最多可以分配 24 * 8 = 192 個 arean,那么某些線程比較多的程序,VIRT 這個指標(biāo)會更高,而物理內(nèi)存占用卻正常。
除了 glibc 基于 CPU 核數(shù)的自動限制策略,環(huán)境變量 MALLOC_ARENA_MAX 可以手工限制最大的 arena 數(shù)量,設(shè)置過小可能會加劇并發(fā)內(nèi)存分配的鎖爭用,但是采用默認(rèn)值看起來又太大。感覺可以設(shè)置小一點兒,但是通常來說,虛擬地址過大也沒有太明顯的負(fù)面影響。
5. 降低虛擬內(nèi)存地址占用
對于 agent 類的應(yīng)用,盡可能少占用宿主機(jī)的資源還是有必要的,所以我們嘗試控制一下虛擬內(nèi)存地址占用,通過以上的分析,解決思路已經(jīng)很明顯了:
1. 通過 MALLOC_ARENA_MAX 限制 arena 數(shù)量上限
2. 減少線程數(shù)量
具體來說:
- 設(shè)置 MALLOC_ARENA_MAX=2
- 設(shè)置 -XX:CICompilerCount=2
由于GC線程數(shù)量與CPU核數(shù)成正比,所以我們已經(jīng)通過 -XX:ConcGCThreads 和 -XX:ParallelGCThreads 限制了GC的線程數(shù),但是看到 41 個線程也是超出了我們的預(yù)期,查了一下發(fā)現(xiàn)是JIT編譯器線程過多,所以通過 -XX:CICompilerCount 限制一下。
修改業(yè)務(wù)方機(jī)器上的啟動腳本:
export MALLOC_ARENA_MAX=2
nohup $JAVA_HOME/bin/java -server \
-XX:CICompilerCount=2 \
-XX:+UseG1GC \
-XX:ConcGCThreads=1 \
-XX:ParallelGCThreads=4 \
-Xmx3g \
-Xms3g \
-Xmn1g \
-XX:+ExplicitGCInvokesConcurrent \
-XX:MaxDirectMemorySize=256m \
-XX:MaxMetaspaceSize=64m \

當(dāng)前 VIRT 值為 4806MB,相比之前的 8725MB,減少了 3919MB (45%),并且性能基本沒有影響。