perf(core): [Init Reflection 1] Probe class availability without initializing#5635
Conversation
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>
📲 Install BuildsAndroid
|
Performance metrics 🚀
|
| 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()); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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>
romtsn
left a comment
There was a problem hiding this comment.
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
…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>
…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>
PR Stack (Init Reflection)
Part of JAVA-587
📜 Description
LoadClass.loadClassusedClass.forName(name), which initializes the class. Since this method is used purely to probe whether an optional integration is on the classpath duringSentryAndroid.init, that eagerly runs unrelated static initializers — the customer trace showsandroidx.compose.ui.node.Owner.<clinit>andFragmentLifecycleIntegration.<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
LoadClassTestincluding a guard asserting that probing a class does not run its static initializer; existing init/integration tests pass unchanged.📝 Checklist
sendDefaultPIIis enabled.🔮 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:
Class.forName(name)Class.forName(name, false, loader)<clinit>invocations under the probeThe 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>andFragmentLifecycleIntegration.<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 newandroid.os.Tracemarker (SentryInitProbes, a no-op unless tracing is active) lets aTraceSectionMetricmeasure just the 6isClassAvailableprobes instead of the ~560 ms whole start — so the sub-millisecond effect clears the noise floor.SentryInitProbesdurationforNameinitializes)Δ 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.