Skip to content

Add fjmethodid=lazy: allocate jmethodIDs at dump time instead of preloading - #864

Closed
rkennke wants to merge 12 commits into
mainfrom
investigate/lazy-jmethodid-resolution
Closed

rkennke wants to merge 12 commits into
mainfrom
investigate/lazy-jmethodid-resolution

Conversation

@rkennke

@rkennke rkennke commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Effect (opt-in via fjmethodid=lazy; HotSpot with cstack=vm; J9 and Zing unchanged):

  • No jmethodID preloading. jmethodIDs are allocated at dump time, only for classes that actually show up in samples, instead of for every method of every loaded class at ClassPrepare.
  • Memory: preloading costs ~15.8 MiB of native memory per million methods (NMT, JDK 25), about 16.6 bytes per method. Lazy mode's own cost in the same benchmark is about 1 MiB in total (see "Overhead compared with preloading").
  • Class loading: application threads no longer call GetClassMethods or contend on the JVM's jmethodID-creation lock. Registering a class is a hash insert plus a weak global ref.
  • Safe against class unloading, metadata reuse and redefinition: a raw frame whose class has since been unloaded, replaced or redefined resolves as unknown. It never reads freed metadata unprotected and can't crash. The one remaining limit is tag collisions (see "Guarantees and limits"). They are impossible before a JVM has created 2^25 classes, and even then they can at most misattribute a single frame. A counter flags when that becomes possible.

What does this PR do?:

Today fjmethodid=false already stores raw Method* frames, but only for system-loader classes. Custom-loader, hidden and lambda classes, which are the ones with high class churn, are still preloaded. This PR adds fjmethodid=lazy, which preloads nothing.

Motivation:
Preloading jmethodIDs is a noticeable memory and class-loading cost, and most of it is spent on classes that are never sampled.

How it works:

Approach

  1. Sample time (signal handler). When the class has no jmethodID yet,
    fillJavaFrame stores the raw Method*. This existing path is reused unchanged. The
    method is on the stack, so it is valid at that moment.
  2. ClassPrepare and startup. Instead of preloading, HotspotSupport::registerClass
    records Klass* → JNI weak global ref to the mirror in KlassRegistry. Array and
    primitive classes are skipped. At start, GetLoadedClasses registers the classes that
    already exist.
  3. Resolve time (dump thread). Under crash protection, read only the holder Klass*
    and the idnum from the possibly-stale Method* (readMethodHolder). Both are treated
    as lookup keys and never trusted. Then, with protection off (resolveViaRegistry):
    • pin(holder): a NewLocalRef of the weak ref. It returns null if the class is
      gone, which yields an unknown frame and bumps method_resolve_lazy_unloaded.
    • A strongly held mirror keeps its class loader, and so the class's metadata, alive.
      The Klass* can now be dereferenced safely.
    • VMKlass::containsMethod(M, idnum) compares pointers only against the class's
      current _methods array, using an idnum fast path plus a linear fallback. A miss
      yields an unknown frame and bumps method_resolve_lazy_not_current.
    • Only then is M dereferenced. If it has no ID, loadMethodIDsIfNeededImpl(load_all)
      allocates IDs for this one class, including the JDK 8 CLD patch. This bumps
      method_resolve_lazy_class_loaded.

After the first dump that touches a class, its jmethodIDs exist, so later samples carry
real IDs and skip the slow path. Lazy mode needs the stack walker to be vm (as resolved by the profiler:
cstack=default becomes dwarf where the VM walker is unavailable); otherwise the profiler
preloads all jmethodIDs as in the default mode. It also needs VMStructs::hasKlassMethods()
and a calibrated JFR trace-ID offset (below); without either, it falls back to partial
mode. It applies to HotSpot only, and J9 and Zing keep preloading.

KlassRegistry

  • An open-addressing table with linear probing, guarded by a mutex. It is never touched
    from a signal handler.
  • Dead entries (weak ref cleared) are purged whenever the table would grow, so memory
    follows the number of live classes.
  • A different mirror at a known Klass* replaces the entry and deletes the old weak ref.
  • pin() calls NewLocalRef under the lock, so a concurrent rehash can't delete the weak
    ref out from under it. Lock ordering is safe: none of the JNI calls made under the lock
    load classes, so a ClassPrepare can't re-enter it.

Raw frame tags (Klass* / Method* reuse)

Freed class metadata is reused: a new class can get the same Klass* and the same Method* addresses as an unloaded one. So every lazy-mode raw frame carries a 26-bit tag, stored in 10 spare bci bits plus the top 16 bits of the Method*: a marker bit plus the low 25 bits of the holder's JFR class trace ID (Klass::_trace_id). JFR assigns that ID at class creation from one global counter, whether or not JFR is recording, so it is unique per load of a class. At resolve time it must match the pinned class's ID. Frames from different loads of a class therefore never merge into one trace, and stale ones are refused.

Partial-mode (fjmethodid=false) frames stay untagged plain Method*s, as before. resolve() goes by the marker bit of each frame, not by the current mode, so frames that survive a restart in the other mode still take the path they were sampled for. A lazy-mode Method* that uses the tag's address bits, or whose holder's trace ID can't be read, is stored as not walkable (walkvm_raw_frame_untaggable); a lazy frame is never stored without its tag.

Finding Klass::_trace_id

The field isn't in gHotSpotVMStructs, and its position moves between releases. On mainline it even depends on a build option. So there is no offset table. KlassTraceId::calibrate() makes the JVM create six array klasses of java.lang.Void back to back. It then scans every 8-byte offset within sizeof(Klass), and accepts the one offset at which all six hold distinct trace IDs no further apart than consecutive creation allows. Pointers, small integers and mark words fail that test, and more than one match counts as failure. The result is published as the klass_trace_id_offset gauge.

What calibration relies on, checked against 107 OpenJDK GA tags

_trace_id is declared in class Klass (so array klasses carry it); arrayKlass.cpp assigns it (INIT_ID(this)); the ID is counter << 16. All three hold on every JFR-enabled tag:

Line GA tags Result Field declared after
8u 8u201 – 8u252 (7) no _trace_id (pre-JFR): calibration fails, partial-mode fallback —
8u 8u262 – 8u504 (29) holds _biased_lock_revocation_count
11u 11.0.2 – 11.0.32.1 (36) holds _access_flags
17u 17 GA – 17.0.17 (20) holds _access_flags
21u 21 GA – 21.0.9 (10) holds _access_flags
22–24 3 holds _access_flags
25+ 25 GA – 25.0.4.1, 26, 28+18 (8) holds _archived_mirror_index

Calibrated offsets at runtime: 8u462 → 184, 11.0.28 → 168, 17.0.16 → 168, 21.0.8 → 184, 25.0.2 → 192.

Redefinition

A redefined class stays alive, but its old methods array and old Method*s are freed once they're off every stack, and a later redefinition can reuse those addresses within the same class. So:

  • the RedefineClasses/RetransformClasses hooks mark the class before calling the original function. The mark lasts while the call runs, and for good if it succeeds. After a successful call in lazy mode, the class's jmethodIDs are preloaded, so its later frames aren't raw. A call that JVMTI rejects changes nothing, and nothing gets preloaded;
  • raw frames of a marked class are refused (method_resolve_lazy_redefined);
  • resolveViaRegistry() checks the mark again after its metadata reads. If it's still absent then, every read happened before the methods were swapped;
  • redefinitions are tracked in every mode once the registry has entries, because raw frames of a lazy session can outlive a restart in another mode.

The hooks don't touch their arguments before JVMTI has checked them. If a caller passes a null array, or something that isn't a regular class (an array class, or an object that isn't a class at all), the call goes to JVMTI unchanged and fails there with JVMTI's usual error.

To check that a sampled method is still one of its class's current methods, the profiler looks for it in the class's methods array. Normally a method sits at the index given by its own ID number (idnum), so that's a single read. Methods added after the class was parsed can move entries around: bridge methods the JVM generates for default-method conflicts at link time, or a redefinition. Then the profiler scans the array, but only in classes of at most 256 methods. In a larger class such a frame is reported as unknown.

Per-dump cache

Lookup caches each distinct raw method's resolution, keyed by untagged Method* and tag. A method that recurs across traces is then resolved once per dump instead of once per occurrence.

Overhead compared with preloading:

Where Preloading jmethodIDs fjmethodid=lazy Measured?
Memory a JNI method-ID slot per method plus a per-class ID cache, kept for the class's lifetime one 24 B registry entry (table kept ≤ ¾ full) plus one weak global ref per class; IDs only for classes that appear in samples yes, see below
Class loading (app threads) GetClassMethods plus the jmethodID-creation lock at every ClassPrepare a hash insert plus NewWeakGlobalRef under the registry mutex only within noise; parallel class-loading contention not yet measured
Signal handler reads the jmethodID cache same read; on a miss it stores the raw Method* (existing path) plus its tag: four SafeAccess loads (holder chain and trace ID) no
Dump thread nothing extra once per distinct raw method per dump: crash-protection setup, registry mutex, NewLocalRef, tag and methods checks; GetClassMethods once per class seen in samples yes: first dump with nearly every frame raw takes 34 ms (60 ms without the per-dump cache); later dumps ~20 ms either way (macOS, debug build)
Steady state — once a class has been through a dump, its samples carry real jmethodIDs, same as preloading —

Memory, NMT: 20,000 classes × 50 static methods (1M methods), 100 classes per custom loader, all kept reachable. JDK 25.0.1, macOS arm64, release build, cpu=10ms,cstack=vm. Committed-memory deltas from an NMT baseline taken just before loading; median of 3 runs per mode, each in a fresh JVM:

Mode Class Δ Internal Δ Native total Δ (Total − Java heap) Load time
no profiler +13.3 MiB +0.0 MiB +201.2 MiB 395 ms
fjmethodid=true +21.5 MiB +7.6 MiB +218.0 MiB 425 ms
fjmethodid=false +21.5 MiB +7.6 MiB +217.3 MiB 428 ms
fjmethodid=lazy +13.3 MiB +0.2 MiB +201.3 MiB 412 ms
  • On JDK 25, jmethodID memory sits in two NMT categories. The per-class ID cache (NEW_C_HEAP_ARRAY(jmethodID, …, mtClass)) is in Class, about 8.2 MiB here. The JNI method-ID slots (JNIMethodBlockNode::_methods, mtInternal) are in Internal, about 7.6 MiB. Together that's ~15.8 MiB per million methods.
  • Partial mode saves nothing here, because every custom-loader class is still preloaded.
  • Lazy mode's +0.2 MiB in Internal are its weak global refs. NMT doesn't see the registry table, which the profiler allocates with calloc: 32,768 slots × 24 B = 768 KiB for the ~22,500 classes in this run. So lazy mode costs about 1 MiB in total.
  • Load-time differences are within noise. An earlier single-run version of this benchmark reported only the Class category (~8.4 MB) and so undercounted preloading by about half.

x64 Linux results (AWS x64 VM, 16 vCPU, not bare metal; profiler built from 75e687020):

Tests: x64 matrix green, offsets identical to aarch64

./utils/run-containers-tests.sh --arch=x64 --tests='*.Lazy*' (Docker, clean clone):

Cell Result klass_trace_id_offset
glibc debug, JDK 8u462 pass (traceIdOffsetIsCalibrated skipped: JDK 8) 184
glibc debug, JDK 11.0.28 pass 168
glibc debug, JDK 17.0.16 pass 168
glibc debug, JDK 21.0.8 pass 184
glibc debug, JDK 25.0.2 pass 192
musl debug, JDK 21 (com.datadoghq.profiler.cpu.Lazy.*) pass —
glibc ASan, JDK 21, Java *.Lazy* pass (LazyTraceIdJfrValidationTest skipped: needs debug) 184
glibc ASan, all 78 gtest binaries pass, no ASan reports —

LazyTraceIdJfrValidationTest passed in every debug cell, so on x64 the profiler's ID equals JFR's class ID. On this host, ASan binaries in the Docker images crash before main in ~25% of runs whether built from this branch or from main (vm.mmap_rnd_bits=32 with clang 14). Startup crashes with no output were retried; nothing failed after gtest started.

Benchmarks: release build, cpu=10ms,cstack=vm, -Xms=-Xmx, 5 fresh JVMs per mode, modes interleaved. Renaissance rows are from the PR head (8a5d5b8c1); the synthetic rows are from 75e687020, and the Renaissance runs on both builds agree to within 0.3 MiB and the dump-latency spread. Every lazy run confirmed lazy mode (klass_trace_id_offset > 0, method_resolve_lazy_class_loaded > 0). klass_trace_id_beyond_tag_range and klass_registry_add_failed were 0 in every run.

Memory: NMT committed Δ over the no-profiler run, Class + Internal, MiB. On JDK 8u262–25, preloading puts the per-class jmethodID cache under Class (NEW_C_HEAP_ARRAY(jmethodID, …, mtClass)) and the jmethodID slots under Internal (JNIMethodBlockNode: NEW_C_HEAP_ARRAY(Method*, …, mtInternal)), 8 B per method each. NMT doesn't see the profiler's own malloc, so the registry table (24 B per entry, ≤ ¾ full) isn't included; for 80k classes it's ~2–3 MiB.

Workload JDK true lazy
80k classes × 51 methods, kept reachable (4.1M methods) 11 / 21 / 25 +66.0 / +65.9 / +66.0 +0.9 / +1.6 / +1.7
same 8 +71.9 +1.7
class churn, ~6k classes/s through short-lived loaders 21 / 25 +50.1 / +49.2 +3.6 / +2.9
Renaissance dotty 11 / 21 / 25 +2.5 / +2.2 / +2.2 +1.6 / +1.3 / +1.3
Renaissance finagle-http 11 / 21 / 25 +1.8 / +2.3 / +2.2 +1.1 / +1.1 / +1.1
Renaissance als (Spark) 11 / 21 / 25 +4.5 / +4.4 / +4.5 +2.1 / +2.2 / +2.1
  • Preloading costs ~16.2 MiB per million methods on x64, JDK 11–25 (17.6 MiB on JDK 8), matching the macOS figure. On JDK 26+, jmethodIDs live in a JmethodIDTable tagged mtJNI (JDK-8268406); not measured here.
  • Lazy's own cost is under Internal: 0.8–3 MiB in these runs, plus the registry table that NMT doesn't see.
  • On Renaissance, lazy saves 35–55% of the preload cost: a class that shows up in a sample still gets jmethodIDs for every method. The full saving shows when most loaded classes are never sampled (the synthetic rows).
  • Class-loading throughput under churn: true −0.8 to −1.7% and lazy −0.3 to −0.6% against no profiler. Renaissance iteration counts are the same in all modes.

Dump latency at the PR head, median [min–max] ms; first dump after 10 s:

Workload JDK First dump true First dump lazy Later dumps true Later dumps lazy
finagle-http 11 130 [129–137] 501 [493–525] 101 [95–203] 98 [91–154]
finagle-http 21 136 [133–144] 539 [529–546] 107 [91–128] 97 [88–153]
finagle-http 25 140 [135–142] 558 [537–566] 99 [89–128] 94 [83–118]
dotty 11 77 [69–79] 162 [153–198] 42 [35–67] 42 [36–136]
dotty 21 52 [51–77] 132 [131–161] 38 [36–60] 39 [37–62]
dotty 25 53 [50–60] 138 [131–151] 37 [35–63] 38 [37–64]
als 11 27 [26–39] 57 [56–60] 76 [60–148] 78 [61–202]
als 21 30 [28–42] 63 [61–76] 67 [59–89] 71 [59–230]
als 25 27 [26–37] 60 [58–88] 71 [57–152] 72 [60–236]
  • The first lazy dump is 2–4× slower: it allocates the jmethodIDs that true allocated at ClassPrepare on application threads, for the ~1,800–2,700 classes resolved per run. Later dumps are the same.
  • The extra first-dump time per resolved class differs ~20× between workloads (~0.22 ms finagle-http, ~0.05 ms dotty, ~0.01 ms als), so it isn't only ID allocation; not profiled yet.
  • Unload paths only showed up under JDK 8 churn: 632 frames unloaded and 2,144 stale (Klass* reuse caught by the tag) across 5 runs, all resolved as unknown, no crash. On JDK 11+ the churned classes weren't unloaded before their frames were resolved.

Still open: a profile of the lazy first dump; parallel class loading with dumps running at the same time (registry mutex against registerClass); whether JDK 8's slower parallel class loading under fjmethodid=true comes from preloading in general or from this branch.

Additional Notes:

  • Mode selection now uses the stack walker mode as the profiler resolved it, rather than the raw cstack argument. Before, fjmethodid=false with cstack=default on a JVM where that resolves to dwarf skipped preloading, even though dwarf gets its Java frames from AsyncGetCallTrace, which needs jmethodIDs. Lazy mode would have inherited the same gap.
Guarantees and limits
  • No crash path: after the class is pinned, only SafeAccess reads and JNI/JVMTI calls on a live class remain. The holder is read from the possibly stale Method* under crash protection, and is only used as a lookup key.
  • Unloading, replacement, metadata reuse: frames are refused by the trace-ID mismatch: whatever class now owns the memory a stale Method* points into, it is a different load with a different ID. Every class is registered at ClassPrepare, before its methods can run. If registration fails, the class's jmethodIDs are preloaded instead, so it never yields raw frames.
  • Redefinition: refused through the mark described above. This relies on every redefinition going through the hooked JVMTI functions.
  • Tag collisions, the explicit limit:
    • Trace-ID tags can only collide once two classes' IDs are ≥ 2^25 apart, so a JVM that has created fewer than 33,554,432 classes cannot misattribute.
    • Beyond that, a misattribution needs all of: the sampled class is unloaded before its frame is resolved; a newer class takes over the exact Method* address; and the IDs differ by an exact multiple of 2^25.
    • The result would be one frame attributed to a live method of the wrong class, never a crash.
    • klass_trace_id_beyond_tag_range counts registrations past the tag range. If it ever fires in practice, the fix is a 64-bit registry epoch per CallTrace, checked as "holder registered no later than the trace", which makes misattribution impossible.
Alternatives considered
  • Runtime cross-check of the offset against JFR's JVM.getClassId(): prototyped and dropped, because it crashed the JVM. getClassId()'s load barrier needs JFR's recorder to exist, and the profiler itself initializes jdk.jfr.internal.JVM (tsc.cpp) without creating one. There is no race-free way to check for the recorder from native code. The offset is validated against JFR in CI instead (LazyTraceIdJfrValidationTest).
  • Registry-epoch tags as a fallback without JFR trace IDs: prototyped and dropped. A 25-bit epoch wraps after 2^24 registrations, which then refuses every frame of long-lived classes. Bumping it on every registration, which soundness needs, also splits identical stacks into separate traces on every class load. JVMs without JFR trace IDs (8u before 8u262, builds without JFR) use partial mode instead.
  • A per-version offset table: offsets move between releases and with build options, so the offset is calibrated instead.
  • JVMTI ObjectFree on tagged mirrors as an unload signal: the events arrive late, from the service thread, so they don't say when metadata is freed.
  • GC start/finish callbacks: JNI isn't allowed inside them, and they aren't known to cover concurrent class unloading.
  • Mapping Method* → jmethodID through GetClassMethods: buys no safety over SafeAccess plus the redefinition mark, costs a JVMTI call per lookup, and doesn't work on JDK 26+, where jmethodIDs aren't pointer slots.
  • JFR's own sampler (JDK 25+): doesn't cover JDK 8–21, J9 or Zing.
New counters

method_resolve_lazy_{unloaded,not_current,stale,redefined,class_loaded}, klass_registry_add_failed, klass_registry_redefinitions_marked, walkvm_raw_frame_untaggable, klass_trace_id_offset (gauge), klass_trace_id_beyond_tag_range, raw_frame_resolve_cache_hits.

How to test the change?:

  • gtests:
    • klassRegistry_ut: registry, redefinition marks (in progress, failed, overlapping), containsMethod including the scan cap.
    • klassTraceId_ut: offset calibration against decoy layouts.
    • hotspotLazyResolve_ut: end to end, every reuse and redefinition case, partial-mode frames, frames resolved after a mode switch, the per-dump cache and the tag-range counter.
    • redefineHooks_ut: invalid arguments to the hooks.
    • Additions to frame_ut, arguments_ut and jvmSupport_ut (mode selection, including cstack=default resolved to dwarf).
  • Java:
    • LazyJmethodIdTest: custom-loader frames resolve; frames of an unloaded class are dropped; calibration finds the offset.
    • LazyTraceIdJfrValidationTest: the calibrated trace ID equals JFR's class ID from jdk.ClassDefine.
    • LazyRedefinitionStressTest: ~1000 live redefinitions racing ~47 dumps.
  • Where it passed: macOS arm64 (JDK 25), and Linux aarch64 containers on the final code (LazyTraceIdJfrValidationTest needs a debug build, so it skips under ASan; LazyRedefinitionStressTest needs the attach API, which JDK 8 lacks):
8u462 musl 11.0.28 musl 17.0.16 21.0.8 25.0.2
LazyJmethodIdTest pass pass pass pass pass
LazyTraceIdJfrValidationTest (profiler = JFR ID) 2077 = 2077 2152 = 2152 2065 = 2065 2154 = 2154 2211 = 2211
LazyRedefinitionStressTest skipped (no attach API on 8) pass pass pass (also ASan) pass
gtests, debug + ASan all 79 tasks

For Datadog employees:

  • If this PR touches code that signs or publishes builds or packages, or handles
    credentials of any kind, I've requested a security review (run the dd:platform-security-review
    skill, or file a request via the PSEC review form).
    bewaire also runs automatically on every PR.
  • This PR doesn't touch any of that.
  • JIRA: [JIRA-XXXX]

🤖 Generated with Claude Code

rkennke and others added 4 commits October 15, 2026 11:00
…registry

With fjmethodid=lazy (HotSpot, cstack=vm), no jmethodIDs are preloaded at
ClassPrepare. Instead every class is recorded in KlassRegistry, a Klass* to
weak-mirror table. walkVM keeps storing raw Method* frames for classes
without jmethodIDs; at dump time resolve() reads only the holder Klass* and
idnum from the Method*, pins the registered mirror (null if the class was
unloaded), checks that the Method* is among the pinned class's current
methods, and only then allocates jmethodIDs for that one class.

Falls back to the partial mode when the Klass*/methods vmStructs are
missing. Adds method_resolve_lazy_{unloaded,not_current,class_loaded}
counters, gtests for the registry, containsMethod, argument parsing and the
load-state selection, and a Java test covering custom-loader resolution and
frames of an unloaded class.

Klass* address reuse after class unloading is not handled yet; see
doc/LazyJmethodIdResolution.md (investigation notes, to be removed before
landing).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ass* addresses

A class unloaded and replaced by a new class at the same Klass* (and
Method*) addresses made frames sampled from the old class resolve as
methods of the new one, and made both produce identical raw frames.

KlassRegistry now keeps an epoch, bumped when a known Klass* gets a
different mirror and when a rehash purges dead entries; each entry records
the epoch it was registered at. walkVM stores the current epoch in every
raw frame as a 26-bit tag (10 spare bci bits plus the top 16 bits of the
Method*), and pin() refuses frames older than the entry. A Method* that
uses the tag's address bits is stored as not walkable. If registering a
class fails, its jmethodIDs are preloaded so it never yields raw frames.

resolve() now takes the frame's bci to recover the tag. Adds
method_resolve_lazy_stale, klass_registry_add_failed and
walkvm_raw_frame_untaggable counters, an end-to-end gtest for the
Klass*-reuse cases, epoch tests for the registry and tag round-trip tests
for FrameType.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
KlassTraceId::calibrate() makes the JVM create six array klasses of
java.lang.Void back to back and accepts the single 8-byte-aligned offset
within sizeof(Klass) at which all of them hold distinct JFR trace IDs
(raw >> 16) no further apart than consecutive creation allows; anything
else, including an ambiguous match, leaves the offset unknown. Runs when
fjmethodid=lazy is selected and publishes the result in the
klass_trace_id_offset gauge. Not used by the sampler yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When KlassTraceId found Klass::_trace_id, walkVM now stores the low 26
bits of the holder's trace ID (read with SafeAccess via the new
VMMethod::holderOrNull()) as the raw frame's tag instead of the registry
epoch, and resolveViaRegistry() compares it with the pinned class's trace
ID. The ID is unique per load of a class, so frames of an earlier class at
a reused Klass* are refused without the epoch, and unrelated registry
bumps no longer change a stack's frames. Frames whose holder trace ID is
unreadable are stored as not walkable.

The tag source is chosen once per process: calibration now runs only on
the first lazy start, and the klass_trace_id_offset gauge is republished
on every start since counters are reset then. The end-to-end lazy-resolve
gtest runs every case under both tag sources.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@datadog-datadog-prod-us1

This comment has been minimized.

@dd-octo-sts

dd-octo-sts Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

CI Test Results

Run: #38759187771 | Commit: 5336479 | Duration: 18m 39s (longest job)

✅ All 32 test jobs passed

Status Overview

JDK glibc-aarch64/debug glibc-amd64/debug musl-aarch64/debug musl-amd64/debug
8 - ✅ - -
8-ibm - ✅ - -
8-j9 ✅ ✅ - -
8-librca - - ✅ ✅
8-orcl - ✅ - -
11 - ✅ - -
11-j9 ✅ ✅ - -
11-librca - - ✅ ✅
17 ✅ ✅ - -
17-graal ✅ ✅ - -
17-j9 ✅ ✅ - -
17-librca - - ✅ ✅
21 ✅ ✅ - -
21-graal ✅ ✅ - -
21-librca - - ✅ ✅
25 ✅ ✅ - -
25-graal ✅ ✅ - -
25-librca - - ✅ ✅

Legend: ✅ passed | ❌ failed | ⚪ skipped | 🚫 cancelled

Summary: Total: 32 | Passed: 32 | Failed: 0


Updated: 2026-10-15 15:41:46 UTC

…st JFR in CI

The top bit of a raw frame's tag now says whether the rest is a trace ID
or a registry epoch (25 bits each), and resolve() goes by the frame's own
kind rather than the current tag source.

In debug builds, resolveViaRegistry() logs the trace ID it read for each
class it allocates jmethodIDs for. LazyTraceIdJfrValidationTest runs a
child JVM (new ExternalLauncher mode lazy-trace-id) with the profiler in
lazy mode and JFR's own sampler, and requires JFR's RecordedClass.getId()
for the probe class to equal that ID. The spin class generator moves to a
shared SpinClasses test helper.

The profiler does not call into JFR at runtime: JVM.getClassId() reaches
JFR state that only exists once its recorder has been created, and the
profiler itself initializes jdk.jfr.internal.JVM (tsc.cpp), so that class
being initialized is no sign the recorder exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rkennke and others added 3 commits October 15, 2026 14:23
Review fixes for fjmethodid=lazy:

- VMKlass::containsMethod() reads the methods array only through
  SafeAccess. Pinning the class does not keep the array alive: a
  redefinition swaps in a new one at a safepoint and frees the old one at
  a later safepoint, neither of which waits for the native dump thread.

- Redefinition: the RedefineClasses/RetransformClasses hooks mark the
  class in KlassRegistry before calling the original function and preload
  its jmethodIDs afterwards. pin() refuses raw frames of a marked class,
  and resolveViaRegistry() checks the mark again after its metadata reads,
  since a redefinition can free a Method* and reuse its address within
  the same class without changing its mirror, trace ID or epoch. New
  counter method_resolve_lazy_redefined; pin() now reports a PinStatus.

- Every registration bumps the registry epoch. Previously only
  replacements and purges did, so a dead class's Method* reused by a class
  loaded at a different Klass* could match an epoch-tagged frame.

- klass_trace_id_beyond_tag_range counts registrations of classes whose
  trace ID no longer fits in the 25-bit tag, the first point at which two
  classes' trace-ID tags can collide.

- klassRegistry_ut includes vmStructs.inline.h so it links in release.

The doc gets a "Guarantees and limits" section stating the exact
condition under which a tag collision can still misattribute a frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tion samples

LazyTraceIdJfrValidationTest now records only jdk.ClassDefine in the
child JVM (settings written by the test) and compares the probe class's
RecordedClass.getId() from that event with the trace ID the profiler
logged. JFR emits the event once per class on every JFR-enabled release,
whereas JFR's pre-25 sampler recorded almost no jdk.ExecutionSample
events for the spinning probe, even without the profiler running.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lookup now keeps a per-dump map from (untagged Method*, raw-frame tag) to
the resolved jmethodID, or nullptr for a refused frame, so a raw method
that recurs across traces is resolved once per dump instead of once per
occurrence. Each resolution sets up crash protection, takes the
KlassRegistry mutex and pins the class through JNI. New counter
raw_frame_resolve_cache_hits. Tests cover repeated occurrences with
different bci and frame type, a fresh cache per dump, and frames from two
loads of a class at the same addresses staying apart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dd-octo-sts

dd-octo-sts Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

✅ All 40 integration tests passed

📊 Dashboard · 👷 Pipeline · 📦 8a5d5b8c

rkennke and others added 3 commits October 15, 2026 15:55
RedefineClassesHook and RetransformClassesHook now forward a call with a
positive count and a null array straight to the original function, which
returns JVMTI_ERROR_NULL_POINTER; before, the hooks' own loops
dereferenced the array first and crashed, in every jmethodID mode.

In fjmethodid=lazy, the per-class work before and after the call only
touches elements that JVMTI's GetClassStatus accepts as instance classes
(HotspotSupport::isInstanceClass, also used by registerClass), so a
non-class object or an array class is never looked up as a Klass* or
preloaded.

redefineHooks_ut calls both hooks with stubbed originals in lazy and
full-preload mode: null arrays, non-class, array-class and null elements.
Without the fix its first case crashes with SIGSEGV.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
LazyRedefinitionStressTest loads a small agent by self-attach to get
Instrumentation, then redefines a generated class with its own bytes
about every 2 ms while another thread spins in it, fjmethodid=lazy
samples it and the profiler dumps every 100 ms. Raw frames of the class
are collected for 300 ms before the first redefinition, so the first
dumps afterwards have stale raw frames of a marked class to refuse. The
test requires the JVM to survive, every redefinition to be marked, a
stale raw frame to be refused, and frames sampled after the
redefinitions to resolve by name.

New counter klass_registry_redefinitions_marked counts the marks the
redefinition hooks set. SpinClasses can hand out a class's bytes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Raw frames of fjmethodid=lazy are now always tagged with the holder's JFR
trace ID. When the trace-ID offset can't be calibrated (8u before 8u262,
builds without JFR), lazy mode falls back to partial mode instead of
tagging frames with the registry epoch. The 25-bit epoch wrapped after
2^24 registrations, after which every frame of a long-lived class was
refused, and bumping it on every registration split identical stacks into
separate traces. KlassRegistry no longer keeps an epoch.

- Only lazy mode tags frames. Partial-mode frames are plain Method*s
  again, and resolve() picks the lazy or partial path from the frame's
  marker bit rather than the current mode, so frames that outlive a
  restart in the other mode still resolve the way they were sampled.
- Redefinition: the hooks call KlassRegistry::beginRedefinition() before
  and endRedefinition() after the original function. A class is refused
  while a call runs and for good once one succeeds; a call JVMTI rejects
  leaves it as before and preloads nothing. Redefinitions are tracked in
  every mode once the registry has entries.
- Mode selection uses the stack walker mode the profiler resolved, not
  the cstack argument: cstack=default resolves to dwarf where the VM
  walker is unavailable, and then all jmethodIDs must be preloaded. This
  also applied to fjmethodid=false.
- VMKlass::containsMethod() scans at most 256 methods after the idnum
  fast path.
- readMethodHolder() uses VMMethod::holderOrNull().
- The rehash() comment now matches what the code does.

The prototype design notes (doc/LazyJmethodIdResolution.md) are removed;
their content moves to the PR description.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rkennke rkennke changed the title [Investigation] fjmethodid=lazy: allocate jmethodIDs at dump time instead of preloading Add fjmethodid=lazy: allocate jmethodIDs at dump time instead of preloading Oct 15, 2026
The parse runs in the thread that reads the child JVM's output, so an
exception there would stop that thread instead of failing the test. A
value that does not parse now leaves the logged ID unset, which the
existing assertion reports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rkennke

rkennke commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #868, which combines this PR's class-liveness checks with the VM-metadata method resolution from #867 and no longer allocates jmethodIDs at dump time.

@rkennke rkennke closed this Oct 20, 2026
rkennke added a commit that referenced this pull request Oct 20, 2026
Working notes for the branch, to be removed before the PR lands.

Co-Authored-By: Zhengyu Gu <zhengyu.gu@datadoghq.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rkennke added a commit that referenced this pull request Oct 21, 2026
Working notes for the branch, to be removed before the PR lands.

Co-Authored-By: Zhengyu Gu <zhengyu.gu@datadoghq.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant