diff --git a/ddprof-lib/src/main/cpp/arguments.cpp b/ddprof-lib/src/main/cpp/arguments.cpp index 82db261aa5..08c09c5201 100644 --- a/ddprof-lib/src/main/cpp/arguments.cpp +++ b/ddprof-lib/src/main/cpp/arguments.cpp @@ -304,8 +304,13 @@ Error Arguments::parse(const char *args) { if (value != nullptr) { if (strcmp(value, "false") == 0) { _force_jmethodID = false; + _lazy_jmethodID = false; } else if (strcmp(value, "true") == 0) { _force_jmethodID = true; + _lazy_jmethodID = false; + } else if (strcmp(value, "lazy") == 0) { + _force_jmethodID = false; + _lazy_jmethodID = true; } else { msg = "Invalid jmethodID creation value"; } diff --git a/ddprof-lib/src/main/cpp/arguments.h b/ddprof-lib/src/main/cpp/arguments.h index d6222f9157..b6fe1f074c 100644 --- a/ddprof-lib/src/main/cpp/arguments.h +++ b/ddprof-lib/src/main/cpp/arguments.h @@ -274,6 +274,7 @@ class Arguments { bool _nativesocket; long _nativesocket_interval; // initial sampling period in nanoseconds; 0 = engine default bool _force_jmethodID; // Load all jmethodIDs, true by default + bool _lazy_jmethodID; // fjmethodid=lazy: allocate jmethodIDs only for sampled classes, at dump time Arguments(bool persistent = false) : _buf(NULL), @@ -324,7 +325,8 @@ class Arguments { _jvmtistacks(false), _nativesocket(false), _nativesocket_interval(0), - _force_jmethodID(true) {} + _force_jmethodID(true), + _lazy_jmethodID(false) {} ~Arguments(); diff --git a/ddprof-lib/src/main/cpp/counters.h b/ddprof-lib/src/main/cpp/counters.h index 0c55316899..917d6bee64 100644 --- a/ddprof-lib/src/main/cpp/counters.h +++ b/ddprof-lib/src/main/cpp/counters.h @@ -169,10 +169,66 @@ X(METHOD_RESOLVE_FAULT_RECOVERED, "method_resolve_fault_recovered") \ /* Symbol length/body rejected during the same resolution: unreadable body, \ * empty (recycled slot), or over MAX_SYMBOL_LEN. A name that merely exceeds \ - * the fixed inline buffers in hotspotSupport.cpp's ResolvedNames still \ + * the fixed inline buffers in ResolvedMethod (hotspotSupport.h) still \ * resolves via its malloc fallback and does not land here. The frame \ * serializes as "unknown". */ \ X(METHOD_RESOLVE_SYMBOL_UNREADABLE, "method_resolve_symbol_unreadable") \ + /* fjmethodid=lazy resolution outcomes. UNLOADED: the frame's class is no \ + * longer registered or reachable. NOT_CURRENT: the class is alive but the \ + * Method* is not among its current methods (freed or redefined). Both \ + * serialize as "unknown". RESOLVED: raw frames whose names were read from \ + * VM metadata, once per method and tag per dump. */ \ + X(METHOD_RESOLVE_LAZY_UNLOADED, "method_resolve_lazy_unloaded") \ + X(METHOD_RESOLVE_LAZY_NOT_CURRENT, "method_resolve_lazy_not_current") \ + X(METHOD_RESOLVE_LAZY_RESOLVED, "method_resolve_lazy_resolved") \ + /* STALE: the class now at the frame's holder address is a different load \ + * (its trace ID differs); serializes as "unknown". \ + * ADD_FAILED: registration failed, so the class was preloaded instead. \ + * UNTAGGABLE: a Method* using address bits the raw-frame tag needs, or \ + * an unreadable holder trace ID; the frame is stored as not walkable. */ \ + X(METHOD_RESOLVE_LAZY_STALE, "method_resolve_lazy_stale") \ + /* REDEFINED: raw frame of a class that has been redefined (its Method*s \ + * may have been freed and reused); serializes as "unknown". */ \ + X(METHOD_RESOLVE_LAZY_REDEFINED, "method_resolve_lazy_redefined") \ + /* Registered classes marked by the redefinition hooks (once per class \ + * per redefine/retransform call). */ \ + X(KLASS_REGISTRY_REDEFINITIONS_MARKED, "klass_registry_redefinitions_marked") \ + /* Registrations whose class trace ID no longer fits in a raw frame's \ + * 25-bit tag. Nonzero means trace-ID tags of two classes can now collide: \ + * a frame sampled from an unloaded class could then, if a newer class \ + * also took over its Method* memory, resolve to that class's method. */ \ + X(KLASS_TRACE_ID_BEYOND_TAG_RANGE, "klass_trace_id_beyond_tag_range") \ + /* Raw Method* frame occurrences answered from Lookup's per-dump cache \ + * instead of resolving them again. */ \ + X(RAW_FRAME_RESOLVE_CACHE_HITS, "raw_frame_resolve_cache_hits") \ + X(KLASS_REGISTRY_ADD_FAILED, "klass_registry_add_failed") \ + /* PROTOTYPE UnloadCapture (DDPROF_CAPTURE_UNLOADING=1). */ \ + X(UNLOAD_CAPTURE_PENDING_ADDED, "unload_capture_pending_added") \ + X(UNLOAD_CAPTURE_PENDING_OVERFLOW, "unload_capture_pending_overflow") \ + X(UNLOAD_CAPTURE_HOOK_CALLS, "unload_capture_hook_calls") \ + X(UNLOAD_CAPTURE_HOOK_BUSY, "unload_capture_hook_busy") \ + X(UNLOAD_CAPTURE_HOOK_NS, "unload_capture_hook_ns") \ + X(UNLOAD_CAPTURE_HOOK_MAX_NS, "unload_capture_hook_max_ns") \ + X(UNLOAD_CAPTURE_CAPTURED, "unload_capture_captured") \ + X(UNLOAD_CAPTURE_REFUSED, "unload_capture_refused") \ + X(UNLOAD_CAPTURE_CACHE_FULL, "unload_capture_cache_full") \ + X(UNLOAD_CAPTURE_USED, "unload_capture_used") \ + X(UNLOAD_CAPTURE_BG_PASSES, "unload_capture_bg_passes") \ + X(UNLOAD_CAPTURE_BG_NS, "unload_capture_bg_ns") \ + X(UNLOAD_CAPTURE_BG_CPU_NS, "unload_capture_bg_cpu_ns") \ + X(UNLOAD_CAPTURE_PENDING_EVICTED, "unload_capture_pending_evicted") \ + X(UNLOAD_CAPTURE_BG_MAX_NS, "unload_capture_bg_max_ns") \ + X(UNLOAD_CAPTURE_BG_CAPTURED, "unload_capture_bg_captured") \ + X(UNLOAD_CAPTURE_BG_REFUSED, "unload_capture_bg_refused") \ + X(UNLOAD_CAPTURE_HOOK_DRAINED, "unload_capture_hook_drained") \ + /* PROTOTYPE: lazy frames whose Method* could not even be read at dump \ + * (freed metadata), not covered by the method_resolve_lazy_* counters. */ \ + X(METHOD_RESOLVE_LAZY_UNREADABLE, "method_resolve_lazy_unreadable") \ + X(WALKVM_RAW_FRAME_UNTAGGABLE, "walkvm_raw_frame_untaggable") \ + /* Gauge, set at each fjmethodid=lazy start: the Klass::_trace_id offset \ + * raw frames are tagged with, or -1 if it was not found (lazy mode then \ + * falls back to fjmethodid=false). 0 if lazy mode was never requested. */ \ + X(KLASS_TRACE_ID_OFFSET, "klass_trace_id_offset") \ /* Strict subset of SAMPLES_DROPPED_THREAD_LOCAL, not an independent count: \ * ThreadLocalDataPool::claim() increments this on capacity exhaustion, and \ * every acquireCurrent() caller that gets nullptr back -- for this or any \ diff --git a/ddprof-lib/src/main/cpp/flightRecorder.cpp b/ddprof-lib/src/main/cpp/flightRecorder.cpp index 98455483d8..4f1b0d6373 100644 --- a/ddprof-lib/src/main/cpp/flightRecorder.cpp +++ b/ddprof-lib/src/main/cpp/flightRecorder.cpp @@ -41,6 +41,7 @@ #include "threadState.h" #include "tsc.h" #include "hotspot/vmStructs.h" +#include "hotspot/unloadCapture.h" #include #include #include @@ -78,15 +79,20 @@ static inline u64 safeDuration(u64 start_time, u64 end_time) { } SharedLineNumberTable::~SharedLineNumberTable() { - // _ptr is the buffer jvmti->GetLineNumberTable() itself returned (see - // Lookup::fillJavaMethodInfo), not a copy -- freed here via + // A JVMTI table is the buffer jvmti->GetLineNumberTable() itself returned + // (see Lookup::fillJavaMethodInfo), not a copy -- freed here via // jvmti->Deallocate(), not plain free(). On HotSpot, JvmtiEnv // Allocate()/Deallocate() are os::malloc()/os::free(), which route through // native memory tracking; freeing with plain free() can result in crash // whenever -XX:NativeMemoryTracking is enabled, because native memory tracking - // adds a header to malloc'd block. + // adds a header to malloc'd block. A table decoded from VM metadata + // (ResolvedMethod) is our own malloc() and goes back to free(). if (_table != nullptr) { - VM::jvmti()->Deallocate((unsigned char *)_table); + if (_jvmti_allocated) { + VM::jvmti()->Deallocate((unsigned char *)_table); + } else { + free(_table); + } Counters::decrement(LINE_NUMBER_TABLES); // _size is the JVMTI entry count passed at construction (see // fillJavaMethodInfo), so the byte size matches the allocation. @@ -266,9 +272,6 @@ bool Lookup::fillJavaMethodInfo(MethodInfo *mi, jmethodID method, char* volatile &class_name = state._class_name; char* volatile &method_name = state._method_name; char* volatile &method_sig = state._method_signature; - u32 class_name_id = 0; - u32 method_name_id = 0; - u32 method_sig_id = 0; jint line_number_table_size = 0; jvmtiLineNumberEntry *line_number_table = NULL; @@ -385,95 +388,15 @@ bool Lookup::fillJavaMethodInfo(MethodInfo *mi, jmethodID method, entry = true; } - // maybe we should store the lookups below in initialisation-time - // constants... - if (has_prefix(class_name, - "Ljdk/internal/reflect/GeneratedConstructorAccessor")) { - class_name_id = _classes->lookupDuringDump( - "jdk/internal/reflect/GeneratedConstructorAccessor", - strlen("jdk/internal/reflect/GeneratedConstructorAccessor"), - Profiler::maxClassMapSize()); - method_name_id = - _symbols.lookup("Object " - "jdk.internal.reflect.GeneratedConstructorAccessor." - "newInstance(Object[])"); - method_sig_id = _symbols.lookup(method_sig); - } else if (has_prefix(class_name, - "Lsun/reflect/GeneratedConstructorAccessor")) { - class_name_id = _classes->lookupDuringDump( - "sun/reflect/GeneratedConstructorAccessor", - strlen("sun/reflect/GeneratedConstructorAccessor"), - Profiler::maxClassMapSize()); - method_name_id = _symbols.lookup( - "Object " - "sun.reflect.GeneratedConstructorAccessor.newInstance(Object[])"); - method_sig_id = _symbols.lookup(method_sig); - } else if (has_prefix(class_name, - "Ljdk/internal/reflect/GeneratedMethodAccessor")) { - class_name_id = _classes->lookupDuringDump( - "jdk/internal/reflect/GeneratedMethodAccessor", - strlen("jdk/internal/reflect/GeneratedMethodAccessor"), - Profiler::maxClassMapSize()); - method_name_id = - _symbols.lookup("Object " - "jdk.internal.reflect.GeneratedMethodAccessor." - "invoke(Object, Object[])"); - method_sig_id = _symbols.lookup(method_sig); - } else if (has_prefix(class_name, - "Lsun/reflect/GeneratedMethodAccessor")) { - class_name_id = _classes->lookupDuringDump( - "sun/reflect/GeneratedMethodAccessor", - strlen("sun/reflect/GeneratedMethodAccessor"), - Profiler::maxClassMapSize()); - method_name_id = _symbols.lookup( - "Object sun.reflect.GeneratedMethodAccessor.invoke(Object, " - "Object[])"); - method_sig_id = _symbols.lookup(method_sig); - } else if (has_prefix(class_name, "Ljava/lang/invoke/LambdaForm$")) { - const int lambdaFormPrefixLength = - strlen("Ljava/lang/invoke/LambdaForm$"); - // we want to normalise to java/lang/invoke/LambdaForm$MH, - // java/lang/invoke/LambdaForm$DMH, java/lang/invoke/LambdaForm$BMH, - if (has_prefix(class_name + lambdaFormPrefixLength, "MH")) { - class_name_id = _classes->lookupDuringDump( - "java/lang/invoke/LambdaForm$MH", - strlen("java/lang/invoke/LambdaForm$MH"), - Profiler::maxClassMapSize()); - } else if (has_prefix(class_name + lambdaFormPrefixLength, "BMH")) { - class_name_id = _classes->lookupDuringDump( - "java/lang/invoke/LambdaForm$BMH", - strlen("java/lang/invoke/LambdaForm$BMH"), - Profiler::maxClassMapSize()); - } else if (has_prefix(class_name + lambdaFormPrefixLength, "DMH")) { - class_name_id = _classes->lookupDuringDump( - "java/lang/invoke/LambdaForm$DMH", - strlen("java/lang/invoke/LambdaForm$DMH"), - Profiler::maxClassMapSize()); - } else { - // don't recognise the suffix, so don't normalise - class_name_id = _classes->lookupDuringDump( - normalized_class_name, normalized_class_name_len, - Profiler::maxClassMapSize()); - } - method_name_id = _symbols.lookup(method_name); - method_sig_id = _symbols.lookup(method_sig); - } else { - class_name_id = _classes->lookupDuringDump( - normalized_class_name, normalized_class_name_len, - Profiler::maxClassMapSize()); - method_name_id = _symbols.lookup(method_name); - method_sig_id = _symbols.lookup(method_sig); - } + fillJavaMethodNames(mi, normalized_class_name, normalized_class_name_len, + method_name, method_sig); } else { Counters::increment(JMETHODID_SKIPPED); - class_name_id = _classes->lookupDuringDump("", 0, Profiler::maxClassMapSize()); - method_name_id = _symbols.lookup(""); - method_sig_id = _symbols.lookup("()L;"); + mi->_class = _classes->lookupDuringDump("", 0, Profiler::maxClassMapSize()); + mi->_name = _symbols.lookup(""); + mi->_sig = _symbols.lookup("()L;"); } - mi->_class = class_name_id; - mi->_name = method_name_id; - mi->_sig = method_sig_id; mi->_type = FRAME_INTERPRETED; mi->_is_entry = entry; // Only touch mi->_line_number_table when a table was actually fetched @@ -501,6 +424,123 @@ bool Lookup::fillJavaMethodInfo(MethodInfo *mi, jmethodID method, return false; } +void Lookup::fillJavaMethodNames(MethodInfo *mi, const char *class_name, + size_t class_name_len, const char *method_name, + const char *method_sig) { + u32 class_name_id; + u32 method_name_id; + // maybe we should store the lookups below in initialisation-time + // constants... + if (has_prefix_n(class_name, class_name_len, + "jdk/internal/reflect/GeneratedConstructorAccessor")) { + class_name_id = _classes->lookupDuringDump( + "jdk/internal/reflect/GeneratedConstructorAccessor", + strlen("jdk/internal/reflect/GeneratedConstructorAccessor"), + Profiler::maxClassMapSize()); + method_name_id = + _symbols.lookup("Object " + "jdk.internal.reflect.GeneratedConstructorAccessor." + "newInstance(Object[])"); + } else if (has_prefix_n(class_name, class_name_len, + "sun/reflect/GeneratedConstructorAccessor")) { + class_name_id = _classes->lookupDuringDump( + "sun/reflect/GeneratedConstructorAccessor", + strlen("sun/reflect/GeneratedConstructorAccessor"), + Profiler::maxClassMapSize()); + method_name_id = _symbols.lookup( + "Object " + "sun.reflect.GeneratedConstructorAccessor.newInstance(Object[])"); + } else if (has_prefix_n(class_name, class_name_len, + "jdk/internal/reflect/GeneratedMethodAccessor")) { + class_name_id = _classes->lookupDuringDump( + "jdk/internal/reflect/GeneratedMethodAccessor", + strlen("jdk/internal/reflect/GeneratedMethodAccessor"), + Profiler::maxClassMapSize()); + method_name_id = + _symbols.lookup("Object " + "jdk.internal.reflect.GeneratedMethodAccessor." + "invoke(Object, Object[])"); + } else if (has_prefix_n(class_name, class_name_len, + "sun/reflect/GeneratedMethodAccessor")) { + class_name_id = _classes->lookupDuringDump( + "sun/reflect/GeneratedMethodAccessor", + strlen("sun/reflect/GeneratedMethodAccessor"), + Profiler::maxClassMapSize()); + method_name_id = _symbols.lookup( + "Object sun.reflect.GeneratedMethodAccessor.invoke(Object, " + "Object[])"); + } else if (has_prefix_n(class_name, class_name_len, + "java/lang/invoke/LambdaForm$")) { + const size_t lambdaFormPrefixLength = + strlen("java/lang/invoke/LambdaForm$"); + const char *suffix = class_name + lambdaFormPrefixLength; + const size_t suffix_len = class_name_len - lambdaFormPrefixLength; + // we want to normalise to java/lang/invoke/LambdaForm$MH, + // java/lang/invoke/LambdaForm$DMH, java/lang/invoke/LambdaForm$BMH, + if (has_prefix_n(suffix, suffix_len, "MH")) { + class_name_id = _classes->lookupDuringDump( + "java/lang/invoke/LambdaForm$MH", + strlen("java/lang/invoke/LambdaForm$MH"), + Profiler::maxClassMapSize()); + } else if (has_prefix_n(suffix, suffix_len, "BMH")) { + class_name_id = _classes->lookupDuringDump( + "java/lang/invoke/LambdaForm$BMH", + strlen("java/lang/invoke/LambdaForm$BMH"), + Profiler::maxClassMapSize()); + } else if (has_prefix_n(suffix, suffix_len, "DMH")) { + class_name_id = _classes->lookupDuringDump( + "java/lang/invoke/LambdaForm$DMH", + strlen("java/lang/invoke/LambdaForm$DMH"), + Profiler::maxClassMapSize()); + } else { + // don't recognise the suffix, so don't normalise + class_name_id = _classes->lookupDuringDump( + class_name, class_name_len, Profiler::maxClassMapSize()); + } + method_name_id = _symbols.lookup(method_name); + } else { + class_name_id = _classes->lookupDuringDump( + class_name, class_name_len, Profiler::maxClassMapSize()); + method_name_id = _symbols.lookup(method_name); + } + + mi->_class = class_name_id; + mi->_name = method_name_id; + mi->_sig = _symbols.lookup(method_sig); +} + +void Lookup::fillJavaMethodInfo(MethodInfo *mi, ResolvedMethod &resolved) { + const char *class_name = resolved.klassName(); + const char *method_name = resolved.methodName(); + const char *method_sig = resolved.methodSignature(); + + fillJavaMethodNames(mi, class_name, strlen(class_name), method_name, method_sig); + mi->_type = FRAME_INTERPRETED; + // Same entry-frame rules as the jmethodID path: Thread.run (or an override + // in a Thread subclass), or public static void main(String[]). + mi->_is_entry = resolved.isThreadRun() || + (strcmp(method_name, "main") == 0 && + strcmp(method_sig, "([Ljava/lang/String;)V") == 0); + + // Raw rows are refilled at their first use in every chunk (see + // fillRawMethod()), so the table is replaced each time rather than kept + // from the first fill as on the jmethodID path. + if (resolved.lineNumberTable() != nullptr) { + int size = resolved.lineNumberTableSize(); + // Built before ownership moves, so a std::bad_alloc here leaves the table + // with `resolved`, which frees it. + auto table = std::make_shared( + size, const_cast(resolved.lineNumberTable()), + false /* malloc'd */); + resolved.takeLineNumberTable(&size); + mi->_line_number_table = std::move(table); + Counters::increment(LINE_NUMBER_TABLES); + NativeMem::record(NM_LINE_TABLES, (long long)((size_t)size * sizeof(jvmtiLineNumberEntry))); + } else { + mi->_line_number_table = nullptr; + } +} + bool Lookup::resolveVTableReceiver(VMSymbol *sym, char *buf, size_t bufsize, u32 *out_class_id) { if (sym == nullptr || !SafeAccess::isReadable(sym)) { @@ -672,7 +712,14 @@ MethodInfo *Lookup::resolveMethod(ASGCT_CallFrame &frame) { return unknown_method; } - // Fast path, deliberately unprotected. For anything but a raw-pointer or + // Raw Method* frames take their own path: resolution pins classes through + // JNI, so it must not run under fillMethod()'s landing pad, and it installs + // its own around the metadata reads. + if (FrameType::isRawPointer(bci)) { + return fillRawMethod(frame); + } + + // Fast path, deliberately unprotected. For anything but a // BCI_VTABLE_RECEIVER frame, methodKey() reads no VM metadata (see its // comment) and an already-marked row needs no symbolication -- there is // nothing here that can fault, hence nothing to recover from. Worth @@ -680,7 +727,7 @@ MethodInfo *Lookup::resolveMethod(ASGCT_CallFrame &frame) { // arming the protection below is not free: initCurrentThreadSignalSafe() // blocks and unblocks signals and sigsetjmp(..., 1) reads the signal mask, // three syscalls on a loop that runs once per frame per trace. - if (!FrameType::isRawPointer(bci) && bci != BCI_VTABLE_RECEIVER) { + if (bci != BCI_VTABLE_RECEIVER) { MethodMap::iterator it = _method_map->find(methodKey(frame, method_id, bci, 0)); if (it != _method_map->end() && it->second._mark) { return &it->second; @@ -702,18 +749,69 @@ MethodInfo *Lookup::resolveMethod(ASGCT_CallFrame &frame) { return fillMethod(frame, method_id, bci, prof_thread); } -MethodInfo *Lookup::fillMethod(ASGCT_CallFrame &frame, jmethodID method_id, - jint bci, ProfiledThread* const prof_thread) { - - assert(prof_thread != nullptr); - // Resolve native method - if (FrameType::isRawPointer(bci)) { - method_id = JVMSupport::resolve(frame.method); - if (method_id == nullptr) { +MethodInfo *Lookup::fillRawMethod(const ASGCT_CallFrame &frame) { + RawMethodKey key{FrameType::untagPointer(frame.method), FrameType::rawTag(frame.bci, frame.method)}; + auto cached = _raw_method_cache.find(key); + if (cached != _raw_method_cache.end()) { + Counters::increment(RAW_FRAME_RESOLVE_CACHE_HITS); + if (cached->second == nullptr) { return unknownMethod(); } + return cached->second; } + ResolvedMethod resolved; + if (!JVMSupport::resolve(frame.method, frame.bci, resolved)) { + UnloadCapture::Entry captured; + if (UnloadCapture::take(key.method, key.tag, &captured)) { + // PROTOTYPE: the class was refused (unloaded or replaced), but its + // names were copied at a GC start while it was still loaded. + MethodInfo *mi = &(*_method_map)[MethodMap::makeRawKey(key.method, key.tag)]; + fillJavaMethodNames(mi, captured.klass_name, strlen(captured.klass_name), + captured.method_name, captured.signature); + mi->_type = FRAME_INTERPRETED; + mi->_is_entry = captured.is_thread_run || + (strcmp(captured.method_name, "main") == 0 && + strcmp(captured.signature, "([Ljava/lang/String;)V") == 0); + if (captured.line_table != nullptr) { + mi->_line_number_table = std::make_shared( + captured.line_table_size, captured.line_table, false /* malloc'd */); + captured.line_table = nullptr; + Counters::increment(LINE_NUMBER_TABLES); + NativeMem::record(NM_LINE_TABLES, (long long)((size_t)captured.line_table_size * sizeof(jvmtiLineNumberEntry))); + } else { + mi->_line_number_table = nullptr; + } + UnloadCapture::freeEntry(captured); + if (mi->_key == 0) { + mi->_key = _method_map->allocId(); + } + mi->_mark = true; + _raw_method_cache.emplace(key, mi); + Counters::increment(UNLOAD_CAPTURE_USED); + return mi; + } + _raw_method_cache.emplace(key, nullptr); + return unknownMethod(); + } + // Refilled even if the row was filled in an earlier chunk: that only proves + // the frame's (Method*, tag) named this method then, while resolve() just + // checked it again. The fill reads only `resolved`, never VM metadata, so + // unlike fillMethod() it needs no landing pad. + MethodInfo *mi = &(*_method_map)[MethodMap::makeRawKey(key.method, key.tag)]; + fillJavaMethodInfo(mi, resolved); + if (mi->_key == 0) { + mi->_key = _method_map->allocId(); // see fillMethod() on why not size()+1 + } + mi->_mark = true; + _raw_method_cache.emplace(key, mi); + return mi; +} + +MethodInfo *Lookup::fillMethod(ASGCT_CallFrame &frame, jmethodID method_id, + jint bci, ProfiledThread* const prof_thread) { + + assert(prof_thread != nullptr); assert(method_id != nullptr && "Already filtered by caller"); // Reinstates the thread's previous landing pad on every exit from this frame, // including a std::bad_alloc thrown by one of the map or dictionary inserts @@ -1713,6 +1811,7 @@ int Recording::writeCpool(Buffer *buf, int *count_offset_in_cpool) { // VARIABLE pools: each returns 1 if emitted, 0 if empty (and thus skipped). pool_count += writeStackTraces(buf, &lookup); pool_count += writeMethods(buf, &lookup); + UnloadCapture::endDump(); pool_count += writeClasses(buf, &lookup); pool_count += writePackages(buf, &lookup); pool_count += writeConstantPoolSection(buf, T_SYMBOL, &lookup._symbols); diff --git a/ddprof-lib/src/main/cpp/flightRecorder.h b/ddprof-lib/src/main/cpp/flightRecorder.h index bee39b415e..d8389f40a1 100644 --- a/ddprof-lib/src/main/cpp/flightRecorder.h +++ b/ddprof-lib/src/main/cpp/flightRecorder.h @@ -103,14 +103,18 @@ struct CpuTimes { class SharedLineNumberTable { public: int _size; - // The buffer jvmti->GetLineNumberTable() returned, held directly (not a - // copy) and freed via jvmti->Deallocate() (see ~SharedLineNumberTable()) - // so native-memory-tracking accounting stays correct. Per the JVMTI spec - // this array is a fresh, caller-owned allocation decoupled from the - // Method's lifetime, so holding onto it is safe across class unload. + // Either the buffer jvmti->GetLineNumberTable() returned, held directly (not + // a copy) and freed via jvmti->Deallocate() (see ~SharedLineNumberTable()) + // so native-memory-tracking accounting stays correct, or -- when + // _jvmti_allocated is false -- a malloc'd table decoded from VM metadata by + // HotspotSupport::resolve(), freed via free(). Either way the array is + // decoupled from the Method's lifetime, so holding onto it is safe across + // class unload. jvmtiLineNumberEntry* _table; + bool _jvmti_allocated; - SharedLineNumberTable(int size, jvmtiLineNumberEntry *ptr) : _size(size), _table(ptr) {} + SharedLineNumberTable(int size, jvmtiLineNumberEntry *ptr, bool jvmti_allocated = true) : + _size(size), _table(ptr), _jvmti_allocated(jvmti_allocated) {} ~SharedLineNumberTable(); }; @@ -136,14 +140,15 @@ class MethodInfo { inline bool isHidden(); }; -// MethodMap's key can be derived from 3 sources: +// MethodMap's key can be derived from 4 sources: // 1) jmethodID for Java methods -// 2) void* address for native method names -// 3) Encoded RemoteFrameInfo +// 2) raw Method* and its frame tag, for HotSpot frames without a jmethodID +// 3) void* address for native method names +// 4) Encoded RemoteFrameInfo // The values of the keys are potentially overlapping, so we use // the highest 2 bits to distinguish them. // Key encoding (top two bits): -// 00 - jmethodID +// 00 - jmethodID, or with bit 61 set a raw Method* (see makeRawKey()) // 10 - void* address (native frame names) // 01 - RemoteFrameInfo (packed remote symbolication) // 11 - vtable_receiver class_id (BCI_VTABLE_RECEIVER frames) @@ -156,6 +161,9 @@ class MethodMap static constexpr unsigned long REMOTE_FRAME_MARK = 0x4000000000000000ULL; static constexpr unsigned long VTABLE_RECEIVER_MARK = ADDRESS_MARK | REMOTE_FRAME_MARK; static constexpr unsigned long KEY_TYPE_MASK = ADDRESS_MARK | REMOTE_FRAME_MARK; + static constexpr unsigned long RAW_METHOD_MARK = 0x2000000000000000ULL; + static constexpr int RAW_METHOD_TAG_SHIFT = 48; + static constexpr uint32_t RAW_METHOD_TAG_MASK = 0x1fff; MethodMap() {} @@ -176,6 +184,19 @@ class MethodMap assert((key & KEY_TYPE_MASK) == 0); return (key | REMOTE_FRAME_MARK);} + // A raw frame's row: its untagged Method* (below 2^48, see + // FrameType::untagPointer()), the low 13 bits of its tag, and + // RAW_METHOD_MARK, which keeps it apart from every jmethodID. The truncated + // tag only has to separate loads of a class at the same address that are + // both accepted in one dump; Lookup::fillRawMethod() keeps refused frames + // away from the rows, and refills every row at its first use in a chunk. + static unsigned long makeRawKey(const void* untagged_method, uint32_t tag) { + unsigned long key = (unsigned long)untagged_method; + assert((key >> RAW_METHOD_TAG_SHIFT) == 0); + return key | RAW_METHOD_MARK | + ((unsigned long)(tag & RAW_METHOD_TAG_MASK) << RAW_METHOD_TAG_SHIFT); + } + // BCI_VTABLE_RECEIVER frames key by the resolved class_id (not by the // VMSymbol* captured at sample time), so two distinct Symbol addresses // for the same class name collapse to a single MethodInfo row. @@ -450,6 +471,7 @@ class Recording { }; class ResolveMethodState; +class ResolvedMethod; class Lookup { public: Recording *_rec; @@ -464,6 +486,31 @@ class Lookup { std::unordered_map, std::equal_to, CountingAllocator, NM_JFR_BUFFERS>> _vtable_receiver_cache; + + // Per-dump cache of raw Method* frame resolutions (HotSpot cstack=vm frames + // whose class had no jmethodID when sampled): the filled row, or nullptr if + // the frame was refused. The same raw method recurs in many traces, and + // each resolution sets up crash protection, takes the KlassRegistry mutex, + // pins the class through JNI and reads names and line numbers. Keyed by the + // untagged Method* and the frame's full tag, so occurrences that differ + // only in bci or frame type share one entry. A cached result stays right + // for the whole dump: an accepted frame was sampled from that method, and a + // refused one stays refused. MethodMap nodes are not erased while a Lookup + // exists, so the row pointers stay valid. + struct RawMethodKey { + const void* method; + uint32_t tag; + bool operator==(const RawMethodKey& o) const { return method == o.method && tag == o.tag; } + }; + struct RawMethodKeyHash { + size_t operator()(const RawMethodKey& k) const { + return std::hash()(k.method) ^ ((size_t)k.tag * KNUTH_MULTIPLICATIVE_CONSTANT); + } + }; + std::unordered_map, + CountingAllocator, NM_JFR_BUFFERS>> + _raw_method_cache; + Dictionary _packages; Dictionary _symbols; @@ -492,6 +539,14 @@ class Lookup { // case. Every other path (including the "" stale-jmethodID // sentinel) fully populates mi and returns true. bool fillJavaMethodInfo(MethodInfo *mi, jmethodID method, bool first_time, ResolveMethodState& state); + // Fills mi from a raw Method* already resolved by HotspotSupport::resolve(), + // taking ownership of its line number table. No JNI/JVMTI. + void fillJavaMethodInfo(MethodInfo *mi, ResolvedMethod& resolved); + // Sets mi's class/name/signature ids, applying the synthetic-accessor and + // LambdaForm class name normalisation. class_name is an internal name + // ("java/lang/Object"), not necessarily NUL-terminated at class_name_len. + void fillJavaMethodNames(MethodInfo *mi, const char *class_name, size_t class_name_len, + const char *method_name, const char *method_sig); bool has_prefix(const char *str, const char *prefix) const { return strncmp(str, prefix, strlen(prefix)) == 0; } @@ -536,6 +591,9 @@ class Lookup { // sigsetjmp/siglongjmp window. MethodInfo *fillMethod(ASGCT_CallFrame &frame, jmethodID method_id, jint bci, ProfiledThread* const prof_thread); + // The row for a raw Method* frame, resolved and filled at most once per + // distinct method and tag per dump, or the unknown row if it is refused. + MethodInfo *fillRawMethod(const ASGCT_CallFrame &frame); // Materializes _unknown_method for this dump (filling it on first use) and // returns it. diff --git a/ddprof-lib/src/main/cpp/frame.h b/ddprof-lib/src/main/cpp/frame.h index 9c5e37cb21..438575d6cb 100644 --- a/ddprof-lib/src/main/cpp/frame.h +++ b/ddprof-lib/src/main/cpp/frame.h @@ -17,6 +17,7 @@ #define _FRAME_H #include +#include #include "vmEntry.h" enum FrameTypeId { @@ -35,11 +36,22 @@ enum FrameTypeId { // Packs frame type and BCI into a single int field stored in CallTrace frames. // // Bit layout of an encoded value (ENCODED_MASK set): -// bit 30 RAW_POINTER_MASK — value is a raw native PC (HotSpot only) +// bit 30 RAW_POINTER_MASK — method field is a raw Method* (HotSpot only) +// bits 29–24 raw tag, bits 9–4 — only with RAW_POINTER_MASK, see below // bits 23–21 frame type — FrameTypeId (0–7) // bit 20 ENCODED_MASK — set to distinguish encoded values from raw ASGCT BCIs +// bits 19–16 raw tag, bits 3–0 — only with RAW_POINTER_MASK, see below // bits 15–0 BCI — bytecode index (0–65534; never 65535, see BCI_MASK) // +// A raw Method* frame sampled in fjmethodid=lazy mode also carries a +// RAW_TAG_BITS-wide tag identifying the load of the class it was sampled from +// (see HotspotSupport::TRACE_ID_TAG). Its low 10 bits sit in the otherwise +// unused BCI bits above; its high 16 bits sit in the top 16 bits of the +// Method* pointer, which user-space addresses below 2^48 leave clear. The tag +// makes frames sampled from different loads of a class at the same +// Klass*/Method* addresses differ, and lets resolution reject frames whose +// class's memory has since been taken over by another. +// // When ENCODED_MASK is not set the field is a raw, unencoded BCI from ASGCT (non-VM stack // walking modes). Raw values may be negative: HotSpot uses -1 as a sentinel for method-entry // and synchronization-entry samples. Both encode() and bci() clamp negative values to 0 so @@ -53,7 +65,15 @@ class FrameType { static constexpr int TYPE_SHIFT = 21; static constexpr int ENCODED_MASK = 1 << 20; // distinguishes encoded values from raw ASGCT BCIs static constexpr int RAW_POINTER_MASK = 1 << 30; + static constexpr int RAW_TAG_LOW_SHIFT = 16; // tag bits 3–0 -> bits 19–16 + static constexpr int RAW_TAG_MID_SHIFT = 24; // tag bits 9–4 -> bits 29–24 + static constexpr int RAW_TAG_BCI_BITS = 10; + static constexpr int POINTER_TAG_SHIFT = 48; // tag bits 25–10 -> pointer bits 63–48 + static constexpr uintptr_t POINTER_ADDRESS_MASK = ((uintptr_t)1 << POINTER_TAG_SHIFT) - 1; public: + static constexpr int RAW_TAG_BITS = 26; + static constexpr uint32_t RAW_TAG_MASK = (1u << RAW_TAG_BITS) - 1; + // Produces an encoded int from a frame type and BCI. Negative BCIs (HotSpot -1 sentinels // for method-entry/sync-entry) are mapped to 0 rather than wrapping to 0xffff. static inline int encode(int type, int bci, bool rawPointer = false) { @@ -63,6 +83,36 @@ class FrameType { return ENCODED_MASK | (type << TYPE_SHIFT) | bci_bits | (rawPointer ? RAW_POINTER_MASK : 0); } + // Encodes a raw Method* frame carrying `tag` (see the layout above). The + // matching method field is tagPointer(method, tag). + static inline int encodeRaw(int type, int bci, uint32_t tag) { + return encode(type, bci, true) | (int)((tag & 0xf) << RAW_TAG_LOW_SHIFT) | + (int)(((tag >> 4) & 0x3f) << RAW_TAG_MID_SHIFT); + } + + // True if `ptr` leaves the pointer tag bits clear, i.e. tagPointer() is reversible. + static inline bool canTagPointer(const void* ptr) { + return ((uintptr_t)ptr & ~POINTER_ADDRESS_MASK) == 0; + } + + static inline const void* tagPointer(const void* ptr, uint32_t tag) { + assert(canTagPointer(ptr)); + uintptr_t high = (uintptr_t)((tag & RAW_TAG_MASK) >> RAW_TAG_BCI_BITS); + return (const void*)((uintptr_t)ptr | (high << POINTER_TAG_SHIFT)); + } + + static inline const void* untagPointer(const void* ptr) { + return (const void*)((uintptr_t)ptr & POINTER_ADDRESS_MASK); + } + + // Reassembles the tag of a raw Method* frame from its encoded BCI and method field. + static inline uint32_t rawTag(int bci, const void* ptr) { + uint32_t low = ((uint32_t)bci >> RAW_TAG_LOW_SHIFT) & 0xf; + uint32_t mid = ((uint32_t)bci >> RAW_TAG_MID_SHIFT) & 0x3f; + uint32_t high = (uint32_t)((uintptr_t)ptr >> POINTER_TAG_SHIFT); + return (low | (mid << 4) | (high << RAW_TAG_BCI_BITS)) & RAW_TAG_MASK; + } + // Extracts the BCI from either an encoded value or a raw ASGCT BCI. // Negative values (HotSpot -1 sentinels) are clamped to 0, matching encode(). static inline int bci(int bci) { diff --git a/ddprof-lib/src/main/cpp/hotspot/hotspotStackFrame.h b/ddprof-lib/src/main/cpp/hotspot/hotspotStackFrame.h index d33aedfb1f..cfce48bb57 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotStackFrame.h +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotStackFrame.h @@ -32,7 +32,7 @@ class HotspotStackFrame : public StackFrame { // anchor. Per the setjmp/longjmp rules (C11 7.13.2.1p3, inherited // by C++), a non-volatile automatic local modified in that window // has an indeterminate value after longjmp -- the same hazard - // ResolvedNames::_long_method_name (hotspotSupport.cpp) guards + // ResolvedMethod::_long_method_name (hotspotSupport.h) guards // against for an analogous fault-recovery readback. VMJavaFrameAnchor* volatile _anchor; const void* volatile _anchor_pc; diff --git a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp index a811682936..b071f1a451 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp @@ -12,6 +12,8 @@ #include "guards.h" #include "hotspot/hotspotSupport.h" #include "hotspot/jitCodeCache.h" +#include "hotspot/klassTraceId.h" +#include "hotspot/unloadCapture.h" #include "hotspot/vmStructs.inline.h" #include "jvmSupport.inline.h" #include "jvmThread.h" @@ -139,7 +141,7 @@ inline EventType eventTypeFromBCI(jint bci_type) { static bool hasTheSameMethod(ASGCT_CallFrame& frame, jmethodID method_id, const VMMethod* method) { if (FrameType::isRawPointer(frame.bci)) { - return frame.method == method; + return HotspotSupport::rawFrameMethod(frame.bci, frame.method) == method; } else { return frame.method_id == method_id; } @@ -186,13 +188,22 @@ static void fillFrameTypes(ASGCT_CallFrame *frames, int num_frames, VMNMethod *n } } -// Fill the frame with raw method pointer +// Fill the frame with raw method pointer, as partial mode stores it. static void fillFrameRaw(ASGCT_CallFrame& frame, FrameTypeId type, int bci, const VMMethod* method) { NO_INJECTION_ASSERT(method != nullptr); frame.bci = FrameType::encode(type, bci, true /*raw method pointer*/); frame.method = static_cast(method); } +// Fill the frame with raw method pointer and its lazy-mode tag (see frame.h +// for where the tag bits go, HotspotSupport::TRACE_ID_TAG for what they hold). +static void fillFrameRawTagged(ASGCT_CallFrame& frame, FrameTypeId type, int bci, const VMMethod* method, + uint32_t tag) { + NO_INJECTION_ASSERT(method != nullptr); + frame.bci = FrameType::encodeRaw(type, bci, tag); + frame.method = FrameType::tagPointer(method, tag); +} + void HotspotSupport::fillJavaFrame(ASGCT_CallFrame& frame, FrameTypeId type, int bci, jmethodID method_id, const VMMethod* method) { if (method_id == JMETHODID_NOT_WALKABLE) { @@ -211,7 +222,22 @@ void HotspotSupport::fillJavaFrame(ASGCT_CallFrame& frame, FrameTypeId type, int // resolution path; the deferred-dereference race at dump time is // inherent to this mode. NO_INJECTION_ASSERT(method != nullptr); - fillFrameRaw(frame, type, bci, method); + uint32_t tag; + const void* holder = nullptr; + if (JVMSupport::getLoadState() != JVMSupport::Lazy_loaded) { + fillFrameRaw(frame, type, bci, method); + } else if (!FrameType::canTagPointer(method) || !lazyFrameTag(method, &tag, &holder)) { + // fjmethodid=lazy, but either the tag's high bits would collide + // with address bits, or the holder's trace ID is unreadable. + // Without a tag the frame could be mistaken for one of a later + // class at the same address, so keep it structurally but resolve + // it as unknown. + Counters::increment(WALKVM_RAW_FRAME_UNTAGGABLE); + fillFrame(frame, type, bci, JMETHODID_NOT_WALKABLE); + } else { + fillFrameRawTagged(frame, type, bci, method, tag); + UnloadCapture::onRawFrame(method, holder, tag); + } } else { // fjmethodid=true: all jmethodIDs should be preloaded, so nullptr is // unexpected — a transient invalidation window or a missed preload. @@ -1453,99 +1479,122 @@ bool HotspotSupport::loadMethodIDsIfNeededImpl(jvmtiEnv *jvmti, JNIEnv *jni, jcl return JVMSupport::loadMethodIDsImpl(jvmti, jni, klass); } -// The three names resolve() needs, owned by resolve()'s frame. Each name has -// a fixed-size inline buffer for the common case, with a malloc'd fallback -// (up to MAX_SYMBOL_LEN) for names that don't fit -- see release() for why -// that fallback needs explicit cleanup on the crash-recovery path. -class ResolvedNames { - // Hard ceiling for the malloc fallback in setImpl(), independent of which - // field is being resolved. Not a JVM/class-file limit (Symbol::length() is - // a u2, so up to 65535 is legal) -- a name that would already have been rejected - // as too long for those fields' fixed buffers doesn't get an unbounded - // allocation just because it went through the malloc path instead. - static constexpr size_t MAX_SYMBOL_LEN = 64 * 1024; - - // Fixed-size fast-path buffers for the common case, sized generously above - // the realistic common-case length for each field (see the per-field - // comments below) but deliberately not so generous that the malloc - // fallback in setImpl() never engages -- these caps are meant to be hit - // occasionally so that path stays exercised. - // HotSpot's Symbol length field is a u2, so the VM permits up to 65535 bytes. - // A name/descriptor that overflows its fixed buffer falls back to malloc - // (see ResolvedNames::setImpl), up to MAX_SYMBOL_LEN below; beyond that it is - // rejected and the frame serializes as "unknown" -- METHOD_RESOLVE_SYMBOL_UNREADABLE - // makes that visible if it bites. Unlike the old stack-only design, the malloc - // fallback means this is no longer a leak-proof region: siglongjmp out of - // resolve() bypasses ~ResolvedNames(), so the fault-recovery path must call - // ResolvedNames::release() explicitly (see resolve()) to free any buffer - // allocated before the fault. - static constexpr size_t MAX_KLASS_NAME_LEN = 1024; // internal names; realistically < 256 - static constexpr size_t MAX_METHOD_NAME_LEN = 512; // realistically < 64 - // A descriptor can in principle exceed this (255 argument *slots*, each able to - // carry an arbitrarily long L...; type name), so this cap is a fidelity choice, - // not a proof: over-cap descriptors serialize as "unknown" rather than being - // truncated. METHOD_RESOLVE_SYMBOL_UNREADABLE makes it visible if that bites. - static constexpr size_t MAX_SIGNATURE_LEN = 1024; - - private: - // volatile: resolve() mutates these (via setMethodName/setMethodSignature/ - // setKlassName, called through readMethodNames()) between sigsetjmp() and a - // possible siglongjmp() out of a fault, then release() reads them back at - // the landing pad to decide what to free. Per the setjmp/longjmp rules - // (C11 7.13.2.1p3, inherited by C++), a non-volatile automatic local - // modified in that window has an indeterminate value after longjmp; - // volatile is what makes release()'s reads on the recovery path defined. - char* volatile _long_method_name; - char* volatile _long_method_signature; - char* volatile _long_klass_name; - - char _method_name[MAX_METHOD_NAME_LEN]; - char _method_signature[MAX_SIGNATURE_LEN]; - char _klass_name[MAX_KLASS_NAME_LEN]; - - bool setImpl(char* short_name, char* volatile& long_name, size_t short_limit, VMSymbol* sym); -public: - // Non-copyable - ResolvedNames(const ResolvedNames&) = delete; - ResolvedNames& operator=(const ResolvedNames&) = delete; - - ResolvedNames(); - ~ResolvedNames(); - void release(); - - bool setMethodName(VMSymbol* sym); - bool setMethodSignature(VMSymbol* sym); - bool setKlassName(VMSymbol* sym); - - const char* methodName() const { - return _long_method_name != nullptr ? _long_method_name : _method_name; - } +KlassRegistry HotspotSupport::_klass_registry; - const char* methodSignature() const { - return _long_method_signature != nullptr ? _long_method_signature : _method_signature; - } +bool HotspotSupport::canResolveLazily() { + return VMStructs::hasKlassMethods(); +} - const char* klassName() const { - return _long_klass_name != nullptr ? _long_klass_name : _klass_name; - } -}; +bool HotspotSupport::initLazyResolution(JNIEnv* jni) { + bool calibrated = KlassTraceId::calibrate(jni); + // Counters are reset on every profiler start, but calibration only runs + // once, so republish its result here. + Counters::set(KLASS_TRACE_ID_OFFSET, calibrated ? KlassTraceId::offset() : -1); + TEST_LOG("Raw frame tags: %s", calibrated ? "klass trace id" : "unavailable, lazy mode off"); + if (calibrated) { + UnloadCapture::init(VM::jvmti()); + if (UnloadCapture::enabled()) { + // registerClass() tells permanent loaders apart through these. + initClassloaderInfo(jni); + } + } + return calibrated; +} -ResolvedNames::ResolvedNames() : - _long_method_name(nullptr), - _long_method_signature(nullptr), - _long_klass_name(nullptr) { +bool HotspotSupport::lazyFrameTag(const VMMethod* method, uint32_t* tag, const void** holder) { + uint64_t id; + const void* klass = method->holderOrNull(); + if (!KlassTraceId::read(klass, &id)) { + return false; + } + if (holder != nullptr) { + *holder = klass; + } + *tag = TRACE_ID_TAG | ((uint32_t)id & TAG_VALUE_MASK); + return true; +} + +bool HotspotSupport::isInstanceClass(jvmtiEnv *jvmti, jclass klass) { + jint status = 0; + // GetClassStatus also returns JVMTI_ERROR_INVALID_CLASS for an object + // that is not a class. + return klass != nullptr && jvmti->GetClassStatus(klass, &status) == JVMTI_ERROR_NONE && + (status & (JVMTI_CLASS_STATUS_ARRAY | JVMTI_CLASS_STATUS_PRIMITIVE)) == 0; } -ResolvedNames::~ResolvedNames() { - release(); +void HotspotSupport::beforeClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass) { + if (_klass_registry.size() == 0 || !isInstanceClass(jvmti, klass)) { + return; + } + if (_klass_registry.beginRedefinition(VMKlass::fromJavaClass(jni, klass))) { + Counters::increment(KLASS_REGISTRY_REDEFINITIONS_MARKED); + } +} + +void HotspotSupport::afterClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass, bool succeeded) { + if (_klass_registry.size() == 0 || !isInstanceClass(jvmti, klass)) { + return; + } + _klass_registry.endRedefinition(VMKlass::fromJavaClass(jni, klass), succeeded); + // The class's raw frames are refused from now on, so give its later + // frames jmethodIDs. After a failed call nothing changed: its frames + // stay raw and resolvable. + if (succeeded && JVMSupport::getLoadState() == JVMSupport::Lazy_loaded) { + loadMethodIDsIfNeededImpl(jvmti, jni, klass, true /* load all */); + } } -void ResolvedNames::release() { +bool HotspotSupport::registerClass(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass) { + // Array and primitive classes have no methods, so no frame ever names them + // as a holder; skipping them keeps GetLoadedClasses() at start cheap. + if (!isInstanceClass(jvmti, klass)) { + return false; + } + const void* vm_klass = VMKlass::fromJavaClass(jni, klass); + if (UnloadCapture::enabled()) { + jobject loader = nullptr; + if (jvmti->GetClassLoader(klass, &loader) == JVMTI_ERROR_NONE && + (loader == nullptr || isSystemClassLoader(jni, loader))) { + UnloadCapture::notePermanentLoaderData(VMKlass::cast_raw(vm_klass)->classLoaderData()); + } + if (loader != nullptr) { + jni->DeleteLocalRef(loader); + } + } + uint64_t trace_id; + if (KlassTraceId::read(vm_klass, &trace_id) && trace_id > TAG_VALUE_MASK) { + // Two classes' 25-bit trace-ID tags can only collide once their IDs + // are at least 2^25 apart, so before any class has an ID this large + // no stale frame can be matched to a newer class by its tag. + Counters::increment(KLASS_TRACE_ID_BEYOND_TAG_RANGE); + } + if (_klass_registry.add(jni, vm_klass, klass)) { + return true; + } + // An unregistered class's raw frames could never be resolved (pin() finds + // no entry), so give it jmethodIDs up front: walkVM then never stores a + // raw frame for it. + Counters::increment(KLASS_REGISTRY_ADD_FAILED); + return loadMethodIDsIfNeededImpl(jvmti, jni, klass, true /*load all*/); +} + +ResolvedMethod::ResolvedMethod() : + _long_method_name(nullptr), + _long_method_signature(nullptr), + _long_klass_name(nullptr), + _line_number_table(nullptr), + _line_number_table_size(0), + _is_thread_run(false) { + _method_name[0] = '\0'; + _method_signature[0] = '\0'; + _klass_name[0] = '\0'; +} + +void ResolvedMethod::release() { // Must be idempotent: resolve()'s fault-recovery path calls this explicitly - // (siglongjmp bypasses ~ResolvedNames()), and then the destructor runs it - // again on the same object when resolve() returns normally afterward. - // Nulling out each pointer after freeing makes the second call a no-op - // instead of a double free. + // (siglongjmp bypasses ~ResolvedMethod()), and the destructor runs it again + // on the same object later. Nulling out each pointer after freeing makes + // the second call a no-op instead of a double free. if (_long_method_name != nullptr) { free(_long_method_name); _long_method_name = nullptr; @@ -1558,9 +1607,15 @@ void ResolvedNames::release() { free(_long_klass_name); _long_klass_name = nullptr; } + if (_line_number_table != nullptr) { + free(_line_number_table); + _line_number_table = nullptr; + } + _line_number_table_size = 0; + _is_thread_run = false; } -bool ResolvedNames::setImpl(char* short_name, char* volatile& long_name, size_t short_limit, VMSymbol* sym) { +bool ResolvedMethod::setName(char* short_name, char* volatile& long_name, size_t short_limit, VMSymbol* sym) { unsigned len = sym->length(); // raw u2 deref; PC stays inside this library // A method name, descriptor or class name is never empty; 0 means the Symbol // slot has been recycled. `>=` leaves room for the NUL. @@ -1585,49 +1640,106 @@ bool ResolvedNames::setImpl(char* short_name, char* volatile& long_name, size_t } } -bool ResolvedNames::setMethodName(VMSymbol* sym) { - return setImpl(_method_name, _long_method_name, MAX_METHOD_NAME_LEN, sym); +bool ResolvedMethod::setMethodName(VMSymbol* sym) { + return setName(_method_name, _long_method_name, MAX_METHOD_NAME_LEN, sym); +} +bool ResolvedMethod::setMethodSignature(VMSymbol* sym) { + return setName(_method_signature, _long_method_signature, MAX_SIGNATURE_LEN, sym); } -bool ResolvedNames::setMethodSignature(VMSymbol* sym) { - return setImpl(_method_signature, _long_method_signature, MAX_SIGNATURE_LEN, sym); + +bool ResolvedMethod::setKlassName(VMSymbol* sym) { + return setName(_klass_name, _long_klass_name, MAX_KLASS_NAME_LEN, sym); } -bool ResolvedNames::setKlassName(VMSymbol* sym) { - return setImpl(_klass_name, _long_klass_name, MAX_KLASS_NAME_LEN, sym); +// Compares a Symbol's contents with `expected` without touching the Symbol +// memory through libc (a fault inside memcmp() would have a pc outside this +// library and could not be recovered). +static bool symbolEquals(VMSymbol* sym, const char* expected, size_t expected_len) { + static constexpr size_t MAX_COMPARE_LEN = 64; + assert(expected_len <= MAX_COMPARE_LEN); + if (sym == nullptr || sym->length() != expected_len) { + return false; + } + char buf[MAX_COMPARE_LEN]; + return SafeAccess::safeCopy(buf, sym->body(), expected_len) && + memcmp(buf, expected, expected_len) == 0; } +// Whether `klass` is java.lang.Thread or one of its subclasses, by walking +// the Klass::_super chain. Unknown (no _super offset) is reported as false. +static bool isThreadSubclass(VMKlass* klass) { + static const char THREAD_CLASS[] = "java/lang/Thread"; + // Real hierarchies are far shallower; the cap only guards against a cycle + // in stale metadata. + static constexpr int MAX_DEPTH = 64; + for (int depth = 0; klass != nullptr && depth < MAX_DEPTH; depth++) { + if (symbolEquals(klass->name(), THREAD_CLASS, sizeof(THREAD_CLASS) - 1)) { + return true; + } + klass = klass->super_or_null(); + } + return false; +} -// PHASE 1 -- the raw HotSpot metadata walk. MUST run with a jmp ctx installed: -// every step is a raw *(void**)(this + offset) whose target may have been freed -// by GC or class unloading since the sample was taken. Deliberately contains no -// JNI and no JVMTI, so the whole protected region stays inside this library, -// where Profiler::checkFault() can actually recover. It can allocate, via -// ResolvedNames::setImpl()'s malloc fallback for over-sized names -- that -// allocation is self-contained (glibc malloc doesn't call back into JNI/JVMTI), -// but it does mean a fault after the allocation needs explicit freeing, since -// siglongjmp out of this scope bypasses ~ResolvedNames() (see resolve()). -// -// Returns false if the metadata is unusable. On success either *out_id holds an -// already-valid jmethodID (and `names` is untouched), or *out_id is null and -// `names` has been filled in for the JNI lookup the caller does afterwards. -static bool readMethodNames(const void* method, VMMethod** out_vm_method, - jmethodID* out_id, ResolvedNames* names) { - *out_vm_method = nullptr; - *out_id = nullptr; +// Decodes the method's compressed line number table into a malloc'd +// jvmtiLineNumberEntry array, the same shape JVMTI GetLineNumberTable() +// produces. Returns false only if the table is present but implausible (a +// bci past the bytecode or too many entries), i.e. the metadata is stale; a +// method without a table returns true with no table attached. +static bool readLineNumberTable(VMConstMethod* const_method, + jvmtiLineNumberEntry* volatile& table, volatile int& table_size) { + const unsigned char* compressed = const_method->compressedLineNumberTable(); + if (compressed == nullptr) { + return true; + } + int code_size = const_method->codeSize(); + + // Pass 1: validate and count, so the second pass can fill an exactly-sized + // buffer. The bound is the one HotSpot's own ConstMethod::verify_on() + // checks every entry against (bci <= code_size). + int count = 0; + VMLineNumberStream counter(compressed); + while (counter.readPair()) { + if (counter.bci() < 0 || counter.bci() > code_size || + ++count > ResolvedMethod::MAX_LINE_NUMBER_TABLE_ENTRIES) { + return false; + } + } + if (count == 0) { + return true; + } + + jvmtiLineNumberEntry* entries = (jvmtiLineNumberEntry*)malloc((size_t)count * sizeof(jvmtiLineNumberEntry)); + if (entries == nullptr) { + return true; // keep the names; the frame just has no line numbers + } + // Owned by the ResolvedMethod from here on, so a fault in pass 2 still frees it. + table = entries; + VMLineNumberStream reader(compressed); + for (int i = 0; i < count; i++) { + if (!reader.readPair()) { + return false; // stream changed under us between the passes + } + entries[i].start_location = (jlocation)reader.bci(); + entries[i].line_number = (jint)reader.line(); + } + table_size = count; + return true; +} + +// Every step is a raw *(void**)(this + offset) whose target may have been +// freed by GC, class unloading or redefinition since the sample was taken, so +// this must run under protectedWalk(). It contains no JNI and no JVMTI, so the +// whole protected region stays inside this library, where +// Profiler::checkFault() can recover. Allocations it makes are owned by +// `result` as soon as they are made, so the landing pad can free them. +bool HotspotSupport::readMethod(const void* method, ResolvedMethod* result) { VMMethod* vm_method = VMMethod::cast_or_null(method); if (vm_method == nullptr) { return false; } - *out_vm_method = vm_method; - - // May have been populated by following code or JMETHODID_NOT_WALKABLE - jmethodID method_id = vm_method->validatedId(); - if (isValidJMethodID(method_id)) { - *out_id = method_id; - return true; - } VMConstMethod* const_method = vm_method->constMethod_or_null(); if (const_method == nullptr) { @@ -1652,124 +1764,63 @@ static bool readMethodNames(const void* method, VMMethod** out_vm_method, return false; } - if (!names->setMethodName(name_sym) || !names->setMethodSignature(sig_sym) || !names->setKlassName(klass_sym)) { + if (!result->setMethodName(name_sym) || !result->setMethodSignature(sig_sym) || !result->setKlassName(klass_sym)) { Counters::increment(METHOD_RESOLVE_SYMBOL_UNREADABLE); return false; } - return true; -} - -// PHASE 2 -- the JNI/JVMTI lookup, reading only the buffers phase 1 filled. -// MUST run with crash protection *off*. Two reasons, both about siglongjmp -// unwinding frames it must not: -// - A fault inside libjvm.so is unrecoverable anyway (checkFault() only -// recovers PCs inside this library), so a landing pad buys nothing here. -// - FindClass() loads the class when it is not already loaded, which -// synchronously runs our own JVMTI ClassPrepare callback -> -// patchClassLoaderData(), which holds the JVM's ClassLoaderData mutex. That -// code *is* in this library, so with a pad installed a fault there would -// siglongjmp out of a JVMTI callback with a JVM lock held, an abandoned JNI -// local frame and unbalanced safepoint state -- trading a crash for a -// JVM-wide deadlock. -// vm_method->validatedId() below is safefetch-based, so it is safe unprotected. -// -// A plain, TU-local helper -- like readMethodNames() -- rather than a -// HotspotSupport member or friend: it never touches HotspotSupport's private -// state directly, so nothing about it, including ResolvedNames (a type -// defined entirely in this file with no header of its own), needs to be -// declared in hotspotSupport.h. The fallback needs the private -// HotspotSupport::loadMethodIDsIfNeededImpl(), so resolve() -- which does -// have access, being a member -- passes it in as a plain function pointer -// instead of this function calling it directly. -// -// Returns a jmethodID valid for as long as the declaring class stays loaded -// (the same ownership/lifetime jmethodIDs always have in this codebase -- no -// release call is needed), or nullptr if the method could not be found via -// JNI/JVMTI. -static jmethodID lookupMethodIdViaJni(VMMethod* vm_method, const ResolvedNames& names, - bool (*loadMethodIDsIfNeededImpl)(jvmtiEnv*, JNIEnv*, jclass, bool)) { - jmethodID method_id = nullptr; - const char* method_name = names.methodName(); - const char* method_signature = names.methodSignature(); - const char* klass_name = names.klassName(); - JNIEnv *jni = VM::jni(); - jclass clz = jni->FindClass(klass_name); - if (clz == nullptr) { - jni->ExceptionClear(); - } else { - method_id = jni->GetMethodID(clz, method_name, method_signature); - if (method_id == nullptr) { - jni->ExceptionClear(); - method_id = jni->GetStaticMethodID(clz, method_name, method_signature); - if (method_id == nullptr) { - jni->ExceptionClear(); - // JNI GetMethodID/GetStaticMethodID cannot look up because - // the JVM intentionally hides class initializers from JNI callers. - // Fall back to loadMethodIDsIfNeededImpl(), which covers all methods - // including and forces jmethodID slot allocation for them - // (going through this helper, rather than calling GetClassMethods - // directly, ensures the JDK-8062116 patchClassLoaderData() workaround - // is applied here too, same as every other jmethodID-preload path). - // After the call, re-read the ID directly from VM metadata. - if (strcmp(method_name, "") == 0) { - jvmtiEnv* jvmti = VM::jvmti(); - if (jvmti != nullptr) { - if (loadMethodIDsIfNeededImpl(jvmti, jni, clz, true /*load all*/)) { - jmethodID validated = vm_method->validatedId(); - if (isValidJMethodID(validated)) { - method_id = validated; - } - } - } - } - } - } - jni->DeleteLocalRef(clz); + if (strcmp(result->methodName(), "run") == 0 && strcmp(result->methodSignature(), "()V") == 0) { + result->_is_thread_run = isThreadSubclass(klass); } - return method_id; + if (!readLineNumberTable(const_method, result->_line_number_table, result->_line_number_table_size)) { + Counters::increment(LINE_NUMBER_TABLE_UNREADABLE); + return false; + } + return true; } -// This method only resolves methods that are loaded by system class loaders -jmethodID HotspotSupport::resolve(const void* method) { - assert(VM::isHotspot()); - NO_INJECTION_ASSERT(method != nullptr); - // fillJavaFrame stores the sentinel without the raw flag, so this should - // never reach the raw-pointer resolve path. Map it to nullptr so the dump - // thread serializes it as the shared unknown method. - if ((jmethodID)method == JMETHODID_NOT_WALKABLE) { - return nullptr; +// fjmethodid=lazy counterpart of readMethod(), under the same protection. +// Reads only what KlassRegistry needs to decide whether the Method* is still +// live: its holder Klass* and its idnum. Both come out of metadata that may +// have been freed and reused, so they are lookup keys, not facts -- +// resolveViaRegistry() validates them against a pinned class before anything +// else is read from the Method*. +static bool readMethodHolder(const void* method, VMMethod** out_vm_method, + const void** out_holder, int* out_idnum) { + VMMethod* vm_method = VMMethod::cast_or_null(method); + if (vm_method == nullptr) { + return false; } - - // The Method* was captured at sample time; GC or class unloading may have - // freed the metadata since, so every dereference below can fault. Install a - // landing pad and report the method as unresolved instead of taking the JVM - // down mid-dump -- nullptr is already a first-class result for our caller - // (Lookup::resolveMethod serializes it as the shared unknown method). - // - // Runs on the JFR dump thread (Profiler::dump/stop), not in a signal handler. - // acquireCurrent() rather than current() because JNI_OnUnload reaches - // Profiler::stop() without priming TLS. - ProfiledThread* prof_thread = ProfiledThread::acquireCurrent(); - if (prof_thread == nullptr) { - // No landing pad available, so refuse to touch metadata that may be stale - // rather than risk crashing. Reached only on an unprimed shutdown path or - // when the thread-local pool is exhausted. - Counters::increment(SAMPLES_DROPPED_THREAD_LOCAL); - return nullptr; + // The same constMethod -> constants -> pool holder walk the sampler does + // when it tags the frame (lazyFrameTag()). + const void* holder = vm_method->holderOrNull(); + VMConstMethod* const_method = vm_method->constMethod_or_null(); + if (holder == nullptr || const_method == nullptr) { + return false; } + *out_vm_method = vm_method; + *out_holder = holder; + *out_idnum = const_method->idnum(); + return true; +} - ResolvedNames names; - VMMethod* vm_method = nullptr; - jmethodID existing_id = nullptr; - bool walked = false; - +// Runs `walk` -- a metadata walk that may fault -- with a landing pad, and +// returns its result, or false if it faulted. On false, `result` is released, +// including anything `walk` allocated before a fault. +// +// Runs on the dump thread, not in a signal handler. Chained via JmpCtxScope: +// the dump thread can be interrupted by a sampling signal whose walkVM() +// installs its own context, so the previous landing pad must be reinstated on +// every exit path from this frame. No JNI or JVMTI may run inside `walk`: a +// fault in libjvm.so is not recoverable (checkFault() only recovers pcs in this +// library), and a fault in one of our own JVMTI callbacks it triggered would +// siglongjmp out of the callback with JVM locks held. +template +static bool protectedWalk(ProfiledThread* prof_thread, ResolvedMethod& result, Walk walk) { + bool ok = false; { sigjmp_buf crash_protection_ctx; - // Chained via JmpCtxScope: the dump thread can be interrupted by a sampling - // signal whose walkVM() installs its own context, so the previous landing - // pad must be reinstated on every exit path from this frame. JmpCtxScope jmp_scope(prof_thread); // savemask must be 1: the siglongjmp originates inside segvHandler, where // the kernel has SIGSEGV blocked, so without restoring the saved mask the @@ -1781,25 +1832,153 @@ jmethodID HotspotSupport::resolve(const void* method) { // before touching anything that could fault again. SIGNAL_HANDLER_UNWIND_AFTER_LONGJMP(); jmp_scope.restore(); - // siglongjmp bypasses ~ResolvedNames(), so a name that was already - // malloc'd (in setImpl()'s over-sized-name fallback) before the fault - // would otherwise leak. release() is idempotent, so it's safe that the - // destructor also runs it when resolve() returns below. - names.release(); + result.release(); Counters::increment(METHOD_RESOLVE_FAULT_RECOVERED); - return nullptr; + return false; } jmp_scope.install(&crash_protection_ctx); + ok = walk(); + } + if (!ok) { + result.release(); + } + return ok; +} + +// Partial mode only reaches here for system-class-loader classes, which are +// never unloaded; lazy mode reaches here for every class without a jmethodID +// and resolves it through _klass_registry. +bool HotspotSupport::resolve(const void* tagged_method, int bci, ResolvedMethod& result) { + assert(VM::isHotspot()); + NO_INJECTION_ASSERT(tagged_method != nullptr); + // fillJavaFrame stores the sentinel without the raw flag, so this should + // never reach the raw-pointer resolve path. Report it as unresolved so the + // dump thread serializes it as the shared unknown method. + if ((jmethodID)tagged_method == JMETHODID_NOT_WALKABLE) { + return false; + } + // The frame says which mode sampled it, whatever mode the profiler is in + // now: a lazy-mode frame (tag-kind bit set) carries the holder's trace ID, + // a partial-mode frame is a plain Method*. + const uint32_t tag = FrameType::rawTag(bci, tagged_method); + const bool lazy = (tag & TRACE_ID_TAG) != 0; + const void* method = rawFrameMethod(bci, tagged_method); - walked = readMethodNames(method, &vm_method, &existing_id, &names); + // The Method* was captured at sample time; GC or class unloading may have + // freed the metadata since, so every dereference can fault and runs under + // protectedWalk(). acquireCurrent() rather than current() because + // JNI_OnUnload reaches Profiler::stop() without priming TLS. + ProfiledThread* prof_thread = ProfiledThread::acquireCurrent(); + if (prof_thread == nullptr) { + // No landing pad available, so refuse to touch metadata that may be stale + // rather than risk crashing. Reached only on an unprimed shutdown path or + // when the thread-local pool is exhausted. + Counters::increment(SAMPLES_DROPPED_THREAD_LOCAL); + return false; } - // --- crash protection is off from here on; see lookupMethodIdViaJni() --- - if (!walked) { - return nullptr; + if (!lazy) { + // A redefinition can free a system class's replaced Method* and reuse its + // memory; nothing here can tell, so such a frame may resolve to the + // method now at that address. + return protectedWalk(prof_thread, result, [&] { return readMethod(method, &result); }); } - if (existing_id != nullptr) { - return existing_id; + VMMethod* vm_method = nullptr; + const void* holder = nullptr; + int idnum = -1; + if (!protectedWalk(prof_thread, result, + [&] { return readMethodHolder(method, &vm_method, &holder, &idnum); })) { + Counters::increment(METHOD_RESOLVE_LAZY_UNREADABLE); + return false; + } + return resolveViaRegistry(prof_thread, vm_method, holder, idnum, tag & TAG_VALUE_MASK, result); +} + +// Pins the class through JNI, so this runs outside any landing pad (see +// protectedWalk()); only the readMethod() call is protected. Nothing here +// dereferences vm_method until the class that owns it is pinned and the +// method is confirmed to be one of that class's current methods. +bool HotspotSupport::resolveViaRegistry(ProfiledThread* prof_thread, VMMethod* vm_method, + const void* holder, int idnum, uint32_t trace_id_bits, + ResolvedMethod& result, bool count) { + JNIEnv* jni = VM::jni(); + KlassRegistry::PinStatus status; + jclass clz = _klass_registry.pin(jni, holder, &status); + if (clz == nullptr) { + // Redefined: the Method* may have been freed and reused within the class. + // Otherwise the class was unloaded after the sample was taken, or `holder` + // was read from a freed Method* and is garbage. + if (count) Counters::increment(status == KlassRegistry::PIN_REDEFINED ? METHOD_RESOLVE_LAZY_REDEFINED + : METHOD_RESOLVE_LAZY_UNLOADED); + return false; + } + + uint64_t trace_id = 0; + if (!KlassTraceId::read(holder, &trace_id) || ((uint32_t)trace_id & TAG_VALUE_MASK) != trace_id_bits) { + // The class at `holder` is a different load than the one sampled: its + // memory was taken over after the sample. + if (count) Counters::increment(METHOD_RESOLVE_LAZY_STALE); + jni->DeleteLocalRef(clz); + return false; + } + bool resolved = false; + // A miss means the Method* was freed (and possibly its memory reused), or + // it is an old version replaced by a redefinition. containsMethod() reads + // through SafeAccess: the pinned class stays alive, but a concurrent + // redefinition can free its old methods array and Method*s. + if (!VMKlass::cast_raw(holder)->containsMethod(vm_method, idnum)) { + if (count) Counters::increment(METHOD_RESOLVE_LAZY_NOT_CURRENT); + } else { + // While the class is pinned its metadata stays allocated, except what a + // concurrent redefinition frees; protectedWalk() covers that case, and + // the mark check below rejects whatever it read. + resolved = protectedWalk(prof_thread, result, [&] { return readMethod(vm_method, &result); }); + } + jni->DeleteLocalRef(clz); + // The class is marked before a redefinition swaps its methods. If the mark + // is still absent now, every read above happened before that swap, so the + // Method* we validated was the class's own; otherwise we cannot tell. + if (resolved && _klass_registry.isRedefined(holder)) { + result.release(); + if (count) Counters::increment(METHOD_RESOLVE_LAZY_REDEFINED); + return false; + } + if (resolved) { + if (count) Counters::increment(METHOD_RESOLVE_LAZY_RESOLVED); + // For CI only: LazyTraceIdJfrValidationTest compares this with the class + // ID JFR records for the same class. + TEST_LOG("Lazy resolution of %s, trace id %llu", result.klassName(), (unsigned long long)trace_id); + } + return resolved; +} + +bool HotspotSupport::captureMethod(ProfiledThread* prof_thread, const void* method, uint32_t tag, + ResolvedMethod& result) { + const uint32_t bits = tag & TAG_VALUE_MASK; + VMMethod* vm_method = nullptr; + const void* holder = nullptr; + int idnum = -1; + return protectedWalk(prof_thread, result, [&] { + if (!readMethodHolder(method, &vm_method, &holder, &idnum)) { + return false; + } + uint64_t trace_id = 0; + if (!KlassTraceId::read(holder, &trace_id) || ((uint32_t)trace_id & TAG_VALUE_MASK) != bits) { + return false; + } + return VMKlass::cast_raw(holder)->containsMethod(vm_method, idnum) && readMethod(vm_method, &result); + }); +} + +bool HotspotSupport::capturePinned(ProfiledThread* prof_thread, const void* method, uint32_t tag, + ResolvedMethod& result) { + VMMethod* vm_method = nullptr; + const void* holder = nullptr; + int idnum = -1; + if (!protectedWalk(prof_thread, result, + [&] { return readMethodHolder(method, &vm_method, &holder, &idnum); })) { + return false; } - return lookupMethodIdViaJni(vm_method, names, &HotspotSupport::loadMethodIDsIfNeededImpl); + return resolveViaRegistry(prof_thread, vm_method, holder, idnum, tag & TAG_VALUE_MASK, result, + false /* count */); } diff --git a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h index 8588635dfc..6a31653881 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h @@ -10,6 +10,7 @@ #include "guards.h" #include "hotspot/hotspotStackFrame.h" #include "hotspot/jitCodeCache.h" +#include "hotspot/klassRegistry.h" #include "frame.h" #include "stackFrame.h" #include "stackWalker.h" @@ -19,6 +20,99 @@ #include class VMMethod; +class VMSymbol; + +// What HotspotSupport::resolve() extracts from a raw Method*: the holder's +// internal class name, the method name and signature, and the decoded line +// number table. Everything is copied out of the VM metadata, so the result +// stays valid after the class has been unloaded. +// +// Each name has a fixed-size inline buffer for the common case, with a +// malloc'd fallback (up to MAX_SYMBOL_LEN) for names that don't fit. The line +// number table is always malloc'd. resolve() can siglongjmp out of a fault +// after any of these allocations, bypassing ~ResolvedMethod(), which is why +// its fault-recovery path calls release() explicitly. +class ResolvedMethod { + // Hard ceiling for the malloc fallback in setName(). Not a JVM/class-file + // limit (Symbol::length() is a u2, so up to 65535 is legal). + static constexpr size_t MAX_SYMBOL_LEN = 64 * 1024; + + // Fast-path buffer sizes, deliberately small enough that the malloc + // fallback in setName() still gets exercised occasionally. A name that does + // not fit even the fallback serializes as "unknown"; + // METHOD_RESOLVE_SYMBOL_UNREADABLE makes that visible. + static constexpr size_t MAX_KLASS_NAME_LEN = 1024; // internal names; realistically < 256 + static constexpr size_t MAX_METHOD_NAME_LEN = 512; // realistically < 64 + static constexpr size_t MAX_SIGNATURE_LEN = 1024; + + // volatile: resolve() mutates these between sigsetjmp() and a possible + // siglongjmp() out of a fault, and release() reads them back at the landing + // pad. Per C11 7.13.2.1p3 a non-volatile automatic modified in that window + // has an indeterminate value after longjmp. + char* volatile _long_method_name; + char* volatile _long_method_signature; + char* volatile _long_klass_name; + jvmtiLineNumberEntry* volatile _line_number_table; + volatile int _line_number_table_size; + volatile bool _is_thread_run; + + char _method_name[MAX_METHOD_NAME_LEN]; + char _method_signature[MAX_SIGNATURE_LEN]; + char _klass_name[MAX_KLASS_NAME_LEN]; + + bool setName(char* short_name, char* volatile& long_name, size_t short_limit, VMSymbol* sym); + + friend class HotspotSupport; + +public: + // Upper bound on decoded line number table entries; mirrors the u2 + // code_length limit of JVMS 4.7.3. + static constexpr int MAX_LINE_NUMBER_TABLE_ENTRIES = 65535; + + ResolvedMethod(const ResolvedMethod&) = delete; + ResolvedMethod& operator=(const ResolvedMethod&) = delete; + + ResolvedMethod(); + ~ResolvedMethod() { release(); } + + // Frees everything still owned. Idempotent. + void release(); + + bool setMethodName(VMSymbol* sym); + bool setMethodSignature(VMSymbol* sym); + bool setKlassName(VMSymbol* sym); + + // Internal (slash-separated) class name, e.g. "java/lang/Thread". + const char* klassName() const { + return _long_klass_name != nullptr ? _long_klass_name : _klass_name; + } + + const char* methodName() const { + return _long_method_name != nullptr ? _long_method_name : _method_name; + } + + const char* methodSignature() const { + return _long_method_signature != nullptr ? _long_method_signature : _method_signature; + } + + // True for run()V declared by java.lang.Thread or one of its subclasses. + bool isThreadRun() const { return _is_thread_run; } + + // Decoded line number table, sorted by bci as HotSpot stores it, or + // nullptr if the method has none. Allocated with malloc(). + const jvmtiLineNumberEntry* lineNumberTable() const { return _line_number_table; } + int lineNumberTableSize() const { return _line_number_table_size; } + + // Hands ownership of the line number table to the caller, who must free() + // it. Returns nullptr if there is none. + jvmtiLineNumberEntry* takeLineNumberTable(int* size) { + jvmtiLineNumberEntry* table = _line_number_table; + *size = _line_number_table_size; + _line_number_table = nullptr; + _line_number_table_size = 0; + return table; + } +}; class HotspotSupport { friend class JVMSupport; @@ -49,6 +143,57 @@ class HotspotSupport { static bool loadMethodIDsIfNeededImpl(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass, bool load_all); + // fjmethodid=lazy: liveness registry for every loaded class, consulted by + // resolve() before it touches a raw Method*. See klassRegistry.h. + static KlassRegistry _klass_registry; + + // Raw frame tags (frame.h). fjmethodid=lazy frames set the top tag bit + // and keep the low 25 bits of the holder's JFR trace ID (KlassTraceId) in + // the rest, unique per load of a class. Partial-mode (fjmethodid=false) + // frames carry no tag: their method field is the plain Method*, whose top + // bit is never set for a user-space address. resolve() goes by each + // frame's own kind, never by the current mode. + static const uint32_t TRACE_ID_TAG = 1u << (FrameType::RAW_TAG_BITS - 1); + static const uint32_t TAG_VALUE_MASK = TRACE_ID_TAG - 1; + + // The tag to store with a lazy-mode raw frame of `method`; false if the + // holder's trace ID is unreadable. Signal safe. + static bool lazyFrameTag(const VMMethod* method, uint32_t* tag, const void** holder = nullptr); + + // fjmethodid=lazy: records `klass` in _klass_registry instead of + // allocating its jmethodIDs. Called wherever the other modes preload. + static bool registerClass(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass); + + // Around every RedefineClasses/RetransformClasses call, in every mode + // once _klass_registry has entries (raw frames of a lazy session can + // outlive it): marks `klass` while the call runs and for good if it + // succeeds (see klassRegistry.h for why its raw frames are refused), and in + // lazy mode preloads its jmethodIDs after a successful call. `klass` comes + // straight from the caller, so anything that is not an instance class is + // ignored, as JVMTI itself rejects it. + static void beforeClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass); + static void afterClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass, bool succeeded); + + // True if JVMTI accepts `klass` as a class that is neither an array nor a + // primitive, i.e. one with a Klass* that has methods. + static bool isInstanceClass(jvmtiEnv *jvmti, jclass klass); + + // fjmethodid=lazy half of resolve(): pins the class registered under + // `holder`, refuses it if its trace ID does not match `trace_id_bits` (the + // frame came from another load of a class whose memory this one reuses), + // checks that `vm_method` is still one of its methods, and only then reads + // its names and line number table into `result`, with the class still + // pinned. `holder` and `idnum` were read from the unverified Method* and + // are only used as lookup keys. + static bool resolveViaRegistry(ProfiledThread* prof_thread, VMMethod* vm_method, + const void* holder, int idnum, uint32_t trace_id_bits, + ResolvedMethod& result, bool count = true); + + // The raw metadata walk behind resolve(): names, signature, Thread.run + // detection and line number table of `method`. Must run with crash + // protection installed; uses no JNI or JVMTI. + static bool readMethod(const void* method, ResolvedMethod* result); + // Runs getJavaTraceAsync() under withUcontextFaultRecovery(), then layers // on its two post-processing steps: resolving frame types for the top // Java frame via fillFrameTypes(), and appending a synthetic "JVM @@ -157,15 +302,51 @@ class HotspotSupport { return JitCodeCache::runtimeStubsMemoryUsage(); } - // If should load all jmethodIDs - static inline bool shouldPreloadJmethodIDs(Arguments& args) { - CStack cstack = args._cstack; - return args._force_jmethodID || - !(cstack == CSTACK_VM || cstack == CSTACK_DEFAULT); // Can only use Method* when cstack = vm + // If should load all jmethodIDs. Raw Method* frames only come from walkVM, + // so anything but the resolved stack walker mode `cstack` == CSTACK_VM -- + // including CSTACK_DEFAULT, which resolves to DWARF where the VM walker is + // unavailable -- needs every jmethodID preloaded. + static inline bool shouldPreloadJmethodIDs(const Arguments& args, CStack cstack) { + return args._force_jmethodID || cstack != CSTACK_VM; + } + + // The Method* a raw frame stands for: untagged for a lazy-mode frame, + // as stored for a partial-mode one. + static inline const void* rawFrameMethod(int bci, const void* method) { + return (FrameType::rawTag(bci, method) & TRACE_ID_TAG) != 0 ? FrameType::untagPointer(method) : method; } - // Resolve a method to a jmethodID at dumping time - static jmethodID resolve(const void* method); + // fjmethodid=lazy needs the Klass*/methods-array structs on top of the + // ones the raw Method* walk already uses; without them, the caller falls + // back to the partial preload mode. + static bool canResolveLazily(); + + // fjmethodid=lazy setup that needs JNI: locates Klass::_trace_id + // (KlassTraceId::calibrate). Returns false if it cannot be found; lazy + // mode then does not run, since nothing else can tell a stale raw frame + // from one of a class that took over its memory. + static bool initLazyResolution(JNIEnv* jni); + + // Resolves a raw-pointer frame at dump time into its class name, method + // name, signature and line number table, read from VM metadata. Takes the + // frame's method field and encoded bci as stored, tag bits included. + // Returns false, leaving `result` empty, if the frame must be treated as + // unknown: the JMETHODID_NOT_WALKABLE sentinel, metadata that is stale or + // unreadable, or a lazy-mode frame the class registry refuses. + static bool resolve(const void* tagged_method, int bci, ResolvedMethod& result); + + // PROTOTYPE (UnloadCapture): reads `method`'s names under crash protection + // if its holder's trace ID still matches `tag` and it is one of the + // holder's current methods. No JNI, no JVMTI, no locks: callable from a + // GarbageCollectionStart callback. + static bool captureMethod(ProfiledThread* prof_thread, const void* method, uint32_t tag, + ResolvedMethod& result); + + // PROTOTYPE (UnloadCapture background drain): resolve() for a lazy + // (Method*, tag) pair, pinning the class through the registry, without + // touching the method_resolve_lazy_* counters. Needs an attached thread. + static bool capturePinned(ProfiledThread* prof_thread, const void* method, uint32_t tag, + ResolvedMethod& result); // Store a Java frame captured from HotSpot metadata. A null jmethodID // triggers the raw Method* fallback only when fjmethodid=false; with diff --git a/ddprof-lib/src/main/cpp/hotspot/klassRegistry.cpp b/ddprof-lib/src/main/cpp/hotspot/klassRegistry.cpp new file mode 100644 index 0000000000..6b661434a1 --- /dev/null +++ b/ddprof-lib/src/main/cpp/hotspot/klassRegistry.cpp @@ -0,0 +1,194 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "hotspot/klassRegistry.h" + +#include + +static inline size_t slotFor(const void* klass, size_t capacity) { + // Klass* is at least 8-byte aligned; drop the always-zero bits before mixing. + uint64_t h = ((uintptr_t)klass >> 3) * 0x9E3779B97F4A7C15ULL; + return (size_t)(h >> 32) & (capacity - 1); +} + +// Linear probing. Returns the slot holding `klass`, or the empty slot where it +// would go. The table is never full (rehash() keeps the load factor <= 3/4), +// so the probe always terminates. +KlassRegistry::Entry* KlassRegistry::find(const void* klass) const { + size_t i = slotFor(klass, _capacity); + while (_table[i].klass != nullptr && _table[i].klass != klass) { + i = (i + 1) & (_capacity - 1); + } + return &_table[i]; +} + +// Called with _lock held. The entry registered for `klass`, or nullptr. +KlassRegistry::Entry* KlassRegistry::findRegistered(const void* klass) const { + if (klass == nullptr || _capacity == 0) { + return nullptr; + } + Entry* e = find(klass); + return e->klass == klass ? e : nullptr; +} + +// Moves the live entries into a table sized for them and deletes the weak +// references of dead ones. Two passes so the old table stays intact if the +// allocation fails: the first only counts live entries to size the new +// table, the second checks liveness again and drops whatever is dead by then, +// so an entry that died in between is purged too. +bool KlassRegistry::rehash(JNIEnv* jni) { + size_t live = 0; + for (size_t i = 0; i < _capacity; i++) { + if (_table[i].klass != nullptr && !jni->IsSameObject(_table[i].mirror, nullptr)) { + live++; + } + } + + size_t capacity = INITIAL_CAPACITY; + while (capacity < (live + 1) * 2) { + capacity <<= 1; + } + Entry* table = (Entry*)calloc(capacity, sizeof(Entry)); + if (table == nullptr) { + return false; + } + + Entry* old_table = _table; + size_t old_capacity = _capacity; + _table = table; + _capacity = capacity; + _size = 0; + for (size_t i = 0; i < old_capacity; i++) { + Entry& e = old_table[i]; + if (e.klass == nullptr) { + continue; + } + if (jni->IsSameObject(e.mirror, nullptr)) { + jni->DeleteWeakGlobalRef(e.mirror); + } else { + *find(e.klass) = e; + _size++; + } + } + free(old_table); + return true; +} + +bool KlassRegistry::add(JNIEnv* jni, const void* klass, jclass mirror) { + if (klass == nullptr || mirror == nullptr) { + return false; + } + MutexLocker ml(_lock); + + if (_capacity != 0) { + Entry* e = find(klass); + if (e->klass == klass) { + if (jni->IsSameObject(e->mirror, mirror)) { + return true; + } + jweak ref = jni->NewWeakGlobalRef(mirror); + if (ref == nullptr) { + return false; + } + jni->DeleteWeakGlobalRef(e->mirror); + e->mirror = ref; + // A different class now lives at this address; the previous one's + // redefinition state does not carry over. + e->redefinitions_in_progress = 0; + e->redefined = false; + return true; + } + } + + if ((_size + 1) * 4 > _capacity * 3 && !rehash(jni)) { + return false; + } + jweak ref = jni->NewWeakGlobalRef(mirror); + if (ref == nullptr) { + return false; + } + Entry* e = find(klass); + e->klass = klass; + e->mirror = ref; + e->redefinitions_in_progress = 0; + e->redefined = false; + _size++; + return true; +} + +jclass KlassRegistry::pin(JNIEnv* jni, const void* klass, PinStatus* status) { + PinStatus local_status; + if (status == nullptr) { + status = &local_status; + } + *status = PIN_ABSENT; + MutexLocker ml(_lock); + Entry* e = findRegistered(klass); + if (e == nullptr) { + return nullptr; + } + if (e->redefined || e->redefinitions_in_progress > 0) { + *status = PIN_REDEFINED; + return nullptr; + } + // NewLocalRef on a weak global reference returns NULL once the referent has + // been collected. Done under the lock so a concurrent rehash() cannot delete + // the weak reference out from under the call. + jclass ref = (jclass)jni->NewLocalRef(e->mirror); + if (ref != nullptr) { + *status = PIN_OK; + } + return ref; +} + +bool KlassRegistry::beginRedefinition(const void* klass) { + MutexLocker ml(_lock); + Entry* e = findRegistered(klass); + if (e == nullptr) { + return false; + } + e->redefinitions_in_progress++; + return true; +} + +void KlassRegistry::endRedefinition(const void* klass, bool succeeded) { + MutexLocker ml(_lock); + Entry* e = findRegistered(klass); + if (e == nullptr) { + return; + } + // Clamped: the entry may have been replaced (a new class at this address) + // while the call was in progress, which resets the count. + if (e->redefinitions_in_progress > 0) { + e->redefinitions_in_progress--; + } + if (succeeded) { + e->redefined = true; + } +} + +bool KlassRegistry::isRedefined(const void* klass) { + MutexLocker ml(_lock); + Entry* e = findRegistered(klass); + return e != nullptr && (e->redefined || e->redefinitions_in_progress > 0); +} + +size_t KlassRegistry::size() { + MutexLocker ml(_lock); + return _size; +} + +void KlassRegistry::clear(JNIEnv* jni) { + MutexLocker ml(_lock); + for (size_t i = 0; i < _capacity; i++) { + if (_table[i].klass != nullptr) { + jni->DeleteWeakGlobalRef(_table[i].mirror); + } + } + free(_table); + _table = nullptr; + _capacity = 0; + _size = 0; +} diff --git a/ddprof-lib/src/main/cpp/hotspot/klassRegistry.h b/ddprof-lib/src/main/cpp/hotspot/klassRegistry.h new file mode 100644 index 0000000000..903a787190 --- /dev/null +++ b/ddprof-lib/src/main/cpp/hotspot/klassRegistry.h @@ -0,0 +1,120 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#ifndef _HOTSPOT_KLASSREGISTRY_H +#define _HOTSPOT_KLASSREGISTRY_H + +#include "mutex.h" + +#include +#include +#include + +/** + * Maps a HotSpot Klass* to a JNI weak global reference to its java.lang.Class + * mirror, so the dump thread can tell whether the class a raw Method* frame + * came from is still loaded -- and keep it loaded while it resolves the frame. + * + * Used by fjmethodid=lazy: walkVM stores a raw Method* for every frame whose + * class has no jmethodID yet, and HotspotSupport::resolve() later reads the + * holder Klass* out of that (possibly freed) Method*. That pointer is only a + * lookup key here and is never dereferenced: pin() returns a strong local + * reference only if the class registered under that address is still + * reachable, and a strongly reachable mirror keeps its class loader -- and so + * the class's metadata -- from being unloaded until the reference is released. + * Whether the pinned class is the same load of a class as the one sampled is + * checked separately, through the class's JFR trace ID (see HotspotSupport). + * + * Entries are added from ClassPrepare and never removed explicitly; dead ones + * (weak reference cleared) are purged whenever the table would have to grow, + * so memory tracks the number of live classes rather than every class ever + * loaded. A Klass* address reused by a newly loaded class simply overwrites + * the dead entry on that class's ClassPrepare. + * + * Redefinition: a redefined class stays alive, but its old methods array and + * old Method*s are freed once no longer on any stack, and later allocations + * -- including another redefinition of the same class -- can reuse them. So + * beginRedefinition() is called before every redefinition and + * endRedefinition() after it. While one is in progress, and for good once one + * has succeeded, pin() refuses the class's raw frames. (After a successful + * redefinition the class's jmethodIDs are preloaded, so its later frames are + * not raw.) A failed call leaves the methods untouched, and the class as it + * was. + * + * All operations take an internal mutex and call JNI, so none of them may be + * used from a signal handler. + */ +class KlassRegistry { + struct Entry { + const void* klass; + jweak mirror; + uint32_t redefinitions_in_progress; + bool redefined; // a redefinition succeeded; never reset + }; + + static const size_t INITIAL_CAPACITY = 1024; + + Mutex _lock; + Entry* _table; + size_t _capacity; // Power of two, or 0 before the first add() + size_t _size; // Occupied slots, live or dead + + Entry* find(const void* klass) const; + Entry* findRegistered(const void* klass) const; + bool rehash(JNIEnv* jni); + +public: + enum PinStatus { + PIN_OK, + PIN_ABSENT, // not registered, or the class has been unloaded + PIN_REDEFINED, // the class is being or has been redefined + }; + + KlassRegistry() : _table(nullptr), _capacity(0), _size(0) {} + + KlassRegistry(const KlassRegistry&) = delete; + KlassRegistry& operator=(const KlassRegistry&) = delete; + + /** + * Records that `klass` is the Klass* of `mirror`. Re-adding the same pair is + * a no-op; adding a different mirror for a known Klass* replaces the entry. + * Returns false if the weak reference or the table could not be allocated. + */ + bool add(JNIEnv* jni, const void* klass, jclass mirror); + + /** + * Returns a new local reference to the mirror registered for `klass`, or + * nullptr -- with the reason in `*status` -- if none is registered, the + * class has been unloaded, or it is being or has been redefined. While the + * caller holds the reference the class cannot be unloaded; it must release + * it with DeleteLocalRef. + */ + jclass pin(JNIEnv* jni, const void* klass, PinStatus* status = nullptr); + + /** + * Call before a redefinition of `klass` starts, so that a resolution which + * validated the class's metadata just before the methods are swapped still + * sees it in isRedefined(). Returns false if `klass` is not registered. + */ + bool beginRedefinition(const void* klass); + + /** + * Call after the redefinition call has returned, once for every + * beginRedefinition() that returned true. `succeeded` makes the mark + * permanent; otherwise the class is treated as it was before the call. + */ + void endRedefinition(const void* klass, bool succeeded); + + /** Whether `klass` is being or has been redefined. */ + bool isRedefined(const void* klass); + + /** Number of occupied slots, including dead entries not yet purged. */ + size_t size(); + + /** Deletes every weak reference and frees the table. */ + void clear(JNIEnv* jni); +}; + +#endif // _HOTSPOT_KLASSREGISTRY_H diff --git a/ddprof-lib/src/main/cpp/hotspot/klassTraceId.cpp b/ddprof-lib/src/main/cpp/hotspot/klassTraceId.cpp new file mode 100644 index 0000000000..1b51679045 --- /dev/null +++ b/ddprof-lib/src/main/cpp/hotspot/klassTraceId.cpp @@ -0,0 +1,109 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "hotspot/klassTraceId.h" + +#include "common.h" +#include "counters.h" +#include "hotspot/vmStructs.inline.h" + +#include + +int KlassTraceId::_offset = -1; +bool KlassTraceId::_calibrated = false; + +// JFR stores the ID shifted left by TRACE_ID_SHIFT (16) above its tag bits. +static const int TRACE_ID_SHIFT = 16; + +static inline uint64_t idAt(const void* klass, size_t offset) { + uint64_t raw; + memcpy(&raw, (const char*)klass + offset, sizeof(raw)); + return raw >> TRACE_ID_SHIFT; +} + +// The probes were created back to back by one thread, so their IDs are +// distinct and lie within a narrow window: PROBE_COUNT consecutive values +// plus whatever other threads took meanwhile. Pointers to neighbouring +// metadata fail this (they are hundreds of bytes apart, so `>> 16` mostly +// collapses them to one value); so do small integers (equal across probes) +// and compressed-klass bits in a mark word (far more than ID_SLACK apart). +static bool plausibleIds(const void* const* klasses, int count, size_t offset) { + uint64_t min = UINT64_MAX; + uint64_t max = 0; + for (int i = 0; i < count; i++) { + uint64_t id = idAt(klasses[i], offset); + if (id == 0) { + return false; + } + for (int j = 0; j < i; j++) { + if (idAt(klasses[j], offset) == id) { + return false; + } + } + min = id < min ? id : min; + max = id > max ? id : max; + } + return max - min <= (uint64_t)(count - 1) + KlassTraceId::ID_SLACK; +} + +int KlassTraceId::findOffset(const void* const* klasses, int count, size_t klass_size) { + if (count < 2) { + return -1; + } + int found = -1; + // Offset 0 is the C++ vtable pointer. + for (size_t offset = 8; offset + sizeof(uint64_t) <= klass_size; offset += 8) { + if (plausibleIds(klasses, count, offset)) { + if (found >= 0) { + return -1; // Ambiguous: refuse rather than guess. + } + found = (int)offset; + } + } + return found; +} + +bool KlassTraceId::calibrate(JNIEnv* jni) { + // Runs from profiler start, which is serialized, so a plain flag will do. + if (_calibrated) { + return offset() >= 0; + } + _calibrated = true; + size_t klass_size = (size_t)VMKlass::type_size(); + if (klass_size == 0 || !VMStructs::hasKlassMethods()) { + return false; + } + + // "[Ljava/lang/Void;" .. "[[[[[[Ljava/lang/Void;". Resolving the deepest + // one makes the JVM create every missing dimension in order, each getting + // the next trace ID; java.lang.Void has no other use for such arrays. + static const char ELEMENT[] = "Ljava/lang/Void;"; + char name[PROBE_COUNT + sizeof(ELEMENT)]; + memset(name, '[', PROBE_COUNT); + memcpy(name + PROBE_COUNT, ELEMENT, sizeof(ELEMENT)); + + const void* klasses[PROBE_COUNT]; + for (int dims = PROBE_COUNT; dims >= 1; dims--) { + const char* dim_name = name + (PROBE_COUNT - dims); + jclass cls = jni->FindClass(dim_name); + if (cls == nullptr) { + jni->ExceptionClear(); + return false; + } + klasses[dims - 1] = VMKlass::fromJavaClass(jni, cls); + jni->DeleteLocalRef(cls); + if (klasses[dims - 1] == nullptr) { + return false; + } + } + + int found = findOffset(klasses, PROBE_COUNT, klass_size); + TEST_LOG("KlassTraceId offset=%d (sizeof(Klass)=%d)", found, (int)klass_size); + if (found < 0) { + return false; + } + __atomic_store_n(&_offset, found, __ATOMIC_RELEASE); + return true; +} diff --git a/ddprof-lib/src/main/cpp/hotspot/klassTraceId.h b/ddprof-lib/src/main/cpp/hotspot/klassTraceId.h new file mode 100644 index 0000000000..529557400c --- /dev/null +++ b/ddprof-lib/src/main/cpp/hotspot/klassTraceId.h @@ -0,0 +1,88 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#ifndef _HOTSPOT_KLASSTRACEID_H +#define _HOTSPOT_KLASSTRACEID_H + +#include "safeAccess.h" + +#include +#include +#include + +/** + * Locates HotSpot's per-class JFR trace ID (Klass::_trace_id) by probing, + * since gHotSpotVMStructs does not expose it. + * + * In JFR-enabled builds every Klass, including array klasses, is assigned + * `(counter << 16) | tag bits` at creation, from one global counter; the low + * 16 bits are JFR tag/epoch bits that JFR rewrites while it runs. So + * `raw >> 16` is unique per loaded class and identifies one particular load + * of a class even when a later class reuses its Klass* address. + * + * The field's position differs between releases (after _access_flags on + * 17u/21u, after _archived_mirror_index on mainline, where it also depends on + * the CDS Java heap build option), so instead of a per-version offset table + * calibrate() makes the JVM create a few array klasses back to back and + * accepts the one 8-byte-aligned offset within sizeof(Klass) at which all of + * them hold distinct IDs no further apart than consecutive creation allows. + * Builds without JFR, or a layout where no single offset qualifies, leave the + * offset unknown, and callers fall back to KlassRegistry's epoch. + */ +class KlassTraceId { + static int _offset; + static bool _calibrated; + +public: + // Array dimensions created by calibrate(): one probe klass per dimension. + static const int PROBE_COUNT = 6; + // How many trace IDs other threads may consume while the probes are + // created, i.e. how far apart beyond PROBE_COUNT - 1 the probes' IDs may be. + static const uint64_t ID_SLACK = 64; + + /** + * Returns the single 8-byte-aligned offset in [8, klass_size) at which the + * `count` klasses all hold plausible, freshly assigned trace IDs, or -1 if + * there is none or more than one. The klasses must be readable for + * klass_size bytes. + */ + static int findOffset(const void* const* klasses, int count, size_t klass_size); + + /** + * Creates the probe klasses through JNI and records the offset found. Only + * the first call per process calibrates; later calls return its result. + * Returns true if an offset is known. + */ + static bool calibrate(JNIEnv* jni); + + /** The calibrated offset, or -1. */ + static int offset() { return __atomic_load_n(&_offset, __ATOMIC_ACQUIRE); } + + /** + * Reads the trace ID (`raw >> 16`) of `klass` with SafeAccess, so it is + * usable from a signal handler on a Klass* that may be stale. Returns false + * if not calibrated or the field is unreadable. + */ + static inline bool read(const void* klass, uint64_t* id) { + int off = offset(); + if (off < 0 || klass == nullptr) { + return false; + } + // An all-ones raw value would mean 2^48 - 1 classes had been created, so + // it can serve as the fault marker. + void* const fault = (void*)UINTPTR_MAX; + void* raw = SafeAccess::loadPtr((void**)((const char*)klass + off), fault); + if (raw == fault) { + return false; + } + *id = (uint64_t)(uintptr_t)raw >> 16; + return true; + } + + /** Test hook: forget or force the calibrated offset. */ + static void setOffsetForTest(int offset) { __atomic_store_n(&_offset, offset, __ATOMIC_RELEASE); } +}; + +#endif // _HOTSPOT_KLASSTRACEID_H diff --git a/ddprof-lib/src/main/cpp/hotspot/unloadCapture.cpp b/ddprof-lib/src/main/cpp/hotspot/unloadCapture.cpp new file mode 100644 index 0000000000..4bbd78fcc1 --- /dev/null +++ b/ddprof-lib/src/main/cpp/hotspot/unloadCapture.cpp @@ -0,0 +1,437 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "hotspot/unloadCapture.h" + +#include "common.h" +#include "counters.h" +#include "hotspot/hotspotSupport.h" +#include "hotspot/vmStructs.h" +#include "os.h" +#include "safeAccess.h" +#include "spinLock.h" +#include "vmEntry.h" + +#include +#include + +#include +#include +#include + +bool UnloadCapture::_enabled = false; +bool UnloadCapture::_gc_hook = false; + +namespace { + +// (Method*, tag) pairs, filled by the sampler. A slot is claimed by a CAS on +// `method`, then published by storing `tag` and setting `state` to PENDING. +// A drainer moves PENDING -> DRAINING by CAS, so the GC hook and the +// background drain never both take a pair. A pair that has been captured +// stays in its slot as DONE, so the sampler does not add it again; endDump() +// clears DONE slots, so a method is captured at most once per dump interval. +enum SlotState : uint32_t { SLOT_FREE = 0, SLOT_PENDING = 1, SLOT_DRAINING = 2, SLOT_DONE = 3 }; +struct PendingSlot { + volatile uintptr_t method; + volatile uint32_t tag; + volatile uint32_t state; +}; + +const size_t PENDING_CAPACITY = 16384; // power of two +const size_t PENDING_PROBES = 16; +PendingSlot pending[PENDING_CAPACITY]; +volatile int pending_count = 0; + +// ClassLoaderData of the boot, platform and application loaders, whose +// classes are never unloaded. Filled from registerClass(). +const size_t MAX_PERMANENT_CLDS = 4; +const void* volatile permanent_clds[MAX_PERMANENT_CLDS]; + +struct Key { + const void* method; + uint32_t tag; + bool operator==(const Key& o) const { return method == o.method && tag == o.tag; } +}; +struct KeyHash { + size_t operator()(const Key& k) const { + return std::hash()(k.method) ^ ((size_t)k.tag * 0x9E3779B97F4A7C15ULL); + } +}; + +// Captured names, written by the GC hook, taken by the dump thread. +SpinLock cache_lock; +std::unordered_map* cache = nullptr; +const size_t MAX_CACHE_ENTRIES = 65536; +volatile uint64_t generation = 0; + +inline size_t slotIndex(const void* method, uint32_t tag) { + uintptr_t h = (uintptr_t)method * 0x9E3779B97F4A7C15ULL ^ ((uintptr_t)tag << 7); + return (size_t)(h >> 17) & (PENDING_CAPACITY - 1); +} + +bool isPermanent(const void* cld) { + for (size_t i = 0; i < MAX_PERMANENT_CLDS; i++) { + const void* p = __atomic_load_n(&permanent_clds[i], __ATOMIC_ACQUIRE); + if (p == nullptr) { + return false; + } + if (p == cld) { + return true; + } + } + return false; +} + +char* dupString(const char* s) { + size_t len = strlen(s); + char* d = (char*)malloc(len + 1); + if (d != nullptr) { + memcpy(d, s, len + 1); + } + return d; +} + + +// Takes a PENDING pair for draining; the caller then calls finishSlot(). +bool claimSlot(PendingSlot* s, uintptr_t* method, uint32_t* tag) { + uint32_t expected = SLOT_PENDING; + if (!__atomic_compare_exchange_n(&s->state, &expected, (uint32_t)SLOT_DRAINING, false, + __ATOMIC_ACQ_REL, __ATOMIC_ACQUIRE)) { + return false; + } + *method = __atomic_load_n(&s->method, __ATOMIC_ACQUIRE); + *tag = __atomic_load_n(&s->tag, __ATOMIC_ACQUIRE); + __atomic_fetch_sub(&pending_count, 1, __ATOMIC_ACQ_REL); + return true; +} + +// Frees a slot its owner (state DRAINING) is done with. State first: a +// sampler that still sees the method treats a FREE state as an insert in +// progress and probes on. +void clearSlot(PendingSlot* s) { + __atomic_store_n(&s->state, (uint32_t)SLOT_FREE, __ATOMIC_RELEASE); + __atomic_store_n(&s->tag, 0, __ATOMIC_RELEASE); + __atomic_store_n(&s->method, 0, __ATOMIC_RELEASE); +} + +// Ends a drain of a claimed slot: DONE if the pair is in the cache, so the +// sampler skips it until endDump(); freed otherwise, so a later sample can +// retry it. +void finishSlot(PendingSlot* s, bool in_cache) { + if (in_cache) { + __atomic_store_n(&s->state, (uint32_t)SLOT_DONE, __ATOMIC_RELEASE); + } else { + clearSlot(s); + } +} + +UnloadCapture::Entry makeEntry(ResolvedMethod& resolved, uint64_t gen, bool* ok) { + UnloadCapture::Entry e; + e.klass_name = dupString(resolved.klassName()); + e.method_name = dupString(resolved.methodName()); + e.signature = dupString(resolved.methodSignature()); + e.is_thread_run = resolved.isThreadRun(); + e.line_table = resolved.takeLineNumberTable(&e.line_table_size); + e.generation = gen; + *ok = e.klass_name != nullptr && e.method_name != nullptr && e.signature != nullptr; + return e; +} + +void updateMax(CounterId id, long long value) { + if (value > Counters::getCounter(id)) { + Counters::set(id, value); + } +} + +volatile bool bg_running = false; +u64 bg_interval_ns = 100 * 1000 * 1000ULL; + +// Background drain: every bg_interval_ns, resolves the pending pairs the way +// the dump does (registry pin + trace-ID check + containsMethod + readMethod), +// outside any pause. cache_lock is held only around cache lookups and +// inserts, never across the JNI pin, so neither the GC hook nor the dump +// waits behind a resolution. +void* bgThreadEntry(void*) { + JNIEnv* jni = VM::attachThread("java-profiler UnloadCapture"); + if (jni == nullptr) { + return nullptr; + } + ProfiledThread* prof_thread = ProfiledThread::initCurrentThreadSignalSafe(); + while (__atomic_load_n(&bg_running, __ATOMIC_ACQUIRE)) { + OS::sleep(bg_interval_ns); + if (prof_thread == nullptr || __atomic_load_n(&pending_count, __ATOMIC_ACQUIRE) == 0) { + continue; + } + u64 start = OS::nanotime(); + struct timespec cpu_start; + clock_gettime(CLOCK_THREAD_CPUTIME_ID, &cpu_start); + Counters::increment(UNLOAD_CAPTURE_BG_PASSES); + for (size_t i = 0; i < PENDING_CAPACITY; i++) { + uintptr_t m; + uint32_t tag; + PendingSlot* s = &pending[i]; + if (!claimSlot(s, &m, &tag)) { + continue; + } + Key key{(const void*)m, tag}; + cache_lock.lock(); + bool known = cache->find(key) != cache->end(); + bool full = cache->size() >= MAX_CACHE_ENTRIES; + cache_lock.unlock(); + if (known || full) { + if (full) { + Counters::increment(UNLOAD_CAPTURE_CACHE_FULL); + } + finishSlot(s, known); + continue; + } + ResolvedMethod resolved; + if (!HotspotSupport::capturePinned(prof_thread, (const void*)m, tag, resolved)) { + Counters::increment(UNLOAD_CAPTURE_BG_REFUSED); + finishSlot(s, false); + continue; + } + bool ok; + UnloadCapture::Entry e = makeEntry(resolved, __atomic_load_n(&generation, __ATOMIC_ACQUIRE), &ok); + if (!ok) { + UnloadCapture::freeEntry(e); + finishSlot(s, false); + continue; + } + cache_lock.lock(); + bool inserted = cache->emplace(key, e).second; + cache_lock.unlock(); + finishSlot(s, true); + if (!inserted) { + UnloadCapture::freeEntry(e); // the GC hook got there first + continue; + } + Counters::increment(UNLOAD_CAPTURE_BG_CAPTURED); + } + long long elapsed = (long long)(OS::nanotime() - start); + Counters::increment(UNLOAD_CAPTURE_BG_NS, elapsed); + struct timespec cpu_end; + clock_gettime(CLOCK_THREAD_CPUTIME_ID, &cpu_end); + Counters::increment(UNLOAD_CAPTURE_BG_CPU_NS, + (long long)(cpu_end.tv_sec - cpu_start.tv_sec) * 1000000000LL + + (cpu_end.tv_nsec - cpu_start.tv_nsec)); + updateMax(UNLOAD_CAPTURE_BG_MAX_NS, elapsed); + } + VM::detachThread(); + return nullptr; +} + +} // namespace + +void UnloadCapture::init(jvmtiEnv* jvmti) { + // 1: GC hook only; 2: background drain + GC hook; 3: background drain only. + const char* env = getenv("DDPROF_CAPTURE_UNLOADING"); + int mode = env != nullptr ? atoi(env) : 0; + _enabled = mode >= 1 && mode <= 3; + if (!_enabled) { + return; + } + _gc_hook = mode != 3; + TEST_LOG("UnloadCapture mode %d", mode); + if (cache == nullptr) { + cache = new std::unordered_map(); + } + if (_gc_hook) { + jvmti->SetEventNotificationMode(JVMTI_ENABLE, JVMTI_EVENT_GARBAGE_COLLECTION_START, nullptr); + } + const char* interval = getenv("DDPROF_CAPTURE_INTERVAL_MS"); + if (interval != nullptr && atoi(interval) > 0) { + bg_interval_ns = (u64)atoi(interval) * 1000 * 1000; + } + // PROTOTYPE: started once and never stopped; it is a daemon thread. + if (mode >= 2 && !__atomic_exchange_n(&bg_running, true, __ATOMIC_ACQ_REL)) { + pthread_t thread; + if (pthread_create(&thread, nullptr, bgThreadEntry, nullptr) != 0) { + __atomic_store_n(&bg_running, false, __ATOMIC_RELEASE); + } else { + pthread_detach(thread); + } + } +} + +void UnloadCapture::notePermanentLoaderData(const void* cld) { + if (cld == nullptr) { + return; + } + for (size_t i = 0; i < MAX_PERMANENT_CLDS; i++) { + const void* expected = nullptr; + if (__atomic_compare_exchange_n(&permanent_clds[i], &expected, cld, false, + __ATOMIC_ACQ_REL, __ATOMIC_ACQUIRE)) { + return; + } + if (expected == cld) { + return; + } + } +} + +void UnloadCapture::onRawFrame(const void* method, const void* holder, uint32_t tag) { + if (!_enabled || holder == nullptr || VMStructs::class_loader_data_offset() < 0) { + return; + } + const void* cld = SafeAccess::load((void**)((const char*)holder + VMStructs::class_loader_data_offset())); + if (cld == nullptr || isPermanent(cld)) { + return; + } + size_t index = slotIndex(method, tag); + for (size_t i = 0; i < PENDING_PROBES; i++) { + PendingSlot* s = &pending[(index + i) & (PENDING_CAPACITY - 1)]; + uintptr_t m = __atomic_load_n(&s->method, __ATOMIC_ACQUIRE); + if (m == 0) { + uintptr_t expected = 0; + if (__atomic_compare_exchange_n(&s->method, &expected, (uintptr_t)method, false, + __ATOMIC_ACQ_REL, __ATOMIC_ACQUIRE)) { + __atomic_store_n(&s->tag, tag, __ATOMIC_RELEASE); + __atomic_fetch_add(&pending_count, 1, __ATOMIC_RELEASE); + __atomic_store_n(&s->state, (uint32_t)SLOT_PENDING, __ATOMIC_RELEASE); + Counters::increment(UNLOAD_CAPTURE_PENDING_ADDED); + return; + } + m = expected; + } + // A published slot (state not FREE) with the same pair is pending, being + // drained, or already captured: nothing to do. + if (m == (uintptr_t)method && __atomic_load_n(&s->state, __ATOMIC_ACQUIRE) != SLOT_FREE && + __atomic_load_n(&s->tag, __ATOMIC_ACQUIRE) == tag) { + return; + } + } + // No free slot. DONE slots only keep the sampler from adding a captured + // pair again, so take one over rather than drop this pair. + for (size_t i = 0; i < PENDING_PROBES; i++) { + PendingSlot* s = &pending[(index + i) & (PENDING_CAPACITY - 1)]; + uint32_t expected = SLOT_DONE; + if (__atomic_compare_exchange_n(&s->state, &expected, (uint32_t)SLOT_DRAINING, false, + __ATOMIC_ACQ_REL, __ATOMIC_ACQUIRE)) { + // Tag before method: a sampler that sees the new method sees its tag. + __atomic_store_n(&s->tag, tag, __ATOMIC_RELEASE); + __atomic_store_n(&s->method, (uintptr_t)method, __ATOMIC_RELEASE); + __atomic_fetch_add(&pending_count, 1, __ATOMIC_RELEASE); + __atomic_store_n(&s->state, (uint32_t)SLOT_PENDING, __ATOMIC_RELEASE); + Counters::increment(UNLOAD_CAPTURE_PENDING_ADDED); + Counters::increment(UNLOAD_CAPTURE_PENDING_EVICTED); + return; + } + } + Counters::increment(UNLOAD_CAPTURE_PENDING_OVERFLOW); +} + +void JNICALL UnloadCapture::GarbageCollectionStart(jvmtiEnv* jvmti) { + if (!_enabled || !_gc_hook || __atomic_load_n(&pending_count, __ATOMIC_ACQUIRE) == 0) { + return; + } + u64 start = OS::nanotime(); + Counters::increment(UNLOAD_CAPTURE_HOOK_CALLS); + if (!cache_lock.tryLock()) { + // The dump thread or the background drain holds the cache; the pairs + // stay pending. + Counters::increment(UNLOAD_CAPTURE_HOOK_BUSY); + return; + } + // A JVMTI callback, so initCurrentThreadSignalSafe() (AGENTS.md). The GC + // callbacks run on the VM thread, which keeps its ProfiledThread afterwards. + ProfiledThread* prof_thread = ProfiledThread::initCurrentThreadSignalSafe(); + if (prof_thread == nullptr) { + Counters::increment(SAMPLES_DROPPED_THREAD_LOCAL); + cache_lock.unlock(); + return; + } + uint64_t gen = __atomic_load_n(&generation, __ATOMIC_ACQUIRE); + for (size_t i = 0; i < PENDING_CAPACITY; i++) { + uintptr_t m; + uint32_t tag; + PendingSlot* s = &pending[i]; + if (!claimSlot(s, &m, &tag)) { + continue; + } + Counters::increment(UNLOAD_CAPTURE_HOOK_DRAINED); + Key key{(const void*)m, tag}; + if (cache->find(key) != cache->end()) { + finishSlot(s, true); + continue; + } + if (cache->size() >= MAX_CACHE_ENTRIES) { + Counters::increment(UNLOAD_CAPTURE_CACHE_FULL); + finishSlot(s, false); + continue; + } + ResolvedMethod resolved; + if (!HotspotSupport::captureMethod(prof_thread, (const void*)m, tag, resolved)) { + Counters::increment(UNLOAD_CAPTURE_REFUSED); + finishSlot(s, false); + continue; + } + bool ok; + Entry e = makeEntry(resolved, gen, &ok); + if (!ok) { + freeEntry(e); + finishSlot(s, false); + continue; + } + cache->emplace(key, e); + finishSlot(s, true); + Counters::increment(UNLOAD_CAPTURE_CAPTURED); + } + cache_lock.unlock(); + long long elapsed = (long long)(OS::nanotime() - start); + Counters::increment(UNLOAD_CAPTURE_HOOK_NS, elapsed); + updateMax(UNLOAD_CAPTURE_HOOK_MAX_NS, elapsed); +} + +bool UnloadCapture::take(const void* method, uint32_t tag, Entry* out) { + if (!_enabled || cache == nullptr) { + return false; + } + cache_lock.lock(); + auto it = cache->find(Key{method, tag}); + bool found = it != cache->end(); + if (found) { + *out = it->second; + cache->erase(it); + } + cache_lock.unlock(); + return found; +} + +void UnloadCapture::endDump() { + if (!_enabled || cache == nullptr) { + return; + } + cache_lock.lock(); + uint64_t gen = __atomic_load_n(&generation, __ATOMIC_ACQUIRE); + for (auto it = cache->begin(); it != cache->end();) { + if (it->second.generation < gen) { + freeEntry(it->second); + it = cache->erase(it); + } else { + ++it; + } + } + __atomic_store_n(&generation, gen + 1, __ATOMIC_RELEASE); + cache_lock.unlock(); + // Lets the sampler add this interval's methods again, so the next interval + // gets fresh entries for classes that are still loaded. + for (size_t i = 0; i < PENDING_CAPACITY; i++) { + uint32_t expected = SLOT_DONE; + if (__atomic_compare_exchange_n(&pending[i].state, &expected, (uint32_t)SLOT_DRAINING, false, + __ATOMIC_ACQ_REL, __ATOMIC_ACQUIRE)) { + clearSlot(&pending[i]); + } + } +} + +void UnloadCapture::freeEntry(Entry& e) { + free(e.klass_name); + free(e.method_name); + free(e.signature); + free(e.line_table); + e.klass_name = e.method_name = e.signature = nullptr; + e.line_table = nullptr; +} diff --git a/ddprof-lib/src/main/cpp/hotspot/unloadCapture.h b/ddprof-lib/src/main/cpp/hotspot/unloadCapture.h new file mode 100644 index 0000000000..1a5fe1413a --- /dev/null +++ b/ddprof-lib/src/main/cpp/hotspot/unloadCapture.h @@ -0,0 +1,75 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#ifndef _HOTSPOT_UNLOADCAPTURE_H +#define _HOTSPOT_UNLOADCAPTURE_H + +#include +#include +#include + +/** + * PROTOTYPE (phase 2 of doc/LazyJmethodIdCombinedPlan.md): keeps the names of + * fjmethodid=lazy raw frames whose class may be unloaded before the dump. + * + * - The sampler adds each raw frame of a class outside the boot, platform and + * application loaders to a fixed-size lock-free set of (Method*, tag) pairs. + * - GarbageCollectionStart drains the set: for each pair not captured yet, it + * checks the trace-ID tag against the holder and copies the names and line + * table into a cache, under crash protection, without JNI or JVMTI. With + * nothing pending it costs one atomic load. + * - The dump takes a frame's captured name when resolve() refuses the frame. + * + * - A background thread (modes 2 and 3) drains the same set every + * DDPROF_CAPTURE_INTERVAL_MS (default 100) outside any pause, resolving + * pairs through the class registry as the dump does. + * + * Enabled with DDPROF_CAPTURE_UNLOADING in lazy mode, so one build can be + * measured in every variant: 1 = GC hook only, 2 = background drain + GC + * hook, 3 = background drain only. + */ +class UnloadCapture { +public: + // A captured method. Strings and the line table are malloc'd; ownership + // passes to whoever take()s the entry. + struct Entry { + char* klass_name; + char* method_name; + char* signature; + jvmtiLineNumberEntry* line_table; + int line_table_size; + bool is_thread_run; + uint64_t generation; // dump generation it was captured in + }; + + static bool enabled() { return _enabled; } + + // Lazy-mode start: reads the environment switch and turns on + // GarbageCollectionStart if enabled. + static void init(jvmtiEnv* jvmti); + + // Records the ClassLoaderData of a class from a loader that never unloads. + static void notePermanentLoaderData(const void* cld); + + // Signal safe. Called for every lazy raw frame with its holder Klass*. + static void onRawFrame(const void* method, const void* holder, uint32_t tag); + + static void JNICALL GarbageCollectionStart(jvmtiEnv* jvmti); + + // Dump thread: moves the entry for (method, tag) into *out, if there is one. + static bool take(const void* method, uint32_t tag, Entry* out); + + // Dump thread, after each dump: frees entries captured before the previous + // dump started, which no remaining trace can refer to. + static void endDump(); + + static void freeEntry(Entry& e); + +private: + static bool _enabled; + static bool _gc_hook; +}; + +#endif // _HOTSPOT_UNLOADCAPTURE_H diff --git a/ddprof-lib/src/main/cpp/hotspot/vmStructs.h b/ddprof-lib/src/main/cpp/hotspot/vmStructs.h index c61ccbc3b3..9e354574ad 100644 --- a/ddprof-lib/src/main/cpp/hotspot/vmStructs.h +++ b/ddprof-lib/src/main/cpp/hotspot/vmStructs.h @@ -217,12 +217,15 @@ typedef void* address; field(_constmethod_idnum_offset, offset, MATCH_SYMBOLS("_method_idnum")) \ field(_constmethod_name_index_offset, offset, MATCH_SYMBOLS("_name_index")) \ field(_constmethod_sig_index_offset, offset, MATCH_SYMBOLS("_signature_index")) \ + field(_constmethod_flags_offset, offset, MATCH_SYMBOLS("_flags", "_flags._flags")) \ + field(_constmethod_code_size_offset, offset, MATCH_SYMBOLS("_code_size")) \ type_end() \ type_begin(VMConstantPool, MATCH_SYMBOLS("ConstantPool")) \ field(_pool_holder_offset, offset, MATCH_SYMBOLS("_pool_holder")) \ type_end() \ type_begin(VMKlass, MATCH_SYMBOLS("Klass", "InstanceKlass")) \ field(_klass_name_offset, offset, MATCH_SYMBOLS("_name")) \ + field(_klass_super_offset, offset, MATCH_SYMBOLS("_super")) \ field(_class_loader_data_offset, offset, MATCH_SYMBOLS("_class_loader_data")) \ field(_methods_offset, offset, MATCH_SYMBOLS("_methods")) \ field(_jmethod_ids_offset, offset, MATCH_SYMBOLS("_methods_jmethod_ids")) \ @@ -328,8 +331,12 @@ typedef void* address; * - constant defines a constant of a class */ -#define DECLARE_INT_CONSTANTS_DO(constant, constant_with_version) \ - constant(frame, entry_frame_call_wrapper_offset) +// The has_linenumber_table bit of ConstMethod::_flags. JDK 21 moved the flags +// into ConstMethodFlags (and widened _flags from u2 to u4). +#define DECLARE_INT_CONSTANTS_DO(constant, constant_with_version) \ + constant(frame, entry_frame_call_wrapper_offset) \ + constant_with_version(ConstMethod, _has_linenumber_table, 8, 20) \ + constant_with_version(ConstMethodFlags, _misc_has_linenumber_table, 21, MAX_VERSION) #define DECLARE_LONG_CONSTANTS_DO(constant, constant_with_version) \ constant_with_version(markWord, klass_shift, 24, MAX_VERSION) \ @@ -495,6 +502,13 @@ class VMStructs { return _has_class_loader_data; } + // What fjmethodid=lazy needs on top of the Method* walk: mapping a + // java.lang.Class to its Klass* (KlassRegistry) and reading a Klass*'s + // methods array (VMKlass::containsMethod). + static bool hasKlassMethods() { + return _has_method_structs && _methods_offset >= 0 && _klass != NULL; + } + static bool hasNativeThreadId() { return _has_native_thread_id; } @@ -703,6 +717,13 @@ DECLARE(VMKlass) return VMSymbol::load_then_cast(at(_klass_name_offset)); } + // Super class, or nullptr for java.lang.Object and interfaces, or when the + // VM does not export Klass::_super. + VMKlass* super_or_null() { + if (_klass_super_offset < 0) return nullptr; + return VMKlass::load_then_cast(at(_klass_super_offset)); + } + VMClassLoaderData* classLoaderData() { assert(_class_loader_data_offset >= 0); return VMClassLoaderData::load_then_cast(at(_class_loader_data_offset)); @@ -718,6 +739,50 @@ DECLARE(VMKlass) assert(_jmethod_ids_offset >= 0); return __atomic_load_n((jmethodID**) at(_jmethod_ids_offset), __ATOMIC_ACQUIRE); } + + // True if `method` is one of the current methods of this InstanceKlass. + // InstanceKlass::_methods is an Array: an int length followed by + // the elements, which start at the next pointer-aligned offset. `method` is + // only compared, never dereferenced, so it may be stale. `idnum_hint` is + // the method's idnum if known (or -1). HotSpot assigns idnums in method + // order when it parses a class, so methods()->at(idnum) is usually the + // method itself; methods added later (default-method overpasses merged in + // at link time, redefinitions) can break that. Then only a class of at + // most MAX_METHODS_SCANNED methods is scanned; for a larger one the + // method counts as not found. + // + // Keeping the class alive does not keep this array alive: a redefinition + // swaps in a new array at a safepoint and frees the old one at a later + // one, and a caller in native state does not hold up either. So every + // load goes through SafeAccess, and a fault reads as "not a member". + static const int MAX_METHODS_SCANNED = 256; + + bool containsMethod(const void* method, int idnum_hint) { + assert(_methods_offset >= 0); + const char* methods = (const char*) SafeAccess::loadPtr((void**) at(_methods_offset), nullptr); + if (methods == NULL) { + return false; + } + // Array::_length is an int; a method count beyond u2 is not a + // valid class file, so a larger value means the array is gone. + int len = SafeAccess::load32((int32_t*) methods, -1); + if (len < 0 || len > 0xffff) { + return false; + } + void** data = (void**)(methods + sizeof(void*)); + if (idnum_hint >= 0 && idnum_hint < len && SafeAccess::loadPtr(&data[idnum_hint], nullptr) == method) { + return true; + } + if (len > MAX_METHODS_SCANNED) { + return false; + } + for (int i = 0; i < len; i++) { + if (SafeAccess::loadPtr(&data[i], nullptr) == method) { + return true; + } + } + return false; + } DECLARE_END DECLARE(VMJavaFrameAnchor) @@ -905,6 +970,12 @@ DECLARE(VMConstMethod) inline VMConstantPool* constants_or_null() const; inline VMSymbol* name() const; inline VMSymbol* signature() const; + inline u16 idnum() const; + inline u16 codeSize() const; + // Start of the compressed line number table, which HotSpot places right + // after the bytecodes, or nullptr if the method has none (or the VM does + // not export what is needed to locate it). + inline const unsigned char* compressedLineNumberTable() const; private: inline u16 nameIndex() const; inline u16 signatureIndex() const; @@ -922,6 +993,11 @@ DECLARE(VMMethod) // Performs extra validation when VMMethod comes from incomplete frame inline jmethodID validatedId(); + // The holder Klass* (constMethod -> constants -> pool holder), read with + // SafeAccess so it is usable from a signal handler; nullptr if any link + // is unreadable. + inline const void* holderOrNull() const; + const char* bytecode() { assert(_method_constmethod_offset >= 0); return *(const char**) at(_method_constmethod_offset) + VMConstMethod::type_size(); @@ -1210,6 +1286,62 @@ class ScopeDesc : VMStructs { } }; +// Decoder for ConstMethod's compressed line number table, a mirror of +// HotSpot's CompressedLineNumberReadStream. Each entry is either a single byte +// holding (bci_delta << 3 | line_delta), or 0xFF followed by the two deltas as +// signed CompressedStream ints. A 0 byte terminates the table. +// +// Reads the raw stream without any safety checks: callers must run with crash +// protection installed and bound the number of entries they accept. +class VMLineNumberStream : VMStructs { + private: + const unsigned char* _stream; + int _bci; + int _line; + + // Same UNSIGNED5 decoding as ScopeDesc::readInt() (see JDK-8292758 for + // the _unsigned5_base shift). + unsigned int readUInt() { + unsigned char c = *_stream++; + unsigned int n = c - _unsigned5_base; + if (c >= 192) { + for (int shift = 6; ; shift += 6) { + c = *_stream++; + n += (c - _unsigned5_base) << shift; + if (c < 192 || shift >= 24) break; + } + } + return n; + } + + int readSignedInt() { + unsigned int n = readUInt(); + return (int)(n >> 1) ^ -(int)(n & 1); + } + + public: + explicit VMLineNumberStream(const unsigned char* table) : _stream(table), _bci(0), _line(0) {} + + // Advances to the next (bci, line) pair; returns false at the terminator. + bool readPair() { + unsigned char next = *_stream++; + if (next == 0) { + return false; + } + if (next == 0xFF) { + _bci += readSignedInt(); + _line += readSignedInt(); + } else { + _bci += next >> 3; + _line += next & 0x7; + } + return true; + } + + int bci() const { return _bci; } + int line() const { return _line; } +}; + class InterpreterFrame : VMStructs { public: enum { diff --git a/ddprof-lib/src/main/cpp/hotspot/vmStructs.inline.h b/ddprof-lib/src/main/cpp/hotspot/vmStructs.inline.h index 043ab06722..e24ba1169c 100644 --- a/ddprof-lib/src/main/cpp/hotspot/vmStructs.inline.h +++ b/ddprof-lib/src/main/cpp/hotspot/vmStructs.inline.h @@ -123,6 +123,19 @@ VMConstMethod* VMMethod::constMethod_or_null() const { return VMConstMethod::load_then_cast(at(_method_constmethod_offset)); } +const void* VMMethod::holderOrNull() const { + const char* const_method = (const char*) SafeAccess::load((void**) at(_method_constmethod_offset)); + if (!goodPtr(const_method)) { + return nullptr; + } + const char* cpool = (const char*) SafeAccess::load((void**)(const_method + _constmethod_constants_offset)); + if (!goodPtr(cpool)) { + return nullptr; + } + const void* holder = SafeAccess::loadPtr((void**)(cpool + _pool_holder_offset), nullptr); + return goodPtr(holder) ? holder : nullptr; +} + jmethodID VMMethod::id() { // We may find a bogus NMethod during stack walking, it does not always point to a valid VMMethod const char* const_method = (const char*) SafeAccess::load((void**) at(_method_constmethod_offset)); @@ -212,6 +225,37 @@ VMSymbol* VMConstMethod::signature() const { return cpool->symbolAt(sig_index); } +u16 VMConstMethod::idnum() const { + assert(_constmethod_idnum_offset >= 0 && "Invalid idnum"); + return *(u16*)at(_constmethod_idnum_offset); +} + +u16 VMConstMethod::codeSize() const { + assert(_constmethod_code_size_offset >= 0); + return *(u16*)at(_constmethod_code_size_offset); +} + +const unsigned char* VMConstMethod::compressedLineNumberTable() const { + if (_constmethod_flags_offset < 0 || _constmethod_code_size_offset < 0) { + return nullptr; + } + bool has_table; + if (_ConstMethodFlags__misc_has_linenumber_table != -1) { + // JDK 21+: u4 ConstMethodFlags::_flags + has_table = (*(u32*)at(_constmethod_flags_offset) & (u32)_ConstMethodFlags__misc_has_linenumber_table) != 0; + } else if (_ConstMethod__has_linenumber_table != -1) { + // JDK 8 - 20: u2 ConstMethod::_flags + has_table = (*(u16*)at(_constmethod_flags_offset) & (u16)_ConstMethod__has_linenumber_table) != 0; + } else { + return nullptr; + } + if (!has_table) { + return nullptr; + } + // ConstMethod::compressed_linenumber_table() == code_end() + return (const unsigned char*)at((int)type_size()) + codeSize(); +} + u16 VMConstMethod::nameIndex() const { assert(_constmethod_name_index_offset >= 0 && "Invalid name index"); return *(u16*)at(_constmethod_name_index_offset); diff --git a/ddprof-lib/src/main/cpp/jvmSupport.cpp b/ddprof-lib/src/main/cpp/jvmSupport.cpp index 4dc2be966e..f9af96cb88 100644 --- a/ddprof-lib/src/main/cpp/jvmSupport.cpp +++ b/ddprof-lib/src/main/cpp/jvmSupport.cpp @@ -72,15 +72,19 @@ void JVMSupport::setLoadState(JMethodIDLoadStats state) { void JVMSupport::initExecution(Arguments& args, jvmtiEnv* jvmti, JNIEnv* jni) { JMethodIDLoadStats current_state = getLoadState(); - bool load_all = true; - if (VM::isHotspot()) { - if (!HotspotSupport::shouldPreloadJmethodIDs(args)) { + JMethodIDLoadStats state = Fully_loaded; + // The stack walker mode as Profiler::start() resolved it from args. + CStack cstack = Profiler::instance()->cstackMode(); + if (VM::isHotspot() && !HotspotSupport::shouldPreloadJmethodIDs(args, cstack)) { + if (args._lazy_jmethodID && HotspotSupport::canResolveLazily() && + HotspotSupport::initLazyResolution(jni)) { + state = Lazy_loaded; + } else { HotspotSupport::initClassloaderInfo(jni); - load_all = false; + state = Partial_loaded; } } - JMethodIDLoadStats state = load_all ? Fully_loaded : Partial_loaded; if (state == current_state) { return; } @@ -149,7 +153,8 @@ void JVMSupport::loadAllMethodIDsIfNeeded(jvmtiEnv *jvmti, JNIEnv *jni) { } jvmti->Deallocate((unsigned char *)classes); } - TEST_LOG("Preloaded jmethodIDs for %d/%d classes", loaded_count, class_count); + TEST_LOG("%s %d/%d classes", getLoadState() == Lazy_loaded ? "Registered (lazy jmethodIDs)" : "Preloaded jmethodIDs for", + loaded_count, class_count); } bool JVMSupport::loadMethodIDsIfNeeded(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass) { @@ -160,6 +165,9 @@ bool JVMSupport::loadMethodIDsIfNeeded(jvmtiEnv *jvmti, JNIEnv *jni, jclass klas return false; } + if (state == Lazy_loaded) { + return HotspotSupport::registerClass(jvmti, jni, klass); + } if (VM::isHotspot()) { return HotspotSupport::loadMethodIDsIfNeededImpl(jvmti, jni, klass, state == Fully_loaded /* load all */); } else { @@ -167,6 +175,21 @@ bool JVMSupport::loadMethodIDsIfNeeded(jvmtiEnv *jvmti, JNIEnv *jni, jclass klas } } +void JVMSupport::beforeClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass) { + if (VM::isHotspot()) { + HotspotSupport::beforeClassRedefinition(jvmti, jni, klass); + } +} + +void JVMSupport::afterClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass, bool succeeded) { + if (VM::isHotspot()) { + HotspotSupport::afterClassRedefinition(jvmti, jni, klass, succeeded); + } + if (succeeded && getLoadState() != Lazy_loaded) { + loadMethodIDsIfNeeded(jvmti, jni, klass); + } +} + bool JVMSupport::loadMethodIDsImpl(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass) { // CRITICAL: GetClassMethods must be called to preallocate jmethodIDs for AsyncGetCallTrace. // AGCT operates in signal handlers where lock acquisition is forbidden, so jmethodIDs must diff --git a/ddprof-lib/src/main/cpp/jvmSupport.h b/ddprof-lib/src/main/cpp/jvmSupport.h index 49b8a9193f..d0e99ce57b 100644 --- a/ddprof-lib/src/main/cpp/jvmSupport.h +++ b/ddprof-lib/src/main/cpp/jvmSupport.h @@ -22,12 +22,14 @@ enum StackRecovery { }; class HotspotSupport; +class ResolvedMethod; class JVMSupport { enum JMethodIDLoadStats { No_loaded, // Has not yet executed any profiling command, no jmethodIDs are loaded Partial_loaded, // Partially loaded, see HotspotSupport::shouldPreloadJmethodIDs() - Fully_loaded // All jmethodIDs are loaded + Fully_loaded, // All jmethodIDs are loaded + Lazy_loaded // None preloaded; allocated at dump time for sampled classes (fjmethodid=lazy) }; friend class HotspotSupport; @@ -66,8 +68,17 @@ class JVMSupport { static void loadAllMethodIDsIfNeeded(jvmtiEnv *jvmti, JNIEnv *jni); static bool loadMethodIDsIfNeeded(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass); - // Resolve method pointer to jmethodID - static inline jmethodID resolve(const void* method); + // Called around every RedefineClasses/RetransformClasses for each class. + // fjmethodid=lazy must know before the methods are swapped; afterwards the + // class's jmethodIDs are (re)loaded as each mode requires. `succeeded` is + // whether the redefinition went through. `klass` is the caller's argument + // and has not been validated by JVMTI yet. + static void beforeClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass); + static void afterClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass, bool succeeded); + + // Resolve a raw-pointer frame (method field and encoded bci) into names and + // line number table (HotSpot only); false if it must be treated as unknown + static inline bool resolve(const void* method, int bci, ResolvedMethod& result); // If a class is hidden class static inline bool isHidden(jint modifiers); diff --git a/ddprof-lib/src/main/cpp/jvmSupport.inline.h b/ddprof-lib/src/main/cpp/jvmSupport.inline.h index bea61880ff..254dadc603 100644 --- a/ddprof-lib/src/main/cpp/jvmSupport.inline.h +++ b/ddprof-lib/src/main/cpp/jvmSupport.inline.h @@ -35,13 +35,14 @@ long long JVMSupport::runtimeStubsMemoryUsage() { } } -// Resolve method pointer to jmethodID -jmethodID JVMSupport::resolve(const void* method) { +// Resolve a raw-pointer frame (method field and encoded bci) into names and +// line number table +bool JVMSupport::resolve(const void* method, int bci, ResolvedMethod& result) { if (VM::isHotspot()) { - return HotspotSupport::resolve(method); + return HotspotSupport::resolve(method, bci, result); } else { assert(false && "Should not reach here"); - return nullptr; + return false; } } diff --git a/ddprof-lib/src/main/cpp/vmEntry.cpp b/ddprof-lib/src/main/cpp/vmEntry.cpp index acf8382322..2b8b178a41 100644 --- a/ddprof-lib/src/main/cpp/vmEntry.cpp +++ b/ddprof-lib/src/main/cpp/vmEntry.cpp @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +#include "hotspot/unloadCapture.h" #include "common.h" #include "vmEntry.h" #include "arguments.h" @@ -451,6 +452,11 @@ bool VM::initializeRequestStackTrace() { // both static callbacks. Order is irrelevant (each only does lock-free // bookkeeping); LivenessTracker's callback keeps its own // initCurrentThreadSignalSafe() behavior. +void JNICALL ForwardedGarbageCollectionStart(jvmtiEnv *jvmti_env) { + UnloadCapture::GarbageCollectionStart(jvmti_env); + ReferenceChainTracker::GarbageCollectionStart(jvmti_env); +} + void JNICALL ForwardedGarbageCollectionFinish(jvmtiEnv *jvmti_env) { LivenessTracker::GarbageCollectionFinish(jvmti_env); ReferenceChainTracker::GarbageCollectionFinish(jvmti_env); @@ -530,7 +536,7 @@ bool VM::initProfilerBridge(JavaVM *vm, bool attach) { callbacks.ThreadStart = Profiler::ThreadStart; callbacks.ThreadEnd = Profiler::ThreadEnd; callbacks.SampledObjectAlloc = ObjectSampler::SampledObjectAlloc; - callbacks.GarbageCollectionStart = ReferenceChainTracker::GarbageCollectionStart; + callbacks.GarbageCollectionStart = ForwardedGarbageCollectionStart; callbacks.GarbageCollectionFinish = ForwardedGarbageCollectionFinish; callbacks.NativeMethodBind = VMStructs::NativeMethodBind; _jvmti->SetEventCallbacks(&callbacks, sizeof(callbacks)); @@ -678,16 +684,25 @@ void JNICALL VM::VMDeath(jvmtiEnv *jvmti, JNIEnv *jni) { jvmtiError VM::RedefineClassesHook(jvmtiEnv *jvmti, jint class_count, const jvmtiClassDefinition *class_definitions) { + // Arguments the original rejects (JVMTI_ERROR_NULL_POINTER) go straight to + // it; the loops below must never dereference them first. + if (class_count > 0 && class_definitions == NULL) { + return _orig_RedefineClasses(jvmti, class_count, class_definitions); + } + JNIEnv *env = jni(); + for (int i = 0; i < class_count; i++) { + if (class_definitions[i].klass != NULL) { + JVMSupport::beforeClassRedefinition(jvmti, env, class_definitions[i].klass); + } + } + jvmtiError result = _orig_RedefineClasses(jvmti, class_count, class_definitions); - if (result == 0) { - // jmethodIDs are invalidated after RedefineClasses - JNIEnv *env = jni(); - for (int i = 0; i < class_count; i++) { - if (class_definitions[i].klass != NULL) { - JVMSupport::loadMethodIDsIfNeeded(jvmti, env, class_definitions[i].klass); - } + // jmethodIDs are invalidated after RedefineClasses + for (int i = 0; i < class_count; i++) { + if (class_definitions[i].klass != NULL) { + JVMSupport::afterClassRedefinition(jvmti, env, class_definitions[i].klass, result == 0); } } @@ -696,15 +711,24 @@ VM::RedefineClassesHook(jvmtiEnv *jvmti, jint class_count, jvmtiError VM::RetransformClassesHook(jvmtiEnv *jvmti, jint class_count, const jclass *classes) { + // Arguments the original rejects (JVMTI_ERROR_NULL_POINTER) go straight to + // it; the loops below must never dereference them first. + if (class_count > 0 && classes == NULL) { + return _orig_RetransformClasses(jvmti, class_count, classes); + } + JNIEnv *env = jni(); + for (int i = 0; i < class_count; i++) { + if (classes[i] != NULL) { + JVMSupport::beforeClassRedefinition(jvmti, env, classes[i]); + } + } + jvmtiError result = _orig_RetransformClasses(jvmti, class_count, classes); - if (result == 0) { - // jmethodIDs are invalidated after RetransformClasses - JNIEnv *env = jni(); - for (int i = 0; i < class_count; i++) { - if (classes[i] != NULL) { - JVMSupport::loadMethodIDsIfNeeded(jvmti, env, classes[i]); - } + // jmethodIDs are invalidated after RetransformClasses + for (int i = 0; i < class_count; i++) { + if (classes[i] != NULL) { + JVMSupport::afterClassRedefinition(jvmti, env, classes[i], result == 0); } } diff --git a/ddprof-lib/src/test/cpp/arguments_ut.cpp b/ddprof-lib/src/test/cpp/arguments_ut.cpp index 8ecefc13fc..225a1d0fa9 100644 --- a/ddprof-lib/src/test/cpp/arguments_ut.cpp +++ b/ddprof-lib/src/test/cpp/arguments_ut.cpp @@ -253,3 +253,35 @@ TEST_F(ArgumentsTest, BooleanOptionRejectsTypoSharingFirstLetterWithValidValue) EXPECT_TRUE(error); } } + +// fjmethodid takes true/false/lazy; lazy implies no preloading, and a later +// true/false in the same string must clear the lazy flag again. +TEST_F(ArgumentsTest, FjmethodidAcceptsLazy) { + { + Arguments args; + EXPECT_TRUE(args._force_jmethodID); + EXPECT_FALSE(args._lazy_jmethodID); + } + { + Arguments args; + EXPECT_FALSE(args.parse("fjmethodid=lazy")); + EXPECT_FALSE(args._force_jmethodID); + EXPECT_TRUE(args._lazy_jmethodID); + } + { + Arguments args; + EXPECT_FALSE(args.parse("fjmethodid=lazy,fjmethodid=false")); + EXPECT_FALSE(args._force_jmethodID); + EXPECT_FALSE(args._lazy_jmethodID); + } + { + Arguments args; + EXPECT_FALSE(args.parse("fjmethodid=lazy,fjmethodid=true")); + EXPECT_TRUE(args._force_jmethodID); + EXPECT_FALSE(args._lazy_jmethodID); + } + { + Arguments args; + EXPECT_TRUE(args.parse("fjmethodid=lazyx")); + } +} diff --git a/ddprof-lib/src/test/cpp/frame_ut.cpp b/ddprof-lib/src/test/cpp/frame_ut.cpp index 951db75fb8..c797d20f76 100644 --- a/ddprof-lib/src/test/cpp/frame_ut.cpp +++ b/ddprof-lib/src/test/cpp/frame_ut.cpp @@ -203,3 +203,54 @@ TEST(FrameTypeIsRawPointerTest, FalseForRawAsgctBciWithBit30SetButNoEncodedMarke << "isRawPointer() must require ENCODED_MASK (bit 20) before trusting bit 30, " << "otherwise raw ASGCT BCIs that never went through encode() can false-positive"; } + +// --------------------------------------------------------------------------- +// Raw Method* frame tags (fjmethodid=lazy): the tag is split across spare BCI +// bits and the top bits of the Method*, and must come back intact without +// disturbing anything the rest of the pipeline reads from the frame. +// --------------------------------------------------------------------------- + +TEST(FrameTypeRawTagTest, TagRoundTripsAndLeavesTypeBciAndPointerIntact) { + VMHotspotGuard hotspot(true); + const void* method = reinterpret_cast(0x00007f12345678a8ULL); + const uint32_t tags[] = {0u, 1u, 0xfu, 0x10u, 0x3ffu, 0x400u, 0x2aaaaaau, FrameType::RAW_TAG_MASK}; + for (uint32_t tag : tags) { + for (int type : {FRAME_INTERPRETED, FRAME_JIT_COMPILED, FRAME_INLINED, FRAME_C1_COMPILED}) { + int bci = FrameType::encodeRaw(type, 4321, tag); + const void* tagged = FrameType::tagPointer(method, tag); + EXPECT_EQ(tag, FrameType::rawTag(bci, tagged)) << std::hex << tag; + EXPECT_EQ(method, FrameType::untagPointer(tagged)); + EXPECT_EQ(4321, FrameType::bci(bci)); + EXPECT_EQ(type, FrameType::decode(bci)); + EXPECT_TRUE(FrameType::isRawPointer(bci)); + EXPECT_GT(bci, 0) << "bit 31 must stay clear"; + } + } +} + +TEST(FrameTypeRawTagTest, TagBitsAboveWidthAreDropped) { + VMHotspotGuard hotspot(true); + const void* method = reinterpret_cast(0x1000); + uint32_t tag = FrameType::RAW_TAG_MASK + 1 + 7; // wraps to 7 + int bci = FrameType::encodeRaw(FRAME_JIT_COMPILED, 0, tag); + EXPECT_EQ(7u, FrameType::rawTag(bci, FrameType::tagPointer(method, tag))); +} + +TEST(FrameTypeRawTagTest, DifferentTagsGiveDifferentFrames) { + // Two loads of a class at the same Method* address must not produce + // identical frames, or the call trace storage would merge their traces. + VMHotspotGuard hotspot(true); + const void* method = reinterpret_cast(0x7f0000001000ULL); + for (uint32_t delta : {1u, 0x10u, 0x400u}) { + int bci_a = FrameType::encodeRaw(FRAME_JIT_COMPILED, 3, 5); + int bci_b = FrameType::encodeRaw(FRAME_JIT_COMPILED, 3, 5 + delta); + bool same = bci_a == bci_b && + FrameType::tagPointer(method, 5) == FrameType::tagPointer(method, 5 + delta); + EXPECT_FALSE(same) << delta; + } +} + +TEST(FrameTypeRawTagTest, CanTagPointerRejectsHighAddresses) { + EXPECT_TRUE(FrameType::canTagPointer(reinterpret_cast(0x00007fffffffffffULL))); + EXPECT_FALSE(FrameType::canTagPointer(reinterpret_cast(0x0001000000000000ULL))); +} diff --git a/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp b/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp new file mode 100644 index 0000000000..fd62794dd2 --- /dev/null +++ b/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp @@ -0,0 +1,736 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#include + +#include "../../main/cpp/counters.h" +#include "../../main/cpp/flightRecorder.h" +#include "../../main/cpp/frame.h" +#include "../../main/cpp/hotspot/hotspotSupport.h" +#include "../../main/cpp/hotspot/klassRegistry.h" +#include "../../main/cpp/hotspot/klassTraceId.h" +#include "../../main/cpp/hotspot/vmStructs.h" +#include "../../main/cpp/jvmSupport.h" +#include "../../main/cpp/profiler.h" +#include "../../main/cpp/threadLocalData.inline.h" +#include "../../main/cpp/vmEntry.h" + +#include +#include +#include + +// fjmethodid=lazy end to end, without a JVM: a raw Method* frame is captured +// with HotspotSupport::fillJavaFrame() exactly as walkVM does, the class's +// registry entry is manipulated the way ClassPrepare and class unloading +// would, and the frame is resolved with HotspotSupport::resolve() as the dump +// thread does. +// +// The metadata is fake but laid out the way the offsets below tell +// VMStructs to read it: Method -> ConstMethod -> ConstantPool -> Klass, with +// the constant pool holding the method's name and signature Symbols and the +// Klass its name and methods array. The class has no jmethodIDs at all, as in +// lazy mode. None of it is ever freed, so no fault handling is needed and the +// tests run on every platform. +// +// The Klass*-reuse tests model what aligned, reused class space makes likely: +// a class is unloaded, and a new class of the same shape is loaded at the +// same Klass* address with its methods at the same Method* addresses. A frame +// sampled from the old class must not resolve as a method of the new one. + +class VMTestAccessor { +public: + static bool getHotspot() { return VM::_hotspot; } + static void setHotspot(bool value) { VM::_hotspot = value; } + static JavaVM* getVm() { return VM::_vm; } + static void setVm(JavaVM* vm) { VM::_vm = vm; } + static jvmtiEnv* getJvmti() { return VM::_jvmti; } + static void setJvmti(jvmtiEnv* env) { VM::_jvmti = env; } +}; + +class JVMSupportTestAccessor { +public: + using LoadState = JVMSupport::JMethodIDLoadStats; + static LoadState getLoadState() { return JVMSupport::getLoadState(); } + static void setLoadState(LoadState s) { JVMSupport::setLoadState(s); } + static constexpr LoadState LazyLoaded() { return JVMSupport::Lazy_loaded; } + static constexpr LoadState PartialLoaded() { return JVMSupport::Partial_loaded; } +}; + +class HotspotSupportTestAccessor { +public: + static KlassRegistry& registry() { return HotspotSupport::_klass_registry; } + static void beforeClassRedefinition(jvmtiEnv* jvmti, JNIEnv* jni, jclass klass) { + HotspotSupport::beforeClassRedefinition(jvmti, jni, klass); + } + static void afterClassRedefinition(jvmtiEnv* jvmti, JNIEnv* jni, jclass klass, bool succeeded) { + HotspotSupport::afterClassRedefinition(jvmti, jni, klass, succeeded); + } + static uint32_t traceIdTagBit() { return HotspotSupport::TRACE_ID_TAG; } + static bool registerClass(jvmtiEnv* jvmti, JNIEnv* jni, jclass klass) { + return HotspotSupport::registerClass(jvmti, jni, klass); + } +}; + +class ProfilerTestAccessor { +public: + static bool getForceJmethodID(Profiler* p) { return p->_force_jmethodID; } + static void setForceJmethodID(Profiler* p, bool v) { p->_force_jmethodID = v; } +}; + +// Points VMStructs at the fake layout below for the lifetime of a test, and +// restores whatever was there before. +class VMStructsTestAccessor { +public: + struct State { + int method_constmethod, constmethod_constants, constmethod_idnum, pool_holder; + int jmethod_ids, methods; + int constmethod_name_index, constmethod_sig_index, klass_name, symbol_length, symbol_body; + int constmethod_flags, constmethod_code_size, klass_super; + uint64_t method_size, const_method_size, constant_pool_size, klass_size, symbol_size; + bool can_dereference_jmethod_id; + }; + + explicit VMStructsTestAccessor(const State& s) : _saved(save()) { apply(s); } + ~VMStructsTestAccessor() { apply(_saved); } + +private: + State _saved; + + static State save() { + return { + VMStructs::_method_constmethod_offset, + VMStructs::_constmethod_constants_offset, + VMStructs::_constmethod_idnum_offset, + VMStructs::_pool_holder_offset, + VMStructs::_jmethod_ids_offset, + VMStructs::_methods_offset, + VMStructs::_constmethod_name_index_offset, + VMStructs::_constmethod_sig_index_offset, + VMStructs::_klass_name_offset, + VMStructs::_symbol_length_offset, + VMStructs::_symbol_body_offset, + VMStructs::_constmethod_flags_offset, + VMStructs::_constmethod_code_size_offset, + VMStructs::_klass_super_offset, + VMStructs::TYPE_SIZE_NAME(VMMethod), + VMStructs::TYPE_SIZE_NAME(VMConstMethod), + VMStructs::TYPE_SIZE_NAME(VMConstantPool), + VMStructs::TYPE_SIZE_NAME(VMKlass), + VMStructs::TYPE_SIZE_NAME(VMSymbol), + VMStructs::_can_dereference_jmethod_id, + }; + } + + static void apply(const State& s) { + VMStructs::_method_constmethod_offset = s.method_constmethod; + VMStructs::_constmethod_constants_offset = s.constmethod_constants; + VMStructs::_constmethod_idnum_offset = s.constmethod_idnum; + VMStructs::_pool_holder_offset = s.pool_holder; + VMStructs::_jmethod_ids_offset = s.jmethod_ids; + VMStructs::_methods_offset = s.methods; + VMStructs::_constmethod_name_index_offset = s.constmethod_name_index; + VMStructs::_constmethod_sig_index_offset = s.constmethod_sig_index; + VMStructs::_klass_name_offset = s.klass_name; + VMStructs::_symbol_length_offset = s.symbol_length; + VMStructs::_symbol_body_offset = s.symbol_body; + VMStructs::_constmethod_flags_offset = s.constmethod_flags; + VMStructs::_constmethod_code_size_offset = s.constmethod_code_size; + VMStructs::_klass_super_offset = s.klass_super; + VMStructs::TYPE_SIZE_NAME(VMMethod) = s.method_size; + VMStructs::TYPE_SIZE_NAME(VMConstMethod) = s.const_method_size; + VMStructs::TYPE_SIZE_NAME(VMConstantPool) = s.constant_pool_size; + VMStructs::TYPE_SIZE_NAME(VMKlass) = s.klass_size; + VMStructs::TYPE_SIZE_NAME(VMSymbol) = s.symbol_size; + VMStructs::_can_dereference_jmethod_id = s.can_dereference_jmethod_id; + } +}; + +namespace { + +struct FakeMethod { + void* const_method; +}; + +struct FakeConstMethod { + void* cpool; + uint16_t idnum; + uint16_t name_index; + uint16_t sig_index; + uint16_t pad; +}; + +// VMConstantPool::base() is `this + type_size()`, so the slots follow holder. +struct FakeConstantPool { + void* holder; + intptr_t symbols[3]; +}; + +struct FakeSymbol { + uint16_t length; + char body[30]; +}; + +// HotSpot's Array: int length, then pointer-aligned elements. +struct FakeMethodArray { + int32_t len; + int32_t pad; + void* data[2]; +}; + +struct FakeKlass { + void* ids; // jmethodID cache; stays null + FakeMethodArray* methods; + uint64_t trace_id; // JFR encoding: (id << 16) | tag bits + FakeSymbol* name; +}; + +const VMStructsTestAccessor::State LAYOUT = { + /*method_constmethod*/ (int)offsetof(FakeMethod, const_method), + /*constmethod_constants*/ (int)offsetof(FakeConstMethod, cpool), + /*constmethod_idnum*/ (int)offsetof(FakeConstMethod, idnum), + /*pool_holder*/ (int)offsetof(FakeConstantPool, holder), + /*jmethod_ids*/ (int)offsetof(FakeKlass, ids), + /*methods*/ (int)offsetof(FakeKlass, methods), + /*constmethod_name_index*/ (int)offsetof(FakeConstMethod, name_index), + /*constmethod_sig_index*/ (int)offsetof(FakeConstMethod, sig_index), + /*klass_name*/ (int)offsetof(FakeKlass, name), + /*symbol_length*/ (int)offsetof(FakeSymbol, length), + /*symbol_body*/ (int)offsetof(FakeSymbol, body), + /*constmethod_flags*/ -1, // no line number tables + /*constmethod_code_size*/ -1, + /*klass_super*/ -1, + /*method_size*/ sizeof(FakeMethod), + /*const_method_size*/ sizeof(FakeConstMethod), + /*constant_pool_size*/ offsetof(FakeConstantPool, symbols), + /*klass_size*/ sizeof(FakeKlass), + /*symbol_size*/ sizeof(FakeSymbol), + /*can_dereference*/ true, +}; + +// One class, com/example/Spin, with one method, spin(J)V, and no jmethodIDs. +// Klass* and Method* addresses are those of these fields, which is what lets +// a test "reload" a class at the same addresses: it only has to register a +// new mirror for the same FakeKlass. +struct FakeClass { + static constexpr const char* KLASS_NAME = "com/example/Spin"; + static constexpr const char* METHOD_NAME = "spin"; + static constexpr const char* SIGNATURE = "(J)V"; + + FakeMethod method; + FakeConstMethod const_method; + FakeConstantPool cpool; + FakeKlass klass; + FakeMethodArray methods; + FakeSymbol klass_name, method_name, signature; + + FakeClass() { + memset(this, 0, sizeof(*this)); + method.const_method = &const_method; + const_method.cpool = &cpool; + const_method.idnum = 0; + const_method.name_index = 1; + const_method.sig_index = 2; + cpool.holder = &klass; + cpool.symbols[1] = (intptr_t)&method_name; + cpool.symbols[2] = (intptr_t)&signature; + klass.methods = &methods; + klass.trace_id = (uint64_t)1000 << 16; + klass.name = &klass_name; + methods.len = 1; + methods.data[0] = &method; + setSymbol(klass_name, KLASS_NAME); + setSymbol(method_name, METHOD_NAME); + setSymbol(signature, SIGNATURE); + } + + static void setSymbol(FakeSymbol& sym, const char* text) { + sym.length = (uint16_t)strlen(text); + memcpy(sym.body, text, sym.length); + } + + const void* klassPtr() const { return &klass; } + VMMethod* vmMethod() { return reinterpret_cast(&method); } +}; + +// Fake JNI with weak-reference semantics, as in klassRegistry_ut.cpp, plus +// the JavaVM that VM::jni() goes through. +struct FakeObject { + int id; + const void* klass = nullptr; // what java.lang.Class.klass holds, for mirrors +}; + +struct FakeWeak { + FakeObject* referent; +}; + +class FakeJvm { +public: + FakeJvm() : _saved_vm(VMTestAccessor::getVm()) { + s_instance = this; + _jni_tbl.NewWeakGlobalRef = &newWeakGlobalRef; + _jni_tbl.DeleteWeakGlobalRef = &deleteWeakGlobalRef; + _jni_tbl.IsSameObject = &isSameObject; + _jni_tbl.NewLocalRef = &newLocalRef; + _jni_tbl.DeleteLocalRef = &deleteLocalRef; + _jni_tbl.GetLongField = &getLongField; + _jni_env.functions = &_jni_tbl; + _jvmti_tbl.GetClassStatus = &getClassStatus; + _jvmti_tbl.GetClassMethods = &getClassMethods; + _jvmti_tbl.Deallocate = &deallocate; + _jvmti.functions = &_jvmti_tbl; + _saved_jvmti = VMTestAccessor::getJvmti(); + VMTestAccessor::setJvmti(&_jvmti); + _vm_tbl.GetEnv = &getEnv; + _vm.functions = &_vm_tbl; + VMTestAccessor::setVm(reinterpret_cast(&_vm)); + } + + ~FakeJvm() { + VMTestAccessor::setJvmti(_saved_jvmti); + VMTestAccessor::setVm(_saved_vm); + for (FakeWeak* w : _weaks) { + delete w; + } + s_instance = nullptr; + } + + JNIEnv* jni() { return &_jni_env; } + jvmtiEnv* jvmti() { return &_jvmti; } + + // KlassRegistry::pin() calls NewLocalRef once per resolution. + int new_local_ref_calls = 0; + int get_class_methods_calls = 0; + + jclass mirror(FakeObject& obj) { return reinterpret_cast(&obj); } + + // Class unloading as far as the registry can observe it: the mirror's + // weak references are cleared. + void collect(FakeObject& obj) { + for (FakeWeak* w : _weaks) { + if (w->referent == &obj) { + w->referent = nullptr; + } + } + } + +private: + jobject resolve(jobject ref) { + FakeWeak* w = reinterpret_cast(ref); + return _weaks.count(w) != 0 ? reinterpret_cast(w->referent) : ref; + } + + static jweak JNICALL newWeakGlobalRef(JNIEnv*, jobject obj) { + FakeWeak* w = new FakeWeak{reinterpret_cast(s_instance->resolve(obj))}; + s_instance->_weaks.insert(w); + return reinterpret_cast(w); + } + static void JNICALL deleteWeakGlobalRef(JNIEnv*, jweak) {} + static jboolean JNICALL isSameObject(JNIEnv*, jobject a, jobject b) { + return s_instance->resolve(a) == s_instance->resolve(b) ? JNI_TRUE : JNI_FALSE; + } + static jobject JNICALL newLocalRef(JNIEnv*, jobject ref) { + s_instance->new_local_ref_calls++; + return s_instance->resolve(ref); + } + static void JNICALL deleteLocalRef(JNIEnv*, jobject) {} + // VMKlass::fromJavaClass() reads java.lang.Class.klass through this. + static jlong JNICALL getLongField(JNIEnv*, jobject obj, jfieldID) { + return (jlong)(uintptr_t)reinterpret_cast(obj)->klass; + } + // A jmethodID preload (loadMethodIDsIfNeededImpl()); only the call is + // recorded. Resolution never makes it: only a successful redefinition + // preloads. + static jvmtiError JNICALL getClassMethods(jvmtiEnv*, jclass, jint* count, jmethodID** methods) { + s_instance->get_class_methods_calls++; + *count = 0; + *methods = nullptr; + return JVMTI_ERROR_NONE; + } + static jvmtiError JNICALL deallocate(jvmtiEnv*, unsigned char*) { return JVMTI_ERROR_NONE; } + static jvmtiError JNICALL getClassStatus(jvmtiEnv*, jclass, jint* status) { + *status = JVMTI_CLASS_STATUS_PREPARED; + return JVMTI_ERROR_NONE; + } + static jint JNICALL getEnv(JavaVM*, void** penv, jint) { + *penv = &s_instance->_jni_env; + return JNI_OK; + } + + static FakeJvm* s_instance; + + JavaVM* _saved_vm; + JNINativeInterface_ _jni_tbl{}; + JNIEnv_ _jni_env{}; + JNIInvokeInterface_ _vm_tbl{}; + JavaVM_ _vm{}; + jvmtiInterface_1_ _jvmti_tbl{}; + _jvmtiEnv _jvmti{}; + jvmtiEnv* _saved_jvmti = nullptr; + std::set _weaks; +}; +FakeJvm* FakeJvm::s_instance = nullptr; + +} // namespace + +class HotspotLazyResolveTest : public ::testing::Test { +protected: + VMStructsTestAccessor _layout{LAYOUT}; + FakeJvm _jvm; + FakeClass _cls; + FakeObject _old_mirror{1}; + FakeObject _new_mirror{2}; + bool _saved_hotspot = false; + bool _saved_force = true; + int _saved_trace_id_offset = -1; + JVMSupportTestAccessor::LoadState _saved_state{}; + + void SetUp() override { + _saved_hotspot = VMTestAccessor::getHotspot(); + VMTestAccessor::setHotspot(true); + _saved_state = JVMSupportTestAccessor::getLoadState(); + JVMSupportTestAccessor::setLoadState(JVMSupportTestAccessor::LazyLoaded()); + // fillJavaFrame() only stores raw Method* frames when jmethodIDs are + // not forced, which is how fjmethodid=lazy configures the profiler. + _saved_force = ProfilerTestAccessor::getForceJmethodID(Profiler::instance()); + ProfilerTestAccessor::setForceJmethodID(Profiler::instance(), false); + _saved_trace_id_offset = KlassTraceId::offset(); + KlassTraceId::setOffsetForTest((int)offsetof(FakeKlass, trace_id)); + // The redefinition hooks find the class through java.lang.Class.klass. + _old_mirror.klass = _cls.klassPtr(); + _new_mirror.klass = _cls.klassPtr(); + // resolve() installs crash protection on the current ProfiledThread. + ProfiledThread::initCurrentThread(); + ASSERT_NE(nullptr, ProfiledThread::current()); + } + + void TearDown() override { + HotspotSupportTestAccessor::registry().clear(_jvm.jni()); + ProfiledThread::release(); + KlassTraceId::setOffsetForTest(_saved_trace_id_offset); + ProfilerTestAccessor::setForceJmethodID(Profiler::instance(), _saved_force); + JVMSupportTestAccessor::setLoadState(_saved_state); + VMTestAccessor::setHotspot(_saved_hotspot); + } + + void registerClass(FakeObject& mirror) { + ASSERT_TRUE(HotspotSupportTestAccessor::registry().add(_jvm.jni(), _cls.klassPtr(), + _jvm.mirror(mirror))); + } + + // The old class is unloaded and a new class of the same shape is loaded at + // the same Klass* and Method* addresses: its mirror is new, and so is the + // trace ID JFR assigns at creation. + void loadNewClassAtSameAddress() { + _jvm.collect(_old_mirror); + _cls.klass.trace_id = ((_cls.klass.trace_id >> 16) + 37) << 16; + registerClass(_new_mirror); + } + + // What walkVM stores for a frame of a class without a jmethodID yet. + ASGCT_CallFrame sample() { + ASGCT_CallFrame frame{}; + HotspotSupport::fillJavaFrame(frame, FRAME_JIT_COMPILED, 7, nullptr, _cls.vmMethod()); + EXPECT_TRUE(FrameType::isRawPointer(frame.bci)); + return frame; + } + + // What VM::RetransformClassesHook does around the original call. + void redefine(bool succeeded) { + HotspotSupportTestAccessor::beforeClassRedefinition(_jvm.jvmti(), _jvm.jni(), _jvm.mirror(_old_mirror)); + HotspotSupportTestAccessor::afterClassRedefinition(_jvm.jvmti(), _jvm.jni(), _jvm.mirror(_old_mirror), + succeeded); + } + + // What Lookup::fillRawMethod() does with a raw frame at dump time. + static bool resolve(const ASGCT_CallFrame& frame) { + ResolvedMethod result; + return HotspotSupport::resolve(frame.method, frame.bci, result); + } + + // Whether `frame` resolves, and to the names of the fake class's method. + static bool resolvesToSpin(const ASGCT_CallFrame& frame) { + ResolvedMethod result; + return HotspotSupport::resolve(frame.method, frame.bci, result) && + strcmp(FakeClass::KLASS_NAME, result.klassName()) == 0 && + strcmp(FakeClass::METHOD_NAME, result.methodName()) == 0 && + strcmp(FakeClass::SIGNATURE, result.methodSignature()) == 0; + } +}; + +// The names come from VM metadata: no jmethodIDs are allocated. +TEST_F(HotspotLazyResolveTest, LiveClassResolves) { + registerClass(_old_mirror); + ASGCT_CallFrame frame = sample(); + EXPECT_TRUE(resolvesToSpin(frame)); + EXPECT_EQ(0, _jvm.get_class_methods_calls); +} + +TEST_F(HotspotLazyResolveTest, UnloadedClassDoesNotResolve) { + registerClass(_old_mirror); + ASGCT_CallFrame frame = sample(); + _jvm.collect(_old_mirror); + EXPECT_FALSE(resolve(frame)); +} + +// The old class is unloaded and a new one is registered at the same Klass*, +// with its method at the same Method* address. The frame was sampled from the +// old class, so it must not resolve to the new class's method. +TEST_F(HotspotLazyResolveTest, FrameFromBeforeKlassReuseDoesNotResolveToNewClass) { + registerClass(_old_mirror); + ASGCT_CallFrame frame = sample(); + + loadNewClassAtSameAddress(); + + EXPECT_FALSE(resolve(frame)); +} + +// Same, but the dead entry was purged by a registry rehash before the new +// class arrived, so the new class's registration looks like a first insert +// rather than a replacement. +TEST_F(HotspotLazyResolveTest, FrameFromBeforeKlassReuseAfterPurgeDoesNotResolveToNewClass) { + registerClass(_old_mirror); + ASGCT_CallFrame frame = sample(); + _jvm.collect(_old_mirror); + + // Enough other classes to push the table past its initial capacity's load + // factor, so the dead entry is purged. + static FakeObject others[1024]; + KlassRegistry& registry = HotspotSupportTestAccessor::registry(); + for (size_t i = 0; i < 1024; i++) { + others[i].id = 100 + (int)i; + const void* key = reinterpret_cast(0x100000 + i * 0x200); + ASSERT_TRUE(registry.add(_jvm.jni(), key, _jvm.mirror(others[i]))); + } + ASSERT_EQ(nullptr, registry.pin(_jvm.jni(), _cls.klassPtr())) + << "the dead entry should be gone, purged or at least unpinnable"; + + _cls.klass.trace_id = ((_cls.klass.trace_id >> 16) + 37) << 16; // the new class's ID + registerClass(_new_mirror); + + EXPECT_FALSE(resolve(frame)); +} + +// Frames sampled from the new class after the reuse must still resolve. +TEST_F(HotspotLazyResolveTest, FrameFromAfterKlassReuseResolves) { + registerClass(_old_mirror); + loadNewClassAtSameAddress(); + + ASGCT_CallFrame frame = sample(); + EXPECT_TRUE(resolvesToSpin(frame)); +} + +// A frame from the old class and one from the new class must be told apart +// already at capture time; otherwise the call trace storage merges them into +// one trace before resolve() ever runs. +TEST_F(HotspotLazyResolveTest, FramesFromBeforeAndAfterKlassReuseDiffer) { + registerClass(_old_mirror); + ASGCT_CallFrame before = sample(); + loadNewClassAtSameAddress(); + ASGCT_CallFrame after = sample(); + + EXPECT_FALSE(before.bci == after.bci && before.method == after.method); +} + +// Tags identify the class load itself, so another class's unloading and the +// reuse of its address leave this class's frames unchanged, and the same stack +// keeps deduplicating into one trace. +TEST_F(HotspotLazyResolveTest, UnrelatedReuseDoesNotChangeFrames) { + registerClass(_old_mirror); + ASGCT_CallFrame before = sample(); + + static FakeObject other_old{10}; + static FakeObject other_new{11}; + const void* other_klass = reinterpret_cast(0x200000); + KlassRegistry& registry = HotspotSupportTestAccessor::registry(); + ASSERT_TRUE(registry.add(_jvm.jni(), other_klass, _jvm.mirror(other_old))); + _jvm.collect(other_old); + ASSERT_TRUE(registry.add(_jvm.jni(), other_klass, _jvm.mirror(other_new))); + + ASGCT_CallFrame after = sample(); + EXPECT_EQ(before.bci, after.bci); + EXPECT_EQ(before.method, after.method); + EXPECT_TRUE(resolvesToSpin(after)); +} + +// The JFR tag bits below the ID change while JFR runs; they must not affect +// the frame's tag or its resolution. +TEST_F(HotspotLazyResolveTest, JfrTagBitsDoNotAffectTraceIdTags) { + registerClass(_old_mirror); + ASGCT_CallFrame before = sample(); + _cls.klass.trace_id |= 0x0303; + ASGCT_CallFrame after = sample(); + EXPECT_EQ(before.bci, after.bci); + EXPECT_EQ(before.method, after.method); + EXPECT_TRUE(resolvesToSpin(before)); +} + +// Partial mode (fjmethodid=false) stores the plain Method*, untagged, and +// never reads the holder's trace ID: a frame whose trace ID is unreadable is +// still stored raw, not turned into an unknown frame. +TEST_F(HotspotLazyResolveTest, PartialModeFramesAreStoredUntagged) { + JVMSupportTestAccessor::setLoadState(JVMSupportTestAccessor::PartialLoaded()); + KlassTraceId::setOffsetForTest(-1); // trace ID unreadable + ASGCT_CallFrame frame = sample(); + EXPECT_EQ(static_cast(_cls.vmMethod()), frame.method); + EXPECT_EQ(0u, FrameType::rawTag(frame.bci, frame.method) & HotspotSupportTestAccessor::traceIdTagBit()); + EXPECT_EQ(static_cast(_cls.vmMethod()), HotspotSupport::rawFrameMethod(frame.bci, frame.method)); +} + +// resolve() goes by the mode the frame was sampled in, not the current one: +// a lazy-mode frame kept across a restart in partial mode still resolves +// through the registry, which is what keeps a custom loader's class from +// being unloaded while its metadata is read. +TEST_F(HotspotLazyResolveTest, LazyFrameResolvesByRegistryAfterSwitchToPartialMode) { + registerClass(_old_mirror); + ASGCT_CallFrame frame = sample(); + ASSERT_NE(0u, FrameType::rawTag(frame.bci, frame.method) & HotspotSupportTestAccessor::traceIdTagBit()); + JVMSupportTestAccessor::setLoadState(JVMSupportTestAccessor::PartialLoaded()); + int before = _jvm.new_local_ref_calls; + EXPECT_TRUE(resolvesToSpin(frame)); + EXPECT_EQ(1, _jvm.new_local_ref_calls - before); +} + +// A redefinition frees the class's old Method*s once they are off every +// stack, and a later redefinition of the same class can allocate a new method +// at the same address. The class, its mirror and its trace ID all +// stay the same, so nothing but the redefinition itself tells the frames +// apart: raw frames of a class marked before its redefinition are refused. +TEST_F(HotspotLazyResolveTest, FrameOfRedefinedClassIsRefused) { + registerClass(_old_mirror); + ASGCT_CallFrame before = sample(); + // Afterwards the Method* at the sampled address is (still, or again) + // among the class's methods. + redefine(true); + EXPECT_FALSE(resolve(before)); + // Its later frames get jmethodIDs instead of being raw. + EXPECT_EQ(1, _jvm.get_class_methods_calls); +} + +// A redefinition JVMTI rejects (e.g. an unmodifiable class) changes nothing: +// the class's raw frames stay resolvable and no jmethodIDs are preloaded. +TEST_F(HotspotLazyResolveTest, FailedRedefinitionLeavesFramesResolvable) { + registerClass(_old_mirror); + ASGCT_CallFrame frame = sample(); + redefine(false); + EXPECT_TRUE(resolvesToSpin(frame)); + EXPECT_EQ(0, _jvm.get_class_methods_calls); +} + +// While the redefinition call runs, the methods may be swapped at any moment. +TEST_F(HotspotLazyResolveTest, FramesAreRefusedWhileRedefinitionRuns) { + registerClass(_old_mirror); + ASGCT_CallFrame frame = sample(); + HotspotSupportTestAccessor::beforeClassRedefinition(_jvm.jvmti(), _jvm.jni(), _jvm.mirror(_old_mirror)); + EXPECT_FALSE(resolve(frame)); + HotspotSupportTestAccessor::afterClassRedefinition(_jvm.jvmti(), _jvm.jni(), _jvm.mirror(_old_mirror), false); + EXPECT_TRUE(resolvesToSpin(frame)); +} + +// Redefinitions are tracked in every mode once the registry has entries: +// raw frames of a lazy session can outlive a restart in another mode, and a +// later lazy session must not trust a class redefined in between. +TEST_F(HotspotLazyResolveTest, RedefinitionIsTrackedOutsideLazyMode) { + registerClass(_old_mirror); + ASGCT_CallFrame frame = sample(); + JVMSupportTestAccessor::setLoadState(JVMSupportTestAccessor::PartialLoaded()); + redefine(true); + EXPECT_TRUE(HotspotSupportTestAccessor::registry().isRedefined(_cls.klassPtr())); + EXPECT_FALSE(resolve(frame)); + // The other modes load jmethodIDs their own way (JVMSupport). + EXPECT_EQ(0, _jvm.get_class_methods_calls); +} + +// The sampled class is unloaded and its Method* memory goes to a class loaded +// later at a different Klass* address. Reading the holder from the stale +// Method* now yields the new class, which is alive and lists the method. +TEST_F(HotspotLazyResolveTest, MethodMemoryReusedByClassAtOtherAddressDoesNotResolve) { + registerClass(_old_mirror); + ASGCT_CallFrame frame = sample(); + + _jvm.collect(_old_mirror); + static FakeKlass other_klass; + static FakeMethodArray other_methods; + other_klass = _cls.klass; + other_klass.methods = &other_methods; + other_klass.trace_id = ((_cls.klass.trace_id >> 16) + 41) << 16; + other_methods = *_cls.klass.methods; + _cls.cpool.holder = &other_klass; // the reused Method* now belongs to it + ASSERT_TRUE(HotspotSupportTestAccessor::registry().add(_jvm.jni(), &other_klass, + _jvm.mirror(_new_mirror))); + + EXPECT_FALSE(resolve(frame)); +} + +// Tripwire for tag collisions: counts registrations of classes whose trace +// ID no longer fits in the 25-bit tag, the first point at which a stale frame +// could match a newer class by its tag. +TEST_F(HotspotLazyResolveTest, CountsClassesWithTraceIdBeyondTagRange) { +#ifndef COUNTERS + GTEST_SKIP() << "debug counters are not compiled into this build"; +#else + long long before = Counters::getCounter(KLASS_TRACE_ID_BEYOND_TAG_RANGE); + _cls.klass.trace_id = ((uint64_t)(1u << 25) - 1) << 16; // largest ID that still fits + ASSERT_TRUE(HotspotSupportTestAccessor::registerClass(_jvm.jvmti(), _jvm.jni(), _jvm.mirror(_old_mirror))); + EXPECT_EQ(before, Counters::getCounter(KLASS_TRACE_ID_BEYOND_TAG_RANGE)); + + static FakeKlass big_klass; + big_klass = _cls.klass; + big_klass.trace_id = ((uint64_t)1 << 25) << 16; + _new_mirror.klass = &big_klass; + ASSERT_TRUE(HotspotSupportTestAccessor::registerClass(_jvm.jvmti(), _jvm.jni(), _jvm.mirror(_new_mirror))); + EXPECT_EQ(before + 1, Counters::getCounter(KLASS_TRACE_ID_BEYOND_TAG_RANGE)); +#endif +} + +// Lookup resolves each distinct raw method once per dump, however often it +// recurs, and whatever bci or frame type the occurrences carry. +TEST_F(HotspotLazyResolveTest, LookupResolvesEachRawMethodOncePerDump) { + registerClass(_old_mirror); + ASGCT_CallFrame a = sample(); + ASGCT_CallFrame b{}; + HotspotSupport::fillJavaFrame(b, FRAME_INTERPRETED, 42, nullptr, _cls.vmMethod()); + ASSERT_TRUE(FrameType::isRawPointer(b.bci)); + ASSERT_NE(a.bci, b.bci); + + StringDictionary classes; + MethodMap methods; + Lookup lookup(nullptr, &methods, &classes); + int before = _jvm.new_local_ref_calls; + MethodInfo* row = lookup.resolveMethod(a); + ASSERT_NE(&lookup._unknown_method, row); + EXPECT_EQ(lookup.getSymbol(FakeClass::METHOD_NAME), row->_name); + EXPECT_EQ(lookup.getSymbol(FakeClass::SIGNATURE), row->_sig); + EXPECT_EQ(row, lookup.resolveMethod(a)); + EXPECT_EQ(row, lookup.resolveMethod(b)); + EXPECT_EQ(1, _jvm.new_local_ref_calls - before); + + // The next dump starts with an empty cache, resolves the frame again and + // refills the same row. + row->_mark = false; // what writeMethods() does at the end of a chunk + Lookup next(nullptr, &methods, &classes); + EXPECT_EQ(row, next.resolveMethod(a)); + EXPECT_TRUE(row->_mark); + EXPECT_EQ(2, _jvm.new_local_ref_calls - before); + EXPECT_EQ(0, _jvm.get_class_methods_calls); +} + +// Frames from two loads of a class at the same addresses carry different tags, +// so the cache keeps their outcomes apart. +TEST_F(HotspotLazyResolveTest, LookupCacheSeparatesFramesOfDifferentClassLoads) { + registerClass(_old_mirror); + ASGCT_CallFrame before = sample(); + loadNewClassAtSameAddress(); + ASGCT_CallFrame after = sample(); + + StringDictionary classes; + MethodMap methods; + Lookup lookup(nullptr, &methods, &classes); + EXPECT_EQ(&lookup._unknown_method, lookup.resolveMethod(before)); + MethodInfo* row = lookup.resolveMethod(after); + EXPECT_NE(&lookup._unknown_method, row); + EXPECT_EQ(&lookup._unknown_method, lookup.resolveMethod(before)); + EXPECT_EQ(row, lookup.resolveMethod(after)); +} + diff --git a/ddprof-lib/src/test/cpp/hotspotMethodId_ut.cpp b/ddprof-lib/src/test/cpp/hotspotMethodId_ut.cpp index f8501cf6e6..c1390bc1c2 100644 --- a/ddprof-lib/src/test/cpp/hotspotMethodId_ut.cpp +++ b/ddprof-lib/src/test/cpp/hotspotMethodId_ut.cpp @@ -19,6 +19,8 @@ #include #include #include +#include +#include #ifdef __linux__ #include @@ -123,6 +125,53 @@ class VMStructsTestAccessor { } }; + // Scoped override for what resolve() needs to locate and decode a + // ConstMethod's line number table and to walk Klass::_super. + class LineTableLayout { + public: + LineTableLayout(int flags_offset, int code_size_offset, int has_table_mask, + unsigned char unsigned5_base, int klass_super_offset) { + _saved = _read(); + VMStructs::_constmethod_flags_offset = flags_offset; + VMStructs::_constmethod_code_size_offset = code_size_offset; + // Model JDK 21+ (u4 ConstMethodFlags); the u2 constant stays unset. + VMStructs::_ConstMethodFlags__misc_has_linenumber_table = has_table_mask; + VMStructs::_ConstMethod__has_linenumber_table = -1; + VMStructs::_unsigned5_base = unsigned5_base; + VMStructs::_klass_super_offset = klass_super_offset; + } + + ~LineTableLayout() { + VMStructs::_constmethod_flags_offset = _saved.flags_offset; + VMStructs::_constmethod_code_size_offset = _saved.code_size_offset; + VMStructs::_ConstMethodFlags__misc_has_linenumber_table = _saved.flags_mask; + VMStructs::_ConstMethod__has_linenumber_table = _saved.legacy_mask; + VMStructs::_unsigned5_base = _saved.unsigned5_base; + VMStructs::_klass_super_offset = _saved.klass_super_offset; + } + + private: + struct Saved { + int flags_offset; + int code_size_offset; + int flags_mask; + int legacy_mask; + unsigned char unsigned5_base; + int klass_super_offset; + } _saved; + + static Saved _read() { + return { + VMStructs::_constmethod_flags_offset, + VMStructs::_constmethod_code_size_offset, + VMStructs::_ConstMethodFlags__misc_has_linenumber_table, + VMStructs::_ConstMethod__has_linenumber_table, + VMStructs::_unsigned5_base, + VMStructs::_klass_super_offset, + }; + } + }; + private: struct SavedOffsets { int method_constmethod; @@ -327,12 +376,12 @@ TEST(HotspotMethodIdTest, IdReturnsValidIdForPopulatedSlot) { // --------------------------------------------------------------------------- // HotspotSupport::resolve() crash protection // -// resolve() turns a raw Method* captured at sample time into a jmethodID on the -// JFR dump thread. GC or class unloading can free that metadata in between, so +// resolve() turns a raw Method* captured at sample time into its class name, +// method name, signature and line number table on the JFR dump thread. GC or class unloading can free that metadata in between, so // every dereference in the walk (constMethod -> constants -> name/signature -> // holder -> klass->name, then Symbol length/body) can fault. resolve() installs // a sigsetjmp landing pad via JmpCtxScope so Profiler::checkFault() recovers and -// resolve() reports the method as unresolved (nullptr) instead of taking the JVM +// resolve() reports the method as unresolved (false) instead of taking the JVM // down mid-dump. // // checkFault() gates recovery on the faulting pc lying inside the profiler @@ -374,7 +423,7 @@ size_t kPageSize() { return OS::page_size; } // and consistent with the struct layouts below. constexpr VMStructsTestAccessor::TypeSizes RESOLVE_TYPE_SIZES = { /*method*/ sizeof(void*), - /*const_method*/ 2 * sizeof(void*), + /*const_method*/ 24, // == offsetof(ResolveFakes::ConstMethod, code) /*constant_pool*/ sizeof(void*), // base() = cpool + this, i.e. &symbols[0] /*klass*/ sizeof(void*), /*symbol*/ 8, @@ -391,9 +440,7 @@ constexpr VMStructsTestAccessor::SymbolOffsets RESOLVE_SYMBOL_OFFSETS = { // jmethod_ids deliberately points at a separate, always-null field rather than // aliasing klass_name at offset 0: VMMethod::id() reads the jmethodID cache // through that offset, and pointing it at the class-name Symbol would make id() -// read a length and a "jmethodID" out of the Symbol's body. That value passes -// isValidJMethodID(), so resolve() would early-return with garbage and never -// reach the symbol-copy code these tests are about. +// read a length and a "jmethodID" out of the Symbol's body. constexpr VMStructsTestAccessor::Offsets RESOLVE_OFFSETS = { /*method_constmethod*/ 0, /*constmethod_constants*/ 0, @@ -413,7 +460,11 @@ struct ResolveFakes { uint16_t name_index; uint16_t sig_index; uint16_t idnum; - uint16_t pad; + uint16_t code_size; // RESOLVE_CODE_SIZE_OFFSET + uint32_t flags; // RESOLVE_FLAGS_OFFSET + uint32_t pad; + // Bytecodes, then the compressed line number table (code_end()). + unsigned char code[64]; } const_method; struct ConstantPool { void* holder; @@ -422,11 +473,12 @@ struct ResolveFakes { struct Klass { void* name_symbol; // klass_name offset 0 void* jmethod_ids; // stays null so VMMethod::id() reports "no cache" - } klass; + void* super_klass; // RESOLVE_KLASS_SUPER_OFFSET + } klass, super_klass; struct Symbol { uint16_t length; char body[64]; - } name_sym, sig_sym, klass_sym; + } name_sym, sig_sym, klass_sym, super_sym; // Wires the chain up so resolve() walks Method -> ConstMethod -> ConstantPool // -> {name, signature} Symbols and ConstantPool -> Klass -> class-name Symbol. @@ -446,74 +498,25 @@ struct ResolveFakes { s.length = (uint16_t)strlen(text); memcpy(s.body, text, s.length); } -}; - -// Minimal fake JavaVM/JNIEnv so HotspotSupport::resolve()'s unprotected JNI -// phase (lookupMethodIdViaJni) can run to completion without a live JVM. -// FindClass always reports success against an opaque, never-dereferenced -// jclass; GetMethodID/GetStaticMethodID record the name/signature they were -// called with and report "not found" -- the same real-world path resolve() -// takes for a method it can't find -- so nothing beyond that is exercised. -// Recording the arguments is what lets a test prove the malloc-fallback path -// copied the right bytes, rather than just checking resolve() didn't crash. -class ScopedFakeJni { -public: - ScopedFakeJni() : _saved_vm(VMTestAccessor::getVm()) { - _jni_tbl = JNINativeInterface_{}; - _jni_tbl.FindClass = &findClass; - _jni_tbl.ExceptionClear = &exceptionClear; - _jni_tbl.GetMethodID = &getMethodId; - _jni_tbl.GetStaticMethodID = &getMethodId; - _jni_tbl.DeleteLocalRef = &deleteLocalRef; - _jni_env.functions = &_jni_tbl; - - _vm_tbl = JNIInvokeInterface_{}; - _vm_tbl.GetEnv = &getEnv; - _vm.functions = &_vm_tbl; - - s_instance = this; - VMTestAccessor::setVm(reinterpret_cast(&_vm)); - } - ~ScopedFakeJni() { - VMTestAccessor::setVm(_saved_vm); - s_instance = nullptr; + // Marks the method as having a line number table and copies `table` + // (already compressed) right after `code_size` bytes of bytecode. + void setLineNumberTable(uint16_t code_size, const unsigned char* table, size_t len) { + const_method.code_size = code_size; + const_method.flags |= 1; + memcpy(const_method.code + code_size, table, len); } +}; - int getMethodIdCalls() const { return _get_method_id_calls; } - const std::string& lastMethodName() const { return _last_method_name; } - const std::string& lastMethodSignature() const { return _last_method_sig; } +// Must stay below the 1024-byte mark ResolveUsesMallocFallbackForOversizedButReadableName +// writes its long Symbol at. +static_assert(sizeof(ResolveFakes) <= 1024, "fakes overlap the long symbol"); +static_assert(offsetof(ResolveFakes::ConstMethod, code) == 24, "RESOLVE_TYPE_SIZES.const_method"); -private: - static jclass JNICALL findClass(JNIEnv*, const char*) { - return reinterpret_cast(&s_instance->_fake_class); - } - static jmethodID JNICALL getMethodId(JNIEnv*, jclass, const char* name, const char* sig) { - s_instance->_get_method_id_calls++; - s_instance->_last_method_name = name != nullptr ? name : ""; - s_instance->_last_method_sig = sig != nullptr ? sig : ""; - return nullptr; - } - static void JNICALL exceptionClear(JNIEnv*) {} - static void JNICALL deleteLocalRef(JNIEnv*, jobject) {} - static jint JNICALL getEnv(JavaVM*, void** penv, jint) { - *penv = reinterpret_cast(&s_instance->_jni_env); - return JNI_OK; - } +constexpr int RESOLVE_CODE_SIZE_OFFSET = offsetof(ResolveFakes::ConstMethod, code_size); +constexpr int RESOLVE_FLAGS_OFFSET = offsetof(ResolveFakes::ConstMethod, flags); +constexpr int RESOLVE_KLASS_SUPER_OFFSET = offsetof(ResolveFakes::Klass, super_klass); - static ScopedFakeJni* s_instance; - - JavaVM* _saved_vm; - JNINativeInterface_ _jni_tbl{}; - JNIEnv_ _jni_env{}; - JNIInvokeInterface_ _vm_tbl{}; - JavaVM_ _vm{}; - int _fake_class = 0; // address-only sentinel; never dereferenced - int _get_method_id_calls = 0; - std::string _last_method_name; - std::string _last_method_sig; -}; -ScopedFakeJni* ScopedFakeJni::s_instance = nullptr; } // namespace @@ -582,10 +585,9 @@ TEST_F(HotspotResolveCrashProtectionTest, ResolveRecoversFromFaultInsteadOfCrash long long before = Counters::getCounter(METHOD_RESOLVE_FAULT_RECOVERED); - // VMMethod::id() reads the same offset through SafeAccess first, so it - // returns the sentinel rather than faulting; resolve() then falls through to - // constMethod_or_null(), whose raw *(void**) deref is what actually faults. - EXPECT_EQ(nullptr, HotspotSupport::resolve(_region)); + // constMethod_or_null()'s raw *(void**) deref is what faults. + ResolvedMethod result; + EXPECT_FALSE(HotspotSupport::resolve(_region, 0, result)); EXPECT_EQ(before + 1, Counters::getCounter(METHOD_RESOLVE_FAULT_RECOVERED)); // The landing pad must hand the previous (here: absent) context back. @@ -604,7 +606,8 @@ TEST_F(HotspotResolveCrashProtectionTest, ResolveRecoversRepeatedly) { long long before = Counters::getCounter(METHOD_RESOLVE_FAULT_RECOVERED); for (int i = 0; i < 10; i++) { - EXPECT_EQ(nullptr, HotspotSupport::resolve(_region)); + ResolvedMethod result; + EXPECT_FALSE(HotspotSupport::resolve(_region, 0, result)); EXPECT_FALSE(_pt->isProtected()); } EXPECT_EQ(before + 10, Counters::getCounter(METHOD_RESOLVE_FAULT_RECOVERED)); @@ -635,7 +638,8 @@ TEST_F(HotspotResolveCrashProtectionTest, ResolveReturnsNullForUnreadableSymbolB long long before = Counters::getCounter(METHOD_RESOLVE_SYMBOL_UNREADABLE); long long faults_before = Counters::getCounter(METHOD_RESOLVE_FAULT_RECOVERED); - EXPECT_EQ(nullptr, HotspotSupport::resolve(&f->method)); + ResolvedMethod result; + EXPECT_FALSE(HotspotSupport::resolve(&f->method, 0, result)); EXPECT_EQ(before + 1, Counters::getCounter(METHOD_RESOLVE_SYMBOL_UNREADABLE)); // Rejected by the readability probe, not by recovering from a real fault. @@ -644,10 +648,10 @@ TEST_F(HotspotResolveCrashProtectionTest, ResolveReturnsNullForUnreadableSymbolB } // A method name longer than the fixed inline buffer (MAX_METHOD_NAME_LEN, -// 512 bytes) but fully readable must take ResolvedNames::setImpl()'s malloc -// fallback and still resolve successfully through to the JNI phase, with the -// full, correctly NUL-terminated name -- not get rejected the way a name -// this size would be by the *unreadable*-body case below. +// 512 bytes) but fully readable must take ResolvedMethod::setName()'s malloc +// fallback and still resolve successfully, with the full, correctly +// NUL-terminated name -- not get rejected the way a name this size would be +// by the *unreadable*-body case below. TEST_F(HotspotResolveCrashProtectionTest, ResolveUsesMallocFallbackForOversizedButReadableName) { HotspotMethodIdVMHotspotGuard hotspot; VMStructsTestAccessor offsets(RESOLVE_OFFSETS); @@ -669,34 +673,24 @@ TEST_F(HotspotResolveCrashProtectionTest, ResolveUsesMallocFallbackForOversizedB memcpy(long_symbol + RESOLVE_SYMBOL_OFFSETS.symbol_body, long_name.data(), long_name.size()); f->cpool.symbols[1] = (intptr_t)long_symbol; // name_index slot, see link() - ScopedFakeJni fake_jni; - long long unreadable_before = Counters::getCounter(METHOD_RESOLVE_SYMBOL_UNREADABLE); long long fault_before = Counters::getCounter(METHOD_RESOLVE_FAULT_RECOVERED); - // The fake JNI's GetMethodID reports "not found", so resolve() itself - // still returns nullptr -- the point of this test is what it does on the - // way there, checked below. - EXPECT_EQ(nullptr, HotspotSupport::resolve(&f->method)); + ResolvedMethod result; + ASSERT_TRUE(HotspotSupport::resolve(&f->method, 0, result)); - // Phase 1 accepted the name via the malloc fallback (no rejection, no - // fault) and reached phase 2, which called GetMethodID with the fully - // copied name -- proof the fallback copied all 600 bytes and - // NUL-terminated them correctly rather than truncating to the inline - // buffer or leaving it uninitialized. + // The fallback copied all 600 bytes and NUL-terminated them, rather than + // truncating to the inline buffer or leaving it uninitialized. + EXPECT_EQ(long_name, result.methodName()); + EXPECT_STREQ("()V", result.methodSignature()); + EXPECT_STREQ("java/lang/Object", result.klassName()); EXPECT_EQ(unreadable_before, Counters::getCounter(METHOD_RESOLVE_SYMBOL_UNREADABLE)); EXPECT_EQ(fault_before, Counters::getCounter(METHOD_RESOLVE_FAULT_RECOVERED)); - // GetMethodID and GetStaticMethodID share this mock and both report "not - // found", so lookupMethodIdViaJni() falls through from one to the other -- - // two calls, both carrying the same (correct) captured name/signature. - EXPECT_EQ(2, fake_jni.getMethodIdCalls()); - EXPECT_EQ(long_name, fake_jni.lastMethodName()); - EXPECT_EQ("()V", fake_jni.lastMethodSignature()); EXPECT_FALSE(_pt->isProtected()); } // NOTE for reviewers: a companion test asserting the *hard* MAX_SYMBOL_LEN -// ceiling (`len >= MAX_SYMBOL_LEN` in ResolvedNames::setImpl) rejects a +// ceiling (`len >= MAX_SYMBOL_LEN` in ResolvedMethod::setName) rejects a // symbol is intentionally not included here. VMSymbol::length() returns // `unsigned short` (vmStructs.h), so the largest value it can ever produce is // 65535 -- which is already below MAX_SYMBOL_LEN (64 * 1024 = 65536). That @@ -727,7 +721,8 @@ TEST_F(HotspotResolveCrashProtectionTest, ResolveRejectsUnreadableSymbolAtMaxLen long long before = Counters::getCounter(METHOD_RESOLVE_SYMBOL_UNREADABLE); - EXPECT_EQ(nullptr, HotspotSupport::resolve(&f->method)); + ResolvedMethod result; + EXPECT_FALSE(HotspotSupport::resolve(&f->method, 0, result)); EXPECT_EQ(before + 1, Counters::getCounter(METHOD_RESOLVE_SYMBOL_UNREADABLE)); EXPECT_FALSE(_pt->isProtected()); @@ -748,7 +743,8 @@ TEST_F(HotspotResolveCrashProtectionTest, ResolveReturnsNullForEmptySymbol) { long long before = Counters::getCounter(METHOD_RESOLVE_SYMBOL_UNREADABLE); - EXPECT_EQ(nullptr, HotspotSupport::resolve(&f->method)); + ResolvedMethod result; + EXPECT_FALSE(HotspotSupport::resolve(&f->method, 0, result)); EXPECT_EQ(before + 1, Counters::getCounter(METHOD_RESOLVE_SYMBOL_UNREADABLE)); EXPECT_FALSE(_pt->isProtected()); @@ -760,10 +756,190 @@ TEST_F(HotspotResolveCrashProtectionTest, ResolveShortCircuitsSentinelWithoutPro HotspotMethodIdVMHotspotGuard hotspot; long long before = Counters::getCounter(METHOD_RESOLVE_FAULT_RECOVERED); - EXPECT_EQ(nullptr, HotspotSupport::resolve((const void*)JMETHODID_NOT_WALKABLE)); + ResolvedMethod result; + EXPECT_FALSE(HotspotSupport::resolve((const void*)JMETHODID_NOT_WALKABLE, 0, result)); EXPECT_EQ(before, Counters::getCounter(METHOD_RESOLVE_FAULT_RECOVERED)); EXPECT_FALSE(_pt->isProtected()); } +// --------------------------------------------------------------------------- +// resolve(): names, line number table and Thread.run detection +// --------------------------------------------------------------------------- + +namespace { + +// Signed CompressedStream int, valid for small values only: single-byte +// UNSIGNED5 needs the zigzag-encoded value to stay below 191. +unsigned char encodeSmallSigned(int v, unsigned char base) { + unsigned int zigzag = ((unsigned int)v << 1) ^ (unsigned int)(v >> 31); + EXPECT_LT(zigzag, 191u); + return (unsigned char)(zigzag + base); +} + +// Compressed form of {(0,10), (4,11), (9,15), (12,13)}: escaped, inline, +// inline, escaped with a negative line delta. +std::vector sampleLineTable(unsigned char base) { + return { + 0xFF, encodeSmallSigned(0, base), encodeSmallSigned(10, base), + (4 << 3) | 1, + (5 << 3) | 4, + 0xFF, encodeSmallSigned(3, base), encodeSmallSigned(-2, base), + 0x00, + }; +} + +} // namespace + +TEST_F(HotspotResolveCrashProtectionTest, ResolveReturnsNamesWithoutLineNumberTable) { + HotspotMethodIdVMHotspotGuard hotspot; + VMStructsTestAccessor offsets(RESOLVE_OFFSETS); + VMStructsTestAccessor::SymbolLayout layout(RESOLVE_SYMBOL_OFFSETS, RESOLVE_TYPE_SIZES); + VMStructsTestAccessor::LineTableLayout lines(RESOLVE_FLAGS_OFFSET, RESOLVE_CODE_SIZE_OFFSET, + 1, 1, RESOLVE_KLASS_SUPER_OFFSET); + + ResolveFakes* f = new (_region) ResolveFakes{}; + f->link(); + ResolveFakes::setSymbol(f->name_sym, "compute"); + ResolveFakes::setSymbol(f->sig_sym, "(I)J"); + ResolveFakes::setSymbol(f->klass_sym, "com/example/Foo"); + f->const_method.code_size = 8; // has_linenumber_table bit stays clear + + ResolvedMethod result; + ASSERT_TRUE(HotspotSupport::resolve(&f->method, 0, result)); + EXPECT_STREQ("compute", result.methodName()); + EXPECT_STREQ("(I)J", result.methodSignature()); + EXPECT_STREQ("com/example/Foo", result.klassName()); + EXPECT_EQ(nullptr, result.lineNumberTable()); + EXPECT_EQ(0, result.lineNumberTableSize()); + EXPECT_FALSE(result.isThreadRun()); + EXPECT_FALSE(_pt->isProtected()); +} + +// Both UNSIGNED5 flavours: base 0 (JDK < 20) and base 1 (JDK-8292758). +TEST_F(HotspotResolveCrashProtectionTest, ResolveDecodesLineNumberTable) { + for (int b : {0, 1}) { + SCOPED_TRACE(b); + const unsigned char base = (unsigned char)b; + HotspotMethodIdVMHotspotGuard hotspot; + VMStructsTestAccessor offsets(RESOLVE_OFFSETS); + VMStructsTestAccessor::SymbolLayout layout(RESOLVE_SYMBOL_OFFSETS, RESOLVE_TYPE_SIZES); + VMStructsTestAccessor::LineTableLayout lines(RESOLVE_FLAGS_OFFSET, RESOLVE_CODE_SIZE_OFFSET, + 1, base, RESOLVE_KLASS_SUPER_OFFSET); + + ResolveFakes* f = new (_region) ResolveFakes{}; + f->link(); + ResolveFakes::setSymbol(f->name_sym, "compute"); + ResolveFakes::setSymbol(f->sig_sym, "()V"); + ResolveFakes::setSymbol(f->klass_sym, "com/example/Foo"); + std::vector table = sampleLineTable(base); + f->setLineNumberTable(16, table.data(), table.size()); + + ResolvedMethod result; + ASSERT_TRUE(HotspotSupport::resolve(&f->method, 0, result)); + ASSERT_EQ(4, result.lineNumberTableSize()); + const jvmtiLineNumberEntry* entries = result.lineNumberTable(); + ASSERT_NE(nullptr, entries); + const jlocation expected_bci[] = {0, 4, 9, 12}; + const jint expected_line[] = {10, 11, 15, 13}; + for (int i = 0; i < 4; i++) { + EXPECT_EQ(expected_bci[i], entries[i].start_location) << i; + EXPECT_EQ(expected_line[i], entries[i].line_number) << i; + } + + int size = 0; + jvmtiLineNumberEntry* owned = result.takeLineNumberTable(&size); + EXPECT_EQ(entries, owned); + EXPECT_EQ(4, size); + EXPECT_EQ(nullptr, result.lineNumberTable()); + free(owned); + } +} + +// A bci past the bytecode means the metadata is not what it claims to be. +// HotSpot's own ConstMethod::verify_on() accepts bci == code_size, so the +// table is only rejected once its last bci exceeds the code size. +TEST_F(HotspotResolveCrashProtectionTest, ResolveRejectsLineNumberTableWithBciOutOfRange) { + HotspotMethodIdVMHotspotGuard hotspot; + VMStructsTestAccessor offsets(RESOLVE_OFFSETS); + VMStructsTestAccessor::SymbolLayout layout(RESOLVE_SYMBOL_OFFSETS, RESOLVE_TYPE_SIZES); + VMStructsTestAccessor::LineTableLayout lines(RESOLVE_FLAGS_OFFSET, RESOLVE_CODE_SIZE_OFFSET, + 1, 1, RESOLVE_KLASS_SUPER_OFFSET); + + ResolveFakes* f = new (_region) ResolveFakes{}; + f->link(); + ResolveFakes::setSymbol(f->name_sym, "compute"); + ResolveFakes::setSymbol(f->sig_sym, "()V"); + ResolveFakes::setSymbol(f->klass_sym, "com/example/Foo"); + std::vector table = sampleLineTable(1); + f->setLineNumberTable(11, table.data(), table.size()); // last bci is 12 + + long long before = Counters::getCounter(LINE_NUMBER_TABLE_UNREADABLE); + + ResolvedMethod result; + EXPECT_FALSE(HotspotSupport::resolve(&f->method, 0, result)); + EXPECT_EQ(nullptr, result.lineNumberTable()); + EXPECT_EQ(before + 1, Counters::getCounter(LINE_NUMBER_TABLE_UNREADABLE)); + EXPECT_FALSE(_pt->isProtected()); +} + +// A table running off the readable page faults mid-decode; the landing pad +// must report failure (and free any partially built table). +TEST_F(HotspotResolveCrashProtectionTest, ResolveRecoversFromFaultInLineNumberTable) { + HotspotMethodIdVMHotspotGuard hotspot; + VMStructsTestAccessor offsets(RESOLVE_OFFSETS); + VMStructsTestAccessor::SymbolLayout layout(RESOLVE_SYMBOL_OFFSETS, RESOLVE_TYPE_SIZES); + VMStructsTestAccessor::LineTableLayout lines(RESOLVE_FLAGS_OFFSET, RESOLVE_CODE_SIZE_OFFSET, + 1, 1, RESOLVE_KLASS_SUPER_OFFSET); + + ResolveFakes* f = new (_region) ResolveFakes{}; + f->link(); + ResolveFakes::setSymbol(f->name_sym, "compute"); + ResolveFakes::setSymbol(f->sig_sym, "()V"); + ResolveFakes::setSymbol(f->klass_sym, "com/example/Foo"); + // code_size is a u2, so the table can start up to 64K past the bytecode + // base: point it into the guard page. + f->const_method.flags |= 1; + f->const_method.code_size = (uint16_t)(kPageSize() - 16); + + long long before = Counters::getCounter(METHOD_RESOLVE_FAULT_RECOVERED); + + ResolvedMethod result; + EXPECT_FALSE(HotspotSupport::resolve(&f->method, 0, result)); + EXPECT_EQ(before + 1, Counters::getCounter(METHOD_RESOLVE_FAULT_RECOVERED)); + EXPECT_FALSE(_pt->isProtected()); +} + +TEST_F(HotspotResolveCrashProtectionTest, ResolveDetectsRunOfThreadSubclass) { + HotspotMethodIdVMHotspotGuard hotspot; + VMStructsTestAccessor offsets(RESOLVE_OFFSETS); + VMStructsTestAccessor::SymbolLayout layout(RESOLVE_SYMBOL_OFFSETS, RESOLVE_TYPE_SIZES); + VMStructsTestAccessor::LineTableLayout lines(RESOLVE_FLAGS_OFFSET, RESOLVE_CODE_SIZE_OFFSET, + 1, 1, RESOLVE_KLASS_SUPER_OFFSET); + + ResolveFakes* f = new (_region) ResolveFakes{}; + f->link(); + ResolveFakes::setSymbol(f->name_sym, "run"); + ResolveFakes::setSymbol(f->sig_sym, "()V"); + ResolveFakes::setSymbol(f->klass_sym, "com/example/Worker"); + ResolveFakes::setSymbol(f->super_sym, "java/lang/Thread"); + f->super_klass.name_symbol = &f->super_sym; + f->klass.super_klass = &f->super_klass; + + { + ResolvedMethod result; + ASSERT_TRUE(HotspotSupport::resolve(&f->method, 0, result)); + EXPECT_TRUE(result.isThreadRun()); + } + + // Same method on a class that does not extend Thread. + ResolveFakes::setSymbol(f->super_sym, "java/lang/Object"); + { + ResolvedMethod result; + ASSERT_TRUE(HotspotSupport::resolve(&f->method, 0, result)); + EXPECT_FALSE(result.isThreadRun()); + } + EXPECT_FALSE(_pt->isProtected()); +} + #endif // __linux__ diff --git a/ddprof-lib/src/test/cpp/jvmSupport_ut.cpp b/ddprof-lib/src/test/cpp/jvmSupport_ut.cpp index 83ab2ecb4f..5f956bb90b 100644 --- a/ddprof-lib/src/test/cpp/jvmSupport_ut.cpp +++ b/ddprof-lib/src/test/cpp/jvmSupport_ut.cpp @@ -65,6 +65,7 @@ class JVMSupportTestAccessor { static constexpr LoadState NoLoaded() { return JVMSupport::No_loaded; } static constexpr LoadState PartialLoaded() { return JVMSupport::Partial_loaded; } static constexpr LoadState FullyLoaded() { return JVMSupport::Fully_loaded; } + static constexpr LoadState LazyLoaded() { return JVMSupport::Lazy_loaded; } }; // --------------------------------------------------------------------------- @@ -81,6 +82,9 @@ class ProfilerTestAccessor { static State getState(Profiler* p) { return p->_state.load(std::memory_order_acquire); } + // The stack walker mode as Profiler::start() resolved it from the arguments. + static CStack getCStack(Profiler* p) { return p->_cstack; } + static void setCStack(Profiler* p, CStack c) { p->_cstack = c; } }; // GetCurrentThread failing is exactly what JVMThread::currentThreadSlow() @@ -251,12 +255,16 @@ class JVMSupportRestartTest : public ::testing::Test { JNIEnv_ mock_jni{}; bool _orig_hotspot; JVMSupportTestAccessor::LoadState _orig_load_state; + CStack _orig_cstack; void SetUp() override { _orig_hotspot = VMTestAccessor::getHotspot(); VMTestAccessor::setHotspot(true); _orig_load_state = JVMSupportTestAccessor::getLoadState(); JVMSupportTestAccessor::setLoadState(JVMSupportTestAccessor::NoLoaded()); + // As if Profiler::start() had resolved the arguments to the VM walker. + _orig_cstack = ProfilerTestAccessor::getCStack(Profiler::instance()); + ProfilerTestAccessor::setCStack(Profiler::instance(), CSTACK_VM); jvmti_tbl = jvmtiInterface_1_{}; jvmti_tbl.GetLoadedClasses = &mock_GetLoadedClasses_empty; @@ -273,6 +281,7 @@ class JVMSupportRestartTest : public ::testing::Test { void TearDown() override { VMTestAccessor::setHotspot(_orig_hotspot); JVMSupportTestAccessor::setLoadState(_orig_load_state); + ProfilerTestAccessor::setCStack(Profiler::instance(), _orig_cstack); } }; @@ -293,3 +302,52 @@ TEST_F(JVMSupportRestartTest, SecondStartWithPartialPreloadIsNotBlockedByStaleFu // so the state stayed Fully_loaded instead of downgrading. EXPECT_EQ(JVMSupportTestAccessor::PartialLoaded(), JVMSupportTestAccessor::getLoadState()); } + +// fjmethodid=lazy needs VMStructs::hasKlassMethods(); without the Klass*/methods +// structs (as in this binary, where no JVM populated VMStructs) it must fall +// back to the partial preload mode, never enter Lazy_loaded with nothing to +// resolve frames against. +TEST_F(JVMSupportRestartTest, LazyFallsBackToPartialWithoutKlassStructs) { + Arguments lazy_args; + lazy_args._force_jmethodID = false; + lazy_args._lazy_jmethodID = true; + lazy_args._cstack = CSTACK_VM; + + JVMSupport::initExecution(lazy_args, &mock_jvmti, reinterpret_cast(&mock_jni)); + + EXPECT_NE(JVMSupportTestAccessor::LazyLoaded(), JVMSupportTestAccessor::getLoadState()); + EXPECT_EQ(JVMSupportTestAccessor::PartialLoaded(), JVMSupportTestAccessor::getLoadState()); +} + +// Lazy mode, like partial mode, relies on walkVM's raw Method* frames; with a +// cstack mode that goes through AsyncGetCallTrace it must preload everything. +TEST_F(JVMSupportRestartTest, LazyRequiresVmStackWalker) { + Arguments lazy_args; + lazy_args._force_jmethodID = false; + lazy_args._lazy_jmethodID = true; + lazy_args._cstack = CSTACK_NO; + ProfilerTestAccessor::setCStack(Profiler::instance(), CSTACK_NO); + + JVMSupport::initExecution(lazy_args, &mock_jvmti, reinterpret_cast(&mock_jni)); + + EXPECT_EQ(JVMSupportTestAccessor::FullyLoaded(), JVMSupportTestAccessor::getLoadState()); +} + +// What counts is the stack walker mode Profiler::start() resolved, not the +// argument: cstack=default becomes DWARF where the VM walker is unavailable +// (macOS, or a JVM without the stack vmStructs), and then Java frames come from +// AsyncGetCallTrace, which needs every jmethodID preloaded. +TEST_F(JVMSupportRestartTest, DefaultCstackResolvedToDwarfPreloadsEverything) { + ProfilerTestAccessor::setCStack(Profiler::instance(), CSTACK_DWARF); + for (bool lazy : {true, false}) { + JVMSupportTestAccessor::setLoadState(JVMSupportTestAccessor::NoLoaded()); + Arguments args; + args._force_jmethodID = false; + args._lazy_jmethodID = lazy; + args._cstack = CSTACK_DEFAULT; + + JVMSupport::initExecution(args, &mock_jvmti, reinterpret_cast(&mock_jni)); + + EXPECT_EQ(JVMSupportTestAccessor::FullyLoaded(), JVMSupportTestAccessor::getLoadState()) << lazy; + } +} diff --git a/ddprof-lib/src/test/cpp/klassRegistry_ut.cpp b/ddprof-lib/src/test/cpp/klassRegistry_ut.cpp new file mode 100644 index 0000000000..453a0d414b --- /dev/null +++ b/ddprof-lib/src/test/cpp/klassRegistry_ut.cpp @@ -0,0 +1,389 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#include + +#include "../../main/cpp/hotspot/klassRegistry.h" +#include "../../main/cpp/hotspot/vmStructs.inline.h" + +#include +#include +#include +#include + +// KlassRegistry only talks to the JVM through five JNI functions, so a fake +// JNIEnv models the reference semantics it relies on: a weak global reference +// resolves to its referent until the referent is "collected", after which +// NewLocalRef() returns NULL and IsSameObject(ref, NULL) is true. +namespace { + +struct FakeObject { + int id; +}; + +struct FakeWeak { + FakeObject* referent; + bool deleted; +}; + +struct FakeHeap { + std::set weaks; + int new_weak_calls = 0; + int delete_weak_calls = 0; + int double_deletes = 0; + + ~FakeHeap() { + for (FakeWeak* w : weaks) { + delete w; + } + } + + jobject resolve(jobject ref) { + FakeWeak* w = reinterpret_cast(ref); + if (weaks.count(w) != 0) { + return reinterpret_cast(w->referent); + } + return ref; + } + + void collect(FakeObject* obj) { + for (FakeWeak* w : weaks) { + if (w->referent == obj) { + w->referent = nullptr; + } + } + } + + int liveWeaks() const { + int n = 0; + for (FakeWeak* w : weaks) { + n += w->deleted ? 0 : 1; + } + return n; + } +}; + +FakeHeap* g_heap = nullptr; + +jweak JNICALL fakeNewWeakGlobalRef(JNIEnv*, jobject obj) { + g_heap->new_weak_calls++; + FakeWeak* w = new FakeWeak{reinterpret_cast(g_heap->resolve(obj)), false}; + g_heap->weaks.insert(w); + return reinterpret_cast(w); +} + +void JNICALL fakeDeleteWeakGlobalRef(JNIEnv*, jweak ref) { + g_heap->delete_weak_calls++; + FakeWeak* w = reinterpret_cast(ref); + if (w->deleted) { + g_heap->double_deletes++; + } + w->deleted = true; +} + +jboolean JNICALL fakeIsSameObject(JNIEnv*, jobject a, jobject b) { + return g_heap->resolve(a) == g_heap->resolve(b) ? JNI_TRUE : JNI_FALSE; +} + +jobject JNICALL fakeNewLocalRef(JNIEnv*, jobject ref) { + return g_heap->resolve(ref); +} + +class KlassRegistryTest : public ::testing::Test { +protected: + FakeHeap heap; + JNINativeInterface_ jni_tbl{}; + JNIEnv_ jni_env{}; + JNIEnv* jni = nullptr; + KlassRegistry registry; + std::vector mirrors; + + void SetUp() override { + g_heap = &heap; + jni_tbl.NewWeakGlobalRef = &fakeNewWeakGlobalRef; + jni_tbl.DeleteWeakGlobalRef = &fakeDeleteWeakGlobalRef; + jni_tbl.IsSameObject = &fakeIsSameObject; + jni_tbl.NewLocalRef = &fakeNewLocalRef; + jni_env.functions = &jni_tbl; + jni = &jni_env; + mirrors.resize(4096); + for (size_t i = 0; i < mirrors.size(); i++) { + mirrors[i].id = (int)i; + } + } + + void TearDown() override { + registry.clear(jni); + g_heap = nullptr; + } + + // Klass* values are only used as keys; any distinct, 8-byte aligned + // addresses will do. + static const void* klassAt(size_t i) { + return reinterpret_cast(0x10000 + i * 0x200); + } + + jclass mirror(size_t i) { + return reinterpret_cast(&mirrors[i]); + } +}; + +} // namespace + +TEST_F(KlassRegistryTest, PinUnknownKlassReturnsNull) { + EXPECT_EQ(nullptr, registry.pin(jni, klassAt(0))); + ASSERT_TRUE(registry.add(jni, klassAt(1), mirror(1))); + EXPECT_EQ(nullptr, registry.pin(jni, klassAt(0))); + EXPECT_EQ(nullptr, registry.pin(jni, nullptr)); +} + +TEST_F(KlassRegistryTest, PinReturnsRegisteredMirror) { + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + ASSERT_TRUE(registry.add(jni, klassAt(1), mirror(1))); + EXPECT_EQ(mirror(0), registry.pin(jni, klassAt(0))); + EXPECT_EQ(mirror(1), registry.pin(jni, klassAt(1))); +} + +TEST_F(KlassRegistryTest, PinReturnsNullOnceClassIsCollected) { + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + heap.collect(&mirrors[0]); + EXPECT_EQ(nullptr, registry.pin(jni, klassAt(0))); +} + +TEST_F(KlassRegistryTest, ReAddingSameMirrorIsNoOp) { + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + int calls = heap.new_weak_calls; + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + EXPECT_EQ(calls, heap.new_weak_calls); + EXPECT_EQ(1u, registry.size()); +} + +TEST_F(KlassRegistryTest, ReusedKlassAddressReplacesDeadEntry) { + // A class is unloaded and a new one is loaded at the same Klass* address: + // the new class's ClassPrepare must win, and the old weak ref be freed. + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + heap.collect(&mirrors[0]); + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(1))); + EXPECT_EQ(mirror(1), registry.pin(jni, klassAt(0))); + EXPECT_EQ(1u, registry.size()); + EXPECT_EQ(1, heap.liveWeaks()); +} + +TEST_F(KlassRegistryTest, GrowthPurgesDeadEntries) { + const size_t first = 700; + for (size_t i = 0; i < first; i++) { + ASSERT_TRUE(registry.add(jni, klassAt(i), mirror(i))); + } + // Unload all but the first ten. + for (size_t i = 10; i < first; i++) { + heap.collect(&mirrors[i]); + } + // Enough new classes to cross the 3/4 load factor of the initial table. + const size_t more = 200; + for (size_t i = first; i < first + more; i++) { + ASSERT_TRUE(registry.add(jni, klassAt(i), mirror(i))); + } + + EXPECT_LT(registry.size(), 10 + more + 1); + EXPECT_EQ((int)registry.size(), heap.liveWeaks()); + EXPECT_EQ(0, heap.double_deletes); + for (size_t i = 0; i < 10; i++) { + EXPECT_EQ(mirror(i), registry.pin(jni, klassAt(i))) << i; + } + for (size_t i = 10; i < first; i++) { + EXPECT_EQ(nullptr, registry.pin(jni, klassAt(i))) << i; + } + for (size_t i = first; i < first + more; i++) { + EXPECT_EQ(mirror(i), registry.pin(jni, klassAt(i))) << i; + } +} + +TEST_F(KlassRegistryTest, GrowthKeepsAllLiveEntries) { + const size_t n = 3000; + for (size_t i = 0; i < n; i++) { + ASSERT_TRUE(registry.add(jni, klassAt(i), mirror(i))); + } + EXPECT_EQ(n, registry.size()); + for (size_t i = 0; i < n; i++) { + EXPECT_EQ(mirror(i), registry.pin(jni, klassAt(i))) << i; + } +} + +TEST_F(KlassRegistryTest, ClearDeletesEveryWeakRef) { + for (size_t i = 0; i < 50; i++) { + ASSERT_TRUE(registry.add(jni, klassAt(i), mirror(i))); + } + registry.clear(jni); + EXPECT_EQ(0u, registry.size()); + EXPECT_EQ(0, heap.liveWeaks()); + EXPECT_EQ(0, heap.double_deletes); + EXPECT_EQ(nullptr, registry.pin(jni, klassAt(0))); + // Usable again after clear(). + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + EXPECT_EQ(mirror(0), registry.pin(jni, klassAt(0))); +} + +// Redefinition: raw frames of a class are refused while a redefinition of it +// is in progress, and for good once one succeeded. A failed call leaves the +// class as it was. + +TEST_F(KlassRegistryTest, SuccessfulRedefinitionIsRefusedForGood) { + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + EXPECT_FALSE(registry.isRedefined(klassAt(0))); + ASSERT_TRUE(registry.beginRedefinition(klassAt(0))); + EXPECT_TRUE(registry.isRedefined(klassAt(0))); + registry.endRedefinition(klassAt(0), true); + EXPECT_TRUE(registry.isRedefined(klassAt(0))); + KlassRegistry::PinStatus status; + EXPECT_EQ(nullptr, registry.pin(jni, klassAt(0), &status)); + EXPECT_EQ(KlassRegistry::PIN_REDEFINED, status); + // Re-registering the same mirror does not clear the mark. + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + EXPECT_TRUE(registry.isRedefined(klassAt(0))); +} + +TEST_F(KlassRegistryTest, RedefinitionInProgressIsRefused) { + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + ASSERT_TRUE(registry.beginRedefinition(klassAt(0))); + KlassRegistry::PinStatus status; + EXPECT_EQ(nullptr, registry.pin(jni, klassAt(0), &status)); + EXPECT_EQ(KlassRegistry::PIN_REDEFINED, status); +} + +TEST_F(KlassRegistryTest, FailedRedefinitionLeavesClassAsBefore) { + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + ASSERT_TRUE(registry.beginRedefinition(klassAt(0))); + registry.endRedefinition(klassAt(0), false); + EXPECT_FALSE(registry.isRedefined(klassAt(0))); + EXPECT_EQ(mirror(0), registry.pin(jni, klassAt(0))); +} + +TEST_F(KlassRegistryTest, OverlappingRedefinitionsAreTracked) { + // Two calls on the same class overlap; the class stays refused until both + // have returned, and a failure of one does not undo the other's success. + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + ASSERT_TRUE(registry.beginRedefinition(klassAt(0))); + ASSERT_TRUE(registry.beginRedefinition(klassAt(0))); + registry.endRedefinition(klassAt(0), false); + EXPECT_TRUE(registry.isRedefined(klassAt(0))); + registry.endRedefinition(klassAt(0), false); + EXPECT_FALSE(registry.isRedefined(klassAt(0))); + + ASSERT_TRUE(registry.beginRedefinition(klassAt(0))); + ASSERT_TRUE(registry.beginRedefinition(klassAt(0))); + registry.endRedefinition(klassAt(0), true); + registry.endRedefinition(klassAt(0), false); + EXPECT_TRUE(registry.isRedefined(klassAt(0))); +} + +TEST_F(KlassRegistryTest, RedefinitionOfUnregisteredClassIsIgnored) { + EXPECT_FALSE(registry.beginRedefinition(klassAt(5))); + registry.endRedefinition(klassAt(5), true); + EXPECT_FALSE(registry.isRedefined(klassAt(5))); + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + EXPECT_FALSE(registry.beginRedefinition(klassAt(5))); + // An unmatched end, e.g. for a class registered while the call ran, does + // not underflow the count. + registry.endRedefinition(klassAt(0), false); + EXPECT_FALSE(registry.isRedefined(klassAt(0))); +} + +TEST_F(KlassRegistryTest, NewClassAtRedefinedClassAddressStartsUnmarked) { + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + ASSERT_TRUE(registry.beginRedefinition(klassAt(0))); + registry.endRedefinition(klassAt(0), true); + heap.collect(&mirrors[0]); + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(1))); + EXPECT_FALSE(registry.isRedefined(klassAt(0))); + EXPECT_EQ(mirror(1), registry.pin(jni, klassAt(0))); +} + +TEST_F(KlassRegistryTest, UnloadedClassIsReportedAbsent) { + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + heap.collect(&mirrors[0]); + KlassRegistry::PinStatus status; + EXPECT_EQ(nullptr, registry.pin(jni, klassAt(0), &status)); + EXPECT_EQ(KlassRegistry::PIN_ABSENT, status); +} + +// --------------------------------------------------------------------------- +// VMKlass::containsMethod() is what makes a Method* read from possibly-freed +// metadata trustworthy: it is only compared against the pinned class's +// current methods array, never dereferenced. +// --------------------------------------------------------------------------- + +class VMStructsTestAccessor { +public: + explicit VMStructsTestAccessor(int methods_offset) : _saved(VMStructs::_methods_offset) { + VMStructs::_methods_offset = methods_offset; + } + ~VMStructsTestAccessor() { VMStructs::_methods_offset = _saved; } + +private: + int _saved; +}; + +namespace { + +// Mirrors HotSpot's Array: an int length, then pointer-aligned data. +struct FakeMethodArray { + int length; + const void* data[8]; +}; + +struct FakeKlass { + void* pad; + FakeMethodArray* methods; +}; + +} // namespace + +TEST(VMKlassContainsMethodTest, FindsCurrentMethodsOnly) { + VMStructsTestAccessor layout(offsetof(FakeKlass, methods)); + int m[4]; + FakeMethodArray array = {3, {&m[0], &m[1], &m[2]}}; + FakeKlass klass = {nullptr, &array}; + VMKlass* vk = VMKlass::cast_raw(&klass); + + // Correct hint, wrong hint, and no hint all find a current method. + EXPECT_TRUE(vk->containsMethod(&m[1], 1)); + EXPECT_TRUE(vk->containsMethod(&m[1], 0)); + EXPECT_TRUE(vk->containsMethod(&m[2], -1)); + // An out-of-range hint (stale idnum) must not read past the array. + EXPECT_TRUE(vk->containsMethod(&m[0], 7)); + // A method that is not (or no longer) in the array. + EXPECT_FALSE(vk->containsMethod(&m[3], 3)); + EXPECT_FALSE(vk->containsMethod(&m[3], -1)); +} + +TEST(VMKlassContainsMethodTest, NoMethodsArray) { + VMStructsTestAccessor layout(offsetof(FakeKlass, methods)); + FakeKlass klass = {nullptr, nullptr}; + int m; + EXPECT_FALSE(VMKlass::cast_raw(&klass)->containsMethod(&m, 0)); +} + +// A method that is not at its idnum's slot (e.g. after default-method +// overpasses were merged in at link time) is still found by a scan in small +// classes, but not in classes beyond MAX_METHODS_SCANNED. +TEST(VMKlassContainsMethodTest, ScanIsCappedForLargeClasses) { + VMStructsTestAccessor layout(offsetof(FakeKlass, methods)); + for (int count : {10, VMKlass::MAX_METHODS_SCANNED, VMKlass::MAX_METHODS_SCANNED + 1, 1000}) { + // Array: int length, padding, then `count` pointers. + std::vector array(1 + count, 0); + *reinterpret_cast(array.data()) = count; + std::vector methods(count); + for (int i = 0; i < count; i++) { + array[1 + i] = reinterpret_cast(&methods[i]); + } + FakeKlass klass = {nullptr, reinterpret_cast(array.data())}; + VMKlass* vk = VMKlass::cast_raw(&klass); + const void* m = &methods[count / 2]; + EXPECT_TRUE(vk->containsMethod(m, count / 2)) << count; + bool scanned = count <= VMKlass::MAX_METHODS_SCANNED; + EXPECT_EQ(scanned, vk->containsMethod(m, 0)) << count; + EXPECT_EQ(scanned, vk->containsMethod(m, -1)) << count; + } +} diff --git a/ddprof-lib/src/test/cpp/klassTraceId_ut.cpp b/ddprof-lib/src/test/cpp/klassTraceId_ut.cpp new file mode 100644 index 0000000000..78ffa9eca0 --- /dev/null +++ b/ddprof-lib/src/test/cpp/klassTraceId_ut.cpp @@ -0,0 +1,121 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#include + +#include "../../main/cpp/hotspot/klassTraceId.h" + +#include +#include + +// KlassTraceId::findOffset() against fake Klass layouts. Each fake klass is a +// row of 64-bit words; the decoy fields mimic what a real Klass holds around +// _trace_id, so the tests pin down which of them the calibration must reject. +namespace { + +const int N = KlassTraceId::PROBE_COUNT; +const size_t WORDS = 24; // 192 bytes, about sizeof(Klass) + +struct FakeKlasses { + std::vector> words; + std::vector ptrs; + + FakeKlasses() : words(N, std::vector(WORDS, 0)) { + for (int i = 0; i < N; i++) { + words[i][0] = 0x7f0000a00000ULL; // C++ vtable: the same for every klass + // Pointers to metadata allocated back to back (Symbol*, mirror + // handle, next sibling...): a few hundred bytes apart. + words[i][2] = 0x7f1234560000ULL + i * 0x1c0; + words[i][3] = 0x7f00fffffff0ULL + i * 0x10; // crosses a 64K boundary + // Small integers that are equal across array klasses. + words[i][4] = 0x0000001000000010ULL; + // Compact-header prototype mark word: narrow klass id << 42. + words[i][6] = ((uint64_t)(0x40000 + i * 0x38)) << 42 | 1; + } + for (int i = 0; i < N; i++) { + ptrs.push_back(words[i].data()); + } + } + + // JFR's encoding: (id << 16) | tag bits. + void setTraceIds(size_t word, uint64_t first, uint64_t step = 1, uint16_t tags = 0) { + for (int i = 0; i < N; i++) { + words[i][word] = ((first + i * step) << 16) | tags; + } + } + + int find() const { return KlassTraceId::findOffset(ptrs.data(), N, WORDS * 8); } +}; + +} // namespace + +TEST(KlassTraceIdTest, FindsTraceIdAmongDecoys) { + FakeKlasses k; + k.setTraceIds(9, 31337); + EXPECT_EQ(9 * 8, k.find()); +} + +TEST(KlassTraceIdTest, IgnoresJfrTagBits) { + FakeKlasses k; + k.setTraceIds(9, 31337, 1, 0x0303); + k.words[2][9] |= 0x00f0; // JFR re-tagged one class while running + EXPECT_EQ(9 * 8, k.find()); +} + +TEST(KlassTraceIdTest, ToleratesIdsTakenByOtherThreadsAndAnyOrder) { + FakeKlasses k; + const uint64_t ids[N] = {5000, 5007, 5003, 5040, 5001, 5002}; + for (int i = 0; i < N; i++) { + k.words[i][9] = ids[i] << 16; + } + EXPECT_EQ(9 * 8, k.find()); +} + +TEST(KlassTraceIdTest, RejectsIdsTooFarApart) { + FakeKlasses k; + k.setTraceIds(9, 5000, 1000); + EXPECT_EQ(-1, k.find()); +} + +TEST(KlassTraceIdTest, RejectsDuplicateIds) { + FakeKlasses k; + k.setTraceIds(9, 5000); + k.words[3][9] = k.words[1][9]; + EXPECT_EQ(-1, k.find()); +} + +TEST(KlassTraceIdTest, RejectsZeroIds) { + // Builds without JFR leave nothing there; a zeroed word must not qualify. + FakeKlasses k; + EXPECT_EQ(-1, k.find()); +} + +TEST(KlassTraceIdTest, RefusesAmbiguousLayout) { + FakeKlasses k; + k.setTraceIds(9, 5000); + k.setTraceIds(12, 9000); + EXPECT_EQ(-1, k.find()); +} + +TEST(KlassTraceIdTest, NeverPicksOffsetZero) { + FakeKlasses k; + k.setTraceIds(0, 5000); + EXPECT_EQ(-1, k.find()); + k.setTraceIds(9, 6000); + EXPECT_EQ(9 * 8, k.find()); +} + +TEST(KlassTraceIdTest, StaysWithinKlassSize) { + FakeKlasses k; + k.setTraceIds(WORDS - 1, 5000); + EXPECT_EQ((int)((WORDS - 1) * 8), k.find()); + EXPECT_EQ(-1, KlassTraceId::findOffset(k.ptrs.data(), N, (WORDS - 1) * 8)); +} + +TEST(KlassTraceIdTest, NeedsAtLeastTwoProbes) { + FakeKlasses k; + k.setTraceIds(9, 5000); + EXPECT_EQ(-1, KlassTraceId::findOffset(k.ptrs.data(), 1, WORDS * 8)); +} diff --git a/ddprof-lib/src/test/cpp/redefineHooks_ut.cpp b/ddprof-lib/src/test/cpp/redefineHooks_ut.cpp new file mode 100644 index 0000000000..1504fc6135 --- /dev/null +++ b/ddprof-lib/src/test/cpp/redefineHooks_ut.cpp @@ -0,0 +1,208 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#include + +#include "../../main/cpp/jvmSupport.h" +#include "../../main/cpp/vmEntry.h" + +#include + +// VM::RedefineClassesHook / RetransformClassesHook wrap the JVMTI functions +// of the same name. Whatever the caller passes, the hooks must let the +// original validate it before they touch it: a positive count with a null +// array is JVMTI_ERROR_NULL_POINTER, and an element that is not an instance +// class is rejected by JVMTI too. These tests replace the originals with +// recording stubs and check that the hooks forward such calls untouched, +// without dereferencing anything. + +class VMTestAccessor { +public: + using RedefineFn = jvmtiError(JNICALL*)(jvmtiEnv*, jint, const jvmtiClassDefinition*); + using RetransformFn = jvmtiError(JNICALL*)(jvmtiEnv*, jint, const jclass*); + + static bool getHotspot() { return VM::_hotspot; } + static void setHotspot(bool v) { VM::_hotspot = v; } + static JavaVM* getVm() { return VM::_vm; } + static void setVm(JavaVM* vm) { VM::_vm = vm; } + static RedefineFn getRedefine() { return VM::_orig_RedefineClasses; } + static void setRedefine(RedefineFn f) { VM::_orig_RedefineClasses = f; } + static RetransformFn getRetransform() { return VM::_orig_RetransformClasses; } + static void setRetransform(RetransformFn f) { VM::_orig_RetransformClasses = f; } +}; + +class JVMSupportTestAccessor { +public: + using LoadState = JVMSupport::JMethodIDLoadStats; + static LoadState getLoadState() { return JVMSupport::getLoadState(); } + static void setLoadState(LoadState s) { JVMSupport::setLoadState(s); } + static constexpr LoadState LazyLoaded() { return JVMSupport::Lazy_loaded; } + static constexpr LoadState FullyLoaded() { return JVMSupport::Fully_loaded; } +}; + +namespace { + +struct Calls { + int redefine = 0; + int retransform = 0; + int get_long_field = 0; // VMKlass::fromJavaClass() + int get_class_methods = 0; // jmethodID preload + jint last_count = 0; + const void* last_array = nullptr; + jint class_status = JVMTI_CLASS_STATUS_PREPARED; + jvmtiError class_status_error = JVMTI_ERROR_NONE; +}; +Calls* g_calls = nullptr; + +jvmtiError JNICALL origRedefine(jvmtiEnv*, jint count, const jvmtiClassDefinition* defs) { + g_calls->redefine++; + g_calls->last_count = count; + g_calls->last_array = defs; + return count > 0 && defs == nullptr ? JVMTI_ERROR_NULL_POINTER : JVMTI_ERROR_INVALID_CLASS; +} + +jvmtiError JNICALL origRetransform(jvmtiEnv*, jint count, const jclass* classes) { + g_calls->retransform++; + g_calls->last_count = count; + g_calls->last_array = classes; + return count > 0 && classes == nullptr ? JVMTI_ERROR_NULL_POINTER : JVMTI_ERROR_INVALID_CLASS; +} + +jvmtiError JNICALL getClassStatus(jvmtiEnv*, jclass, jint* status) { + *status = g_calls->class_status; + return g_calls->class_status_error; +} + +jvmtiError JNICALL getClassMethods(jvmtiEnv*, jclass, jint* count, jmethodID** methods) { + g_calls->get_class_methods++; + *count = 0; + *methods = nullptr; + return JVMTI_ERROR_NONE; +} + +jvmtiError JNICALL deallocate(jvmtiEnv*, unsigned char*) { return JVMTI_ERROR_NONE; } + +jlong JNICALL getLongField(JNIEnv*, jobject, jfieldID) { + g_calls->get_long_field++; + return 0; +} + +JNIEnv_* g_env = nullptr; +jint JNICALL getEnv(JavaVM*, void** penv, jint) { + *penv = g_env; + return JNI_OK; +} + +} // namespace + +class RedefineHooksTest : public ::testing::TestWithParam { +protected: + Calls _calls; + jvmtiInterface_1_ _jvmti_tbl{}; + _jvmtiEnv _jvmti{}; + JNINativeInterface_ _jni_tbl{}; + JNIEnv_ _jni{}; + JNIInvokeInterface_ _vm_tbl{}; + JavaVM_ _vm{}; + + bool _saved_hotspot = false; + JavaVM* _saved_vm = nullptr; + VMTestAccessor::RedefineFn _saved_redefine = nullptr; + VMTestAccessor::RetransformFn _saved_retransform = nullptr; + JVMSupportTestAccessor::LoadState _saved_state{}; + + // The parameter: run the hooks in fjmethodid=lazy (true) or the default + // full preload mode (false). Both must forward invalid calls untouched. + static bool lazy() { return GetParam(); } + + void SetUp() override { + g_calls = &_calls; + _jvmti_tbl.GetClassStatus = &getClassStatus; + _jvmti_tbl.GetClassMethods = &getClassMethods; + _jvmti_tbl.Deallocate = &deallocate; + _jvmti.functions = &_jvmti_tbl; + _jni_tbl.GetLongField = &getLongField; + _jni.functions = &_jni_tbl; + g_env = &_jni; + _vm_tbl.GetEnv = &getEnv; + _vm.functions = &_vm_tbl; + + _saved_hotspot = VMTestAccessor::getHotspot(); + _saved_vm = VMTestAccessor::getVm(); + _saved_redefine = VMTestAccessor::getRedefine(); + _saved_retransform = VMTestAccessor::getRetransform(); + _saved_state = JVMSupportTestAccessor::getLoadState(); + VMTestAccessor::setHotspot(true); + VMTestAccessor::setVm(reinterpret_cast(&_vm)); + VMTestAccessor::setRedefine(&origRedefine); + VMTestAccessor::setRetransform(&origRetransform); + JVMSupportTestAccessor::setLoadState(lazy() ? JVMSupportTestAccessor::LazyLoaded() + : JVMSupportTestAccessor::FullyLoaded()); + } + + void TearDown() override { + JVMSupportTestAccessor::setLoadState(_saved_state); + VMTestAccessor::setRetransform(_saved_retransform); + VMTestAccessor::setRedefine(_saved_redefine); + VMTestAccessor::setVm(_saved_vm); + VMTestAccessor::setHotspot(_saved_hotspot); + g_env = nullptr; + g_calls = nullptr; + } +}; + +TEST_P(RedefineHooksTest, RedefineWithNullArrayIsForwardedUntouched) { + EXPECT_EQ(JVMTI_ERROR_NULL_POINTER, VM::RedefineClassesHook(&_jvmti, 2, nullptr)); + EXPECT_EQ(1, _calls.redefine); + EXPECT_EQ(2, _calls.last_count); + EXPECT_EQ(nullptr, _calls.last_array); +} + +TEST_P(RedefineHooksTest, RetransformWithNullArrayIsForwardedUntouched) { + EXPECT_EQ(JVMTI_ERROR_NULL_POINTER, VM::RetransformClassesHook(&_jvmti, 3, nullptr)); + EXPECT_EQ(1, _calls.retransform); + EXPECT_EQ(3, _calls.last_count); + EXPECT_EQ(nullptr, _calls.last_array); +} + +// An element JVMTI does not accept as a class (JVMTI_ERROR_INVALID_CLASS) is +// neither looked up as a Klass* nor preloaded, before or after the call. +TEST_P(RedefineHooksTest, NonClassElementIsNotTouched) { + _calls.class_status_error = JVMTI_ERROR_INVALID_CLASS; + int not_a_class = 0; + jclass classes[] = {reinterpret_cast(¬_a_class)}; + jvmtiClassDefinition defs[] = {{classes[0], 0, nullptr}}; + + EXPECT_EQ(JVMTI_ERROR_INVALID_CLASS, VM::RetransformClassesHook(&_jvmti, 1, classes)); + EXPECT_EQ(JVMTI_ERROR_INVALID_CLASS, VM::RedefineClassesHook(&_jvmti, 1, defs)); + EXPECT_EQ(0, _calls.get_long_field); + EXPECT_EQ(0, _calls.get_class_methods); +} + +// Array classes have no InstanceKlass fields to read; JVMTI rejects them as +// unmodifiable, and the hooks must not inspect them either. +TEST_P(RedefineHooksTest, ArrayClassElementIsNotTouched) { + _calls.class_status = JVMTI_CLASS_STATUS_ARRAY; + int array_class = 0; + jclass classes[] = {reinterpret_cast(&array_class)}; + + VM::RetransformClassesHook(&_jvmti, 1, classes); + EXPECT_EQ(1, _calls.retransform); + EXPECT_EQ(0, _calls.get_long_field); + EXPECT_EQ(0, _calls.get_class_methods); +} + +// A null element inside a valid array is skipped as before. +TEST_P(RedefineHooksTest, NullElementIsSkipped) { + jclass classes[] = {nullptr}; + VM::RetransformClassesHook(&_jvmti, 1, classes); + EXPECT_EQ(1, _calls.retransform); + EXPECT_EQ(0, _calls.get_long_field); +} + +INSTANTIATE_TEST_SUITE_P(Mode, RedefineHooksTest, ::testing::Bool(), + [](const ::testing::TestParamInfo& info) { + return info.param ? "Lazy" : "FullPreload"; + }); diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/ExternalLauncher.java b/ddprof-test/src/test/java/com/datadoghq/profiler/ExternalLauncher.java index 2dbb429668..fef60528e9 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/ExternalLauncher.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/ExternalLauncher.java @@ -9,8 +9,17 @@ import java.lang.management.ManagementFactory; import java.lang.management.ThreadMXBean; import java.lang.reflect.Method; +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.Random; +import java.util.TreeMap; import java.util.concurrent.atomic.LongAdder; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * External launcher for the profiler under test. @@ -26,10 +35,17 @@ *
  • profiler-sequence [';'-delimited steps] - runs a sequence of start/stop calls in this * process; each step is either the literal {@code STOP} (calls {@link JavaProfiler#stop()}) * or a comma delimited profiler command list (calls {@link JavaProfiler#execute(String)})
  • + *
  • lazy-trace-id <comma delimited profiler command list, required> - starts the profiler, spins + * in {@code com.datadoghq.profiler.generated.LazyTraceIdProbe} (defined by its own class loader, so it + * has no jmethodIDs until the profiler resolves its frames) and stops the profiler
  • *
  • entry-frames <comma delimited profiler command list, required> - starts the profiler, then burns * CPU concurrently on the main thread, on a plain {@code new Thread(Runnable)} and on a * two-level {@link Thread} subclass, and stops the profiler again. The resulting recording * holds samples rooted at each of the three thread entry points; see {@code EntryFrameTest}
  • + *
  • line-numbers <';'-delimited profiler command lists, required> - for each command list in + * turn, starts the profiler, runs the same JDK-heavy workload, prints the + * {@code method_resolve_lazy_resolved} counter and stops the profiler; see + * {@code LazyLineNumberConsistencyTest}
  • * */ public class ExternalLauncher { @@ -119,6 +135,41 @@ private static void entryFrameBurn(long millis) { entryFrameSink = acc; } + /** How long the {@code line-numbers} workload runs under each command list. */ + private static final long LINE_NUMBER_WORKLOAD_MILLIS = 1500; + + private static volatile long lineNumberSink; + + /** + * Spends its time in many different JDK methods (regular expressions, formatting, maps, + * sorting, big-integer arithmetic), so that their frames, and the lines of their bytecode + * indices, show up in the recording. + */ + private static void runLineNumberWorkload() { + Pattern pattern = Pattern.compile("([a-z]+)-(\\d+)"); + Random rnd = new Random(42); + long acc = 0; + long deadline = System.currentTimeMillis() + LINE_NUMBER_WORKLOAD_MILLIS; + while (System.currentTimeMillis() < deadline) { + Map counts = new HashMap<>(); + TreeMap sorted = new TreeMap<>(); + List words = new ArrayList<>(); + for (int i = 0; i < 200; i++) { + String word = String.format("%s-%d", Long.toString(rnd.nextInt(1 << 20), 36), i); + Matcher m = pattern.matcher(word); + if (m.matches()) { + counts.merge(m.group(1), Integer.parseInt(m.group(2)), Integer::sum); + } + sorted.put(word.hashCode(), word); + words.add(word.toUpperCase()); + } + Collections.sort(words); + BigInteger big = BigInteger.valueOf(rnd.nextLong()).pow(8).mod(BigInteger.valueOf(1_000_000_007L)); + acc += counts.size() + sorted.firstKey() + words.get(0).length() + big.intValue(); + } + lineNumberSink = acc; + } + public static void main(String[] args) throws Exception { Thread worker = null; try { @@ -158,6 +209,30 @@ public static void main(String[] args) throws Exception { } } } + } else if (args[0].equals("lazy-trace-id")) { + if (args.length < 2 || args[1].isEmpty()) { + throw new IllegalArgumentException("lazy-trace-id requires a profiler command list"); + } + JavaProfiler instance = JavaProfiler.getInstance(); + instance.execute(args[1]); + Class probe = SpinClasses.define("com/datadoghq/profiler/generated/LazyTraceIdProbe"); + probe.getMethod("spin", long.class).invoke(null, 2_000_000_000L); + // Stopping dumps the recording, which resolves the probe's raw frames. + instance.stop(); + } else if (args[0].equals("line-numbers")) { + if (args.length < 2 || args[1].isEmpty()) { + throw new IllegalArgumentException("line-numbers requires profiler command lists"); + } + JavaProfiler instance = JavaProfiler.getInstance(); + for (String commands : args[1].split(";")) { + instance.execute(commands); + runLineNumberWorkload(); + // Counters are reset on start, so read them while this session's are current; + // the dump on stop() resolves the raw frames, so stop first. + instance.stop(); + System.out.println("[line-numbers] method_resolve_lazy_resolved=" + + instance.getDebugCounters().get("method_resolve_lazy_resolved")); + } } else if (args[0].equals("entry-frames")) { // Unlike the modes above, this one is only meaningful with a running profiler: // silently skipping the start would leave the parent process parsing an empty diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/JfrFrame.java b/ddprof-test/src/test/java/com/datadoghq/profiler/JfrFrame.java index 8f346b1a90..73c9b4ace2 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/JfrFrame.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/JfrFrame.java @@ -45,6 +45,21 @@ public String methodDescriptor() { return method != null ? symbolString(method.get("descriptor")) : null; } + /** The frame's line number, or -1 if unavailable. */ + public int lineNumber() { + return intField("lineNumber"); + } + + /** The frame's bytecode index, or -1 if unavailable. */ + public int bytecodeIndex() { + return intField("bytecodeIndex"); + } + + private int intField(String name) { + Object v = value.get(name); + return v instanceof Number ? ((Number) v).intValue() : -1; + } + /** * The frame's {@code jdk.types.FrameType} description (e.g. {@code "Native (remote)"}), or * {@code null} if the frame type has no entry in the constant pool. diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/SpinClasses.java b/ddprof-test/src/test/java/com/datadoghq/profiler/SpinClasses.java new file mode 100644 index 0000000000..38625c7a00 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/SpinClasses.java @@ -0,0 +1,94 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler; + +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.Label; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** + * Defines throwaway classes with {@code public static void spin(long nanos)}, which busy-waits + * so that CPU samples land in it. Each class gets its own class loader, so nothing else gives it + * jmethodIDs and it can be unloaded once the caller drops it. + */ +public final class SpinClasses { + /** + * The line numbers in {@code spin}'s line number table. The first is large enough that + * HotSpot stores it in the escaped, multi-byte form of its compressed table; the others use + * the single-byte form. + */ + public static final Set SPIN_LINES = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(1000, 1001, 1002))); + + private SpinClasses() {} + + /** + * Defines {@code internalName} (slash-separated) in a fresh class loader. + * + * @param internalName the class name in internal form, e.g. {@code a/b/C} + * @return the defined class + */ + public static Class define(String internalName) { + return new Loader().define(internalName); + } + + /** + * Returns the class file {@link #define} defines for {@code internalName}, e.g. to redefine a + * defined class with its own bytes. + * + * @param internalName the class name in internal form, e.g. {@code a/b/C} + * @return the class file bytes + */ + public static byte[] bytes(String internalName) { + return generate(internalName); + } + + private static final class Loader extends ClassLoader { + Class define(String internalName) { + byte[] bytes = generate(internalName); + return defineClass(internalName.replace('/', '.'), bytes, 0, bytes.length); + } + } + + private static byte[] generate(String internalName) { + ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS); + cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, internalName, null, "java/lang/Object", null); + + MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "spin", "(J)V", null, null); + mv.visitCode(); + Label start = new Label(); + mv.visitLabel(start); + mv.visitLineNumber(1000, start); + // long deadline = System.nanoTime() + nanos; + mv.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/System", "nanoTime", "()J", false); + mv.visitVarInsn(Opcodes.LLOAD, 0); + mv.visitInsn(Opcodes.LADD); + mv.visitVarInsn(Opcodes.LSTORE, 2); + // while (System.nanoTime() < deadline) {} + Label loop = new Label(); + Label done = new Label(); + mv.visitLabel(loop); + mv.visitLineNumber(1001, loop); + mv.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/System", "nanoTime", "()J", false); + mv.visitVarInsn(Opcodes.LLOAD, 2); + mv.visitInsn(Opcodes.LCMP); + mv.visitJumpInsn(Opcodes.IFGE, done); + mv.visitJumpInsn(Opcodes.GOTO, loop); + mv.visitLabel(done); + mv.visitLineNumber(1002, done); + mv.visitInsn(Opcodes.RETURN); + mv.visitMaxs(0, 0); + mv.visitEnd(); + + cw.visitEnd(); + return cw.toByteArray(); + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/ClinitResolutionTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/ClinitResolutionTest.java index 10ca084fd7..7fccc6e1bc 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/ClinitResolutionTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/ClinitResolutionTest.java @@ -21,30 +21,26 @@ *

    With {@code cstack=vm,fjmethodid=false}, jmethodID preloading is disabled for all * classes ({@code _force_jmethodID=false} makes {@code shouldPreloadJmethodIDs()} return * false). walkVM stores method frames as raw VMMethod* pointers when no jmethodID has been - * allocated. At dump time, HotspotSupport::resolve() tries JNI GetMethodID / - * GetStaticMethodID to obtain the jmethodID, but JNI intentionally hides class initializers - * from callers. Without the fix both calls return null and the frame is serialised as - * "unknown". With the fix, a JVMTI GetClassMethods fallback forces jmethodID-slot allocation - * for all methods in the class (including {@code }), and re-reading from VM metadata - * returns the correct identifier. + * allocated. At dump time, HotspotSupport::resolve() reads the method's names from VM + * metadata. A lookup through JNI GetMethodID / GetStaticMethodID could not find + * {@code }, since JNI hides class initializers from callers; this test makes sure the + * frame is not serialised as "unknown". */ public class ClinitResolutionTest extends AbstractProfilerTest { // SpinningClinit must NOT be referenced anywhere except inside the test body so that // the class is not loaded (and therefore not initialised) until the profiler is active. - // It is a named static inner class rather than ASM-generated bytes so that FindClass - // can locate it via the system/application class loader, which is the exact code path - // exercised by the fix (system-loader classes skip jmethodID preloading but ARE - // reachable via FindClass at dump time). + // It is a named static inner class rather than ASM-generated bytes so that it is defined by + // the system/application class loader, whose classes skip jmethodID preloading in this + // mode. static class SpinningClinit { // Spin for ~2 s in the static initialiser so the cpu=1ms profiler gets // thousands of sample opportunities inside this . // System.nanoTime() calls prevent the JIT from eliminating the loop body, // and the loop count is high enough that HotSpot's C1/C2 compilation kicks in // — but crucially, HotSpot's JIT does NOT allocate jmethodID slots; only JNI - // or JVMTI callers do that. The jmethodID slot therefore stays null until - // HotspotSupport::resolve() runs at dump time, which is exactly the scenario - // that requires the JVMTI fallback for . + // or JVMTI callers do that. The jmethodID slot therefore stays null, and the + // frames stay raw Method* frames for HotspotSupport::resolve() at dump time. static { long deadline = System.nanoTime() + 2_000_000_000L; while (System.nanoTime() < deadline) { @@ -62,7 +58,7 @@ protected String getProfilerCommand() { // fjmethodid=false disables the _force_jmethodID default so that shouldPreloadJmethodIDs() // returns false, preventing ClassPrepare from allocating jmethodID slots before // runs. Without fjmethodid=false the preload still happens (force_jmethodID=true by - // default) and the JVMTI fallback in HotspotSupport::resolve() is never exercised. + // default) and the raw-frame path in HotspotSupport::resolve() is never exercised. return "cpu=1ms,cstack=vm,fjmethodid=false"; } diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyJmethodIdTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyJmethodIdTest.java new file mode 100644 index 0000000000..5fc3229bba --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyJmethodIdTest.java @@ -0,0 +1,172 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.cpu; + +import com.datadoghq.profiler.AbstractProfilerTest; +import com.datadoghq.profiler.JfrEvent; +import com.datadoghq.profiler.JfrFrame; +import com.datadoghq.profiler.JfrEvents; +import com.datadoghq.profiler.Platform; +import com.datadoghq.profiler.SpinClasses; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junitpioneer.jupiter.RetryingTest; + +import java.io.IOException; +import java.lang.ref.WeakReference; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests {@code fjmethodid=lazy}: no jmethodIDs are preloaded at ClassPrepare. walkVM stores + * raw {@code Method*} frames, and the dump thread resolves them only after + * {@code KlassRegistry} confirms the owning class is still loaded, reading names and line + * numbers from VM metadata without allocating any jmethodIDs. + * + *

    The sampled class is defined by a custom class loader. In the partial mode + * ({@code fjmethodid=false}) such classes are still preloaded, because the dump-time + * {@code FindClass} lookup cannot see them; lazy mode must resolve them without either. + */ +public class LazyJmethodIdTest extends AbstractProfilerTest { + + private static final String LAZY_COMMAND = "cpu=1ms,cstack=vm,fjmethodid=lazy"; + + private static int classCounter = 0; + + @Override + protected String getProfilerCommand() { + return LAZY_COMMAND; + } + + /** + * Frames from a custom-loader class must resolve to their real names and line numbers, read + * from VM metadata at dump time. + */ + @RetryingTest(3) + public void customLoaderFramesResolve() throws Exception { + assumeHotspot(); + waitForProfilerReady(2000); + + String className = nextClassName("LazySpin"); + Class clazz = SpinClasses.define(className); + clazz.getMethod("spin", long.class).invoke(null, 1_000_000_000L); + + stopProfiler(); + + String simpleName = className.substring(className.lastIndexOf('/') + 1); + int spinFrames = 0; + for (JfrEvent sample : verifyEvents("datadog.ExecutionSample")) { + if (!sample.has("stackTrace")) { + continue; + } + for (JfrFrame frame : sample.getStackTrace().frames()) { + String frameClass = frame.className(); + if (frameClass != null && frameClass.endsWith(simpleName) && "spin".equals(frame.methodName())) { + spinFrames++; + assertTrue(SpinClasses.SPIN_LINES.contains(frame.lineNumber()), + "spin frame has line " + frame.lineNumber() + ", expected one of " + SpinClasses.SPIN_LINES); + } + } + } + assertTrue(spinFrames > 0, "No CPU sample contained a resolved " + simpleName + ".spin frame"); + + // Absent in builds without counters; only check when present. + Long resolved = profiler.getDebugCounters().get("method_resolve_lazy_resolved"); + if (resolved != null) { + assertTrue(resolved > 0, "Expected raw frames to be resolved from VM metadata"); + } + } + + /** + * Frames whose class was unloaded between the sample and the dump must not be + * dereferenced: the dump completes and those frames serialize as unknown. + */ + @Test + @Timeout(60) + public void framesOfUnloadedClassAreDroppedSafely() throws Exception { + assumeHotspot(); + stopProfiler(); + + Path dir = Paths.get(System.getProperty("java.io.tmpdir"), "recordings"); + Files.createDirectories(dir); + Path baseFile = Files.createTempFile(dir, "lazy-jmethodid-base-", ".jfr"); + Path dumpFile = Files.createTempFile(dir, "lazy-jmethodid-dump-", ".jfr"); + String className = nextClassName("LazyUnload"); + try { + profiler.execute("start," + LAZY_COMMAND + ",jfr,file=" + baseFile.toAbsolutePath()); + try { + Thread.sleep(200); + WeakReference loaderRef = spinInUnloadableClass(className); + + long deadline = System.currentTimeMillis() + 8_000; + while (loaderRef.get() != null && System.currentTimeMillis() < deadline) { + System.gc(); + Thread.sleep(20); + } + Assumptions.assumeTrue(loaderRef.get() == null, + "JVM did not unload the class within the deadline"); + + profiler.dump(dumpFile); + assertTrue(Files.size(dumpFile) > 0, "Dump after class unload produced no output"); + } finally { + profiler.stop(); + } + + String simpleName = className.substring(className.lastIndexOf('/') + 1); + for (JfrEvent sample : verifyEvents(dumpFile, "datadog.ExecutionSample", false)) { + String stackTrace = sample.getStackTraceString(); + assertTrue(stackTrace == null || !stackTrace.contains(simpleName), + "A frame of the unloaded class was resolved after unload: " + stackTrace); + } + Map counters = profiler.getDebugCounters(); + System.out.println("method_resolve_lazy_unloaded=" + counters.get("method_resolve_lazy_unloaded") + + ", method_resolve_lazy_not_current=" + counters.get("method_resolve_lazy_not_current") + + ", method_resolve_fault_recovered=" + counters.get("method_resolve_fault_recovered")); + } finally { + try { Files.deleteIfExists(baseFile); } catch (IOException ignored) {} + try { Files.deleteIfExists(dumpFile); } catch (IOException ignored) {} + } + } + + /** + * On HotSpot builds with JFR, lazy mode must locate {@code Klass::_trace_id} by probing, + * since vmStructs does not expose it. + */ + @Test + public void traceIdOffsetIsCalibrated() throws Exception { + assumeHotspot(); + waitForProfilerReady(2000); + Long offset = profiler.getDebugCounters().get("klass_trace_id_offset"); + System.out.println("klass_trace_id_offset=" + offset + " java.version=" + System.getProperty("java.version")); + // Absent in builds without counters. + Assumptions.assumeTrue(offset != null, "debug counters not available"); + // JDK 8 has JFR only from 8u272 on; without it there is no field to find. + Assumptions.assumeTrue(Platform.isJavaVersionAtLeast(11), "JFR may be absent on JDK 8"); + assertTrue(offset > 0, "Klass::_trace_id offset was not found: " + offset); + } + + private static void assumeHotspot() { + // Lazy mode is HotSpot-only (raw Method* frames come from walkVM). + Assumptions.assumeFalse(Platform.isZing() || Platform.isJ9() || Platform.isGraal()); + } + + private static synchronized String nextClassName(String prefix) { + return "com/datadoghq/profiler/generated/" + prefix + (classCounter++); + } + + // Kept out of the caller's frame so no strong reference to the class or its + // loader survives on the stack. + private static WeakReference spinInUnloadableClass(String className) throws Exception { + Class clazz = SpinClasses.define(className); + clazz.getMethod("spin", long.class).invoke(null, 300_000_000L); + return new WeakReference<>(clazz.getClassLoader()); + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyLineNumberConsistencyTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyLineNumberConsistencyTest.java new file mode 100644 index 0000000000..b27f894014 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyLineNumberConsistencyTest.java @@ -0,0 +1,117 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.cpu; + +import com.datadoghq.profiler.AbstractProcessProfilerTest; +import com.datadoghq.profiler.JfrEvents; +import com.datadoghq.profiler.JfrFrame; +import com.datadoghq.profiler.Platform; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Checks the line number tables {@code fjmethodid=lazy} decodes from HotSpot's compressed + * {@code ConstMethod} tables against the ones JVMTI {@code GetLineNumberTable} returns. + * + *

    A fresh child JVM, in which nothing has allocated jmethodIDs for most classes yet, runs the + * same JDK-heavy workload twice: first in lazy mode, where frames without a jmethodID are + * resolved from VM metadata, then with {@code fjmethodid=true}, where every frame is resolved + * through JVMTI. Every (method, bytecode index) that both recordings contain must map to the + * same line in both. + */ +public class LazyLineNumberConsistencyTest extends AbstractProcessProfilerTest { + private static final String LAZY = "cpu=1ms,cstack=vm,fjmethodid=lazy"; + private static final String JVMTI = "cpu=1ms,cstack=vm,fjmethodid=true"; + // Enough frames for the comparison to cover many methods and both encodings of the + // compressed table, without depending on how much the scheduler lets the workload run. + private static final int MIN_COMMON_FRAMES = 20; + + @Test + @Timeout(120) + public void lazyLineNumbersMatchJvmti() throws Exception { + Assumptions.assumeFalse(Platform.isZing() || Platform.isJ9() || Platform.isGraal(), "HotSpot only"); + + Path lazyRecording = Files.createTempFile("lazy-lines-vm-", ".jfr"); + Path jvmtiRecording = Files.createTempFile("lazy-lines-jvmti-", ".jfr"); + try { + AtomicLong lazyResolved = new AtomicLong(-1); + LaunchResult result = launch("line-numbers", Collections.emptyList(), + "start," + LAZY + ",jfr,file=" + lazyRecording + ";start," + JVMTI + ",jfr,file=" + jvmtiRecording, + line -> { + String prefix = "[line-numbers] method_resolve_lazy_resolved="; + // The first line is the lazy session's. + if (line.startsWith(prefix) && lazyResolved.get() < 0) { + String value = line.substring(prefix.length()); + lazyResolved.set("null".equals(value) ? 0 : Long.parseLong(value)); + } + return LineConsumerResult.CONTINUE; + }, null); + assertTrue(result.inTime, "child JVM did not finish in time"); + assertEquals(0, result.exitCode, "child JVM failed"); + // Lazy mode falls back to partial mode where it cannot run (e.g. no JFR trace IDs); + // its frames still come from VM metadata, but the counter stays 0. + Assumptions.assumeTrue(lazyResolved.get() > 0, "lazy mode resolved no raw frames"); + + Map lazyLines = linesByFrame(lazyRecording); + Map jvmtiLines = linesByFrame(jvmtiRecording); + List mismatches = new ArrayList<>(); + int common = 0; + for (Map.Entry e : lazyLines.entrySet()) { + Integer jvmtiLine = jvmtiLines.get(e.getKey()); + if (jvmtiLine == null) { + continue; + } + common++; + if (!jvmtiLine.equals(e.getValue())) { + mismatches.add(e.getKey() + ": lazy " + e.getValue() + ", JVMTI " + jvmtiLine); + } + } + System.out.println("raw frames resolved=" + lazyResolved.get() + ", frames in both recordings=" + common + + ", mismatches=" + mismatches.size()); + assertTrue(common >= MIN_COMMON_FRAMES, "only " + common + " frames in both recordings"); + assertTrue(mismatches.isEmpty(), "line numbers differ: " + mismatches); + } finally { + try { Files.deleteIfExists(lazyRecording); } catch (IOException ignored) {} + try { Files.deleteIfExists(jvmtiRecording); } catch (IOException ignored) {} + } + } + + /** + * Maps "class.method descriptor @bci" to the line the recording gives that frame, for every + * frame of {@code datadog.ExecutionSample}. Frames without a line are kept, so that a table + * one side failed to decode shows up as a mismatch. + */ + private static Map linesByFrame(Path recording) throws Exception { + Map lines = new HashMap<>(); + JfrEvents.forEach(recording, "datadog.ExecutionSample"::equals, event -> { + if (!event.has("stackTrace")) { + return; + } + for (JfrFrame frame : event.getStackTrace().frames()) { + if (frame.className() == null || frame.bytecodeIndex() < 0) { + continue; + } + lines.put(frame.className() + "." + frame.methodName() + frame.methodDescriptor() + + " @" + frame.bytecodeIndex(), frame.lineNumber()); + } + }); + return lines; + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyRedefinitionStressTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyRedefinitionStressTest.java new file mode 100644 index 0000000000..29d5580718 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyRedefinitionStressTest.java @@ -0,0 +1,195 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.cpu; + +import com.datadoghq.profiler.AbstractProfilerTest; +import com.datadoghq.profiler.JfrEvent; +import com.datadoghq.profiler.Platform; +import com.datadoghq.profiler.SpinClasses; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.io.IOException; +import java.io.OutputStream; +import java.lang.instrument.ClassDefinition; +import java.lang.instrument.Instrumentation; +import java.lang.management.ManagementFactory; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.jar.Attributes; +import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; + +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Redefines a class over and over in a live JVM while {@code fjmethodid=lazy} samples it and the + * profiler dumps concurrently. + * + *

    Each {@code Instrumentation.redefineClasses()} call with the class's own bytes is a full + * HotSpot redefinition: the class gets a new methods array and new {@code Method*}s, and the old + * ones are freed once they are off every stack, so their memory can be reused by the next + * redefinition of the same class. Raw frames sampled before a redefinition must be refused + * ({@code method_resolve_lazy_redefined}) rather than read through freed metadata or resolved to + * a reused address; frames sampled after it carry the jmethodIDs the redefinition hook preloads + * and resolve normally. + */ +public class LazyRedefinitionStressTest extends AbstractProfilerTest { + private static final String CLASS_NAME = "com/datadoghq/profiler/generated/LazyRedefinitionProbe"; + private static final long DURATION_NANOS = 5_000_000_000L; + + @Override + protected String getProfilerCommand() { + return "cpu=1ms,cstack=vm,fjmethodid=lazy"; + } + + @Test + @Timeout(120) + public void concurrentRedefinitionDoesNotCrashOrMisresolve() throws Exception { + Assumptions.assumeFalse(Platform.isZing() || Platform.isJ9() || Platform.isGraal(), "HotSpot only"); + Instrumentation inst = loadAgent(); + Assumptions.assumeTrue(inst != null && inst.isRedefineClassesSupported(), "class redefinition not available"); + waitForProfilerReady(2000); + + Class probe = SpinClasses.define(CLASS_NAME); + byte[] bytes = SpinClasses.bytes(CLASS_NAME); + Method spin = probe.getMethod("spin", long.class); + AtomicBoolean done = new AtomicBoolean(false); + AtomicReference failure = new AtomicReference<>(); + AtomicInteger redefinitions = new AtomicInteger(); + + Thread spinner = new Thread(() -> { + try { + while (!done.get()) { + spin.invoke(null, 1_000_000L); + } + } catch (Throwable t) { + failure.compareAndSet(null, t); + } + }, "lazy-redefinition-spinner"); + Thread redefiner = new Thread(() -> { + try { + while (!done.get()) { + inst.redefineClasses(new ClassDefinition(probe, bytes)); + redefinitions.incrementAndGet(); + Thread.sleep(2); + } + } catch (Throwable t) { + failure.compareAndSet(null, t); + } + }, "lazy-redefinition-redefiner"); + spinner.start(); + // Let raw frames of the class pile up first, with no dump in between, so + // that the first dumps after redefinitions start have stale raw frames of + // a marked class to refuse. + Thread.sleep(300); + redefiner.start(); + + Path dir = Paths.get(System.getProperty("java.io.tmpdir"), "recordings"); + Files.createDirectories(dir); + Path dumpFile = Files.createTempFile(dir, "lazy-redefinition-", ".jfr"); + int dumps = 0; + try { + long deadline = System.nanoTime() + DURATION_NANOS; + while (System.nanoTime() < deadline) { + // Each dump resolves the raw frames collected since the previous one, + // racing with the redefinitions. + profiler.dump(dumpFile); + dumps++; + Thread.sleep(100); + } + } finally { + done.set(true); + spinner.join(); + redefiner.join(); + } + assertNull(failure.get(), "workload thread failed: " + failure.get()); + + // Samples taken after the last redefinitions carry jmethodIDs and resolve by name. + Thread.sleep(50); + Thread tail = new Thread(() -> { + try { + spin.invoke(null, 500_000_000L); + } catch (Throwable t) { + failure.compareAndSet(null, t); + } + }); + tail.start(); + tail.join(); + stopProfiler(); + + Map counters = profiler.getDebugCounters(); + System.out.println("redefinitions=" + redefinitions.get() + " dumps=" + dumps + + " klass_registry_redefinitions_marked=" + counters.get("klass_registry_redefinitions_marked") + + " method_resolve_lazy_redefined=" + counters.get("method_resolve_lazy_redefined") + + " method_resolve_lazy_not_current=" + counters.get("method_resolve_lazy_not_current") + + " method_resolve_fault_recovered=" + counters.get("method_resolve_fault_recovered")); + assertTrue(redefinitions.get() > 100, "too few redefinitions to stress anything: " + redefinitions.get()); + Long marked = counters.get("klass_registry_redefinitions_marked"); + Long refused = counters.get("method_resolve_lazy_redefined"); + if (marked != null) { + // Absent in builds without counters; when present, both paths must have run. + assertTrue(marked > 0, "the redefinition hooks never marked the class"); + assertTrue(refused != null && refused > 0, "no raw frame of the redefined class was refused"); + } + + String simpleName = CLASS_NAME.substring(CLASS_NAME.lastIndexOf('/') + 1); + boolean resolved = false; + for (JfrEvent sample : verifyEvents("datadog.ExecutionSample")) { + String stackTrace = sample.getStackTraceString(); + if (stackTrace != null && stackTrace.contains(simpleName) && stackTrace.contains("spin")) { + resolved = true; + break; + } + } + assertTrue(resolved, "frames of the redefined class sampled after the redefinitions did not resolve"); + try { Files.deleteIfExists(dumpFile); } catch (IOException ignored) {} + } + + /** + * Loads {@link RetransformAgent} into this JVM through the attach API (by reflection: it lives + * in jdk.attach, or tools.jar on JDK 8, neither of which the test sources compile against) and + * returns its {@link Instrumentation}, or null if self-attach is unavailable. + */ + private static Instrumentation loadAgent() throws Exception { + if (RetransformAgent.instrumentation != null) { + return RetransformAgent.instrumentation; + } + Class vmClass; + try { + vmClass = Class.forName("com.sun.tools.attach.VirtualMachine"); + } catch (ClassNotFoundException e) { + return null; + } + Path jar = Files.createTempFile("lazy-redefinition-agent-", ".jar"); + Manifest manifest = new Manifest(); + Attributes attributes = manifest.getMainAttributes(); + attributes.put(Attributes.Name.MANIFEST_VERSION, "1.0"); + attributes.putValue("Agent-Class", RetransformAgent.class.getName()); + attributes.putValue("Can-Redefine-Classes", "true"); + attributes.putValue("Can-Retransform-Classes", "true"); + try (OutputStream out = Files.newOutputStream(jar); JarOutputStream ignored = new JarOutputStream(out, manifest)) { + // Manifest only: the agent class is loaded from the test classpath. + } + String pid = ManagementFactory.getRuntimeMXBean().getName().split("@")[0]; + Object vm = vmClass.getMethod("attach", String.class).invoke(null, pid); + try { + vmClass.getMethod("loadAgent", String.class).invoke(vm, jar.toString()); + } finally { + vmClass.getMethod("detach").invoke(vm); + Files.deleteIfExists(jar); + } + return RetransformAgent.instrumentation; + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyTraceIdJfrValidationTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyTraceIdJfrValidationTest.java new file mode 100644 index 0000000000..5a6374a687 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyTraceIdJfrValidationTest.java @@ -0,0 +1,144 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.cpu; + +import com.datadoghq.profiler.AbstractProcessProfilerTest; +import com.datadoghq.profiler.Platform; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.io.IOException; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Validates, against JFR itself, the {@code Klass::_trace_id} offset that {@code fjmethodid=lazy} + * finds by calibration. + * + *

    A child JVM runs the profiler in lazy mode and a JFR recording of {@code jdk.ClassDefine} while + * it spins in a probe class. In debug builds the profiler logs the trace ID it reads for the class + * of every raw frame it resolves. JFR emits {@code jdk.ClassDefine} when the probe class is created, + * and the event's {@code definedClass} carries JFR's trace ID for it as {@code RecordedClass.getId()}. + * The two must be equal. {@code jdk.ClassDefine} is used rather than JFR's execution samples + * because it is emitted exactly once per class on every JFR-enabled release (8u262 on), while JFR's + * pre-25 sampler recorded almost no {@code jdk.ExecutionSample} events for the spinning probe. + * + *

    This check runs in CI only. The profiler never calls into JFR at runtime: JFR's + * {@code JVM.getClassId()} touches JFR state that only exists once its recorder has been created, + * which the profiler cannot verify safely. + */ +public class LazyTraceIdJfrValidationTest extends AbstractProcessProfilerTest { + private static final String PROBE_CLASS = "com.datadoghq.profiler.generated.LazyTraceIdProbe"; + private static final Pattern LOGGED_ID = Pattern.compile( + "\\[TEST::INFO\\] Lazy resolution of " + Pattern.quote(PROBE_CLASS.replace('.', '/')) + ", trace id (\\d+)"); + + @Test + @Timeout(120) + public void calibratedTraceIdMatchesJfrClassId() throws Exception { + // The profiler's side of the comparison is a debug-build log line. + Assumptions.assumeTrue("debug".equals(System.getProperty("ddprof_test.config")), "needs a debug build"); + Assumptions.assumeFalse(Platform.isZing() || Platform.isJ9() || Platform.isGraal(), "HotSpot only"); + Assumptions.assumeTrue(jfrConsumerAvailable(), "JFR not available"); + + Path jfrRecording = Files.createTempFile("lazy-trace-id-jfr-", ".jfr"); + Path ddprofRecording = Files.createTempFile("lazy-trace-id-ddprof-", ".jfr"); + Path jfrSettings = Files.createTempFile("lazy-trace-id-", ".jfc"); + Files.write(jfrSettings, Arrays.asList( + "", + "", + " ", + " true", + " false", + " ", + "")); + try { + AtomicLong loggedId = new AtomicLong(-1); + AtomicBoolean traceIdTags = new AtomicBoolean(false); + LaunchResult result = launch("lazy-trace-id", + Arrays.asList("-XX:StartFlightRecording=filename=" + jfrRecording + ",settings=" + jfrSettings + ",dumponexit=true"), + "start,cpu=1ms,cstack=vm,fjmethodid=lazy,jfr,file=" + ddprofRecording, + line -> { + if (line.contains("[TEST::INFO] Raw frame tags: klass trace id")) { + traceIdTags.set(true); + } + Matcher m = LOGGED_ID.matcher(line); + if (m.find()) { + try { + loggedId.set(Long.parseLong(m.group(1))); + } catch (NumberFormatException ignored) { + // Leaves loggedId unset; the assertion below reports it. + } + } + return LineConsumerResult.CONTINUE; + }, null); + assertTrue(result.inTime, "child JVM did not finish in time"); + assertEquals(0, result.exitCode, "child JVM failed"); + // Builds without JFR (e.g. 8u before 8u262) have no trace ID; lazy mode uses the epoch. + Assumptions.assumeTrue(traceIdTags.get(), "calibration found no trace ID"); + assertTrue(loggedId.get() > 0, "the profiler did not log the probe class's trace ID"); + + Set jfrIds = jfrClassIds(jfrRecording, PROBE_CLASS); + System.out.println("profiler trace id=" + loggedId.get() + ", JFR class ids=" + jfrIds); + assertTrue(!jfrIds.isEmpty(), "JFR recorded no jdk.ClassDefine for " + PROBE_CLASS); + assertEquals(new HashSet<>(Arrays.asList(loggedId.get())), jfrIds, + "the calibrated Klass::_trace_id offset does not read JFR's class ID"); + } finally { + try { Files.deleteIfExists(jfrRecording); } catch (IOException ignored) {} + try { Files.deleteIfExists(ddprofRecording); } catch (IOException ignored) {} + try { Files.deleteIfExists(jfrSettings); } catch (IOException ignored) {} + } + } + + private static boolean jfrConsumerAvailable() { + try { + Class.forName("jdk.jfr.consumer.RecordingFile"); + return true; + } catch (ClassNotFoundException e) { + return false; + } + } + + /** + * Returns the IDs JFR's {@code jdk.ClassDefine} events give {@code className}. Uses + * jdk.jfr.consumer through reflection because the test sources compile with + * {@code --release 8}, which hides jdk.jfr. + */ + private static Set jfrClassIds(Path recording, String className) throws Exception { + Class recordingFile = Class.forName("jdk.jfr.consumer.RecordingFile"); + List events = (List) recordingFile.getMethod("readAllEvents", Path.class).invoke(null, recording); + Set ids = new HashSet<>(); + for (Object event : events) { + Object type = call(event, "getEventType"); + if (!"jdk.ClassDefine".equals(call(type, "getName"))) { + continue; + } + Object clazz = event.getClass().getMethod("getClass", String.class).invoke(event, "definedClass"); + if (clazz != null && className.equals(call(clazz, "getName"))) { + ids.add((Long) call(clazz, "getId")); + } + } + return ids; + } + + private static Object call(Object target, String name) throws Exception { + Method m = target.getClass().getMethod(name); + m.setAccessible(true); + return m.invoke(target); + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/RetransformAgent.java b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/RetransformAgent.java new file mode 100644 index 0000000000..00640aba50 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/RetransformAgent.java @@ -0,0 +1,29 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.cpu; + +import java.lang.instrument.Instrumentation; + +/** + * Agent loaded into the test JVM by self-attach (see {@link LazyRedefinitionStressTest}); it only + * hands out the {@link Instrumentation} instance. + */ +public final class RetransformAgent { + /** Set by {@link #agentmain} once the agent has been loaded. */ + public static volatile Instrumentation instrumentation; + + private RetransformAgent() {} + + /** + * Agent entry point for dynamic loading. + * + * @param args ignored + * @param inst the instrumentation instance of this JVM + */ + public static void agentmain(String args, Instrumentation inst) { + instrumentation = inst; + } +} diff --git a/doc/LazyJmethodIdCombinedPlan.md b/doc/LazyJmethodIdCombinedPlan.md new file mode 100644 index 0000000000..0077dd547c --- /dev/null +++ b/doc/LazyJmethodIdCombinedPlan.md @@ -0,0 +1,271 @@ +# Combining #864 and #867: plan + +> **Working notes. Remove before the PR lands.** +> Branch: `investigate/lazy-jmethodid-vm-resolution`, off #864's `investigate/lazy-jmethodid-resolution`. +> Co-authors: Roman Kennke, Zhengyu Gu (#867). Started 2026-10-16. + +Keep #864's safety model (class registry, trace-ID frame tags, redefinition marks) and add #867's +two strong ideas: resolving frames from VM memory, and keeping names of classes that get unloaded. +Leave out #867's one-chunk-per-GC writing. + +## Goal + +- Memory savings for **all** classes, ideally with **no jmethodIDs at all** for raw frames, not even + for sampled classes. +- No extra GC-pause time and no recording-size growth. +- Names even for classes unloaded before the dump (`method_resolve_lazy_unloaded` close to 0). +- No JNI/JVMTI when naming raw frames. + +## What to take from #867, and what to leave + +| Take | Leave out | +|---|---| +| `ResolvedMethod` / `readMethod()`: names, signatures, Thread.run detection from VM memory | One JFR chunk per GC (pause and size cost) | +| Line-number decoding (`VMLineNumberStream`, `compressedLineNumberTable()`, `codeSize()`) | Rotating dictionaries inside the GC callback (likely cause of the mixed-up names) | +| `fillJavaMethodNames()` / `fillJavaMethodInfo(mi, ResolvedMethod)` refactor | Flipping the default without an opt-in (separate rollout decision) | +| `ContextClassLoaderUnloadTest`, plus its gtest additions where they still apply | The code-coverage commits and `.bak` files (their own PR) | +| The "capture names before classes unload" idea, made incremental and file-free | | + +## Phase 1: resolve raw frames from VM memory instead of allocating jmethodIDs + +1. Bring over #867's `ResolvedMethod`, `readMethod()`, `VMLineNumberStream`, the new vmStructs + fields (`_constmethod_code_size`, `_constmethod_flags`, `_klass_super`, the version-dependent + "has line-number table" constant) and `fillJavaMethodNames()`. Zhengyu as author or co-author. +2. `resolveViaRegistry()`: after `pin()`, the trace-ID check and `containsMethod()`, call + `readMethod()` under crash protection while the class is still pinned. Return a `ResolvedMethod` + instead of a jmethodID. Drop the dump-time `GetClassMethods`. +3. Key raw frames in Lookup's method table by (untagged `Method*`, trace-ID tag), so resolved rows + can be reused across dumps without being mistaken for a later class at the same address. The + per-dump cache (`_raw_method_cache`) becomes unnecessary or holds the row. Open: whether rows + reused across dumps need a redefinition check through the registry mark (probably yes). +4. Partial mode (`fjmethodid=false`, and lazy's fallback without JFR): replace the `FindClass` + lookup with `readMethod()`. It keeps today's redefinition gap for system-loader classes; note it, + or cover it with the registry marks. +5. Bonus: lazy mode no longer needs dereferenceable jmethodIDs, so it could run on JDK 26+. Check + that nothing else in its path depends on `_can_dereference_jmethod_id`. +6. Validation: sweep the OpenJDK GA tags for what `readMethod()` and the line decoding rely on (the + compressed line-table format and its UNSIGNED5 change in JDK 20, the "has line-number table" + flag that moved between 20 and 21, `Klass::_super`); compare decoded line tables with JVMTI + `GetLineNumberTable` on real JVMs in a CI test; rerun NMT (expected: jmethodID memory ≈ 0 even + for sampled classes). + +### Phase 1 status (2026-10-16) + +- Code: commit "Resolve raw frames from VM metadata instead of allocating jmethodIDs". +- Steps 1, 2, 4 done; step 3 settled: refused frames never touch MethodMap rows, rows are keyed by + (untagged `Method*`, low 13 tag bits, `RAW_METHOD_MARK`) and refilled at first use in every chunk, + so no redefinition re-check is needed for reused rows. Step 5 (JDK 26+) open. +- Line-table bound is HotSpot's own (`bci <= code_size`, `ConstMethod::verify_on()`), not #867's `<`. +- Sweep: `_flags`/`_code_size`/`Klass::_super` and the has-line-number-table constant checked on + JDK 11-26 GA tags; 8u via containers. +- `LazyLineNumberConsistencyTest` (VM vs JVMTI line tables): 0 mismatches on macOS JDK 25 and in + every aarch64 container cell (glibc 17/21/25 debug, 21 ASan; musl 8/11/21 debug), 249-389 common + frames each. All `*Lazy*` tests pass in those cells; JFR trace ID matches in every debug cell. + glibc 8/11 are not runnable on aarch64 (skipped as in CI). gtests: debug + release pass on macOS, + debug in every container cell. +- NMT (JDK 25, debug, 1M methods, KB committed): sampled run with ~9.3k raw frames resolved keeps + Class at the no-profiler level (+14,234 vs +14,236; preloading +22,982). Compared against the + shipping modes and no profiler only; #864 is superseded. Dump +~10 ms vs `fjmethodid=true`. Harness: + `/tmp/jmbench/Bench2.java`. + +## Phase 2: keep names of classes that unload before the dump, cheaply + +1. Capture only methods whose frames are still unresolved **and** whose class can be unloaded. + Record "unloadable" in the registry entry at registration, from the class loader (boot, + platform and application loaders never unload). +2. Know what's pending without scanning every trace during a pause. Prototype and measure both: + (a) a bounded lock-free "first seen" set the sampler fills and the hook drains; (b) walking only + the traces created since the last capture (trace IDs grow monotonically within a storage + instance). +3. In `GarbageCollectionStart` (VM stopped), copy names and line tables with `readMethod()` into a + pre-resolved cache keyed by (`Method*`, tag). No JNI, no JVMTI, no file writes, no dictionary + rotation. Incremental: only methods first seen since the last GC. +4. Skip when nothing unloadable is pending; also skip young-only pauses if they can be told apart + (open question: GC cause through VM state?). +5. Do the same capture in the pre-redefinition hook for that class's pending frames, so frames + refused after a redefinition keep their names too. +6. At dump time, when `pin()` reports the class gone, use the pre-resolved name instead of + "unknown"; drop cache entries once written. +7. Validation: the GC-pause benchmark from the #867 review must show safepoint times unchanged + compared with `fjmethodid=true`, recording size unchanged, and `ContextClassLoaderUnloadTest` + must find names for frames of unloaded classes. + +## Tests + +- Everything in #864. +- #867's class-unload test. +- A JVMTI-against-VM line-table comparison test. +- The GC-pause and recording-size benchmark as a nightly or slow test, with thresholds. +- `LazyRedefinitionStressTest` extended to check that frames refused after a redefinition still + have names (phase 2, step 5). + +## Order and PR structure + +1. **One PR for #864 + phase 1** (this branch), superseding #864 and #867, co-authored with + Zhengyu. Phase 1 rewrites #864's dump path (no dump-time jmethodID allocation), so #864 does not + land on its own. The PR description says what changed since #864's two review rounds. Needs + Zhengyu's agreement first, via the (unposted) #867 review. +2. **Phase 2 as a separate follow-up PR**: names for unloaded classes. It is additive (without it + those frames stay "unknown", as in #864), the riskiest part (GC-callback work, where #867's + pause cost came from), and conditional on the GC-pause benchmark. Zhengyu might want to lead it. +3. Default flip after a nightly soak, watching the counters. + +## Risks and open questions + +- More parsing of HotSpot internals for names and line tables; the phase 1 sweep is meant to + contain that. +- Pending-frame tracking in phase 2 must keep signal-handler work and memory bounded. +- Whether young pauses can be told apart without GC-specific internals. +- Whether partial mode gets the registry's redefinition marks, or stays as today. + +## Hand-over to the Linux box (x64) + +State at hand-over (2026-10-16): phase 1 complete on this branch, validated on macOS arm64 and in +aarch64 containers (see "Phase 1 status"). Draft PR open against `main`; it carries #864's commits +and will replace #864 (and #867's core), pending agreement with Zhengyu. The #867 review is still +unposted. + +To do on x64, in this order: + +1. **Tests.** `./utils/run-containers-tests.sh --arch=x64 --tests='*.Lazy*' --gtest` for glibc + 8/11/17/21/25 debug, glibc 21 ASan, musl 21. On musl the test runner takes a regex: + `--tests='com.datadoghq.profiler.cpu.Lazy.*'`. Watch `LazyLineNumberConsistencyTest`'s + `mismatches=` line and `LazyTraceIdJfrValidationTest`'s `profiler trace id=` line. +2. **JDK 26+ (phase 1 step 5).** No container image has 26. Lazy mode is gated only on + `VMStructs::hasKlassMethods()` and trace-ID calibration, not on `_can_dereference_jmethod_id` + (false on > 25), so it may already run; check that walkVM's jmethodID lookup and the redefinition + preload behave with JDK-8268406's `JmethodIDTable`. Either make it work or state it is out of + scope in the PR. +3. **Renaissance, same setup as #864's tables** (release build, `cpu=10ms,cstack=vm`, `-Xms=-Xmx`, + 5 fresh JVMs per mode, modes interleaved, first dump after 10 s; `dotty`, `finagle-http`, `als`; + JDK 11/21/25), `fjmethodid=true` vs `lazy`: + - NMT Class + Internal over no profiler. Expected: lazy close to its own registry cost, since + sampled classes no longer get jmethodIDs (#864 saved only 35-55% here). + - Dump latency, first and later dumps. #864's first lazy dump was 2-4x `true` (dump-time + jmethodID allocation, now gone). New risk: later dumps resolve raw frames every time instead + of matching `true`. On macOS (debug, 1,600 generated classes) lazy was 18.9 / ~16 ms vs `true` + 15.2 / ~13.4 ms (first / later). If `finagle-http` later dumps are clearly slower, skip + `readMethod()` for rows filled in an earlier chunk while the class pins with the same trace ID + and is not redefined. + - Counter renamed: `method_resolve_lazy_class_loaded` -> `method_resolve_lazy_resolved` (raw + frames resolved per dump); use it to confirm lazy mode is active. +4. **Synthetic NMT**, optional: the harness below, both variants, on x64. + +Then: review pass over the phase 1 code (VM reader, line tables, MethodMap raw keys, partial mode), +rewrite the PR description from #864's (step 3 of "How it works", overhead table, counters, new +tests, changes since #864's reviews), remove this file, and ask Zhengyu before un-drafting. + +

    NMT harness (Bench2.java, was /tmp/jmbench on the Mac) + +Needs ASM 9 and the profiler's Java classes on the classpath. Run with +`-XX:NativeMemoryTracking=summary`: +`java -XX:NativeMemoryTracking=summary -cp asm.jar::. Bench2 20000 `. +Results on macOS: `load` lazy Class +14,233 KB / Internal +191 KB vs `true` +22,981 / +8,136; +`sample` lazy +14,234 / +471 vs `true` +22,982 / +8,409, with ~9.3k raw frames resolved. + +```java +import com.datadoghq.profiler.JavaProfiler; +import org.objectweb.asm.*; +import java.lang.management.ManagementFactory; +import javax.management.ObjectName; +import java.nio.file.*; +import java.util.*; + +// Bench2 +// load: like Bench (load-only, no profiled calls into the generated classes). +// sample: additionally calls every generated class round-robin for SAMPLE_MS under the profiler, +// then dumps (which is when lazy resolution happens) before measuring NMT. +public class Bench2 { + static int METHODS = 50; + static long SAMPLE_MS = 8000; + static String nmt(String arg) throws Exception { + return (String) ManagementFactory.getPlatformMBeanServer().invoke( + new ObjectName("com.sun.management:type=DiagnosticCommand"), "vmNativeMemory", + new Object[]{new String[]{arg}}, new String[]{String[].class.getName()}); + } + public static void main(String[] a) throws Exception { + int n = Integer.parseInt(a[0]); + String mode = a[1]; + boolean sample = a[2].equals("sample"); + JavaProfiler prof = null; + if (!mode.equals("none")) { + prof = JavaProfiler.getInstance(a[3], null); + prof.execute("start,cpu=1ms,cstack=vm,fjmethodid=" + mode + ",jfr,file=/tmp/jmbench/b2-" + mode + ".jfr"); + } + load(2000, "W"); System.gc(); Thread.sleep(200); + nmt("baseline"); + long t0 = System.nanoTime(); + List keep = load(n, "B"); + System.out.println("load_ms=" + (System.nanoTime() - t0) / 1_000_000); + if (sample) { + long end = System.currentTimeMillis() + SAMPLE_MS; + while (System.currentTimeMillis() < end) { + for (Runnable r : keep) r.run(); + } + if (prof != null) { + long d0 = System.nanoTime(); + prof.dump(Paths.get("/tmp/jmbench/b2-" + mode + "-dump.jfr")); + System.out.println("dump_ms=" + (System.nanoTime() - d0) / 1_000_000); + Map c = prof.getDebugCounters(); + System.out.println("resolved=" + c.get("method_resolve_lazy_resolved")); + } + } + java.util.regex.Pattern p = java.util.regex.Pattern.compile("^-\\s+(.+?) \\(reserved=.*?committed=\\d+KB(?: ([+-]\\d+)KB)?"); + for (String line : nmt("summary.diff").split("\n")) { + java.util.regex.Matcher m = p.matcher(line); + if (m.find()) { + System.out.println("CAT " + m.group(1).trim() + "=" + (m.group(2) == null ? "0" : m.group(2))); + } else if (line.trim().startsWith("Total:")) { + java.util.regex.Matcher t = java.util.regex.Pattern.compile("committed=\\d+KB ([+-]\\d+)KB").matcher(line); + if (t.find()) System.out.println("CAT Total=" + t.group(1)); + } + } + System.out.println(keep.size()); + } + static List load(int n, String p) throws Exception { + List out = new ArrayList<>(); + L loader = null; + for (int i = 0; i < n; i++) { + if (i % 100 == 0) loader = new L(); + String name = "gen/" + p + i; + Class c = loader.def(name.replace('/', '.'), gen(name)); + out.add((Runnable) c.getConstructor().newInstance()); + } + return out; + } + static byte[] gen(String name) { + ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS); + cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, name, null, "java/lang/Object", new String[]{"java/lang/Runnable"}); + MethodVisitor ctor = cw.visitMethod(Opcodes.ACC_PUBLIC, "", "()V", null, null); + ctor.visitCode(); ctor.visitVarInsn(Opcodes.ALOAD, 0); + ctor.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "", "()V", false); + ctor.visitInsn(Opcodes.RETURN); ctor.visitMaxs(0, 0); ctor.visitEnd(); + for (int i = 0; i < METHODS; i++) { + MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "m" + i, "()I", null, null); + mv.visitCode(); mv.visitLdcInsn(i); mv.visitInsn(Opcodes.IRETURN); mv.visitMaxs(0, 0); mv.visitEnd(); + } + // run(): sum of all m_i, so every method of the class can appear in samples. + MethodVisitor run = cw.visitMethod(Opcodes.ACC_PUBLIC, "run", "()V", null, null); + run.visitCode(); run.visitInsn(Opcodes.ICONST_0); + for (int i = 0; i < METHODS; i++) { + run.visitMethodInsn(Opcodes.INVOKESTATIC, name, "m" + i, "()I", false); + run.visitInsn(Opcodes.IADD); + } + run.visitInsn(Opcodes.POP); run.visitInsn(Opcodes.RETURN); run.visitMaxs(0, 0); run.visitEnd(); + cw.visitEnd(); + return cw.toByteArray(); + } + static final class L extends ClassLoader { + Class def(String n, byte[] b) { return defineClass(n, b, 0, b.length); } + } +} +``` + +
    + +## Measurements to compare against (from the #867 review) + +macOS arm64, JDK 25, G1, ~650–950 young GCs/s, 15 s: safepoint time per young GC 0.34 ms (no +profiler), 0.35 ms (`fjmethodid=true`), 0.36 ms (#864), 0.84 ms (#867). NMT, 1M methods in +custom-loader classes: preloading ~15.8 MiB; #864 ~1 MiB. Harness: `/tmp/gcbench` and +`/tmp/jmbench` on the Mac (not in the repo). diff --git a/doc/LazyJmethodIdPhase2Handover.md b/doc/LazyJmethodIdPhase2Handover.md new file mode 100644 index 0000000000..56b913289e --- /dev/null +++ b/doc/LazyJmethodIdPhase2Handover.md @@ -0,0 +1,224 @@ +# Phase 2 prototype: hand-over + +> **Prototype branch, not for merging.** Branch `proto/phase2-pending-set`, off #868's +> `investigate/lazy-jmethodid-vm-resolution`. Plan: `doc/LazyJmethodIdCombinedPlan.md` (phase 2). +> Written 2026-10-16 on macOS arm64 (JDK 25.0.1 Corretto, release build); continued the same day on +> Linux arm64 (Ubuntu 24.04, 16 cores, JDK 21.0.12 Microsoft build, g++ 13 release build). + +## Goal and acceptance criterion + +Give names to lazy raw frames whose class is unloaded before the dump. The user's criterion: +**worth doing only if GC pause times don't increase significantly.** + +## What the prototype does (`ddprof-lib/src/main/cpp/hotspot/unloadCapture.{h,cpp}`) + +Enabled with `DDPROF_CAPTURE_UNLOADING` in `fjmethodid=lazy` mode; off otherwise, so one build +measures every variant: `1` = GC hook only, `2` = background drain + GC hook, `3` = background +drain only (the recommended variant, see the Linux results). `DDPROF_CAPTURE_INTERVAL_MS` sets the +drain interval (default 100). + +- **Sampler** (`HotspotSupport::fillJavaFrame` -> `UnloadCapture::onRawFrame`): a raw frame whose + holder's `ClassLoaderData` is not the boot, platform or app loader's goes into a lock-free set of + (`Method*`, tag) pairs: 16,384 slots, 16 linear probes. Each slot has a state word: FREE -> + PENDING (published by the sampler) -> DRAINING (claimed by CAS, so two drainers never take the + same pair) -> DONE (captured; the pair stays in the set so the sampler does not add it again) or + back to FREE (refused). `endDump()` clears DONE slots, so a method is captured at most about + once per dump interval. When all 16 probes are taken, the sampler evicts a DONE slot (DONE is + only a dedup hint); only then is overflow counted. Permanent CLDs are learned in + `registerClass()` (GetClassLoader + `isSystemClassLoader`). +- **Background drain** (modes 2, 3): a daemon thread attached as "java-profiler UnloadCapture" + wakes every interval and, if anything is pending, resolves each claimed pair through + `HotspotSupport::capturePinned()` = `readMethodHolder` under crash protection, then + `resolveViaRegistry()` (registry `pin()` + trace-ID check + `containsMethod` + `readMethod` + + redefinition mark), the same path as the dump but without touching the + `method_resolve_lazy_*` counters. `cache_lock` is held only around cache lookups and inserts, + never across the JNI pin. Started once, never stopped. +- **`GarbageCollectionStart`** (modes 1, 2; `vmEntry.cpp` forwards to it and to + ReferenceChainTracker): one atomic load if nothing is pending. Otherwise, under `tryLock` of the + cache: claims pending pairs and, for each not cached yet, `HotspotSupport::captureMethod()` = + `readMethodHolder` + trace-ID check + `containsMethod` + `readMethod`, all under crash + protection, no JNI/JVMTI/locks. Uses `initCurrentThreadSignalSafe()` (the callback runs on the + VM thread; `acquireCurrent()` returned null on macOS, pool exhausted). +- **Dump** (`Lookup::fillRawMethod`): when `resolve()` refuses a frame, `UnloadCapture::take()` + supplies the captured name. `endDump()` (after `writeMethods`) frees entries captured before the + previous dump and clears DONE slots. +- Counters `unload_capture_*`: `pending_added`, `pending_evicted`, `pending_overflow`, + `hook_calls`, `hook_busy`, `hook_drained`, `hook_ns`, `hook_max_ns`, `captured`, `refused`, + `bg_passes`, `bg_ns` (wall), `bg_cpu_ns` (thread CPU), `bg_max_ns` (wall), `bg_captured`, + `bg_refused`, `cache_full`, `used`. New: `method_resolve_lazy_unreadable` counts lazy frames + whose `Method*` could not even be read at dump time (freed metadata); before it, most losses + were not counted at all. +- Known prototype gaps: the GC-hook path has no redefinition check beyond `containsMethod`; the + drain scans all 16,384 slots per pass (~256 KB, cheap at 10 Hz but not at every GC pause); each + entry is 3-4 mallocs (names + line table); the cache is a `std::unordered_map` under a spinlock; + the drain thread is never stopped or joined (it must be before dlclose / profiler restart). + +## Results on macOS (release, first prototype: GC hook only) + +Harnesses in `utils/phase2-proto/` (they lived in /tmp on the Mac). The "lost" numbers in this +section undercount: they predate `method_resolve_lazy_unreadable`, see the Linux section. + +**1. Is there a problem to solve?** `Bench3`: worker threads churn class loaders (10 classes per +loader, 50 ms per loader, ~800 classes dying/s), JVM unloads on its own schedule, periodic dumps. + +| G1, 50 ms generations | Lost names (lazy counters: unloaded + stale + not_current) | +|---|---| +| 5 s dumps | <= 0.3% | +| 60 s dumps (dd-trace-java default) | 28% (G1), 43% (`-XX:MetaspaceSize=16m`) | +| 1 s generations (~40 classes/s) | 0: no class unloaded in 120 s | + +- Most refusals are `stale`, not `unloaded`: freed metaspace is reused at once by a same-shaped + class at the same `Klass*`, and the trace-ID check refuses the old frame (correct). +- `fjmethodid=true` loses these names too (15,241 `` methods in the same 60 s run; + counted differently, not directly comparable). So phase 2 improves on today's default, it does + not fix a lazy-mode regression. +- ZGC runs unloaded no classes at all; not explained yet. + +**2. Recovery with the prototype** (same 60 s / 50 ms G1 run): `used=14811` frames named from the +capture, `captured=29174`, `pending_overflow=2`, `cache_full=0`. That is more than the lazy refusal +counters (32 unloaded + 6,815 stale), so some failures are not counted by them (probably +`readMethodHolder` failing on freed memory, or faults); the real loss was higher than the table +above. + +**3. GC pause cost.** Hook time in the 60 s churn run: 30 calls, 22.6 ms total, **max 2.77 ms in +one pause** (~0.6 us per captured method). Few GCs there (21 young + 12 concurrent in 120 s), so +thousands of methods pile up between GCs and are captured in one burst. + +`GcBench2` (4 allocating threads, `-Xmn32m`, ~800 young GCs/s, plus 2 churn threads), +`-Xlog:safepoint`, 3 interleaved runs of 15 s, `analyze_sp.py` ("At safepoint" of G1 operations): + +| Mode | GC safepoints/run | mean ms | p50 | p99 | stopped s/run | +|---|---:|---:|---:|---:|---:| +| no profiler | 11,956 | 0.371 | 0.364 | 0.463 | 4.76 | +| lazy | 11,689 | 0.402 | 0.396 | 0.498 | 5.02 | +| lazy + capture | 11,673 | 0.405 | 0.400 | 0.500 | 5.05 | + +With frequent GCs the hook adds ~nothing (few methods per pause). **The problem is the burst with +rare GCs**: up to ~2.8 ms in a single pause. + +## Results on Linux arm64 (release; JDK 21 unless noted) + +**Conclusion: use the background drain only (mode 3) and drop the GC hook.** It recovers the +names (0% lost in every churn run) and leaves GC pauses where plain lazy mode has them. The GC +hook costs every pause something whenever the pending set is non-empty, which it nearly always +is under class churn, and its worst burst was 8.7 ms in one pause. + +**1. The loss is much larger than measured on macOS.** With `method_resolve_lazy_unreadable` added, +`Bench3` (G1, 60 s dumps, 50 ms generations, 120 s) loses **62%** of raw frames in plain lazy mode +(2 runs: resolved ~17.7k; unloaded 45-81, stale 5.4-7.6k, not_current 3-5, **unreadable 21-24k**). +With 10 ms generations: 87% lost. Most failures are `unreadable`: the `Method*` is already freed +when the dump reads it. Single-generation ZGC (`-XX:-ZGenerational`): 87% lost. + +**2. Recovery** (`lost` = refused by `resolve()` and not named from the capture either): + +| Bench3 run (G1, 60 s dumps, 120 s) | lost | hook max (in a pause) | drain CPU | +|---|---:|---:|---:| +| lazy, 50 ms generations | 62% | - | - | +| mode 1 (GC hook), 50 ms | 0.0% | **8.5-8.7 ms** | - | +| mode 2 (drain + hook), 50 ms | 0.0% | 0.1 ms | - | +| mode 3 (drain only), 50 ms | 0.0% | - | 0.31 s | +| mode 3, 10 ms generations | 0.0% | - | 0.32 s | +| mode 3, single-gen ZGC, 50 ms | 0.3% | - | - | + +The mode 1 burst: 41 hook calls drained 47.8k pairs, most of them in a few pauses (G1 ran only ~21 +young + ~19 concurrent cycles in 120 s). With the drain at 100 ms the hook finds ~1k pairs in total +and is not needed: mode 3 recovers as well as mode 2. Drain CPU is ~7 us per captured method in +Bench3 (~0.26% of one core); its wall time per pass is much higher than its CPU time (`bg_max_ns` +up to 37-130 ms) because the thread blocks in JNI during safepoints and on the registry lock. That +costs the application nothing, but widens the window in which a class can unload before capture. + +The table is the final variant (DONE marks + eviction). Two intermediate variants, for the +record: without DONE marks each drained pair was re-added on its next sample (`pending_added` 305k +in dotty, 1.1M in finagle-http, for 14k / 9k methods); with DONE marks but no eviction the set +filled up within a dump interval in Bench3 (~47k distinct methods/min) and loss rose to 1.3-17%. + +**3. GC pauses** (`GcBench2`, `gcbench.sh`, 3 interleaved runs of 15 s per mode, "At safepoint" of +G1 operations, final variant): + +| `-Xmn512m` (~65 GCs/s) | GC sp/run | mean ms | p50 | p99 | stopped s/run | +|---|---:|---:|---:|---:|---:| +| no profiler | 995 | 0.836 | 0.820 | 1.136 | 0.918 | +| lazy | 976 | 0.856 | 0.845 | 1.114 | 0.913 | +| mode 1 (GC hook) | 965 | 1.031 | 1.011 | 1.289 | 1.074 | +| mode 2 (drain + hook) | 966 | 1.039 | 1.022 | 1.288 | 1.079 | +| **mode 3 (drain only)** | 978 | **0.861** | 0.846 | 1.122 | 0.921 | + +| `-Xmn32m` (~650 GCs/s) | GC sp/run | mean ms | p50 | p99 | stopped s/run | +|---|---:|---:|---:|---:|---:| +| no profiler | 10,009 | 0.531 | 0.522 | 0.661 | 5.984 | +| lazy | 9,833 | 0.540 | 0.531 | 0.687 | 6.002 | +| mode 1 (GC hook) | 9,495 | 0.594 | 0.556 | 0.817 | 6.262 | +| mode 2 (drain + hook) | 9,535 | 0.595 | 0.556 | 0.823 | 6.252 | +| **mode 3 (drain only)** | 9,876 | **0.544** | 0.534 | 0.701 | 5.971 | + +Max values are single outliers in every mode (3-12 ms, no pattern) and are left out. Mode 3 vs +lazy: +4-5 us mean, +8-14 us p99, inside run-to-run noise; time to safepoint is unchanged (mean +62 vs 62 us at 512m, 48 vs 58 us at 32m). Every mode with the hook adds ~55-175 us to the mean +pause: whenever the set is non-empty the hook scans all slots, and with the 16k set (needed for +DONE marks) that is the dominant cost. Before the set grew (8,192 slots, no DONE marks) the hook +modes were +15-35 us mean, +0.1 ms p99; the same direction, but smaller. + +**4. ZGC.** Generational ZGC (`-XX:+ZGenerational` on 21, the only ZGC mode on JDK 25) unloads +**no** classes in this workload, with or without the profiler (`Churn.java`: 37k +classes loaded in 40 s, 0 unloaded, on JDK 21 and 25.0.4; metaspace grows to ~128 MB; major +collections triggered by "Metadata GC Threshold" and "CodeCache GC Threshold" run "Concurrent +Process Non-Strong" but unload nothing). Not explained; it is JVM behaviour, not ours. Under it +there is nothing to recover: Bench3 lazy shows 0% lost. Single-generation ZGC on 21 unloads like +G1 (87% lost in lazy mode, 0.3% with mode 3). + +**5. Real workloads (Renaissance 0.16 `dotty`, `finagle-http`, G1, `cpu=10ms,wall=50ms`, 60 s dumps, +150 s each, `RenWrap.java`).** Plain lazy mode loses **nothing** (dotty: 20,085 resolved, 14 +not_current, 0 unloaded/stale/unreadable; finagle-http: 12,663 / 8). Renaissance runs each +benchmark in its own class loader, so all benchmark code counts as unloadable, as application code +would in a Spring Boot fat jar or a servlet container. In such apps the capture is pure overhead: +mode 3 captured 14.2k (dotty) / 9.3k (finagle-http) methods, used none, at 0.28 s / 0.19 s drain +CPU per run (~0.2% / ~0.1% of one core), plus ~5k cache entries with 3-4 mallocs each per dump +interval. + +## Next steps (in order) + +1. **Decide whether the default needs phase 2 at all.** Real workloads lost nothing; the loss is + specific to rapid class-loader churn (generated classes, scripting engines, short-lived + plugin loaders). The cost when it is not needed is small but not zero: ~0.1-0.3% of a core and + one capture per sampled method per dump interval in apps whose code lives in a custom loader. +2. If kept: **mode 3 only**. Remove the GC hook, the GarbageCollectionStart forwarding and + `captureMethod()`. Then: + - Allocate entries from a per-generation arena (chunked bump allocator, freed whole in + `endDump()`) instead of 3-4 mallocs per entry, to keep the malloc arena out of it. + - Replace `std::unordered_map` + spinlock with something bounded (an open-addressing table + sized like the pending set). + - Stop and join the drain thread on profiler stop (as `ReferenceChainTracker::stopThread()`). + - Consider a cheaper "unloadable" filter, so apps with a long-lived custom loader pay less, e.g. + only capture loaders that are not `isSystemClassLoader` *and* were created after profiling + started, or count capture usefulness (`used / captured`) and back off when it stays 0. +3. Turn `method_resolve_lazy_unreadable` into a real counter in the main lazy PR regardless: + without it the loss is invisible. +4. Validation tests per the combined plan: `ContextClassLoaderUnloadTest` with names for frames of + unloaded classes, and the pause benchmark as a slow test. +5. Open: why generational ZGC never unloads these loaders. + +**Tests run** (Linux arm64, debug, g++): `testDebug -Ptests=*Lazy*` passes (6/6) with capture off. +With `DDPROF_CAPTURE_UNLOADING=3`, 5/6 pass (including `LazyRedefinitionStressTest`); +`LazyJmethodIdTest.framesOfUnloadedClassAreDroppedSafely` fails as expected: it asserts that a +frame of an unloaded class is *not* named after unload, and the capture names it. If phase 2 +lands, that assertion becomes "named from the capture". + +## How to run the harnesses + +``` +ASM=; CLS=ddprof-lib/build/classes/java/main; LIB=ddprof-lib/build/lib/main/release/linux//libjavaProfiler.so +cd utils/phase2-proto && javac -cp "$ASM:$CLS" -d /tmp/p2 *.java +# Linux: build with -Pnative.forceCompiler=g++ if clang++ cannot find the libstdc++ headers +# Unload / recovery: [threads]; -Dmode=true|lazy +DDPROF_CAPTURE_UNLOADING=1 java -cp "$ASM:$CLS:/tmp/p2" Bench3 $LIB 60 50 120 +# GC pauses: modes none / lazy (DDPROF_CAPTURE_UNLOADING=0) / capture (=1), 15 s each, interleaved +DDPROF_CAPTURE_UNLOADING=1 java -XX:+UseG1GC -Xmx512m -Xmn32m -Xlog:safepoint:file=R/capture-1-sp.log \ + -agentpath:$LIB=start,cpu=1ms,cstack=vm,fjmethodid=lazy,jfr,file=R/capture-1.jfr -cp "$ASM:/tmp/p2" GcBench2 15 +python3 analyze_sp.py R none lazy capture +# Linux drivers (same harnesses; set ASM, CLS first; modes 0-3 as DDPROF_CAPTURE_UNLOADING): +utils/phase2-proto/bench3.sh $LIB out/m3 3 60 50 120 [-XX:+UseZGC ...] +utils/phase2-proto/gcbench.sh $LIB out/gc512 3 15 512m # modes none lazy gc bggc bg +# Renaissance (renaissance-mit-0.16.0.jar on the class path): +DDPROF_CAPTURE_UNLOADING=3 java -cp "$CLS:/tmp/p2:renaissance-mit-0.16.0.jar" RenWrap $LIB 60 -t 150 dotty +# NMT (phase 1): java -XX:NativeMemoryTracking=summary -cp "$ASM:$CLS:/tmp/p2" Bench2 20000 $LIB +``` diff --git a/utils/phase2-proto/Bench2.java b/utils/phase2-proto/Bench2.java new file mode 100644 index 0000000000..1b58255538 --- /dev/null +++ b/utils/phase2-proto/Bench2.java @@ -0,0 +1,95 @@ +import com.datadoghq.profiler.JavaProfiler; +import org.objectweb.asm.*; +import java.lang.management.ManagementFactory; +import javax.management.ObjectName; +import java.nio.file.*; +import java.util.*; + +// Bench2 +// load: like Bench (load-only, no profiled calls into the generated classes). +// sample: additionally calls every generated class round-robin for SAMPLE_MS under the profiler, +// then dumps (which is when lazy resolution happens) before measuring NMT. +public class Bench2 { + static int METHODS = 50; + static long SAMPLE_MS = 8000; + static String nmt(String arg) throws Exception { + return (String) ManagementFactory.getPlatformMBeanServer().invoke( + new ObjectName("com.sun.management:type=DiagnosticCommand"), "vmNativeMemory", + new Object[]{new String[]{arg}}, new String[]{String[].class.getName()}); + } + public static void main(String[] a) throws Exception { + int n = Integer.parseInt(a[0]); + String mode = a[1]; + boolean sample = a[2].equals("sample"); + JavaProfiler prof = null; + if (!mode.equals("none")) { + prof = JavaProfiler.getInstance(a[3], null); + prof.execute("start,cpu=1ms,cstack=vm,fjmethodid=" + mode + ",jfr,file=/tmp/jmbench/b2-" + mode + ".jfr"); + } + load(2000, "W"); System.gc(); Thread.sleep(200); + nmt("baseline"); + long t0 = System.nanoTime(); + List keep = load(n, "B"); + System.out.println("load_ms=" + (System.nanoTime() - t0) / 1_000_000); + if (sample) { + long end = System.currentTimeMillis() + SAMPLE_MS; + while (System.currentTimeMillis() < end) { + for (Runnable r : keep) r.run(); + } + if (prof != null) { + long d0 = System.nanoTime(); + prof.dump(Paths.get("/tmp/jmbench/b2-" + mode + "-dump.jfr")); + System.out.println("dump_ms=" + (System.nanoTime() - d0) / 1_000_000); + Map c = prof.getDebugCounters(); + System.out.println("resolved=" + c.get("method_resolve_lazy_resolved")); + } + } + java.util.regex.Pattern p = java.util.regex.Pattern.compile("^-\\s+(.+?) \\(reserved=.*?committed=\\d+KB(?: ([+-]\\d+)KB)?"); + for (String line : nmt("summary.diff").split("\n")) { + java.util.regex.Matcher m = p.matcher(line); + if (m.find()) { + System.out.println("CAT " + m.group(1).trim() + "=" + (m.group(2) == null ? "0" : m.group(2))); + } else if (line.trim().startsWith("Total:")) { + java.util.regex.Matcher t = java.util.regex.Pattern.compile("committed=\\d+KB ([+-]\\d+)KB").matcher(line); + if (t.find()) System.out.println("CAT Total=" + t.group(1)); + } + } + System.out.println(keep.size()); + } + static List load(int n, String p) throws Exception { + List out = new ArrayList<>(); + L loader = null; + for (int i = 0; i < n; i++) { + if (i % 100 == 0) loader = new L(); + String name = "gen/" + p + i; + Class c = loader.def(name.replace('/', '.'), gen(name)); + out.add((Runnable) c.getConstructor().newInstance()); + } + return out; + } + static byte[] gen(String name) { + ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS); + cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, name, null, "java/lang/Object", new String[]{"java/lang/Runnable"}); + MethodVisitor ctor = cw.visitMethod(Opcodes.ACC_PUBLIC, "", "()V", null, null); + ctor.visitCode(); ctor.visitVarInsn(Opcodes.ALOAD, 0); + ctor.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "", "()V", false); + ctor.visitInsn(Opcodes.RETURN); ctor.visitMaxs(0, 0); ctor.visitEnd(); + for (int i = 0; i < METHODS; i++) { + MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "m" + i, "()I", null, null); + mv.visitCode(); mv.visitLdcInsn(i); mv.visitInsn(Opcodes.IRETURN); mv.visitMaxs(0, 0); mv.visitEnd(); + } + // run(): sum of all m_i, so every method of the class can appear in samples. + MethodVisitor run = cw.visitMethod(Opcodes.ACC_PUBLIC, "run", "()V", null, null); + run.visitCode(); run.visitInsn(Opcodes.ICONST_0); + for (int i = 0; i < METHODS; i++) { + run.visitMethodInsn(Opcodes.INVOKESTATIC, name, "m" + i, "()I", false); + run.visitInsn(Opcodes.IADD); + } + run.visitInsn(Opcodes.POP); run.visitInsn(Opcodes.RETURN); run.visitMaxs(0, 0); run.visitEnd(); + cw.visitEnd(); + return cw.toByteArray(); + } + static final class L extends ClassLoader { + Class def(String n, byte[] b) { return defineClass(n, b, 0, b.length); } + } +} diff --git a/utils/phase2-proto/Bench3.java b/utils/phase2-proto/Bench3.java new file mode 100644 index 0000000000..a0f0110d2c --- /dev/null +++ b/utils/phase2-proto/Bench3.java @@ -0,0 +1,111 @@ +import com.datadoghq.profiler.JavaProfiler; +import org.objectweb.asm.*; +import java.lang.management.*; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.atomic.*; + +// Bench3 [threads] +// Worst case for phase 2: worker threads keep creating a class loader with CLASSES classes, spin +// in each class for generation_ms / CLASSES, then drop the loader. The JVM unloads them on its own +// schedule (no System.gc()). The main thread dumps every dump_interval_s. Reports how many raw +// frames resolved vs. came back unloaded (class gone before the dump). +public class Bench3 { + static final int CLASSES = 10; + static volatile long sink; + public static void main(String[] a) throws Exception { + String lib = a[0]; + long dumpMs = Long.parseLong(a[1]) * 1000; + long genMs = Long.parseLong(a[2]); + long runMs = Long.parseLong(a[3]) * 1000; + int threads = a.length > 4 ? Integer.parseInt(a[4]) : 4; + JavaProfiler prof = JavaProfiler.getInstance(lib, null); + prof.execute("start,cpu=10ms,cstack=vm,fjmethodid=" + System.getProperty("mode", "lazy") + ",jfr,file=/tmp/jmbench/b3.jfr"); + AtomicBoolean stop = new AtomicBoolean(); + AtomicLong generations = new AtomicLong(); + AtomicLong counter = new AtomicLong(); + List workers = new ArrayList<>(); + for (int t = 0; t < threads; t++) { + Thread w = new Thread(() -> { + long garbage = 0; + while (!stop.get()) { + try { + L loader = new L(); + for (int c = 0; c < CLASSES; c++) { + String name = "gen/C" + counter.incrementAndGet(); + Class k = loader.def(name.replace('/', '.'), gen(name)); + k.getMethod("spin", long.class).invoke(null, genMs * 1_000_000L / CLASSES); + } + // Some allocation, so the heap moves as in a real application. + for (int i = 0; i < 2000; i++) garbage += new byte[256].length; + generations.incrementAndGet(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + sink = garbage; + }); + w.start(); + workers.add(w); + } + long start = System.currentTimeMillis(); + int dumps = 0; + long maxDumpMs = 0; + while (System.currentTimeMillis() - start < runMs) { + Thread.sleep(dumpMs); + long d0 = System.nanoTime(); + prof.dump(Paths.get("/tmp/jmbench/b3-dump.jfr")); + maxDumpMs = Math.max(maxDumpMs, (System.nanoTime() - d0) / 1_000_000); + dumps++; + } + stop.set(true); + for (Thread w : workers) w.join(); + Map c = prof.getDebugCounters(); + long resolved = c.getOrDefault("method_resolve_lazy_resolved", 0L); + long unloaded = c.getOrDefault("method_resolve_lazy_unloaded", 0L); + long stale = c.getOrDefault("method_resolve_lazy_stale", 0L); + long notCurrent = c.getOrDefault("method_resolve_lazy_not_current", 0L); + long unreadable = c.getOrDefault("method_resolve_lazy_unreadable", 0L); + long used = c.getOrDefault("unload_capture_used", 0L); + long skipped = c.getOrDefault("jmethodid_skipped_count", 0L); + long gcs = 0; + StringBuilder gcInfo = new StringBuilder(); + for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) { + gcInfo.append(gc.getName().replace(' ', '_')).append('=').append(gc.getCollectionCount()).append(' '); + } + System.out.printf("RESULT dump_s=%d gen_ms=%d dumps=%d generations=%d classes_loaded=%d unloaded_classes=%d " + + "skipped=" + skipped + " resolved=%d unloaded=%d stale=%d not_current=%d unreadable=%d used=%d failed_pct=%.1f lost_pct=%.1f max_dump_ms=%d %s%n", + dumpMs / 1000, genMs, dumps, generations.get(), counter.get(), + ManagementFactory.getClassLoadingMXBean().getUnloadedClassCount(), + resolved, unloaded, stale, notCurrent, unreadable, used, + // failed: frames resolve() refused; lost: those the capture did not name either. + 100.0 * (unloaded + stale + notCurrent + unreadable) / Math.max(1, resolved + unloaded + stale + notCurrent + unreadable), + 100.0 * Math.max(0, unloaded + stale + notCurrent + unreadable - used) / Math.max(1, resolved + unloaded + stale + notCurrent + unreadable), + maxDumpMs, gcInfo); + StringBuilder uc = new StringBuilder(); + for (Map.Entry e : new TreeMap<>(c).entrySet()) { + if (e.getKey().startsWith("unload_capture_")) uc.append(e.getKey().substring(15)).append('=').append(e.getValue()).append(' '); + } + System.out.println("CAPTURE " + uc); + prof.stop(); + } + static byte[] gen(String name) { + ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS); + cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, name, null, "java/lang/Object", null); + MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "spin", "(J)V", null, null); + mv.visitCode(); + mv.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/System", "nanoTime", "()J", false); + mv.visitVarInsn(Opcodes.LLOAD, 0); mv.visitInsn(Opcodes.LADD); mv.visitVarInsn(Opcodes.LSTORE, 2); + Label loop = new Label(), done = new Label(); + mv.visitLabel(loop); + mv.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/System", "nanoTime", "()J", false); + mv.visitVarInsn(Opcodes.LLOAD, 2); mv.visitInsn(Opcodes.LCMP); + mv.visitJumpInsn(Opcodes.IFGE, done); mv.visitJumpInsn(Opcodes.GOTO, loop); + mv.visitLabel(done); mv.visitInsn(Opcodes.RETURN); mv.visitMaxs(0, 0); mv.visitEnd(); + cw.visitEnd(); + return cw.toByteArray(); + } + static final class L extends ClassLoader { + Class def(String n, byte[] b) { return defineClass(n, b, 0, b.length); } + } +} diff --git a/utils/phase2-proto/Churn.java b/utils/phase2-proto/Churn.java new file mode 100644 index 0000000000..91ef56979e --- /dev/null +++ b/utils/phase2-proto/Churn.java @@ -0,0 +1,17 @@ +import org.objectweb.asm.*; +import java.lang.management.*; +// Churn : Bench3's loader churn without the profiler; prints unloaded class count. +public class Churn { + public static void main(String[] a) throws Exception { + long end = System.currentTimeMillis() + Long.parseLong(a[0]) * 1000; + long n = 0; + while (System.currentTimeMillis() < end) { + Bench3.L loader = new Bench3.L(); + for (int c = 0; c < 10; c++) { + String name = "gen/X" + (n++); + loader.def(name.replace('/', '.'), Bench3.gen(name)).getMethod("spin", long.class).invoke(null, 1_000_000L); + } + } + System.out.println("loaded=" + n + " unloaded=" + ManagementFactory.getClassLoadingMXBean().getUnloadedClassCount()); + } +} diff --git a/utils/phase2-proto/GcBench2.java b/utils/phase2-proto/GcBench2.java new file mode 100644 index 0000000000..2bb002a857 --- /dev/null +++ b/utils/phase2-proto/GcBench2.java @@ -0,0 +1,60 @@ +import org.objectweb.asm.*; +import java.util.*; +import java.util.concurrent.atomic.*; + +public class GcBench2 { + static volatile Object sink; + public static void main(String[] a) throws Exception { + long end = System.nanoTime() + Long.parseLong(a[0]) * 1_000_000_000L; + List ts = new ArrayList<>(); + for (int t = 0; t < 4; t++) { + Thread th = new Thread(() -> { while (System.nanoTime() < end) level1(); }); + ts.add(th); th.start(); + } + // Two churn threads: a fresh loader with 10 classes every ~50 ms each, each class sampled + // while it spins, then dropped, so the profiler's pending set is rarely empty. + AtomicLong n = new AtomicLong(); + for (int c = 0; c < 2; c++) { + Thread churn = new Thread(() -> { + try { + while (System.nanoTime() < end) { + L loader = new L(); + for (int k = 0; k < 10; k++) { + String name = "gen/Churn" + n.incrementAndGet(); + Class cl = loader.def(name.replace('/', '.'), gen(name)); + cl.getMethod("spin", long.class).invoke(null, 5_000_000L); + } + } + } catch (Exception e) { throw new RuntimeException(e); } + }); + churn.start(); ts.add(churn); + } + for (Thread th : ts) th.join(); + } + static void level1() { level2(); } + static void level2() { level3(); } + static void level3() { + Object[] ring = new Object[256]; + for (int i = 0; i < 100_000; i++) ring[i & 255] = new byte[512]; + sink = ring; + } + static byte[] gen(String name) { + ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS); + cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, name, null, "java/lang/Object", null); + MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "spin", "(J)V", null, null); + mv.visitCode(); + mv.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/System", "nanoTime", "()J", false); + mv.visitVarInsn(Opcodes.LLOAD, 0); mv.visitInsn(Opcodes.LADD); mv.visitVarInsn(Opcodes.LSTORE, 2); + Label loop = new Label(), done = new Label(); + mv.visitLabel(loop); + mv.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/System", "nanoTime", "()J", false); + mv.visitVarInsn(Opcodes.LLOAD, 2); mv.visitInsn(Opcodes.LCMP); mv.visitJumpInsn(Opcodes.IFGE, done); + mv.visitJumpInsn(Opcodes.GOTO, loop); + mv.visitLabel(done); mv.visitInsn(Opcodes.RETURN); mv.visitMaxs(0, 0); mv.visitEnd(); + cw.visitEnd(); + return cw.toByteArray(); + } + static final class L extends ClassLoader { + Class def(String n, byte[] b) { return defineClass(n, b, 0, b.length); } + } +} diff --git a/utils/phase2-proto/RenWrap.java b/utils/phase2-proto/RenWrap.java new file mode 100644 index 0000000000..390366dfbb --- /dev/null +++ b/utils/phase2-proto/RenWrap.java @@ -0,0 +1,46 @@ +import com.datadoghq.profiler.JavaProfiler; +import java.nio.file.*; +import java.util.*; + +// RenWrap , with the Renaissance jar on the class path. +// Starts the profiler in fjmethodid=lazy mode (DDPROF_CAPTURE_UNLOADING picks the capture variant), +// dumps every dump_interval_s like dd-trace-java, and prints the lazy-resolution and capture +// counters when the JVM exits (Renaissance calls System.exit()). +public class RenWrap { + public static void main(String[] a) throws Exception { + JavaProfiler prof = JavaProfiler.getInstance(a[0], null); + long dumpMs = Long.parseLong(a[1]) * 1000; + prof.execute("start,cpu=10ms,wall=50ms,cstack=vm,fjmethodid=" + System.getProperty("mode", "lazy") + + ",jfr,file=/tmp/jmbench/ren.jfr"); + Thread dumper = new Thread(() -> { + try { + while (true) { + Thread.sleep(dumpMs); + prof.dump(Paths.get("/tmp/jmbench/ren-dump.jfr")); + } + } catch (Exception e) { + // exit + } + }); + dumper.setDaemon(true); + dumper.start(); + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + try { + prof.dump(Paths.get("/tmp/jmbench/ren-dump.jfr")); + } catch (Exception e) { + // report what we have + } + StringBuilder sb = new StringBuilder("COUNTERS "); + for (Map.Entry e : new TreeMap<>(prof.getDebugCounters()).entrySet()) { + String k = e.getKey(); + if (k.startsWith("unload_capture_") || k.startsWith("method_resolve_lazy_")) { + sb.append(k.replace("unload_capture_", "uc_").replace("method_resolve_lazy_", "lazy_")) + .append('=').append(e.getValue()).append(' '); + } + } + System.out.println(sb); + })); + String[] rest = Arrays.copyOfRange(a, 2, a.length); + Class.forName("org.renaissance.core.Launcher").getMethod("main", String[].class).invoke(null, (Object) rest); + } +} diff --git a/utils/phase2-proto/analyze_sp.py b/utils/phase2-proto/analyze_sp.py new file mode 100644 index 0000000000..5f819acda5 --- /dev/null +++ b/utils/phase2-proto/analyze_sp.py @@ -0,0 +1,21 @@ +# Usage: python3 analyze_sp.py ... +# Summarizes -Xlog:safepoint logs named --sp.log: GC safepoints (G1 operations), their +# count, mean / p50 / p99 / max of "At safepoint" time (the pause itself, where the GC-start hook +# runs) and total stopped time per run. +import glob, re, statistics, sys +d, modes = sys.argv[1], sys.argv[2:] +pat = re.compile(r'Safepoint "([^"]+)".*?At safepoint: (\d+) ns.*?Total: (\d+) ns') +print(f"{'mode':10} {'runs':>4} {'GC sp/run':>9} {'mean ms':>8} {'p50':>7} {'p99':>7} {'max':>7} {'stopped s/run':>13}") +for m in modes: + at, runs, stopped = [], 0, [] + for f in sorted(glob.glob(f"{d}/{m}-*-sp.log")): + runs += 1 + rows = pat.findall(open(f).read()) + gc = [int(a) / 1e6 for op, a, t in rows if op.startswith('G1') or 'Collect' in op] + at += gc + stopped.append(sum(int(t) for op, a, t in rows) / 1e9) + if not at: + print(m, "no data"); continue + at.sort() + q = lambda p: at[min(len(at) - 1, int(p * len(at)))] + print(f"{m:10} {runs:>4} {len(at)//runs:>9} {statistics.mean(at):>8.3f} {q(.5):>7.3f} {q(.99):>7.3f} {at[-1]:>7.3f} {statistics.mean(stopped):>13.3f}") diff --git a/utils/phase2-proto/bench3.sh b/utils/phase2-proto/bench3.sh new file mode 100755 index 0000000000..397eee6a39 --- /dev/null +++ b/utils/phase2-proto/bench3.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# bench3.sh [extra jvm opts]; G1 unless opts pick a GC +: "${ASM:?set ASM to an asm-9.x jar}"; : "${CLS:?set CLS to ddprof-lib/build/classes/java/main}" +lib=$1; out=$2; cap=$3; d=$4; g=$5; r=$6; shift 6 +gc=-XX:+UseG1GC; case "$*" in *Use*GC*) gc=;; esac +DDPROF_CAPTURE_UNLOADING=$cap java $gc "$@" -cp "$ASM:$CLS:/tmp/p2" Bench3 $lib $d $g $r > $out.txt 2>&1 diff --git a/utils/phase2-proto/gcbench.sh b/utils/phase2-proto/gcbench.sh new file mode 100755 index 0000000000..451127c383 --- /dev/null +++ b/utils/phase2-proto/gcbench.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# gcbench.sh ; modes none lazy gc bggc bg, interleaved +: "${ASM:?set ASM to an asm-9.x jar}"; R=$(cd "$(dirname "$0")/../.." && pwd) +lib=$1; out=$2; runs=$3; secs=$4; xmn=$5 +mkdir -p $out +for r in $(seq 1 $runs); do + for m in none lazy gc bggc bg; do + case $m in none) cap=0;; lazy) cap=0;; gc) cap=1;; bggc) cap=2;; bg) cap=3;; esac + agent="" + [ $m != none ] && agent="-agentpath:$lib=start,cpu=1ms,cstack=vm,fjmethodid=lazy,jfr,file=$out/$m-$r.jfr" + DDPROF_CAPTURE_UNLOADING=$cap java -XX:+UseG1GC -Xmx1g -Xmn$xmn -Xlog:safepoint:file=$out/$m-$r-sp.log $agent \ + -cp "$ASM:/tmp/p2" GcBench2 $secs > $out/$m-$r.out 2>&1 + done +done +python3 $R/utils/phase2-proto/analyze_sp.py $out none lazy gc bggc bg