| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
Sorry, something went wrong.
Sorry, something went wrong.
📲 Install BuildsAndroid
|
Sorry, something went wrong.
Performance metrics 🚀
Baseline results on branch: mainStartup times
App size
Previous results on branch: no/java-588-lazy-lock-allocationStartup times
App size
|
Sorry, something went wrong.
There was a problem hiding this comment.
great stuff!
Sorry, something went wrong.
AutoClosableReentrantLock extended ReentrantLock, so every SDK object holding one allocated a ReentrantLock (and its AbstractQueuedSynchronizer) eagerly in its field initializer. A customer Perfetto trace showed ~81 such allocations on the main thread during SentryAndroid.init, many for locks that are never acquired during init. Hold the ReentrantLock internally and create it lazily on first acquire(), using an AtomicReferenceFieldUpdater CAS so creation stays atomic and Loom-friendly (no synchronized, preserving #3715). Every call site uses acquire() only, so dropping the ReentrantLock superclass touches no caller. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…internal (JAVA-588) Replace the unreachable candidate fallback after a failed CAS with an explicit non-null check, so a broken invariant fails loudly instead of handing two threads different locks. Mark the class @ApiStatus.Internal and make the lazy-allocation test assert the lock field directly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
While the changes LGTM, we should consider changing this in the next major since it's a breaking change for anyone using the AutoClosableReentrantLock. However the majority of users will likely prefer to just have this improvement and even if someone's using it they'll likely be using the same APIs that still exist that we use so approving.
Sorry, something went wrong.
Every acquire() allocated a fresh lifecycle token, which is per-use garbage on every lock acquisition forever, not just at init. The token was stateless apart from its lock reference, so AutoClosableReentrantLock now implements ISentryLifecycleToken itself and acquire() returns this, making the steady-state acquire/close path allocation-free. Semantics are unchanged: try-with-resources closes once per acquire, so reentrant acquires stay balanced, and unlocking without holding the lock still throws IllegalMonitorStateException. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| Back | FazBrowse Home | New Git URL |
📜 Description
io.sentry.util.AutoClosableReentrantLock extended java.util.concurrent.locks.ReentrantLock, and ~57 SDK classes create one eagerly in a field initializer. Constructing the SDK object graph therefore allocated a ReentrantLock (plus its AbstractQueuedSynchronizer Sync) per object — even for objects whose lock is never acquired.
This change holds the ReentrantLock internally and creates it lazily on the first acquire(), using an AtomicReferenceFieldUpdater CAS so the lazy creation is atomic and stays Loom-friendly (no synchronized, preserving the intent of #3715). If the CAS loses the first-acquire race, the caller uses the winner's lock; that invariant is enforced with an explicit non-null check so a broken invariant fails loudly instead of silently handing two threads different locks.
On top of the lazy lock, AutoClosableReentrantLock now implements ISentryLifecycleToken itself and acquire() returns this, eliminating the per-acquire lifecycle-token allocation entirely. That allocation happened on every lock use forever, not just at init, so the steady-state acquire/close path is now allocation-free. Semantics are unchanged: try-with-resources closes once per acquire (reentrant acquires stay balanced) and unlocking without holding the lock still throws IllegalMonitorStateException.
💡 Motivation and Context
Part of the Reduce SDK init time [Android] effort (JAVA-588). A customer-provided Perfetto trace showed ~81 ReentrantLock allocations on the main thread under SentryAndroid.init, contributing GC pressure and main-thread CPU. With lazy allocation, only locks actually acquired during init allocate; the many never-contended locks no longer allocate at construction.
Compatibility note: this is technically a binary-compatibility change — AutoClosableReentrantLock no longer extends ReentrantLock, so the inherited ReentrantLock public methods leave its API surface (reflected in sentry.api). A usage audit confirmed every call site uses only acquire() (try-with-resources); nothing calls lock()/unlock()/tryLock()/isHeldByCurrentThread() directly or types a field/param as ReentrantLock, so no caller is affected. The class is now also marked @ApiStatus.Internal to make explicit that it carries no compatibility guarantees for external consumers.
An on-device A/B benchmark on a Pixel 3 (run locally, not included in this PR) quantified the allocation reduction and confirmed acquire() hot-path throughput is unchanged — see results below.
📈 Benchmark results (Pixel 3, Android 12)
Measured on a release build using ART instrumented method tracing, with slices counted in Perfetto trace_processor (btrace app-tracing is broken on this device). The A/B was done by swapping only AutoClosableReentrantLock between the old (extends ReentrantLock) and new (lazy) implementations and rebuilding; everything else is identical.
Real SentryAndroid.init (cold) — lock allocations
70 fewer ReentrantLock allocations per init (140 fewer heap objects: 70 locks + 70 Sync), a ~65% reduction. Counts were identical across 3 runs of each build. Both builds make the same 40 acquire() calls, so the init code path is unchanged — only never-contended locks stop allocating (37 distinct locks are acquired during init vs 107 created). (This benchmark app allocates 107; the customer trace above showed ~81.)
Construction cost (per lock): old ~73–78 ns/alloc, new ~60–66 ns/alloc → ~11.5 ns saved per allocation. Across the 70 avoided allocations that is ≈0.8 µs of direct allocation work per init — below the cold-start noise floor. The benefit is therefore reduced allocation/GC pressure, not a measurable init-latency win.
acquire() hot path: ~161 ns/op for acquire() + close() when the lock already exists — unchanged; the lazy path adds only a volatile read + null check after the first acquire.
💚 How did you test it?
Unit tests in AutoClosableReentrantLockTest (lazy creation asserted directly against the underlying lock field via a @TestOnly accessor, token identity — acquire() returns the lock itself, reentrancy, and an 8-thread × 1000-iteration mutual-exclusion stress test); full :sentry:test suite green.
📝 Checklist
🔮 Next steps
Biggest remaining main-thread init cost in the same trace is Manifest parsing (~147ms) — tracked separately (SDK-1322 / JAVA-531, compile-time injection).
Fixes JAVA-588