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..398a13c49c 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: preload no jmethodIDs; resolve raw Method* frames from VM metadata at dump time Arguments(bool persistent = false) : _buf(NULL), @@ -324,7 +325,8 @@ class Arguments { _jvmtistacks(false), _nativesocket(false), _nativesocket_interval(0), - _force_jmethodID(true) {} + _force_jmethodID(true), + _lazy_jmethodID(false) {} ~Arguments(); diff --git a/ddprof-lib/src/main/cpp/counters.h b/ddprof-lib/src/main/cpp/counters.h index 1f0846b2d0..950ce1fb49 100644 --- a/ddprof-lib/src/main/cpp/counters.h +++ b/ddprof-lib/src/main/cpp/counters.h @@ -94,6 +94,8 @@ X(CALLTRACE_STORAGE_EXPANSION_SKIPPED, "calltrace_storage_expansion_skipped") \ X(LINE_NUMBER_TABLES, "line_number_tables") \ X(LINE_NUMBER_TABLE_UNREADABLE, "line_number_table_unreadable") \ + /* Raw frames' line number tables dropped because malloc() failed. */ \ + X(LINE_NUMBER_TABLE_ALLOC_FAILED, "line_number_table_alloc_failed") \ X(REMOTE_SYMBOLICATION_FRAMES, "remote_symbolication_frames") \ X(REMOTE_SYMBOLICATION_LIBS_WITH_BUILD_ID, "remote_symbolication_libs_with_build_id") \ X(REMOTE_SYMBOLICATION_BUILD_ID_CACHE_HITS, "remote_symbolication_build_id_cache_hits") \ @@ -170,10 +172,70 @@ X(METHOD_RESOLVE_FAULT_RECOVERED, "method_resolve_fault_recovered") \ /* Symbol length/body rejected during the same resolution: unreadable body, \ * empty (recycled slot), or over MAX_SYMBOL_LEN. A name that merely exceeds \ - * the fixed inline buffers in hotspotSupport.cpp's ResolvedNames still \ + * the fixed inline buffers in ResolvedMethod (hotspotSupport.h) still \ * resolves via its malloc fallback and does not land here. The frame \ * serializes as "unknown". */ \ X(METHOD_RESOLVE_SYMBOL_UNREADABLE, "method_resolve_symbol_unreadable") \ + /* fjmethodid=lazy resolution outcomes. UNLOADED: the frame's class is not \ + * among the classes the dump pins (unloaded since the sample, or the \ + * holder read from a freed Method* is garbage). NOT_CURRENT: the class \ + * is alive but the Method* is not among its current methods (freed or \ + * redefined). Both \ + * serialize as "unknown". RESOLVED: raw frames whose names were read from \ + * VM metadata, once per method and tag per dump. */ \ + X(METHOD_RESOLVE_LAZY_UNLOADED, "method_resolve_lazy_unloaded") \ + X(METHOD_RESOLVE_LAZY_NOT_CURRENT, "method_resolve_lazy_not_current") \ + /* CLASS_TOO_LARGE: like NOT_CURRENT, but the Method* was not at its \ + * idnum's index and the class has too many methods to scan \ + * (VMKlass::MAX_METHODS_SCANNED), so it may well be current; serializes \ + * as "unknown". */ \ + X(METHOD_RESOLVE_LAZY_CLASS_TOO_LARGE, "method_resolve_lazy_class_too_large") \ + X(METHOD_RESOLVE_LAZY_RESOLVED, "method_resolve_lazy_resolved") \ + /* STALE: the class now at the frame's holder address is a different load \ + * (its trace ID differs); serializes as "unknown". \ + * 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") \ + /* UNREADABLE: the frame's Method* no longer leads to a holder Klass* and \ + * ConstMethod (the metadata was freed when its class was unloaded, or the \ + * read faulted), so no class could even be looked up; serializes as \ + * "unknown". Under class churn this is most of the frames lost. */ \ + X(METHOD_RESOLVE_LAZY_UNREADABLE, "method_resolve_lazy_unreadable") \ + /* Classes marked by the redefinition hooks (once per class per \ + * redefine/retransform call). */ \ + X(KLASS_REGISTRY_REDEFINITIONS_MARKED, "klass_registry_redefinitions_marked") \ + /* Gauge, set by each dump that resolves lazy raw frames: loaded classes \ + * whose trace ID no longer fits in a raw frame's 25-bit tag. Nonzero means trace-ID tags of two classes \ + * can now collide: a frame sampled from an unloaded class could then, if \ + * a newer class also took over its Method* memory, resolve to that \ + * class's method. */ \ + X(KLASS_TRACE_ID_BEYOND_TAG_RANGE, "klass_trace_id_beyond_tag_range") \ + /* Gauges, set by each dump that resolves lazy raw frames: the classes it \ + * pinned (array classes included), and the nanoseconds it took to collect \ + * their Klass*s. */ \ + X(LAZY_DUMP_CLASSES, "lazy_dump_classes") \ + X(LAZY_DUMP_PIN_NS, "lazy_dump_pin_ns") \ + /* Raw Method* frame occurrences answered from Lookup's per-dump cache \ + * instead of resolving them again. */ \ + X(RAW_FRAME_RESOLVE_CACHE_HITS, "raw_frame_resolve_cache_hits") \ + /* Redefinitions whose mark could not be recorded (no memory for it); \ + * every lazy frame is refused for the rest of the process once this is \ + * nonzero. */ \ + X(KLASS_REGISTRY_ADD_FAILED, "klass_registry_add_failed") \ + /* Gauges: KlassRegistry's table slots (24 B each; NMT does not see the \ + * table) and occupied slots, live or dead. It holds only redefined \ + * classes. PURGED: dead entries dropped when the table was rebuilt. */ \ + X(KLASS_REGISTRY_CAPACITY, "klass_registry_capacity") \ + X(KLASS_REGISTRY_ENTRIES, "klass_registry_entries") \ + X(KLASS_REGISTRY_PURGED, "klass_registry_purged") \ + 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 e5c432603c..64c158df8e 100644 --- a/ddprof-lib/src/main/cpp/flightRecorder.cpp +++ b/ddprof-lib/src/main/cpp/flightRecorder.cpp @@ -78,15 +78,20 @@ static inline u64 safeDuration(u64 start_time, u64 end_time) { } SharedLineNumberTable::~SharedLineNumberTable() { - // _ptr is the buffer jvmti->GetLineNumberTable() itself returned (see - // Lookup::fillJavaMethodInfo), not a copy -- freed here via + // A JVMTI table is the buffer jvmti->GetLineNumberTable() itself returned + // (see Lookup::fillJavaMethodInfo), not a copy -- freed here via // jvmti->Deallocate(), not plain free(). On HotSpot, JvmtiEnv // Allocate()/Deallocate() are os::malloc()/os::free(), which route through // native memory tracking; freeing with plain free() can result in crash // whenever -XX:NativeMemoryTracking is enabled, because native memory tracking - // adds a header to malloc'd block. + // adds a header to malloc'd block. A table decoded from VM metadata + // (ResolvedMethod) is our own malloc() and goes back to free(). if (_table != nullptr) { - VM::jvmti()->Deallocate((unsigned char *)_table); + if (_jvmti_allocated) { + VM::jvmti()->Deallocate((unsigned char *)_table); + } else { + free(_table); + } Counters::decrement(LINE_NUMBER_TABLES); // _size is the JVMTI entry count passed at construction (see // fillJavaMethodInfo), so the byte size matches the allocation. @@ -266,9 +271,6 @@ bool Lookup::fillJavaMethodInfo(MethodInfo *mi, jmethodID method, char* volatile &class_name = state._class_name; char* volatile &method_name = state._method_name; char* volatile &method_sig = state._method_signature; - u32 class_name_id = 0; - u32 method_name_id = 0; - u32 method_sig_id = 0; jint line_number_table_size = 0; jvmtiLineNumberEntry *line_number_table = NULL; @@ -385,95 +387,15 @@ bool Lookup::fillJavaMethodInfo(MethodInfo *mi, jmethodID method, entry = true; } - // maybe we should store the lookups below in initialisation-time - // constants... - if (has_prefix(class_name, - "Ljdk/internal/reflect/GeneratedConstructorAccessor")) { - class_name_id = _classes->lookupDuringDump( - "jdk/internal/reflect/GeneratedConstructorAccessor", - strlen("jdk/internal/reflect/GeneratedConstructorAccessor"), - Profiler::maxClassMapSize()); - method_name_id = - _symbols.lookup("Object " - "jdk.internal.reflect.GeneratedConstructorAccessor." - "newInstance(Object[])"); - method_sig_id = _symbols.lookup(method_sig); - } else if (has_prefix(class_name, - "Lsun/reflect/GeneratedConstructorAccessor")) { - class_name_id = _classes->lookupDuringDump( - "sun/reflect/GeneratedConstructorAccessor", - strlen("sun/reflect/GeneratedConstructorAccessor"), - Profiler::maxClassMapSize()); - method_name_id = _symbols.lookup( - "Object " - "sun.reflect.GeneratedConstructorAccessor.newInstance(Object[])"); - method_sig_id = _symbols.lookup(method_sig); - } else if (has_prefix(class_name, - "Ljdk/internal/reflect/GeneratedMethodAccessor")) { - class_name_id = _classes->lookupDuringDump( - "jdk/internal/reflect/GeneratedMethodAccessor", - strlen("jdk/internal/reflect/GeneratedMethodAccessor"), - Profiler::maxClassMapSize()); - method_name_id = - _symbols.lookup("Object " - "jdk.internal.reflect.GeneratedMethodAccessor." - "invoke(Object, Object[])"); - method_sig_id = _symbols.lookup(method_sig); - } else if (has_prefix(class_name, - "Lsun/reflect/GeneratedMethodAccessor")) { - class_name_id = _classes->lookupDuringDump( - "sun/reflect/GeneratedMethodAccessor", - strlen("sun/reflect/GeneratedMethodAccessor"), - Profiler::maxClassMapSize()); - method_name_id = _symbols.lookup( - "Object sun.reflect.GeneratedMethodAccessor.invoke(Object, " - "Object[])"); - method_sig_id = _symbols.lookup(method_sig); - } else if (has_prefix(class_name, "Ljava/lang/invoke/LambdaForm$")) { - const int lambdaFormPrefixLength = - strlen("Ljava/lang/invoke/LambdaForm$"); - // we want to normalise to java/lang/invoke/LambdaForm$MH, - // java/lang/invoke/LambdaForm$DMH, java/lang/invoke/LambdaForm$BMH, - if (has_prefix(class_name + lambdaFormPrefixLength, "MH")) { - class_name_id = _classes->lookupDuringDump( - "java/lang/invoke/LambdaForm$MH", - strlen("java/lang/invoke/LambdaForm$MH"), - Profiler::maxClassMapSize()); - } else if (has_prefix(class_name + lambdaFormPrefixLength, "BMH")) { - class_name_id = _classes->lookupDuringDump( - "java/lang/invoke/LambdaForm$BMH", - strlen("java/lang/invoke/LambdaForm$BMH"), - Profiler::maxClassMapSize()); - } else if (has_prefix(class_name + lambdaFormPrefixLength, "DMH")) { - class_name_id = _classes->lookupDuringDump( - "java/lang/invoke/LambdaForm$DMH", - strlen("java/lang/invoke/LambdaForm$DMH"), - Profiler::maxClassMapSize()); - } else { - // don't recognise the suffix, so don't normalise - class_name_id = _classes->lookupDuringDump( - normalized_class_name, normalized_class_name_len, - Profiler::maxClassMapSize()); - } - method_name_id = _symbols.lookup(method_name); - method_sig_id = _symbols.lookup(method_sig); - } else { - class_name_id = _classes->lookupDuringDump( - normalized_class_name, normalized_class_name_len, - Profiler::maxClassMapSize()); - method_name_id = _symbols.lookup(method_name); - method_sig_id = _symbols.lookup(method_sig); - } + fillJavaMethodNames(mi, normalized_class_name, normalized_class_name_len, + method_name, method_sig); } else { Counters::increment(JMETHODID_SKIPPED); - class_name_id = _classes->lookupDuringDump("", 0, Profiler::maxClassMapSize()); - method_name_id = _symbols.lookup(""); - method_sig_id = _symbols.lookup("()L;"); + mi->_class = _classes->lookupDuringDump("", 0, Profiler::maxClassMapSize()); + mi->_name = _symbols.lookup(""); + mi->_sig = _symbols.lookup("()L;"); } - mi->_class = class_name_id; - mi->_name = method_name_id; - mi->_sig = method_sig_id; mi->_type = FRAME_INTERPRETED; mi->_is_entry = entry; // Only touch mi->_line_number_table when a table was actually fetched @@ -501,6 +423,123 @@ bool Lookup::fillJavaMethodInfo(MethodInfo *mi, jmethodID method, return false; } +void Lookup::fillJavaMethodNames(MethodInfo *mi, const char *class_name, + size_t class_name_len, const char *method_name, + const char *method_sig) { + u32 class_name_id; + u32 method_name_id; + // maybe we should store the lookups below in initialisation-time + // constants... + if (has_prefix_n(class_name, class_name_len, + "jdk/internal/reflect/GeneratedConstructorAccessor")) { + class_name_id = _classes->lookupDuringDump( + "jdk/internal/reflect/GeneratedConstructorAccessor", + strlen("jdk/internal/reflect/GeneratedConstructorAccessor"), + Profiler::maxClassMapSize()); + method_name_id = + _symbols.lookup("Object " + "jdk.internal.reflect.GeneratedConstructorAccessor." + "newInstance(Object[])"); + } else if (has_prefix_n(class_name, class_name_len, + "sun/reflect/GeneratedConstructorAccessor")) { + class_name_id = _classes->lookupDuringDump( + "sun/reflect/GeneratedConstructorAccessor", + strlen("sun/reflect/GeneratedConstructorAccessor"), + Profiler::maxClassMapSize()); + method_name_id = _symbols.lookup( + "Object " + "sun.reflect.GeneratedConstructorAccessor.newInstance(Object[])"); + } else if (has_prefix_n(class_name, class_name_len, + "jdk/internal/reflect/GeneratedMethodAccessor")) { + class_name_id = _classes->lookupDuringDump( + "jdk/internal/reflect/GeneratedMethodAccessor", + strlen("jdk/internal/reflect/GeneratedMethodAccessor"), + Profiler::maxClassMapSize()); + method_name_id = + _symbols.lookup("Object " + "jdk.internal.reflect.GeneratedMethodAccessor." + "invoke(Object, Object[])"); + } else if (has_prefix_n(class_name, class_name_len, + "sun/reflect/GeneratedMethodAccessor")) { + class_name_id = _classes->lookupDuringDump( + "sun/reflect/GeneratedMethodAccessor", + strlen("sun/reflect/GeneratedMethodAccessor"), + Profiler::maxClassMapSize()); + method_name_id = _symbols.lookup( + "Object sun.reflect.GeneratedMethodAccessor.invoke(Object, " + "Object[])"); + } else if (has_prefix_n(class_name, class_name_len, + "java/lang/invoke/LambdaForm$")) { + const size_t lambdaFormPrefixLength = + strlen("java/lang/invoke/LambdaForm$"); + const char *suffix = class_name + lambdaFormPrefixLength; + const size_t suffix_len = class_name_len - lambdaFormPrefixLength; + // we want to normalise to java/lang/invoke/LambdaForm$MH, + // java/lang/invoke/LambdaForm$DMH, java/lang/invoke/LambdaForm$BMH, + if (has_prefix_n(suffix, suffix_len, "MH")) { + class_name_id = _classes->lookupDuringDump( + "java/lang/invoke/LambdaForm$MH", + strlen("java/lang/invoke/LambdaForm$MH"), + Profiler::maxClassMapSize()); + } else if (has_prefix_n(suffix, suffix_len, "BMH")) { + class_name_id = _classes->lookupDuringDump( + "java/lang/invoke/LambdaForm$BMH", + strlen("java/lang/invoke/LambdaForm$BMH"), + Profiler::maxClassMapSize()); + } else if (has_prefix_n(suffix, suffix_len, "DMH")) { + class_name_id = _classes->lookupDuringDump( + "java/lang/invoke/LambdaForm$DMH", + strlen("java/lang/invoke/LambdaForm$DMH"), + Profiler::maxClassMapSize()); + } else { + // don't recognise the suffix, so don't normalise + class_name_id = _classes->lookupDuringDump( + class_name, class_name_len, Profiler::maxClassMapSize()); + } + method_name_id = _symbols.lookup(method_name); + } else { + class_name_id = _classes->lookupDuringDump( + class_name, class_name_len, Profiler::maxClassMapSize()); + method_name_id = _symbols.lookup(method_name); + } + + mi->_class = class_name_id; + mi->_name = method_name_id; + mi->_sig = _symbols.lookup(method_sig); +} + +void Lookup::fillJavaMethodInfo(MethodInfo *mi, ResolvedMethod &resolved) { + const char *class_name = resolved.klassName(); + const char *method_name = resolved.methodName(); + const char *method_sig = resolved.methodSignature(); + + fillJavaMethodNames(mi, class_name, strlen(class_name), method_name, method_sig); + mi->_type = FRAME_INTERPRETED; + // Same entry-frame rules as the jmethodID path: Thread.run (or an override + // in a Thread subclass), or public static void main(String[]). + mi->_is_entry = resolved.isThreadRun() || + (strcmp(method_name, "main") == 0 && + strcmp(method_sig, "([Ljava/lang/String;)V") == 0); + + // Raw rows are refilled at their first use in every chunk (see + // fillRawMethod()), so the table is replaced each time rather than kept + // from the first fill as on the jmethodID path. + if (resolved.lineNumberTable() != nullptr) { + int size = resolved.lineNumberTableSize(); + // Built before ownership moves, so a std::bad_alloc here leaves the table + // with `resolved`, which frees it. + auto table = std::make_shared( + size, const_cast(resolved.lineNumberTable()), + false /* malloc'd */); + resolved.takeLineNumberTable(&size); + mi->_line_number_table = std::move(table); + Counters::increment(LINE_NUMBER_TABLES); + NativeMem::record(NM_LINE_TABLES, (long long)((size_t)size * sizeof(jvmtiLineNumberEntry))); + } else { + mi->_line_number_table = nullptr; + } +} + bool Lookup::resolveVTableReceiver(VMSymbol *sym, char *buf, size_t bufsize, u32 *out_class_id) { if (sym == nullptr || !SafeAccess::isReadable(sym)) { @@ -672,7 +711,15 @@ MethodInfo *Lookup::resolveMethod(ASGCT_CallFrame &frame) { return unknown_method; } - // Fast path, deliberately unprotected. For anything but a raw-pointer or + // Raw Method* frames take their own path: resolution makes JNI calls (it + // collects the dump's pinned classes on first use and takes the registry + // mutex), so it must not run under fillMethod()'s landing pad, and it + // installs its own around the metadata reads. + if (FrameType::isRawPointer(bci)) { + return fillRawMethod(frame); + } + + // Fast path, deliberately unprotected. For anything but a // BCI_VTABLE_RECEIVER frame, methodKey() reads no VM metadata (see its // comment) and an already-marked row needs no symbolication -- there is // nothing here that can fault, hence nothing to recover from. Worth @@ -680,7 +727,7 @@ MethodInfo *Lookup::resolveMethod(ASGCT_CallFrame &frame) { // arming the protection below is not free: initCurrentThreadSignalSafe() // blocks and unblocks signals and sigsetjmp(..., 1) reads the signal mask, // three syscalls on a loop that runs once per frame per trace. - if (!FrameType::isRawPointer(bci) && bci != BCI_VTABLE_RECEIVER) { + if (bci != BCI_VTABLE_RECEIVER) { MethodMap::iterator it = _method_map->find(methodKey(frame, method_id, bci, 0)); if (it != _method_map->end() && it->second._mark) { return &it->second; @@ -702,18 +749,40 @@ MethodInfo *Lookup::resolveMethod(ASGCT_CallFrame &frame) { return fillMethod(frame, method_id, bci, prof_thread); } -MethodInfo *Lookup::fillMethod(ASGCT_CallFrame &frame, jmethodID method_id, - jint bci, ProfiledThread* const prof_thread) { - - assert(prof_thread != nullptr); - // Resolve native method - if (FrameType::isRawPointer(bci)) { - method_id = JVMSupport::resolve(frame.method); - if (method_id == nullptr) { +MethodInfo *Lookup::fillRawMethod(const ASGCT_CallFrame &frame) { + RawMethodKey key{FrameType::untagPointer(frame.method), FrameType::rawTag(frame.bci, frame.method)}; + auto cached = _raw_method_cache.find(key); + if (cached != _raw_method_cache.end()) { + Counters::increment(RAW_FRAME_RESOLVE_CACHE_HITS); + if (cached->second == nullptr) { return unknownMethod(); } + return cached->second; } + ResolvedMethod resolved; + if (!JVMSupport::resolve(frame.method, frame.bci, resolved)) { + _raw_method_cache.emplace(key, nullptr); + return unknownMethod(); + } + // Refilled even if the row was filled in an earlier chunk: that only proves + // the frame's (Method*, tag) named this method then, while resolve() just + // checked it again. The fill reads only `resolved`, never VM metadata, so + // unlike fillMethod() it needs no landing pad. + MethodInfo *mi = &(*_method_map)[MethodMap::makeRawKey(key.method, key.tag)]; + fillJavaMethodInfo(mi, resolved); + if (mi->_key == 0) { + mi->_key = _method_map->allocId(); // see fillMethod() on why not size()+1 + } + mi->_mark = true; + _raw_method_cache.emplace(key, mi); + return mi; +} + +MethodInfo *Lookup::fillMethod(ASGCT_CallFrame &frame, jmethodID method_id, + jint bci, ProfiledThread* const prof_thread) { + + assert(prof_thread != nullptr); assert(method_id != nullptr && "Already filtered by caller"); // Reinstates the thread's previous landing pad on every exit from this frame, // including a std::bad_alloc thrown by one of the map or dictionary inserts @@ -959,6 +1028,14 @@ off_t Recording::finishChunk(bool end_recording, bool do_cleanup) { // call. Classes already unloaded before finishChunk() was entered are not // present in the list and receive no protection here. jvmtiError err = jvmti->GetLoadedClasses(&count, &classes); + // fjmethodid=lazy resolves raw frames only of these classes: they stay + // pinned until the local references are deleted below. Always called, so a + // failed GetLoadedClasses() leaves no list at all rather than a stale one, + // and ended on every exit from this function, including a std::bad_alloc. + JVMSupport::beginDump(env, err ? nullptr : classes, err ? 0 : count); + struct DumpScope { + ~DumpScope() { JVMSupport::endDump(); } + } dump_scope; flush(&_cpu_monitor_buf); @@ -1077,6 +1154,7 @@ off_t Recording::finishChunk(bool end_recording, bool do_cleanup) { // values rather than pre-. capturePostFlushNativeMem(); + JVMSupport::endDump(); if (!err) { // delete all local references for (int i = 0; i < count; i++) { @@ -1701,6 +1779,8 @@ int Recording::writeCpool(Buffer *buf, int *count_offset_in_cpool) { // classes are inserted into the new-active by fillJavaMethodInfo during // writeStackTraces/writeMethods, and those would not appear in the snapshot — // standby() captures the pre-rotation state which writeClasses extends. + // One Lookup per dump: its raw frame cache holds this dump's answers (see + // Lookup::_raw_method_cache), which the next dump's pinned classes can change. Lookup lookup(this, &_method_map, Profiler::instance()->classMap()); // CONSTANT pools: always non-empty, always emitted -> 5 sections. // writeThreads always emits: it inserts _tid unconditionally before checking. diff --git a/ddprof-lib/src/main/cpp/flightRecorder.h b/ddprof-lib/src/main/cpp/flightRecorder.h index f660e85c3b..5c827a2ced 100644 --- a/ddprof-lib/src/main/cpp/flightRecorder.h +++ b/ddprof-lib/src/main/cpp/flightRecorder.h @@ -104,15 +104,24 @@ struct CpuTimes { class SharedLineNumberTable { public: int _size; - // The buffer jvmti->GetLineNumberTable() returned, held directly (not a - // copy) and freed via jvmti->Deallocate() (see ~SharedLineNumberTable()) - // so native-memory-tracking accounting stays correct. Per the JVMTI spec - // this array is a fresh, caller-owned allocation decoupled from the - // Method's lifetime, so holding onto it is safe across class unload. + // Either the buffer jvmti->GetLineNumberTable() returned, held directly (not + // a copy) and freed via jvmti->Deallocate() (see ~SharedLineNumberTable()) + // so native-memory-tracking accounting stays correct, or -- when + // _jvmti_allocated is false -- a malloc'd table decoded from VM metadata by + // HotspotSupport::resolve(), freed via free(). Either way the array is + // decoupled from the Method's lifetime, so holding onto it is safe across + // class unload. jvmtiLineNumberEntry* _table; + bool _jvmti_allocated; - SharedLineNumberTable(int size, jvmtiLineNumberEntry *ptr) : _size(size), _table(ptr) {} + SharedLineNumberTable(int size, jvmtiLineNumberEntry *ptr, bool jvmti_allocated = true) : + _size(size), _table(ptr), _jvmti_allocated(jvmti_allocated) {} ~SharedLineNumberTable(); + // The destructor frees _table, so a copy would free it twice. + SharedLineNumberTable(const SharedLineNumberTable&) = delete; + SharedLineNumberTable& operator=(const SharedLineNumberTable&) = delete; + SharedLineNumberTable(SharedLineNumberTable&&) = delete; + SharedLineNumberTable& operator=(SharedLineNumberTable&&) = delete; }; class MethodInfo { @@ -137,14 +146,15 @@ class MethodInfo { inline bool isHidden(); }; -// MethodMap's key can be derived from 3 sources: +// MethodMap's key can be derived from 4 sources: // 1) jmethodID for Java methods -// 2) void* address for native method names -// 3) Encoded RemoteFrameInfo +// 2) raw Method* and its frame tag, for HotSpot frames without a jmethodID +// 3) void* address for native method names +// 4) Encoded RemoteFrameInfo // The values of the keys are potentially overlapping, so we use // the highest 2 bits to distinguish them. // Key encoding (top two bits): -// 00 - jmethodID +// 00 - jmethodID, or with bit 61 set a raw Method* (see makeRawKey()) // 10 - void* address (native frame names) // 01 - RemoteFrameInfo (packed remote symbolication) // 11 - vtable_receiver class_id (BCI_VTABLE_RECEIVER frames) @@ -157,6 +167,9 @@ class MethodMap static constexpr unsigned long REMOTE_FRAME_MARK = 0x4000000000000000ULL; static constexpr unsigned long VTABLE_RECEIVER_MARK = ADDRESS_MARK | REMOTE_FRAME_MARK; static constexpr unsigned long KEY_TYPE_MASK = ADDRESS_MARK | REMOTE_FRAME_MARK; + static constexpr unsigned long RAW_METHOD_MARK = 0x2000000000000000ULL; + static constexpr int RAW_METHOD_TAG_SHIFT = 48; + static constexpr uint32_t RAW_METHOD_TAG_MASK = 0x1fff; MethodMap() {} @@ -177,6 +190,19 @@ class MethodMap assert((key & KEY_TYPE_MASK) == 0); return (key | REMOTE_FRAME_MARK);} + // A raw frame's row: its untagged Method* (below 2^48, which + // HotspotSupport::fillJavaFrame() checks when it stores the frame), the low 13 bits of its tag, and + // RAW_METHOD_MARK, which keeps it apart from every jmethodID. The truncated + // tag only has to separate loads of a class at the same address that are + // both accepted in one dump; Lookup::fillRawMethod() keeps refused frames + // away from the rows, and refills every row at its first use in a chunk. + static unsigned long makeRawKey(const void* untagged_method, uint32_t tag) { + unsigned long key = (unsigned long)untagged_method; + assert((key >> RAW_METHOD_TAG_SHIFT) == 0); + return key | RAW_METHOD_MARK | + ((unsigned long)(tag & RAW_METHOD_TAG_MASK) << RAW_METHOD_TAG_SHIFT); + } + // BCI_VTABLE_RECEIVER frames key by the resolved class_id (not by the // VMSymbol* captured at sample time), so two distinct Symbol addresses // for the same class name collapse to a single MethodInfo row. @@ -451,6 +477,7 @@ class Recording { }; class ResolveMethodState; +class ResolvedMethod; class Lookup { public: Recording *_rec; @@ -465,6 +492,33 @@ class Lookup { std::unordered_map, std::equal_to, CountingAllocator, NM_JFR_BUFFERS>> _vtable_receiver_cache; + + // Per-dump cache of raw Method* frame resolutions (HotSpot cstack=vm frames + // whose class had no jmethodID when sampled): the filled row, or nullptr if + // the frame was refused. The same raw method recurs in many traces, and + // each resolution sets up crash protection, checks the class against the + // dump's pinned classes and its redefinition mark, and reads names and line + // numbers. Keyed by the + // untagged Method* and the frame's full tag, so occurrences that differ + // only in bci or frame type share one entry. A cached result stays right + // for the whole dump: an accepted frame was sampled from that method, and a + // refused one stays refused. Recording::writeCpool() constructs a Lookup + // for each dump, so the cache never outlives one. MethodMap nodes are not + // erased while a Lookup exists, so the row pointers stay valid. + struct RawMethodKey { + const void* method; + uint32_t tag; + 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; @@ -493,6 +547,14 @@ class Lookup { // case. Every other path (including the "" stale-jmethodID // sentinel) fully populates mi and returns true. bool fillJavaMethodInfo(MethodInfo *mi, jmethodID method, bool first_time, ResolveMethodState& state); + // Fills mi from a raw Method* already resolved by HotspotSupport::resolve(), + // taking ownership of its line number table. No JNI/JVMTI. + void fillJavaMethodInfo(MethodInfo *mi, ResolvedMethod& resolved); + // Sets mi's class/name/signature ids, applying the synthetic-accessor and + // LambdaForm class name normalisation. class_name is an internal name + // ("java/lang/Object"), not necessarily NUL-terminated at class_name_len. + void fillJavaMethodNames(MethodInfo *mi, const char *class_name, size_t class_name_len, + const char *method_name, const char *method_sig); bool has_prefix(const char *str, const char *prefix) const { return strncmp(str, prefix, strlen(prefix)) == 0; } @@ -537,6 +599,9 @@ class Lookup { // sigsetjmp/siglongjmp window. MethodInfo *fillMethod(ASGCT_CallFrame &frame, jmethodID method_id, jint bci, ProfiledThread* const prof_thread); + // The row for a raw Method* frame, resolved and filled at most once per + // distinct method and tag per dump, or the unknown row if it is refused. + MethodInfo *fillRawMethod(const ASGCT_CallFrame &frame); // Materializes _unknown_method for this dump (filling it on first use) and // returns it. diff --git a/ddprof-lib/src/main/cpp/frame.h b/ddprof-lib/src/main/cpp/frame.h index 9c5e37cb21..eeeeb88bd8 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,38 @@ 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; + } + + // Every caller has checked canTagPointer(ptr) first: HotspotSupport:: + // fillJavaFrame() refuses raw frames whose Method* fails it. + static inline const void* tagPointer(const void* ptr, uint32_t tag) { + assert(canTagPointer(ptr)); + uintptr_t high = (uintptr_t)((tag & RAW_TAG_MASK) >> RAW_TAG_BCI_BITS); + return (const void*)((uintptr_t)ptr | (high << POINTER_TAG_SHIFT)); + } + + static inline const void* untagPointer(const void* ptr) { + return (const void*)((uintptr_t)ptr & POINTER_ADDRESS_MASK); + } + + // Reassembles the tag of a raw Method* frame from its encoded BCI and method field. + static inline uint32_t rawTag(int bci, const void* ptr) { + uint32_t low = ((uint32_t)bci >> RAW_TAG_LOW_SHIFT) & 0xf; + uint32_t mid = ((uint32_t)bci >> RAW_TAG_MID_SHIFT) & 0x3f; + uint32_t high = (uint32_t)((uintptr_t)ptr >> POINTER_TAG_SHIFT); + return (low | (mid << 4) | (high << RAW_TAG_BCI_BITS)) & RAW_TAG_MASK; + } + // Extracts the BCI from either an encoded value or a raw ASGCT BCI. // Negative values (HotSpot -1 sentinels) are clamped to 0, matching encode(). static inline int bci(int bci) { diff --git a/ddprof-lib/src/main/cpp/hotspot/hotspotStackFrame.h b/ddprof-lib/src/main/cpp/hotspot/hotspotStackFrame.h index d33aedfb1f..cfce48bb57 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotStackFrame.h +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotStackFrame.h @@ -32,7 +32,7 @@ class HotspotStackFrame : public StackFrame { // anchor. Per the setjmp/longjmp rules (C11 7.13.2.1p3, inherited // by C++), a non-volatile automatic local modified in that window // has an indeterminate value after longjmp -- the same hazard - // ResolvedNames::_long_method_name (hotspotSupport.cpp) guards + // ResolvedMethod::_long_method_name (hotspotSupport.h) guards // against for an analogous fault-recovery readback. VMJavaFrameAnchor* volatile _anchor; const void* volatile _anchor_pc; diff --git a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp index a811682936..52bf894354 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.cpp @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +#include #include #include #include "asyncSampleMutex.h" @@ -12,6 +13,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 +141,7 @@ inline EventType eventTypeFromBCI(jint bci_type) { static bool hasTheSameMethod(ASGCT_CallFrame& frame, jmethodID method_id, const VMMethod* method) { if (FrameType::isRawPointer(frame.bci)) { - return frame.method == method; + return HotspotSupport::rawFrameMethod(frame.bci, frame.method) == method; } else { return frame.method_id == method_id; } @@ -186,13 +188,22 @@ static void fillFrameTypes(ASGCT_CallFrame *frames, int num_frames, VMNMethod *n } } -// Fill the frame with raw method pointer +// Fill the frame with raw method pointer, as partial mode stores it. static void fillFrameRaw(ASGCT_CallFrame& frame, FrameTypeId type, int bci, const VMMethod* method) { NO_INJECTION_ASSERT(method != nullptr); frame.bci = FrameType::encode(type, bci, true /*raw method pointer*/); frame.method = static_cast(method); } +// Fill the frame with raw method pointer and its lazy-mode tag (see frame.h +// for where the tag bits go, HotspotSupport::TRACE_ID_TAG for what they hold). +static void fillFrameRawTagged(ASGCT_CallFrame& frame, FrameTypeId type, int bci, const VMMethod* method, + uint32_t tag) { + NO_INJECTION_ASSERT(method != nullptr); + frame.bci = FrameType::encodeRaw(type, bci, tag); + frame.method = FrameType::tagPointer(method, tag); +} + void HotspotSupport::fillJavaFrame(ASGCT_CallFrame& frame, FrameTypeId type, int bci, jmethodID method_id, const VMMethod* method) { if (method_id == JMETHODID_NOT_WALKABLE) { @@ -211,7 +222,25 @@ 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); + // Every raw frame is resolved by its untagged Method* (and keyed by + // it in Lookup::makeRawKey), both of which assume the address bits + // above 2^48 are clear; this check is the one place that ensures it. + uint32_t tag; + if (!FrameType::canTagPointer(method)) { + Counters::increment(WALKVM_RAW_FRAME_UNTAGGABLE); + fillFrame(frame, type, bci, JMETHODID_NOT_WALKABLE); + } else if (JVMSupport::getLoadState() != JVMSupport::Lazy_loaded) { + fillFrameRaw(frame, type, bci, method); + } else if (!lazyFrameTag(method, &tag)) { + // fjmethodid=lazy, but the holder's trace ID is unreadable. + // Without a tag the frame could be mistaken for one of a later + // class at the same address, so keep it structurally but resolve + // it as unknown. + 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,99 +1482,266 @@ bool HotspotSupport::loadMethodIDsIfNeededImpl(jvmtiEnv *jvmti, JNIEnv *jni, jcl return JVMSupport::loadMethodIDsImpl(jvmti, jni, klass); } -// The three names resolve() needs, owned by resolve()'s frame. Each name has -// a fixed-size inline buffer for the common case, with a malloc'd fallback -// (up to MAX_SYMBOL_LEN) for names that don't fit -- see release() for why -// that fallback needs explicit cleanup on the crash-recovery path. -class ResolvedNames { - // Hard ceiling for the malloc fallback in setImpl(), independent of which - // field is being resolved. Not a JVM/class-file limit (Symbol::length() is - // a u2, so up to 65535 is legal) -- a name that would already have been rejected - // as too long for those fields' fixed buffers doesn't get an unbounded - // allocation just because it went through the malloc path instead. - static constexpr size_t MAX_SYMBOL_LEN = 64 * 1024; - - // Fixed-size fast-path buffers for the common case, sized generously above - // the realistic common-case length for each field (see the per-field - // comments below) but deliberately not so generous that the malloc - // fallback in setImpl() never engages -- these caps are meant to be hit - // occasionally so that path stays exercised. - // HotSpot's Symbol length field is a u2, so the VM permits up to 65535 bytes. - // A name/descriptor that overflows its fixed buffer falls back to malloc - // (see ResolvedNames::setImpl), up to MAX_SYMBOL_LEN below; beyond that it is - // rejected and the frame serializes as "unknown" -- METHOD_RESOLVE_SYMBOL_UNREADABLE - // makes that visible if it bites. Unlike the old stack-only design, the malloc - // fallback means this is no longer a leak-proof region: siglongjmp out of - // resolve() bypasses ~ResolvedNames(), so the fault-recovery path must call - // ResolvedNames::release() explicitly (see resolve()) to free any buffer - // allocated before the fault. - static constexpr size_t MAX_KLASS_NAME_LEN = 1024; // internal names; realistically < 256 - static constexpr size_t MAX_METHOD_NAME_LEN = 512; // realistically < 64 - // A descriptor can in principle exceed this (255 argument *slots*, each able to - // carry an arbitrarily long L...; type name), so this cap is a fidelity choice, - // not a proof: over-cap descriptors serialize as "unknown" rather than being - // truncated. METHOD_RESOLVE_SYMBOL_UNREADABLE makes it visible if that bites. - static constexpr size_t MAX_SIGNATURE_LEN = 1024; - - private: - // volatile: resolve() mutates these (via setMethodName/setMethodSignature/ - // setKlassName, called through readMethodNames()) between sigsetjmp() and a - // possible siglongjmp() out of a fault, then release() reads them back at - // the landing pad to decide what to free. Per the setjmp/longjmp rules - // (C11 7.13.2.1p3, inherited by C++), a non-volatile automatic local - // modified in that window has an indeterminate value after longjmp; - // volatile is what makes release()'s reads on the recovery path defined. - char* volatile _long_method_name; - char* volatile _long_method_signature; - char* volatile _long_klass_name; - - char _method_name[MAX_METHOD_NAME_LEN]; - char _method_signature[MAX_SIGNATURE_LEN]; - char _klass_name[MAX_KLASS_NAME_LEN]; - - bool setImpl(char* short_name, char* volatile& long_name, size_t short_limit, VMSymbol* sym); -public: - // Non-copyable - ResolvedNames(const ResolvedNames&) = delete; - ResolvedNames& operator=(const ResolvedNames&) = delete; - - ResolvedNames(); - ~ResolvedNames(); - void release(); - - bool setMethodName(VMSymbol* sym); - bool setMethodSignature(VMSymbol* sym); - bool setKlassName(VMSymbol* sym); - - const char* methodName() const { - return _long_method_name != nullptr ? _long_method_name : _method_name; - } +KlassRegistry HotspotSupport::_klass_registry; +bool HotspotSupport::_lazy_used = false; +int HotspotSupport::_untracked_redefinition_calls = 0; +bool HotspotSupport::_redefinitions_untracked = false; +const jclass* HotspotSupport::_dump_jclasses = nullptr; +int HotspotSupport::_dump_jclass_count = 0; +const void** HotspotSupport::_dump_classes = nullptr; +size_t HotspotSupport::_dump_classes_capacity = 0; + +bool HotspotSupport::canResolveLazily() { + return VMStructs::hasKlassMethods(); +} - const char* methodSignature() const { - return _long_method_signature != nullptr ? _long_method_signature : _method_signature; - } +bool HotspotSupport::initLazyResolution(JNIEnv* jni) { + bool calibrated = KlassTraceId::calibrate(jni); + // Counters are reset on every profiler start, and a calibration found + // once is kept, so republish its result here. + Counters::set(KLASS_TRACE_ID_OFFSET, calibrated ? KlassTraceId::offset() : -1); + _klass_registry.publishCounters(); + TEST_LOG("Raw frame tags: %s", calibrated ? "klass trace id" : "unavailable, lazy mode off"); + if (!calibrated) { + Log::warn("fjmethodid=lazy: could not locate the JFR class trace ID (a JVM without JFR?); " + "preloading jmethodIDs as with fjmethodid=false"); + return false; + } + // From here on every redefinition call that starts is tracked; the calls + // already running are not, so no lazy frame may be sampled before they + // end. + __atomic_store_n(&_lazy_used, true, __ATOMIC_SEQ_CST); + if (!awaitUntrackedRedefinitions(MAX_UNTRACKED_REDEFINITION_WAIT_NS)) { + Log::warn("fjmethodid=lazy: class redefinitions that started before lazy mode are still running; " + "preloading jmethodIDs as with fjmethodid=false for this session"); + return false; + } + return true; +} - const char* klassName() const { - return _long_klass_name != nullptr ? _long_klass_name : _klass_name; - } +bool HotspotSupport::enterRedefinitionCall() { + // Pairs with initLazyResolution(), both sequentially consistent: either + // this call sees _lazy_used and its classes are marked, or + // initLazyResolution() sees the count and waits for the call to end. + __atomic_add_fetch(&_untracked_redefinition_calls, 1, __ATOMIC_SEQ_CST); + if (__atomic_load_n(&_lazy_used, __ATOMIC_SEQ_CST)) { + __atomic_sub_fetch(&_untracked_redefinition_calls, 1, __ATOMIC_SEQ_CST); + return false; + } + return true; +} + +void HotspotSupport::exitRedefinitionCall(bool untracked) { + if (untracked) { + __atomic_sub_fetch(&_untracked_redefinition_calls, 1, __ATOMIC_SEQ_CST); + } +} + +bool HotspotSupport::awaitUntrackedRedefinitions(u64 max_wait_ns) { + u64 deadline = OS::nanotime() + max_wait_ns; + while (__atomic_load_n(&_untracked_redefinition_calls, __ATOMIC_SEQ_CST) != 0) { + if (OS::nanotime() >= deadline) { + return false; + } + OS::sleep(1000000); // 1 ms + } + return true; +} + +bool HotspotSupport::lazyFrameTag(const VMMethod* method, uint32_t* tag) { + 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; +} + +// JVMTI allows RedefineClasses/RetransformClasses with a Java exception +// pending, but JNI forbids most calls then. The redefinition hooks set such an +// exception aside while they work, and throw it again before the original +// function runs or returns to its caller. +class PendingExceptionScope { + JNIEnv* _jni; + jthrowable _pending; + +public: + explicit PendingExceptionScope(JNIEnv* jni) : _jni(jni), _pending(jni->ExceptionOccurred()) { + if (_pending != nullptr) { + _jni->ExceptionClear(); + } + } + ~PendingExceptionScope() { + if (_pending != nullptr) { + _jni->ExceptionClear(); + _jni->Throw(_pending); + _jni->DeleteLocalRef(_pending); + } + } + PendingExceptionScope(const PendingExceptionScope&) = delete; + PendingExceptionScope& operator=(const PendingExceptionScope&) = delete; }; -ResolvedNames::ResolvedNames() : - _long_method_name(nullptr), - _long_method_signature(nullptr), - _long_klass_name(nullptr) { +void HotspotSupport::beforeClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass) { + // Before lazy mode there is nothing to mark for; a call that is still + // running when lazy mode starts holds up that start instead + // (enterRedefinitionCall()). + if (!__atomic_load_n(&_lazy_used, __ATOMIC_ACQUIRE) || !isInstanceClass(jvmti, klass)) { + return; + } + PendingExceptionScope pending(jni); + // Only redefined classes are registered, so the mark has a place to live. + // An entry stays after a call JVMTI rejects (without a lasting mark), and + // is purged when the table next grows after its class is unloaded. + const void* vm_klass = VMKlass::fromJavaClass(jni, klass); + if (vm_klass != nullptr && _klass_registry.add(jni, vm_klass, klass) && + _klass_registry.beginRedefinition(vm_klass)) { + Counters::increment(KLASS_REGISTRY_REDEFINITIONS_MARKED); + } else { + // Nothing can tell this class's frames apart any more, so refuse + // every lazy frame from now on. Deliberately never reset: raw frames + // sampled before the redefinition can outlive any session boundary. + Counters::increment(KLASS_REGISTRY_ADD_FAILED); + // Sequentially consistent, pairing with the fence in resolveLazily(): + // a resolution that misses this store read everything before it. + __atomic_store_n(&_redefinitions_untracked, true, __ATOMIC_SEQ_CST); + } +} + +void HotspotSupport::afterClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass, bool succeeded) { + if (!__atomic_load_n(&_lazy_used, __ATOMIC_ACQUIRE) || !isInstanceClass(jvmti, klass)) { + return; + } + PendingExceptionScope pending(jni); + // The caller holds `klass` throughout the call, so the class cannot have + // been unloaded, and its Klass* still names the entry begin marked. + _klass_registry.endRedefinition(VMKlass::fromJavaClass(jni, klass), succeeded); + // The class's raw frames are refused from now on, so give its later + // frames jmethodIDs. After a failed call nothing changed: its frames + // stay raw and resolvable. + if (succeeded && JVMSupport::getLoadState() == JVMSupport::Lazy_loaded) { + loadMethodIDsIfNeededImpl(jvmti, jni, klass, true /* load all */); + } } -ResolvedNames::~ResolvedNames() { - release(); +bool HotspotSupport::registerClass(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass) { + // Nothing to do at ClassPrepare: each dump pins the classes it resolves + // frames of (beginDump()). + return true; } -void ResolvedNames::release() { +bool HotspotSupport::isDumpClass(const void* klass) { + if (_dump_classes == nullptr) { + buildDumpClasses(); + } + if (_dump_classes == nullptr || klass == nullptr) { + return false; + } + size_t mask = _dump_classes_capacity - 1; + for (size_t i = klassSlot(klass, _dump_classes_capacity); ; i = (i + 1) & mask) { + if (_dump_classes[i] == klass) { + return true; + } + if (_dump_classes[i] == nullptr) { + return false; + } + } +} + +void HotspotSupport::beginDump(JNIEnv* jni, const jclass* classes, int count) { + endDump(); + if (!__atomic_load_n(&_lazy_used, __ATOMIC_ACQUIRE) || classes == nullptr || count <= 0) { + return; + } + _dump_jclasses = classes; + _dump_jclass_count = count; +} + +void HotspotSupport::buildDumpClasses() { + if (_dump_jclasses == nullptr) { + return; // not in a dump + } + u64 start = OS::nanotime(); + const int count = _dump_jclass_count; + size_t capacity = 1024; + while (capacity < (size_t)count * 2) { + capacity <<= 1; + } + // mmap rather than malloc: at hundreds of thousands of classes this is + // megabytes, allocated and freed every dump, which would fragment the + // malloc arenas. Zero-filled. + const void** table = (const void**)OS::safeAlloc(capacity * sizeof(const void*)); + if (table == nullptr) { + _dump_jclasses = nullptr; // every lazy frame of this dump resolves as unknown + return; + } + JNIEnv* jni = VM::jni(); + size_t mask = capacity - 1; + long long beyond_tag_range = 0; + for (int c = 0; c < count; c++) { + const void* klass = VMKlass::fromJavaClass(jni, _dump_jclasses[c]); + if (klass == nullptr) { + continue; + } + uint64_t trace_id; + if (KlassTraceId::read(klass, &trace_id) && trace_id > TAG_VALUE_MASK) { + beyond_tag_range++; + } + for (size_t i = klassSlot(klass, capacity); ; i = (i + 1) & mask) { + if (table[i] == klass) { + break; + } + if (table[i] == nullptr) { + table[i] = klass; + break; + } + } + } + // Two classes' 25-bit trace-ID tags can only collide once their IDs are at + // least 2^25 apart, so while no loaded class has an ID this large no stale + // frame can be matched to a newer class by its tag. + Counters::set(KLASS_TRACE_ID_BEYOND_TAG_RANGE, beyond_tag_range); + Counters::set(LAZY_DUMP_CLASSES, count); + Counters::set(LAZY_DUMP_PIN_NS, (long long)(OS::nanotime() - start)); + _dump_classes_capacity = capacity; + _dump_classes = table; +} + +void HotspotSupport::endDump() { + if (_dump_classes != nullptr) { + OS::safeFree(_dump_classes, _dump_classes_capacity * sizeof(const void*)); + } + _dump_classes = nullptr; + _dump_classes_capacity = 0; + _dump_jclasses = nullptr; + _dump_jclass_count = 0; +} + +ResolvedMethod::ResolvedMethod() : + _long_method_name(nullptr), + _long_method_signature(nullptr), + _long_klass_name(nullptr), + _line_number_table(nullptr), + _line_number_table_size(0), + _is_thread_run(false) { + _method_name[0] = '\0'; + _method_signature[0] = '\0'; + _klass_name[0] = '\0'; +} + +void ResolvedMethod::release() { // Must be idempotent: resolve()'s fault-recovery path calls this explicitly - // (siglongjmp bypasses ~ResolvedNames()), and then the destructor runs it - // again on the same object when resolve() returns normally afterward. - // Nulling out each pointer after freeing makes the second call a no-op - // instead of a double free. + // (siglongjmp bypasses ~ResolvedMethod()), and the destructor runs it again + // on the same object later. Nulling out each pointer after freeing makes + // the second call a no-op instead of a double free. if (_long_method_name != nullptr) { free(_long_method_name); _long_method_name = nullptr; @@ -1558,9 +1754,15 @@ void ResolvedNames::release() { free(_long_klass_name); _long_klass_name = nullptr; } + if (_line_number_table != nullptr) { + free(_line_number_table); + _line_number_table = nullptr; + } + _line_number_table_size = 0; + _is_thread_run = false; } -bool ResolvedNames::setImpl(char* short_name, char* volatile& long_name, size_t short_limit, VMSymbol* sym) { +bool ResolvedMethod::setName(char* short_name, char* volatile& long_name, size_t short_limit, VMSymbol* sym) { unsigned len = sym->length(); // raw u2 deref; PC stays inside this library // A method name, descriptor or class name is never empty; 0 means the Symbol // slot has been recycled. `>=` leaves room for the NUL. @@ -1585,50 +1787,146 @@ bool ResolvedNames::setImpl(char* short_name, char* volatile& long_name, size_t } } -bool ResolvedNames::setMethodName(VMSymbol* sym) { - return setImpl(_method_name, _long_method_name, MAX_METHOD_NAME_LEN, sym); - +bool ResolvedMethod::setMethodName(VMSymbol* sym) { + return setName(_method_name, _long_method_name, MAX_METHOD_NAME_LEN, sym); } -bool ResolvedNames::setMethodSignature(VMSymbol* sym) { - return setImpl(_method_signature, _long_method_signature, MAX_SIGNATURE_LEN, sym); + +bool ResolvedMethod::setMethodSignature(VMSymbol* sym) { + return setName(_method_signature, _long_method_signature, MAX_SIGNATURE_LEN, sym); } -bool ResolvedNames::setKlassName(VMSymbol* sym) { - return setImpl(_klass_name, _long_klass_name, MAX_KLASS_NAME_LEN, sym); +bool ResolvedMethod::setKlassName(VMSymbol* sym) { + return setName(_klass_name, _long_klass_name, MAX_KLASS_NAME_LEN, sym); } +void ResolvedMethod::useHiddenClassSignatureName() { + char* name = _long_klass_name != nullptr ? _long_klass_name : _klass_name; + char* plus = strrchr(name, '+'); + if (plus == nullptr || plus[1] != '0' || plus[2] != 'x' || plus[3] == '\0') { + return; + } + for (const char* c = plus + 3; *c != '\0'; c++) { + if (!isxdigit((unsigned char)*c)) { + return; + } + } + *plus = '.'; +} -// PHASE 1 -- the raw HotSpot metadata walk. MUST run with a jmp ctx installed: -// every step is a raw *(void**)(this + offset) whose target may have been freed -// by GC or class unloading since the sample was taken. Deliberately contains no -// JNI and no JVMTI, so the whole protected region stays inside this library, -// where Profiler::checkFault() can actually recover. It can allocate, via -// ResolvedNames::setImpl()'s malloc fallback for over-sized names -- that -// allocation is self-contained (glibc malloc doesn't call back into JNI/JVMTI), -// but it does mean a fault after the allocation needs explicit freeing, since -// siglongjmp out of this scope bypasses ~ResolvedNames() (see resolve()). -// -// Returns false if the metadata is unusable. On success either *out_id holds an -// already-valid jmethodID (and `names` is untouched), or *out_id is null and -// `names` has been filled in for the JNI lookup the caller does afterwards. -static bool readMethodNames(const void* method, VMMethod** out_vm_method, - jmethodID* out_id, ResolvedNames* names) { - *out_vm_method = nullptr; - *out_id = nullptr; +// Compares a Symbol's contents with `expected` without touching the Symbol +// memory through libc (a fault inside memcmp() would have a pc outside this +// library and could not be recovered). +static bool symbolEquals(VMSymbol* sym, const char* expected, size_t expected_len) { + static constexpr size_t MAX_COMPARE_LEN = 64; + assert(expected_len <= MAX_COMPARE_LEN); + if (sym == nullptr || sym->length() != expected_len) { + return false; + } + char buf[MAX_COMPARE_LEN]; + return SafeAccess::safeCopy(buf, sym->body(), expected_len) && + memcmp(buf, expected, expected_len) == 0; +} - VMMethod* vm_method = VMMethod::cast_or_null(method); - if (vm_method == nullptr) { +// Whether `klass` is java.lang.Thread or one of its subclasses, by walking +// the Klass::_super chain. Unknown (no _super offset) is reported as false. +// Whether `klass` may be a hidden class, so that its name needs the +// treatment useHiddenClassSignatureName() gives it. Hidden classes came with +// JDK 15. Up to JDK 23 they carry JVM_ACC_IS_HIDDEN_CLASS in +// Klass::_access_flags (utilities/accessFlags.hpp); JDK 24 moved the flag to +// Klass::_misc_flags, which vmStructs does not export, so from then on only +// the name tells. +static bool mayBeHiddenClass(VMKlass* klass) { + static const uint32_t JVM_ACC_IS_HIDDEN_CLASS = 0x04000000; + int version = VM::hotspot_version(); + if (version < 15) { return false; } - *out_vm_method = vm_method; + uint32_t flags; + if (version <= 23 && klass->accessFlags(&flags)) { + return (flags & JVM_ACC_IS_HIDDEN_CLASS) != 0; + } + return true; +} - // May have been populated by following code or JMETHODID_NOT_WALKABLE - jmethodID method_id = vm_method->validatedId(); - if (isValidJMethodID(method_id)) { - *out_id = method_id; +static bool isThreadSubclass(VMKlass* klass) { + static const char THREAD_CLASS[] = "java/lang/Thread"; + // Real hierarchies are far shallower; the cap only guards against a cycle + // in stale metadata. + static constexpr int MAX_DEPTH = 64; + for (int depth = 0; klass != nullptr && depth < MAX_DEPTH; depth++) { + if (symbolEquals(klass->name(), THREAD_CLASS, sizeof(THREAD_CLASS) - 1)) { + return true; + } + klass = klass->super_or_null(); + } + return false; +} + +// Decodes the method's compressed line number table into a malloc'd +// jvmtiLineNumberEntry array, the same shape JVMTI GetLineNumberTable() +// produces. Returns false only if the table is present but implausible (a +// bci past the bytecode or too many entries), i.e. the metadata is stale; a +// method without a table returns true with no table attached. +static bool readLineNumberTable(VMConstMethod* const_method, + jvmtiLineNumberEntry* volatile& table, volatile int& table_size) { + const unsigned char* compressed = const_method->compressedLineNumberTable(); + if (compressed == nullptr) { + return true; + } + int code_size = const_method->codeSize(); + + // Pass 1: validate and count, so the second pass can fill an exactly-sized + // buffer. The bound is the one HotSpot's own ConstMethod::verify_on() + // checks every entry against (bci <= code_size). + int count = 0; + VMLineNumberStream counter(compressed); + while (counter.readPair()) { + if (counter.bci() < 0 || counter.bci() > code_size || + ++count > ResolvedMethod::MAX_LINE_NUMBER_TABLE_ENTRIES) { + return false; + } + } + if (count == 0) { return true; } + jvmtiLineNumberEntry* entries = (jvmtiLineNumberEntry*)malloc((size_t)count * sizeof(jvmtiLineNumberEntry)); + if (entries == nullptr) { + Counters::increment(LINE_NUMBER_TABLE_ALLOC_FAILED); + return true; // keep the names; the frame just has no line numbers + } + // Owned by the ResolvedMethod from here on, so a fault in pass 2 still frees it. + table = entries; + + // Pass 2 checks every entry again and that the stream still ends after + // `count` of them: if it changed between the passes, the metadata is stale. + VMLineNumberStream reader(compressed); + for (int i = 0; i < count; i++) { + if (!reader.readPair() || reader.bci() < 0 || reader.bci() > code_size) { + return false; + } + entries[i].start_location = (jlocation)reader.bci(); + entries[i].line_number = (jint)reader.line(); + } + if (reader.readPair()) { + return false; + } + table_size = count; + return true; +} + +// Every step is a raw *(void**)(this + offset) whose target may have been +// freed by GC, class unloading or redefinition since the sample was taken, so +// this must run under protectedWalk(). It contains no JNI and no JVMTI, so the +// whole protected region stays inside this library, where +// Profiler::checkFault() can recover. Allocations it makes are owned by +// `result` as soon as they are made, so the landing pad can free them. +bool HotspotSupport::readMethod(const void* method, ResolvedMethod* result) { + VMMethod* vm_method = VMMethod::cast_or_null(method); + if (vm_method == nullptr) { + return false; + } + VMConstMethod* const_method = vm_method->constMethod_or_null(); if (const_method == nullptr) { return false; @@ -1652,124 +1950,66 @@ static bool readMethodNames(const void* method, VMMethod** out_vm_method, return false; } - if (!names->setMethodName(name_sym) || !names->setMethodSignature(sig_sym) || !names->setKlassName(klass_sym)) { + if (!result->setMethodName(name_sym) || !result->setMethodSignature(sig_sym) || !result->setKlassName(klass_sym)) { Counters::increment(METHOD_RESOLVE_SYMBOL_UNREADABLE); return false; } - return true; -} - -// PHASE 2 -- the JNI/JVMTI lookup, reading only the buffers phase 1 filled. -// MUST run with crash protection *off*. Two reasons, both about siglongjmp -// unwinding frames it must not: -// - A fault inside libjvm.so is unrecoverable anyway (checkFault() only -// recovers PCs inside this library), so a landing pad buys nothing here. -// - FindClass() loads the class when it is not already loaded, which -// synchronously runs our own JVMTI ClassPrepare callback -> -// patchClassLoaderData(), which holds the JVM's ClassLoaderData mutex. That -// code *is* in this library, so with a pad installed a fault there would -// siglongjmp out of a JVMTI callback with a JVM lock held, an abandoned JNI -// local frame and unbalanced safepoint state -- trading a crash for a -// JVM-wide deadlock. -// vm_method->validatedId() below is safefetch-based, so it is safe unprotected. -// -// A plain, TU-local helper -- like readMethodNames() -- rather than a -// HotspotSupport member or friend: it never touches HotspotSupport's private -// state directly, so nothing about it, including ResolvedNames (a type -// defined entirely in this file with no header of its own), needs to be -// declared in hotspotSupport.h. The fallback needs the private -// HotspotSupport::loadMethodIDsIfNeededImpl(), so resolve() -- which does -// have access, being a member -- passes it in as a plain function pointer -// instead of this function calling it directly. -// -// Returns a jmethodID valid for as long as the declaring class stays loaded -// (the same ownership/lifetime jmethodIDs always have in this codebase -- no -// release call is needed), or nullptr if the method could not be found via -// JNI/JVMTI. -static jmethodID lookupMethodIdViaJni(VMMethod* vm_method, const ResolvedNames& names, - bool (*loadMethodIDsIfNeededImpl)(jvmtiEnv*, JNIEnv*, jclass, bool)) { - jmethodID method_id = nullptr; - const char* method_name = names.methodName(); - const char* method_signature = names.methodSignature(); - const char* klass_name = names.klassName(); + if (mayBeHiddenClass(klass)) { + result->useHiddenClassSignatureName(); + } - JNIEnv *jni = VM::jni(); - jclass clz = jni->FindClass(klass_name); - if (clz == nullptr) { - jni->ExceptionClear(); - } else { - method_id = jni->GetMethodID(clz, method_name, method_signature); - if (method_id == nullptr) { - jni->ExceptionClear(); - method_id = jni->GetStaticMethodID(clz, method_name, method_signature); - if (method_id == nullptr) { - jni->ExceptionClear(); - // JNI GetMethodID/GetStaticMethodID cannot look up because - // the JVM intentionally hides class initializers from JNI callers. - // Fall back to loadMethodIDsIfNeededImpl(), which covers all methods - // including and forces jmethodID slot allocation for them - // (going through this helper, rather than calling GetClassMethods - // directly, ensures the JDK-8062116 patchClassLoaderData() workaround - // is applied here too, same as every other jmethodID-preload path). - // After the call, re-read the ID directly from VM metadata. - if (strcmp(method_name, "") == 0) { - jvmtiEnv* jvmti = VM::jvmti(); - if (jvmti != nullptr) { - if (loadMethodIDsIfNeededImpl(jvmti, jni, clz, true /*load all*/)) { - jmethodID validated = vm_method->validatedId(); - if (isValidJMethodID(validated)) { - method_id = validated; - } - } - } - } - } - } - jni->DeleteLocalRef(clz); + if (strcmp(result->methodName(), "run") == 0 && strcmp(result->methodSignature(), "()V") == 0) { + result->_is_thread_run = isThreadSubclass(klass); } - return method_id; + if (!readLineNumberTable(const_method, result->_line_number_table, result->_line_number_table_size)) { + Counters::increment(LINE_NUMBER_TABLE_UNREADABLE); + return false; + } + return true; } -// This method only resolves methods that are loaded by system class loaders -jmethodID HotspotSupport::resolve(const void* method) { - assert(VM::isHotspot()); - NO_INJECTION_ASSERT(method != nullptr); - // fillJavaFrame stores the sentinel without the raw flag, so this should - // never reach the raw-pointer resolve path. Map it to nullptr so the dump - // thread serializes it as the shared unknown method. - if ((jmethodID)method == JMETHODID_NOT_WALKABLE) { - return nullptr; +// fjmethodid=lazy counterpart of readMethod(), under the same protection. +// Reads only what resolveLazily() needs to decide whether the Method* is +// still live: its holder Klass* and its idnum. Both come out of metadata that +// may have been freed and reused, so they are lookup keys, not facts -- +// resolveLazily() validates them against a pinned class before anything else +// is read from the Method*. +static bool readMethodHolder(const void* method, VMMethod** out_vm_method, + const void** out_holder, int* out_idnum) { + VMMethod* vm_method = VMMethod::cast_or_null(method); + if (vm_method == nullptr) { + return false; } - - // The Method* was captured at sample time; GC or class unloading may have - // freed the metadata since, so every dereference below can fault. Install a - // landing pad and report the method as unresolved instead of taking the JVM - // down mid-dump -- nullptr is already a first-class result for our caller - // (Lookup::resolveMethod serializes it as the shared unknown method). - // - // Runs on the JFR dump thread (Profiler::dump/stop), not in a signal handler. - // acquireCurrent() rather than current() because JNI_OnUnload reaches - // Profiler::stop() without priming TLS. - ProfiledThread* prof_thread = ProfiledThread::acquireCurrent(); - if (prof_thread == nullptr) { - // No landing pad available, so refuse to touch metadata that may be stale - // rather than risk crashing. Reached only on an unprimed shutdown path or - // when the thread-local pool is exhausted. - Counters::increment(SAMPLES_DROPPED_THREAD_LOCAL); - return nullptr; + // The same constMethod -> constants -> pool holder walk the sampler does + // when it tags the frame (lazyFrameTag()). + const void* holder = vm_method->holderOrNull(); + VMConstMethod* const_method = vm_method->constMethod_or_null(); + if (holder == nullptr || const_method == nullptr) { + return false; } + *out_vm_method = vm_method; + *out_holder = holder; + *out_idnum = const_method->idnum(); + return true; +} - ResolvedNames names; - VMMethod* vm_method = nullptr; - jmethodID existing_id = nullptr; - bool walked = false; - +// Runs `walk` -- a metadata walk that may fault -- with a landing pad, and +// returns its result, or false if it faulted. On false, `result` is released, +// including anything `walk` allocated before a fault. +// +// Runs on the dump thread, not in a signal handler. Chained via JmpCtxScope: +// the dump thread can be interrupted by a sampling signal whose walkVM() +// installs its own context, so the previous landing pad must be reinstated on +// every exit path from this frame. No JNI or JVMTI may run inside `walk`: a +// fault in libjvm.so is not recoverable (checkFault() only recovers pcs in this +// library), and a fault in one of our own JVMTI callbacks it triggered would +// siglongjmp out of the callback with JVM locks held. +template +static bool protectedWalk(ProfiledThread* prof_thread, ResolvedMethod& result, Walk walk) { + bool ok = false; { sigjmp_buf crash_protection_ctx; - // Chained via JmpCtxScope: the dump thread can be interrupted by a sampling - // signal whose walkVM() installs its own context, so the previous landing - // pad must be reinstated on every exit path from this frame. JmpCtxScope jmp_scope(prof_thread); // savemask must be 1: the siglongjmp originates inside segvHandler, where // the kernel has SIGSEGV blocked, so without restoring the saved mask the @@ -1781,25 +2021,132 @@ jmethodID HotspotSupport::resolve(const void* method) { // before touching anything that could fault again. SIGNAL_HANDLER_UNWIND_AFTER_LONGJMP(); jmp_scope.restore(); - // siglongjmp bypasses ~ResolvedNames(), so a name that was already - // malloc'd (in setImpl()'s over-sized-name fallback) before the fault - // would otherwise leak. release() is idempotent, so it's safe that the - // destructor also runs it when resolve() returns below. - names.release(); + result.release(); Counters::increment(METHOD_RESOLVE_FAULT_RECOVERED); - return nullptr; + return false; } jmp_scope.install(&crash_protection_ctx); + ok = walk(); + } + if (!ok) { + result.release(); + } + return ok; +} - walked = readMethodNames(method, &vm_method, &existing_id, &names); +// Partial mode reaches here for classes it did not preload (those of the +// boot, platform and application loaders, which are never unloaded) and for +// methods a class's jmethodID cache does not cover after a redefinition; +// lazy mode reaches here for every class without a jmethodID and resolves it +// against the classes the current dump pins. +bool HotspotSupport::resolve(const void* tagged_method, int bci, ResolvedMethod& result) { + assert(VM::isHotspot()); + NO_INJECTION_ASSERT(tagged_method != nullptr); + // fillJavaFrame stores the sentinel without the raw flag, so this should + // never reach the raw-pointer resolve path. Report it as unresolved so the + // dump thread serializes it as the shared unknown method. + if ((jmethodID)tagged_method == JMETHODID_NOT_WALKABLE) { + return false; } - // --- crash protection is off from here on; see lookupMethodIdViaJni() --- + // 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); - if (!walked) { - return nullptr; + // The Method* was captured at sample time; GC or class unloading may have + // freed the metadata since, so every dereference can fault and runs under + // protectedWalk(). acquireCurrent() rather than current() because + // JNI_OnUnload reaches Profiler::stop() without priming TLS. + ProfiledThread* prof_thread = ProfiledThread::acquireCurrent(); + if (prof_thread == nullptr) { + // No landing pad available, so refuse to touch metadata that may be stale + // rather than risk crashing. Reached only on an unprimed shutdown path or + // when the thread-local pool is exhausted. + Counters::increment(SAMPLES_DROPPED_THREAD_LOCAL); + return false; + } + + if (!lazy) { + // A redefinition can free a replaced Method* and reuse its memory, and + // partial mode keeps no redefinition marks to tell, so such a frame may + // resolve to the method now at that address. + return protectedWalk(prof_thread, result, [&] { return readMethod(method, &result); }); + } + VMMethod* vm_method = nullptr; + const void* holder = nullptr; + int idnum = -1; + if (!protectedWalk(prof_thread, result, + [&] { return readMethodHolder(method, &vm_method, &holder, &idnum); })) { + Counters::increment(METHOD_RESOLVE_LAZY_UNREADABLE); + return false; + } + return resolveLazily(prof_thread, vm_method, holder, idnum, tag & TAG_VALUE_MASK, result); +} + +// Runs on the dump thread between beginDump() and endDump(), so every class in +// _dump_classes stays loaded throughout. Nothing here dereferences vm_method +// until its holder is confirmed to be one of those classes and the method one +// of the holder's current methods; only the readMethod() call needs the +// landing pad, for a concurrent redefinition. +bool HotspotSupport::resolveLazily(ProfiledThread* prof_thread, VMMethod* vm_method, + const void* holder, int idnum, uint32_t trace_id_bits, + ResolvedMethod& result) { + if (!isDumpClass(holder)) { + // The class was unloaded after the sample was taken, or `holder` was read + // from a freed Method* and is garbage. + Counters::increment(METHOD_RESOLVE_LAZY_UNLOADED); + return false; + } + if (__atomic_load_n(&_redefinitions_untracked, __ATOMIC_ACQUIRE) || + _klass_registry.isRedefined(VM::jni(), holder)) { + // The Method* may have been freed and reused within the class. + Counters::increment(METHOD_RESOLVE_LAZY_REDEFINED); + return false; + } + + 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); + return false; + } + bool resolved = false; + // A miss means the Method* was freed (and possibly its memory reused), or + // it is an old version replaced by a redefinition. containsMethod() reads + // through SafeAccess: the pinned class stays alive, but a concurrent + // redefinition can free its old methods array and Method*s. + bool not_scanned = false; + if (!VMKlass::cast_raw(holder)->containsMethod(vm_method, idnum, ¬_scanned)) { + Counters::increment(not_scanned ? METHOD_RESOLVE_LAZY_CLASS_TOO_LARGE : METHOD_RESOLVE_LAZY_NOT_CURRENT); + } else { + // While the class is pinned its metadata stays allocated, except what a + // concurrent redefinition frees; protectedWalk() covers that case, and + // the mark check below rejects whatever it read. + resolved = protectedWalk(prof_thread, result, [&] { return readMethod(vm_method, &result); }); + } + // 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. A + // redefinition that could not be marked sets _redefinitions_untracked + // instead, so that is checked again too. The fence keeps the metadata reads + // above from being reordered past that load. + if (resolved) { + __atomic_thread_fence(__ATOMIC_SEQ_CST); + } + if (resolved && (__atomic_load_n(&_redefinitions_untracked, __ATOMIC_SEQ_CST) || + _klass_registry.isRedefined(VM::jni(), holder))) { + result.release(); + Counters::increment(METHOD_RESOLVE_LAZY_REDEFINED); + return false; } - if (existing_id != nullptr) { - return existing_id; + if (resolved) { + Counters::increment(METHOD_RESOLVE_LAZY_RESOLVED); + // For CI only: LazyTraceIdJfrValidationTest compares this with the class + // ID JFR records for the same class. + TEST_LOG("Lazy resolution of %s, trace id %llu", result.klassName(), (unsigned long long)trace_id); } - return lookupMethodIdViaJni(vm_method, names, &HotspotSupport::loadMethodIDsIfNeededImpl); + return resolved; } diff --git a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h index 8588635dfc..624b0b06f4 100644 --- a/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h +++ b/ddprof-lib/src/main/cpp/hotspot/hotspotSupport.h @@ -10,6 +10,7 @@ #include "guards.h" #include "hotspot/hotspotStackFrame.h" #include "hotspot/jitCodeCache.h" +#include "hotspot/klassRegistry.h" #include "frame.h" #include "stackFrame.h" #include "stackWalker.h" @@ -19,6 +20,112 @@ #include class VMMethod; +class VMSymbol; + +// What HotspotSupport::resolve() extracts from a raw Method*: the holder's +// internal class name, the method name and signature, and the decoded line +// number table. Everything is copied out of the VM metadata, so the result +// stays valid after the class has been unloaded. +// +// Each name has a fixed-size inline buffer for the common case, with a +// malloc'd fallback (up to MAX_SYMBOL_LEN) for names that don't fit. The line +// number table is always malloc'd. resolve() can siglongjmp out of a fault +// after any of these allocations, bypassing ~ResolvedMethod(), which is why +// its fault-recovery path calls release() explicitly. +class ResolvedMethod { + // Hard ceiling for the malloc fallback in setName(). Not a JVM/class-file + // limit (Symbol::length() is a u2, so up to 65535 is legal). + static constexpr size_t MAX_SYMBOL_LEN = 64 * 1024; + + // Fast-path buffer sizes, deliberately small enough that the malloc + // fallback in setName() still gets exercised occasionally. A name that does + // not fit even the fallback serializes as "unknown"; + // METHOD_RESOLVE_SYMBOL_UNREADABLE makes that visible. + static constexpr size_t MAX_KLASS_NAME_LEN = 1024; // internal names; realistically < 256 + static constexpr size_t MAX_METHOD_NAME_LEN = 512; // realistically < 64 + static constexpr size_t MAX_SIGNATURE_LEN = 1024; + + // volatile: resolve() mutates these between sigsetjmp() and a possible + // siglongjmp() out of a fault, and release() reads them back at the landing + // pad. Per C11 7.13.2.1p3 a non-volatile automatic modified in that window + // has an indeterminate value after longjmp. + char* volatile _long_method_name; + char* volatile _long_method_signature; + char* volatile _long_klass_name; + jvmtiLineNumberEntry* volatile _line_number_table; + volatile int _line_number_table_size; + volatile bool _is_thread_run; + + char _method_name[MAX_METHOD_NAME_LEN]; + char _method_signature[MAX_SIGNATURE_LEN]; + char _klass_name[MAX_KLASS_NAME_LEN]; + + bool setName(char* short_name, char* volatile& long_name, size_t short_limit, VMSymbol* sym); + + friend class HotspotSupport; + +public: + // Upper bound on decoded line number table entries; mirrors the u2 + // code_length limit of JVMS 4.7.3. + static constexpr int MAX_LINE_NUMBER_TABLE_ENTRIES = 65535; + + ResolvedMethod(const ResolvedMethod&) = delete; + ResolvedMethod& operator=(const ResolvedMethod&) = delete; + ResolvedMethod(ResolvedMethod&&) = delete; + ResolvedMethod& operator=(ResolvedMethod&&) = delete; + + ResolvedMethod(); + ~ResolvedMethod() { release(); } + + // Frees everything still owned. Idempotent. + void release(); + + bool setMethodName(VMSymbol* sym); + bool setMethodSignature(VMSymbol* sym); + bool setKlassName(VMSymbol* sym); + + // Gives a hidden class the name JVMTI GetClassSignature() reports for it, + // so frames are named the same in every fjmethodid mode. HotSpot names a + // hidden class "+0x" (ClassFileParser::mangle_hidden_class_name) + // and InstanceKlass::signature_name() turns that last '+' into '.'. Only + // called for classes that may be hidden (mayBeHiddenClass() in the .cpp): + // on JDK 24+ the hidden flag cannot be read, so there a non-hidden class + // whose own name ends in "+0x" (javac never produces one, since '+' + // is not a Java identifier character, but bytecode generators may) is + // reported with '.' instead of '+'. + void useHiddenClassSignatureName(); + + // Internal (slash-separated) class name, e.g. "java/lang/Thread". + const char* klassName() const { + return _long_klass_name != nullptr ? _long_klass_name : _klass_name; + } + + const char* methodName() const { + return _long_method_name != nullptr ? _long_method_name : _method_name; + } + + const char* methodSignature() const { + return _long_method_signature != nullptr ? _long_method_signature : _method_signature; + } + + // True for run()V declared by java.lang.Thread or one of its subclasses. + bool isThreadRun() const { return _is_thread_run; } + + // Decoded line number table, sorted by bci as HotSpot stores it, or + // nullptr if the method has none. Allocated with malloc(). + const jvmtiLineNumberEntry* lineNumberTable() const { return _line_number_table; } + int lineNumberTableSize() const { return _line_number_table_size; } + + // Hands ownership of the line number table to the caller, who must free() + // it. Returns nullptr if there is none. + jvmtiLineNumberEntry* takeLineNumberTable(int* size) { + jvmtiLineNumberEntry* table = _line_number_table; + *size = _line_number_table_size; + _line_number_table = nullptr; + _line_number_table_size = 0; + return table; + } +}; class HotspotSupport { friend class JVMSupport; @@ -49,6 +156,100 @@ class HotspotSupport { static bool loadMethodIDsIfNeededImpl(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass, bool load_all); + // fjmethodid=lazy: redefinition marks of the classes redefined while lazy + // mode was in use (see klassRegistry.h). Only redefined classes are in it. + static KlassRegistry _klass_registry; + + // Set once lazy mode has started; raw frames of a lazy session can + // outlive it, so redefinitions are tracked and dumps pin classes from then + // on. + static bool _lazy_used; + // Redefinition calls that started before _lazy_used was set and have not + // returned: their classes were not marked. + static int _untracked_redefinition_calls; + // How long initLazyResolution() waits for those calls to end. + static const u64 MAX_UNTRACKED_REDEFINITION_WAIT_NS = 5000000000ULL; + // Waits up to `max_wait_ns` until no untracked redefinition call is + // running. Returns false on timeout. + static bool awaitUntrackedRedefinitions(u64 max_wait_ns); + + // Set if a redefinition could not be marked (no memory for the registry + // entry): from then on every lazy frame is refused, for the rest of the + // process, since raw frames sampled before that redefinition can outlive + // any profiler session. klass_registry_add_failed counts these. + static bool _redefinitions_untracked; + + // fjmethodid=lazy: the classes GetLoadedClasses() returned at the start + // of the current dump. The dump holds a local reference to each of them, + // so none can be unloaded until it ends. nullptr outside a dump. + static const jclass* _dump_jclasses; + static int _dump_jclass_count; + + // Their Klass*s (open addressing, power-of-two capacity), built by the + // dump's first lazy resolution, so a dump without lazy frames never + // builds it. nullptr until then. + static const void** _dump_classes; + static size_t _dump_classes_capacity; + + // Whether `klass` is one of the classes pinned by the current dump. + static bool isDumpClass(const void* klass); + static void buildDumpClasses(); + + // Raw frame tags (frame.h). fjmethodid=lazy frames set the top tag bit + // and keep the low 25 bits of the holder's JFR trace ID (KlassTraceId) in + // the rest, unique per load of a class. Partial-mode (fjmethodid=false) + // 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: does nothing instead of allocating `klass`'s + // jmethodIDs; each dump pins the classes it resolves frames of + // (beginDump()). Called wherever the other modes preload. + static bool registerClass(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass); + + // Around every RedefineClasses/RetransformClasses call, in every mode + // once lazy mode has started (raw frames of a lazy session can outlive + // it): registers `klass` in _klass_registry and marks it while the call + // runs and for good if it succeeds (see klassRegistry.h for why its raw + // frames are refused), and in lazy mode preloads its jmethodIDs after a + // successful call. `klass` comes + // straight from the caller, so anything that is not an instance class is + // ignored, as JVMTI itself rejects it. + static void beforeClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass); + static void afterClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass, bool succeeded); + // Around each RedefineClasses/RetransformClasses call, outside the + // per-class hooks. enterRedefinitionCall() returns true if the call is + // not tracked (lazy mode had not started); pass that to + // exitRedefinitionCall(). + static bool enterRedefinitionCall(); + static void exitRedefinitionCall(bool untracked); + + // True if JVMTI accepts `klass` as a class that is neither an array nor a + // primitive, i.e. one with a Klass* that has methods. + static bool isInstanceClass(jvmtiEnv *jvmti, jclass klass); + + // fjmethodid=lazy half of resolve(): refuses the frame unless `holder` is + // one of the classes the current dump pins and is not marked redefined, + // refuses it if its trace ID does not match `trace_id_bits` (the frame + // came from another load of a class whose memory this one reuses), checks + // that `vm_method` is still one of its methods, and only then reads its + // names and line number table into `result`. `holder` and `idnum` were + // read from the unverified Method* and are only used as lookup keys. + static bool resolveLazily(ProfiledThread* prof_thread, VMMethod* vm_method, + const void* holder, int idnum, uint32_t trace_id_bits, + ResolvedMethod& result); + + // The raw metadata walk behind resolve(): names, signature, Thread.run + // detection and line number table of `method`. Must run with crash + // protection installed; uses no JNI or JVMTI. + static bool readMethod(const void* method, ResolvedMethod* result); + // Runs getJavaTraceAsync() under withUcontextFaultRecovery(), then layers // on its two post-processing steps: resolving frame types for the top // Java frame via fillFrameTypes(), and appending a synthetic "JVM @@ -63,6 +264,14 @@ class HotspotSupport { public: static void initClassloaderInfo(JNIEnv* jni); + // Around each dump, with the classes GetLoadedClasses() returned. The + // caller keeps the array and its local references valid until after + // endDump(). resolve() accepts raw frames of exactly these classes. + // endDump() is idempotent: Recording::finishChunk() calls it before + // deleting the local references, and again from its DumpScope guard. + static void beginDump(JNIEnv* jni, const jclass* classes, int count); + static void endDump(); + // Runs `work` under the ucontext-fault-recovery protocol walkJavaStack() // needs around getJavaTraceAsync(): installs a sigsetjmp/siglongjmp // crash-protection scope chained on prof_thread (JmpCtxScope, guards.h), @@ -157,15 +366,39 @@ 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; } - // Resolve a method to a jmethodID at dumping time - static jmethodID resolve(const void* method); + // The Method* a raw frame stands for: untagged for a lazy-mode frame, + // as stored for a partial-mode one. + static inline const void* rawFrameMethod(int bci, const void* method) { + return (FrameType::rawTag(bci, method) & TRACE_ID_TAG) != 0 ? FrameType::untagPointer(method) : method; + } + + // fjmethodid=lazy needs the Klass*/methods-array structs on top of the + // ones the raw Method* walk already uses; without them, the caller falls + // back to the partial preload mode. + static bool canResolveLazily(); + + // fjmethodid=lazy setup that needs JNI: locates Klass::_trace_id + // (KlassTraceId::calibrate). Returns false if it cannot be found; lazy + // mode then does not run, since nothing else can tell a stale raw frame + // from one of a class that took over its memory. + static bool initLazyResolution(JNIEnv* jni); + + // Resolves a raw-pointer frame at dump time into its class name, method + // name, signature and line number table, read from VM metadata. Takes the + // frame's method field and encoded bci as stored, tag bits included. + // Returns false, leaving `result` empty, if the frame must be treated as + // unknown: the JMETHODID_NOT_WALKABLE sentinel, metadata that is stale or + // unreadable, or a lazy-mode frame whose class the dump does not pin, was + // replaced by another class, or was redefined. + static bool resolve(const void* tagged_method, int bci, ResolvedMethod& result); // Store a Java frame captured from HotSpot metadata. A null jmethodID // 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..bc00a4ac16 --- /dev/null +++ b/ddprof-lib/src/main/cpp/hotspot/klassRegistry.cpp @@ -0,0 +1,202 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "hotspot/klassRegistry.h" + +#include "counters.h" + +#include + +// 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 = klassSlot(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; + size_t old_size = _size; + _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); + Counters::increment(KLASS_REGISTRY_PURGED, (long long)(old_size - _size)); + publishLocked(); + return true; +} + +void KlassRegistry::publishLocked() { + Counters::set(KLASS_REGISTRY_CAPACITY, (long long)_capacity); + Counters::set(KLASS_REGISTRY_ENTRIES, (long long)_size); +} + +void KlassRegistry::publishCounters() { + MutexLocker ml(_lock); + publishLocked(); +} + +// NewWeakGlobalRef raises an OutOfMemoryError when it fails. add() runs in the +// redefinition hooks, on the thread that called RedefineClasses, which must +// not see an exception it did not cause. JNI forbids calling NewWeakGlobalRef +// with an exception pending, so one pending afterwards is its own. +static jweak newWeakRef(JNIEnv* jni, jobject obj) { + jweak ref = jni->NewWeakGlobalRef(obj); + if (ref == nullptr && jni->ExceptionCheck()) { + jni->ExceptionClear(); + } + return ref; +} + +bool KlassRegistry::add(JNIEnv* jni, const void* klass, jclass mirror) { + if (klass == nullptr || mirror == nullptr) { + return false; + } + MutexLocker ml(_lock); + + if (_capacity != 0) { + Entry* e = find(klass); + if (e->klass == klass) { + if (jni->IsSameObject(e->mirror, mirror)) { + return true; + } + jweak ref = newWeakRef(jni, 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 = newWeakRef(jni, 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++; + publishLocked(); + return true; +} + +bool KlassRegistry::contains(JNIEnv* jni, const void* klass) { + MutexLocker ml(_lock); + Entry* e = findRegistered(klass); + return e != nullptr && !jni->IsSameObject(e->mirror, nullptr); +} + +bool KlassRegistry::beginRedefinition(const void* klass) { + MutexLocker ml(_lock); + Entry* e = findRegistered(klass); + if (e == nullptr) { + return false; + } + e->redefinitions_in_progress++; + return true; +} + +void KlassRegistry::endRedefinition(const void* klass, bool succeeded) { + MutexLocker ml(_lock); + Entry* e = findRegistered(klass); + if (e == nullptr) { + return; + } + // An end without a matching begin, e.g. after the begin could not register + // the class but another call did meanwhile, changes nothing. (The entry is + // not replaced while a call runs: its caller holds the class, so it cannot + // be unloaded and its Klass* reused.) + if (e->redefinitions_in_progress == 0) { + return; + } + e->redefinitions_in_progress--; + if (succeeded) { + e->redefined = true; + } +} + +bool KlassRegistry::isRedefined(JNIEnv* jni, const void* klass) { + MutexLocker ml(_lock); + Entry* e = findRegistered(klass); + return e != nullptr && (e->redefined || e->redefinitions_in_progress > 0) && + !jni->IsSameObject(e->mirror, nullptr); +} + +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; + publishLocked(); +} 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..53cf996a4c --- /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 "common.h" +#include "mutex.h" + +#include +#include +#include + +/** + * Redefinition marks for fjmethodid=lazy, keyed by Klass*: which classes are + * being, or have been, redefined. + * + * A redefined class stays alive, but its old methods array and old Method*s + * are freed once no longer on any stack, and later allocations -- including + * another redefinition of the same class -- can reuse them. So the + * RedefineClasses/RetransformClasses hooks call beginRedefinition() before + * every redefinition and endRedefinition() after it, and the dump refuses raw + * frames of a class while one is in progress, and for good once one has + * succeeded. (After a successful redefinition the class's jmethodIDs are + * preloaded, so its later frames are not raw.) A failed call leaves the + * methods untouched, and the class as it was. + * + * Only classes passed to a redefinition call are added, including calls + * that JVMTI then rejects; such an entry has no mark. Each entry holds a JNI weak global + * reference to the class's mirror, so a mark is only reported while that + * class is alive: once it is unloaded, a new class at the same Klass* address + * does not inherit it, and if that class is redefined too, its add() + * replaces the entry. Dead + * entries are purged whenever the table would have to grow. + * + * All operations take an internal mutex and call JNI, so none of them may be + * used from a signal handler. + */ +// The home slot of `klass` in an open-addressing table of `capacity` (a power +// of two) slots, probed linearly from there. Shared by KlassRegistry and the +// per-dump class set in HotspotSupport, whose inserts and lookups must agree. +static inline size_t klassSlot(const void* klass, size_t capacity) { + // Klass* is at least 8-byte aligned; drop the always-zero bits before mixing. + uint64_t h = ((uintptr_t)klass >> 3) * KNUTH_MULTIPLICATIVE_CONSTANT; + return (size_t)(h >> 32) & (capacity - 1); +} + +class KlassRegistry { + struct Entry { + const void* klass; + 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); + void publishLocked(); + +public: + KlassRegistry() : _table(nullptr), _capacity(0), _size(0) {} + + KlassRegistry(const KlassRegistry&) = delete; + KlassRegistry& operator=(const KlassRegistry&) = delete; + + /** + * Records that `klass` is the Klass* of `mirror`. Re-adding the same pair is + * a no-op; adding a different mirror for a known Klass* replaces the entry. + * Returns false if the weak reference or the table could not be allocated. + */ + bool add(JNIEnv* jni, const void* klass, jclass mirror); + + /** Whether `klass` is registered and its class is still alive. */ + bool contains(JNIEnv* jni, const void* klass); + + /** + * Call before a redefinition of `klass` starts, so that a resolution which + * 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 the class at `klass` is being or has been redefined. False once + * the marked class has been unloaded, so a new class at the same address + * starts unmarked. + */ + bool isRedefined(JNIEnv* jni, const void* klass); + + /** Number of occupied slots, including dead entries not yet purged. */ + size_t size(); + + /** + * Sets the klass_registry_capacity and klass_registry_entries gauges. + * They are updated on every change; call this after counters are reset. + */ + void publishCounters(); + + /** Deletes every weak reference and frees the table. */ + void clear(JNIEnv* jni); +}; + +#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..87bb2e00dd --- /dev/null +++ b/ddprof-lib/src/main/cpp/hotspot/klassTraceId.cpp @@ -0,0 +1,174 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "hotspot/klassTraceId.h" + +#include "common.h" +#include "counters.h" +#include "hotspot/vmStructs.inline.h" + +#include + +int KlassTraceId::_offset = -1; + +// JFR stores the ID shifted left by TRACE_ID_SHIFT (16) above its tag bits. +static const int TRACE_ID_SHIFT = 16; + +static inline uint64_t idAt(const void* klass, size_t offset) { + uint64_t raw; + memcpy(&raw, (const char*)klass + offset, sizeof(raw)); + return raw >> TRACE_ID_SHIFT; +} + +// The probes were created back to back by one thread, so their IDs are +// distinct and lie within a narrow window: PROBE_COUNT consecutive values +// plus whatever other threads took meanwhile. Pointers to neighbouring +// metadata fail this (they are hundreds of bytes apart, so `>> 16` mostly +// collapses them to one value); so do small integers (equal across probes) +// and compressed-klass bits in a mark word (far more than ID_SLACK apart). +static bool plausibleIds(const void* const* klasses, int count, size_t offset) { + uint64_t min = UINT64_MAX; + uint64_t max = 0; + for (int i = 0; i < count; i++) { + uint64_t id = idAt(klasses[i], offset); + if (id == 0) { + return false; + } + for (int j = 0; j < i; j++) { + if (idAt(klasses[j], offset) == id) { + return false; + } + } + min = id < min ? id : min; + max = id > max ? id : max; + } + return max - min <= (uint64_t)(count - 1) + KlassTraceId::ID_SLACK; +} + +int KlassTraceId::findOffset(const void* const* klasses, int count, size_t klass_size) { + if (count < 2) { + return -1; + } + int found = -1; + // Offset 0 is the C++ vtable pointer. + for (size_t offset = 8; offset + sizeof(uint64_t) <= klass_size; offset += 8) { + if (plausibleIds(klasses, count, offset)) { + if (found >= 0) { + return -1; // Ambiguous: refuse rather than guess. + } + found = (int)offset; + } + } + return found; +} + +// Dumps what each probe holds at every offset findOffset() looked at, so a +// release whose layout calibration rejects can be diagnosed from a debug CI +// log. +static void logProbes(const void* const* klasses, size_t klass_size) { +#ifdef DEBUG + for (size_t offset = 8; offset + sizeof(uint64_t) <= klass_size; offset += 8) { + uint64_t raw[KlassTraceId::PROBE_COUNT]; + for (int i = 0; i < KlassTraceId::PROBE_COUNT; i++) { + memcpy(&raw[i], (const char*)klasses[i] + offset, sizeof(raw[i])); + } + TEST_LOG("KlassTraceId probe @%3d: %016llx %016llx %016llx %016llx %016llx %016llx%s", (int)offset, + (unsigned long long)raw[0], (unsigned long long)raw[1], (unsigned long long)raw[2], + (unsigned long long)raw[3], (unsigned long long)raw[4], (unsigned long long)raw[5], + plausibleIds(klasses, KlassTraceId::PROBE_COUNT, offset) ? " plausible" : ""); + } +#endif +} + +// The class file of `public class datadog/ddprof/KlassTraceIdProbe`, with no +// fields and no methods: just enough for DefineClass() to create a Klass. +static const unsigned char PROBE_CLASS_FILE[] = { + 0xCA, 0xFE, 0xBA, 0xBE, 0x00, 0x00, 0x00, 0x32, // magic, version 50.0 + 0x00, 0x05, // constant pool count + 0x01, 0x00, 0x20, 'd', 'a', 't', 'a', 'd', 'o', 'g', '/', 'd', 'd', 'p', 'r', 'o', 'f', '/', + 'K', 'l', 'a', 's', 's', 'T', 'r', 'a', 'c', 'e', 'I', 'd', 'P', 'r', 'o', 'b', 'e', // #1 Utf8 + 0x07, 0x00, 0x01, // #2 Class #1 + 0x01, 0x00, 0x10, 'j', 'a', 'v', 'a', '/', 'l', 'a', 'n', 'g', '/', 'O', 'b', 'j', 'e', 'c', 't', // #3 Utf8 + 0x07, 0x00, 0x03, // #4 Class #3 + 0x00, 0x21, // ACC_PUBLIC | ACC_SUPER + 0x00, 0x02, 0x00, 0x04, // this #2, super #4 + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // no interfaces, fields, methods, attributes +}; +static const char PROBE_CLASS_NAME[] = "datadog/ddprof/KlassTraceIdProbe"; + +// Creates the probe klasses of one attempt, in creation order: arrays of +// dimensions 1 .. PROBE_COUNT of a class defined in a new class loader for +// this attempt, so none of them can have existed before. Probes that existed +// already, created far apart, would let the right field fail the check while +// another passes it (the mirrors' heap addresses, shifted right by 16, can be +// distinct and close together). Nothing keeps the loader reachable +// afterwards, so it and its classes can be unloaded. Returns false on any JNI +// failure. Must run inside a JNI local frame. +static bool createProbes(JNIEnv* jni, const void** klasses) { + jclass loader_class = jni->FindClass("java/security/SecureClassLoader"); + jmethodID init = loader_class == nullptr ? nullptr : jni->GetMethodID(loader_class, "", "(Ljava/lang/ClassLoader;)V"); + // JNI does not check access, so the protected constructor will do; the + // parent is the bootstrap loader. + jobject loader = init == nullptr ? nullptr : jni->NewObject(loader_class, init, (jobject)nullptr); + jclass cls = loader == nullptr ? nullptr + : jni->DefineClass(PROBE_CLASS_NAME, loader, (const jbyte*)PROBE_CLASS_FILE, + (jsize)sizeof(PROBE_CLASS_FILE)); + for (int i = 0; i < KlassTraceId::PROBE_COUNT && cls != nullptr; i++) { + // An empty array of `cls` makes the JVM create the next dimension. + jobjectArray array = jni->NewObjectArray(0, cls, nullptr); + cls = array == nullptr ? nullptr : jni->GetObjectClass(array); + klasses[i] = cls == nullptr ? nullptr : VMKlass::fromJavaClass(jni, cls); + if (klasses[i] == nullptr) { + cls = nullptr; + } + } + if (cls == nullptr) { + jni->ExceptionClear(); + TEST_LOG("KlassTraceId: could not create the probe classes"); + return false; + } + return true; +} + +// One attempt: creates fresh probe klasses and looks for the offset in them. +// Returns the offset, or -1. +static int probe(JNIEnv* jni, size_t klass_size) { + if (jni->PushLocalFrame(2 * KlassTraceId::PROBE_COUNT + 8) != JNI_OK) { + jni->ExceptionClear(); + return -1; + } + const void* klasses[KlassTraceId::PROBE_COUNT]; + int found = -1; + if (createProbes(jni, klasses)) { + found = KlassTraceId::findOffset(klasses, KlassTraceId::PROBE_COUNT, klass_size); + TEST_LOG("KlassTraceId offset=%d (sizeof(Klass)=%d)", found, (int)klass_size); + if (found < 0) { + logProbes(klasses, klass_size); + } + } + jni->PopLocalFrame(nullptr); + return found; +} + +bool KlassTraceId::calibrate(JNIEnv* jni) { + if (offset() >= 0) { + return true; + } + size_t klass_size = (size_t)VMKlass::type_size(); + if (klass_size == 0 || !VMStructs::hasKlassMethods()) { + TEST_LOG("KlassTraceId: no Klass layout from vmStructs"); + return false; + } + // A failed attempt usually means other threads created many classes while + // the probes were created; that is unlikely to repeat right away. + for (int attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + int found = probe(jni, klass_size); + if (found >= 0) { + __atomic_store_n(&_offset, found, __ATOMIC_RELEASE); + return true; + } + } + return false; +} 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..e95e019e4f --- /dev/null +++ b/ddprof-lib/src/main/cpp/hotspot/klassTraceId.h @@ -0,0 +1,94 @@ +/* + * 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, of a + * class it defines in a new class loader so that they cannot have existed + * before, and accepts the one 8-byte-aligned offset within sizeof(Klass) at + * which all of them hold distinct IDs no further apart than consecutive + * creation allows. + * Builds without JFR, or a layout where no single offset qualifies, leave the + * offset unknown, and fjmethodid=lazy does not run. + */ +class KlassTraceId { + static int _offset; + +public: + // Array dimensions created by one calibration attempt: one probe klass per + // dimension. + static const int PROBE_COUNT = 6; + // Attempts each calibrate() call makes, each with a new class loader. + static const int MAX_ATTEMPTS = 4; + // How many trace IDs other threads may consume while the probes are + // created, i.e. how far apart beyond PROBE_COUNT - 1 the probes' IDs may be. + static const uint64_t ID_SLACK = 64; + + /** + * 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, in + * up to MAX_ATTEMPTS attempts. Once an offset is found, later calls return + * true without probing again; after a failure, the next call tries again. + * Returns true if an offset is known. Each attempt leaves behind an + * unreferenced class loader with a few tiny classes, for GC to unload. + */ + static bool calibrate(JNIEnv* jni); + + /** 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..e3ed7e53ea 100644 --- a/ddprof-lib/src/main/cpp/hotspot/vmStructs.h +++ b/ddprof-lib/src/main/cpp/hotspot/vmStructs.h @@ -217,12 +217,16 @@ typedef void* address; field(_constmethod_idnum_offset, offset, MATCH_SYMBOLS("_method_idnum")) \ field(_constmethod_name_index_offset, offset, MATCH_SYMBOLS("_name_index")) \ field(_constmethod_sig_index_offset, offset, MATCH_SYMBOLS("_signature_index")) \ + field(_constmethod_flags_offset, offset, MATCH_SYMBOLS("_flags", "_flags._flags")) \ + field(_constmethod_code_size_offset, offset, MATCH_SYMBOLS("_code_size")) \ type_end() \ type_begin(VMConstantPool, MATCH_SYMBOLS("ConstantPool")) \ field(_pool_holder_offset, offset, MATCH_SYMBOLS("_pool_holder")) \ type_end() \ type_begin(VMKlass, MATCH_SYMBOLS("Klass", "InstanceKlass")) \ field(_klass_name_offset, offset, MATCH_SYMBOLS("_name")) \ + field(_klass_super_offset, offset, MATCH_SYMBOLS("_super")) \ + field(_klass_access_flags_offset, offset, MATCH_SYMBOLS("_access_flags")) \ field(_class_loader_data_offset, offset, MATCH_SYMBOLS("_class_loader_data")) \ field(_methods_offset, offset, MATCH_SYMBOLS("_methods")) \ field(_jmethod_ids_offset, offset, MATCH_SYMBOLS("_methods_jmethod_ids")) \ @@ -328,8 +332,12 @@ typedef void* address; * - constant defines a constant of a class */ -#define DECLARE_INT_CONSTANTS_DO(constant, constant_with_version) \ - constant(frame, entry_frame_call_wrapper_offset) +// The has_linenumber_table bit of ConstMethod::_flags. JDK 21 moved the flags +// into ConstMethodFlags (and widened _flags from u2 to u4). +#define DECLARE_INT_CONSTANTS_DO(constant, constant_with_version) \ + constant(frame, entry_frame_call_wrapper_offset) \ + constant_with_version(ConstMethod, _has_linenumber_table, 8, 20) \ + constant_with_version(ConstMethodFlags, _misc_has_linenumber_table, 21, MAX_VERSION) #define DECLARE_LONG_CONSTANTS_DO(constant, constant_with_version) \ constant_with_version(markWord, klass_shift, 24, MAX_VERSION) \ @@ -495,6 +503,13 @@ class VMStructs { return _has_class_loader_data; } + // What fjmethodid=lazy needs on top of the Method* walk: mapping a + // java.lang.Class to its Klass* (KlassRegistry) and reading a Klass*'s + // methods array (VMKlass::containsMethod). + static bool hasKlassMethods() { + return _has_method_structs && _methods_offset >= 0 && _klass != NULL; + } + static bool hasNativeThreadId() { return _has_native_thread_id; } @@ -703,6 +718,21 @@ DECLARE(VMKlass) return VMSymbol::load_then_cast(at(_klass_name_offset)); } + // Super class, or nullptr for java.lang.Object and interfaces, or when the + // VM does not export Klass::_super. + VMKlass* super_or_null() { + if (_klass_super_offset < 0) return nullptr; + return VMKlass::load_then_cast(at(_klass_super_offset)); + } + + // Klass::_access_flags (an AccessFlags, i.e. one jint) into `*flags`, or + // false if the VM does not export it. + bool accessFlags(uint32_t* flags) { + if (_klass_access_flags_offset < 0) return false; + *flags = *(const uint32_t*)at(_klass_access_flags_offset); + return true; + } + VMClassLoaderData* classLoaderData() { assert(_class_loader_data_offset >= 0); return VMClassLoaderData::load_then_cast(at(_class_loader_data_offset)); @@ -718,6 +748,55 @@ 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, and `*not_scanned` (if given) is set. + // + // Keeping the class alive does not keep this array alive: a redefinition + // swaps in a new array at a safepoint and frees the old one at a later + // 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, bool* not_scanned = nullptr) { + 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; + } + // HotSpot's Array (oops/array.hpp) is `int _length; T _data[1];`, + // so for pointer elements the data starts at the pointer size. + void** data = (void**)(methods + sizeof(void*)); + if (idnum_hint >= 0 && idnum_hint < len && SafeAccess::loadPtr(&data[idnum_hint], nullptr) == method) { + return true; + } + if (len > MAX_METHODS_SCANNED) { + if (not_scanned != nullptr) { + *not_scanned = true; + } + 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 +984,12 @@ DECLARE(VMConstMethod) inline VMConstantPool* constants_or_null() const; inline VMSymbol* name() const; inline VMSymbol* signature() const; + inline u16 idnum() const; + inline u16 codeSize() const; + // Start of the compressed line number table, which HotSpot places right + // after the bytecodes, or nullptr if the method has none (or the VM does + // not export what is needed to locate it). + inline const unsigned char* compressedLineNumberTable() const; private: inline u16 nameIndex() const; inline u16 signatureIndex() const; @@ -922,6 +1007,11 @@ DECLARE(VMMethod) // Performs extra validation when VMMethod comes from incomplete frame inline jmethodID validatedId(); + // The holder Klass* (constMethod -> constants -> pool holder), read with + // SafeAccess so it is usable from a signal handler; nullptr if any link + // is unreadable. + inline const void* holderOrNull() const; + const char* bytecode() { assert(_method_constmethod_offset >= 0); return *(const char**) at(_method_constmethod_offset) + VMConstMethod::type_size(); @@ -1210,6 +1300,62 @@ class ScopeDesc : VMStructs { } }; +// Decoder for ConstMethod's compressed line number table, a mirror of +// HotSpot's CompressedLineNumberReadStream. Each entry is either a single byte +// holding (bci_delta << 3 | line_delta), or 0xFF followed by the two deltas as +// signed CompressedStream ints. A 0 byte terminates the table. +// +// Reads the raw stream without any safety checks: callers must run with crash +// protection installed and bound the number of entries they accept. +class VMLineNumberStream : VMStructs { + private: + const unsigned char* _stream; + int _bci; + int _line; + + // Same UNSIGNED5 decoding as ScopeDesc::readInt() (see JDK-8292758 for + // the _unsigned5_base shift). + unsigned int readUInt() { + unsigned char c = *_stream++; + unsigned int n = c - _unsigned5_base; + if (c >= 192) { + for (int shift = 6; ; shift += 6) { + c = *_stream++; + n += (c - _unsigned5_base) << shift; + if (c < 192 || shift >= 24) break; + } + } + return n; + } + + int readSignedInt() { + unsigned int n = readUInt(); + return (int)(n >> 1) ^ -(int)(n & 1); + } + + public: + explicit VMLineNumberStream(const unsigned char* table) : _stream(table), _bci(0), _line(0) {} + + // Advances to the next (bci, line) pair; returns false at the terminator. + bool readPair() { + unsigned char next = *_stream++; + if (next == 0) { + return false; + } + if (next == 0xFF) { + _bci += readSignedInt(); + _line += readSignedInt(); + } else { + _bci += next >> 3; + _line += next & 0x7; + } + return true; + } + + int bci() const { return _bci; } + int line() const { return _line; } +}; + class InterpreterFrame : VMStructs { public: enum { diff --git a/ddprof-lib/src/main/cpp/hotspot/vmStructs.inline.h b/ddprof-lib/src/main/cpp/hotspot/vmStructs.inline.h index 043ab06722..e24ba1169c 100644 --- a/ddprof-lib/src/main/cpp/hotspot/vmStructs.inline.h +++ b/ddprof-lib/src/main/cpp/hotspot/vmStructs.inline.h @@ -123,6 +123,19 @@ VMConstMethod* VMMethod::constMethod_or_null() const { return VMConstMethod::load_then_cast(at(_method_constmethod_offset)); } +const void* VMMethod::holderOrNull() const { + const char* const_method = (const char*) SafeAccess::load((void**) at(_method_constmethod_offset)); + if (!goodPtr(const_method)) { + return nullptr; + } + const char* cpool = (const char*) SafeAccess::load((void**)(const_method + _constmethod_constants_offset)); + if (!goodPtr(cpool)) { + return nullptr; + } + const void* holder = SafeAccess::loadPtr((void**)(cpool + _pool_holder_offset), nullptr); + return goodPtr(holder) ? holder : nullptr; +} + jmethodID VMMethod::id() { // We may find a bogus NMethod during stack walking, it does not always point to a valid VMMethod const char* const_method = (const char*) SafeAccess::load((void**) at(_method_constmethod_offset)); @@ -212,6 +225,37 @@ VMSymbol* VMConstMethod::signature() const { return cpool->symbolAt(sig_index); } +u16 VMConstMethod::idnum() const { + assert(_constmethod_idnum_offset >= 0 && "Invalid idnum"); + return *(u16*)at(_constmethod_idnum_offset); +} + +u16 VMConstMethod::codeSize() const { + assert(_constmethod_code_size_offset >= 0); + return *(u16*)at(_constmethod_code_size_offset); +} + +const unsigned char* VMConstMethod::compressedLineNumberTable() const { + if (_constmethod_flags_offset < 0 || _constmethod_code_size_offset < 0) { + return nullptr; + } + bool has_table; + if (_ConstMethodFlags__misc_has_linenumber_table != -1) { + // JDK 21+: u4 ConstMethodFlags::_flags + has_table = (*(u32*)at(_constmethod_flags_offset) & (u32)_ConstMethodFlags__misc_has_linenumber_table) != 0; + } else if (_ConstMethod__has_linenumber_table != -1) { + // JDK 8 - 20: u2 ConstMethod::_flags + has_table = (*(u16*)at(_constmethod_flags_offset) & (u16)_ConstMethod__has_linenumber_table) != 0; + } else { + return nullptr; + } + if (!has_table) { + return nullptr; + } + // ConstMethod::compressed_linenumber_table() == code_end() + return (const unsigned char*)at((int)type_size()) + codeSize(); +} + u16 VMConstMethod::nameIndex() const { assert(_constmethod_name_index_offset >= 0 && "Invalid name index"); return *(u16*)at(_constmethod_name_index_offset); diff --git a/ddprof-lib/src/main/cpp/jvmSupport.cpp b/ddprof-lib/src/main/cpp/jvmSupport.cpp index 4dc2be966e..82e61f7473 100644 --- a/ddprof-lib/src/main/cpp/jvmSupport.cpp +++ b/ddprof-lib/src/main/cpp/jvmSupport.cpp @@ -8,6 +8,7 @@ #include "asyncSampleMutex.h" #include "common.h" #include "frames.h" +#include "log.h" #include "os.h" #include "profiler.h" #include "threadLocalData.inline.h" @@ -72,15 +73,30 @@ 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) { + state = Partial_loaded; + } else if (!HotspotSupport::canResolveLazily()) { + Log::warn("fjmethodid=lazy: this JVM does not expose the class and method structures it needs; " + "preloading jmethodIDs as with fjmethodid=false"); + state = Partial_loaded; + } else if (HotspotSupport::initLazyResolution(jni)) { + state = Lazy_loaded; + } else { + // initLazyResolution() has logged why. + state = Partial_loaded; + } + if (state == Partial_loaded) { HotspotSupport::initClassloaderInfo(jni); - load_all = false; } + } else if (args._lazy_jmethodID) { + Log::warn(VM::isHotspot() ? "fjmethodid=lazy needs cstack=vm; preloading all jmethodIDs" + : "fjmethodid=lazy is only supported on HotSpot; preloading all jmethodIDs"); } - JMethodIDLoadStats state = load_all ? Fully_loaded : Partial_loaded; if (state == current_state) { return; } @@ -136,6 +152,9 @@ int JVMSupport::asyncGetCallTrace(ASGCT_CallFrame *frames, int max_depth, void* void JVMSupport::loadAllMethodIDsIfNeeded(jvmtiEnv *jvmti, JNIEnv *jni) { assert(getLoadState() != No_loaded && "Should not call before profiler execution"); + if (getLoadState() == Lazy_loaded) { + return; // nothing to preload; each dump pins the classes it needs + } jint class_count = 0; jclass *classes = nullptr; @@ -160,6 +179,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 +189,43 @@ bool JVMSupport::loadMethodIDsIfNeeded(jvmtiEnv *jvmti, JNIEnv *jni, jclass klas } } +bool JVMSupport::enterRedefinitionCall() { + return VM::isHotspot() && HotspotSupport::enterRedefinitionCall(); +} + +void JVMSupport::exitRedefinitionCall(bool token) { + if (VM::isHotspot()) { + HotspotSupport::exitRedefinitionCall(token); + } +} + +void JVMSupport::beforeClassRedefinition(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass) { + if (VM::isHotspot()) { + HotspotSupport::beforeClassRedefinition(jvmti, jni, klass); + } +} + +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); + } +} + +void JVMSupport::beginDump(JNIEnv *jni, const jclass *classes, int count) { + if (VM::isHotspot()) { + HotspotSupport::beginDump(jni, classes, count); + } +} + +void JVMSupport::endDump() { + if (VM::isHotspot()) { + HotspotSupport::endDump(); + } +} + bool JVMSupport::loadMethodIDsImpl(jvmtiEnv *jvmti, JNIEnv *jni, jclass klass) { // CRITICAL: GetClassMethods must be called to preallocate jmethodIDs for AsyncGetCallTrace. // AGCT operates in signal handlers where lock acquisition is forbidden, so jmethodIDs must diff --git a/ddprof-lib/src/main/cpp/jvmSupport.h b/ddprof-lib/src/main/cpp/jvmSupport.h index 49b8a9193f..825e6e967d 100644 --- a/ddprof-lib/src/main/cpp/jvmSupport.h +++ b/ddprof-lib/src/main/cpp/jvmSupport.h @@ -22,12 +22,14 @@ enum StackRecovery { }; class HotspotSupport; +class ResolvedMethod; class JVMSupport { enum JMethodIDLoadStats { No_loaded, // Has not yet executed any profiling command, no jmethodIDs are loaded Partial_loaded, // Partially loaded, see HotspotSupport::shouldPreloadJmethodIDs() - Fully_loaded // All jmethodIDs are loaded + Fully_loaded, // All jmethodIDs are loaded + Lazy_loaded // None preloaded (fjmethodid=lazy); only classes redefined meanwhile get them, from the redefinition hook }; friend class HotspotSupport; @@ -66,8 +68,29 @@ 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); + // Called once around each whole RedefineClasses/RetransformClasses call, + // outside the per-class hooks: pass what enterRedefinitionCall() returned + // to exitRedefinitionCall(). + static bool enterRedefinitionCall(); + static void exitRedefinitionCall(bool token); + + // Around each dump: `classes` are the ones GetLoadedClasses() returned, + // referenced by the caller until after endDump(). Raw frames of other + // classes resolve as unknown. endDump() may be called more than once, and + // without a beginDump(). + static void beginDump(JNIEnv *jni, const jclass *classes, int count); + static void endDump(); + + // Resolve a raw-pointer frame (method field and encoded bci) into names and + // line number table (HotSpot only); false if it must be treated as unknown + static inline bool resolve(const void* method, int bci, ResolvedMethod& result); // If a class is hidden class static inline bool isHidden(jint modifiers); diff --git a/ddprof-lib/src/main/cpp/jvmSupport.inline.h b/ddprof-lib/src/main/cpp/jvmSupport.inline.h index bea61880ff..254dadc603 100644 --- a/ddprof-lib/src/main/cpp/jvmSupport.inline.h +++ b/ddprof-lib/src/main/cpp/jvmSupport.inline.h @@ -35,13 +35,14 @@ long long JVMSupport::runtimeStubsMemoryUsage() { } } -// Resolve method pointer to jmethodID -jmethodID JVMSupport::resolve(const void* method) { +// Resolve a raw-pointer frame (method field and encoded bci) into names and +// line number table +bool JVMSupport::resolve(const void* method, int bci, ResolvedMethod& result) { if (VM::isHotspot()) { - return HotspotSupport::resolve(method); + return HotspotSupport::resolve(method, bci, result); } else { assert(false && "Should not reach here"); - return nullptr; + return false; } } diff --git a/ddprof-lib/src/main/cpp/profiler.cpp b/ddprof-lib/src/main/cpp/profiler.cpp index 778cc5bbdd..94f8df8d0d 100644 --- a/ddprof-lib/src/main/cpp/profiler.cpp +++ b/ddprof-lib/src/main/cpp/profiler.cpp @@ -1630,7 +1630,8 @@ Error Profiler::start(Arguments &args, bool reset) { } args._cstack = _cstack; - // Prepare JVMSupport for execution + // Prepare JVMSupport for execution. Must follow the _cstack resolution + // above: initExecution() picks the jmethodID mode from cstackMode(). JVMSupport::initExecution(args, VM::jvmti(), VM::jni()); // Must precede the first updateSymbols(): it is what allows LibraryPatcher to diff --git a/ddprof-lib/src/main/cpp/vmEntry.cpp b/ddprof-lib/src/main/cpp/vmEntry.cpp index acf8382322..7da8ab380a 100644 --- a/ddprof-lib/src/main/cpp/vmEntry.cpp +++ b/ddprof-lib/src/main/cpp/vmEntry.cpp @@ -678,35 +678,57 @@ 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(); + bool call_token = JVMSupport::enterRedefinitionCall(); + for (int i = 0; i < class_count; i++) { + if (class_definitions[i].klass != NULL) { + JVMSupport::beforeClassRedefinition(jvmti, env, class_definitions[i].klass); + } + } + 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); } } + JVMSupport::exitRedefinitionCall(call_token); return result; } 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(); + bool call_token = JVMSupport::enterRedefinitionCall(); + 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); } } + JVMSupport::exitRedefinitionCall(call_token); return result; } 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..b328470323 --- /dev/null +++ b/ddprof-lib/src/test/cpp/hotspotLazyResolve_ut.cpp @@ -0,0 +1,1042 @@ +/* + * 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 +#include + +// fjmethodid=lazy end to end, without a JVM: a raw Method* frame is captured +// with HotspotSupport::fillJavaFrame() exactly as walkVM does, the class's +// registry entry is manipulated the way ClassPrepare and class unloading +// would, and the frame is resolved with HotspotSupport::resolve() as the dump +// thread does. +// +// The metadata is fake but laid out the way the offsets below tell +// VMStructs to read it: Method -> ConstMethod -> ConstantPool -> Klass, with +// the constant pool holding the method's name and signature Symbols and the +// Klass its name and methods array. The class has no jmethodIDs at all, as in +// lazy mode. None of it is ever freed, so no fault handling is needed and the +// tests run on every platform. +// +// The Klass*-reuse tests model what aligned, reused class space makes likely: +// a class is unloaded, and a new class of the same shape is loaded at the +// same Klass* address with its methods at the same Method* addresses. A frame +// sampled from the old class must not resolve as a method of the new one. + +class VMTestAccessor { +public: + static bool getHotspot() { return VM::_hotspot; } + static void setHotspot(bool value) { VM::_hotspot = value; } + static JavaVM* getVm() { return VM::_vm; } + static void setVm(JavaVM* vm) { VM::_vm = vm; } + static jvmtiEnv* getJvmti() { return VM::_jvmti; } + static void setJvmti(jvmtiEnv* env) { VM::_jvmti = env; } + static int getHotspotVersion() { return VM::_hotspot_version; } + static void setHotspotVersion(int v) { VM::_hotspot_version = v; } +}; + +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 setLazyUsed(bool used) { HotspotSupport::_lazy_used = used; } + static bool enterRedefinitionCall() { return HotspotSupport::enterRedefinitionCall(); } + static void exitRedefinitionCall(bool untracked) { HotspotSupport::exitRedefinitionCall(untracked); } + static bool awaitUntrackedRedefinitions(u64 max_wait_ns) { + return HotspotSupport::awaitUntrackedRedefinitions(max_wait_ns); + } + static void setRedefinitionsUntracked(bool v) { HotspotSupport::_redefinitions_untracked = v; } + static bool dumpClassesBuilt() { return HotspotSupport::_dump_classes != nullptr; } + static void beforeClassRedefinition(jvmtiEnv* jvmti, JNIEnv* jni, jclass klass) { + HotspotSupport::beforeClassRedefinition(jvmti, jni, klass); + } + static void afterClassRedefinition(jvmtiEnv* jvmti, JNIEnv* jni, jclass klass, bool succeeded) { + HotspotSupport::afterClassRedefinition(jvmti, jni, klass, succeeded); + } + static uint32_t traceIdTagBit() { return HotspotSupport::TRACE_ID_TAG; } + static bool registerClass(jvmtiEnv* jvmti, JNIEnv* jni, jclass klass) { + return HotspotSupport::registerClass(jvmti, jni, klass); + } +}; + +class ProfilerTestAccessor { +public: + static bool getForceJmethodID(Profiler* p) { return p->_force_jmethodID; } + static void setForceJmethodID(Profiler* p, bool v) { p->_force_jmethodID = v; } +}; + +// Points VMStructs at the fake layout below for the lifetime of a test, and +// restores whatever was there before. +class VMStructsTestAccessor { +public: + struct State { + int method_constmethod, constmethod_constants, constmethod_idnum, pool_holder; + int jmethod_ids, methods; + int constmethod_name_index, constmethod_sig_index, klass_name, symbol_length, symbol_body; + int constmethod_flags, constmethod_code_size, klass_super, klass_access_flags; + uint64_t method_size, const_method_size, constant_pool_size, klass_size, symbol_size; + bool can_dereference_jmethod_id; + }; + + explicit VMStructsTestAccessor(const State& s) : _saved(save()) { apply(s); } + ~VMStructsTestAccessor() { apply(_saved); } + +private: + State _saved; + + static State save() { + return { + VMStructs::_method_constmethod_offset, + VMStructs::_constmethod_constants_offset, + VMStructs::_constmethod_idnum_offset, + VMStructs::_pool_holder_offset, + VMStructs::_jmethod_ids_offset, + VMStructs::_methods_offset, + VMStructs::_constmethod_name_index_offset, + VMStructs::_constmethod_sig_index_offset, + VMStructs::_klass_name_offset, + VMStructs::_symbol_length_offset, + VMStructs::_symbol_body_offset, + VMStructs::_constmethod_flags_offset, + VMStructs::_constmethod_code_size_offset, + VMStructs::_klass_super_offset, + VMStructs::_klass_access_flags_offset, + VMStructs::TYPE_SIZE_NAME(VMMethod), + VMStructs::TYPE_SIZE_NAME(VMConstMethod), + VMStructs::TYPE_SIZE_NAME(VMConstantPool), + VMStructs::TYPE_SIZE_NAME(VMKlass), + VMStructs::TYPE_SIZE_NAME(VMSymbol), + VMStructs::_can_dereference_jmethod_id, + }; + } + + static void apply(const State& s) { + VMStructs::_method_constmethod_offset = s.method_constmethod; + VMStructs::_constmethod_constants_offset = s.constmethod_constants; + VMStructs::_constmethod_idnum_offset = s.constmethod_idnum; + VMStructs::_pool_holder_offset = s.pool_holder; + VMStructs::_jmethod_ids_offset = s.jmethod_ids; + VMStructs::_methods_offset = s.methods; + VMStructs::_constmethod_name_index_offset = s.constmethod_name_index; + VMStructs::_constmethod_sig_index_offset = s.constmethod_sig_index; + VMStructs::_klass_name_offset = s.klass_name; + VMStructs::_symbol_length_offset = s.symbol_length; + VMStructs::_symbol_body_offset = s.symbol_body; + VMStructs::_constmethod_flags_offset = s.constmethod_flags; + VMStructs::_constmethod_code_size_offset = s.constmethod_code_size; + VMStructs::_klass_super_offset = s.klass_super; + VMStructs::_klass_access_flags_offset = s.klass_access_flags; + VMStructs::TYPE_SIZE_NAME(VMMethod) = s.method_size; + VMStructs::TYPE_SIZE_NAME(VMConstMethod) = s.const_method_size; + VMStructs::TYPE_SIZE_NAME(VMConstantPool) = s.constant_pool_size; + VMStructs::TYPE_SIZE_NAME(VMKlass) = s.klass_size; + VMStructs::TYPE_SIZE_NAME(VMSymbol) = s.symbol_size; + VMStructs::_can_dereference_jmethod_id = s.can_dereference_jmethod_id; + } +}; + +namespace { + +struct FakeMethod { + void* const_method; +}; + +struct FakeConstMethod { + void* cpool; + uint16_t idnum; + uint16_t name_index; + uint16_t sig_index; + uint16_t pad; +}; + +// VMConstantPool::base() is `this + type_size()`, so the slots follow holder. +struct FakeConstantPool { + void* holder; + intptr_t symbols[3]; +}; + +struct FakeSymbol { + uint16_t length; + char body[30]; +}; + +// HotSpot's Array: int length, then pointer-aligned elements. +struct FakeMethodArray { + int32_t len; + int32_t pad; + void* data[2]; +}; + +struct FakeKlass { + void* ids; // jmethodID cache; stays null + FakeMethodArray* methods; + uint64_t trace_id; // JFR encoding: (id << 16) | tag bits + FakeSymbol* name; + uint32_t access_flags; // AccessFlags, as on JDK 15-23 +}; + +const VMStructsTestAccessor::State LAYOUT = { + /*method_constmethod*/ (int)offsetof(FakeMethod, const_method), + /*constmethod_constants*/ (int)offsetof(FakeConstMethod, cpool), + /*constmethod_idnum*/ (int)offsetof(FakeConstMethod, idnum), + /*pool_holder*/ (int)offsetof(FakeConstantPool, holder), + /*jmethod_ids*/ (int)offsetof(FakeKlass, ids), + /*methods*/ (int)offsetof(FakeKlass, methods), + /*constmethod_name_index*/ (int)offsetof(FakeConstMethod, name_index), + /*constmethod_sig_index*/ (int)offsetof(FakeConstMethod, sig_index), + /*klass_name*/ (int)offsetof(FakeKlass, name), + /*symbol_length*/ (int)offsetof(FakeSymbol, length), + /*symbol_body*/ (int)offsetof(FakeSymbol, body), + /*constmethod_flags*/ -1, // no line number tables + /*constmethod_code_size*/ -1, + /*klass_super*/ -1, + /*klass_access_flags*/ (int)offsetof(FakeKlass, access_flags), + /*method_size*/ sizeof(FakeMethod), + /*const_method_size*/ sizeof(FakeConstMethod), + /*constant_pool_size*/ offsetof(FakeConstantPool, symbols), + /*klass_size*/ sizeof(FakeKlass), + /*symbol_size*/ sizeof(FakeSymbol), + /*can_dereference*/ true, +}; + +// One class, com/example/Spin, with one method, spin(J)V, and no jmethodIDs. +// Klass* and Method* addresses are those of these fields, which is what lets +// a test "reload" a class at the same addresses: it only has to register a +// new mirror for the same FakeKlass. +struct FakeClass { + static constexpr const char* KLASS_NAME = "com/example/Spin"; + static constexpr const char* METHOD_NAME = "spin"; + static constexpr const char* SIGNATURE = "(J)V"; + + FakeMethod method; + FakeConstMethod const_method; + FakeConstantPool cpool; + FakeKlass klass; + FakeMethodArray methods; + FakeSymbol klass_name, method_name, signature; + + FakeClass() { + memset(this, 0, sizeof(*this)); + method.const_method = &const_method; + const_method.cpool = &cpool; + const_method.idnum = 0; + const_method.name_index = 1; + const_method.sig_index = 2; + cpool.holder = &klass; + cpool.symbols[1] = (intptr_t)&method_name; + cpool.symbols[2] = (intptr_t)&signature; + klass.methods = &methods; + klass.trace_id = (uint64_t)1000 << 16; + klass.name = &klass_name; + methods.len = 1; + methods.data[0] = &method; + setSymbol(klass_name, KLASS_NAME); + setSymbol(method_name, METHOD_NAME); + setSymbol(signature, SIGNATURE); + } + + static void setSymbol(FakeSymbol& sym, const char* text) { + sym.length = (uint16_t)strlen(text); + memcpy(sym.body, text, sym.length); + } + + const void* klassPtr() const { return &klass; } + VMMethod* vmMethod() { return reinterpret_cast(&method); } +}; + +// Fake JNI with weak-reference semantics, as in klassRegistry_ut.cpp, plus +// the JavaVM that VM::jni() goes through. +struct FakeObject { + int id; + const void* klass = nullptr; // what java.lang.Class.klass holds, for mirrors + bool collected = false; // the class has been unloaded +}; + +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_tbl.ExceptionCheck = &exceptionCheck; + _jni_tbl.ExceptionClear = &exceptionClear; + _jni_tbl.ExceptionOccurred = &exceptionOccurred; + _jni_tbl.Throw = &throwException; + _jni_env.functions = &_jni_tbl; + _jvmti_tbl.GetClassStatus = &getClassStatus; + _jvmti_tbl.GetClassMethods = &getClassMethods; + _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; } + + int new_local_ref_calls = 0; + int get_class_methods_calls = 0; + bool fail_weak_refs = false; // NewWeakGlobalRef fails, as when out of memory + bool exception_pending = false; // set by a failed NewWeakGlobalRef, as JNI does + int throws = 0; + // JNI forbids these calls while an exception is pending. + int calls_with_exception_pending = 0; + + jclass mirror(FakeObject& obj) { return reinterpret_cast(&obj); } + + // Class unloading: the class is no longer among the loaded classes, and + // the mirror's weak references are cleared. + void collect(FakeObject& obj) { + obj.collected = true; + for (FakeWeak* w : _weaks) { + if (w->referent == &obj) { + w->referent = nullptr; + } + } + } + +private: + jobject resolve(jobject ref) { + FakeWeak* w = reinterpret_cast(ref); + return _weaks.count(w) != 0 ? reinterpret_cast(w->referent) : ref; + } + + static void checkNoException() { + if (s_instance->exception_pending) { + s_instance->calls_with_exception_pending++; + } + } + static jweak JNICALL newWeakGlobalRef(JNIEnv*, jobject obj) { + checkNoException(); + if (s_instance->fail_weak_refs) { + s_instance->exception_pending = true; // OutOfMemoryError + return nullptr; + } + 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 exceptionCheck(JNIEnv*) { + return s_instance->exception_pending ? JNI_TRUE : JNI_FALSE; + } + static void JNICALL exceptionClear(JNIEnv*) { s_instance->exception_pending = false; } + static jthrowable JNICALL exceptionOccurred(JNIEnv*) { + return s_instance->exception_pending ? reinterpret_cast(&s_instance->_throwable) : nullptr; + } + static jint JNICALL throwException(JNIEnv*, jthrowable) { + s_instance->throws++; + s_instance->exception_pending = true; + return 0; + } + static jboolean JNICALL isSameObject(JNIEnv*, jobject a, jobject b) { + checkNoException(); + return s_instance->resolve(a) == s_instance->resolve(b) ? JNI_TRUE : JNI_FALSE; + } + static jobject JNICALL newLocalRef(JNIEnv*, jobject ref) { + checkNoException(); + s_instance->new_local_ref_calls++; + return s_instance->resolve(ref); + } + static void JNICALL deleteLocalRef(JNIEnv*, jobject) {} + // VMKlass::fromJavaClass() reads java.lang.Class.klass through this. + static jlong JNICALL getLongField(JNIEnv*, jobject obj, jfieldID) { + checkNoException(); + return (jlong)(uintptr_t)reinterpret_cast(obj)->klass; + } + // A jmethodID preload (loadMethodIDsIfNeededImpl()); only the call is + // recorded. Resolution never makes it: only a successful redefinition + // preloads. + static jvmtiError JNICALL getClassMethods(jvmtiEnv*, jclass, jint* count, jmethodID** methods) { + s_instance->get_class_methods_calls++; + *count = 0; + *methods = nullptr; + return JVMTI_ERROR_NONE; + } + static jvmtiError JNICALL deallocate(jvmtiEnv*, unsigned char*) { return JVMTI_ERROR_NONE; } + static jvmtiError JNICALL getClassStatus(jvmtiEnv*, jclass, jint* status) { + *status = JVMTI_CLASS_STATUS_PREPARED; + return JVMTI_ERROR_NONE; + } + static jint JNICALL getEnv(JavaVM*, void** penv, jint) { + *penv = &s_instance->_jni_env; + return JNI_OK; + } + + static FakeJvm* s_instance; + + JavaVM* _saved_vm; + JNINativeInterface_ _jni_tbl{}; + JNIEnv_ _jni_env{}; + JNIInvokeInterface_ _vm_tbl{}; + JavaVM_ _vm{}; + jvmtiInterface_1_ _jvmti_tbl{}; + _jvmtiEnv _jvmti{}; + jvmtiEnv* _saved_jvmti = nullptr; + std::set _weaks; + int _throwable = 0; // what ExceptionOccurred() returns +}; +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()); + HotspotSupportTestAccessor::setLazyUsed(true); + } + + void TearDown() override { + HotspotSupport::endDump(); + HotspotSupportTestAccessor::setLazyUsed(false); + HotspotSupportTestAccessor::setRedefinitionsUntracked(false); + 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); + } + + // Classes loaded so far; collect() unloads them. + std::vector _loaded; + + // A class is loaded: from now on it is among the classes a dump pins. + void registerClass(FakeObject& mirror) { + _loaded.push_back(&mirror); + } + + // The class list of the current dump; like finishChunk()'s, it must stay + // valid until the dump ends. + std::vector _dump_classes; + + // What Recording::finishChunk() does at the start of a dump: pins the + // classes that are loaded now. + void beginDump() { + HotspotSupport::endDump(); + _dump_classes.clear(); + for (FakeObject* m : _loaded) { + if (!m->collected) { + _dump_classes.push_back(_jvm.mirror(*m)); + } + } + HotspotSupport::beginDump(_jvm.jni(), _dump_classes.data(), (int)_dump_classes.size()); + } + + // The old class is unloaded and a new class of the same shape is loaded at + // the same Klass* and Method* addresses: its mirror is new, and so is the + // trace ID JFR assigns at creation. + void loadNewClassAtSameAddress() { + _jvm.collect(_old_mirror); + _cls.klass.trace_id = ((_cls.klass.trace_id >> 16) + 37) << 16; + registerClass(_new_mirror); + } + + // What walkVM stores for a frame of a class without a jmethodID yet. + ASGCT_CallFrame sample() { + ASGCT_CallFrame frame{}; + HotspotSupport::fillJavaFrame(frame, FRAME_JIT_COMPILED, 7, nullptr, _cls.vmMethod()); + EXPECT_TRUE(FrameType::isRawPointer(frame.bci)); + return frame; + } + + // What VM::RetransformClassesHook does around the original call. + void redefine(bool succeeded) { + HotspotSupportTestAccessor::beforeClassRedefinition(_jvm.jvmti(), _jvm.jni(), _jvm.mirror(_old_mirror)); + HotspotSupportTestAccessor::afterClassRedefinition(_jvm.jvmti(), _jvm.jni(), _jvm.mirror(_old_mirror), + succeeded); + } + + // What Lookup::fillRawMethod() does with a raw frame in a dump that + // starts now. + bool resolve(const ASGCT_CallFrame& frame) { + beginDump(); + ResolvedMethod result; + return HotspotSupport::resolve(frame.method, frame.bci, result); + } + + // Whether `frame` resolves, and to the names of the fake class's method. + bool resolvesToSpin(const ASGCT_CallFrame& frame) { + beginDump(); + ResolvedMethod result; + return HotspotSupport::resolve(frame.method, frame.bci, result) && + strcmp(FakeClass::KLASS_NAME, result.klassName()) == 0 && + strcmp(FakeClass::METHOD_NAME, result.methodName()) == 0 && + strcmp(FakeClass::SIGNATURE, result.methodSignature()) == 0; + } +}; + +// A hidden class gets the name JVMTI GetClassSignature() reports, so its +// frames are named the same with and without jmethodIDs; other names that +// merely contain a '+' are left alone. On JDK 24+, where the hidden flag is +// not readable, the name alone decides. +TEST_F(HotspotLazyResolveTest, HiddenClassNamedAsJvmtiSignature) { + registerClass(_old_mirror); + const struct { const char* vm_name; const char* expected; } cases[] = { + {"a/Spin$$Lambda+0x8010a0", "a/Spin$$Lambda.0x8010a0"}, + {"a/B+0x1+0x2f", "a/B+0x1.0x2f"}, + {"a/Plus+Sign", "a/Plus+Sign"}, + {"a/Spin+0x", "a/Spin+0x"}, + {"a/Spin+0x12g", "a/Spin+0x12g"}, + }; + int saved_version = VMTestAccessor::getHotspotVersion(); + VMTestAccessor::setHotspotVersion(25); + beginDump(); + for (const auto& c : cases) { + SCOPED_TRACE(c.vm_name); + FakeClass::setSymbol(_cls.klass_name, c.vm_name); + ASGCT_CallFrame frame = sample(); + ResolvedMethod result; + ASSERT_TRUE(HotspotSupport::resolve(frame.method, frame.bci, result)); + EXPECT_STREQ(c.expected, result.klassName()); + } + VMTestAccessor::setHotspotVersion(saved_version); +} + +// Where the hidden flag is readable (JDK 15-23, Klass::_access_flags), only +// a class that has it is renamed: an ordinary class may be named +// "a/Plain+0x1234" too. Before JDK 15 there are no hidden classes. +TEST_F(HotspotLazyResolveTest, OnlyClassesFlaggedHiddenAreRenamed) { + static const uint32_t JVM_ACC_IS_HIDDEN_CLASS = 0x04000000; + registerClass(_old_mirror); + FakeClass::setSymbol(_cls.klass_name, "a/Plain+0x1234"); + const struct { int version; uint32_t flags; const char* expected; } cases[] = { + {21, 0, "a/Plain+0x1234"}, + {21, JVM_ACC_IS_HIDDEN_CLASS, "a/Plain.0x1234"}, + {17, JVM_ACC_IS_HIDDEN_CLASS, "a/Plain.0x1234"}, + {23, 0, "a/Plain+0x1234"}, + {11, JVM_ACC_IS_HIDDEN_CLASS, "a/Plain+0x1234"}, + }; + int saved_version = VMTestAccessor::getHotspotVersion(); + beginDump(); + for (const auto& c : cases) { + SCOPED_TRACE(testing::Message() << "JDK " << c.version << " flags " << c.flags); + VMTestAccessor::setHotspotVersion(c.version); + _cls.klass.access_flags = c.flags; + ASGCT_CallFrame frame = sample(); + ResolvedMethod result; + ASSERT_TRUE(HotspotSupport::resolve(frame.method, frame.bci, result)); + EXPECT_STREQ(c.expected, result.klassName()); + } + _cls.klass.access_flags = 0; + VMTestAccessor::setHotspotVersion(saved_version); +} + +// The names come from VM metadata: no jmethodIDs are allocated. +TEST_F(HotspotLazyResolveTest, LiveClassResolves) { + registerClass(_old_mirror); + ASGCT_CallFrame frame = sample(); + EXPECT_TRUE(resolvesToSpin(frame)); + EXPECT_EQ(0, _jvm.get_class_methods_calls); +} + +TEST_F(HotspotLazyResolveTest, UnloadedClassDoesNotResolve) { + registerClass(_old_mirror); + ASGCT_CallFrame frame = sample(); + _jvm.collect(_old_mirror); + EXPECT_FALSE(resolve(frame)); +} + +// The old class is unloaded and a new one is registered at the same Klass*, +// with its method at the same Method* address. The frame was sampled from the +// old class, so it must not resolve to the new class's method. +TEST_F(HotspotLazyResolveTest, FrameFromBeforeKlassReuseDoesNotResolveToNewClass) { + registerClass(_old_mirror); + ASGCT_CallFrame frame = sample(); + + loadNewClassAtSameAddress(); + + EXPECT_FALSE(resolve(frame)); +} + +// Frames sampled from the new class after the reuse must still resolve. +TEST_F(HotspotLazyResolveTest, FrameFromAfterKlassReuseResolves) { + registerClass(_old_mirror); + loadNewClassAtSameAddress(); + + ASGCT_CallFrame frame = sample(); + EXPECT_TRUE(resolvesToSpin(frame)); +} + +// A frame from the old class and one from the new class must be told apart +// already at capture time; otherwise the call trace storage merges them into +// one trace before resolve() ever runs. +TEST_F(HotspotLazyResolveTest, FramesFromBeforeAndAfterKlassReuseDiffer) { + registerClass(_old_mirror); + ASGCT_CallFrame before = sample(); + loadNewClassAtSameAddress(); + ASGCT_CallFrame after = sample(); + + EXPECT_FALSE(before.bci == after.bci && before.method == after.method); +} + +// Tags identify the class load itself, so another class's unloading and the +// reuse of its address leave this class's frames unchanged, and the same stack +// keeps deduplicating into one trace. +TEST_F(HotspotLazyResolveTest, UnrelatedReuseDoesNotChangeFrames) { + registerClass(_old_mirror); + ASGCT_CallFrame before = sample(); + + static FakeKlass other_klass; + other_klass = _cls.klass; + other_klass.trace_id = ((_cls.klass.trace_id >> 16) + 7) << 16; + static FakeObject other_old{10}; + static FakeObject other_new{11}; + other_old = FakeObject{10, &other_klass}; + other_new = FakeObject{11, &other_klass}; + registerClass(other_old); + _jvm.collect(other_old); + other_klass.trace_id += (uint64_t)1 << 16; + registerClass(other_new); + + ASGCT_CallFrame after = sample(); + EXPECT_EQ(before.bci, after.bci); + EXPECT_EQ(before.method, after.method); + EXPECT_TRUE(resolvesToSpin(after)); +} + +// The JFR tag bits below the ID change while JFR runs; they must not affect +// the frame's tag or its resolution. +TEST_F(HotspotLazyResolveTest, JfrTagBitsDoNotAffectTraceIdTags) { + registerClass(_old_mirror); + ASGCT_CallFrame before = sample(); + _cls.klass.trace_id |= 0x0303; + ASGCT_CallFrame after = sample(); + EXPECT_EQ(before.bci, after.bci); + EXPECT_EQ(before.method, after.method); + EXPECT_TRUE(resolvesToSpin(before)); +} + +// Partial mode (fjmethodid=false) stores the plain Method*, untagged, and +// never reads the holder's trace ID: a frame whose trace ID is unreadable is +// still stored raw, not turned into an unknown frame. +TEST_F(HotspotLazyResolveTest, PartialModeFramesAreStoredUntagged) { + JVMSupportTestAccessor::setLoadState(JVMSupportTestAccessor::PartialLoaded()); + KlassTraceId::setOffsetForTest(-1); // trace ID unreadable + ASGCT_CallFrame frame = sample(); + EXPECT_EQ(static_cast(_cls.vmMethod()), frame.method); + EXPECT_EQ(0u, FrameType::rawTag(frame.bci, frame.method) & HotspotSupportTestAccessor::traceIdTagBit()); + EXPECT_EQ(static_cast(_cls.vmMethod()), HotspotSupport::rawFrameMethod(frame.bci, frame.method)); +} + +// A raw frame is later looked up by its Method* with the bits above 2^48 +// masked off, so a Method* that uses them is stored as an unknown frame in +// every mode instead of one that would resolve to another address. +TEST_F(HotspotLazyResolveTest, RawFrameAboveTaggableAddressesIsStoredUnknown) { + const VMMethod* high = reinterpret_cast((uintptr_t)1 << 52 | (uintptr_t)_cls.vmMethod()); + for (bool lazy : {true, false}) { + JVMSupportTestAccessor::setLoadState(lazy ? JVMSupportTestAccessor::LazyLoaded() + : JVMSupportTestAccessor::PartialLoaded()); + ASGCT_CallFrame frame{}; + HotspotSupport::fillJavaFrame(frame, FRAME_JIT_COMPILED, 7, nullptr, high); + EXPECT_FALSE(FrameType::isRawPointer(frame.bci)) << "lazy=" << lazy; + EXPECT_EQ(JMETHODID_NOT_WALKABLE, frame.method_id) << "lazy=" << lazy; + } +} + +// resolve() goes by the mode the frame was sampled in, not the current one: +// a lazy-mode frame kept across a restart in partial mode still resolves +// against the classes the dump pins, which is what keeps a custom loader's +// class from being unloaded while its metadata is read. +TEST_F(HotspotLazyResolveTest, LazyFrameResolvesAgainstPinnedClassesAfterSwitchToPartialMode) { + registerClass(_old_mirror); + ASGCT_CallFrame frame = sample(); + ASSERT_NE(0u, FrameType::rawTag(frame.bci, frame.method) & HotspotSupportTestAccessor::traceIdTagBit()); + JVMSupportTestAccessor::setLoadState(JVMSupportTestAccessor::PartialLoaded()); + EXPECT_TRUE(resolvesToSpin(frame)); +} + +// A dump collects the pinned classes' Klass*s only when it resolves its first +// lazy frame, so a dump without lazy frames pays nothing for them. +TEST_F(HotspotLazyResolveTest, DumpCollectsPinnedClassesOnlyForLazyFrames) { + registerClass(_old_mirror); + beginDump(); + EXPECT_FALSE(HotspotSupportTestAccessor::dumpClassesBuilt()); + ASGCT_CallFrame frame = sample(); + ResolvedMethod result; + EXPECT_TRUE(HotspotSupport::resolve(frame.method, frame.bci, result)); + EXPECT_TRUE(HotspotSupportTestAccessor::dumpClassesBuilt()); + HotspotSupport::endDump(); + EXPECT_FALSE(HotspotSupportTestAccessor::dumpClassesBuilt()); +} + +// A redefined class that is unloaded leaves its mark behind in the registry; +// a new class at the same address was never redefined, and its frames resolve. +TEST_F(HotspotLazyResolveTest, RedefinitionMarkDoesNotCarryOverToNewClassAtSameAddress) { + registerClass(_old_mirror); + redefine(true); + ASSERT_FALSE(resolve(sample())); + loadNewClassAtSameAddress(); + EXPECT_TRUE(resolvesToSpin(sample())); +} + +// A dump whose GetLoadedClasses() failed passes no list; it must not fall back +// to an earlier dump's list, whose local references are gone. +TEST_F(HotspotLazyResolveTest, DumpWithoutClassListPinsNothing) { + registerClass(_old_mirror); + ASGCT_CallFrame frame = sample(); + ASSERT_TRUE(resolvesToSpin(frame)); // an earlier dump, ended without endDump() + HotspotSupport::beginDump(_jvm.jni(), nullptr, 0); + ResolvedMethod result; + EXPECT_FALSE(HotspotSupport::resolve(frame.method, frame.bci, result)); +} + +// Enough pinned classes to grow the set past its minimum size, so lookups +// probe across a larger table. +TEST_F(HotspotLazyResolveTest, ClassIsFoundAmongManyPinnedClasses) { + static FakeKlass others[3000]; + static FakeObject other_mirrors[3000]; + for (int i = 0; i < 3000; i++) { + others[i] = _cls.klass; + other_mirrors[i] = FakeObject{100 + i, &others[i]}; + registerClass(other_mirrors[i]); + } + registerClass(_old_mirror); + EXPECT_TRUE(resolvesToSpin(sample())); +} + +// If a redefinition cannot be marked, nothing tells its class's frames apart +// any more, so every lazy frame is refused from then on. +TEST_F(HotspotLazyResolveTest, UnmarkableRedefinitionRefusesAllLazyFrames) { + registerClass(_old_mirror); + ASGCT_CallFrame frame = sample(); + _jvm.fail_weak_refs = true; + redefine(false); + _jvm.fail_weak_refs = false; + // The failed allocation's OutOfMemoryError must not reach the caller of + // RedefineClasses. + EXPECT_FALSE(_jvm.exception_pending); + EXPECT_FALSE(resolve(frame)); +} + +// JVMTI allows a redefinition with a Java exception pending. The hooks set it +// aside instead of calling JNI with it pending, mark the class as usual, and +// throw it again for the caller of RedefineClasses. +TEST_F(HotspotLazyResolveTest, RedefinitionWithPendingExceptionIsMarked) { + registerClass(_old_mirror); + ASGCT_CallFrame frame = sample(); + long long add_failed = Counters::getCounter(KLASS_REGISTRY_ADD_FAILED); + _jvm.exception_pending = true; + redefine(true); + EXPECT_TRUE(_jvm.exception_pending); + EXPECT_EQ(2, _jvm.throws); // once per hook + EXPECT_EQ(0, _jvm.calls_with_exception_pending); + _jvm.exception_pending = false; + EXPECT_EQ(1u, HotspotSupportTestAccessor::registry().size()); + EXPECT_FALSE(resolve(frame)); + EXPECT_EQ(add_failed, Counters::getCounter(KLASS_REGISTRY_ADD_FAILED)); +} + +// A rejected call with an exception pending leaves the class as it was: no +// mark is left behind, so its frames still resolve. +TEST_F(HotspotLazyResolveTest, RejectedRedefinitionWithPendingExceptionLeavesFramesResolvable) { + registerClass(_old_mirror); + ASGCT_CallFrame frame = sample(); + _jvm.exception_pending = true; + redefine(false); + EXPECT_TRUE(_jvm.exception_pending); + EXPECT_EQ(0, _jvm.calls_with_exception_pending); + _jvm.exception_pending = false; + EXPECT_TRUE(resolvesToSpin(frame)); +} + +// A redefinition call that started before lazy mode does not mark its +// classes, so lazy mode must not start sampling until it has returned. +TEST_F(HotspotLazyResolveTest, LazyStartWaitsForRedefinitionsStartedBeforeIt) { + HotspotSupportTestAccessor::setLazyUsed(false); + bool untracked = HotspotSupportTestAccessor::enterRedefinitionCall(); + EXPECT_TRUE(untracked); + HotspotSupportTestAccessor::setLazyUsed(true); // what initLazyResolution() does first + EXPECT_FALSE(HotspotSupportTestAccessor::awaitUntrackedRedefinitions(10 * 1000 * 1000)); + HotspotSupportTestAccessor::exitRedefinitionCall(untracked); + EXPECT_TRUE(HotspotSupportTestAccessor::awaitUntrackedRedefinitions(10 * 1000 * 1000)); + + // A call that starts once lazy mode is on is tracked, and not waited for. + bool later = HotspotSupportTestAccessor::enterRedefinitionCall(); + EXPECT_FALSE(later); + EXPECT_TRUE(HotspotSupportTestAccessor::awaitUntrackedRedefinitions(0)); + HotspotSupportTestAccessor::exitRedefinitionCall(later); +} + +// Before lazy mode has started there are no lazy frames, so redefinitions +// are not tracked. +TEST_F(HotspotLazyResolveTest, RedefinitionBeforeLazyModeIsNotTracked) { + HotspotSupportTestAccessor::setLazyUsed(false); + redefine(true); + EXPECT_EQ(0u, HotspotSupportTestAccessor::registry().size()); +} + +// Outside a dump no class is pinned, so a lazy frame is never read. +TEST_F(HotspotLazyResolveTest, FrameIsRefusedOutsideADump) { + registerClass(_old_mirror); + ASGCT_CallFrame frame = sample(); + HotspotSupport::endDump(); + ResolvedMethod result; + EXPECT_FALSE(HotspotSupport::resolve(frame.method, frame.bci, result)); +} + +// ClassPrepare registers nothing: a class enters the registry only when it is +// redefined, to hold its redefinition mark. +TEST_F(HotspotLazyResolveTest, ClassPrepareRegistersNothing) { + ASSERT_TRUE(HotspotSupportTestAccessor::registerClass(_jvm.jvmti(), _jvm.jni(), _jvm.mirror(_old_mirror))); + EXPECT_EQ(0u, HotspotSupportTestAccessor::registry().size()); + registerClass(_old_mirror); + redefine(false); + EXPECT_EQ(1u, HotspotSupportTestAccessor::registry().size()); +} + +// A redefinition frees the class's old Method*s once they are off every +// stack, and a later redefinition of the same class can allocate a new method +// at the same address. The class, its mirror and its trace ID all +// stay the same, so nothing but the redefinition itself tells the frames +// apart: raw frames of a class marked before its redefinition are refused. +TEST_F(HotspotLazyResolveTest, FrameOfRedefinedClassIsRefused) { + registerClass(_old_mirror); + ASGCT_CallFrame before = sample(); + // Afterwards the Method* at the sampled address is (still, or again) + // among the class's methods. + redefine(true); + EXPECT_FALSE(resolve(before)); + // Its later frames get jmethodIDs instead of being raw. + EXPECT_EQ(1, _jvm.get_class_methods_calls); +} + +// A redefinition JVMTI rejects (e.g. an unmodifiable class) changes nothing: +// the class's raw frames stay resolvable and no jmethodIDs are preloaded. +TEST_F(HotspotLazyResolveTest, FailedRedefinitionLeavesFramesResolvable) { + registerClass(_old_mirror); + ASGCT_CallFrame frame = sample(); + redefine(false); + EXPECT_TRUE(resolvesToSpin(frame)); + EXPECT_EQ(0, _jvm.get_class_methods_calls); +} + +// While the redefinition call runs, the methods may be swapped at any moment. +TEST_F(HotspotLazyResolveTest, FramesAreRefusedWhileRedefinitionRuns) { + registerClass(_old_mirror); + ASGCT_CallFrame frame = sample(); + HotspotSupportTestAccessor::beforeClassRedefinition(_jvm.jvmti(), _jvm.jni(), _jvm.mirror(_old_mirror)); + EXPECT_FALSE(resolve(frame)); + HotspotSupportTestAccessor::afterClassRedefinition(_jvm.jvmti(), _jvm.jni(), _jvm.mirror(_old_mirror), false); + EXPECT_TRUE(resolvesToSpin(frame)); +} + +// Redefinitions are tracked in every mode once the registry has entries: +// raw frames of a lazy session can outlive a restart in another mode, and a +// later lazy session must not trust a class redefined in between. +TEST_F(HotspotLazyResolveTest, RedefinitionIsTrackedOutsideLazyMode) { + registerClass(_old_mirror); + ASGCT_CallFrame frame = sample(); + JVMSupportTestAccessor::setLoadState(JVMSupportTestAccessor::PartialLoaded()); + redefine(true); + EXPECT_TRUE(HotspotSupportTestAccessor::registry().isRedefined(_jvm.jni(), _cls.klassPtr())); + EXPECT_FALSE(resolve(frame)); + // The other modes load jmethodIDs their own way (JVMSupport). + EXPECT_EQ(0, _jvm.get_class_methods_calls); +} + +// 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 + _new_mirror.klass = &other_klass; + registerClass(_new_mirror); + + EXPECT_FALSE(resolve(frame)); +} + +// Tripwire for tag collisions: each dump reports how many of the classes it +// pins have a trace ID that no longer fits in the 25-bit tag, the point from +// which a stale frame could match a newer class by its tag. +TEST_F(HotspotLazyResolveTest, CountsClassesWithTraceIdBeyondTagRange) { +#ifndef COUNTERS + GTEST_SKIP() << "debug counters are not compiled into this build"; +#else + _cls.klass.trace_id = ((uint64_t)(1u << 25) - 1) << 16; // largest ID that still fits + registerClass(_old_mirror); + EXPECT_TRUE(resolve(sample())); + EXPECT_EQ(0, Counters::getCounter(KLASS_TRACE_ID_BEYOND_TAG_RANGE)); + + static FakeKlass big_klass; + big_klass = _cls.klass; + big_klass.trace_id = ((uint64_t)1 << 25) << 16; + _new_mirror.klass = &big_klass; + registerClass(_new_mirror); + EXPECT_TRUE(resolve(sample())); + EXPECT_EQ(1, Counters::getCounter(KLASS_TRACE_ID_BEYOND_TAG_RANGE)); + EXPECT_EQ(2, Counters::getCounter(LAZY_DUMP_CLASSES)); +#endif +} + +// A frame whose Method* metadata was freed after the sample (here: its +// ConstMethod pointer cleared) cannot even be traced to a class. It is +// refused and counted as unreadable, not as any of the registry outcomes. +TEST_F(HotspotLazyResolveTest, FrameWithFreedMethodIsCountedUnreadable) { +#ifndef COUNTERS + GTEST_SKIP() << "debug counters are not compiled into this build"; +#else + registerClass(_old_mirror); + ASGCT_CallFrame frame = sample(); + _jvm.collect(_old_mirror); + _cls.method.const_method = nullptr; + + const CounterId registry_outcomes[] = {METHOD_RESOLVE_LAZY_UNLOADED, METHOD_RESOLVE_LAZY_STALE, + METHOD_RESOLVE_LAZY_NOT_CURRENT, METHOD_RESOLVE_LAZY_CLASS_TOO_LARGE, + METHOD_RESOLVE_LAZY_REDEFINED, METHOD_RESOLVE_LAZY_RESOLVED}; + long long before[6]; + for (int i = 0; i < 6; i++) { + before[i] = Counters::getCounter(registry_outcomes[i]); + } + long long unreadable = Counters::getCounter(METHOD_RESOLVE_LAZY_UNREADABLE); + + EXPECT_FALSE(resolve(frame)); + + EXPECT_EQ(unreadable + 1, Counters::getCounter(METHOD_RESOLVE_LAZY_UNREADABLE)); + for (int i = 0; i < 6; i++) { + EXPECT_EQ(before[i], Counters::getCounter(registry_outcomes[i])) << "counter " << registry_outcomes[i]; + } + _cls.method.const_method = &_cls.const_method; +#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); + beginDump(); +#ifdef COUNTERS + long long before = Counters::getCounter(METHOD_RESOLVE_LAZY_RESOLVED); +#endif + MethodInfo* row = lookup.resolveMethod(a); + ASSERT_NE(&lookup._unknown_method, row); + EXPECT_EQ(lookup.getSymbol(FakeClass::METHOD_NAME), row->_name); + EXPECT_EQ(lookup.getSymbol(FakeClass::SIGNATURE), row->_sig); + EXPECT_EQ(row, lookup.resolveMethod(a)); + EXPECT_EQ(row, lookup.resolveMethod(b)); +#ifdef COUNTERS + EXPECT_EQ(1, Counters::getCounter(METHOD_RESOLVE_LAZY_RESOLVED) - before); +#endif + + // The next dump starts with an empty cache, resolves the frame again and + // refills the same row. + row->_mark = false; // what writeMethods() does at the end of a chunk + Lookup next(nullptr, &methods, &classes); + beginDump(); + EXPECT_EQ(row, next.resolveMethod(a)); + EXPECT_TRUE(row->_mark); +#ifdef COUNTERS + EXPECT_EQ(2, Counters::getCounter(METHOD_RESOLVE_LAZY_RESOLVED) - before); +#endif + EXPECT_EQ(0, _jvm.get_class_methods_calls); +} + +// Frames from two loads of a class at the same addresses carry different tags, +// so the cache keeps their outcomes apart. +TEST_F(HotspotLazyResolveTest, LookupCacheSeparatesFramesOfDifferentClassLoads) { + registerClass(_old_mirror); + ASGCT_CallFrame before = sample(); + loadNewClassAtSameAddress(); + ASGCT_CallFrame after = sample(); + + StringDictionary classes; + MethodMap methods; + Lookup lookup(nullptr, &methods, &classes); + beginDump(); + EXPECT_EQ(&lookup._unknown_method, lookup.resolveMethod(before)); + MethodInfo* row = lookup.resolveMethod(after); + EXPECT_NE(&lookup._unknown_method, row); + EXPECT_EQ(&lookup._unknown_method, lookup.resolveMethod(before)); + EXPECT_EQ(row, lookup.resolveMethod(after)); +} + diff --git a/ddprof-lib/src/test/cpp/hotspotMethodId_ut.cpp b/ddprof-lib/src/test/cpp/hotspotMethodId_ut.cpp index f8501cf6e6..c1390bc1c2 100644 --- a/ddprof-lib/src/test/cpp/hotspotMethodId_ut.cpp +++ b/ddprof-lib/src/test/cpp/hotspotMethodId_ut.cpp @@ -19,6 +19,8 @@ #include #include #include +#include +#include #ifdef __linux__ #include @@ -123,6 +125,53 @@ class VMStructsTestAccessor { } }; + // Scoped override for what resolve() needs to locate and decode a + // ConstMethod's line number table and to walk Klass::_super. + class LineTableLayout { + public: + LineTableLayout(int flags_offset, int code_size_offset, int has_table_mask, + unsigned char unsigned5_base, int klass_super_offset) { + _saved = _read(); + VMStructs::_constmethod_flags_offset = flags_offset; + VMStructs::_constmethod_code_size_offset = code_size_offset; + // Model JDK 21+ (u4 ConstMethodFlags); the u2 constant stays unset. + VMStructs::_ConstMethodFlags__misc_has_linenumber_table = has_table_mask; + VMStructs::_ConstMethod__has_linenumber_table = -1; + VMStructs::_unsigned5_base = unsigned5_base; + VMStructs::_klass_super_offset = klass_super_offset; + } + + ~LineTableLayout() { + VMStructs::_constmethod_flags_offset = _saved.flags_offset; + VMStructs::_constmethod_code_size_offset = _saved.code_size_offset; + VMStructs::_ConstMethodFlags__misc_has_linenumber_table = _saved.flags_mask; + VMStructs::_ConstMethod__has_linenumber_table = _saved.legacy_mask; + VMStructs::_unsigned5_base = _saved.unsigned5_base; + VMStructs::_klass_super_offset = _saved.klass_super_offset; + } + + private: + struct Saved { + int flags_offset; + int code_size_offset; + int flags_mask; + int legacy_mask; + unsigned char unsigned5_base; + int klass_super_offset; + } _saved; + + static Saved _read() { + return { + VMStructs::_constmethod_flags_offset, + VMStructs::_constmethod_code_size_offset, + VMStructs::_ConstMethodFlags__misc_has_linenumber_table, + VMStructs::_ConstMethod__has_linenumber_table, + VMStructs::_unsigned5_base, + VMStructs::_klass_super_offset, + }; + } + }; + private: struct SavedOffsets { int method_constmethod; @@ -327,12 +376,12 @@ TEST(HotspotMethodIdTest, IdReturnsValidIdForPopulatedSlot) { // --------------------------------------------------------------------------- // HotspotSupport::resolve() crash protection // -// resolve() turns a raw Method* captured at sample time into a jmethodID on the -// JFR dump thread. GC or class unloading can free that metadata in between, so +// resolve() turns a raw Method* captured at sample time into its class name, +// method name, signature and line number table on the JFR dump thread. GC or class unloading can free that metadata in between, so // every dereference in the walk (constMethod -> constants -> name/signature -> // holder -> klass->name, then Symbol length/body) can fault. resolve() installs // a sigsetjmp landing pad via JmpCtxScope so Profiler::checkFault() recovers and -// resolve() reports the method as unresolved (nullptr) instead of taking the JVM +// resolve() reports the method as unresolved (false) instead of taking the JVM // down mid-dump. // // checkFault() gates recovery on the faulting pc lying inside the profiler @@ -374,7 +423,7 @@ size_t kPageSize() { return OS::page_size; } // and consistent with the struct layouts below. constexpr VMStructsTestAccessor::TypeSizes RESOLVE_TYPE_SIZES = { /*method*/ sizeof(void*), - /*const_method*/ 2 * sizeof(void*), + /*const_method*/ 24, // == offsetof(ResolveFakes::ConstMethod, code) /*constant_pool*/ sizeof(void*), // base() = cpool + this, i.e. &symbols[0] /*klass*/ sizeof(void*), /*symbol*/ 8, @@ -391,9 +440,7 @@ constexpr VMStructsTestAccessor::SymbolOffsets RESOLVE_SYMBOL_OFFSETS = { // jmethod_ids deliberately points at a separate, always-null field rather than // aliasing klass_name at offset 0: VMMethod::id() reads the jmethodID cache // through that offset, and pointing it at the class-name Symbol would make id() -// read a length and a "jmethodID" out of the Symbol's body. That value passes -// isValidJMethodID(), so resolve() would early-return with garbage and never -// reach the symbol-copy code these tests are about. +// read a length and a "jmethodID" out of the Symbol's body. constexpr VMStructsTestAccessor::Offsets RESOLVE_OFFSETS = { /*method_constmethod*/ 0, /*constmethod_constants*/ 0, @@ -413,7 +460,11 @@ struct ResolveFakes { uint16_t name_index; uint16_t sig_index; uint16_t idnum; - uint16_t pad; + uint16_t code_size; // RESOLVE_CODE_SIZE_OFFSET + uint32_t flags; // RESOLVE_FLAGS_OFFSET + uint32_t pad; + // Bytecodes, then the compressed line number table (code_end()). + unsigned char code[64]; } const_method; struct ConstantPool { void* holder; @@ -422,11 +473,12 @@ struct ResolveFakes { struct Klass { void* name_symbol; // klass_name offset 0 void* jmethod_ids; // stays null so VMMethod::id() reports "no cache" - } klass; + void* super_klass; // RESOLVE_KLASS_SUPER_OFFSET + } klass, super_klass; struct Symbol { uint16_t length; char body[64]; - } name_sym, sig_sym, klass_sym; + } name_sym, sig_sym, klass_sym, super_sym; // Wires the chain up so resolve() walks Method -> ConstMethod -> ConstantPool // -> {name, signature} Symbols and ConstantPool -> Klass -> class-name Symbol. @@ -446,74 +498,25 @@ struct ResolveFakes { s.length = (uint16_t)strlen(text); memcpy(s.body, text, s.length); } -}; - -// Minimal fake JavaVM/JNIEnv so HotspotSupport::resolve()'s unprotected JNI -// phase (lookupMethodIdViaJni) can run to completion without a live JVM. -// FindClass always reports success against an opaque, never-dereferenced -// jclass; GetMethodID/GetStaticMethodID record the name/signature they were -// called with and report "not found" -- the same real-world path resolve() -// takes for a method it can't find -- so nothing beyond that is exercised. -// Recording the arguments is what lets a test prove the malloc-fallback path -// copied the right bytes, rather than just checking resolve() didn't crash. -class ScopedFakeJni { -public: - ScopedFakeJni() : _saved_vm(VMTestAccessor::getVm()) { - _jni_tbl = JNINativeInterface_{}; - _jni_tbl.FindClass = &findClass; - _jni_tbl.ExceptionClear = &exceptionClear; - _jni_tbl.GetMethodID = &getMethodId; - _jni_tbl.GetStaticMethodID = &getMethodId; - _jni_tbl.DeleteLocalRef = &deleteLocalRef; - _jni_env.functions = &_jni_tbl; - - _vm_tbl = JNIInvokeInterface_{}; - _vm_tbl.GetEnv = &getEnv; - _vm.functions = &_vm_tbl; - - s_instance = this; - VMTestAccessor::setVm(reinterpret_cast(&_vm)); - } - ~ScopedFakeJni() { - VMTestAccessor::setVm(_saved_vm); - s_instance = nullptr; + // Marks the method as having a line number table and copies `table` + // (already compressed) right after `code_size` bytes of bytecode. + void setLineNumberTable(uint16_t code_size, const unsigned char* table, size_t len) { + const_method.code_size = code_size; + const_method.flags |= 1; + memcpy(const_method.code + code_size, table, len); } +}; - int getMethodIdCalls() const { return _get_method_id_calls; } - const std::string& lastMethodName() const { return _last_method_name; } - const std::string& lastMethodSignature() const { return _last_method_sig; } +// Must stay below the 1024-byte mark ResolveUsesMallocFallbackForOversizedButReadableName +// writes its long Symbol at. +static_assert(sizeof(ResolveFakes) <= 1024, "fakes overlap the long symbol"); +static_assert(offsetof(ResolveFakes::ConstMethod, code) == 24, "RESOLVE_TYPE_SIZES.const_method"); -private: - static jclass JNICALL findClass(JNIEnv*, const char*) { - return reinterpret_cast(&s_instance->_fake_class); - } - static jmethodID JNICALL getMethodId(JNIEnv*, jclass, const char* name, const char* sig) { - s_instance->_get_method_id_calls++; - s_instance->_last_method_name = name != nullptr ? name : ""; - s_instance->_last_method_sig = sig != nullptr ? sig : ""; - return nullptr; - } - static void JNICALL exceptionClear(JNIEnv*) {} - static void JNICALL deleteLocalRef(JNIEnv*, jobject) {} - static jint JNICALL getEnv(JavaVM*, void** penv, jint) { - *penv = reinterpret_cast(&s_instance->_jni_env); - return JNI_OK; - } +constexpr int RESOLVE_CODE_SIZE_OFFSET = offsetof(ResolveFakes::ConstMethod, code_size); +constexpr int RESOLVE_FLAGS_OFFSET = offsetof(ResolveFakes::ConstMethod, flags); +constexpr int RESOLVE_KLASS_SUPER_OFFSET = offsetof(ResolveFakes::Klass, super_klass); - static ScopedFakeJni* s_instance; - - JavaVM* _saved_vm; - JNINativeInterface_ _jni_tbl{}; - JNIEnv_ _jni_env{}; - JNIInvokeInterface_ _vm_tbl{}; - JavaVM_ _vm{}; - int _fake_class = 0; // address-only sentinel; never dereferenced - int _get_method_id_calls = 0; - std::string _last_method_name; - std::string _last_method_sig; -}; -ScopedFakeJni* ScopedFakeJni::s_instance = nullptr; } // namespace @@ -582,10 +585,9 @@ TEST_F(HotspotResolveCrashProtectionTest, ResolveRecoversFromFaultInsteadOfCrash long long before = Counters::getCounter(METHOD_RESOLVE_FAULT_RECOVERED); - // VMMethod::id() reads the same offset through SafeAccess first, so it - // returns the sentinel rather than faulting; resolve() then falls through to - // constMethod_or_null(), whose raw *(void**) deref is what actually faults. - EXPECT_EQ(nullptr, HotspotSupport::resolve(_region)); + // constMethod_or_null()'s raw *(void**) deref is what faults. + ResolvedMethod result; + EXPECT_FALSE(HotspotSupport::resolve(_region, 0, result)); EXPECT_EQ(before + 1, Counters::getCounter(METHOD_RESOLVE_FAULT_RECOVERED)); // The landing pad must hand the previous (here: absent) context back. @@ -604,7 +606,8 @@ TEST_F(HotspotResolveCrashProtectionTest, ResolveRecoversRepeatedly) { long long before = Counters::getCounter(METHOD_RESOLVE_FAULT_RECOVERED); for (int i = 0; i < 10; i++) { - EXPECT_EQ(nullptr, HotspotSupport::resolve(_region)); + ResolvedMethod result; + EXPECT_FALSE(HotspotSupport::resolve(_region, 0, result)); EXPECT_FALSE(_pt->isProtected()); } EXPECT_EQ(before + 10, Counters::getCounter(METHOD_RESOLVE_FAULT_RECOVERED)); @@ -635,7 +638,8 @@ TEST_F(HotspotResolveCrashProtectionTest, ResolveReturnsNullForUnreadableSymbolB long long before = Counters::getCounter(METHOD_RESOLVE_SYMBOL_UNREADABLE); long long faults_before = Counters::getCounter(METHOD_RESOLVE_FAULT_RECOVERED); - EXPECT_EQ(nullptr, HotspotSupport::resolve(&f->method)); + ResolvedMethod result; + EXPECT_FALSE(HotspotSupport::resolve(&f->method, 0, result)); EXPECT_EQ(before + 1, Counters::getCounter(METHOD_RESOLVE_SYMBOL_UNREADABLE)); // Rejected by the readability probe, not by recovering from a real fault. @@ -644,10 +648,10 @@ TEST_F(HotspotResolveCrashProtectionTest, ResolveReturnsNullForUnreadableSymbolB } // A method name longer than the fixed inline buffer (MAX_METHOD_NAME_LEN, -// 512 bytes) but fully readable must take ResolvedNames::setImpl()'s malloc -// fallback and still resolve successfully through to the JNI phase, with the -// full, correctly NUL-terminated name -- not get rejected the way a name -// this size would be by the *unreadable*-body case below. +// 512 bytes) but fully readable must take ResolvedMethod::setName()'s malloc +// fallback and still resolve successfully, with the full, correctly +// NUL-terminated name -- not get rejected the way a name this size would be +// by the *unreadable*-body case below. TEST_F(HotspotResolveCrashProtectionTest, ResolveUsesMallocFallbackForOversizedButReadableName) { HotspotMethodIdVMHotspotGuard hotspot; VMStructsTestAccessor offsets(RESOLVE_OFFSETS); @@ -669,34 +673,24 @@ TEST_F(HotspotResolveCrashProtectionTest, ResolveUsesMallocFallbackForOversizedB memcpy(long_symbol + RESOLVE_SYMBOL_OFFSETS.symbol_body, long_name.data(), long_name.size()); f->cpool.symbols[1] = (intptr_t)long_symbol; // name_index slot, see link() - ScopedFakeJni fake_jni; - long long unreadable_before = Counters::getCounter(METHOD_RESOLVE_SYMBOL_UNREADABLE); long long fault_before = Counters::getCounter(METHOD_RESOLVE_FAULT_RECOVERED); - // The fake JNI's GetMethodID reports "not found", so resolve() itself - // still returns nullptr -- the point of this test is what it does on the - // way there, checked below. - EXPECT_EQ(nullptr, HotspotSupport::resolve(&f->method)); + ResolvedMethod result; + ASSERT_TRUE(HotspotSupport::resolve(&f->method, 0, result)); - // Phase 1 accepted the name via the malloc fallback (no rejection, no - // fault) and reached phase 2, which called GetMethodID with the fully - // copied name -- proof the fallback copied all 600 bytes and - // NUL-terminated them correctly rather than truncating to the inline - // buffer or leaving it uninitialized. + // The fallback copied all 600 bytes and NUL-terminated them, rather than + // truncating to the inline buffer or leaving it uninitialized. + EXPECT_EQ(long_name, result.methodName()); + EXPECT_STREQ("()V", result.methodSignature()); + EXPECT_STREQ("java/lang/Object", result.klassName()); EXPECT_EQ(unreadable_before, Counters::getCounter(METHOD_RESOLVE_SYMBOL_UNREADABLE)); EXPECT_EQ(fault_before, Counters::getCounter(METHOD_RESOLVE_FAULT_RECOVERED)); - // GetMethodID and GetStaticMethodID share this mock and both report "not - // found", so lookupMethodIdViaJni() falls through from one to the other -- - // two calls, both carrying the same (correct) captured name/signature. - EXPECT_EQ(2, fake_jni.getMethodIdCalls()); - EXPECT_EQ(long_name, fake_jni.lastMethodName()); - EXPECT_EQ("()V", fake_jni.lastMethodSignature()); EXPECT_FALSE(_pt->isProtected()); } // NOTE for reviewers: a companion test asserting the *hard* MAX_SYMBOL_LEN -// ceiling (`len >= MAX_SYMBOL_LEN` in ResolvedNames::setImpl) rejects a +// ceiling (`len >= MAX_SYMBOL_LEN` in ResolvedMethod::setName) rejects a // symbol is intentionally not included here. VMSymbol::length() returns // `unsigned short` (vmStructs.h), so the largest value it can ever produce is // 65535 -- which is already below MAX_SYMBOL_LEN (64 * 1024 = 65536). That @@ -727,7 +721,8 @@ TEST_F(HotspotResolveCrashProtectionTest, ResolveRejectsUnreadableSymbolAtMaxLen long long before = Counters::getCounter(METHOD_RESOLVE_SYMBOL_UNREADABLE); - EXPECT_EQ(nullptr, HotspotSupport::resolve(&f->method)); + ResolvedMethod result; + EXPECT_FALSE(HotspotSupport::resolve(&f->method, 0, result)); EXPECT_EQ(before + 1, Counters::getCounter(METHOD_RESOLVE_SYMBOL_UNREADABLE)); EXPECT_FALSE(_pt->isProtected()); @@ -748,7 +743,8 @@ TEST_F(HotspotResolveCrashProtectionTest, ResolveReturnsNullForEmptySymbol) { long long before = Counters::getCounter(METHOD_RESOLVE_SYMBOL_UNREADABLE); - EXPECT_EQ(nullptr, HotspotSupport::resolve(&f->method)); + ResolvedMethod result; + EXPECT_FALSE(HotspotSupport::resolve(&f->method, 0, result)); EXPECT_EQ(before + 1, Counters::getCounter(METHOD_RESOLVE_SYMBOL_UNREADABLE)); EXPECT_FALSE(_pt->isProtected()); @@ -760,10 +756,190 @@ TEST_F(HotspotResolveCrashProtectionTest, ResolveShortCircuitsSentinelWithoutPro HotspotMethodIdVMHotspotGuard hotspot; long long before = Counters::getCounter(METHOD_RESOLVE_FAULT_RECOVERED); - EXPECT_EQ(nullptr, HotspotSupport::resolve((const void*)JMETHODID_NOT_WALKABLE)); + ResolvedMethod result; + EXPECT_FALSE(HotspotSupport::resolve((const void*)JMETHODID_NOT_WALKABLE, 0, result)); EXPECT_EQ(before, Counters::getCounter(METHOD_RESOLVE_FAULT_RECOVERED)); EXPECT_FALSE(_pt->isProtected()); } +// --------------------------------------------------------------------------- +// resolve(): names, line number table and Thread.run detection +// --------------------------------------------------------------------------- + +namespace { + +// Signed CompressedStream int, valid for small values only: single-byte +// UNSIGNED5 needs the zigzag-encoded value to stay below 191. +unsigned char encodeSmallSigned(int v, unsigned char base) { + unsigned int zigzag = ((unsigned int)v << 1) ^ (unsigned int)(v >> 31); + EXPECT_LT(zigzag, 191u); + return (unsigned char)(zigzag + base); +} + +// Compressed form of {(0,10), (4,11), (9,15), (12,13)}: escaped, inline, +// inline, escaped with a negative line delta. +std::vector sampleLineTable(unsigned char base) { + return { + 0xFF, encodeSmallSigned(0, base), encodeSmallSigned(10, base), + (4 << 3) | 1, + (5 << 3) | 4, + 0xFF, encodeSmallSigned(3, base), encodeSmallSigned(-2, base), + 0x00, + }; +} + +} // namespace + +TEST_F(HotspotResolveCrashProtectionTest, ResolveReturnsNamesWithoutLineNumberTable) { + HotspotMethodIdVMHotspotGuard hotspot; + VMStructsTestAccessor offsets(RESOLVE_OFFSETS); + VMStructsTestAccessor::SymbolLayout layout(RESOLVE_SYMBOL_OFFSETS, RESOLVE_TYPE_SIZES); + VMStructsTestAccessor::LineTableLayout lines(RESOLVE_FLAGS_OFFSET, RESOLVE_CODE_SIZE_OFFSET, + 1, 1, RESOLVE_KLASS_SUPER_OFFSET); + + ResolveFakes* f = new (_region) ResolveFakes{}; + f->link(); + ResolveFakes::setSymbol(f->name_sym, "compute"); + ResolveFakes::setSymbol(f->sig_sym, "(I)J"); + ResolveFakes::setSymbol(f->klass_sym, "com/example/Foo"); + f->const_method.code_size = 8; // has_linenumber_table bit stays clear + + ResolvedMethod result; + ASSERT_TRUE(HotspotSupport::resolve(&f->method, 0, result)); + EXPECT_STREQ("compute", result.methodName()); + EXPECT_STREQ("(I)J", result.methodSignature()); + EXPECT_STREQ("com/example/Foo", result.klassName()); + EXPECT_EQ(nullptr, result.lineNumberTable()); + EXPECT_EQ(0, result.lineNumberTableSize()); + EXPECT_FALSE(result.isThreadRun()); + EXPECT_FALSE(_pt->isProtected()); +} + +// Both UNSIGNED5 flavours: base 0 (JDK < 20) and base 1 (JDK-8292758). +TEST_F(HotspotResolveCrashProtectionTest, ResolveDecodesLineNumberTable) { + for (int b : {0, 1}) { + SCOPED_TRACE(b); + const unsigned char base = (unsigned char)b; + HotspotMethodIdVMHotspotGuard hotspot; + VMStructsTestAccessor offsets(RESOLVE_OFFSETS); + VMStructsTestAccessor::SymbolLayout layout(RESOLVE_SYMBOL_OFFSETS, RESOLVE_TYPE_SIZES); + VMStructsTestAccessor::LineTableLayout lines(RESOLVE_FLAGS_OFFSET, RESOLVE_CODE_SIZE_OFFSET, + 1, base, RESOLVE_KLASS_SUPER_OFFSET); + + ResolveFakes* f = new (_region) ResolveFakes{}; + f->link(); + ResolveFakes::setSymbol(f->name_sym, "compute"); + ResolveFakes::setSymbol(f->sig_sym, "()V"); + ResolveFakes::setSymbol(f->klass_sym, "com/example/Foo"); + std::vector table = sampleLineTable(base); + f->setLineNumberTable(16, table.data(), table.size()); + + ResolvedMethod result; + ASSERT_TRUE(HotspotSupport::resolve(&f->method, 0, result)); + ASSERT_EQ(4, result.lineNumberTableSize()); + const jvmtiLineNumberEntry* entries = result.lineNumberTable(); + ASSERT_NE(nullptr, entries); + const jlocation expected_bci[] = {0, 4, 9, 12}; + const jint expected_line[] = {10, 11, 15, 13}; + for (int i = 0; i < 4; i++) { + EXPECT_EQ(expected_bci[i], entries[i].start_location) << i; + EXPECT_EQ(expected_line[i], entries[i].line_number) << i; + } + + int size = 0; + jvmtiLineNumberEntry* owned = result.takeLineNumberTable(&size); + EXPECT_EQ(entries, owned); + EXPECT_EQ(4, size); + EXPECT_EQ(nullptr, result.lineNumberTable()); + free(owned); + } +} + +// A bci past the bytecode means the metadata is not what it claims to be. +// HotSpot's own ConstMethod::verify_on() accepts bci == code_size, so the +// table is only rejected once its last bci exceeds the code size. +TEST_F(HotspotResolveCrashProtectionTest, ResolveRejectsLineNumberTableWithBciOutOfRange) { + HotspotMethodIdVMHotspotGuard hotspot; + VMStructsTestAccessor offsets(RESOLVE_OFFSETS); + VMStructsTestAccessor::SymbolLayout layout(RESOLVE_SYMBOL_OFFSETS, RESOLVE_TYPE_SIZES); + VMStructsTestAccessor::LineTableLayout lines(RESOLVE_FLAGS_OFFSET, RESOLVE_CODE_SIZE_OFFSET, + 1, 1, RESOLVE_KLASS_SUPER_OFFSET); + + ResolveFakes* f = new (_region) ResolveFakes{}; + f->link(); + ResolveFakes::setSymbol(f->name_sym, "compute"); + ResolveFakes::setSymbol(f->sig_sym, "()V"); + ResolveFakes::setSymbol(f->klass_sym, "com/example/Foo"); + std::vector table = sampleLineTable(1); + f->setLineNumberTable(11, table.data(), table.size()); // last bci is 12 + + long long before = Counters::getCounter(LINE_NUMBER_TABLE_UNREADABLE); + + ResolvedMethod result; + EXPECT_FALSE(HotspotSupport::resolve(&f->method, 0, result)); + EXPECT_EQ(nullptr, result.lineNumberTable()); + EXPECT_EQ(before + 1, Counters::getCounter(LINE_NUMBER_TABLE_UNREADABLE)); + EXPECT_FALSE(_pt->isProtected()); +} + +// A table running off the readable page faults mid-decode; the landing pad +// must report failure (and free any partially built table). +TEST_F(HotspotResolveCrashProtectionTest, ResolveRecoversFromFaultInLineNumberTable) { + HotspotMethodIdVMHotspotGuard hotspot; + VMStructsTestAccessor offsets(RESOLVE_OFFSETS); + VMStructsTestAccessor::SymbolLayout layout(RESOLVE_SYMBOL_OFFSETS, RESOLVE_TYPE_SIZES); + VMStructsTestAccessor::LineTableLayout lines(RESOLVE_FLAGS_OFFSET, RESOLVE_CODE_SIZE_OFFSET, + 1, 1, RESOLVE_KLASS_SUPER_OFFSET); + + ResolveFakes* f = new (_region) ResolveFakes{}; + f->link(); + ResolveFakes::setSymbol(f->name_sym, "compute"); + ResolveFakes::setSymbol(f->sig_sym, "()V"); + ResolveFakes::setSymbol(f->klass_sym, "com/example/Foo"); + // code_size is a u2, so the table can start up to 64K past the bytecode + // base: point it into the guard page. + f->const_method.flags |= 1; + f->const_method.code_size = (uint16_t)(kPageSize() - 16); + + long long before = Counters::getCounter(METHOD_RESOLVE_FAULT_RECOVERED); + + ResolvedMethod result; + EXPECT_FALSE(HotspotSupport::resolve(&f->method, 0, result)); + EXPECT_EQ(before + 1, Counters::getCounter(METHOD_RESOLVE_FAULT_RECOVERED)); + EXPECT_FALSE(_pt->isProtected()); +} + +TEST_F(HotspotResolveCrashProtectionTest, ResolveDetectsRunOfThreadSubclass) { + HotspotMethodIdVMHotspotGuard hotspot; + VMStructsTestAccessor offsets(RESOLVE_OFFSETS); + VMStructsTestAccessor::SymbolLayout layout(RESOLVE_SYMBOL_OFFSETS, RESOLVE_TYPE_SIZES); + VMStructsTestAccessor::LineTableLayout lines(RESOLVE_FLAGS_OFFSET, RESOLVE_CODE_SIZE_OFFSET, + 1, 1, RESOLVE_KLASS_SUPER_OFFSET); + + ResolveFakes* f = new (_region) ResolveFakes{}; + f->link(); + ResolveFakes::setSymbol(f->name_sym, "run"); + ResolveFakes::setSymbol(f->sig_sym, "()V"); + ResolveFakes::setSymbol(f->klass_sym, "com/example/Worker"); + ResolveFakes::setSymbol(f->super_sym, "java/lang/Thread"); + f->super_klass.name_symbol = &f->super_sym; + f->klass.super_klass = &f->super_klass; + + { + ResolvedMethod result; + ASSERT_TRUE(HotspotSupport::resolve(&f->method, 0, result)); + EXPECT_TRUE(result.isThreadRun()); + } + + // Same method on a class that does not extend Thread. + ResolveFakes::setSymbol(f->super_sym, "java/lang/Object"); + { + ResolvedMethod result; + ASSERT_TRUE(HotspotSupport::resolve(&f->method, 0, result)); + EXPECT_FALSE(result.isThreadRun()); + } + EXPECT_FALSE(_pt->isProtected()); +} + #endif // __linux__ diff --git a/ddprof-lib/src/test/cpp/jvmSupport_ut.cpp b/ddprof-lib/src/test/cpp/jvmSupport_ut.cpp index 83ab2ecb4f..5f956bb90b 100644 --- a/ddprof-lib/src/test/cpp/jvmSupport_ut.cpp +++ b/ddprof-lib/src/test/cpp/jvmSupport_ut.cpp @@ -65,6 +65,7 @@ class JVMSupportTestAccessor { static constexpr LoadState NoLoaded() { return JVMSupport::No_loaded; } static constexpr LoadState PartialLoaded() { return JVMSupport::Partial_loaded; } static constexpr LoadState FullyLoaded() { return JVMSupport::Fully_loaded; } + static constexpr LoadState LazyLoaded() { return JVMSupport::Lazy_loaded; } }; // --------------------------------------------------------------------------- @@ -81,6 +82,9 @@ class ProfilerTestAccessor { static State getState(Profiler* p) { return p->_state.load(std::memory_order_acquire); } + // The stack walker mode as Profiler::start() resolved it from the arguments. + static CStack getCStack(Profiler* p) { return p->_cstack; } + static void setCStack(Profiler* p, CStack c) { p->_cstack = c; } }; // GetCurrentThread failing is exactly what JVMThread::currentThreadSlow() @@ -251,12 +255,16 @@ class JVMSupportRestartTest : public ::testing::Test { JNIEnv_ mock_jni{}; bool _orig_hotspot; JVMSupportTestAccessor::LoadState _orig_load_state; + CStack _orig_cstack; void SetUp() override { _orig_hotspot = VMTestAccessor::getHotspot(); VMTestAccessor::setHotspot(true); _orig_load_state = JVMSupportTestAccessor::getLoadState(); JVMSupportTestAccessor::setLoadState(JVMSupportTestAccessor::NoLoaded()); + // As if Profiler::start() had resolved the arguments to the VM walker. + _orig_cstack = ProfilerTestAccessor::getCStack(Profiler::instance()); + ProfilerTestAccessor::setCStack(Profiler::instance(), CSTACK_VM); jvmti_tbl = jvmtiInterface_1_{}; jvmti_tbl.GetLoadedClasses = &mock_GetLoadedClasses_empty; @@ -273,6 +281,7 @@ class JVMSupportRestartTest : public ::testing::Test { void TearDown() override { VMTestAccessor::setHotspot(_orig_hotspot); JVMSupportTestAccessor::setLoadState(_orig_load_state); + ProfilerTestAccessor::setCStack(Profiler::instance(), _orig_cstack); } }; @@ -293,3 +302,52 @@ TEST_F(JVMSupportRestartTest, SecondStartWithPartialPreloadIsNotBlockedByStaleFu // so the state stayed Fully_loaded instead of downgrading. EXPECT_EQ(JVMSupportTestAccessor::PartialLoaded(), JVMSupportTestAccessor::getLoadState()); } + +// fjmethodid=lazy needs VMStructs::hasKlassMethods(); without the Klass*/methods +// structs (as in this binary, where no JVM populated VMStructs) it must fall +// back to the partial preload mode, never enter Lazy_loaded with nothing to +// resolve frames against. +TEST_F(JVMSupportRestartTest, LazyFallsBackToPartialWithoutKlassStructs) { + Arguments lazy_args; + lazy_args._force_jmethodID = false; + lazy_args._lazy_jmethodID = true; + lazy_args._cstack = CSTACK_VM; + + JVMSupport::initExecution(lazy_args, &mock_jvmti, reinterpret_cast(&mock_jni)); + + EXPECT_NE(JVMSupportTestAccessor::LazyLoaded(), JVMSupportTestAccessor::getLoadState()); + EXPECT_EQ(JVMSupportTestAccessor::PartialLoaded(), JVMSupportTestAccessor::getLoadState()); +} + +// Lazy mode, like partial mode, relies on walkVM's raw Method* frames; with a +// cstack mode that goes through AsyncGetCallTrace it must preload everything. +TEST_F(JVMSupportRestartTest, LazyRequiresVmStackWalker) { + Arguments lazy_args; + lazy_args._force_jmethodID = false; + lazy_args._lazy_jmethodID = true; + lazy_args._cstack = CSTACK_NO; + ProfilerTestAccessor::setCStack(Profiler::instance(), CSTACK_NO); + + JVMSupport::initExecution(lazy_args, &mock_jvmti, reinterpret_cast(&mock_jni)); + + EXPECT_EQ(JVMSupportTestAccessor::FullyLoaded(), JVMSupportTestAccessor::getLoadState()); +} + +// What counts is the stack walker mode Profiler::start() resolved, not the +// argument: cstack=default becomes DWARF where the VM walker is unavailable +// (macOS, or a JVM without the stack vmStructs), and then Java frames come from +// AsyncGetCallTrace, which needs every jmethodID preloaded. +TEST_F(JVMSupportRestartTest, DefaultCstackResolvedToDwarfPreloadsEverything) { + ProfilerTestAccessor::setCStack(Profiler::instance(), CSTACK_DWARF); + for (bool lazy : {true, false}) { + JVMSupportTestAccessor::setLoadState(JVMSupportTestAccessor::NoLoaded()); + Arguments args; + args._force_jmethodID = false; + args._lazy_jmethodID = lazy; + args._cstack = CSTACK_DEFAULT; + + JVMSupport::initExecution(args, &mock_jvmti, reinterpret_cast(&mock_jni)); + + EXPECT_EQ(JVMSupportTestAccessor::FullyLoaded(), JVMSupportTestAccessor::getLoadState()) << lazy; + } +} diff --git a/ddprof-lib/src/test/cpp/klassRegistry_ut.cpp b/ddprof-lib/src/test/cpp/klassRegistry_ut.cpp new file mode 100644 index 0000000000..81525ef4ac --- /dev/null +++ b/ddprof-lib/src/test/cpp/klassRegistry_ut.cpp @@ -0,0 +1,448 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#include + +#include "../../main/cpp/counters.h" +#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, UnknownKlassIsNotContained) { + EXPECT_FALSE(registry.contains(jni, klassAt(0))); + ASSERT_TRUE(registry.add(jni, klassAt(1), mirror(1))); + EXPECT_FALSE(registry.contains(jni, klassAt(0))); + EXPECT_FALSE(registry.contains(jni, nullptr)); +} + +TEST_F(KlassRegistryTest, RegisteredClassesAreContained) { + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + ASSERT_TRUE(registry.add(jni, klassAt(1), mirror(1))); + EXPECT_TRUE(registry.contains(jni, klassAt(0))); + EXPECT_TRUE(registry.contains(jni, klassAt(1))); +} + +TEST_F(KlassRegistryTest, CollectedClassIsNotContained) { + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + heap.collect(&mirrors[0]); + EXPECT_FALSE(registry.contains(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 registered at the same Klass* + // address: the new class must win, and the old weak ref be freed. + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + heap.collect(&mirrors[0]); + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(1))); + EXPECT_TRUE(registry.contains(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_TRUE(registry.contains(jni, klassAt(i))) << i; + } + for (size_t i = 10; i < first; i++) { + EXPECT_FALSE(registry.contains(jni, klassAt(i))) << i; + } + for (size_t i = first; i < first + more; i++) { + EXPECT_TRUE(registry.contains(jni, klassAt(i))) << i; + } +} + +// The table is invisible to NMT, so its size and its purges are reported as +// counters: here 690 of the first 700 classes are dead when the 769th +// registration crosses the 3/4 load factor of the initial 1024 slots. +TEST_F(KlassRegistryTest, CountersReportTableSizeAndPurges) { +#ifndef COUNTERS + GTEST_SKIP() << "debug counters are not compiled into this build"; +#else + long long purged_before = Counters::getCounter(KLASS_REGISTRY_PURGED); + const size_t first = 700; + for (size_t i = 0; i < first; i++) { + ASSERT_TRUE(registry.add(jni, klassAt(i), mirror(i))); + } + EXPECT_EQ(1024, Counters::getCounter(KLASS_REGISTRY_CAPACITY)); + EXPECT_EQ((long long)first, Counters::getCounter(KLASS_REGISTRY_ENTRIES)); + for (size_t i = 10; i < first; i++) { + heap.collect(&mirrors[i]); + } + const size_t more = 200; + for (size_t i = first; i < first + more; i++) { + ASSERT_TRUE(registry.add(jni, klassAt(i), mirror(i))); + } + EXPECT_EQ(690, Counters::getCounter(KLASS_REGISTRY_PURGED) - purged_before); + EXPECT_EQ(1024, Counters::getCounter(KLASS_REGISTRY_CAPACITY)); + EXPECT_EQ((long long)registry.size(), Counters::getCounter(KLASS_REGISTRY_ENTRIES)); + EXPECT_EQ(10 + (long long)more, Counters::getCounter(KLASS_REGISTRY_ENTRIES)); + + registry.clear(jni); + EXPECT_EQ(0, Counters::getCounter(KLASS_REGISTRY_CAPACITY)); + EXPECT_EQ(0, Counters::getCounter(KLASS_REGISTRY_ENTRIES)); +#endif +} + +TEST_F(KlassRegistryTest, GrowthKeepsAllLiveEntries) { + const size_t n = 3000; + for (size_t i = 0; i < n; i++) { + ASSERT_TRUE(registry.add(jni, klassAt(i), mirror(i))); + } + EXPECT_EQ(n, registry.size()); + for (size_t i = 0; i < n; i++) { + EXPECT_TRUE(registry.contains(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_FALSE(registry.contains(jni, klassAt(0))); + // Usable again after clear(). + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + EXPECT_TRUE(registry.contains(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(jni, klassAt(0))); + ASSERT_TRUE(registry.beginRedefinition(klassAt(0))); + EXPECT_TRUE(registry.isRedefined(jni, klassAt(0))); + registry.endRedefinition(klassAt(0), true); + EXPECT_TRUE(registry.isRedefined(jni, klassAt(0))); + // Re-registering the same mirror does not clear the mark. + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + EXPECT_TRUE(registry.isRedefined(jni, klassAt(0))); +} + +TEST_F(KlassRegistryTest, RedefinitionInProgressIsRefused) { + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + ASSERT_TRUE(registry.beginRedefinition(klassAt(0))); + EXPECT_TRUE(registry.isRedefined(jni, klassAt(0))); +} + +TEST_F(KlassRegistryTest, FailedRedefinitionLeavesClassAsBefore) { + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + ASSERT_TRUE(registry.beginRedefinition(klassAt(0))); + registry.endRedefinition(klassAt(0), false); + EXPECT_FALSE(registry.isRedefined(jni, klassAt(0))); + EXPECT_TRUE(registry.contains(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(jni, klassAt(0))); + registry.endRedefinition(klassAt(0), false); + EXPECT_FALSE(registry.isRedefined(jni, klassAt(0))); + + ASSERT_TRUE(registry.beginRedefinition(klassAt(0))); + ASSERT_TRUE(registry.beginRedefinition(klassAt(0))); + registry.endRedefinition(klassAt(0), true); + registry.endRedefinition(klassAt(0), false); + EXPECT_TRUE(registry.isRedefined(jni, klassAt(0))); +} + +TEST_F(KlassRegistryTest, RedefinitionOfUnregisteredClassIsIgnored) { + EXPECT_FALSE(registry.beginRedefinition(klassAt(5))); + registry.endRedefinition(klassAt(5), true); + EXPECT_FALSE(registry.isRedefined(jni, klassAt(5))); + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + EXPECT_FALSE(registry.beginRedefinition(klassAt(5))); + // An unmatched end, e.g. for a class registered while the call ran, does + // not underflow the count. + registry.endRedefinition(klassAt(0), false); + EXPECT_FALSE(registry.isRedefined(jni, 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(jni, klassAt(0))); + EXPECT_TRUE(registry.contains(jni, klassAt(0))); +} + +// The hooks never see this (the caller of RedefineClasses holds the class), +// but the registry alone does not rely on it: an end that finds a different +// class at the address does not mark it. +TEST_F(KlassRegistryTest, RedefinitionEndingAfterReplacementDoesNotMarkNewClass) { + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + ASSERT_TRUE(registry.beginRedefinition(klassAt(0))); + heap.collect(&mirrors[0]); + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(1))); + registry.endRedefinition(klassAt(0), true); + EXPECT_FALSE(registry.isRedefined(jni, klassAt(0))); +} + +TEST_F(KlassRegistryTest, UnloadedClassIsReportedAbsent) { + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + heap.collect(&mirrors[0]); + EXPECT_FALSE(registry.contains(jni, klassAt(0))); +} + +// Once a redefined class is unloaded, its mark is no longer reported, even +// before a new class at the same address replaces the entry: that class was +// never redefined. +TEST_F(KlassRegistryTest, MarkOfUnloadedClassIsNotReported) { + ASSERT_TRUE(registry.add(jni, klassAt(0), mirror(0))); + ASSERT_TRUE(registry.beginRedefinition(klassAt(0))); + registry.endRedefinition(klassAt(0), true); + ASSERT_TRUE(registry.isRedefined(jni, klassAt(0))); + heap.collect(&mirrors[0]); + EXPECT_FALSE(registry.isRedefined(jni, klassAt(0))); +} + +// --------------------------------------------------------------------------- +// 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; + bool not_scanned = false; + EXPECT_EQ(scanned, vk->containsMethod(m, 0, ¬_scanned)) << count; + EXPECT_EQ(!scanned, not_scanned) << count; + not_scanned = false; + EXPECT_EQ(scanned, vk->containsMethod(m, -1, ¬_scanned)) << count; + EXPECT_EQ(!scanned, not_scanned) << count; + // A method found at its idnum, or absent from a scanned class, is not + // reported as unscanned. + not_scanned = false; + EXPECT_TRUE(vk->containsMethod(m, count / 2, ¬_scanned)) << count; + EXPECT_FALSE(not_scanned) << count; + } +} 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..a81fad4d63 --- /dev/null +++ b/ddprof-lib/src/test/cpp/redefineHooks_ut.cpp @@ -0,0 +1,239 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#include + +#include "../../main/cpp/hotspot/hotspotSupport.h" +#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; } +}; + +class HotspotSupportTestAccessor { +public: + static int untrackedCalls() { return HotspotSupport::_untracked_redefinition_calls; } + static void setLazyUsed(bool used) { HotspotSupport::_lazy_used = used; } +}; + +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; + int untracked_during_call = -1; // untracked redefinition calls seen by the original +}; +Calls* g_calls = nullptr; + +jvmtiError JNICALL origRedefine(jvmtiEnv*, jint count, const jvmtiClassDefinition* defs) { + g_calls->redefine++; + g_calls->untracked_during_call = HotspotSupportTestAccessor::untrackedCalls(); + g_calls->last_count = count; + g_calls->last_array = defs; + return count > 0 && defs == nullptr ? JVMTI_ERROR_NULL_POINTER : JVMTI_ERROR_INVALID_CLASS; +} + +jvmtiError JNICALL origRetransform(jvmtiEnv*, jint count, const jclass* classes) { + g_calls->retransform++; + g_calls->untracked_during_call = HotspotSupportTestAccessor::untrackedCalls(); + g_calls->last_count = count; + g_calls->last_array = classes; + return count > 0 && classes == nullptr ? JVMTI_ERROR_NULL_POINTER : JVMTI_ERROR_INVALID_CLASS; +} + +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 call that starts before lazy mode is counted while it runs, so that the +// first lazy start can wait for it (its classes are not marked); one that +// starts after is tracked and not counted. +TEST_P(RedefineHooksTest, CallsBeforeLazyModeAreCountedWhileTheyRun) { + jclass classes[] = {nullptr}; + jvmtiClassDefinition defs[] = {{nullptr, 0, nullptr}}; + HotspotSupportTestAccessor::setLazyUsed(false); + VM::RetransformClassesHook(&_jvmti, 1, classes); + EXPECT_EQ(1, _calls.untracked_during_call); + VM::RedefineClassesHook(&_jvmti, 1, defs); + EXPECT_EQ(1, _calls.untracked_during_call); + EXPECT_EQ(0, HotspotSupportTestAccessor::untrackedCalls()); + + HotspotSupportTestAccessor::setLazyUsed(true); + VM::RetransformClassesHook(&_jvmti, 1, classes); + EXPECT_EQ(0, _calls.untracked_during_call); + VM::RedefineClassesHook(&_jvmti, 1, defs); + EXPECT_EQ(0, _calls.untracked_during_call); + HotspotSupportTestAccessor::setLazyUsed(false); +} + +// A null element inside a valid array is skipped as before. +TEST_P(RedefineHooksTest, NullElementIsSkipped) { + jclass classes[] = {nullptr}; + 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 b22c6f42e4..e709954c9d 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/ExternalLauncher.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/ExternalLauncher.java @@ -14,9 +14,18 @@ import java.lang.management.ManagementFactory; import java.lang.management.ThreadMXBean; import java.lang.reflect.Method; +import java.math.BigInteger; import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.Random; +import java.util.TreeMap; import java.util.concurrent.atomic.LongAdder; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * External launcher for the profiler under test. @@ -32,6 +41,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 @@ -58,6 +70,15 @@ * {@link ThreadLocalLeakScenario}: a growing {@code byte[]} collection held only through the * leaking thread's {@code ThreadLocal} map; the {@code -late} variant starts the leak thread * only after the recording began (see that scenario's own comment).
  • + *
  • lazy-calibration-preexisting-arrays <comma delimited profiler command list, required> - + * creates {@code Void} arrays of dimensions 1 to the {@code ddprof_test.calibration_dims} system + * property (default 0), each with a hundred class definitions in between, then starts the + * profiler, prints the {@code klass_trace_id_offset} counter and stops it; see + * {@code LazyTraceIdCalibrationTest}
  • + *
  • line-numbers <';'-delimited profiler command lists, required> - for each command list in + * turn, starts the profiler, runs the same JDK-heavy workload, prints the + * {@code method_resolve_lazy_resolved} counter and stops the profiler; see + * {@code LazyLineNumberConsistencyTest}
  • * */ public class ExternalLauncher { @@ -161,6 +182,41 @@ private static String[] unpackScenarioArgs(String mode, String[] args) { return new String[] {packed.substring(0, sep), packed.substring(sep + "|||".length())}; } + /** How long the {@code line-numbers} workload runs under each command list. */ + private static final long LINE_NUMBER_WORKLOAD_MILLIS = 1500; + + private static volatile long lineNumberSink; + + /** + * Spends its time in many different JDK methods (regular expressions, formatting, maps, + * sorting, big-integer arithmetic), so that their frames, and the lines of their bytecode + * indices, show up in the recording. + */ + private static void runLineNumberWorkload() { + Pattern pattern = Pattern.compile("([a-z]+)-(\\d+)"); + Random rnd = new Random(42); + long acc = 0; + long deadline = System.currentTimeMillis() + LINE_NUMBER_WORKLOAD_MILLIS; + while (System.currentTimeMillis() < deadline) { + Map counts = new HashMap<>(); + TreeMap sorted = new TreeMap<>(); + List words = new ArrayList<>(); + for (int i = 0; i < 200; i++) { + String word = String.format("%s-%d", Long.toString(rnd.nextInt(1 << 20), 36), i); + Matcher m = pattern.matcher(word); + if (m.matches()) { + counts.merge(m.group(1), m.group(2).length(), Integer::sum); + } + sorted.put(word.hashCode(), word); + words.add(word.toUpperCase()); + } + Collections.sort(words); + BigInteger big = BigInteger.valueOf(rnd.nextLong()).pow(8).mod(BigInteger.valueOf(1_000_000_007L)); + acc += counts.size() + sorted.firstKey() + words.get(0).length() + big.intValue(); + } + lineNumberSink = acc; + } + public static void main(String[] args) throws Exception { Thread worker = null; try { @@ -200,6 +256,48 @@ 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("lazy-calibration-preexisting-arrays")) { + if (args.length < 2 || args[1].isEmpty()) { + throw new IllegalArgumentException("lazy-calibration-preexisting-arrays requires a profiler command list"); + } + int dims = Integer.getInteger("ddprof_test.calibration_dims", 0); + Class arrayClass = Void.class; + for (int dim = 1; dim <= dims; dim++) { + for (int i = 0; i < 100; i++) { + SpinClasses.define("com/datadoghq/profiler/generated/CalibrationGap" + dim + "_" + i); + } + arrayClass = java.lang.reflect.Array.newInstance(arrayClass, 0).getClass(); + } + JavaProfiler instance = JavaProfiler.getInstance(); + instance.execute(args[1]); + System.out.println("[calibration] klass_trace_id_offset=" + + instance.getDebugCounters().get("klass_trace_id_offset") + + " arrays=" + arrayClass.getName()); + instance.stop(); + } else if (args[0].equals("line-numbers")) { + if (args.length < 2 || args[1].isEmpty()) { + throw new IllegalArgumentException("line-numbers requires profiler command lists"); + } + JavaProfiler instance = JavaProfiler.getInstance(); + for (String commands : args[1].split(";")) { + instance.execute(commands); + runLineNumberWorkload(); + // Counters are reset on start, so read them while this session's are current; + // the dump on stop() resolves the raw frames, so stop first. + instance.stop(); + System.out.println("[line-numbers] method_resolve_lazy_resolved=" + + instance.getDebugCounters().get("method_resolve_lazy_resolved")); + } } else if (args[0].equals("entry-frames")) { // Unlike the modes above, this one is only meaningful with a running profiler: // silently skipping the start would leave the parent process parsing an empty diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/JfrFrame.java b/ddprof-test/src/test/java/com/datadoghq/profiler/JfrFrame.java index 8f346b1a90..73c9b4ace2 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/JfrFrame.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/JfrFrame.java @@ -45,6 +45,21 @@ public String methodDescriptor() { return method != null ? symbolString(method.get("descriptor")) : null; } + /** The frame's line number, or -1 if unavailable. */ + public int lineNumber() { + return intField("lineNumber"); + } + + /** The frame's bytecode index, or -1 if unavailable. */ + public int bytecodeIndex() { + return intField("bytecodeIndex"); + } + + private int intField(String name) { + Object v = value.get(name); + return v instanceof Number ? ((Number) v).intValue() : -1; + } + /** * The frame's {@code jdk.types.FrameType} description (e.g. {@code "Native (remote)"}), or * {@code null} if the frame type has no entry in the constant pool. diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/SpinClasses.java b/ddprof-test/src/test/java/com/datadoghq/profiler/SpinClasses.java new file mode 100644 index 0000000000..38625c7a00 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/SpinClasses.java @@ -0,0 +1,94 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler; + +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.Label; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** + * Defines throwaway classes with {@code public static void spin(long nanos)}, which busy-waits + * so that CPU samples land in it. Each class gets its own class loader, so nothing else gives it + * jmethodIDs and it can be unloaded once the caller drops it. + */ +public final class SpinClasses { + /** + * The line numbers in {@code spin}'s line number table. The first is large enough that + * HotSpot stores it in the escaped, multi-byte form of its compressed table; the others use + * the single-byte form. + */ + public static final Set SPIN_LINES = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(1000, 1001, 1002))); + + private SpinClasses() {} + + /** + * Defines {@code internalName} (slash-separated) in a fresh class loader. + * + * @param internalName the class name in internal form, e.g. {@code a/b/C} + * @return the defined class + */ + public static Class define(String internalName) { + return new Loader().define(internalName); + } + + /** + * Returns the class file {@link #define} defines for {@code internalName}, e.g. to redefine a + * defined class with its own bytes. + * + * @param internalName the class name in internal form, e.g. {@code a/b/C} + * @return the class file bytes + */ + public static byte[] bytes(String internalName) { + return generate(internalName); + } + + private static final class Loader extends ClassLoader { + Class define(String internalName) { + byte[] bytes = generate(internalName); + return defineClass(internalName.replace('/', '.'), bytes, 0, bytes.length); + } + } + + private static byte[] generate(String internalName) { + ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS); + cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, internalName, null, "java/lang/Object", null); + + MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "spin", "(J)V", null, null); + mv.visitCode(); + Label start = new Label(); + mv.visitLabel(start); + mv.visitLineNumber(1000, start); + // long deadline = System.nanoTime() + nanos; + mv.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/System", "nanoTime", "()J", false); + mv.visitVarInsn(Opcodes.LLOAD, 0); + mv.visitInsn(Opcodes.LADD); + mv.visitVarInsn(Opcodes.LSTORE, 2); + // while (System.nanoTime() < deadline) {} + Label loop = new Label(); + Label done = new Label(); + mv.visitLabel(loop); + mv.visitLineNumber(1001, loop); + mv.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/System", "nanoTime", "()J", false); + mv.visitVarInsn(Opcodes.LLOAD, 2); + mv.visitInsn(Opcodes.LCMP); + mv.visitJumpInsn(Opcodes.IFGE, done); + mv.visitJumpInsn(Opcodes.GOTO, loop); + mv.visitLabel(done); + mv.visitLineNumber(1002, done); + mv.visitInsn(Opcodes.RETURN); + mv.visitMaxs(0, 0); + mv.visitEnd(); + + cw.visitEnd(); + return cw.toByteArray(); + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/ClinitResolutionTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/ClinitResolutionTest.java index 10ca084fd7..7fccc6e1bc 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/ClinitResolutionTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/ClinitResolutionTest.java @@ -21,30 +21,26 @@ *

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

    The sampled class is defined by a custom class loader. In the partial mode + * ({@code fjmethodid=false}) such classes are still preloaded; lazy mode must resolve them + * without preloading. + */ +public class LazyJmethodIdTest extends AbstractProfilerTest { + + private static final String LAZY_COMMAND = "cpu=1ms,cstack=vm,fjmethodid=lazy"; + + private static int classCounter = 0; + + @Override + protected String getProfilerCommand() { + return LAZY_COMMAND; + } + + /** + * Frames from a custom-loader class must resolve to their real names and line numbers, read + * from VM metadata at dump time. + */ + @RetryingTest(3) + public void customLoaderFramesResolve() throws Exception { + assumeHotspot(); + waitForProfilerReady(2000); + + String className = nextClassName("LazySpin"); + Class clazz = SpinClasses.define(className); + clazz.getMethod("spin", long.class).invoke(null, 1_000_000_000L); + + stopProfiler(); + + String simpleName = className.substring(className.lastIndexOf('/') + 1); + int spinFrames = 0; + for (JfrEvent sample : verifyEvents("datadog.ExecutionSample")) { + if (!sample.has("stackTrace")) { + continue; + } + for (JfrFrame frame : sample.getStackTrace().frames()) { + String frameClass = frame.className(); + if (frameClass != null && frameClass.endsWith(simpleName) && "spin".equals(frame.methodName())) { + spinFrames++; + assertTrue(SpinClasses.SPIN_LINES.contains(frame.lineNumber()), + "spin frame has line " + frame.lineNumber() + ", expected one of " + SpinClasses.SPIN_LINES); + } + } + } + assertTrue(spinFrames > 0, "No CPU sample contained a resolved " + simpleName + ".spin frame"); + + // Absent in builds without counters; only check when present. + Long resolved = profiler.getDebugCounters().get("method_resolve_lazy_resolved"); + if (resolved != null) { + assertTrue(resolved > 0, "Expected raw frames to be resolved from VM metadata"); + } + } + + /** + * Frames whose class was unloaded between the sample and the dump must not be + * dereferenced: the dump completes and those frames serialize as unknown. Where debug + * counters are available, the dump must also have refused raw frames, so the class was + * sampled and the refusal path ran. + */ + @Test + @Timeout(60) + 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 { + waitForProfilerReady(2000); + 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_lazy_unreadable=" + counters.get("method_resolve_lazy_unreadable") + + ", method_resolve_fault_recovered=" + counters.get("method_resolve_fault_recovered")); + // Absent in builds without counters; only check when present. + if (counters.containsKey("method_resolve_lazy_unloaded")) { + long refused = counters.getOrDefault("method_resolve_lazy_unloaded", 0L) + + counters.getOrDefault("method_resolve_lazy_not_current", 0L) + + counters.getOrDefault("method_resolve_lazy_unreadable", 0L); + assertTrue(refused > 0, "No raw frame was refused, so the unloaded class was never sampled"); + } + } finally { + try { Files.deleteIfExists(baseFile); } catch (IOException ignored) {} + try { Files.deleteIfExists(dumpFile); } catch (IOException ignored) {} + } + } + + /** + * 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, 1_000_000_000L); + return new WeakReference<>(clazz.getClassLoader()); + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyLineNumberConsistencyTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyLineNumberConsistencyTest.java new file mode 100644 index 0000000000..9738668acc --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyLineNumberConsistencyTest.java @@ -0,0 +1,142 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.cpu; + +import com.datadoghq.profiler.AbstractProcessProfilerTest; +import com.datadoghq.profiler.JfrEvents; +import com.datadoghq.profiler.JfrFrame; +import com.datadoghq.profiler.Platform; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Checks the line number tables {@code fjmethodid=lazy} decodes from HotSpot's compressed + * {@code ConstMethod} tables against the ones JVMTI {@code GetLineNumberTable} returns. + * + *

    A fresh child JVM, in which nothing has allocated jmethodIDs for most classes yet, runs the + * same JDK-heavy workload twice: first in lazy mode, where frames without a jmethodID are + * resolved from VM metadata, then with {@code fjmethodid=true}, where every frame is resolved + * through JVMTI. Every (method, bytecode index) that both recordings contain must map to the + * same line in both. + */ +public class LazyLineNumberConsistencyTest extends AbstractProcessProfilerTest { + private static final String LAZY = "cpu=1ms,cstack=vm,fjmethodid=lazy"; + private static final String JVMTI = "cpu=1ms,cstack=vm,fjmethodid=true"; + // Enough frames for the comparison to cover many methods and both encodings of the + // compressed table, without depending on how much the scheduler lets the workload run. + private static final int MIN_COMMON_FRAMES = 20; + + @Test + @Timeout(120) + public void lazyLineNumbersMatchJvmti() throws Exception { + Assumptions.assumeFalse(Platform.isZing() || Platform.isJ9() || Platform.isGraal(), "HotSpot only"); + + Path lazyRecording = Files.createTempFile("lazy-lines-vm-", ".jfr"); + Path jvmtiRecording = Files.createTempFile("lazy-lines-jvmti-", ".jfr"); + try { + AtomicLong lazyResolved = new AtomicLong(-1); + AtomicReference malformed = new AtomicReference<>(); + LaunchResult result = launch("line-numbers", Collections.emptyList(), + "start," + LAZY + ",jfr,file=" + lazyRecording + ";start," + JVMTI + ",jfr,file=" + jvmtiRecording, + line -> { + String prefix = "[line-numbers] method_resolve_lazy_resolved="; + // The first line is the lazy session's. + if (line.startsWith(prefix) && lazyResolved.get() < 0) { + String value = line.substring(prefix.length()); + try { + lazyResolved.set("null".equals(value) ? 0 : Long.parseLong(value)); + } catch (NumberFormatException e) { + // Reported below; an unset count would only skip the test. + malformed.compareAndSet(null, line); + } + } + return LineConsumerResult.CONTINUE; + }, null); + assertTrue(result.inTime, "child JVM did not finish in time"); + assertEquals(0, result.exitCode, "child JVM failed"); + assertNull(malformed.get(), "unparsable counter line from the child JVM"); + // Lazy mode falls back to partial mode where it cannot run (e.g. no JFR trace IDs); + // its frames still come from VM metadata, but the counter stays 0. + Assumptions.assumeTrue(lazyResolved.get() > 0, "lazy mode resolved no raw frames"); + + List vmHiddenNames = new ArrayList<>(); + List inconsistent = new ArrayList<>(); + Map lazyLines = linesByFrame(lazyRecording, vmHiddenNames, inconsistent); + Map jvmtiLines = linesByFrame(jvmtiRecording, vmHiddenNames, inconsistent); + assertTrue(inconsistent.isEmpty(), "line numbers differ within one recording: " + inconsistent); + // JVMTI reports a hidden class "Foo$$Lambda+0x…" as "Foo$$Lambda.0x…", and lazy + // mode must do the same, or a lambda's frames are named differently per mode. + assertTrue(vmHiddenNames.isEmpty(), "hidden classes not named as JVMTI does: " + vmHiddenNames); + List mismatches = new ArrayList<>(); + int common = 0; + for (Map.Entry e : lazyLines.entrySet()) { + Integer jvmtiLine = jvmtiLines.get(e.getKey()); + if (jvmtiLine == null) { + continue; + } + common++; + if (!jvmtiLine.equals(e.getValue())) { + mismatches.add(e.getKey() + ": lazy " + e.getValue() + ", JVMTI " + jvmtiLine); + } + } + System.out.println("raw frames resolved=" + lazyResolved.get() + ", frames in both recordings=" + common + + ", mismatches=" + mismatches.size()); + assertTrue(common >= MIN_COMMON_FRAMES, "only " + common + " frames in both recordings"); + assertTrue(mismatches.isEmpty(), "line numbers differ: " + mismatches); + } finally { + try { Files.deleteIfExists(lazyRecording); } catch (IOException ignored) {} + try { Files.deleteIfExists(jvmtiRecording); } catch (IOException ignored) {} + } + } + + /** + * Maps "class.method descriptor @bci" to the line the recording gives that frame, for every + * frame of {@code datadog.ExecutionSample}. Frames without a line are kept, so that a table + * one side failed to decode shows up as a mismatch. Adds class names still in HotSpot's + * internal hidden-class form ({@code +0x} suffix) to {@code vmHiddenNames}, and frames the + * recording gives different lines in different samples to {@code inconsistent}. + */ + private static Map linesByFrame(Path recording, List vmHiddenNames, + List inconsistent) throws Exception { + Map lines = new HashMap<>(); + JfrEvents.forEach(recording, "datadog.ExecutionSample"::equals, event -> { + if (!event.has("stackTrace")) { + return; + } + for (JfrFrame frame : event.getStackTrace().frames()) { + if (frame.className() == null || frame.bytecodeIndex() < 0) { + continue; + } + if (frame.className().contains("+0x") && !vmHiddenNames.contains(frame.className())) { + vmHiddenNames.add(frame.className()); + } + String key = frame.className() + "." + frame.methodName() + frame.methodDescriptor() + + " @" + frame.bytecodeIndex(); + Integer previous = lines.put(key, frame.lineNumber()); + if (previous != null && previous != frame.lineNumber()) { + inconsistent.add(recording.getFileName() + " " + key + ": " + previous + " and " + frame.lineNumber()); + } + } + }); + return lines; + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyRedefinitionStressTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyRedefinitionStressTest.java new file mode 100644 index 0000000000..66d544a890 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyRedefinitionStressTest.java @@ -0,0 +1,262 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.cpu; + +import com.datadoghq.profiler.AbstractProfilerTest; +import com.datadoghq.profiler.JfrEvent; +import com.datadoghq.profiler.JfrFrame; +import com.datadoghq.profiler.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.net.URL; +import java.net.URLClassLoader; +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. A second thread retransforms the class meanwhile, which goes through the + * profiler's RetransformClasses hook instead. + */ +public class LazyRedefinitionStressTest extends AbstractProfilerTest { + private static final String CLASS_NAME = "com/datadoghq/profiler/generated/LazyRedefinitionProbe"; + 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); + Long traceIdOffset = profiler.getDebugCounters().get("klass_trace_id_offset"); + if (traceIdOffset != null) { + // Absent in builds without counters. Without a trace ID offset the profiler preloads + // jmethodIDs as in fjmethodid=false, and nothing here would exercise lazy mode. + Assumptions.assumeTrue(Platform.hasJfr(), "JDK without JFR"); + assertTrue(traceIdOffset > 0, "lazy mode is not active: klass_trace_id_offset=" + traceIdOffset); + } + + Class probe = SpinClasses.define(CLASS_NAME); + byte[] bytes = SpinClasses.bytes(CLASS_NAME); + Method spin = probe.getMethod("spin", long.class); + AtomicBoolean done = new AtomicBoolean(false); + AtomicReference failure = new AtomicReference<>(); + AtomicInteger redefinitions = new AtomicInteger(); + AtomicInteger retransformations = 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"); + Thread retransformer = new Thread(() -> { + try { + while (!done.get()) { + inst.retransformClasses(probe); + retransformations.incrementAndGet(); + Thread.sleep(5); + } + } catch (Throwable t) { + failure.compareAndSet(null, t); + } + }, "lazy-redefinition-retransformer"); + spinner.start(); + // Let raw frames of the class pile up first, with no dump in between, so + // that the first dumps after redefinitions start have stale raw frames of + // a marked class to refuse. + Thread.sleep(300); + redefiner.start(); + if (inst.isRetransformClassesSupported()) { + retransformer.start(); + } + + Path dir = Paths.get(System.getProperty("java.io.tmpdir"), "recordings"); + Files.createDirectories(dir); + Path dumpFile = Files.createTempFile(dir, "lazy-redefinition-", ".jfr"); + try { + runScenario(spin, dumpFile, done, failure, redefinitions, retransformations, spinner, redefiner, + retransformer); + } finally { + try { Files.deleteIfExists(dumpFile); } catch (IOException ignored) {} + } + } + + private void runScenario(Method spin, Path dumpFile, AtomicBoolean done, AtomicReference failure, + AtomicInteger redefinitions, AtomicInteger retransformations, Thread spinner, Thread redefiner, + Thread retransformer) throws Exception { + int dumps = 0; + try { + long deadline = System.nanoTime() + DURATION_NANOS; + 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(); + retransformer.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(); + assertNull(failure.get(), "tail spin failed: " + failure.get()); + stopProfiler(); + + Map counters = profiler.getDebugCounters(); + System.out.println("redefinitions=" + redefinitions.get() + " retransformations=" + retransformations.get() + + " dumps=" + dumps + + " klass_registry_redefinitions_marked=" + counters.get("klass_registry_redefinitions_marked") + + " method_resolve_lazy_redefined=" + counters.get("method_resolve_lazy_redefined") + + " method_resolve_lazy_not_current=" + counters.get("method_resolve_lazy_not_current") + + " 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"); + } + + // A frame resolved through a freed or reused Method* would decode some other method's + // line number table, so every resolved spin frame must have one of spin's lines. + String simpleName = CLASS_NAME.substring(CLASS_NAME.lastIndexOf('/') + 1); + int spinFrames = 0; + for (JfrEvent sample : verifyEvents("datadog.ExecutionSample")) { + if (!sample.has("stackTrace")) { + continue; + } + for (JfrFrame frame : sample.getStackTrace().frames()) { + String frameClass = frame.className(); + if (frameClass != null && frameClass.endsWith(simpleName) && "spin".equals(frame.methodName())) { + spinFrames++; + assertTrue(SpinClasses.SPIN_LINES.contains(frame.lineNumber()), + "spin frame has line " + frame.lineNumber() + ", expected one of " + SpinClasses.SPIN_LINES); + } + } + } + assertTrue(spinFrames > 0, "frames of the redefined class sampled after the redefinitions did not resolve"); + } + + /** + * 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 = attachApi(); + if (vmClass == null) { + 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; + } + + /** + * Returns {@code com.sun.tools.attach.VirtualMachine}, or null if this JVM has no attach API. + * On JDK 9+ it comes from the jdk.attach module. On JDK 8 it lives in the JDK's + * {@code lib/tools.jar}, which isn't on the test classpath, so it is loaded from there with a + * child class loader; {@code AttachProvider} finds its Linux/macOS provider through the + * same loader. + */ + private static Class attachApi() throws Exception { + try { + return Class.forName("com.sun.tools.attach.VirtualMachine"); + } catch (ClassNotFoundException e) { + // JDK 8: java.home is /jre + Path toolsJar = Paths.get(System.getProperty("java.home"), "..", "lib", "tools.jar").normalize(); + if (!Files.isRegularFile(toolsJar)) { + return null; + } + ClassLoader loader = new URLClassLoader(new URL[] {toolsJar.toUri().toURL()}, + LazyRedefinitionStressTest.class.getClassLoader()); + return Class.forName("com.sun.tools.attach.VirtualMachine", true, loader); + } + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyTraceIdCalibrationTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyTraceIdCalibrationTest.java new file mode 100644 index 0000000000..6a24b1bf02 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyTraceIdCalibrationTest.java @@ -0,0 +1,85 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.cpu; + +import com.datadoghq.profiler.AbstractProcessProfilerTest; +import com.datadoghq.profiler.Platform; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * {@code fjmethodid=lazy} must find {@code Klass::_trace_id} whatever array classes the application + * has created. + * + *

    Calibration looks for the offset at which a few freshly created array klasses hold nearly + * consecutive JFR trace IDs. If its probes were classes that already existed, created far apart, + * the real field would fail that check while another one could pass it: the mirrors' heap + * addresses, shifted right by 16, can be distinct and close together. A child JVM therefore creates + * {@code Void} arrays of many dimensions, far apart, before the profiler starts, and must + * calibrate to the same offset as a child JVM that creates none. + */ +public class LazyTraceIdCalibrationTest extends AbstractProcessProfilerTest { + private static final String PREFIX = "[calibration] klass_trace_id_offset="; + + @Test + @Timeout(90) + public void calibratesRegardlessOfPreexistingArrayClasses() throws Exception { + Assumptions.assumeFalse(Platform.isZing() || Platform.isJ9() || Platform.isGraal(), "HotSpot only"); + // Without JFR (e.g. 8u before 8u262) there is no trace ID to find. + Assumptions.assumeTrue(Platform.hasJfr(), "JDK without JFR"); + long reference = calibratedOffset(0); + Assumptions.assumeTrue(reference != Long.MIN_VALUE, "build without debug counters"); + assertTrue(reference > 0, "trace-ID calibration failed: klass_trace_id_offset=" + reference); + assertEquals(reference, calibratedOffset(24), + "calibration with pre-created Void arrays found a different offset"); + } + + /** + * The offset a child JVM calibrates to after creating {@code Void} arrays of dimensions 1 to + * {@code dims}, or {@link Long#MIN_VALUE} if the build has no debug counters. + */ + private long calibratedOffset(int dims) throws Exception { + AtomicLong offset = new AtomicLong(Long.MIN_VALUE); + AtomicReference malformed = new AtomicReference<>(); + Path recording = Files.createTempFile("lazy-calibration-", ".jfr"); + LaunchResult result; + try { + result = launch("lazy-calibration-preexisting-arrays", + Collections.singletonList("-Dddprof_test.calibration_dims=" + dims), + "start,cpu=10ms,cstack=vm,fjmethodid=lazy,jfr,file=" + recording, line -> { + if (line.startsWith(PREFIX)) { + String value = line.substring(PREFIX.length()).split(" ")[0]; + if (!"null".equals(value)) { + try { + offset.set(Long.parseLong(value)); + } catch (NumberFormatException e) { + malformed.compareAndSet(null, line); + } + } + } + return LineConsumerResult.CONTINUE; + }, null); + } finally { + try { Files.deleteIfExists(recording); } catch (IOException ignored) {} + } + assertTrue(result.inTime, "child JVM did not finish in time (dims=" + dims + ")"); + assertEquals(0, result.exitCode, "child JVM failed (dims=" + dims + ")"); + assertNull(malformed.get(), "unparsable offset line from the child JVM"); + return offset.get(); + } +} 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..590353a55a --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/cpu/LazyTraceIdJfrValidationTest.java @@ -0,0 +1,151 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.cpu; + +import com.datadoghq.profiler.AbstractProcessProfilerTest; +import com.datadoghq.profiler.Platform; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.io.IOException; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Validates, against JFR itself, the {@code Klass::_trace_id} offset that {@code fjmethodid=lazy} + * finds by calibration. + * + *

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

    This check runs in CI only. The profiler never calls into JFR at runtime: JFR's + * {@code JVM.getClassId()} touches JFR state that only exists once its recorder has been created, + * which the profiler cannot verify safely. + */ +public class LazyTraceIdJfrValidationTest extends AbstractProcessProfilerTest { + private static final String PROBE_CLASS = "com.datadoghq.profiler.generated.LazyTraceIdProbe"; + private static final Pattern LOGGED_ID = Pattern.compile( + "\\[TEST::INFO\\] Lazy resolution of " + Pattern.quote(PROBE_CLASS.replace('.', '/')) + ", trace id (\\d+)"); + + @Test + @Timeout(120) + public void calibratedTraceIdMatchesJfrClassId() throws Exception { + // The profiler's side of the comparison is a debug-build log line. + Assumptions.assumeTrue("debug".equals(System.getProperty("ddprof_test.config")), "needs a debug build"); + Assumptions.assumeFalse(Platform.isZing() || Platform.isJ9() || Platform.isGraal(), "HotSpot only"); + Assumptions.assumeTrue(jfrConsumerAvailable(), "JFR not available"); + + Path jfrRecording = Files.createTempFile("lazy-trace-id-jfr-", ".jfr"); + Path ddprofRecording = Files.createTempFile("lazy-trace-id-ddprof-", ".jfr"); + Path jfrSettings = Files.createTempFile("lazy-trace-id-", ".jfc"); + Files.write(jfrSettings, Arrays.asList( + "", + "", + " ", + " true", + " false", + " ", + "")); + try { + AtomicLong loggedId = new AtomicLong(-1); + AtomicBoolean traceIdTags = new AtomicBoolean(false); + AtomicBoolean tagsLogged = new AtomicBoolean(false); + LaunchResult result = launch("lazy-trace-id", + Arrays.asList("-XX:StartFlightRecording=filename=" + jfrRecording + ",settings=" + jfrSettings + ",dumponexit=true"), + "start,cpu=1ms,cstack=vm,fjmethodid=lazy,jfr,file=" + ddprofRecording, + line -> { + if (line.contains("[TEST::INFO] Raw frame tags: ")) { + tagsLogged.set(true); + if (line.contains("[TEST::INFO] Raw frame tags: klass trace id")) { + traceIdTags.set(true); + } + } + Matcher m = LOGGED_ID.matcher(line); + if (m.find()) { + 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"); + assertTrue(tagsLogged.get(), "the profiler did not log its raw frame tags"); + // JDKs without JFR (e.g. 8u before 8u262) have no trace ID, and lazy mode stays off; + // on every other JDK calibration must find it. + Assumptions.assumeTrue(Platform.hasJfr(), "JDK without JFR"); + assertTrue(traceIdTags.get(), "calibration did not find the trace ID offset"); + assertTrue(loggedId.get() > 0, "the profiler did not log the probe class's trace ID"); + + Set jfrIds = jfrClassIds(jfrRecording, PROBE_CLASS); + 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; + } +}