
阿里妹導讀
本文詳細記錄和分析了在應用升級到JDK 11後,由於堆外記憶體(Direct Memory)管理策略的變化導致的記憶體利用率告警問題。
背景
近期,我們應用開始出現sunfire記憶體利用率的告警,規律是應用重啟後,記憶體利用率緩慢增長,一段時間不重啟後,就會出現告警,一開始看到記憶體利用率第一反應是堆記憶體利用率的問題,走了一些彎路,最終發現是堆外記憶體的影響,本文主要記錄和總結該問題的排查過程。

環境
-
JDK版本:ajdk11_11.0.14.13_fp2
-
netty版本:4.1.31.Final
問題分析
記憶體利用率
登陸機器,使用 free -m 檢視記憶體使用情況,可以看到記憶體利用率為76.5% = 6269/8192,不過這裡有一個問題,這個76.5%和sufire上的82%是對不上的,原因是我們登陸機器後看到的是業務容器記憶體利用率,在sunfire上面選擇單機就能分別看到POD、業務容器、運維容器利用率。



業務容器記憶體利用率
透過sunfire觀察到運維容器記憶體利用率一直是比較穩定,重點需要分析的業務記憶體利用率,使用top命名檢視各程序的記憶體使用情況,可以看到JAVA應用就佔了74.3%,接下來繼續分析JAVA的記憶體分佈了。

JAVA程序記憶體

可以看到,JAVA程序記憶體主要可以分為堆記憶體和非堆記憶體/堆外記憶體
Java 堆記憶體
1.定義:
-
Java 堆記憶體是 JVM 用來儲存所有 Java 物件的記憶體區域,所有透過 new 關鍵字建立的物件以及陣列都在此區域分配記憶體。
2.配置:
-
Java 堆記憶體由 JVM 的垃圾回收器(GC)自動管理,負責回收不再被引用的物件以釋放記憶體。
-
堆記憶體的使用情況可以透過 JVM 引數 -Xms 和 -Xmx 來配置,其中:
-
-Xms 設定初始堆大小。
-
-Xmx 設定最大堆大小。
3.構成:
-
堆記憶體通常被分為兩個主要部分:新生代(Young Generation)和老年代(Old Generation)。
-
新生代:包含新建立的物件,消費垃圾回收頻繁。由於新物件大多數是短命的,因此 GC 處理頻率較高。
-
老年代:存放長生命週期的物件,GC 處理不如新生代頻繁。
非堆記憶體/堆外記憶體
非堆記憶體:
-
非堆記憶體是指不受 Java 垃圾回收管理的記憶體區域,包括方法區、Java 方法棧區、native 堆(C heap)等。
-
特別強調的是,方法區(Metaspace 區域在現代 JVM 中),儲存類的元資料和靜態資訊,也被視為非堆記憶體。
堆外記憶體(Direct Memory):
定義
堆外記憶體通常指直接記憶體(Direct Memory),可以透過 java.nio.ByteBuffer 的 allocateDirect() 方法分配,它包含Mapped Buffer pool和Direct Buffer pool。與 Java 堆記憶體相比,堆外記憶體不受垃圾回收的影響,因此可以減少 Full GC 對應用效能的影響,但需要手動管理記憶體生命週期。在sunfire中,堆外記憶體的資料來自JMX的介面,可透過java.nio:type=BufferPool,name=direct和java.nio:type=BufferPool,name=mapped查詢出來。
配置
堆外記憶體可以透過 JVM 引數MaxDirectMemorySize來配置
-
-XX:MaxDirectMemorySize=
堆外記憶體的優勢
1.降低延遲:
-
使用堆外記憶體可以避免因 Full GC 導致的 Stop-The-World 現象,從而減少應用的暫停時間。
2.提高效率:
-
透過減少 Java 堆和原生堆之間的資料複製,可以提高資料的讀寫效率。例如,在使用 NIO 進行大檔案操作時,堆外記憶體可以直接進行記憶體對映,提高訪問速度。許多大資料處理框架,如 Spark、Flink 和 Kafka,利用堆外記憶體以提高效能和資源利用率。例如,Netty 作為一套高效能網路通訊框架,也大量使用了堆外記憶體來實現高效的資料傳輸。
Reserved (保留記憶體)/Committed (承諾記憶體)/Resident (常駐記憶體)
在分析下面的問題之前,我們先理解三個記憶體相關的名詞,來幫助我們理解接下來的問題,在計算機系統中,特別是在涉及記憶體管理的上下文中,"Reserved"、"Committed" 和 "Resident" 是三個不同的術語,主要用於描述記憶體的使用情況。以下是對這三個術語的解釋:
1.Reserved (保留記憶體):
-
保留記憶體是指作業系統已經為某個應用程式預留的虛擬記憶體地址空間,但並沒有實際分配物理記憶體。換句話說,保留的記憶體區域可以被應用程式使用,但在實際使用之前,作業系統不必立刻為其分配物理 RAM。保留記憶體的目的是為了保證應用程式可以在將來使用這些地址,而不會與其他應用程式發生衝突。
2.Committed (承諾記憶體):
-
承諾記憶體是指已經分配並實際使用的記憶體。這部分記憶體可以被視為已承諾給應用程式使用的物理記憶體,作業系統為其分配了物理 RAM。簡單來說,承諾記憶體就是已經被分配並實際存在於物理記憶體中的那部分記憶體。
3.Resident (常駐記憶體):
-
常駐記憶體指的是當前在物理 RAM 中駐留的記憶體頁。這部分記憶體是已經承諾並分配的記憶體,且確實載入到了物理記憶體中。常駐記憶體與承諾記憶體的主要區別在於,承諾記憶體不一定在物理記憶體中,可能會被交換到磁碟上,而常駐記憶體則永遠是在物理記憶體中。
在本文中我們監控的記憶體利用率的指標,是統計的Resident (常駐記憶體)。
哪塊記憶體區域的變化導致了記憶體利用率的增長
瞭解到了java程序的記憶體主要構成,那再回到一開始的問題,到底是什麼原因導致了pod記憶體利用率的告警,透過每個指標的對比,就能很快的發現堆外記憶體的增長和記憶體利用率的陡升是同步的,檢視當天做了什麼變更,進行了JDK11升級的釋出,那麼問題就回到了為什麼JDK11的升級會引發記憶體利用率的陡升呢?


為什麼JDK11的升級會引發記憶體利用率的陡升
1.決策記憶體管理策略
之前JDK8時,USE_DIRECT_BUFFER_NO_CLEANER = true,走noCleaner(PlatformDependent.allocateDirectNoCleaner)的分支,升級到JDK11後,走hasCleaner(ByteBuffer.allocateDirect)的分支。
if (maxDirectMemory == 0
|| !hasUnsafe()
|| !PlatformDependent0.hasDirectBufferNoCleanerConstructor()) {
USE_DIRECT_BUFFER_NO_CLEANER = false;
} else {
USE_DIRECT_BUFFER_NO_CLEANER = true;
}
privatestatic ByteBuffer allocateDirect(int capacity){
return PlatformDependent.useDirectBufferNoCleaner() ?
PlatformDependent.allocateDirectNoCleaner(capacity) : ByteBuffer.allocateDirect(capacity);
}
2.NoCleaner策略(PlatformDependent.allocateDirectNoCleaner)
UNSAFE.allocateMemory這一行程式碼會呼叫native方法allocateMemory劃分一塊承諾記憶體 (Committed)
// 核心程式碼
newDirectBuffer(UNSAFE.allocateMemory(capacity), capacity)
3.HasCleaner策略(ByteBuffer.allocateDirect)
可以看到HasCleaner策略,除了執行UNSAFE.allocateMemory外匯,還會執行UNSAFE.setMemory(base, size, (byte) 0)這一行程式碼,這就是堆外記憶體增長的核心原因了; 這個方法背後會呼叫native方法setMemory,找到承諾並分配的記憶體載入到RAM物理記憶體中成為Resident記憶體。
DirectByteBuffer(int cap) {
super(-1, 0, cap, cap);
boolean pa = VM.isDirectMemoryPageAligned();
int ps = Bits.pageSize();
long size = Math.max(1L, (long)cap + (pa ? ps : 0));
Bits.reserveMemory(size, cap);
long base = 0;
try {
base = UNSAFE.allocateMemory(size);
} catch (OutOfMemoryError x) {
Bits.unreserveMemory(size, cap);
throw x;
}
UNSAFE.setMemory(base, size, (byte) 0);
if (pa && (base % ps != 0)) {
// Round up to page boundary
address = base + ps - (base & (ps - 1));
} else {
address = base;
}
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
att = null;
}
為什麼JDK8升級到JDK11之後USE_DIRECT_BUFFER_NO_CLEANER = false
可以看到USE_DIRECT_BUFFER_NO_CLEANER依賴於maxDirectMemory、hasUnsafe() 、PlatformDependent0.hasDirectBufferNoCleanerConstructor(),透過觀察日誌應用啟動日誌(其實在升級JDK11的時候就會新增這個debug級別的告警日誌但被忽略了-.-)發現maxDirectMemory和hasUnsafe()在JDK8和JDK11是一致的,不一樣的就是PlatformDependent0.hasDirectBufferNoCleanerConstructor這個方法的返回值,下面我們看下為什麼hasDirectBufferNoCleanerConstructor返回值不一樣。
if (maxDirectMemory == 0
|| !hasUnsafe()
|| !PlatformDependent0.hasDirectBufferNoCleanerConstructor()) {
USE_DIRECT_BUFFER_NO_CLEANER = false;
} else {
USE_DIRECT_BUFFER_NO_CLEANER = true;
}

當DirectBuffer構造器不為null時,hasDirectBufferNoCleanerConstructor返回true,就會走到else分支 設定USE_DIRECT_BUFFER_NO_CLEANER = true; 而當DIRECT_BUFFER_CONSTRUCTOR不為null,需要ReflectionUtil.trySetAccessible設定成功。
final Object maybeDirectBufferConstructor =
AccessController.doPrivileged(new PrivilegedAction<Object>() {
@Override
public Object run() {
try {
final Constructor<?> constructor =
direct.getClass().getDeclaredConstructor(long.class, int.class);
Throwable cause = ReflectionUtil.trySetAccessible(constructor, true);
if (cause != null) {
return cause;
}
return constructor;
} catch (NoSuchMethodException e) {
return e;
} catch (SecurityException e) {
return e;
}
}
});
DIRECT_BUFFER_CONSTRUCTOR = directBufferConstructor
由於預設沒設定io.netty.tryReflectionSetAccessible的值,當java版本低於JDK9時,返回了true,也就是說之前是JDK8,ReflectionUtil.trySetAccessible設定成功了,所以DIRECT_BUFFER_CONSTRUCTOR不為null,走到else分支 設定USE_DIRECT_BUFFER_NO_CLEANER = true,升級到JDK11後就走到了if分支USE_DIRECT_BUFFER_NO_CLEANER = false
publicstatic Throwable trySetAccessible(AccessibleObject object, boolean checkAccessible){
if (checkAccessible && !PlatformDependent0.isExplicitTryReflectionSetAccessible()) {
returnnew UnsupportedOperationException("Reflective setAccessible(true) disabled");
}
try {
object.setAccessible(true);
return null;
} catch (SecurityException e) {
return e;
} catch (RuntimeException e) {
return handleInaccessibleObjectException(e);
}
}
privatestatic boolean explicitTryReflectionSetAccessible0(){
// we disable reflective access
return SystemPropertyUtil.getBoolean("io.netty.tryReflectionSetAccessible", javaVersion() < 9);
}
publicstatic boolean getBoolean(String key, boolean def){
String value = get(key);
if (value == null) {
return def;
}
value = value.trim().toLowerCase();
if (value.isEmpty()) {
return def;
}
if ("true".equals(value) || "yes".equals(value) || "1".equals(value)) {
returntrue;
}
if ("false".equals(value) || "no".equals(value) || "0".equals(value)) {
returnfalse;
}
logger.warn(
"Unable to parse the boolean system property '{}':{} - using the default value: {}",
key, value, def
);
return def;
}
應該選擇HasCleaner還是NoCleaner策略
一般建議採用NoCleaner策略,即使當前應該還沒有達到記憶體利用率瓶頸。
因為noCleaner是Netty在4.1引入的策略:建立不帶Cleaner的DirectByteBuffer物件,這樣做的好處是繞開帶Cleaner的DirectByteBuffer執行構造方法和執行Cleaner的clean()方法中一些額外開銷,一方面可以減少Resident (常駐記憶體)的使用,另外當堆外記憶體不夠的時候,也不會觸發System.gc(),提高效能。
JDK11如何和JDK 8一樣採用NoCleaner策略
在Java啟動引數增加如下部分:
-
新增jvm引數 -Dio.netty.tryReflectionSetAccessible=true 引數
-
新增jvm引數 –add-opens=java.base/jdk.internal.misc=ALL-UNNAMED引數 開啟Unsafe許可權
-
新增jvm引數 –add-opens=java.base/java.nio=ALL-ALL-UNNAMED開啟nio的包訪問限制
JVM新增以上引數後,我門再來看一下記憶體變化,java程序RES記憶體減少了500多MB,也就是對應堆外記憶體佔用的Resident (常駐記憶體)。
新增引數前:
TOP監控
Resident記憶體4.8G

NMT監控
$jcmd 8607 VM.native_memory scale=MB
8607:
Native Memory Tracking:
Total: reserved=7359MB, committed=5538MB
- Java Heap (reserved=3840MB, committed=3840MB)
(mmap: reserved=3840MB, committed=3840MB)
- Class (reserved=1227MB, committed=527MB)
(classes #83975)
( instance classes #80682, array classes #3293)
(malloc=23MB #393787)
(mmap: reserved=1204MB, committed=504MB)
( Metadata: )
( reserved=444MB, committed=443MB)
( used=432MB)
( free=11MB)
( waste=0MB =0.00%)
( Class space:)
( reserved=760MB, committed=61MB)
( used=55MB)
( free=6MB)
( waste=0MB =0.00%)
- Thread (reserved=1183MB, committed=134MB)
(thread #1173)
(stack: reserved=1178MB, committed=128MB)
(malloc=4MB #7040)
(arena=1MB #2345)
- Code (reserved=256MB, committed=184MB)
(malloc=15MB #51500)
(mmap: reserved=242MB, committed=170MB)
- GC (reserved=26MB, committed=26MB)
(malloc=15MB #13986)
(mmap: reserved=11MB, committed=11MB)
- Compiler (reserved=6MB, committed=6MB)
(malloc=6MB #4322)
- Internal (reserved=72MB, committed=72MB)
(malloc=72MB #94258)
- Other (reserved=525MB, committed=525MB)
(malloc=525MB #447)
- Symbol (reserved=73MB, committed=73MB)
(malloc=69MB #977115)
(arena=4MB #1)
- Native Memory Tracking (reserved=25MB, committed=25MB)
(tracking overhead=25MB)
- Arena Chunk (reserved=55MB, committed=55MB)
(malloc=55MB)
- Module (reserved=8MB, committed=8MB)
(malloc=8MB #45253)
- Synchronizer (reserved=2MB, committed=2MB)
(malloc=2MB #15079)
- (null) (reserved=60MB, committed=60MB)
(mmap: reserved=60MB, committed=60MB)
arthas監控
direct堆外記憶體526 MB = 551703298 B/(1024*1024)
[arthas@8607]$ mbean java.nio:name=direct,type=BufferPool
OBJECT_NAME java.nio:name=direct,type=BufferPool
-----------------------------------------------------
NAME VALUE
-----------------------------------------------------
TotalCapacity 551703289
MemoryUsed 551703298
Name direct
Count 202
ObjectName java.nio:type=BufferPool,name=direct
mapped堆外記憶體幾乎沒有:
OBJECT_NAME java.nio:name=mapped,type=BufferPool
-----------------------------------------------------
NAME VALUE
-----------------------------------------------------
TotalCapacity 1024
MemoryUsed 1024
Name mapped
Count 1
ObjectName java.nio:type=BufferPool,name=mapped
新增引數後:
TOP監控
Resident記憶體4.2G,對比新增引數前減少了近0.6G(實際為500多 MB,這裡單位為GB小數位截斷導致),剛好和新增引數前的堆外記憶體526 MB差不多。

NMT監控
可以看到NMT監控到的JVM記憶體幾乎沒有變化,原因在於NMT監控的是reserved和committed記憶體,netty無論用哪種方式管理記憶體,都會在初始化時執行UNSAFE.allocateMemory這一行程式碼劃分一塊承諾記憶體 (Committed)。
jcmd 9964 VM.native_memory scale=MB
9964:
Native Memory Tracking:
Total: reserved=7214MB, committed=5399MB
- Java Heap (reserved=3840MB, committed=3840MB)
(mmap: reserved=3840MB, committed=3840MB)
- Class (reserved=1202MB, committed=501MB)
(classes #81265)
( instance classes #78038, array classes #3227)
(malloc=14MB #260100)
(mmap: reserved=1188MB, committed=486MB)
( Metadata: )
( reserved=428MB, committed=428MB)
( used=418MB)
( free=10MB)
( waste=0MB =0.00%)
( Class space:)
( reserved=760MB, committed=59MB)
( used=54MB)
( free=5MB)
( waste=0MB =0.00%)
- Thread (reserved=1154MB, committed=130MB)
(thread #1144)
(stack: reserved=1148MB, committed=124MB)
(malloc=4MB #6866)
(arena=1MB #2287)
- Code (reserved=255MB, committed=165MB)
(malloc=13MB #47242)
(mmap: reserved=242MB, committed=152MB)
- GC (reserved=26MB, committed=26MB)
(malloc=15MB #11967)
(mmap: reserved=11MB, committed=11MB)
- Compiler (reserved=5MB, committed=5MB)
(malloc=4MB #3623)
- Internal (reserved=45MB, committed=45MB)
(malloc=45MB #28840)
- Other (reserved=525MB, committed=525MB)
(malloc=525MB #452)
- Symbol (reserved=72MB, committed=72MB)
(malloc=68MB #959726)
(arena=4MB #1)
- Native Memory Tracking (reserved=21MB, committed=21MB)
(tracking overhead=21MB)
- Module (reserved=7MB, committed=7MB)
(malloc=7MB #38082)
- Synchronizer (reserved=2MB, committed=2MB)
(malloc=2MB #12973)
- (null) (reserved=60MB, committed=60MB)
(mmap: reserved=60MB, committed=60MB)
arthas監控
direct堆外記憶體10 MB = 10526012 B/(1024*1024)
[arthas@10089]$ mbean java.nio:name=direct,type=BufferPool
OBJECT_NAME java.nio:name=direct,type=BufferPool
-----------------------------------------------------
NAME VALUE
-----------------------------------------------------
TotalCapacity 10526004
MemoryUsed 10526012
Name direct
Count 147
ObjectName java.nio:type=BufferPool,name=direct
mapped堆外記憶體仍然幾乎沒有
OBJECT_NAME java.nio:name=mapped,type=BufferPool
-----------------------------------------------------
NAME VALUE
-----------------------------------------------------
TotalCapacity 1024
MemoryUsed 1024
Name mapped
Count 1
ObjectName java.nio:type=BufferPool,name=mapped
sunfire監控
堆外記憶體:新增引數後可以看到堆外記憶體明顯下降,和arthas監控的mbean堆外記憶體資料一致。

記憶體利用率:記憶體利用率明顯下降,恢復到JDK11升級前水位。

參考文件:
https://aliyuque.antfin.com/sunfire/manual/nle3ub
https://aliyuque.antfin.com/sigmahost/bdrtdk/sco1i3#sZ2dH
https://aliyuque.antfin.com/sunfire/manual/sg4g44oz4m0tr058#tIBNI
https://github.com/netty/netty/pull/7650
https://stackoverflow.com/questions/57885828/netty-cannot-access-class-jdk-internal-misc-unsafe/57892679
https://stackoverflow.com/questions/31173374/why-does-a-jvm-report-more-committed-memory-than-the-linux-process-resident-set
https://stackoverflow.com/questions/41468670/difference-in-used-committed-and-max-heap-memory
https://stackoverflow.com/questions/71366522/how-does-java-guarateee-reserved-memory
https://www.youtube.com/watch?v=c755fFv1Rnk&t=1615s
https://www.pengzna.top/article/Java-Memory/
https://www.cnblogs.com/stateis0/p/9062152.html
https://www.cnblogs.com/exmyth/p/14205361.htmls
雲上經典架構serverless版
本方案採用雲上的Serverless架構,原生支援彈性伸縮、按量付費和服務託管,減少企業手動資源管理和效能成本最佳化的工作,同時透過高可用的配置,避免可能遇到的單點故障風險。
點選閱讀原文檢視詳情。