Repository navigation
Conversation
Bound signal-handler stub lookups and move allocations out of the exclusive stubs lock. Closes #843 JIRA: PROF-16313 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI Test ResultsRun: #39987347888 | Commit:
Status Overview
Legend: ✅ passed | ❌ failed | ⚪ skipped | 🚫 cancelled Summary: Total: 76 | Passed: 76 | Failed: 0 Updated: 2026-10-23 17:00:44 UTC |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
More details
Writer serialization protects the prepare/commit state, and lookups copy stub fields before replaced arrays can be freed. Static review identified no reportable defect; runtime tests were not executed.
🤖 Bits Code Review · Commit cc87ec9
Reliability & Chaos Results✅ All reliability & chaos checks passed Pipeline: https://gitlab.ddbuild.io/DataDog/java-profiler/-/pipelines/147147005 |
rkennke
left a comment
There was a problem hiding this comment.
Thanks, the deadlock fix looks right to me. Nothing is allocated or freed under the exclusive lock, replaced arrays are freed only after the unlock, lookups copy what they need before releasing the shared lock, and the write mutex is recursive and never taken in a signal handler.
Two comments I'd like addressed (jitCodeCache.cpp L175 and hotspotSupport.cpp L1174). They are related: with the spin budget, contention among readers alone can make a stub lookup fail, and on aarch64 a failed lookup changes how the frame is unwound, not just what it's called. Using tryLockShared() with no argument should fix both.
The comments marked Optional are suggestions. Up to you.
| const char **name) { | ||
| // Bounded: the caller is a signal handler, and the exclusive holder may be | ||
| // the interrupted thread itself. | ||
| if (!_stubs_lock.tryLockShared(SpinLock::DEFAULT_SHARED_SPIN_BUDGET)) { |
There was a problem hiding this comment.
Readers competing with each other can now make a lookup fail
tryLockShared(DEFAULT_SHARED_SPIN_BUDGET) gives up after 256 failed CAS attempts even when no writer holds the lock. In a wall-clock burst, many sampled threads take the shared lock at the same moment, and CAS failures between those readers alone can use up the budget. A real stub is then reported as "not found". Before this PR, lockShared() always succeeded in that case. The same applies to findRuntimeStubInfo() at L199.
Suggestion: use the overload with no argument, tryLockShared() (spinLock.h:61). It returns false only when a writer holds the lock, which is exactly the deadlock case, and it keeps retrying while only readers compete. That retry loop ends, because readers hold the lock for a short, finite time. With commitAdd() now allocation-free, the writer's critical section is also short, so the spin budget no longer protects against anything here. This also means STUB_LOOKUP_LOCK_UNAVAILABLE would count only one cause, the writer holding the lock, so the metric becomes easier to interpret.
| if (stub != NULL) { | ||
| stub_start = stub->_start; | ||
| stub_name = stub->_name; | ||
| if (JitCodeCache::findRuntimeStub((const void *)frame.pc(), &stub_start, &stub_name)) { |
There was a problem hiding this comment.
A failed lookup also changes how the frame is unwound
When this returns false because the lock is busy, the code falls through to the CodeHeap::findNMethod() branch. That branch finds the stub blob and calls frame.unwindStub(NULL, nmethod->name()), with no stub start and no StubUnwindInfo. On aarch64 that means the old heuristics instead of the precomputed table, and jitCodeCache.cpp describes those heuristics as able to read a stale x30 slot. The walkVM path (L771) also continues with stub_info == nullptr.
So a busy lock can produce a wrong caller frame, not just a missing or less specific frame name. Inside the short registration window that is acceptable, but the reader-contention case in the other comment makes it happen more often. Fixing that comment is enough. It would still be worth saying in the PR description and the header comment that the fallback is a degraded unwind.
| // Registrations can arrive concurrently from several threads. The writer | ||
| // mutex orders them, so the state read while preparing below stays valid | ||
| // until the commit. Signal handlers never take it. | ||
| MutexLocker writer(_stubs_write_lock); |
There was a problem hiding this comment.
Optional: up to you, not blocking.
The writer mutex is taken before the aarch64 analyzeStubUnwind() call, so every stub's instruction decode and allocation runs while _stubs_write_lock is held. The analysis only reads the stub's code and touches no shared state. Moving the MutexLocker below the #ifdef __aarch64__ analysis block would stop concurrent DynamicCodeGenerated calls (at startup, or when GenerateEvents replays them) from waiting on each other's scans. The duplicate check and prepareAdd() are the only parts that need the mutex.
| // Every heap allocation and free happens outside the exclusive lock. A | ||
| // thread that holds the allocator lock can be interrupted by a profiling | ||
| // signal whose handler looks up a stub; if this thread then blocked in the | ||
| // allocator while holding _stubs_lock, that lookup could never succeed. |
There was a problem hiding this comment.
Optional: up to you, not blocking.
This comment is slightly imprecise. "this thread" refers to two different threads, and "that lookup could never succeed" describes the old unbounded lockShared(). With the bounded readers in this PR, the lookup returns false instead of spinning. Maybe say it explicitly: the bounded reader is what prevents the deadlock, and allocating outside the lock keeps the window short in which lookups fail and fall back to the degraded unwind.
| * that returned "not found" without searching because the shared stubs \ | ||
| * lock was held exclusively by a registration or the shared spin budget \ | ||
| * ran out; the stack walk continued with its stub-not-found fallback. */ \ | ||
| X(STUB_LOOKUP_LOCK_UNAVAILABLE, "stub_lookup_lock_unavailable") \ |
There was a problem hiding this comment.
Optional: up to you, not blocking.
This counter combines "a writer held the lock" (an expected, short registration window) with "the spin budget ran out" (reader contention). If you switch to the no-argument tryLockShared(), only the first cause remains and this goes away. Otherwise, two counters would make the telemetry possible to act on.
| int capacity; // _capacity that this preparation is valid for | ||
| }; | ||
|
|
||
| // Two-phase add() for a cache whose readers run in signal handlers under a |
There was a problem hiding this comment.
Optional: up to you, not blocking.
A design point to consider. The bounded readers alone already break the deadlock, so prepareAdd()/commitAdd() plus the second mutex are about keeping the exclusive window short. I think that's worth keeping: without it, a writer blocked in malloc holds the lock exclusively for an unbounded time, every stub lookup fails during that time, and runtimeStubsMemoryUsage() waits behind it.
The rules, though, are enforced only by debug asserts: exactly one commitAdd() per prepareAdd(), nothing changing the cache in between, the caller deletes the returned array after unlocking, and writers are serialized externally. To make misuse harder, PreparedAdd could be an RAII type that frees its name copy and grown array if it is never committed.
| } // namespace | ||
|
|
||
| // Regression for the stub-lock deadlock: a signal-handler lookup must not wait | ||
| // for a registration that holds _stubs_lock exclusively. Before the fix the |
There was a problem hiding this comment.
Optional: up to you, not blocking.
Two small points:
- The tests cover the bounded-reader half of the fix, but nothing would catch a
malloc/new/delete[]moved back inside the_stubs_lock.lock()/unlock()block inDynamicCodeGeneratedoutside ASan. That half is hard to test directly. A debug-only "allocation forbidden" flag, or at least a clear comment at the critical section, would help. - "Before the fix the lookup spun in lockShared()" describes history. It would be better to describe what the test checks now.
What does this PR do?:
Removes a JVM deadlock between
JitCodeCache::DynamicCodeGeneratedand the sampling signal handler.findRuntimeStub()andfindRuntimeStubInfo()usetryLockShared(DEFAULT_SHARED_SPIN_BUDGET). When the lock is not available, the lookup returns "not found", incrementsstub_lookup_lock_unavailable, and the stack walk uses its existing fallback.DynamicCodeGeneratedallocates and frees memory only outside_stubs_lock. Inside the lock, it only stores and swaps pointers.findRuntimeStub()copies the stub start and name while it holds the lock. The old version returned aCodeBlob*that a concurrent registration could free.DynamicCodeGeneratedfrees the unwind info of a stub when the registry cannot grow.Registration path in detail
_stubs_write_lock(newMutex). It orders concurrent writers. Signal handlers never take it._stubs_lock:CodeCache::prepareAdd()copies the name and, when the blob array is full, allocates and fills a larger array._stubs_lock:CodeCache::commitAdd()and the registry insert store and swap pointers only._stubs_lock, then free the replaced arrays.CodeCache::add()callsprepareAdd()andcommitAdd()and keeps the same results and memory accounting. This PR removesCodeCache::expand().Motivation:
Issue #843 reports this cycle (JDK 25, glibc):
DynamicCodeGenerated_stubs_lock(exclusive)mallocmalloc_stubs_lock, spinning inlockShared()_stubs_lock, spinning inlockShared()The JVM cannot reach a safepoint, and the sampled threads use 100% CPU.
The bounded lookup also prevents a wait on the same thread. The thread that holds
_stubs_lockcan take a sample of itself through a profiling signal or through the malloc hook. When its stack contains a runtime-stub frame, the walk calls the stub lookup.Additional Notes:
_stubs_write_lock, then_stubs_lock. Readers take only_stubs_lock.runtimeStubsMemoryUsage()runs on the dump thread, so it keepslockShared().CodeHeap::findNMethod()whenVMStructs::hasMethodStructs()is true.How to test the change?:
New tests:
FindRuntimeStubDoesNotWaitForExclusiveHolder_stubs_lockheld, the lookup returns "not found" within 5 s and increments the counter. On base commitaf819c666, the test fails.FindRuntimeStubInfoDoesNotWaitForExclusiveHolderRegistrationsAcrossGrowthStayFindableDuplicateRegistrationKeepsFirstInfoConcurrentRegistrationAndLookupTwoPhaseAddGrowsOnlyWhenFullprepareAdd()/commitAdd()grow only when full and keep the exact memory accounting.TwoPhaseAddReplacesNonPrintableNameprepareAdd()replaces non-printable name characters.No automated test checks that the exclusive section makes no allocator calls. Code review covers that point.
Results:
:ddprof-lib:gtestDebug,:ddprof-lib:gtestReleasetestDebugaf819c666.VtableReceiverFrameTest,NativeThreadTest,DynamicNativeThreadjitCodeCache_ut,codeCache_ut:ddprof-test:testDebugFor Datadog employees:
credentials of any kind, I've requested a security review (run the
dd:platform-security-reviewskill, or file a request via the PSEC review form).
bewairealso runs automatically on every PR.Closes #843
Unsure? Have a question? Request a review!
🤖 Generated with Claude Code