Skip to content

Commit 8abc6c3

Browse files
rkennkedevflow.devflow-routing-intake
andauthored
Fix JFR startup deadlock without disabling built-in JFR events (#11957)
Actually initialize JFR Handlers class early to avoid deadlock 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> Address review: cover renamed holder class and init before all JFR registration 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> Register JDK JFR events before forcing holder init, to avoid null handlers 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> Add regression test for JFR handler null-poisoning 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> Route JFR event registration through a single init-first choke point 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> Merge branch 'master' into rkennke/fix-jfr-handlers-clinit-deadlock Remove duplicated comment in initializeJfrEventHolderClass Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Address pre-review: docs, version accuracy, log wording, test caveats - 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> Select JFR holder class by version instead of catching CNFE 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> Merge branch 'master' into rkennke/fix-jfr-handlers-clinit-deadlock Merge branch 'master' into rkennke/fix-jfr-handlers-clinit-deadlock Scope the JFR holder-class workaround to HotSpot 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> Co-authored-by: devflow.devflow-routing-intake <devflow.devflow-routing-intake@kubernetes.us1.ddbuild.io>
1 parent 084b01b commit 8abc6c3

3 files changed

Lines changed: 218 additions & 26 deletions

File tree

dd-java-agent/agent-bootstrap/build.gradle

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,4 +72,9 @@ tasks.withType(Test).configureEach {
7272
JavaVersion.VERSION_16,
7373
['--add-opens', 'java.base/java.net=ALL-UNNAMED'] // for HostNameResolverForkedTest
7474
)
75+
conditionalJvmArgs(
76+
it,
77+
JavaVersion.VERSION_11,
78+
['--add-opens', 'jdk.jfr/jdk.jfr.events=ALL-UNNAMED'] // for JfrEventHolderInitForkedTest
79+
)
7580
}

dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/Agent.java

Lines changed: 132 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package datadog.trace.bootstrap;
22

3+
import static datadog.environment.JavaVirtualMachine.isHotspot;
34
import static datadog.environment.JavaVirtualMachine.isJavaVersionAtLeast;
45
import static datadog.environment.JavaVirtualMachine.isOracleJDK8;
56
import static datadog.trace.api.Config.isExplicitlyDisabled;
@@ -908,6 +909,8 @@ private static synchronized void startJmx() {
908909
}
909910
}
910911
if (profilingEnabled) {
912+
// Both of these register JFR events through registerJfrEvents(), which force-initializes the
913+
// JDK's JFR event-holder class first (see initializeJfrEventHolderClass) to avoid a deadlock.
911914
registerDeadlockDetectionEvent();
912915
registerSmapEntryEvent();
913916
if (PROFILER_INIT_AFTER_JMX != null) {
@@ -937,40 +940,143 @@ When getJmxStartDelay() is set to 0 we will attempt to initialize the JMX subsys
937940

938941
private static synchronized void registerDeadlockDetectionEvent() {
939942
log.debug("Initializing JMX thread deadlock detector");
943+
registerJfrEvents(
944+
"com.datadog.profiling.controller.openjdk.events.DeadlockEventFactory",
945+
"JMX thread deadlock detection");
946+
}
947+
948+
private static void initializeJfrEventHolderClass() {
949+
initializeJfrEventHolderClass(AGENT_CLASSLOADER);
950+
}
951+
952+
/**
953+
* Force-initializes the JDK's JFR event-holder class early to avoid an ABBA deadlock between
954+
* {@code jdk.jfr.internal.Utils}'s monitor and the holder class's initialization lock. See <a
955+
* href="https://bugs.openjdk.org/browse/JDK-8371889">JDK-8371889</a> and the SCP-1278 thread
956+
* dump.
957+
*
958+
* <p>The holder's {@code <clinit>} looks up the JDK's built-in event handlers through {@code
959+
* jdk.jfr.internal.Utils} (taking its monitor); conversely, JFR event registration initializes
960+
* the holder while holding that same monitor. The deadlock forms when one thread holds the {@code
961+
* Utils} monitor and wants the holder's class-init lock, while another thread holds the
962+
* class-init lock (running {@code <clinit>}) and wants the {@code Utils} monitor. In SCP-1278
963+
* this was the profiler's smap-event registration (holding {@code Utils}) against a concurrent
964+
* JMXFetch ByteBuddy transform that had triggered the holder {@code <clinit>}. Running the {@code
965+
* <clinit>} here, on this thread, before we register our own JFR events, means the holder is
966+
* already initialized by then, so the cycle cannot form.
967+
*
968+
* <p>Ordering matters twice over:
969+
*
970+
* <ul>
971+
* <li>We force {@code FlightRecorder} initialization first, which registers the JDK's built-in
972+
* events (via {@code JDKEvents.initialize()}). The holder's fields are {@code static final}
973+
* and are populated from {@code Utils} at {@code <clinit>} time; running {@code <clinit>}
974+
* before the events are registered would cache {@code null} into those fields permanently
975+
* and silently disable the built-in socket/file/exception JFR events (verified on JDK
976+
* 17.0.17 and 21.0.9). This mirrors the JDK's own fix, which initializes the holder only
977+
* after {@code JDKEvents.initialize()}. The event registration below would trigger the same
978+
* {@code FlightRecorder} initialization anyway; we merely order it ahead of the holder
979+
* {@code <clinit>}. If {@code FlightRecorder} init fails, JFR is unavailable and there is
980+
* nothing to protect against, so we skip the holder init entirely.
981+
* <li>{@link Class#forName(String, boolean, ClassLoader)} with {@code initialize=true} is
982+
* required: {@link ClassLoader#loadClass(String)} would only load the class without running
983+
* {@code <clinit>}, so it would neither take the class-init lock early nor prevent the
984+
* deadlock.
985+
* </ul>
986+
*
987+
* <p>The holder class was renamed across JDK versions, so it is selected by version (see {@link
988+
* #jfrEventHolderClassName()}): {@code jdk.jfr.events.Handlers} on JDK 15-18, {@code
989+
* jdk.jfr.events.EventConfigurations} on JDK 19-22. Earlier JDKs (including 11 LTS) predate the
990+
* holder and JDK 23+ removed the eager-init pattern; on those this method does nothing. On
991+
* patched JDKs (the JDK-8371889 fix was backported to 21.0.11) the JDK already initializes the
992+
* holder safely during {@code FlightRecorder} startup, so forcing it here is a harmless no-op.
993+
*
994+
* @param loader class loader used to resolve the JFR classes (package-private for testing; see
995+
* JfrEventHolderInitForkedTest)
996+
*/
997+
static void initializeJfrEventHolderClass(final ClassLoader loader) {
998+
final String holderClassName = jfrEventHolderClassName();
999+
if (holderClassName == null) {
1000+
return; // no eager-init holder on this JDK, so there is no deadlock to prevent
1001+
}
9401002
try {
941-
final Class<?> deadlockFactoryClass =
942-
AGENT_CLASSLOADER.loadClass(
943-
"com.datadog.profiling.controller.openjdk.events.DeadlockEventFactory");
944-
final Method registerMethod = deadlockFactoryClass.getMethod("registerEvents");
945-
registerMethod.invoke(null);
946-
} catch (final NoClassDefFoundError
947-
| ClassNotFoundException
948-
| UnsupportedClassVersionError ignored) {
949-
log.debug("JMX deadlock detection not supported");
950-
} catch (final Throwable ex) {
951-
log.error("Unable to initialize JMX thread deadlock detector", ex);
1003+
// Register the JDK's built-in JFR events first, so the holder's <clinit> below sees non-null
1004+
// handlers instead of caching null.
1005+
Class.forName("jdk.jfr.FlightRecorder", true, loader)
1006+
.getMethod("getFlightRecorder")
1007+
.invoke(null);
1008+
// Force the holder's <clinit>.
1009+
Class.forName(holderClassName, true, loader);
1010+
} catch (final Throwable ignored) {
1011+
// JFR unavailable/disabled or initialization failed: nothing (left) to do. We catch Throwable
1012+
// rather than Exception because forcing initialization can surface Errors such as
1013+
// ExceptionInInitializerError, NoClassDefFoundError or LinkageError.
1014+
}
1015+
}
1016+
1017+
/**
1018+
* Returns the fully-qualified name of the JDK's JFR event-holder class for the running JVM, or
1019+
* {@code null} if this JDK has no such class. The class is selected by JDK version rather than by
1020+
* probing with {@link ClassNotFoundException} so the mapping is explicit:
1021+
*
1022+
* <ul>
1023+
* <li>JDK 15-18: {@code jdk.jfr.events.Handlers}
1024+
* <li>JDK 19-22: {@code jdk.jfr.events.EventConfigurations}
1025+
* <li>otherwise (JDK 14 and earlier predate the holder; JDK 23+ removed the eager-init
1026+
* pattern): {@code null}
1027+
* </ul>
1028+
*
1029+
* <p>Also returns {@code null} on non-HotSpot VMs: JDK-8371889 is a HotSpot JFR bug, and other
1030+
* VMs (e.g. Eclipse OpenJ9 / IBM Semeru) ship a different JFR implementation where this holder /
1031+
* {@code Utils} mechanism does not apply.
1032+
*/
1033+
static String jfrEventHolderClassName() {
1034+
if (!isHotspot()) {
1035+
return null;
9521036
}
1037+
if (isJavaVersionAtLeast(15) && !isJavaVersionAtLeast(19)) {
1038+
return "jdk.jfr.events.Handlers";
1039+
}
1040+
if (isJavaVersionAtLeast(19) && !isJavaVersionAtLeast(23)) {
1041+
return "jdk.jfr.events.EventConfigurations";
1042+
}
1043+
return null;
9531044
}
9541045

9551046
private static synchronized void registerSmapEntryEvent() {
9561047
log.debug("Initializing smap entry scraping");
1048+
registerJfrEvents(
1049+
"com.datadog.profiling.controller.openjdk.events.SmapEntryFactory", "Smap entry scraping");
1050+
}
9571051

958-
// Load JFR Handlers class early, if present (it has been moved and renamed in JDK23+).
959-
// This prevents a deadlock. See https://bugs.openjdk.org/browse/JDK-8371889.
960-
try {
961-
AGENT_CLASSLOADER.loadClass("jdk.jfr.events.Handlers");
962-
} catch (Exception e) {
963-
// Ignore when the class is not found or anything else goes wrong.
964-
}
965-
1052+
/**
1053+
* Registers a profiling JFR event factory's events, after force-initializing the JDK's JFR
1054+
* event-holder class (see {@link #initializeJfrEventHolderClass(ClassLoader)}).
1055+
*
1056+
* <p><strong>All JFR event registration during agent startup must go through this
1057+
* method.</strong> Registering a JFR event triggers the holder class's initialization while
1058+
* holding the {@code jdk.jfr.internal.Utils} monitor; unless the holder has already been fully
1059+
* initialized, that can deadlock (JDK-8371889). Routing every registration through here
1060+
* guarantees the holder is initialized first, so future event registrations cannot reintroduce
1061+
* the deadlock by running before it.
1062+
*
1063+
* @param factoryClassName fully-qualified name of the event factory with a static {@code
1064+
* registerEvents()} method
1065+
* @param description human-readable name used in log messages
1066+
*/
1067+
private static void registerJfrEvents(final String factoryClassName, final String description) {
1068+
// Enforce the ordering invariant: the holder must be initialized before any JFR event is
1069+
// registered. Idempotent and cheap once done, so it is safe to call before every registration.
1070+
initializeJfrEventHolderClass();
9661071
try {
967-
final Class<?> smapFactoryClass =
968-
AGENT_CLASSLOADER.loadClass(
969-
"com.datadog.profiling.controller.openjdk.events.SmapEntryFactory");
970-
final Method registerMethod = smapFactoryClass.getMethod("registerEvents");
971-
registerMethod.invoke(null);
972-
} catch (final Exception ignored) {
973-
log.debug("Smap entry scraping not supported");
1072+
final Class<?> factoryClass = AGENT_CLASSLOADER.loadClass(factoryClassName);
1073+
factoryClass.getMethod("registerEvents").invoke(null);
1074+
} catch (final NoClassDefFoundError
1075+
| ClassNotFoundException
1076+
| UnsupportedClassVersionError ignored) {
1077+
log.debug("{} not supported", description);
1078+
} catch (final Throwable ex) {
1079+
log.error("Unable to initialize {}", description, ex);
9741080
}
9751081
}
9761082

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package datadog.trace.bootstrap;
2+
3+
import static org.junit.jupiter.api.Assertions.assertTrue;
4+
import static org.junit.jupiter.api.Assumptions.assumeTrue;
5+
6+
import java.lang.reflect.Field;
7+
import java.lang.reflect.Modifier;
8+
import java.util.ArrayList;
9+
import java.util.List;
10+
import org.junit.jupiter.api.Test;
11+
12+
/**
13+
* Regression test for the JFR startup-deadlock workaround in {@link Agent}.
14+
*
15+
* <p>{@link Agent#initializeJfrEventHolderClass(ClassLoader)} force-initializes the JDK's JFR
16+
* event-holder class ({@code jdk.jfr.events.Handlers} on JDK 15-18, {@code
17+
* jdk.jfr.events.EventConfigurations} on JDK 19-22) to avoid an ABBA deadlock. Its static-final
18+
* handler fields are populated from {@code jdk.jfr.internal.Utils} at {@code <clinit>} time and
19+
* stay {@code null} forever if the class is initialized before the JDK's built-in events are
20+
* registered (i.e. before {@code FlightRecorder} initialization). A null-poisoned holder silently
21+
* disables the built-in socket/file/exception JFR events.
22+
*
23+
* <p>This test runs the production initialization path and asserts none of the handler fields were
24+
* poisoned. It is a {@code ForkedTest} because JFR/class initialization happens once per JVM, so
25+
* the ordering under test is only exercised in a fresh JVM that has not yet touched JFR.
26+
*
27+
* <p>Scope and limitations:
28+
*
29+
* <ul>
30+
* <li>It verifies the ordering <em>invariant</em> (non-null handler fields), not end-to-end event
31+
* emission. That keeps it fast and non-flaky; the deadlock itself is timing-dependent and is
32+
* defended structurally (see {@code Agent#registerJfrEvents}) rather than reproduced here.
33+
* <li>It assumes the forked JVM has not initialized JFR before the test runs. If the test JVM is
34+
* ever launched with JFR already started (e.g. {@code -XX:StartFlightRecording} or an
35+
* attached JFR profiler), the holder would already hold valid handlers and the test would
36+
* pass without exercising the ordering. In the standard forked test JVM nothing touches JFR
37+
* first, so the test is meaningful (confirmed: it fails if the FlightRecorder-before-holder
38+
* ordering is reversed).
39+
* </ul>
40+
*
41+
* <p>Requires {@code --add-opens jdk.jfr/jdk.jfr.events=ALL-UNNAMED} (configured in build.gradle)
42+
* to read the holder's fields reflectively. Automatically skipped where there is no holder to
43+
* initialize: JDK 14 and earlier, JDK 23+ (eager-init pattern removed), and non-HotSpot VMs such as
44+
* Eclipse OpenJ9 / IBM Semeru (different JFR implementation).
45+
*/
46+
public class JfrEventHolderInitForkedTest {
47+
48+
@Test
49+
public void productionInitOrderingDoesNotPoisonHandlers() throws Exception {
50+
final ClassLoader loader = getClass().getClassLoader();
51+
52+
// The holder class (if any) is selected by JDK version; skip when this JDK has none (JDK 8,
53+
// 23+).
54+
final String holderName = Agent.jfrEventHolderClassName();
55+
assumeTrue(holderName != null, "No JFR event-holder class on this JDK; nothing to test");
56+
57+
// Exercise the exact production path: FlightRecorder init first, then holder <clinit>.
58+
Agent.initializeJfrEventHolderClass(loader);
59+
60+
// The holder is now initialized; read its static handler fields and check none are null.
61+
final Class<?> holder = Class.forName(holderName, false, loader);
62+
final List<String> nullFields = new ArrayList<>();
63+
int handlerFields = 0;
64+
for (final Field field : holder.getDeclaredFields()) {
65+
if (!Modifier.isStatic(field.getModifiers()) || field.getType().isPrimitive()) {
66+
continue;
67+
}
68+
handlerFields++;
69+
field.setAccessible(true);
70+
if (field.get(null) == null) {
71+
nullFields.add(field.getName());
72+
}
73+
}
74+
75+
assertTrue(handlerFields > 0, "expected " + holderName + " to declare handler fields");
76+
assertTrue(
77+
nullFields.isEmpty(),
78+
"JFR handler fields were poisoned to null (holder initialized before FlightRecorder): "
79+
+ nullFields);
80+
}
81+
}

0 commit comments

Comments
 (0)