Skip to content

perf(core): [Init Reflection 1] Probe class availability without initializing#5635

Merged
runningcode merged 4 commits into
no/perf-init-reflectionfrom
no/perf-init-reflection-no-init
Jun 30, 2026
Merged

perf(core): [Init Reflection 1] Probe class availability without initializing#5635
runningcode merged 4 commits into
no/perf-init-reflectionfrom
no/perf-init-reflection-no-init

Conversation

@runningcode

@runningcode runningcode commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

PR Stack (Init Reflection)


Part of JAVA-587

📜 Description

LoadClass.loadClass used Class.forName(name), which initializes the class. Since this method is used purely to probe whether an optional integration is on the classpath during SentryAndroid.init, that eagerly runs unrelated static initializers — the customer trace shows androidx.compose.ui.node.Owner.<clinit> and FragmentLifecycleIntegration.<clinit> executing under the availability check.

This switches to Class.forName(name, false, classLoader) so the class is loaded but only initialized lazily on first real use (e.g. when it's actually instantiated).

💡 Motivation and Context

First of three stacked PRs reducing reflection cost on the init path, from the customer-provided Perfetto trace in the Reduce SDK init time [Android] project (JAVA-586 area).

💚 How did you test it?

New LoadClassTest including a guard asserting that probing a class does not run its static initializer; existing init/integration tests pass unchanged.

📝 Checklist

  • I added tests to verify the changes.
  • No new PII added or SDK only sends newly added PII if sendDefaultPII is enabled.
  • No breaking change or entry added to the changelog.

🔮 Next steps

PR 2 caches lookups and collapses double-probes; PR 3 gates the Compose probes behind their features.

⏱️ Pixel 3 benchmark (ART method trace → Perfetto trace_processor)

Probing a class that has a (deliberately heavy) static initializer:

old Class.forName(name) new Class.forName(name, false, loader)
target-class <clinit> invocations under the probe 1 0

The static initializer runs under the old probe and is entirely skipped under the new one. In the production trace this was androidx.compose.ui.node.Owner.<clinit> and FragmentLifecycleIntegration.<clinit> running during init. (Method tracing inflates the absolute <clinit> time, so only the invocation count is reported.)

⏱️ Cold-start macrobenchmark (Pixel 3, sentry-samples-android)

Jetpack Macrobenchmark, StartupMode.COLD + CompilationMode.Full(), 20 iterations. A new android.os.Trace marker (SentryInitProbes, a no-op unless tracing is active) lets a TraceSectionMetric measure just the 6 isClassAvailable probes instead of the ~560 ms whole start — so the sub-millisecond effect clears the noise floor.

SentryInitProbes duration median min max
old (forName initializes) 0.833 ms 0.725 1.056
new (no-init) 0.434 ms 0.399 0.549

Δ median ≈ −399 µs per init on this app (timber + fragment + replay present), with non-overlapping ranges. The saving is the deferred static initializers — dominated by ReplayIntegration.<clinit> — which now run lazily on first use instead of under the probe. At whole-startup granularity (timeToInitialDisplay ≈ 563 ms ± 50 ms) the change is below run-to-run noise; it's a critical-path micro-optimization, not a measurable end-to-end startup win on its own.

⚠️ Merge this PR using a merge commit (not squash). Only the collection branch is squash-merged into main.

runningcode and others added 2 commits June 25, 2026 13:31
LoadClass.loadClass used Class.forName(name) which initializes the
class. Used purely for availability probing during init, this eagerly
runs unrelated static initializers (e.g. Compose's Owner, the fragment
integration). Use Class.forName(name, false, classLoader) so the class
is only initialized lazily on first real use.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@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.45.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 359.58 ms 433.92 ms 74.34 ms
Size 0 B 0 B 0 B

@linear-code

linear-code Bot commented Jun 25, 2026

Copy link
Copy Markdown

JAVA-587

@runningcode runningcode marked this pull request as ready for review June 25, 2026 12:03
return Class.forName(clazz);
// Don't initialize the class just to probe for availability; it gets initialized lazily on
// first use. This avoids running unrelated static initializers during SDK init.
return Class.forName(clazz, false, LoadClass.class.getClassLoader());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

In SentryNdk the static init block will now run much later, potentially causing ANRs.

Previously Class.forName triggered static block early and gave it time to run SentryNdk.loadNativeLibraries() in the background before we waited for loadLibraryLatch.await in the init method.

Now this may cause applications to spend more time waiting on main thread. Worst case would be 2s additional wait on main thread.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This also leads to a very theoretical case of classes being handed back that then fail when creating an instance. This could bite us on the OTel span factory and scopes storage. One fix here could be to just catch Throwable in SpanFactoryFactory and ScopesStorageFactory instead of all the individual exceptions where we might then miss ExceptionInInitializerError/NoClassDefFoundError/LinkageError.

But looking at the finding above, it may make sense to allow controlling true/false for the Class.forName call from the caller.

This however would increase complexity of caching results since we might have invoked Class.forName with false already and cached the result, then another caller might want true but since we already cached it it won't do it.

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.

Thanks for the information! I didn't know this. The idea here was to use this performance enhancement in places that are just checking for the presence of classes.

I propose adjusting only the isClassAvailable method and leaving the loadClass method untouched.

That being said, if we are relying on NDK integration loading in the background and then blocking on it that sound like a potential for an ANR with or without this change.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

That being said, if we are relying on NDK integration loading in the background and then blocking on it that sound like a potential for an ANR with or without this change.

The way this is implemented on main, gives the SDK a chance to perform other work before waiting for NDK to be loaded. I don't think we could remove the await since we're relying on NDK to perform some work before we trigger outbox sender. Not sure what'd happen if NDK was still moving files after we started sending out.

@markushi has already implemented some optimizations around NDK init, so he may have more details here.

propose adjusting only the isClassAvailable method and leaving the loadClass method untouched.

Could you performance test, whether calling Class.forName again for classes that can be found has a real performance cost? Maybe we could just cache whether a class exists (isClassAvailable) and make loadClass check if the class has already been cached as not found. My idea is that it may only have a large performance impact to call Class.forName if the class doesn't exist because then there's no cache. This might behave differently for different JDK versions and Android versions.

The simpler solution would probably be what you proposed and not use loadClass from isClassAvailable, if that's what you mean.

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.

Here's a benchmark I did. Let me know if you'd like me to commit it or see the benchmark.
Otherwise the code has been adjusted so only the isClassAvailable changes in behavior and loadClass stays the same.

Cold-start macrobenchmark (Pixel 3, sentry-samples-android)

Jetpack Macrobenchmark, StartupMode.COLD + CompilationMode.Full(), 20 iterations. I added an android.os.Trace marker
(SentryInitProbes, a no-op unless tracing is active) around the 6 isClassAvailable probes in SentryAndroid.init, so a
TraceSectionMetric measures just that block instead of the ~560 ms whole start — which is what lets a sub-millisecond effect clear the noise
floor.

SentryInitProbes duration median min max
old (forName initializes) 0.833 ms 0.725 1.056
new (no-init) 0.434 ms 0.399 0.549

Δ median ≈ −399 µs per init on this app (timber + fragment + replay present), with non-overlapping distributions (new max 0.549 < old
min 0.725). The saving is the deferred static initializers — dominated by ReplayIntegration.<clinit> — which now run lazily on first use
instead of under the availability probe.

The previous change made loadClass itself skip class initialization,
which affected callers that load a class to actually use it (NDK
integration, OTEL span factory and scopes storage). Restore loadClass
to its initializing behavior and confine the non-initializing probe to
isClassAvailable, which is only ever used for classpath availability
checks. This keeps SDK init cheap while leaving real-use callers
unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@runningcode runningcode requested a review from adinauer June 29, 2026 07:25

@romtsn romtsn left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

looks great, thanks for doing additional benchmarks! I guess using bytecode manipulation to inject these class names would still gain us even more savings, but after this lands, it's not that crucial anymore

@adinauer adinauer left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM

@runningcode runningcode merged commit 4b31414 into no/perf-init-reflection Jun 30, 2026
62 of 63 checks passed
@runningcode runningcode deleted the no/perf-init-reflection-no-init branch June 30, 2026 08:29
runningcode added a commit that referenced this pull request Jun 30, 2026
…ializing (#5635)

* perf(core): Probe class availability without initializing the class

LoadClass.loadClass used Class.forName(name) which initializes the
class. Used purely for availability probing during init, this eagerly
runs unrelated static initializers (e.g. Compose's Owner, the fragment
integration). Use Class.forName(name, false, classLoader) so the class
is only initialized lazily on first real use.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* changelog

* changelog: move init reflection entries to Performance

* perf(core): Limit no-init class probing to isClassAvailable

The previous change made loadClass itself skip class initialization,
which affected callers that load a class to actually use it (NDK
integration, OTEL span factory and scopes storage). Restore loadClass
to its initializing behavior and confine the non-initializing probe to
isClassAvailable, which is only ever used for classpath availability
checks. This keeps SDK init cheap while leaving real-use callers
unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
runningcode added a commit that referenced this pull request Jun 30, 2026
…5634)

* collection: Reduce reflection cost during SDK init

* perf(core): [Init Reflection 1] Probe class availability without initializing (#5635)

* perf(core): Probe class availability without initializing the class

LoadClass.loadClass used Class.forName(name) which initializes the
class. Used purely for availability probing during init, this eagerly
runs unrelated static initializers (e.g. Compose's Owner, the fragment
integration). Use Class.forName(name, false, classLoader) so the class
is only initialized lazily on first real use.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* changelog

* changelog: move init reflection entries to Performance

* perf(core): Limit no-init class probing to isClassAvailable

The previous change made loadClass itself skip class initialization,
which affected callers that load a class to actually use it (NDK
integration, OTEL span factory and scopes storage). Restore loadClass
to its initializing behavior and confine the non-initializing probe to
isClassAvailable, which is only ever used for classpath availability
checks. This keeps SDK init cheap while leaving real-use callers
unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

3 participants