diff --git a/ddprof-lib/src/main/cpp/arguments.cpp b/ddprof-lib/src/main/cpp/arguments.cpp index 82db261aa5..08c09c5201 100644 --- a/ddprof-lib/src/main/cpp/arguments.cpp +++ b/ddprof-lib/src/main/cpp/arguments.cpp @@ -304,8 +304,13 @@ Error Arguments::parse(const char *args) { if (value != nullptr) { if (strcmp(value, "false") == 0) { _force_jmethodID = false; + _lazy_jmethodID = false; } else if (strcmp(value, "true") == 0) { _force_jmethodID = true; + _lazy_jmethodID = false; + } else if (strcmp(value, "lazy") == 0) { + _force_jmethodID = false; + _lazy_jmethodID = true; } else { msg = "Invalid jmethodID creation value"; } diff --git a/ddprof-lib/src/main/cpp/arguments.h b/ddprof-lib/src/main/cpp/arguments.h index d6222f9157..b6fe1f074c 100644 --- a/ddprof-lib/src/main/cpp/arguments.h +++ b/ddprof-lib/src/main/cpp/arguments.h @@ -274,6 +274,7 @@ class Arguments { bool _nativesocket; long _nativesocket_interval; // initial sampling period in nanoseconds; 0 = engine default bool _force_jmethodID; // Load all jmethodIDs, true by default + bool _lazy_jmethodID; // fjmethodid=lazy: allocate jmethodIDs only for sampled classes, at dump time Arguments(bool persistent = false) : _buf(NULL), @@ -324,7 +325,8 @@ class Arguments { _jvmtistacks(false), _nativesocket(false), _nativesocket_interval(0), - _force_jmethodID(true) {} + _force_jmethodID(true), + _lazy_jmethodID(false) {} ~Arguments(); diff --git a/ddprof-lib/src/main/cpp/counters.h b/ddprof-lib/src/main/cpp/counters.h index 0c55316899..8ccb8c1675 100644 --- a/ddprof-lib/src/main/cpp/counters.h +++ b/ddprof-lib/src/main/cpp/counters.h @@ -173,6 +173,40 @@ * 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") \ + /* STALE: the class now at the frame's holder address is a different load \ + * (its trace ID differs); serializes as "unknown". \ + * ADD_FAILED: registration failed, so the class was preloaded instead. \ + * UNTAGGABLE: a Method* using address bits the raw-frame tag needs, or \ + * an unreadable holder trace ID; the frame is stored as not walkable. */ \ + X(METHOD_RESOLVE_LAZY_STALE, "method_resolve_lazy_stale") \ + /* REDEFINED: raw frame of a class that has been redefined (its Method*s \ + * may have been freed and reused); serializes as "unknown". */ \ + X(METHOD_RESOLVE_LAZY_REDEFINED, "method_resolve_lazy_redefined") \ + /* Registered classes marked by the redefinition hooks (once per class \ + * per redefine/retransform call). */ \ + X(KLASS_REGISTRY_REDEFINITIONS_MARKED, "klass_registry_redefinitions_marked") \ + /* Registrations whose class trace ID no longer fits in a raw frame's \ + * 25-bit tag. Nonzero means trace-ID tags of two classes can now collide: \ + * a frame sampled from an unloaded class could then, if a newer class \ + * also took over its Method* memory, resolve to that class's method. */ \ + X(KLASS_TRACE_ID_BEYOND_TAG_RANGE, "klass_trace_id_beyond_tag_range") \ + /* Raw Method* frame occurrences answered from Lookup's per-dump cache \ + * instead of resolving them again. */ \ + X(RAW_FRAME_RESOLVE_CACHE_HITS, "raw_frame_resolve_cache_hits") \ + X(KLASS_REGISTRY_ADD_FAILED, "klass_registry_add_failed") \ + X(WALKVM_RAW_FRAME_UNTAGGABLE, "walkvm_raw_frame_untaggable") \ + /* Gauge, set at each fjmethodid=lazy start: the Klass::_trace_id offset \ + * raw frames are tagged with, or -1 if it was not found (lazy mode then \ + * falls back to fjmethodid=false). 0 if lazy mode was never requested. */ \ + X(KLASS_TRACE_ID_OFFSET, "klass_trace_id_offset") \ /* Strict subset of SAMPLES_DROPPED_THREAD_LOCAL, not an independent count: \ * ThreadLocalDataPool::claim() increments this on capacity exhaustion, and \ * every acquireCurrent() caller that gets nullptr back -- for this or any \ diff --git a/ddprof-lib/src/main/cpp/flightRecorder.cpp b/ddprof-lib/src/main/cpp/flightRecorder.cpp index 98455483d8..6b310a13e3 100644 --- a/ddprof-lib/src/main/cpp/flightRecorder.cpp +++ b/ddprof-lib/src/main/cpp/flightRecorder.cpp @@ -702,13 +702,25 @@ MethodInfo *Lookup::resolveMethod(ASGCT_CallFrame &frame) { return fillMethod(frame, method_id, bci, prof_thread); } +jmethodID Lookup::resolveRawMethod(const ASGCT_CallFrame &frame) { + RawMethodKey key{FrameType::untagPointer(frame.method), FrameType::rawTag(frame.bci, frame.method)}; + auto cached = _raw_method_cache.find(key); + if (cached != _raw_method_cache.end()) { + Counters::increment(RAW_FRAME_RESOLVE_CACHE_HITS); + return cached->second; + } + jmethodID method_id = JVMSupport::resolve(frame.method, frame.bci); + _raw_method_cache.emplace(key, method_id); + return method_id; +} + MethodInfo *Lookup::fillMethod(ASGCT_CallFrame &frame, jmethodID method_id, jint bci, ProfiledThread* const prof_thread) { assert(prof_thread != nullptr); // Resolve native method if (FrameType::isRawPointer(bci)) { - method_id = JVMSupport::resolve(frame.method); + method_id = resolveRawMethod(frame); if (method_id == nullptr) { return unknownMethod(); } diff --git a/ddprof-lib/src/main/cpp/flightRecorder.h b/ddprof-lib/src/main/cpp/flightRecorder.h index bee39b415e..e5f56d4305 100644 --- a/ddprof-lib/src/main/cpp/flightRecorder.h +++ b/ddprof-lib/src/main/cpp/flightRecorder.h @@ -464,6 +464,29 @@ class Lookup { std::unordered_map, std::equal_to, CountingAllocator, NM_JFR_BUFFERS>> _vtable_receiver_cache; + + // Per-dump cache of raw Method* frame resolutions (HotSpot cstack=vm frames + // whose class had no jmethodID when sampled). The same raw method recurs in + // many traces, and each resolution sets up crash protection, takes the + // KlassRegistry mutex and pins the class through JNI. Keyed by the untagged + // Method* and the frame's tag, so occurrences that differ only in bci or + // frame type share one entry. A cached result stays right for the whole + // dump: an accepted frame was sampled from that method, and a refused one + // stays refused. + struct RawMethodKey { + const void* method; + uint32_t tag; + bool operator==(const RawMethodKey& o) const { return method == o.method && tag == o.tag; } + }; + struct RawMethodKeyHash { + size_t operator()(const RawMethodKey& k) const { + return std::hash()(k.method) ^ ((size_t)k.tag * KNUTH_MULTIPLICATIVE_CONSTANT); + } + }; + std::unordered_map, + CountingAllocator, NM_JFR_BUFFERS>> + _raw_method_cache; + Dictionary _packages; Dictionary _symbols; @@ -547,6 +570,9 @@ class Lookup { _symbols() {} MethodInfo *resolveMethod(ASGCT_CallFrame &frame); + // Resolves a raw Method* frame to a jmethodID (nullptr if it must be + // treated as unknown), at most once per distinct method and tag per dump. + jmethodID resolveRawMethod(const ASGCT_CallFrame &frame); u32 getPackage(const char *class_name); u32 getSymbol(const char *name); }; diff --git a/ddprof-lib/src/main/cpp/frame.h b/ddprof-lib/src/main/cpp/frame.h index 9c5e37cb21..438575d6cb 100644 --- a/ddprof-lib/src/main/cpp/frame.h +++ b/ddprof-lib/src/main/cpp/frame.h @@ -17,6 +17,7 @@ #define _FRAME_H #include +#include #include "vmEntry.h" enum FrameTypeId { @@ -35,11 +36,22 @@ enum FrameTypeId { // Packs frame type and BCI into a single int field stored in CallTrace frames. // // Bit layout of an encoded value (ENCODED_MASK set): -// bit 30 RAW_POINTER_MASK — value is a raw native PC (HotSpot only) +// bit 30 RAW_POINTER_MASK — method field is a raw Method* (HotSpot only) +// bits 29–24 raw tag, bits 9–4 — only with RAW_POINTER_MASK, see below // bits 23–21 frame type — FrameTypeId (0–7) // bit 20 ENCODED_MASK — set to distinguish encoded values from raw ASGCT BCIs +// bits 19–16 raw tag, bits 3–0 — only with RAW_POINTER_MASK, see below // bits 15–0 BCI — bytecode index (0–65534; never 65535, see BCI_MASK) // +// A raw Method* frame sampled in fjmethodid=lazy mode also carries a +// RAW_TAG_BITS-wide tag identifying the load of the class it was sampled from +// (see HotspotSupport::TRACE_ID_TAG). Its low 10 bits sit in the otherwise +// unused BCI bits above; its high 16 bits sit in the top 16 bits of the +// Method* pointer, which user-space addresses below 2^48 leave clear. The tag +// makes frames sampled from different loads of a class at the same +// Klass*/Method* addresses differ, and lets resolution reject frames whose +// class's memory has since been taken over by another. +// // When ENCODED_MASK is not set the field is a raw, unencoded BCI from ASGCT (non-VM stack // walking modes). Raw values may be negative: HotSpot uses -1 as a sentinel for method-entry // and synchronization-entry samples. Both encode() and bci() clamp negative values to 0 so @@ -53,7 +65,15 @@ class FrameType { static constexpr int TYPE_SHIFT = 21; static constexpr int ENCODED_MASK = 1 << 20; // distinguishes encoded values from raw ASGCT BCIs static constexpr int RAW_POINTER_MASK = 1 << 30; + static constexpr int RAW_TAG_LOW_SHIFT = 16; // tag bits 3–0 -> bits 19–16 + static constexpr int RAW_TAG_MID_SHIFT = 24; // tag bits 9–4 -> bits 29–24 + static constexpr int RAW_TAG_BCI_BITS = 10; + static constexpr int POINTER_TAG_SHIFT = 48; // tag bits 25–10 -> pointer bits 63–48 + static constexpr uintptr_t POINTER_ADDRESS_MASK = ((uintptr_t)1 << POINTER_TAG_SHIFT) - 1; public: + static constexpr int RAW_TAG_BITS = 26; + static constexpr uint32_t RAW_TAG_MASK = (1u << RAW_TAG_BITS) - 1; + // Produces an encoded int from a frame type and BCI. Negative BCIs (HotSpot -1 sentinels // for method-entry/sync-entry) are mapped to 0 rather than wrapping to 0xffff. static inline int encode(int type, int bci, bool rawPointer = false) { @@ -63,6 +83,36 @@ class FrameType { return ENCODED_MASK | (type << TYPE_SHIFT) | bci_bits | (rawPointer ? RAW_POINTER_MASK : 0); } + // Encodes a raw Method* frame carrying `tag` (see the layout above). The + // matching method field is tagPointer(method, tag). + static inline int encodeRaw(int type, int bci, uint32_t tag) { + return encode(type, bci, true) | (int)((tag & 0xf) << RAW_TAG_LOW_SHIFT) | + (int)(((tag >> 4) & 0x3f) << RAW_TAG_MID_SHIFT); + } + + // True if `ptr` leaves the pointer tag bits clear, i.e. tagPointer() is reversible. + static inline bool canTagPointer(const void* ptr) { + return ((uintptr_t)ptr & ~POINTER_ADDRESS_MASK) == 0; + } + + static inline const void* tagPointer(const void* ptr, uint32_t tag) { + assert(canTagPointer(ptr)); + uintptr_t high = (uintptr_t)((tag & RAW_TAG_MASK) >> RAW_TAG_BCI_BITS); + return (const void*)((uintptr_t)ptr | (high << POINTER_TAG_SHIFT)); + } + + static inline const void* untagPointer(const void* ptr) { + return (const void*)((uintptr_t)ptr & POINTER_ADDRESS_MASK); + } + + // Reassembles the tag of a raw Method* frame from its encoded BCI and method field. + static inline uint32_t rawTag(int bci, const void* ptr) { + uint32_t low = ((uint32_t)bci >> RAW_TAG_LOW_SHIFT) & 0xf; + uint32_t mid = ((uint32_t)bci >> RAW_TAG_MID_SHIFT) & 0x3f; + uint32_t high = (uint32_t)((uintptr_t)ptr >> POINTER_TAG_SHIFT); + return (low | (mid << 4) | (high << RAW_TAG_BCI_BITS)) & RAW_TAG_MASK; + } + // Extracts the BCI from either an encoded value or a raw ASGCT BCI. // Negative values (HotSpot -1 sentinels) are clamped to 0, matching encode(). static inline int bci(int bci) { diff --git a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp index a811682936..6ef10fabbd 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" @@ -139,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 frame.method == method; + return HotspotSupport::rawFrameMethod(frame.bci, frame.method) == method; } else { return frame.method_id == method_id; } @@ -186,13 +187,22 @@ static void fillFrameTypes(ASGCT_CallFrame *frames, int num_frames, VMNMethod *n } } -// Fill the frame with raw method pointer +// Fill the frame with raw method pointer, as partial mode stores it. static void fillFrameRaw(ASGCT_CallFrame& frame, FrameTypeId type, int bci, const VMMethod* method) { NO_INJECTION_ASSERT(method != nullptr); frame.bci = FrameType::encode(type, bci, true /*raw method pointer*/); frame.method = static_cast(method); } +// Fill the frame with raw method pointer and its lazy-mode tag (see frame.h +// for where the tag bits go, HotspotSupport::TRACE_ID_TAG for what they hold). +static void fillFrameRawTagged(ASGCT_CallFrame& frame, FrameTypeId type, int bci, const VMMethod* method, + uint32_t tag) { + NO_INJECTION_ASSERT(method != nullptr); + frame.bci = FrameType::encodeRaw(type, bci, tag); + frame.method = FrameType::tagPointer(method, tag); +} + void HotspotSupport::fillJavaFrame(ASGCT_CallFrame& frame, FrameTypeId type, int bci, jmethodID method_id, const VMMethod* method) { if (method_id == JMETHODID_NOT_WALKABLE) { @@ -211,7 +221,20 @@ void HotspotSupport::fillJavaFrame(ASGCT_CallFrame& frame, FrameTypeId type, int // resolution path; the deferred-dereference race at dump time is // inherent to this mode. NO_INJECTION_ASSERT(method != nullptr); - fillFrameRaw(frame, type, bci, method); + uint32_t tag; + 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 { + fillFrameRawTagged(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. @@ -1453,6 +1476,84 @@ 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::initLazyResolution(JNIEnv* jni) { + bool calibrated = KlassTraceId::calibrate(jni); + // Counters are reset on every profiler start, but calibration only runs + // once, so republish its result here. + Counters::set(KLASS_TRACE_ID_OFFSET, calibrated ? KlassTraceId::offset() : -1); + TEST_LOG("Raw frame tags: %s", calibrated ? "klass trace id" : "unavailable, lazy mode off"); + return calibrated; +} + +bool HotspotSupport::lazyFrameTag(const VMMethod* method, uint32_t* tag) { + uint64_t id; + if (!KlassTraceId::read(method->holderOrNull(), &id)) { + return false; + } + *tag = TRACE_ID_TAG | ((uint32_t)id & TAG_VALUE_MASK); + return true; +} + +bool HotspotSupport::isInstanceClass(jvmtiEnv *jvmti, jclass klass) { + jint status = 0; + // GetClassStatus also returns JVMTI_ERROR_INVALID_CLASS for an object + // that is not a class. + return klass != nullptr && jvmti->GetClassStatus(klass, &status) == JVMTI_ERROR_NONE && + (status & (JVMTI_CLASS_STATUS_ARRAY | JVMTI_CLASS_STATUS_PRIMITIVE)) == 0; +} + +void HotspotSupport::beforeClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass) { + if (_klass_registry.size() == 0 || !isInstanceClass(jvmti, klass)) { + return; + } + if (_klass_registry.beginRedefinition(VMKlass::fromJavaClass(jni, klass))) { + Counters::increment(KLASS_REGISTRY_REDEFINITIONS_MARKED); + } +} + +void HotspotSupport::afterClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass, bool succeeded) { + if (_klass_registry.size() == 0 || !isInstanceClass(jvmti, klass)) { + return; + } + _klass_registry.endRedefinition(VMKlass::fromJavaClass(jni, klass), succeeded); + // The class's raw frames are refused from now on, so give its later + // frames jmethodIDs. After a failed call nothing changed: its frames + // stay raw and resolvable. + if (succeeded && JVMSupport::getLoadState() == JVMSupport::Lazy_loaded) { + loadMethodIDsIfNeededImpl(jvmti, jni, klass, true /* load all */); + } +} + +bool HotspotSupport::registerClass(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass) { + // Array and primitive classes have no methods, so no frame ever names them + // as a holder; skipping them keeps GetLoadedClasses() at start cheap. + if (!isInstanceClass(jvmti, klass)) { + return false; + } + const void* vm_klass = VMKlass::fromJavaClass(jni, klass); + uint64_t trace_id; + if (KlassTraceId::read(vm_klass, &trace_id) && trace_id > TAG_VALUE_MASK) { + // Two classes' 25-bit trace-ID tags can only collide once their IDs + // are at least 2^25 apart, so before any class has an ID this large + // no stale frame can be matched to a newer class by its tag. + Counters::increment(KLASS_TRACE_ID_BEYOND_TAG_RANGE); + } + if (_klass_registry.add(jni, vm_klass, klass)) { + return true; + } + // An unregistered class's raw frames could never be resolved (pin() finds + // no entry), so give it jmethodIDs up front: walkVM then never stores a + // raw frame for it. + Counters::increment(KLASS_REGISTRY_ADD_FAILED); + return loadMethodIDsIfNeededImpl(jvmti, jni, klass, true /*load all*/); +} + // 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 +1760,31 @@ 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; + } + // The same constMethod -> constants -> pool holder walk the sampler does + // when it tags the frame (lazyFrameTag()). + const void* holder = vm_method->holderOrNull(); + VMConstMethod* const_method = vm_method->constMethod_or_null(); + if (holder == nullptr || const_method == nullptr) { + return false; + } + *out_vm_method = vm_method; + *out_holder = holder; + *out_idnum = const_method->idnum(); + return true; +} + // 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,16 +1857,24 @@ static jmethodID lookupMethodIdViaJni(VMMethod* vm_method, const ResolvedNames& return method_id; } -// This method only resolves methods that are loaded by system class loaders -jmethodID HotspotSupport::resolve(const void* method) { +// 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) { 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; } + // 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 @@ -1763,6 +1897,8 @@ jmethodID HotspotSupport::resolve(const void* method) { ResolvedNames names; VMMethod* vm_method = nullptr; jmethodID existing_id = nullptr; + const void* holder = nullptr; + int idnum = -1; bool walked = false; { @@ -1791,15 +1927,96 @@ 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, 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); + } +} +#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 +// 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) { + 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); + return nullptr; + } + + 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; + } + // 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 { + 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; + } + } + } + } + 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 8588635dfc..3ea2984dab 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,50 @@ class HotspotSupport { static bool loadMethodIDsIfNeededImpl(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass, bool load_all); + // fjmethodid=lazy: liveness registry for every loaded class, consulted by + // resolve() before it touches a raw Method*. See klassRegistry.h. + static KlassRegistry _klass_registry; + + // Raw frame tags (frame.h). fjmethodid=lazy frames set the top tag bit + // and keep the low 25 bits of the holder's JFR trace ID (KlassTraceId) in + // the rest, unique per load of a class. Partial-mode (fjmethodid=false) + // frames carry no tag: their method field is the plain Method*, whose top + // bit is never set for a user-space address. resolve() goes by each + // frame's own kind, never by the current mode. + static const uint32_t TRACE_ID_TAG = 1u << (FrameType::RAW_TAG_BITS - 1); + static const uint32_t TAG_VALUE_MASK = TRACE_ID_TAG - 1; + + // The tag to store with a lazy-mode raw frame of `method`; false if the + // holder's trace ID is unreadable. Signal safe. + static bool lazyFrameTag(const VMMethod* method, uint32_t* tag); + + // fjmethodid=lazy: records `klass` in _klass_registry instead of + // allocating its jmethodIDs. Called wherever the other modes preload. + static bool registerClass(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass); + + // Around every RedefineClasses/RetransformClasses call, in every mode + // once _klass_registry has entries (raw frames of a lazy session can + // outlive it): marks `klass` while the call runs and for good if it + // succeeds (see klassRegistry.h for why its raw frames are refused), and in + // lazy mode preloads its jmethodIDs after a successful call. `klass` comes + // straight from the caller, so anything that is not an instance class is + // ignored, as JVMTI itself rejects it. + static void beforeClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass); + static void afterClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass, bool succeeded); + + // True if JVMTI accepts `klass` as a class that is neither an array nor a + // primitive, i.e. one with a Klass* that has methods. + static bool isInstanceClass(jvmtiEnv *jvmti, jclass klass); + + // fjmethodid=lazy half of resolve(): pins the class registered under + // `holder`, refuses it if its trace ID does not match `trace_id_bits` (the + // frame came from another load of a class whose memory this one reuses), + // checks that `vm_method` is still one of its methods, and only then reads + // 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); + // Runs getJavaTraceAsync() under withUcontextFaultRecovery(), then layers // on its two post-processing steps: resolving frame types for the top // Java frame via fillFrameTypes(), and appending a synthetic "JVM @@ -157,15 +202,34 @@ class HotspotSupport { return JitCodeCache::runtimeStubsMemoryUsage(); } - // If should load all jmethodIDs - static inline bool shouldPreloadJmethodIDs(Arguments& args) { - CStack cstack = args._cstack; - return args._force_jmethodID || - !(cstack == CSTACK_VM || cstack == CSTACK_DEFAULT); // Can only use Method* when cstack = vm + // If should load all jmethodIDs. Raw Method* frames only come from walkVM, + // so anything but the resolved stack walker mode `cstack` == CSTACK_VM -- + // including CSTACK_DEFAULT, which resolves to DWARF where the VM walker is + // unavailable -- needs every jmethodID preloaded. + static inline bool shouldPreloadJmethodIDs(const Arguments& args, CStack cstack) { + return args._force_jmethodID || cstack != CSTACK_VM; + } + + // The Method* a raw frame stands for: untagged for a lazy-mode frame, + // as stored for a partial-mode one. + static inline const void* rawFrameMethod(int bci, const void* method) { + return (FrameType::rawTag(bci, method) & TRACE_ID_TAG) != 0 ? FrameType::untagPointer(method) : method; } - // Resolve a method to a jmethodID at dumping time - static jmethodID resolve(const void* method); + // fjmethodid=lazy needs the Klass*/methods-array structs on top of the + // ones the raw Method* walk already uses; without them, the caller falls + // back to the partial preload mode. + static bool canResolveLazily(); + + // fjmethodid=lazy setup that needs JNI: locates Klass::_trace_id + // (KlassTraceId::calibrate). Returns false if it cannot be found; lazy + // mode then does not run, since nothing else can tell a stale raw frame + // from one of a class that took over its memory. + static bool initLazyResolution(JNIEnv* jni); + + // 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 new file mode 100644 index 0000000000..6b661434a1 --- /dev/null +++ b/ddprof-lib/src/main/cpp/hotspot/klassRegistry.cpp @@ -0,0 +1,194 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "hotspot/klassRegistry.h" + +#include + +static inline size_t slotFor(const void* klass, size_t capacity) { + // Klass* is at least 8-byte aligned; drop the always-zero bits before mixing. + uint64_t h = ((uintptr_t)klass >> 3) * 0x9E3779B97F4A7C15ULL; + return (size_t)(h >> 32) & (capacity - 1); +} + +// Linear probing. Returns the slot holding `klass`, or the empty slot where it +// would go. The table is never full (rehash() keeps the load factor <= 3/4), +// so the probe always terminates. +KlassRegistry::Entry* KlassRegistry::find(const void* klass) const { + size_t i = slotFor(klass, _capacity); + while (_table[i].klass != nullptr && _table[i].klass != klass) { + i = (i + 1) & (_capacity - 1); + } + return &_table[i]; +} + +// Called with _lock held. The entry registered for `klass`, or nullptr. +KlassRegistry::Entry* KlassRegistry::findRegistered(const void* klass) const { + if (klass == nullptr || _capacity == 0) { + return nullptr; + } + Entry* e = find(klass); + return e->klass == klass ? e : nullptr; +} + +// Moves the live entries into a table sized for them and deletes the weak +// references of dead ones. Two passes so the old table stays intact if the +// allocation fails: the first only counts live entries to size the new +// table, the second checks liveness again and drops whatever is dead by then, +// so an entry that died in between is purged too. +bool KlassRegistry::rehash(JNIEnv* jni) { + size_t live = 0; + for (size_t i = 0; i < _capacity; i++) { + if (_table[i].klass != nullptr && !jni->IsSameObject(_table[i].mirror, nullptr)) { + live++; + } + } + + size_t capacity = INITIAL_CAPACITY; + while (capacity < (live + 1) * 2) { + capacity <<= 1; + } + Entry* table = (Entry*)calloc(capacity, sizeof(Entry)); + if (table == nullptr) { + return false; + } + + Entry* old_table = _table; + size_t old_capacity = _capacity; + _table = table; + _capacity = capacity; + _size = 0; + for (size_t i = 0; i < old_capacity; i++) { + Entry& e = old_table[i]; + if (e.klass == nullptr) { + continue; + } + if (jni->IsSameObject(e.mirror, nullptr)) { + jni->DeleteWeakGlobalRef(e.mirror); + } else { + *find(e.klass) = e; + _size++; + } + } + free(old_table); + return true; +} + +bool KlassRegistry::add(JNIEnv* jni, const void* klass, jclass mirror) { + if (klass == nullptr || mirror == nullptr) { + return false; + } + MutexLocker ml(_lock); + + if (_capacity != 0) { + Entry* e = find(klass); + if (e->klass == klass) { + if (jni->IsSameObject(e->mirror, mirror)) { + return true; + } + jweak ref = jni->NewWeakGlobalRef(mirror); + if (ref == nullptr) { + return false; + } + jni->DeleteWeakGlobalRef(e->mirror); + e->mirror = ref; + // A different class now lives at this address; the previous one's + // redefinition state does not carry over. + e->redefinitions_in_progress = 0; + e->redefined = false; + return true; + } + } + + if ((_size + 1) * 4 > _capacity * 3 && !rehash(jni)) { + return false; + } + jweak ref = jni->NewWeakGlobalRef(mirror); + if (ref == nullptr) { + return false; + } + Entry* e = find(klass); + e->klass = klass; + e->mirror = ref; + e->redefinitions_in_progress = 0; + e->redefined = false; + _size++; + return true; +} + +jclass KlassRegistry::pin(JNIEnv* jni, const void* klass, PinStatus* status) { + PinStatus local_status; + if (status == nullptr) { + status = &local_status; + } + *status = PIN_ABSENT; + MutexLocker ml(_lock); + Entry* e = findRegistered(klass); + if (e == nullptr) { + return nullptr; + } + if (e->redefined || e->redefinitions_in_progress > 0) { + *status = PIN_REDEFINED; + return nullptr; + } + // NewLocalRef on a weak global reference returns NULL once the referent has + // been collected. Done under the lock so a concurrent rehash() cannot delete + // the weak reference out from under the call. + jclass ref = (jclass)jni->NewLocalRef(e->mirror); + if (ref != nullptr) { + *status = PIN_OK; + } + return ref; +} + +bool KlassRegistry::beginRedefinition(const void* klass) { + MutexLocker ml(_lock); + Entry* e = findRegistered(klass); + if (e == nullptr) { + return false; + } + e->redefinitions_in_progress++; + return true; +} + +void KlassRegistry::endRedefinition(const void* klass, bool succeeded) { + MutexLocker ml(_lock); + Entry* e = findRegistered(klass); + if (e == nullptr) { + return; + } + // Clamped: the entry may have been replaced (a new class at this address) + // while the call was in progress, which resets the count. + if (e->redefinitions_in_progress > 0) { + e->redefinitions_in_progress--; + } + if (succeeded) { + e->redefined = true; + } +} + +bool KlassRegistry::isRedefined(const void* klass) { + MutexLocker ml(_lock); + Entry* e = findRegistered(klass); + return e != nullptr && (e->redefined || e->redefinitions_in_progress > 0); +} + +size_t KlassRegistry::size() { + MutexLocker ml(_lock); + return _size; +} + +void KlassRegistry::clear(JNIEnv* jni) { + MutexLocker ml(_lock); + for (size_t i = 0; i < _capacity; i++) { + if (_table[i].klass != nullptr) { + jni->DeleteWeakGlobalRef(_table[i].mirror); + } + } + free(_table); + _table = nullptr; + _capacity = 0; + _size = 0; +} diff --git a/ddprof-lib/src/main/cpp/hotspot/klassRegistry.h b/ddprof-lib/src/main/cpp/hotspot/klassRegistry.h new file mode 100644 index 0000000000..903a787190 --- /dev/null +++ b/ddprof-lib/src/main/cpp/hotspot/klassRegistry.h @@ -0,0 +1,120 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#ifndef _HOTSPOT_KLASSREGISTRY_H +#define _HOTSPOT_KLASSREGISTRY_H + +#include "mutex.h" + +#include +#include +#include + +/** + * Maps a HotSpot Klass* to a JNI weak global reference to its java.lang.Class + * mirror, so the dump thread can tell whether the class a raw Method* frame + * came from is still loaded -- and keep it loaded while it resolves the frame. + * + * Used by fjmethodid=lazy: walkVM stores a raw Method* for every frame whose + * class has no jmethodID yet, and HotspotSupport::resolve() later reads the + * holder Klass* out of that (possibly freed) Method*. That pointer is only a + * lookup key here and is never dereferenced: pin() returns a strong local + * reference only if the class registered under that address is still + * reachable, and a strongly reachable mirror keeps its class loader -- and so + * the class's metadata -- from being unloaded until the reference is released. + * Whether the pinned class is the same load of a class as the one sampled is + * checked separately, through the class's JFR trace ID (see HotspotSupport). + * + * Entries are added from ClassPrepare and never removed explicitly; dead ones + * (weak reference cleared) are purged whenever the table would have to grow, + * so memory tracks the number of live classes rather than every class ever + * loaded. A Klass* address reused by a newly loaded class simply overwrites + * the dead entry on that class's ClassPrepare. + * + * Redefinition: a redefined class stays alive, but its old methods array and + * old Method*s are freed once no longer on any stack, and later allocations + * -- including another redefinition of the same class -- can reuse them. So + * beginRedefinition() is called before every redefinition and + * endRedefinition() after it. While one is in progress, and for good once one + * has succeeded, pin() refuses the class's raw frames. (After a successful + * redefinition the class's jmethodIDs are preloaded, so its later frames are + * not raw.) A failed call leaves the methods untouched, and the class as it + * was. + * + * All operations take an internal mutex and call JNI, so none of them may be + * used from a signal handler. + */ +class KlassRegistry { + struct Entry { + const void* klass; + jweak mirror; + uint32_t redefinitions_in_progress; + bool redefined; // a redefinition succeeded; never reset + }; + + static const size_t INITIAL_CAPACITY = 1024; + + Mutex _lock; + Entry* _table; + size_t _capacity; // Power of two, or 0 before the first add() + size_t _size; // Occupied slots, live or dead + + Entry* find(const void* klass) const; + Entry* findRegistered(const void* klass) const; + bool rehash(JNIEnv* jni); + +public: + enum PinStatus { + PIN_OK, + PIN_ABSENT, // not registered, or the class has been unloaded + PIN_REDEFINED, // the class is being or has been redefined + }; + + KlassRegistry() : _table(nullptr), _capacity(0), _size(0) {} + + KlassRegistry(const KlassRegistry&) = delete; + KlassRegistry& operator=(const KlassRegistry&) = delete; + + /** + * Records that `klass` is the Klass* of `mirror`. Re-adding the same pair is + * a no-op; adding a different mirror for a known Klass* replaces the entry. + * Returns false if the weak reference or the table could not be allocated. + */ + bool add(JNIEnv* jni, const void* klass, jclass mirror); + + /** + * Returns a new local reference to the mirror registered for `klass`, or + * nullptr -- with the reason in `*status` -- if none is registered, the + * class has been unloaded, or it is being or has been redefined. While the + * caller holds the reference the class cannot be unloaded; it must release + * it with DeleteLocalRef. + */ + jclass pin(JNIEnv* jni, const void* klass, PinStatus* status = nullptr); + + /** + * Call before a redefinition of `klass` starts, so that a resolution which + * validated the class's metadata just before the methods are swapped still + * sees it in isRedefined(). Returns false if `klass` is not registered. + */ + bool beginRedefinition(const void* klass); + + /** + * Call after the redefinition call has returned, once for every + * beginRedefinition() that returned true. `succeeded` makes the mark + * permanent; otherwise the class is treated as it was before the call. + */ + void endRedefinition(const void* klass, bool succeeded); + + /** Whether `klass` is being or has been redefined. */ + bool isRedefined(const void* klass); + + /** Number of occupied slots, including dead entries not yet purged. */ + size_t size(); + + /** Deletes every weak reference and frees the table. */ + void clear(JNIEnv* jni); +}; + +#endif // _HOTSPOT_KLASSREGISTRY_H diff --git a/ddprof-lib/src/main/cpp/hotspot/klassTraceId.cpp b/ddprof-lib/src/main/cpp/hotspot/klassTraceId.cpp new file mode 100644 index 0000000000..1b51679045 --- /dev/null +++ b/ddprof-lib/src/main/cpp/hotspot/klassTraceId.cpp @@ -0,0 +1,109 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "hotspot/klassTraceId.h" + +#include "common.h" +#include "counters.h" +#include "hotspot/vmStructs.inline.h" + +#include + +int KlassTraceId::_offset = -1; +bool KlassTraceId::_calibrated = false; + +// JFR stores the ID shifted left by TRACE_ID_SHIFT (16) above its tag bits. +static const int TRACE_ID_SHIFT = 16; + +static inline uint64_t idAt(const void* klass, size_t offset) { + uint64_t raw; + memcpy(&raw, (const char*)klass + offset, sizeof(raw)); + return raw >> TRACE_ID_SHIFT; +} + +// The probes were created back to back by one thread, so their IDs are +// distinct and lie within a narrow window: PROBE_COUNT consecutive values +// plus whatever other threads took meanwhile. Pointers to neighbouring +// metadata fail this (they are hundreds of bytes apart, so `>> 16` mostly +// collapses them to one value); so do small integers (equal across probes) +// and compressed-klass bits in a mark word (far more than ID_SLACK apart). +static bool plausibleIds(const void* const* klasses, int count, size_t offset) { + uint64_t min = UINT64_MAX; + uint64_t max = 0; + for (int i = 0; i < count; i++) { + uint64_t id = idAt(klasses[i], offset); + if (id == 0) { + return false; + } + for (int j = 0; j < i; j++) { + if (idAt(klasses[j], offset) == id) { + return false; + } + } + min = id < min ? id : min; + max = id > max ? id : max; + } + return max - min <= (uint64_t)(count - 1) + KlassTraceId::ID_SLACK; +} + +int KlassTraceId::findOffset(const void* const* klasses, int count, size_t klass_size) { + if (count < 2) { + return -1; + } + int found = -1; + // Offset 0 is the C++ vtable pointer. + for (size_t offset = 8; offset + sizeof(uint64_t) <= klass_size; offset += 8) { + if (plausibleIds(klasses, count, offset)) { + if (found >= 0) { + return -1; // Ambiguous: refuse rather than guess. + } + found = (int)offset; + } + } + return found; +} + +bool KlassTraceId::calibrate(JNIEnv* jni) { + // Runs from profiler start, which is serialized, so a plain flag will do. + if (_calibrated) { + return offset() >= 0; + } + _calibrated = true; + size_t klass_size = (size_t)VMKlass::type_size(); + if (klass_size == 0 || !VMStructs::hasKlassMethods()) { + return false; + } + + // "[Ljava/lang/Void;" .. "[[[[[[Ljava/lang/Void;". Resolving the deepest + // one makes the JVM create every missing dimension in order, each getting + // the next trace ID; java.lang.Void has no other use for such arrays. + static const char ELEMENT[] = "Ljava/lang/Void;"; + char name[PROBE_COUNT + sizeof(ELEMENT)]; + memset(name, '[', PROBE_COUNT); + memcpy(name + PROBE_COUNT, ELEMENT, sizeof(ELEMENT)); + + const void* klasses[PROBE_COUNT]; + for (int dims = PROBE_COUNT; dims >= 1; dims--) { + const char* dim_name = name + (PROBE_COUNT - dims); + jclass cls = jni->FindClass(dim_name); + if (cls == nullptr) { + jni->ExceptionClear(); + return false; + } + klasses[dims - 1] = VMKlass::fromJavaClass(jni, cls); + jni->DeleteLocalRef(cls); + if (klasses[dims - 1] == nullptr) { + return false; + } + } + + int found = findOffset(klasses, PROBE_COUNT, klass_size); + TEST_LOG("KlassTraceId offset=%d (sizeof(Klass)=%d)", found, (int)klass_size); + if (found < 0) { + return false; + } + __atomic_store_n(&_offset, found, __ATOMIC_RELEASE); + return true; +} diff --git a/ddprof-lib/src/main/cpp/hotspot/klassTraceId.h b/ddprof-lib/src/main/cpp/hotspot/klassTraceId.h new file mode 100644 index 0000000000..529557400c --- /dev/null +++ b/ddprof-lib/src/main/cpp/hotspot/klassTraceId.h @@ -0,0 +1,88 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#ifndef _HOTSPOT_KLASSTRACEID_H +#define _HOTSPOT_KLASSTRACEID_H + +#include "safeAccess.h" + +#include +#include +#include + +/** + * Locates HotSpot's per-class JFR trace ID (Klass::_trace_id) by probing, + * since gHotSpotVMStructs does not expose it. + * + * In JFR-enabled builds every Klass, including array klasses, is assigned + * `(counter << 16) | tag bits` at creation, from one global counter; the low + * 16 bits are JFR tag/epoch bits that JFR rewrites while it runs. So + * `raw >> 16` is unique per loaded class and identifies one particular load + * of a class even when a later class reuses its Klass* address. + * + * The field's position differs between releases (after _access_flags on + * 17u/21u, after _archived_mirror_index on mainline, where it also depends on + * the CDS Java heap build option), so instead of a per-version offset table + * calibrate() makes the JVM create a few array klasses back to back and + * accepts the one 8-byte-aligned offset within sizeof(Klass) at which all of + * them hold distinct IDs no further apart than consecutive creation allows. + * Builds without JFR, or a layout where no single offset qualifies, leave the + * offset unknown, and callers fall back to KlassRegistry's epoch. + */ +class KlassTraceId { + static int _offset; + static bool _calibrated; + +public: + // Array dimensions created by calibrate(): one probe klass per dimension. + static const int PROBE_COUNT = 6; + // How many trace IDs other threads may consume while the probes are + // created, i.e. how far apart beyond PROBE_COUNT - 1 the probes' IDs may be. + static const uint64_t ID_SLACK = 64; + + /** + * Returns the single 8-byte-aligned offset in [8, klass_size) at which the + * `count` klasses all hold plausible, freshly assigned trace IDs, or -1 if + * there is none or more than one. The klasses must be readable for + * klass_size bytes. + */ + static int findOffset(const void* const* klasses, int count, size_t klass_size); + + /** + * Creates the probe klasses through JNI and records the offset found. Only + * the first call per process calibrates; later calls return its result. + * Returns true if an offset is known. + */ + static bool calibrate(JNIEnv* jni); + + /** The calibrated offset, or -1. */ + static int offset() { return __atomic_load_n(&_offset, __ATOMIC_ACQUIRE); } + + /** + * Reads the trace ID (`raw >> 16`) of `klass` with SafeAccess, so it is + * usable from a signal handler on a Klass* that may be stale. Returns false + * if not calibrated or the field is unreadable. + */ + static inline bool read(const void* klass, uint64_t* id) { + int off = offset(); + if (off < 0 || klass == nullptr) { + return false; + } + // An all-ones raw value would mean 2^48 - 1 classes had been created, so + // it can serve as the fault marker. + void* const fault = (void*)UINTPTR_MAX; + void* raw = SafeAccess::loadPtr((void**)((const char*)klass + off), fault); + if (raw == fault) { + return false; + } + *id = (uint64_t)(uintptr_t)raw >> 16; + return true; + } + + /** Test hook: forget or force the calibrated offset. */ + static void setOffsetForTest(int offset) { __atomic_store_n(&_offset, offset, __ATOMIC_RELEASE); } +}; + +#endif // _HOTSPOT_KLASSTRACEID_H diff --git a/ddprof-lib/src/main/cpp/hotspot/vmStructs.h b/ddprof-lib/src/main/cpp/hotspot/vmStructs.h index c61ccbc3b3..b3f4a956c3 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,50 @@ DECLARE(VMKlass) assert(_jmethod_ids_offset >= 0); return __atomic_load_n((jmethodID**) at(_jmethod_ids_offset), __ATOMIC_ACQUIRE); } + + // True if `method` is one of the current methods of this InstanceKlass. + // InstanceKlass::_methods is an Array: an int length followed by + // the elements, which start at the next pointer-aligned offset. `method` is + // only compared, never dereferenced, so it may be stale. `idnum_hint` is + // the method's idnum if known (or -1). HotSpot assigns idnums in method + // order when it parses a class, so methods()->at(idnum) is usually the + // method itself; methods added later (default-method overpasses merged in + // at link time, redefinitions) can break that. Then only a class of at + // most MAX_METHODS_SCANNED methods is scanned; for a larger one the + // method counts as not found. + // + // Keeping the class alive does not keep this array alive: a redefinition + // swaps in a new array at a safepoint and frees the old one at a later + // one, and a caller in native state does not hold up either. So every + // load goes through SafeAccess, and a fault reads as "not a member". + static const int MAX_METHODS_SCANNED = 256; + + bool containsMethod(const void* method, int idnum_hint) { + assert(_methods_offset >= 0); + const char* methods = (const char*) SafeAccess::loadPtr((void**) at(_methods_offset), nullptr); + if (methods == NULL) { + return false; + } + // Array::_length is an int; a method count beyond u2 is not a + // valid class file, so a larger value means the array is gone. + int len = SafeAccess::load32((int32_t*) methods, -1); + if (len < 0 || len > 0xffff) { + return false; + } + void** data = (void**)(methods + sizeof(void*)); + if (idnum_hint >= 0 && idnum_hint < len && SafeAccess::loadPtr(&data[idnum_hint], nullptr) == method) { + return true; + } + if (len > MAX_METHODS_SCANNED) { + return false; + } + for (int i = 0; i < len; i++) { + if (SafeAccess::loadPtr(&data[i], nullptr) == method) { + return true; + } + } + return false; + } DECLARE_END DECLARE(VMJavaFrameAnchor) @@ -905,6 +956,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; @@ -922,6 +974,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 043ab06722..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)); @@ -212,6 +225,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..f9af96cb88 100644 --- a/ddprof-lib/src/main/cpp/jvmSupport.cpp +++ b/ddprof-lib/src/main/cpp/jvmSupport.cpp @@ -72,15 +72,19 @@ void JVMSupport::setLoadState(JMethodIDLoadStats state) { void JVMSupport::initExecution(Arguments& args, jvmtiEnv* jvmti, JNIEnv* jni) { JMethodIDLoadStats current_state = getLoadState(); - bool load_all = true; - if (VM::isHotspot()) { - if (!HotspotSupport::shouldPreloadJmethodIDs(args)) { + JMethodIDLoadStats state = Fully_loaded; + // The stack walker mode as Profiler::start() resolved it from args. + CStack cstack = Profiler::instance()->cstackMode(); + if (VM::isHotspot() && !HotspotSupport::shouldPreloadJmethodIDs(args, cstack)) { + if (args._lazy_jmethodID && HotspotSupport::canResolveLazily() && + HotspotSupport::initLazyResolution(jni)) { + state = Lazy_loaded; + } else { HotspotSupport::initClassloaderInfo(jni); - load_all = false; + state = Partial_loaded; } } - JMethodIDLoadStats state = load_all ? Fully_loaded : Partial_loaded; if (state == current_state) { return; } @@ -149,7 +153,8 @@ void JVMSupport::loadAllMethodIDsIfNeeded(jvmtiEnv *jvmti, JNIEnv *jni) { } jvmti->Deallocate((unsigned char *)classes); } - TEST_LOG("Preloaded jmethodIDs for %d/%d classes", loaded_count, class_count); + TEST_LOG("%s %d/%d classes", getLoadState() == Lazy_loaded ? "Registered (lazy jmethodIDs)" : "Preloaded jmethodIDs for", + loaded_count, class_count); } bool JVMSupport::loadMethodIDsIfNeeded(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass) { @@ -160,6 +165,9 @@ bool JVMSupport::loadMethodIDsIfNeeded(jvmtiEnv *jvmti, JNIEnv *jni, jclass klas return false; } + if (state == Lazy_loaded) { + return HotspotSupport::registerClass(jvmti, jni, klass); + } if (VM::isHotspot()) { return HotspotSupport::loadMethodIDsIfNeededImpl(jvmti, jni, klass, state == Fully_loaded /* load all */); } else { @@ -167,6 +175,21 @@ bool JVMSupport::loadMethodIDsIfNeeded(jvmtiEnv *jvmti, JNIEnv *jni, jclass klas } } +void JVMSupport::beforeClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass) { + if (VM::isHotspot()) { + HotspotSupport::beforeClassRedefinition(jvmti, jni, klass); + } +} + +void JVMSupport::afterClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass, bool succeeded) { + if (VM::isHotspot()) { + HotspotSupport::afterClassRedefinition(jvmti, jni, klass, succeeded); + } + if (succeeded && getLoadState() != Lazy_loaded) { + loadMethodIDsIfNeeded(jvmti, jni, klass); + } +} + bool JVMSupport::loadMethodIDsImpl(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass) { // CRITICAL: GetClassMethods must be called to preallocate jmethodIDs for AsyncGetCallTrace. // AGCT operates in signal handlers where lock acquisition is forbidden, so jmethodIDs must diff --git a/ddprof-lib/src/main/cpp/jvmSupport.h b/ddprof-lib/src/main/cpp/jvmSupport.h index 49b8a9193f..e5a1ddfbaa 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; @@ -66,8 +67,16 @@ class JVMSupport { static void loadAllMethodIDsIfNeeded(jvmtiEnv *jvmti, JNIEnv *jni); static bool loadMethodIDsIfNeeded(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass); - // Resolve method pointer to jmethodID - static inline jmethodID resolve(const void* method); + // Called around every RedefineClasses/RetransformClasses for each class. + // fjmethodid=lazy must know before the methods are swapped; afterwards the + // class's jmethodIDs are (re)loaded as each mode requires. `succeeded` is + // whether the redefinition went through. `klass` is the caller's argument + // and has not been validated by JVMTI yet. + static void beforeClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass); + static void afterClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass, bool succeeded); + + // Resolve a raw-pointer frame (method field and encoded bci) 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/main/cpp/vmEntry.cpp b/ddprof-lib/src/main/cpp/vmEntry.cpp index acf8382322..1f7fa8792c 100644 --- a/ddprof-lib/src/main/cpp/vmEntry.cpp +++ b/ddprof-lib/src/main/cpp/vmEntry.cpp @@ -678,16 +678,25 @@ void JNICALL VM::VMDeath(jvmtiEnv *jvmti, JNIEnv *jni) { jvmtiError VM::RedefineClassesHook(jvmtiEnv *jvmti, jint class_count, const jvmtiClassDefinition *class_definitions) { + // Arguments the original rejects (JVMTI_ERROR_NULL_POINTER) go straight to + // it; the loops below must never dereference them first. + if (class_count > 0 && class_definitions == NULL) { + return _orig_RedefineClasses(jvmti, class_count, class_definitions); + } + JNIEnv *env = jni(); + for (int i = 0; i < class_count; i++) { + if (class_definitions[i].klass != NULL) { + JVMSupport::beforeClassRedefinition(jvmti, env, class_definitions[i].klass); + } + } + jvmtiError result = _orig_RedefineClasses(jvmti, class_count, class_definitions); - if (result == 0) { - // jmethodIDs are invalidated after RedefineClasses - JNIEnv *env = jni(); - for (int i = 0; i < class_count; i++) { - if (class_definitions[i].klass != NULL) { - JVMSupport::loadMethodIDsIfNeeded(jvmti, env, class_definitions[i].klass); - } + // jmethodIDs are invalidated after RedefineClasses + for (int i = 0; i < class_count; i++) { + if (class_definitions[i].klass != NULL) { + JVMSupport::afterClassRedefinition(jvmti, env, class_definitions[i].klass, result == 0); } } @@ -696,15 +705,24 @@ VM::RedefineClassesHook(jvmtiEnv *jvmti, jint class_count, jvmtiError VM::RetransformClassesHook(jvmtiEnv *jvmti, jint class_count, const jclass *classes) { + // Arguments the original rejects (JVMTI_ERROR_NULL_POINTER) go straight to + // it; the loops below must never dereference them first. + if (class_count > 0 && classes == NULL) { + return _orig_RetransformClasses(jvmti, class_count, classes); + } + JNIEnv *env = jni(); + for (int i = 0; i < class_count; i++) { + if (classes[i] != NULL) { + JVMSupport::beforeClassRedefinition(jvmti, env, classes[i]); + } + } + jvmtiError result = _orig_RetransformClasses(jvmti, class_count, classes); - if (result == 0) { - // jmethodIDs are invalidated after RetransformClasses - JNIEnv *env = jni(); - for (int i = 0; i < class_count; i++) { - if (classes[i] != NULL) { - JVMSupport::loadMethodIDsIfNeeded(jvmti, env, classes[i]); - } + // jmethodIDs are invalidated after RetransformClasses + for (int i = 0; i < class_count; i++) { + if (classes[i] != NULL) { + JVMSupport::afterClassRedefinition(jvmti, env, classes[i], result == 0); } } diff --git a/ddprof-lib/src/test/cpp/arguments_ut.cpp b/ddprof-lib/src/test/cpp/arguments_ut.cpp index 8ecefc13fc..225a1d0fa9 100644 --- a/ddprof-lib/src/test/cpp/arguments_ut.cpp +++ b/ddprof-lib/src/test/cpp/arguments_ut.cpp @@ -253,3 +253,35 @@ TEST_F(ArgumentsTest, BooleanOptionRejectsTypoSharingFirstLetterWithValidValue) EXPECT_TRUE(error); } } + +// fjmethodid takes true/false/lazy; lazy implies no preloading, and a later +// true/false in the same string must clear the lazy flag again. +TEST_F(ArgumentsTest, FjmethodidAcceptsLazy) { + { + Arguments args; + EXPECT_TRUE(args._force_jmethodID); + EXPECT_FALSE(args._lazy_jmethodID); + } + { + Arguments args; + EXPECT_FALSE(args.parse("fjmethodid=lazy")); + EXPECT_FALSE(args._force_jmethodID); + EXPECT_TRUE(args._lazy_jmethodID); + } + { + Arguments args; + EXPECT_FALSE(args.parse("fjmethodid=lazy,fjmethodid=false")); + EXPECT_FALSE(args._force_jmethodID); + EXPECT_FALSE(args._lazy_jmethodID); + } + { + Arguments args; + EXPECT_FALSE(args.parse("fjmethodid=lazy,fjmethodid=true")); + EXPECT_TRUE(args._force_jmethodID); + EXPECT_FALSE(args._lazy_jmethodID); + } + { + Arguments args; + EXPECT_TRUE(args.parse("fjmethodid=lazyx")); + } +} diff --git a/ddprof-lib/src/test/cpp/frame_ut.cpp b/ddprof-lib/src/test/cpp/frame_ut.cpp index 951db75fb8..c797d20f76 100644 --- a/ddprof-lib/src/test/cpp/frame_ut.cpp +++ b/ddprof-lib/src/test/cpp/frame_ut.cpp @@ -203,3 +203,54 @@ TEST(FrameTypeIsRawPointerTest, FalseForRawAsgctBciWithBit30SetButNoEncodedMarke << "isRawPointer() must require ENCODED_MASK (bit 20) before trusting bit 30, " << "otherwise raw ASGCT BCIs that never went through encode() can false-positive"; } + +// --------------------------------------------------------------------------- +// Raw Method* frame tags (fjmethodid=lazy): the tag is split across spare BCI +// bits and the top bits of the Method*, and must come back intact without +// disturbing anything the rest of the pipeline reads from the frame. +// --------------------------------------------------------------------------- + +TEST(FrameTypeRawTagTest, TagRoundTripsAndLeavesTypeBciAndPointerIntact) { + VMHotspotGuard hotspot(true); + const void* method = reinterpret_cast(0x00007f12345678a8ULL); + const uint32_t tags[] = {0u, 1u, 0xfu, 0x10u, 0x3ffu, 0x400u, 0x2aaaaaau, FrameType::RAW_TAG_MASK}; + for (uint32_t tag : tags) { + for (int type : {FRAME_INTERPRETED, FRAME_JIT_COMPILED, FRAME_INLINED, FRAME_C1_COMPILED}) { + int bci = FrameType::encodeRaw(type, 4321, tag); + const void* tagged = FrameType::tagPointer(method, tag); + EXPECT_EQ(tag, FrameType::rawTag(bci, tagged)) << std::hex << tag; + EXPECT_EQ(method, FrameType::untagPointer(tagged)); + EXPECT_EQ(4321, FrameType::bci(bci)); + EXPECT_EQ(type, FrameType::decode(bci)); + EXPECT_TRUE(FrameType::isRawPointer(bci)); + EXPECT_GT(bci, 0) << "bit 31 must stay clear"; + } + } +} + +TEST(FrameTypeRawTagTest, TagBitsAboveWidthAreDropped) { + VMHotspotGuard hotspot(true); + const void* method = reinterpret_cast(0x1000); + uint32_t tag = FrameType::RAW_TAG_MASK + 1 + 7; // wraps to 7 + int bci = FrameType::encodeRaw(FRAME_JIT_COMPILED, 0, tag); + EXPECT_EQ(7u, FrameType::rawTag(bci, FrameType::tagPointer(method, tag))); +} + +TEST(FrameTypeRawTagTest, DifferentTagsGiveDifferentFrames) { + // Two loads of a class at the same Method* address must not produce + // identical frames, or the call trace storage would merge their traces. + VMHotspotGuard hotspot(true); + const void* method = reinterpret_cast(0x7f0000001000ULL); + for (uint32_t delta : {1u, 0x10u, 0x400u}) { + int bci_a = FrameType::encodeRaw(FRAME_JIT_COMPILED, 3, 5); + int bci_b = FrameType::encodeRaw(FRAME_JIT_COMPILED, 3, 5 + delta); + bool same = bci_a == bci_b && + FrameType::tagPointer(method, 5) == FrameType::tagPointer(method, 5 + delta); + EXPECT_FALSE(same) << delta; + } +} + +TEST(FrameTypeRawTagTest, CanTagPointerRejectsHighAddresses) { + EXPECT_TRUE(FrameType::canTagPointer(reinterpret_cast(0x00007fffffffffffULL))); + EXPECT_FALSE(FrameType::canTagPointer(reinterpret_cast(0x0001000000000000ULL))); +} diff --git a/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp b/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp new file mode 100644 index 0000000000..916790b83f --- /dev/null +++ b/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp @@ -0,0 +1,672 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#include + +#include "../../main/cpp/counters.h" +#include "../../main/cpp/flightRecorder.h" +#include "../../main/cpp/frame.h" +#include "../../main/cpp/hotspot/hotspotSupport.h" +#include "../../main/cpp/hotspot/klassRegistry.h" +#include "../../main/cpp/hotspot/klassTraceId.h" +#include "../../main/cpp/hotspot/vmStructs.h" +#include "../../main/cpp/jvmSupport.h" +#include "../../main/cpp/profiler.h" +#include "../../main/cpp/threadLocalData.inline.h" +#include "../../main/cpp/vmEntry.h" + +#include +#include +#include + +// fjmethodid=lazy end to end, without a JVM: a raw Method* frame is captured +// with HotspotSupport::fillJavaFrame() exactly as walkVM does, the class's +// registry entry is manipulated the way ClassPrepare and class unloading +// would, and the frame is resolved with HotspotSupport::resolve() as the dump +// thread does. +// +// The metadata is fake but laid out the way the offsets below tell +// VMStructs to read it: Method -> ConstMethod -> ConstantPool -> Klass, with +// the 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; } + static jvmtiEnv* getJvmti() { return VM::_jvmti; } + static void setJvmti(jvmtiEnv* env) { VM::_jvmti = env; } +}; + +class JVMSupportTestAccessor { +public: + using LoadState = JVMSupport::JMethodIDLoadStats; + static LoadState getLoadState() { return JVMSupport::getLoadState(); } + static void setLoadState(LoadState s) { JVMSupport::setLoadState(s); } + static constexpr LoadState LazyLoaded() { return JVMSupport::Lazy_loaded; } + static constexpr LoadState PartialLoaded() { return JVMSupport::Partial_loaded; } +}; + +class HotspotSupportTestAccessor { +public: + static KlassRegistry& registry() { return HotspotSupport::_klass_registry; } + static void beforeClassRedefinition(jvmtiEnv* jvmti, JNIEnv* jni, jclass klass) { + HotspotSupport::beforeClassRedefinition(jvmti, jni, klass); + } + static void afterClassRedefinition(jvmtiEnv* jvmti, JNIEnv* jni, jclass klass, bool succeeded) { + HotspotSupport::afterClassRedefinition(jvmti, jni, klass, succeeded); + } + static uint32_t traceIdTagBit() { return HotspotSupport::TRACE_ID_TAG; } + static bool registerClass(jvmtiEnv* jvmti, JNIEnv* jni, jclass klass) { + return HotspotSupport::registerClass(jvmti, jni, klass); + } +}; + +class ProfilerTestAccessor { +public: + static bool getForceJmethodID(Profiler* p) { return p->_force_jmethodID; } + static void setForceJmethodID(Profiler* p, bool v) { p->_force_jmethodID = v; } +}; + +// Points VMStructs at the fake layout below for the lifetime of a test, and +// restores whatever was there before. +class VMStructsTestAccessor { +public: + struct State { + int method_constmethod, constmethod_constants, constmethod_idnum, pool_holder; + int jmethod_ids, methods; + 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; + uint64_t trace_id; // JFR encoding: (id << 16) | tag bits +}; + +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; + klass.trace_id = (uint64_t)1000 << 16; + 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; + const void* klass = nullptr; // what java.lang.Class.klass holds, for mirrors +}; + +struct FakeWeak { + FakeObject* referent; +}; + +class FakeJvm { +public: + FakeJvm() : _saved_vm(VMTestAccessor::getVm()) { + s_instance = this; + _jni_tbl.NewWeakGlobalRef = &newWeakGlobalRef; + _jni_tbl.DeleteWeakGlobalRef = &deleteWeakGlobalRef; + _jni_tbl.IsSameObject = &isSameObject; + _jni_tbl.NewLocalRef = &newLocalRef; + _jni_tbl.DeleteLocalRef = &deleteLocalRef; + _jni_tbl.GetLongField = &getLongField; + _jni_env.functions = &_jni_tbl; + _jvmti_tbl.GetClassStatus = &getClassStatus; + _jvmti_tbl.GetClassMethods = &getClassMethods; + _jvmti_tbl.Deallocate = &deallocate; + _jvmti.functions = &_jvmti_tbl; + _saved_jvmti = VMTestAccessor::getJvmti(); + VMTestAccessor::setJvmti(&_jvmti); + _vm_tbl.GetEnv = &getEnv; + _vm.functions = &_vm_tbl; + VMTestAccessor::setVm(reinterpret_cast(&_vm)); + } + + ~FakeJvm() { + VMTestAccessor::setJvmti(_saved_jvmti); + VMTestAccessor::setVm(_saved_vm); + for (FakeWeak* w : _weaks) { + delete w; + } + s_instance = nullptr; + } + + JNIEnv* jni() { return &_jni_env; } + jvmtiEnv* jvmti() { return &_jvmti; } + + // KlassRegistry::pin() calls NewLocalRef once per resolution. + int new_local_ref_calls = 0; + int get_class_methods_calls = 0; + + jclass mirror(FakeObject& obj) { return reinterpret_cast(&obj); } + + // Class unloading as far as the registry can observe it: the mirror's + // weak references are cleared. + void collect(FakeObject& obj) { + for (FakeWeak* w : _weaks) { + if (w->referent == &obj) { + w->referent = nullptr; + } + } + } + +private: + jobject resolve(jobject ref) { + FakeWeak* w = reinterpret_cast(ref); + return _weaks.count(w) != 0 ? reinterpret_cast(w->referent) : ref; + } + + static jweak JNICALL newWeakGlobalRef(JNIEnv*, jobject obj) { + FakeWeak* w = new FakeWeak{reinterpret_cast(s_instance->resolve(obj))}; + s_instance->_weaks.insert(w); + return reinterpret_cast(w); + } + static void JNICALL deleteWeakGlobalRef(JNIEnv*, jweak) {} + static jboolean JNICALL isSameObject(JNIEnv*, jobject a, jobject b) { + return s_instance->resolve(a) == s_instance->resolve(b) ? JNI_TRUE : JNI_FALSE; + } + static jobject JNICALL newLocalRef(JNIEnv*, jobject ref) { + s_instance->new_local_ref_calls++; + return s_instance->resolve(ref); + } + static void JNICALL deleteLocalRef(JNIEnv*, jobject) {} + // VMKlass::fromJavaClass() reads java.lang.Class.klass through this. + static jlong JNICALL getLongField(JNIEnv*, jobject obj, jfieldID) { + return (jlong)(uintptr_t)reinterpret_cast(obj)->klass; + } + // A jmethodID preload (loadMethodIDsIfNeededImpl()); 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; + } + static jint JNICALL getEnv(JavaVM*, void** penv, jint) { + *penv = &s_instance->_jni_env; + return JNI_OK; + } + + static FakeJvm* s_instance; + + JavaVM* _saved_vm; + JNINativeInterface_ _jni_tbl{}; + JNIEnv_ _jni_env{}; + JNIInvokeInterface_ _vm_tbl{}; + JavaVM_ _vm{}; + jvmtiInterface_1_ _jvmti_tbl{}; + _jvmtiEnv _jvmti{}; + jvmtiEnv* _saved_jvmti = nullptr; + std::set _weaks; +}; +FakeJvm* FakeJvm::s_instance = nullptr; + +} // namespace + +class HotspotLazyResolveTest : public ::testing::Test { +protected: + VMStructsTestAccessor _layout{LAYOUT}; + FakeJvm _jvm; + FakeClass _cls; + FakeObject _old_mirror{1}; + FakeObject _new_mirror{2}; + bool _saved_hotspot = false; + bool _saved_force = true; + int _saved_trace_id_offset = -1; + JVMSupportTestAccessor::LoadState _saved_state{}; + + void SetUp() override { + _saved_hotspot = VMTestAccessor::getHotspot(); + VMTestAccessor::setHotspot(true); + _saved_state = JVMSupportTestAccessor::getLoadState(); + JVMSupportTestAccessor::setLoadState(JVMSupportTestAccessor::LazyLoaded()); + // fillJavaFrame() only stores raw Method* frames when jmethodIDs are + // not forced, which is how fjmethodid=lazy configures the profiler. + _saved_force = ProfilerTestAccessor::getForceJmethodID(Profiler::instance()); + ProfilerTestAccessor::setForceJmethodID(Profiler::instance(), false); + _saved_trace_id_offset = KlassTraceId::offset(); + KlassTraceId::setOffsetForTest((int)offsetof(FakeKlass, trace_id)); + // The redefinition hooks find the class through java.lang.Class.klass. + _old_mirror.klass = _cls.klassPtr(); + _new_mirror.klass = _cls.klassPtr(); + // resolve() installs crash protection on the current ProfiledThread. + ProfiledThread::initCurrentThread(); + ASSERT_NE(nullptr, ProfiledThread::current()); + } + + void TearDown() override { + HotspotSupportTestAccessor::registry().clear(_jvm.jni()); + ProfiledThread::release(); + KlassTraceId::setOffsetForTest(_saved_trace_id_offset); + ProfilerTestAccessor::setForceJmethodID(Profiler::instance(), _saved_force); + JVMSupportTestAccessor::setLoadState(_saved_state); + VMTestAccessor::setHotspot(_saved_hotspot); + } + + void registerClass(FakeObject& mirror) { + ASSERT_TRUE(HotspotSupportTestAccessor::registry().add(_jvm.jni(), _cls.klassPtr(), + _jvm.mirror(mirror))); + } + + // The old class is unloaded and a new class of the same shape is loaded at + // the same Klass* and Method* addresses: its mirror is new, and so is the + // trace ID JFR assigns at creation. + void loadNewClassAtSameAddress() { + _jvm.collect(_old_mirror); + _cls.klass.trace_id = ((_cls.klass.trace_id >> 16) + 37) << 16; + registerClass(_new_mirror); + } + + // What walkVM stores for a frame of a class without a jmethodID yet. + ASGCT_CallFrame sample() { + ASGCT_CallFrame frame{}; + HotspotSupport::fillJavaFrame(frame, FRAME_JIT_COMPILED, 7, nullptr, _cls.vmMethod()); + EXPECT_TRUE(FrameType::isRawPointer(frame.bci)); + return frame; + } + + // What VM::RetransformClassesHook does around the original call. + void redefine(bool succeeded) { + HotspotSupportTestAccessor::beforeClassRedefinition(_jvm.jvmti(), _jvm.jni(), _jvm.mirror(_old_mirror)); + HotspotSupportTestAccessor::afterClassRedefinition(_jvm.jvmti(), _jvm.jni(), _jvm.mirror(_old_mirror), + succeeded); + } + + // What Lookup::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(); + + loadNewClassAtSameAddress(); + + 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())) + << "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) { + registerClass(_old_mirror); + loadNewClassAtSameAddress(); + + 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(); + loadNewClassAtSameAddress(); + ASGCT_CallFrame after = sample(); + + EXPECT_FALSE(before.bci == after.bci && before.method == after.method); +} + +// Tags identify the class load itself, so another class's unloading and the +// reuse of its address leave this class's frames unchanged, and the same stack +// keeps deduplicating into one trace. +TEST_F(HotspotLazyResolveTest, UnrelatedReuseDoesNotChangeFrames) { + registerClass(_old_mirror); + ASGCT_CallFrame before = sample(); + + static FakeObject other_old{10}; + static FakeObject other_new{11}; + const void* other_klass = reinterpret_cast(0x200000); + KlassRegistry& registry = HotspotSupportTestAccessor::registry(); + ASSERT_TRUE(registry.add(_jvm.jni(), other_klass, _jvm.mirror(other_old))); + _jvm.collect(other_old); + ASSERT_TRUE(registry.add(_jvm.jni(), other_klass, _jvm.mirror(other_new))); + + ASGCT_CallFrame after = sample(); + EXPECT_EQ(before.bci, after.bci); + EXPECT_EQ(before.method, after.method); + EXPECT_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_F(HotspotLazyResolveTest, JfrTagBitsDoNotAffectTraceIdTags) { + registerClass(_old_mirror); + ASGCT_CallFrame before = sample(); + _cls.klass.trace_id |= 0x0303; + ASGCT_CallFrame after = sample(); + EXPECT_EQ(before.bci, after.bci); + EXPECT_EQ(before.method, after.method); + EXPECT_EQ(_cls.jmethodId(), resolve(before)); +} + +// Partial mode (fjmethodid=false) stores the plain Method*, untagged, and +// never reads the holder's trace ID: a frame whose trace ID is unreadable is +// still stored raw, not turned into an unknown frame. +TEST_F(HotspotLazyResolveTest, PartialModeFramesAreStoredUntagged) { + JVMSupportTestAccessor::setLoadState(JVMSupportTestAccessor::PartialLoaded()); + KlassTraceId::setOffsetForTest(-1); // trace ID unreadable + ASGCT_CallFrame frame = sample(); + EXPECT_EQ(static_cast(_cls.vmMethod()), frame.method); + EXPECT_EQ(0u, FrameType::rawTag(frame.bci, frame.method) & HotspotSupportTestAccessor::traceIdTagBit()); + EXPECT_EQ(static_cast(_cls.vmMethod()), HotspotSupport::rawFrameMethod(frame.bci, frame.method)); +} + +// resolve() goes by the mode the frame was sampled in, not the current one: +// a lazy-mode frame kept across a restart in partial mode still resolves +// through the registry (partial mode's FindClass lookup cannot see classes of +// custom loaders). +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_EQ(1, _jvm.new_local_ref_calls - before); +} + +// A redefinition frees the class's old Method*s once they are off every +// stack, and a later redefinition of the same class can allocate a new method +// at the same address. The class, its mirror and its trace ID all +// stay the same, so nothing but the redefinition itself tells the frames +// apart: raw frames of a class marked before its redefinition are refused. +TEST_F(HotspotLazyResolveTest, FrameOfRedefinedClassIsRefused) { + registerClass(_old_mirror); + ASGCT_CallFrame before = sample(); + // Afterwards the Method* at the sampled address is (still, or again) + // among the class's methods. + redefine(true); + EXPECT_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_F(HotspotLazyResolveTest, MethodMemoryReusedByClassAtOtherAddressDoesNotResolve) { + registerClass(_old_mirror); + ASGCT_CallFrame frame = sample(); + + _jvm.collect(_old_mirror); + static FakeKlass other_klass; + static FakeMethodArray other_methods; + other_klass = _cls.klass; + other_klass.methods = &other_methods; + other_klass.trace_id = ((_cls.klass.trace_id >> 16) + 41) << 16; + other_methods = *_cls.klass.methods; + _cls.cpool.holder = &other_klass; // the reused Method* now belongs to it + ASSERT_TRUE(HotspotSupportTestAccessor::registry().add(_jvm.jni(), &other_klass, + _jvm.mirror(_new_mirror))); + + EXPECT_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_F(HotspotLazyResolveTest, CountsClassesWithTraceIdBeyondTagRange) { +#ifndef COUNTERS + GTEST_SKIP() << "debug counters are not compiled into this build"; +#else + long long before = Counters::getCounter(KLASS_TRACE_ID_BEYOND_TAG_RANGE); + _cls.klass.trace_id = ((uint64_t)(1u << 25) - 1) << 16; // largest ID that still fits + ASSERT_TRUE(HotspotSupportTestAccessor::registerClass(_jvm.jvmti(), _jvm.jni(), _jvm.mirror(_old_mirror))); + EXPECT_EQ(before, Counters::getCounter(KLASS_TRACE_ID_BEYOND_TAG_RANGE)); + + static FakeKlass big_klass; + big_klass = _cls.klass; + big_klass.trace_id = ((uint64_t)1 << 25) << 16; + _new_mirror.klass = &big_klass; + ASSERT_TRUE(HotspotSupportTestAccessor::registerClass(_jvm.jvmti(), _jvm.jni(), _jvm.mirror(_new_mirror))); + EXPECT_EQ(before + 1, Counters::getCounter(KLASS_TRACE_ID_BEYOND_TAG_RANGE)); +#endif +} + +// Lookup resolves each distinct raw method once per dump, however often it +// recurs, and whatever bci or frame type the occurrences carry. +TEST_F(HotspotLazyResolveTest, LookupResolvesEachRawMethodOncePerDump) { + registerClass(_old_mirror); + ASGCT_CallFrame a = sample(); + ASGCT_CallFrame b{}; + HotspotSupport::fillJavaFrame(b, FRAME_INTERPRETED, 42, nullptr, _cls.vmMethod()); + ASSERT_TRUE(FrameType::isRawPointer(b.bci)); + ASSERT_NE(a.bci, b.bci); + + StringDictionary classes; + MethodMap methods; + Lookup lookup(nullptr, &methods, &classes); + int before = _jvm.new_local_ref_calls; + 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_F(HotspotLazyResolveTest, LookupCacheSeparatesFramesOfDifferentClassLoads) { + registerClass(_old_mirror); + ASGCT_CallFrame before = sample(); + loadNewClassAtSameAddress(); + ASGCT_CallFrame after = sample(); + + StringDictionary classes; + MethodMap methods; + Lookup lookup(nullptr, &methods, &classes); + EXPECT_EQ(nullptr, lookup.resolveRawMethod(before)); + EXPECT_EQ(_cls.jmethodId(), lookup.resolveRawMethod(after)); + EXPECT_EQ(nullptr, lookup.resolveRawMethod(before)); +} + 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/jvmSupport_ut.cpp b/ddprof-lib/src/test/cpp/jvmSupport_ut.cpp index 83ab2ecb4f..5f956bb90b 100644 --- a/ddprof-lib/src/test/cpp/jvmSupport_ut.cpp +++ b/ddprof-lib/src/test/cpp/jvmSupport_ut.cpp @@ -65,6 +65,7 @@ class JVMSupportTestAccessor { static constexpr LoadState NoLoaded() { return JVMSupport::No_loaded; } static constexpr LoadState PartialLoaded() { return JVMSupport::Partial_loaded; } static constexpr LoadState FullyLoaded() { return JVMSupport::Fully_loaded; } + static constexpr LoadState LazyLoaded() { return JVMSupport::Lazy_loaded; } }; // --------------------------------------------------------------------------- @@ -81,6 +82,9 @@ class ProfilerTestAccessor { static State getState(Profiler* p) { return p->_state.load(std::memory_order_acquire); } + // The stack walker mode as Profiler::start() resolved it from the arguments. + static CStack getCStack(Profiler* p) { return p->_cstack; } + static void setCStack(Profiler* p, CStack c) { p->_cstack = c; } }; // GetCurrentThread failing is exactly what JVMThread::currentThreadSlow() @@ -251,12 +255,16 @@ class JVMSupportRestartTest : public ::testing::Test { JNIEnv_ mock_jni{}; bool _orig_hotspot; JVMSupportTestAccessor::LoadState _orig_load_state; + CStack _orig_cstack; void SetUp() override { _orig_hotspot = VMTestAccessor::getHotspot(); VMTestAccessor::setHotspot(true); _orig_load_state = JVMSupportTestAccessor::getLoadState(); JVMSupportTestAccessor::setLoadState(JVMSupportTestAccessor::NoLoaded()); + // As if Profiler::start() had resolved the arguments to the VM walker. + _orig_cstack = ProfilerTestAccessor::getCStack(Profiler::instance()); + ProfilerTestAccessor::setCStack(Profiler::instance(), CSTACK_VM); jvmti_tbl = jvmtiInterface_1_{}; jvmti_tbl.GetLoadedClasses = &mock_GetLoadedClasses_empty; @@ -273,6 +281,7 @@ class JVMSupportRestartTest : public ::testing::Test { void TearDown() override { VMTestAccessor::setHotspot(_orig_hotspot); JVMSupportTestAccessor::setLoadState(_orig_load_state); + ProfilerTestAccessor::setCStack(Profiler::instance(), _orig_cstack); } }; @@ -293,3 +302,52 @@ TEST_F(JVMSupportRestartTest, SecondStartWithPartialPreloadIsNotBlockedByStaleFu // so the state stayed Fully_loaded instead of downgrading. EXPECT_EQ(JVMSupportTestAccessor::PartialLoaded(), JVMSupportTestAccessor::getLoadState()); } + +// fjmethodid=lazy needs VMStructs::hasKlassMethods(); without the Klass*/methods +// structs (as in this binary, where no JVM populated VMStructs) it must fall +// back to the partial preload mode, never enter Lazy_loaded with nothing to +// resolve frames against. +TEST_F(JVMSupportRestartTest, LazyFallsBackToPartialWithoutKlassStructs) { + Arguments lazy_args; + lazy_args._force_jmethodID = false; + lazy_args._lazy_jmethodID = true; + lazy_args._cstack = CSTACK_VM; + + JVMSupport::initExecution(lazy_args, &mock_jvmti, reinterpret_cast(&mock_jni)); + + EXPECT_NE(JVMSupportTestAccessor::LazyLoaded(), JVMSupportTestAccessor::getLoadState()); + EXPECT_EQ(JVMSupportTestAccessor::PartialLoaded(), JVMSupportTestAccessor::getLoadState()); +} + +// Lazy mode, like partial mode, relies on walkVM's raw Method* frames; with a +// cstack mode that goes through AsyncGetCallTrace it must preload everything. +TEST_F(JVMSupportRestartTest, LazyRequiresVmStackWalker) { + Arguments lazy_args; + lazy_args._force_jmethodID = false; + lazy_args._lazy_jmethodID = true; + lazy_args._cstack = CSTACK_NO; + ProfilerTestAccessor::setCStack(Profiler::instance(), CSTACK_NO); + + JVMSupport::initExecution(lazy_args, &mock_jvmti, reinterpret_cast(&mock_jni)); + + EXPECT_EQ(JVMSupportTestAccessor::FullyLoaded(), JVMSupportTestAccessor::getLoadState()); +} + +// What counts is the stack walker mode Profiler::start() resolved, not the +// argument: cstack=default becomes DWARF where the VM walker is unavailable +// (macOS, or a JVM without the stack vmStructs), and then Java frames come from +// AsyncGetCallTrace, which needs every jmethodID preloaded. +TEST_F(JVMSupportRestartTest, DefaultCstackResolvedToDwarfPreloadsEverything) { + ProfilerTestAccessor::setCStack(Profiler::instance(), CSTACK_DWARF); + for (bool lazy : {true, false}) { + JVMSupportTestAccessor::setLoadState(JVMSupportTestAccessor::NoLoaded()); + Arguments args; + args._force_jmethodID = false; + args._lazy_jmethodID = lazy; + args._cstack = CSTACK_DEFAULT; + + JVMSupport::initExecution(args, &mock_jvmti, reinterpret_cast(&mock_jni)); + + EXPECT_EQ(JVMSupportTestAccessor::FullyLoaded(), JVMSupportTestAccessor::getLoadState()) << lazy; + } +} diff --git a/ddprof-lib/src/test/cpp/klassRegistry_ut.cpp b/ddprof-lib/src/test/cpp/klassRegistry_ut.cpp new file mode 100644 index 0000000000..453a0d414b --- /dev/null +++ b/ddprof-lib/src/test/cpp/klassRegistry_ut.cpp @@ -0,0 +1,389 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#include + +#include "../../main/cpp/hotspot/klassRegistry.h" +#include "../../main/cpp/hotspot/vmStructs.inline.h" + +#include +#include +#include +#include + +// KlassRegistry only talks to the JVM through five JNI functions, so a fake +// JNIEnv models the reference semantics it relies on: a weak global reference +// resolves to its referent until the referent is "collected", after which +// NewLocalRef() returns NULL and IsSameObject(ref, NULL) is true. +namespace { + +struct FakeObject { + int id; +}; + +struct FakeWeak { + FakeObject* referent; + bool deleted; +}; + +struct FakeHeap { + std::set weaks; + int new_weak_calls = 0; + int delete_weak_calls = 0; + int double_deletes = 0; + + ~FakeHeap() { + for (FakeWeak* w : weaks) { + delete w; + } + } + + jobject resolve(jobject ref) { + FakeWeak* w = reinterpret_cast(ref); + if (weaks.count(w) != 0) { + return reinterpret_cast(w->referent); + } + return ref; + } + + void collect(FakeObject* obj) { + for (FakeWeak* w : weaks) { + if (w->referent == obj) { + w->referent = nullptr; + } + } + } + + int liveWeaks() const { + int n = 0; + for (FakeWeak* w : weaks) { + n += w->deleted ? 0 : 1; + } + return n; + } +}; + +FakeHeap* g_heap = nullptr; + +jweak JNICALL fakeNewWeakGlobalRef(JNIEnv*, jobject obj) { + g_heap->new_weak_calls++; + FakeWeak* w = new FakeWeak{reinterpret_cast(g_heap->resolve(obj)), false}; + g_heap->weaks.insert(w); + return reinterpret_cast(w); +} + +void JNICALL fakeDeleteWeakGlobalRef(JNIEnv*, jweak ref) { + g_heap->delete_weak_calls++; + FakeWeak* w = reinterpret_cast(ref); + if (w->deleted) { + g_heap->double_deletes++; + } + w->deleted = true; +} + +jboolean JNICALL fakeIsSameObject(JNIEnv*, jobject a, jobject b) { + return g_heap->resolve(a) == g_heap->resolve(b) ? JNI_TRUE : JNI_FALSE; +} + +jobject JNICALL fakeNewLocalRef(JNIEnv*, jobject ref) { + return g_heap->resolve(ref); +} + +class KlassRegistryTest : public ::testing::Test { +protected: + FakeHeap heap; + JNINativeInterface_ jni_tbl{}; + JNIEnv_ jni_env{}; + JNIEnv* jni = nullptr; + KlassRegistry registry; + std::vector mirrors; + + void SetUp() override { + g_heap = &heap; + jni_tbl.NewWeakGlobalRef = &fakeNewWeakGlobalRef; + jni_tbl.DeleteWeakGlobalRef = &fakeDeleteWeakGlobalRef; + jni_tbl.IsSameObject = &fakeIsSameObject; + jni_tbl.NewLocalRef = &fakeNewLocalRef; + jni_env.functions = &jni_tbl; + jni = &jni_env; + mirrors.resize(4096); + for (size_t i = 0; i < mirrors.size(); i++) { + mirrors[i].id = (int)i; + } + } + + void TearDown() override { + registry.clear(jni); + g_heap = nullptr; + } + + // Klass* values are only used as keys; any distinct, 8-byte aligned + // addresses will do. + static const void* klassAt(size_t i) { + return reinterpret_cast(0x10000 + i * 0x200); + } + + jclass mirror(size_t i) { + return reinterpret_cast(&mirrors[i]); + } +}; + +} // namespace + +TEST_F(KlassRegistryTest, PinUnknownKlassReturnsNull) { + EXPECT_EQ(nullptr, registry.pin(jni, klassAt(0))); + ASSERT_TRUE(registry.add(jni, klassAt(1), mirror(1))); + EXPECT_EQ(nullptr, registry.pin(jni, klassAt(0))); + EXPECT_EQ(nullptr, registry.pin(jni, nullptr)); +} + +TEST_F(KlassRegistryTest, PinReturnsRegisteredMirror) { + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + ASSERT_TRUE(registry.add(jni, klassAt(1), mirror(1))); + EXPECT_EQ(mirror(0), registry.pin(jni, klassAt(0))); + EXPECT_EQ(mirror(1), registry.pin(jni, klassAt(1))); +} + +TEST_F(KlassRegistryTest, PinReturnsNullOnceClassIsCollected) { + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + heap.collect(&mirrors[0]); + EXPECT_EQ(nullptr, registry.pin(jni, klassAt(0))); +} + +TEST_F(KlassRegistryTest, ReAddingSameMirrorIsNoOp) { + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + int calls = heap.new_weak_calls; + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + EXPECT_EQ(calls, heap.new_weak_calls); + EXPECT_EQ(1u, registry.size()); +} + +TEST_F(KlassRegistryTest, ReusedKlassAddressReplacesDeadEntry) { + // A class is unloaded and a new one is loaded at the same Klass* address: + // the new class's ClassPrepare must win, and the old weak ref be freed. + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + heap.collect(&mirrors[0]); + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(1))); + EXPECT_EQ(mirror(1), registry.pin(jni, klassAt(0))); + EXPECT_EQ(1u, registry.size()); + EXPECT_EQ(1, heap.liveWeaks()); +} + +TEST_F(KlassRegistryTest, GrowthPurgesDeadEntries) { + const size_t first = 700; + for (size_t i = 0; i < first; i++) { + ASSERT_TRUE(registry.add(jni, klassAt(i), mirror(i))); + } + // Unload all but the first ten. + for (size_t i = 10; i < first; i++) { + heap.collect(&mirrors[i]); + } + // Enough new classes to cross the 3/4 load factor of the initial table. + const size_t more = 200; + for (size_t i = first; i < first + more; i++) { + ASSERT_TRUE(registry.add(jni, klassAt(i), mirror(i))); + } + + EXPECT_LT(registry.size(), 10 + more + 1); + EXPECT_EQ((int)registry.size(), heap.liveWeaks()); + EXPECT_EQ(0, heap.double_deletes); + for (size_t i = 0; i < 10; i++) { + EXPECT_EQ(mirror(i), registry.pin(jni, klassAt(i))) << i; + } + for (size_t i = 10; i < first; i++) { + EXPECT_EQ(nullptr, registry.pin(jni, klassAt(i))) << i; + } + for (size_t i = first; i < first + more; i++) { + EXPECT_EQ(mirror(i), registry.pin(jni, klassAt(i))) << i; + } +} + +TEST_F(KlassRegistryTest, GrowthKeepsAllLiveEntries) { + const size_t n = 3000; + for (size_t i = 0; i < n; i++) { + ASSERT_TRUE(registry.add(jni, klassAt(i), mirror(i))); + } + EXPECT_EQ(n, registry.size()); + for (size_t i = 0; i < n; i++) { + EXPECT_EQ(mirror(i), registry.pin(jni, klassAt(i))) << i; + } +} + +TEST_F(KlassRegistryTest, ClearDeletesEveryWeakRef) { + for (size_t i = 0; i < 50; i++) { + ASSERT_TRUE(registry.add(jni, klassAt(i), mirror(i))); + } + registry.clear(jni); + EXPECT_EQ(0u, registry.size()); + EXPECT_EQ(0, heap.liveWeaks()); + EXPECT_EQ(0, heap.double_deletes); + EXPECT_EQ(nullptr, registry.pin(jni, klassAt(0))); + // Usable again after clear(). + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + EXPECT_EQ(mirror(0), registry.pin(jni, klassAt(0))); +} + +// Redefinition: raw frames of a class are refused while a redefinition of it +// is in progress, and for good once one succeeded. A failed call leaves the +// class as it was. + +TEST_F(KlassRegistryTest, SuccessfulRedefinitionIsRefusedForGood) { + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + EXPECT_FALSE(registry.isRedefined(klassAt(0))); + ASSERT_TRUE(registry.beginRedefinition(klassAt(0))); + EXPECT_TRUE(registry.isRedefined(klassAt(0))); + registry.endRedefinition(klassAt(0), true); + EXPECT_TRUE(registry.isRedefined(klassAt(0))); + KlassRegistry::PinStatus status; + EXPECT_EQ(nullptr, registry.pin(jni, klassAt(0), &status)); + EXPECT_EQ(KlassRegistry::PIN_REDEFINED, status); + // Re-registering the same mirror does not clear the mark. + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + EXPECT_TRUE(registry.isRedefined(klassAt(0))); +} + +TEST_F(KlassRegistryTest, RedefinitionInProgressIsRefused) { + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + ASSERT_TRUE(registry.beginRedefinition(klassAt(0))); + KlassRegistry::PinStatus status; + EXPECT_EQ(nullptr, registry.pin(jni, klassAt(0), &status)); + EXPECT_EQ(KlassRegistry::PIN_REDEFINED, status); +} + +TEST_F(KlassRegistryTest, FailedRedefinitionLeavesClassAsBefore) { + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + ASSERT_TRUE(registry.beginRedefinition(klassAt(0))); + registry.endRedefinition(klassAt(0), false); + EXPECT_FALSE(registry.isRedefined(klassAt(0))); + EXPECT_EQ(mirror(0), registry.pin(jni, klassAt(0))); +} + +TEST_F(KlassRegistryTest, OverlappingRedefinitionsAreTracked) { + // Two calls on the same class overlap; the class stays refused until both + // have returned, and a failure of one does not undo the other's success. + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + ASSERT_TRUE(registry.beginRedefinition(klassAt(0))); + ASSERT_TRUE(registry.beginRedefinition(klassAt(0))); + registry.endRedefinition(klassAt(0), false); + EXPECT_TRUE(registry.isRedefined(klassAt(0))); + registry.endRedefinition(klassAt(0), false); + EXPECT_FALSE(registry.isRedefined(klassAt(0))); + + ASSERT_TRUE(registry.beginRedefinition(klassAt(0))); + ASSERT_TRUE(registry.beginRedefinition(klassAt(0))); + registry.endRedefinition(klassAt(0), true); + registry.endRedefinition(klassAt(0), false); + EXPECT_TRUE(registry.isRedefined(klassAt(0))); +} + +TEST_F(KlassRegistryTest, RedefinitionOfUnregisteredClassIsIgnored) { + EXPECT_FALSE(registry.beginRedefinition(klassAt(5))); + registry.endRedefinition(klassAt(5), true); + EXPECT_FALSE(registry.isRedefined(klassAt(5))); + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + EXPECT_FALSE(registry.beginRedefinition(klassAt(5))); + // An unmatched end, e.g. for a class registered while the call ran, does + // not underflow the count. + registry.endRedefinition(klassAt(0), false); + EXPECT_FALSE(registry.isRedefined(klassAt(0))); +} + +TEST_F(KlassRegistryTest, NewClassAtRedefinedClassAddressStartsUnmarked) { + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + ASSERT_TRUE(registry.beginRedefinition(klassAt(0))); + registry.endRedefinition(klassAt(0), true); + heap.collect(&mirrors[0]); + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(1))); + EXPECT_FALSE(registry.isRedefined(klassAt(0))); + EXPECT_EQ(mirror(1), registry.pin(jni, klassAt(0))); +} + +TEST_F(KlassRegistryTest, UnloadedClassIsReportedAbsent) { + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + heap.collect(&mirrors[0]); + KlassRegistry::PinStatus status; + EXPECT_EQ(nullptr, registry.pin(jni, klassAt(0), &status)); + EXPECT_EQ(KlassRegistry::PIN_ABSENT, status); +} + +// --------------------------------------------------------------------------- +// VMKlass::containsMethod() is what makes a Method* read from possibly-freed +// metadata trustworthy: it is only compared against the pinned class's +// current methods array, never dereferenced. +// --------------------------------------------------------------------------- + +class VMStructsTestAccessor { +public: + explicit VMStructsTestAccessor(int methods_offset) : _saved(VMStructs::_methods_offset) { + VMStructs::_methods_offset = methods_offset; + } + ~VMStructsTestAccessor() { VMStructs::_methods_offset = _saved; } + +private: + int _saved; +}; + +namespace { + +// Mirrors HotSpot's Array: an int length, then pointer-aligned data. +struct FakeMethodArray { + int length; + const void* data[8]; +}; + +struct FakeKlass { + void* pad; + FakeMethodArray* methods; +}; + +} // namespace + +TEST(VMKlassContainsMethodTest, FindsCurrentMethodsOnly) { + VMStructsTestAccessor layout(offsetof(FakeKlass, methods)); + int m[4]; + FakeMethodArray array = {3, {&m[0], &m[1], &m[2]}}; + FakeKlass klass = {nullptr, &array}; + VMKlass* vk = VMKlass::cast_raw(&klass); + + // Correct hint, wrong hint, and no hint all find a current method. + EXPECT_TRUE(vk->containsMethod(&m[1], 1)); + EXPECT_TRUE(vk->containsMethod(&m[1], 0)); + EXPECT_TRUE(vk->containsMethod(&m[2], -1)); + // An out-of-range hint (stale idnum) must not read past the array. + EXPECT_TRUE(vk->containsMethod(&m[0], 7)); + // A method that is not (or no longer) in the array. + EXPECT_FALSE(vk->containsMethod(&m[3], 3)); + EXPECT_FALSE(vk->containsMethod(&m[3], -1)); +} + +TEST(VMKlassContainsMethodTest, NoMethodsArray) { + VMStructsTestAccessor layout(offsetof(FakeKlass, methods)); + FakeKlass klass = {nullptr, nullptr}; + int m; + EXPECT_FALSE(VMKlass::cast_raw(&klass)->containsMethod(&m, 0)); +} + +// A method that is not at its idnum's slot (e.g. after default-method +// overpasses were merged in at link time) is still found by a scan in small +// classes, but not in classes beyond MAX_METHODS_SCANNED. +TEST(VMKlassContainsMethodTest, ScanIsCappedForLargeClasses) { + VMStructsTestAccessor layout(offsetof(FakeKlass, methods)); + for (int count : {10, VMKlass::MAX_METHODS_SCANNED, VMKlass::MAX_METHODS_SCANNED + 1, 1000}) { + // Array: int length, padding, then `count` pointers. + std::vector array(1 + count, 0); + *reinterpret_cast(array.data()) = count; + std::vector methods(count); + for (int i = 0; i < count; i++) { + array[1 + i] = reinterpret_cast(&methods[i]); + } + FakeKlass klass = {nullptr, reinterpret_cast(array.data())}; + VMKlass* vk = VMKlass::cast_raw(&klass); + const void* m = &methods[count / 2]; + EXPECT_TRUE(vk->containsMethod(m, count / 2)) << count; + bool scanned = count <= VMKlass::MAX_METHODS_SCANNED; + EXPECT_EQ(scanned, vk->containsMethod(m, 0)) << count; + EXPECT_EQ(scanned, vk->containsMethod(m, -1)) << count; + } +} diff --git a/ddprof-lib/src/test/cpp/klassTraceId_ut.cpp b/ddprof-lib/src/test/cpp/klassTraceId_ut.cpp new file mode 100644 index 0000000000..78ffa9eca0 --- /dev/null +++ b/ddprof-lib/src/test/cpp/klassTraceId_ut.cpp @@ -0,0 +1,121 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#include + +#include "../../main/cpp/hotspot/klassTraceId.h" + +#include +#include + +// KlassTraceId::findOffset() against fake Klass layouts. Each fake klass is a +// row of 64-bit words; the decoy fields mimic what a real Klass holds around +// _trace_id, so the tests pin down which of them the calibration must reject. +namespace { + +const int N = KlassTraceId::PROBE_COUNT; +const size_t WORDS = 24; // 192 bytes, about sizeof(Klass) + +struct FakeKlasses { + std::vector> words; + std::vector ptrs; + + FakeKlasses() : words(N, std::vector(WORDS, 0)) { + for (int i = 0; i < N; i++) { + words[i][0] = 0x7f0000a00000ULL; // C++ vtable: the same for every klass + // Pointers to metadata allocated back to back (Symbol*, mirror + // handle, next sibling...): a few hundred bytes apart. + words[i][2] = 0x7f1234560000ULL + i * 0x1c0; + words[i][3] = 0x7f00fffffff0ULL + i * 0x10; // crosses a 64K boundary + // Small integers that are equal across array klasses. + words[i][4] = 0x0000001000000010ULL; + // Compact-header prototype mark word: narrow klass id << 42. + words[i][6] = ((uint64_t)(0x40000 + i * 0x38)) << 42 | 1; + } + for (int i = 0; i < N; i++) { + ptrs.push_back(words[i].data()); + } + } + + // JFR's encoding: (id << 16) | tag bits. + void setTraceIds(size_t word, uint64_t first, uint64_t step = 1, uint16_t tags = 0) { + for (int i = 0; i < N; i++) { + words[i][word] = ((first + i * step) << 16) | tags; + } + } + + int find() const { return KlassTraceId::findOffset(ptrs.data(), N, WORDS * 8); } +}; + +} // namespace + +TEST(KlassTraceIdTest, FindsTraceIdAmongDecoys) { + FakeKlasses k; + k.setTraceIds(9, 31337); + EXPECT_EQ(9 * 8, k.find()); +} + +TEST(KlassTraceIdTest, IgnoresJfrTagBits) { + FakeKlasses k; + k.setTraceIds(9, 31337, 1, 0x0303); + k.words[2][9] |= 0x00f0; // JFR re-tagged one class while running + EXPECT_EQ(9 * 8, k.find()); +} + +TEST(KlassTraceIdTest, ToleratesIdsTakenByOtherThreadsAndAnyOrder) { + FakeKlasses k; + const uint64_t ids[N] = {5000, 5007, 5003, 5040, 5001, 5002}; + for (int i = 0; i < N; i++) { + k.words[i][9] = ids[i] << 16; + } + EXPECT_EQ(9 * 8, k.find()); +} + +TEST(KlassTraceIdTest, RejectsIdsTooFarApart) { + FakeKlasses k; + k.setTraceIds(9, 5000, 1000); + EXPECT_EQ(-1, k.find()); +} + +TEST(KlassTraceIdTest, RejectsDuplicateIds) { + FakeKlasses k; + k.setTraceIds(9, 5000); + k.words[3][9] = k.words[1][9]; + EXPECT_EQ(-1, k.find()); +} + +TEST(KlassTraceIdTest, RejectsZeroIds) { + // Builds without JFR leave nothing there; a zeroed word must not qualify. + FakeKlasses k; + EXPECT_EQ(-1, k.find()); +} + +TEST(KlassTraceIdTest, RefusesAmbiguousLayout) { + FakeKlasses k; + k.setTraceIds(9, 5000); + k.setTraceIds(12, 9000); + EXPECT_EQ(-1, k.find()); +} + +TEST(KlassTraceIdTest, NeverPicksOffsetZero) { + FakeKlasses k; + k.setTraceIds(0, 5000); + EXPECT_EQ(-1, k.find()); + k.setTraceIds(9, 6000); + EXPECT_EQ(9 * 8, k.find()); +} + +TEST(KlassTraceIdTest, StaysWithinKlassSize) { + FakeKlasses k; + k.setTraceIds(WORDS - 1, 5000); + EXPECT_EQ((int)((WORDS - 1) * 8), k.find()); + EXPECT_EQ(-1, KlassTraceId::findOffset(k.ptrs.data(), N, (WORDS - 1) * 8)); +} + +TEST(KlassTraceIdTest, NeedsAtLeastTwoProbes) { + FakeKlasses k; + k.setTraceIds(9, 5000); + EXPECT_EQ(-1, KlassTraceId::findOffset(k.ptrs.data(), 1, WORDS * 8)); +} diff --git a/ddprof-lib/src/test/cpp/redefineHooks_ut.cpp b/ddprof-lib/src/test/cpp/redefineHooks_ut.cpp new file mode 100644 index 0000000000..1504fc6135 --- /dev/null +++ b/ddprof-lib/src/test/cpp/redefineHooks_ut.cpp @@ -0,0 +1,208 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#include + +#include "../../main/cpp/jvmSupport.h" +#include "../../main/cpp/vmEntry.h" + +#include + +// VM::RedefineClassesHook / RetransformClassesHook wrap the JVMTI functions +// of the same name. Whatever the caller passes, the hooks must let the +// original validate it before they touch it: a positive count with a null +// array is JVMTI_ERROR_NULL_POINTER, and an element that is not an instance +// class is rejected by JVMTI too. These tests replace the originals with +// recording stubs and check that the hooks forward such calls untouched, +// without dereferencing anything. + +class VMTestAccessor { +public: + using RedefineFn = jvmtiError(JNICALL*)(jvmtiEnv*, jint, const jvmtiClassDefinition*); + using RetransformFn = jvmtiError(JNICALL*)(jvmtiEnv*, jint, const jclass*); + + static bool getHotspot() { return VM::_hotspot; } + static void setHotspot(bool v) { VM::_hotspot = v; } + static JavaVM* getVm() { return VM::_vm; } + static void setVm(JavaVM* vm) { VM::_vm = vm; } + static RedefineFn getRedefine() { return VM::_orig_RedefineClasses; } + static void setRedefine(RedefineFn f) { VM::_orig_RedefineClasses = f; } + static RetransformFn getRetransform() { return VM::_orig_RetransformClasses; } + static void setRetransform(RetransformFn f) { VM::_orig_RetransformClasses = f; } +}; + +class JVMSupportTestAccessor { +public: + using LoadState = JVMSupport::JMethodIDLoadStats; + static LoadState getLoadState() { return JVMSupport::getLoadState(); } + static void setLoadState(LoadState s) { JVMSupport::setLoadState(s); } + static constexpr LoadState LazyLoaded() { return JVMSupport::Lazy_loaded; } + static constexpr LoadState FullyLoaded() { return JVMSupport::Fully_loaded; } +}; + +namespace { + +struct Calls { + int redefine = 0; + int retransform = 0; + int get_long_field = 0; // VMKlass::fromJavaClass() + int get_class_methods = 0; // jmethodID preload + jint last_count = 0; + const void* last_array = nullptr; + jint class_status = JVMTI_CLASS_STATUS_PREPARED; + jvmtiError class_status_error = JVMTI_ERROR_NONE; +}; +Calls* g_calls = nullptr; + +jvmtiError JNICALL origRedefine(jvmtiEnv*, jint count, const jvmtiClassDefinition* defs) { + g_calls->redefine++; + g_calls->last_count = count; + g_calls->last_array = defs; + return count > 0 && defs == nullptr ? JVMTI_ERROR_NULL_POINTER : JVMTI_ERROR_INVALID_CLASS; +} + +jvmtiError JNICALL origRetransform(jvmtiEnv*, jint count, const jclass* classes) { + g_calls->retransform++; + g_calls->last_count = count; + g_calls->last_array = classes; + return count > 0 && classes == nullptr ? JVMTI_ERROR_NULL_POINTER : JVMTI_ERROR_INVALID_CLASS; +} + +jvmtiError JNICALL getClassStatus(jvmtiEnv*, jclass, jint* status) { + *status = g_calls->class_status; + return g_calls->class_status_error; +} + +jvmtiError JNICALL getClassMethods(jvmtiEnv*, jclass, jint* count, jmethodID** methods) { + g_calls->get_class_methods++; + *count = 0; + *methods = nullptr; + return JVMTI_ERROR_NONE; +} + +jvmtiError JNICALL deallocate(jvmtiEnv*, unsigned char*) { return JVMTI_ERROR_NONE; } + +jlong JNICALL getLongField(JNIEnv*, jobject, jfieldID) { + g_calls->get_long_field++; + return 0; +} + +JNIEnv_* g_env = nullptr; +jint JNICALL getEnv(JavaVM*, void** penv, jint) { + *penv = g_env; + return JNI_OK; +} + +} // namespace + +class RedefineHooksTest : public ::testing::TestWithParam { +protected: + Calls _calls; + jvmtiInterface_1_ _jvmti_tbl{}; + _jvmtiEnv _jvmti{}; + JNINativeInterface_ _jni_tbl{}; + JNIEnv_ _jni{}; + JNIInvokeInterface_ _vm_tbl{}; + JavaVM_ _vm{}; + + bool _saved_hotspot = false; + JavaVM* _saved_vm = nullptr; + VMTestAccessor::RedefineFn _saved_redefine = nullptr; + VMTestAccessor::RetransformFn _saved_retransform = nullptr; + JVMSupportTestAccessor::LoadState _saved_state{}; + + // The parameter: run the hooks in fjmethodid=lazy (true) or the default + // full preload mode (false). Both must forward invalid calls untouched. + static bool lazy() { return GetParam(); } + + void SetUp() override { + g_calls = &_calls; + _jvmti_tbl.GetClassStatus = &getClassStatus; + _jvmti_tbl.GetClassMethods = &getClassMethods; + _jvmti_tbl.Deallocate = &deallocate; + _jvmti.functions = &_jvmti_tbl; + _jni_tbl.GetLongField = &getLongField; + _jni.functions = &_jni_tbl; + g_env = &_jni; + _vm_tbl.GetEnv = &getEnv; + _vm.functions = &_vm_tbl; + + _saved_hotspot = VMTestAccessor::getHotspot(); + _saved_vm = VMTestAccessor::getVm(); + _saved_redefine = VMTestAccessor::getRedefine(); + _saved_retransform = VMTestAccessor::getRetransform(); + _saved_state = JVMSupportTestAccessor::getLoadState(); + VMTestAccessor::setHotspot(true); + VMTestAccessor::setVm(reinterpret_cast(&_vm)); + VMTestAccessor::setRedefine(&origRedefine); + VMTestAccessor::setRetransform(&origRetransform); + JVMSupportTestAccessor::setLoadState(lazy() ? JVMSupportTestAccessor::LazyLoaded() + : JVMSupportTestAccessor::FullyLoaded()); + } + + void TearDown() override { + JVMSupportTestAccessor::setLoadState(_saved_state); + VMTestAccessor::setRetransform(_saved_retransform); + VMTestAccessor::setRedefine(_saved_redefine); + VMTestAccessor::setVm(_saved_vm); + VMTestAccessor::setHotspot(_saved_hotspot); + g_env = nullptr; + g_calls = nullptr; + } +}; + +TEST_P(RedefineHooksTest, RedefineWithNullArrayIsForwardedUntouched) { + EXPECT_EQ(JVMTI_ERROR_NULL_POINTER, VM::RedefineClassesHook(&_jvmti, 2, nullptr)); + EXPECT_EQ(1, _calls.redefine); + EXPECT_EQ(2, _calls.last_count); + EXPECT_EQ(nullptr, _calls.last_array); +} + +TEST_P(RedefineHooksTest, RetransformWithNullArrayIsForwardedUntouched) { + EXPECT_EQ(JVMTI_ERROR_NULL_POINTER, VM::RetransformClassesHook(&_jvmti, 3, nullptr)); + EXPECT_EQ(1, _calls.retransform); + EXPECT_EQ(3, _calls.last_count); + EXPECT_EQ(nullptr, _calls.last_array); +} + +// An element JVMTI does not accept as a class (JVMTI_ERROR_INVALID_CLASS) is +// neither looked up as a Klass* nor preloaded, before or after the call. +TEST_P(RedefineHooksTest, NonClassElementIsNotTouched) { + _calls.class_status_error = JVMTI_ERROR_INVALID_CLASS; + int not_a_class = 0; + jclass classes[] = {reinterpret_cast(¬_a_class)}; + jvmtiClassDefinition defs[] = {{classes[0], 0, nullptr}}; + + EXPECT_EQ(JVMTI_ERROR_INVALID_CLASS, VM::RetransformClassesHook(&_jvmti, 1, classes)); + EXPECT_EQ(JVMTI_ERROR_INVALID_CLASS, VM::RedefineClassesHook(&_jvmti, 1, defs)); + EXPECT_EQ(0, _calls.get_long_field); + EXPECT_EQ(0, _calls.get_class_methods); +} + +// Array classes have no InstanceKlass fields to read; JVMTI rejects them as +// unmodifiable, and the hooks must not inspect them either. +TEST_P(RedefineHooksTest, ArrayClassElementIsNotTouched) { + _calls.class_status = JVMTI_CLASS_STATUS_ARRAY; + int array_class = 0; + jclass classes[] = {reinterpret_cast(&array_class)}; + + VM::RetransformClassesHook(&_jvmti, 1, classes); + EXPECT_EQ(1, _calls.retransform); + EXPECT_EQ(0, _calls.get_long_field); + EXPECT_EQ(0, _calls.get_class_methods); +} + +// A null element inside a valid array is skipped as before. +TEST_P(RedefineHooksTest, NullElementIsSkipped) { + jclass classes[] = {nullptr}; + VM::RetransformClassesHook(&_jvmti, 1, classes); + EXPECT_EQ(1, _calls.retransform); + EXPECT_EQ(0, _calls.get_long_field); +} + +INSTANTIATE_TEST_SUITE_P(Mode, RedefineHooksTest, ::testing::Bool(), + [](const ::testing::TestParamInfo& info) { + return info.param ? "Lazy" : "FullPreload"; + }); diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/ExternalLauncher.java b/ddprof-test/src/test/java/com/datadoghq/profiler/ExternalLauncher.java index 2dbb429668..16ec45f645 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/ExternalLauncher.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/ExternalLauncher.java @@ -26,6 +26,9 @@ *
  • profiler-sequence [';'-delimited steps] - runs a sequence of start/stop calls in this * process; each step is either the literal {@code STOP} (calls {@link JavaProfiler#stop()}) * or a comma delimited profiler command list (calls {@link JavaProfiler#execute(String)})
  • + *
  • lazy-trace-id <comma delimited profiler command list, required> - starts the profiler, spins + * in {@code com.datadoghq.profiler.generated.LazyTraceIdProbe} (defined by its own class loader, so it + * has no jmethodIDs until the profiler resolves its frames) and stops the profiler
  • *
  • entry-frames <comma delimited profiler command list, required> - starts the profiler, then burns * CPU concurrently on the main thread, on a plain {@code new Thread(Runnable)} and on a * two-level {@link Thread} subclass, and stops the profiler again. The resulting recording @@ -158,6 +161,16 @@ public static void main(String[] args) throws Exception { } } } + } else if (args[0].equals("lazy-trace-id")) { + if (args.length < 2 || args[1].isEmpty()) { + throw new IllegalArgumentException("lazy-trace-id requires a profiler command list"); + } + JavaProfiler instance = JavaProfiler.getInstance(); + instance.execute(args[1]); + Class probe = SpinClasses.define("com/datadoghq/profiler/generated/LazyTraceIdProbe"); + probe.getMethod("spin", long.class).invoke(null, 2_000_000_000L); + // Stopping dumps the recording, which resolves the probe's raw frames. + instance.stop(); } else if (args[0].equals("entry-frames")) { // Unlike the modes above, this one is only meaningful with a running profiler: // silently skipping the start would leave the parent process parsing an empty diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/SpinClasses.java b/ddprof-test/src/test/java/com/datadoghq/profiler/SpinClasses.java new file mode 100644 index 0000000000..cb8a4256da --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/SpinClasses.java @@ -0,0 +1,77 @@ +/* + * 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); + } + + /** + * Returns the class file {@link #define} defines for {@code internalName}, e.g. to redefine a + * defined class with its own bytes. + * + * @param internalName the class name in internal form, e.g. {@code a/b/C} + * @return the class file bytes + */ + public static byte[] bytes(String internalName) { + return generate(internalName); + } + + private static final class Loader extends ClassLoader { + Class define(String internalName) { + byte[] bytes = generate(internalName); + return defineClass(internalName.replace('/', '.'), bytes, 0, bytes.length); + } + } + + private static byte[] generate(String internalName) { + ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS); + cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, internalName, null, "java/lang/Object", null); + + MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "spin", "(J)V", null, null); + mv.visitCode(); + // 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 new file mode 100644 index 0000000000..c5e3d3f62e --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyJmethodIdTest.java @@ -0,0 +1,166 @@ +/* + * 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 com.datadoghq.profiler.SpinClasses; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junitpioneer.jupiter.RetryingTest; + +import java.io.IOException; +import java.lang.ref.WeakReference; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests {@code fjmethodid=lazy}: no jmethodIDs are preloaded at ClassPrepare. walkVM stores + * raw {@code Method*} frames, and the dump thread resolves them only after + * {@code KlassRegistry} confirms the owning class is still loaded, 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 = SpinClasses.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) {} + } + } + + /** + * On HotSpot builds with JFR, lazy mode must locate {@code Klass::_trace_id} by probing, + * since vmStructs does not expose it. + */ + @Test + public void traceIdOffsetIsCalibrated() throws Exception { + assumeHotspot(); + waitForProfilerReady(2000); + Long offset = profiler.getDebugCounters().get("klass_trace_id_offset"); + System.out.println("klass_trace_id_offset=" + offset + " java.version=" + System.getProperty("java.version")); + // Absent in builds without counters. + Assumptions.assumeTrue(offset != null, "debug counters not available"); + // JDK 8 has JFR only from 8u272 on; without it there is no field to find. + Assumptions.assumeTrue(Platform.isJavaVersionAtLeast(11), "JFR may be absent on JDK 8"); + assertTrue(offset > 0, "Klass::_trace_id offset was not found: " + offset); + } + + private static void assumeHotspot() { + // Lazy mode is HotSpot-only (raw Method* frames come from walkVM). + Assumptions.assumeFalse(Platform.isZing() || Platform.isJ9() || Platform.isGraal()); + } + + private static synchronized String nextClassName(String prefix) { + return "com/datadoghq/profiler/generated/" + prefix + (classCounter++); + } + + // Kept out of the caller's frame so no strong reference to the class or its + // loader survives on the stack. + private static WeakReference spinInUnloadableClass(String className) throws Exception { + Class clazz = SpinClasses.define(className); + clazz.getMethod("spin", long.class).invoke(null, 300_000_000L); + return new WeakReference<>(clazz.getClassLoader()); + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyRedefinitionStressTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyRedefinitionStressTest.java new file mode 100644 index 0000000000..29d5580718 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyRedefinitionStressTest.java @@ -0,0 +1,195 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.cpu; + +import com.datadoghq.profiler.AbstractProfilerTest; +import com.datadoghq.profiler.JfrEvent; +import com.datadoghq.profiler.Platform; +import com.datadoghq.profiler.SpinClasses; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.io.IOException; +import java.io.OutputStream; +import java.lang.instrument.ClassDefinition; +import java.lang.instrument.Instrumentation; +import java.lang.management.ManagementFactory; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.jar.Attributes; +import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; + +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Redefines a class over and over in a live JVM while {@code fjmethodid=lazy} samples it and the + * profiler dumps concurrently. + * + *

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

    A child JVM runs the profiler in lazy mode and a JFR recording of {@code jdk.ClassDefine} while + * it spins in a probe class. In debug builds the profiler logs the trace ID it reads for 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, + * 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"); + Path jfrSettings = Files.createTempFile("lazy-trace-id-", ".jfc"); + Files.write(jfrSettings, Arrays.asList( + "", + "", + " ", + " true", + " false", + " ", + "")); + try { + AtomicLong loggedId = new AtomicLong(-1); + AtomicBoolean traceIdTags = new AtomicBoolean(false); + LaunchResult result = launch("lazy-trace-id", + Arrays.asList("-XX:StartFlightRecording=filename=" + jfrRecording + ",settings=" + jfrSettings + ",dumponexit=true"), + "start,cpu=1ms,cstack=vm,fjmethodid=lazy,jfr,file=" + ddprofRecording, + line -> { + if (line.contains("[TEST::INFO] Raw frame tags: klass trace id")) { + traceIdTags.set(true); + } + Matcher m = LOGGED_ID.matcher(line); + if (m.find()) { + try { + loggedId.set(Long.parseLong(m.group(1))); + } catch (NumberFormatException ignored) { + // Leaves loggedId unset; the assertion below reports it. + } + } + return LineConsumerResult.CONTINUE; + }, null); + assertTrue(result.inTime, "child JVM did not finish in time"); + assertEquals(0, result.exitCode, "child JVM failed"); + // Builds without JFR (e.g. 8u before 8u262) have no trace ID; lazy mode uses the epoch. + Assumptions.assumeTrue(traceIdTags.get(), "calibration found no trace ID"); + assertTrue(loggedId.get() > 0, "the profiler did not log the probe class's trace ID"); + + Set jfrIds = jfrClassIds(jfrRecording, PROBE_CLASS); + System.out.println("profiler trace id=" + loggedId.get() + ", JFR class ids=" + jfrIds); + assertTrue(!jfrIds.isEmpty(), "JFR recorded no jdk.ClassDefine for " + PROBE_CLASS); + assertEquals(new HashSet<>(Arrays.asList(loggedId.get())), jfrIds, + "the calibrated Klass::_trace_id offset does not read JFR's class ID"); + } finally { + try { Files.deleteIfExists(jfrRecording); } catch (IOException ignored) {} + try { Files.deleteIfExists(ddprofRecording); } catch (IOException ignored) {} + try { Files.deleteIfExists(jfrSettings); } catch (IOException ignored) {} + } + } + + private static boolean jfrConsumerAvailable() { + try { + Class.forName("jdk.jfr.consumer.RecordingFile"); + return true; + } catch (ClassNotFoundException e) { + return false; + } + } + + /** + * Returns the IDs JFR's {@code jdk.ClassDefine} events give {@code className}. Uses + * jdk.jfr.consumer through reflection because the test sources compile with + * {@code --release 8}, which hides jdk.jfr. + */ + private static Set jfrClassIds(Path recording, String className) throws Exception { + Class recordingFile = Class.forName("jdk.jfr.consumer.RecordingFile"); + List events = (List) recordingFile.getMethod("readAllEvents", Path.class).invoke(null, recording); + Set ids = new HashSet<>(); + for (Object event : events) { + Object type = call(event, "getEventType"); + if (!"jdk.ClassDefine".equals(call(type, "getName"))) { + continue; + } + Object clazz = event.getClass().getMethod("getClass", String.class).invoke(event, "definedClass"); + if (clazz != null && className.equals(call(clazz, "getName"))) { + ids.add((Long) call(clazz, "getId")); + } + } + return ids; + } + + private static Object call(Object target, String name) throws Exception { + Method m = target.getClass().getMethod(name); + m.setAccessible(true); + return m.invoke(target); + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/RetransformAgent.java b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/RetransformAgent.java new file mode 100644 index 0000000000..00640aba50 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/RetransformAgent.java @@ -0,0 +1,29 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.cpu; + +import java.lang.instrument.Instrumentation; + +/** + * Agent loaded into the test JVM by self-attach (see {@link LazyRedefinitionStressTest}); it only + * hands out the {@link Instrumentation} instance. + */ +public final class RetransformAgent { + /** Set by {@link #agentmain} once the agent has been loaded. */ + public static volatile Instrumentation instrumentation; + + private RetransformAgent() {} + + /** + * Agent entry point for dynamic loading. + * + * @param args ignored + * @param inst the instrumentation instance of this JVM + */ + public static void agentmain(String args, Instrumentation inst) { + instrumentation = inst; + } +}