Skip to content

Fix JFR startup deadlock without disabling built-in JFR events#11957

Merged
gh-worker-dd-mergequeue-cf854d[bot] merged 12 commits into
masterfrom
rkennke/fix-jfr-handlers-clinit-deadlock
Jul 16, 2026
Merged

Fix JFR startup deadlock without disabling built-in JFR events#11957
gh-worker-dd-mergequeue-cf854d[bot] merged 12 commits into
masterfrom
rkennke/fix-jfr-handlers-clinit-deadlock

Conversation

@rkennke

@rkennke rkennke commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

What Does This Do

Fixes the JFR startup deadlock from #10096 for real, without disabling the JDK's built-in JFR events, and enforces the ordering structurally so it can't silently regress.

registerSmapEntryEvent() could deadlock against JFR class initialization (SCP-1278, upstream JDK-8371889). PR #10096 tried to pre-empt this by calling AGENT_CLASSLOADER.loadClass("jdk.jfr.events.Handlers") early. That doesn't work, for two independent reasons this PR corrects:

  1. loadClass() doesn't initialize the class. It loads the class but never runs its <clinit>, so it never acquires the class-initialization lock and never prevents the deadlock. Initialization stays deferred to first active use, inside the racing window. Class.forName(name, true, loader) is required to force <clinit>.
  2. The class name was wrong on most affected JDKs. jdk.jfr.events.Handlers only exists on JDK 17-18. On JDK 19-22 the holder is jdk.jfr.events.EventConfigurations (same eager Utils-based init, same deadlock), so the original loadClass threw ClassNotFoundException and silently no-op'd there.

Motivation

The deadlock (ABBA)

The holder class's <clinit> looks up the JDK's built-in event handlers through jdk.jfr.internal.Utils, taking its monitor. In the SCP-1278 thread dump:

  • dd-task-scheduler: registerSmapEntryEvent()EventType.getEventType() holds the Utils monitor, then wants the holder's class-init lock.
  • dd-jmx-collector: JMXFetch YAML parse → ByteBuddy transform is mid-<clinit> of the holder (holds the class-init lock), then wants the Utils monitor.

Cross-wait → the holder init never completes → every thread that subsequently touches file I/O or classloading (including the application's main) piles up on the same monitor.

Forcing the holder's <clinit> on a single controlled thread before we register our own JFR events removes the edge our own code contributed (holding Utils while wanting the class-init lock). Once the holder is fully initialized, the later registration finds it already-initialized and needs no class-init lock, so the cycle cannot form — even with JMXFetch running concurrently.

Why ordering matters twice

The fix has to sit inside a specific window, which the upstream JDK-8371889 fix makes explicit — it calls the new EventConfigurations.initialize() between JDKEvents.initialize() and JDKEvents.addInstrumentation():

  • After the JDK events are registered. The holder's fields are static final and are populated from Utils at <clinit> time. Utils.getHandler/getConfiguration returns null until the events are registered (via FlightRecorder init → JDKEvents.initialize()). Running <clinit> too early caches null into those fields permanently, silently disabling the built-in socket/file/exception JFR events.

    This matters here because with the default profiling.start-force-first=false, the profiler defers FlightRecorder initialization until after startJmx(), so a naive early forName would poison the handlers. Verified empirically on two unpatched JDKs:

    JDK Holder <clinit> before FlightRecorder after FlightRecorder
    17.0.17 Handlers 7/7 fields NULL 0/7 NULL
    21.0.9 EventConfigurations 7/7 fields NULL 0/7 NULL

    So we force FlightRecorder initialization first (which runs JDKEvents.initialize()), then the holder <clinit>. If FlightRecorder init fails, JFR is unavailable and there is nothing to protect against, so the holder init is skipped.

  • Before the concurrency. Forcing <clinit> early, on this thread, before our JFR registrations take the Utils monitor.

What This Changes

  • initializeJfrEventHolderClass(ClassLoader) forces FlightRecorder init, then the holder <clinit>, trying jdk.jfr.events.Handlers (17-18) and falling back to jdk.jfr.events.EventConfigurations (19-22).
  • Single choke point. registerDeadlockDetectionEvent() and registerSmapEntryEvent() shared the same reflective "invoke Factory.registerEvents()" pattern; both now route through one registerJfrEvents(...) method that force-initializes the holder first. Routing all startup JFR registration through this one method enforces the ordering structurally — a future event registration cannot reintroduce the deadlock by running before the holder init. This also unifies exception handling (and hardens the smap path, which previously caught only Exception and would have let an Error escape startup).
  • Everything runs under a single catch (Throwable) (a missing/renamed holder class or a failed init must never break startup); a narrow inner catch (ClassNotFoundException) only drives the HandlersEventConfigurations version fallback. Throwable rather than Exception because forcing initialization can surface Errors (ExceptionInInitializerError, NoClassDefFoundError, LinkageError).

Testing

Adds JfrEventHolderInitForkedTest (JUnit 5, forked JVM): it runs the production init path in a fresh JVM and asserts none of the holder's handler fields were poisoned to null. This locks in the FlightRecorder-before-holder ordering — dropping or reordering it fails the test.

  • Forked because JFR/class initialization is once-per-JVM; the ordering is only exercised in a JVM that hasn't touched JFR yet.
  • Verified: passes on JDK 17 and 21 with correct ordering; fails when the ordering is reversed (negative control confirmed).
  • Auto-skips on JDKs without the holder class (JDK 8, and 23+ where the eager-init pattern was removed).
  • build.gradle adds --add-opens jdk.jfr/jdk.jfr.events=ALL-UNNAMED so the test can read the holder fields reflectively.

The deadlock itself is timing-dependent and is not reproduced as a test (a spin-loop repro would be flaky in CI); it is instead defended structurally by the choke point above, with the poisoning invariant covered by the test.

JDK Version Notes

The holder class was renamed/removed across versions: jdk.jfr.events.Handlers on JDK 17-18, jdk.jfr.events.EventConfigurations on JDK 19-22. JDK 23+ removed the eager-init pattern entirely (the deadlock cannot occur), and the JDK-8371889 fix was backported to 21.0.11 — so EventConfigurations coverage is a safety net for unpatched 21.0.0-21.0.10 and JDK 20. On JDKs without either class the forName calls simply fail and are ignored.

The proper fix is the JDK-side lock-ordering change in JDK-8371889; this remains the workaround for unpatched JDKs.

Jira ticket: PROF-13025

🤖 Generated with Claude Code

PR #10096 attempted to preload jdk.jfr.events.Handlers via
ClassLoader.loadClass() to prevent an AB-BA deadlock between the JFR
Utils monitor and the Handlers class-initialization lock (JDK-8371889).

However, ClassLoader.loadClass() only loads the class; it does not run
the static initializer and does not acquire the class-initialization
lock. Initialization is still deferred to first active use, inside the
racing window, so the deadlock is not actually prevented.

Use Class.forName(name, true, loader) instead, which forces <clinit> to
run on this thread before the JFR code acquires the Utils monitor. Also
widen the catch to Throwable, since forcing initialization can surface
Errors (ExceptionInInitializerError, NoClassDefFoundError, LinkageError)
that the previous catch (Exception) would not have handled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@rkennke rkennke marked this pull request as ready for review July 15, 2026 10:54
@rkennke rkennke requested a review from a team as a code owner July 15, 2026 10:54
@rkennke rkennke requested a review from PerfectSlayer July 15, 2026 10:54
@dd-octo-sts

dd-octo-sts Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Hi! 👋 Thanks for your pull request! 🎉

To help us review it, please make sure to:

  • Add at least one type, and one component or instrumentation label to the pull request

If you need help, please check our contributing guidelines.

@dd-octo-sts dd-octo-sts Bot added the tag: ai generated Largely based on code generated by an AI or LLM label Jul 15, 2026

@datadog-prod-us1-4 datadog-prod-us1-4 Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Datadog Autotest: PASS

More details

The change correctly fixes the incomplete deadlock mitigation from PR #10096. The original fix using loadClass() only loaded the class but didn't initialize it, leaving the deadlock window open. This PR correctly uses Class.forName(..., true, ...) to force immediate initialization, and broadens exception handling from Exception to Throwable to catch Error subtypes that can occur during initialization. The code is well-commented, scope is minimal, and no behavioral regressions detected.

Was this helpful? React 👍 or 👎

📊 Validated against 7 scenarios · Open Bits AI session

🤖 Datadog Autotest · Commit 029c60c · What is Autotest? · Any feedback? Reach out in #autotest

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 029c60ce0e

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/Agent.java Outdated
Comment thread dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/Agent.java Outdated
Comment thread dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/Agent.java Outdated
@datadog-prod-us1-4

datadog-prod-us1-4 Bot commented Jul 15, 2026

Copy link
Copy Markdown

🎯 Code Coverage (details)
Patch Coverage: 3.33%
Overall Coverage: 61.26% (+4.08%)

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 0d01fd1 | Docs | Datadog PR Page | Give us feedback!

@dd-octo-sts

dd-octo-sts Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

🟢 Java Benchmark SLOs — All performance SLOs passed

Suite Status
Startup 🟢 pass

SLO thresholds are defined here based on automatically generated metrics. A warning is raised when results are within 5% of the threshold.

PR vs. master results
Scenario Candidate master Δ (95% CI of mean)
startup:insecure-bank:iast:Agent 14.81 s 14.73 s [-0.3%; +1.4%] (no difference)
startup:insecure-bank:tracing:Agent 13.59 s 13.76 s [-2.2%; -0.4%] (maybe better)
startup:petclinic:appsec:Agent 16.87 s 16.71 s [-0.1%; +2.0%] (no difference)
startup:petclinic:iast:Agent 16.84 s 16.95 s [-1.6%; +0.2%] (no difference)
startup:petclinic:profiling:Agent 16.53 s 16.05 s [-2.9%; +8.7%] (unstable)
startup:petclinic:sca:Agent 16.91 s 16.80 s [-0.3%; +1.7%] (no difference)
startup:petclinic:tracing:Agent 16.09 s 16.13 s [-1.2%; +0.6%] (no difference)

Commit: 0d01fd13 · CI Pipeline · Benchmarking Platform UI


Load and DaCapo benchmarks can be triggered manually in the GitLab pipeline. Results will appear in the Benchmarking Platform UI after completion.

@rkennke rkennke requested a review from jbachorik July 15, 2026 12:04
rkennke and others added 2 commits July 15, 2026 17:23
…gistration

Two follow-ups from PR review:

- The JFR event-holder class was renamed across JDK versions. It is
  jdk.jfr.events.Handlers only on JDK 17-18; on JDK 19-22 it is
  jdk.jfr.events.EventConfigurations (same Utils-based eager init, same
  deadlock); on JDK 23+ the pattern was removed. Targeting only Handlers
  meant the workaround silently no-op'd (ClassNotFoundException) on JDK
  19-22, which are affected. Try both class names.

- registerDeadlockDetectionEvent() also registers a JFR event and runs
  before registerSmapEntryEvent(). Hoist the holder-class initialization
  into startJmx() ahead of both registrations so it is always fully
  initialized before any JFR registration acquires the Utils monitor.

Extracted the logic into initializeJfrEventHolderClass().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…dlers

Forcing the holder class's <clinit> (jdk.jfr.events.Handlers on JDK 17-18,
EventConfigurations on 19-22) before the JDK's built-in events are
registered caches null into its static-final handler fields permanently,
silently disabling the built-in socket/file/exception JFR events.
Empirically verified on JDK 17.0.17 and 21.0.9 (7/7 fields null when the
holder is initialized before FlightRecorder; 0/7 when after).

In the default profiling.start-force-first=false config the profiler
defers FlightRecorder initialization until after startJmx(), so the
previous placement would poison the handlers. Force FlightRecorder init
(which runs JDKEvents.initialize()) before the holder <clinit>, mirroring
the JDK's own JDK-8371889 fix which initializes the holder only after
JDKEvents.initialize(). If FlightRecorder init fails there is nothing to
protect, so the holder init is skipped. Collapsed the handling into a
single catch(Throwable) with a narrow inner ClassNotFoundException for the
Handlers -> EventConfigurations version fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@rkennke rkennke changed the title Actually initialize JFR Handlers class early to avoid deadlock Fix JFR startup deadlock without disabling built-in JFR events Jul 15, 2026
rkennke and others added 5 commits July 15, 2026 21:00
Adds JfrEventHolderInitForkedTest, which runs the production
initialization path (Agent.initializeJfrEventHolderClass) in a fresh JVM
and asserts the JDK's built-in JFR event-holder fields are not poisoned to
null. This guards the ordering invariant that FlightRecorder must be
initialized (registering the built-in events) before the holder class's
<clinit> runs; without it the static-final handler fields cache null
permanently and silently disable socket/file/exception JFR events.

Verified: passes on JDK 17 and 21 with the correct ordering, and fails
when the ordering is reversed. Auto-skips on JDKs without the holder class
(JDK 8 and 23+). Behavior underpinning the test was reproduced standalone
on JDK 17.0.17 and 21.0.9 (7/7 fields null when initialized too early,
0/7 when after FlightRecorder).

To make the logic testable, initializeJfrEventHolderClass() takes the
ClassLoader as an argument (the production no-arg overload passes
AGENT_CLASSLOADER). build.gradle adds --add-opens
jdk.jfr/jdk.jfr.events=ALL-UNNAMED so the test can read the holder fields
reflectively.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both registerDeadlockDetectionEvent() and registerSmapEntryEvent() shared
the same reflective "invoke Factory.registerEvents()" pattern. Extract it
into registerJfrEvents(), which force-initializes the JDK JFR event-holder
class before registering anything. Routing all startup JFR registration
through this one method enforces the ordering invariant structurally, so a
future event registration cannot reintroduce the JDK-8371889 deadlock by
running before the holder is initialized. The explicit pre-init call in
startJmx() is now redundant and removed.

Also unifies exception handling: NoClassDefFoundError/ClassNotFoundException/
UnsupportedClassVersionError -> "not supported" (debug); any other Throwable
-> error. This is slightly more robust for the smap path, which previously
caught only Exception and would have let an Error (e.g. NoClassDefFoundError)
propagate out of startup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Correct the Handlers version range to JDK 15-18 (it is absent on 11-14),
  and note that JDK 11 LTS and earlier predate the holder class, so they
  are unaffected.
- Move the explanatory Javadoc onto the initializeJfrEventHolderClass
  implementation (it was on the no-arg wrapper, while @link targets and the
  real logic are on the ClassLoader overload).
- Reference the SCP-1278 deadlock mechanism (smap registration vs. a
  concurrent JMXFetch ByteBuddy transform) and note the event registration
  would trigger FlightRecorder init anyway; we only order it first.
- Capitalize the smap log description so "Smap entry scraping not supported"
  reads correctly.
- Document the regression test's scope: it checks the ordering invariant
  (non-null handlers), not end-to-end event emission, and assumes a fresh
  forked JVM that has not started JFR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/Agent.java Outdated
Per review, replace the exception-driven "try Handlers, catch
ClassNotFoundException, try EventConfigurations" control flow with an
explicit version check. New jfrEventHolderClassName() maps the running JDK
to its holder class (Handlers on 15-18, EventConfigurations on 19-22, null
otherwise) using JavaVirtualMachine.isJavaVersionAtLeast(), and is the
single source of truth shared by the production init path and the
regression test (which now uses it to decide the target class and skip
JDKs without a holder). The remaining try/catch(Throwable) is genuine
error handling (JFR disabled / init failure), not control flow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@rkennke rkennke added this pull request to the merge queue Jul 16, 2026
@dd-octo-sts

dd-octo-sts Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

/merge

@gh-worker-devflow-routing-ef8351

gh-worker-devflow-routing-ef8351 Bot commented Jul 16, 2026

Copy link
Copy Markdown

View all feedbacks in Devflow UI.

2026-07-16 12:37:52 UTC ℹ️ Start processing command /merge


2026-07-16 12:37:58 UTC ℹ️ MergeQueue: pull request added to the queue

The expected merge time in master is approximately 2h (p90).


2026-07-16 12:56:56 UTCMergeQueue: The build pipeline contains failing jobs for this merge request

Build pipeline has failing jobs for 820090a:

⚠️ Do NOT retry failed jobs directly (why?).

What to do next?

  • Investigate the failures and when ready, re-add your pull request to the queue!
  • If your PR checks are green, try to rebase/merge. It might be because the CI run is a bit old.
  • Any question, go check the FAQ.
Details

Since those jobs are not marked as being allowed to fail, the pipeline will most likely fail.
Therefore, and to allow other builds to be processed, this merge request has been rejected and the pipeline got canceled.

@github-merge-queue github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Jul 16, 2026
@rkennke rkennke enabled auto-merge July 16, 2026 13:49
@rkennke rkennke added this pull request to the merge queue Jul 16, 2026
@dd-octo-sts

dd-octo-sts Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

/merge

@gh-worker-devflow-routing-ef8351

gh-worker-devflow-routing-ef8351 Bot commented Jul 16, 2026

Copy link
Copy Markdown

View all feedbacks in Devflow UI.

2026-07-16 14:38:17 UTC ℹ️ Start processing command /merge


2026-07-16 14:38:22 UTC ℹ️ MergeQueue: pull request added to the queue

The expected merge time in master is approximately 2h (p90).


2026-07-16 15:04:01 UTCMergeQueue: The build pipeline contains failing jobs for this merge request

Build pipeline has failing jobs for cdb88ed:

⚠️ Do NOT retry failed jobs directly (why?).

What to do next?

  • Investigate the failures and when ready, re-add your pull request to the queue!
  • If your PR checks are green, try to rebase/merge. It might be because the CI run is a bit old.
  • Any question, go check the FAQ.
Details

Since those jobs are not marked as being allowed to fail, the pipeline will most likely fail.
Therefore, and to allow other builds to be processed, this merge request has been rejected and the pipeline got canceled.

@github-merge-queue github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Jul 16, 2026
The build pipeline's test_base:[semeru17] job failed: on JDK 17 the
version check selected jdk.jfr.events.Handlers, but Eclipse OpenJ9 / IBM
Semeru ships a different JFR implementation where the holder / Utils
handler mechanism does not behave like HotSpot, so the regression test's
non-null-handler assertion did not hold. (semeru8/semeru11 passed because
those versions have no holder and the test skips.)

JDK-8371889 is a HotSpot JFR bug, so gate jfrEventHolderClassName() on
JavaVirtualMachine.isHotspot(): it now returns null on non-HotSpot VMs,
making the production init a no-op there and the regression test skip.
Verified the test still runs (not skipped) and passes on HotSpot JDK 17.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@rkennke rkennke enabled auto-merge July 16, 2026 15:44
@rkennke rkennke added this pull request to the merge queue Jul 16, 2026
@dd-octo-sts

dd-octo-sts Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

/merge

@gh-worker-devflow-routing-ef8351

gh-worker-devflow-routing-ef8351 Bot commented Jul 16, 2026

Copy link
Copy Markdown

View all feedbacks in Devflow UI.

2026-07-16 16:22:22 UTC ℹ️ Start processing command /merge


2026-07-16 16:22:28 UTC ℹ️ MergeQueue: pull request added to the queue

The expected merge time in master is approximately 2h (p90).


2026-07-16 17:30:41 UTC ℹ️ MergeQueue: This merge request was merged

@github-merge-queue github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Jul 16, 2026
@gh-worker-dd-mergequeue-cf854d gh-worker-dd-mergequeue-cf854d Bot merged commit 8abc6c3 into master Jul 16, 2026
586 checks passed
@gh-worker-dd-mergequeue-cf854d gh-worker-dd-mergequeue-cf854d Bot deleted the rkennke/fix-jfr-handlers-clinit-deadlock branch July 16, 2026 17:30
@github-actions github-actions Bot added this to the 1.65.0 milestone Jul 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp: profiling Profiling tag: ai generated Largely based on code generated by an AI or LLM type: bug fix Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants