Skip to content

Fix native OOM from unbounded CallTraceHashTable expansion#670

Open
jbachorik wants to merge 1 commit into
mainfrom
jb/fix-liveheap-calltrace-oom
Open

Fix native OOM from unbounded CallTraceHashTable expansion#670
jbachorik wants to merge 1 commit into
mainfrom
jb/fix-liveheap-calltrace-oom

Conversation

@jbachorik

Copy link
Copy Markdown
Collaborator

Problem

Under live-heap profiling (memory=...:l) with a long-running, unrotated recording, the profiler can exhaust native address space and crash with os::commit_memory ... failed; error='Not enough space' (errno=12) / pthread_create failed (EAGAIN). The failure is vm.max_map_count (VMA) exhaustion, not RAM: a captured memory map showed tens of thousands of 8 MB (and 8 MB-multiple) anonymous regions.

Root cause

CallTraceHashTable grows its backing LongHashTable by doubling capacity, allocating each generation from a LinearAllocator whose chunks are a fixed 8 MB (CALL_TRACE_CHUNK). Once a table's byte size exceeds one chunk (capacity ≥ ~512K entries), LinearAllocator::alloc() can never place it: the bump-allocator's inner loop only fits offs + size <= _chunk_size, so an oversized request falls through to getNextChunk(), which allocates a fresh 8 MB chunk that also can't fit it — looping and allocating chunks until mmap fails.

The active table only grows this large between processTraces() rotations (which reset it to the initial capacity). In a single start-to-end JFR recording no rotation happens, so under the high distinct-trace volume that live-heap sampling sustains, the table crosses the one-chunk threshold and the allocator runs away.

Fix

expandTableIfNeeded() now refuses to expand when the next generation's getSize() would exceed LinearAllocator::maxAllocatableSize() (one chunk minus its header). Instead of an unbounded allocation loop, put() keeps working on the current table at a higher load factor; the next rotation resets it. The bound is derived from the chunk size, so it stays correct if CALL_TRACE_CHUNK changes. A new calltrace_storage_expansion_skipped counter records when the cap engages.

This also includes a related per-thread native leak fix in LivenessTracker: its subsampling RNG ThreadLocals (gen/dis/skipped) were reclaimed only via pthread-key destructors, which never fire when a JNI-attached thread detaches on a reused OS thread. They are now released explicitly from Profiler::onThreadEnd(), matching how the CPU/wall engines and ProfiledThread are already torn down.

Validation

Reproducer (renaissance akka-uct -r 20 under memory=64:l) OOM'd at ~90 s / 13 iterations before the fix. With the fix it completes all 20 iterations with peak VMA count in the hundreds (was pinned at the 65530 limit). Compiles clean; verified the change is compatible with the existing expandTableIfNeeded/overflow-guard unit tests by inspection (the new check sits alongside the existing guards and only engages for tables that exceed one chunk).

🤖 Generated with Claude Code

CallTraceHashTable grows its backing LongHashTable by doubling capacity
from a LinearAllocator whose chunks are a fixed 8MB. Once a table exceeds
one chunk (capacity >= 512K entries), LinearAllocator::alloc() can never
place it: the bump-allocator loops allocating fresh chunks that still
cannot fit the oversized request, until mmap fails - a native OOM /
vm.max_map_count exhaustion, not a heap-space error. This is reachable
under live-heap profiling (memory=...:l) with a long, unrotated recording,
where the active table grows past that threshold between processTraces()
rotations.

Cap expansion at the largest table that fits in one allocator chunk
(LinearAllocator::maxAllocatableSize()); further growth degrades put() to
a higher load factor and recovers on the next rotation. Deriving the bound
from the chunk size keeps it correct if the chunk size ever changes.

Also fix a per-thread native leak in LivenessTracker's subsampling RNG
state: the gen/dis/skipped ThreadLocals were reclaimed only via pthread-key
destructors, which never fire when a JNI-attached thread detaches on a
reused OS thread. Release them explicitly from Profiler::onThreadEnd(),
matching how the CPU/wall engines and ProfiledThread are already torn down.

Environment: Datadog workspace

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jbachorik
jbachorik requested a review from a team as a code owner July 17, 2026 18:25
@dd-octo-sts

dd-octo-sts Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Benchmark Results (commit 75285e9)

Pipeline: https://gitlab.ddbuild.io/DataDog/apm-reliability/benchmarking-platform/-/pipelines/125374352 Commit: 75285e92c193b1b4eedd58201cecc5e0f6694060

✅ Within expected boundaries

No significant runtime deltas (all within run-to-run noise) and no internal-counter outliers.

Runtime details (per benchmark × JDK)
Benchmark JDK Latest Dev Δ (dev vs latest) Issues L/D
akka-uct 21 ✅ 10181 ms (21 iters) ✅ 10290 ms (21 iters) ≈ +1.1% (±10.8%) — / —
finagle-chirper 21 ✅ 5949 ms (33 iters) ✅ 5906 ms (33 iters) ≈ -0.7% (±25.3%) ⚠️ W:3 / ⚠️ W:3
finagle-chirper 25 ✅ 5510 ms (36 iters) ✅ 5495 ms (36 iters) ≈ -0.3% (±25.2%) ⚠️ W:3 / ⚠️ W:3
fj-kmeans 21 ✅ 2716 ms (69 iters) ✅ 2726 ms (68 iters) ≈ +0.4% (±2.7%) — / —
fj-kmeans 25 ✅ 2866 ms (66 iters) ✅ 2832 ms (66 iters) ≈ -1.2% (±2.5%) — / —
future-genetic 21 ✅ 2037 ms (90 iters) ✅ 2073 ms (90 iters) ≈ +1.8% (±2.6%) — / —
future-genetic 25 ✅ 2063 ms (90 iters) ✅ 2048 ms (90 iters) ≈ -0.7% (±2.9%) — / —
naive-bayes 21 ✅ 1299 ms (132 iters) ✅ 1277 ms (134 iters) ≈ -1.7% (±32.4%) — / —
naive-bayes 25 ✅ 1018 ms (168 iters) ✅ 1004 ms (170 iters) ≈ -1.4% (±31.4%) — / —
reactors 21 ✅ 16455 ms (15 iters) ✅ 16332 ms (15 iters) ≈ -0.7% (±8.1%) — / —
reactors 25 ✅ 18441 ms (15 iters) ✅ 18882 ms (15 iters) ≈ +2.4% (±4.1%) — / —
Internal counter details (ddprof)

ddprof internal counters, latest / dev (✅ = 0, · = unavailable):

Benchmark JDK Dropped rec Dropped jvmti Dropped trace Skipped WC AGCT fail Unwind fail
akka-uct 21 ✅ / ✅ ✅ / ✅ 1 / 2 1952 / 1942 ✅ / ✅ ✅ / ✅
akka-uct 25 ✅ / ✅ ✅ / ✅ 1 / 3 2169 / 2356 ✅ / ✅ ✅ / ✅
finagle-chirper 21 ✅ / ✅ ✅ / ✅ 4 / 2 8479 / 8210 ✅ / ✅ ✅ / ✅
finagle-chirper 25 ✅ / ✅ ✅ / ✅ ✅ / ✅ 8416 / 8155 ✅ / ✅ ✅ / ✅
fj-kmeans 21 ✅ / ✅ ✅ / ✅ 1 / 5 1282 / 1277 ✅ / ✅ ✅ / ✅
fj-kmeans 25 ✅ / ✅ ✅ / ✅ 3 / 3 1298 / 1255 ✅ / ✅ ✅ / ✅
future-genetic 21 ✅ / ✅ ✅ / ✅ 2 / 2 2922 / 2931 ✅ / ✅ ✅ / ✅
future-genetic 25 ✅ / ✅ ✅ / ✅ 2 / 1 2888 / 2859 ✅ / ✅ ✅ / ✅
naive-bayes 21 ✅ / ✅ ✅ / ✅ 4 / 4 3531 / 3536 ✅ / ✅ ✅ / ✅
naive-bayes 25 ✅ / ✅ ✅ / ✅ 3 / 4 3463 / 3494 ✅ / ✅ ✅ / ✅
reactors 21 ✅ / ✅ ✅ / ✅ ✅ / ✅ 1612 / 1596 ✅ / ✅ ✅ / ✅
reactors 25 ✅ / ✅ ✅ / ✅ ✅ / 1 1952 / 1935 ✅ / ✅ ✅ / ✅

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