Skip to content

perf(core): Drop per-instance lock from SentryId and SpanId#5645

Open
runningcode wants to merge 2 commits into
mainfrom
no/java-589-reduce-id-lock-overhead
Open

perf(core): Drop per-instance lock from SentryId and SpanId#5645
runningcode wants to merge 2 commits into
mainfrom
no/java-589-reduce-id-lock-overhead

Conversation

@runningcode

@runningcode runningcode commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

📜 Description

SentryId and SpanId stored their string value behind a LazyEvaluator<String>. Each LazyEvaluator allocates an AutoClosableReentrantLock (a ReentrantLock subclass, including its internal Sync object) plus a capturing lambda — on every instance.

This change replaces the LazyEvaluator<String> in both classes with a plain volatile String guarded by a double-checked synchronized (this) block:

  • Eager (string-arg) constructors now assign the value directly — no lazy wrapper, no lock.
  • The no-arg / UUID constructors still defer UUID-string generation; SentryId keeps the UUID reference so toSentryIdString stays lazy.
  • Synchronization is retained (not a lock-free race) because UUID generation is non-idempotent — two racing threads must not produce different ids for the same instance.

No public API change (apiDump produces no diff).

💡 Motivation and Context

One SentryId is created per event/transaction and one SpanId per span, so these are among the most frequently allocated objects in the SDK. Paying for a per-instance ReentrantLock + lambda to guard a single String is 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 SpanIdTest and SentryIdTest cover the lazy-generation and normalization behavior (UUID generated/normalized only once, not on init, etc.). The full :sentry unit test suite passes (3372/3373; the one failing SentryIdTest case is a pre-existing test-isolation artifact that fails identically on main and is unrelated to this change).

Benchmark

Measured against main using the real compiled classes on the same JVM (Temurin 17.0.16). Allocation is measured via ThreadMXBean.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.

Scenario Bytes/op (before → after) Δ bytes Throughput ops/ms (before → after) Speedup
SpanId(String) eager 104 → 16 −88 (−85%) 93,580 → 458,935 4.9×
SpanId() lazy + toString 232 → 144 −88 (−38%) 41,587 → 65,556 1.6×
SentryId(String) eager 104 → 24 −80 (−77%) 138,296 → 312,029 2.3×
SentryId() lazy + toString 320 → 208 −112 (−35%) 26,472 → 34,084 1.3×
SentryId(UUID) lazy + toString 408 → 304 −104 (−25%) 4,430 → 4,579 1.03×

The ~88-byte reduction on eager SpanId accounts exactly for the removed object graph: LazyEvaluator (24B) + AutoClosableReentrantLock (16B) + its internal NonfairSync/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 by UUID.randomUUID() (a SecureRandom draw), not the lock.

📝 Checklist

  • I added GH Issue ID & Linear ID
  • I added tests to verify the changes.
  • No new PII added or SDK only sends newly added PII if sendDefaultPII is enabled.
  • I updated the docs if needed.
  • I updated the wizard if needed.
  • Review from the native team if needed.
  • No breaking change or entry added to the changelog.
  • No breaking change for hybrid SDKs or communicated to hybrid SDKs.

🔮 Next steps

Other LazyEvaluator/AutoClosableReentrantLock usages that guard genuinely light objects can be reviewed similarly as part of the SDK overhead reduction effort.

@linear-code

linear-code Bot commented Jun 25, 2026

Copy link
Copy Markdown

JAVA-589

@sentry

sentry Bot commented Jun 25, 2026

Copy link
Copy Markdown

📲 Install Builds

Android

🔗 App Name App ID Version Configuration
SDK Size io.sentry.tests.size 8.46.0 (1) release

⚙️ sentry-android Build Distribution Settings

@github-actions

github-actions Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Performance metrics 🚀

  Plain With Sentry Diff
Startup time 314.28 ms 362.36 ms 48.08 ms
Size 0 B 0 B 0 B

Baseline results on branch: main

Startup times

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;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

using synchronized here still provides thread safety without allocating any extra objects.

runningcode and others added 2 commits June 30, 2026 12:15
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>
@runningcode runningcode force-pushed the no/java-589-reduce-id-lock-overhead branch from 8d0622c to 288b97d Compare June 30, 2026 10:16
@runningcode runningcode marked this pull request as ready for review June 30, 2026 10:16
} else {
this.lazyStringValue = new LazyEvaluator<>(() -> normalized);
this.uuid = null;
this.value = normalized.length() == 36 ? normalize(normalized) : normalized;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

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