perf(core): Drop per-instance lock from SentryId and SpanId#5645
perf(core): Drop per-instance lock from SentryId and SpanId#5645runningcode wants to merge 2 commits into
Conversation
📲 Install BuildsAndroid
|
Performance metrics 🚀
|
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 1edbdfa | 364.77 ms | 450.29 ms | 85.52 ms |
| a416a65 | 333.78 ms | 410.37 ms | 76.59 ms |
| d15471f | 302.62 ms | 353.84 ms | 51.22 ms |
| 22f4345 | 314.79 ms | 375.02 ms | 60.23 ms |
| 6b019b7 | 343.31 ms | 417.23 ms | 73.91 ms |
| 22f4345 | 312.78 ms | 347.40 ms | 34.62 ms |
| d217708 | 411.22 ms | 430.86 ms | 19.63 ms |
| 319f256 | 315.96 ms | 372.96 ms | 57.00 ms |
| e2dce0b | 308.96 ms | 360.10 ms | 51.14 ms |
| 8558cac | 306.16 ms | 355.24 ms | 49.09 ms |
App size
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 1edbdfa | 1.58 MiB | 2.20 MiB | 635.34 KiB |
| a416a65 | 1.58 MiB | 2.12 MiB | 555.26 KiB |
| d15471f | 1.58 MiB | 2.13 MiB | 559.54 KiB |
| 22f4345 | 1.58 MiB | 2.29 MiB | 719.83 KiB |
| 6b019b7 | 0 B | 0 B | 0 B |
| 22f4345 | 1.58 MiB | 2.29 MiB | 719.83 KiB |
| d217708 | 1.58 MiB | 2.10 MiB | 532.97 KiB |
| 319f256 | 1.58 MiB | 2.19 MiB | 619.79 KiB |
| e2dce0b | 0 B | 0 B | 0 B |
| 8558cac | 0 B | 0 B | 0 B |
| new SentryId(StringUtils.PROPER_NIL_UUID.replace("-", "")); | ||
|
|
||
| private final @NotNull LazyEvaluator<String> lazyStringValue; | ||
| private volatile @Nullable String value; |
There was a problem hiding this comment.
i don't like the name of this field value along with the getValue() method but to change the getValue() would break the API so probably better to keep it like this.
| private @NotNull String getValue() { | ||
| String result = value; | ||
| if (result == null) { | ||
| synchronized (this) { |
There was a problem hiding this comment.
using synchronized here still provides thread safety without allocating any extra objects.
SentryId and SpanId stored their string value behind a LazyEvaluator<String>, which allocates an AutoClosableReentrantLock (a ReentrantLock with its internal Sync) plus a capturing lambda on every instance. Since one SentryId is created per event/transaction and one SpanId per span, this per-instance lock machinery is far heavier than the single String it guards, and the eager string-arg constructors gained no laziness at all. Replace the LazyEvaluator with a plain volatile String guarded by a double-checked synchronized(this) block. Eager constructors now assign the value directly; the no-arg and UUID constructors still defer UUID-string generation. Synchronization is retained because UUID generation is non-idempotent and two racing threads must not produce different ids. Follow-up to the SDK Overhead Reduction work (#5499). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
8d0622c to
288b97d
Compare
| } else { | ||
| this.lazyStringValue = new LazyEvaluator<>(() -> normalized); | ||
| this.uuid = null; | ||
| this.value = normalized.length() == 36 ? normalize(normalized) : normalized; |
There was a problem hiding this comment.
Bug: The SentryId(String) constructor calls StringUtils.normalizeUUID twice when a 36-character UUID string is provided, which is inefficient.
Severity: LOW
Suggested Fix
Refactor the SentryId(String) constructor to ensure StringUtils.normalizeUUID is only called once. The result of the initial normalization can be passed to the normalize helper method, or the logic can be consolidated to avoid the second call.
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.
Location: sentry/src/main/java/io/sentry/protocol/SentryId.java#L41
Potential issue: When a `SentryId` is constructed with a 36-character string, such as a
standard UUID with dashes, the constructor calls `StringUtils.normalizeUUID` on the
input string. It then checks the length and, if it's 36, calls the internal `normalize`
method, which in turn calls `StringUtils.normalizeUUID` a second time on the same
string. While this doesn't cause incorrect behavior because the function is idempotent,
it results in a redundant and inefficient function call.
Did we get this right? 👍 / 👎 to inform future reviews.
📜 Description
SentryIdandSpanIdstored their string value behind aLazyEvaluator<String>. EachLazyEvaluatorallocates anAutoClosableReentrantLock(aReentrantLocksubclass, including its internalSyncobject) plus a capturing lambda — on every instance.This change replaces the
LazyEvaluator<String>in both classes with a plainvolatile Stringguarded by a double-checkedsynchronized (this)block:UUIDconstructors still defer UUID-string generation;SentryIdkeeps theUUIDreference sotoSentryIdStringstays lazy.No public API change (
apiDumpproduces no diff).💡 Motivation and Context
One
SentryIdis created per event/transaction and oneSpanIdper span, so these are among the most frequently allocated objects in the SDK. Paying for a per-instanceReentrantLock+ lambda to guard a singleStringis pure overhead, and the eager string-arg constructors got no laziness benefit at all. Follow-up to the SDK Overhead Reduction work (#5499).Resolves JAVA-589.
💚 How did you test it?
Existing
SpanIdTestandSentryIdTestcover the lazy-generation and normalization behavior (UUID generated/normalized only once, not on init, etc.). The full:sentryunit test suite passes (3372/3373; the one failingSentryIdTestcase is a pre-existing test-isolation artifact that fails identically onmainand is unrelated to this change).Benchmark
Measured against
mainusing the real compiled classes on the same JVM (Temurin 17.0.16). Allocation is measured viaThreadMXBean.getThreadAllocatedBytes(exact TLAB accounting), with objects stored into a live array so the JIT can't scalar-replace them. Throughput is single-threaded, best-of-10 measured rounds after 5 warmup rounds of 1M ops each.SpanId(String)eagerSpanId()lazy +toStringSentryId(String)eagerSentryId()lazy +toStringSentryId(UUID)lazy +toStringThe ~88-byte reduction on eager
SpanIdaccounts exactly for the removed object graph:LazyEvaluator(24B) +AutoClosableReentrantLock(16B) + its internalNonfairSync/AQS (32B) + the capturing lambda (16B). Eager constructors (hot deserialization/propagation paths) win biggest. The lazy paths still shed the lock machinery;SentryId(UUID)throughput barely moves because that path is dominated byUUID.randomUUID()(aSecureRandomdraw), not the lock.📝 Checklist
sendDefaultPIIis enabled.🔮 Next steps
Other
LazyEvaluator/AutoClosableReentrantLockusages that guard genuinely light objects can be reviewed similarly as part of the SDK overhead reduction effort.