引言
在 Java 性能調優的工具箱中,async-profiler 是一款備受青睞的低開銷采樣分析器。它不僅能分析 CPU 熱點,還能精確追蹤內存分配情況。本文將深入探討 async-profiler 實現內存采樣的多種機制,結合代碼示例解析其工作原理。
為什么需要內存采樣?
在排查 Java 應用的內存問題時,我們常常需要回答這些問題:
- 哪些對象占用了最多的堆內存?
- 哪些代碼路徑產生了大量臨時對象?
- 垃圾回收頻繁的根源是什么?
async-profiler 的內存采樣功能能夠追蹤對象分配的位置和大小,幫助我們定位內存泄漏和過度分配問題。
JVM 內存分配基礎
在深入 async-profiler 的實現之前,先簡要了解 JVM 的內存分配機制:
- TLAB(Thread Local Allocation Buffer):每個線程獨享的小型內存區域,用于快速分配小型對象
- 大對象直接分配:超過 TLAB 大小的對象會直接在堆上分配
- 棧上分配:某些情況下,對象可以直接在棧上分配,避免堆內存壓力
Async-profiler 內存采樣的多種機制
機制一:JVMTI ObjectSample 事件(JDK 11+)
JVMTI(Java Virtual Machine Tool Interface)提供了 ObjectSample 事件,允許在對象分配時觸發回調。這是最直接的內存采樣方式,但在 JDK 11 之前存在局限性。
// JVMTI ObjectSample 事件監聽示例
public class AllocationListener {public static void main(String[] args) throws Exception {// 通過JVMTI注冊對象分配事件Agent.setObjectAllocationCallback((thread, classDesc, size) -> {System.out.printf("分配對象: %s, 大小: %d 字節\n", classDesc, size);});// 應用代碼繼續執行// ...}
}
局限性:
- 在 JDK 11 之前,只能捕獲大對象(超過 TLAB 大小)的分配
- 啟用該事件會帶來顯著的性能開銷
機制二:二進制插樁(JDK 11 之前的主要方式)
對于 JDK 11 之前的版本,async-profiler 采用更底層的二進制插樁技術,直接修改 HotSpot VM 的代碼。
關鍵步驟:
1. 定位目標函數:在 HotSpot VM 的二進制代碼中找到關鍵的內存分配函數
if ((ne = libjvm->findSymbolByPrefix("_ZN11AllocTracer27send_allocation_in_new_tlab")) != NULL &&(oe = libjvm->findSymbolByPrefix("_ZN11AllocTracer28send_allocation_outside_tlab")) != NULL) {_trap_kind = 1; // JDK 10+} else if ((ne = libjvm->findSymbolByPrefix("_ZN11AllocTracer33send_allocation_in_new_tlab_eventE11KlassHandleP8HeapWord")) != NULL &&(oe = libjvm->findSymbolByPrefix("_ZN11AllocTracer34send_allocation_outside_tlab_eventE11KlassHandleP8HeapWord")) != NULL) {_trap_kind = 1; // JDK 8u262+} else if ((ne = libjvm->findSymbolByPrefix("_ZN11AllocTracer33send_allocation_in_new_tlab_event")) != NULL &&(oe = libjvm->findSymbolByPrefix("_ZN11AllocTracer34send_allocation_outside_tlab_event")) != NULL) {_trap_kind = 2; // JDK 7-9} else {return Error("No AllocTracer symbols found. Are JDK debug symbols installed?");}
這個步驟需要JDK的Debug Symbols,所以很多系統比如Alpine運行的java應用就不支持內存采樣,因為Alpine的SDK為了精簡體積默認都不包含Debug Symbols。
2. 插入陷阱指令:在函數入口處寫入跳轉指令,指向自定義的處理函數
# 偽代碼:在目標函數起始位置寫入跳轉指令
push <trap_handler_address>
ret
3. 陷阱處理函數:收集分配信息并采樣堆棧
// 陷阱處理函數
void trap_handler(KlassHandle klass, HeapWord* obj) {// 獲取對象大小size_t size = get_object_size(klass);// 采樣當前線程的堆棧void* stack[100];int depth = capture_stacktrace(stack, 100);// 記錄分配事件record_allocation(obj, size, stack, depth);// 跳回原始函數繼續執行execute_original_instructions();
}
4. 恢復原始代碼:采樣結束后恢復原始指令,減少對性能的影響
這種方法雖然強大,但也有明顯缺點:
- 與特定 JDK 版本深度耦合,兼容性差
- 需要JDK包含Debug Symbols,很多系統比如Alpine的SDK都支持
- 需要 root 權限才能修改運行中的 VM 進程
- 實現復雜,稍有不慎就可能導致 JVM 崩潰
機制三:LD_PRELOAD 技術(針對堆外內存)
對于 Java 堆外內存分配(如 JNI 調用),async-profiler 使用 LD_PRELOAD 技術攔截 C 庫的內存分配函數。
// preload.c - 使用LD_PRELOAD攔截malloc
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <dlfcn.h>// 原始malloc函數指針
static void* (*real_malloc)(size_t) = NULL;// 自定義malloc函數
void* malloc(size_t size) {// 首次調用時獲取原始malloc函數地址if (!real_malloc) {real_malloc = dlsym(RTLD_NEXT, "malloc");}// 記錄分配前的時間和堆棧void* ptr = real_malloc(size);// 記錄分配信息record_allocation(ptr, size, get_current_stack());return ptr;
}
使用方式:
# 編譯共享庫
gcc -shared -fPIC preload.c -o preload.so -ldl# 運行Java程序時加載攔截庫
LD_PRELOAD=./preload.so java YourMainClass
機制四:DTrace/SystemTap(特定平臺)
在支持 DTrace 或 SystemTap 的系統中,async-profiler 可以使用這些工具進行動態插樁。
DTrace 示例:
// 監控Java對象分配的DTrace腳本
hotspot$target:::object-allocated
{// 獲取對象類型和大小@allocations[copyinstr(arg1)] = sum(arg2);// 記錄堆棧trace(arg0);ustack();
}
運行方式:
dtrace -s alloc.d -p <java_pid>
這種方法的優勢是無需修改 Java 程序或 VM,但依賴特定平臺支持。
Async-profiler 內存采樣實戰
下面通過一個簡單的 Java 程序,演示如何使用 async-profiler 進行內存采樣。
示例程序:
import java.util.ArrayList;
import java.util.List;public class MemoryAllocationDemo {public static void main(String[] args) throws InterruptedException {List<String> list = new ArrayList<>();// 生成大量字符串對象for (int i = 0; i < 1000000; i++) {list.add("Object-" + i);// 每10萬次分配休眠一下,方便我們進行采樣if (i % 100000 == 0) {Thread.sleep(100);}}System.out.println("分配完成,按任意鍵退出...");System.in.read();}
}
使用 async-profiler 進行內存采樣:
# 編譯Java程序
javac MemoryAllocationDemo.java# 運行程序
java MemoryAllocationDemo &# 獲取Java進程ID
PID=$!# 使用async-profiler進行10秒的內存分配采樣
./profiler.sh -e alloc -d 10 $PID# 生成火焰圖
./profiler.sh -e alloc -f allocation-flamegraph.svg $PID
總結
async-profiler 的內存采樣機制根據不同 JDK 版本和場景采用了多種技術:
- JVMTI ObjectSample:簡單直接,但在 JDK 11 之前功能有限
- 二進制插樁:強大但復雜,與特定 JDK 版本深度綁定,且需要SDK含有Debug Symbols
- LD_PRELOAD:適用于堆外內存分配的攔截
- DTrace/SystemTap:平臺特定但無需修改目標程序
理解這些機制有助于我們在不同場景下選擇最合適的工具和方法,更高效地解決 Java 應用的內存問題。