作者:閑魚技術——螢音
如無特殊說明,本文預設基于以下環境叙述:
JDK: OpenJDK 14GA
macOS 10.15
Arthas 3.3.9
VisualVM 2.0.2
從Arthas 3.4.2開始,此問題已經被修複。感謝Arthas團隊對此問題的重視。
背景
Arthas是一款由阿裡巴巴開源的Java應用程式診斷工具,它功能強大,且不需要對原有的應用做任何改動,即可幫助開發者全方位地觀測Java應用程式的運作狀态,特别是線上上服務不便于調試,問題複現機率低的場景下極大地友善了開發人員的調試工作,是以深受集團内外的開發者喜愛,筆者在工作中也經常使用Arthas幫助定位一些服務運作過程中的問題。
今年8月中旬,在工作中需要使用Arthas的trace指令統計一個有大量get set及多種接口調用的巨大方法,執行trace指令後,Arthas遲遲沒有顯示指令調用成功的提示,同時連接配接Arthas的終端失去了響應。嘗試重新連接配接Arthas,再次進行trace,結果卻彈出了trace失敗的提示:
Enhance error! exception: java.lang.InternalError
error happens when enhancing class: null, check arthas log: /path/to/server-log/arthas.log
于是檢視伺服器上的Arthas運作日志,發現日志中有以下的異常堆棧:
java.lang.InternalError: null
at sun.instrument.InstrumentationImpl.retransformClasses0(Native Method)
at sun.instrument.InstrumentationImpl.retransformClasses(InstrumentationImpl.java:144)
at com.taobao.arthas.core.advisor.Enhancer.enhance(Enhancer.java:368)
at com.taobao.arthas.core.command.monitor200.EnhancerCommand.enhance(EnhancerCommand.java:149)
at com.taobao.arthas.core.command.monitor200.EnhancerCommand.process(EnhancerCommand.java:96)
at com.taobao.arthas.core.shell.command.impl.AnnotatedCommandImpl.process(AnnotatedCommandImpl.java:82)
at com.taobao.arthas.core.shell.command.impl.AnnotatedCommandImpl.access$100(AnnotatedCommandImpl.java:18)
at com.taobao.arthas.core.shell.command.impl.AnnotatedCommandImpl$ProcessHandler.handle(AnnotatedCommandImpl.java:111)
// ...
幾乎同時,筆者收到了監控平台發出的目标機器Metaspace OOM的告警,檢視伺服器監控面闆,發現目前JVM的Metaspace已經爆滿。回到開發環境,再次嘗試了幾次相同操作,竟然是穩定複現Metaspace OOM。于是開始着手排查這個問題。
問題分析
初窺Metaspace結構
目标應用運作在集團基于OpenJDK 8深度定制的AliJDK上,查閱相關文檔知,它和普通的OpenJDK一樣,Metaspace是實作為堆外記憶體,是以傳統的Dump heap分析前後堆内對象數量變化的思路便行不通了,隻能先從Metaspace的存儲結構入手分析。
Metaspace 主要分為Non-Class space和Class space兩部分。他們的作用分别如下所示:
- Class space
存放Klass對象、vtable, itable, 以及記錄類中非靜态成員引用對象的位址的Map,等等。
- Klass對象是Java的類在JVM層次的運作時資料結構,當類被加載的時候,會産生一個描述目前類的InstanceKlass對象,這些Klass對象會儲存在Metaspace的Class space區域。在Java對象的對象頭中有指向對象所屬類的Klass對象的指針。
- vtable 是為了實作Java中的虛分派功能而存在。HotSpot把Java中的方法都抽象成了
對象,Method
中的成員屬性InstanceKlass
就儲存了目前類所有方法對應的_methods
執行個體。HotSpot并沒有顯式地把虛函數表設計為Method
的field,而是提供了一個虛函數表視圖。在Klass
檔案被解析的過程中會計算vtable的大小,在類被連接配接的時候會真正産生出vtable。.class
- itable 記錄的是當一個類有實作接口時,接口方法在vtable中的偏移量。在
檔案被解析的過程中會計算itable的大小,在類被連接配接的時候會真正産生出itable。.class
- Non-class Space
這個區域有很多的東西,下面這些占用了最多的空間:
- 常量池,可變大小(注意是class檔案中的常量池的結構化表示,而不是運作時的String常量);
- 每個成員方法的 Metadata:ConstMethod 結構,包含了好幾個可變大小的内部結構,如方法位元組碼、局部變量表、異常表、參數資訊、方法簽名等;
- 運作時資料,用來控制 JIT 的行為;
- 注解資料等等
檢視診斷指令輸出
了解Metaspace中主要存儲的資料後,便可以使用診斷指令去檢視Metaspace的記憶體占用情況。
對于JDK 8,可以使用指令
jstat -gc <pid>
而 高版本的 JDK (通常在JDK 12以後),
引入了VM.metaspace
診斷指令,
jcmd <pid> VM.metaspace
可以輸出更為全面的診斷資訊。
先看trace前的
jstat
輸出:
可以看到MU大約是95MB左右,CCSU大概在14MB左右。由于MU = Non-class Space + Class space, 是以Non-class space大概在80多MB。
如果使用了高版本的JDK,可以使用
VM.metaspace
指令檢視更詳細的結果:
可以看到資料符合之前的預期。接下來看一下trace後的診斷資訊:
發現Non-class區大小激增,而Class區大小及已加載的類數量沒有明顯變化。這一現象說明,引起Metaspace OOM的原因很可能是JVM在解析Arthas增強後的類位元組碼資料,向Non-class區放入新生成的方法、常量池等資料時申請了大量的Non-class空間導緻的。是以,接下來需要分析增強前後位元組碼的差別。
分析Arthas的指令執行過程
因為增強後的位元組碼是由Arthas輸出并注入到JVM的,在分析之前便需要搞清楚Arthas是如何産生增強後的位元組碼的。由于本例中的Arthas是以Agent方式運作的,是以直接看源碼,了解ArthasAgent的附加過程:
// arthas-agent-attach/src/main/java/com/taobao/arthas/agent/attach/ArthasAgent.java
public void init() throws IllegalStateException {
// ...
// 通過反射調用 ArthasBootstrap bootstrap = ArthasBootstrap.getInstance(inst);
Class<?> bootstrapClass = arthasClassLoader.loadClass(ARTHAS_BOOTSTRAP);
Object bootstrap = bootstrapClass.getMethod(GET_INSTANCE, Instrumentation.class, Map.class).invoke(null,instrumentation, configMap);
boolean isBind = (Boolean) bootstrapClass.getMethod(IS_BIND).invoke(bootstrap);
if (!isBind) {
String errorMsg = "Arthas server port binding failed! Please check $HOME/logs/arthas/arthas.log for more details.";
throw new RuntimeException(errorMsg);
}
// ...
}
最終會調用到ArthasBootstrap的構造方法:
private ArthasBootstrap(Instrumentation instrumentation, Map<String, String> args) throws Throwable {
// ...
shutdown = new Thread("as-shutdown-hooker") {
@Override
public void run() {
ArthasBootstrap.this.destroy();
}
};
// 這裡使用先前傳入的instrumentation構造類位元組碼的transformerManager。
transformerManager = new TransformerManager(instrumentation);
Runtime.getRuntime().addShutdownHook(shutdown);
}
跟入
TransformManager
可以看到注冊類位元組碼增強回調函數的代碼:
public TransformerManager(Instrumentation instrumentation) {
this.instrumentation = instrumentation;
classFileTransformer = new ClassFileTransformer() {
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
// ...
// TraceTransformer
for (ClassFileTransformer classFileTransformer : traceTransformers) {
byte[] transformResult = classFileTransformer.transform(loader, className, classBeingRedefined,protectionDomain, classfileBuffer);
if (transformResult != null) {
classfileBuffer = transformResult;
}
}
return classfileBuffer;
}
};
instrumentation.addTransformer(classFileTransformer, true);
}
很巧的是,這裡有一個traceTransformers。對Arthas源碼進行斷點調試,發現trace操作确實會走到此回調方法。于是在此處修改Arthas的代碼,判斷如果待transform的類是會引發OOM的目标類,那就把
classfileBuffer
和transform完成的
transformResult
都儲存到檔案。以此方式順利地拿到了增強前後的位元組碼。
分析增強前後的位元組碼結構
新生成的
.class
檔案比老的
.class
檔案大了很多。将兩個
.class
檔案拖入IDEA中進行反編譯,檢視對應的Java代碼。由于被trace的方法體本身非常龐大,内部具有大量的DTO轉換操作,充斥着大量的get set方法調用,是以Arthas在生成增強的位元組碼時在方法調用前後插入了大量的計時代碼
不過仔細看,可以發現,雖然看上去代碼中有非常多的字元串,但是實際上很多字元串都是一模一樣的,隻是反編譯過程中重複顯示了而已,這一點可以從
.class
的檔案大小得出結論:雖然新類中多了不少字元串,但是不同的字元串肯定很少,否則
.class
檔案中需要耗費大量的空間去儲存這些不一樣的字元串,勢必檔案大小也會膨脹得厲害;而現在新類的
.class
檔案才1M左右,與Metaspace OOM時暴漲500MB的表現實在是相去甚遠,是以并不是常量過多引發Metaspace暴漲。
既然從反編譯的結果中得不到問題的突破口,于是嘗試使用
javap -verbose
輸出增強前後的類位元組碼内容。
對比兩個前後
javap
工具輸出的資訊,發現了兩個令人在意的細節:
- 增強後的類常量池區域的内容結構完全變了,增強前的類常量池一開始都隻是些方法引用,字元串類型的常量index基本都在400、1200左右。而新的類常量池一開始全是類及字元串常量的index,方法引用、類引用夾雜在字元串常量之間。
- StackMapTable産生了大量的Entries,且有很多Entry是full frame。
frame_type常見取值含義:
- frame_type = SAME ;/ 0-63 / 與上一個比較位置的局部變量表相同,且操作數棧為空,這個值也是隐含的 offset_delta
- frame_type = SAME_LOCALS_1_STACK_ITEM; / 64-127 / 目前幀與上一幀有相同的局部變量,操作數棧中的變量數目為 1,隐式 offset_delta 為 frame_type – 64
- frame_type = SAME_LOCALS_1_STACK_ITEM_EXTENDED; / 247 /
- frame_type = CHOP / 248- 250 /
- frame_type = SAME_FRAME_EXTENDED / 251 / 局部變量資訊和上一個幀相同,且操作數棧為空
- frame_type = APPEND ; / 252-254 / 目前幀比上一幀多了k個局部變量,且操作數棧為空,其中 k = frame_type -251
- frame_type = FULL_FRAME;/ 255 / 局部變量表和操作數棧做完整記錄
考慮到StackMapTable的作用基本上是在位元組碼驗證期間校驗位元組碼合法性的,是以考慮先關閉JVM的位元組碼校驗功能,看看排除了StackMapTable的影響後是否能夠減輕Metaspace空間上漲的症狀。
可以看到關閉位元組碼校驗後,确實能夠緩解Metaspace空間上漲的問題,但是關閉JVM的位元組碼校驗功能并不見得是一個安全的操作,這使得應用更容易受到非法位元組碼的影響:不單單是增加了被惡意的位元組碼攻擊應用的風險,而且在應用中為了實作AOP,也引入了不少的動态生成位元組碼的工具;缺乏位元組碼校驗能力,同樣也會增加由于位元組碼生成工具可能存在的問題而導緻不合法的位元組碼影響應用穩定的風險。是以,在沒有搞清楚問題根源就簡單地關閉掉位元組碼校驗,是弊大于利,得不償失的。有必要進一步分析産生Metaspace OOM問題的原因。
問題定位
目前為止,雖然我們已經在位元組碼層面上看到了異常的ConstantPool layout以及龐大的StackMapTable,但卻得不到更多的資訊來發現問題了。是以隻能考慮從JVM層面入手。
由于筆者發現Metaspace OOM的問題在普通的JDK上也存在(在macOS上測試了OpenJDK 8及14,在Ubuntu 18上測試了OpenJDK 12,問題均存在),于是下載下傳一份OpenJDK 14的源碼,打開slowdebug模式編譯了一份可進行調試的JDK。我們知道類加載過程中申請Metaspace空間最終會調用到
share/memory/metaspace/spaceManager.cpp#SpaceManager::get_new_chunk
方法:
Metachunk* SpaceManager::get_new_chunk(size_t chunk_word_size) {
// Get a chunk from the chunk freelist
Metachunk* next = chunk_manager()->chunk_freelist_allocate(chunk_word_size);
if (next == NULL) {
next = vs_list()->get_new_chunk(chunk_word_size,
medium_chunk_bunch());
}
Log(gc, metaspace, alloc) log;
if (log.is_trace() && next != NULL &&
SpaceManager::is_humongous(next->word_size())) {
log.trace(" new humongous chunk word size " PTR_FORMAT, next->word_size());
}
return next;
}
是以可以在方法頭部下條件斷點
chunk_word_size > 8192
,期望能從調用棧中看到消耗Metaspace的“罪魁禍首"。
一個新産生的普通ClassLoader一開始會拿到4KB大小的chunks,直到申請次數達到一個上限(目前這個上限為4),接下來Allocator就會”失去耐心“,每次都給這個ClassLoader配置設定64K大小的chunks。因為是word_size,是以在筆者的x64 Mac上,一個word的size為64,64 Kbytes = 65536 bytes = 8192 * 64 / 8,是以設成8192是恰到好處的。
很快,發現了申請大量Metaspace的調用棧:
逐級跟入調用棧,發現有兩個方法的注釋值得關注:
// We have entries mapped between the new and merged constant pools
// so we have to rewrite some constant pool references.
// 存在需要在新的及合并後的Constant Pool間映射的Entry,是以我們必須重寫一些Constant Pool的引用。
if (!rewrite_cp_refs(scratch_class, THREAD)) {
return JVMTI_ERROR_INTERNAL;
}
// Rewrite constant pool references in the specific method. This code
// was adapted from Rewriter::rewrite_method().
void VM_RedefineClasses::rewrite_cp_refs_in_method(methodHandle method,methodHandle *new_method_p, TRAPS) {
// ...
// the new value needs ldc_w instead of ldc
u_char inst_buffer[4]; // max instruction size is 4 bytes
bcp = (address)inst_buffer;
// construct new instruction sequence
*bcp = Bytecodes::_ldc_w;
bcp++;
Bytes::put_Java_u2(bcp, new_index);
Relocator rc(method, NULL /* no RelocatorListener needed */);
methodHandle m;
{
PauseNoSafepointVerifier pnsv(&nsv);
// ldc is 2 bytes and ldc_w is 3 bytes
// 執行到這一句進入空間配置設定
m = rc.insert_space_at(bci, 3, inst_buffer, CHECK);
}
// return the new method so that the caller can update
// the containing class
*new_method_p = method = m;
// switch our bytecode processing loop from the old method
// to the new method
// ...
} // end we need ldc_w instead of ldc
} // end if there is a mapped index
} break;
// ...
這個方法的主要作用是重寫指定方法的位元組碼在常量池中的引用,從調試資訊中可以看到,目前需要重寫的位元組碼指令為ldc, 在老常量池中ldc的常量池引用index為2,而在新類中為385,不滿足
new_index <= max_jubyte(255)
的條件,需要将
ldc
指令擴充為
ldc_w
,是以插入新的位元組碼指令
而在插入位元組碼指令的過程中,JDK會複制一遍目前方法的StackMapTable,
這個方法的StackMapTable很大,達到了900多KB,是以每擴充一次
ldc
指令到
ldc_w
,差不多就需要向Metaspace申請約1MB的空間。老類中的ldc指令隻有32個,而新類中的ldc指令多達1054個,再考慮到剛才從
javap -verbose
結果中看到的,新類中Constant Pool layout與老類完全不同,這就意味着有很多的ldc指令因為錯位而需要擴充,考慮到
max_jubyte
的取值為255,1054/2大約就是500個左右的ldc指令需要擴充。最終便導緻了文章開頭的情景:Metaspace激增了約500MB。
到這裡,還剩下最後一個問題,為什麼關掉JVM的位元組碼校驗,就不會出現Metaspace激增呢?因為關閉JVM的位元組碼校驗後,ClassFileParser就不會去解析
.class
檔案的StackMapTable部分,進而走不到
if(m->has_stackmap_table())
語句,避免了StackMapTable的複制。這一點也可以從JVM源碼中得到佐證:
// src/hotspot/share/classfile/classFileParser.cpp # parse_stackmap_table
static const u1* parse_stackmap_table(const ClassFileStream* const cfs,
u4 code_attribute_length,
bool need_verify,
TRAPS) {
// ...
// check code_attribute_length first
cfs->skip_u1(code_attribute_length, CHECK_NULL);
// 關注這一行
if (!need_verify && !DumpSharedSpaces) {
return NULL;
}
return stackmap_table_start;
}
如果不需要verify且不需要DumpSharedSpaces,那麼parse_stackmap_table會直接傳回NULL。
繼續檢視調用棧,整個棧是由
VM_RedefineClasses::load_new_class_versions
方法一路觸發調用的,
jvmtiError VM_RedefineClasses::load_new_class_versions(TRAPS) {
// ...
for (int i = 0; i < _class_count; i++) {
// Create HandleMark so that any handles created while loading new class
// versions are deleted. Constant pools are deallocated while merging
// constant pools
HandleMark hm(THREAD);
InstanceKlass* the_class = get_ik(_class_defs[i].klass);
Symbol* the_class_sym = the_class->name();
log_debug(redefine, class, load)
("loading name=%s kind=%d (avail_mem=" UINT64_FORMAT "K)",
the_class->external_name(), _class_load_kind, os::available_memory() >> 10);
// 構造了這個ClassFileStream對象↓
ClassFileStream st((u1*)_class_defs[i].class_bytes,
_class_defs[i].class_byte_count,
"__VM_RedefineClasses__",
ClassFileStream::verify);
// ...
方法開頭構造了一個
ClassFileStream
對象,這個對象的
verify_stream
屬性被設定為
ClassFileStream::verify
,而這個值預設是為true。
在ClassFileParser的構造函數中有設定_need_verify的代碼:
// Figure out whether we can skip format checking (matching classic VM behavior)
if (DumpSharedSpaces) { // 沒有啟動參數,為false
// verify == true means it's a 'remote' class (i.e., non-boot class)
// Verification decision is based on BytecodeVerificationRemote flag
// for those classes.
_need_verify = (stream->need_verify()) ? BytecodeVerificationRemote :
BytecodeVerificationLocal;
}
else {
// 走到這個分支
_need_verify = Verifier::should_verify_for(_loader_data->class_loader(),
stream->need_verify());
}
bool Verifier::should_verify_for(oop class_loader, bool should_verify_class) {
return (class_loader == NULL || !should_verify_class) ?
BytecodeVerificationLocal : BytecodeVerificationRemote;
}
而
class_loader !=null
,
should_verify_class
為
true
,于是走到了取值
BytecodeVerificationRemote
,而這個值正好就是由
-noverify
啟動參數決定的。隻要在啟動參數中關閉JVM位元組碼校驗,那麼
BytecodeVerificationRemote
就為
false
,最終方法就不會攜帶StackMapTable資訊,避免了StackMapTable的複制而導緻占用大量Metaspace空間。
至此,我們終于搞清楚了導緻Metaspace OOM的根源:在trace巨大方法時,Arthas産生新類的Constant Pool的Layout發生變化導緻ldc指令需要rewrite,新的指令index超過max_jubyte後需要擴充ldc指令為ldc_w指令,指令擴充過程中需要插入新的位元組碼操作符,而插入新的位元組碼操作符時又需要複制StackMapTable,而巨大的StackMapTable以及大量的ldc指令需要擴充,最終導緻Metaspace空間暴增,引發問題。
問題解決
既然知道了Metaspace OOM是由StackMapTable的複制引起的,而StackMapTable的複制又是在新舊Constant Pool index需要映射的情況下發生,那有沒有辦法盡可能的保持Constant Pool layout一緻,避免這樣的重映射呢?閱讀了Arthas的源碼及其使用的位元組碼增強庫bytebuddy的接口方法後,答案是肯定的。于是筆者開始嘗試修改Arthas的代碼,以便盡可能地保持新舊類的Constant Pool Layout一緻。
// com/alibaba/repackage-asm/0.0.7/com/alibaba/deps/org/objectweb/asm/ClassWriter.class
參數 ClassReader: ClassReader執行個體用于讀取原始類檔案,它将會被用于從原始類中複制完整的常量池、Bootstrap Method以及其他原始類中可複制部分的位元組碼。
修改
com.taobao.arthas.core.advisor.Enhancer
類兩處,一處擷取
ClassReader
執行個體的引用:
// src/main/java/com/taobao/arthas/core/advisor/Enhancer.java
// ...
if (matchingClasses != null && !matchingClasses.contains(classBeingRedefined)) {
return null;
}
ClassNode classNode = new ClassNode(Opcodes.ASM8);
// 在AsmUtils中新增方法,傳回處理ClassNode的ClassReader。
// 此時這個ClassReader中已經儲存了原始類的Constant Pool等資訊
// 保持着這個ClassReader對象,在最後生成位元組碼的時候有用
ClassReader classReader = AsmUtils.toClassReader(classfileBuffer, classNode);
// remove JSR https://github.com/alibaba/arthas/issues/1304
classNode = AsmUtils.removeJSRInstructions(classNode);
// 生成增強位元組碼
DefaultInterceptorClassParser defaultInterceptorClassParser = new DefaultInterceptorClassParser();
// ...
一處将先前擷取到的
ClassReader
執行個體傳入位元組碼生成方法中用于複制常量池
// src/main/java/com/taobao/arthas/core/advisor/Enhancer.java
// ...
// https://github.com/alibaba/arthas/issues/1223
if (classNode.version < Opcodes.V1_5) {
classNode.version = Opcodes.V1_5;
}
byte[] enhanceClassByteArray = AsmUtils.toBytes(classNode, inClassLoader, classReader);
// 增強成功,記錄類
classBytesCache.put(classBeingRedefined, new Object());
// dump the class
dumpClassIfNecessary(className, enhanceClassByteArray, affect);
// 成功計數
affect.cCnt(1);
// ...
再修改類
com.taobao.arthas.bytekit.utils.AsmUtils
,新增接受ClassReader參數的重載方法,用于在産生新位元組碼時複制常量池等資訊
// src/main/java/com/taobao/arthas/bytekit/utils/AsmUtils.java
// ...
// 新增方法如下
public static byte[] toBytes(ClassNode classNode, ClassLoader classLoader, ClassReader classReader) {
int flags = ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS;
ClassWriter writer = new ClassLoaderAwareClassWriter(classReader, flags, classLoader);
classNode.accept(writer);
return writer.toByteArray();
}
編譯打包修改後的Arthas,再次trace目标類,可見Metaspace沒有再發生上漲,并且在AliJDK及OpenJDK 8上也測試正常。
對比
javap -verbose
輸出的資料,可見兩邊的Constant Pool對于新舊類中共同存在的常量項,index基本不發生變化。
而Arthas為了實作trace統計而引入的計數器辨別符常量,在新的類中基本上都排在了常量池的末尾,不再和舊類中的常量“搶位置”了。
至此,一場由Arthas引起的Metaspace OOM問題就真正的告一段落。
思考
從發現文中提及的Metaspace OOM的問題,到真正解決此問題,斷斷續續地花費了筆者近2周的時間。在日常的開發工作中,我們通常情況下碰到的大部分是堆OOM的故障,這種情況下隻要把堆Dump下來”作案現場“便一目了然。而本文介紹的Metaspace OOM問題在JDK 8後便成為了一種”堆外記憶體洩露“問題;并且,在JDK 8環境中甚至還缺乏
VM.metaspace
之類的診斷指令,種種原因相加,導緻了堆外記憶體洩漏相較于堆内記憶體洩漏更難定位、分析。
而整篇文章分析下來,可以發現解決該問題最有力的抓手,正是”已加載類數量","Non-class Space Size", "Class Space Size"等幾個重要的堆外記憶體監控名額,目前這些更加細節的堆外記憶體使用名額還沒有很清晰地反映在生産環境的監控系統中。私以為,在以後的開發工作中,可以充分發揮集團内有自研AliJDK的優勢,補足這些監控名額,将高版本OpenJDK才開始具備的診斷指令提早內建到AliJDK中,友善開發同學對JVM的運作狀态有更全面的把握,降低諸如此類堆外記憶體洩露的排查難度,進一步確定生産環境的安全穩定。
後記
筆者準備了一個可複現上述情形的Demo:
https://github.com/LinZong/HugeStackMapTableOom歡迎感興趣的讀者嘗試。