From 4c6c7d183d85f29c0172a817a572a118675b6867 Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Thu, 15 Oct 2026 11:00:27 +0200 Subject: [PATCH 01/31] 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 1f0846b2d0..10b68204e9 100644 --- a/ddprof-lib/src/main/cpp/counters.h +++ b/ddprof-lib/src/main/cpp/counters.h @@ -174,6 +174,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 89174665b22d08ae40a21cc7d661d984cf0c3d7a Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Thu, 15 Oct 2026 11:17:21 +0200 Subject: [PATCH 02/31] 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 10b68204e9..2a6ed9b938 100644 --- a/ddprof-lib/src/main/cpp/counters.h +++ b/ddprof-lib/src/main/cpp/counters.h @@ -182,6 +182,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 e5c432603c..7137ddd171 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 9c3ad33beb51e36924caa91aed94b8a3530cd2e6 Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Thu, 15 Oct 2026 11:32:13 +0200 Subject: [PATCH 03/31] 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 2a6ed9b938..3cb381ecea 100644 --- a/ddprof-lib/src/main/cpp/counters.h +++ b/ddprof-lib/src/main/cpp/counters.h @@ -190,6 +190,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 133d109f3999fb5f7d3209aee7bc9a903a3b401b Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Thu, 15 Oct 2026 12:44:15 +0200 Subject: [PATCH 04/31] 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 3cb381ecea..fda9100279 100644 --- a/ddprof-lib/src/main/cpp/counters.h +++ b/ddprof-lib/src/main/cpp/counters.h @@ -185,13 +185,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 1772384b2f0ba9615ba5e4aeb461de2d2ff93903 Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Thu, 15 Oct 2026 13:21:06 +0200 Subject: [PATCH 05/31] 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 b22c6f42e4..84453f0c0f 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/ExternalLauncher.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/ExternalLauncher.java @@ -32,6 +32,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 @@ -200,6 +203,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 feded39aa8971effe40bf405711ad9ccf5c8caaf Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Thu, 15 Oct 2026 14:23:47 +0200 Subject: [PATCH 06/31] 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 fda9100279..8e4e955d46 100644 --- a/ddprof-lib/src/main/cpp/counters.h +++ b/ddprof-lib/src/main/cpp/counters.h @@ -188,6 +188,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 6ccbfa402b524f1c24519aeeb9f33219a30248dd Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Thu, 15 Oct 2026 14:40:16 +0200 Subject: [PATCH 07/31] 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 50089d8d13d701fc472816e67be6a142053b6963 Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Thu, 15 Oct 2026 14:55:16 +0200 Subject: [PATCH 08/31] 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 8e4e955d46..f3a86f5702 100644 --- a/ddprof-lib/src/main/cpp/counters.h +++ b/ddprof-lib/src/main/cpp/counters.h @@ -196,6 +196,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 7137ddd171..7d01619b6e 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 f660e85c3b..7ecd34a03c 100644 --- a/ddprof-lib/src/main/cpp/flightRecorder.h +++ b/ddprof-lib/src/main/cpp/flightRecorder.h @@ -465,6 +465,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; @@ -548,6 +571,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 76292f47bc7c124cfa04a7df02cf27ffd7fcb9e8 Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Thu, 15 Oct 2026 15:55:12 +0200 Subject: [PATCH 09/31] 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 f4833eb03a0adefa61d0c99447dfd27cf2e419d6 Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Thu, 15 Oct 2026 16:20:00 +0200 Subject: [PATCH 10/31] 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 f3a86f5702..e36e585fc4 100644 --- a/ddprof-lib/src/main/cpp/counters.h +++ b/ddprof-lib/src/main/cpp/counters.h @@ -191,6 +191,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 85dd1ad496b809dd4ce9c5bc3a62f6acb65ae335 Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Thu, 15 Oct 2026 17:00:24 +0200 Subject: [PATCH 11/31] 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 e36e585fc4..4419e0f3e5 100644 --- a/ddprof-lib/src/main/cpp/counters.h +++ b/ddprof-lib/src/main/cpp/counters.h @@ -182,8 +182,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. */ \ @@ -205,8 +205,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 45c3dd9cb51a795b4cd13952d429ce0cf3c411c2 Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Thu, 15 Oct 2026 17:18:29 +0200 Subject: [PATCH 12/31] 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 f428e7bd206d578a514a7e02c84f4b5e40d8c3c0 Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Fri, 16 Oct 2026 11:01:36 +0200 Subject: [PATCH 13/31] 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 80940ecc711cb84495db329fead9e48550c6c00d Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Fri, 16 Oct 2026 11:38:42 +0200 Subject: [PATCH 14/31] 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 4419e0f3e5..54461fe788 100644 --- a/ddprof-lib/src/main/cpp/counters.h +++ b/ddprof-lib/src/main/cpp/counters.h @@ -170,18 +170,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 7d01619b6e..f3a878a94a 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 7ecd34a03c..933f13c080 100644 --- a/ddprof-lib/src/main/cpp/flightRecorder.h +++ b/ddprof-lib/src/main/cpp/flightRecorder.h @@ -104,14 +104,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(); }; @@ -137,14 +141,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) @@ -157,6 +162,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() {} @@ -177,6 +185,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. @@ -451,6 +472,7 @@ class Recording { }; class ResolveMethodState; +class ResolvedMethod; class Lookup { public: Recording *_rec; @@ -467,13 +489,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; @@ -484,8 +508,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; @@ -516,6 +540,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; } @@ -560,6 +592,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. @@ -571,9 +606,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 84453f0c0f..80df798a7d 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/ExternalLauncher.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/ExternalLauncher.java @@ -14,9 +14,18 @@ import java.lang.management.ManagementFactory; import java.lang.management.ThreadMXBean; import java.lang.reflect.Method; +import java.math.BigInteger; import java.nio.file.Paths; +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. @@ -61,6 +70,10 @@ * {@link ThreadLocalLeakScenario}: a growing {@code byte[]} collection held only through the * leaking thread's {@code ThreadLocal} map; the {@code -late} variant starts the leak thread * only after the recording began (see that scenario's own comment).

  • + *
  • 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 { @@ -164,6 +177,41 @@ private static String[] unpackScenarioArgs(String mode, String[] args) { return new String[] {packed.substring(0, sep), packed.substring(sep + "|||".length())}; } + /** 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 { @@ -213,6 +261,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 1a44b706ba94d77935bd35bcb16b6ebbb36e905e Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Fri, 16 Oct 2026 12:37:57 +0200 Subject: [PATCH 15/31] 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 fd9dfc5d67cb088c5cae41bb0dbd30d244fce7bc Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Fri, 16 Oct 2026 11:12:49 +0000 Subject: [PATCH 16/31] Run LazyRedefinitionStressTest on JDK 8 The test attaches its agent through com.sun.tools.attach.VirtualMachine, which on JDK 8 lives in the JDK's lib/tools.jar and is not on the test classpath, so the test was always skipped there. Load the attach API from tools.jar through a child class loader when it isn't on the classpath. Co-Authored-By: Claude Opus 5.5 --- .../cpu/LazyRedefinitionStressTest.java | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) 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 index 29d5580718..9fbb1a9d10 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyRedefinitionStressTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyRedefinitionStressTest.java @@ -19,6 +19,8 @@ import java.lang.instrument.Instrumentation; import java.lang.management.ManagementFactory; import java.lang.reflect.Method; +import java.net.URL; +import java.net.URLClassLoader; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -166,10 +168,8 @@ 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) { + Class vmClass = attachApi(); + if (vmClass == null) { return null; } Path jar = Files.createTempFile("lazy-redefinition-agent-", ".jar"); @@ -192,4 +192,26 @@ private static Instrumentation loadAgent() throws Exception { } return RetransformAgent.instrumentation; } + + /** + * Returns {@code com.sun.tools.attach.VirtualMachine}, or null if this JVM has no attach API. + * On JDK 9+ it comes from the jdk.attach module. On JDK 8 it lives in the JDK's + * {@code lib/tools.jar}, which isn't on the test classpath, so it is loaded from there with a + * child class loader; {@code AttachProvider} finds its Linux/macOS provider through the + * same loader. + */ + private static Class attachApi() throws Exception { + try { + return Class.forName("com.sun.tools.attach.VirtualMachine"); + } catch (ClassNotFoundException e) { + // JDK 8: java.home is /jre + Path toolsJar = Paths.get(System.getProperty("java.home"), "..", "lib", "tools.jar").normalize(); + if (!Files.isRegularFile(toolsJar)) { + return null; + } + ClassLoader loader = new URLClassLoader(new URL[] {toolsJar.toUri().toURL()}, + LazyRedefinitionStressTest.class.getClassLoader()); + return Class.forName("com.sun.tools.attach.VirtualMachine", true, loader); + } + } } From 35b44ee463674382fbc7504ffb3a4f51e9c5d926 Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Mon, 19 Oct 2026 12:58:33 +0200 Subject: [PATCH 17/31] Count lazy frames whose Method* can no longer be read A lazy raw frame whose Method* no longer leads to a holder Klass* and ConstMethod (its metadata freed after class unloading, or the read faulted) was refused without being counted by any method_resolve_lazy_* counter. Under class-loader churn this is most of the lost frames, so the loss was largely invisible. Count it as method_resolve_lazy_unreadable. Adds a gtest that clears a sampled method's ConstMethod and checks that only the new counter moves, and prints the counter in LazyJmethodIdTest's unload test. Environment: Datadog workspace Co-Authored-By: Claude Opus 5.5 --- ddprof-lib/src/main/cpp/counters.h | 5 +++ .../src/main/cpp/hotspot/hotspotSupport.cpp | 1 + .../src/test/cpp/hotspotLazyResolve_ut.cpp | 31 +++++++++++++++++++ .../profiler/cpu/LazyJmethodIdTest.java | 1 + 4 files changed, 38 insertions(+) diff --git a/ddprof-lib/src/main/cpp/counters.h b/ddprof-lib/src/main/cpp/counters.h index 54461fe788..e81cc8079d 100644 --- a/ddprof-lib/src/main/cpp/counters.h +++ b/ddprof-lib/src/main/cpp/counters.h @@ -191,6 +191,11 @@ /* 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") \ + /* UNREADABLE: the frame's Method* no longer leads to a holder Klass* and \ + * ConstMethod (the metadata was freed when its class was unloaded, or the \ + * read faulted), so no class could even be looked up; serializes as \ + * "unknown". Under class churn this is most of the frames lost. */ \ + X(METHOD_RESOLVE_LAZY_UNREADABLE, "method_resolve_lazy_unreadable") \ /* Registered classes marked by the redefinition hooks (once per class \ * per redefine/retransform call). */ \ X(KLASS_REGISTRY_REDEFINITIONS_MARKED, "klass_registry_redefinitions_marked") \ diff --git a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp index e4fdb69a1b..ed2341704f 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp @@ -1864,6 +1864,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); diff --git a/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp b/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp index fd62794dd2..c8b0da4b5f 100644 --- a/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp +++ b/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp @@ -684,6 +684,37 @@ TEST_F(HotspotLazyResolveTest, CountsClassesWithTraceIdBeyondTagRange) { #endif } +// A frame whose Method* metadata was freed after the sample (here: its +// ConstMethod pointer cleared) cannot even be traced to a class. It is +// refused and counted as unreadable, not as any of the registry outcomes. +TEST_F(HotspotLazyResolveTest, FrameWithFreedMethodIsCountedUnreadable) { +#ifndef COUNTERS + GTEST_SKIP() << "debug counters are not compiled into this build"; +#else + registerClass(_old_mirror); + ASGCT_CallFrame frame = sample(); + _jvm.collect(_old_mirror); + _cls.method.const_method = nullptr; + + const CounterId registry_outcomes[] = {METHOD_RESOLVE_LAZY_UNLOADED, METHOD_RESOLVE_LAZY_STALE, + METHOD_RESOLVE_LAZY_NOT_CURRENT, METHOD_RESOLVE_LAZY_REDEFINED, + METHOD_RESOLVE_LAZY_RESOLVED}; + long long before[5]; + for (int i = 0; i < 5; i++) { + before[i] = Counters::getCounter(registry_outcomes[i]); + } + long long unreadable = Counters::getCounter(METHOD_RESOLVE_LAZY_UNREADABLE); + + EXPECT_FALSE(resolve(frame)); + + EXPECT_EQ(unreadable + 1, Counters::getCounter(METHOD_RESOLVE_LAZY_UNREADABLE)); + for (int i = 0; i < 5; i++) { + EXPECT_EQ(before[i], Counters::getCounter(registry_outcomes[i])) << "counter " << registry_outcomes[i]; + } + _cls.method.const_method = &_cls.const_method; +#endif +} + // Lookup resolves each distinct raw method once per dump, however often it // recurs, and whatever bci or frame type the occurrences carry. TEST_F(HotspotLazyResolveTest, LookupResolvesEachRawMethodOncePerDump) { 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 5fc3229bba..ee0f1fab7b 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 @@ -129,6 +129,7 @@ public void framesOfUnloadedClassAreDroppedSafely() throws Exception { 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_lazy_unreadable=" + counters.get("method_resolve_lazy_unreadable") + ", method_resolve_fault_recovered=" + counters.get("method_resolve_fault_recovered")); } finally { try { Files.deleteIfExists(baseFile); } catch (IOException ignored) {} From ca557cbeeb4a0a7f94537a3166b1300abd2aa557 Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Mon, 19 Oct 2026 13:34:21 +0200 Subject: [PATCH 18/31] Retry trace-ID calibration on fresh array dimensions Calibration probed Void arrays of dimensions 1-6 once per process and kept a failure for good. If some of those dimensions already existed, their trace IDs were far apart from the new ones, no offset qualified, and fjmethodid=lazy stayed off for every later start in that JVM. calibrate() now makes up to four attempts, each on the six dimensions above the previous attempt's, so every attempt probes klasses it has just created. Only a found offset is kept; after a failure the next start tries again. Debug builds log which early exit was taken and dump the probes' words when no offset qualifies. LazyTraceIdCalibrationTest creates Void[] and Void[][] with a few hundred class definitions in between before starting lazy mode in a child JVM, and requires calibration to succeed. Co-Authored-By: Claude Opus 5.5 --- .../src/main/cpp/hotspot/hotspotSupport.cpp | 4 +- .../src/main/cpp/hotspot/klassTraceId.cpp | 99 +++++++++++++------ .../src/main/cpp/hotspot/klassTraceId.h | 16 +-- .../datadoghq/profiler/ExternalLauncher.java | 19 ++++ .../cpu/LazyTraceIdCalibrationTest.java | 64 ++++++++++++ 5 files changed, 166 insertions(+), 36 deletions(-) create mode 100644 ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyTraceIdCalibrationTest.java diff --git a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp index ed2341704f..0cfd0a8930 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp @@ -1484,8 +1484,8 @@ bool HotspotSupport::canResolveLazily() { 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 are reset on every profiler start, and a calibration found + // once is kept, 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"); return calibrated; diff --git a/ddprof-lib/src/main/cpp/hotspot/klassTraceId.cpp b/ddprof-lib/src/main/cpp/hotspot/klassTraceId.cpp index 1b51679045..c2c99fc58d 100644 --- a/ddprof-lib/src/main/cpp/hotspot/klassTraceId.cpp +++ b/ddprof-lib/src/main/cpp/hotspot/klassTraceId.cpp @@ -12,7 +12,6 @@ #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,45 +64,89 @@ int KlassTraceId::findOffset(const void* const* klasses, int count, size_t klass return found; } -bool KlassTraceId::calibrate(JNIEnv* jni) { - // Runs from profiler start, which is serialized, so a plain flag will do. - if (_calibrated) { - return offset() >= 0; - } - _calibrated = true; - size_t klass_size = (size_t)VMKlass::type_size(); - if (klass_size == 0 || !VMStructs::hasKlassMethods()) { - return false; +// Highest dimension any attempt has created so far; the next attempt probes +// the PROBE_COUNT dimensions above it. +static int _probed_dims = 0; + +// Dumps what each probe holds at every offset findOffset() looked at, so a +// release whose layout calibration rejects can be diagnosed from a debug CI +// log. +static void logProbes(const void* const* klasses, size_t klass_size) { +#ifdef DEBUG + for (size_t offset = 8; offset + sizeof(uint64_t) <= klass_size; offset += 8) { + uint64_t raw[KlassTraceId::PROBE_COUNT]; + for (int i = 0; i < KlassTraceId::PROBE_COUNT; i++) { + memcpy(&raw[i], (const char*)klasses[i] + offset, sizeof(raw[i])); + } + TEST_LOG("KlassTraceId probe @%3d: %016llx %016llx %016llx %016llx %016llx %016llx%s", (int)offset, + (unsigned long long)raw[0], (unsigned long long)raw[1], (unsigned long long)raw[2], + (unsigned long long)raw[3], (unsigned long long)raw[4], (unsigned long long)raw[5], + plausibleIds(klasses, KlassTraceId::PROBE_COUNT, offset) ? " plausible" : ""); } +#endif +} - // "[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. +// One attempt: makes the JVM create array klasses of java.lang.Void with +// dimensions first_dim .. first_dim + PROBE_COUNT - 1 and looks for the +// offset in them. Resolving the deepest one creates every missing dimension +// in order, each getting the next trace ID; java.lang.Void has no other use +// for such arrays. Earlier attempts (or other code) may have created lower +// dimensions already, so only the top PROBE_COUNT dimensions are probed, and +// those are new. Returns the offset, or -1. +static int probe(JNIEnv* jni, int first_dim, size_t klass_size) { 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 int top_dim = first_dim + KlassTraceId::PROBE_COUNT - 1; + char name[KlassTraceId::MAX_ATTEMPTS * KlassTraceId::PROBE_COUNT + sizeof(ELEMENT)]; + memset(name, '[', top_dim); + memcpy(name + top_dim, 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); + const void* klasses[KlassTraceId::PROBE_COUNT]; + for (int i = KlassTraceId::PROBE_COUNT - 1; i >= 0; i--) { + const int dims = first_dim + i; + jclass cls = jni->FindClass(name + (top_dim - dims)); if (cls == nullptr) { jni->ExceptionClear(); - return false; + TEST_LOG("KlassTraceId: FindClass failed for %d dimensions", dims); + return -1; } - klasses[dims - 1] = VMKlass::fromJavaClass(jni, cls); + klasses[i] = VMKlass::fromJavaClass(jni, cls); jni->DeleteLocalRef(cls); - if (klasses[dims - 1] == nullptr) { - return false; + if (klasses[i] == nullptr) { + TEST_LOG("KlassTraceId: no Klass* for %d dimensions", dims); + return -1; } } - int found = findOffset(klasses, PROBE_COUNT, klass_size); - TEST_LOG("KlassTraceId offset=%d (sizeof(Klass)=%d)", found, (int)klass_size); + int found = KlassTraceId::findOffset(klasses, KlassTraceId::PROBE_COUNT, klass_size); + TEST_LOG("KlassTraceId offset=%d (sizeof(Klass)=%d, dimensions %d-%d)", found, (int)klass_size, + first_dim, top_dim); if (found < 0) { + logProbes(klasses, klass_size); + } + return found; +} + +bool KlassTraceId::calibrate(JNIEnv* jni) { + // Runs from profiler start, which is serialized, so plain statics will do. + if (offset() >= 0) { + return true; + } + size_t klass_size = (size_t)VMKlass::type_size(); + if (klass_size == 0 || !VMStructs::hasKlassMethods()) { + TEST_LOG("KlassTraceId: no Klass layout from vmStructs"); return false; } - __atomic_store_n(&_offset, found, __ATOMIC_RELEASE); - return true; + // A failed attempt usually means the probes' IDs were not consecutive: + // other threads created many classes meanwhile, or some of the dimensions + // already existed. Fresh dimensions make either unlikely to repeat. + while (_probed_dims + PROBE_COUNT <= MAX_ATTEMPTS * PROBE_COUNT) { + const int first_dim = _probed_dims + 1; + _probed_dims += PROBE_COUNT; + int found = probe(jni, first_dim, klass_size); + if (found >= 0) { + __atomic_store_n(&_offset, found, __ATOMIC_RELEASE); + return true; + } + } + return false; } diff --git a/ddprof-lib/src/main/cpp/hotspot/klassTraceId.h b/ddprof-lib/src/main/cpp/hotspot/klassTraceId.h index 529557400c..8a92f0bd7e 100644 --- a/ddprof-lib/src/main/cpp/hotspot/klassTraceId.h +++ b/ddprof-lib/src/main/cpp/hotspot/klassTraceId.h @@ -29,15 +29,18 @@ * 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. + * offset unknown, and fjmethodid=lazy does not run. */ class KlassTraceId { static int _offset; - static bool _calibrated; public: - // Array dimensions created by calibrate(): one probe klass per dimension. + // Array dimensions created by one calibration attempt: one probe klass per + // dimension. static const int PROBE_COUNT = 6; + // Attempts calibrate() makes, each on PROBE_COUNT dimensions above the + // previous attempt's, so each probes klasses it has just created. + static const int MAX_ATTEMPTS = 4; // 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; @@ -51,9 +54,10 @@ 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. Only - * the first call per process calibrates; later calls return its result. - * Returns true if an offset is known. + * Creates the probe klasses through JNI and records the offset found. + * Once an offset is found, later calls return true without probing again; + * after a failure, the next call tries again on fresh dimensions. Returns + * true if an offset is known. */ static bool calibrate(JNIEnv* jni); 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 80df798a7d..a9f24e6d8c 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/ExternalLauncher.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/ExternalLauncher.java @@ -70,6 +70,10 @@ * {@link ThreadLocalLeakScenario}: a growing {@code byte[]} collection held only through the * leaking thread's {@code ThreadLocal} map; the {@code -late} variant starts the leak thread * only after the recording began (see that scenario's own comment). + *
  • lazy-calibration-preexisting-arrays <comma delimited profiler command list, required> - + * creates {@code Void[]} and {@code Void[][]} with a few hundred class definitions in between, so their + * JFR trace IDs are far apart, then starts the profiler, prints the {@code klass_trace_id_offset} + * counter and stops it; see {@code LazyTraceIdCalibrationTest}
  • *
  • 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 @@ -261,6 +265,21 @@ 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("lazy-calibration-preexisting-arrays")) { + if (args.length < 2 || args[1].isEmpty()) { + throw new IllegalArgumentException("lazy-calibration-preexisting-arrays requires a profiler command list"); + } + Object lowDimension = java.lang.reflect.Array.newInstance(Void.class, 0); + for (int i = 0; i < 200; i++) { + SpinClasses.define("com/datadoghq/profiler/generated/CalibrationGap" + i); + } + Object secondDimension = java.lang.reflect.Array.newInstance(lowDimension.getClass(), 0); + JavaProfiler instance = JavaProfiler.getInstance(); + instance.execute(args[1]); + System.out.println("[calibration] klass_trace_id_offset=" + + instance.getDebugCounters().get("klass_trace_id_offset") + + " arrays=" + secondDimension.getClass().getName()); + 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"); diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyTraceIdCalibrationTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyTraceIdCalibrationTest.java new file mode 100644 index 0000000000..b6f73fa4e9 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyTraceIdCalibrationTest.java @@ -0,0 +1,64 @@ +/* + * 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.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * {@code fjmethodid=lazy} must find {@code Klass::_trace_id} even when a test JVM, or an + * application, already created some of the array classes calibration probes. + * + *

    Calibration makes the JVM create {@code Void} arrays of several dimensions back to back and + * looks for the offset at which their JFR trace IDs are nearly consecutive. A child JVM first + * creates {@code Void[]} and {@code Void[][]} with a few hundred class definitions in between, so + * those two IDs are far apart and an attempt that includes them cannot succeed. Calibration must + * still find the offset by probing dimensions it creates itself. + */ +public class LazyTraceIdCalibrationTest extends AbstractProcessProfilerTest { + private static final String PREFIX = "[calibration] klass_trace_id_offset="; + + @Test + @Timeout(60) + public void calibratesWhenLowProbeDimensionsAlreadyExist() throws Exception { + Assumptions.assumeFalse(Platform.isZing() || Platform.isJ9() || Platform.isGraal(), "HotSpot only"); + AtomicLong offset = new AtomicLong(Long.MIN_VALUE); + Path recording = Files.createTempFile("lazy-calibration-", ".jfr"); + LaunchResult result; + try { + result = launch("lazy-calibration-preexisting-arrays", Collections.emptyList(), + "start,cpu=10ms,cstack=vm,fjmethodid=lazy,jfr,file=" + recording, line -> { + if (line.startsWith(PREFIX)) { + String value = line.substring(PREFIX.length()).split(" ")[0]; + if (!"null".equals(value)) { + offset.set(Long.parseLong(value)); + } + } + return LineConsumerResult.CONTINUE; + }, null); + } finally { + try { Files.deleteIfExists(recording); } catch (IOException ignored) {} + } + assertTrue(result.inTime, "child JVM did not finish in time"); + assertEquals(0, result.exitCode, "child JVM failed"); + Assumptions.assumeTrue(offset.get() != Long.MIN_VALUE, "build without debug counters"); + // Without JFR (e.g. 8u before 8u262) there is no trace ID to find. + Assumptions.assumeTrue(Platform.hasJfr(), "JDK without JFR"); + assertTrue(offset.get() > 0, "trace-ID calibration failed: klass_trace_id_offset=" + offset.get()); + } +} From 9c8acd3b86e3905cb1d3996b5492fea65a960824 Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Mon, 19 Oct 2026 14:43:17 +0200 Subject: [PATCH 19/31] Fail on unparsable child-JVM output instead of skipping LazyLineNumberConsistencyTest and LazyTraceIdCalibrationTest parse a counter from the child JVM's output in the line consumer. A malformed line threw NumberFormatException from the stdout reader and left the value unset, which the tests' assumptions then turned into a skip. Keep the line and fail on it after the child exits. The line-number workload no longer parses the regex's digits; it merges their length, which is the same regex and map work. Co-Authored-By: Claude Opus 5.5 --- .../java/com/datadoghq/profiler/ExternalLauncher.java | 2 +- .../profiler/cpu/LazyLineNumberConsistencyTest.java | 11 ++++++++++- .../profiler/cpu/LazyTraceIdCalibrationTest.java | 11 ++++++++++- 3 files changed, 21 insertions(+), 3 deletions(-) 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 a9f24e6d8c..22350b83f0 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/ExternalLauncher.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/ExternalLauncher.java @@ -204,7 +204,7 @@ private static void runLineNumberWorkload() { 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); + counts.merge(m.group(1), m.group(2).length(), Integer::sum); } sorted.put(word.hashCode(), word); words.add(word.toUpperCase()); 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 index b27f894014..a3b0ed567d 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyLineNumberConsistencyTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyLineNumberConsistencyTest.java @@ -22,8 +22,10 @@ import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -52,6 +54,7 @@ public void lazyLineNumbersMatchJvmti() throws Exception { Path jvmtiRecording = Files.createTempFile("lazy-lines-jvmti-", ".jfr"); try { AtomicLong lazyResolved = new AtomicLong(-1); + AtomicReference malformed = new AtomicReference<>(); LaunchResult result = launch("line-numbers", Collections.emptyList(), "start," + LAZY + ",jfr,file=" + lazyRecording + ";start," + JVMTI + ",jfr,file=" + jvmtiRecording, line -> { @@ -59,12 +62,18 @@ public void lazyLineNumbersMatchJvmti() throws Exception { // 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)); + try { + lazyResolved.set("null".equals(value) ? 0 : Long.parseLong(value)); + } catch (NumberFormatException e) { + // Reported below; an unset count would only skip the test. + malformed.compareAndSet(null, line); + } } return LineConsumerResult.CONTINUE; }, null); assertTrue(result.inTime, "child JVM did not finish in time"); assertEquals(0, result.exitCode, "child JVM failed"); + assertNull(malformed.get(), "unparsable counter line from the child JVM"); // 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"); diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyTraceIdCalibrationTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyTraceIdCalibrationTest.java index b6f73fa4e9..df4c874dc4 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyTraceIdCalibrationTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyTraceIdCalibrationTest.java @@ -16,8 +16,10 @@ import java.nio.file.Path; import java.util.Collections; import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -38,6 +40,7 @@ public class LazyTraceIdCalibrationTest extends AbstractProcessProfilerTest { public void calibratesWhenLowProbeDimensionsAlreadyExist() throws Exception { Assumptions.assumeFalse(Platform.isZing() || Platform.isJ9() || Platform.isGraal(), "HotSpot only"); AtomicLong offset = new AtomicLong(Long.MIN_VALUE); + AtomicReference malformed = new AtomicReference<>(); Path recording = Files.createTempFile("lazy-calibration-", ".jfr"); LaunchResult result; try { @@ -46,7 +49,12 @@ public void calibratesWhenLowProbeDimensionsAlreadyExist() throws Exception { if (line.startsWith(PREFIX)) { String value = line.substring(PREFIX.length()).split(" ")[0]; if (!"null".equals(value)) { - offset.set(Long.parseLong(value)); + try { + offset.set(Long.parseLong(value)); + } catch (NumberFormatException e) { + // Reported below; an unset offset would only skip the test. + malformed.compareAndSet(null, line); + } } } return LineConsumerResult.CONTINUE; @@ -56,6 +64,7 @@ public void calibratesWhenLowProbeDimensionsAlreadyExist() throws Exception { } assertTrue(result.inTime, "child JVM did not finish in time"); assertEquals(0, result.exitCode, "child JVM failed"); + assertNull(malformed.get(), "unparsable offset line from the child JVM"); Assumptions.assumeTrue(offset.get() != Long.MIN_VALUE, "build without debug counters"); // Without JFR (e.g. 8u before 8u262) there is no trace ID to find. Assumptions.assumeTrue(Platform.hasJfr(), "JDK without JFR"); From b13bc80239eb1ba7abf745cbf89df13bda225fa0 Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Mon, 19 Oct 2026 14:59:04 +0200 Subject: [PATCH 20/31] Name hidden classes as JVMTI does when resolving raw frames HotSpot names a hidden class "+0x", and JVMTI GetClassSignature() reports it with that last '+' turned into '.'. Raw frames copied Klass::_name as is, so a lambda's frames were named differently with and without jmethodIDs, and one class could appear under both names in a chunk. readMethod() now applies the same rename; the hidden flag is not in vmStructs on JDK 21+, so the "+0x" suffix identifies the class. Also correct which frames reach the partial-mode path in resolve(). Co-Authored-By: Claude Opus 5.5 --- .../src/main/cpp/hotspot/hotspotSupport.cpp | 30 +++++++++++++++---- .../src/main/cpp/hotspot/hotspotSupport.h | 8 +++++ .../src/test/cpp/hotspotLazyResolve_ut.cpp | 22 ++++++++++++++ .../cpu/LazyLineNumberConsistencyTest.java | 16 +++++++--- 4 files changed, 66 insertions(+), 10 deletions(-) diff --git a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp index 0cfd0a8930..0ee9c2a62e 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +#include #include #include #include "asyncSampleMutex.h" @@ -1628,6 +1629,20 @@ bool ResolvedMethod::setKlassName(VMSymbol* sym) { return setName(_klass_name, _long_klass_name, MAX_KLASS_NAME_LEN, sym); } +void ResolvedMethod::useHiddenClassSignatureName() { + char* name = _long_klass_name != nullptr ? _long_klass_name : _klass_name; + char* plus = strrchr(name, '+'); + if (plus == nullptr || plus[1] != '0' || plus[2] != 'x' || plus[3] == '\0') { + return; + } + for (const char* c = plus + 3; *c != '\0'; c++) { + if (!isxdigit((unsigned char)*c)) { + return; + } + } + *plus = '.'; +} + // 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). @@ -1744,6 +1759,7 @@ bool HotspotSupport::readMethod(const void* method, ResolvedMethod* result) { Counters::increment(METHOD_RESOLVE_SYMBOL_UNREADABLE); return false; } + result->useHiddenClassSignatureName(); if (strcmp(result->methodName(), "run") == 0 && strcmp(result->methodSignature(), "()V") == 0) { result->_is_thread_run = isThreadSubclass(klass); @@ -1821,9 +1837,11 @@ static bool protectedWalk(ProfiledThread* prof_thread, ResolvedMethod& result, W return ok; } -// Partial mode only reaches here for system-class-loader classes, which are -// never unloaded; lazy mode reaches here for every class without a jmethodID -// and resolves it through _klass_registry. +// Partial mode reaches here for classes it did not preload (those of the +// boot, platform and application loaders, which are never unloaded) and for +// methods a class's jmethodID cache does not cover after a redefinition; +// 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); @@ -1854,9 +1872,9 @@ bool HotspotSupport::resolve(const void* tagged_method, int bci, ResolvedMethod& } 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. + // A redefinition can free a replaced Method* and reuse its memory, and + // partial mode has no registry to 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; diff --git a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h index 480c4883a3..cfd354525b 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h @@ -82,6 +82,14 @@ class ResolvedMethod { bool setMethodSignature(VMSymbol* sym); bool setKlassName(VMSymbol* sym); + // Gives a hidden class the name JVMTI GetClassSignature() reports for it, + // so frames are named the same in every fjmethodid mode. HotSpot names a + // hidden class "+0x" (ClassFileParser::mangle_hidden_class_name) + // and InstanceKlass::signature_name() turns that last '+' into '.'. The + // hidden flag itself is not in vmStructs on JDK 21+, so the suffix is what + // identifies a hidden class here. + void useHiddenClassSignatureName(); + // Internal (slash-separated) class name, e.g. "java/lang/Thread". const char* klassName() const { return _long_klass_name != nullptr ? _long_klass_name : _klass_name; diff --git a/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp b/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp index c8b0da4b5f..e1ddb890cd 100644 --- a/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp +++ b/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp @@ -459,6 +459,28 @@ class HotspotLazyResolveTest : public ::testing::Test { } }; +// A hidden class gets the name JVMTI GetClassSignature() reports, so its +// frames are named the same with and without jmethodIDs; other names that +// merely contain a '+' are left alone. +TEST_F(HotspotLazyResolveTest, HiddenClassNamedAsJvmtiSignature) { + registerClass(_old_mirror); + const struct { const char* vm_name; const char* expected; } cases[] = { + {"a/Spin$$Lambda+0x8010a0", "a/Spin$$Lambda.0x8010a0"}, + {"a/B+0x1+0x2f", "a/B+0x1.0x2f"}, + {"a/Plus+Sign", "a/Plus+Sign"}, + {"a/Spin+0x", "a/Spin+0x"}, + {"a/Spin+0x12g", "a/Spin+0x12g"}, + }; + for (const auto& c : cases) { + SCOPED_TRACE(c.vm_name); + FakeClass::setSymbol(_cls.klass_name, c.vm_name); + ASGCT_CallFrame frame = sample(); + ResolvedMethod result; + ASSERT_TRUE(HotspotSupport::resolve(frame.method, frame.bci, result)); + EXPECT_STREQ(c.expected, result.klassName()); + } +} + // The names come from VM metadata: no jmethodIDs are allocated. TEST_F(HotspotLazyResolveTest, LiveClassResolves) { registerClass(_old_mirror); 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 index a3b0ed567d..24ec5469af 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyLineNumberConsistencyTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyLineNumberConsistencyTest.java @@ -78,8 +78,12 @@ public void lazyLineNumbersMatchJvmti() throws Exception { // 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 vmHiddenNames = new ArrayList<>(); + Map lazyLines = linesByFrame(lazyRecording, vmHiddenNames); + Map jvmtiLines = linesByFrame(jvmtiRecording, vmHiddenNames); + // JVMTI reports a hidden class "Foo$$Lambda+0x…" as "Foo$$Lambda.0x…", and lazy + // mode must do the same, or a lambda's frames are named differently per mode. + assertTrue(vmHiddenNames.isEmpty(), "hidden classes not named as JVMTI does: " + vmHiddenNames); List mismatches = new ArrayList<>(); int common = 0; for (Map.Entry e : lazyLines.entrySet()) { @@ -105,9 +109,10 @@ public void lazyLineNumbersMatchJvmti() throws Exception { /** * 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. + * one side failed to decode shows up as a mismatch. Adds class names still in HotSpot's + * internal hidden-class form ({@code +0x} suffix) to {@code vmHiddenNames}. */ - private static Map linesByFrame(Path recording) throws Exception { + private static Map linesByFrame(Path recording, List vmHiddenNames) throws Exception { Map lines = new HashMap<>(); JfrEvents.forEach(recording, "datadog.ExecutionSample"::equals, event -> { if (!event.has("stackTrace")) { @@ -117,6 +122,9 @@ private static Map linesByFrame(Path recording) throws Exceptio if (frame.className() == null || frame.bytecodeIndex() < 0) { continue; } + if (frame.className().contains("+0x") && !vmHiddenNames.contains(frame.className())) { + vmHiddenNames.add(frame.className()); + } lines.put(frame.className() + "." + frame.methodName() + frame.methodDescriptor() + " @" + frame.bytecodeIndex(), frame.lineNumber()); } From f81edc2f64242603686137e1e9397b9bfa0c740e Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Mon, 19 Oct 2026 14:59:05 +0200 Subject: [PATCH 21/31] Remove the working plan document Co-Authored-By: Zhengyu Gu Co-Authored-By: Claude Opus 5.5 --- doc/LazyJmethodIdCombinedPlan.md | 271 ------------------------------- 1 file changed, 271 deletions(-) delete mode 100644 doc/LazyJmethodIdCombinedPlan.md diff --git a/doc/LazyJmethodIdCombinedPlan.md b/doc/LazyJmethodIdCombinedPlan.md deleted file mode 100644 index 0077dd547c..0000000000 --- a/doc/LazyJmethodIdCombinedPlan.md +++ /dev/null @@ -1,271 +0,0 @@ -# Combining #864 and #867: plan - -> **Working notes. Remove before the PR lands.** -> Branch: `investigate/lazy-jmethodid-vm-resolution`, off #864's `investigate/lazy-jmethodid-resolution`. -> Co-authors: Roman Kennke, Zhengyu Gu (#867). Started 2026-10-16. - -Keep #864's safety model (class registry, trace-ID frame tags, redefinition marks) and add #867's -two strong ideas: resolving frames from VM memory, and keeping names of classes that get unloaded. -Leave out #867's one-chunk-per-GC writing. - -## Goal - -- Memory savings for **all** classes, ideally with **no jmethodIDs at all** for raw frames, not even - for sampled classes. -- No extra GC-pause time and no recording-size growth. -- Names even for classes unloaded before the dump (`method_resolve_lazy_unloaded` close to 0). -- No JNI/JVMTI when naming raw frames. - -## What to take from #867, and what to leave - -| Take | Leave out | -|---|---| -| `ResolvedMethod` / `readMethod()`: names, signatures, Thread.run detection from VM memory | One JFR chunk per GC (pause and size cost) | -| Line-number decoding (`VMLineNumberStream`, `compressedLineNumberTable()`, `codeSize()`) | Rotating dictionaries inside the GC callback (likely cause of the mixed-up names) | -| `fillJavaMethodNames()` / `fillJavaMethodInfo(mi, ResolvedMethod)` refactor | Flipping the default without an opt-in (separate rollout decision) | -| `ContextClassLoaderUnloadTest`, plus its gtest additions where they still apply | The code-coverage commits and `.bak` files (their own PR) | -| The "capture names before classes unload" idea, made incremental and file-free | | - -## Phase 1: resolve raw frames from VM memory instead of allocating jmethodIDs - -1. Bring over #867's `ResolvedMethod`, `readMethod()`, `VMLineNumberStream`, the new vmStructs - fields (`_constmethod_code_size`, `_constmethod_flags`, `_klass_super`, the version-dependent - "has line-number table" constant) and `fillJavaMethodNames()`. Zhengyu as author or co-author. -2. `resolveViaRegistry()`: after `pin()`, the trace-ID check and `containsMethod()`, call - `readMethod()` under crash protection while the class is still pinned. Return a `ResolvedMethod` - instead of a jmethodID. Drop the dump-time `GetClassMethods`. -3. Key raw frames in Lookup's method table by (untagged `Method*`, trace-ID tag), so resolved rows - can be reused across dumps without being mistaken for a later class at the same address. The - per-dump cache (`_raw_method_cache`) becomes unnecessary or holds the row. Open: whether rows - reused across dumps need a redefinition check through the registry mark (probably yes). -4. Partial mode (`fjmethodid=false`, and lazy's fallback without JFR): replace the `FindClass` - lookup with `readMethod()`. It keeps today's redefinition gap for system-loader classes; note it, - or cover it with the registry marks. -5. Bonus: lazy mode no longer needs dereferenceable jmethodIDs, so it could run on JDK 26+. Check - that nothing else in its path depends on `_can_dereference_jmethod_id`. -6. Validation: sweep the OpenJDK GA tags for what `readMethod()` and the line decoding rely on (the - compressed line-table format and its UNSIGNED5 change in JDK 20, the "has line-number table" - flag that moved between 20 and 21, `Klass::_super`); compare decoded line tables with JVMTI - `GetLineNumberTable` on real JVMs in a CI test; rerun NMT (expected: jmethodID memory ≈ 0 even - for sampled classes). - -### Phase 1 status (2026-10-16) - -- Code: commit "Resolve raw frames from VM metadata instead of allocating jmethodIDs". -- Steps 1, 2, 4 done; step 3 settled: refused frames never touch MethodMap rows, rows are keyed by - (untagged `Method*`, low 13 tag bits, `RAW_METHOD_MARK`) and refilled at first use in every chunk, - so no redefinition re-check is needed for reused rows. Step 5 (JDK 26+) open. -- Line-table bound is HotSpot's own (`bci <= code_size`, `ConstMethod::verify_on()`), not #867's `<`. -- Sweep: `_flags`/`_code_size`/`Klass::_super` and the has-line-number-table constant checked on - JDK 11-26 GA tags; 8u via containers. -- `LazyLineNumberConsistencyTest` (VM vs JVMTI line tables): 0 mismatches on macOS JDK 25 and in - every aarch64 container cell (glibc 17/21/25 debug, 21 ASan; musl 8/11/21 debug), 249-389 common - frames each. All `*Lazy*` tests pass in those cells; JFR trace ID matches in every debug cell. - glibc 8/11 are not runnable on aarch64 (skipped as in CI). gtests: debug + release pass on macOS, - debug in every container cell. -- NMT (JDK 25, debug, 1M methods, KB committed): sampled run with ~9.3k raw frames resolved keeps - Class at the no-profiler level (+14,234 vs +14,236; preloading +22,982). Compared against the - shipping modes and no profiler only; #864 is superseded. Dump +~10 ms vs `fjmethodid=true`. Harness: - `/tmp/jmbench/Bench2.java`. - -## Phase 2: keep names of classes that unload before the dump, cheaply - -1. Capture only methods whose frames are still unresolved **and** whose class can be unloaded. - Record "unloadable" in the registry entry at registration, from the class loader (boot, - platform and application loaders never unload). -2. Know what's pending without scanning every trace during a pause. Prototype and measure both: - (a) a bounded lock-free "first seen" set the sampler fills and the hook drains; (b) walking only - the traces created since the last capture (trace IDs grow monotonically within a storage - instance). -3. In `GarbageCollectionStart` (VM stopped), copy names and line tables with `readMethod()` into a - pre-resolved cache keyed by (`Method*`, tag). No JNI, no JVMTI, no file writes, no dictionary - rotation. Incremental: only methods first seen since the last GC. -4. Skip when nothing unloadable is pending; also skip young-only pauses if they can be told apart - (open question: GC cause through VM state?). -5. Do the same capture in the pre-redefinition hook for that class's pending frames, so frames - refused after a redefinition keep their names too. -6. At dump time, when `pin()` reports the class gone, use the pre-resolved name instead of - "unknown"; drop cache entries once written. -7. Validation: the GC-pause benchmark from the #867 review must show safepoint times unchanged - compared with `fjmethodid=true`, recording size unchanged, and `ContextClassLoaderUnloadTest` - must find names for frames of unloaded classes. - -## Tests - -- Everything in #864. -- #867's class-unload test. -- A JVMTI-against-VM line-table comparison test. -- The GC-pause and recording-size benchmark as a nightly or slow test, with thresholds. -- `LazyRedefinitionStressTest` extended to check that frames refused after a redefinition still - have names (phase 2, step 5). - -## Order and PR structure - -1. **One PR for #864 + phase 1** (this branch), superseding #864 and #867, co-authored with - Zhengyu. Phase 1 rewrites #864's dump path (no dump-time jmethodID allocation), so #864 does not - land on its own. The PR description says what changed since #864's two review rounds. Needs - Zhengyu's agreement first, via the (unposted) #867 review. -2. **Phase 2 as a separate follow-up PR**: names for unloaded classes. It is additive (without it - those frames stay "unknown", as in #864), the riskiest part (GC-callback work, where #867's - pause cost came from), and conditional on the GC-pause benchmark. Zhengyu might want to lead it. -3. Default flip after a nightly soak, watching the counters. - -## Risks and open questions - -- More parsing of HotSpot internals for names and line tables; the phase 1 sweep is meant to - contain that. -- Pending-frame tracking in phase 2 must keep signal-handler work and memory bounded. -- Whether young pauses can be told apart without GC-specific internals. -- Whether partial mode gets the registry's redefinition marks, or stays as today. - -## Hand-over to the Linux box (x64) - -State at hand-over (2026-10-16): phase 1 complete on this branch, validated on macOS arm64 and in -aarch64 containers (see "Phase 1 status"). Draft PR open against `main`; it carries #864's commits -and will replace #864 (and #867's core), pending agreement with Zhengyu. The #867 review is still -unposted. - -To do on x64, in this order: - -1. **Tests.** `./utils/run-containers-tests.sh --arch=x64 --tests='*.Lazy*' --gtest` for glibc - 8/11/17/21/25 debug, glibc 21 ASan, musl 21. On musl the test runner takes a regex: - `--tests='com.datadoghq.profiler.cpu.Lazy.*'`. Watch `LazyLineNumberConsistencyTest`'s - `mismatches=` line and `LazyTraceIdJfrValidationTest`'s `profiler trace id=` line. -2. **JDK 26+ (phase 1 step 5).** No container image has 26. Lazy mode is gated only on - `VMStructs::hasKlassMethods()` and trace-ID calibration, not on `_can_dereference_jmethod_id` - (false on > 25), so it may already run; check that walkVM's jmethodID lookup and the redefinition - preload behave with JDK-8268406's `JmethodIDTable`. Either make it work or state it is out of - scope in the PR. -3. **Renaissance, same setup as #864's tables** (release build, `cpu=10ms,cstack=vm`, `-Xms=-Xmx`, - 5 fresh JVMs per mode, modes interleaved, first dump after 10 s; `dotty`, `finagle-http`, `als`; - JDK 11/21/25), `fjmethodid=true` vs `lazy`: - - NMT Class + Internal over no profiler. Expected: lazy close to its own registry cost, since - sampled classes no longer get jmethodIDs (#864 saved only 35-55% here). - - Dump latency, first and later dumps. #864's first lazy dump was 2-4x `true` (dump-time - jmethodID allocation, now gone). New risk: later dumps resolve raw frames every time instead - of matching `true`. On macOS (debug, 1,600 generated classes) lazy was 18.9 / ~16 ms vs `true` - 15.2 / ~13.4 ms (first / later). If `finagle-http` later dumps are clearly slower, skip - `readMethod()` for rows filled in an earlier chunk while the class pins with the same trace ID - and is not redefined. - - Counter renamed: `method_resolve_lazy_class_loaded` -> `method_resolve_lazy_resolved` (raw - frames resolved per dump); use it to confirm lazy mode is active. -4. **Synthetic NMT**, optional: the harness below, both variants, on x64. - -Then: review pass over the phase 1 code (VM reader, line tables, MethodMap raw keys, partial mode), -rewrite the PR description from #864's (step 3 of "How it works", overhead table, counters, new -tests, changes since #864's reviews), remove this file, and ask Zhengyu before un-drafting. - -

    NMT harness (Bench2.java, was /tmp/jmbench on the Mac) - -Needs ASM 9 and the profiler's Java classes on the classpath. Run with -`-XX:NativeMemoryTracking=summary`: -`java -XX:NativeMemoryTracking=summary -cp asm.jar::. Bench2 20000 `. -Results on macOS: `load` lazy Class +14,233 KB / Internal +191 KB vs `true` +22,981 / +8,136; -`sample` lazy +14,234 / +471 vs `true` +22,982 / +8,409, with ~9.3k raw frames resolved. - -```java -import com.datadoghq.profiler.JavaProfiler; -import org.objectweb.asm.*; -import java.lang.management.ManagementFactory; -import javax.management.ObjectName; -import java.nio.file.*; -import java.util.*; - -// Bench2 -// load: like Bench (load-only, no profiled calls into the generated classes). -// sample: additionally calls every generated class round-robin for SAMPLE_MS under the profiler, -// then dumps (which is when lazy resolution happens) before measuring NMT. -public class Bench2 { - static int METHODS = 50; - static long SAMPLE_MS = 8000; - static String nmt(String arg) throws Exception { - return (String) ManagementFactory.getPlatformMBeanServer().invoke( - new ObjectName("com.sun.management:type=DiagnosticCommand"), "vmNativeMemory", - new Object[]{new String[]{arg}}, new String[]{String[].class.getName()}); - } - public static void main(String[] a) throws Exception { - int n = Integer.parseInt(a[0]); - String mode = a[1]; - boolean sample = a[2].equals("sample"); - JavaProfiler prof = null; - if (!mode.equals("none")) { - prof = JavaProfiler.getInstance(a[3], null); - prof.execute("start,cpu=1ms,cstack=vm,fjmethodid=" + mode + ",jfr,file=/tmp/jmbench/b2-" + mode + ".jfr"); - } - load(2000, "W"); System.gc(); Thread.sleep(200); - nmt("baseline"); - long t0 = System.nanoTime(); - List keep = load(n, "B"); - System.out.println("load_ms=" + (System.nanoTime() - t0) / 1_000_000); - if (sample) { - long end = System.currentTimeMillis() + SAMPLE_MS; - while (System.currentTimeMillis() < end) { - for (Runnable r : keep) r.run(); - } - if (prof != null) { - long d0 = System.nanoTime(); - prof.dump(Paths.get("/tmp/jmbench/b2-" + mode + "-dump.jfr")); - System.out.println("dump_ms=" + (System.nanoTime() - d0) / 1_000_000); - Map c = prof.getDebugCounters(); - System.out.println("resolved=" + c.get("method_resolve_lazy_resolved")); - } - } - java.util.regex.Pattern p = java.util.regex.Pattern.compile("^-\\s+(.+?) \\(reserved=.*?committed=\\d+KB(?: ([+-]\\d+)KB)?"); - for (String line : nmt("summary.diff").split("\n")) { - java.util.regex.Matcher m = p.matcher(line); - if (m.find()) { - System.out.println("CAT " + m.group(1).trim() + "=" + (m.group(2) == null ? "0" : m.group(2))); - } else if (line.trim().startsWith("Total:")) { - java.util.regex.Matcher t = java.util.regex.Pattern.compile("committed=\\d+KB ([+-]\\d+)KB").matcher(line); - if (t.find()) System.out.println("CAT Total=" + t.group(1)); - } - } - System.out.println(keep.size()); - } - static List load(int n, String p) throws Exception { - List out = new ArrayList<>(); - L loader = null; - for (int i = 0; i < n; i++) { - if (i % 100 == 0) loader = new L(); - String name = "gen/" + p + i; - Class c = loader.def(name.replace('/', '.'), gen(name)); - out.add((Runnable) c.getConstructor().newInstance()); - } - return out; - } - static byte[] gen(String name) { - ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS); - cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, name, null, "java/lang/Object", new String[]{"java/lang/Runnable"}); - MethodVisitor ctor = cw.visitMethod(Opcodes.ACC_PUBLIC, "", "()V", null, null); - ctor.visitCode(); ctor.visitVarInsn(Opcodes.ALOAD, 0); - ctor.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "", "()V", false); - ctor.visitInsn(Opcodes.RETURN); ctor.visitMaxs(0, 0); ctor.visitEnd(); - for (int i = 0; i < METHODS; i++) { - MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "m" + i, "()I", null, null); - mv.visitCode(); mv.visitLdcInsn(i); mv.visitInsn(Opcodes.IRETURN); mv.visitMaxs(0, 0); mv.visitEnd(); - } - // run(): sum of all m_i, so every method of the class can appear in samples. - MethodVisitor run = cw.visitMethod(Opcodes.ACC_PUBLIC, "run", "()V", null, null); - run.visitCode(); run.visitInsn(Opcodes.ICONST_0); - for (int i = 0; i < METHODS; i++) { - run.visitMethodInsn(Opcodes.INVOKESTATIC, name, "m" + i, "()I", false); - run.visitInsn(Opcodes.IADD); - } - run.visitInsn(Opcodes.POP); run.visitInsn(Opcodes.RETURN); run.visitMaxs(0, 0); run.visitEnd(); - cw.visitEnd(); - return cw.toByteArray(); - } - static final class L extends ClassLoader { - Class def(String n, byte[] b) { return defineClass(n, b, 0, b.length); } - } -} -``` - -
    - -## Measurements to compare against (from the #867 review) - -macOS arm64, JDK 25, G1, ~650–950 young GCs/s, 15 s: safepoint time per young GC 0.34 ms (no -profiler), 0.35 ms (`fjmethodid=true`), 0.36 ms (#864), 0.84 ms (#867). NMT, 1M methods in -custom-loader classes: preloading ~15.8 MiB; #864 ~1 MiB. Harness: `/tmp/gcbench` and -`/tmp/jmbench` on the Mac (not in the repo). From 666dd56a035d2f8d6ab28c630a1457082ff5fbd5 Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Mon, 19 Oct 2026 17:49:17 +0200 Subject: [PATCH 22/31] Report KlassRegistry's table size and purges as counters NMT does not see the registry's table, which the profiler allocates with calloc. klass_registry_capacity and klass_registry_entries (gauges) give its slots (24 B each) and occupied entries, live or dead, and klass_registry_purged counts dead entries dropped when the table is rebuilt. Lazy mode's start republishes the gauges after counters are reset. Co-Authored-By: Claude Opus 5.5 --- ddprof-lib/src/main/cpp/counters.h | 6 ++++ .../src/main/cpp/hotspot/hotspotSupport.cpp | 1 + .../src/main/cpp/hotspot/klassRegistry.cpp | 17 ++++++++++ .../src/main/cpp/hotspot/klassRegistry.h | 7 ++++ ddprof-lib/src/test/cpp/klassRegistry_ut.cpp | 33 +++++++++++++++++++ 5 files changed, 64 insertions(+) diff --git a/ddprof-lib/src/main/cpp/counters.h b/ddprof-lib/src/main/cpp/counters.h index e81cc8079d..0dee46be61 100644 --- a/ddprof-lib/src/main/cpp/counters.h +++ b/ddprof-lib/src/main/cpp/counters.h @@ -208,6 +208,12 @@ * 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") \ + /* Gauges: KlassRegistry's table slots (24 B each; NMT does not see the \ + * table) and occupied slots, live or dead. PURGED: dead entries dropped \ + * when the table was rebuilt. */ \ + X(KLASS_REGISTRY_CAPACITY, "klass_registry_capacity") \ + X(KLASS_REGISTRY_ENTRIES, "klass_registry_entries") \ + X(KLASS_REGISTRY_PURGED, "klass_registry_purged") \ 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 0ee9c2a62e..0f00388e40 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp @@ -1488,6 +1488,7 @@ bool HotspotSupport::initLazyResolution(JNIEnv* jni) { // Counters are reset on every profiler start, and a calibration found // once is kept, so republish its result here. Counters::set(KLASS_TRACE_ID_OFFSET, calibrated ? KlassTraceId::offset() : -1); + _klass_registry.publishCounters(); TEST_LOG("Raw frame tags: %s", calibrated ? "klass trace id" : "unavailable, lazy mode off"); return calibrated; } diff --git a/ddprof-lib/src/main/cpp/hotspot/klassRegistry.cpp b/ddprof-lib/src/main/cpp/hotspot/klassRegistry.cpp index 6b661434a1..28789b5ac2 100644 --- a/ddprof-lib/src/main/cpp/hotspot/klassRegistry.cpp +++ b/ddprof-lib/src/main/cpp/hotspot/klassRegistry.cpp @@ -5,6 +5,8 @@ #include "hotspot/klassRegistry.h" +#include "counters.h" + #include static inline size_t slotFor(const void* klass, size_t capacity) { @@ -57,6 +59,7 @@ bool KlassRegistry::rehash(JNIEnv* jni) { Entry* old_table = _table; size_t old_capacity = _capacity; + size_t old_size = _size; _table = table; _capacity = capacity; _size = 0; @@ -73,9 +76,21 @@ bool KlassRegistry::rehash(JNIEnv* jni) { } } free(old_table); + Counters::increment(KLASS_REGISTRY_PURGED, (long long)(old_size - _size)); + publishLocked(); return true; } +void KlassRegistry::publishLocked() { + Counters::set(KLASS_REGISTRY_CAPACITY, (long long)_capacity); + Counters::set(KLASS_REGISTRY_ENTRIES, (long long)_size); +} + +void KlassRegistry::publishCounters() { + MutexLocker ml(_lock); + publishLocked(); +} + bool KlassRegistry::add(JNIEnv* jni, const void* klass, jclass mirror) { if (klass == nullptr || mirror == nullptr) { return false; @@ -115,6 +130,7 @@ bool KlassRegistry::add(JNIEnv* jni, const void* klass, jclass mirror) { e->redefinitions_in_progress = 0; e->redefined = false; _size++; + publishLocked(); return true; } @@ -191,4 +207,5 @@ void KlassRegistry::clear(JNIEnv* jni) { _table = nullptr; _capacity = 0; _size = 0; + publishLocked(); } diff --git a/ddprof-lib/src/main/cpp/hotspot/klassRegistry.h b/ddprof-lib/src/main/cpp/hotspot/klassRegistry.h index 903a787190..031c6214e4 100644 --- a/ddprof-lib/src/main/cpp/hotspot/klassRegistry.h +++ b/ddprof-lib/src/main/cpp/hotspot/klassRegistry.h @@ -64,6 +64,7 @@ class KlassRegistry { Entry* find(const void* klass) const; Entry* findRegistered(const void* klass) const; bool rehash(JNIEnv* jni); + void publishLocked(); public: enum PinStatus { @@ -113,6 +114,12 @@ class KlassRegistry { /** Number of occupied slots, including dead entries not yet purged. */ size_t size(); + /** + * Sets the klass_registry_capacity and klass_registry_entries gauges. + * They are updated on every change; call this after counters are reset. + */ + void publishCounters(); + /** Deletes every weak reference and frees the table. */ void clear(JNIEnv* jni); }; diff --git a/ddprof-lib/src/test/cpp/klassRegistry_ut.cpp b/ddprof-lib/src/test/cpp/klassRegistry_ut.cpp index 453a0d414b..72f8868d3d 100644 --- a/ddprof-lib/src/test/cpp/klassRegistry_ut.cpp +++ b/ddprof-lib/src/test/cpp/klassRegistry_ut.cpp @@ -5,6 +5,7 @@ #include +#include "../../main/cpp/counters.h" #include "../../main/cpp/hotspot/klassRegistry.h" #include "../../main/cpp/hotspot/vmStructs.inline.h" @@ -200,6 +201,38 @@ TEST_F(KlassRegistryTest, GrowthPurgesDeadEntries) { } } +// The table is invisible to NMT, so its size and its purges are reported as +// counters: here 690 of the first 700 classes are dead when the 769th +// registration crosses the 3/4 load factor of the initial 1024 slots. +TEST_F(KlassRegistryTest, CountersReportTableSizeAndPurges) { +#ifndef COUNTERS + GTEST_SKIP() << "debug counters are not compiled into this build"; +#else + long long purged_before = Counters::getCounter(KLASS_REGISTRY_PURGED); + const size_t first = 700; + for (size_t i = 0; i < first; i++) { + ASSERT_TRUE(registry.add(jni, klassAt(i), mirror(i))); + } + EXPECT_EQ(1024, Counters::getCounter(KLASS_REGISTRY_CAPACITY)); + EXPECT_EQ((long long)first, Counters::getCounter(KLASS_REGISTRY_ENTRIES)); + for (size_t i = 10; i < first; i++) { + heap.collect(&mirrors[i]); + } + const size_t more = 200; + for (size_t i = first; i < first + more; i++) { + ASSERT_TRUE(registry.add(jni, klassAt(i), mirror(i))); + } + EXPECT_EQ(690, Counters::getCounter(KLASS_REGISTRY_PURGED) - purged_before); + EXPECT_EQ(1024, Counters::getCounter(KLASS_REGISTRY_CAPACITY)); + EXPECT_EQ((long long)registry.size(), Counters::getCounter(KLASS_REGISTRY_ENTRIES)); + EXPECT_EQ(10 + (long long)more, Counters::getCounter(KLASS_REGISTRY_ENTRIES)); + + registry.clear(jni); + EXPECT_EQ(0, Counters::getCounter(KLASS_REGISTRY_CAPACITY)); + EXPECT_EQ(0, Counters::getCounter(KLASS_REGISTRY_ENTRIES)); +#endif +} + TEST_F(KlassRegistryTest, GrowthKeepsAllLiveEntries) { const size_t n = 3000; for (size_t i = 0; i < n; i++) { From 1eb2f01a2320bd2200cdd9cb30e096dd2584b401 Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Mon, 19 Oct 2026 21:01:35 +0200 Subject: [PATCH 23/31] Pin classes per dump instead of registering every class Lazy mode kept a JNI weak reference and a 24-byte table entry for every loaded class, registered at ClassPrepare. Dead entries were only purged when the table had to grow, so after heavy class churn they could stay for good: on JDK 26, where the JVM frees jmethodIDs, that left lazy mode using more memory than preloading. Every dump already pins all loaded classes: Recording::finishChunk() holds the local references GetLoadedClasses() returns until it ends. The dump now hands that list to HotspotSupport::beginDump(), and the first lazy resolution of the dump collects the classes' Klass*s into a temporary set, freed by endDump(). A raw frame is read only if its holder is in that set; the trace-ID, current-method and redefinition checks are unchanged. A dump without lazy frames builds no set. ClassPrepare no longer does anything in lazy mode. KlassRegistry now only holds the redefinition marks: the hooks register the class they mark, and a mark is no longer reported once that class has been unloaded, so a new class at the same address starts unmarked. New gauges lazy_dump_classes and lazy_dump_pin_ns report the classes a dump pinned and the time it took to collect them; klass_trace_id_beyond_tag_range becomes a gauge computed from them. klass_registry_add_failed is removed. Co-Authored-By: Claude Opus 5.5 --- ddprof-lib/src/main/cpp/counters.h | 31 ++-- ddprof-lib/src/main/cpp/flightRecorder.cpp | 6 + ddprof-lib/src/main/cpp/flightRecorder.h | 5 +- .../src/main/cpp/hotspot/hotspotSupport.cpp | 167 ++++++++++++----- .../src/main/cpp/hotspot/hotspotSupport.h | 64 +++++-- .../src/main/cpp/hotspot/klassRegistry.cpp | 28 +-- .../src/main/cpp/hotspot/klassRegistry.h | 67 +++---- ddprof-lib/src/main/cpp/jvmSupport.cpp | 12 ++ ddprof-lib/src/main/cpp/jvmSupport.h | 6 + .../src/test/cpp/hotspotLazyResolve_ut.cpp | 175 ++++++++++++------ ddprof-lib/src/test/cpp/klassRegistry_ut.cpp | 85 +++++---- 11 files changed, 404 insertions(+), 242 deletions(-) diff --git a/ddprof-lib/src/main/cpp/counters.h b/ddprof-lib/src/main/cpp/counters.h index 0dee46be61..7e757784a8 100644 --- a/ddprof-lib/src/main/cpp/counters.h +++ b/ddprof-lib/src/main/cpp/counters.h @@ -174,9 +174,11 @@ * 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 \ + /* fjmethodid=lazy resolution outcomes. UNLOADED: the frame's class is not \ + * among the classes the dump pins (unloaded since the sample, or the \ + * holder read from a freed Method* is garbage). NOT_CURRENT: the class \ + * is alive but the Method* is not among its current methods (freed or \ + * redefined). Both \ * serialize as "unknown". RESOLVED: raw frames whose names were read from \ * VM metadata, once per method and tag per dump. */ \ X(METHOD_RESOLVE_LAZY_UNLOADED, "method_resolve_lazy_unloaded") \ @@ -184,7 +186,6 @@ X(METHOD_RESOLVE_LAZY_RESOLVED, "method_resolve_lazy_resolved") \ /* STALE: the class now at the frame's holder address is a different load \ * (its trace ID differs); serializes as "unknown". \ - * ADD_FAILED: registration failed, so the class was preloaded instead. \ * UNTAGGABLE: a Method* using address bits the raw-frame tag needs, or \ * an unreadable holder trace ID; the frame is stored as not walkable. */ \ X(METHOD_RESOLVE_LAZY_STALE, "method_resolve_lazy_stale") \ @@ -196,21 +197,25 @@ * read faulted), so no class could even be looked up; serializes as \ * "unknown". Under class churn this is most of the frames lost. */ \ X(METHOD_RESOLVE_LAZY_UNREADABLE, "method_resolve_lazy_unreadable") \ - /* Registered classes marked by the redefinition hooks (once per class \ - * per redefine/retransform call). */ \ + /* Classes marked by the redefinition hooks (once per class per \ + * redefine/retransform call). */ \ X(KLASS_REGISTRY_REDEFINITIONS_MARKED, "klass_registry_redefinitions_marked") \ - /* Registrations whose class trace ID no longer fits in a raw frame's \ - * 25-bit tag. Nonzero means trace-ID tags of two classes can now collide: \ - * a frame sampled from an unloaded class could then, if a newer class \ - * also took over its Method* memory, resolve to that class's method. */ \ + /* Gauge, set at each dump: loaded classes whose 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") \ + /* Gauges, set by each dump that resolves lazy raw frames: the classes it \ + * pinned, and the nanoseconds it took to collect their Klass*s. */ \ + X(LAZY_DUMP_CLASSES, "lazy_dump_classes") \ + X(LAZY_DUMP_PIN_NS, "lazy_dump_pin_ns") \ /* 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") \ /* Gauges: KlassRegistry's table slots (24 B each; NMT does not see the \ - * table) and occupied slots, live or dead. PURGED: dead entries dropped \ - * when the table was rebuilt. */ \ + * table) and occupied slots, live or dead. It holds only redefined \ + * classes. PURGED: dead entries dropped when the table was rebuilt. */ \ X(KLASS_REGISTRY_CAPACITY, "klass_registry_capacity") \ X(KLASS_REGISTRY_ENTRIES, "klass_registry_entries") \ X(KLASS_REGISTRY_PURGED, "klass_registry_purged") \ diff --git a/ddprof-lib/src/main/cpp/flightRecorder.cpp b/ddprof-lib/src/main/cpp/flightRecorder.cpp index f3a878a94a..ea954a09ce 100644 --- a/ddprof-lib/src/main/cpp/flightRecorder.cpp +++ b/ddprof-lib/src/main/cpp/flightRecorder.cpp @@ -1027,6 +1027,11 @@ off_t Recording::finishChunk(bool end_recording, bool do_cleanup) { // call. Classes already unloaded before finishChunk() was entered are not // present in the list and receive no protection here. jvmtiError err = jvmti->GetLoadedClasses(&count, &classes); + // fjmethodid=lazy resolves raw frames only of these classes: they stay + // pinned until the local references are deleted below. + if (!err) { + JVMSupport::beginDump(env, classes, count); + } flush(&_cpu_monitor_buf); @@ -1145,6 +1150,7 @@ off_t Recording::finishChunk(bool end_recording, bool do_cleanup) { // values rather than pre-. capturePostFlushNativeMem(); + JVMSupport::endDump(); if (!err) { // delete all local references for (int i = 0; i < count; i++) { diff --git a/ddprof-lib/src/main/cpp/flightRecorder.h b/ddprof-lib/src/main/cpp/flightRecorder.h index 933f13c080..9d1911b94c 100644 --- a/ddprof-lib/src/main/cpp/flightRecorder.h +++ b/ddprof-lib/src/main/cpp/flightRecorder.h @@ -491,8 +491,9 @@ class Lookup { // Per-dump cache of raw Method* frame resolutions (HotSpot cstack=vm frames // whose class had no jmethodID when sampled): the filled row, or nullptr if // the frame was refused. The same raw method recurs in many traces, and - // each resolution sets up crash protection, takes the KlassRegistry mutex, - // pins the class through JNI and reads names and line numbers. Keyed by the + // each resolution sets up crash protection, checks the class against the + // dump's pinned classes and its redefinition mark, 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 diff --git a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp index 0f00388e40..04e77e1aec 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp @@ -1478,6 +1478,11 @@ bool HotspotSupport::loadMethodIDsIfNeededImpl(jvmtiEnv *jvmti, JNIEnv *jni, jcl } KlassRegistry HotspotSupport::_klass_registry; +bool HotspotSupport::_lazy_used = false; +const jclass* HotspotSupport::_dump_jclasses = nullptr; +int HotspotSupport::_dump_jclass_count = 0; +const void** HotspotSupport::_dump_classes = nullptr; +size_t HotspotSupport::_dump_classes_capacity = 0; bool HotspotSupport::canResolveLazily() { return VMStructs::hasKlassMethods(); @@ -1490,6 +1495,9 @@ bool HotspotSupport::initLazyResolution(JNIEnv* jni) { Counters::set(KLASS_TRACE_ID_OFFSET, calibrated ? KlassTraceId::offset() : -1); _klass_registry.publishCounters(); TEST_LOG("Raw frame tags: %s", calibrated ? "klass trace id" : "unavailable, lazy mode off"); + if (calibrated) { + _lazy_used = true; + } return calibrated; } @@ -1511,16 +1519,18 @@ bool HotspotSupport::isInstanceClass(jvmtiEnv *jvmti, jclass klass) { } void HotspotSupport::beforeClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass) { - if (_klass_registry.size() == 0 || !isInstanceClass(jvmti, klass)) { + if (!_lazy_used || !isInstanceClass(jvmti, klass)) { return; } - if (_klass_registry.beginRedefinition(VMKlass::fromJavaClass(jni, klass))) { + // Only redefined classes are registered, so the mark has a place to live. + const void* vm_klass = VMKlass::fromJavaClass(jni, klass); + if (_klass_registry.add(jni, vm_klass, klass) && _klass_registry.beginRedefinition(vm_klass)) { Counters::increment(KLASS_REGISTRY_REDEFINITIONS_MARKED); } } void HotspotSupport::afterClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass, bool succeeded) { - if (_klass_registry.size() == 0 || !isInstanceClass(jvmti, klass)) { + if (!_lazy_used || !isInstanceClass(jvmti, klass)) { return; } _klass_registry.endRedefinition(VMKlass::fromJavaClass(jni, klass), succeeded); @@ -1533,27 +1543,91 @@ void HotspotSupport::afterClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclass } bool HotspotSupport::registerClass(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass) { - // Array and primitive classes have no methods, so no frame ever names them - // as a holder; skipping them keeps GetLoadedClasses() at start cheap. - if (!isInstanceClass(jvmti, klass)) { + // Nothing to do at ClassPrepare: each dump pins the classes it resolves + // frames of (beginDump()). + return true; +} + +bool HotspotSupport::isDumpClass(const void* klass) { + if (_dump_classes == nullptr) { + buildDumpClasses(); + } + if (_dump_classes == nullptr || klass == nullptr) { return false; } - const void* vm_klass = VMKlass::fromJavaClass(jni, klass); - uint64_t trace_id; - if (KlassTraceId::read(vm_klass, &trace_id) && trace_id > TAG_VALUE_MASK) { - // Two classes' 25-bit trace-ID tags can only collide once their IDs - // are at least 2^25 apart, so before any class has an ID this large - // no stale frame can be matched to a newer class by its tag. - Counters::increment(KLASS_TRACE_ID_BEYOND_TAG_RANGE); + size_t mask = _dump_classes_capacity - 1; + for (size_t i = ((uintptr_t)klass >> 3) * 0x9E3779B97F4A7C15ULL >> 32 & mask; ; i = (i + 1) & mask) { + if (_dump_classes[i] == klass) { + return true; + } + if (_dump_classes[i] == nullptr) { + return false; + } } - if (_klass_registry.add(jni, vm_klass, klass)) { - return true; +} + +void HotspotSupport::beginDump(JNIEnv* jni, const jclass* classes, int count) { + endDump(); + if (!_lazy_used || count <= 0) { + return; + } + _dump_jclasses = classes; + _dump_jclass_count = count; +} + +void HotspotSupport::buildDumpClasses() { + if (_dump_jclasses == nullptr) { + return; // not in a dump + } + u64 start = OS::nanotime(); + const int count = _dump_jclass_count; + size_t capacity = 1024; + while (capacity < (size_t)count * 2) { + capacity <<= 1; } - // 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*/); + const void** table = (const void**)calloc(capacity, sizeof(const void*)); + if (table == nullptr) { + _dump_jclasses = nullptr; // every lazy frame of this dump resolves as unknown + return; + } + JNIEnv* jni = VM::jni(); + size_t mask = capacity - 1; + long long beyond_tag_range = 0; + for (int c = 0; c < count; c++) { + const void* klass = VMKlass::fromJavaClass(jni, _dump_jclasses[c]); + if (klass == nullptr) { + continue; + } + uint64_t trace_id; + if (KlassTraceId::read(klass, &trace_id) && trace_id > TAG_VALUE_MASK) { + beyond_tag_range++; + } + for (size_t i = ((uintptr_t)klass >> 3) * 0x9E3779B97F4A7C15ULL >> 32 & mask; ; i = (i + 1) & mask) { + if (table[i] == klass) { + break; + } + if (table[i] == nullptr) { + table[i] = klass; + break; + } + } + } + // Two classes' 25-bit trace-ID tags can only collide once their IDs are at + // least 2^25 apart, so while no loaded class has an ID this large no stale + // frame can be matched to a newer class by its tag. + Counters::set(KLASS_TRACE_ID_BEYOND_TAG_RANGE, beyond_tag_range); + Counters::set(LAZY_DUMP_CLASSES, count); + Counters::set(LAZY_DUMP_PIN_NS, (long long)(OS::nanotime() - start)); + _dump_classes_capacity = capacity; + _dump_classes = table; +} + +void HotspotSupport::endDump() { + free(_dump_classes); + _dump_classes = nullptr; + _dump_classes_capacity = 0; + _dump_jclasses = nullptr; + _dump_jclass_count = 0; } ResolvedMethod::ResolvedMethod() : @@ -1774,11 +1848,11 @@ bool HotspotSupport::readMethod(const void* method, ResolvedMethod* result) { } // 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*. +// Reads only what resolveLazily() 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 -- +// resolveLazily() 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); @@ -1874,8 +1948,8 @@ bool HotspotSupport::resolve(const void* tagged_method, int bci, ResolvedMethod& if (!lazy) { // A redefinition can free a replaced Method* and reuse its memory, and - // partial mode has no registry to tell, so such a frame may resolve to - // the method now at that address. + // partial mode keeps no redefinition marks to 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; @@ -1886,25 +1960,26 @@ bool HotspotSupport::resolve(const void* tagged_method, int bci, ResolvedMethod& Counters::increment(METHOD_RESOLVE_LAZY_UNREADABLE); return false; } - return resolveViaRegistry(prof_thread, vm_method, holder, idnum, tag & TAG_VALUE_MASK, result); + return resolveLazily(prof_thread, vm_method, holder, idnum, tag & TAG_VALUE_MASK, result); } -// Pins the class through JNI, so this runs outside any landing pad (see -// protectedWalk()); only the readMethod() call is protected. Nothing here -// dereferences vm_method until the class that owns it is pinned and the -// method is confirmed to be one of that class's current methods. -bool HotspotSupport::resolveViaRegistry(ProfiledThread* prof_thread, VMMethod* vm_method, - const void* holder, int idnum, uint32_t trace_id_bits, - ResolvedMethod& result) { - JNIEnv* jni = VM::jni(); - KlassRegistry::PinStatus status; - jclass clz = _klass_registry.pin(jni, holder, &status); - if (clz == nullptr) { - // Redefined: the Method* may have been freed and reused within the class. - // Otherwise the class was unloaded after the sample was taken, or `holder` - // was read from a freed Method* and is garbage. - Counters::increment(status == KlassRegistry::PIN_REDEFINED ? METHOD_RESOLVE_LAZY_REDEFINED - : METHOD_RESOLVE_LAZY_UNLOADED); +// Runs on the dump thread between beginDump() and endDump(), so every class in +// _dump_classes stays loaded throughout. Nothing here dereferences vm_method +// until its holder is confirmed to be one of those classes and the method one +// of the holder's current methods; only the readMethod() call needs the +// landing pad, for a concurrent redefinition. +bool HotspotSupport::resolveLazily(ProfiledThread* prof_thread, VMMethod* vm_method, + const void* holder, int idnum, uint32_t trace_id_bits, + ResolvedMethod& result) { + if (!isDumpClass(holder)) { + // 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 false; + } + if (_klass_registry.isRedefined(VM::jni(), holder)) { + // The Method* may have been freed and reused within the class. + Counters::increment(METHOD_RESOLVE_LAZY_REDEFINED); return false; } @@ -1913,7 +1988,6 @@ bool HotspotSupport::resolveViaRegistry(ProfiledThread* prof_thread, VMMethod* v // 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 false; } bool resolved = false; @@ -1929,11 +2003,10 @@ bool HotspotSupport::resolveViaRegistry(ProfiledThread* prof_thread, VMMethod* v // the mark check below rejects whatever it read. resolved = protectedWalk(prof_thread, result, [&] { return readMethod(vm_method, &result); }); } - jni->DeleteLocalRef(clz); // The class is marked before a redefinition swaps its methods. If the mark // is still absent now, every read above happened before that swap, so the // Method* we validated was the class's own; otherwise we cannot tell. - if (resolved && _klass_registry.isRedefined(holder)) { + if (resolved && _klass_registry.isRedefined(VM::jni(), holder)) { result.release(); Counters::increment(METHOD_RESOLVE_LAZY_REDEFINED); return false; diff --git a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h index cfd354525b..ebefe01dbb 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h @@ -151,10 +151,31 @@ 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. + // fjmethodid=lazy: redefinition marks of the classes redefined while lazy + // mode was in use (see klassRegistry.h). Only redefined classes are in it. static KlassRegistry _klass_registry; + // Set once lazy mode has started; raw frames of a lazy session can + // outlive it, so redefinitions are tracked and dumps pin classes from then + // on. + static bool _lazy_used; + + // fjmethodid=lazy: the classes GetLoadedClasses() returned at the start + // of the current dump. The dump holds a local reference to each of them, + // so none can be unloaded until it ends. nullptr outside a dump. + static const jclass* _dump_jclasses; + static int _dump_jclass_count; + + // Their Klass*s (open addressing, power-of-two capacity), built by the + // dump's first lazy resolution, so a dump without lazy frames never + // builds it. nullptr until then. + static const void** _dump_classes; + static size_t _dump_classes_capacity; + + // Whether `klass` is one of the classes pinned by the current dump. + static bool isDumpClass(const void* klass); + static void buildDumpClasses(); + // 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) @@ -168,15 +189,17 @@ class HotspotSupport { // 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. + // fjmethodid=lazy: does nothing instead of allocating `klass`'s + // jmethodIDs; each dump pins the classes it resolves frames of + // (beginDump()). Called wherever the other modes preload. static bool registerClass(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass); // Around every RedefineClasses/RetransformClasses call, in every mode - // once _klass_registry has entries (raw frames of a lazy session can - // outlive it): marks `klass` while the call runs and for good if it - // succeeds (see klassRegistry.h for why its raw frames are refused), and in - // lazy mode preloads its jmethodIDs after a successful call. `klass` comes + // once lazy mode has started (raw frames of a lazy session can outlive + // it): registers `klass` in _klass_registry and marks it 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); @@ -186,14 +209,14 @@ class HotspotSupport { // primitive, i.e. one with a Klass* that has methods. static bool isInstanceClass(jvmtiEnv *jvmti, jclass klass); - // fjmethodid=lazy half of resolve(): pins the class registered under - // `holder`, refuses it if its trace ID does not match `trace_id_bits` (the - // frame came from another load of a class whose memory this one reuses), - // checks that `vm_method` is still one of its methods, and only then reads - // its names and line number table into `result`, with the class still - // pinned. `holder` and `idnum` were read from the unverified Method* and - // are only used as lookup keys. - static bool resolveViaRegistry(ProfiledThread* prof_thread, VMMethod* vm_method, + // fjmethodid=lazy half of resolve(): refuses the frame unless `holder` is + // one of the classes the current dump pins and is not marked redefined, + // refuses it if its trace ID does not match `trace_id_bits` (the frame + // came from another load of a class whose memory this one reuses), checks + // that `vm_method` is still one of its methods, and only then reads its + // names and line number table into `result`. `holder` and `idnum` were + // read from the unverified Method* and are only used as lookup keys. + static bool resolveLazily(ProfiledThread* prof_thread, VMMethod* vm_method, const void* holder, int idnum, uint32_t trace_id_bits, ResolvedMethod& result); @@ -216,6 +239,12 @@ class HotspotSupport { public: static void initClassloaderInfo(JNIEnv* jni); + // Around each dump, with the classes GetLoadedClasses() returned. The + // caller keeps the array and its local references valid until after + // endDump(). resolve() accepts raw frames of exactly these classes. + static void beginDump(JNIEnv* jni, const jclass* classes, int count); + static void endDump(); + // Runs `work` under the ucontext-fault-recovery protocol walkJavaStack() // needs around getJavaTraceAsync(): installs a sigsetjmp/siglongjmp // crash-protection scope chained on prof_thread (JmpCtxScope, guards.h), @@ -340,7 +369,8 @@ class HotspotSupport { // frame's method field and encoded bci as stored, tag bits included. // Returns false, leaving `result` empty, if the frame must be treated as // unknown: the JMETHODID_NOT_WALKABLE sentinel, metadata that is stale or - // unreadable, or a lazy-mode frame the class registry refuses. + // unreadable, or a lazy-mode frame whose class the dump does not pin, was + // replaced by another class, or was redefined. static bool resolve(const void* tagged_method, int bci, ResolvedMethod& result); // Store a Java frame captured from HotSpot metadata. A null jmethodID diff --git a/ddprof-lib/src/main/cpp/hotspot/klassRegistry.cpp b/ddprof-lib/src/main/cpp/hotspot/klassRegistry.cpp index 28789b5ac2..21f24c28e9 100644 --- a/ddprof-lib/src/main/cpp/hotspot/klassRegistry.cpp +++ b/ddprof-lib/src/main/cpp/hotspot/klassRegistry.cpp @@ -134,29 +134,10 @@ bool KlassRegistry::add(JNIEnv* jni, const void* klass, jclass mirror) { return true; } -jclass KlassRegistry::pin(JNIEnv* jni, const void* klass, PinStatus* status) { - PinStatus local_status; - if (status == nullptr) { - status = &local_status; - } - *status = PIN_ABSENT; +bool KlassRegistry::contains(JNIEnv* jni, const void* klass) { MutexLocker ml(_lock); Entry* e = findRegistered(klass); - if (e == nullptr) { - return nullptr; - } - if (e->redefined || e->redefinitions_in_progress > 0) { - *status = PIN_REDEFINED; - return nullptr; - } - // NewLocalRef on a weak global reference returns NULL once the referent has - // been collected. Done under the lock so a concurrent rehash() cannot delete - // the weak reference out from under the call. - jclass ref = (jclass)jni->NewLocalRef(e->mirror); - if (ref != nullptr) { - *status = PIN_OK; - } - return ref; + return e != nullptr && !jni->IsSameObject(e->mirror, nullptr); } bool KlassRegistry::beginRedefinition(const void* klass) { @@ -185,10 +166,11 @@ void KlassRegistry::endRedefinition(const void* klass, bool succeeded) { } } -bool KlassRegistry::isRedefined(const void* klass) { +bool KlassRegistry::isRedefined(JNIEnv* jni, const void* klass) { MutexLocker ml(_lock); Entry* e = findRegistered(klass); - return e != nullptr && (e->redefined || e->redefinitions_in_progress > 0); + return e != nullptr && (e->redefined || e->redefinitions_in_progress > 0) && + !jni->IsSameObject(e->mirror, nullptr); } 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 031c6214e4..551ea56534 100644 --- a/ddprof-lib/src/main/cpp/hotspot/klassRegistry.h +++ b/ddprof-lib/src/main/cpp/hotspot/klassRegistry.h @@ -13,35 +13,24 @@ #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. + * Redefinition marks for fjmethodid=lazy, keyed by Klass*: which classes are + * being, or have been, redefined. * - * Used by fjmethodid=lazy: walkVM stores a raw Method* for every frame whose - * class has no jmethodID yet, and HotspotSupport::resolve() later reads the - * holder Klass* out of that (possibly freed) Method*. That pointer is only a - * lookup key here and is never dereferenced: pin() returns a strong local - * reference only if the class registered under that address is still - * reachable, and a strongly reachable mirror keeps its class loader -- and so - * the class's metadata -- from being unloaded until the reference is released. - * Whether the pinned class is the same load of a class as the one sampled is - * checked separately, through the class's JFR trace ID (see HotspotSupport). + * 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 the + * RedefineClasses/RetransformClasses hooks call beginRedefinition() before + * every redefinition and endRedefinition() after it, and the dump refuses raw + * frames of a class while one is in progress, and for good once one has + * succeeded. (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. * - * Entries are added from ClassPrepare and never removed explicitly; dead ones - * (weak reference cleared) are purged whenever the table would have to grow, - * so memory tracks the number of live classes rather than every class ever - * loaded. A Klass* address reused by a newly loaded class simply overwrites - * the dead entry on that class's ClassPrepare. - * - * Redefinition: a redefined class stays alive, but its old methods array and - * old Method*s are freed once no longer on any stack, and later allocations - * -- including another redefinition of the same class -- can reuse them. So - * beginRedefinition() is called before every redefinition and - * endRedefinition() after it. While one is in progress, and for good once one - * has succeeded, pin() refuses the class's raw frames. (After a successful - * redefinition the class's jmethodIDs are preloaded, so its later frames are - * not raw.) A failed call leaves the methods untouched, and the class as it - * was. + * Only redefined classes are added. Each entry holds a JNI weak global + * reference to the class's mirror, so a mark is only reported while that + * class is alive: once it is unloaded, a new class at the same Klass* address + * does not inherit it, and that class's own add() replaces the entry. Dead + * entries are purged whenever the table would have to grow. * * All operations take an internal mutex and call JNI, so none of them may be * used from a signal handler. @@ -67,12 +56,6 @@ class KlassRegistry { void publishLocked(); public: - enum PinStatus { - PIN_OK, - PIN_ABSENT, // not registered, or the class has been unloaded - PIN_REDEFINED, // the class is being or has been redefined - }; - KlassRegistry() : _table(nullptr), _capacity(0), _size(0) {} KlassRegistry(const KlassRegistry&) = delete; @@ -85,14 +68,8 @@ class KlassRegistry { */ bool add(JNIEnv* jni, const void* klass, jclass mirror); - /** - * Returns a new local reference to the mirror registered for `klass`, or - * nullptr -- with the reason in `*status` -- if none is registered, the - * class has been unloaded, or it is being or has been redefined. While the - * caller holds the reference the class cannot be unloaded; it must release - * it with DeleteLocalRef. - */ - jclass pin(JNIEnv* jni, const void* klass, PinStatus* status = nullptr); + /** Whether `klass` is registered and its class is still alive. */ + bool contains(JNIEnv* jni, const void* klass); /** * Call before a redefinition of `klass` starts, so that a resolution which @@ -108,8 +85,12 @@ class KlassRegistry { */ void endRedefinition(const void* klass, bool succeeded); - /** Whether `klass` is being or has been redefined. */ - bool isRedefined(const void* klass); + /** + * Whether the class at `klass` is being or has been redefined. False once + * the marked class has been unloaded, so a new class at the same address + * starts unmarked. + */ + bool isRedefined(JNIEnv* jni, const void* klass); /** Number of occupied slots, including dead entries not yet purged. */ size_t size(); diff --git a/ddprof-lib/src/main/cpp/jvmSupport.cpp b/ddprof-lib/src/main/cpp/jvmSupport.cpp index f9af96cb88..2a8b4a4b92 100644 --- a/ddprof-lib/src/main/cpp/jvmSupport.cpp +++ b/ddprof-lib/src/main/cpp/jvmSupport.cpp @@ -190,6 +190,18 @@ void JVMSupport::afterClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclass kla } } +void JVMSupport::beginDump(JNIEnv *jni, const jclass *classes, int count) { + if (VM::isHotspot()) { + HotspotSupport::beginDump(jni, classes, count); + } +} + +void JVMSupport::endDump() { + if (VM::isHotspot()) { + HotspotSupport::endDump(); + } +} + 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 d0e99ce57b..43990f9a09 100644 --- a/ddprof-lib/src/main/cpp/jvmSupport.h +++ b/ddprof-lib/src/main/cpp/jvmSupport.h @@ -76,6 +76,12 @@ class JVMSupport { static void beforeClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass); static void afterClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass, bool succeeded); + // Around each dump: `classes` are the ones GetLoadedClasses() returned, + // referenced by the caller until after endDump(). Raw frames of other + // classes resolve as unknown. + static void beginDump(JNIEnv *jni, const jclass *classes, int count); + static void endDump(); + // 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); diff --git a/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp b/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp index e1ddb890cd..6af0f98d21 100644 --- a/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp +++ b/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp @@ -20,6 +20,7 @@ #include #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 @@ -61,6 +62,8 @@ class JVMSupportTestAccessor { class HotspotSupportTestAccessor { public: static KlassRegistry& registry() { return HotspotSupport::_klass_registry; } + static void setLazyUsed(bool used) { HotspotSupport::_lazy_used = used; } + static bool dumpClassesBuilt() { return HotspotSupport::_dump_classes != nullptr; } static void beforeClassRedefinition(jvmtiEnv* jvmti, JNIEnv* jni, jclass klass) { HotspotSupport::beforeClassRedefinition(jvmti, jni, klass); } @@ -259,6 +262,7 @@ struct FakeClass { struct FakeObject { int id; const void* klass = nullptr; // what java.lang.Class.klass holds, for mirrors + bool collected = false; // the class has been unloaded }; struct FakeWeak { @@ -299,15 +303,15 @@ class FakeJvm { JNIEnv* jni() { return &_jni_env; } jvmtiEnv* jvmti() { return &_jvmti; } - // KlassRegistry::pin() calls NewLocalRef once per resolution. int new_local_ref_calls = 0; int get_class_methods_calls = 0; jclass mirror(FakeObject& obj) { return reinterpret_cast(&obj); } - // Class unloading as far as the registry can observe it: the mirror's - // weak references are cleared. + // Class unloading: the class is no longer among the loaded classes, and + // the mirror's weak references are cleared. void collect(FakeObject& obj) { + obj.collected = true; for (FakeWeak* w : _weaks) { if (w->referent == &obj) { w->referent = nullptr; @@ -403,9 +407,12 @@ class HotspotLazyResolveTest : public ::testing::Test { // resolve() installs crash protection on the current ProfiledThread. ProfiledThread::initCurrentThread(); ASSERT_NE(nullptr, ProfiledThread::current()); + HotspotSupportTestAccessor::setLazyUsed(true); } void TearDown() override { + HotspotSupport::endDump(); + HotspotSupportTestAccessor::setLazyUsed(false); HotspotSupportTestAccessor::registry().clear(_jvm.jni()); ProfiledThread::release(); KlassTraceId::setOffsetForTest(_saved_trace_id_offset); @@ -414,9 +421,29 @@ class HotspotLazyResolveTest : public ::testing::Test { VMTestAccessor::setHotspot(_saved_hotspot); } + // Classes loaded so far; collect() unloads them. + std::vector _loaded; + + // A class is loaded: from now on it is among the classes a dump pins. void registerClass(FakeObject& mirror) { - ASSERT_TRUE(HotspotSupportTestAccessor::registry().add(_jvm.jni(), _cls.klassPtr(), - _jvm.mirror(mirror))); + _loaded.push_back(&mirror); + } + + // The class list of the current dump; like finishChunk()'s, it must stay + // valid until the dump ends. + std::vector _dump_classes; + + // What Recording::finishChunk() does at the start of a dump: pins the + // classes that are loaded now. + void beginDump() { + HotspotSupport::endDump(); + _dump_classes.clear(); + for (FakeObject* m : _loaded) { + if (!m->collected) { + _dump_classes.push_back(_jvm.mirror(*m)); + } + } + HotspotSupport::beginDump(_jvm.jni(), _dump_classes.data(), (int)_dump_classes.size()); } // The old class is unloaded and a new class of the same shape is loaded at @@ -443,14 +470,17 @@ class HotspotLazyResolveTest : public ::testing::Test { succeeded); } - // What Lookup::fillRawMethod() does with a raw frame at dump time. - static bool resolve(const ASGCT_CallFrame& frame) { + // What Lookup::fillRawMethod() does with a raw frame in a dump that + // starts now. + bool resolve(const ASGCT_CallFrame& frame) { + beginDump(); 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) { + bool resolvesToSpin(const ASGCT_CallFrame& frame) { + beginDump(); ResolvedMethod result; return HotspotSupport::resolve(frame.method, frame.bci, result) && strcmp(FakeClass::KLASS_NAME, result.klassName()) == 0 && @@ -471,6 +501,7 @@ TEST_F(HotspotLazyResolveTest, HiddenClassNamedAsJvmtiSignature) { {"a/Spin+0x", "a/Spin+0x"}, {"a/Spin+0x12g", "a/Spin+0x12g"}, }; + beginDump(); for (const auto& c : cases) { SCOPED_TRACE(c.vm_name); FakeClass::setSymbol(_cls.klass_name, c.vm_name); @@ -508,32 +539,6 @@ TEST_F(HotspotLazyResolveTest, FrameFromBeforeKlassReuseDoesNotResolveToNewClass EXPECT_FALSE(resolve(frame)); } -// Same, but the dead entry was purged by a registry rehash before the new -// class arrived, so the new class's registration looks like a first insert -// rather than a replacement. -TEST_F(HotspotLazyResolveTest, FrameFromBeforeKlassReuseAfterPurgeDoesNotResolveToNewClass) { - registerClass(_old_mirror); - ASGCT_CallFrame frame = sample(); - _jvm.collect(_old_mirror); - - // Enough other classes to push the table past its initial capacity's load - // factor, so the dead entry is purged. - static FakeObject others[1024]; - KlassRegistry& registry = HotspotSupportTestAccessor::registry(); - for (size_t i = 0; i < 1024; i++) { - others[i].id = 100 + (int)i; - const void* key = reinterpret_cast(0x100000 + i * 0x200); - ASSERT_TRUE(registry.add(_jvm.jni(), key, _jvm.mirror(others[i]))); - } - ASSERT_EQ(nullptr, registry.pin(_jvm.jni(), _cls.klassPtr())) - << "the dead entry should be gone, purged or at least unpinnable"; - - _cls.klass.trace_id = ((_cls.klass.trace_id >> 16) + 37) << 16; // the new class's ID - registerClass(_new_mirror); - - EXPECT_FALSE(resolve(frame)); -} - // Frames sampled from the new class after the reuse must still resolve. TEST_F(HotspotLazyResolveTest, FrameFromAfterKlassReuseResolves) { registerClass(_old_mirror); @@ -562,13 +567,17 @@ TEST_F(HotspotLazyResolveTest, UnrelatedReuseDoesNotChangeFrames) { registerClass(_old_mirror); ASGCT_CallFrame before = sample(); + static FakeKlass other_klass; + other_klass = _cls.klass; + other_klass.trace_id = ((_cls.klass.trace_id >> 16) + 7) << 16; 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))); + other_old = FakeObject{10, &other_klass}; + other_new = FakeObject{11, &other_klass}; + registerClass(other_old); _jvm.collect(other_old); - ASSERT_TRUE(registry.add(_jvm.jni(), other_klass, _jvm.mirror(other_new))); + other_klass.trace_id += (uint64_t)1 << 16; + registerClass(other_new); ASGCT_CallFrame after = sample(); EXPECT_EQ(before.bci, after.bci); @@ -602,16 +611,57 @@ 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, which is what keeps a custom loader's class from -// being unloaded while its metadata is read. -TEST_F(HotspotLazyResolveTest, LazyFrameResolvesByRegistryAfterSwitchToPartialMode) { +// against the classes the dump pins, which is what keeps a custom loader's +// class from being unloaded while its metadata is read. +TEST_F(HotspotLazyResolveTest, LazyFrameResolvesAgainstPinnedClassesAfterSwitchToPartialMode) { registerClass(_old_mirror); ASGCT_CallFrame frame = sample(); ASSERT_NE(0u, FrameType::rawTag(frame.bci, frame.method) & HotspotSupportTestAccessor::traceIdTagBit()); JVMSupportTestAccessor::setLoadState(JVMSupportTestAccessor::PartialLoaded()); - int before = _jvm.new_local_ref_calls; EXPECT_TRUE(resolvesToSpin(frame)); - EXPECT_EQ(1, _jvm.new_local_ref_calls - before); +} + +// A dump collects the pinned classes' Klass*s only when it resolves its first +// lazy frame, so a dump without lazy frames pays nothing for them. +TEST_F(HotspotLazyResolveTest, DumpCollectsPinnedClassesOnlyForLazyFrames) { + registerClass(_old_mirror); + beginDump(); + EXPECT_FALSE(HotspotSupportTestAccessor::dumpClassesBuilt()); + ASGCT_CallFrame frame = sample(); + ResolvedMethod result; + EXPECT_TRUE(HotspotSupport::resolve(frame.method, frame.bci, result)); + EXPECT_TRUE(HotspotSupportTestAccessor::dumpClassesBuilt()); + HotspotSupport::endDump(); + EXPECT_FALSE(HotspotSupportTestAccessor::dumpClassesBuilt()); +} + +// A redefined class that is unloaded leaves its mark behind in the registry; +// a new class at the same address was never redefined, and its frames resolve. +TEST_F(HotspotLazyResolveTest, RedefinitionMarkDoesNotCarryOverToNewClassAtSameAddress) { + registerClass(_old_mirror); + redefine(true); + ASSERT_FALSE(resolve(sample())); + loadNewClassAtSameAddress(); + EXPECT_TRUE(resolvesToSpin(sample())); +} + +// Outside a dump no class is pinned, so a lazy frame is never read. +TEST_F(HotspotLazyResolveTest, FrameIsRefusedOutsideADump) { + registerClass(_old_mirror); + ASGCT_CallFrame frame = sample(); + HotspotSupport::endDump(); + ResolvedMethod result; + EXPECT_FALSE(HotspotSupport::resolve(frame.method, frame.bci, result)); +} + +// ClassPrepare registers nothing: a class enters the registry only when it is +// redefined, to hold its redefinition mark. +TEST_F(HotspotLazyResolveTest, ClassPrepareRegistersNothing) { + ASSERT_TRUE(HotspotSupportTestAccessor::registerClass(_jvm.jvmti(), _jvm.jni(), _jvm.mirror(_old_mirror))); + EXPECT_EQ(0u, HotspotSupportTestAccessor::registry().size()); + registerClass(_old_mirror); + redefine(false); + EXPECT_EQ(1u, HotspotSupportTestAccessor::registry().size()); } // A redefinition frees the class's old Method*s once they are off every @@ -658,7 +708,7 @@ TEST_F(HotspotLazyResolveTest, RedefinitionIsTrackedOutsideLazyMode) { ASGCT_CallFrame frame = sample(); JVMSupportTestAccessor::setLoadState(JVMSupportTestAccessor::PartialLoaded()); redefine(true); - EXPECT_TRUE(HotspotSupportTestAccessor::registry().isRedefined(_cls.klassPtr())); + EXPECT_TRUE(HotspotSupportTestAccessor::registry().isRedefined(_jvm.jni(), _cls.klassPtr())); EXPECT_FALSE(resolve(frame)); // The other modes load jmethodIDs their own way (JVMSupport). EXPECT_EQ(0, _jvm.get_class_methods_calls); @@ -679,30 +729,32 @@ TEST_F(HotspotLazyResolveTest, MethodMemoryReusedByClassAtOtherAddressDoesNotRes 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))); + _new_mirror.klass = &other_klass; + registerClass(_new_mirror); EXPECT_FALSE(resolve(frame)); } -// Tripwire for tag collisions: counts registrations of classes whose trace -// ID no longer fits in the 25-bit tag, the first point at which a stale frame -// could match a newer class by its tag. +// Tripwire for tag collisions: each dump reports how many of the classes it +// pins have a trace ID that no longer fits in the 25-bit tag, the point from +// which a stale frame could match a newer class by its tag. TEST_F(HotspotLazyResolveTest, CountsClassesWithTraceIdBeyondTagRange) { #ifndef COUNTERS GTEST_SKIP() << "debug counters are not compiled into this build"; #else - long long before = Counters::getCounter(KLASS_TRACE_ID_BEYOND_TAG_RANGE); _cls.klass.trace_id = ((uint64_t)(1u << 25) - 1) << 16; // largest ID that still fits - ASSERT_TRUE(HotspotSupportTestAccessor::registerClass(_jvm.jvmti(), _jvm.jni(), _jvm.mirror(_old_mirror))); - EXPECT_EQ(before, Counters::getCounter(KLASS_TRACE_ID_BEYOND_TAG_RANGE)); + registerClass(_old_mirror); + EXPECT_TRUE(resolve(sample())); + EXPECT_EQ(0, 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)); + registerClass(_new_mirror); + EXPECT_TRUE(resolve(sample())); + EXPECT_EQ(1, Counters::getCounter(KLASS_TRACE_ID_BEYOND_TAG_RANGE)); + EXPECT_EQ(2, Counters::getCounter(LAZY_DUMP_CLASSES)); #endif } @@ -750,22 +802,30 @@ TEST_F(HotspotLazyResolveTest, LookupResolvesEachRawMethodOncePerDump) { StringDictionary classes; MethodMap methods; Lookup lookup(nullptr, &methods, &classes); - int before = _jvm.new_local_ref_calls; + beginDump(); +#ifdef COUNTERS + long long before = Counters::getCounter(METHOD_RESOLVE_LAZY_RESOLVED); +#endif 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); +#ifdef COUNTERS + EXPECT_EQ(1, Counters::getCounter(METHOD_RESOLVE_LAZY_RESOLVED) - before); +#endif // 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); + beginDump(); EXPECT_EQ(row, next.resolveMethod(a)); EXPECT_TRUE(row->_mark); - EXPECT_EQ(2, _jvm.new_local_ref_calls - before); +#ifdef COUNTERS + EXPECT_EQ(2, Counters::getCounter(METHOD_RESOLVE_LAZY_RESOLVED) - before); +#endif EXPECT_EQ(0, _jvm.get_class_methods_calls); } @@ -780,6 +840,7 @@ TEST_F(HotspotLazyResolveTest, LookupCacheSeparatesFramesOfDifferentClassLoads) StringDictionary classes; MethodMap methods; Lookup lookup(nullptr, &methods, &classes); + beginDump(); EXPECT_EQ(&lookup._unknown_method, lookup.resolveMethod(before)); MethodInfo* row = lookup.resolveMethod(after); EXPECT_NE(&lookup._unknown_method, row); diff --git a/ddprof-lib/src/test/cpp/klassRegistry_ut.cpp b/ddprof-lib/src/test/cpp/klassRegistry_ut.cpp index 72f8868d3d..30ba86d375 100644 --- a/ddprof-lib/src/test/cpp/klassRegistry_ut.cpp +++ b/ddprof-lib/src/test/cpp/klassRegistry_ut.cpp @@ -133,24 +133,24 @@ class KlassRegistryTest : public ::testing::Test { } // namespace -TEST_F(KlassRegistryTest, PinUnknownKlassReturnsNull) { - EXPECT_EQ(nullptr, registry.pin(jni, klassAt(0))); +TEST_F(KlassRegistryTest, UnknownKlassIsNotContained) { + EXPECT_FALSE(registry.contains(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)); + EXPECT_FALSE(registry.contains(jni, klassAt(0))); + EXPECT_FALSE(registry.contains(jni, nullptr)); } -TEST_F(KlassRegistryTest, PinReturnsRegisteredMirror) { +TEST_F(KlassRegistryTest, RegisteredClassesAreContained) { 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_TRUE(registry.contains(jni, klassAt(0))); + EXPECT_TRUE(registry.contains(jni, klassAt(1))); } -TEST_F(KlassRegistryTest, PinReturnsNullOnceClassIsCollected) { +TEST_F(KlassRegistryTest, CollectedClassIsNotContained) { ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); heap.collect(&mirrors[0]); - EXPECT_EQ(nullptr, registry.pin(jni, klassAt(0))); + EXPECT_FALSE(registry.contains(jni, klassAt(0))); } TEST_F(KlassRegistryTest, ReAddingSameMirrorIsNoOp) { @@ -162,12 +162,12 @@ TEST_F(KlassRegistryTest, ReAddingSameMirrorIsNoOp) { } 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. + // A class is unloaded and a new one is registered at the same Klass* + // address: the new class 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_TRUE(registry.contains(jni, klassAt(0))); EXPECT_EQ(1u, registry.size()); EXPECT_EQ(1, heap.liveWeaks()); } @@ -191,13 +191,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_TRUE(registry.contains(jni, klassAt(i))) << i; } for (size_t i = 10; i < first; i++) { - EXPECT_EQ(nullptr, registry.pin(jni, klassAt(i))) << i; + EXPECT_FALSE(registry.contains(jni, klassAt(i))) << i; } for (size_t i = first; i < first + more; i++) { - EXPECT_EQ(mirror(i), registry.pin(jni, klassAt(i))) << i; + EXPECT_TRUE(registry.contains(jni, klassAt(i))) << i; } } @@ -240,7 +240,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_TRUE(registry.contains(jni, klassAt(i))) << i; } } @@ -252,10 +252,10 @@ 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_FALSE(registry.contains(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))); + EXPECT_TRUE(registry.contains(jni, klassAt(0))); } // Redefinition: raw frames of a class are refused while a redefinition of it @@ -264,33 +264,28 @@ TEST_F(KlassRegistryTest, ClearDeletesEveryWeakRef) { TEST_F(KlassRegistryTest, SuccessfulRedefinitionIsRefusedForGood) { ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); - EXPECT_FALSE(registry.isRedefined(klassAt(0))); + EXPECT_FALSE(registry.isRedefined(jni, klassAt(0))); ASSERT_TRUE(registry.beginRedefinition(klassAt(0))); - EXPECT_TRUE(registry.isRedefined(klassAt(0))); + EXPECT_TRUE(registry.isRedefined(jni, 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); + EXPECT_TRUE(registry.isRedefined(jni, klassAt(0))); // 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_TRUE(registry.isRedefined(jni, klassAt(0))); } TEST_F(KlassRegistryTest, RedefinitionInProgressIsRefused) { ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); ASSERT_TRUE(registry.beginRedefinition(klassAt(0))); - KlassRegistry::PinStatus status; - EXPECT_EQ(nullptr, registry.pin(jni, klassAt(0), &status)); - EXPECT_EQ(KlassRegistry::PIN_REDEFINED, status); + EXPECT_TRUE(registry.isRedefined(jni, klassAt(0))); } TEST_F(KlassRegistryTest, FailedRedefinitionLeavesClassAsBefore) { ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); ASSERT_TRUE(registry.beginRedefinition(klassAt(0))); registry.endRedefinition(klassAt(0), false); - EXPECT_FALSE(registry.isRedefined(klassAt(0))); - EXPECT_EQ(mirror(0), registry.pin(jni, klassAt(0))); + EXPECT_FALSE(registry.isRedefined(jni, klassAt(0))); + EXPECT_TRUE(registry.contains(jni, klassAt(0))); } TEST_F(KlassRegistryTest, OverlappingRedefinitionsAreTracked) { @@ -300,27 +295,27 @@ TEST_F(KlassRegistryTest, OverlappingRedefinitionsAreTracked) { ASSERT_TRUE(registry.beginRedefinition(klassAt(0))); ASSERT_TRUE(registry.beginRedefinition(klassAt(0))); registry.endRedefinition(klassAt(0), false); - EXPECT_TRUE(registry.isRedefined(klassAt(0))); + EXPECT_TRUE(registry.isRedefined(jni, klassAt(0))); registry.endRedefinition(klassAt(0), false); - EXPECT_FALSE(registry.isRedefined(klassAt(0))); + EXPECT_FALSE(registry.isRedefined(jni, klassAt(0))); ASSERT_TRUE(registry.beginRedefinition(klassAt(0))); ASSERT_TRUE(registry.beginRedefinition(klassAt(0))); registry.endRedefinition(klassAt(0), true); registry.endRedefinition(klassAt(0), false); - EXPECT_TRUE(registry.isRedefined(klassAt(0))); + EXPECT_TRUE(registry.isRedefined(jni, klassAt(0))); } TEST_F(KlassRegistryTest, RedefinitionOfUnregisteredClassIsIgnored) { EXPECT_FALSE(registry.beginRedefinition(klassAt(5))); registry.endRedefinition(klassAt(5), true); - EXPECT_FALSE(registry.isRedefined(klassAt(5))); + EXPECT_FALSE(registry.isRedefined(jni, klassAt(5))); ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); EXPECT_FALSE(registry.beginRedefinition(klassAt(5))); // An unmatched end, e.g. for a class registered while the call ran, does // not underflow the count. registry.endRedefinition(klassAt(0), false); - EXPECT_FALSE(registry.isRedefined(klassAt(0))); + EXPECT_FALSE(registry.isRedefined(jni, klassAt(0))); } TEST_F(KlassRegistryTest, NewClassAtRedefinedClassAddressStartsUnmarked) { @@ -329,16 +324,26 @@ TEST_F(KlassRegistryTest, NewClassAtRedefinedClassAddressStartsUnmarked) { registry.endRedefinition(klassAt(0), true); heap.collect(&mirrors[0]); ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(1))); - EXPECT_FALSE(registry.isRedefined(klassAt(0))); - EXPECT_EQ(mirror(1), registry.pin(jni, klassAt(0))); + EXPECT_FALSE(registry.isRedefined(jni, klassAt(0))); + EXPECT_TRUE(registry.contains(jni, klassAt(0))); } TEST_F(KlassRegistryTest, UnloadedClassIsReportedAbsent) { ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); heap.collect(&mirrors[0]); - KlassRegistry::PinStatus status; - EXPECT_EQ(nullptr, registry.pin(jni, klassAt(0), &status)); - EXPECT_EQ(KlassRegistry::PIN_ABSENT, status); + EXPECT_FALSE(registry.contains(jni, klassAt(0))); +} + +// Once a redefined class is unloaded, its mark is no longer reported, even +// before a new class at the same address replaces the entry: that class was +// never redefined. +TEST_F(KlassRegistryTest, MarkOfUnloadedClassIsNotReported) { + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + ASSERT_TRUE(registry.beginRedefinition(klassAt(0))); + registry.endRedefinition(klassAt(0), true); + ASSERT_TRUE(registry.isRedefined(jni, klassAt(0))); + heap.collect(&mirrors[0]); + EXPECT_FALSE(registry.isRedefined(jni, klassAt(0))); } // --------------------------------------------------------------------------- From 4e73a9403967ff2f74a58a6e1a406da843fbf438 Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Mon, 19 Oct 2026 22:36:04 +0200 Subject: [PATCH 24/31] Harden per-dump pinning - finishChunk() always calls beginDump(), with no list if GetLoadedClasses() failed, and ends the dump through a scope guard, so an exception out of writeCpool() can't leave a later dump reading a stale class list whose local references are gone. - A redefinition whose mark can't be recorded (no memory for the registry entry) is counted in klass_registry_add_failed, and every lazy frame is refused from then on instead of being trusted unmarked. - _lazy_used is read by redefining threads, so it is published with release/acquire atomics. - The per-dump Klass* table is mmap'd (OS::safeAlloc): at hundreds of thousands of classes it is megabytes, allocated and freed every dump, which would fragment the malloc arenas. - Lazy mode skips the start-up GetLoadedClasses() pass, which only registered classes. - Stale comments describing the class registry are updated. New gtests cover a dump without a class list, a lookup among 3000 pinned classes, an unmarkable redefinition, and a redefinition before lazy mode started. Co-Authored-By: Claude Opus 5.5 --- ddprof-lib/src/main/cpp/counters.h | 6 ++- ddprof-lib/src/main/cpp/flightRecorder.cpp | 18 ++++--- .../src/main/cpp/hotspot/hotspotSupport.cpp | 28 ++++++++--- .../src/main/cpp/hotspot/hotspotSupport.h | 4 ++ .../src/main/cpp/hotspot/klassRegistry.h | 3 +- ddprof-lib/src/main/cpp/jvmSupport.cpp | 6 ++- .../src/test/cpp/hotspotLazyResolve_ut.cpp | 50 +++++++++++++++++++ .../profiler/cpu/LazyJmethodIdTest.java | 10 ++-- 8 files changed, 101 insertions(+), 24 deletions(-) diff --git a/ddprof-lib/src/main/cpp/counters.h b/ddprof-lib/src/main/cpp/counters.h index 7e757784a8..e59e16d1bb 100644 --- a/ddprof-lib/src/main/cpp/counters.h +++ b/ddprof-lib/src/main/cpp/counters.h @@ -207,12 +207,16 @@ * class's method. */ \ X(KLASS_TRACE_ID_BEYOND_TAG_RANGE, "klass_trace_id_beyond_tag_range") \ /* Gauges, set by each dump that resolves lazy raw frames: the classes it \ - * pinned, and the nanoseconds it took to collect their Klass*s. */ \ + * pinned (array classes included), and the nanoseconds it took to collect \ + * their Klass*s. */ \ X(LAZY_DUMP_CLASSES, "lazy_dump_classes") \ X(LAZY_DUMP_PIN_NS, "lazy_dump_pin_ns") \ /* 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") \ + /* Redefinitions whose mark could not be recorded (out of memory); every \ + * lazy frame is refused once this is nonzero. */ \ + X(KLASS_REGISTRY_ADD_FAILED, "klass_registry_add_failed") \ /* Gauges: KlassRegistry's table slots (24 B each; NMT does not see the \ * table) and occupied slots, live or dead. It holds only redefined \ * classes. PURGED: dead entries dropped when the table was rebuilt. */ \ diff --git a/ddprof-lib/src/main/cpp/flightRecorder.cpp b/ddprof-lib/src/main/cpp/flightRecorder.cpp index ea954a09ce..fa07556f06 100644 --- a/ddprof-lib/src/main/cpp/flightRecorder.cpp +++ b/ddprof-lib/src/main/cpp/flightRecorder.cpp @@ -711,9 +711,10 @@ MethodInfo *Lookup::resolveMethod(ASGCT_CallFrame &frame) { return unknown_method; } - // 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. + // Raw Method* frames take their own path: resolution makes JNI calls (it + // collects the dump's pinned classes on first use and takes the registry + // mutex), 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); } @@ -1028,10 +1029,13 @@ off_t Recording::finishChunk(bool end_recording, bool do_cleanup) { // present in the list and receive no protection here. jvmtiError err = jvmti->GetLoadedClasses(&count, &classes); // fjmethodid=lazy resolves raw frames only of these classes: they stay - // pinned until the local references are deleted below. - if (!err) { - JVMSupport::beginDump(env, classes, count); - } + // pinned until the local references are deleted below. Always called, so a + // failed GetLoadedClasses() leaves no list at all rather than a stale one, + // and ended on every exit from this function, including a std::bad_alloc. + JVMSupport::beginDump(env, err ? nullptr : classes, err ? 0 : count); + struct DumpScope { + ~DumpScope() { JVMSupport::endDump(); } + } dump_scope; flush(&_cpu_monitor_buf); diff --git a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp index 04e77e1aec..54cb836f40 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp @@ -1479,6 +1479,7 @@ bool HotspotSupport::loadMethodIDsIfNeededImpl(jvmtiEnv *jvmti, JNIEnv *jni, jcl KlassRegistry HotspotSupport::_klass_registry; bool HotspotSupport::_lazy_used = false; +bool HotspotSupport::_redefinitions_untracked = false; const jclass* HotspotSupport::_dump_jclasses = nullptr; int HotspotSupport::_dump_jclass_count = 0; const void** HotspotSupport::_dump_classes = nullptr; @@ -1496,7 +1497,7 @@ bool HotspotSupport::initLazyResolution(JNIEnv* jni) { _klass_registry.publishCounters(); TEST_LOG("Raw frame tags: %s", calibrated ? "klass trace id" : "unavailable, lazy mode off"); if (calibrated) { - _lazy_used = true; + __atomic_store_n(&_lazy_used, true, __ATOMIC_RELEASE); } return calibrated; } @@ -1519,18 +1520,23 @@ bool HotspotSupport::isInstanceClass(jvmtiEnv *jvmti, jclass klass) { } void HotspotSupport::beforeClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass) { - if (!_lazy_used || !isInstanceClass(jvmti, klass)) { + if (!__atomic_load_n(&_lazy_used, __ATOMIC_ACQUIRE) || !isInstanceClass(jvmti, klass)) { return; } // Only redefined classes are registered, so the mark has a place to live. const void* vm_klass = VMKlass::fromJavaClass(jni, klass); if (_klass_registry.add(jni, vm_klass, klass) && _klass_registry.beginRedefinition(vm_klass)) { Counters::increment(KLASS_REGISTRY_REDEFINITIONS_MARKED); + } else { + // Out of memory for the mark: nothing can tell this class's frames + // apart any more, so refuse every lazy frame from now on. + Counters::increment(KLASS_REGISTRY_ADD_FAILED); + __atomic_store_n(&_redefinitions_untracked, true, __ATOMIC_RELEASE); } } void HotspotSupport::afterClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass, bool succeeded) { - if (!_lazy_used || !isInstanceClass(jvmti, klass)) { + if (!__atomic_load_n(&_lazy_used, __ATOMIC_ACQUIRE) || !isInstanceClass(jvmti, klass)) { return; } _klass_registry.endRedefinition(VMKlass::fromJavaClass(jni, klass), succeeded); @@ -1568,7 +1574,7 @@ bool HotspotSupport::isDumpClass(const void* klass) { void HotspotSupport::beginDump(JNIEnv* jni, const jclass* classes, int count) { endDump(); - if (!_lazy_used || count <= 0) { + if (!__atomic_load_n(&_lazy_used, __ATOMIC_ACQUIRE) || classes == nullptr || count <= 0) { return; } _dump_jclasses = classes; @@ -1585,7 +1591,10 @@ void HotspotSupport::buildDumpClasses() { while (capacity < (size_t)count * 2) { capacity <<= 1; } - const void** table = (const void**)calloc(capacity, sizeof(const void*)); + // mmap rather than malloc: at hundreds of thousands of classes this is + // megabytes, allocated and freed every dump, which would fragment the + // malloc arenas. Zero-filled. + const void** table = (const void**)OS::safeAlloc(capacity * sizeof(const void*)); if (table == nullptr) { _dump_jclasses = nullptr; // every lazy frame of this dump resolves as unknown return; @@ -1623,7 +1632,9 @@ void HotspotSupport::buildDumpClasses() { } void HotspotSupport::endDump() { - free(_dump_classes); + if (_dump_classes != nullptr) { + OS::safeFree(_dump_classes, _dump_classes_capacity * sizeof(const void*)); + } _dump_classes = nullptr; _dump_classes_capacity = 0; _dump_jclasses = nullptr; @@ -1916,7 +1927,7 @@ static bool protectedWalk(ProfiledThread* prof_thread, ResolvedMethod& result, W // boot, platform and application loaders, which are never unloaded) and for // methods a class's jmethodID cache does not cover after a redefinition; // lazy mode reaches here for every class without a jmethodID and resolves it -// through _klass_registry. +// against the classes the current dump pins. bool HotspotSupport::resolve(const void* tagged_method, int bci, ResolvedMethod& result) { assert(VM::isHotspot()); NO_INJECTION_ASSERT(tagged_method != nullptr); @@ -1977,7 +1988,8 @@ bool HotspotSupport::resolveLazily(ProfiledThread* prof_thread, VMMethod* vm_met Counters::increment(METHOD_RESOLVE_LAZY_UNLOADED); return false; } - if (_klass_registry.isRedefined(VM::jni(), holder)) { + if (__atomic_load_n(&_redefinitions_untracked, __ATOMIC_ACQUIRE) || + _klass_registry.isRedefined(VM::jni(), holder)) { // The Method* may have been freed and reused within the class. Counters::increment(METHOD_RESOLVE_LAZY_REDEFINED); return false; diff --git a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h index ebefe01dbb..4dbbfbcac9 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h @@ -160,6 +160,10 @@ class HotspotSupport { // on. static bool _lazy_used; + // Set if a redefinition could not be marked (out of memory for the + // registry entry): from then on every lazy frame is refused. + static bool _redefinitions_untracked; + // fjmethodid=lazy: the classes GetLoadedClasses() returned at the start // of the current dump. The dump holds a local reference to each of them, // so none can be unloaded until it ends. nullptr outside a dump. diff --git a/ddprof-lib/src/main/cpp/hotspot/klassRegistry.h b/ddprof-lib/src/main/cpp/hotspot/klassRegistry.h index 551ea56534..3891dc5e94 100644 --- a/ddprof-lib/src/main/cpp/hotspot/klassRegistry.h +++ b/ddprof-lib/src/main/cpp/hotspot/klassRegistry.h @@ -29,7 +29,8 @@ * Only redefined classes are added. Each entry holds a JNI weak global * reference to the class's mirror, so a mark is only reported while that * class is alive: once it is unloaded, a new class at the same Klass* address - * does not inherit it, and that class's own add() replaces the entry. Dead + * does not inherit it, and if that class is redefined too, its add() + * replaces the entry. Dead * entries are purged whenever the table would have to grow. * * All operations take an internal mutex and call JNI, so none of them may be diff --git a/ddprof-lib/src/main/cpp/jvmSupport.cpp b/ddprof-lib/src/main/cpp/jvmSupport.cpp index 2a8b4a4b92..f43b117d57 100644 --- a/ddprof-lib/src/main/cpp/jvmSupport.cpp +++ b/ddprof-lib/src/main/cpp/jvmSupport.cpp @@ -140,6 +140,9 @@ int JVMSupport::asyncGetCallTrace(ASGCT_CallFrame *frames, int max_depth, void* void JVMSupport::loadAllMethodIDsIfNeeded(jvmtiEnv *jvmti, JNIEnv *jni) { assert(getLoadState() != No_loaded && "Should not call before profiler execution"); + if (getLoadState() == Lazy_loaded) { + return; // nothing to preload; each dump pins the classes it needs + } jint class_count = 0; jclass *classes = nullptr; @@ -153,8 +156,7 @@ void JVMSupport::loadAllMethodIDsIfNeeded(jvmtiEnv *jvmti, JNIEnv *jni) { } jvmti->Deallocate((unsigned char *)classes); } - TEST_LOG("%s %d/%d classes", getLoadState() == Lazy_loaded ? "Registered (lazy jmethodIDs)" : "Preloaded jmethodIDs for", - loaded_count, class_count); + TEST_LOG("Preloaded jmethodIDs for %d/%d classes", loaded_count, class_count); } bool JVMSupport::loadMethodIDsIfNeeded(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass) { diff --git a/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp b/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp index 6af0f98d21..376dbaee6f 100644 --- a/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp +++ b/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp @@ -63,6 +63,7 @@ class HotspotSupportTestAccessor { public: static KlassRegistry& registry() { return HotspotSupport::_klass_registry; } static void setLazyUsed(bool used) { HotspotSupport::_lazy_used = used; } + static void setRedefinitionsUntracked(bool v) { HotspotSupport::_redefinitions_untracked = v; } static bool dumpClassesBuilt() { return HotspotSupport::_dump_classes != nullptr; } static void beforeClassRedefinition(jvmtiEnv* jvmti, JNIEnv* jni, jclass klass) { HotspotSupport::beforeClassRedefinition(jvmti, jni, klass); @@ -305,6 +306,7 @@ class FakeJvm { int new_local_ref_calls = 0; int get_class_methods_calls = 0; + bool fail_weak_refs = false; // NewWeakGlobalRef fails, as when out of memory jclass mirror(FakeObject& obj) { return reinterpret_cast(&obj); } @@ -326,6 +328,9 @@ class FakeJvm { } static jweak JNICALL newWeakGlobalRef(JNIEnv*, jobject obj) { + if (s_instance->fail_weak_refs) { + return nullptr; + } FakeWeak* w = new FakeWeak{reinterpret_cast(s_instance->resolve(obj))}; s_instance->_weaks.insert(w); return reinterpret_cast(w); @@ -413,6 +418,7 @@ class HotspotLazyResolveTest : public ::testing::Test { void TearDown() override { HotspotSupport::endDump(); HotspotSupportTestAccessor::setLazyUsed(false); + HotspotSupportTestAccessor::setRedefinitionsUntracked(false); HotspotSupportTestAccessor::registry().clear(_jvm.jni()); ProfiledThread::release(); KlassTraceId::setOffsetForTest(_saved_trace_id_offset); @@ -645,6 +651,50 @@ TEST_F(HotspotLazyResolveTest, RedefinitionMarkDoesNotCarryOverToNewClassAtSameA EXPECT_TRUE(resolvesToSpin(sample())); } +// A dump whose GetLoadedClasses() failed passes no list; it must not fall back +// to an earlier dump's list, whose local references are gone. +TEST_F(HotspotLazyResolveTest, DumpWithoutClassListPinsNothing) { + registerClass(_old_mirror); + ASGCT_CallFrame frame = sample(); + ASSERT_TRUE(resolvesToSpin(frame)); // an earlier dump, ended without endDump() + HotspotSupport::beginDump(_jvm.jni(), nullptr, 0); + ResolvedMethod result; + EXPECT_FALSE(HotspotSupport::resolve(frame.method, frame.bci, result)); +} + +// Enough pinned classes to grow the set past its minimum size, so lookups +// probe across a larger table. +TEST_F(HotspotLazyResolveTest, ClassIsFoundAmongManyPinnedClasses) { + static FakeKlass others[3000]; + static FakeObject other_mirrors[3000]; + for (int i = 0; i < 3000; i++) { + others[i] = _cls.klass; + other_mirrors[i] = FakeObject{100 + i, &others[i]}; + registerClass(other_mirrors[i]); + } + registerClass(_old_mirror); + EXPECT_TRUE(resolvesToSpin(sample())); +} + +// If a redefinition cannot be marked, nothing tells its class's frames apart +// any more, so every lazy frame is refused from then on. +TEST_F(HotspotLazyResolveTest, UnmarkableRedefinitionRefusesAllLazyFrames) { + registerClass(_old_mirror); + ASGCT_CallFrame frame = sample(); + _jvm.fail_weak_refs = true; + redefine(false); + _jvm.fail_weak_refs = false; + EXPECT_FALSE(resolve(frame)); +} + +// Before lazy mode has started there are no lazy frames, so redefinitions +// are not tracked. +TEST_F(HotspotLazyResolveTest, RedefinitionBeforeLazyModeIsNotTracked) { + HotspotSupportTestAccessor::setLazyUsed(false); + redefine(true); + EXPECT_EQ(0u, HotspotSupportTestAccessor::registry().size()); +} + // Outside a dump no class is pinned, so a lazy frame is never read. TEST_F(HotspotLazyResolveTest, FrameIsRefusedOutsideADump) { registerClass(_old_mirror); 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 ee0f1fab7b..2a2859d908 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 @@ -27,13 +27,13 @@ /** * Tests {@code fjmethodid=lazy}: no jmethodIDs are preloaded at ClassPrepare. walkVM stores - * raw {@code Method*} frames, and the dump thread resolves them only after - * {@code KlassRegistry} confirms the owning class is still loaded, reading names and line - * numbers from VM metadata without allocating any jmethodIDs. + * raw {@code Method*} frames, and the dump thread resolves them only if the owning class is + * among the classes the dump pins, reading names and line numbers from VM metadata without + * allocating any jmethodIDs. * *

    The sampled class is defined by a custom class loader. In the partial mode - * ({@code fjmethodid=false}) such classes are still preloaded, because the dump-time - * {@code FindClass} lookup cannot see them; lazy mode must resolve them without either. + * ({@code fjmethodid=false}) such classes are still preloaded; lazy mode must resolve them + * without preloading. */ public class LazyJmethodIdTest extends AbstractProfilerTest { From 44603722babee309c357217c689d6794384c51a7 Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Tue, 20 Oct 2026 13:50:54 +0200 Subject: [PATCH 25/31] Recheck untracked redefinitions after reading a raw frame's metadata A redefinition whose mark can't be recorded sets _redefinitions_untracked instead. resolveLazily() checked that flag only before reading the metadata, so a redefinition starting during the read slipped past the check afterwards, which only looked at the registry mark. It now rechecks the flag too, behind a full fence so the metadata reads can't be reordered past the load, and the hook stores the flag with sequential consistency. Co-Authored-By: Claude Opus 5.5 --- .../src/main/cpp/hotspot/hotspotSupport.cpp | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp index 54cb836f40..afd259f7a5 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp @@ -1531,7 +1531,9 @@ void HotspotSupport::beforeClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclas // Out of memory for the mark: nothing can tell this class's frames // apart any more, so refuse every lazy frame from now on. Counters::increment(KLASS_REGISTRY_ADD_FAILED); - __atomic_store_n(&_redefinitions_untracked, true, __ATOMIC_RELEASE); + // Sequentially consistent, pairing with the fence in resolveLazily(): + // a resolution that misses this store read everything before it. + __atomic_store_n(&_redefinitions_untracked, true, __ATOMIC_SEQ_CST); } } @@ -2017,8 +2019,15 @@ bool HotspotSupport::resolveLazily(ProfiledThread* prof_thread, VMMethod* vm_met } // The class is marked before a redefinition swaps its methods. If the mark // is still absent now, every read above happened before that swap, so the - // Method* we validated was the class's own; otherwise we cannot tell. - if (resolved && _klass_registry.isRedefined(VM::jni(), holder)) { + // Method* we validated was the class's own; otherwise we cannot tell. A + // redefinition that could not be marked sets _redefinitions_untracked + // instead, so that is checked again too. The fence keeps the metadata reads + // above from being reordered past that load. + if (resolved) { + __atomic_thread_fence(__ATOMIC_SEQ_CST); + } + if (resolved && (__atomic_load_n(&_redefinitions_untracked, __ATOMIC_SEQ_CST) || + _klass_registry.isRedefined(VM::jni(), holder))) { result.release(); Counters::increment(METHOD_RESOLVE_LAZY_REDEFINED); return false; From c93e3143d342fd704717f3627b1622cecb924c2f Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Tue, 20 Oct 2026 14:08:56 +0200 Subject: [PATCH 26/31] Clear the OutOfMemoryError of a failed redefinition-mark allocation KlassRegistry::add() runs in the RedefineClasses/RetransformClasses hooks, on the caller's thread. When NewWeakGlobalRef failed, the OutOfMemoryError JNI raises stayed pending, so the caller saw an exception it did not cause. add() now clears it; JNI forbids calling NewWeakGlobalRef with an exception pending, so the one pending afterwards is its own. Co-Authored-By: Claude Opus 5.5 --- .../src/main/cpp/hotspot/klassRegistry.cpp | 16 ++++++++++++++-- .../src/test/cpp/hotspotLazyResolve_ut.cpp | 11 +++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/ddprof-lib/src/main/cpp/hotspot/klassRegistry.cpp b/ddprof-lib/src/main/cpp/hotspot/klassRegistry.cpp index 21f24c28e9..719db710c1 100644 --- a/ddprof-lib/src/main/cpp/hotspot/klassRegistry.cpp +++ b/ddprof-lib/src/main/cpp/hotspot/klassRegistry.cpp @@ -91,6 +91,18 @@ void KlassRegistry::publishCounters() { publishLocked(); } +// NewWeakGlobalRef raises an OutOfMemoryError when it fails. add() runs in the +// redefinition hooks, on the thread that called RedefineClasses, which must +// not see an exception it did not cause. JNI forbids calling NewWeakGlobalRef +// with an exception pending, so one pending afterwards is its own. +static jweak newWeakRef(JNIEnv* jni, jobject obj) { + jweak ref = jni->NewWeakGlobalRef(obj); + if (ref == nullptr && jni->ExceptionCheck()) { + jni->ExceptionClear(); + } + return ref; +} + bool KlassRegistry::add(JNIEnv* jni, const void* klass, jclass mirror) { if (klass == nullptr || mirror == nullptr) { return false; @@ -103,7 +115,7 @@ bool KlassRegistry::add(JNIEnv* jni, const void* klass, jclass mirror) { if (jni->IsSameObject(e->mirror, mirror)) { return true; } - jweak ref = jni->NewWeakGlobalRef(mirror); + jweak ref = newWeakRef(jni, mirror); if (ref == nullptr) { return false; } @@ -120,7 +132,7 @@ bool KlassRegistry::add(JNIEnv* jni, const void* klass, jclass mirror) { if ((_size + 1) * 4 > _capacity * 3 && !rehash(jni)) { return false; } - jweak ref = jni->NewWeakGlobalRef(mirror); + jweak ref = newWeakRef(jni, mirror); if (ref == nullptr) { return false; } diff --git a/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp b/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp index 376dbaee6f..818f0b19dd 100644 --- a/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp +++ b/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp @@ -280,6 +280,8 @@ class FakeJvm { _jni_tbl.NewLocalRef = &newLocalRef; _jni_tbl.DeleteLocalRef = &deleteLocalRef; _jni_tbl.GetLongField = &getLongField; + _jni_tbl.ExceptionCheck = &exceptionCheck; + _jni_tbl.ExceptionClear = &exceptionClear; _jni_env.functions = &_jni_tbl; _jvmti_tbl.GetClassStatus = &getClassStatus; _jvmti_tbl.GetClassMethods = &getClassMethods; @@ -307,6 +309,7 @@ class FakeJvm { int new_local_ref_calls = 0; int get_class_methods_calls = 0; bool fail_weak_refs = false; // NewWeakGlobalRef fails, as when out of memory + bool exception_pending = false; // set by a failed NewWeakGlobalRef, as JNI does jclass mirror(FakeObject& obj) { return reinterpret_cast(&obj); } @@ -329,6 +332,7 @@ class FakeJvm { static jweak JNICALL newWeakGlobalRef(JNIEnv*, jobject obj) { if (s_instance->fail_weak_refs) { + s_instance->exception_pending = true; // OutOfMemoryError return nullptr; } FakeWeak* w = new FakeWeak{reinterpret_cast(s_instance->resolve(obj))}; @@ -336,6 +340,10 @@ class FakeJvm { return reinterpret_cast(w); } static void JNICALL deleteWeakGlobalRef(JNIEnv*, jweak) {} + static jboolean JNICALL exceptionCheck(JNIEnv*) { + return s_instance->exception_pending ? JNI_TRUE : JNI_FALSE; + } + static void JNICALL exceptionClear(JNIEnv*) { s_instance->exception_pending = false; } static jboolean JNICALL isSameObject(JNIEnv*, jobject a, jobject b) { return s_instance->resolve(a) == s_instance->resolve(b) ? JNI_TRUE : JNI_FALSE; } @@ -684,6 +692,9 @@ TEST_F(HotspotLazyResolveTest, UnmarkableRedefinitionRefusesAllLazyFrames) { _jvm.fail_weak_refs = true; redefine(false); _jvm.fail_weak_refs = false; + // The failed allocation's OutOfMemoryError must not reach the caller of + // RedefineClasses. + EXPECT_FALSE(_jvm.exception_pending); EXPECT_FALSE(resolve(frame)); } From 364df5590fda5be6ff2a3cdc9fb145a95073909f Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Tue, 20 Oct 2026 19:33:46 +0200 Subject: [PATCH 27/31] Harden lazy resolution against review findings - Redefinition hooks make no JNI call with a Java exception pending; the redefinition is treated as unmarkable, so lazy frames are refused. - endRedefinition() no longer marks a new class that replaced the entry while the call ran. - Every raw frame whose Method* uses the tag address bits is stored as unknown, in partial mode too; tagPointer()/makeRawKey() rely on it. - Trace-ID calibration makes up to MAX_ATTEMPTS new attempts per call, on fresh dimensions up to the JVM's 255-dimension limit, and takes an offset only once two attempts agree: pre-existing far-apart probes let the mirrors' heap addresses pass for trace IDs. - Line number tables are rechecked in the decoder's second pass, and malloc failures are counted (line_number_table_alloc_failed). - Deleted copy/move on SharedLineNumberTable and moves on ResolvedMethod; one klassSlot() helper for both Klass* hash tables. - Comment fixes: lazy-mode descriptions, gauge timing, Lookup lifetime, endDump() idempotency, rejected redefinitions' entries, Array layout, initExecution() ordering, calibration serialization. Co-Authored-By: Claude Opus 5.5 --- ddprof-lib/src/main/cpp/arguments.h | 2 +- ddprof-lib/src/main/cpp/counters.h | 11 +-- ddprof-lib/src/main/cpp/flightRecorder.cpp | 2 + ddprof-lib/src/main/cpp/flightRecorder.h | 14 ++-- ddprof-lib/src/main/cpp/frame.h | 2 + .../src/main/cpp/hotspot/hotspotSupport.cpp | 47 ++++++++---- .../src/main/cpp/hotspot/hotspotSupport.h | 11 ++- .../src/main/cpp/hotspot/klassRegistry.cpp | 16 ++--- .../src/main/cpp/hotspot/klassRegistry.h | 13 +++- .../src/main/cpp/hotspot/klassTraceId.cpp | 22 ++++-- .../src/main/cpp/hotspot/klassTraceId.h | 23 +++--- ddprof-lib/src/main/cpp/hotspot/vmStructs.h | 2 + ddprof-lib/src/main/cpp/jvmSupport.h | 5 +- ddprof-lib/src/main/cpp/profiler.cpp | 3 +- .../src/test/cpp/hotspotLazyResolve_ut.cpp | 29 ++++++++ ddprof-lib/src/test/cpp/klassRegistry_ut.cpp | 12 ++++ .../datadoghq/profiler/ExternalLauncher.java | 25 +++++++ .../cpu/LazyTraceIdCalibrationTest.java | 72 +++++++++++++++++++ 18 files changed, 262 insertions(+), 49 deletions(-) diff --git a/ddprof-lib/src/main/cpp/arguments.h b/ddprof-lib/src/main/cpp/arguments.h index b6fe1f074c..398a13c49c 100644 --- a/ddprof-lib/src/main/cpp/arguments.h +++ b/ddprof-lib/src/main/cpp/arguments.h @@ -274,7 +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 + bool _lazy_jmethodID; // fjmethodid=lazy: preload no jmethodIDs; resolve raw Method* frames from VM metadata at dump time Arguments(bool persistent = false) : _buf(NULL), diff --git a/ddprof-lib/src/main/cpp/counters.h b/ddprof-lib/src/main/cpp/counters.h index e59e16d1bb..62a45946c9 100644 --- a/ddprof-lib/src/main/cpp/counters.h +++ b/ddprof-lib/src/main/cpp/counters.h @@ -94,6 +94,8 @@ X(CALLTRACE_STORAGE_EXPANSION_SKIPPED, "calltrace_storage_expansion_skipped") \ X(LINE_NUMBER_TABLES, "line_number_tables") \ X(LINE_NUMBER_TABLE_UNREADABLE, "line_number_table_unreadable") \ + /* Raw frames' line number tables dropped because malloc() failed. */ \ + X(LINE_NUMBER_TABLE_ALLOC_FAILED, "line_number_table_alloc_failed") \ X(REMOTE_SYMBOLICATION_FRAMES, "remote_symbolication_frames") \ X(REMOTE_SYMBOLICATION_LIBS_WITH_BUILD_ID, "remote_symbolication_libs_with_build_id") \ X(REMOTE_SYMBOLICATION_BUILD_ID_CACHE_HITS, "remote_symbolication_build_id_cache_hits") \ @@ -200,8 +202,8 @@ /* Classes marked by the redefinition hooks (once per class per \ * redefine/retransform call). */ \ X(KLASS_REGISTRY_REDEFINITIONS_MARKED, "klass_registry_redefinitions_marked") \ - /* Gauge, set at each dump: loaded classes whose trace ID no longer fits \ - * in a raw frame's 25-bit tag. Nonzero means trace-ID tags of two classes \ + /* Gauge, set by each dump that resolves lazy raw frames: loaded classes \ + * whose 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. */ \ @@ -214,8 +216,9 @@ /* 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") \ - /* Redefinitions whose mark could not be recorded (out of memory); every \ - * lazy frame is refused once this is nonzero. */ \ + /* Redefinitions whose mark could not be recorded (no memory for it, or \ + * the hook was called with a Java exception pending); every lazy frame \ + * is refused for the rest of the process once this is nonzero. */ \ X(KLASS_REGISTRY_ADD_FAILED, "klass_registry_add_failed") \ /* Gauges: KlassRegistry's table slots (24 B each; NMT does not see the \ * table) and occupied slots, live or dead. It holds only redefined \ diff --git a/ddprof-lib/src/main/cpp/flightRecorder.cpp b/ddprof-lib/src/main/cpp/flightRecorder.cpp index fa07556f06..64c158df8e 100644 --- a/ddprof-lib/src/main/cpp/flightRecorder.cpp +++ b/ddprof-lib/src/main/cpp/flightRecorder.cpp @@ -1779,6 +1779,8 @@ int Recording::writeCpool(Buffer *buf, int *count_offset_in_cpool) { // classes are inserted into the new-active by fillJavaMethodInfo during // writeStackTraces/writeMethods, and those would not appear in the snapshot — // standby() captures the pre-rotation state which writeClasses extends. + // One Lookup per dump: its raw frame cache holds this dump's answers (see + // Lookup::_raw_method_cache), which the next dump's pinned classes can change. Lookup lookup(this, &_method_map, Profiler::instance()->classMap()); // CONSTANT pools: always non-empty, always emitted -> 5 sections. // writeThreads always emits: it inserts _tid unconditionally before checking. diff --git a/ddprof-lib/src/main/cpp/flightRecorder.h b/ddprof-lib/src/main/cpp/flightRecorder.h index 9d1911b94c..5c827a2ced 100644 --- a/ddprof-lib/src/main/cpp/flightRecorder.h +++ b/ddprof-lib/src/main/cpp/flightRecorder.h @@ -117,6 +117,11 @@ class SharedLineNumberTable { SharedLineNumberTable(int size, jvmtiLineNumberEntry *ptr, bool jvmti_allocated = true) : _size(size), _table(ptr), _jvmti_allocated(jvmti_allocated) {} ~SharedLineNumberTable(); + // The destructor frees _table, so a copy would free it twice. + SharedLineNumberTable(const SharedLineNumberTable&) = delete; + SharedLineNumberTable& operator=(const SharedLineNumberTable&) = delete; + SharedLineNumberTable(SharedLineNumberTable&&) = delete; + SharedLineNumberTable& operator=(SharedLineNumberTable&&) = delete; }; class MethodInfo { @@ -185,8 +190,8 @@ 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 + // A raw frame's row: its untagged Method* (below 2^48, which + // HotspotSupport::fillJavaFrame() checks when it stores the frame), 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 @@ -497,8 +502,9 @@ class Lookup { // 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. + // refused one stays refused. Recording::writeCpool() constructs a Lookup + // for each dump, so the cache never outlives one. MethodMap nodes are not + // erased while a Lookup exists, so the row pointers stay valid. struct RawMethodKey { const void* method; uint32_t tag; diff --git a/ddprof-lib/src/main/cpp/frame.h b/ddprof-lib/src/main/cpp/frame.h index 438575d6cb..eeeeb88bd8 100644 --- a/ddprof-lib/src/main/cpp/frame.h +++ b/ddprof-lib/src/main/cpp/frame.h @@ -95,6 +95,8 @@ class FrameType { return ((uintptr_t)ptr & ~POINTER_ADDRESS_MASK) == 0; } + // Every caller has checked canTagPointer(ptr) first: HotspotSupport:: + // fillJavaFrame() refuses raw frames whose Method* fails it. 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); diff --git a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp index afd259f7a5..5aa5701f42 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp @@ -222,12 +222,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); + // Every raw frame is resolved by its untagged Method* (and keyed by + // it in Lookup::makeRawKey), both of which assume the address bits + // above 2^48 are clear; this check is the one place that ensures it. uint32_t tag; - if (JVMSupport::getLoadState() != JVMSupport::Lazy_loaded) { + if (!FrameType::canTagPointer(method)) { + Counters::increment(WALKVM_RAW_FRAME_UNTAGGABLE); + fillFrame(frame, type, bci, JMETHODID_NOT_WALKABLE); + } else 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. + } else if (!lazyFrameTag(method, &tag)) { + // fjmethodid=lazy, but 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. @@ -1523,13 +1528,20 @@ void HotspotSupport::beforeClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclas if (!__atomic_load_n(&_lazy_used, __ATOMIC_ACQUIRE) || !isInstanceClass(jvmti, klass)) { return; } + // JVMTI allows RedefineClasses with a Java exception pending, but JNI + // forbids the calls that marking the class needs; then the class can't be + // marked, as when there is no memory for the mark. // Only redefined classes are registered, so the mark has a place to live. - const void* vm_klass = VMKlass::fromJavaClass(jni, klass); - if (_klass_registry.add(jni, vm_klass, klass) && _klass_registry.beginRedefinition(vm_klass)) { + // An entry stays after a call JVMTI rejects (without a lasting mark), and + // is purged when the table next grows after its class is unloaded. + const void* vm_klass = jni->ExceptionCheck() ? nullptr : VMKlass::fromJavaClass(jni, klass); + if (vm_klass != nullptr && _klass_registry.add(jni, vm_klass, klass) && + _klass_registry.beginRedefinition(vm_klass)) { Counters::increment(KLASS_REGISTRY_REDEFINITIONS_MARKED); } else { - // Out of memory for the mark: nothing can tell this class's frames - // apart any more, so refuse every lazy frame from now on. + // Nothing can tell this class's frames apart any more, so refuse + // every lazy frame from now on. Deliberately never reset: raw frames + // sampled before the redefinition can outlive any session boundary. Counters::increment(KLASS_REGISTRY_ADD_FAILED); // Sequentially consistent, pairing with the fence in resolveLazily(): // a resolution that misses this store read everything before it. @@ -1541,6 +1553,11 @@ void HotspotSupport::afterClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclass if (!__atomic_load_n(&_lazy_used, __ATOMIC_ACQUIRE) || !isInstanceClass(jvmti, klass)) { return; } + if (jni->ExceptionCheck()) { + // No JNI calls allowed. The class stays marked as being redefined, + // so its raw frames are refused for good: safe, if lossy. + 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 @@ -1564,7 +1581,7 @@ bool HotspotSupport::isDumpClass(const void* klass) { return false; } size_t mask = _dump_classes_capacity - 1; - for (size_t i = ((uintptr_t)klass >> 3) * 0x9E3779B97F4A7C15ULL >> 32 & mask; ; i = (i + 1) & mask) { + for (size_t i = klassSlot(klass, _dump_classes_capacity); ; i = (i + 1) & mask) { if (_dump_classes[i] == klass) { return true; } @@ -1613,7 +1630,7 @@ void HotspotSupport::buildDumpClasses() { if (KlassTraceId::read(klass, &trace_id) && trace_id > TAG_VALUE_MASK) { beyond_tag_range++; } - for (size_t i = ((uintptr_t)klass >> 3) * 0x9E3779B97F4A7C15ULL >> 32 & mask; ; i = (i + 1) & mask) { + for (size_t i = klassSlot(klass, capacity); ; i = (i + 1) & mask) { if (table[i] == klass) { break; } @@ -1791,19 +1808,25 @@ static bool readLineNumberTable(VMConstMethod* const_method, jvmtiLineNumberEntry* entries = (jvmtiLineNumberEntry*)malloc((size_t)count * sizeof(jvmtiLineNumberEntry)); if (entries == nullptr) { + Counters::increment(LINE_NUMBER_TABLE_ALLOC_FAILED); 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; + // Pass 2 checks every entry again and that the stream still ends after + // `count` of them: if it changed between the passes, the metadata is stale. VMLineNumberStream reader(compressed); for (int i = 0; i < count; i++) { - if (!reader.readPair()) { - return false; // stream changed under us between the passes + if (!reader.readPair() || reader.bci() < 0 || reader.bci() > code_size) { + return false; } entries[i].start_location = (jlocation)reader.bci(); entries[i].line_number = (jint)reader.line(); } + if (reader.readPair()) { + return false; + } table_size = count; return true; } diff --git a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h index 4dbbfbcac9..404dac4815 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h @@ -71,6 +71,8 @@ class ResolvedMethod { ResolvedMethod(const ResolvedMethod&) = delete; ResolvedMethod& operator=(const ResolvedMethod&) = delete; + ResolvedMethod(ResolvedMethod&&) = delete; + ResolvedMethod& operator=(ResolvedMethod&&) = delete; ResolvedMethod(); ~ResolvedMethod() { release(); } @@ -160,8 +162,11 @@ class HotspotSupport { // on. static bool _lazy_used; - // Set if a redefinition could not be marked (out of memory for the - // registry entry): from then on every lazy frame is refused. + // Set if a redefinition could not be marked (no memory for the registry + // entry, or the hook was called with a Java exception pending): from then + // on every lazy frame is refused, for the rest of the process, since raw + // frames sampled before that redefinition can outlive any profiler + // session. klass_registry_add_failed counts these. static bool _redefinitions_untracked; // fjmethodid=lazy: the classes GetLoadedClasses() returned at the start @@ -246,6 +251,8 @@ class HotspotSupport { // Around each dump, with the classes GetLoadedClasses() returned. The // caller keeps the array and its local references valid until after // endDump(). resolve() accepts raw frames of exactly these classes. + // endDump() is idempotent: Recording::finishChunk() calls it before + // deleting the local references, and again from its DumpScope guard. static void beginDump(JNIEnv* jni, const jclass* classes, int count); static void endDump(); diff --git a/ddprof-lib/src/main/cpp/hotspot/klassRegistry.cpp b/ddprof-lib/src/main/cpp/hotspot/klassRegistry.cpp index 719db710c1..d578109ecd 100644 --- a/ddprof-lib/src/main/cpp/hotspot/klassRegistry.cpp +++ b/ddprof-lib/src/main/cpp/hotspot/klassRegistry.cpp @@ -9,17 +9,11 @@ #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); + size_t i = klassSlot(klass, _capacity); while (_table[i].klass != nullptr && _table[i].klass != klass) { i = (i + 1) & (_capacity - 1); } @@ -169,10 +163,12 @@ void KlassRegistry::endRedefinition(const void* klass, bool succeeded) { return; } // Clamped: the entry may have been replaced (a new class at this address) - // while the call was in progress, which resets the count. - if (e->redefinitions_in_progress > 0) { - e->redefinitions_in_progress--; + // while the call was in progress, which resets the count. Then the + // permanent mark is not applied either: the new class was not redefined. + if (e->redefinitions_in_progress == 0) { + return; } + e->redefinitions_in_progress--; if (succeeded) { e->redefined = true; } diff --git a/ddprof-lib/src/main/cpp/hotspot/klassRegistry.h b/ddprof-lib/src/main/cpp/hotspot/klassRegistry.h index 3891dc5e94..53cf996a4c 100644 --- a/ddprof-lib/src/main/cpp/hotspot/klassRegistry.h +++ b/ddprof-lib/src/main/cpp/hotspot/klassRegistry.h @@ -6,6 +6,7 @@ #ifndef _HOTSPOT_KLASSREGISTRY_H #define _HOTSPOT_KLASSREGISTRY_H +#include "common.h" #include "mutex.h" #include @@ -26,7 +27,8 @@ * preloaded, so its later frames are not raw.) A failed call leaves the * methods untouched, and the class as it was. * - * Only redefined classes are added. Each entry holds a JNI weak global + * Only classes passed to a redefinition call are added, including calls + * that JVMTI then rejects; such an entry has no mark. Each entry holds a JNI weak global * reference to the class's mirror, so a mark is only reported while that * class is alive: once it is unloaded, a new class at the same Klass* address * does not inherit it, and if that class is redefined too, its add() @@ -36,6 +38,15 @@ * All operations take an internal mutex and call JNI, so none of them may be * used from a signal handler. */ +// The home slot of `klass` in an open-addressing table of `capacity` (a power +// of two) slots, probed linearly from there. Shared by KlassRegistry and the +// per-dump class set in HotspotSupport, whose inserts and lookups must agree. +static inline size_t klassSlot(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) * KNUTH_MULTIPLICATIVE_CONSTANT; + return (size_t)(h >> 32) & (capacity - 1); +} + class KlassRegistry { struct Entry { const void* klass; diff --git a/ddprof-lib/src/main/cpp/hotspot/klassTraceId.cpp b/ddprof-lib/src/main/cpp/hotspot/klassTraceId.cpp index c2c99fc58d..7df24a6dba 100644 --- a/ddprof-lib/src/main/cpp/hotspot/klassTraceId.cpp +++ b/ddprof-lib/src/main/cpp/hotspot/klassTraceId.cpp @@ -67,6 +67,9 @@ int KlassTraceId::findOffset(const void* const* klasses, int count, size_t klass // Highest dimension any attempt has created so far; the next attempt probes // the PROBE_COUNT dimensions above it. static int _probed_dims = 0; +// The offset the last successful attempt found, waiting for a second attempt +// to confirm it. +static int _candidate = -1; // Dumps what each probe holds at every offset findOffset() looked at, so a // release whose layout calibration rejects can be diagnosed from a debug CI @@ -96,7 +99,8 @@ static void logProbes(const void* const* klasses, size_t klass_size) { static int probe(JNIEnv* jni, int first_dim, size_t klass_size) { static const char ELEMENT[] = "Ljava/lang/Void;"; const int top_dim = first_dim + KlassTraceId::PROBE_COUNT - 1; - char name[KlassTraceId::MAX_ATTEMPTS * KlassTraceId::PROBE_COUNT + sizeof(ELEMENT)]; + char name[KlassTraceId::MAX_DIMENSIONS + sizeof(ELEMENT)]; + assert(first_dim >= 1 && top_dim <= KlassTraceId::MAX_DIMENSIONS); memset(name, '[', top_dim); memcpy(name + top_dim, ELEMENT, sizeof(ELEMENT)); @@ -127,7 +131,8 @@ static int probe(JNIEnv* jni, int first_dim, size_t klass_size) { } bool KlassTraceId::calibrate(JNIEnv* jni) { - // Runs from profiler start, which is serialized, so plain statics will do. + // Runs from Profiler::start() with _state_lock held, so plain statics will + // do. if (offset() >= 0) { return true; } @@ -139,14 +144,23 @@ bool KlassTraceId::calibrate(JNIEnv* jni) { // A failed attempt usually means the probes' IDs were not consecutive: // other threads created many classes meanwhile, or some of the dimensions // already existed. Fresh dimensions make either unlikely to repeat. - while (_probed_dims + PROBE_COUNT <= MAX_ATTEMPTS * PROBE_COUNT) { + // If all of an attempt's dimensions already existed, created far apart, + // their trace IDs fail the check while another field can pass it: the + // mirrors' heap addresses, shifted right by 16, can be distinct and close + // together. Probes created back to back have adjacent mirrors, which that + // shift makes equal, so an offset is only taken once two attempts on + // different dimensions agree on it. + for (int attempt = 0; attempt < MAX_ATTEMPTS && _probed_dims + PROBE_COUNT <= MAX_DIMENSIONS; attempt++) { const int first_dim = _probed_dims + 1; _probed_dims += PROBE_COUNT; int found = probe(jni, first_dim, klass_size); - if (found >= 0) { + if (found >= 0 && found == _candidate) { __atomic_store_n(&_offset, found, __ATOMIC_RELEASE); return true; } + if (found >= 0) { + _candidate = found; + } } return false; } diff --git a/ddprof-lib/src/main/cpp/hotspot/klassTraceId.h b/ddprof-lib/src/main/cpp/hotspot/klassTraceId.h index 8a92f0bd7e..43a7fef006 100644 --- a/ddprof-lib/src/main/cpp/hotspot/klassTraceId.h +++ b/ddprof-lib/src/main/cpp/hotspot/klassTraceId.h @@ -26,8 +26,9 @@ * 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. + * looks for the one 8-byte-aligned offset within sizeof(Klass) at which all + * of them hold distinct IDs no further apart than consecutive creation + * allows, accepting it once two such attempts agree. * Builds without JFR, or a layout where no single offset qualifies, leave the * offset unknown, and fjmethodid=lazy does not run. */ @@ -38,9 +39,13 @@ class KlassTraceId { // Array dimensions created by one calibration attempt: one probe klass per // dimension. static const int PROBE_COUNT = 6; - // Attempts calibrate() makes, each on PROBE_COUNT dimensions above the - // previous attempt's, so each probes klasses it has just created. + // Attempts each calibrate() call makes, each on PROBE_COUNT dimensions + // above the previous attempt's (of this call or an earlier one), so each + // probes klasses it has just created. static const int MAX_ATTEMPTS = 4; + // The JVM's limit on array dimensions (JVMS 4.4.1), which bounds the + // dimensions all calibrate() calls of the process can probe together. + static const int MAX_DIMENSIONS = 255; // 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; @@ -54,10 +59,12 @@ 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. - * Once an offset is found, later calls return true without probing again; - * after a failure, the next call tries again on fresh dimensions. Returns - * true if an offset is known. + * Creates the probe klasses through JNI and records the offset that two + * attempts on different dimensions agree on. Once an offset is found, later calls return true without probing again; + * after a failure, the next call makes up to MAX_ATTEMPTS new attempts on + * fresh dimensions, until MAX_DIMENSIONS are used up. Returns true if an + * offset is known. Callers must be serialized: Profiler::start() calls it + * with _state_lock held. */ static bool calibrate(JNIEnv* jni); diff --git a/ddprof-lib/src/main/cpp/hotspot/vmStructs.h b/ddprof-lib/src/main/cpp/hotspot/vmStructs.h index 9e354574ad..cf39126cb2 100644 --- a/ddprof-lib/src/main/cpp/hotspot/vmStructs.h +++ b/ddprof-lib/src/main/cpp/hotspot/vmStructs.h @@ -769,6 +769,8 @@ DECLARE(VMKlass) if (len < 0 || len > 0xffff) { return false; } + // HotSpot's Array (oops/array.hpp) is `int _length; T _data[1];`, + // so for pointer elements the data starts at the pointer size. void** data = (void**)(methods + sizeof(void*)); if (idnum_hint >= 0 && idnum_hint < len && SafeAccess::loadPtr(&data[idnum_hint], nullptr) == method) { return true; diff --git a/ddprof-lib/src/main/cpp/jvmSupport.h b/ddprof-lib/src/main/cpp/jvmSupport.h index 43990f9a09..e7bae6bb44 100644 --- a/ddprof-lib/src/main/cpp/jvmSupport.h +++ b/ddprof-lib/src/main/cpp/jvmSupport.h @@ -29,7 +29,7 @@ class JVMSupport { 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 - Lazy_loaded // None preloaded; allocated at dump time for sampled classes (fjmethodid=lazy) + Lazy_loaded // None preloaded (fjmethodid=lazy); only classes redefined meanwhile get them, from the redefinition hook }; friend class HotspotSupport; @@ -78,7 +78,8 @@ class JVMSupport { // Around each dump: `classes` are the ones GetLoadedClasses() returned, // referenced by the caller until after endDump(). Raw frames of other - // classes resolve as unknown. + // classes resolve as unknown. endDump() may be called more than once, and + // without a beginDump(). static void beginDump(JNIEnv *jni, const jclass *classes, int count); static void endDump(); diff --git a/ddprof-lib/src/main/cpp/profiler.cpp b/ddprof-lib/src/main/cpp/profiler.cpp index 778cc5bbdd..94f8df8d0d 100644 --- a/ddprof-lib/src/main/cpp/profiler.cpp +++ b/ddprof-lib/src/main/cpp/profiler.cpp @@ -1630,7 +1630,8 @@ Error Profiler::start(Arguments &args, bool reset) { } args._cstack = _cstack; - // Prepare JVMSupport for execution + // Prepare JVMSupport for execution. Must follow the _cstack resolution + // above: initExecution() picks the jmethodID mode from cstackMode(). JVMSupport::initExecution(args, VM::jvmti(), VM::jni()); // Must precede the first updateSymbols(): it is what allows LibraryPatcher to diff --git a/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp b/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp index 818f0b19dd..1d2142056b 100644 --- a/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp +++ b/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp @@ -623,6 +623,21 @@ TEST_F(HotspotLazyResolveTest, PartialModeFramesAreStoredUntagged) { EXPECT_EQ(static_cast(_cls.vmMethod()), HotspotSupport::rawFrameMethod(frame.bci, frame.method)); } +// A raw frame is later looked up by its Method* with the bits above 2^48 +// masked off, so a Method* that uses them is stored as an unknown frame in +// every mode instead of one that would resolve to another address. +TEST_F(HotspotLazyResolveTest, RawFrameAboveTaggableAddressesIsStoredUnknown) { + const VMMethod* high = reinterpret_cast((uintptr_t)1 << 52 | (uintptr_t)_cls.vmMethod()); + for (bool lazy : {true, false}) { + JVMSupportTestAccessor::setLoadState(lazy ? JVMSupportTestAccessor::LazyLoaded() + : JVMSupportTestAccessor::PartialLoaded()); + ASGCT_CallFrame frame{}; + HotspotSupport::fillJavaFrame(frame, FRAME_JIT_COMPILED, 7, nullptr, high); + EXPECT_FALSE(FrameType::isRawPointer(frame.bci)) << "lazy=" << lazy; + EXPECT_EQ(JMETHODID_NOT_WALKABLE, frame.method_id) << "lazy=" << lazy; + } +} + // 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 // against the classes the dump pins, which is what keeps a custom loader's @@ -698,6 +713,20 @@ TEST_F(HotspotLazyResolveTest, UnmarkableRedefinitionRefusesAllLazyFrames) { EXPECT_FALSE(resolve(frame)); } +// A hook called with a Java exception pending cannot make JNI calls, so it +// cannot mark the class; every lazy frame is refused instead, and the +// exception is left for the caller of RedefineClasses. +TEST_F(HotspotLazyResolveTest, RedefinitionWithPendingExceptionRefusesAllLazyFrames) { + registerClass(_old_mirror); + ASGCT_CallFrame frame = sample(); + _jvm.exception_pending = true; + redefine(true); + EXPECT_TRUE(_jvm.exception_pending); + _jvm.exception_pending = false; + EXPECT_EQ(0u, HotspotSupportTestAccessor::registry().size()); + EXPECT_FALSE(resolve(frame)); +} + // Before lazy mode has started there are no lazy frames, so redefinitions // are not tracked. TEST_F(HotspotLazyResolveTest, RedefinitionBeforeLazyModeIsNotTracked) { diff --git a/ddprof-lib/src/test/cpp/klassRegistry_ut.cpp b/ddprof-lib/src/test/cpp/klassRegistry_ut.cpp index 30ba86d375..e42afdbbe5 100644 --- a/ddprof-lib/src/test/cpp/klassRegistry_ut.cpp +++ b/ddprof-lib/src/test/cpp/klassRegistry_ut.cpp @@ -328,6 +328,18 @@ TEST_F(KlassRegistryTest, NewClassAtRedefinedClassAddressStartsUnmarked) { EXPECT_TRUE(registry.contains(jni, klassAt(0))); } +// A class unloaded while its redefinition call runs can be replaced by a new +// class at the same address before the call returns; the call's success must +// not mark the new class, which was never redefined. +TEST_F(KlassRegistryTest, RedefinitionEndingAfterReplacementDoesNotMarkNewClass) { + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + ASSERT_TRUE(registry.beginRedefinition(klassAt(0))); + heap.collect(&mirrors[0]); + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(1))); + registry.endRedefinition(klassAt(0), true); + EXPECT_FALSE(registry.isRedefined(jni, klassAt(0))); +} + TEST_F(KlassRegistryTest, UnloadedClassIsReportedAbsent) { ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); heap.collect(&mirrors[0]); 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 22350b83f0..2b75b89e5e 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/ExternalLauncher.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/ExternalLauncher.java @@ -74,6 +74,10 @@ * creates {@code Void[]} and {@code Void[][]} with a few hundred class definitions in between, so their * JFR trace IDs are far apart, then starts the profiler, prints the {@code klass_trace_id_offset} * counter and stops it; see {@code LazyTraceIdCalibrationTest}

  • + *
  • lazy-calibration-retry <comma delimited profiler command list, required> - creates + * {@code Void} arrays of every dimension the first profiler start probes, each with a few hundred + * class definitions in between, then starts the profiler twice, printing the + * {@code klass_trace_id_offset} counter after each start; see {@code LazyTraceIdCalibrationTest}
  • *
  • 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 @@ -280,6 +284,27 @@ public static void main(String[] args) throws Exception { + instance.getDebugCounters().get("klass_trace_id_offset") + " arrays=" + secondDimension.getClass().getName()); instance.stop(); + } else if (args[0].equals("lazy-calibration-retry")) { + if (args.length < 2 || args[1].isEmpty()) { + throw new IllegalArgumentException("lazy-calibration-retry requires a profiler command list"); + } + // KlassTraceId::MAX_ATTEMPTS * PROBE_COUNT: every dimension one calibrate() call probes. + final int firstCallDimensions = 24; + Class arrayClass = Void.class; + for (int dim = 1; dim <= firstCallDimensions; dim++) { + for (int i = 0; i < 100; i++) { + SpinClasses.define("com/datadoghq/profiler/generated/CalibrationRetryGap" + dim + "_" + i); + } + arrayClass = java.lang.reflect.Array.newInstance(arrayClass, 0).getClass(); + } + JavaProfiler instance = JavaProfiler.getInstance(); + for (String start : new String[] {"first", "second"}) { + instance.execute(args[1]); + System.out.println("[calibration] " + start + " klass_trace_id_offset=" + + instance.getDebugCounters().get("klass_trace_id_offset") + + " arrays=" + arrayClass.getName().length()); + 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"); diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyTraceIdCalibrationTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyTraceIdCalibrationTest.java index df4c874dc4..99cc31e041 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyTraceIdCalibrationTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyTraceIdCalibrationTest.java @@ -31,9 +31,17 @@ * creates {@code Void[]} and {@code Void[][]} with a few hundred class definitions in between, so * those two IDs are far apart and an attempt that includes them cannot succeed. Calibration must * still find the offset by probing dimensions it creates itself. + * + *

    A calibration that fails must not use up later profiler starts' attempts: a second child JVM + * pre-creates every dimension the first start probes, so that start fails, and the next start must + * still calibrate on fresh dimensions, to the same offset a JVM without pre-created dimensions + * finds. Pre-created dimensions also put another field to the test: the mirrors' heap addresses + * can pass for trace IDs, and calibration must not take that offset. */ public class LazyTraceIdCalibrationTest extends AbstractProcessProfilerTest { private static final String PREFIX = "[calibration] klass_trace_id_offset="; + private static final String FIRST_PREFIX = "[calibration] first klass_trace_id_offset="; + private static final String SECOND_PREFIX = "[calibration] second klass_trace_id_offset="; @Test @Timeout(60) @@ -70,4 +78,68 @@ public void calibratesWhenLowProbeDimensionsAlreadyExist() throws Exception { Assumptions.assumeTrue(Platform.hasJfr(), "JDK without JFR"); assertTrue(offset.get() > 0, "trace-ID calibration failed: klass_trace_id_offset=" + offset.get()); } + + @Test + @Timeout(60) + public void retriesOnNextStartAfterFailedCalibration() throws Exception { + Assumptions.assumeFalse(Platform.isZing() || Platform.isJ9() || Platform.isGraal(), "HotSpot only"); + Assumptions.assumeTrue(Platform.hasJfr(), "JDK without JFR"); + AtomicLong first = new AtomicLong(Long.MIN_VALUE); + AtomicLong second = new AtomicLong(Long.MIN_VALUE); + AtomicReference malformed = new AtomicReference<>(); + Path recording = Files.createTempFile("lazy-calibration-retry-", ".jfr"); + LaunchResult result; + try { + result = launch("lazy-calibration-retry", Collections.emptyList(), + "start,cpu=10ms,cstack=vm,fjmethodid=lazy,jfr,file=" + recording, line -> { + parseOffset(line, FIRST_PREFIX, first, malformed); + parseOffset(line, SECOND_PREFIX, second, malformed); + return LineConsumerResult.CONTINUE; + }, null); + } finally { + try { Files.deleteIfExists(recording); } catch (IOException ignored) {} + } + assertTrue(result.inTime, "child JVM did not finish in time"); + assertEquals(0, result.exitCode, "child JVM failed"); + assertNull(malformed.get(), "unparsable offset line from the child JVM"); + Assumptions.assumeTrue(first.get() != Long.MIN_VALUE, "build without debug counters"); + assertEquals(-1, first.get(), "first start should fail on pre-created dimensions"); + assertTrue(second.get() > 0, "second start did not calibrate: klass_trace_id_offset=" + second.get()); + assertEquals(referenceOffset(), second.get(), "second start calibrated to the wrong offset"); + } + + /** The offset a child JVM calibrates to with only the two lowest dimensions pre-created. */ + private long referenceOffset() throws Exception { + AtomicLong offset = new AtomicLong(Long.MIN_VALUE); + AtomicReference malformed = new AtomicReference<>(); + Path recording = Files.createTempFile("lazy-calibration-reference-", ".jfr"); + try { + LaunchResult result = launch("lazy-calibration-preexisting-arrays", Collections.emptyList(), + "start,cpu=10ms,cstack=vm,fjmethodid=lazy,jfr,file=" + recording, line -> { + parseOffset(line, PREFIX, offset, malformed); + return LineConsumerResult.CONTINUE; + }, null); + assertTrue(result.inTime && result.exitCode == 0, "reference child JVM failed"); + } finally { + try { Files.deleteIfExists(recording); } catch (IOException ignored) {} + } + assertNull(malformed.get(), "unparsable offset line from the reference child JVM"); + assertTrue(offset.get() > 0, "reference child JVM did not calibrate: " + offset.get()); + return offset.get(); + } + + private static void parseOffset(String line, String prefix, AtomicLong target, AtomicReference malformed) { + if (!line.startsWith(prefix)) { + return; + } + String value = line.substring(prefix.length()).split(" ")[0]; + if ("null".equals(value)) { + return; + } + try { + target.set(Long.parseLong(value)); + } catch (NumberFormatException e) { + malformed.compareAndSet(null, line); + } + } } From a212fd3e61c0cccab20514f54f465f8721bcf01b Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Tue, 20 Oct 2026 19:34:03 +0200 Subject: [PATCH 28/31] Tighten lazy-mode Java tests - LazyJmethodIdTest: wait for the profiler, spin longer, and assert that the dump refused raw frames of the unloaded class. - LazyLineNumberConsistencyTest: fail on a (method, bci) given different lines within one recording. - LazyRedefinitionStressTest: assert lazy mode is active, retransform the class alongside the redefinitions, check the tail thread's failure, require spin frames to carry spin's lines, delete the dump in finally. - LazyTraceIdJfrValidationTest: fail instead of skipping when calibration fails on a JDK with JFR. Co-Authored-By: Claude Opus 5.5 --- .../profiler/cpu/LazyJmethodIdTest.java | 15 ++++- .../cpu/LazyLineNumberConsistencyTest.java | 20 ++++-- .../cpu/LazyRedefinitionStressTest.java | 63 ++++++++++++++++--- .../cpu/LazyTraceIdJfrValidationTest.java | 15 +++-- 4 files changed, 91 insertions(+), 22 deletions(-) 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 2a2859d908..0f28945d1b 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 @@ -87,7 +87,9 @@ public void customLoaderFramesResolve() throws Exception { /** * Frames whose class was unloaded between the sample and the dump must not be - * dereferenced: the dump completes and those frames serialize as unknown. + * dereferenced: the dump completes and those frames serialize as unknown. Where debug + * counters are available, the dump must also have refused raw frames, so the class was + * sampled and the refusal path ran. */ @Test @Timeout(60) @@ -103,7 +105,7 @@ public void framesOfUnloadedClassAreDroppedSafely() throws Exception { try { profiler.execute("start," + LAZY_COMMAND + ",jfr,file=" + baseFile.toAbsolutePath()); try { - Thread.sleep(200); + waitForProfilerReady(2000); WeakReference loaderRef = spinInUnloadableClass(className); long deadline = System.currentTimeMillis() + 8_000; @@ -131,6 +133,13 @@ public void framesOfUnloadedClassAreDroppedSafely() throws Exception { + ", method_resolve_lazy_not_current=" + counters.get("method_resolve_lazy_not_current") + ", method_resolve_lazy_unreadable=" + counters.get("method_resolve_lazy_unreadable") + ", method_resolve_fault_recovered=" + counters.get("method_resolve_fault_recovered")); + // Absent in builds without counters; only check when present. + if (counters.containsKey("method_resolve_lazy_unloaded")) { + long refused = counters.getOrDefault("method_resolve_lazy_unloaded", 0L) + + counters.getOrDefault("method_resolve_lazy_not_current", 0L) + + counters.getOrDefault("method_resolve_lazy_unreadable", 0L); + assertTrue(refused > 0, "No raw frame was refused, so the unloaded class was never sampled"); + } } finally { try { Files.deleteIfExists(baseFile); } catch (IOException ignored) {} try { Files.deleteIfExists(dumpFile); } catch (IOException ignored) {} @@ -167,7 +176,7 @@ private static synchronized String nextClassName(String prefix) { // loader survives on the stack. private static WeakReference spinInUnloadableClass(String className) throws Exception { Class clazz = SpinClasses.define(className); - clazz.getMethod("spin", long.class).invoke(null, 300_000_000L); + clazz.getMethod("spin", long.class).invoke(null, 1_000_000_000L); return new WeakReference<>(clazz.getClassLoader()); } } diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyLineNumberConsistencyTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyLineNumberConsistencyTest.java index 24ec5469af..9738668acc 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyLineNumberConsistencyTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyLineNumberConsistencyTest.java @@ -79,8 +79,10 @@ public void lazyLineNumbersMatchJvmti() throws Exception { Assumptions.assumeTrue(lazyResolved.get() > 0, "lazy mode resolved no raw frames"); List vmHiddenNames = new ArrayList<>(); - Map lazyLines = linesByFrame(lazyRecording, vmHiddenNames); - Map jvmtiLines = linesByFrame(jvmtiRecording, vmHiddenNames); + List inconsistent = new ArrayList<>(); + Map lazyLines = linesByFrame(lazyRecording, vmHiddenNames, inconsistent); + Map jvmtiLines = linesByFrame(jvmtiRecording, vmHiddenNames, inconsistent); + assertTrue(inconsistent.isEmpty(), "line numbers differ within one recording: " + inconsistent); // JVMTI reports a hidden class "Foo$$Lambda+0x…" as "Foo$$Lambda.0x…", and lazy // mode must do the same, or a lambda's frames are named differently per mode. assertTrue(vmHiddenNames.isEmpty(), "hidden classes not named as JVMTI does: " + vmHiddenNames); @@ -110,9 +112,11 @@ public void lazyLineNumbersMatchJvmti() throws Exception { * 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. Adds class names still in HotSpot's - * internal hidden-class form ({@code +0x} suffix) to {@code vmHiddenNames}. + * internal hidden-class form ({@code +0x} suffix) to {@code vmHiddenNames}, and frames the + * recording gives different lines in different samples to {@code inconsistent}. */ - private static Map linesByFrame(Path recording, List vmHiddenNames) throws Exception { + private static Map linesByFrame(Path recording, List vmHiddenNames, + List inconsistent) throws Exception { Map lines = new HashMap<>(); JfrEvents.forEach(recording, "datadog.ExecutionSample"::equals, event -> { if (!event.has("stackTrace")) { @@ -125,8 +129,12 @@ private static Map linesByFrame(Path recording, List vm if (frame.className().contains("+0x") && !vmHiddenNames.contains(frame.className())) { vmHiddenNames.add(frame.className()); } - lines.put(frame.className() + "." + frame.methodName() + frame.methodDescriptor() - + " @" + frame.bytecodeIndex(), frame.lineNumber()); + String key = frame.className() + "." + frame.methodName() + frame.methodDescriptor() + + " @" + frame.bytecodeIndex(); + Integer previous = lines.put(key, frame.lineNumber()); + if (previous != null && previous != frame.lineNumber()) { + inconsistent.add(recording.getFileName() + " " + key + ": " + previous + " and " + frame.lineNumber()); + } } }); return lines; diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyRedefinitionStressTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyRedefinitionStressTest.java index 9fbb1a9d10..66d544a890 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyRedefinitionStressTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyRedefinitionStressTest.java @@ -7,6 +7,7 @@ import com.datadoghq.profiler.AbstractProfilerTest; import com.datadoghq.profiler.JfrEvent; +import com.datadoghq.profiler.JfrFrame; import com.datadoghq.profiler.Platform; import com.datadoghq.profiler.SpinClasses; import org.junit.jupiter.api.Assumptions; @@ -45,7 +46,8 @@ * 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. + * and resolve normally. A second thread retransforms the class meanwhile, which goes through the + * profiler's RetransformClasses hook instead. */ public class LazyRedefinitionStressTest extends AbstractProfilerTest { private static final String CLASS_NAME = "com/datadoghq/profiler/generated/LazyRedefinitionProbe"; @@ -63,6 +65,13 @@ public void concurrentRedefinitionDoesNotCrashOrMisresolve() throws Exception { Instrumentation inst = loadAgent(); Assumptions.assumeTrue(inst != null && inst.isRedefineClassesSupported(), "class redefinition not available"); waitForProfilerReady(2000); + Long traceIdOffset = profiler.getDebugCounters().get("klass_trace_id_offset"); + if (traceIdOffset != null) { + // Absent in builds without counters. Without a trace ID offset the profiler preloads + // jmethodIDs as in fjmethodid=false, and nothing here would exercise lazy mode. + Assumptions.assumeTrue(Platform.hasJfr(), "JDK without JFR"); + assertTrue(traceIdOffset > 0, "lazy mode is not active: klass_trace_id_offset=" + traceIdOffset); + } Class probe = SpinClasses.define(CLASS_NAME); byte[] bytes = SpinClasses.bytes(CLASS_NAME); @@ -70,6 +79,7 @@ public void concurrentRedefinitionDoesNotCrashOrMisresolve() throws Exception { AtomicBoolean done = new AtomicBoolean(false); AtomicReference failure = new AtomicReference<>(); AtomicInteger redefinitions = new AtomicInteger(); + AtomicInteger retransformations = new AtomicInteger(); Thread spinner = new Thread(() -> { try { @@ -91,16 +101,41 @@ public void concurrentRedefinitionDoesNotCrashOrMisresolve() throws Exception { failure.compareAndSet(null, t); } }, "lazy-redefinition-redefiner"); + Thread retransformer = new Thread(() -> { + try { + while (!done.get()) { + inst.retransformClasses(probe); + retransformations.incrementAndGet(); + Thread.sleep(5); + } + } catch (Throwable t) { + failure.compareAndSet(null, t); + } + }, "lazy-redefinition-retransformer"); 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(); + if (inst.isRetransformClassesSupported()) { + retransformer.start(); + } Path dir = Paths.get(System.getProperty("java.io.tmpdir"), "recordings"); Files.createDirectories(dir); Path dumpFile = Files.createTempFile(dir, "lazy-redefinition-", ".jfr"); + try { + runScenario(spin, dumpFile, done, failure, redefinitions, retransformations, spinner, redefiner, + retransformer); + } finally { + try { Files.deleteIfExists(dumpFile); } catch (IOException ignored) {} + } + } + + private void runScenario(Method spin, Path dumpFile, AtomicBoolean done, AtomicReference failure, + AtomicInteger redefinitions, AtomicInteger retransformations, Thread spinner, Thread redefiner, + Thread retransformer) throws Exception { int dumps = 0; try { long deadline = System.nanoTime() + DURATION_NANOS; @@ -115,6 +150,7 @@ public void concurrentRedefinitionDoesNotCrashOrMisresolve() throws Exception { done.set(true); spinner.join(); redefiner.join(); + retransformer.join(); } assertNull(failure.get(), "workload thread failed: " + failure.get()); @@ -129,10 +165,12 @@ public void concurrentRedefinitionDoesNotCrashOrMisresolve() throws Exception { }); tail.start(); tail.join(); + assertNull(failure.get(), "tail spin failed: " + failure.get()); stopProfiler(); Map counters = profiler.getDebugCounters(); - System.out.println("redefinitions=" + redefinitions.get() + " dumps=" + dumps + System.out.println("redefinitions=" + redefinitions.get() + " retransformations=" + retransformations.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") @@ -146,17 +184,24 @@ public void concurrentRedefinitionDoesNotCrashOrMisresolve() throws Exception { assertTrue(refused != null && refused > 0, "no raw frame of the redefined class was refused"); } + // A frame resolved through a freed or reused Method* would decode some other method's + // line number table, so every resolved spin frame must have one of spin's lines. String simpleName = CLASS_NAME.substring(CLASS_NAME.lastIndexOf('/') + 1); - boolean resolved = false; + int spinFrames = 0; for (JfrEvent sample : verifyEvents("datadog.ExecutionSample")) { - String stackTrace = sample.getStackTraceString(); - if (stackTrace != null && stackTrace.contains(simpleName) && stackTrace.contains("spin")) { - resolved = 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(resolved, "frames of the redefined class sampled after the redefinitions did not resolve"); - try { Files.deleteIfExists(dumpFile); } catch (IOException ignored) {} + assertTrue(spinFrames > 0, "frames of the redefined class sampled after the redefinitions did not resolve"); } /** 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 5a6374a687..590353a55a 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 @@ -70,12 +70,16 @@ public void calibratedTraceIdMatchesJfrClassId() throws Exception { try { AtomicLong loggedId = new AtomicLong(-1); AtomicBoolean traceIdTags = new AtomicBoolean(false); + AtomicBoolean tagsLogged = new AtomicBoolean(false); LaunchResult result = launch("lazy-trace-id", Arrays.asList("-XX:StartFlightRecording=filename=" + jfrRecording + ",settings=" + jfrSettings + ",dumponexit=true"), "start,cpu=1ms,cstack=vm,fjmethodid=lazy,jfr,file=" + ddprofRecording, line -> { - if (line.contains("[TEST::INFO] Raw frame tags: klass trace id")) { - traceIdTags.set(true); + if (line.contains("[TEST::INFO] Raw frame tags: ")) { + tagsLogged.set(true); + if (line.contains("[TEST::INFO] Raw frame tags: klass trace id")) { + traceIdTags.set(true); + } } Matcher m = LOGGED_ID.matcher(line); if (m.find()) { @@ -89,8 +93,11 @@ public void calibratedTraceIdMatchesJfrClassId() throws Exception { }, 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(tagsLogged.get(), "the profiler did not log its raw frame tags"); + // JDKs without JFR (e.g. 8u before 8u262) have no trace ID, and lazy mode stays off; + // on every other JDK calibration must find it. + Assumptions.assumeTrue(Platform.hasJfr(), "JDK without JFR"); + assertTrue(traceIdTags.get(), "calibration did not find the trace ID offset"); assertTrue(loggedId.get() > 0, "the profiler did not log the probe class's trace ID"); Set jfrIds = jfrClassIds(jfrRecording, PROBE_CLASS); From ed6408d9229f5d2e5be8ff225fc1ba4961b27ec9 Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Tue, 20 Oct 2026 19:46:33 +0200 Subject: [PATCH 29/31] Count lazy frames refused because their class is too large to scan A raw frame whose Method* is not at its idnum's index, in a class with more than MAX_METHODS_SCANNED methods, was counted as not current, though it may well be. It now has its own counter, method_resolve_lazy_class_too_large. Co-Authored-By: Claude Opus 5.5 --- ddprof-lib/src/main/cpp/counters.h | 5 +++++ ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp | 5 +++-- ddprof-lib/src/main/cpp/hotspot/vmStructs.h | 7 +++++-- ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp | 10 +++++----- ddprof-lib/src/test/cpp/klassRegistry_ut.cpp | 13 +++++++++++-- 5 files changed, 29 insertions(+), 11 deletions(-) diff --git a/ddprof-lib/src/main/cpp/counters.h b/ddprof-lib/src/main/cpp/counters.h index 62a45946c9..0226350127 100644 --- a/ddprof-lib/src/main/cpp/counters.h +++ b/ddprof-lib/src/main/cpp/counters.h @@ -185,6 +185,11 @@ * 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") \ + /* CLASS_TOO_LARGE: like NOT_CURRENT, but the Method* was not at its \ + * idnum's index and the class has too many methods to scan \ + * (VMKlass::MAX_METHODS_SCANNED), so it may well be current; serializes \ + * as "unknown". */ \ + X(METHOD_RESOLVE_LAZY_CLASS_TOO_LARGE, "method_resolve_lazy_class_too_large") \ 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". \ diff --git a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp index 5aa5701f42..f22f5635da 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp @@ -2032,8 +2032,9 @@ bool HotspotSupport::resolveLazily(ProfiledThread* prof_thread, VMMethod* vm_met // 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); + bool not_scanned = false; + if (!VMKlass::cast_raw(holder)->containsMethod(vm_method, idnum, ¬_scanned)) { + Counters::increment(not_scanned ? METHOD_RESOLVE_LAZY_CLASS_TOO_LARGE : 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 diff --git a/ddprof-lib/src/main/cpp/hotspot/vmStructs.h b/ddprof-lib/src/main/cpp/hotspot/vmStructs.h index cf39126cb2..2d9a11d2fc 100644 --- a/ddprof-lib/src/main/cpp/hotspot/vmStructs.h +++ b/ddprof-lib/src/main/cpp/hotspot/vmStructs.h @@ -749,7 +749,7 @@ DECLARE(VMKlass) // 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. + // method counts as not found, and `*not_scanned` (if given) is set. // // 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 @@ -757,7 +757,7 @@ DECLARE(VMKlass) // 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) { + bool containsMethod(const void* method, int idnum_hint, bool* not_scanned = nullptr) { assert(_methods_offset >= 0); const char* methods = (const char*) SafeAccess::loadPtr((void**) at(_methods_offset), nullptr); if (methods == NULL) { @@ -776,6 +776,9 @@ DECLARE(VMKlass) return true; } if (len > MAX_METHODS_SCANNED) { + if (not_scanned != nullptr) { + *not_scanned = true; + } return false; } for (int i = 0; i < len; i++) { diff --git a/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp b/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp index 1d2142056b..4a7e043070 100644 --- a/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp +++ b/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp @@ -861,10 +861,10 @@ TEST_F(HotspotLazyResolveTest, FrameWithFreedMethodIsCountedUnreadable) { _cls.method.const_method = nullptr; const CounterId registry_outcomes[] = {METHOD_RESOLVE_LAZY_UNLOADED, METHOD_RESOLVE_LAZY_STALE, - METHOD_RESOLVE_LAZY_NOT_CURRENT, METHOD_RESOLVE_LAZY_REDEFINED, - METHOD_RESOLVE_LAZY_RESOLVED}; - long long before[5]; - for (int i = 0; i < 5; i++) { + METHOD_RESOLVE_LAZY_NOT_CURRENT, METHOD_RESOLVE_LAZY_CLASS_TOO_LARGE, + METHOD_RESOLVE_LAZY_REDEFINED, METHOD_RESOLVE_LAZY_RESOLVED}; + long long before[6]; + for (int i = 0; i < 6; i++) { before[i] = Counters::getCounter(registry_outcomes[i]); } long long unreadable = Counters::getCounter(METHOD_RESOLVE_LAZY_UNREADABLE); @@ -872,7 +872,7 @@ TEST_F(HotspotLazyResolveTest, FrameWithFreedMethodIsCountedUnreadable) { EXPECT_FALSE(resolve(frame)); EXPECT_EQ(unreadable + 1, Counters::getCounter(METHOD_RESOLVE_LAZY_UNREADABLE)); - for (int i = 0; i < 5; i++) { + for (int i = 0; i < 6; i++) { EXPECT_EQ(before[i], Counters::getCounter(registry_outcomes[i])) << "counter " << registry_outcomes[i]; } _cls.method.const_method = &_cls.const_method; diff --git a/ddprof-lib/src/test/cpp/klassRegistry_ut.cpp b/ddprof-lib/src/test/cpp/klassRegistry_ut.cpp index e42afdbbe5..bdb45eaa63 100644 --- a/ddprof-lib/src/test/cpp/klassRegistry_ut.cpp +++ b/ddprof-lib/src/test/cpp/klassRegistry_ut.cpp @@ -433,7 +433,16 @@ TEST(VMKlassContainsMethodTest, ScanIsCappedForLargeClasses) { 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; + bool not_scanned = false; + EXPECT_EQ(scanned, vk->containsMethod(m, 0, ¬_scanned)) << count; + EXPECT_EQ(!scanned, not_scanned) << count; + not_scanned = false; + EXPECT_EQ(scanned, vk->containsMethod(m, -1, ¬_scanned)) << count; + EXPECT_EQ(!scanned, not_scanned) << count; + // A method found at its idnum, or absent from a scanned class, is not + // reported as unscanned. + not_scanned = false; + EXPECT_TRUE(vk->containsMethod(m, count / 2, ¬_scanned)) << count; + EXPECT_FALSE(not_scanned) << count; } } From a154d1700bb4957513286ef4024676dfbcf560ad Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Tue, 20 Oct 2026 23:03:32 +0200 Subject: [PATCH 30/31] Probe fresh classes for the trace ID; set pending exceptions aside Calibration probed Void array klasses. When those dimensions already existed, created far apart, the trace ID field failed the check while the mirrors' heap addresses (shifted right by 16) could pass it, and attempts could even agree on that wrong offset. Each attempt now defines a tiny class in a new class loader and probes its arrays of dimensions 1-6, which cannot have existed before. Nothing references the loader afterwards, so it can be unloaded. The dimension cursor and the two-attempt confirmation are gone. LazyTraceIdCalibrationTest now checks that 24 pre-created Void dimensions do not change the calibrated offset. The redefinition hooks set a pending Java exception aside while they work and throw it again, instead of skipping their JNI calls: such a redefinition is marked as usual rather than refusing every lazy frame, and a rejected one leaves no mark behind. Co-Authored-By: Claude Opus 5.5 --- ddprof-lib/src/main/cpp/counters.h | 6 +- .../src/main/cpp/hotspot/hotspotSupport.cpp | 39 ++++-- .../src/main/cpp/hotspot/hotspotSupport.h | 7 +- .../src/main/cpp/hotspot/klassRegistry.cpp | 7 +- .../src/main/cpp/hotspot/klassTraceId.cpp | 124 ++++++++++-------- .../src/main/cpp/hotspot/klassTraceId.h | 27 ++-- .../src/test/cpp/hotspotLazyResolve_ut.cpp | 50 ++++++- ddprof-lib/src/test/cpp/klassRegistry_ut.cpp | 6 +- .../datadoghq/profiler/ExternalLauncher.java | 45 ++----- .../cpu/LazyTraceIdCalibrationTest.java | 116 ++++------------ 10 files changed, 205 insertions(+), 222 deletions(-) diff --git a/ddprof-lib/src/main/cpp/counters.h b/ddprof-lib/src/main/cpp/counters.h index 0226350127..950ce1fb49 100644 --- a/ddprof-lib/src/main/cpp/counters.h +++ b/ddprof-lib/src/main/cpp/counters.h @@ -221,9 +221,9 @@ /* 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") \ - /* Redefinitions whose mark could not be recorded (no memory for it, or \ - * the hook was called with a Java exception pending); every lazy frame \ - * is refused for the rest of the process once this is nonzero. */ \ + /* Redefinitions whose mark could not be recorded (no memory for it); \ + * every lazy frame is refused for the rest of the process once this is \ + * nonzero. */ \ X(KLASS_REGISTRY_ADD_FAILED, "klass_registry_add_failed") \ /* Gauges: KlassRegistry's table slots (24 B each; NMT does not see the \ * table) and occupied slots, live or dead. It holds only redefined \ diff --git a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp index f22f5635da..7150a4e407 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp @@ -1524,17 +1524,40 @@ bool HotspotSupport::isInstanceClass(jvmtiEnv *jvmti, jclass klass) { (status & (JVMTI_CLASS_STATUS_ARRAY | JVMTI_CLASS_STATUS_PRIMITIVE)) == 0; } +// JVMTI allows RedefineClasses/RetransformClasses with a Java exception +// pending, but JNI forbids most calls then. The redefinition hooks set such an +// exception aside while they work, and throw it again before the original +// function runs or returns to its caller. +class PendingExceptionScope { + JNIEnv* _jni; + jthrowable _pending; + +public: + explicit PendingExceptionScope(JNIEnv* jni) : _jni(jni), _pending(jni->ExceptionOccurred()) { + if (_pending != nullptr) { + _jni->ExceptionClear(); + } + } + ~PendingExceptionScope() { + if (_pending != nullptr) { + _jni->ExceptionClear(); + _jni->Throw(_pending); + _jni->DeleteLocalRef(_pending); + } + } + PendingExceptionScope(const PendingExceptionScope&) = delete; + PendingExceptionScope& operator=(const PendingExceptionScope&) = delete; +}; + void HotspotSupport::beforeClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass) { if (!__atomic_load_n(&_lazy_used, __ATOMIC_ACQUIRE) || !isInstanceClass(jvmti, klass)) { return; } - // JVMTI allows RedefineClasses with a Java exception pending, but JNI - // forbids the calls that marking the class needs; then the class can't be - // marked, as when there is no memory for the mark. + PendingExceptionScope pending(jni); // Only redefined classes are registered, so the mark has a place to live. // An entry stays after a call JVMTI rejects (without a lasting mark), and // is purged when the table next grows after its class is unloaded. - const void* vm_klass = jni->ExceptionCheck() ? nullptr : VMKlass::fromJavaClass(jni, klass); + const void* vm_klass = VMKlass::fromJavaClass(jni, klass); if (vm_klass != nullptr && _klass_registry.add(jni, vm_klass, klass) && _klass_registry.beginRedefinition(vm_klass)) { Counters::increment(KLASS_REGISTRY_REDEFINITIONS_MARKED); @@ -1553,11 +1576,9 @@ void HotspotSupport::afterClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclass if (!__atomic_load_n(&_lazy_used, __ATOMIC_ACQUIRE) || !isInstanceClass(jvmti, klass)) { return; } - if (jni->ExceptionCheck()) { - // No JNI calls allowed. The class stays marked as being redefined, - // so its raw frames are refused for good: safe, if lossy. - return; - } + PendingExceptionScope pending(jni); + // The caller holds `klass` throughout the call, so the class cannot have + // been unloaded, and its Klass* still names the entry begin marked. _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 diff --git a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h index 404dac4815..a6da0e729f 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h @@ -163,10 +163,9 @@ class HotspotSupport { static bool _lazy_used; // Set if a redefinition could not be marked (no memory for the registry - // entry, or the hook was called with a Java exception pending): from then - // on every lazy frame is refused, for the rest of the process, since raw - // frames sampled before that redefinition can outlive any profiler - // session. klass_registry_add_failed counts these. + // entry): from then on every lazy frame is refused, for the rest of the + // process, since raw frames sampled before that redefinition can outlive + // any profiler session. klass_registry_add_failed counts these. static bool _redefinitions_untracked; // fjmethodid=lazy: the classes GetLoadedClasses() returned at the start diff --git a/ddprof-lib/src/main/cpp/hotspot/klassRegistry.cpp b/ddprof-lib/src/main/cpp/hotspot/klassRegistry.cpp index d578109ecd..bc00a4ac16 100644 --- a/ddprof-lib/src/main/cpp/hotspot/klassRegistry.cpp +++ b/ddprof-lib/src/main/cpp/hotspot/klassRegistry.cpp @@ -162,9 +162,10 @@ void KlassRegistry::endRedefinition(const void* klass, bool succeeded) { if (e == nullptr) { return; } - // Clamped: the entry may have been replaced (a new class at this address) - // while the call was in progress, which resets the count. Then the - // permanent mark is not applied either: the new class was not redefined. + // An end without a matching begin, e.g. after the begin could not register + // the class but another call did meanwhile, changes nothing. (The entry is + // not replaced while a call runs: its caller holds the class, so it cannot + // be unloaded and its Klass* reused.) if (e->redefinitions_in_progress == 0) { return; } diff --git a/ddprof-lib/src/main/cpp/hotspot/klassTraceId.cpp b/ddprof-lib/src/main/cpp/hotspot/klassTraceId.cpp index 7df24a6dba..87bb2e00dd 100644 --- a/ddprof-lib/src/main/cpp/hotspot/klassTraceId.cpp +++ b/ddprof-lib/src/main/cpp/hotspot/klassTraceId.cpp @@ -64,13 +64,6 @@ int KlassTraceId::findOffset(const void* const* klasses, int count, size_t klass return found; } -// Highest dimension any attempt has created so far; the next attempt probes -// the PROBE_COUNT dimensions above it. -static int _probed_dims = 0; -// The offset the last successful attempt found, waiting for a second attempt -// to confirm it. -static int _candidate = -1; - // Dumps what each probe holds at every offset findOffset() looked at, so a // release whose layout calibration rejects can be diagnosed from a debug CI // log. @@ -89,50 +82,77 @@ static void logProbes(const void* const* klasses, size_t klass_size) { #endif } -// One attempt: makes the JVM create array klasses of java.lang.Void with -// dimensions first_dim .. first_dim + PROBE_COUNT - 1 and looks for the -// offset in them. Resolving the deepest one creates every missing dimension -// in order, each getting the next trace ID; java.lang.Void has no other use -// for such arrays. Earlier attempts (or other code) may have created lower -// dimensions already, so only the top PROBE_COUNT dimensions are probed, and -// those are new. Returns the offset, or -1. -static int probe(JNIEnv* jni, int first_dim, size_t klass_size) { - static const char ELEMENT[] = "Ljava/lang/Void;"; - const int top_dim = first_dim + KlassTraceId::PROBE_COUNT - 1; - char name[KlassTraceId::MAX_DIMENSIONS + sizeof(ELEMENT)]; - assert(first_dim >= 1 && top_dim <= KlassTraceId::MAX_DIMENSIONS); - memset(name, '[', top_dim); - memcpy(name + top_dim, ELEMENT, sizeof(ELEMENT)); +// The class file of `public class datadog/ddprof/KlassTraceIdProbe`, with no +// fields and no methods: just enough for DefineClass() to create a Klass. +static const unsigned char PROBE_CLASS_FILE[] = { + 0xCA, 0xFE, 0xBA, 0xBE, 0x00, 0x00, 0x00, 0x32, // magic, version 50.0 + 0x00, 0x05, // constant pool count + 0x01, 0x00, 0x20, 'd', 'a', 't', 'a', 'd', 'o', 'g', '/', 'd', 'd', 'p', 'r', 'o', 'f', '/', + 'K', 'l', 'a', 's', 's', 'T', 'r', 'a', 'c', 'e', 'I', 'd', 'P', 'r', 'o', 'b', 'e', // #1 Utf8 + 0x07, 0x00, 0x01, // #2 Class #1 + 0x01, 0x00, 0x10, 'j', 'a', 'v', 'a', '/', 'l', 'a', 'n', 'g', '/', 'O', 'b', 'j', 'e', 'c', 't', // #3 Utf8 + 0x07, 0x00, 0x03, // #4 Class #3 + 0x00, 0x21, // ACC_PUBLIC | ACC_SUPER + 0x00, 0x02, 0x00, 0x04, // this #2, super #4 + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // no interfaces, fields, methods, attributes +}; +static const char PROBE_CLASS_NAME[] = "datadog/ddprof/KlassTraceIdProbe"; - const void* klasses[KlassTraceId::PROBE_COUNT]; - for (int i = KlassTraceId::PROBE_COUNT - 1; i >= 0; i--) { - const int dims = first_dim + i; - jclass cls = jni->FindClass(name + (top_dim - dims)); - if (cls == nullptr) { - jni->ExceptionClear(); - TEST_LOG("KlassTraceId: FindClass failed for %d dimensions", dims); - return -1; - } - klasses[i] = VMKlass::fromJavaClass(jni, cls); - jni->DeleteLocalRef(cls); +// Creates the probe klasses of one attempt, in creation order: arrays of +// dimensions 1 .. PROBE_COUNT of a class defined in a new class loader for +// this attempt, so none of them can have existed before. Probes that existed +// already, created far apart, would let the right field fail the check while +// another passes it (the mirrors' heap addresses, shifted right by 16, can be +// distinct and close together). Nothing keeps the loader reachable +// afterwards, so it and its classes can be unloaded. Returns false on any JNI +// failure. Must run inside a JNI local frame. +static bool createProbes(JNIEnv* jni, const void** klasses) { + jclass loader_class = jni->FindClass("java/security/SecureClassLoader"); + jmethodID init = loader_class == nullptr ? nullptr : jni->GetMethodID(loader_class, "", "(Ljava/lang/ClassLoader;)V"); + // JNI does not check access, so the protected constructor will do; the + // parent is the bootstrap loader. + jobject loader = init == nullptr ? nullptr : jni->NewObject(loader_class, init, (jobject)nullptr); + jclass cls = loader == nullptr ? nullptr + : jni->DefineClass(PROBE_CLASS_NAME, loader, (const jbyte*)PROBE_CLASS_FILE, + (jsize)sizeof(PROBE_CLASS_FILE)); + for (int i = 0; i < KlassTraceId::PROBE_COUNT && cls != nullptr; i++) { + // An empty array of `cls` makes the JVM create the next dimension. + jobjectArray array = jni->NewObjectArray(0, cls, nullptr); + cls = array == nullptr ? nullptr : jni->GetObjectClass(array); + klasses[i] = cls == nullptr ? nullptr : VMKlass::fromJavaClass(jni, cls); if (klasses[i] == nullptr) { - TEST_LOG("KlassTraceId: no Klass* for %d dimensions", dims); - return -1; + cls = nullptr; } } + if (cls == nullptr) { + jni->ExceptionClear(); + TEST_LOG("KlassTraceId: could not create the probe classes"); + return false; + } + return true; +} - int found = KlassTraceId::findOffset(klasses, KlassTraceId::PROBE_COUNT, klass_size); - TEST_LOG("KlassTraceId offset=%d (sizeof(Klass)=%d, dimensions %d-%d)", found, (int)klass_size, - first_dim, top_dim); - if (found < 0) { - logProbes(klasses, klass_size); +// One attempt: creates fresh probe klasses and looks for the offset in them. +// Returns the offset, or -1. +static int probe(JNIEnv* jni, size_t klass_size) { + if (jni->PushLocalFrame(2 * KlassTraceId::PROBE_COUNT + 8) != JNI_OK) { + jni->ExceptionClear(); + return -1; } + const void* klasses[KlassTraceId::PROBE_COUNT]; + int found = -1; + if (createProbes(jni, klasses)) { + found = KlassTraceId::findOffset(klasses, KlassTraceId::PROBE_COUNT, klass_size); + TEST_LOG("KlassTraceId offset=%d (sizeof(Klass)=%d)", found, (int)klass_size); + if (found < 0) { + logProbes(klasses, klass_size); + } + } + jni->PopLocalFrame(nullptr); return found; } bool KlassTraceId::calibrate(JNIEnv* jni) { - // Runs from Profiler::start() with _state_lock held, so plain statics will - // do. if (offset() >= 0) { return true; } @@ -141,26 +161,14 @@ bool KlassTraceId::calibrate(JNIEnv* jni) { TEST_LOG("KlassTraceId: no Klass layout from vmStructs"); return false; } - // A failed attempt usually means the probes' IDs were not consecutive: - // other threads created many classes meanwhile, or some of the dimensions - // already existed. Fresh dimensions make either unlikely to repeat. - // If all of an attempt's dimensions already existed, created far apart, - // their trace IDs fail the check while another field can pass it: the - // mirrors' heap addresses, shifted right by 16, can be distinct and close - // together. Probes created back to back have adjacent mirrors, which that - // shift makes equal, so an offset is only taken once two attempts on - // different dimensions agree on it. - for (int attempt = 0; attempt < MAX_ATTEMPTS && _probed_dims + PROBE_COUNT <= MAX_DIMENSIONS; attempt++) { - const int first_dim = _probed_dims + 1; - _probed_dims += PROBE_COUNT; - int found = probe(jni, first_dim, klass_size); - if (found >= 0 && found == _candidate) { + // A failed attempt usually means other threads created many classes while + // the probes were created; that is unlikely to repeat right away. + for (int attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + int found = probe(jni, klass_size); + if (found >= 0) { __atomic_store_n(&_offset, found, __ATOMIC_RELEASE); return true; } - if (found >= 0) { - _candidate = found; - } } return false; } diff --git a/ddprof-lib/src/main/cpp/hotspot/klassTraceId.h b/ddprof-lib/src/main/cpp/hotspot/klassTraceId.h index 43a7fef006..e95e019e4f 100644 --- a/ddprof-lib/src/main/cpp/hotspot/klassTraceId.h +++ b/ddprof-lib/src/main/cpp/hotspot/klassTraceId.h @@ -25,10 +25,11 @@ * 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 - * looks for the one 8-byte-aligned offset within sizeof(Klass) at which all - * of them hold distinct IDs no further apart than consecutive creation - * allows, accepting it once two such attempts agree. + * calibrate() makes the JVM create a few array klasses back to back, of a + * class it defines in a new class loader so that they cannot have existed + * before, 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 fjmethodid=lazy does not run. */ @@ -39,13 +40,8 @@ class KlassTraceId { // Array dimensions created by one calibration attempt: one probe klass per // dimension. static const int PROBE_COUNT = 6; - // Attempts each calibrate() call makes, each on PROBE_COUNT dimensions - // above the previous attempt's (of this call or an earlier one), so each - // probes klasses it has just created. + // Attempts each calibrate() call makes, each with a new class loader. static const int MAX_ATTEMPTS = 4; - // The JVM's limit on array dimensions (JVMS 4.4.1), which bounds the - // dimensions all calibrate() calls of the process can probe together. - static const int MAX_DIMENSIONS = 255; // 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; @@ -59,12 +55,11 @@ 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 that two - * attempts on different dimensions agree on. Once an offset is found, later calls return true without probing again; - * after a failure, the next call makes up to MAX_ATTEMPTS new attempts on - * fresh dimensions, until MAX_DIMENSIONS are used up. Returns true if an - * offset is known. Callers must be serialized: Profiler::start() calls it - * with _state_lock held. + * Creates the probe klasses through JNI and records the offset found, in + * up to MAX_ATTEMPTS attempts. Once an offset is found, later calls return + * true without probing again; after a failure, the next call tries again. + * Returns true if an offset is known. Each attempt leaves behind an + * unreferenced class loader with a few tiny classes, for GC to unload. */ 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 4a7e043070..edf74b1416 100644 --- a/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp +++ b/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp @@ -282,6 +282,8 @@ class FakeJvm { _jni_tbl.GetLongField = &getLongField; _jni_tbl.ExceptionCheck = &exceptionCheck; _jni_tbl.ExceptionClear = &exceptionClear; + _jni_tbl.ExceptionOccurred = &exceptionOccurred; + _jni_tbl.Throw = &throwException; _jni_env.functions = &_jni_tbl; _jvmti_tbl.GetClassStatus = &getClassStatus; _jvmti_tbl.GetClassMethods = &getClassMethods; @@ -310,6 +312,9 @@ class FakeJvm { int get_class_methods_calls = 0; bool fail_weak_refs = false; // NewWeakGlobalRef fails, as when out of memory bool exception_pending = false; // set by a failed NewWeakGlobalRef, as JNI does + int throws = 0; + // JNI forbids these calls while an exception is pending. + int calls_with_exception_pending = 0; jclass mirror(FakeObject& obj) { return reinterpret_cast(&obj); } @@ -330,7 +335,13 @@ class FakeJvm { return _weaks.count(w) != 0 ? reinterpret_cast(w->referent) : ref; } + static void checkNoException() { + if (s_instance->exception_pending) { + s_instance->calls_with_exception_pending++; + } + } static jweak JNICALL newWeakGlobalRef(JNIEnv*, jobject obj) { + checkNoException(); if (s_instance->fail_weak_refs) { s_instance->exception_pending = true; // OutOfMemoryError return nullptr; @@ -344,16 +355,27 @@ class FakeJvm { return s_instance->exception_pending ? JNI_TRUE : JNI_FALSE; } static void JNICALL exceptionClear(JNIEnv*) { s_instance->exception_pending = false; } + static jthrowable JNICALL exceptionOccurred(JNIEnv*) { + return s_instance->exception_pending ? reinterpret_cast(&s_instance->_throwable) : nullptr; + } + static jint JNICALL throwException(JNIEnv*, jthrowable) { + s_instance->throws++; + s_instance->exception_pending = true; + return 0; + } static jboolean JNICALL isSameObject(JNIEnv*, jobject a, jobject b) { + checkNoException(); return s_instance->resolve(a) == s_instance->resolve(b) ? JNI_TRUE : JNI_FALSE; } static jobject JNICALL newLocalRef(JNIEnv*, jobject ref) { + checkNoException(); 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) { + checkNoException(); return (jlong)(uintptr_t)reinterpret_cast(obj)->klass; } // A jmethodID preload (loadMethodIDsIfNeededImpl()); only the call is @@ -386,6 +408,7 @@ class FakeJvm { _jvmtiEnv _jvmti{}; jvmtiEnv* _saved_jvmti = nullptr; std::set _weaks; + int _throwable = 0; // what ExceptionOccurred() returns }; FakeJvm* FakeJvm::s_instance = nullptr; @@ -713,18 +736,35 @@ TEST_F(HotspotLazyResolveTest, UnmarkableRedefinitionRefusesAllLazyFrames) { EXPECT_FALSE(resolve(frame)); } -// A hook called with a Java exception pending cannot make JNI calls, so it -// cannot mark the class; every lazy frame is refused instead, and the -// exception is left for the caller of RedefineClasses. -TEST_F(HotspotLazyResolveTest, RedefinitionWithPendingExceptionRefusesAllLazyFrames) { +// JVMTI allows a redefinition with a Java exception pending. The hooks set it +// aside instead of calling JNI with it pending, mark the class as usual, and +// throw it again for the caller of RedefineClasses. +TEST_F(HotspotLazyResolveTest, RedefinitionWithPendingExceptionIsMarked) { registerClass(_old_mirror); ASGCT_CallFrame frame = sample(); + long long add_failed = Counters::getCounter(KLASS_REGISTRY_ADD_FAILED); _jvm.exception_pending = true; redefine(true); EXPECT_TRUE(_jvm.exception_pending); + EXPECT_EQ(2, _jvm.throws); // once per hook + EXPECT_EQ(0, _jvm.calls_with_exception_pending); _jvm.exception_pending = false; - EXPECT_EQ(0u, HotspotSupportTestAccessor::registry().size()); + EXPECT_EQ(1u, HotspotSupportTestAccessor::registry().size()); EXPECT_FALSE(resolve(frame)); + EXPECT_EQ(add_failed, Counters::getCounter(KLASS_REGISTRY_ADD_FAILED)); +} + +// A rejected call with an exception pending leaves the class as it was: no +// mark is left behind, so its frames still resolve. +TEST_F(HotspotLazyResolveTest, RejectedRedefinitionWithPendingExceptionLeavesFramesResolvable) { + registerClass(_old_mirror); + ASGCT_CallFrame frame = sample(); + _jvm.exception_pending = true; + redefine(false); + EXPECT_TRUE(_jvm.exception_pending); + EXPECT_EQ(0, _jvm.calls_with_exception_pending); + _jvm.exception_pending = false; + EXPECT_TRUE(resolvesToSpin(frame)); } // Before lazy mode has started there are no lazy frames, so redefinitions diff --git a/ddprof-lib/src/test/cpp/klassRegistry_ut.cpp b/ddprof-lib/src/test/cpp/klassRegistry_ut.cpp index bdb45eaa63..81525ef4ac 100644 --- a/ddprof-lib/src/test/cpp/klassRegistry_ut.cpp +++ b/ddprof-lib/src/test/cpp/klassRegistry_ut.cpp @@ -328,9 +328,9 @@ TEST_F(KlassRegistryTest, NewClassAtRedefinedClassAddressStartsUnmarked) { EXPECT_TRUE(registry.contains(jni, klassAt(0))); } -// A class unloaded while its redefinition call runs can be replaced by a new -// class at the same address before the call returns; the call's success must -// not mark the new class, which was never redefined. +// The hooks never see this (the caller of RedefineClasses holds the class), +// but the registry alone does not rely on it: an end that finds a different +// class at the address does not mark it. TEST_F(KlassRegistryTest, RedefinitionEndingAfterReplacementDoesNotMarkNewClass) { ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); ASSERT_TRUE(registry.beginRedefinition(klassAt(0))); 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 2b75b89e5e..e709954c9d 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/ExternalLauncher.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/ExternalLauncher.java @@ -71,13 +71,10 @@ * leaking thread's {@code ThreadLocal} map; the {@code -late} variant starts the leak thread * only after the recording began (see that scenario's own comment).

  • *
  • lazy-calibration-preexisting-arrays <comma delimited profiler command list, required> - - * creates {@code Void[]} and {@code Void[][]} with a few hundred class definitions in between, so their - * JFR trace IDs are far apart, then starts the profiler, prints the {@code klass_trace_id_offset} - * counter and stops it; see {@code LazyTraceIdCalibrationTest}
  • - *
  • lazy-calibration-retry <comma delimited profiler command list, required> - creates - * {@code Void} arrays of every dimension the first profiler start probes, each with a few hundred - * class definitions in between, then starts the profiler twice, printing the - * {@code klass_trace_id_offset} counter after each start; see {@code LazyTraceIdCalibrationTest}
  • + * creates {@code Void} arrays of dimensions 1 to the {@code ddprof_test.calibration_dims} system + * property (default 0), each with a hundred class definitions in between, then starts the + * profiler, prints the {@code klass_trace_id_offset} counter and stops it; see + * {@code LazyTraceIdCalibrationTest} *
  • 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 @@ -273,38 +270,20 @@ public static void main(String[] args) throws Exception { if (args.length < 2 || args[1].isEmpty()) { throw new IllegalArgumentException("lazy-calibration-preexisting-arrays requires a profiler command list"); } - Object lowDimension = java.lang.reflect.Array.newInstance(Void.class, 0); - for (int i = 0; i < 200; i++) { - SpinClasses.define("com/datadoghq/profiler/generated/CalibrationGap" + i); - } - Object secondDimension = java.lang.reflect.Array.newInstance(lowDimension.getClass(), 0); - JavaProfiler instance = JavaProfiler.getInstance(); - instance.execute(args[1]); - System.out.println("[calibration] klass_trace_id_offset=" - + instance.getDebugCounters().get("klass_trace_id_offset") - + " arrays=" + secondDimension.getClass().getName()); - instance.stop(); - } else if (args[0].equals("lazy-calibration-retry")) { - if (args.length < 2 || args[1].isEmpty()) { - throw new IllegalArgumentException("lazy-calibration-retry requires a profiler command list"); - } - // KlassTraceId::MAX_ATTEMPTS * PROBE_COUNT: every dimension one calibrate() call probes. - final int firstCallDimensions = 24; + int dims = Integer.getInteger("ddprof_test.calibration_dims", 0); Class arrayClass = Void.class; - for (int dim = 1; dim <= firstCallDimensions; dim++) { + for (int dim = 1; dim <= dims; dim++) { for (int i = 0; i < 100; i++) { - SpinClasses.define("com/datadoghq/profiler/generated/CalibrationRetryGap" + dim + "_" + i); + SpinClasses.define("com/datadoghq/profiler/generated/CalibrationGap" + dim + "_" + i); } arrayClass = java.lang.reflect.Array.newInstance(arrayClass, 0).getClass(); } JavaProfiler instance = JavaProfiler.getInstance(); - for (String start : new String[] {"first", "second"}) { - instance.execute(args[1]); - System.out.println("[calibration] " + start + " klass_trace_id_offset=" - + instance.getDebugCounters().get("klass_trace_id_offset") - + " arrays=" + arrayClass.getName().length()); - instance.stop(); - } + instance.execute(args[1]); + System.out.println("[calibration] klass_trace_id_offset=" + + instance.getDebugCounters().get("klass_trace_id_offset") + + " arrays=" + arrayClass.getName()); + 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"); diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyTraceIdCalibrationTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyTraceIdCalibrationTest.java index 99cc31e041..6a24b1bf02 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyTraceIdCalibrationTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyTraceIdCalibrationTest.java @@ -23,36 +23,44 @@ import static org.junit.jupiter.api.Assertions.assertTrue; /** - * {@code fjmethodid=lazy} must find {@code Klass::_trace_id} even when a test JVM, or an - * application, already created some of the array classes calibration probes. + * {@code fjmethodid=lazy} must find {@code Klass::_trace_id} whatever array classes the application + * has created. * - *

    Calibration makes the JVM create {@code Void} arrays of several dimensions back to back and - * looks for the offset at which their JFR trace IDs are nearly consecutive. A child JVM first - * creates {@code Void[]} and {@code Void[][]} with a few hundred class definitions in between, so - * those two IDs are far apart and an attempt that includes them cannot succeed. Calibration must - * still find the offset by probing dimensions it creates itself. - * - *

    A calibration that fails must not use up later profiler starts' attempts: a second child JVM - * pre-creates every dimension the first start probes, so that start fails, and the next start must - * still calibrate on fresh dimensions, to the same offset a JVM without pre-created dimensions - * finds. Pre-created dimensions also put another field to the test: the mirrors' heap addresses - * can pass for trace IDs, and calibration must not take that offset. + *

    Calibration looks for the offset at which a few freshly created array klasses hold nearly + * consecutive JFR trace IDs. If its probes were classes that already existed, created far apart, + * the real field would fail that check while another one could pass it: the mirrors' heap + * addresses, shifted right by 16, can be distinct and close together. A child JVM therefore creates + * {@code Void} arrays of many dimensions, far apart, before the profiler starts, and must + * calibrate to the same offset as a child JVM that creates none. */ public class LazyTraceIdCalibrationTest extends AbstractProcessProfilerTest { private static final String PREFIX = "[calibration] klass_trace_id_offset="; - private static final String FIRST_PREFIX = "[calibration] first klass_trace_id_offset="; - private static final String SECOND_PREFIX = "[calibration] second klass_trace_id_offset="; @Test - @Timeout(60) - public void calibratesWhenLowProbeDimensionsAlreadyExist() throws Exception { + @Timeout(90) + public void calibratesRegardlessOfPreexistingArrayClasses() throws Exception { Assumptions.assumeFalse(Platform.isZing() || Platform.isJ9() || Platform.isGraal(), "HotSpot only"); + // Without JFR (e.g. 8u before 8u262) there is no trace ID to find. + Assumptions.assumeTrue(Platform.hasJfr(), "JDK without JFR"); + long reference = calibratedOffset(0); + Assumptions.assumeTrue(reference != Long.MIN_VALUE, "build without debug counters"); + assertTrue(reference > 0, "trace-ID calibration failed: klass_trace_id_offset=" + reference); + assertEquals(reference, calibratedOffset(24), + "calibration with pre-created Void arrays found a different offset"); + } + + /** + * The offset a child JVM calibrates to after creating {@code Void} arrays of dimensions 1 to + * {@code dims}, or {@link Long#MIN_VALUE} if the build has no debug counters. + */ + private long calibratedOffset(int dims) throws Exception { AtomicLong offset = new AtomicLong(Long.MIN_VALUE); AtomicReference malformed = new AtomicReference<>(); Path recording = Files.createTempFile("lazy-calibration-", ".jfr"); LaunchResult result; try { - result = launch("lazy-calibration-preexisting-arrays", Collections.emptyList(), + result = launch("lazy-calibration-preexisting-arrays", + Collections.singletonList("-Dddprof_test.calibration_dims=" + dims), "start,cpu=10ms,cstack=vm,fjmethodid=lazy,jfr,file=" + recording, line -> { if (line.startsWith(PREFIX)) { String value = line.substring(PREFIX.length()).split(" ")[0]; @@ -60,7 +68,6 @@ public void calibratesWhenLowProbeDimensionsAlreadyExist() throws Exception { try { offset.set(Long.parseLong(value)); } catch (NumberFormatException e) { - // Reported below; an unset offset would only skip the test. malformed.compareAndSet(null, line); } } @@ -70,76 +77,9 @@ public void calibratesWhenLowProbeDimensionsAlreadyExist() throws Exception { } finally { try { Files.deleteIfExists(recording); } catch (IOException ignored) {} } - assertTrue(result.inTime, "child JVM did not finish in time"); - assertEquals(0, result.exitCode, "child JVM failed"); + assertTrue(result.inTime, "child JVM did not finish in time (dims=" + dims + ")"); + assertEquals(0, result.exitCode, "child JVM failed (dims=" + dims + ")"); assertNull(malformed.get(), "unparsable offset line from the child JVM"); - Assumptions.assumeTrue(offset.get() != Long.MIN_VALUE, "build without debug counters"); - // Without JFR (e.g. 8u before 8u262) there is no trace ID to find. - Assumptions.assumeTrue(Platform.hasJfr(), "JDK without JFR"); - assertTrue(offset.get() > 0, "trace-ID calibration failed: klass_trace_id_offset=" + offset.get()); - } - - @Test - @Timeout(60) - public void retriesOnNextStartAfterFailedCalibration() throws Exception { - Assumptions.assumeFalse(Platform.isZing() || Platform.isJ9() || Platform.isGraal(), "HotSpot only"); - Assumptions.assumeTrue(Platform.hasJfr(), "JDK without JFR"); - AtomicLong first = new AtomicLong(Long.MIN_VALUE); - AtomicLong second = new AtomicLong(Long.MIN_VALUE); - AtomicReference malformed = new AtomicReference<>(); - Path recording = Files.createTempFile("lazy-calibration-retry-", ".jfr"); - LaunchResult result; - try { - result = launch("lazy-calibration-retry", Collections.emptyList(), - "start,cpu=10ms,cstack=vm,fjmethodid=lazy,jfr,file=" + recording, line -> { - parseOffset(line, FIRST_PREFIX, first, malformed); - parseOffset(line, SECOND_PREFIX, second, malformed); - return LineConsumerResult.CONTINUE; - }, null); - } finally { - try { Files.deleteIfExists(recording); } catch (IOException ignored) {} - } - assertTrue(result.inTime, "child JVM did not finish in time"); - assertEquals(0, result.exitCode, "child JVM failed"); - assertNull(malformed.get(), "unparsable offset line from the child JVM"); - Assumptions.assumeTrue(first.get() != Long.MIN_VALUE, "build without debug counters"); - assertEquals(-1, first.get(), "first start should fail on pre-created dimensions"); - assertTrue(second.get() > 0, "second start did not calibrate: klass_trace_id_offset=" + second.get()); - assertEquals(referenceOffset(), second.get(), "second start calibrated to the wrong offset"); - } - - /** The offset a child JVM calibrates to with only the two lowest dimensions pre-created. */ - private long referenceOffset() throws Exception { - AtomicLong offset = new AtomicLong(Long.MIN_VALUE); - AtomicReference malformed = new AtomicReference<>(); - Path recording = Files.createTempFile("lazy-calibration-reference-", ".jfr"); - try { - LaunchResult result = launch("lazy-calibration-preexisting-arrays", Collections.emptyList(), - "start,cpu=10ms,cstack=vm,fjmethodid=lazy,jfr,file=" + recording, line -> { - parseOffset(line, PREFIX, offset, malformed); - return LineConsumerResult.CONTINUE; - }, null); - assertTrue(result.inTime && result.exitCode == 0, "reference child JVM failed"); - } finally { - try { Files.deleteIfExists(recording); } catch (IOException ignored) {} - } - assertNull(malformed.get(), "unparsable offset line from the reference child JVM"); - assertTrue(offset.get() > 0, "reference child JVM did not calibrate: " + offset.get()); return offset.get(); } - - private static void parseOffset(String line, String prefix, AtomicLong target, AtomicReference malformed) { - if (!line.startsWith(prefix)) { - return; - } - String value = line.substring(prefix.length()).split(" ")[0]; - if ("null".equals(value)) { - return; - } - try { - target.set(Long.parseLong(value)); - } catch (NumberFormatException e) { - malformed.compareAndSet(null, line); - } - } } From 337b79bbde3b326b5af2d76926018a203135d71d Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Wed, 21 Oct 2026 16:39:04 +0200 Subject: [PATCH 31/31] Close the pre-lazy redefinition window; log lazy-mode fallbacks A redefinition call that started before the first lazy start did not mark its classes, so lazy frames sampled while it still ran could be resolved through methods it swapped. The redefinition hooks now count, around each whole call, the calls that start while lazy mode is off, and initLazyResolution() waits for them to end after setting _lazy_used (both sequentially consistent), falling back to preloading for that session if they have not ended within 5 s. When fjmethodid=lazy is requested but not used, a warning now names the reason: cstack is not vm, not HotSpot, missing VM structures, trace ID not found, or such redefinitions still running. A class's name is rewritten to the JVMTI form for hidden classes only when the class may be hidden: on JDK 15-23 Klass::_access_flags carries JVM_ACC_IS_HIDDEN_CLASS, so an ordinary class named "...+0x" keeps its '+'; before 15 there are no hidden classes; on 24+ the flag is not exported and the name decides as before. Co-Authored-By: Claude Opus 5.5 --- .../src/main/cpp/hotspot/hotspotSupport.cpp | 73 ++++++++++++++++++- .../src/main/cpp/hotspot/hotspotSupport.h | 23 +++++- ddprof-lib/src/main/cpp/hotspot/vmStructs.h | 9 +++ ddprof-lib/src/main/cpp/jvmSupport.cpp | 28 ++++++- ddprof-lib/src/main/cpp/jvmSupport.h | 5 ++ ddprof-lib/src/main/cpp/vmEntry.cpp | 4 + .../src/test/cpp/hotspotLazyResolve_ut.cpp | 66 ++++++++++++++++- ddprof-lib/src/test/cpp/redefineHooks_ut.cpp | 31 ++++++++ 8 files changed, 227 insertions(+), 12 deletions(-) diff --git a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp index 7150a4e407..52bf894354 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp @@ -1484,6 +1484,7 @@ bool HotspotSupport::loadMethodIDsIfNeededImpl(jvmtiEnv *jvmti, JNIEnv *jni, jcl KlassRegistry HotspotSupport::_klass_registry; bool HotspotSupport::_lazy_used = false; +int HotspotSupport::_untracked_redefinition_calls = 0; bool HotspotSupport::_redefinitions_untracked = false; const jclass* HotspotSupport::_dump_jclasses = nullptr; int HotspotSupport::_dump_jclass_count = 0; @@ -1501,10 +1502,50 @@ bool HotspotSupport::initLazyResolution(JNIEnv* jni) { Counters::set(KLASS_TRACE_ID_OFFSET, calibrated ? KlassTraceId::offset() : -1); _klass_registry.publishCounters(); TEST_LOG("Raw frame tags: %s", calibrated ? "klass trace id" : "unavailable, lazy mode off"); - if (calibrated) { - __atomic_store_n(&_lazy_used, true, __ATOMIC_RELEASE); + if (!calibrated) { + Log::warn("fjmethodid=lazy: could not locate the JFR class trace ID (a JVM without JFR?); " + "preloading jmethodIDs as with fjmethodid=false"); + return false; + } + // From here on every redefinition call that starts is tracked; the calls + // already running are not, so no lazy frame may be sampled before they + // end. + __atomic_store_n(&_lazy_used, true, __ATOMIC_SEQ_CST); + if (!awaitUntrackedRedefinitions(MAX_UNTRACKED_REDEFINITION_WAIT_NS)) { + Log::warn("fjmethodid=lazy: class redefinitions that started before lazy mode are still running; " + "preloading jmethodIDs as with fjmethodid=false for this session"); + return false; } - return calibrated; + return true; +} + +bool HotspotSupport::enterRedefinitionCall() { + // Pairs with initLazyResolution(), both sequentially consistent: either + // this call sees _lazy_used and its classes are marked, or + // initLazyResolution() sees the count and waits for the call to end. + __atomic_add_fetch(&_untracked_redefinition_calls, 1, __ATOMIC_SEQ_CST); + if (__atomic_load_n(&_lazy_used, __ATOMIC_SEQ_CST)) { + __atomic_sub_fetch(&_untracked_redefinition_calls, 1, __ATOMIC_SEQ_CST); + return false; + } + return true; +} + +void HotspotSupport::exitRedefinitionCall(bool untracked) { + if (untracked) { + __atomic_sub_fetch(&_untracked_redefinition_calls, 1, __ATOMIC_SEQ_CST); + } +} + +bool HotspotSupport::awaitUntrackedRedefinitions(u64 max_wait_ns) { + u64 deadline = OS::nanotime() + max_wait_ns; + while (__atomic_load_n(&_untracked_redefinition_calls, __ATOMIC_SEQ_CST) != 0) { + if (OS::nanotime() >= deadline) { + return false; + } + OS::sleep(1000000); // 1 ms + } + return true; } bool HotspotSupport::lazyFrameTag(const VMMethod* method, uint32_t* tag) { @@ -1550,6 +1591,9 @@ class PendingExceptionScope { }; void HotspotSupport::beforeClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass) { + // Before lazy mode there is nothing to mark for; a call that is still + // running when lazy mode starts holds up that start instead + // (enterRedefinitionCall()). if (!__atomic_load_n(&_lazy_used, __ATOMIC_ACQUIRE) || !isInstanceClass(jvmti, klass)) { return; } @@ -1785,6 +1829,25 @@ static bool symbolEquals(VMSymbol* sym, const char* expected, size_t expected_le // 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. +// Whether `klass` may be a hidden class, so that its name needs the +// treatment useHiddenClassSignatureName() gives it. Hidden classes came with +// JDK 15. Up to JDK 23 they carry JVM_ACC_IS_HIDDEN_CLASS in +// Klass::_access_flags (utilities/accessFlags.hpp); JDK 24 moved the flag to +// Klass::_misc_flags, which vmStructs does not export, so from then on only +// the name tells. +static bool mayBeHiddenClass(VMKlass* klass) { + static const uint32_t JVM_ACC_IS_HIDDEN_CLASS = 0x04000000; + int version = VM::hotspot_version(); + if (version < 15) { + return false; + } + uint32_t flags; + if (version <= 23 && klass->accessFlags(&flags)) { + return (flags & JVM_ACC_IS_HIDDEN_CLASS) != 0; + } + return true; +} + 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 @@ -1891,7 +1954,9 @@ bool HotspotSupport::readMethod(const void* method, ResolvedMethod* result) { Counters::increment(METHOD_RESOLVE_SYMBOL_UNREADABLE); return false; } - result->useHiddenClassSignatureName(); + if (mayBeHiddenClass(klass)) { + result->useHiddenClassSignatureName(); + } if (strcmp(result->methodName(), "run") == 0 && strcmp(result->methodSignature(), "()V") == 0) { result->_is_thread_run = isThreadSubclass(klass); diff --git a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h index a6da0e729f..624b0b06f4 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h @@ -87,9 +87,12 @@ class ResolvedMethod { // Gives a hidden class the name JVMTI GetClassSignature() reports for it, // so frames are named the same in every fjmethodid mode. HotSpot names a // hidden class "+0x" (ClassFileParser::mangle_hidden_class_name) - // and InstanceKlass::signature_name() turns that last '+' into '.'. The - // hidden flag itself is not in vmStructs on JDK 21+, so the suffix is what - // identifies a hidden class here. + // and InstanceKlass::signature_name() turns that last '+' into '.'. Only + // called for classes that may be hidden (mayBeHiddenClass() in the .cpp): + // on JDK 24+ the hidden flag cannot be read, so there a non-hidden class + // whose own name ends in "+0x" (javac never produces one, since '+' + // is not a Java identifier character, but bytecode generators may) is + // reported with '.' instead of '+'. void useHiddenClassSignatureName(); // Internal (slash-separated) class name, e.g. "java/lang/Thread". @@ -161,6 +164,14 @@ class HotspotSupport { // outlive it, so redefinitions are tracked and dumps pin classes from then // on. static bool _lazy_used; + // Redefinition calls that started before _lazy_used was set and have not + // returned: their classes were not marked. + static int _untracked_redefinition_calls; + // How long initLazyResolution() waits for those calls to end. + static const u64 MAX_UNTRACKED_REDEFINITION_WAIT_NS = 5000000000ULL; + // Waits up to `max_wait_ns` until no untracked redefinition call is + // running. Returns false on timeout. + static bool awaitUntrackedRedefinitions(u64 max_wait_ns); // Set if a redefinition could not be marked (no memory for the registry // entry): from then on every lazy frame is refused, for the rest of the @@ -212,6 +223,12 @@ class HotspotSupport { // ignored, as JVMTI itself rejects it. static void beforeClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass); static void afterClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass, bool succeeded); + // Around each RedefineClasses/RetransformClasses call, outside the + // per-class hooks. enterRedefinitionCall() returns true if the call is + // not tracked (lazy mode had not started); pass that to + // exitRedefinitionCall(). + static bool enterRedefinitionCall(); + static void exitRedefinitionCall(bool untracked); // 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. diff --git a/ddprof-lib/src/main/cpp/hotspot/vmStructs.h b/ddprof-lib/src/main/cpp/hotspot/vmStructs.h index 2d9a11d2fc..e3ed7e53ea 100644 --- a/ddprof-lib/src/main/cpp/hotspot/vmStructs.h +++ b/ddprof-lib/src/main/cpp/hotspot/vmStructs.h @@ -226,6 +226,7 @@ typedef void* address; type_begin(VMKlass, MATCH_SYMBOLS("Klass", "InstanceKlass")) \ field(_klass_name_offset, offset, MATCH_SYMBOLS("_name")) \ field(_klass_super_offset, offset, MATCH_SYMBOLS("_super")) \ + field(_klass_access_flags_offset, offset, MATCH_SYMBOLS("_access_flags")) \ 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")) \ @@ -724,6 +725,14 @@ DECLARE(VMKlass) return VMKlass::load_then_cast(at(_klass_super_offset)); } + // Klass::_access_flags (an AccessFlags, i.e. one jint) into `*flags`, or + // false if the VM does not export it. + bool accessFlags(uint32_t* flags) { + if (_klass_access_flags_offset < 0) return false; + *flags = *(const uint32_t*)at(_klass_access_flags_offset); + return true; + } + VMClassLoaderData* classLoaderData() { assert(_class_loader_data_offset >= 0); return VMClassLoaderData::load_then_cast(at(_class_loader_data_offset)); diff --git a/ddprof-lib/src/main/cpp/jvmSupport.cpp b/ddprof-lib/src/main/cpp/jvmSupport.cpp index f43b117d57..82e61f7473 100644 --- a/ddprof-lib/src/main/cpp/jvmSupport.cpp +++ b/ddprof-lib/src/main/cpp/jvmSupport.cpp @@ -8,6 +8,7 @@ #include "asyncSampleMutex.h" #include "common.h" #include "frames.h" +#include "log.h" #include "os.h" #include "profiler.h" #include "threadLocalData.inline.h" @@ -76,13 +77,24 @@ void JVMSupport::initExecution(Arguments& args, jvmtiEnv* jvmti, JNIEnv* 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)) { + if (!args._lazy_jmethodID) { + state = Partial_loaded; + } else if (!HotspotSupport::canResolveLazily()) { + Log::warn("fjmethodid=lazy: this JVM does not expose the class and method structures it needs; " + "preloading jmethodIDs as with fjmethodid=false"); + state = Partial_loaded; + } else if (HotspotSupport::initLazyResolution(jni)) { state = Lazy_loaded; } else { - HotspotSupport::initClassloaderInfo(jni); + // initLazyResolution() has logged why. state = Partial_loaded; } + if (state == Partial_loaded) { + HotspotSupport::initClassloaderInfo(jni); + } + } else if (args._lazy_jmethodID) { + Log::warn(VM::isHotspot() ? "fjmethodid=lazy needs cstack=vm; preloading all jmethodIDs" + : "fjmethodid=lazy is only supported on HotSpot; preloading all jmethodIDs"); } if (state == current_state) { @@ -177,6 +189,16 @@ bool JVMSupport::loadMethodIDsIfNeeded(jvmtiEnv *jvmti, JNIEnv *jni, jclass klas } } +bool JVMSupport::enterRedefinitionCall() { + return VM::isHotspot() && HotspotSupport::enterRedefinitionCall(); +} + +void JVMSupport::exitRedefinitionCall(bool token) { + if (VM::isHotspot()) { + HotspotSupport::exitRedefinitionCall(token); + } +} + void JVMSupport::beforeClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass) { if (VM::isHotspot()) { HotspotSupport::beforeClassRedefinition(jvmti, jni, klass); diff --git a/ddprof-lib/src/main/cpp/jvmSupport.h b/ddprof-lib/src/main/cpp/jvmSupport.h index e7bae6bb44..825e6e967d 100644 --- a/ddprof-lib/src/main/cpp/jvmSupport.h +++ b/ddprof-lib/src/main/cpp/jvmSupport.h @@ -75,6 +75,11 @@ class JVMSupport { // 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); + // Called once around each whole RedefineClasses/RetransformClasses call, + // outside the per-class hooks: pass what enterRedefinitionCall() returned + // to exitRedefinitionCall(). + static bool enterRedefinitionCall(); + static void exitRedefinitionCall(bool token); // Around each dump: `classes` are the ones GetLoadedClasses() returned, // referenced by the caller until after endDump(). Raw frames of other diff --git a/ddprof-lib/src/main/cpp/vmEntry.cpp b/ddprof-lib/src/main/cpp/vmEntry.cpp index 1f7fa8792c..7da8ab380a 100644 --- a/ddprof-lib/src/main/cpp/vmEntry.cpp +++ b/ddprof-lib/src/main/cpp/vmEntry.cpp @@ -684,6 +684,7 @@ VM::RedefineClassesHook(jvmtiEnv *jvmti, jint class_count, return _orig_RedefineClasses(jvmti, class_count, class_definitions); } JNIEnv *env = jni(); + bool call_token = JVMSupport::enterRedefinitionCall(); for (int i = 0; i < class_count; i++) { if (class_definitions[i].klass != NULL) { JVMSupport::beforeClassRedefinition(jvmti, env, class_definitions[i].klass); @@ -699,6 +700,7 @@ VM::RedefineClassesHook(jvmtiEnv *jvmti, jint class_count, JVMSupport::afterClassRedefinition(jvmti, env, class_definitions[i].klass, result == 0); } } + JVMSupport::exitRedefinitionCall(call_token); return result; } @@ -711,6 +713,7 @@ jvmtiError VM::RetransformClassesHook(jvmtiEnv *jvmti, jint class_count, return _orig_RetransformClasses(jvmti, class_count, classes); } JNIEnv *env = jni(); + bool call_token = JVMSupport::enterRedefinitionCall(); for (int i = 0; i < class_count; i++) { if (classes[i] != NULL) { JVMSupport::beforeClassRedefinition(jvmti, env, classes[i]); @@ -725,6 +728,7 @@ jvmtiError VM::RetransformClassesHook(jvmtiEnv *jvmti, jint class_count, JVMSupport::afterClassRedefinition(jvmti, env, classes[i], result == 0); } } + JVMSupport::exitRedefinitionCall(call_token); return result; } diff --git a/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp b/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp index edf74b1416..b328470323 100644 --- a/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp +++ b/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp @@ -48,6 +48,8 @@ class VMTestAccessor { static void setVm(JavaVM* vm) { VM::_vm = vm; } static jvmtiEnv* getJvmti() { return VM::_jvmti; } static void setJvmti(jvmtiEnv* env) { VM::_jvmti = env; } + static int getHotspotVersion() { return VM::_hotspot_version; } + static void setHotspotVersion(int v) { VM::_hotspot_version = v; } }; class JVMSupportTestAccessor { @@ -63,6 +65,11 @@ class HotspotSupportTestAccessor { public: static KlassRegistry& registry() { return HotspotSupport::_klass_registry; } static void setLazyUsed(bool used) { HotspotSupport::_lazy_used = used; } + static bool enterRedefinitionCall() { return HotspotSupport::enterRedefinitionCall(); } + static void exitRedefinitionCall(bool untracked) { HotspotSupport::exitRedefinitionCall(untracked); } + static bool awaitUntrackedRedefinitions(u64 max_wait_ns) { + return HotspotSupport::awaitUntrackedRedefinitions(max_wait_ns); + } static void setRedefinitionsUntracked(bool v) { HotspotSupport::_redefinitions_untracked = v; } static bool dumpClassesBuilt() { return HotspotSupport::_dump_classes != nullptr; } static void beforeClassRedefinition(jvmtiEnv* jvmti, JNIEnv* jni, jclass klass) { @@ -91,7 +98,7 @@ class VMStructsTestAccessor { int method_constmethod, constmethod_constants, constmethod_idnum, pool_holder; int jmethod_ids, methods; int constmethod_name_index, constmethod_sig_index, klass_name, symbol_length, symbol_body; - int constmethod_flags, constmethod_code_size, klass_super; + int constmethod_flags, constmethod_code_size, klass_super, klass_access_flags; uint64_t method_size, const_method_size, constant_pool_size, klass_size, symbol_size; bool can_dereference_jmethod_id; }; @@ -118,6 +125,7 @@ class VMStructsTestAccessor { VMStructs::_constmethod_flags_offset, VMStructs::_constmethod_code_size_offset, VMStructs::_klass_super_offset, + VMStructs::_klass_access_flags_offset, VMStructs::TYPE_SIZE_NAME(VMMethod), VMStructs::TYPE_SIZE_NAME(VMConstMethod), VMStructs::TYPE_SIZE_NAME(VMConstantPool), @@ -142,6 +150,7 @@ class VMStructsTestAccessor { VMStructs::_constmethod_flags_offset = s.constmethod_flags; VMStructs::_constmethod_code_size_offset = s.constmethod_code_size; VMStructs::_klass_super_offset = s.klass_super; + VMStructs::_klass_access_flags_offset = s.klass_access_flags; 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; @@ -188,6 +197,7 @@ struct FakeKlass { FakeMethodArray* methods; uint64_t trace_id; // JFR encoding: (id << 16) | tag bits FakeSymbol* name; + uint32_t access_flags; // AccessFlags, as on JDK 15-23 }; const VMStructsTestAccessor::State LAYOUT = { @@ -205,6 +215,7 @@ const VMStructsTestAccessor::State LAYOUT = { /*constmethod_flags*/ -1, // no line number tables /*constmethod_code_size*/ -1, /*klass_super*/ -1, + /*klass_access_flags*/ (int)offsetof(FakeKlass, access_flags), /*method_size*/ sizeof(FakeMethod), /*const_method_size*/ sizeof(FakeConstMethod), /*constant_pool_size*/ offsetof(FakeConstantPool, symbols), @@ -528,7 +539,8 @@ class HotspotLazyResolveTest : public ::testing::Test { // A hidden class gets the name JVMTI GetClassSignature() reports, so its // frames are named the same with and without jmethodIDs; other names that -// merely contain a '+' are left alone. +// merely contain a '+' are left alone. On JDK 24+, where the hidden flag is +// not readable, the name alone decides. TEST_F(HotspotLazyResolveTest, HiddenClassNamedAsJvmtiSignature) { registerClass(_old_mirror); const struct { const char* vm_name; const char* expected; } cases[] = { @@ -538,6 +550,8 @@ TEST_F(HotspotLazyResolveTest, HiddenClassNamedAsJvmtiSignature) { {"a/Spin+0x", "a/Spin+0x"}, {"a/Spin+0x12g", "a/Spin+0x12g"}, }; + int saved_version = VMTestAccessor::getHotspotVersion(); + VMTestAccessor::setHotspotVersion(25); beginDump(); for (const auto& c : cases) { SCOPED_TRACE(c.vm_name); @@ -547,6 +561,36 @@ TEST_F(HotspotLazyResolveTest, HiddenClassNamedAsJvmtiSignature) { ASSERT_TRUE(HotspotSupport::resolve(frame.method, frame.bci, result)); EXPECT_STREQ(c.expected, result.klassName()); } + VMTestAccessor::setHotspotVersion(saved_version); +} + +// Where the hidden flag is readable (JDK 15-23, Klass::_access_flags), only +// a class that has it is renamed: an ordinary class may be named +// "a/Plain+0x1234" too. Before JDK 15 there are no hidden classes. +TEST_F(HotspotLazyResolveTest, OnlyClassesFlaggedHiddenAreRenamed) { + static const uint32_t JVM_ACC_IS_HIDDEN_CLASS = 0x04000000; + registerClass(_old_mirror); + FakeClass::setSymbol(_cls.klass_name, "a/Plain+0x1234"); + const struct { int version; uint32_t flags; const char* expected; } cases[] = { + {21, 0, "a/Plain+0x1234"}, + {21, JVM_ACC_IS_HIDDEN_CLASS, "a/Plain.0x1234"}, + {17, JVM_ACC_IS_HIDDEN_CLASS, "a/Plain.0x1234"}, + {23, 0, "a/Plain+0x1234"}, + {11, JVM_ACC_IS_HIDDEN_CLASS, "a/Plain+0x1234"}, + }; + int saved_version = VMTestAccessor::getHotspotVersion(); + beginDump(); + for (const auto& c : cases) { + SCOPED_TRACE(testing::Message() << "JDK " << c.version << " flags " << c.flags); + VMTestAccessor::setHotspotVersion(c.version); + _cls.klass.access_flags = c.flags; + ASGCT_CallFrame frame = sample(); + ResolvedMethod result; + ASSERT_TRUE(HotspotSupport::resolve(frame.method, frame.bci, result)); + EXPECT_STREQ(c.expected, result.klassName()); + } + _cls.klass.access_flags = 0; + VMTestAccessor::setHotspotVersion(saved_version); } // The names come from VM metadata: no jmethodIDs are allocated. @@ -767,6 +811,24 @@ TEST_F(HotspotLazyResolveTest, RejectedRedefinitionWithPendingExceptionLeavesFra EXPECT_TRUE(resolvesToSpin(frame)); } +// A redefinition call that started before lazy mode does not mark its +// classes, so lazy mode must not start sampling until it has returned. +TEST_F(HotspotLazyResolveTest, LazyStartWaitsForRedefinitionsStartedBeforeIt) { + HotspotSupportTestAccessor::setLazyUsed(false); + bool untracked = HotspotSupportTestAccessor::enterRedefinitionCall(); + EXPECT_TRUE(untracked); + HotspotSupportTestAccessor::setLazyUsed(true); // what initLazyResolution() does first + EXPECT_FALSE(HotspotSupportTestAccessor::awaitUntrackedRedefinitions(10 * 1000 * 1000)); + HotspotSupportTestAccessor::exitRedefinitionCall(untracked); + EXPECT_TRUE(HotspotSupportTestAccessor::awaitUntrackedRedefinitions(10 * 1000 * 1000)); + + // A call that starts once lazy mode is on is tracked, and not waited for. + bool later = HotspotSupportTestAccessor::enterRedefinitionCall(); + EXPECT_FALSE(later); + EXPECT_TRUE(HotspotSupportTestAccessor::awaitUntrackedRedefinitions(0)); + HotspotSupportTestAccessor::exitRedefinitionCall(later); +} + // Before lazy mode has started there are no lazy frames, so redefinitions // are not tracked. TEST_F(HotspotLazyResolveTest, RedefinitionBeforeLazyModeIsNotTracked) { diff --git a/ddprof-lib/src/test/cpp/redefineHooks_ut.cpp b/ddprof-lib/src/test/cpp/redefineHooks_ut.cpp index 1504fc6135..a81fad4d63 100644 --- a/ddprof-lib/src/test/cpp/redefineHooks_ut.cpp +++ b/ddprof-lib/src/test/cpp/redefineHooks_ut.cpp @@ -5,6 +5,7 @@ #include +#include "../../main/cpp/hotspot/hotspotSupport.h" #include "../../main/cpp/jvmSupport.h" #include "../../main/cpp/vmEntry.h" @@ -42,6 +43,12 @@ class JVMSupportTestAccessor { static constexpr LoadState FullyLoaded() { return JVMSupport::Fully_loaded; } }; +class HotspotSupportTestAccessor { +public: + static int untrackedCalls() { return HotspotSupport::_untracked_redefinition_calls; } + static void setLazyUsed(bool used) { HotspotSupport::_lazy_used = used; } +}; + namespace { struct Calls { @@ -53,11 +60,13 @@ struct Calls { const void* last_array = nullptr; jint class_status = JVMTI_CLASS_STATUS_PREPARED; jvmtiError class_status_error = JVMTI_ERROR_NONE; + int untracked_during_call = -1; // untracked redefinition calls seen by the original }; Calls* g_calls = nullptr; jvmtiError JNICALL origRedefine(jvmtiEnv*, jint count, const jvmtiClassDefinition* defs) { g_calls->redefine++; + g_calls->untracked_during_call = HotspotSupportTestAccessor::untrackedCalls(); g_calls->last_count = count; g_calls->last_array = defs; return count > 0 && defs == nullptr ? JVMTI_ERROR_NULL_POINTER : JVMTI_ERROR_INVALID_CLASS; @@ -65,6 +74,7 @@ jvmtiError JNICALL origRedefine(jvmtiEnv*, jint count, const jvmtiClassDefinitio jvmtiError JNICALL origRetransform(jvmtiEnv*, jint count, const jclass* classes) { g_calls->retransform++; + g_calls->untracked_during_call = HotspotSupportTestAccessor::untrackedCalls(); g_calls->last_count = count; g_calls->last_array = classes; return count > 0 && classes == nullptr ? JVMTI_ERROR_NULL_POINTER : JVMTI_ERROR_INVALID_CLASS; @@ -194,6 +204,27 @@ TEST_P(RedefineHooksTest, ArrayClassElementIsNotTouched) { EXPECT_EQ(0, _calls.get_class_methods); } +// A call that starts before lazy mode is counted while it runs, so that the +// first lazy start can wait for it (its classes are not marked); one that +// starts after is tracked and not counted. +TEST_P(RedefineHooksTest, CallsBeforeLazyModeAreCountedWhileTheyRun) { + jclass classes[] = {nullptr}; + jvmtiClassDefinition defs[] = {{nullptr, 0, nullptr}}; + HotspotSupportTestAccessor::setLazyUsed(false); + VM::RetransformClassesHook(&_jvmti, 1, classes); + EXPECT_EQ(1, _calls.untracked_during_call); + VM::RedefineClassesHook(&_jvmti, 1, defs); + EXPECT_EQ(1, _calls.untracked_during_call); + EXPECT_EQ(0, HotspotSupportTestAccessor::untrackedCalls()); + + HotspotSupportTestAccessor::setLazyUsed(true); + VM::RetransformClassesHook(&_jvmti, 1, classes); + EXPECT_EQ(0, _calls.untracked_during_call); + VM::RedefineClassesHook(&_jvmti, 1, defs); + EXPECT_EQ(0, _calls.untracked_during_call); + HotspotSupportTestAccessor::setLazyUsed(false); +} + // A null element inside a valid array is skipped as before. TEST_P(RedefineHooksTest, NullElementIsSkipped) { jclass classes[] = {nullptr};