From 8ef576b6abe2246181c9b730916af41993f11e54 Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Thu, 15 Oct 2026 11:00:27 +0200 Subject: [PATCH 01/17] Add fjmethodid=lazy: resolve raw Method* frames via a class liveness registry With fjmethodid=lazy (HotSpot, cstack=vm), no jmethodIDs are preloaded at ClassPrepare. Instead every class is recorded in KlassRegistry, a Klass* to weak-mirror table. walkVM keeps storing raw Method* frames for classes without jmethodIDs; at dump time resolve() reads only the holder Klass* and idnum from the Method*, pins the registered mirror (null if the class was unloaded), checks that the Method* is among the pinned class's current methods, and only then allocates jmethodIDs for that one class. Falls back to the partial mode when the Klass*/methods vmStructs are missing. Adds method_resolve_lazy_{unloaded,not_current,class_loaded} counters, gtests for the registry, containsMethod, argument parsing and the load-state selection, and a Java test covering custom-loader resolution and frames of an unloaded class. Klass* address reuse after class unloading is not handled yet; see doc/LazyJmethodIdResolution.md (investigation notes, to be removed before landing). Co-Authored-By: Claude Opus 5.5 --- ddprof-lib/src/main/cpp/arguments.cpp | 5 + ddprof-lib/src/main/cpp/arguments.h | 4 +- ddprof-lib/src/main/cpp/counters.h | 8 + .../src/main/cpp/hotspot/hotspotSupport.cpp | 100 ++++++- .../src/main/cpp/hotspot/hotspotSupport.h | 20 ++ .../src/main/cpp/hotspot/klassRegistry.cpp | 139 +++++++++ .../src/main/cpp/hotspot/klassRegistry.h | 81 +++++ ddprof-lib/src/main/cpp/hotspot/vmStructs.h | 35 +++ .../src/main/cpp/hotspot/vmStructs.inline.h | 5 + ddprof-lib/src/main/cpp/jvmSupport.cpp | 17 +- ddprof-lib/src/main/cpp/jvmSupport.h | 3 +- ddprof-lib/src/test/cpp/arguments_ut.cpp | 32 ++ ddprof-lib/src/test/cpp/jvmSupport_ut.cpp | 30 ++ ddprof-lib/src/test/cpp/klassRegistry_ut.cpp | 283 ++++++++++++++++++ .../profiler/cpu/LazyJmethodIdTest.java | 193 ++++++++++++ doc/LazyJmethodIdResolution.md | 245 +++++++++++++++ 16 files changed, 1190 insertions(+), 10 deletions(-) create mode 100644 ddprof-lib/src/main/cpp/hotspot/klassRegistry.cpp create mode 100644 ddprof-lib/src/main/cpp/hotspot/klassRegistry.h create mode 100644 ddprof-lib/src/test/cpp/klassRegistry_ut.cpp create mode 100644 ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyJmethodIdTest.java create mode 100644 doc/LazyJmethodIdResolution.md 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..3fcb1f752f 100644 --- a/ddprof-lib/src/main/cpp/counters.h +++ b/ddprof-lib/src/main/cpp/counters.h @@ -173,6 +173,14 @@ * 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". CLASS_LOADED: classes whose jmethodIDs were \ + * allocated at dump time because one of their methods was sampled. */ \ + X(METHOD_RESOLVE_LAZY_UNLOADED, "method_resolve_lazy_unloaded") \ + X(METHOD_RESOLVE_LAZY_NOT_CURRENT, "method_resolve_lazy_not_current") \ + X(METHOD_RESOLVE_LAZY_CLASS_LOADED, "method_resolve_lazy_class_loaded") \ /* 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/hotspot/hotspotSupport.cpp b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp index a811682936..86f3096ced 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp @@ -1453,6 +1453,23 @@ bool HotspotSupport::loadMethodIDsIfNeededImpl(jvmtiEnv *jvmti, JNIEnv *jni, jcl return JVMSupport::loadMethodIDsImpl(jvmti, jni, klass); } +KlassRegistry HotspotSupport::_klass_registry; + +bool HotspotSupport::canResolveLazily() { + return VMStructs::hasKlassMethods(); +} + +bool HotspotSupport::registerClass(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass) { + jint status = 0; + // Array and primitive classes have no methods, so no frame ever names them + // as a holder; skipping them keeps GetLoadedClasses() at start cheap. + if (jvmti->GetClassStatus(klass, &status) != JVMTI_ERROR_NONE || + (status & (JVMTI_CLASS_STATUS_ARRAY | JVMTI_CLASS_STATUS_PRIMITIVE)) != 0) { + return false; + } + return _klass_registry.add(jni, VMKlass::fromJavaClass(jni, klass), 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 @@ -1659,6 +1676,36 @@ static bool readMethodNames(const void* method, VMMethod** out_vm_method, return true; } +// PHASE 1, fjmethodid=lazy variant -- runs under the same crash protection as +// readMethodNames(). 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; + } + VMConstMethod* const_method = vm_method->constMethod_or_null(); + if (const_method == nullptr) { + return false; + } + VMConstantPool* const_pool = const_method->constants_or_null(); + if (const_pool == nullptr) { + return false; + } + VMKlass* holder = const_pool->holder_or_null(); + if (holder == nullptr) { + return false; + } + *out_vm_method = vm_method; + *out_holder = holder; + *out_idnum = const_method->idnum(); + 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: @@ -1731,7 +1778,9 @@ static jmethodID lookupMethodIdViaJni(VMMethod* vm_method, const ResolvedNames& return method_id; } -// This method only resolves methods that are loaded by system class loaders +// Partial mode only reaches here for system-class-loader classes (FindClass +// must be able to see them); lazy mode reaches here for every class without a +// jmethodID and resolves it through _klass_registry instead. jmethodID HotspotSupport::resolve(const void* method) { assert(VM::isHotspot()); NO_INJECTION_ASSERT(method != nullptr); @@ -1760,9 +1809,12 @@ jmethodID HotspotSupport::resolve(const void* method) { return nullptr; } + const bool lazy = JVMSupport::getLoadState() == JVMSupport::Lazy_loaded; ResolvedNames names; VMMethod* vm_method = nullptr; jmethodID existing_id = nullptr; + const void* holder = nullptr; + int idnum = -1; bool walked = false; { @@ -1791,15 +1843,59 @@ jmethodID HotspotSupport::resolve(const void* method) { } jmp_scope.install(&crash_protection_ctx); - walked = readMethodNames(method, &vm_method, &existing_id, &names); + walked = lazy ? readMethodHolder(method, &vm_method, &holder, &idnum) + : readMethodNames(method, &vm_method, &existing_id, &names); } // --- crash protection is off from here on; see lookupMethodIdViaJni() --- if (!walked) { return nullptr; } + if (lazy) { + return resolveViaRegistry(vm_method, holder, idnum); + } if (existing_id != nullptr) { return existing_id; } return lookupMethodIdViaJni(vm_method, names, &HotspotSupport::loadMethodIDsIfNeededImpl); } + +// Runs with crash protection off (see lookupMethodIdViaJni() for why the +// GetClassMethods call below must not run under a landing pad). 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. +jmethodID HotspotSupport::resolveViaRegistry(VMMethod* vm_method, const void* holder, int idnum) { + JNIEnv* jni = VM::jni(); + jclass clz = _klass_registry.pin(jni, holder); + if (clz == nullptr) { + // Either the class was unloaded after the sample was taken, or `holder` + // was read from a freed Method* and is garbage. + Counters::increment(METHOD_RESOLVE_LAZY_UNLOADED); + return nullptr; + } + + jmethodID method_id = nullptr; + // The pinned class keeps its metadata alive, so its methods array is safe + // to read. A miss means the Method* was freed (and possibly its holder + // address reused), or it is an old version replaced by a redefinition. + if (!VMKlass::cast_raw(holder)->containsMethod(vm_method, idnum)) { + Counters::increment(METHOD_RESOLVE_LAZY_NOT_CURRENT); + } else { + method_id = vm_method->validatedId(); + if (!isValidJMethodID(method_id)) { + // First sample from this class: allocate its jmethodIDs now, on the + // dump thread, instead of at ClassPrepare. + method_id = nullptr; + jvmtiEnv* jvmti = VM::jvmti(); + if (jvmti != nullptr && loadMethodIDsIfNeededImpl(jvmti, jni, clz, true /*load all*/)) { + Counters::increment(METHOD_RESOLVE_LAZY_CLASS_LOADED); + jmethodID validated = vm_method->validatedId(); + if (isValidJMethodID(validated)) { + method_id = validated; + } + } + } + } + jni->DeleteLocalRef(clz); + return method_id; +} diff --git a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h index 8588635dfc..e58b11d2cf 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" @@ -49,6 +50,20 @@ 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; + + // 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); + + // fjmethodid=lazy half of resolve(): pins the class registered under + // `holder`, checks that `vm_method` is still one of its methods, and only + // then reads or allocates the jmethodID. `holder` and `idnum` were read + // from the unverified Method* and are only used as lookup keys. + static jmethodID resolveViaRegistry(VMMethod* vm_method, const void* holder, int idnum); + // 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 @@ -164,6 +179,11 @@ class HotspotSupport { !(cstack == CSTACK_VM || cstack == CSTACK_DEFAULT); // Can only use Method* when cstack = vm } + // 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(); + // Resolve a method to a jmethodID at dumping time static jmethodID resolve(const void* method); 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..10fb6ba51b --- /dev/null +++ b/ddprof-lib/src/main/cpp/hotspot/klassRegistry.cpp @@ -0,0 +1,139 @@ +/* + * 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]; +} + +// 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: liveness is only counted in the first, and an entry that +// dies between the passes is just carried over and purged next time. +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; + 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; + _size++; + return true; +} + +jclass KlassRegistry::pin(JNIEnv* jni, const void* klass) { + if (klass == nullptr) { + return nullptr; + } + MutexLocker ml(_lock); + if (_capacity == 0) { + return nullptr; + } + Entry* e = find(klass); + if (e->klass != klass) { + 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. + return (jclass)jni->NewLocalRef(e->mirror); +} + +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..917fa30a69 --- /dev/null +++ b/ddprof-lib/src/main/cpp/hotspot/klassRegistry.h @@ -0,0 +1,81 @@ +/* + * 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. + * + * 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. + * + * 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; + }; + + 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; + bool rehash(JNIEnv* jni); + +public: + 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 if none is registered or the class has been unloaded. While the + * caller holds the reference the class cannot be unloaded; it must release + * it with DeleteLocalRef. + */ + jclass pin(JNIEnv* jni, 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/vmStructs.h b/ddprof-lib/src/main/cpp/hotspot/vmStructs.h index c61ccbc3b3..6abe409902 100644 --- a/ddprof-lib/src/main/cpp/hotspot/vmStructs.h +++ b/ddprof-lib/src/main/cpp/hotspot/vmStructs.h @@ -495,6 +495,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; } @@ -718,6 +725,33 @@ 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 keeps methods()->at(idnum) + // equal to the method with that idnum until a redefinition reorders them, + // so the hint usually avoids the linear scan. The klass itself must be + // kept alive by the caller. + bool containsMethod(const void* method, int idnum_hint) { + assert(_methods_offset >= 0); + const char* methods = *(const char**) at(_methods_offset); + if (methods == NULL) { + return false; + } + int len = *(const int*)methods; + const void* const* data = (const void* const*)(methods + sizeof(void*)); + if (idnum_hint >= 0 && idnum_hint < len && data[idnum_hint] == method) { + return true; + } + for (int i = 0; i < len; i++) { + if (data[i] == method) { + return true; + } + } + return false; + } DECLARE_END DECLARE(VMJavaFrameAnchor) @@ -905,6 +939,7 @@ DECLARE(VMConstMethod) inline VMConstantPool* constants_or_null() const; inline VMSymbol* name() const; inline VMSymbol* signature() const; + inline u16 idnum() const; private: inline u16 nameIndex() const; inline u16 signatureIndex() const; diff --git a/ddprof-lib/src/main/cpp/hotspot/vmStructs.inline.h b/ddprof-lib/src/main/cpp/hotspot/vmStructs.inline.h index 043ab06722..b69cfef4cf 100644 --- a/ddprof-lib/src/main/cpp/hotspot/vmStructs.inline.h +++ b/ddprof-lib/src/main/cpp/hotspot/vmStructs.inline.h @@ -212,6 +212,11 @@ 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::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..440270d0d7 100644 --- a/ddprof-lib/src/main/cpp/jvmSupport.cpp +++ b/ddprof-lib/src/main/cpp/jvmSupport.cpp @@ -72,15 +72,16 @@ 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; + if (VM::isHotspot() && !HotspotSupport::shouldPreloadJmethodIDs(args)) { + if (args._lazy_jmethodID && HotspotSupport::canResolveLazily()) { + 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 +150,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 +162,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 { diff --git a/ddprof-lib/src/main/cpp/jvmSupport.h b/ddprof-lib/src/main/cpp/jvmSupport.h index 49b8a9193f..a04f17f622 100644 --- a/ddprof-lib/src/main/cpp/jvmSupport.h +++ b/ddprof-lib/src/main/cpp/jvmSupport.h @@ -27,7 +27,8 @@ 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; 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/jvmSupport_ut.cpp b/ddprof-lib/src/test/cpp/jvmSupport_ut.cpp index 83ab2ecb4f..3088e0560d 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; } }; // --------------------------------------------------------------------------- @@ -293,3 +294,32 @@ 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; + + JVMSupport::initExecution(lazy_args, &mock_jvmti, reinterpret_cast(&mock_jni)); + + EXPECT_EQ(JVMSupportTestAccessor::FullyLoaded(), JVMSupportTestAccessor::getLoadState()); +} 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..b3aaad3038 --- /dev/null +++ b/ddprof-lib/src/test/cpp/klassRegistry_ut.cpp @@ -0,0 +1,283 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#include + +#include "../../main/cpp/hotspot/klassRegistry.h" +#include "../../main/cpp/hotspot/vmStructs.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))); +} + +// --------------------------------------------------------------------------- +// 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)); +} 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..0242380969 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyJmethodIdTest.java @@ -0,0 +1,193 @@ +/* + * 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.JfrEvents; +import com.datadoghq.profiler.Platform; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junitpioneer.jupiter.RetryingTest; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.Label; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; + +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, allocating jmethodIDs for + * that one class on first use. + * + *

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 the class's + * jmethodIDs must have been allocated at dump time rather than preloaded. + */ + @RetryingTest(3) + public void customLoaderFramesResolve() throws Exception { + assumeHotspot(); + waitForProfilerReady(2000); + + String className = nextClassName("LazySpin"); + Class clazz = new SpinClassLoader().define(className); + clazz.getMethod("spin", long.class).invoke(null, 1_000_000_000L); + + stopProfiler(); + + String simpleName = className.substring(className.lastIndexOf('/') + 1); + boolean found = false; + for (JfrEvent sample : verifyEvents("datadog.ExecutionSample")) { + String stackTrace = sample.getStackTraceString(); + if (stackTrace != null && stackTrace.contains(simpleName) && stackTrace.contains("spin")) { + found = true; + break; + } + } + assertTrue(found, "No CPU sample contained a resolved " + simpleName + ".spin frame"); + + // Absent in builds without counters; only check when present. + Long classesLoaded = profiler.getDebugCounters().get("method_resolve_lazy_class_loaded"); + if (classesLoaded != null) { + assertTrue(classesLoaded > 0, + "Expected jmethodIDs to be allocated at dump time for at least one class"); + } + } + + /** + * 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) {} + } + } + + 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 { + SpinClassLoader loader = new SpinClassLoader(); + Class clazz = loader.define(className); + clazz.getMethod("spin", long.class).invoke(null, 300_000_000L); + return new WeakReference<>(loader); + } + + /** + * Defines a class with {@code public static void spin(long nanos)}, which busy-waits so + * that CPU samples land in it. + */ + private static final class SpinClassLoader 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(); + // 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.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(); + } + } +} diff --git a/doc/LazyJmethodIdResolution.md b/doc/LazyJmethodIdResolution.md new file mode 100644 index 0000000000..9df5e46fc8 --- /dev/null +++ b/doc/LazyJmethodIdResolution.md @@ -0,0 +1,245 @@ +# Lazy jmethodID Resolution + +> **Prototype notes. Remove before the PR lands.** +> Branch: `jmethodid-lazy-resolve` (off `main` @ a60c7b1cf). Flag: `fjmethodid=lazy`. Last updated 2026-10-15. + +Stop preloading jmethodIDs for every loaded class. Record raw `Method*` frames while +sampling, and turn them into jmethodIDs on the dump thread, only after confirming that +the owning class is still loaded. + +## Problem + +Today every class gets its jmethodIDs at ClassPrepare (`GetClassMethods`). In partial +mode (`fjmethodid=false`) only system-loader classes skip this, and custom-loader, hidden +and lambda classes are still preloaded. The cost is a jmethodID per method plus a +per-class ID cache, kept until the class unloads, and contention on the JVM's +jmethodID-creation lock during class loading. + +Preloading doesn't stop allocation inside the signal handler; nothing allocates there. +AGCT uses `find_jmethod_id_or_null`, and `walkVM` reads the per-class cache directly +(`VMMethod::id()`). Preloading only makes sure an ID already exists when the sampler +looks. The sampler doesn't actually need a jmethodID. It needs an identifier that can +be resolved later, safely. + +## Approach + +1. **Sample time (signal handler).** When the class has no jmethodID yet, + `fillJavaFrame` stores the raw `Method*`. This existing path is reused unchanged. The + method is on the stack, so it is valid at that moment. +2. **ClassPrepare and startup.** Instead of preloading, `HotspotSupport::registerClass` + records `Klass* → JNI weak global ref to the mirror` in `KlassRegistry`. Array and + primitive classes are skipped. At start, `GetLoadedClasses` registers the classes that + already exist. +3. **Resolve time (dump thread).** Under crash protection, read only the holder `Klass*` + and the idnum from the possibly-stale `Method*` (`readMethodHolder`). Both are treated + as lookup keys and never trusted. Then, with protection off (`resolveViaRegistry`): + - `pin(holder)`: a `NewLocalRef` of the weak ref. It returns null if the class is + gone, which yields an unknown frame and bumps `method_resolve_lazy_unloaded`. + - A strongly held mirror keeps its class loader, and so the class's metadata, alive. + The `Klass*` can now be dereferenced safely. + - `VMKlass::containsMethod(M, idnum)` compares pointers only against the class's + current `_methods` array, using an idnum fast path plus a linear fallback. A miss + yields an unknown frame and bumps `method_resolve_lazy_not_current`. + - Only then is `M` dereferenced. If it has no ID, `loadMethodIDsIfNeededImpl(load_all)` + allocates IDs for this one class, including the JDK 8 CLD patch. This bumps + `method_resolve_lazy_class_loaded`. + +After the first dump that touches a class, its jmethodIDs exist, so later samples carry +real IDs and skip the slow path. Lazy mode needs `cstack=vm` and +`VMStructs::hasKlassMethods()`; otherwise it falls back to partial mode. It applies to +HotSpot only, and J9 and Zing keep preloading. + +### KlassRegistry + +- An open-addressing table with linear probing, guarded by a mutex. It is never touched + from a signal handler. +- Dead entries (weak ref cleared) are purged whenever the table would grow, so memory + follows the number of live classes. +- A different mirror at a known `Klass*` replaces the entry and deletes the old weak ref. +- `pin()` calls `NewLocalRef` under the lock, so a concurrent rehash can't delete the weak + ref out from under it. Lock ordering is safe: none of the JNI calls made under the lock + load classes, so a ClassPrepare can't re-enter it. + +## Open problem: Klass* address reuse (blocking) + +Class-space blocks are aligned and reused, so a new class at the exact address of an +unloaded one is likely. The current checks don't catch it: + +1. `K_old.m` is sampled as raw `M`, with holder at `A`. +2. `K_old` unloads. `K_new` is allocated at `A`. A class of the same shape that gets the + freed chunks back very likely puts its methods at the same addresses, so one of them + lands at `M`. +3. `K_new`'s ClassPrepare replaces the registry entry for `A`. +4. At dump, `M` reads back as holder `A`, the class at `A` is alive, and `M` is in its + methods. The frame resolves as `K_new`'s method, which is wrong. + +It also breaks trace dedup. `K_new` samples at `M` produce the same raw frames as the +old `K_old` samples, so within one dump interval they collapse into one trace ID. No +check at resolve time can separate them, so **the frame itself has to carry an +incarnation identity.** The existing modes never hit this, because unloadable classes +always get jmethodIDs. + +### Option A: reuse epoch (portable fallback) + +- The registry keeps a global `_epoch`, and each entry records the epoch it was + registered at. +- The epoch is bumped on a replacement (known key, different mirror) and whenever a + rehash purges dead entries, because a purged key can come back looking like a fresh + insert. +- The sampler stores the current epoch in the raw frame: one acquire load, with no + registry lookup in the handler. +- Resolve accepts only if `entry.epoch ≤ frame.epoch`, compared modularly. Live classes + always pass, because ClassPrepare runs before their methods do. Old-incarnation frames + fail, and old and new frames no longer dedup together. +- Cost: a stack sampled across a bump splits into two traces. In heavy churn, bumps + follow the reuse rate. A "seen since last bump" flag can reduce how often it bumps. + +### Option B: HotSpot's JFR class trace ID, read by offset (preferred) + +Every `Klass` carries `mutable traceid _trace_id` (`JFR_ONLY(DEFINE_TRACE_ID_FIELD)`). +It is not in `gHotSpotVMStructs`. From the local OpenJDK sources: + +- **Assignment:** `next_class_id()` is + `(atomic_inc(counter) << TRACE_ID_SHIFT) | EPOCH_CLEARED_BITS`, with + `TRACE_ID_SHIFT = 16`. It is assigned at class creation (`Jfr::on_klass_creation` on + master, `INIT_ID(ik)` in classFileParser on 17u) and on CDS restoration, whether or not + JFR is recording. It is globally monotonic, so it is unique per incarnation. +- **Encoding:** the low 16 bits are JFR tag/epoch bits that JFR changes while it runs. + Use `raw >> 16` only. +- **Not present** in builds without JFR: JDK 8 before the 8u272 backport, and + `--disable-jfr` builds. Option A is still needed as a fallback. + +| Source tree | `_trace_id` declared after | Nearest vmStructs anchor | +|---|---|---| +| jdk17u (17.0.18) | `AccessFlags _access_flags` | `Klass::_access_flags` | +| jdk21u-dev (21.0.10) | `AccessFlags _access_flags` | `Klass::_access_flags` | +| jdk master (28) | `CDS_JAVA_HEAP_ONLY(int _archived_mirror_index)`, after `_vtable_len` | `Klass::_vtable_len` (+4 if CDS heap) | +| 8u, 11u, 25 | not checked yet: no local checkout | | + +An anchor-relative offset per version range is more robust than absolute offsets, and +the repo already keys a few offsets on `VM::hotspot_version()` in `vmStructs.cpp`. The +field moves between releases, and on master its position depends on a build option (the +CDS Java heap). So the offset must be confirmed at runtime, not trusted from a table +alone. + +Advantages over A: no global state, no trace splitting, and two incarnations get the same +stored bits only if a multiple of 2^N classes were created between them. + +#### Confirming the offset + +**Self-calibration (decides at startup).** Register a few probe classes we define in a +known order. Check that `(raw >> 16)` at the candidate offset is distinct for each and +strictly increasing in load order. This has to decide before the first sample, because +the sampler must know which bits to store from then on. + +**Cross-check against JFR (confirms later).** dd-trace-java runs a real +`jdk.jfr.Recording` next to ddprof (`OpenJdkOngoingRecording`; when ddprof is enabled it +only turns off the overlapping JFR events). So in most production JVMs JFR gets +initialized, and its own answer can confirm our offset: + +- **Detect JFR by watching, not by loading.** Don't call JNI `FindClass`: HotSpot's + `jni_FindClass` passes `init=true`, which would run `JVM.` (`registerNatives`, + JFR log-level setup, `Options.ensureInitialized()`). Reflection from Java has the same + problem. Lazy mode already sees every class through ClassPrepare and `GetLoadedClasses`. + When `Ljdk/jfr/internal/JVM;` shows up, keep a global ref to it, and call into it only + once `GetClassStatus` reports `JVMTI_CLASS_STATUS_INITIALIZED`. +- **`getClassId` is not a pure read.** `jfr_class_id` goes through + `JfrTraceIdLoadBarrier::load(klass)`. If the class isn't tagged yet, the barrier marks + it used for the current epoch and adds it to JFR's class queue, so the class ends up in + the user's recording. Probe only classes that are certainly tagged already + (`java.lang.Object`, `java.lang.Thread`); for those `should_tag()` is false and the + call is effectively a read. It returns `TRACE_ID(klass)`, which is `raw >> 16`, so the + check is a direct equality test. +- **Mismatch handling.** Switch new samples to Option A. Treat raw frames captured under + the unconfirmed offset as unresolvable (unknown) rather than resolve them. Wrong names + are worse than dropped frames. Add a counter for both outcomes. +- JNI calls ignore module encapsulation, so `jdk.jfr.internal` not being exported shouldn't + matter. Verify this when implementing. + +### Frame bit budget (both options) + +- The bci field has 10 spare bits: 16–19 and 24–29. Bit 31 must stay clear, because + `isRawPointer()` requires `bci > 0`. +- The top 16 bits of `Method*` look free for 48-bit user-space addresses. arm64 can hand + out 52-bit addresses when asked, so this needs a startup VA check, falling back to the + 10 bci bits. +- 10 bits alone is too few under heavy churn for either option. Resolving from a + background thread shrinks the window between sample and resolve, and helps both. + +## Measurements + +20,000 classes × 50 static methods (1M methods), 100 classes per custom loader, all kept +reachable. JDK 25.0.1 (Corretto), macOS arm64, debug build of the profiler, +`cpu=10ms,cstack=vm`. Figures are the NMT `summary.diff` against a baseline taken just +before loading. + +| Mode | Class, committed Δ | Total committed Δ | Load time | +|---|---:|---:|---:| +| no profiler | +13.6 MB | +722 MB | 393 ms | +| `fjmethodid=true` | +22.1 MB | +748 MB | 420 ms | +| `fjmethodid=false` | +22.1 MB | +739 MB | 446 ms | +| **`fjmethodid=lazy`** | **+13.6 MB** | **+721 MB** | 420 ms | + +Preloading costs about 8.4 MB of committed memory per million methods. Partial mode saves +nothing in this workload, because every custom-loader class is still preloaded. Lazy mode +matches the no-profiler baseline. Load-time differences are within noise. In-process RSS +deltas (about 375–390 MB in every mode) were too noisy to show any of this. Linux numbers +are still to do. + +The harness is a standalone `Bench.java`: ASM on the classpath, the profiler loaded with +`-agentpath:…=start,cpu=10ms,cstack=vm,fjmethodid=`, and the JVM run with +`-XX:NativeMemoryTracking=summary`. It reads `vmNativeMemory baseline` / `summary.diff` +through the `com.sun.management:type=DiagnosticCommand` MBean. It lives in +`/tmp/jmbench` on the Mac and isn't in the repo. + +## Status + +| Item | State | +|---|---| +| `fjmethodid=lazy` parsing, `Lazy_loaded` state, registry, resolve path, counters | done | +| gtests: `klassRegistry_ut` (10), lazy cases in `jvmSupport_ut` and `arguments_ut` | pass (macOS) | +| `LazyJmethodIdTest`: custom-loader frames resolve; frames of unloaded class dropped (`method_resolve_lazy_unloaded=5`) | pass (macOS) | +| Deterministic ABA gtest (re-register a new mirror at the same `Klass*` with `M` in its methods; fails before the fix) | open | +| Frame bits: bci spare bits plus `Method*` top bits with VA check | open | +| Option A (epoch fallback) | open | +| Option B (trace-ID offset, self-calibration, JFR cross-check) | open | +| Linux runs: Linux-only `hotspotMethodId_ut` cases, ASan, aarch64, musl, benchmark | open | +| Per-dump cache for repeated raw frames (each occurrence currently re-pins) | open | +| `JmethodIdOverheadBenchTest.java`: temporary harness, don't commit | uncommitted | + +### Files touched + +``` +ddprof-lib/src/main/cpp/hotspot/klassRegistry.{h,cpp} new: Klass* → weak mirror registry +ddprof-lib/src/main/cpp/hotspot/hotspotSupport.{h,cpp} registerClass, readMethodHolder, resolveViaRegistry +ddprof-lib/src/main/cpp/hotspot/vmStructs{.h,.inline.h} VMKlass::containsMethod, VMConstMethod::idnum, hasKlassMethods +ddprof-lib/src/main/cpp/jvmSupport.{h,cpp} Lazy_loaded state and dispatch +ddprof-lib/src/main/cpp/arguments.{h,cpp} fjmethodid=lazy +ddprof-lib/src/main/cpp/counters.h method_resolve_lazy_{unloaded,not_current,class_loaded} +ddprof-lib/src/test/cpp/{klassRegistry,jvmSupport,arguments}_ut.cpp +ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyJmethodIdTest.java +``` + +## Continuing on Linux + +``` +git fetch && git switch jmethodid-lazy-resolve +./.claude/commands/build-and-summarize :ddprof-lib:gtestDebug +./.claude/commands/build-and-summarize :ddprof-test:testDebug -Ptests='*Jmethod*' +# or, from any host with podman/docker: +./utils/run-containers-tests.sh --config=asan --libc=glibc --jdk=21 --tests='*Jmethod*' +./utils/run-containers-tests.sh --arch=aarch64 --config=debug --jdk=21 --tests='*Jmethod*' +``` + +`-Ptests` takes a single pattern. Comma-separated lists don't match. + +## Alternatives set aside + +- **JVMTI ObjectFree on tagged mirrors** as an unload signal: it needs + `can_tag_objects`, and the events are posted later from the service thread, so they + don't tell you when the metadata is freed. +- **GC start/finish callbacks** to resolve before unloading: JNI isn't allowed inside + them, and they aren't known to cover unloading by concurrent collectors. +- **Copying names in the handler:** too expensive, and Symbols can be freed too. +- **JFR's own sampler (JDK 25+):** it solves this inside the VM, but it doesn't cover + JDK 8–21, J9 or Zing. From 581e2dff310f9820450658390512679aca2bd62c Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Thu, 15 Oct 2026 11:17:21 +0200 Subject: [PATCH 02/17] Tag raw Method* frames with a KlassRegistry epoch to reject reused Klass* addresses A class unloaded and replaced by a new class at the same Klass* (and Method*) addresses made frames sampled from the old class resolve as methods of the new one, and made both produce identical raw frames. KlassRegistry now keeps an epoch, bumped when a known Klass* gets a different mirror and when a rehash purges dead entries; each entry records the epoch it was registered at. walkVM stores the current epoch in every raw frame as a 26-bit tag (10 spare bci bits plus the top 16 bits of the Method*), and pin() refuses frames older than the entry. A Method* that uses the tag's address bits is stored as not walkable. If registering a class fails, its jmethodIDs are preloaded so it never yields raw frames. resolve() now takes the frame's bci to recover the tag. Adds method_resolve_lazy_stale, klass_registry_add_failed and walkvm_raw_frame_untaggable counters, an end-to-end gtest for the Klass*-reuse cases, epoch tests for the registry and tag round-trip tests for FrameType. Co-Authored-By: Claude Opus 5.5 --- ddprof-lib/src/main/cpp/counters.h | 8 + ddprof-lib/src/main/cpp/flightRecorder.cpp | 2 +- ddprof-lib/src/main/cpp/frame.h | 51 ++- .../src/main/cpp/hotspot/hotspotSupport.cpp | 58 ++- .../src/main/cpp/hotspot/hotspotSupport.h | 11 +- .../src/main/cpp/hotspot/klassRegistry.cpp | 34 +- .../src/main/cpp/hotspot/klassRegistry.h | 44 +- ddprof-lib/src/main/cpp/jvmSupport.h | 4 +- ddprof-lib/src/main/cpp/jvmSupport.inline.h | 6 +- ddprof-lib/src/test/cpp/frame_ut.cpp | 51 +++ .../src/test/cpp/hotspotLazyResolve_ut.cpp | 403 ++++++++++++++++++ .../src/test/cpp/hotspotMethodId_ut.cpp | 14 +- ddprof-lib/src/test/cpp/klassRegistry_ut.cpp | 94 +++- doc/LazyJmethodIdResolution.md | 31 +- 14 files changed, 751 insertions(+), 60 deletions(-) create mode 100644 ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp diff --git a/ddprof-lib/src/main/cpp/counters.h b/ddprof-lib/src/main/cpp/counters.h index 3fcb1f752f..294d0dff19 100644 --- a/ddprof-lib/src/main/cpp/counters.h +++ b/ddprof-lib/src/main/cpp/counters.h @@ -181,6 +181,14 @@ X(METHOD_RESOLVE_LAZY_UNLOADED, "method_resolve_lazy_unloaded") \ X(METHOD_RESOLVE_LAZY_NOT_CURRENT, "method_resolve_lazy_not_current") \ X(METHOD_RESOLVE_LAZY_CLASS_LOADED, "method_resolve_lazy_class_loaded") \ + /* STALE: the frame predates the class now registered at its Klass* \ + * address (Klass* reuse after unloading); serializes as "unknown". \ + * ADD_FAILED: registration failed, so the class was preloaded instead. \ + * UNTAGGABLE: a Method* using address bits the raw-frame tag needs; the \ + * frame is stored as not walkable. */ \ + X(METHOD_RESOLVE_LAZY_STALE, "method_resolve_lazy_stale") \ + X(KLASS_REGISTRY_ADD_FAILED, "klass_registry_add_failed") \ + X(WALKVM_RAW_FRAME_UNTAGGABLE, "walkvm_raw_frame_untaggable") \ /* 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..842730ff6b 100644 --- a/ddprof-lib/src/main/cpp/flightRecorder.cpp +++ b/ddprof-lib/src/main/cpp/flightRecorder.cpp @@ -708,7 +708,7 @@ MethodInfo *Lookup::fillMethod(ASGCT_CallFrame &frame, jmethodID method_id, assert(prof_thread != nullptr); // Resolve native method if (FrameType::isRawPointer(bci)) { - method_id = JVMSupport::resolve(frame.method); + method_id = JVMSupport::resolve(frame.method, bci); if (method_id == nullptr) { return unknownMethod(); } diff --git a/ddprof-lib/src/main/cpp/frame.h b/ddprof-lib/src/main/cpp/frame.h index 9c5e37cb21..52281371da 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,21 @@ 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 also carries a RAW_TAG_BITS-wide tag: the KlassRegistry +// epoch the frame was sampled under (hotspot/klassRegistry.h). 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 that +// predate the class currently registered at that address. +// // 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 +64,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 +82,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/hotspotSupport.cpp b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp index 86f3096ced..1efae51db5 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp @@ -139,7 +139,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 FrameType::untagPointer(frame.method) == method; } else { return frame.method_id == method_id; } @@ -186,11 +186,24 @@ static void fillFrameTypes(ASGCT_CallFrame *frames, int num_frames, VMNMethod *n } } -// Fill the frame with raw method pointer -static void fillFrameRaw(ASGCT_CallFrame& frame, FrameTypeId type, int bci, const VMMethod* method) { +static_assert(KlassRegistry::EPOCH_BITS == FrameType::RAW_TAG_BITS, + "a raw frame must store the whole KlassRegistry epoch"); + +// Fill the frame with raw method pointer, tagged with the KlassRegistry epoch +// it was sampled under (see frame.h for where the tag bits go). +static void fillFrameRaw(ASGCT_CallFrame& frame, FrameTypeId type, int bci, const VMMethod* method, + uint32_t epoch) { NO_INJECTION_ASSERT(method != nullptr); - frame.bci = FrameType::encode(type, bci, true /*raw method pointer*/); - frame.method = static_cast(method); + if (!FrameType::canTagPointer(method)) { + // The tag's high bits would collide with address bits. Without the + // 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); + return; + } + frame.bci = FrameType::encodeRaw(type, bci, epoch); + frame.method = FrameType::tagPointer(method, epoch); } void HotspotSupport::fillJavaFrame(ASGCT_CallFrame& frame, FrameTypeId type, int bci, @@ -211,7 +224,7 @@ 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); + fillFrameRaw(frame, type, bci, method, _klass_registry.epoch()); } else { // fjmethodid=true: all jmethodIDs should be preloaded, so nullptr is // unexpected — a transient invalidation window or a missed preload. @@ -1467,7 +1480,14 @@ bool HotspotSupport::registerClass(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass) { (status & (JVMTI_CLASS_STATUS_ARRAY | JVMTI_CLASS_STATUS_PRIMITIVE)) != 0) { return false; } - return _klass_registry.add(jni, VMKlass::fromJavaClass(jni, klass), klass); + if (_klass_registry.add(jni, VMKlass::fromJavaClass(jni, klass), klass)) { + return true; + } + // An unregistered class's frames could never be told apart from a later + // class at the same address (see klassRegistry.h), 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*/); } // The three names resolve() needs, owned by resolve()'s frame. Each name has @@ -1781,15 +1801,17 @@ static jmethodID lookupMethodIdViaJni(VMMethod* vm_method, const ResolvedNames& // Partial mode only reaches here for system-class-loader classes (FindClass // must be able to see them); lazy mode reaches here for every class without a // jmethodID and resolves it through _klass_registry instead. -jmethodID HotspotSupport::resolve(const void* method) { +jmethodID HotspotSupport::resolve(const void* tagged_method, int bci) { assert(VM::isHotspot()); - NO_INJECTION_ASSERT(method != nullptr); + NO_INJECTION_ASSERT(tagged_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) { + if ((jmethodID)tagged_method == JMETHODID_NOT_WALKABLE) { return nullptr; } + const void* method = FrameType::untagPointer(tagged_method); + const uint32_t epoch = FrameType::rawTag(bci, tagged_method); // 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 @@ -1852,7 +1874,7 @@ jmethodID HotspotSupport::resolve(const void* method) { return nullptr; } if (lazy) { - return resolveViaRegistry(vm_method, holder, idnum); + return resolveViaRegistry(vm_method, holder, idnum, epoch); } if (existing_id != nullptr) { return existing_id; @@ -1864,13 +1886,17 @@ jmethodID HotspotSupport::resolve(const void* method) { // GetClassMethods call below must not run under a landing pad). 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. -jmethodID HotspotSupport::resolveViaRegistry(VMMethod* vm_method, const void* holder, int idnum) { +jmethodID HotspotSupport::resolveViaRegistry(VMMethod* vm_method, const void* holder, int idnum, + uint32_t epoch) { JNIEnv* jni = VM::jni(); - jclass clz = _klass_registry.pin(jni, holder); + bool stale = false; + jclass clz = _klass_registry.pin(jni, holder, epoch, &stale); if (clz == nullptr) { - // Either the class was unloaded after the sample was taken, or `holder` - // was read from a freed Method* and is garbage. - Counters::increment(METHOD_RESOLVE_LAZY_UNLOADED); + // Stale: the class registered at `holder` arrived after the sample, so the + // frame belongs to an earlier class at the same address. Otherwise the + // class was unloaded after the sample was taken, or `holder` was read from + // a freed Method* and is garbage. + Counters::increment(stale ? METHOD_RESOLVE_LAZY_STALE : METHOD_RESOLVE_LAZY_UNLOADED); return nullptr; } diff --git a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h index e58b11d2cf..fb4cce5147 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h @@ -59,10 +59,12 @@ class HotspotSupport { static bool registerClass(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass); // fjmethodid=lazy half of resolve(): pins the class registered under - // `holder`, checks that `vm_method` is still one of its methods, and only + // `holder` (refusing it if registered after `epoch`, the frame's sample + // epoch), checks that `vm_method` is still one of its methods, and only // then reads or allocates the jmethodID. `holder` and `idnum` were read // from the unverified Method* and are only used as lookup keys. - static jmethodID resolveViaRegistry(VMMethod* vm_method, const void* holder, int idnum); + static jmethodID resolveViaRegistry(VMMethod* vm_method, const void* holder, int idnum, + uint32_t epoch); // Runs getJavaTraceAsync() under withUcontextFaultRecovery(), then layers // on its two post-processing steps: resolving frame types for the top @@ -184,8 +186,9 @@ class HotspotSupport { // back to the partial preload mode. static bool canResolveLazily(); - // Resolve a method to a jmethodID at dumping time - static jmethodID resolve(const void* method); + // Resolve a raw-pointer frame to a jmethodID at dumping time. Takes the + // frame's method field and encoded bci as stored, tag bits included. + static jmethodID resolve(const void* tagged_method, int bci); // 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 index 10fb6ba51b..21d2bd7c15 100644 --- a/ddprof-lib/src/main/cpp/hotspot/klassRegistry.cpp +++ b/ddprof-lib/src/main/cpp/hotspot/klassRegistry.cpp @@ -24,6 +24,15 @@ KlassRegistry::Entry* KlassRegistry::find(const void* klass) const { return &_table[i]; } +// Called with _lock held. The release store pairs with epoch()'s acquire +// load: a thread that runs a method of a class registered here, and then +// samples it, observes at least the epoch that class was registered at. +uint32_t KlassRegistry::bumpEpoch() { + uint32_t next = _epoch + 1; + __atomic_store_n(&_epoch, next, __ATOMIC_RELEASE); + return next; +} + // 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: liveness is only counted in the first, and an entry that @@ -50,6 +59,7 @@ bool KlassRegistry::rehash(JNIEnv* jni) { _table = table; _capacity = capacity; _size = 0; + bool purged = false; for (size_t i = 0; i < old_capacity; i++) { Entry& e = old_table[i]; if (e.klass == nullptr) { @@ -57,12 +67,18 @@ bool KlassRegistry::rehash(JNIEnv* jni) { } if (jni->IsSameObject(e.mirror, nullptr)) { jni->DeleteWeakGlobalRef(e.mirror); + purged = true; } else { *find(e.klass) = e; _size++; } } free(old_table); + if (purged) { + // A purged address may be handed to a new class whose add() then looks + // like a first insert; frames of the purged class must stay older. + bumpEpoch(); + } return true; } @@ -84,6 +100,9 @@ bool KlassRegistry::add(JNIEnv* jni, const void* klass, jclass mirror) { } jni->DeleteWeakGlobalRef(e->mirror); e->mirror = ref; + // A different class now lives at this address; frames sampled from the + // previous one must not match it. + e->epoch = bumpEpoch(); return true; } } @@ -98,11 +117,15 @@ bool KlassRegistry::add(JNIEnv* jni, const void* klass, jclass mirror) { Entry* e = find(klass); e->klass = klass; e->mirror = ref; + e->epoch = _epoch; _size++; return true; } -jclass KlassRegistry::pin(JNIEnv* jni, const void* klass) { +jclass KlassRegistry::pin(JNIEnv* jni, const void* klass, uint32_t sampled_epoch, bool* stale) { + if (stale != nullptr) { + *stale = false; + } if (klass == nullptr) { return nullptr; } @@ -114,6 +137,15 @@ jclass KlassRegistry::pin(JNIEnv* jni, const void* klass) { if (e->klass != klass) { return nullptr; } + // Serial-number comparison: the frame is at least as new as the entry iff + // (sampled - registered) mod 2^EPOCH_BITS lies in the lower half. + const uint32_t mask = (1u << EPOCH_BITS) - 1; + if (((sampled_epoch - e->epoch) & mask) >= (1u << (EPOCH_BITS - 1))) { + if (stale != nullptr) { + *stale = true; + } + 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. diff --git a/ddprof-lib/src/main/cpp/hotspot/klassRegistry.h b/ddprof-lib/src/main/cpp/hotspot/klassRegistry.h index 917fa30a69..04cd1b1c47 100644 --- a/ddprof-lib/src/main/cpp/hotspot/klassRegistry.h +++ b/ddprof-lib/src/main/cpp/hotspot/klassRegistry.h @@ -31,13 +31,29 @@ * loaded. A Klass* address reused by a newly loaded class simply overwrites * the dead entry on that class's ClassPrepare. * - * All operations take an internal mutex and call JNI, so none of them may be - * used from a signal handler. + * Klass* reuse: class space hands a freed Klass* address -- and very likely + * the same Method* addresses -- to the next class of the same shape, so a + * Method* sampled from an unloaded class can look exactly like a method of + * the class that replaced it. The registry therefore keeps an epoch: every + * entry records the epoch it was registered at, and the epoch is bumped + * whenever a registration may reuse the address of a class that is gone -- + * when a known Klass* gets a different mirror, and when a rehash purges dead + * entries (after which the old address can come back as a first insert). + * walkVM stores epoch() in each raw frame; pin() refuses a frame whose epoch + * predates the entry, since such a frame can only come from an earlier + * occupant of the address. This relies on every class being registered + * before any of its methods can run, which ClassPrepare guarantees; when + * registration fails, the caller must preload the class's jmethodIDs instead + * so that no raw frame of it is ever produced. + * + * Except for epoch(), 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 epoch; // _epoch when this mirror was registered }; static const size_t INITIAL_CAPACITY = 1024; @@ -46,12 +62,18 @@ class KlassRegistry { Entry* _table; size_t _capacity; // Power of two, or 0 before the first add() size_t _size; // Occupied slots, live or dead + volatile uint32_t _epoch; Entry* find(const void* klass) const; bool rehash(JNIEnv* jni); + uint32_t bumpEpoch(); public: - KlassRegistry() : _table(nullptr), _capacity(0), _size(0) {} + // Width of the epoch as stored in a frame and compared by pin(); must equal + // FrameType::RAW_TAG_BITS (checked in hotspotSupport.cpp). + static const int EPOCH_BITS = 26; + + KlassRegistry() : _table(nullptr), _capacity(0), _size(0), _epoch(0) {} KlassRegistry(const KlassRegistry&) = delete; KlassRegistry& operator=(const KlassRegistry&) = delete; @@ -63,13 +85,21 @@ class KlassRegistry { */ bool add(JNIEnv* jni, const void* klass, jclass mirror); + /** + * The current epoch, to be stored with a frame sampled now. Signal safe: + * a single acquire load. + */ + uint32_t epoch() const { return __atomic_load_n(&_epoch, __ATOMIC_ACQUIRE); } + /** * Returns a new local reference to the mirror registered for `klass`, or - * nullptr if none is registered or the class has been unloaded. While the - * caller holds the reference the class cannot be unloaded; it must release - * it with DeleteLocalRef. + * nullptr if none is registered, the class has been unloaded, or the + * registration is newer than `sampled_epoch` (the frame came from an + * earlier class at the same address; `*stale` is then set to true). Epochs + * compare modulo 2^EPOCH_BITS. While the caller holds the reference the + * class cannot be unloaded; it must release it with DeleteLocalRef. */ - jclass pin(JNIEnv* jni, const void* klass); + jclass pin(JNIEnv* jni, const void* klass, uint32_t sampled_epoch, bool* stale = nullptr); /** Number of occupied slots, including dead entries not yet purged. */ size_t size(); diff --git a/ddprof-lib/src/main/cpp/jvmSupport.h b/ddprof-lib/src/main/cpp/jvmSupport.h index a04f17f622..56a40b6967 100644 --- a/ddprof-lib/src/main/cpp/jvmSupport.h +++ b/ddprof-lib/src/main/cpp/jvmSupport.h @@ -67,8 +67,8 @@ 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); + // Resolve a raw-pointer frame (method field and encoded bci) to a jmethodID + static inline jmethodID resolve(const void* method, int bci); // 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..ba571dd195 100644 --- a/ddprof-lib/src/main/cpp/jvmSupport.inline.h +++ b/ddprof-lib/src/main/cpp/jvmSupport.inline.h @@ -35,10 +35,10 @@ 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) to a jmethodID +jmethodID JVMSupport::resolve(const void* method, int bci) { if (VM::isHotspot()) { - return HotspotSupport::resolve(method); + return HotspotSupport::resolve(method, bci); } else { assert(false && "Should not reach here"); return nullptr; 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..1c6e6e2ebe --- /dev/null +++ b/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp @@ -0,0 +1,403 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#include + +#include "../../main/cpp/frame.h" +#include "../../main/cpp/hotspot/hotspotSupport.h" +#include "../../main/cpp/hotspot/klassRegistry.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 Klass holding both its jmethodID cache and its methods array. 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; } +}; + +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; } +}; + +class HotspotSupportTestAccessor { +public: + static KlassRegistry& registry() { return HotspotSupport::_klass_registry; } +}; + +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; + uint64_t method_size, const_method_size, constant_pool_size, klass_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::TYPE_SIZE_NAME(VMMethod), + VMStructs::TYPE_SIZE_NAME(VMConstMethod), + VMStructs::TYPE_SIZE_NAME(VMConstantPool), + VMStructs::TYPE_SIZE_NAME(VMKlass), + 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::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::_can_dereference_jmethod_id = s.can_dereference_jmethod_id; + } +}; + +namespace { + +struct FakeMethod { + void* const_method; +}; + +struct FakeConstMethod { + void* cpool; + uint16_t idnum; + uint16_t pad[3]; +}; + +struct FakeConstantPool { + void* holder; +}; + +// jmethodID cache: [length (i32), padding, id[0], id[1], ...]. +struct FakeIdCache { + int32_t len; + int32_t pad; + jmethodID slots[2]; +}; + +// HotSpot's Array: int length, then pointer-aligned elements. +struct FakeMethodArray { + int32_t len; + int32_t pad; + void* data[2]; +}; + +struct FakeKlass { + FakeIdCache* ids; + FakeMethodArray* methods; +}; + +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), + /*method_size*/ sizeof(FakeMethod), + /*const_method_size*/ sizeof(FakeConstMethod), + /*constant_pool_size*/ sizeof(FakeConstantPool), + /*klass_size*/ sizeof(FakeKlass), + /*can_dereference*/ true, +}; + +// One class with one method whose jmethodID already exists, so resolve() +// never needs JVMTI. 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 { + FakeMethod method; + FakeConstMethod const_method; + FakeConstantPool cpool; + FakeKlass klass; + FakeIdCache ids; + FakeMethodArray methods; + // A jmethodID is a pointer to a slot holding the Method*; validatedId() + // checks exactly that when _can_dereference_jmethod_id is set. + void* jmethod_id_slot; + + FakeClass() { + memset(this, 0, sizeof(*this)); + method.const_method = &const_method; + const_method.cpool = &cpool; + const_method.idnum = 0; + cpool.holder = &klass; + klass.ids = &ids; + klass.methods = &methods; + methods.len = 1; + methods.data[0] = &method; + jmethod_id_slot = &method; + ids.len = 1; + ids.slots[0] = jmethodId(); + } + + const void* klassPtr() const { return &klass; } + VMMethod* vmMethod() { return reinterpret_cast(&method); } + jmethodID jmethodId() { return reinterpret_cast(&jmethod_id_slot); } +}; + +// Fake JNI with weak-reference semantics, as in klassRegistry_ut.cpp, plus +// the JavaVM that VM::jni() goes through. +struct FakeObject { + int id; +}; + +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_env.functions = &_jni_tbl; + _vm_tbl.GetEnv = &getEnv; + _vm.functions = &_vm_tbl; + VMTestAccessor::setVm(reinterpret_cast(&_vm)); + } + + ~FakeJvm() { + VMTestAccessor::setVm(_saved_vm); + for (FakeWeak* w : _weaks) { + delete w; + } + s_instance = nullptr; + } + + JNIEnv* jni() { return &_jni_env; } + + 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) { return s_instance->resolve(ref); } + static void JNICALL deleteLocalRef(JNIEnv*, jobject) {} + 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{}; + 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; + 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); + // 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(); + 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))); + } + + // 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 Lookup::fillMethod() does with a raw frame at dump time. + static jmethodID resolve(const ASGCT_CallFrame& frame) { + return HotspotSupport::resolve(frame.method, frame.bci); + } +}; + +TEST_F(HotspotLazyResolveTest, LiveClassResolves) { + registerClass(_old_mirror); + ASGCT_CallFrame frame = sample(); + EXPECT_EQ(_cls.jmethodId(), resolve(frame)); +} + +TEST_F(HotspotLazyResolveTest, UnloadedClassDoesNotResolve) { + registerClass(_old_mirror); + ASGCT_CallFrame frame = sample(); + _jvm.collect(_old_mirror); + EXPECT_EQ(nullptr, 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(); + + _jvm.collect(_old_mirror); + registerClass(_new_mirror); + + EXPECT_EQ(nullptr, 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(), registry.epoch())) + << "the dead entry should be gone, purged or at least unpinnable"; + + registerClass(_new_mirror); + + EXPECT_EQ(nullptr, resolve(frame)); +} + +// Frames sampled from the new class after the reuse must still resolve. +TEST_F(HotspotLazyResolveTest, FrameFromAfterKlassReuseResolves) { + registerClass(_old_mirror); + _jvm.collect(_old_mirror); + registerClass(_new_mirror); + + ASGCT_CallFrame frame = sample(); + EXPECT_EQ(_cls.jmethodId(), resolve(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(); + _jvm.collect(_old_mirror); + registerClass(_new_mirror); + ASGCT_CallFrame after = sample(); + + EXPECT_FALSE(before.bci == after.bci && before.method == after.method); +} diff --git a/ddprof-lib/src/test/cpp/hotspotMethodId_ut.cpp b/ddprof-lib/src/test/cpp/hotspotMethodId_ut.cpp index f8501cf6e6..698567da1f 100644 --- a/ddprof-lib/src/test/cpp/hotspotMethodId_ut.cpp +++ b/ddprof-lib/src/test/cpp/hotspotMethodId_ut.cpp @@ -585,7 +585,7 @@ TEST_F(HotspotResolveCrashProtectionTest, ResolveRecoversFromFaultInsteadOfCrash // 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)); + EXPECT_EQ(nullptr, HotspotSupport::resolve(_region, 0)); EXPECT_EQ(before + 1, Counters::getCounter(METHOD_RESOLVE_FAULT_RECOVERED)); // The landing pad must hand the previous (here: absent) context back. @@ -604,7 +604,7 @@ 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)); + EXPECT_EQ(nullptr, HotspotSupport::resolve(_region, 0)); EXPECT_FALSE(_pt->isProtected()); } EXPECT_EQ(before + 10, Counters::getCounter(METHOD_RESOLVE_FAULT_RECOVERED)); @@ -635,7 +635,7 @@ 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)); + EXPECT_EQ(nullptr, HotspotSupport::resolve(&f->method, 0)); EXPECT_EQ(before + 1, Counters::getCounter(METHOD_RESOLVE_SYMBOL_UNREADABLE)); // Rejected by the readability probe, not by recovering from a real fault. @@ -677,7 +677,7 @@ TEST_F(HotspotResolveCrashProtectionTest, ResolveUsesMallocFallbackForOversizedB // 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)); + EXPECT_EQ(nullptr, HotspotSupport::resolve(&f->method, 0)); // Phase 1 accepted the name via the malloc fallback (no rejection, no // fault) and reached phase 2, which called GetMethodID with the fully @@ -727,7 +727,7 @@ TEST_F(HotspotResolveCrashProtectionTest, ResolveRejectsUnreadableSymbolAtMaxLen long long before = Counters::getCounter(METHOD_RESOLVE_SYMBOL_UNREADABLE); - EXPECT_EQ(nullptr, HotspotSupport::resolve(&f->method)); + EXPECT_EQ(nullptr, HotspotSupport::resolve(&f->method, 0)); EXPECT_EQ(before + 1, Counters::getCounter(METHOD_RESOLVE_SYMBOL_UNREADABLE)); EXPECT_FALSE(_pt->isProtected()); @@ -748,7 +748,7 @@ TEST_F(HotspotResolveCrashProtectionTest, ResolveReturnsNullForEmptySymbol) { long long before = Counters::getCounter(METHOD_RESOLVE_SYMBOL_UNREADABLE); - EXPECT_EQ(nullptr, HotspotSupport::resolve(&f->method)); + EXPECT_EQ(nullptr, HotspotSupport::resolve(&f->method, 0)); EXPECT_EQ(before + 1, Counters::getCounter(METHOD_RESOLVE_SYMBOL_UNREADABLE)); EXPECT_FALSE(_pt->isProtected()); @@ -760,7 +760,7 @@ 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)); + EXPECT_EQ(nullptr, HotspotSupport::resolve((const void*)JMETHODID_NOT_WALKABLE, 0)); EXPECT_EQ(before, Counters::getCounter(METHOD_RESOLVE_FAULT_RECOVERED)); EXPECT_FALSE(_pt->isProtected()); diff --git a/ddprof-lib/src/test/cpp/klassRegistry_ut.cpp b/ddprof-lib/src/test/cpp/klassRegistry_ut.cpp index b3aaad3038..ae8d1bf232 100644 --- a/ddprof-lib/src/test/cpp/klassRegistry_ut.cpp +++ b/ddprof-lib/src/test/cpp/klassRegistry_ut.cpp @@ -133,23 +133,23 @@ class KlassRegistryTest : public ::testing::Test { } // namespace TEST_F(KlassRegistryTest, PinUnknownKlassReturnsNull) { - EXPECT_EQ(nullptr, registry.pin(jni, klassAt(0))); + EXPECT_EQ(nullptr, registry.pin(jni, klassAt(0), registry.epoch())); ASSERT_TRUE(registry.add(jni, klassAt(1), mirror(1))); - EXPECT_EQ(nullptr, registry.pin(jni, klassAt(0))); - EXPECT_EQ(nullptr, registry.pin(jni, nullptr)); + EXPECT_EQ(nullptr, registry.pin(jni, klassAt(0), registry.epoch())); + EXPECT_EQ(nullptr, registry.pin(jni, nullptr, registry.epoch())); } 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))); + EXPECT_EQ(mirror(0), registry.pin(jni, klassAt(0), registry.epoch())); + EXPECT_EQ(mirror(1), registry.pin(jni, klassAt(1), registry.epoch())); } 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))); + EXPECT_EQ(nullptr, registry.pin(jni, klassAt(0), registry.epoch())); } TEST_F(KlassRegistryTest, ReAddingSameMirrorIsNoOp) { @@ -166,7 +166,7 @@ TEST_F(KlassRegistryTest, ReusedKlassAddressReplacesDeadEntry) { 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(mirror(1), registry.pin(jni, klassAt(0), registry.epoch())); EXPECT_EQ(1u, registry.size()); EXPECT_EQ(1, heap.liveWeaks()); } @@ -190,13 +190,13 @@ TEST_F(KlassRegistryTest, GrowthPurgesDeadEntries) { 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; + EXPECT_EQ(mirror(i), registry.pin(jni, klassAt(i), registry.epoch())) << i; } for (size_t i = 10; i < first; i++) { - EXPECT_EQ(nullptr, registry.pin(jni, klassAt(i))) << i; + EXPECT_EQ(nullptr, registry.pin(jni, klassAt(i), registry.epoch())) << i; } for (size_t i = first; i < first + more; i++) { - EXPECT_EQ(mirror(i), registry.pin(jni, klassAt(i))) << i; + EXPECT_EQ(mirror(i), registry.pin(jni, klassAt(i), registry.epoch())) << i; } } @@ -207,7 +207,7 @@ TEST_F(KlassRegistryTest, GrowthKeepsAllLiveEntries) { } EXPECT_EQ(n, registry.size()); for (size_t i = 0; i < n; i++) { - EXPECT_EQ(mirror(i), registry.pin(jni, klassAt(i))) << i; + EXPECT_EQ(mirror(i), registry.pin(jni, klassAt(i), registry.epoch())) << i; } } @@ -219,10 +219,78 @@ TEST_F(KlassRegistryTest, ClearDeletesEveryWeakRef) { EXPECT_EQ(0u, registry.size()); EXPECT_EQ(0, heap.liveWeaks()); EXPECT_EQ(0, heap.double_deletes); - EXPECT_EQ(nullptr, registry.pin(jni, klassAt(0))); + EXPECT_EQ(nullptr, registry.pin(jni, klassAt(0), registry.epoch())); // Usable again after clear(). ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); - EXPECT_EQ(mirror(0), registry.pin(jni, klassAt(0))); + EXPECT_EQ(mirror(0), registry.pin(jni, klassAt(0), registry.epoch())); +} + +// Epochs: a frame records registry.epoch() when sampled; pin() refuses it when +// the entry for its Klass* was registered later. Only registrations that may +// reuse a gone class's address bump the epoch. + +TEST_F(KlassRegistryTest, FirstInsertDoesNotBumpEpoch) { + uint32_t e0 = registry.epoch(); + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + ASSERT_TRUE(registry.add(jni, klassAt(1), mirror(1))); + EXPECT_EQ(e0, registry.epoch()); + // Re-adding the same pair (e.g. after a redefinition) is not a reuse. + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + EXPECT_EQ(e0, registry.epoch()); +} + +TEST_F(KlassRegistryTest, ReplacementBumpsEpochAndRejectsOlderFrames) { + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + uint32_t sampled_old = registry.epoch(); + heap.collect(&mirrors[0]); + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(1))); + EXPECT_NE(sampled_old, registry.epoch()); + + bool stale = false; + EXPECT_EQ(nullptr, registry.pin(jni, klassAt(0), sampled_old, &stale)); + EXPECT_TRUE(stale); + EXPECT_EQ(mirror(1), registry.pin(jni, klassAt(0), registry.epoch(), &stale)); + EXPECT_FALSE(stale); +} + +TEST_F(KlassRegistryTest, PurgeBumpsEpochSoReinsertRejectsOlderFrames) { + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + uint32_t sampled_old = registry.epoch(); + heap.collect(&mirrors[0]); + // Grow past the initial table so the dead entry is purged. + for (size_t i = 1; i < 1000; i++) { + ASSERT_TRUE(registry.add(jni, klassAt(i), mirror(i))); + } + EXPECT_NE(sampled_old, registry.epoch()); + // klassAt(0) comes back as a first insert, not a replacement. + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(1000))); + + bool stale = false; + EXPECT_EQ(nullptr, registry.pin(jni, klassAt(0), sampled_old, &stale)); + EXPECT_TRUE(stale); + // Classes that stayed live across the purge still accept their old frames. + EXPECT_EQ(mirror(1), registry.pin(jni, klassAt(1), sampled_old, &stale)); + EXPECT_FALSE(stale); +} + +TEST_F(KlassRegistryTest, UnloadedClassIsNotReportedStale) { + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + uint32_t sampled = registry.epoch(); + heap.collect(&mirrors[0]); + bool stale = true; + EXPECT_EQ(nullptr, registry.pin(jni, klassAt(0), sampled, &stale)); + EXPECT_FALSE(stale); +} + +TEST_F(KlassRegistryTest, EpochComparisonSurvivesWraparound) { + // Frames and entries are compared modulo 2^EPOCH_BITS, so an entry + // registered just before the wrap still accepts frames sampled just after. + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + uint32_t registered = registry.epoch(); + const uint32_t mask = (1u << KlassRegistry::EPOCH_BITS) - 1; + EXPECT_EQ(mirror(0), registry.pin(jni, klassAt(0), (registered + 5) & mask)); + // A frame sampled before the registration is refused even across the wrap. + EXPECT_EQ(nullptr, registry.pin(jni, klassAt(0), (registered - 1) & mask)); } // --------------------------------------------------------------------------- diff --git a/doc/LazyJmethodIdResolution.md b/doc/LazyJmethodIdResolution.md index 9df5e46fc8..e23aaf89c3 100644 --- a/doc/LazyJmethodIdResolution.md +++ b/doc/LazyJmethodIdResolution.md @@ -79,7 +79,15 @@ check at resolve time can separate them, so **the frame itself has to carry an incarnation identity.** The existing modes never hit this, because unloadable classes always get jmethodIDs. -### Option A: reuse epoch (portable fallback) +### Option A: reuse epoch (portable fallback) — implemented + +Implemented as described below (`KlassRegistry::epoch()`/`bumpEpoch()`, `pin(…, sampled_epoch, &stale)`, +counter `method_resolve_lazy_stale`). It also relies on one invariant, which is now enforced: every class is +registered before any of its methods can run. ClassPrepare guarantees this; if `add()` fails, +`registerClass` preloads that class's jmethodIDs instead (`klass_registry_add_failed`), so no raw +frame of an unregistered class is ever produced. Without that, a later class at the same address +could be inserted fresh, without a bump, and accept the unregistered class's frames. + - The registry keeps a global `_epoch`, and each entry records the epoch it was registered at. @@ -156,7 +164,18 @@ initialized, and its own answer can confirm our offset: - JNI calls ignore module encapsulation, so `jdk.jfr.internal` not being exported shouldn't matter. Verify this when implementing. -### Frame bit budget (both options) +### Frame bits — implemented + +`FrameType::encodeRaw` / `tagPointer` / `rawTag` / `untagPointer` (frame.h) carry a 26-bit tag: +the low 10 bits in bci bits 16–19 and 24–29, and the high 16 bits in pointer bits 48–63. A +`Method*` with any of those pointer bits set is stored as `JMETHODID_NOT_WALKABLE` and counted +(`walkvm_raw_frame_untaggable`), never stored without its tag. `resolve()` now takes +`(method, bci)` so it can take the tag back out. The JFR writer masks bci to 16 bits and the +type to 3 bits, so tag bits never reach the recording. `hasTheSameMethod` compares untagged +pointers. + +The original budget analysis: + - The bci field has 10 spare bits: 16–19 and 24–29. Bit 31 must stay clear, because `isRawPointer()` requires `bci > 0`. @@ -199,9 +218,11 @@ through the `com.sun.management:type=DiagnosticCommand` MBean. It lives in | `fjmethodid=lazy` parsing, `Lazy_loaded` state, registry, resolve path, counters | done | | gtests: `klassRegistry_ut` (10), lazy cases in `jvmSupport_ut` and `arguments_ut` | pass (macOS) | | `LazyJmethodIdTest`: custom-loader frames resolve; frames of unloaded class dropped (`method_resolve_lazy_unloaded=5`) | pass (macOS) | -| Deterministic ABA gtest (re-register a new mirror at the same `Klass*` with `M` in its methods; fails before the fix) | open | -| Frame bits: bci spare bits plus `Method*` top bits with VA check | open | -| Option A (epoch fallback) | open | +| ABA gtest `hotspotLazyResolve_ut` (6 cases: controls + replacement, purge-then-reinsert, before/after frames differ) | pass (macOS) | +| Frame bits: bci spare bits plus `Method*` top bits; untaggable pointers dropped | done | +| Epoch tests in `klassRegistry_ut`, tag round-trip tests in `frame_ut` | pass (macOS) | +| Linux-only `hotspotMethodId_ut` cases: `resolve()` call sites updated to `(ptr, 0)` but **not compiled on macOS** | verify on Linux | +| Option A (epoch fallback) | done | | Option B (trace-ID offset, self-calibration, JFR cross-check) | open | | Linux runs: Linux-only `hotspotMethodId_ut` cases, ASan, aarch64, musl, benchmark | open | | Per-dump cache for repeated raw frames (each occurrence currently re-pins) | open | From a3bab23d08590a102936ce67a5a2ff9a083fe548 Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Thu, 15 Oct 2026 11:32:13 +0200 Subject: [PATCH 03/17] Locate Klass::_trace_id by probing array klasses KlassTraceId::calibrate() makes the JVM create six array klasses of java.lang.Void back to back and accepts the single 8-byte-aligned offset within sizeof(Klass) at which all of them hold distinct JFR trace IDs (raw >> 16) no further apart than consecutive creation allows; anything else, including an ambiguous match, leaves the offset unknown. Runs when fjmethodid=lazy is selected and publishes the result in the klass_trace_id_offset gauge. Not used by the sampler yet. Co-Authored-By: Claude Opus 5.5 --- ddprof-lib/src/main/cpp/counters.h | 3 + .../src/main/cpp/hotspot/hotspotSupport.cpp | 5 + .../src/main/cpp/hotspot/hotspotSupport.h | 4 + .../src/main/cpp/hotspot/klassTraceId.cpp | 107 ++++++++++++++++ .../src/main/cpp/hotspot/klassTraceId.h | 64 +++++++++ ddprof-lib/src/main/cpp/jvmSupport.cpp | 1 + ddprof-lib/src/test/cpp/klassTraceId_ut.cpp | 121 ++++++++++++++++++ .../profiler/cpu/LazyJmethodIdTest.java | 17 +++ doc/LazyJmethodIdResolution.md | 34 ++++- 9 files changed, 355 insertions(+), 1 deletion(-) create mode 100644 ddprof-lib/src/main/cpp/hotspot/klassTraceId.cpp create mode 100644 ddprof-lib/src/main/cpp/hotspot/klassTraceId.h create mode 100644 ddprof-lib/src/test/cpp/klassTraceId_ut.cpp diff --git a/ddprof-lib/src/main/cpp/counters.h b/ddprof-lib/src/main/cpp/counters.h index 294d0dff19..d94a46d199 100644 --- a/ddprof-lib/src/main/cpp/counters.h +++ b/ddprof-lib/src/main/cpp/counters.h @@ -189,6 +189,9 @@ X(METHOD_RESOLVE_LAZY_STALE, "method_resolve_lazy_stale") \ X(KLASS_REGISTRY_ADD_FAILED, "klass_registry_add_failed") \ X(WALKVM_RAW_FRAME_UNTAGGABLE, "walkvm_raw_frame_untaggable") \ + /* Gauge: Klass::_trace_id offset found by KlassTraceId::calibrate(), -1 if \ + * calibration ran and failed, 0 if it has not run. */ \ + 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/hotspot/hotspotSupport.cpp b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp index 1efae51db5..65bc9e5735 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp @@ -12,6 +12,7 @@ #include "guards.h" #include "hotspot/hotspotSupport.h" #include "hotspot/jitCodeCache.h" +#include "hotspot/klassTraceId.h" #include "hotspot/vmStructs.inline.h" #include "jvmSupport.inline.h" #include "jvmThread.h" @@ -1472,6 +1473,10 @@ bool HotspotSupport::canResolveLazily() { return VMStructs::hasKlassMethods(); } +void HotspotSupport::initLazyResolution(JNIEnv* jni) { + KlassTraceId::calibrate(jni); +} + bool HotspotSupport::registerClass(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass) { jint status = 0; // Array and primitive classes have no methods, so no frame ever names them diff --git a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h index fb4cce5147..3b0435dbc3 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h @@ -186,6 +186,10 @@ class HotspotSupport { // back to the partial preload mode. static bool canResolveLazily(); + // fjmethodid=lazy setup that needs JNI: locates Klass::_trace_id + // (KlassTraceId::calibrate). + static void initLazyResolution(JNIEnv* jni); + // Resolve a raw-pointer frame to a jmethodID at dumping time. Takes the // frame's method field and encoded bci as stored, tag bits included. static jmethodID resolve(const void* tagged_method, int bci); 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..a280031493 --- /dev/null +++ b/ddprof-lib/src/main/cpp/hotspot/klassTraceId.cpp @@ -0,0 +1,107 @@ +/* + * 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; + +// 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) { + if (offset() >= 0) { + return 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); + Counters::set(KLASS_TRACE_ID_OFFSET, found); + 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..6cd470e907 --- /dev/null +++ b/ddprof-lib/src/main/cpp/hotspot/klassTraceId.h @@ -0,0 +1,64 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#ifndef _HOTSPOT_KLASSTRACEID_H +#define _HOTSPOT_KLASSTRACEID_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; + +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. Safe + * to call more than once; later calls reuse the existing probes. Returns + * true if an offset is known afterwards. + */ + static bool calibrate(JNIEnv* jni); + + /** The calibrated offset, or -1. */ + static int offset() { return __atomic_load_n(&_offset, __ATOMIC_ACQUIRE); } + + /** 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/jvmSupport.cpp b/ddprof-lib/src/main/cpp/jvmSupport.cpp index 440270d0d7..2d86c51156 100644 --- a/ddprof-lib/src/main/cpp/jvmSupport.cpp +++ b/ddprof-lib/src/main/cpp/jvmSupport.cpp @@ -75,6 +75,7 @@ void JVMSupport::initExecution(Arguments& args, jvmtiEnv* jvmti, JNIEnv* jni) { JMethodIDLoadStats state = Fully_loaded; if (VM::isHotspot() && !HotspotSupport::shouldPreloadJmethodIDs(args)) { if (args._lazy_jmethodID && HotspotSupport::canResolveLazily()) { + HotspotSupport::initLazyResolution(jni); state = Lazy_loaded; } else { HotspotSupport::initClassloaderInfo(jni); 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-test/src/test/java/com/datadoghq/profiler/cpu/LazyJmethodIdTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyJmethodIdTest.java index 0242380969..ef4c15760b 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyJmethodIdTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyJmethodIdTest.java @@ -133,6 +133,23 @@ public void framesOfUnloadedClassAreDroppedSafely() throws Exception { } } + /** + * 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()); diff --git a/doc/LazyJmethodIdResolution.md b/doc/LazyJmethodIdResolution.md index e23aaf89c3..79279556b6 100644 --- a/doc/LazyJmethodIdResolution.md +++ b/doc/LazyJmethodIdResolution.md @@ -114,7 +114,7 @@ It is not in `gHotSpotVMStructs`. From the local OpenJDK sources: JFR is recording. It is globally monotonic, so it is unique per incarnation. - **Encoding:** the low 16 bits are JFR tag/epoch bits that JFR changes while it runs. Use `raw >> 16` only. -- **Not present** in builds without JFR: JDK 8 before the 8u272 backport, and +- **Not present** in builds without JFR: JDK 8 before 8u262 (when JFR reached upstream 8u), and `--disable-jfr` builds. Option A is still needed as a fallback. | Source tree | `_trace_id` declared after | Nearest vmStructs anchor | @@ -133,6 +133,38 @@ alone. Advantages over A: no global state, no trace splitting, and two incarnations get the same stored bits only if a multiple of 2^N classes were created between them. +#### Offset discovery — implemented (calibration only, not used by the sampler yet) + +Offsets can move even between minor releases, so there is no version table. `KlassTraceId::calibrate()` +(hotspot/klassTraceId.{h,cpp}) runs when lazy mode is selected: + +- It resolves `[[[[[[Ljava/lang/Void;`, which makes the JVM create up to six array klasses back + to back, each getting the next trace ID. No classes of our own are defined. +- It scans every 8-byte-aligned offset in `[8, sizeof(Klass))` and accepts one only if all six + probes hold nonzero `raw >> 16` values, pairwise distinct, within `5 + ID_SLACK (64)` of each + other. This rejects pointers (a few hundred bytes apart, so `>> 16` collapses them), equal small + ints, and compressed-klass bits in a mark word (far apart). More than one match means failure. +- The result goes to the `klass_trace_id_offset` gauge: the offset, -1 on failure, 0 if + calibration hasn't run. + +**Assumptions it relies on, swept across 36 GA tags** (17 GA – 17.0.17, 21 GA – 21.0.9, 22–26, 28+18): +`_trace_id` is declared in `class Klass` (so array klasses have it, and it lies inside +`sizeof(Klass)`); `arrayKlass.cpp` has `INIT_ID(this)`; `TRACE_ID_SHIFT` is 16; `next_class_id` +shifts the counter by it. All hold on every tag. The field's neighbour was `_access_flags` through +24 (with different preceding fields in 24) and `_archived_mirror_index` from 25 on. LTS lines +swept too, from blob-less partial clones of openjdk/jdk8u, jdk11u and jdk25u: + +| Line | GA tags | Result | Declared after | +|---|---|---|---| +| 8u | 8u201 – 8u252 (7) | no `_trace_id` (pre-JFR): calibration fails, epoch fallback | — | +| 8u | 8u262 – 8u504 (29) | all assumptions hold | `_biased_lock_revocation_count` | +| 11u | 11.0.2 – 11.0.32.1 (36) | all assumptions hold | `_access_flags` | +| 25u | 25 GA – 25.0.4.1 (6) | all assumptions hold | `_archived_mirror_index` | + +So 107 GA tags in total. The sweep compares neighbouring declarations, not absolute byte offsets. +That's enough, since calibration assumes none. Script: `/tmp/jfrsweep/sweep2.sh ...` +(Mac), with `P=hotspot/src/share/vm/` for 8u. + #### Confirming the offset **Self-calibration (decides at startup).** Register a few probe classes we define in a From a9b67775ac2cde12750a7218099f85da1addbe57 Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Thu, 15 Oct 2026 12:44:15 +0200 Subject: [PATCH 04/17] Tag raw Method* frames with the holder's JFR trace ID when calibrated When KlassTraceId found Klass::_trace_id, walkVM now stores the low 26 bits of the holder's trace ID (read with SafeAccess via the new VMMethod::holderOrNull()) as the raw frame's tag instead of the registry epoch, and resolveViaRegistry() compares it with the pinned class's trace ID. The ID is unique per load of a class, so frames of an earlier class at a reused Klass* are refused without the epoch, and unrelated registry bumps no longer change a stack's frames. Frames whose holder trace ID is unreadable are stored as not walkable. The tag source is chosen once per process: calibration now runs only on the first lazy start, and the klass_trace_id_offset gauge is republished on every start since counters are reset then. The end-to-end lazy-resolve gtest runs every case under both tag sources. Co-Authored-By: Claude Opus 5.5 --- ddprof-lib/src/main/cpp/counters.h | 9 +- .../src/main/cpp/hotspot/hotspotSupport.cpp | 72 +++++++++++---- .../src/main/cpp/hotspot/hotspotSupport.h | 23 +++-- .../src/main/cpp/hotspot/klassTraceId.cpp | 8 +- .../src/main/cpp/hotspot/klassTraceId.h | 32 ++++++- ddprof-lib/src/main/cpp/hotspot/vmStructs.h | 5 ++ .../src/main/cpp/hotspot/vmStructs.inline.h | 13 +++ .../src/test/cpp/hotspotLazyResolve_ut.cpp | 90 ++++++++++++++++--- 8 files changed, 204 insertions(+), 48 deletions(-) diff --git a/ddprof-lib/src/main/cpp/counters.h b/ddprof-lib/src/main/cpp/counters.h index d94a46d199..f9f99418ee 100644 --- a/ddprof-lib/src/main/cpp/counters.h +++ b/ddprof-lib/src/main/cpp/counters.h @@ -184,13 +184,14 @@ /* STALE: the frame predates the class now registered at its Klass* \ * address (Klass* reuse after unloading); serializes as "unknown". \ * ADD_FAILED: registration failed, so the class was preloaded instead. \ - * UNTAGGABLE: a Method* using address bits the raw-frame tag needs; the \ - * frame is stored as not walkable. */ \ + * 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") \ X(KLASS_REGISTRY_ADD_FAILED, "klass_registry_add_failed") \ X(WALKVM_RAW_FRAME_UNTAGGABLE, "walkvm_raw_frame_untaggable") \ - /* Gauge: Klass::_trace_id offset found by KlassTraceId::calibrate(), -1 if \ - * calibration ran and failed, 0 if it has not run. */ \ + /* Gauge, set at each fjmethodid=lazy start: the Klass::_trace_id offset \ + * raw frames are tagged with, or -1 if they use the registry epoch. 0 if \ + * lazy mode never started. */ \ 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 \ diff --git a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp index 65bc9e5735..73594ad92e 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp @@ -190,21 +190,13 @@ static void fillFrameTypes(ASGCT_CallFrame *frames, int num_frames, VMNMethod *n static_assert(KlassRegistry::EPOCH_BITS == FrameType::RAW_TAG_BITS, "a raw frame must store the whole KlassRegistry epoch"); -// Fill the frame with raw method pointer, tagged with the KlassRegistry epoch -// it was sampled under (see frame.h for where the tag bits go). +// Fill the frame with raw method pointer and its tag (see frame.h for where +// the tag bits go, HotspotSupport::_tag_with_trace_id for what they hold). static void fillFrameRaw(ASGCT_CallFrame& frame, FrameTypeId type, int bci, const VMMethod* method, - uint32_t epoch) { + uint32_t tag) { NO_INJECTION_ASSERT(method != nullptr); - if (!FrameType::canTagPointer(method)) { - // The tag's high bits would collide with address bits. Without the - // 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); - return; - } - frame.bci = FrameType::encodeRaw(type, bci, epoch); - frame.method = FrameType::tagPointer(method, epoch); + frame.bci = FrameType::encodeRaw(type, bci, tag); + frame.method = FrameType::tagPointer(method, tag); } void HotspotSupport::fillJavaFrame(ASGCT_CallFrame& frame, FrameTypeId type, int bci, @@ -225,7 +217,17 @@ 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, _klass_registry.epoch()); + uint32_t tag; + if (!FrameType::canTagPointer(method) || !rawFrameTag(method, &tag)) { + // 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 { + fillFrameRaw(frame, type, bci, method, tag); + } } else { // fjmethodid=true: all jmethodIDs should be preloaded, so nullptr is // unexpected — a transient invalidation window or a missed preload. @@ -1468,13 +1470,33 @@ bool HotspotSupport::loadMethodIDsIfNeededImpl(jvmtiEnv *jvmti, JNIEnv *jni, jcl } KlassRegistry HotspotSupport::_klass_registry; +bool HotspotSupport::_tag_with_trace_id = false; bool HotspotSupport::canResolveLazily() { return VMStructs::hasKlassMethods(); } void HotspotSupport::initLazyResolution(JNIEnv* jni) { - KlassTraceId::calibrate(jni); + // calibrate() only probes once per process, so this is stable across + // profiler restarts and raw frames of an earlier session keep their meaning. + _tag_with_trace_id = 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, _tag_with_trace_id ? KlassTraceId::offset() : -1); + TEST_LOG("Raw frame tags: %s", _tag_with_trace_id ? "klass trace id" : "registry epoch"); +} + +bool HotspotSupport::rawFrameTag(const VMMethod* method, uint32_t* tag) { + if (!_tag_with_trace_id) { + *tag = _klass_registry.epoch(); + return true; + } + uint64_t id; + if (!KlassTraceId::read(method->holderOrNull(), &id)) { + return false; + } + *tag = (uint32_t)id & FrameType::RAW_TAG_MASK; + return true; } bool HotspotSupport::registerClass(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass) { @@ -1816,7 +1838,7 @@ jmethodID HotspotSupport::resolve(const void* tagged_method, int bci) { return nullptr; } const void* method = FrameType::untagPointer(tagged_method); - const uint32_t epoch = FrameType::rawTag(bci, tagged_method); + const uint32_t tag = FrameType::rawTag(bci, tagged_method); // 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 @@ -1879,7 +1901,7 @@ jmethodID HotspotSupport::resolve(const void* tagged_method, int bci) { return nullptr; } if (lazy) { - return resolveViaRegistry(vm_method, holder, idnum, epoch); + return resolveViaRegistry(vm_method, holder, idnum, tag); } if (existing_id != nullptr) { return existing_id; @@ -1892,10 +1914,14 @@ jmethodID HotspotSupport::resolve(const void* tagged_method, int bci) { // 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. jmethodID HotspotSupport::resolveViaRegistry(VMMethod* vm_method, const void* holder, int idnum, - uint32_t epoch) { + uint32_t tag) { JNIEnv* jni = VM::jni(); bool stale = false; - jclass clz = _klass_registry.pin(jni, holder, epoch, &stale); + // With trace-ID tags the registry's epoch plays no part: passing the current + // epoch makes pin() accept any registration, and the trace ID is compared + // once the class is pinned. + uint32_t sampled_epoch = _tag_with_trace_id ? _klass_registry.epoch() : tag; + jclass clz = _klass_registry.pin(jni, holder, sampled_epoch, &stale); if (clz == nullptr) { // Stale: the class registered at `holder` arrived after the sample, so the // frame belongs to an earlier class at the same address. Otherwise the @@ -1906,6 +1932,14 @@ jmethodID HotspotSupport::resolveViaRegistry(VMMethod* vm_method, const void* ho } jmethodID method_id = nullptr; + uint64_t trace_id = 0; + if (_tag_with_trace_id && + (!KlassTraceId::read(holder, &trace_id) || ((uint32_t)trace_id & FrameType::RAW_TAG_MASK) != tag)) { + // The class at `holder` is a different load than the one sampled. + Counters::increment(METHOD_RESOLVE_LAZY_STALE); + jni->DeleteLocalRef(clz); + return nullptr; + } // The pinned class keeps its metadata alive, so its methods array is safe // to read. A miss means the Method* was freed (and possibly its holder // address reused), or it is an old version replaced by a redefinition. diff --git a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h index 3b0435dbc3..760a644774 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h @@ -54,17 +54,28 @@ class HotspotSupport { // resolve() before it touches a raw Method*. See klassRegistry.h. static KlassRegistry _klass_registry; + // fjmethodid=lazy: what a raw frame's tag holds (frame.h). True: the low + // RAW_TAG_BITS of the holder's JFR trace ID (KlassTraceId), unique per + // load of a class. False: _klass_registry's epoch. Set once per process by + // initLazyResolution(), before any raw frame exists. + static bool _tag_with_trace_id; + + // The tag to store with a raw frame of `method`; false if it cannot be + // determined (unreadable holder or trace ID). Signal safe. + static bool rawFrameTag(const VMMethod* method, uint32_t* tag); + // 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); // fjmethodid=lazy half of resolve(): pins the class registered under - // `holder` (refusing it if registered after `epoch`, the frame's sample - // epoch), checks that `vm_method` is still one of its methods, and only - // then reads or allocates the jmethodID. `holder` and `idnum` were read - // from the unverified Method* and are only used as lookup keys. + // `holder`, refuses it if `tag` (the frame's, see _tag_with_trace_id) + // shows the frame came from an earlier class at that address, checks that + // `vm_method` is still one of its methods, and only then reads or + // allocates the jmethodID. `holder` and `idnum` were read from the + // unverified Method* and are only used as lookup keys. static jmethodID resolveViaRegistry(VMMethod* vm_method, const void* holder, int idnum, - uint32_t epoch); + uint32_t tag); // Runs getJavaTraceAsync() under withUcontextFaultRecovery(), then layers // on its two post-processing steps: resolving frame types for the top @@ -187,7 +198,7 @@ class HotspotSupport { static bool canResolveLazily(); // fjmethodid=lazy setup that needs JNI: locates Klass::_trace_id - // (KlassTraceId::calibrate). + // (KlassTraceId::calibrate) and picks the raw-frame tag source. static void initLazyResolution(JNIEnv* jni); // Resolve a raw-pointer frame to a jmethodID at dumping time. Takes the diff --git a/ddprof-lib/src/main/cpp/hotspot/klassTraceId.cpp b/ddprof-lib/src/main/cpp/hotspot/klassTraceId.cpp index a280031493..1b51679045 100644 --- a/ddprof-lib/src/main/cpp/hotspot/klassTraceId.cpp +++ b/ddprof-lib/src/main/cpp/hotspot/klassTraceId.cpp @@ -12,6 +12,7 @@ #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; @@ -65,9 +66,11 @@ int KlassTraceId::findOffset(const void* const* klasses, int count, size_t klass } bool KlassTraceId::calibrate(JNIEnv* jni) { - if (offset() >= 0) { - return true; + // 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; @@ -97,7 +100,6 @@ bool KlassTraceId::calibrate(JNIEnv* jni) { } int found = findOffset(klasses, PROBE_COUNT, klass_size); - Counters::set(KLASS_TRACE_ID_OFFSET, found); TEST_LOG("KlassTraceId offset=%d (sizeof(Klass)=%d)", found, (int)klass_size); if (found < 0) { return false; diff --git a/ddprof-lib/src/main/cpp/hotspot/klassTraceId.h b/ddprof-lib/src/main/cpp/hotspot/klassTraceId.h index 6cd470e907..c24fa00a6c 100644 --- a/ddprof-lib/src/main/cpp/hotspot/klassTraceId.h +++ b/ddprof-lib/src/main/cpp/hotspot/klassTraceId.h @@ -6,6 +6,8 @@ #ifndef _HOTSPOT_KLASSTRACEID_H #define _HOTSPOT_KLASSTRACEID_H +#include "safeAccess.h" + #include #include #include @@ -31,6 +33,7 @@ */ class KlassTraceId { static int _offset; + static bool _calibrated; public: // Array dimensions created by calibrate(): one probe klass per dimension. @@ -48,15 +51,38 @@ class KlassTraceId { static int findOffset(const void* const* klasses, int count, size_t klass_size); /** - * Creates the probe klasses through JNI and records the offset found. Safe - * to call more than once; later calls reuse the existing probes. Returns - * true if an offset is known afterwards. + * Creates the probe klasses through JNI and records the offset found. Only + * the first call per process calibrates; later calls return its result, so + * the tag source of raw frames (see HotspotSupport) never changes while + * frames from an earlier profiling session may still be stored. 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); } }; diff --git a/ddprof-lib/src/main/cpp/hotspot/vmStructs.h b/ddprof-lib/src/main/cpp/hotspot/vmStructs.h index 6abe409902..e7c4c9b6c6 100644 --- a/ddprof-lib/src/main/cpp/hotspot/vmStructs.h +++ b/ddprof-lib/src/main/cpp/hotspot/vmStructs.h @@ -957,6 +957,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(); diff --git a/ddprof-lib/src/main/cpp/hotspot/vmStructs.inline.h b/ddprof-lib/src/main/cpp/hotspot/vmStructs.inline.h index b69cfef4cf..307f5accdc 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)); diff --git a/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp b/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp index 1c6e6e2ebe..086407ed83 100644 --- a/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp +++ b/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp @@ -8,6 +8,7 @@ #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" @@ -54,6 +55,8 @@ class JVMSupportTestAccessor { class HotspotSupportTestAccessor { public: static KlassRegistry& registry() { return HotspotSupport::_klass_registry; } + static bool getTagWithTraceId() { return HotspotSupport::_tag_with_trace_id; } + static void setTagWithTraceId(bool v) { HotspotSupport::_tag_with_trace_id = v; } }; class ProfilerTestAccessor { @@ -143,6 +146,7 @@ struct FakeMethodArray { struct FakeKlass { FakeIdCache* ids; FakeMethodArray* methods; + uint64_t trace_id; // JFR encoding: (id << 16) | tag bits }; const VMStructsTestAccessor::State LAYOUT = { @@ -182,6 +186,7 @@ struct FakeClass { cpool.holder = &klass; klass.ids = &ids; klass.methods = &methods; + klass.trace_id = (uint64_t)1000 << 16; methods.len = 1; methods.data[0] = &method; jmethod_id_slot = &method; @@ -276,7 +281,7 @@ FakeJvm* FakeJvm::s_instance = nullptr; } // namespace -class HotspotLazyResolveTest : public ::testing::Test { +class HotspotLazyResolveTest : public ::testing::TestWithParam { protected: VMStructsTestAccessor _layout{LAYOUT}; FakeJvm _jvm; @@ -285,8 +290,14 @@ class HotspotLazyResolveTest : public ::testing::Test { FakeObject _new_mirror{2}; bool _saved_hotspot = false; bool _saved_force = true; + bool _saved_tag_with_trace_id = false; + int _saved_trace_id_offset = -1; JVMSupportTestAccessor::LoadState _saved_state{}; + // The parameter: tag raw frames with the holder's trace ID (true) or the + // registry epoch (false). Every Klass*-reuse case must hold for both. + static bool traceIdTags() { return GetParam(); } + void SetUp() override { _saved_hotspot = VMTestAccessor::getHotspot(); VMTestAccessor::setHotspot(true); @@ -296,6 +307,10 @@ class HotspotLazyResolveTest : public ::testing::Test { // not forced, which is how fjmethodid=lazy configures the profiler. _saved_force = ProfilerTestAccessor::getForceJmethodID(Profiler::instance()); ProfilerTestAccessor::setForceJmethodID(Profiler::instance(), false); + _saved_tag_with_trace_id = HotspotSupportTestAccessor::getTagWithTraceId(); + _saved_trace_id_offset = KlassTraceId::offset(); + HotspotSupportTestAccessor::setTagWithTraceId(traceIdTags()); + KlassTraceId::setOffsetForTest(traceIdTags() ? (int)offsetof(FakeKlass, trace_id) : -1); // resolve() installs crash protection on the current ProfiledThread. ProfiledThread::initCurrentThread(); ASSERT_NE(nullptr, ProfiledThread::current()); @@ -304,6 +319,8 @@ class HotspotLazyResolveTest : public ::testing::Test { void TearDown() override { HotspotSupportTestAccessor::registry().clear(_jvm.jni()); ProfiledThread::release(); + KlassTraceId::setOffsetForTest(_saved_trace_id_offset); + HotspotSupportTestAccessor::setTagWithTraceId(_saved_tag_with_trace_id); ProfilerTestAccessor::setForceJmethodID(Profiler::instance(), _saved_force); JVMSupportTestAccessor::setLoadState(_saved_state); VMTestAccessor::setHotspot(_saved_hotspot); @@ -314,6 +331,15 @@ class HotspotLazyResolveTest : public ::testing::Test { _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{}; @@ -328,13 +354,13 @@ class HotspotLazyResolveTest : public ::testing::Test { } }; -TEST_F(HotspotLazyResolveTest, LiveClassResolves) { +TEST_P(HotspotLazyResolveTest, LiveClassResolves) { registerClass(_old_mirror); ASGCT_CallFrame frame = sample(); EXPECT_EQ(_cls.jmethodId(), resolve(frame)); } -TEST_F(HotspotLazyResolveTest, UnloadedClassDoesNotResolve) { +TEST_P(HotspotLazyResolveTest, UnloadedClassDoesNotResolve) { registerClass(_old_mirror); ASGCT_CallFrame frame = sample(); _jvm.collect(_old_mirror); @@ -344,12 +370,11 @@ TEST_F(HotspotLazyResolveTest, UnloadedClassDoesNotResolve) { // 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) { +TEST_P(HotspotLazyResolveTest, FrameFromBeforeKlassReuseDoesNotResolveToNewClass) { registerClass(_old_mirror); ASGCT_CallFrame frame = sample(); - _jvm.collect(_old_mirror); - registerClass(_new_mirror); + loadNewClassAtSameAddress(); EXPECT_EQ(nullptr, resolve(frame)); } @@ -357,7 +382,7 @@ TEST_F(HotspotLazyResolveTest, FrameFromBeforeKlassReuseDoesNotResolveToNewClass // 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) { +TEST_P(HotspotLazyResolveTest, FrameFromBeforeKlassReuseAfterPurgeDoesNotResolveToNewClass) { registerClass(_old_mirror); ASGCT_CallFrame frame = sample(); _jvm.collect(_old_mirror); @@ -374,16 +399,16 @@ TEST_F(HotspotLazyResolveTest, FrameFromBeforeKlassReuseAfterPurgeDoesNotResolve ASSERT_EQ(nullptr, registry.pin(_jvm.jni(), _cls.klassPtr(), registry.epoch())) << "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_EQ(nullptr, resolve(frame)); } // Frames sampled from the new class after the reuse must still resolve. -TEST_F(HotspotLazyResolveTest, FrameFromAfterKlassReuseResolves) { +TEST_P(HotspotLazyResolveTest, FrameFromAfterKlassReuseResolves) { registerClass(_old_mirror); - _jvm.collect(_old_mirror); - registerClass(_new_mirror); + loadNewClassAtSameAddress(); ASGCT_CallFrame frame = sample(); EXPECT_EQ(_cls.jmethodId(), resolve(frame)); @@ -392,12 +417,51 @@ TEST_F(HotspotLazyResolveTest, FrameFromAfterKlassReuseResolves) { // 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) { +TEST_P(HotspotLazyResolveTest, FramesFromBeforeAndAfterKlassReuseDiffer) { registerClass(_old_mirror); ASGCT_CallFrame before = sample(); - _jvm.collect(_old_mirror); - registerClass(_new_mirror); + loadNewClassAtSameAddress(); ASGCT_CallFrame after = sample(); EXPECT_FALSE(before.bci == after.bci && before.method == after.method); } + +// Trace-ID tags identify the class load itself, so a registry epoch bump +// caused by an unrelated class leaves them unchanged and the same stack keeps +// deduplicating into one trace. Epoch tags change with every bump. +TEST_P(HotspotLazyResolveTest, UnrelatedReuseChangesFramesOnlyWithEpochTags) { + registerClass(_old_mirror); + ASGCT_CallFrame before = sample(); + + // Another class at another address is unloaded and its address reused. + 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(); + bool same = before.bci == after.bci && before.method == after.method; + EXPECT_EQ(traceIdTags(), same); + EXPECT_EQ(_cls.jmethodId(), resolve(before)); + EXPECT_EQ(_cls.jmethodId(), resolve(after)); +} + +// The JFR tag bits below the ID change while JFR runs; they must not affect +// the frame's tag or its resolution. +TEST_P(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_EQ(_cls.jmethodId(), resolve(before)); +} + +INSTANTIATE_TEST_SUITE_P(TagSource, HotspotLazyResolveTest, ::testing::Bool(), + [](const ::testing::TestParamInfo& info) { + return info.param ? "TraceId" : "Epoch"; + }); From cd01bdab5a30f8392a6a99962efca218947cd482 Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Thu, 15 Oct 2026 13:21:06 +0200 Subject: [PATCH 05/17] Mark each raw frame's tag kind and validate the trace-ID offset against JFR in CI The top bit of a raw frame's tag now says whether the rest is a trace ID or a registry epoch (25 bits each), and resolve() goes by the frame's own kind rather than the current tag source. In debug builds, resolveViaRegistry() logs the trace ID it read for each class it allocates jmethodIDs for. LazyTraceIdJfrValidationTest runs a child JVM (new ExternalLauncher mode lazy-trace-id) with the profiler in lazy mode and JFR's own sampler, and requires JFR's RecordedClass.getId() for the probe class to equal that ID. The spin class generator moves to a shared SpinClasses test helper. The profiler does not call into JFR at runtime: JVM.getClassId() reaches JFR state that only exists once its recorder has been created, and the profiler itself initializes jdk.jfr.internal.JVM (tsc.cpp), so that class being initialized is no sign the recorder exists. Co-Authored-By: Claude Opus 5.5 --- .../src/main/cpp/hotspot/hotspotSupport.cpp | 50 ++++--- .../src/main/cpp/hotspot/hotspotSupport.h | 16 ++- .../src/main/cpp/hotspot/klassRegistry.h | 7 +- .../src/main/cpp/hotspot/klassTraceId.h | 6 +- .../src/test/cpp/hotspotLazyResolve_ut.cpp | 19 +++ .../datadoghq/profiler/ExternalLauncher.java | 13 ++ .../com/datadoghq/profiler/SpinClasses.java | 66 +++++++++ .../profiler/cpu/LazyJmethodIdTest.java | 52 +------ .../cpu/LazyTraceIdJfrValidationTest.java | 134 ++++++++++++++++++ doc/LazyJmethodIdResolution.md | 101 +++++++------ 10 files changed, 342 insertions(+), 122 deletions(-) create mode 100644 ddprof-test/src/test/java/com/datadoghq/profiler/SpinClasses.java create mode 100644 ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyTraceIdJfrValidationTest.java diff --git a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp index 73594ad92e..c1bf1a86e4 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp @@ -187,9 +187,6 @@ static void fillFrameTypes(ASGCT_CallFrame *frames, int num_frames, VMNMethod *n } } -static_assert(KlassRegistry::EPOCH_BITS == FrameType::RAW_TAG_BITS, - "a raw frame must store the whole KlassRegistry epoch"); - // Fill the frame with raw method pointer and its tag (see frame.h for where // the tag bits go, HotspotSupport::_tag_with_trace_id for what they hold). static void fillFrameRaw(ASGCT_CallFrame& frame, FrameTypeId type, int bci, const VMMethod* method, @@ -1470,32 +1467,34 @@ bool HotspotSupport::loadMethodIDsIfNeededImpl(jvmtiEnv *jvmti, JNIEnv *jni, jcl } KlassRegistry HotspotSupport::_klass_registry; -bool HotspotSupport::_tag_with_trace_id = false; +volatile bool HotspotSupport::_tag_with_trace_id = false; + +static_assert(KlassRegistry::EPOCH_BITS == FrameType::RAW_TAG_BITS - 1, + "a raw frame's tag holds a whole epoch plus the tag-kind bit"); bool HotspotSupport::canResolveLazily() { return VMStructs::hasKlassMethods(); } void HotspotSupport::initLazyResolution(JNIEnv* jni) { - // calibrate() only probes once per process, so this is stable across - // profiler restarts and raw frames of an earlier session keep their meaning. - _tag_with_trace_id = KlassTraceId::calibrate(jni); + bool trace_ids = KlassTraceId::calibrate(jni); + __atomic_store_n(&_tag_with_trace_id, trace_ids, __ATOMIC_RELEASE); // Counters are reset on every profiler start, but calibration only runs // once, so republish its result here. - Counters::set(KLASS_TRACE_ID_OFFSET, _tag_with_trace_id ? KlassTraceId::offset() : -1); - TEST_LOG("Raw frame tags: %s", _tag_with_trace_id ? "klass trace id" : "registry epoch"); + Counters::set(KLASS_TRACE_ID_OFFSET, trace_ids ? KlassTraceId::offset() : -1); + TEST_LOG("Raw frame tags: %s", trace_ids ? "klass trace id" : "registry epoch"); } bool HotspotSupport::rawFrameTag(const VMMethod* method, uint32_t* tag) { - if (!_tag_with_trace_id) { - *tag = _klass_registry.epoch(); + if (!__atomic_load_n(&_tag_with_trace_id, __ATOMIC_ACQUIRE)) { + *tag = _klass_registry.epoch() & TAG_VALUE_MASK; return true; } uint64_t id; if (!KlassTraceId::read(method->holderOrNull(), &id)) { return false; } - *tag = (uint32_t)id & FrameType::RAW_TAG_MASK; + *tag = TRACE_ID_TAG | ((uint32_t)id & TAG_VALUE_MASK); return true; } @@ -1909,6 +1908,22 @@ jmethodID HotspotSupport::resolve(const void* tagged_method, int bci) { return lookupMethodIdViaJni(vm_method, names, &HotspotSupport::loadMethodIDsIfNeededImpl); } +#ifdef DEBUG +// For CI only: LazyTraceIdJfrValidationTest compares this with the class ID +// JFR records for the same class. +static void logLazilyLoadedClass(jvmtiEnv* jvmti, jclass clz, const void* holder) { + char* signature = nullptr; + uint64_t trace_id = 0; + if (KlassTraceId::read(holder, &trace_id) && + jvmti->GetClassSignature(clz, &signature, nullptr) == JVMTI_ERROR_NONE) { + TEST_LOG("Lazy jmethodIDs for %s, trace id %llu", signature, (unsigned long long)trace_id); + } + if (signature != nullptr) { + jvmti->Deallocate((unsigned char*)signature); + } +} +#endif + // Runs with crash protection off (see lookupMethodIdViaJni() for why the // GetClassMethods call below must not run under a landing pad). Nothing here // dereferences vm_method until the class that owns it is pinned and the @@ -1916,11 +1931,13 @@ jmethodID HotspotSupport::resolve(const void* tagged_method, int bci) { jmethodID HotspotSupport::resolveViaRegistry(VMMethod* vm_method, const void* holder, int idnum, uint32_t tag) { JNIEnv* jni = VM::jni(); + const bool trace_id_tag = (tag & TRACE_ID_TAG) != 0; + const uint32_t tag_value = tag & TAG_VALUE_MASK; bool stale = false; - // With trace-ID tags the registry's epoch plays no part: passing the current + // For a trace-ID tag the registry's epoch plays no part: passing the current // epoch makes pin() accept any registration, and the trace ID is compared // once the class is pinned. - uint32_t sampled_epoch = _tag_with_trace_id ? _klass_registry.epoch() : tag; + uint32_t sampled_epoch = trace_id_tag ? _klass_registry.epoch() : tag_value; jclass clz = _klass_registry.pin(jni, holder, sampled_epoch, &stale); if (clz == nullptr) { // Stale: the class registered at `holder` arrived after the sample, so the @@ -1933,8 +1950,8 @@ jmethodID HotspotSupport::resolveViaRegistry(VMMethod* vm_method, const void* ho jmethodID method_id = nullptr; uint64_t trace_id = 0; - if (_tag_with_trace_id && - (!KlassTraceId::read(holder, &trace_id) || ((uint32_t)trace_id & FrameType::RAW_TAG_MASK) != tag)) { + if (trace_id_tag && + (!KlassTraceId::read(holder, &trace_id) || ((uint32_t)trace_id & TAG_VALUE_MASK) != tag_value)) { // The class at `holder` is a different load than the one sampled. Counters::increment(METHOD_RESOLVE_LAZY_STALE); jni->DeleteLocalRef(clz); @@ -1954,6 +1971,7 @@ jmethodID HotspotSupport::resolveViaRegistry(VMMethod* vm_method, const void* ho jvmtiEnv* jvmti = VM::jvmti(); if (jvmti != nullptr && loadMethodIDsIfNeededImpl(jvmti, jni, clz, true /*load all*/)) { Counters::increment(METHOD_RESOLVE_LAZY_CLASS_LOADED); + DEBUG_ONLY(logLazilyLoadedClass(jvmti, clz, holder)); jmethodID validated = vm_method->validatedId(); if (isValidJMethodID(validated)) { method_id = validated; diff --git a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h index 760a644774..8575ae01a1 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h @@ -54,11 +54,17 @@ class HotspotSupport { // resolve() before it touches a raw Method*. See klassRegistry.h. static KlassRegistry _klass_registry; - // fjmethodid=lazy: what a raw frame's tag holds (frame.h). True: the low - // RAW_TAG_BITS of the holder's JFR trace ID (KlassTraceId), unique per - // load of a class. False: _klass_registry's epoch. Set once per process by - // initLazyResolution(), before any raw frame exists. - static bool _tag_with_trace_id; + // fjmethodid=lazy raw frame tags (frame.h). The top bit says what the + // rest holds: set, the low TAG_VALUE_BITS of the holder's JFR trace ID + // (KlassTraceId), unique per load of a class; clear, _klass_registry's + // epoch. resolve() goes by each frame's own kind, never by the current + // tag source. + static const uint32_t TRACE_ID_TAG = 1u << (FrameType::RAW_TAG_BITS - 1); + static const uint32_t TAG_VALUE_MASK = TRACE_ID_TAG - 1; + + // Whether new raw frames get trace-ID tags: set by initLazyResolution() + // when calibration found the offset. + static volatile bool _tag_with_trace_id; // The tag to store with a raw frame of `method`; false if it cannot be // determined (unreadable holder or trace ID). Signal safe. diff --git a/ddprof-lib/src/main/cpp/hotspot/klassRegistry.h b/ddprof-lib/src/main/cpp/hotspot/klassRegistry.h index 04cd1b1c47..d429650b89 100644 --- a/ddprof-lib/src/main/cpp/hotspot/klassRegistry.h +++ b/ddprof-lib/src/main/cpp/hotspot/klassRegistry.h @@ -69,9 +69,10 @@ class KlassRegistry { uint32_t bumpEpoch(); public: - // Width of the epoch as stored in a frame and compared by pin(); must equal - // FrameType::RAW_TAG_BITS (checked in hotspotSupport.cpp). - static const int EPOCH_BITS = 26; + // Width of the epoch as stored in a frame and compared by pin(): a raw + // frame's tag minus the bit that says which kind of tag it is (checked in + // hotspotSupport.cpp). + static const int EPOCH_BITS = 25; KlassRegistry() : _table(nullptr), _capacity(0), _size(0), _epoch(0) {} diff --git a/ddprof-lib/src/main/cpp/hotspot/klassTraceId.h b/ddprof-lib/src/main/cpp/hotspot/klassTraceId.h index c24fa00a6c..529557400c 100644 --- a/ddprof-lib/src/main/cpp/hotspot/klassTraceId.h +++ b/ddprof-lib/src/main/cpp/hotspot/klassTraceId.h @@ -52,10 +52,8 @@ class KlassTraceId { /** * Creates the probe klasses through JNI and records the offset found. Only - * the first call per process calibrates; later calls return its result, so - * the tag source of raw frames (see HotspotSupport) never changes while - * frames from an earlier profiling session may still be stored. Returns - * true if an offset is known. + * the first call per process calibrates; later calls return its result. + * Returns true if an offset is known. */ static bool calibrate(JNIEnv* jni); diff --git a/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp b/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp index 086407ed83..1f6eddbc35 100644 --- a/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp +++ b/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp @@ -57,6 +57,7 @@ class HotspotSupportTestAccessor { static KlassRegistry& registry() { return HotspotSupport::_klass_registry; } static bool getTagWithTraceId() { return HotspotSupport::_tag_with_trace_id; } static void setTagWithTraceId(bool v) { HotspotSupport::_tag_with_trace_id = v; } + static uint32_t traceIdTagBit() { return HotspotSupport::TRACE_ID_TAG; } }; class ProfilerTestAccessor { @@ -461,6 +462,24 @@ TEST_P(HotspotLazyResolveTest, JfrTagBitsDoNotAffectTraceIdTags) { EXPECT_EQ(_cls.jmethodId(), resolve(before)); } +// resolve() goes by the kind each frame records, not by the current tag +// source: frames of both kinds resolve side by side. +TEST_P(HotspotLazyResolveTest, FramesResolveByTheirOwnTagKind) { + if (!traceIdTags()) { + GTEST_SKIP() << "needs a calibrated trace-ID offset to take trace-ID-tagged samples"; + } + registerClass(_old_mirror); + HotspotSupportTestAccessor::setTagWithTraceId(false); + ASGCT_CallFrame epoch_frame = sample(); + HotspotSupportTestAccessor::setTagWithTraceId(true); + ASGCT_CallFrame trace_frame = sample(); + const uint32_t bit = HotspotSupportTestAccessor::traceIdTagBit(); + EXPECT_EQ(0u, FrameType::rawTag(epoch_frame.bci, epoch_frame.method) & bit); + EXPECT_NE(0u, FrameType::rawTag(trace_frame.bci, trace_frame.method) & bit); + EXPECT_EQ(_cls.jmethodId(), resolve(epoch_frame)); + EXPECT_EQ(_cls.jmethodId(), resolve(trace_frame)); +} + INSTANTIATE_TEST_SUITE_P(TagSource, HotspotLazyResolveTest, ::testing::Bool(), [](const ::testing::TestParamInfo& info) { return info.param ? "TraceId" : "Epoch"; 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..16ec45f645 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/ExternalLauncher.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/ExternalLauncher.java @@ -26,6 +26,9 @@ *

  • 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 @@ -158,6 +161,16 @@ 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("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/SpinClasses.java b/ddprof-test/src/test/java/com/datadoghq/profiler/SpinClasses.java new file mode 100644 index 0000000000..20e7450d83 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/SpinClasses.java @@ -0,0 +1,66 @@ +/* + * 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; + +/** + * 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 { + 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); + } + + 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(); + // 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.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(); + } +} 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 index ef4c15760b..c5e3d3f62e 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyJmethodIdTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyJmethodIdTest.java @@ -9,14 +9,11 @@ import com.datadoghq.profiler.JfrEvent; 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 org.objectweb.asm.ClassWriter; -import org.objectweb.asm.Label; -import org.objectweb.asm.MethodVisitor; -import org.objectweb.asm.Opcodes; import java.io.IOException; import java.lang.ref.WeakReference; @@ -58,7 +55,7 @@ public void customLoaderFramesResolve() throws Exception { waitForProfilerReady(2000); String className = nextClassName("LazySpin"); - Class clazz = new SpinClassLoader().define(className); + Class clazz = SpinClasses.define(className); clazz.getMethod("spin", long.class).invoke(null, 1_000_000_000L); stopProfiler(); @@ -162,49 +159,8 @@ private static synchronized String nextClassName(String prefix) { // 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 { - SpinClassLoader loader = new SpinClassLoader(); - Class clazz = loader.define(className); + Class clazz = SpinClasses.define(className); clazz.getMethod("spin", long.class).invoke(null, 300_000_000L); - return new WeakReference<>(loader); - } - - /** - * Defines a class with {@code public static void spin(long nanos)}, which busy-waits so - * that CPU samples land in it. - */ - private static final class SpinClassLoader 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(); - // 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.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(); - } + return new WeakReference<>(clazz.getClassLoader()); } } 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..3544448357 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyTraceIdJfrValidationTest.java @@ -0,0 +1,134 @@ +/* + * 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 both the profiler (lazy mode) and JFR's own CPU sampler while spinning in a + * probe class. In debug builds the profiler logs the trace ID it reads for each class it allocates + * jmethodIDs for; JFR records the same class in its {@code jdk.ExecutionSample} stack frames, and + * {@code RecordedClass.getId()} is JFR's trace ID for it. The two must be equal. + * + *

    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 jmethodIDs for L" + 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"); + try { + AtomicLong loggedId = new AtomicLong(-1); + AtomicBoolean traceIdTags = new AtomicBoolean(false); + LaunchResult result = launch("lazy-trace-id", + Arrays.asList("-XX:StartFlightRecording=filename=" + jfrRecording + ",settings=profile,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()) { + loggedId.set(Long.parseLong(m.group(1))); + } + 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 execution sample in " + 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) {} + } + } + + private static boolean jfrConsumerAvailable() { + try { + Class.forName("jdk.jfr.consumer.RecordingFile"); + return true; + } catch (ClassNotFoundException e) { + return false; + } + } + + /** + * Returns the IDs JFR gave {@code className} in the method types of its + * {@code jdk.ExecutionSample} stack frames. 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.ExecutionSample".equals(call(type, "getName"))) { + continue; + } + Object stackTrace = call(event, "getStackTrace"); + if (stackTrace == null) { + continue; + } + for (Object frame : (List) call(stackTrace, "getFrames")) { + Object method = call(frame, "getMethod"); + Object clazz = method != null ? call(method, "getType") : null; + 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/doc/LazyJmethodIdResolution.md b/doc/LazyJmethodIdResolution.md index 79279556b6..c4c0559cec 100644 --- a/doc/LazyJmethodIdResolution.md +++ b/doc/LazyJmethodIdResolution.md @@ -60,7 +60,7 @@ HotSpot only, and J9 and Zing keep preloading. ref out from under it. Lock ordering is safe: none of the JNI calls made under the lock load classes, so a ClassPrepare can't re-enter it. -## Open problem: Klass* address reuse (blocking) +## Klass* address reuse (solved: frame tags) Class-space blocks are aligned and reused, so a new class at the exact address of an unloaded one is likely. The current checks don't catch it: @@ -102,7 +102,7 @@ could be inserted fresh, without a bump, and accept the unregistered class's fra - Cost: a stack sampled across a bump splits into two traces. In heavy churn, bumps follow the reuse rate. A "seen since last bump" flag can reduce how often it bumps. -### Option B: HotSpot's JFR class trace ID, read by offset (preferred) +### Option B: HotSpot's JFR class trace ID, read by offset — implemented, preferred Every `Klass` carries `mutable traceid _trace_id` (`JFR_ONLY(DEFINE_TRACE_ID_FIELD)`). It is not in `gHotSpotVMStructs`. From the local OpenJDK sources: @@ -133,7 +133,7 @@ alone. Advantages over A: no global state, no trace splitting, and two incarnations get the same stored bits only if a multiple of 2^N classes were created between them. -#### Offset discovery — implemented (calibration only, not used by the sampler yet) +#### Offset discovery — implemented Offsets can move even between minor releases, so there is no version table. `KlassTraceId::calibrate()` (hotspot/klassTraceId.{h,cpp}) runs when lazy mode is selected: @@ -165,36 +165,41 @@ So 107 GA tags in total. The sweep compares neighbouring declarations, not absol That's enough, since calibration assumes none. Script: `/tmp/jfrsweep/sweep2.sh ...` (Mac), with `P=hotspot/src/share/vm/` for 8u. -#### Confirming the offset - -**Self-calibration (decides at startup).** Register a few probe classes we define in a -known order. Check that `(raw >> 16)` at the candidate offset is distinct for each and -strictly increasing in load order. This has to decide before the first sample, because -the sampler must know which bits to store from then on. - -**Cross-check against JFR (confirms later).** dd-trace-java runs a real -`jdk.jfr.Recording` next to ddprof (`OpenJdkOngoingRecording`; when ddprof is enabled it -only turns off the overlapping JFR events). So in most production JVMs JFR gets -initialized, and its own answer can confirm our offset: - -- **Detect JFR by watching, not by loading.** Don't call JNI `FindClass`: HotSpot's - `jni_FindClass` passes `init=true`, which would run `JVM.` (`registerNatives`, - JFR log-level setup, `Options.ensureInitialized()`). Reflection from Java has the same - problem. Lazy mode already sees every class through ClassPrepare and `GetLoadedClasses`. - When `Ljdk/jfr/internal/JVM;` shows up, keep a global ref to it, and call into it only - once `GetClassStatus` reports `JVMTI_CLASS_STATUS_INITIALIZED`. -- **`getClassId` is not a pure read.** `jfr_class_id` goes through - `JfrTraceIdLoadBarrier::load(klass)`. If the class isn't tagged yet, the barrier marks - it used for the current epoch and adds it to JFR's class queue, so the class ends up in - the user's recording. Probe only classes that are certainly tagged already - (`java.lang.Object`, `java.lang.Thread`); for those `should_tag()` is false and the - call is effectively a read. It returns `TRACE_ID(klass)`, which is `raw >> 16`, so the - check is a direct equality test. -- **Mismatch handling.** Switch new samples to Option A. Treat raw frames captured under - the unconfirmed offset as unresolvable (unknown) rather than resolve them. Wrong names - are worse than dropped frames. Add a counter for both outcomes. -- JNI calls ignore module encapsulation, so `jdk.jfr.internal` not being exported shouldn't - matter. Verify this when implementing. +#### Confirming the offset — CI only + +Self-calibration is the only production gate. JFR's own class IDs confirm it in CI, never at runtime. + +**Why not a runtime cross-check against `JVM.getClassId()`:** prototyped and dropped after it +crashed the JVM (SIGSEGV in `JfrTraceIdKlassQueue::enqueue`, called from `getClassId`'s load +barrier). +- `getClassId()` tags a class JFR hasn't tagged yet, and adds it to JFR's class queue. +- That queue only exists once JFR's recorder has been created (`JfrRecorder::create_components` + → `JfrCheckpointManager::initialize` → `JfrTraceIdLoadBarrier::initialize`). +- "`jdk.jfr.internal.JVM` is initialized" doesn't mean the recorder exists. The profiler itself + initializes that class: `tsc.cpp` does `FindClass("jdk/jfr/internal/JVM")` for JFR's tick + frequency. So every lazy-mode session crashed on its first dump. +- A safe precondition would be "recorder exists and won't be destroyed during the call". There's + no race-free way to check that from native code: + - `JVM.isRecording()` differs by release: static native on 25+, instance native on 17u, + instance Java wrapper on 8u/11u. + - JFR's shutdown hook calls `destroyJFR()`, which tears those structures down while the VM is + still running. + - Our crash protection only recovers faults inside our own library, not inside the JVM. + +**CI validation instead:** `LazyTraceIdJfrValidationTest`, which needs a debug build. +- A child JVM (`ExternalLauncher lazy-trace-id`) runs the profiler in lazy mode alongside JFR's + own sampler (`-XX:StartFlightRecording=…,settings=profile`) while it spins in a probe class. +- In debug builds, `resolveViaRegistry` logs `Lazy jmethodIDs for , trace id N` for every + class it allocates jmethodIDs for. +- The test reads `jdk.ExecutionSample` stack frames through `jdk.jfr.consumer` (by reflection, + because the test sources compile with `--release 8`). It requires the probe class's + `RecordedClass.getId()` to equal the logged ID. +- Result so far: JDK 25.0.1 (macOS) gives profiler 2382 and JFR 2382. + +**Tag kind bit:** kept from the prototype. The tag's top bit records whether a frame carries a +trace ID or an epoch, so the epoch keeps 25 bits. `resolve()` goes by each frame's own kind, not by +the current tag source. That keeps frames self-describing; nothing switches sources at runtime any +more. ### Frame bits — implemented @@ -247,23 +252,24 @@ through the `com.sun.management:type=DiagnosticCommand` MBean. It lives in | Item | State | |---|---| -| `fjmethodid=lazy` parsing, `Lazy_loaded` state, registry, resolve path, counters | done | -| gtests: `klassRegistry_ut` (10), lazy cases in `jvmSupport_ut` and `arguments_ut` | pass (macOS) | -| `LazyJmethodIdTest`: custom-loader frames resolve; frames of unloaded class dropped (`method_resolve_lazy_unloaded=5`) | pass (macOS) | -| ABA gtest `hotspotLazyResolve_ut` (6 cases: controls + replacement, purge-then-reinsert, before/after frames differ) | pass (macOS) | -| Frame bits: bci spare bits plus `Method*` top bits; untaggable pointers dropped | done | -| Epoch tests in `klassRegistry_ut`, tag round-trip tests in `frame_ut` | pass (macOS) | -| Linux-only `hotspotMethodId_ut` cases: `resolve()` call sites updated to `(ptr, 0)` but **not compiled on macOS** | verify on Linux | -| Option A (epoch fallback) | done | -| Option B (trace-ID offset, self-calibration, JFR cross-check) | open | -| Linux runs: Linux-only `hotspotMethodId_ut` cases, ASan, aarch64, musl, benchmark | open | -| Per-dump cache for repeated raw frames (each occurrence currently re-pins) | open | +| `fjmethodid=lazy`, `KlassRegistry`, resolve path, counters | done | +| Frame tags: 26 bits (tag-kind bit + 25-bit value) in spare bci bits and `Method*` top bits | done | +| Epoch tags (fallback) and trace-ID tags (when calibrated) | done | +| Trace-ID offset calibration; source sweep over 107 GA tags (8u–28) | done | +| gtests: `klassRegistry_ut`, `klassTraceId_ut`, `hotspotLazyResolve_ut` (every reuse case under both tag sources), `frame_ut`, `jvmSupport_ut`, `arguments_ut` | pass (macOS; Linux debug + ASan before the trace-ID commits) | +| `LazyJmethodIdTest` on JDK 8u462, 11.0.28 (musl) and 17.0.16, 21.0.8, 25.0.2 (glibc), aarch64 | pass, trace-ID tags on all five | +| Calibrated offsets: 8u462 → 184, 11.0.28 → 168, 17.0.16 → 168, 21.0.8 → 184, 25.0.2 → 192 | measured | +| `LazyTraceIdJfrValidationTest` (CI cross-check against JFR) | pass on macOS JDK 25; containers pending | +| Runtime JFR cross-check | dropped, see "Confirming the offset" | +| Dump-latency benchmark; per-dump cache for repeated raw frames | open | | `JmethodIdOverheadBenchTest.java`: temporary harness, don't commit | uncommitted | ### Files touched ``` -ddprof-lib/src/main/cpp/hotspot/klassRegistry.{h,cpp} new: Klass* → weak mirror registry +ddprof-lib/src/main/cpp/hotspot/klassRegistry.{h,cpp} new: Klass* → weak mirror registry, epoch +ddprof-lib/src/main/cpp/hotspot/klassTraceId.{h,cpp} new: Klass::_trace_id calibration and signal-safe read +ddprof-lib/src/main/cpp/frame.h raw-frame tag encoding ddprof-lib/src/main/cpp/hotspot/hotspotSupport.{h,cpp} registerClass, readMethodHolder, resolveViaRegistry ddprof-lib/src/main/cpp/hotspot/vmStructs{.h,.inline.h} VMKlass::containsMethod, VMConstMethod::idnum, hasKlassMethods ddprof-lib/src/main/cpp/jvmSupport.{h,cpp} Lazy_loaded state and dispatch @@ -271,6 +277,9 @@ ddprof-lib/src/main/cpp/arguments.{h,cpp} fjmethodid=lazy ddprof-lib/src/main/cpp/counters.h method_resolve_lazy_{unloaded,not_current,class_loaded} ddprof-lib/src/test/cpp/{klassRegistry,jvmSupport,arguments}_ut.cpp ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyJmethodIdTest.java +ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyTraceIdJfrValidationTest.java +ddprof-test/src/test/java/com/datadoghq/profiler/SpinClasses.java + ExternalLauncher lazy-trace-id +ddprof-lib/src/test/cpp/{klassTraceId,hotspotLazyResolve,frame}_ut.cpp ``` ## Continuing on Linux @@ -284,7 +293,7 @@ git fetch && git switch jmethodid-lazy-resolve ./utils/run-containers-tests.sh --arch=aarch64 --config=debug --jdk=21 --tests='*Jmethod*' ``` -`-Ptests` takes a single pattern. Comma-separated lists don't match. +`-Ptests` takes a single pattern. Comma-separated lists don't match. The musl runner treats it as a regex (`*Foo` fails there); a plain class name works for both. ## Alternatives set aside From f86c2d212233f67a27e602bc039feb6e05edfb95 Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Thu, 15 Oct 2026 14:23:47 +0200 Subject: [PATCH 06/17] Harden lazy resolution against metadata reuse and redefinition Review fixes for fjmethodid=lazy: - VMKlass::containsMethod() reads the methods array only through SafeAccess. Pinning the class does not keep the array alive: a redefinition swaps in a new one at a safepoint and frees the old one at a later safepoint, neither of which waits for the native dump thread. - Redefinition: the RedefineClasses/RetransformClasses hooks mark the class in KlassRegistry before calling the original function and preload its jmethodIDs afterwards. pin() refuses raw frames of a marked class, and resolveViaRegistry() checks the mark again after its metadata reads, since a redefinition can free a Method* and reuse its address within the same class without changing its mirror, trace ID or epoch. New counter method_resolve_lazy_redefined; pin() now reports a PinStatus. - Every registration bumps the registry epoch. Previously only replacements and purges did, so a dead class's Method* reused by a class loaded at a different Klass* could match an epoch-tagged frame. - klass_trace_id_beyond_tag_range counts registrations of classes whose trace ID no longer fits in the 25-bit tag, the first point at which two classes' trace-ID tags can collide. - klassRegistry_ut includes vmStructs.inline.h so it links in release. The doc gets a "Guarantees and limits" section stating the exact condition under which a tag collision can still misattribute a frame. Co-Authored-By: Claude Opus 5.5 --- ddprof-lib/src/main/cpp/counters.h | 8 ++ .../src/main/cpp/hotspot/hotspotSupport.cpp | 40 +++++++-- .../src/main/cpp/hotspot/hotspotSupport.h | 4 + .../src/main/cpp/hotspot/klassRegistry.cpp | 60 +++++++++---- .../src/main/cpp/hotspot/klassRegistry.h | 67 ++++++++++----- ddprof-lib/src/main/cpp/hotspot/vmStructs.h | 23 +++-- ddprof-lib/src/main/cpp/jvmSupport.cpp | 16 ++++ ddprof-lib/src/main/cpp/jvmSupport.h | 7 ++ ddprof-lib/src/main/cpp/vmEntry.cpp | 36 +++++--- .../src/test/cpp/hotspotLazyResolve_ut.cpp | 85 +++++++++++++++++++ ddprof-lib/src/test/cpp/klassRegistry_ut.cpp | 83 +++++++++++++----- doc/LazyJmethodIdResolution.md | 63 ++++++++++++-- 12 files changed, 397 insertions(+), 95 deletions(-) diff --git a/ddprof-lib/src/main/cpp/counters.h b/ddprof-lib/src/main/cpp/counters.h index f9f99418ee..49192a3e8f 100644 --- a/ddprof-lib/src/main/cpp/counters.h +++ b/ddprof-lib/src/main/cpp/counters.h @@ -187,6 +187,14 @@ * 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") \ + /* 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") \ X(KLASS_REGISTRY_ADD_FAILED, "klass_registry_add_failed") \ X(WALKVM_RAW_FRAME_UNTAGGABLE, "walkvm_raw_frame_untaggable") \ /* Gauge, set at each fjmethodid=lazy start: the Klass::_trace_id offset \ diff --git a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp index c1bf1a86e4..6fc7be0735 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp @@ -1498,6 +1498,10 @@ bool HotspotSupport::rawFrameTag(const VMMethod* method, uint32_t* tag) { return true; } +void HotspotSupport::beforeClassRedefinition(JNIEnv *jni, jclass klass) { + _klass_registry.markRedefined(VMKlass::fromJavaClass(jni, klass)); +} + bool HotspotSupport::registerClass(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass) { jint status = 0; // Array and primitive classes have no methods, so no frame ever names them @@ -1506,7 +1510,16 @@ bool HotspotSupport::registerClass(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass) { (status & (JVMTI_CLASS_STATUS_ARRAY | JVMTI_CLASS_STATUS_PRIMITIVE)) != 0) { return false; } - if (_klass_registry.add(jni, VMKlass::fromJavaClass(jni, klass), klass)) { + const void* vm_klass = VMKlass::fromJavaClass(jni, klass); + uint64_t trace_id; + if (__atomic_load_n(&_tag_with_trace_id, __ATOMIC_ACQUIRE) && 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 frames could never be told apart from a later @@ -1933,18 +1946,21 @@ jmethodID HotspotSupport::resolveViaRegistry(VMMethod* vm_method, const void* ho JNIEnv* jni = VM::jni(); const bool trace_id_tag = (tag & TRACE_ID_TAG) != 0; const uint32_t tag_value = tag & TAG_VALUE_MASK; - bool stale = false; + KlassRegistry::PinStatus status; // For a trace-ID tag the registry's epoch plays no part: passing the current // epoch makes pin() accept any registration, and the trace ID is compared // once the class is pinned. uint32_t sampled_epoch = trace_id_tag ? _klass_registry.epoch() : tag_value; - jclass clz = _klass_registry.pin(jni, holder, sampled_epoch, &stale); + jclass clz = _klass_registry.pin(jni, holder, sampled_epoch, &status); if (clz == nullptr) { // Stale: the class registered at `holder` arrived after the sample, so the - // frame belongs to an earlier class at the same address. Otherwise the + // frame belongs to an earlier occupant of that memory. 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. - Counters::increment(stale ? METHOD_RESOLVE_LAZY_STALE : METHOD_RESOLVE_LAZY_UNLOADED); + Counters::increment(status == KlassRegistry::PIN_STALE ? METHOD_RESOLVE_LAZY_STALE + : status == KlassRegistry::PIN_REDEFINED ? METHOD_RESOLVE_LAZY_REDEFINED + : METHOD_RESOLVE_LAZY_UNLOADED); return nullptr; } @@ -1957,9 +1973,10 @@ jmethodID HotspotSupport::resolveViaRegistry(VMMethod* vm_method, const void* ho jni->DeleteLocalRef(clz); return nullptr; } - // The pinned class keeps its metadata alive, so its methods array is safe - // to read. A miss means the Method* was freed (and possibly its holder - // address reused), or it is an old version replaced by a redefinition. + // A miss means the Method* was freed (and possibly its memory reused), or + // it is an old version replaced by a redefinition. containsMethod() and + // validatedId() read 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)) { Counters::increment(METHOD_RESOLVE_LAZY_NOT_CURRENT); } else { @@ -1980,5 +1997,12 @@ jmethodID HotspotSupport::resolveViaRegistry(VMMethod* vm_method, const void* ho } } 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 (method_id != nullptr && _klass_registry.isRedefined(holder)) { + Counters::increment(METHOD_RESOLVE_LAZY_REDEFINED); + return nullptr; + } return method_id; } diff --git a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h index 8575ae01a1..385a15b21d 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h @@ -74,6 +74,10 @@ class HotspotSupport { // allocating its jmethodIDs. Called wherever the other modes preload. static bool registerClass(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass); + // fjmethodid=lazy: marks `klass` in _klass_registry before a redefinition + // (see klassRegistry.h for why its raw frames are refused from then on). + static void beforeClassRedefinition(JNIEnv *jni, jclass klass); + // fjmethodid=lazy half of resolve(): pins the class registered under // `holder`, refuses it if `tag` (the frame's, see _tag_with_trace_id) // shows the frame came from an earlier class at that address, checks that diff --git a/ddprof-lib/src/main/cpp/hotspot/klassRegistry.cpp b/ddprof-lib/src/main/cpp/hotspot/klassRegistry.cpp index 21d2bd7c15..e99cb8577b 100644 --- a/ddprof-lib/src/main/cpp/hotspot/klassRegistry.cpp +++ b/ddprof-lib/src/main/cpp/hotspot/klassRegistry.cpp @@ -59,7 +59,6 @@ bool KlassRegistry::rehash(JNIEnv* jni) { _table = table; _capacity = capacity; _size = 0; - bool purged = false; for (size_t i = 0; i < old_capacity; i++) { Entry& e = old_table[i]; if (e.klass == nullptr) { @@ -67,18 +66,12 @@ bool KlassRegistry::rehash(JNIEnv* jni) { } if (jni->IsSameObject(e.mirror, nullptr)) { jni->DeleteWeakGlobalRef(e.mirror); - purged = true; } else { *find(e.klass) = e; _size++; } } free(old_table); - if (purged) { - // A purged address may be handed to a new class whose add() then looks - // like a first insert; frames of the purged class must stay older. - bumpEpoch(); - } return true; } @@ -103,6 +96,7 @@ bool KlassRegistry::add(JNIEnv* jni, const void* klass, jclass mirror) { // A different class now lives at this address; frames sampled from the // previous one must not match it. e->epoch = bumpEpoch(); + e->redefined = false; return true; } } @@ -117,15 +111,20 @@ bool KlassRegistry::add(JNIEnv* jni, const void* klass, jclass mirror) { Entry* e = find(klass); e->klass = klass; e->mirror = ref; - e->epoch = _epoch; + // Every registration gets a new epoch: this class may occupy memory freed + // by a class that frames sampled before now came from. + e->epoch = bumpEpoch(); + e->redefined = false; _size++; return true; } -jclass KlassRegistry::pin(JNIEnv* jni, const void* klass, uint32_t sampled_epoch, bool* stale) { - if (stale != nullptr) { - *stale = false; +jclass KlassRegistry::pin(JNIEnv* jni, const void* klass, uint32_t sampled_epoch, PinStatus* status) { + PinStatus local_status; + if (status == nullptr) { + status = &local_status; } + *status = PIN_ABSENT; if (klass == nullptr) { return nullptr; } @@ -137,19 +136,50 @@ jclass KlassRegistry::pin(JNIEnv* jni, const void* klass, uint32_t sampled_epoch if (e->klass != klass) { return nullptr; } + if (e->redefined) { + *status = PIN_REDEFINED; + return nullptr; + } // Serial-number comparison: the frame is at least as new as the entry iff // (sampled - registered) mod 2^EPOCH_BITS lies in the lower half. const uint32_t mask = (1u << EPOCH_BITS) - 1; if (((sampled_epoch - e->epoch) & mask) >= (1u << (EPOCH_BITS - 1))) { - if (stale != nullptr) { - *stale = true; - } + *status = PIN_STALE; 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. - return (jclass)jni->NewLocalRef(e->mirror); + jclass ref = (jclass)jni->NewLocalRef(e->mirror); + if (ref != nullptr) { + *status = PIN_OK; + } + return ref; +} + +bool KlassRegistry::markRedefined(const void* klass) { + if (klass == nullptr) { + return false; + } + MutexLocker ml(_lock); + if (_capacity == 0) { + return false; + } + Entry* e = find(klass); + if (e->klass != klass) { + return false; + } + e->redefined = true; + return true; +} + +bool KlassRegistry::isRedefined(const void* klass) { + MutexLocker ml(_lock); + if (_capacity == 0) { + return false; + } + Entry* e = find(klass); + return e->klass == klass && e->redefined; } size_t KlassRegistry::size() { diff --git a/ddprof-lib/src/main/cpp/hotspot/klassRegistry.h b/ddprof-lib/src/main/cpp/hotspot/klassRegistry.h index d429650b89..e01df4cfb4 100644 --- a/ddprof-lib/src/main/cpp/hotspot/klassRegistry.h +++ b/ddprof-lib/src/main/cpp/hotspot/klassRegistry.h @@ -31,20 +31,26 @@ * loaded. A Klass* address reused by a newly loaded class simply overwrites * the dead entry on that class's ClassPrepare. * - * Klass* reuse: class space hands a freed Klass* address -- and very likely - * the same Method* addresses -- to the next class of the same shape, so a - * Method* sampled from an unloaded class can look exactly like a method of - * the class that replaced it. The registry therefore keeps an epoch: every - * entry records the epoch it was registered at, and the epoch is bumped - * whenever a registration may reuse the address of a class that is gone -- - * when a known Klass* gets a different mirror, and when a rehash purges dead - * entries (after which the old address can come back as a first insert). - * walkVM stores epoch() in each raw frame; pin() refuses a frame whose epoch - * predates the entry, since such a frame can only come from an earlier - * occupant of the address. This relies on every class being registered - * before any of its methods can run, which ClassPrepare guarantees; when - * registration fails, the caller must preload the class's jmethodIDs instead - * so that no raw frame of it is ever produced. + * Metadata reuse: the memory of a freed class -- its Klass* and very likely + * its Method* addresses -- goes to classes loaded later, so a Method* sampled + * from an unloaded class can look exactly like a method of a newer class, + * whether that one sits at the same Klass* or elsewhere. The registry + * therefore keeps an epoch: every registration bumps it and records the new + * value, so any class registered after a frame was sampled has a newer epoch + * than the frame. walkVM can store epoch() in each raw frame (when no JFR + * trace ID is available, see HotspotSupport); pin() then refuses a frame + * whose epoch predates the entry, since that frame can only come from an + * earlier occupant of the memory. This relies on every class being + * registered before any of its methods can run, which ClassPrepare + * guarantees; when registration fails, the caller must preload the class's + * jmethodIDs instead so that no raw frame of it is ever produced. + * + * 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 + * markRedefined() is called before every redefinition, the class's + * jmethodIDs are preloaded after it (so its later frames are not raw), and + * pin() refuses every raw frame of a marked class. * * Except for epoch(), all operations take an internal mutex and call JNI, so * none of them may be used from a signal handler. @@ -53,7 +59,8 @@ class KlassRegistry { struct Entry { const void* klass; jweak mirror; - uint32_t epoch; // _epoch when this mirror was registered + uint32_t epoch; // _epoch when this mirror was registered + bool redefined; // markRedefined() was called; never reset }; static const size_t INITIAL_CAPACITY = 1024; @@ -76,6 +83,13 @@ class KlassRegistry { KlassRegistry() : _table(nullptr), _capacity(0), _size(0), _epoch(0) {} + enum PinStatus { + PIN_OK, + PIN_ABSENT, // not registered, or the class has been unloaded + PIN_STALE, // registered after the frame was sampled + PIN_REDEFINED, // the class has been redefined since registration + }; + KlassRegistry(const KlassRegistry&) = delete; KlassRegistry& operator=(const KlassRegistry&) = delete; @@ -94,13 +108,24 @@ class KlassRegistry { /** * Returns a new local reference to the mirror registered for `klass`, or - * nullptr if none is registered, the class has been unloaded, or the - * registration is newer than `sampled_epoch` (the frame came from an - * earlier class at the same address; `*stale` is then set to true). Epochs - * compare modulo 2^EPOCH_BITS. While the caller holds the reference the - * class cannot be unloaded; it must release it with DeleteLocalRef. + * nullptr -- with the reason in `*status` -- if none is registered, the + * class has been unloaded, the registration is newer than `sampled_epoch`, + * or the class has been redefined. Epochs compare modulo 2^EPOCH_BITS. + * While the caller holds the reference the class cannot be unloaded; it + * must release it with DeleteLocalRef. */ - jclass pin(JNIEnv* jni, const void* klass, uint32_t sampled_epoch, bool* stale = nullptr); + jclass pin(JNIEnv* jni, const void* klass, uint32_t sampled_epoch, PinStatus* status = nullptr); + + /** + * Marks `klass` as redefined, for good. Call before the redefinition + * starts, so that a resolution which validated the class's metadata just + * before the methods were swapped still sees the mark in isRedefined(). + * Returns false if `klass` is not registered. + */ + bool markRedefined(const void* klass); + + /** Whether markRedefined() was called for `klass`. */ + bool isRedefined(const void* klass); /** Number of occupied slots, including dead entries not yet purged. */ size_t size(); diff --git a/ddprof-lib/src/main/cpp/hotspot/vmStructs.h b/ddprof-lib/src/main/cpp/hotspot/vmStructs.h index e7c4c9b6c6..5d95d3f940 100644 --- a/ddprof-lib/src/main/cpp/hotspot/vmStructs.h +++ b/ddprof-lib/src/main/cpp/hotspot/vmStructs.h @@ -732,21 +732,30 @@ DECLARE(VMKlass) // only compared, never dereferenced, so it may be stale. `idnum_hint` is // the method's idnum if known (or -1): HotSpot keeps methods()->at(idnum) // equal to the method with that idnum until a redefinition reorders them, - // so the hint usually avoids the linear scan. The klass itself must be - // kept alive by the caller. + // so the hint usually avoids the linear scan. + // + // 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". bool containsMethod(const void* method, int idnum_hint) { assert(_methods_offset >= 0); - const char* methods = *(const char**) at(_methods_offset); + const char* methods = (const char*) SafeAccess::loadPtr((void**) at(_methods_offset), nullptr); if (methods == NULL) { return false; } - int len = *(const int*)methods; - const void* const* data = (const void* const*)(methods + sizeof(void*)); - if (idnum_hint >= 0 && idnum_hint < len && data[idnum_hint] == method) { + // 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; } for (int i = 0; i < len; i++) { - if (data[i] == method) { + if (SafeAccess::loadPtr(&data[i], nullptr) == method) { return true; } } diff --git a/ddprof-lib/src/main/cpp/jvmSupport.cpp b/ddprof-lib/src/main/cpp/jvmSupport.cpp index 2d86c51156..336810eebc 100644 --- a/ddprof-lib/src/main/cpp/jvmSupport.cpp +++ b/ddprof-lib/src/main/cpp/jvmSupport.cpp @@ -173,6 +173,22 @@ bool JVMSupport::loadMethodIDsIfNeeded(jvmtiEnv *jvmti, JNIEnv *jni, jclass klas } } +void JVMSupport::beforeClassRedefinition(JNIEnv *jni, jclass klass) { + if (getLoadState() == Lazy_loaded) { + HotspotSupport::beforeClassRedefinition(jni, klass); + } +} + +void JVMSupport::afterClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass, bool succeeded) { + if (getLoadState() == Lazy_loaded) { + // Even after a failed attempt: the class is marked for good, and only + // jmethodIDs keep its later frames from being raw (and refused). + HotspotSupport::loadMethodIDsIfNeededImpl(jvmti, jni, klass, true /* load all */); + } else if (succeeded) { + 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 56a40b6967..596a447a36 100644 --- a/ddprof-lib/src/main/cpp/jvmSupport.h +++ b/ddprof-lib/src/main/cpp/jvmSupport.h @@ -67,6 +67,13 @@ class JVMSupport { static void loadAllMethodIDsIfNeeded(jvmtiEnv *jvmti, JNIEnv *jni); static bool loadMethodIDsIfNeeded(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass); + // 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. + static void beforeClassRedefinition(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) to a jmethodID static inline jmethodID resolve(const void* method, int bci); diff --git a/ddprof-lib/src/main/cpp/vmEntry.cpp b/ddprof-lib/src/main/cpp/vmEntry.cpp index acf8382322..dfc5936ba4 100644 --- a/ddprof-lib/src/main/cpp/vmEntry.cpp +++ b/ddprof-lib/src/main/cpp/vmEntry.cpp @@ -678,16 +678,20 @@ void JNICALL VM::VMDeath(jvmtiEnv *jvmti, JNIEnv *jni) { jvmtiError VM::RedefineClassesHook(jvmtiEnv *jvmti, jint class_count, const jvmtiClassDefinition *class_definitions) { + JNIEnv *env = jni(); + for (int i = 0; i < class_count; i++) { + if (class_definitions[i].klass != NULL) { + JVMSupport::beforeClassRedefinition(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 +700,19 @@ VM::RedefineClassesHook(jvmtiEnv *jvmti, jint class_count, jvmtiError VM::RetransformClassesHook(jvmtiEnv *jvmti, jint class_count, const jclass *classes) { + JNIEnv *env = jni(); + for (int i = 0; i < class_count; i++) { + if (classes[i] != NULL) { + JVMSupport::beforeClassRedefinition(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/hotspotLazyResolve_ut.cpp b/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp index 1f6eddbc35..bfcba01805 100644 --- a/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp +++ b/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp @@ -5,6 +5,7 @@ #include +#include "../../main/cpp/counters.h" #include "../../main/cpp/frame.h" #include "../../main/cpp/hotspot/hotspotSupport.h" #include "../../main/cpp/hotspot/klassRegistry.h" @@ -42,6 +43,8 @@ class VMTestAccessor { 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 { @@ -58,6 +61,9 @@ class HotspotSupportTestAccessor { static bool getTagWithTraceId() { return HotspotSupport::_tag_with_trace_id; } static void setTagWithTraceId(bool v) { HotspotSupport::_tag_with_trace_id = v; } 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 { @@ -204,6 +210,7 @@ struct FakeClass { // 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 { @@ -219,13 +226,19 @@ class FakeJvm { _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.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; @@ -234,6 +247,7 @@ class FakeJvm { } JNIEnv* jni() { return &_jni_env; } + jvmtiEnv* jvmti() { return &_jvmti; } jclass mirror(FakeObject& obj) { return reinterpret_cast(&obj); } @@ -264,6 +278,14 @@ class FakeJvm { } static jobject JNICALL newLocalRef(JNIEnv*, jobject ref) { 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; + } + 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; @@ -276,6 +298,9 @@ class FakeJvm { 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; @@ -480,6 +505,66 @@ TEST_P(HotspotLazyResolveTest, FramesResolveByTheirOwnTagKind) { EXPECT_EQ(_cls.jmethodId(), resolve(trace_frame)); } +// 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, trace ID and registry epoch 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_P(HotspotLazyResolveTest, FrameOfRedefinedClassIsRefused) { + registerClass(_old_mirror); + ASGCT_CallFrame before = sample(); + // What VM::RetransformClassesHook does first. Afterwards the Method* at + // the sampled address is (still, or again) among the class's methods. + ASSERT_TRUE(HotspotSupportTestAccessor::registry().markRedefined(_cls.klassPtr())); + EXPECT_EQ(nullptr, resolve(before)); +} + +// 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_P(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_EQ(nullptr, 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_P(HotspotLazyResolveTest, CountsClassesWithTraceIdBeyondTagRange) { +#ifndef COUNTERS + GTEST_SKIP() << "debug counters are not compiled into this build"; +#else + if (!traceIdTags()) { + GTEST_SKIP() << "only trace-ID tags can run out of range"; + } + long long before = Counters::getCounter(KLASS_TRACE_ID_BEYOND_TAG_RANGE); + _old_mirror.klass = _cls.klassPtr(); + _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 +} + INSTANTIATE_TEST_SUITE_P(TagSource, HotspotLazyResolveTest, ::testing::Bool(), [](const ::testing::TestParamInfo& info) { return info.param ? "TraceId" : "Epoch"; diff --git a/ddprof-lib/src/test/cpp/klassRegistry_ut.cpp b/ddprof-lib/src/test/cpp/klassRegistry_ut.cpp index ae8d1bf232..0977722034 100644 --- a/ddprof-lib/src/test/cpp/klassRegistry_ut.cpp +++ b/ddprof-lib/src/test/cpp/klassRegistry_ut.cpp @@ -6,7 +6,7 @@ #include #include "../../main/cpp/hotspot/klassRegistry.h" -#include "../../main/cpp/hotspot/vmStructs.h" +#include "../../main/cpp/hotspot/vmStructs.inline.h" #include #include @@ -226,17 +226,56 @@ TEST_F(KlassRegistryTest, ClearDeletesEveryWeakRef) { } // Epochs: a frame records registry.epoch() when sampled; pin() refuses it when -// the entry for its Klass* was registered later. Only registrations that may -// reuse a gone class's address bump the epoch. +// the entry for its Klass* was registered later. Every new registration bumps +// the epoch, since any new class may occupy memory of one that is gone. -TEST_F(KlassRegistryTest, FirstInsertDoesNotBumpEpoch) { +TEST_F(KlassRegistryTest, EveryRegistrationBumpsEpochButReAddingDoesNot) { uint32_t e0 = registry.epoch(); ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + uint32_t e1 = registry.epoch(); + EXPECT_NE(e0, e1); ASSERT_TRUE(registry.add(jni, klassAt(1), mirror(1))); - EXPECT_EQ(e0, registry.epoch()); - // Re-adding the same pair (e.g. after a redefinition) is not a reuse. + uint32_t e2 = registry.epoch(); + EXPECT_NE(e1, e2); + // Re-adding the same pair is not a new registration. ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); - EXPECT_EQ(e0, registry.epoch()); + EXPECT_EQ(e2, registry.epoch()); +} + +TEST_F(KlassRegistryTest, FrameSampledBeforeAnotherClassRegisteredIsStaleForThatClass) { + // A frame whose Method* memory now belongs to a class registered after the + // sample -- at any address -- must not match that class. + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + uint32_t sampled = registry.epoch(); + ASSERT_TRUE(registry.add(jni, klassAt(1), mirror(1))); + KlassRegistry::PinStatus status; + EXPECT_EQ(nullptr, registry.pin(jni, klassAt(1), sampled, &status)); + EXPECT_EQ(KlassRegistry::PIN_STALE, status); + EXPECT_EQ(mirror(0), registry.pin(jni, klassAt(0), sampled, &status)); + EXPECT_EQ(KlassRegistry::PIN_OK, status); +} + +TEST_F(KlassRegistryTest, RedefinedClassIsRefusedForGood) { + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + EXPECT_FALSE(registry.isRedefined(klassAt(0))); + EXPECT_TRUE(registry.markRedefined(klassAt(0))); + EXPECT_TRUE(registry.isRedefined(klassAt(0))); + KlassRegistry::PinStatus status; + EXPECT_EQ(nullptr, registry.pin(jni, klassAt(0), registry.epoch(), &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))); + EXPECT_FALSE(registry.markRedefined(klassAt(5))); +} + +TEST_F(KlassRegistryTest, NewClassAtRedefinedClassAddressStartsUnmarked) { + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + ASSERT_TRUE(registry.markRedefined(klassAt(0))); + 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), registry.epoch())); } TEST_F(KlassRegistryTest, ReplacementBumpsEpochAndRejectsOlderFrames) { @@ -246,14 +285,14 @@ TEST_F(KlassRegistryTest, ReplacementBumpsEpochAndRejectsOlderFrames) { ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(1))); EXPECT_NE(sampled_old, registry.epoch()); - bool stale = false; - EXPECT_EQ(nullptr, registry.pin(jni, klassAt(0), sampled_old, &stale)); - EXPECT_TRUE(stale); - EXPECT_EQ(mirror(1), registry.pin(jni, klassAt(0), registry.epoch(), &stale)); - EXPECT_FALSE(stale); + KlassRegistry::PinStatus status; + EXPECT_EQ(nullptr, registry.pin(jni, klassAt(0), sampled_old, &status)); + EXPECT_EQ(KlassRegistry::PIN_STALE, status); + EXPECT_EQ(mirror(1), registry.pin(jni, klassAt(0), registry.epoch(), &status)); + EXPECT_EQ(KlassRegistry::PIN_OK, status); } -TEST_F(KlassRegistryTest, PurgeBumpsEpochSoReinsertRejectsOlderFrames) { +TEST_F(KlassRegistryTest, PurgedAddressReinsertedRejectsOlderFrames) { ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); uint32_t sampled_old = registry.epoch(); heap.collect(&mirrors[0]); @@ -265,21 +304,21 @@ TEST_F(KlassRegistryTest, PurgeBumpsEpochSoReinsertRejectsOlderFrames) { // klassAt(0) comes back as a first insert, not a replacement. ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(1000))); - bool stale = false; - EXPECT_EQ(nullptr, registry.pin(jni, klassAt(0), sampled_old, &stale)); - EXPECT_TRUE(stale); - // Classes that stayed live across the purge still accept their old frames. - EXPECT_EQ(mirror(1), registry.pin(jni, klassAt(1), sampled_old, &stale)); - EXPECT_FALSE(stale); + KlassRegistry::PinStatus status; + EXPECT_EQ(nullptr, registry.pin(jni, klassAt(0), sampled_old, &status)); + EXPECT_EQ(KlassRegistry::PIN_STALE, status); + // A frame sampled now matches. + EXPECT_EQ(mirror(1), registry.pin(jni, klassAt(1), registry.epoch(), &status)); + EXPECT_EQ(KlassRegistry::PIN_OK, status); } TEST_F(KlassRegistryTest, UnloadedClassIsNotReportedStale) { ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); uint32_t sampled = registry.epoch(); heap.collect(&mirrors[0]); - bool stale = true; - EXPECT_EQ(nullptr, registry.pin(jni, klassAt(0), sampled, &stale)); - EXPECT_FALSE(stale); + KlassRegistry::PinStatus status; + EXPECT_EQ(nullptr, registry.pin(jni, klassAt(0), sampled, &status)); + EXPECT_EQ(KlassRegistry::PIN_ABSENT, status); } TEST_F(KlassRegistryTest, EpochComparisonSurvivesWraparound) { diff --git a/doc/LazyJmethodIdResolution.md b/doc/LazyJmethodIdResolution.md index c4c0559cec..f744a7d0bd 100644 --- a/doc/LazyJmethodIdResolution.md +++ b/doc/LazyJmethodIdResolution.md @@ -81,8 +81,9 @@ always get jmethodIDs. ### Option A: reuse epoch (portable fallback) — implemented -Implemented as described below (`KlassRegistry::epoch()`/`bumpEpoch()`, `pin(…, sampled_epoch, &stale)`, -counter `method_resolve_lazy_stale`). It also relies on one invariant, which is now enforced: every class is +Implemented (`KlassRegistry::epoch()`/`bumpEpoch()`, `pin(…, sampled_epoch, &status)`, counter +`method_resolve_lazy_stale`). After review, **every registration bumps the epoch**, not only replacements +and purges (see "Guarantees and limits" below). It also relies on one invariant, which is now enforced: every class is registered before any of its methods can run. ClassPrepare guarantees this; if `add()` fails, `registerClass` preloads that class's jmethodIDs instead (`klass_registry_add_failed`), so no raw frame of an unregistered class is ever produced. Without that, a later class at the same address @@ -91,16 +92,17 @@ could be inserted fresh, without a bump, and accept the unregistered class's fra - The registry keeps a global `_epoch`, and each entry records the epoch it was registered at. -- The epoch is bumped on a replacement (known key, different mirror) and whenever a - rehash purges dead entries, because a purged key can come back looking like a fresh - insert. +- ~~The epoch is bumped on a replacement and on a purge.~~ It is bumped on **every** registration. + The original rule missed a dead class's `Method*` memory being reused by a class loaded fresh + at a *different* `Klass*`. - The sampler stores the current epoch in the raw frame: one acquire load, with no registry lookup in the handler. - Resolve accepts only if `entry.epoch ≤ frame.epoch`, compared modularly. Live classes always pass, because ClassPrepare runs before their methods do. Old-incarnation frames fail, and old and new frames no longer dedup together. -- Cost: a stack sampled across a bump splits into two traces. In heavy churn, bumps - follow the reuse rate. A "seen since last bump" flag can reduce how often it bumps. +- Cost: a stack sampled across a bump splits into two traces. With a bump per registration, + epoch tags split traces on every class load. That's acceptable for a fallback used only + without JFR, and trace-ID tags don't have this problem. ### Option B: HotSpot's JFR class trace ID, read by offset — implemented, preferred @@ -222,6 +224,49 @@ The original budget analysis: - 10 bits alone is too few under heavy churn for either option. Resolving from a background thread shrinks the window between sample and resolve, and helps both. +## Guarantees and limits (after review) + +A review of `a9b67775` found four issues. All have been checked against the OpenJDK sources +and fixed or bounded: + +1. **Methods array read without protection after pinning.** Redefinition swaps `_methods` at a + safepoint (`the_class->set_methods(_new_methods)`) and frees previous versions later, at another + safepoint (`ClassLoaderDataGraph::clean_deallocate_lists`). The dump thread is in native and + holds up neither. Fixed: every load in `VMKlass::containsMethod()` goes through `SafeAccess`, + so a fault reads as "not a member". +2. **`Method*` reuse within a live class (repeated redefinition).** The class's mirror, trace ID + and epoch don't change, so no tag can tell the frames apart. Fixed: the redefine/retransform + hooks call `KlassRegistry::markRedefined()` *before* the original JVMTI function, and preload + the class's jmethodIDs afterwards, so its later frames aren't raw. `pin()` refuses raw frames + of a marked class (`method_resolve_lazy_redefined`), and `resolveViaRegistry()` checks the + mark again after its metadata reads. If the mark is still absent then, every read happened + before the methods were swapped. The same analysis found the epoch-mode hole fixed by + per-registration bumps (above). +3. **`klassRegistry_ut` didn't link in release** (missing `vmStructs.inline.h`). Fixed; all + new gtest binaries pass in release. +4. **Tag collisions.** Bounded and monitored, not eliminated: + - **Can't crash:** after pinning, only `SafeAccess` reads and JNI/JVMTI on a live class remain. + - **Trace-ID tags** can only collide once two classes' trace IDs are ≥ 2^25 apart. A JVM that + has created fewer than 2^25 (33,554,432) classes can't misattribute. + - **After that** (extreme class churn after long uptime), a stale frame can resolve to a live + method of the wrong class. That needs all of: the sampled class is unloaded before the frame + is resolved; a newer class takes over its exact `Method*` address; and the two IDs differ by + an exact multiple of 2^25. The result is one misattributed frame. + - **Epoch tags** need 2^24 registrations while one trace is still unresolved. + - **Tripwire:** `klass_trace_id_beyond_tag_range` counts registrations of classes whose trace + ID exceeds the tag range. If it ever fires in practice, the planned fix is a 64-bit registry + epoch per `CallTrace`, checked as "holder registered no later than the trace". That makes + misattribution impossible and leaves dropped frames as the worst case. A trace-lifetime bound + would *not* help trace-ID tags, since collisions depend on the classes' age difference, not + the frame's. + +Correctness also rests on two invariants: every class is registered before its methods can run +(ClassPrepare; registration failure falls back to preloading), and every redefinition goes +through the hooked `RedefineClasses`/`RetransformClasses`. + +Size correction: `KlassRegistry::Entry` is 24 bytes on 64-bit (`Klass*`, `jweak`, `uint32_t` +epoch, `bool`), not 16. + ## Measurements 20,000 classes × 50 static methods (1M methods), 100 classes per custom loader, all kept @@ -259,9 +304,11 @@ through the `com.sun.management:type=DiagnosticCommand` MBean. It lives in | gtests: `klassRegistry_ut`, `klassTraceId_ut`, `hotspotLazyResolve_ut` (every reuse case under both tag sources), `frame_ut`, `jvmSupport_ut`, `arguments_ut` | pass (macOS; Linux debug + ASan before the trace-ID commits) | | `LazyJmethodIdTest` on JDK 8u462, 11.0.28 (musl) and 17.0.16, 21.0.8, 25.0.2 (glibc), aarch64 | pass, trace-ID tags on all five | | Calibrated offsets: 8u462 → 184, 11.0.28 → 168, 17.0.16 → 168, 21.0.8 → 184, 25.0.2 → 192 | measured | -| `LazyTraceIdJfrValidationTest` (CI cross-check against JFR) | pass on macOS JDK 25; containers pending | +| `LazyTraceIdJfrValidationTest` (CI cross-check against JFR) | pass on JDK 25 (macOS, Linux); 8–21 see below | | Runtime JFR cross-check | dropped, see "Confirming the offset" | +| Review fixes: `SafeAccess` in `containsMethod`, redefinition marking, per-registration epoch, release link fix, tag-range tripwire | done | | Dump-latency benchmark; per-dump cache for repeated raw frames | open | +| `LazyTraceIdJfrValidationTest` finds no JFR samples for the probe on JDK 8–21 (test setup, not an offset mismatch) | open | | `JmethodIdOverheadBenchTest.java`: temporary harness, don't commit | uncommitted | ### Files touched From 55a047a649f01fdcb40b732536da17dcb03ed114 Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Thu, 15 Oct 2026 14:40:16 +0200 Subject: [PATCH 07/17] Validate the trace-ID offset against jdk.ClassDefine instead of execution samples LazyTraceIdJfrValidationTest now records only jdk.ClassDefine in the child JVM (settings written by the test) and compares the probe class's RecordedClass.getId() from that event with the trace ID the profiler logged. JFR emits the event once per class on every JFR-enabled release, whereas JFR's pre-25 sampler recorded almost no jdk.ExecutionSample events for the spinning probe, even without the profiler running. Co-Authored-By: Claude Opus 5.5 --- .../cpu/LazyTraceIdJfrValidationTest.java | 46 +++++++++++-------- doc/LazyJmethodIdResolution.md | 16 ++++--- 2 files changed, 36 insertions(+), 26 deletions(-) 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 index 3544448357..fc78bdb0a0 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyTraceIdJfrValidationTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyTraceIdJfrValidationTest.java @@ -31,10 +31,13 @@ * Validates, against JFR itself, the {@code Klass::_trace_id} offset that {@code fjmethodid=lazy} * finds by calibration. * - *

    A child JVM runs both the profiler (lazy mode) and JFR's own CPU sampler while spinning in a - * probe class. In debug builds the profiler logs the trace ID it reads for each class it allocates - * jmethodIDs for; JFR records the same class in its {@code jdk.ExecutionSample} stack frames, and - * {@code RecordedClass.getId()} is JFR's trace ID for it. The two must be equal. + *

    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 each class + * it allocates jmethodIDs for. 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, @@ -55,11 +58,20 @@ public void calibratedTraceIdMatchesJfrClassId() throws Exception { 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=profile,dumponexit=true"), + 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")) { @@ -79,12 +91,13 @@ public void calibratedTraceIdMatchesJfrClassId() throws Exception { Set jfrIds = jfrClassIds(jfrRecording, PROBE_CLASS); System.out.println("profiler trace id=" + loggedId.get() + ", JFR class ids=" + jfrIds); - assertTrue(!jfrIds.isEmpty(), "JFR recorded no execution sample in " + PROBE_CLASS); + 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) {} } } @@ -98,9 +111,9 @@ private static boolean jfrConsumerAvailable() { } /** - * Returns the IDs JFR gave {@code className} in the method types of its - * {@code jdk.ExecutionSample} stack frames. Uses jdk.jfr.consumer through reflection because - * the test sources compile with {@code --release 8}, which hides jdk.jfr. + * 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"); @@ -108,19 +121,12 @@ private static Set jfrClassIds(Path recording, String className) throws Ex Set ids = new HashSet<>(); for (Object event : events) { Object type = call(event, "getEventType"); - if (!"jdk.ExecutionSample".equals(call(type, "getName"))) { + if (!"jdk.ClassDefine".equals(call(type, "getName"))) { continue; } - Object stackTrace = call(event, "getStackTrace"); - if (stackTrace == null) { - continue; - } - for (Object frame : (List) call(stackTrace, "getFrames")) { - Object method = call(frame, "getMethod"); - Object clazz = method != null ? call(method, "getType") : null; - if (clazz != null && className.equals(call(clazz, "getName"))) { - ids.add((Long) call(clazz, "getId")); - } + 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; diff --git a/doc/LazyJmethodIdResolution.md b/doc/LazyJmethodIdResolution.md index f744a7d0bd..39b3939800 100644 --- a/doc/LazyJmethodIdResolution.md +++ b/doc/LazyJmethodIdResolution.md @@ -193,10 +193,15 @@ barrier). own sampler (`-XX:StartFlightRecording=…,settings=profile`) while it spins in a probe class. - In debug builds, `resolveViaRegistry` logs `Lazy jmethodIDs for , trace id N` for every class it allocates jmethodIDs for. -- The test reads `jdk.ExecutionSample` stack frames through `jdk.jfr.consumer` (by reflection, - because the test sources compile with `--release 8`). It requires the probe class's - `RecordedClass.getId()` to equal the logged ID. -- Result so far: JDK 25.0.1 (macOS) gives profiler 2382 and JFR 2382. +- The child records only `jdk.ClassDefine` (a `.jfc` written by the test). JFR emits it once + when the probe class is created (`define_instance_class` on 8u/11u, `Jfr::on_klass_creation` + on mainline); its `definedClass` is the probe's `RecordedClass`. The test reads it through + `jdk.jfr.consumer` (by reflection, because the test sources compile with `--release 8`) and + requires `getId()` to equal the logged ID. +- `jdk.ExecutionSample` was tried first. Before JDK 25, JFR's own sampler recorded almost no + samples for the spinning probe, even with our profiler not started, so it couldn't serve. +- Results, profiler ID = JFR ID: 8u (musl) 2077, 11 (musl) 2152, 17 2065, 21 2154, 25 2211; + macOS 25 2210. **Tag kind bit:** kept from the prototype. The tag's top bit records whether a frame carries a trace ID or an epoch, so the epoch keeps 25 bits. `resolve()` goes by each frame's own kind, not by @@ -304,11 +309,10 @@ through the `com.sun.management:type=DiagnosticCommand` MBean. It lives in | gtests: `klassRegistry_ut`, `klassTraceId_ut`, `hotspotLazyResolve_ut` (every reuse case under both tag sources), `frame_ut`, `jvmSupport_ut`, `arguments_ut` | pass (macOS; Linux debug + ASan before the trace-ID commits) | | `LazyJmethodIdTest` on JDK 8u462, 11.0.28 (musl) and 17.0.16, 21.0.8, 25.0.2 (glibc), aarch64 | pass, trace-ID tags on all five | | Calibrated offsets: 8u462 → 184, 11.0.28 → 168, 17.0.16 → 168, 21.0.8 → 184, 25.0.2 → 192 | measured | -| `LazyTraceIdJfrValidationTest` (CI cross-check against JFR) | pass on JDK 25 (macOS, Linux); 8–21 see below | +| `LazyTraceIdJfrValidationTest` (CI cross-check against JFR) | pass on JDK 8, 11 (musl), 17, 21, 25 (glibc) and macOS 25: profiler ID = JFR ID on each | | Runtime JFR cross-check | dropped, see "Confirming the offset" | | Review fixes: `SafeAccess` in `containsMethod`, redefinition marking, per-registration epoch, release link fix, tag-range tripwire | done | | Dump-latency benchmark; per-dump cache for repeated raw frames | open | -| `LazyTraceIdJfrValidationTest` finds no JFR samples for the probe on JDK 8–21 (test setup, not an offset mismatch) | open | | `JmethodIdOverheadBenchTest.java`: temporary harness, don't commit | uncommitted | ### Files touched From 75e687020a2444b0bc7f50ecc5f245e13cf803fc Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Thu, 15 Oct 2026 14:55:16 +0200 Subject: [PATCH 08/17] Cache raw frame resolutions per dump Lookup now keeps a per-dump map from (untagged Method*, raw-frame tag) to the resolved jmethodID, or nullptr for a refused frame, so a raw method that recurs across traces is resolved once per dump instead of once per occurrence. Each resolution sets up crash protection, takes the KlassRegistry mutex and pins the class through JNI. New counter raw_frame_resolve_cache_hits. Tests cover repeated occurrences with different bci and frame type, a fresh cache per dump, and frames from two loads of a class at the same addresses staying apart. Co-Authored-By: Claude Opus 5.5 --- ddprof-lib/src/main/cpp/counters.h | 3 ++ ddprof-lib/src/main/cpp/flightRecorder.cpp | 14 +++++- ddprof-lib/src/main/cpp/flightRecorder.h | 26 ++++++++++ .../src/test/cpp/hotspotLazyResolve_ut.cpp | 50 ++++++++++++++++++- doc/LazyJmethodIdResolution.md | 3 +- 5 files changed, 93 insertions(+), 3 deletions(-) diff --git a/ddprof-lib/src/main/cpp/counters.h b/ddprof-lib/src/main/cpp/counters.h index 49192a3e8f..10ef27a014 100644 --- a/ddprof-lib/src/main/cpp/counters.h +++ b/ddprof-lib/src/main/cpp/counters.h @@ -195,6 +195,9 @@ * 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") \ X(WALKVM_RAW_FRAME_UNTAGGABLE, "walkvm_raw_frame_untaggable") \ /* Gauge, set at each fjmethodid=lazy start: the Klass::_trace_id offset \ diff --git a/ddprof-lib/src/main/cpp/flightRecorder.cpp b/ddprof-lib/src/main/cpp/flightRecorder.cpp index 842730ff6b..6b310a13e3 100644 --- a/ddprof-lib/src/main/cpp/flightRecorder.cpp +++ b/ddprof-lib/src/main/cpp/flightRecorder.cpp @@ -702,13 +702,25 @@ MethodInfo *Lookup::resolveMethod(ASGCT_CallFrame &frame) { return fillMethod(frame, method_id, bci, prof_thread); } +jmethodID Lookup::resolveRawMethod(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); + return cached->second; + } + jmethodID method_id = JVMSupport::resolve(frame.method, frame.bci); + _raw_method_cache.emplace(key, method_id); + return method_id; +} + 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, bci); + method_id = resolveRawMethod(frame); if (method_id == nullptr) { return unknownMethod(); } diff --git a/ddprof-lib/src/main/cpp/flightRecorder.h b/ddprof-lib/src/main/cpp/flightRecorder.h index bee39b415e..e5f56d4305 100644 --- a/ddprof-lib/src/main/cpp/flightRecorder.h +++ b/ddprof-lib/src/main/cpp/flightRecorder.h @@ -464,6 +464,29 @@ 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 same raw method recurs in + // many traces, and each resolution sets up crash protection, takes the + // KlassRegistry mutex and pins the class through JNI. Keyed by the untagged + // Method* and the frame's 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. + 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; @@ -547,6 +570,9 @@ class Lookup { _symbols() {} MethodInfo *resolveMethod(ASGCT_CallFrame &frame); + // Resolves a raw Method* frame to a jmethodID (nullptr if it must be + // treated as unknown), at most once per distinct method and tag per dump. + jmethodID resolveRawMethod(const ASGCT_CallFrame &frame); u32 getPackage(const char *class_name); u32 getSymbol(const char *name); }; diff --git a/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp b/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp index bfcba01805..ee9626692b 100644 --- a/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp +++ b/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp @@ -6,6 +6,7 @@ #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" @@ -249,6 +250,9 @@ class FakeJvm { JNIEnv* jni() { return &_jni_env; } jvmtiEnv* jvmti() { return &_jvmti; } + // KlassRegistry::pin() calls NewLocalRef once per resolution. + int new_local_ref_calls = 0; + jclass mirror(FakeObject& obj) { return reinterpret_cast(&obj); } // Class unloading as far as the registry can observe it: the mirror's @@ -276,7 +280,10 @@ class FakeJvm { 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) { return s_instance->resolve(ref); } + 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) { @@ -565,6 +572,47 @@ TEST_P(HotspotLazyResolveTest, CountsClassesWithTraceIdBeyondTagRange) { #endif } +// Lookup resolves each distinct raw method once per dump, however often it +// recurs, and whatever bci or frame type the occurrences carry. +TEST_P(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; + EXPECT_EQ(_cls.jmethodId(), lookup.resolveRawMethod(a)); + EXPECT_EQ(_cls.jmethodId(), lookup.resolveRawMethod(a)); + EXPECT_EQ(_cls.jmethodId(), lookup.resolveRawMethod(b)); + EXPECT_EQ(1, _jvm.new_local_ref_calls - before); + + // The next dump starts with an empty cache. + Lookup next(nullptr, &methods, &classes); + EXPECT_EQ(_cls.jmethodId(), next.resolveRawMethod(a)); + EXPECT_EQ(2, _jvm.new_local_ref_calls - before); +} + +// Frames from two loads of a class at the same addresses carry different tags, +// so the cache keeps their outcomes apart. +TEST_P(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(nullptr, lookup.resolveRawMethod(before)); + EXPECT_EQ(_cls.jmethodId(), lookup.resolveRawMethod(after)); + EXPECT_EQ(nullptr, lookup.resolveRawMethod(before)); +} + INSTANTIATE_TEST_SUITE_P(TagSource, HotspotLazyResolveTest, ::testing::Bool(), [](const ::testing::TestParamInfo& info) { return info.param ? "TraceId" : "Epoch"; diff --git a/doc/LazyJmethodIdResolution.md b/doc/LazyJmethodIdResolution.md index 39b3939800..c3a7fad06e 100644 --- a/doc/LazyJmethodIdResolution.md +++ b/doc/LazyJmethodIdResolution.md @@ -312,7 +312,8 @@ through the `com.sun.management:type=DiagnosticCommand` MBean. It lives in | `LazyTraceIdJfrValidationTest` (CI cross-check against JFR) | pass on JDK 8, 11 (musl), 17, 21, 25 (glibc) and macOS 25: profiler ID = JFR ID on each | | Runtime JFR cross-check | dropped, see "Confirming the offset" | | Review fixes: `SafeAccess` in `containsMethod`, redefinition marking, per-registration epoch, release link fix, tag-range tripwire | done | -| Dump-latency benchmark; per-dump cache for repeated raw frames | open | +| Per-dump cache for raw frame resolutions (`Lookup::_raw_method_cache`) | done: first dump with nearly every frame raw went from 60.3 ms to 34.3 ms (27,956 hits, ~0.9 µs saved each; macOS debug build, single run); later dumps unchanged (~20 ms) | +| Release-build dump latency and memory on a real workload, x64 runs | open | | `JmethodIdOverheadBenchTest.java`: temporary harness, don't commit | uncommitted | ### Files touched From 3f848c6d4408da9b6a285c5ea222755ebbb9761f Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Thu, 15 Oct 2026 15:55:12 +0200 Subject: [PATCH 09/17] Let JVMTI validate redefinition arguments before the hooks inspect them RedefineClassesHook and RetransformClassesHook now forward a call with a positive count and a null array straight to the original function, which returns JVMTI_ERROR_NULL_POINTER; before, the hooks' own loops dereferenced the array first and crashed, in every jmethodID mode. In fjmethodid=lazy, the per-class work before and after the call only touches elements that JVMTI's GetClassStatus accepts as instance classes (HotspotSupport::isInstanceClass, also used by registerClass), so a non-class object or an array class is never looked up as a Klass* or preloaded. redefineHooks_ut calls both hooks with stubbed originals in lazy and full-preload mode: null arrays, non-class, array-class and null elements. Without the fix its first case crashes with SIGSEGV. Co-Authored-By: Claude Opus 5.5 --- .../src/main/cpp/hotspot/hotspotSupport.cpp | 24 +- .../src/main/cpp/hotspot/hotspotSupport.h | 12 +- ddprof-lib/src/main/cpp/jvmSupport.cpp | 6 +- ddprof-lib/src/main/cpp/jvmSupport.h | 5 +- ddprof-lib/src/main/cpp/vmEntry.cpp | 14 +- ddprof-lib/src/test/cpp/redefineHooks_ut.cpp | 208 ++++++++++++++++++ 6 files changed, 255 insertions(+), 14 deletions(-) create mode 100644 ddprof-lib/src/test/cpp/redefineHooks_ut.cpp diff --git a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp index 6fc7be0735..8ac33a0e6e 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp @@ -1498,16 +1498,30 @@ bool HotspotSupport::rawFrameTag(const VMMethod* method, uint32_t* tag) { return true; } -void HotspotSupport::beforeClassRedefinition(JNIEnv *jni, jclass klass) { - _klass_registry.markRedefined(VMKlass::fromJavaClass(jni, klass)); +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; +} + +void HotspotSupport::beforeClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass) { + if (isInstanceClass(jvmti, klass)) { + _klass_registry.markRedefined(VMKlass::fromJavaClass(jni, klass)); + } +} + +void HotspotSupport::afterClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass) { + if (isInstanceClass(jvmti, klass)) { + loadMethodIDsIfNeededImpl(jvmti, jni, klass, true /* load all */); + } } bool HotspotSupport::registerClass(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass) { - jint status = 0; // Array and primitive classes have no methods, so no frame ever names them // as a holder; skipping them keeps GetLoadedClasses() at start cheap. - if (jvmti->GetClassStatus(klass, &status) != JVMTI_ERROR_NONE || - (status & (JVMTI_CLASS_STATUS_ARRAY | JVMTI_CLASS_STATUS_PRIMITIVE)) != 0) { + if (!isInstanceClass(jvmti, klass)) { return false; } const void* vm_klass = VMKlass::fromJavaClass(jni, klass); diff --git a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h index 385a15b21d..6cd389fc62 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h @@ -75,8 +75,16 @@ class HotspotSupport { static bool registerClass(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass); // fjmethodid=lazy: marks `klass` in _klass_registry before a redefinition - // (see klassRegistry.h for why its raw frames are refused from then on). - static void beforeClassRedefinition(JNIEnv *jni, jclass klass); + // (see klassRegistry.h for why its raw frames are refused from then on) + // and preloads its jmethodIDs after it. `klass` comes straight from the + // RedefineClasses/RetransformClasses caller, so anything that is not an + // instance class is ignored, as JVMTI itself would reject it. + static void beforeClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass); + static void afterClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass); + + // 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 `tag` (the frame's, see _tag_with_trace_id) diff --git a/ddprof-lib/src/main/cpp/jvmSupport.cpp b/ddprof-lib/src/main/cpp/jvmSupport.cpp index 336810eebc..f1d9735f50 100644 --- a/ddprof-lib/src/main/cpp/jvmSupport.cpp +++ b/ddprof-lib/src/main/cpp/jvmSupport.cpp @@ -173,9 +173,9 @@ bool JVMSupport::loadMethodIDsIfNeeded(jvmtiEnv *jvmti, JNIEnv *jni, jclass klas } } -void JVMSupport::beforeClassRedefinition(JNIEnv *jni, jclass klass) { +void JVMSupport::beforeClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass) { if (getLoadState() == Lazy_loaded) { - HotspotSupport::beforeClassRedefinition(jni, klass); + HotspotSupport::beforeClassRedefinition(jvmti, jni, klass); } } @@ -183,7 +183,7 @@ void JVMSupport::afterClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclass kla if (getLoadState() == Lazy_loaded) { // Even after a failed attempt: the class is marked for good, and only // jmethodIDs keep its later frames from being raw (and refused). - HotspotSupport::loadMethodIDsIfNeededImpl(jvmti, jni, klass, true /* load all */); + HotspotSupport::afterClassRedefinition(jvmti, jni, klass); } else if (succeeded) { loadMethodIDsIfNeeded(jvmti, jni, klass); } diff --git a/ddprof-lib/src/main/cpp/jvmSupport.h b/ddprof-lib/src/main/cpp/jvmSupport.h index 596a447a36..e5a1ddfbaa 100644 --- a/ddprof-lib/src/main/cpp/jvmSupport.h +++ b/ddprof-lib/src/main/cpp/jvmSupport.h @@ -70,8 +70,9 @@ class JVMSupport { // 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. - static void beforeClassRedefinition(JNIEnv *jni, jclass klass); + // 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) to a jmethodID diff --git a/ddprof-lib/src/main/cpp/vmEntry.cpp b/ddprof-lib/src/main/cpp/vmEntry.cpp index dfc5936ba4..1f7fa8792c 100644 --- a/ddprof-lib/src/main/cpp/vmEntry.cpp +++ b/ddprof-lib/src/main/cpp/vmEntry.cpp @@ -678,10 +678,15 @@ 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(env, class_definitions[i].klass); + JVMSupport::beforeClassRedefinition(jvmti, env, class_definitions[i].klass); } } @@ -700,10 +705,15 @@ 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(env, classes[i]); + JVMSupport::beforeClassRedefinition(jvmti, env, classes[i]); } } 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"; + }); From 433e7f0e5782b6e500a796f30068b0da50118d43 Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Thu, 15 Oct 2026 16:20:00 +0200 Subject: [PATCH 10/17] Stress-test lazy resolution against live class redefinition LazyRedefinitionStressTest loads a small agent by self-attach to get Instrumentation, then redefines a generated class with its own bytes about every 2 ms while another thread spins in it, fjmethodid=lazy samples it and the profiler dumps every 100 ms. Raw frames of the class are collected for 300 ms before the first redefinition, so the first dumps afterwards have stale raw frames of a marked class to refuse. The test requires the JVM to survive, every redefinition to be marked, a stale raw frame to be refused, and frames sampled after the redefinitions to resolve by name. New counter klass_registry_redefinitions_marked counts the marks the redefinition hooks set. SpinClasses can hand out a class's bytes. Co-Authored-By: Claude Opus 5.5 --- ddprof-lib/src/main/cpp/counters.h | 3 + .../src/main/cpp/hotspot/hotspotSupport.cpp | 4 +- .../com/datadoghq/profiler/SpinClasses.java | 11 + .../cpu/LazyRedefinitionStressTest.java | 195 ++++++++++++++++++ .../profiler/cpu/RetransformAgent.java | 29 +++ doc/LazyJmethodIdResolution.md | 2 + 6 files changed, 242 insertions(+), 2 deletions(-) create mode 100644 ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyRedefinitionStressTest.java create mode 100644 ddprof-test/src/test/java/com/datadoghq/profiler/cpu/RetransformAgent.java diff --git a/ddprof-lib/src/main/cpp/counters.h b/ddprof-lib/src/main/cpp/counters.h index 10ef27a014..0103da8ef9 100644 --- a/ddprof-lib/src/main/cpp/counters.h +++ b/ddprof-lib/src/main/cpp/counters.h @@ -190,6 +190,9 @@ /* 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 \ diff --git a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp index 8ac33a0e6e..224ac15991 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp @@ -1507,8 +1507,8 @@ bool HotspotSupport::isInstanceClass(jvmtiEnv *jvmti, jclass klass) { } void HotspotSupport::beforeClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass) { - if (isInstanceClass(jvmti, klass)) { - _klass_registry.markRedefined(VMKlass::fromJavaClass(jni, klass)); + if (isInstanceClass(jvmti, klass) && _klass_registry.markRedefined(VMKlass::fromJavaClass(jni, klass))) { + Counters::increment(KLASS_REGISTRY_REDEFINITIONS_MARKED); } } diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/SpinClasses.java b/ddprof-test/src/test/java/com/datadoghq/profiler/SpinClasses.java index 20e7450d83..cb8a4256da 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/SpinClasses.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/SpinClasses.java @@ -28,6 +28,17 @@ 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); 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/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/LazyJmethodIdResolution.md b/doc/LazyJmethodIdResolution.md index c3a7fad06e..284cce80bb 100644 --- a/doc/LazyJmethodIdResolution.md +++ b/doc/LazyJmethodIdResolution.md @@ -313,6 +313,8 @@ through the `com.sun.management:type=DiagnosticCommand` MBean. It lives in | Runtime JFR cross-check | dropped, see "Confirming the offset" | | Review fixes: `SafeAccess` in `containsMethod`, redefinition marking, per-registration epoch, release link fix, tag-range tripwire | done | | Per-dump cache for raw frame resolutions (`Lookup::_raw_method_cache`) | done: first dump with nearly every frame raw went from 60.3 ms to 34.3 ms (27,956 hits, ~0.9 µs saved each; macOS debug build, single run); later dumps unchanged (~20 ms) | +| Redefinition hooks forward invalid arguments to JVMTI untouched (`redefineHooks_ut`) | done | +| Live-JVM redefinition stress test (`LazyRedefinitionStressTest`: ~1000 redefinitions racing ~47 dumps; every redefinition marked, stale raw frames refused, later frames resolve by name) | pass on JDK 17, 21, 25 (glibc), 21 under ASan, 11 (musl), macOS 25 | | Release-build dump latency and memory on a real workload, x64 runs | open | | `JmethodIdOverheadBenchTest.java`: temporary harness, don't commit | uncommitted | From fdbb8cad34197cf1201401e2c322835d90a404c6 Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Thu, 15 Oct 2026 17:00:24 +0200 Subject: [PATCH 11/17] Drop registry-epoch tags; lazy mode requires JFR trace IDs Raw frames of fjmethodid=lazy are now always tagged with the holder's JFR trace ID. When the trace-ID offset can't be calibrated (8u before 8u262, builds without JFR), lazy mode falls back to partial mode instead of tagging frames with the registry epoch. The 25-bit epoch wrapped after 2^24 registrations, after which every frame of a long-lived class was refused, and bumping it on every registration split identical stacks into separate traces. KlassRegistry no longer keeps an epoch. - Only lazy mode tags frames. Partial-mode frames are plain Method*s again, and resolve() picks the lazy or partial path from the frame's marker bit rather than the current mode, so frames that outlive a restart in the other mode still resolve the way they were sampled. - Redefinition: the hooks call KlassRegistry::beginRedefinition() before and endRedefinition() after the original function. A class is refused while a call runs and for good once one succeeds; a call JVMTI rejects leaves it as before and preloads nothing. Redefinitions are tracked in every mode once the registry has entries. - Mode selection uses the stack walker mode the profiler resolved, not the cstack argument: cstack=default resolves to dwarf where the VM walker is unavailable, and then all jmethodIDs must be preloaded. This also applied to fjmethodid=false. - VMKlass::containsMethod() scans at most 256 methods after the idnum fast path. - readMethodHolder() uses VMMethod::holderOrNull(). - The rehash() comment now matches what the code does. The prototype design notes (doc/LazyJmethodIdResolution.md) are removed; their content moves to the PR description. Co-Authored-By: Claude Opus 5.5 --- ddprof-lib/src/main/cpp/counters.h | 8 +- ddprof-lib/src/main/cpp/frame.h | 15 +- .../src/main/cpp/hotspot/hotspotSupport.cpp | 128 +++---- .../src/main/cpp/hotspot/hotspotSupport.h | 68 ++-- .../src/main/cpp/hotspot/klassRegistry.cpp | 85 ++--- .../src/main/cpp/hotspot/klassRegistry.h | 79 ++-- ddprof-lib/src/main/cpp/hotspot/vmStructs.h | 14 +- ddprof-lib/src/main/cpp/jvmSupport.cpp | 19 +- .../src/test/cpp/hotspotLazyResolve_ut.cpp | 173 ++++++--- ddprof-lib/src/test/cpp/jvmSupport_ut.cpp | 28 ++ ddprof-lib/src/test/cpp/klassRegistry_ut.cpp | 179 +++++---- doc/LazyJmethodIdResolution.md | 361 ------------------ 12 files changed, 435 insertions(+), 722 deletions(-) delete mode 100644 doc/LazyJmethodIdResolution.md diff --git a/ddprof-lib/src/main/cpp/counters.h b/ddprof-lib/src/main/cpp/counters.h index 0103da8ef9..8ccb8c1675 100644 --- a/ddprof-lib/src/main/cpp/counters.h +++ b/ddprof-lib/src/main/cpp/counters.h @@ -181,8 +181,8 @@ X(METHOD_RESOLVE_LAZY_UNLOADED, "method_resolve_lazy_unloaded") \ X(METHOD_RESOLVE_LAZY_NOT_CURRENT, "method_resolve_lazy_not_current") \ X(METHOD_RESOLVE_LAZY_CLASS_LOADED, "method_resolve_lazy_class_loaded") \ - /* STALE: the frame predates the class now registered at its Klass* \ - * address (Klass* reuse after unloading); serializes as "unknown". \ + /* 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. */ \ @@ -204,8 +204,8 @@ X(KLASS_REGISTRY_ADD_FAILED, "klass_registry_add_failed") \ 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 they use the registry epoch. 0 if \ - * lazy mode never started. */ \ + * 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 \ diff --git a/ddprof-lib/src/main/cpp/frame.h b/ddprof-lib/src/main/cpp/frame.h index 52281371da..438575d6cb 100644 --- a/ddprof-lib/src/main/cpp/frame.h +++ b/ddprof-lib/src/main/cpp/frame.h @@ -43,13 +43,14 @@ enum FrameTypeId { // 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 also carries a RAW_TAG_BITS-wide tag: the KlassRegistry -// epoch the frame was sampled under (hotspot/klassRegistry.h). 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 that -// predate the class currently registered at that address. +// 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 diff --git a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp index 224ac15991..6ef10fabbd 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp @@ -140,7 +140,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 FrameType::untagPointer(frame.method) == method; + return HotspotSupport::rawFrameMethod(frame.bci, frame.method) == method; } else { return frame.method_id == method_id; } @@ -187,10 +187,17 @@ static void fillFrameTypes(ASGCT_CallFrame *frames, int num_frames, VMNMethod *n } } -// Fill the frame with raw method pointer and its tag (see frame.h for where -// the tag bits go, HotspotSupport::_tag_with_trace_id for what they hold). -static void fillFrameRaw(ASGCT_CallFrame& frame, FrameTypeId type, int bci, const VMMethod* method, - uint32_t tag) { +// 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); @@ -215,15 +222,18 @@ void HotspotSupport::fillJavaFrame(ASGCT_CallFrame& frame, FrameTypeId type, int // inherent to this mode. NO_INJECTION_ASSERT(method != nullptr); uint32_t tag; - if (!FrameType::canTagPointer(method) || !rawFrameTag(method, &tag)) { - // 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. + if (JVMSupport::getLoadState() != JVMSupport::Lazy_loaded) { + fillFrameRaw(frame, type, bci, method); + } else if (!FrameType::canTagPointer(method) || !lazyFrameTag(method, &tag)) { + // 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 { - fillFrameRaw(frame, type, bci, method, tag); + fillFrameRawTagged(frame, type, bci, method, tag); } } else { // fjmethodid=true: all jmethodIDs should be preloaded, so nullptr is @@ -1467,29 +1477,21 @@ bool HotspotSupport::loadMethodIDsIfNeededImpl(jvmtiEnv *jvmti, JNIEnv *jni, jcl } KlassRegistry HotspotSupport::_klass_registry; -volatile bool HotspotSupport::_tag_with_trace_id = false; - -static_assert(KlassRegistry::EPOCH_BITS == FrameType::RAW_TAG_BITS - 1, - "a raw frame's tag holds a whole epoch plus the tag-kind bit"); bool HotspotSupport::canResolveLazily() { return VMStructs::hasKlassMethods(); } -void HotspotSupport::initLazyResolution(JNIEnv* jni) { - bool trace_ids = KlassTraceId::calibrate(jni); - __atomic_store_n(&_tag_with_trace_id, trace_ids, __ATOMIC_RELEASE); +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, trace_ids ? KlassTraceId::offset() : -1); - TEST_LOG("Raw frame tags: %s", trace_ids ? "klass trace id" : "registry epoch"); + Counters::set(KLASS_TRACE_ID_OFFSET, calibrated ? KlassTraceId::offset() : -1); + TEST_LOG("Raw frame tags: %s", calibrated ? "klass trace id" : "unavailable, lazy mode off"); + return calibrated; } -bool HotspotSupport::rawFrameTag(const VMMethod* method, uint32_t* tag) { - if (!__atomic_load_n(&_tag_with_trace_id, __ATOMIC_ACQUIRE)) { - *tag = _klass_registry.epoch() & TAG_VALUE_MASK; - return true; - } +bool HotspotSupport::lazyFrameTag(const VMMethod* method, uint32_t* tag) { uint64_t id; if (!KlassTraceId::read(method->holderOrNull(), &id)) { return false; @@ -1507,13 +1509,23 @@ bool HotspotSupport::isInstanceClass(jvmtiEnv *jvmti, jclass klass) { } void HotspotSupport::beforeClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass) { - if (isInstanceClass(jvmti, klass) && _klass_registry.markRedefined(VMKlass::fromJavaClass(jni, 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) { - if (isInstanceClass(jvmti, klass)) { +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 */); } } @@ -1526,8 +1538,7 @@ bool HotspotSupport::registerClass(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass) { } const void* vm_klass = VMKlass::fromJavaClass(jni, klass); uint64_t trace_id; - if (__atomic_load_n(&_tag_with_trace_id, __ATOMIC_ACQUIRE) && KlassTraceId::read(vm_klass, &trace_id) && - trace_id > TAG_VALUE_MASK) { + 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. @@ -1536,9 +1547,9 @@ bool HotspotSupport::registerClass(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass) { if (_klass_registry.add(jni, vm_klass, klass)) { return true; } - // An unregistered class's frames could never be told apart from a later - // class at the same address (see klassRegistry.h), so give it jmethodIDs - // up front: walkVM then never stores a raw frame for it. + // 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*/); } @@ -1761,16 +1772,11 @@ static bool readMethodHolder(const void* method, VMMethod** out_vm_method, if (vm_method == nullptr) { return false; } + // 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 (const_method == nullptr) { - return false; - } - VMConstantPool* const_pool = const_method->constants_or_null(); - if (const_pool == nullptr) { - return false; - } - VMKlass* holder = const_pool->holder_or_null(); - if (holder == nullptr) { + if (holder == nullptr || const_method == nullptr) { return false; } *out_vm_method = vm_method; @@ -1863,8 +1869,12 @@ jmethodID HotspotSupport::resolve(const void* tagged_method, int bci) { if ((jmethodID)tagged_method == JMETHODID_NOT_WALKABLE) { return nullptr; } - const void* method = FrameType::untagPointer(tagged_method); + // 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); // 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 @@ -1884,7 +1894,6 @@ jmethodID HotspotSupport::resolve(const void* tagged_method, int bci) { return nullptr; } - const bool lazy = JVMSupport::getLoadState() == JVMSupport::Lazy_loaded; ResolvedNames names; VMMethod* vm_method = nullptr; jmethodID existing_id = nullptr; @@ -1927,7 +1936,7 @@ jmethodID HotspotSupport::resolve(const void* tagged_method, int bci) { return nullptr; } if (lazy) { - return resolveViaRegistry(vm_method, holder, idnum, tag); + return resolveViaRegistry(vm_method, holder, idnum, tag & TAG_VALUE_MASK); } if (existing_id != nullptr) { return existing_id; @@ -1956,33 +1965,24 @@ static void logLazilyLoadedClass(jvmtiEnv* jvmti, jclass clz, const void* holder // 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. jmethodID HotspotSupport::resolveViaRegistry(VMMethod* vm_method, const void* holder, int idnum, - uint32_t tag) { + uint32_t trace_id_bits) { JNIEnv* jni = VM::jni(); - const bool trace_id_tag = (tag & TRACE_ID_TAG) != 0; - const uint32_t tag_value = tag & TAG_VALUE_MASK; KlassRegistry::PinStatus status; - // For a trace-ID tag the registry's epoch plays no part: passing the current - // epoch makes pin() accept any registration, and the trace ID is compared - // once the class is pinned. - uint32_t sampled_epoch = trace_id_tag ? _klass_registry.epoch() : tag_value; - jclass clz = _klass_registry.pin(jni, holder, sampled_epoch, &status); + jclass clz = _klass_registry.pin(jni, holder, &status); if (clz == nullptr) { - // Stale: the class registered at `holder` arrived after the sample, so the - // frame belongs to an earlier occupant of that memory. 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. - Counters::increment(status == KlassRegistry::PIN_STALE ? METHOD_RESOLVE_LAZY_STALE - : status == KlassRegistry::PIN_REDEFINED ? METHOD_RESOLVE_LAZY_REDEFINED - : METHOD_RESOLVE_LAZY_UNLOADED); + // 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. + Counters::increment(status == KlassRegistry::PIN_REDEFINED ? METHOD_RESOLVE_LAZY_REDEFINED + : METHOD_RESOLVE_LAZY_UNLOADED); return nullptr; } jmethodID method_id = nullptr; uint64_t trace_id = 0; - if (trace_id_tag && - (!KlassTraceId::read(holder, &trace_id) || ((uint32_t)trace_id & TAG_VALUE_MASK) != tag_value)) { - // The class at `holder` is a different load than the one sampled. + 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. Counters::increment(METHOD_RESOLVE_LAZY_STALE); jni->DeleteLocalRef(clz); return nullptr; diff --git a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h index 6cd389fc62..3ea2984dab 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h @@ -54,46 +54,45 @@ class HotspotSupport { // resolve() before it touches a raw Method*. See klassRegistry.h. static KlassRegistry _klass_registry; - // fjmethodid=lazy raw frame tags (frame.h). The top bit says what the - // rest holds: set, the low TAG_VALUE_BITS of the holder's JFR trace ID - // (KlassTraceId), unique per load of a class; clear, _klass_registry's - // epoch. resolve() goes by each frame's own kind, never by the current - // tag source. + // 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; - // Whether new raw frames get trace-ID tags: set by initLazyResolution() - // when calibration found the offset. - static volatile bool _tag_with_trace_id; - - // The tag to store with a raw frame of `method`; false if it cannot be - // determined (unreadable holder or trace ID). Signal safe. - static bool rawFrameTag(const VMMethod* method, uint32_t* tag); + // 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); // 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); - // fjmethodid=lazy: marks `klass` in _klass_registry before a redefinition - // (see klassRegistry.h for why its raw frames are refused from then on) - // and preloads its jmethodIDs after it. `klass` comes straight from the - // RedefineClasses/RetransformClasses caller, so anything that is not an - // instance class is ignored, as JVMTI itself would reject it. + // 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); + 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 `tag` (the frame's, see _tag_with_trace_id) - // shows the frame came from an earlier class at that address, checks that - // `vm_method` is still one of its methods, and only then reads or - // allocates the jmethodID. `holder` and `idnum` were read from the + // `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 + // or allocates the jmethodID. `holder` and `idnum` were read from the // unverified Method* and are only used as lookup keys. static jmethodID resolveViaRegistry(VMMethod* vm_method, const void* holder, int idnum, - uint32_t tag); + uint32_t trace_id_bits); // Runs getJavaTraceAsync() under withUcontextFaultRecovery(), then layers // on its two post-processing steps: resolving frame types for the top @@ -203,11 +202,18 @@ 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; } // fjmethodid=lazy needs the Klass*/methods-array structs on top of the @@ -216,8 +222,10 @@ class HotspotSupport { static bool canResolveLazily(); // fjmethodid=lazy setup that needs JNI: locates Klass::_trace_id - // (KlassTraceId::calibrate) and picks the raw-frame tag source. - static void initLazyResolution(JNIEnv* jni); + // (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); // Resolve a raw-pointer frame to a jmethodID at dumping time. Takes the // frame's method field and encoded bci as stored, tag bits included. diff --git a/ddprof-lib/src/main/cpp/hotspot/klassRegistry.cpp b/ddprof-lib/src/main/cpp/hotspot/klassRegistry.cpp index e99cb8577b..6b661434a1 100644 --- a/ddprof-lib/src/main/cpp/hotspot/klassRegistry.cpp +++ b/ddprof-lib/src/main/cpp/hotspot/klassRegistry.cpp @@ -24,19 +24,20 @@ KlassRegistry::Entry* KlassRegistry::find(const void* klass) const { return &_table[i]; } -// Called with _lock held. The release store pairs with epoch()'s acquire -// load: a thread that runs a method of a class registered here, and then -// samples it, observes at least the epoch that class was registered at. -uint32_t KlassRegistry::bumpEpoch() { - uint32_t next = _epoch + 1; - __atomic_store_n(&_epoch, next, __ATOMIC_RELEASE); - return next; +// 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: liveness is only counted in the first, and an entry that -// dies between the passes is just carried over and purged next time. +// 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++) { @@ -93,9 +94,9 @@ bool KlassRegistry::add(JNIEnv* jni, const void* klass, jclass mirror) { } jni->DeleteWeakGlobalRef(e->mirror); e->mirror = ref; - // A different class now lives at this address; frames sampled from the - // previous one must not match it. - e->epoch = bumpEpoch(); + // 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; } @@ -111,42 +112,27 @@ bool KlassRegistry::add(JNIEnv* jni, const void* klass, jclass mirror) { Entry* e = find(klass); e->klass = klass; e->mirror = ref; - // Every registration gets a new epoch: this class may occupy memory freed - // by a class that frames sampled before now came from. - e->epoch = bumpEpoch(); + e->redefinitions_in_progress = 0; e->redefined = false; _size++; return true; } -jclass KlassRegistry::pin(JNIEnv* jni, const void* klass, uint32_t sampled_epoch, PinStatus* status) { +jclass KlassRegistry::pin(JNIEnv* jni, const void* klass, PinStatus* status) { PinStatus local_status; if (status == nullptr) { status = &local_status; } *status = PIN_ABSENT; - if (klass == nullptr) { - return nullptr; - } MutexLocker ml(_lock); - if (_capacity == 0) { + Entry* e = findRegistered(klass); + if (e == nullptr) { return nullptr; } - Entry* e = find(klass); - if (e->klass != klass) { - return nullptr; - } - if (e->redefined) { + if (e->redefined || e->redefinitions_in_progress > 0) { *status = PIN_REDEFINED; return nullptr; } - // Serial-number comparison: the frame is at least as new as the entry iff - // (sampled - registered) mod 2^EPOCH_BITS lies in the lower half. - const uint32_t mask = (1u << EPOCH_BITS) - 1; - if (((sampled_epoch - e->epoch) & mask) >= (1u << (EPOCH_BITS - 1))) { - *status = PIN_STALE; - 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. @@ -157,29 +143,36 @@ jclass KlassRegistry::pin(JNIEnv* jni, const void* klass, uint32_t sampled_epoch return ref; } -bool KlassRegistry::markRedefined(const void* klass) { - if (klass == nullptr) { +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); - if (_capacity == 0) { - return false; + Entry* e = findRegistered(klass); + if (e == nullptr) { + return; } - Entry* e = find(klass); - if (e->klass != klass) { - return false; + // 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; } - e->redefined = true; - return true; } bool KlassRegistry::isRedefined(const void* klass) { MutexLocker ml(_lock); - if (_capacity == 0) { - return false; - } - Entry* e = find(klass); - return e->klass == klass && e->redefined; + Entry* e = findRegistered(klass); + return e != nullptr && (e->redefined || e->redefinitions_in_progress > 0); } size_t KlassRegistry::size() { diff --git a/ddprof-lib/src/main/cpp/hotspot/klassRegistry.h b/ddprof-lib/src/main/cpp/hotspot/klassRegistry.h index e01df4cfb4..903a787190 100644 --- a/ddprof-lib/src/main/cpp/hotspot/klassRegistry.h +++ b/ddprof-lib/src/main/cpp/hotspot/klassRegistry.h @@ -24,6 +24,8 @@ * 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, @@ -31,36 +33,25 @@ * loaded. A Klass* address reused by a newly loaded class simply overwrites * the dead entry on that class's ClassPrepare. * - * Metadata reuse: the memory of a freed class -- its Klass* and very likely - * its Method* addresses -- goes to classes loaded later, so a Method* sampled - * from an unloaded class can look exactly like a method of a newer class, - * whether that one sits at the same Klass* or elsewhere. The registry - * therefore keeps an epoch: every registration bumps it and records the new - * value, so any class registered after a frame was sampled has a newer epoch - * than the frame. walkVM can store epoch() in each raw frame (when no JFR - * trace ID is available, see HotspotSupport); pin() then refuses a frame - * whose epoch predates the entry, since that frame can only come from an - * earlier occupant of the memory. This relies on every class being - * registered before any of its methods can run, which ClassPrepare - * guarantees; when registration fails, the caller must preload the class's - * jmethodIDs instead so that no raw frame of it is ever produced. - * * 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 - * markRedefined() is called before every redefinition, the class's - * jmethodIDs are preloaded after it (so its later frames are not raw), and - * pin() refuses every raw frame of a marked class. + * 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. * - * Except for epoch(), all operations take an internal mutex and call JNI, so - * none of them may be used from a signal handler. + * 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 epoch; // _epoch when this mirror was registered - bool redefined; // markRedefined() was called; never reset + uint32_t redefinitions_in_progress; + bool redefined; // a redefinition succeeded; never reset }; static const size_t INITIAL_CAPACITY = 1024; @@ -69,27 +60,20 @@ class KlassRegistry { Entry* _table; size_t _capacity; // Power of two, or 0 before the first add() size_t _size; // Occupied slots, live or dead - volatile uint32_t _epoch; Entry* find(const void* klass) const; + Entry* findRegistered(const void* klass) const; bool rehash(JNIEnv* jni); - uint32_t bumpEpoch(); public: - // Width of the epoch as stored in a frame and compared by pin(): a raw - // frame's tag minus the bit that says which kind of tag it is (checked in - // hotspotSupport.cpp). - static const int EPOCH_BITS = 25; - - KlassRegistry() : _table(nullptr), _capacity(0), _size(0), _epoch(0) {} - enum PinStatus { PIN_OK, PIN_ABSENT, // not registered, or the class has been unloaded - PIN_STALE, // registered after the frame was sampled - PIN_REDEFINED, // the class has been redefined since registration + 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; @@ -101,30 +85,29 @@ class KlassRegistry { bool add(JNIEnv* jni, const void* klass, jclass mirror); /** - * The current epoch, to be stored with a frame sampled now. Signal safe: - * a single acquire load. + * 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. */ - uint32_t epoch() const { return __atomic_load_n(&_epoch, __ATOMIC_ACQUIRE); } + jclass pin(JNIEnv* jni, const void* klass, PinStatus* status = nullptr); /** - * 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, the registration is newer than `sampled_epoch`, - * or the class has been redefined. Epochs compare modulo 2^EPOCH_BITS. - * While the caller holds the reference the class cannot be unloaded; it - * must release it with DeleteLocalRef. + * 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. */ - jclass pin(JNIEnv* jni, const void* klass, uint32_t sampled_epoch, PinStatus* status = nullptr); + bool beginRedefinition(const void* klass); /** - * Marks `klass` as redefined, for good. Call before the redefinition - * starts, so that a resolution which validated the class's metadata just - * before the methods were swapped still sees the mark in isRedefined(). - * Returns false if `klass` is not registered. + * 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. */ - bool markRedefined(const void* klass); + void endRedefinition(const void* klass, bool succeeded); - /** Whether markRedefined() was called for `klass`. */ + /** Whether `klass` is being or has been redefined. */ bool isRedefined(const void* klass); /** Number of occupied slots, including dead entries not yet purged. */ diff --git a/ddprof-lib/src/main/cpp/hotspot/vmStructs.h b/ddprof-lib/src/main/cpp/hotspot/vmStructs.h index 5d95d3f940..b3f4a956c3 100644 --- a/ddprof-lib/src/main/cpp/hotspot/vmStructs.h +++ b/ddprof-lib/src/main/cpp/hotspot/vmStructs.h @@ -730,14 +730,19 @@ DECLARE(VMKlass) // 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 keeps methods()->at(idnum) - // equal to the method with that idnum until a redefinition reorders them, - // so the hint usually avoids the linear scan. + // 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); @@ -754,6 +759,9 @@ DECLARE(VMKlass) 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; diff --git a/ddprof-lib/src/main/cpp/jvmSupport.cpp b/ddprof-lib/src/main/cpp/jvmSupport.cpp index f1d9735f50..f9af96cb88 100644 --- a/ddprof-lib/src/main/cpp/jvmSupport.cpp +++ b/ddprof-lib/src/main/cpp/jvmSupport.cpp @@ -73,9 +73,11 @@ void JVMSupport::initExecution(Arguments& args, jvmtiEnv* jvmti, JNIEnv* jni) { JMethodIDLoadStats current_state = getLoadState(); JMethodIDLoadStats state = Fully_loaded; - if (VM::isHotspot() && !HotspotSupport::shouldPreloadJmethodIDs(args)) { - if (args._lazy_jmethodID && HotspotSupport::canResolveLazily()) { - HotspotSupport::initLazyResolution(jni); + // 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); @@ -174,17 +176,16 @@ bool JVMSupport::loadMethodIDsIfNeeded(jvmtiEnv *jvmti, JNIEnv *jni, jclass klas } void JVMSupport::beforeClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass) { - if (getLoadState() == Lazy_loaded) { + if (VM::isHotspot()) { HotspotSupport::beforeClassRedefinition(jvmti, jni, klass); } } void JVMSupport::afterClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass, bool succeeded) { - if (getLoadState() == Lazy_loaded) { - // Even after a failed attempt: the class is marked for good, and only - // jmethodIDs keep its later frames from being raw (and refused). - HotspotSupport::afterClassRedefinition(jvmti, jni, klass); - } else if (succeeded) { + if (VM::isHotspot()) { + HotspotSupport::afterClassRedefinition(jvmti, jni, klass, succeeded); + } + if (succeeded && getLoadState() != Lazy_loaded) { loadMethodIDsIfNeeded(jvmti, jni, klass); } } diff --git a/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp b/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp index ee9626692b..916790b83f 100644 --- a/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp +++ b/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp @@ -54,13 +54,18 @@ class JVMSupportTestAccessor { 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 bool getTagWithTraceId() { return HotspotSupport::_tag_with_trace_id; } - static void setTagWithTraceId(bool v) { HotspotSupport::_tag_with_trace_id = v; } + 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); @@ -230,6 +235,8 @@ class FakeJvm { _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); @@ -252,6 +259,7 @@ class FakeJvm { // 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); } @@ -289,6 +297,15 @@ class FakeJvm { static jlong JNICALL getLongField(JNIEnv*, jobject obj, jfieldID) { return (jlong)(uintptr_t)reinterpret_cast(obj)->klass; } + // A jmethodID preload (loadMethodIDsIfNeededImpl()); the fake class's IDs + // already exist, so only the call is recorded. + 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; @@ -314,7 +331,7 @@ FakeJvm* FakeJvm::s_instance = nullptr; } // namespace -class HotspotLazyResolveTest : public ::testing::TestWithParam { +class HotspotLazyResolveTest : public ::testing::Test { protected: VMStructsTestAccessor _layout{LAYOUT}; FakeJvm _jvm; @@ -323,14 +340,9 @@ class HotspotLazyResolveTest : public ::testing::TestWithParam { FakeObject _new_mirror{2}; bool _saved_hotspot = false; bool _saved_force = true; - bool _saved_tag_with_trace_id = false; int _saved_trace_id_offset = -1; JVMSupportTestAccessor::LoadState _saved_state{}; - // The parameter: tag raw frames with the holder's trace ID (true) or the - // registry epoch (false). Every Klass*-reuse case must hold for both. - static bool traceIdTags() { return GetParam(); } - void SetUp() override { _saved_hotspot = VMTestAccessor::getHotspot(); VMTestAccessor::setHotspot(true); @@ -340,10 +352,11 @@ class HotspotLazyResolveTest : public ::testing::TestWithParam { // not forced, which is how fjmethodid=lazy configures the profiler. _saved_force = ProfilerTestAccessor::getForceJmethodID(Profiler::instance()); ProfilerTestAccessor::setForceJmethodID(Profiler::instance(), false); - _saved_tag_with_trace_id = HotspotSupportTestAccessor::getTagWithTraceId(); _saved_trace_id_offset = KlassTraceId::offset(); - HotspotSupportTestAccessor::setTagWithTraceId(traceIdTags()); - KlassTraceId::setOffsetForTest(traceIdTags() ? (int)offsetof(FakeKlass, trace_id) : -1); + 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()); @@ -353,7 +366,6 @@ class HotspotLazyResolveTest : public ::testing::TestWithParam { HotspotSupportTestAccessor::registry().clear(_jvm.jni()); ProfiledThread::release(); KlassTraceId::setOffsetForTest(_saved_trace_id_offset); - HotspotSupportTestAccessor::setTagWithTraceId(_saved_tag_with_trace_id); ProfilerTestAccessor::setForceJmethodID(Profiler::instance(), _saved_force); JVMSupportTestAccessor::setLoadState(_saved_state); VMTestAccessor::setHotspot(_saved_hotspot); @@ -381,19 +393,26 @@ class HotspotLazyResolveTest : public ::testing::TestWithParam { 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::fillMethod() does with a raw frame at dump time. static jmethodID resolve(const ASGCT_CallFrame& frame) { return HotspotSupport::resolve(frame.method, frame.bci); } }; -TEST_P(HotspotLazyResolveTest, LiveClassResolves) { +TEST_F(HotspotLazyResolveTest, LiveClassResolves) { registerClass(_old_mirror); ASGCT_CallFrame frame = sample(); EXPECT_EQ(_cls.jmethodId(), resolve(frame)); } -TEST_P(HotspotLazyResolveTest, UnloadedClassDoesNotResolve) { +TEST_F(HotspotLazyResolveTest, UnloadedClassDoesNotResolve) { registerClass(_old_mirror); ASGCT_CallFrame frame = sample(); _jvm.collect(_old_mirror); @@ -403,7 +422,7 @@ TEST_P(HotspotLazyResolveTest, UnloadedClassDoesNotResolve) { // 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_P(HotspotLazyResolveTest, FrameFromBeforeKlassReuseDoesNotResolveToNewClass) { +TEST_F(HotspotLazyResolveTest, FrameFromBeforeKlassReuseDoesNotResolveToNewClass) { registerClass(_old_mirror); ASGCT_CallFrame frame = sample(); @@ -415,7 +434,7 @@ TEST_P(HotspotLazyResolveTest, FrameFromBeforeKlassReuseDoesNotResolveToNewClass // 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_P(HotspotLazyResolveTest, FrameFromBeforeKlassReuseAfterPurgeDoesNotResolveToNewClass) { +TEST_F(HotspotLazyResolveTest, FrameFromBeforeKlassReuseAfterPurgeDoesNotResolveToNewClass) { registerClass(_old_mirror); ASGCT_CallFrame frame = sample(); _jvm.collect(_old_mirror); @@ -429,7 +448,7 @@ TEST_P(HotspotLazyResolveTest, FrameFromBeforeKlassReuseAfterPurgeDoesNotResolve 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(), registry.epoch())) + 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 @@ -439,7 +458,7 @@ TEST_P(HotspotLazyResolveTest, FrameFromBeforeKlassReuseAfterPurgeDoesNotResolve } // Frames sampled from the new class after the reuse must still resolve. -TEST_P(HotspotLazyResolveTest, FrameFromAfterKlassReuseResolves) { +TEST_F(HotspotLazyResolveTest, FrameFromAfterKlassReuseResolves) { registerClass(_old_mirror); loadNewClassAtSameAddress(); @@ -450,7 +469,7 @@ TEST_P(HotspotLazyResolveTest, FrameFromAfterKlassReuseResolves) { // 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_P(HotspotLazyResolveTest, FramesFromBeforeAndAfterKlassReuseDiffer) { +TEST_F(HotspotLazyResolveTest, FramesFromBeforeAndAfterKlassReuseDiffer) { registerClass(_old_mirror); ASGCT_CallFrame before = sample(); loadNewClassAtSameAddress(); @@ -459,14 +478,13 @@ TEST_P(HotspotLazyResolveTest, FramesFromBeforeAndAfterKlassReuseDiffer) { EXPECT_FALSE(before.bci == after.bci && before.method == after.method); } -// Trace-ID tags identify the class load itself, so a registry epoch bump -// caused by an unrelated class leaves them unchanged and the same stack keeps -// deduplicating into one trace. Epoch tags change with every bump. -TEST_P(HotspotLazyResolveTest, UnrelatedReuseChangesFramesOnlyWithEpochTags) { +// 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(); - // Another class at another address is unloaded and its address reused. static FakeObject other_old{10}; static FakeObject other_new{11}; const void* other_klass = reinterpret_cast(0x200000); @@ -476,15 +494,14 @@ TEST_P(HotspotLazyResolveTest, UnrelatedReuseChangesFramesOnlyWithEpochTags) { ASSERT_TRUE(registry.add(_jvm.jni(), other_klass, _jvm.mirror(other_new))); ASGCT_CallFrame after = sample(); - bool same = before.bci == after.bci && before.method == after.method; - EXPECT_EQ(traceIdTags(), same); - EXPECT_EQ(_cls.jmethodId(), resolve(before)); + EXPECT_EQ(before.bci, after.bci); + EXPECT_EQ(before.method, after.method); EXPECT_EQ(_cls.jmethodId(), resolve(after)); } // The JFR tag bits below the ID change while JFR runs; they must not affect // the frame's tag or its resolution. -TEST_P(HotspotLazyResolveTest, JfrTagBitsDoNotAffectTraceIdTags) { +TEST_F(HotspotLazyResolveTest, JfrTagBitsDoNotAffectTraceIdTags) { registerClass(_old_mirror); ASGCT_CallFrame before = sample(); _cls.klass.trace_id |= 0x0303; @@ -494,42 +511,86 @@ TEST_P(HotspotLazyResolveTest, JfrTagBitsDoNotAffectTraceIdTags) { EXPECT_EQ(_cls.jmethodId(), resolve(before)); } -// resolve() goes by the kind each frame records, not by the current tag -// source: frames of both kinds resolve side by side. -TEST_P(HotspotLazyResolveTest, FramesResolveByTheirOwnTagKind) { - if (!traceIdTags()) { - GTEST_SKIP() << "needs a calibrated trace-ID offset to take trace-ID-tagged samples"; - } +// 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 (partial mode's FindClass lookup cannot see classes of +// custom loaders). +TEST_F(HotspotLazyResolveTest, LazyFrameResolvesByRegistryAfterSwitchToPartialMode) { registerClass(_old_mirror); - HotspotSupportTestAccessor::setTagWithTraceId(false); - ASGCT_CallFrame epoch_frame = sample(); - HotspotSupportTestAccessor::setTagWithTraceId(true); - ASGCT_CallFrame trace_frame = sample(); - const uint32_t bit = HotspotSupportTestAccessor::traceIdTagBit(); - EXPECT_EQ(0u, FrameType::rawTag(epoch_frame.bci, epoch_frame.method) & bit); - EXPECT_NE(0u, FrameType::rawTag(trace_frame.bci, trace_frame.method) & bit); - EXPECT_EQ(_cls.jmethodId(), resolve(epoch_frame)); - EXPECT_EQ(_cls.jmethodId(), resolve(trace_frame)); + 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_EQ(_cls.jmethodId(), resolve(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, trace ID and registry epoch all +// 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_P(HotspotLazyResolveTest, FrameOfRedefinedClassIsRefused) { +TEST_F(HotspotLazyResolveTest, FrameOfRedefinedClassIsRefused) { registerClass(_old_mirror); ASGCT_CallFrame before = sample(); - // What VM::RetransformClassesHook does first. Afterwards the Method* at - // the sampled address is (still, or again) among the class's methods. - ASSERT_TRUE(HotspotSupportTestAccessor::registry().markRedefined(_cls.klassPtr())); + // Afterwards the Method* at the sampled address is (still, or again) + // among the class's methods. + redefine(true); EXPECT_EQ(nullptr, 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_EQ(_cls.jmethodId(), resolve(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_EQ(nullptr, resolve(frame)); + HotspotSupportTestAccessor::afterClassRedefinition(_jvm.jvmti(), _jvm.jni(), _jvm.mirror(_old_mirror), false); + EXPECT_EQ(_cls.jmethodId(), resolve(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_EQ(nullptr, 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_P(HotspotLazyResolveTest, MethodMemoryReusedByClassAtOtherAddressDoesNotResolve) { +TEST_F(HotspotLazyResolveTest, MethodMemoryReusedByClassAtOtherAddressDoesNotResolve) { registerClass(_old_mirror); ASGCT_CallFrame frame = sample(); @@ -550,15 +611,11 @@ TEST_P(HotspotLazyResolveTest, MethodMemoryReusedByClassAtOtherAddressDoesNotRes // 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_P(HotspotLazyResolveTest, CountsClassesWithTraceIdBeyondTagRange) { +TEST_F(HotspotLazyResolveTest, CountsClassesWithTraceIdBeyondTagRange) { #ifndef COUNTERS GTEST_SKIP() << "debug counters are not compiled into this build"; #else - if (!traceIdTags()) { - GTEST_SKIP() << "only trace-ID tags can run out of range"; - } long long before = Counters::getCounter(KLASS_TRACE_ID_BEYOND_TAG_RANGE); - _old_mirror.klass = _cls.klassPtr(); _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)); @@ -574,7 +631,7 @@ TEST_P(HotspotLazyResolveTest, CountsClassesWithTraceIdBeyondTagRange) { // Lookup resolves each distinct raw method once per dump, however often it // recurs, and whatever bci or frame type the occurrences carry. -TEST_P(HotspotLazyResolveTest, LookupResolvesEachRawMethodOncePerDump) { +TEST_F(HotspotLazyResolveTest, LookupResolvesEachRawMethodOncePerDump) { registerClass(_old_mirror); ASGCT_CallFrame a = sample(); ASGCT_CallFrame b{}; @@ -599,7 +656,7 @@ TEST_P(HotspotLazyResolveTest, LookupResolvesEachRawMethodOncePerDump) { // Frames from two loads of a class at the same addresses carry different tags, // so the cache keeps their outcomes apart. -TEST_P(HotspotLazyResolveTest, LookupCacheSeparatesFramesOfDifferentClassLoads) { +TEST_F(HotspotLazyResolveTest, LookupCacheSeparatesFramesOfDifferentClassLoads) { registerClass(_old_mirror); ASGCT_CallFrame before = sample(); loadNewClassAtSameAddress(); @@ -613,7 +670,3 @@ TEST_P(HotspotLazyResolveTest, LookupCacheSeparatesFramesOfDifferentClassLoads) EXPECT_EQ(nullptr, lookup.resolveRawMethod(before)); } -INSTANTIATE_TEST_SUITE_P(TagSource, HotspotLazyResolveTest, ::testing::Bool(), - [](const ::testing::TestParamInfo& info) { - return info.param ? "TraceId" : "Epoch"; - }); diff --git a/ddprof-lib/src/test/cpp/jvmSupport_ut.cpp b/ddprof-lib/src/test/cpp/jvmSupport_ut.cpp index 3088e0560d..5f956bb90b 100644 --- a/ddprof-lib/src/test/cpp/jvmSupport_ut.cpp +++ b/ddprof-lib/src/test/cpp/jvmSupport_ut.cpp @@ -82,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() @@ -252,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; @@ -274,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); } }; @@ -318,8 +326,28 @@ TEST_F(JVMSupportRestartTest, LazyRequiresVmStackWalker) { 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 index 0977722034..453a0d414b 100644 --- a/ddprof-lib/src/test/cpp/klassRegistry_ut.cpp +++ b/ddprof-lib/src/test/cpp/klassRegistry_ut.cpp @@ -133,23 +133,23 @@ class KlassRegistryTest : public ::testing::Test { } // namespace TEST_F(KlassRegistryTest, PinUnknownKlassReturnsNull) { - EXPECT_EQ(nullptr, registry.pin(jni, klassAt(0), registry.epoch())); + 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), registry.epoch())); - EXPECT_EQ(nullptr, registry.pin(jni, nullptr, registry.epoch())); + 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), registry.epoch())); - EXPECT_EQ(mirror(1), registry.pin(jni, klassAt(1), registry.epoch())); + 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), registry.epoch())); + EXPECT_EQ(nullptr, registry.pin(jni, klassAt(0))); } TEST_F(KlassRegistryTest, ReAddingSameMirrorIsNoOp) { @@ -166,7 +166,7 @@ TEST_F(KlassRegistryTest, ReusedKlassAddressReplacesDeadEntry) { 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), registry.epoch())); + EXPECT_EQ(mirror(1), registry.pin(jni, klassAt(0))); EXPECT_EQ(1u, registry.size()); EXPECT_EQ(1, heap.liveWeaks()); } @@ -190,13 +190,13 @@ TEST_F(KlassRegistryTest, GrowthPurgesDeadEntries) { 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), registry.epoch())) << 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), registry.epoch())) << 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), registry.epoch())) << i; + EXPECT_EQ(mirror(i), registry.pin(jni, klassAt(i))) << i; } } @@ -207,7 +207,7 @@ TEST_F(KlassRegistryTest, GrowthKeepsAllLiveEntries) { } EXPECT_EQ(n, registry.size()); for (size_t i = 0; i < n; i++) { - EXPECT_EQ(mirror(i), registry.pin(jni, klassAt(i), registry.epoch())) << i; + EXPECT_EQ(mirror(i), registry.pin(jni, klassAt(i))) << i; } } @@ -219,119 +219,95 @@ TEST_F(KlassRegistryTest, ClearDeletesEveryWeakRef) { EXPECT_EQ(0u, registry.size()); EXPECT_EQ(0, heap.liveWeaks()); EXPECT_EQ(0, heap.double_deletes); - EXPECT_EQ(nullptr, registry.pin(jni, klassAt(0), registry.epoch())); + 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), registry.epoch())); + EXPECT_EQ(mirror(0), registry.pin(jni, klassAt(0))); } -// Epochs: a frame records registry.epoch() when sampled; pin() refuses it when -// the entry for its Klass* was registered later. Every new registration bumps -// the epoch, since any new class may occupy memory of one that is gone. +// 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, EveryRegistrationBumpsEpochButReAddingDoesNot) { - uint32_t e0 = registry.epoch(); +TEST_F(KlassRegistryTest, SuccessfulRedefinitionIsRefusedForGood) { ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); - uint32_t e1 = registry.epoch(); - EXPECT_NE(e0, e1); - ASSERT_TRUE(registry.add(jni, klassAt(1), mirror(1))); - uint32_t e2 = registry.epoch(); - EXPECT_NE(e1, e2); - // Re-adding the same pair is not a new registration. + 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_EQ(e2, registry.epoch()); + EXPECT_TRUE(registry.isRedefined(klassAt(0))); } -TEST_F(KlassRegistryTest, FrameSampledBeforeAnotherClassRegisteredIsStaleForThatClass) { - // A frame whose Method* memory now belongs to a class registered after the - // sample -- at any address -- must not match that class. +TEST_F(KlassRegistryTest, RedefinitionInProgressIsRefused) { ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); - uint32_t sampled = registry.epoch(); - ASSERT_TRUE(registry.add(jni, klassAt(1), mirror(1))); + ASSERT_TRUE(registry.beginRedefinition(klassAt(0))); KlassRegistry::PinStatus status; - EXPECT_EQ(nullptr, registry.pin(jni, klassAt(1), sampled, &status)); - EXPECT_EQ(KlassRegistry::PIN_STALE, status); - EXPECT_EQ(mirror(0), registry.pin(jni, klassAt(0), sampled, &status)); - EXPECT_EQ(KlassRegistry::PIN_OK, status); + EXPECT_EQ(nullptr, registry.pin(jni, klassAt(0), &status)); + EXPECT_EQ(KlassRegistry::PIN_REDEFINED, status); } -TEST_F(KlassRegistryTest, RedefinedClassIsRefusedForGood) { +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_TRUE(registry.markRedefined(klassAt(0))); - EXPECT_TRUE(registry.isRedefined(klassAt(0))); - KlassRegistry::PinStatus status; - EXPECT_EQ(nullptr, registry.pin(jni, klassAt(0), registry.epoch(), &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))); - EXPECT_FALSE(registry.markRedefined(klassAt(5))); + EXPECT_EQ(mirror(0), registry.pin(jni, klassAt(0))); } -TEST_F(KlassRegistryTest, NewClassAtRedefinedClassAddressStartsUnmarked) { +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.markRedefined(klassAt(0))); - heap.collect(&mirrors[0]); - ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(1))); + 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))); - EXPECT_EQ(mirror(1), registry.pin(jni, klassAt(0), registry.epoch())); + + 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, ReplacementBumpsEpochAndRejectsOlderFrames) { +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))); - uint32_t sampled_old = registry.epoch(); - heap.collect(&mirrors[0]); - ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(1))); - EXPECT_NE(sampled_old, registry.epoch()); - - KlassRegistry::PinStatus status; - EXPECT_EQ(nullptr, registry.pin(jni, klassAt(0), sampled_old, &status)); - EXPECT_EQ(KlassRegistry::PIN_STALE, status); - EXPECT_EQ(mirror(1), registry.pin(jni, klassAt(0), registry.epoch(), &status)); - EXPECT_EQ(KlassRegistry::PIN_OK, status); + 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, PurgedAddressReinsertedRejectsOlderFrames) { +TEST_F(KlassRegistryTest, NewClassAtRedefinedClassAddressStartsUnmarked) { ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); - uint32_t sampled_old = registry.epoch(); + ASSERT_TRUE(registry.beginRedefinition(klassAt(0))); + registry.endRedefinition(klassAt(0), true); heap.collect(&mirrors[0]); - // Grow past the initial table so the dead entry is purged. - for (size_t i = 1; i < 1000; i++) { - ASSERT_TRUE(registry.add(jni, klassAt(i), mirror(i))); - } - EXPECT_NE(sampled_old, registry.epoch()); - // klassAt(0) comes back as a first insert, not a replacement. - ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(1000))); - - KlassRegistry::PinStatus status; - EXPECT_EQ(nullptr, registry.pin(jni, klassAt(0), sampled_old, &status)); - EXPECT_EQ(KlassRegistry::PIN_STALE, status); - // A frame sampled now matches. - EXPECT_EQ(mirror(1), registry.pin(jni, klassAt(1), registry.epoch(), &status)); - EXPECT_EQ(KlassRegistry::PIN_OK, status); + 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, UnloadedClassIsNotReportedStale) { +TEST_F(KlassRegistryTest, UnloadedClassIsReportedAbsent) { ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); - uint32_t sampled = registry.epoch(); heap.collect(&mirrors[0]); KlassRegistry::PinStatus status; - EXPECT_EQ(nullptr, registry.pin(jni, klassAt(0), sampled, &status)); + EXPECT_EQ(nullptr, registry.pin(jni, klassAt(0), &status)); EXPECT_EQ(KlassRegistry::PIN_ABSENT, status); } -TEST_F(KlassRegistryTest, EpochComparisonSurvivesWraparound) { - // Frames and entries are compared modulo 2^EPOCH_BITS, so an entry - // registered just before the wrap still accepts frames sampled just after. - ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); - uint32_t registered = registry.epoch(); - const uint32_t mask = (1u << KlassRegistry::EPOCH_BITS) - 1; - EXPECT_EQ(mirror(0), registry.pin(jni, klassAt(0), (registered + 5) & mask)); - // A frame sampled before the registration is refused even across the wrap. - EXPECT_EQ(nullptr, registry.pin(jni, klassAt(0), (registered - 1) & mask)); -} - // --------------------------------------------------------------------------- // VMKlass::containsMethod() is what makes a Method* read from possibly-freed // metadata trustworthy: it is only compared against the pinned class's @@ -388,3 +364,26 @@ TEST(VMKlassContainsMethodTest, NoMethodsArray) { 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/doc/LazyJmethodIdResolution.md b/doc/LazyJmethodIdResolution.md deleted file mode 100644 index 284cce80bb..0000000000 --- a/doc/LazyJmethodIdResolution.md +++ /dev/null @@ -1,361 +0,0 @@ -# Lazy jmethodID Resolution - -> **Prototype notes. Remove before the PR lands.** -> Branch: `jmethodid-lazy-resolve` (off `main` @ a60c7b1cf). Flag: `fjmethodid=lazy`. Last updated 2026-10-15. - -Stop preloading jmethodIDs for every loaded class. Record raw `Method*` frames while -sampling, and turn them into jmethodIDs on the dump thread, only after confirming that -the owning class is still loaded. - -## Problem - -Today every class gets its jmethodIDs at ClassPrepare (`GetClassMethods`). In partial -mode (`fjmethodid=false`) only system-loader classes skip this, and custom-loader, hidden -and lambda classes are still preloaded. The cost is a jmethodID per method plus a -per-class ID cache, kept until the class unloads, and contention on the JVM's -jmethodID-creation lock during class loading. - -Preloading doesn't stop allocation inside the signal handler; nothing allocates there. -AGCT uses `find_jmethod_id_or_null`, and `walkVM` reads the per-class cache directly -(`VMMethod::id()`). Preloading only makes sure an ID already exists when the sampler -looks. The sampler doesn't actually need a jmethodID. It needs an identifier that can -be resolved later, safely. - -## Approach - -1. **Sample time (signal handler).** When the class has no jmethodID yet, - `fillJavaFrame` stores the raw `Method*`. This existing path is reused unchanged. The - method is on the stack, so it is valid at that moment. -2. **ClassPrepare and startup.** Instead of preloading, `HotspotSupport::registerClass` - records `Klass* → JNI weak global ref to the mirror` in `KlassRegistry`. Array and - primitive classes are skipped. At start, `GetLoadedClasses` registers the classes that - already exist. -3. **Resolve time (dump thread).** Under crash protection, read only the holder `Klass*` - and the idnum from the possibly-stale `Method*` (`readMethodHolder`). Both are treated - as lookup keys and never trusted. Then, with protection off (`resolveViaRegistry`): - - `pin(holder)`: a `NewLocalRef` of the weak ref. It returns null if the class is - gone, which yields an unknown frame and bumps `method_resolve_lazy_unloaded`. - - A strongly held mirror keeps its class loader, and so the class's metadata, alive. - The `Klass*` can now be dereferenced safely. - - `VMKlass::containsMethod(M, idnum)` compares pointers only against the class's - current `_methods` array, using an idnum fast path plus a linear fallback. A miss - yields an unknown frame and bumps `method_resolve_lazy_not_current`. - - Only then is `M` dereferenced. If it has no ID, `loadMethodIDsIfNeededImpl(load_all)` - allocates IDs for this one class, including the JDK 8 CLD patch. This bumps - `method_resolve_lazy_class_loaded`. - -After the first dump that touches a class, its jmethodIDs exist, so later samples carry -real IDs and skip the slow path. Lazy mode needs `cstack=vm` and -`VMStructs::hasKlassMethods()`; otherwise it falls back to partial mode. It applies to -HotSpot only, and J9 and Zing keep preloading. - -### KlassRegistry - -- An open-addressing table with linear probing, guarded by a mutex. It is never touched - from a signal handler. -- Dead entries (weak ref cleared) are purged whenever the table would grow, so memory - follows the number of live classes. -- A different mirror at a known `Klass*` replaces the entry and deletes the old weak ref. -- `pin()` calls `NewLocalRef` under the lock, so a concurrent rehash can't delete the weak - ref out from under it. Lock ordering is safe: none of the JNI calls made under the lock - load classes, so a ClassPrepare can't re-enter it. - -## Klass* address reuse (solved: frame tags) - -Class-space blocks are aligned and reused, so a new class at the exact address of an -unloaded one is likely. The current checks don't catch it: - -1. `K_old.m` is sampled as raw `M`, with holder at `A`. -2. `K_old` unloads. `K_new` is allocated at `A`. A class of the same shape that gets the - freed chunks back very likely puts its methods at the same addresses, so one of them - lands at `M`. -3. `K_new`'s ClassPrepare replaces the registry entry for `A`. -4. At dump, `M` reads back as holder `A`, the class at `A` is alive, and `M` is in its - methods. The frame resolves as `K_new`'s method, which is wrong. - -It also breaks trace dedup. `K_new` samples at `M` produce the same raw frames as the -old `K_old` samples, so within one dump interval they collapse into one trace ID. No -check at resolve time can separate them, so **the frame itself has to carry an -incarnation identity.** The existing modes never hit this, because unloadable classes -always get jmethodIDs. - -### Option A: reuse epoch (portable fallback) — implemented - -Implemented (`KlassRegistry::epoch()`/`bumpEpoch()`, `pin(…, sampled_epoch, &status)`, counter -`method_resolve_lazy_stale`). After review, **every registration bumps the epoch**, not only replacements -and purges (see "Guarantees and limits" below). It also relies on one invariant, which is now enforced: every class is -registered before any of its methods can run. ClassPrepare guarantees this; if `add()` fails, -`registerClass` preloads that class's jmethodIDs instead (`klass_registry_add_failed`), so no raw -frame of an unregistered class is ever produced. Without that, a later class at the same address -could be inserted fresh, without a bump, and accept the unregistered class's frames. - - -- The registry keeps a global `_epoch`, and each entry records the epoch it was - registered at. -- ~~The epoch is bumped on a replacement and on a purge.~~ It is bumped on **every** registration. - The original rule missed a dead class's `Method*` memory being reused by a class loaded fresh - at a *different* `Klass*`. -- The sampler stores the current epoch in the raw frame: one acquire load, with no - registry lookup in the handler. -- Resolve accepts only if `entry.epoch ≤ frame.epoch`, compared modularly. Live classes - always pass, because ClassPrepare runs before their methods do. Old-incarnation frames - fail, and old and new frames no longer dedup together. -- Cost: a stack sampled across a bump splits into two traces. With a bump per registration, - epoch tags split traces on every class load. That's acceptable for a fallback used only - without JFR, and trace-ID tags don't have this problem. - -### Option B: HotSpot's JFR class trace ID, read by offset — implemented, preferred - -Every `Klass` carries `mutable traceid _trace_id` (`JFR_ONLY(DEFINE_TRACE_ID_FIELD)`). -It is not in `gHotSpotVMStructs`. From the local OpenJDK sources: - -- **Assignment:** `next_class_id()` is - `(atomic_inc(counter) << TRACE_ID_SHIFT) | EPOCH_CLEARED_BITS`, with - `TRACE_ID_SHIFT = 16`. It is assigned at class creation (`Jfr::on_klass_creation` on - master, `INIT_ID(ik)` in classFileParser on 17u) and on CDS restoration, whether or not - JFR is recording. It is globally monotonic, so it is unique per incarnation. -- **Encoding:** the low 16 bits are JFR tag/epoch bits that JFR changes while it runs. - Use `raw >> 16` only. -- **Not present** in builds without JFR: JDK 8 before 8u262 (when JFR reached upstream 8u), and - `--disable-jfr` builds. Option A is still needed as a fallback. - -| Source tree | `_trace_id` declared after | Nearest vmStructs anchor | -|---|---|---| -| jdk17u (17.0.18) | `AccessFlags _access_flags` | `Klass::_access_flags` | -| jdk21u-dev (21.0.10) | `AccessFlags _access_flags` | `Klass::_access_flags` | -| jdk master (28) | `CDS_JAVA_HEAP_ONLY(int _archived_mirror_index)`, after `_vtable_len` | `Klass::_vtable_len` (+4 if CDS heap) | -| 8u, 11u, 25 | not checked yet: no local checkout | | - -An anchor-relative offset per version range is more robust than absolute offsets, and -the repo already keys a few offsets on `VM::hotspot_version()` in `vmStructs.cpp`. The -field moves between releases, and on master its position depends on a build option (the -CDS Java heap). So the offset must be confirmed at runtime, not trusted from a table -alone. - -Advantages over A: no global state, no trace splitting, and two incarnations get the same -stored bits only if a multiple of 2^N classes were created between them. - -#### Offset discovery — implemented - -Offsets can move even between minor releases, so there is no version table. `KlassTraceId::calibrate()` -(hotspot/klassTraceId.{h,cpp}) runs when lazy mode is selected: - -- It resolves `[[[[[[Ljava/lang/Void;`, which makes the JVM create up to six array klasses back - to back, each getting the next trace ID. No classes of our own are defined. -- It scans every 8-byte-aligned offset in `[8, sizeof(Klass))` and accepts one only if all six - probes hold nonzero `raw >> 16` values, pairwise distinct, within `5 + ID_SLACK (64)` of each - other. This rejects pointers (a few hundred bytes apart, so `>> 16` collapses them), equal small - ints, and compressed-klass bits in a mark word (far apart). More than one match means failure. -- The result goes to the `klass_trace_id_offset` gauge: the offset, -1 on failure, 0 if - calibration hasn't run. - -**Assumptions it relies on, swept across 36 GA tags** (17 GA – 17.0.17, 21 GA – 21.0.9, 22–26, 28+18): -`_trace_id` is declared in `class Klass` (so array klasses have it, and it lies inside -`sizeof(Klass)`); `arrayKlass.cpp` has `INIT_ID(this)`; `TRACE_ID_SHIFT` is 16; `next_class_id` -shifts the counter by it. All hold on every tag. The field's neighbour was `_access_flags` through -24 (with different preceding fields in 24) and `_archived_mirror_index` from 25 on. LTS lines -swept too, from blob-less partial clones of openjdk/jdk8u, jdk11u and jdk25u: - -| Line | GA tags | Result | Declared after | -|---|---|---|---| -| 8u | 8u201 – 8u252 (7) | no `_trace_id` (pre-JFR): calibration fails, epoch fallback | — | -| 8u | 8u262 – 8u504 (29) | all assumptions hold | `_biased_lock_revocation_count` | -| 11u | 11.0.2 – 11.0.32.1 (36) | all assumptions hold | `_access_flags` | -| 25u | 25 GA – 25.0.4.1 (6) | all assumptions hold | `_archived_mirror_index` | - -So 107 GA tags in total. The sweep compares neighbouring declarations, not absolute byte offsets. -That's enough, since calibration assumes none. Script: `/tmp/jfrsweep/sweep2.sh ...` -(Mac), with `P=hotspot/src/share/vm/` for 8u. - -#### Confirming the offset — CI only - -Self-calibration is the only production gate. JFR's own class IDs confirm it in CI, never at runtime. - -**Why not a runtime cross-check against `JVM.getClassId()`:** prototyped and dropped after it -crashed the JVM (SIGSEGV in `JfrTraceIdKlassQueue::enqueue`, called from `getClassId`'s load -barrier). -- `getClassId()` tags a class JFR hasn't tagged yet, and adds it to JFR's class queue. -- That queue only exists once JFR's recorder has been created (`JfrRecorder::create_components` - → `JfrCheckpointManager::initialize` → `JfrTraceIdLoadBarrier::initialize`). -- "`jdk.jfr.internal.JVM` is initialized" doesn't mean the recorder exists. The profiler itself - initializes that class: `tsc.cpp` does `FindClass("jdk/jfr/internal/JVM")` for JFR's tick - frequency. So every lazy-mode session crashed on its first dump. -- A safe precondition would be "recorder exists and won't be destroyed during the call". There's - no race-free way to check that from native code: - - `JVM.isRecording()` differs by release: static native on 25+, instance native on 17u, - instance Java wrapper on 8u/11u. - - JFR's shutdown hook calls `destroyJFR()`, which tears those structures down while the VM is - still running. - - Our crash protection only recovers faults inside our own library, not inside the JVM. - -**CI validation instead:** `LazyTraceIdJfrValidationTest`, which needs a debug build. -- A child JVM (`ExternalLauncher lazy-trace-id`) runs the profiler in lazy mode alongside JFR's - own sampler (`-XX:StartFlightRecording=…,settings=profile`) while it spins in a probe class. -- In debug builds, `resolveViaRegistry` logs `Lazy jmethodIDs for , trace id N` for every - class it allocates jmethodIDs for. -- The child records only `jdk.ClassDefine` (a `.jfc` written by the test). JFR emits it once - when the probe class is created (`define_instance_class` on 8u/11u, `Jfr::on_klass_creation` - on mainline); its `definedClass` is the probe's `RecordedClass`. The test reads it through - `jdk.jfr.consumer` (by reflection, because the test sources compile with `--release 8`) and - requires `getId()` to equal the logged ID. -- `jdk.ExecutionSample` was tried first. Before JDK 25, JFR's own sampler recorded almost no - samples for the spinning probe, even with our profiler not started, so it couldn't serve. -- Results, profiler ID = JFR ID: 8u (musl) 2077, 11 (musl) 2152, 17 2065, 21 2154, 25 2211; - macOS 25 2210. - -**Tag kind bit:** kept from the prototype. The tag's top bit records whether a frame carries a -trace ID or an epoch, so the epoch keeps 25 bits. `resolve()` goes by each frame's own kind, not by -the current tag source. That keeps frames self-describing; nothing switches sources at runtime any -more. - -### Frame bits — implemented - -`FrameType::encodeRaw` / `tagPointer` / `rawTag` / `untagPointer` (frame.h) carry a 26-bit tag: -the low 10 bits in bci bits 16–19 and 24–29, and the high 16 bits in pointer bits 48–63. A -`Method*` with any of those pointer bits set is stored as `JMETHODID_NOT_WALKABLE` and counted -(`walkvm_raw_frame_untaggable`), never stored without its tag. `resolve()` now takes -`(method, bci)` so it can take the tag back out. The JFR writer masks bci to 16 bits and the -type to 3 bits, so tag bits never reach the recording. `hasTheSameMethod` compares untagged -pointers. - -The original budget analysis: - - -- The bci field has 10 spare bits: 16–19 and 24–29. Bit 31 must stay clear, because - `isRawPointer()` requires `bci > 0`. -- The top 16 bits of `Method*` look free for 48-bit user-space addresses. arm64 can hand - out 52-bit addresses when asked, so this needs a startup VA check, falling back to the - 10 bci bits. -- 10 bits alone is too few under heavy churn for either option. Resolving from a - background thread shrinks the window between sample and resolve, and helps both. - -## Guarantees and limits (after review) - -A review of `a9b67775` found four issues. All have been checked against the OpenJDK sources -and fixed or bounded: - -1. **Methods array read without protection after pinning.** Redefinition swaps `_methods` at a - safepoint (`the_class->set_methods(_new_methods)`) and frees previous versions later, at another - safepoint (`ClassLoaderDataGraph::clean_deallocate_lists`). The dump thread is in native and - holds up neither. Fixed: every load in `VMKlass::containsMethod()` goes through `SafeAccess`, - so a fault reads as "not a member". -2. **`Method*` reuse within a live class (repeated redefinition).** The class's mirror, trace ID - and epoch don't change, so no tag can tell the frames apart. Fixed: the redefine/retransform - hooks call `KlassRegistry::markRedefined()` *before* the original JVMTI function, and preload - the class's jmethodIDs afterwards, so its later frames aren't raw. `pin()` refuses raw frames - of a marked class (`method_resolve_lazy_redefined`), and `resolveViaRegistry()` checks the - mark again after its metadata reads. If the mark is still absent then, every read happened - before the methods were swapped. The same analysis found the epoch-mode hole fixed by - per-registration bumps (above). -3. **`klassRegistry_ut` didn't link in release** (missing `vmStructs.inline.h`). Fixed; all - new gtest binaries pass in release. -4. **Tag collisions.** Bounded and monitored, not eliminated: - - **Can't crash:** after pinning, only `SafeAccess` reads and JNI/JVMTI on a live class remain. - - **Trace-ID tags** can only collide once two classes' trace IDs are ≥ 2^25 apart. A JVM that - has created fewer than 2^25 (33,554,432) classes can't misattribute. - - **After that** (extreme class churn after long uptime), a stale frame can resolve to a live - method of the wrong class. That needs all of: the sampled class is unloaded before the frame - is resolved; a newer class takes over its exact `Method*` address; and the two IDs differ by - an exact multiple of 2^25. The result is one misattributed frame. - - **Epoch tags** need 2^24 registrations while one trace is still unresolved. - - **Tripwire:** `klass_trace_id_beyond_tag_range` counts registrations of classes whose trace - ID exceeds the tag range. If it ever fires in practice, the planned fix is a 64-bit registry - epoch per `CallTrace`, checked as "holder registered no later than the trace". That makes - misattribution impossible and leaves dropped frames as the worst case. A trace-lifetime bound - would *not* help trace-ID tags, since collisions depend on the classes' age difference, not - the frame's. - -Correctness also rests on two invariants: every class is registered before its methods can run -(ClassPrepare; registration failure falls back to preloading), and every redefinition goes -through the hooked `RedefineClasses`/`RetransformClasses`. - -Size correction: `KlassRegistry::Entry` is 24 bytes on 64-bit (`Klass*`, `jweak`, `uint32_t` -epoch, `bool`), not 16. - -## Measurements - -20,000 classes × 50 static methods (1M methods), 100 classes per custom loader, all kept -reachable. JDK 25.0.1 (Corretto), macOS arm64, debug build of the profiler, -`cpu=10ms,cstack=vm`. Figures are the NMT `summary.diff` against a baseline taken just -before loading. - -| Mode | Class, committed Δ | Total committed Δ | Load time | -|---|---:|---:|---:| -| no profiler | +13.6 MB | +722 MB | 393 ms | -| `fjmethodid=true` | +22.1 MB | +748 MB | 420 ms | -| `fjmethodid=false` | +22.1 MB | +739 MB | 446 ms | -| **`fjmethodid=lazy`** | **+13.6 MB** | **+721 MB** | 420 ms | - -Preloading costs about 8.4 MB of committed memory per million methods. Partial mode saves -nothing in this workload, because every custom-loader class is still preloaded. Lazy mode -matches the no-profiler baseline. Load-time differences are within noise. In-process RSS -deltas (about 375–390 MB in every mode) were too noisy to show any of this. Linux numbers -are still to do. - -The harness is a standalone `Bench.java`: ASM on the classpath, the profiler loaded with -`-agentpath:…=start,cpu=10ms,cstack=vm,fjmethodid=`, and the JVM run with -`-XX:NativeMemoryTracking=summary`. It reads `vmNativeMemory baseline` / `summary.diff` -through the `com.sun.management:type=DiagnosticCommand` MBean. It lives in -`/tmp/jmbench` on the Mac and isn't in the repo. - -## Status - -| Item | State | -|---|---| -| `fjmethodid=lazy`, `KlassRegistry`, resolve path, counters | done | -| Frame tags: 26 bits (tag-kind bit + 25-bit value) in spare bci bits and `Method*` top bits | done | -| Epoch tags (fallback) and trace-ID tags (when calibrated) | done | -| Trace-ID offset calibration; source sweep over 107 GA tags (8u–28) | done | -| gtests: `klassRegistry_ut`, `klassTraceId_ut`, `hotspotLazyResolve_ut` (every reuse case under both tag sources), `frame_ut`, `jvmSupport_ut`, `arguments_ut` | pass (macOS; Linux debug + ASan before the trace-ID commits) | -| `LazyJmethodIdTest` on JDK 8u462, 11.0.28 (musl) and 17.0.16, 21.0.8, 25.0.2 (glibc), aarch64 | pass, trace-ID tags on all five | -| Calibrated offsets: 8u462 → 184, 11.0.28 → 168, 17.0.16 → 168, 21.0.8 → 184, 25.0.2 → 192 | measured | -| `LazyTraceIdJfrValidationTest` (CI cross-check against JFR) | pass on JDK 8, 11 (musl), 17, 21, 25 (glibc) and macOS 25: profiler ID = JFR ID on each | -| Runtime JFR cross-check | dropped, see "Confirming the offset" | -| Review fixes: `SafeAccess` in `containsMethod`, redefinition marking, per-registration epoch, release link fix, tag-range tripwire | done | -| Per-dump cache for raw frame resolutions (`Lookup::_raw_method_cache`) | done: first dump with nearly every frame raw went from 60.3 ms to 34.3 ms (27,956 hits, ~0.9 µs saved each; macOS debug build, single run); later dumps unchanged (~20 ms) | -| Redefinition hooks forward invalid arguments to JVMTI untouched (`redefineHooks_ut`) | done | -| Live-JVM redefinition stress test (`LazyRedefinitionStressTest`: ~1000 redefinitions racing ~47 dumps; every redefinition marked, stale raw frames refused, later frames resolve by name) | pass on JDK 17, 21, 25 (glibc), 21 under ASan, 11 (musl), macOS 25 | -| Release-build dump latency and memory on a real workload, x64 runs | open | -| `JmethodIdOverheadBenchTest.java`: temporary harness, don't commit | uncommitted | - -### Files touched - -``` -ddprof-lib/src/main/cpp/hotspot/klassRegistry.{h,cpp} new: Klass* → weak mirror registry, epoch -ddprof-lib/src/main/cpp/hotspot/klassTraceId.{h,cpp} new: Klass::_trace_id calibration and signal-safe read -ddprof-lib/src/main/cpp/frame.h raw-frame tag encoding -ddprof-lib/src/main/cpp/hotspot/hotspotSupport.{h,cpp} registerClass, readMethodHolder, resolveViaRegistry -ddprof-lib/src/main/cpp/hotspot/vmStructs{.h,.inline.h} VMKlass::containsMethod, VMConstMethod::idnum, hasKlassMethods -ddprof-lib/src/main/cpp/jvmSupport.{h,cpp} Lazy_loaded state and dispatch -ddprof-lib/src/main/cpp/arguments.{h,cpp} fjmethodid=lazy -ddprof-lib/src/main/cpp/counters.h method_resolve_lazy_{unloaded,not_current,class_loaded} -ddprof-lib/src/test/cpp/{klassRegistry,jvmSupport,arguments}_ut.cpp -ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyJmethodIdTest.java -ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyTraceIdJfrValidationTest.java -ddprof-test/src/test/java/com/datadoghq/profiler/SpinClasses.java + ExternalLauncher lazy-trace-id -ddprof-lib/src/test/cpp/{klassTraceId,hotspotLazyResolve,frame}_ut.cpp -``` - -## Continuing on Linux - -``` -git fetch && git switch jmethodid-lazy-resolve -./.claude/commands/build-and-summarize :ddprof-lib:gtestDebug -./.claude/commands/build-and-summarize :ddprof-test:testDebug -Ptests='*Jmethod*' -# or, from any host with podman/docker: -./utils/run-containers-tests.sh --config=asan --libc=glibc --jdk=21 --tests='*Jmethod*' -./utils/run-containers-tests.sh --arch=aarch64 --config=debug --jdk=21 --tests='*Jmethod*' -``` - -`-Ptests` takes a single pattern. Comma-separated lists don't match. The musl runner treats it as a regex (`*Foo` fails there); a plain class name works for both. - -## Alternatives set aside - -- **JVMTI ObjectFree on tagged mirrors** as an unload signal: it needs - `can_tag_objects`, and the events are posted later from the service thread, so they - don't tell you when the metadata is freed. -- **GC start/finish callbacks** to resolve before unloading: JNI isn't allowed inside - them, and they aren't known to cover unloading by concurrent collectors. -- **Copying names in the handler:** too expensive, and Symbols can be freed too. -- **JFR's own sampler (JDK 25+):** it solves this inside the VM, but it doesn't cover - JDK 8–21, J9 or Zing. From 8a5d5b8c1a5b9a6e44290146d38a253d71606c8a Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Thu, 15 Oct 2026 17:18:29 +0200 Subject: [PATCH 12/17] Catch a malformed trace ID in LazyTraceIdJfrValidationTest's log parser The parse runs in the thread that reads the child JVM's output, so an exception there would stop that thread instead of failing the test. A value that does not parse now leaves the logged ID unset, which the existing assertion reports. Co-Authored-By: Claude Opus 5.5 --- .../profiler/cpu/LazyTraceIdJfrValidationTest.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 index fc78bdb0a0..017de90c04 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyTraceIdJfrValidationTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyTraceIdJfrValidationTest.java @@ -79,7 +79,11 @@ public void calibratedTraceIdMatchesJfrClassId() throws Exception { } Matcher m = LOGGED_ID.matcher(line); if (m.find()) { - loggedId.set(Long.parseLong(m.group(1))); + try { + loggedId.set(Long.parseLong(m.group(1))); + } catch (NumberFormatException ignored) { + // Leaves loggedId unset; the assertion below reports it. + } } return LineConsumerResult.CONTINUE; }, null); From 8201325f387b9098213820233fb7f88ec518382e Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Fri, 16 Oct 2026 11:01:36 +0200 Subject: [PATCH 13/17] Add working plan for combining #864 and #867 Working notes for the branch, to be removed before the PR lands. Co-Authored-By: Zhengyu Gu Co-Authored-By: Claude Opus 5.5 --- doc/LazyJmethodIdCombinedPlan.md | 103 +++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 doc/LazyJmethodIdCombinedPlan.md diff --git a/doc/LazyJmethodIdCombinedPlan.md b/doc/LazyJmethodIdCombinedPlan.md new file mode 100644 index 0000000000..10cee88368 --- /dev/null +++ b/doc/LazyJmethodIdCombinedPlan.md @@ -0,0 +1,103 @@ +# 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 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. #864 first, roughly as it is: opt-in, reviewed twice, tested across the LTS releases. +2. PR A, phase 1 (this branch): VM-memory resolution with no jmethodIDs, co-authored with Zhengyu. +3. PR B, phase 2: names for unloaded classes; Zhengyu might want to lead it. +4. 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. + +## 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). From ef10a2dd94d6b3637616037254202d03d798c114 Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Fri, 16 Oct 2026 11:38:42 +0200 Subject: [PATCH 14/17] Resolve raw frames from VM metadata instead of allocating jmethodIDs HotspotSupport::resolve() now reads a raw Method* frame's class name, method name, signature, Thread.run status and line number table straight from HotSpot metadata (readMethod(), under crash protection) and returns them in a ResolvedMethod. In fjmethodid=lazy mode this runs while the class is pinned through KlassRegistry, after the trace-ID and current-method checks, so the dump no longer allocates jmethodIDs for sampled classes. Partial mode reads the same metadata instead of looking the method up through JNI FindClass/GetMethodID, which also covers without a special case. - vmStructs: ConstMethod::_flags and _code_size, Klass::_super, the version-dependent has-line-number-table constant, and a decoder for the compressed line number table (VMLineNumberStream). - Lookup: raw frames get MethodMap rows keyed by the untagged Method* and tag bits (RAW_METHOD_MARK); refused frames never touch a row, and rows are refilled at their first use in every chunk. The JVMTI and VM-metadata paths share fillJavaMethodNames(); line tables decoded from VM metadata are malloc'd and freed with free(). - Counter method_resolve_lazy_class_loaded becomes method_resolve_lazy_resolved. - Tests: the resolve() crash-protection gtests and the line table, Thread.run and bci-bound cases; the lazy-resolution gtests now run on a class without jmethodIDs and check none are allocated; LazyLineNumberConsistencyTest compares decoded line numbers with JVMTI's; SpinClasses' generated method has a line number table. Co-Authored-By: Zhengyu Gu Co-Authored-By: Claude Opus 5.5 --- ddprof-lib/src/main/cpp/counters.h | 8 +- ddprof-lib/src/main/cpp/flightRecorder.cpp | 268 ++++++---- ddprof-lib/src/main/cpp/flightRecorder.h | 76 ++- .../src/main/cpp/hotspot/hotspotStackFrame.h | 2 +- .../src/main/cpp/hotspot/hotspotSupport.cpp | 502 +++++++----------- .../src/main/cpp/hotspot/hotspotSupport.h | 116 +++- ddprof-lib/src/main/cpp/hotspot/vmStructs.h | 79 ++- .../src/main/cpp/hotspot/vmStructs.inline.h | 26 + ddprof-lib/src/main/cpp/jvmSupport.h | 6 +- ddprof-lib/src/main/cpp/jvmSupport.inline.h | 9 +- .../src/test/cpp/hotspotLazyResolve_ut.cpp | 172 ++++-- .../src/test/cpp/hotspotMethodId_ut.cpp | 384 ++++++++++---- .../datadoghq/profiler/ExternalLauncher.java | 62 +++ .../java/com/datadoghq/profiler/JfrFrame.java | 15 + .../com/datadoghq/profiler/SpinClasses.java | 17 + .../profiler/cpu/ClinitResolutionTest.java | 24 +- .../profiler/cpu/LazyJmethodIdTest.java | 34 +- .../cpu/LazyLineNumberConsistencyTest.java | 117 ++++ .../cpu/LazyTraceIdJfrValidationTest.java | 6 +- 19 files changed, 1289 insertions(+), 634 deletions(-) create mode 100644 ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyLineNumberConsistencyTest.java diff --git a/ddprof-lib/src/main/cpp/counters.h b/ddprof-lib/src/main/cpp/counters.h index 8ccb8c1675..9dcdd0e933 100644 --- a/ddprof-lib/src/main/cpp/counters.h +++ b/ddprof-lib/src/main/cpp/counters.h @@ -169,18 +169,18 @@ 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". CLASS_LOADED: classes whose jmethodIDs were \ - * allocated at dump time because one of their methods was sampled. */ \ + * 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_CLASS_LOADED, "method_resolve_lazy_class_loaded") \ + 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. \ diff --git a/ddprof-lib/src/main/cpp/flightRecorder.cpp b/ddprof-lib/src/main/cpp/flightRecorder.cpp index 6b310a13e3..6c73916225 100644 --- a/ddprof-lib/src/main/cpp/flightRecorder.cpp +++ b/ddprof-lib/src/main/cpp/flightRecorder.cpp @@ -78,15 +78,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 +271,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 +387,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 +423,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 +711,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 +726,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,30 +748,40 @@ MethodInfo *Lookup::resolveMethod(ASGCT_CallFrame &frame) { return fillMethod(frame, method_id, bci, prof_thread); } -jmethodID Lookup::resolveRawMethod(const ASGCT_CallFrame &frame) { +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; } - jmethodID method_id = JVMSupport::resolve(frame.method, frame.bci); - _raw_method_cache.emplace(key, method_id); - return method_id; + + ResolvedMethod resolved; + if (!JVMSupport::resolve(frame.method, frame.bci, resolved)) { + _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); - // Resolve native method - if (FrameType::isRawPointer(bci)) { - method_id = resolveRawMethod(frame); - if (method_id == nullptr) { - return unknownMethod(); - } - } - 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 diff --git a/ddprof-lib/src/main/cpp/flightRecorder.h b/ddprof-lib/src/main/cpp/flightRecorder.h index e5f56d4305..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; @@ -466,13 +488,15 @@ class Lookup { _vtable_receiver_cache; // Per-dump cache of raw Method* frame resolutions (HotSpot cstack=vm frames - // whose class had no jmethodID when sampled). The same raw method recurs in - // many traces, and each resolution sets up crash protection, takes the - // KlassRegistry mutex and pins the class through JNI. Keyed by the untagged - // Method* and the frame's 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. + // 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; @@ -483,8 +507,8 @@ class Lookup { return std::hash()(k.method) ^ ((size_t)k.tag * KNUTH_MULTIPLICATIVE_CONSTANT); } }; - std::unordered_map, - CountingAllocator, NM_JFR_BUFFERS>> + std::unordered_map, + CountingAllocator, NM_JFR_BUFFERS>> _raw_method_cache; Dictionary _packages; @@ -515,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; } @@ -559,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. @@ -570,9 +605,6 @@ class Lookup { _symbols() {} MethodInfo *resolveMethod(ASGCT_CallFrame &frame); - // Resolves a raw Method* frame to a jmethodID (nullptr if it must be - // treated as unknown), at most once per distinct method and tag per dump. - jmethodID resolveRawMethod(const ASGCT_CallFrame &frame); u32 getPackage(const char *class_name); u32 getSymbol(const char *name); }; 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 6ef10fabbd..e4fdb69a1b 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp @@ -1554,99 +1554,23 @@ bool HotspotSupport::registerClass(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass) { return loadMethodIDsIfNeededImpl(jvmti, jni, klass, true /*load all*/); } -// 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; - } - - const char* methodSignature() const { - return _long_method_signature != nullptr ? _long_method_signature : _method_signature; - } - - const char* klassName() const { - return _long_klass_name != nullptr ? _long_klass_name : _klass_name; - } -}; - -ResolvedNames::ResolvedNames() : +ResolvedMethod::ResolvedMethod() : _long_method_name(nullptr), _long_method_signature(nullptr), - _long_klass_name(nullptr) { -} - -ResolvedNames::~ResolvedNames() { - release(); + _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 ResolvedNames::release() { +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; @@ -1659,9 +1583,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. @@ -1686,49 +1616,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) { @@ -1753,19 +1740,28 @@ 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; } + + if (strcmp(result->methodName(), "run") == 0 && strcmp(result->methodSignature(), "()V") == 0) { + result->_is_thread_run = isThreadSubclass(klass); + } + + if (!readLineNumberTable(const_method, result->_line_number_table, result->_line_number_table_size)) { + Counters::increment(LINE_NUMBER_TABLE_UNREADABLE); + return false; + } return true; } -// PHASE 1, fjmethodid=lazy variant -- runs under the same crash protection as -// readMethodNames(). 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*. +// 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); @@ -1785,89 +1781,57 @@ static bool readMethodHolder(const void* method, VMMethod** out_vm_method, 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. +// 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. // -// 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; - } - } - } - } - } +// 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; + 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 + // signal would stay blocked and the next fault on this thread would be + // fatal. + if (sigsetjmp(crash_protection_ctx, 1) != 0) { + // checkFault() does a siglongjmp from inside segvHandler, bypassing + // segvHandler's SignalHandlerScope destructor. Compensate, then disarm + // before touching anything that could fault again. + SIGNAL_HANDLER_UNWIND_AFTER_LONGJMP(); + jmp_scope.restore(); + result.release(); + Counters::increment(METHOD_RESOLVE_FAULT_RECOVERED); + return false; } - jni->DeleteLocalRef(clz); + jmp_scope.install(&crash_protection_ctx); + ok = walk(); } - - return method_id; + if (!ok) { + result.release(); + } + return ok; } -// Partial mode only reaches here for system-class-loader classes (FindClass -// must be able to see them); lazy mode reaches here for every class without a -// jmethodID and resolves it through _klass_registry instead. -jmethodID HotspotSupport::resolve(const void* tagged_method, int bci) { +// 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. Map it to nullptr so the dump - // thread serializes it as the shared unknown method. + // 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 nullptr; + 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, @@ -1877,95 +1841,41 @@ jmethodID HotspotSupport::resolve(const void* tagged_method, int bci) { const void* method = rawFrameMethod(bci, tagged_method); // 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. + // 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 nullptr; + return false; } - ResolvedNames names; + 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); }); + } VMMethod* vm_method = nullptr; - jmethodID existing_id = nullptr; const void* holder = nullptr; int idnum = -1; - bool walked = 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 - // signal would stay blocked and the next fault on this thread would be - // fatal. - if (sigsetjmp(crash_protection_ctx, 1) != 0) { - // checkFault() does a siglongjmp from inside segvHandler, bypassing - // segvHandler's SignalHandlerScope destructor. Compensate, then disarm - // 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(); - Counters::increment(METHOD_RESOLVE_FAULT_RECOVERED); - return nullptr; - } - jmp_scope.install(&crash_protection_ctx); - - walked = lazy ? readMethodHolder(method, &vm_method, &holder, &idnum) - : readMethodNames(method, &vm_method, &existing_id, &names); - } - // --- crash protection is off from here on; see lookupMethodIdViaJni() --- - - if (!walked) { - return nullptr; - } - if (lazy) { - return resolveViaRegistry(vm_method, holder, idnum, tag & TAG_VALUE_MASK); - } - if (existing_id != nullptr) { - return existing_id; - } - return lookupMethodIdViaJni(vm_method, names, &HotspotSupport::loadMethodIDsIfNeededImpl); -} - -#ifdef DEBUG -// For CI only: LazyTraceIdJfrValidationTest compares this with the class ID -// JFR records for the same class. -static void logLazilyLoadedClass(jvmtiEnv* jvmti, jclass clz, const void* holder) { - char* signature = nullptr; - uint64_t trace_id = 0; - if (KlassTraceId::read(holder, &trace_id) && - jvmti->GetClassSignature(clz, &signature, nullptr) == JVMTI_ERROR_NONE) { - TEST_LOG("Lazy jmethodIDs for %s, trace id %llu", signature, (unsigned long long)trace_id); - } - if (signature != nullptr) { - jvmti->Deallocate((unsigned char*)signature); + if (!protectedWalk(prof_thread, result, + [&] { return readMethodHolder(method, &vm_method, &holder, &idnum); })) { + return false; } + return resolveViaRegistry(prof_thread, vm_method, holder, idnum, tag & TAG_VALUE_MASK, result); } -#endif -// Runs with crash protection off (see lookupMethodIdViaJni() for why the -// GetClassMethods call below must not run under a landing pad). Nothing here +// 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. -jmethodID HotspotSupport::resolveViaRegistry(VMMethod* vm_method, const void* holder, int idnum, - uint32_t trace_id_bits) { +bool HotspotSupport::resolveViaRegistry(ProfiledThread* prof_thread, VMMethod* vm_method, + const void* holder, int idnum, uint32_t trace_id_bits, + ResolvedMethod& result) { JNIEnv* jni = VM::jni(); KlassRegistry::PinStatus status; jclass clz = _klass_registry.pin(jni, holder, &status); @@ -1975,48 +1885,44 @@ jmethodID HotspotSupport::resolveViaRegistry(VMMethod* vm_method, const void* ho // was read from a freed Method* and is garbage. Counters::increment(status == KlassRegistry::PIN_REDEFINED ? METHOD_RESOLVE_LAZY_REDEFINED : METHOD_RESOLVE_LAZY_UNLOADED); - return nullptr; + return false; } - jmethodID method_id = nullptr; 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. Counters::increment(METHOD_RESOLVE_LAZY_STALE); jni->DeleteLocalRef(clz); - return nullptr; + 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() and - // validatedId() read through SafeAccess: the pinned class stays alive, but - // a concurrent redefinition can free its old methods array and Method*s. + // 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)) { Counters::increment(METHOD_RESOLVE_LAZY_NOT_CURRENT); } else { - method_id = vm_method->validatedId(); - if (!isValidJMethodID(method_id)) { - // First sample from this class: allocate its jmethodIDs now, on the - // dump thread, instead of at ClassPrepare. - method_id = nullptr; - jvmtiEnv* jvmti = VM::jvmti(); - if (jvmti != nullptr && loadMethodIDsIfNeededImpl(jvmti, jni, clz, true /*load all*/)) { - Counters::increment(METHOD_RESOLVE_LAZY_CLASS_LOADED); - DEBUG_ONLY(logLazilyLoadedClass(jvmti, clz, holder)); - jmethodID validated = vm_method->validatedId(); - if (isValidJMethodID(validated)) { - method_id = validated; - } - } - } + // 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 (method_id != nullptr && _klass_registry.isRedefined(holder)) { + if (resolved && _klass_registry.isRedefined(holder)) { + result.release(); Counters::increment(METHOD_RESOLVE_LAZY_REDEFINED); - return nullptr; + return false; + } + if (resolved) { + 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 method_id; + return resolved; } diff --git a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h index 3ea2984dab..480c4883a3 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h @@ -20,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; @@ -89,10 +182,17 @@ class HotspotSupport { // `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 - // or allocates the jmethodID. `holder` and `idnum` were read from the - // unverified Method* and are only used as lookup keys. - static jmethodID resolveViaRegistry(VMMethod* vm_method, const void* holder, int idnum, - uint32_t trace_id_bits); + // 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); + + // 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 @@ -227,9 +327,13 @@ class HotspotSupport { // from one of a class that took over its memory. static bool initLazyResolution(JNIEnv* jni); - // Resolve a raw-pointer frame to a jmethodID at dumping time. Takes the + // 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. - static jmethodID resolve(const void* tagged_method, int bci); + // 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); // 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/vmStructs.h b/ddprof-lib/src/main/cpp/hotspot/vmStructs.h index b3f4a956c3..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) \ @@ -710,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)); @@ -957,6 +971,11 @@ DECLARE(VMConstMethod) 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; @@ -1267,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 307f5accdc..e24ba1169c 100644 --- a/ddprof-lib/src/main/cpp/hotspot/vmStructs.inline.h +++ b/ddprof-lib/src/main/cpp/hotspot/vmStructs.inline.h @@ -230,6 +230,32 @@ u16 VMConstMethod::idnum() const { 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.h b/ddprof-lib/src/main/cpp/jvmSupport.h index e5a1ddfbaa..d0e99ce57b 100644 --- a/ddprof-lib/src/main/cpp/jvmSupport.h +++ b/ddprof-lib/src/main/cpp/jvmSupport.h @@ -22,6 +22,7 @@ enum StackRecovery { }; class HotspotSupport; +class ResolvedMethod; class JVMSupport { enum JMethodIDLoadStats { @@ -75,8 +76,9 @@ class JVMSupport { 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) to a jmethodID - static inline jmethodID resolve(const void* method, int bci); + // 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 ba571dd195..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 a raw-pointer frame (method field and encoded bci) to a jmethodID -jmethodID JVMSupport::resolve(const void* method, int bci) { +// 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, bci); + return HotspotSupport::resolve(method, bci, result); } else { assert(false && "Should not reach here"); - return nullptr; + return false; } } diff --git a/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp b/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp index 916790b83f..fd62794dd2 100644 --- a/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp +++ b/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp @@ -29,9 +29,10 @@ // // The metadata is fake but laid out the way the offsets below tell // VMStructs to read it: Method -> ConstMethod -> ConstantPool -> Klass, with -// the Klass holding both its jmethodID cache and its methods array. None of -// it is ever freed, so no fault handling is needed and the tests run on every -// platform. +// 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 @@ -85,7 +86,9 @@ class VMStructsTestAccessor { struct State { int method_constmethod, constmethod_constants, constmethod_idnum, pool_holder; int jmethod_ids, methods; - uint64_t method_size, const_method_size, constant_pool_size, klass_size; + 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; }; @@ -103,10 +106,19 @@ class VMStructsTestAccessor { 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, }; } @@ -118,10 +130,19 @@ class VMStructsTestAccessor { 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; } }; @@ -135,18 +156,20 @@ struct FakeMethod { struct FakeConstMethod { void* cpool; uint16_t idnum; - uint16_t pad[3]; + 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]; }; -// jmethodID cache: [length (i32), padding, id[0], id[1], ...]. -struct FakeIdCache { - int32_t len; - int32_t pad; - jmethodID slots[2]; +struct FakeSymbol { + uint16_t length; + char body[30]; }; // HotSpot's Array: int length, then pointer-aligned elements. @@ -157,9 +180,10 @@ struct FakeMethodArray { }; struct FakeKlass { - FakeIdCache* ids; + void* ids; // jmethodID cache; stays null FakeMethodArray* methods; uint64_t trace_id; // JFR encoding: (id << 16) | tag bits + FakeSymbol* name; }; const VMStructsTestAccessor::State LAYOUT = { @@ -169,47 +193,65 @@ const VMStructsTestAccessor::State LAYOUT = { /*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*/ sizeof(FakeConstantPool), + /*constant_pool_size*/ offsetof(FakeConstantPool, symbols), /*klass_size*/ sizeof(FakeKlass), + /*symbol_size*/ sizeof(FakeSymbol), /*can_dereference*/ true, }; -// One class with one method whose jmethodID already exists, so resolve() -// never needs JVMTI. 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. +// 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; - FakeIdCache ids; FakeMethodArray methods; - // A jmethodID is a pointer to a slot holding the Method*; validatedId() - // checks exactly that when _can_dereference_jmethod_id is set. - void* jmethod_id_slot; + 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; - klass.ids = &ids; + 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; - jmethod_id_slot = &method; - ids.len = 1; - ids.slots[0] = jmethodId(); + 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); } - jmethodID jmethodId() { return reinterpret_cast(&jmethod_id_slot); } }; // Fake JNI with weak-reference semantics, as in klassRegistry_ut.cpp, plus @@ -297,8 +339,9 @@ class FakeJvm { static jlong JNICALL getLongField(JNIEnv*, jobject obj, jfieldID) { return (jlong)(uintptr_t)reinterpret_cast(obj)->klass; } - // A jmethodID preload (loadMethodIDsIfNeededImpl()); the fake class's IDs - // already exist, so only the call is recorded. + // 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; @@ -400,23 +443,35 @@ class HotspotLazyResolveTest : public ::testing::Test { succeeded); } - // What Lookup::fillMethod() does with a raw frame at dump time. - static jmethodID resolve(const ASGCT_CallFrame& frame) { - return HotspotSupport::resolve(frame.method, frame.bci); + // 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_EQ(_cls.jmethodId(), resolve(frame)); + 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_EQ(nullptr, resolve(frame)); + EXPECT_FALSE(resolve(frame)); } // The old class is unloaded and a new one is registered at the same Klass*, @@ -428,7 +483,7 @@ TEST_F(HotspotLazyResolveTest, FrameFromBeforeKlassReuseDoesNotResolveToNewClass loadNewClassAtSameAddress(); - EXPECT_EQ(nullptr, resolve(frame)); + EXPECT_FALSE(resolve(frame)); } // Same, but the dead entry was purged by a registry rehash before the new @@ -454,7 +509,7 @@ TEST_F(HotspotLazyResolveTest, FrameFromBeforeKlassReuseAfterPurgeDoesNotResolve _cls.klass.trace_id = ((_cls.klass.trace_id >> 16) + 37) << 16; // the new class's ID registerClass(_new_mirror); - EXPECT_EQ(nullptr, resolve(frame)); + EXPECT_FALSE(resolve(frame)); } // Frames sampled from the new class after the reuse must still resolve. @@ -463,7 +518,7 @@ TEST_F(HotspotLazyResolveTest, FrameFromAfterKlassReuseResolves) { loadNewClassAtSameAddress(); ASGCT_CallFrame frame = sample(); - EXPECT_EQ(_cls.jmethodId(), resolve(frame)); + EXPECT_TRUE(resolvesToSpin(frame)); } // A frame from the old class and one from the new class must be told apart @@ -496,7 +551,7 @@ TEST_F(HotspotLazyResolveTest, UnrelatedReuseDoesNotChangeFrames) { ASGCT_CallFrame after = sample(); EXPECT_EQ(before.bci, after.bci); EXPECT_EQ(before.method, after.method); - EXPECT_EQ(_cls.jmethodId(), resolve(after)); + EXPECT_TRUE(resolvesToSpin(after)); } // The JFR tag bits below the ID change while JFR runs; they must not affect @@ -508,7 +563,7 @@ TEST_F(HotspotLazyResolveTest, JfrTagBitsDoNotAffectTraceIdTags) { ASGCT_CallFrame after = sample(); EXPECT_EQ(before.bci, after.bci); EXPECT_EQ(before.method, after.method); - EXPECT_EQ(_cls.jmethodId(), resolve(before)); + EXPECT_TRUE(resolvesToSpin(before)); } // Partial mode (fjmethodid=false) stores the plain Method*, untagged, and @@ -525,15 +580,15 @@ TEST_F(HotspotLazyResolveTest, PartialModeFramesAreStoredUntagged) { // 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 (partial mode's FindClass lookup cannot see classes of -// custom loaders). +// 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_EQ(_cls.jmethodId(), resolve(frame)); + EXPECT_TRUE(resolvesToSpin(frame)); EXPECT_EQ(1, _jvm.new_local_ref_calls - before); } @@ -548,7 +603,7 @@ TEST_F(HotspotLazyResolveTest, FrameOfRedefinedClassIsRefused) { // Afterwards the Method* at the sampled address is (still, or again) // among the class's methods. redefine(true); - EXPECT_EQ(nullptr, resolve(before)); + EXPECT_FALSE(resolve(before)); // Its later frames get jmethodIDs instead of being raw. EXPECT_EQ(1, _jvm.get_class_methods_calls); } @@ -559,7 +614,7 @@ TEST_F(HotspotLazyResolveTest, FailedRedefinitionLeavesFramesResolvable) { registerClass(_old_mirror); ASGCT_CallFrame frame = sample(); redefine(false); - EXPECT_EQ(_cls.jmethodId(), resolve(frame)); + EXPECT_TRUE(resolvesToSpin(frame)); EXPECT_EQ(0, _jvm.get_class_methods_calls); } @@ -568,9 +623,9 @@ TEST_F(HotspotLazyResolveTest, FramesAreRefusedWhileRedefinitionRuns) { registerClass(_old_mirror); ASGCT_CallFrame frame = sample(); HotspotSupportTestAccessor::beforeClassRedefinition(_jvm.jvmti(), _jvm.jni(), _jvm.mirror(_old_mirror)); - EXPECT_EQ(nullptr, resolve(frame)); + EXPECT_FALSE(resolve(frame)); HotspotSupportTestAccessor::afterClassRedefinition(_jvm.jvmti(), _jvm.jni(), _jvm.mirror(_old_mirror), false); - EXPECT_EQ(_cls.jmethodId(), resolve(frame)); + EXPECT_TRUE(resolvesToSpin(frame)); } // Redefinitions are tracked in every mode once the registry has entries: @@ -582,7 +637,7 @@ TEST_F(HotspotLazyResolveTest, RedefinitionIsTrackedOutsideLazyMode) { JVMSupportTestAccessor::setLoadState(JVMSupportTestAccessor::PartialLoaded()); redefine(true); EXPECT_TRUE(HotspotSupportTestAccessor::registry().isRedefined(_cls.klassPtr())); - EXPECT_EQ(nullptr, resolve(frame)); + EXPECT_FALSE(resolve(frame)); // The other modes load jmethodIDs their own way (JVMSupport). EXPECT_EQ(0, _jvm.get_class_methods_calls); } @@ -605,7 +660,7 @@ TEST_F(HotspotLazyResolveTest, MethodMemoryReusedByClassAtOtherAddressDoesNotRes ASSERT_TRUE(HotspotSupportTestAccessor::registry().add(_jvm.jni(), &other_klass, _jvm.mirror(_new_mirror))); - EXPECT_EQ(nullptr, resolve(frame)); + EXPECT_FALSE(resolve(frame)); } // Tripwire for tag collisions: counts registrations of classes whose trace @@ -643,15 +698,22 @@ TEST_F(HotspotLazyResolveTest, LookupResolvesEachRawMethodOncePerDump) { MethodMap methods; Lookup lookup(nullptr, &methods, &classes); int before = _jvm.new_local_ref_calls; - EXPECT_EQ(_cls.jmethodId(), lookup.resolveRawMethod(a)); - EXPECT_EQ(_cls.jmethodId(), lookup.resolveRawMethod(a)); - EXPECT_EQ(_cls.jmethodId(), lookup.resolveRawMethod(b)); + 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. + // 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(_cls.jmethodId(), next.resolveRawMethod(a)); + 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, @@ -665,8 +727,10 @@ TEST_F(HotspotLazyResolveTest, LookupCacheSeparatesFramesOfDifferentClassLoads) StringDictionary classes; MethodMap methods; Lookup lookup(nullptr, &methods, &classes); - EXPECT_EQ(nullptr, lookup.resolveRawMethod(before)); - EXPECT_EQ(_cls.jmethodId(), lookup.resolveRawMethod(after)); - EXPECT_EQ(nullptr, lookup.resolveRawMethod(before)); + 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 698567da1f..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, 0)); + // 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, 0)); + 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, 0)); + 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, 0)); + 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, 0)); + 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, 0)); + 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, 0)); + 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-test/src/test/java/com/datadoghq/profiler/ExternalLauncher.java b/ddprof-test/src/test/java/com/datadoghq/profiler/ExternalLauncher.java index 16ec45f645..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. @@ -33,6 +42,10 @@ * 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 { @@ -122,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 { @@ -171,6 +219,20 @@ public static void main(String[] args) throws Exception { 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 index cb8a4256da..38625c7a00 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/SpinClasses.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/SpinClasses.java @@ -10,12 +10,24 @@ 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() {} /** @@ -52,6 +64,9 @@ private static byte[] generate(String internalName) { 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); @@ -61,12 +76,14 @@ private static byte[] generate(String internalName) { 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(); 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 index c5e3d3f62e..5fc3229bba 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyJmethodIdTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyJmethodIdTest.java @@ -7,6 +7,7 @@ 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; @@ -27,8 +28,8 @@ /** * 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, allocating jmethodIDs for - * that one class on first use. + * {@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 @@ -46,8 +47,8 @@ protected String getProfilerCommand() { } /** - * Frames from a custom-loader class must resolve to their real names, and the class's - * jmethodIDs must have been allocated at dump time rather than preloaded. + * 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 { @@ -61,21 +62,26 @@ public void customLoaderFramesResolve() throws Exception { stopProfiler(); String simpleName = className.substring(className.lastIndexOf('/') + 1); - boolean found = false; + int spinFrames = 0; for (JfrEvent sample : verifyEvents("datadog.ExecutionSample")) { - String stackTrace = sample.getStackTraceString(); - if (stackTrace != null && stackTrace.contains(simpleName) && stackTrace.contains("spin")) { - found = true; - break; + 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(found, "No CPU sample contained a resolved " + simpleName + ".spin frame"); + assertTrue(spinFrames > 0, "No CPU sample contained a resolved " + simpleName + ".spin frame"); // Absent in builds without counters; only check when present. - Long classesLoaded = profiler.getDebugCounters().get("method_resolve_lazy_class_loaded"); - if (classesLoaded != null) { - assertTrue(classesLoaded > 0, - "Expected jmethodIDs to be allocated at dump time for at least one class"); + Long resolved = profiler.getDebugCounters().get("method_resolve_lazy_resolved"); + if (resolved != null) { + assertTrue(resolved > 0, "Expected raw frames to be resolved from VM metadata"); } } 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/LazyTraceIdJfrValidationTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyTraceIdJfrValidationTest.java index 017de90c04..5a6374a687 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyTraceIdJfrValidationTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyTraceIdJfrValidationTest.java @@ -32,8 +32,8 @@ * 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 each class - * it allocates jmethodIDs for. JFR emits {@code jdk.ClassDefine} when the probe class is created, + * 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 @@ -46,7 +46,7 @@ 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 jmethodIDs for L" + Pattern.quote(PROBE_CLASS.replace('.', '/')) + ";, trace id (\\d+)"); + "\\[TEST::INFO\\] Lazy resolution of " + Pattern.quote(PROBE_CLASS.replace('.', '/')) + ", trace id (\\d+)"); @Test @Timeout(120) From 83406257b2f255a9b00a1d0df83ff7df7241239e Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Fri, 16 Oct 2026 12:37:57 +0200 Subject: [PATCH 15/17] Update the combined plan: PR structure, phase 1 status, Linux hand-over Co-Authored-By: Zhengyu Gu Co-Authored-By: Claude Opus 5.5 --- doc/LazyJmethodIdCombinedPlan.md | 176 ++++++++++++++++++++++++++++++- 1 file changed, 172 insertions(+), 4 deletions(-) diff --git a/doc/LazyJmethodIdCombinedPlan.md b/doc/LazyJmethodIdCombinedPlan.md index 10cee88368..0077dd547c 100644 --- a/doc/LazyJmethodIdCombinedPlan.md +++ b/doc/LazyJmethodIdCombinedPlan.md @@ -49,6 +49,25 @@ Leave out #867's one-chunk-per-GC writing. `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. @@ -82,10 +101,14 @@ Leave out #867's one-chunk-per-GC writing. ## Order and PR structure -1. #864 first, roughly as it is: opt-in, reviewed twice, tested across the LTS releases. -2. PR A, phase 1 (this branch): VM-memory resolution with no jmethodIDs, co-authored with Zhengyu. -3. PR B, phase 2: names for unloaded classes; Zhengyu might want to lead it. -4. Default flip after a nightly soak, watching the counters. +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 @@ -95,6 +118,151 @@ Leave out #867's one-chunk-per-GC writing. - 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 From 0c78fb1028535a79b2ab84df82ff8d8d1e2fee9c Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Fri, 16 Oct 2026 14:58:42 +0200 Subject: [PATCH 16/17] Prototype: capture names of unloadable raw frames at GC start Not for merging. With DDPROF_CAPTURE_UNLOADING=1 in fjmethodid=lazy mode, the sampler records raw frames of classes outside the boot, platform and application loaders in a lock-free pending set; GarbageCollectionStart copies their names and line tables into a cache under crash protection; the dump uses a captured name when resolve() refuses a frame. Adds unload_capture_* counters, the benchmark harnesses used to measure it (utils/phase2-proto) and a hand-over note with the results so far. Co-Authored-By: Zhengyu Gu Co-Authored-By: Claude Opus 5.5 --- ddprof-lib/src/main/cpp/counters.h | 11 + ddprof-lib/src/main/cpp/flightRecorder.cpp | 31 +++ .../src/main/cpp/hotspot/hotspotSupport.cpp | 48 +++- .../src/main/cpp/hotspot/hotspotSupport.h | 9 +- .../src/main/cpp/hotspot/unloadCapture.cpp | 260 ++++++++++++++++++ .../src/main/cpp/hotspot/unloadCapture.h | 69 +++++ ddprof-lib/src/main/cpp/vmEntry.cpp | 8 +- doc/LazyJmethodIdPhase2Handover.md | 109 ++++++++ utils/phase2-proto/Bench2.java | 95 +++++++ utils/phase2-proto/Bench3.java | 105 +++++++ utils/phase2-proto/GcBench2.java | 60 ++++ utils/phase2-proto/analyze_sp.py | 21 ++ 12 files changed, 821 insertions(+), 5 deletions(-) create mode 100644 ddprof-lib/src/main/cpp/hotspot/unloadCapture.cpp create mode 100644 ddprof-lib/src/main/cpp/hotspot/unloadCapture.h create mode 100644 doc/LazyJmethodIdPhase2Handover.md create mode 100644 utils/phase2-proto/Bench2.java create mode 100644 utils/phase2-proto/Bench3.java create mode 100644 utils/phase2-proto/GcBench2.java create mode 100644 utils/phase2-proto/analyze_sp.py diff --git a/ddprof-lib/src/main/cpp/counters.h b/ddprof-lib/src/main/cpp/counters.h index 9dcdd0e933..0a31b973f3 100644 --- a/ddprof-lib/src/main/cpp/counters.h +++ b/ddprof-lib/src/main/cpp/counters.h @@ -202,6 +202,17 @@ * 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(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 \ diff --git a/ddprof-lib/src/main/cpp/flightRecorder.cpp b/ddprof-lib/src/main/cpp/flightRecorder.cpp index 6c73916225..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 @@ -761,6 +762,35 @@ MethodInfo *Lookup::fillRawMethod(const ASGCT_CallFrame &frame) { 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(); } @@ -1781,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/hotspot/hotspotSupport.cpp b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp index e4fdb69a1b..4526f537ef 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp @@ -13,6 +13,7 @@ #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" @@ -222,9 +223,10 @@ void HotspotSupport::fillJavaFrame(ASGCT_CallFrame& frame, FrameTypeId type, int // inherent to this mode. NO_INJECTION_ASSERT(method != nullptr); 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)) { + } 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 @@ -234,6 +236,7 @@ void HotspotSupport::fillJavaFrame(ASGCT_CallFrame& frame, FrameTypeId type, int 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 @@ -1488,14 +1491,25 @@ bool HotspotSupport::initLazyResolution(JNIEnv* jni) { // 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; } -bool HotspotSupport::lazyFrameTag(const VMMethod* method, uint32_t* tag) { +bool HotspotSupport::lazyFrameTag(const VMMethod* method, uint32_t* tag, const void** holder) { uint64_t id; - if (!KlassTraceId::read(method->holderOrNull(), &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; } @@ -1537,6 +1551,16 @@ bool HotspotSupport::registerClass(jvmtiEnv *jvmti, JNIEnv *jni, jclass 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 @@ -1926,3 +1950,21 @@ bool HotspotSupport::resolveViaRegistry(ProfiledThread* prof_thread, VMMethod* v } 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); + }); +} diff --git a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h index 480c4883a3..efe1b2a23e 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h @@ -158,7 +158,7 @@ class HotspotSupport { // 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); + 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. @@ -335,6 +335,13 @@ class HotspotSupport { // 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); + // Store a Java frame captured from HotSpot metadata. A null jmethodID // triggers the raw Method* fallback only when fjmethodid=false; with // fjmethodid=true the sentinel is used instead to avoid deferring a 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..6351f41336 --- /dev/null +++ b/ddprof-lib/src/main/cpp/hotspot/unloadCapture.cpp @@ -0,0 +1,260 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "hotspot/unloadCapture.h" + +#include "counters.h" +#include "hotspot/hotspotSupport.h" +#include "hotspot/vmStructs.h" +#include "os.h" +#include "safeAccess.h" +#include "spinLock.h" + +#include +#include +#include + +bool UnloadCapture::_enabled = false; + +namespace { + +// Pending (Method*, tag) pairs, filled by the sampler. A slot is claimed by a +// CAS on `method`, then published by storing a non-zero `tag` (lazy tags +// always have HotspotSupport::TRACE_ID_TAG set). +struct PendingSlot { + volatile uintptr_t method; + volatile uint32_t tag; +}; + +const size_t PENDING_CAPACITY = 8192; // 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; +} + +} // namespace + +void UnloadCapture::init(jvmtiEnv* jvmti) { + const char* env = getenv("DDPROF_CAPTURE_UNLOADING"); + _enabled = env != nullptr && strcmp(env, "1") == 0; + if (!_enabled) { + return; + } + if (cache == nullptr) { + cache = new std::unordered_map(); + } + jvmti->SetEventNotificationMode(JVMTI_ENABLE, JVMTI_EVENT_GARBAGE_COLLECTION_START, nullptr); +} + +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 == (uintptr_t)method) { + if (__atomic_load_n(&s->tag, __ATOMIC_ACQUIRE) == tag) { + return; // already pending + } + continue; + } + 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); + Counters::increment(UNLOAD_CAPTURE_PENDING_ADDED); + return; + } + if (expected == (uintptr_t)method && __atomic_load_n(&s->tag, __ATOMIC_ACQUIRE) == tag) { + return; + } + } + } + Counters::increment(UNLOAD_CAPTURE_PENDING_OVERFLOW); +} + +void JNICALL UnloadCapture::GarbageCollectionStart(jvmtiEnv* jvmti) { + if (!_enabled || __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 is reading 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++) { + PendingSlot* s = &pending[i]; + uintptr_t m = __atomic_load_n(&s->method, __ATOMIC_ACQUIRE); + if (m == 0) { + continue; + } + uint32_t tag = __atomic_load_n(&s->tag, __ATOMIC_ACQUIRE); + if (tag == 0) { + continue; // being inserted; the next GC sees it + } + __atomic_store_n(&s->tag, 0, __ATOMIC_RELEASE); + __atomic_store_n(&s->method, 0, __ATOMIC_RELEASE); + __atomic_fetch_sub(&pending_count, 1, __ATOMIC_ACQ_REL); + + Key key{(const void*)m, tag}; + if (cache->find(key) != cache->end()) { + continue; + } + if (cache->size() >= MAX_CACHE_ENTRIES) { + Counters::increment(UNLOAD_CAPTURE_CACHE_FULL); + continue; + } + ResolvedMethod resolved; + if (!HotspotSupport::captureMethod(prof_thread, (const void*)m, tag, resolved)) { + Counters::increment(UNLOAD_CAPTURE_REFUSED); + continue; + } + 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; + if (e.klass_name == nullptr || e.method_name == nullptr || e.signature == nullptr) { + freeEntry(e); + continue; + } + cache->emplace(key, e); + Counters::increment(UNLOAD_CAPTURE_CAPTURED); + } + cache_lock.unlock(); + u64 elapsed = OS::nanotime() - start; + Counters::increment(UNLOAD_CAPTURE_HOOK_NS, (long long)elapsed); + long long max = Counters::getCounter(UNLOAD_CAPTURE_HOOK_MAX_NS); + if ((long long)elapsed > max) { + Counters::set(UNLOAD_CAPTURE_HOOK_MAX_NS, (long long)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(); +} + +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..f264e788ef --- /dev/null +++ b/ddprof-lib/src/main/cpp/hotspot/unloadCapture.h @@ -0,0 +1,69 @@ +/* + * 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. + * + * Enabled with DDPROF_CAPTURE_UNLOADING=1 in lazy mode, so one build can be + * measured with and without it. + */ +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; +}; + +#endif // _HOTSPOT_UNLOADCAPTURE_H diff --git a/ddprof-lib/src/main/cpp/vmEntry.cpp b/ddprof-lib/src/main/cpp/vmEntry.cpp index 1f7fa8792c..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)); diff --git a/doc/LazyJmethodIdPhase2Handover.md b/doc/LazyJmethodIdPhase2Handover.md new file mode 100644 index 0000000000..d842c9d019 --- /dev/null +++ b/doc/LazyJmethodIdPhase2Handover.md @@ -0,0 +1,109 @@ +# 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) for continuing on Linux. + +## 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=1` in `fjmethodid=lazy` mode; off otherwise, so one build +measures both. + +- **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: 8,192 slots, 16 linear probes, duplicates skipped, overflow counted. + Permanent CLDs are learned in `registerClass()` (GetClassLoader + `isSystemClassLoader`). +- **`GarbageCollectionStart`** (`vmEntry.cpp` forwards to it and to ReferenceChainTracker): one + atomic load if nothing is pending. Otherwise, under `tryLock` of the cache: drains the set and, + for each pair not cached yet, `HotspotSupport::captureMethod()` = `readMethodHolder` + trace-ID + check + `containsMethod` + `readMethod`, all under crash protection, no JNI/JVMTI/locks. Copies + names and line table into a cache entry (malloc). 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. +- Counters `unload_capture_*`: `pending_added`, `pending_overflow`, `hook_calls`, `hook_busy`, + `hook_ns`, `hook_max_ns`, `captured`, `refused`, `cache_full`, `used`. +- Known prototype gaps: no redefinition check at capture beyond `containsMethod`; slot drain can + race an insert into the same slot (loses that pair); the drain scans all 8,192 slots whenever + anything is pending; the cache is a std::unordered_map under a spinlock. + +## Results so far (macOS, release) + +Harnesses in `utils/phase2-proto/` (they lived in /tmp on the Mac). + +**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. + +## Next steps (in order) + +1. **Background drain** (the proposed fix for bursts): a profiler thread drains the pending set + every ~100 ms *outside* any pause, resolving pairs the normal way (registry `pin()` + trace-ID + check + `containsMethod` + `readMethod`, as `resolveViaRegistry` does) into the same cache. A + name is then lost only if its class unloads within ~100 ms of the sample, instead of within the + dump interval. Keep the GC hook only for what is left (at most ~100 ms of new methods), or drop + it and measure what that costs in recovered names. Measure both. +2. Re-measure on Linux x64 with both harnesses: `Bench3` (lost / recovered, hook max) and + `GcBench2` (pause table), plus a low-GC-rate variant of `GcBench2` (e.g. `-Xmn512m`) to provoke + bursts. Acceptance: no visible change in p99/max pause vs `lazy`, and hook max well below a + young pause. +3. Count every resolution failure (add a counter where `readMethodHolder` fails), so "lost" is + measured directly. +4. ZGC: find out why `Bench3` unloads nothing (needs a major cycle with class unloading; try + `-XX:ZCollectionInterval` or more metaspace pressure), then measure there too. +5. Renaissance (`dotty`, `finagle-http`): `method_resolve_lazy_{resolved,unloaded,stale,not_current}` + and `unload_capture_used` at the normal dump interval, to see whether real workloads lose names. + +## How to run the harnesses + +``` +ASM=; CLS=ddprof-lib/build/classes/java/main; LIB=ddprof-lib/build/lib/main/release/linux/x64/libjavaProfiler.so +cd utils/phase2-proto && javac -cp "$ASM:$CLS" -d /tmp/p2 Bench2.java Bench3.java GcBench2.java +# 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 +# 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..e5c2c2304d --- /dev/null +++ b/utils/phase2-proto/Bench3.java @@ -0,0 +1,105 @@ +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 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 lost_pct=%.1f max_dump_ms=%d %s%n", + dumpMs / 1000, genMs, dumps, generations.get(), counter.get(), + ManagementFactory.getClassLoadingMXBean().getUnloadedClassCount(), + resolved, unloaded, stale, notCurrent, + 100.0 * unloaded / Math.max(1, resolved + unloaded + stale + notCurrent), 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/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/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}") From 6a6000dd86f934137692bbc6f3ed90078813755f Mon Sep 17 00:00:00 2001 From: "roman.kennke" Date: Mon, 19 Oct 2026 10:20:41 +0000 Subject: [PATCH 17/17] Prototype: drain unloadable raw frames off-pause; measure on Linux Not for merging. Adds a background drain to UnloadCapture: with DDPROF_CAPTURE_UNLOADING=2 (drain + GC hook) or 3 (drain only), a daemon thread resolves pending (Method*, tag) pairs every DDPROF_CAPTURE_INTERVAL_MS (default 100) through the class registry, via the new HotspotSupport::capturePinned(); resolveViaRegistry() gains a flag to leave the method_resolve_lazy_* counters untouched for it. Pending slots get a state word (free, pending, draining, done): drainers claim pairs by CAS, captured pairs stay as DONE until endDump() so the sampler does not add them again, and a sampler with no free slot evicts a DONE one. The set grows to 16,384 slots. Adds counters method_resolve_lazy_unreadable (a lazy Method* that could not be read at dump time) and unload_capture_{bg_*,hook_drained, pending_evicted}; Bench3 reports failed and lost percentages from them. Adds driver scripts and Churn/RenWrap harnesses, and records the Linux arm64 results and next steps in the hand-over note. Environment: Datadog workspace Co-Authored-By: Claude Opus 5.5 --- ddprof-lib/src/main/cpp/counters.h | 11 + .../src/main/cpp/hotspot/hotspotSupport.cpp | 26 +- .../src/main/cpp/hotspot/hotspotSupport.h | 8 +- .../src/main/cpp/hotspot/unloadCapture.cpp | 259 +++++++++++++++--- .../src/main/cpp/hotspot/unloadCapture.h | 10 +- doc/LazyJmethodIdPhase2Handover.md | 193 ++++++++++--- utils/phase2-proto/Bench3.java | 14 +- utils/phase2-proto/Churn.java | 17 ++ utils/phase2-proto/RenWrap.java | 46 ++++ utils/phase2-proto/bench3.sh | 6 + utils/phase2-proto/gcbench.sh | 15 + 11 files changed, 512 insertions(+), 93 deletions(-) create mode 100644 utils/phase2-proto/Churn.java create mode 100644 utils/phase2-proto/RenWrap.java create mode 100755 utils/phase2-proto/bench3.sh create mode 100755 utils/phase2-proto/gcbench.sh diff --git a/ddprof-lib/src/main/cpp/counters.h b/ddprof-lib/src/main/cpp/counters.h index 0a31b973f3..917d6bee64 100644 --- a/ddprof-lib/src/main/cpp/counters.h +++ b/ddprof-lib/src/main/cpp/counters.h @@ -213,6 +213,17 @@ 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 \ diff --git a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp index 4526f537ef..b071f1a451 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp @@ -1888,6 +1888,7 @@ bool HotspotSupport::resolve(const void* tagged_method, int bci, ResolvedMethod& 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); @@ -1899,7 +1900,7 @@ bool HotspotSupport::resolve(const void* tagged_method, int bci, ResolvedMethod& // 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) { + ResolvedMethod& result, bool count) { JNIEnv* jni = VM::jni(); KlassRegistry::PinStatus status; jclass clz = _klass_registry.pin(jni, holder, &status); @@ -1907,7 +1908,7 @@ bool HotspotSupport::resolveViaRegistry(ProfiledThread* prof_thread, VMMethod* v // 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. - Counters::increment(status == KlassRegistry::PIN_REDEFINED ? METHOD_RESOLVE_LAZY_REDEFINED + if (count) Counters::increment(status == KlassRegistry::PIN_REDEFINED ? METHOD_RESOLVE_LAZY_REDEFINED : METHOD_RESOLVE_LAZY_UNLOADED); return false; } @@ -1916,7 +1917,7 @@ bool HotspotSupport::resolveViaRegistry(ProfiledThread* prof_thread, VMMethod* v 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. - Counters::increment(METHOD_RESOLVE_LAZY_STALE); + if (count) Counters::increment(METHOD_RESOLVE_LAZY_STALE); jni->DeleteLocalRef(clz); return false; } @@ -1926,7 +1927,7 @@ bool HotspotSupport::resolveViaRegistry(ProfiledThread* prof_thread, VMMethod* v // 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)) { - Counters::increment(METHOD_RESOLVE_LAZY_NOT_CURRENT); + 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 @@ -1939,11 +1940,11 @@ bool HotspotSupport::resolveViaRegistry(ProfiledThread* prof_thread, VMMethod* v // Method* we validated was the class's own; otherwise we cannot tell. if (resolved && _klass_registry.isRedefined(holder)) { result.release(); - Counters::increment(METHOD_RESOLVE_LAZY_REDEFINED); + if (count) Counters::increment(METHOD_RESOLVE_LAZY_REDEFINED); return false; } if (resolved) { - Counters::increment(METHOD_RESOLVE_LAZY_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); @@ -1968,3 +1969,16 @@ bool HotspotSupport::captureMethod(ProfiledThread* prof_thread, const void* meth 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 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 efe1b2a23e..6a31653881 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h @@ -187,7 +187,7 @@ class HotspotSupport { // 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); + 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 @@ -342,6 +342,12 @@ class HotspotSupport { 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 // fjmethodid=true the sentinel is used instead to avoid deferring a diff --git a/ddprof-lib/src/main/cpp/hotspot/unloadCapture.cpp b/ddprof-lib/src/main/cpp/hotspot/unloadCapture.cpp index 6351f41336..4bbd78fcc1 100644 --- a/ddprof-lib/src/main/cpp/hotspot/unloadCapture.cpp +++ b/ddprof-lib/src/main/cpp/hotspot/unloadCapture.cpp @@ -5,30 +5,41 @@ #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 { -// Pending (Method*, tag) pairs, filled by the sampler. A slot is claimed by a -// CAS on `method`, then published by storing a non-zero `tag` (lazy tags -// always have HotspotSupport::TRACE_ID_TAG set). +// (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 = 8192; // power of two +const size_t PENDING_CAPACITY = 16384; // power of two const size_t PENDING_PROBES = 16; PendingSlot pending[PENDING_CAPACITY]; volatile int pending_count = 0; @@ -82,18 +93,167 @@ char* dupString(const char* s) { 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"); - _enabled = env != nullptr && strcmp(env, "1") == 0; + 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(); } - jvmti->SetEventNotificationMode(JVMTI_ENABLE, JVMTI_EVENT_GARBAGE_COLLECTION_START, nullptr); + 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) { @@ -124,37 +284,54 @@ void UnloadCapture::onRawFrame(const void* method, const void* holder, uint32_t 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 == (uintptr_t)method) { - if (__atomic_load_n(&s->tag, __ATOMIC_ACQUIRE) == tag) { - return; // already pending - } - continue; - } 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; } - if (expected == (uintptr_t)method && __atomic_load_n(&s->tag, __ATOMIC_ACQUIRE) == tag) { - 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 || __atomic_load_n(&pending_count, __ATOMIC_ACQUIRE) == 0) { + 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 is reading the cache; the pairs stay pending. + // The dump thread or the background drain holds the cache; the pairs + // stay pending. Counters::increment(UNLOAD_CAPTURE_HOOK_BUSY); return; } @@ -168,53 +345,44 @@ void JNICALL UnloadCapture::GarbageCollectionStart(jvmtiEnv* jvmti) { } 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]; - uintptr_t m = __atomic_load_n(&s->method, __ATOMIC_ACQUIRE); - if (m == 0) { + if (!claimSlot(s, &m, &tag)) { continue; } - uint32_t tag = __atomic_load_n(&s->tag, __ATOMIC_ACQUIRE); - if (tag == 0) { - continue; // being inserted; the next GC sees it - } - __atomic_store_n(&s->tag, 0, __ATOMIC_RELEASE); - __atomic_store_n(&s->method, 0, __ATOMIC_RELEASE); - __atomic_fetch_sub(&pending_count, 1, __ATOMIC_ACQ_REL); - + 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; } - 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; - if (e.klass_name == nullptr || e.method_name == nullptr || e.signature == nullptr) { + 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(); - u64 elapsed = OS::nanotime() - start; - Counters::increment(UNLOAD_CAPTURE_HOOK_NS, (long long)elapsed); - long long max = Counters::getCounter(UNLOAD_CAPTURE_HOOK_MAX_NS); - if ((long long)elapsed > max) { - Counters::set(UNLOAD_CAPTURE_HOOK_MAX_NS, (long long)elapsed); - } + 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) { @@ -248,6 +416,15 @@ void UnloadCapture::endDump() { } __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) { diff --git a/ddprof-lib/src/main/cpp/hotspot/unloadCapture.h b/ddprof-lib/src/main/cpp/hotspot/unloadCapture.h index f264e788ef..1a5fe1413a 100644 --- a/ddprof-lib/src/main/cpp/hotspot/unloadCapture.h +++ b/ddprof-lib/src/main/cpp/hotspot/unloadCapture.h @@ -22,8 +22,13 @@ * nothing pending it costs one atomic load. * - The dump takes a frame's captured name when resolve() refuses the frame. * - * Enabled with DDPROF_CAPTURE_UNLOADING=1 in lazy mode, so one build can be - * measured with and without it. + * - 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: @@ -64,6 +69,7 @@ class UnloadCapture { private: static bool _enabled; + static bool _gc_hook; }; #endif // _HOTSPOT_UNLOADCAPTURE_H diff --git a/doc/LazyJmethodIdPhase2Handover.md b/doc/LazyJmethodIdPhase2Handover.md index d842c9d019..56b913289e 100644 --- a/doc/LazyJmethodIdPhase2Handover.md +++ b/doc/LazyJmethodIdPhase2Handover.md @@ -2,7 +2,8 @@ > **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) for continuing on Linux. +> 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 @@ -11,31 +12,51 @@ Give names to lazy raw frames whose class is unloaded before the dump. The user' ## What the prototype does (`ddprof-lib/src/main/cpp/hotspot/unloadCapture.{h,cpp}`) -Enabled with `DDPROF_CAPTURE_UNLOADING=1` in `fjmethodid=lazy` mode; off otherwise, so one build -measures both. +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: 8,192 slots, 16 linear probes, duplicates skipped, overflow counted. - Permanent CLDs are learned in `registerClass()` (GetClassLoader + `isSystemClassLoader`). -- **`GarbageCollectionStart`** (`vmEntry.cpp` forwards to it and to ReferenceChainTracker): one - atomic load if nothing is pending. Otherwise, under `tryLock` of the cache: drains the set and, - for each pair not cached yet, `HotspotSupport::captureMethod()` = `readMethodHolder` + trace-ID - check + `containsMethod` + `readMethod`, all under crash protection, no JNI/JVMTI/locks. Copies - names and line table into a cache entry (malloc). Uses `initCurrentThreadSignalSafe()` (the - callback runs on the VM thread; `acquireCurrent()` returned null on macOS, pool exhausted). + (`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. -- Counters `unload_capture_*`: `pending_added`, `pending_overflow`, `hook_calls`, `hook_busy`, - `hook_ns`, `hook_max_ns`, `captured`, `refused`, `cache_full`, `used`. -- Known prototype gaps: no redefinition check at capture beyond `containsMethod`; slot drain can - race an insert into the same slot (loses that pair); the drain scans all 8,192 slots whenever - anything is pending; the cache is a std::unordered_map under a spinlock. - -## Results so far (macOS, release) - -Harnesses in `utils/phase2-proto/` (they lived in /tmp on the Mac). + 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. @@ -75,35 +96,129 @@ thousands of methods pile up between GCs and are captured in one burst. 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. **Background drain** (the proposed fix for bursts): a profiler thread drains the pending set - every ~100 ms *outside* any pause, resolving pairs the normal way (registry `pin()` + trace-ID - check + `containsMethod` + `readMethod`, as `resolveViaRegistry` does) into the same cache. A - name is then lost only if its class unloads within ~100 ms of the sample, instead of within the - dump interval. Keep the GC hook only for what is left (at most ~100 ms of new methods), or drop - it and measure what that costs in recovered names. Measure both. -2. Re-measure on Linux x64 with both harnesses: `Bench3` (lost / recovered, hook max) and - `GcBench2` (pause table), plus a low-GC-rate variant of `GcBench2` (e.g. `-Xmn512m`) to provoke - bursts. Acceptance: no visible change in p99/max pause vs `lazy`, and hook max well below a - young pause. -3. Count every resolution failure (add a counter where `readMethodHolder` fails), so "lost" is - measured directly. -4. ZGC: find out why `Bench3` unloads nothing (needs a major cycle with class unloading; try - `-XX:ZCollectionInterval` or more metaspace pressure), then measure there too. -5. Renaissance (`dotty`, `finagle-http`): `method_resolve_lazy_{resolved,unloaded,stale,not_current}` - and `unload_capture_used` at the normal dump interval, to see whether real workloads lose names. +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/x64/libjavaProfiler.so -cd utils/phase2-proto && javac -cp "$ASM:$CLS" -d /tmp/p2 Bench2.java Bench3.java GcBench2.java +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/Bench3.java b/utils/phase2-proto/Bench3.java index e5c2c2304d..a0f0110d2c 100644 --- a/utils/phase2-proto/Bench3.java +++ b/utils/phase2-proto/Bench3.java @@ -64,18 +64,24 @@ public static void main(String[] a) throws Exception { 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 skipped = c.getOrDefault("jmethodid_skipped_count", 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 lost_pct=%.1f max_dump_ms=%d %s%n", + + "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, - 100.0 * unloaded / Math.max(1, resolved + unloaded + stale + notCurrent), maxDumpMs, gcInfo); + 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(' '); 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/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/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