Mobile Computing · Mobile Performance
Class Loading and Verification Cost on the Startup Path
How the runtime turns a class reference into executable code at first use, why verification walks bytecode the program may never run, and how ahead-of-time compilation changes the split.
A class reference in source code is a promise the runtime has to keep. The first time a method touches a class, the runtime must find the class’s bytes, turn them into an internal representation, check that the representation is internally consistent and safe, and prepare the method for execution. All of that happens on whichever thread made the first touch, and during startup that thread is usually the main thread.
The stages, in order
Loading is the first stage: the runtime locates the class’s definition, by name, through a class loader. On a mobile platform the loader usually reads from the installed application package, which is a memory-mapped archive, so the bytes are not copied unless they are modified. Locating a class inside a package with tens of thousands of entries costs a lookup per class, and the lookup is not cached across processes.
Linking follows, and it has three parts. Verification checks that the bytecode is well-formed and cannot violate the runtime’s invariants. Preparation allocates storage for static fields and sets them to their default values. Resolution replaces symbolic references in the constant pool with direct references, either eagerly or lazily depending on the runtime’s policy.
Initialization runs the class’s static initializer, once, on first active use. The runtime guarantees that no other thread can observe a partially initialized class, which it enforces with a per-class lock. The lock is the source of one of the classic startup deadlocks: two threads initializing two classes that reference each other, each waiting for the other’s lock.
The ordering is observable. A class that fails verification throws at first use rather than at load, which means a defect can lie dormant through installation and testing and appear only when a rarely used path executes. The error is a VerifyError, and it is a linkage error rather than a runtime exception, so it can surface from a call site that has no try block around it.
Verification is the expensive part
The verifier’s job is to prove that every instruction operates on values of the correct type, without executing the code. It does this by simulating the operand stack and local variable table along every control-flow path, tracking a type for each slot and merging types at join points. A method with n instructions and b branches has a verification cost proportional to n times the number of paths, though the merge at join points keeps it from being exponential in practice.
The work is done per method, at the granularity of the class, and it covers methods that will never run. A class with fifty methods has all fifty verified when the class is linked, even if the app calls one. This is the reason a large dependency graph costs startup time even when most of it is unused: the cost is in the classes touched, and touching a class means paying for all of its methods.
The verifier can be relaxed for code that comes from a trusted source. A class compiled ahead of time and signed as part of the installed application can be marked as verified, and the runtime skips the check at load. That is what ahead-of-time compilation buys in addition to removing interpretation: the verification result is computed once, at install, and reused on every launch.
Ahead-of-time versus just-in-time versus profile-guided
Three compilation policies produce three different startup profiles.
Full ahead-of-time compilation translates every method to native code at install time. Startup then consists of mapping the compiled code and resolving what remains. The costs move to install, which can take a long time for a large app, and to storage, since the compiled code is larger than the bytecode. A method that is never called still occupies space and still had to be compiled.
Pure just-in-time compilation interprets every method on first call and compiles only those that become hot. Startup is fast because nothing is compiled, but the first execution of any method is interpreted, which is an order of magnitude slower than compiled code. For a startup path that executes thousands of methods once each, the interpretation cost accumulates and the compilation threshold is never reached, so the whole path runs interpreted.
Profile-guided compilation is the compromise the platform settled on. The runtime records which methods execute during real use and writes that profile back. On a later background pass, those methods are compiled and the result is stored alongside the installed package. Startup then runs compiled code for the methods the profile covers, and interprets the rest. The profile is per-app and per-device, so the first launches after an install are slower than steady state, and the gap is the reason a post-install measurement is not representative of anything.
The three policies are not exclusive. A device can run ahead-of-time compilation for a subset and profile-guided for the rest, and the profile can be invalidated by an app update, returning the app to interpreted execution until a new profile is collected. The invalidation is why an update can produce a startup regression that disappears after a few launches with no code change.
What the app controls
The app cannot choose the compilation policy, but it chooses which classes are touched before the first frame, and that choice dominates.
The largest single lever is the dependency graph reachable from the application’s initialization path. A dependency injection framework that reflects over its configuration at startup, a serialization library that scans for annotated classes, and an analytics SDK that initializes in a provider all add classes to the startup set. The cost is not the framework’s code alone but every class the framework’s code references, transitively, because touching one class can force resolution of the classes its method signatures mention.
Method signatures matter more than they appear to. Resolving a method reference requires the runtime to resolve the types in its descriptor, so a method that takes a large configuration object as a parameter pulls that type into the startup set even if the method body never reads it. Flattening a public API that takes many optional parameters into several narrower entry points reduces what a call forces into memory.
Static initializers are the other lever, and they are worse than they look because they run at first active use, which is often the first frame. A static field initialized by a computation that parses a resource or builds a lookup table does that work on whichever thread first touches the class. Moving that work to an explicit initialization call the app controls, and deferring it until after the first frame, moves the cost off the critical path without changing the total.
// Runs during class initialization, i.e. on the thread that first
// touches this class, often the main thread before the first frame.
final class Patterns {
static final Pattern ID = Pattern.compile("[a-z0-9-]+");
static final Map<String, Rule> RULES = loadRules(); // parses a resource
}
// Explicit and deferrable: the class initializes cheaply, and the
// expensive work happens when the caller decides.
final class Patterns2 {
private static volatile Map<String, Rule> rules;
static Map<String, Rule> rules() {
Map<String, Rule> r = rules;
if (r == null) { r = loadRules(); rules = r; }
return r;
}
}
The second form is not free of synchronization, and the volatile read per call is a real cost in a hot loop. The point is that the cost is now a per-call read rather than a one-time parse on the startup thread, and the parse can be scheduled after the first frame.
Linkage errors and their disguises
A class initializer that throws. The class is marked erroneous, and every subsequent attempt to use it throws NoClassDefFoundError with the original cause attached. The first failure’s stack trace is the only useful one; later ones point at the use site and obscure the cause. Catching the initializer’s exception inside the initializer and logging it does not help, because the class is still marked erroneous.
Class loading on the main thread during interaction. Lazy initialization is the right default, but it moves the cost to the first interaction that triggers it. A screen that loads a chart library on first display pays verification for the whole library inside the frame, and that frame competes with the queue described in The Main Thread Message Queue and Why a Single Long Task Delays Frames. The remedy is to touch the library’s entry class during a background warm-up before the screen opens, which pays the cost where it does not matter.
Hidden API enforcement. The runtime restricts reflective access to non-public platform interfaces, and the check happens at resolution time. Code that resolved fine on an older platform version can fail at first use on a newer one, and the failure appears as a linkage error during startup rather than as a compile-time problem.
Duplicate classes across loaders. A class loaded by two different loaders is two different types, so an object created by one cannot be cast to the other’s type. On mobile this arises with plugin architectures and with shared libraries loaded in separate loaders. The symptom is a cast failure that looks impossible because the class names are identical.
Verification and stack maps. Verification of a method that contains a branch requires type information at join points. Compilers emit this as a stack map table; hand-written or instrumented bytecode that omits it fails verification. Tools that rewrite bytecode for instrumentation must regenerate the table, and a tool that does not will produce classes that load on a lenient runtime and fail on a strict one.
The cost this article describes is one stage of the launch accounting in Cold Start Phases and Where Launch Time Is Spent. Its share grows with the size of the dependency graph and shrinks with the coverage of the device’s compilation profile, which is why the same build shows different startup times on devices that have been used differently.
References
- The Java Virtual Machine Specification, Chapter 5: Loading, Linking, and Initializing — Oracle
- Dex format — Android Open Source Project