Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
4f1ddfb
JFR event types and emission for reference chains (#796)
jbachorik Sep 22, 2026
1700bf3
Implement reference-chain tracking and the leak-signal engine
jbachorik Sep 17, 2026
3ac47ce
Deallocate GetObjectsWithTags results in hopLabelClassFor
jbachorik Sep 17, 2026
96dc67b
Fix clang scan-build findings in the tracker and engine
jbachorik Sep 17, 2026
d0b175b
Fix review findings in the tracker and liveness engine
jbachorik Sep 17, 2026
c2c5143
Clear the klass-population scratch in the test-reset seam
jbachorik Sep 18, 2026
5c1717c
Replace uncommitted-plan and line-number references with symbol refs
jbachorik Sep 18, 2026
e1da26d
Adapt tracker to merged ReferenceChainHop; drop Jira refs
jbachorik Sep 18, 2026
d744989
Make comments layer-local and drop stale plan refs from tests
jbachorik Sep 18, 2026
68ddab6
Drop forward references to the profiler-side writer
jbachorik Sep 21, 2026
a8bf8b5
Restore the merged-hop event API in the reference-chain tests
jbachorik Sep 21, 2026
1602a86
Drop design-doc reference from the population table comment
jbachorik Sep 21, 2026
9f141e3
Drop the last cross-layer references from the test comments
jbachorik Sep 21, 2026
fef03c2
Drop the last cross-layer references from the test comments
jbachorik Sep 21, 2026
5cba371
Wire reference chains into the profiler lifecycle and JNI API
jbachorik Sep 17, 2026
a0b9eb2
Drop remaining Jira and cross-layer test references
jbachorik Sep 18, 2026
919b232
Drop remaining Jira and cross-layer test references
jbachorik Sep 18, 2026
1a645c5
Add remaining C++ unit tests for reference-chain work
jbachorik Sep 17, 2026
cb32970
Drop plan-document and stale line-number references from test comments
jbachorik Sep 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ddprof-lib/src/main/cpp/callTraceHashTable.h
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ class CallTraceHashTable {
// - ACQUIRE loads in collect(), put(), and putWithExistingId()
// Required for correct visibility on weakly-ordered architectures (aarch64).
LongHashTable* _table;

volatile u64 _overflow;

u64 calcHash(int num_frames, ASGCT_CallFrame *frames, bool truncated);
Expand Down
1 change: 1 addition & 0 deletions ddprof-lib/src/main/cpp/callTraceStorage.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/

#include <unistd.h>
#include "callTraceStorage.h"
#include "counters.h"
#include "log.h"
Expand Down
68 changes: 68 additions & 0 deletions ddprof-lib/src/main/cpp/classTagAllocator.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* Copyright 2026, Datadog, Inc.
* SPDX-License-Identifier: Apache-2.0
*/

#ifndef _CLASS_TAG_ALLOCATOR_H
#define _CLASS_TAG_ALLOCATOR_H

#include "arch.h"
#include <jni.h>

// Process-wide, negative JVMTI class-object tag allocator, shared by
// ReferenceChainTracker (which tags every loaded class's own jclass object
// via SetTag - see resolveLoadedClasses(), referenceChains.cpp) and
// LivenessTracker (which needs a stable per-class identifier independent of
// Profiler::classMap()'s dictionary id - see KlassPopulationEntry::
// stable_class_tag's own comment, livenessTracker.h, for why: that
// dictionary can be compacted/regenerated, silently reassigning the same
// class a different id at different points in the process's life, breaking
// any attempt to correlate a klass_id LivenessTracker reports as growing
// against ReferenceChainTracker::FrontierEntry::referrer_klass values
// recorded at a different time).
//
// A single shared counter, not one independently owned by each subsystem,
// for two reasons, both load-bearing:
// 1. Two independent counters could otherwise hand out the SAME numeric
// value to TWO DIFFERENT classes (one minted by each subsystem for a
// class the other has not seen yet), making any cross-subsystem
// comparison meaningless.
// 2. Class tags must stay strictly NEGATIVE:
// ReferenceChainTracker::heapReferenceCallback() (referenceChains.cpp)
// uses `*tag_ptr < 0` to distinguish "this heap-walk-visited object is a
// pre-tagged class object" from an ordinary admitted instance (always
// tagged with a positive value via nextTag()). A class tagged by a
// counter that does not preserve this sign convention would be
// misidentified as an ordinary object and incorrectly admitted into the
// frontier table - a real correctness bug, not just a matching
// inconvenience.
//
// Deliberately a plain header-only function (Meyer's-singleton pattern,
// exactly like LivenessTracker::instance()/ReferenceChainTracker::
// instance()'s own lazy-static singletons) rather than a member of either
// singleton class: ReferenceChainTracker already depends on LivenessTracker
// (referenceChains.cpp includes livenessTracker.h and calls into it), so
// putting this counter inside either one and having the other call into it
// would introduce a circular dependency between the two headers.
namespace ClassTagAllocator {

inline volatile jlong &magnitude() {
static volatile jlong m = 1;
return m;
}

// Hands out a fresh negative class tag - see this file's own header comment
// for why negative, and why this must be the only place in the process that
// mints one.
inline jlong next() { return -atomicIncRelaxed(magnitude(), (jlong)1); }

// Test-only: resets the shared counter back to its starting value. Without
// this, gtest cases that assert on exact tag values (e.g. "the first class
// tagged gets -1") would see values keep climbing across every TEST_F in the
// same gtest binary, since this counter is genuinely process-wide (shared
// with LivenessTracker) rather than per-ReferenceChainTracker-instance.
inline void resetForTest() { magnitude() = 1; }

} // namespace ClassTagAllocator

#endif
39 changes: 39 additions & 0 deletions ddprof-lib/src/main/cpp/counters.h
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,45 @@
* signal for spotting a recurrence. */ \
X(METADATA_TREE_NULL_CHILD, "metadata_tree_null_child") \
X(METADATA_TREE_DEPTH_EXCEEDED, "metadata_tree_depth_exceeded") \
/* A resolved datadog.ReferenceChain could not be cached in \
* ReferenceChainTracker::_resolved_chains (referenceChains.h): a brand-new \
* leak-candidate klass arrived with the cache already at \
* MAX_RESOLVED_CHAINS, so its chain is dropped rather than evicting some \
* other still-live sample's chain. See that constant's own comment. */ \
X(REFERENCE_CHAIN_EVENTS_DROPPED, "reference_chain_events_dropped") \
/* ReferenceChainTracker::releaseSearchTags() (referenceChains.cpp) failed \
* to call GetObjectsWithTags() for at least one batch - the search's tag \
* release is retried on a later call rather than proceeding, but this \
* counts how often that retry path is taken. */ \
X(REFERENCE_CHAIN_TAG_RELEASE_FAILED, "reference_chain_tag_release_failed") \
/* The profiler-side reference-chain writer could not acquire a \
* sample-record lock within its bounded retry budget and dropped the \
* already-dequeued datadog.ReferenceChain event for this dump - not \
* permanently lost, since ReferenceChainTracker::_resolved_chains (see \
* REFERENCE_CHAIN_EVENTS_DROPPED above) keeps the resolved chain cached \
* and re-emits it on a later dump while the leak candidate is still \
* live. */ \
X(REFERENCE_CHAIN_WRITE_DROPPED, "reference_chain_write_dropped") \
/* FrontierTable's own calloc/realloc-backed storage (referenceChains.cpp) - \
* outside NMT's visibility since it bypasses os::malloc, so this is the only \
* way to attribute its native RSS contribution. */ \
X(REFERENCE_CHAIN_FRONTIER_TABLE_BYTES, "reference_chain_frontier_table_bytes") \
X(REFERENCE_CHAIN_FRONTIER_TABLE_CAPACITY, "reference_chain_frontier_table_capacity") \
X(REFERENCE_CHAIN_CANDIDATE_COUNT, "reference_chain_candidate_count") \
X(REFERENCE_CHAIN_CANDIDATES_FOUND, "reference_chain_candidates_found") \
/* admitStaticFieldRoots() per-class non-static quota: non-STATIC_FIELD \
* edges (CONSTANT_POOL, INTERFACE, SUPERCLASS, CLASS_LOADER, ...) that \
* were dropped because the class already hit \
* STATIC_FIELD_SWEEP_NON_STATIC_CAP_PER_CLASS. Total drops across all \
* classes/laps — compare against kind_counts (k9 total) to gauge how \
* much CP pressure the quota is absorbing. */ \
X(REFERENCE_CHAIN_STATIC_SWEEP_NON_STATIC_DROPPED, "reference_chain_static_sweep_non_static_dropped") \
/* Incremented once per class that hit the non-static cap at least once \
* in a lap (on the first drop for that class). Distinguishes "a few fat \
* outlier classes dropping many edges" from "systematic drops across \
* almost all classes" — if this tracks the total class count per lap, \
* the cap is too low; if it stays near zero, the cap is fine. */ \
X(REFERENCE_CHAIN_STATIC_SWEEP_CLASSES_CAPPED, "reference_chain_static_sweep_classes_capped") \
DD_COUNTER_TABLE_FAULT_INJECTION(X) \
DD_COUNTER_TABLE_FI_DEBUG(X) \
DD_COUNTER_TABLE_DEBUG(X)
Expand Down
9 changes: 5 additions & 4 deletions ddprof-lib/src/main/cpp/flightRecorder.h
Original file line number Diff line number Diff line change
Expand Up @@ -581,20 +581,21 @@ class FlightRecorder {

// Mirrors recordHeapUsage()'s shape exactly - ReferenceChainAbandonedEvent
// is not stack-sample-shaped (no tid/call_trace_id), same as HeapUsage.
// Called from Profiler::writeReferenceChainAbandoned() (profiler.cpp),
// Called from the profiler's dump-time abandoned-event drain,
// wired from Profiler::dump() the same way LivenessTracker::flush() is.
void recordReferenceChainAbandoned(int lock_index,
ReferenceChainAbandonedEvent *event);

// Mirrors recordReferenceChainAbandoned() above exactly, for
// ReferenceChainEvent instead. Called from Profiler::writeReferenceChain()
// (profiler.cpp), itself called from Profiler::dump()'s drain loop over
// ReferenceChainEvent instead. Called from the profiler's dump()-time
// writer, itself called from Profiler::dump()'s drain loop over
// the engine's resolved-chain cache snapshot: the BFS
// scheduling thread only caches resolved chains and each dump re-emits
// the cache, so chain events
// are written on dump()'s own thread, not from the tracker thread, and
// unlike recordReferenceChainAbandoned() (unbounded retry budget per
// event) the batch shares one deadline (writeReferenceChain()'s comment).
// event) the batch shares one deadline (see the writer's contract in
// Profiler - the drain batch, not each event, owns the retry budget).
void recordReferenceChain(int lock_index, ReferenceChainEvent *event);
};

Expand Down
217 changes: 217 additions & 0 deletions ddprof-lib/src/main/cpp/javaApi.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1104,6 +1104,223 @@ Java_com_datadoghq_profiler_JavaProfiler_dumpContext(JNIEnv* env, jclass unused)
TEST_LOG("===> Context: tid:%lu, spanId=%lu, rootSpanId=%lu", OS::threadId(), spanId, rootSpanId);
}

// LivenessTracker/ReferenceChainTracker test seams. Unlike
// testlog()/dumpContext() above (harmless no-ops in release, via TEST_LOG's
// own release-mode expansion to nothing), these mutate real tracker state
// (tagging objects, seeding population history) - shipping them into a
// release build would let a caller corrupt the actual leak-detection state,
// not just add a silent no-op. Guarded out entirely instead, so they only
// exist in the debug build ddprof-test's `testdebug` Gradle task loads
// (`-DDEBUG`, see ConfigurationPresets.kt's configureDebug()) - never in the
// `-DNDEBUG` release build.
#ifdef DEBUG
#include "livenessTracker.h"
#include "referenceChains.h"
#include <vector>

extern "C" DLLEXPORT jboolean JNICALL
Java_com_datadoghq_profiler_JavaProfiler_setGcGenerationsEnabled0(
JNIEnv *env, jclass unused, jboolean enabled) {
LivenessTracker::instance()->setGcGenerationsForTest(enabled);
return JNI_TRUE;
}

extern "C" DLLEXPORT void JNICALL
Java_com_datadoghq_profiler_JavaProfiler_seedKlassPopulationSample0(
JNIEnv *env, jclass unused, jint klassId, jint count, jlong epoch) {
int slot;
bool created;
LivenessTracker::instance()->klassPopulationRecordForTest(
(u32)klassId, (u16)count, (u64)epoch, &slot, &created);
}

// Seeds one per-(klass, tid) trend sample - see tidTrendRecordForTest()'s
// own comment (livenessTracker.h) for the synthetic-flag exemption and the
// real-tid requirement scenarios must honor.
extern "C" DLLEXPORT void JNICALL
Java_com_datadoghq_profiler_JavaProfiler_seedTidTrendSample0(
JNIEnv *env, jclass unused, jint klassId, jint tid, jint count,
jlong epoch) {
LivenessTracker::instance()->tidTrendRecordForTest(
(u32)klassId, (jint)tid, (u32)count, (u64)epoch);
}

// Wires a real, caller-chosen live object in as klassId's leak-candidate
// representative, so a test-seeded slope signal (seedKlassPopulationSample0
// above) and a directly-tagged frontier root (tagAsReferenceChainRoot0
// below) can be joined into one deterministic end-to-end run of
// pollWatchedTargets()'s bridging step - without either LivenessTracker's
// real allocation sampler or ReferenceChainTracker's root-seeded walk ever
// running. Takes its own weak global ref (klassPopulationSetRepresentativeForTest()'s
// own contract, livenessTracker.h) rather than aliasing any handle the
// caller manages.
extern "C" DLLEXPORT void JNICALL
Java_com_datadoghq_profiler_JavaProfiler_setKlassPopulationRepresentativeForTest0(
JNIEnv *env, jclass unused, jint klassId, jobject representative) {
jweak rep = env->NewWeakGlobalRef(representative);
LivenessTracker::instance()->klassPopulationSetRepresentativeForTest(
env, (u32)klassId, rep);
}

extern "C" DLLEXPORT void JNICALL
Java_com_datadoghq_profiler_JavaProfiler_resetKlassPopulationForTest0(
JNIEnv *env, jclass unused) {
LivenessTracker::instance()->klassPopulationResetForTest();
}

extern "C" DLLEXPORT jintArray JNICALL
Java_com_datadoghq_profiler_JavaProfiler_selectLeakCandidateKlassIds0(
JNIEnv *env, jclass unused) {
KlassCandidate candidates[5];
int n = LivenessTracker::instance()->selectLeakCandidates(candidates, 5);
jintArray result = env->NewIntArray(n);
if (result == nullptr || n == 0) {
return result;
}
jint ids[5];
for (int i = 0; i < n; i++) {
ids[i] = (jint)candidates[i].klass_id;
}
env->SetIntArrayRegion(result, 0, n, ids);
return result;
}

extern "C" DLLEXPORT jlong JNICALL
Java_com_datadoghq_profiler_JavaProfiler_tagAsReferenceChainRoot0(
JNIEnv *env, jclass unused, jobject target) {
jvmtiEnv *jvmti = VM::jvmti();
if (jvmti == nullptr) {
return 0;
}
return ReferenceChainTracker::instance()->tagAsRootForTest(jvmti, env,
target);
}

extern "C" DLLEXPORT jboolean JNICALL
Java_com_datadoghq_profiler_JavaProfiler_runReferenceChainPass0(
JNIEnv *env, jclass unused) {
jvmtiEnv *jvmti = VM::jvmti();
if (jvmti == nullptr) {
return JNI_FALSE;
}
return ReferenceChainTracker::instance()->runPassSerialized(jvmti, env);
}

extern "C" DLLEXPORT void JNICALL
Java_com_datadoghq_profiler_JavaProfiler_pollReferenceChainTargets0(
JNIEnv *env, jclass unused) {
jvmtiEnv *jvmti = VM::jvmti();
if (jvmti == nullptr) {
return;
}
ReferenceChainTracker::instance()->pollWatchedTargetsSerialized(jvmti, env);
}

extern "C" DLLEXPORT jint JNICALL
Java_com_datadoghq_profiler_JavaProfiler_drainReferenceChainEventCount0(
JNIEnv *env, jclass unused) {
std::vector<ReferenceChainEvent> events;
ReferenceChainTracker::instance()->drainPendingChainEvents(&events);
return (jint)events.size();
}

extern "C" DLLEXPORT void JNICALL
Java_com_datadoghq_profiler_JavaProfiler_resetReferenceChainSearchForTest0(
JNIEnv *env, jclass unused) {
jvmtiEnv *jvmti = VM::jvmti();
ReferenceChainTracker::instance()->resetSearchStateForTest(jvmti, env);
}

// Diagnostic-only: reads target's existing JVMTI tag (does NOT tag it -
// unlike tagAsReferenceChainRoot0 above, a target the real search has not
// reached yet must be left untagged) and reports its FIFO distance from the
// front of ReferenceChainTracker's pending-expansion queue. See
// ReferenceChainTracker::pendingExpandPositionForTest()'s own comment for
// the return-value contract.
extern "C" DLLEXPORT jlong JNICALL
Java_com_datadoghq_profiler_JavaProfiler_getReferenceChainPendingPositionForTest0(
JNIEnv *env, jclass unused, jobject target) {
jvmtiEnv *jvmti = VM::jvmti();
if (jvmti == nullptr || target == nullptr) {
return -2;
}
jlong tag = 0;
jvmtiError err = jvmti->GetTag(target, &tag);
if (err != JVMTI_ERROR_NONE) {
return -2;
}
return (jlong)ReferenceChainTracker::instance()->pendingExpandPositionForTest(
tag);
}

extern "C" DLLEXPORT jlong JNICALL
Java_com_datadoghq_profiler_JavaProfiler_getReferenceChainPendingSizeForTest0(
JNIEnv *env, jclass unused) {
return (jlong)ReferenceChainTracker::instance()->pendingExpandSizeForTest();
}

// Seeds one heap-floor-ring sample directly (LivenessTracker::secondsToOOM()'s
// input), bypassing the real GarbageCollectionFinish callback - lets a test
// build an arbitrary rising/flat heap-usage-over-time history without
// waiting on real GCs. timestampNs values are only ever compared against
// each other (secondsToOOM()'s own ringThirdsStats() deltas), never against
// a real wall clock, so a test may use any self-consistent, strictly
// increasing sequence.
extern "C" DLLEXPORT void JNICALL
Java_com_datadoghq_profiler_JavaProfiler_heapFloorRecordForTest0(
JNIEnv *env, jclass unused, jlong usedBytes, jlong timestampNs) {
LivenessTracker::instance()->heapFloorRecordForTest((u64)usedBytes,
(u64)timestampNs);
}

// Bypasses initialize_table()'s JNI-dependent HeapUsage::getMaxHeap() call so
// secondsToOOM() can be exercised against a test-chosen fake max heap size,
// independent of whatever -Xmx this JVM's own shared, no-forkEvery fork
// happens to run with.
extern "C" DLLEXPORT void JNICALL
Java_com_datadoghq_profiler_JavaProfiler_setMaxHeapBytesForTest0(
JNIEnv *env, jclass unused, jlong maxHeapBytes) {
LivenessTracker::instance()->setMaxHeapBytesForTest((jlong)maxHeapBytes);
}

// Temporarily disables onGC()'s own recordHeapFloorSample() call so a test
// can seed the heap-floor ring exclusively via heapFloorRecordForTest0()
// without a real GC interleaving a sample with a real OS::nanotime()
// timestamp and real heap usage, corrupting secondsToOOM()'s projection.
extern "C" DLLEXPORT void JNICALL
Java_com_datadoghq_profiler_JavaProfiler_setHeapFloorRecordingForTest0(
JNIEnv *env, jclass unused, jboolean enabled) {
LivenessTracker::instance()->setHeapFloorRecordingForTest(enabled == JNI_TRUE);
}

// Exposes ReferenceChainTracker::shouldRunPass() directly (see that seam's
// own comment, referenceChains.h) - unlike runReferenceChainPass0() above,
// which calls runPass() unconditionally, this reports whether the
// search-restart gate itself (canAffordNewSearch() -> hasLeakSignal()) would
// currently allow a fresh/terminal search to start.
extern "C" DLLEXPORT jboolean JNICALL
Java_com_datadoghq_profiler_JavaProfiler_shouldRunPassForTest0(JNIEnv *env,
jclass unused) {
return ReferenceChainTracker::instance()->shouldRunPassForTest(
OS::nanotime())
? JNI_TRUE
: JNI_FALSE;
}

// Exposes ReferenceChainTracker::passesRun() directly - not itself DEBUG-gated on the native side
// (used by production JFR event fields too), but exposed here only for test use: lets a test note
// the current pass count before creating an object, then wait for that count to advance before
// trusting any match against it - the only way to be certain a match came from a pass whose own
// expandFrontier() (and therefore collectStaleExpandedEntriesForRotation()) ran strictly after the
// object existed, rather than from the same pass racing the object's creation.
extern "C" DLLEXPORT jint JNICALL
Java_com_datadoghq_profiler_JavaProfiler_referenceChainPassesRunForTest0(
JNIEnv *env, jclass unused) {
return (jint)ReferenceChainTracker::instance()->passesRun();
}

#endif // DEBUG

// ---- Test-only reads of the current thread's OTEP record -----------------------------------
// Each reads the current carrier's record directly via ProfiledThread::current(), with no
// detach/attach (diagnostic-only, not on any signal-handler or hot write path).
Expand Down
Loading
Loading