Runtime
How the JVM Finds and Loads a Class
A hello-world program loads 423 classes before it prints anything. Where they come from, what the JVM does to each one, and how to see it.

On this page
Before your main method runs, the JVM has already found, read, parsed, verified, linked and initialised several hundred classes. On JDK 26.0.2.1 (macOS, aarch64), a program whose entire body is a println loads 423 of them.
That number is the floor of every startup measurement you will ever take, and almost nobody looks at it. Two logging flags and one jcmd make the whole process visible.
Count them first
java -Xlog:class+load:file=classes.log Hello
grep -c "class,load" classes.log
Each line names a class and, more usefully, where it came from:
[0.005s][info][class,load] java.lang.Object source: shared objects file
[0.006s][info][class,load] java.util.HashMap source: shared objects file
[0.061s][info][class,load] jdk.internal.loader.URLClassPath$FileLoader source: jrt:/java.base
[0.063s][info][class,load] Hello source: file:/tmp/jvm/
Sorting that source: field is the fastest triage available. In the run above, 420 classes came from the shared archive, two from the runtime image, and exactly one — mine — from the class path. A fourth source appears as soon as lambdas or method handles are involved: __JVM_LookupDefineClass__, the hidden classes the JVM spins up for call sites that did not exist in any class file.
For a running process, jcmd groups the same work by loader:
$ jcmd <pid> VM.classloader_stats
ClassLoader Parent CLD* Classes ChunkSz BlockSz Type
0x00001800001d1038 0x0000000000000000 0x0c3d1980a0 0 0 0 ...PlatformClassLoader
0x00001800001d0b18 0x00001800001d1038 0x0c3d198000 2 10240 6736 ...AppClassLoader
0x0000000000000000 0x0000000000000000 0x0102d31130 527 4456448 100072 <boot class loader>
The totals will not match the log’s count, and they are not meant to: VM.classloader_stats reports every klass charged to a loader, including the array classes that -Xlog:class+load never prints. Two numbers matter here. Classes tells you which loader is doing the work — in a framework application the app loader’s count grows into the thousands while the boot loader’s stays roughly constant. ChunkSz is metaspace, and a loader whose chunk size grows without its class count falling is the shape of a class loader leak.
Three loaders and one rule
The JVM ships three built-in loaders: the boot loader, which defines java.base and most of the Java SE platform besides; the platform loader, which defines a small remainder — java.sql, java.compiler, java.scripting and a handful of others; and the application loader, which defines whatever arrives on your class path and module path. The split is narrower than the names suggest, and a one-line probe of ModuleLayer.boot() shows it: java.xml, java.logging, java.desktop and java.management all belong to the boot loader, not the platform one. Every other loader you meet — a web container’s, a plugin system’s, a test framework’s — sits below them.
The rule connecting them is delegation: a loader asks its parent before looking on its own. Demonstrating this takes twenty lines.
ClassLoader app = Delegate.class.getClassLoader();
URLClassLoader child = new URLClassLoader(
new URL[]{ new File("libdir/").toURI().toURL() }, app);
Class<?> viaApp = app.loadClass("Shared");
Class<?> viaChild = child.loadClass("Shared");
System.out.println("defined by : " + viaChild.getClassLoader());
System.out.println("same class : " + (viaApp == viaChild));
defined by : jdk.internal.loader.ClassLoaders$AppClassLoader@41e986c9
same class : true
Shared.class sits in the child’s own directory, and the child still did not define it. It asked its parent first, the parent had a copy, and that copy won. This is why a java.lang.String on your class path is inert, and why the loader that finds a class is rarely the one you asked.
The consequence worth carrying: a class’s identity is the pair (binary name, defining loader). Two loaders that both define com.example.Config produce two distinct types, and casting between them fails with a ClassCastException whose message names the same class twice. That message is not a bug — it is delegation being explicit about what happened.
Loading, linking and initialising are three moments
-Xlog:class+init separates them:
[0.014s][info][class,init] Start class verification for: Hello
[0.014s][info][class,init] End class verification for: Hello
[0.014s][info][class,init] 307 Initializing 'Hello'(no method) (0x00000ffc01040000) by thread "main"
hello
Loading produces a class object from bytes. Linking verifies the bytecode, prepares static fields with default values, and resolves symbolic references — the last of these lazily, as each reference is first used. Initialisation runs the static initialiser, and the JVM defers it until something actually touches the class.
That deferral is observable. My test class contains a nested Lazy type, referenced only on a code path that a command-line argument enables, whose static block prints a line. Run without the argument and Hello$Lazy never appears in the log at all. Run with it and the verification and initialisation lines show up mid-execution, after main has already started.
The practical version: a static initialiser costs nothing until first touch, and then costs everything at once, on whichever thread got there first. A framework that opens a connection in a static block has not moved that work out of the request path; it has moved it into an unpredictable request.
Verification is charged to your classes, not the JDK’s
Verification is the expensive part of linking, and the JVM already exempts most of what it loads:
$ java -XX:+PrintFlagsFinal -version | grep BytecodeVerification
bool BytecodeVerificationLocal = false {diagnostic} {default}
bool BytecodeVerificationRemote = true {diagnostic} {default}
“Local” means classes from the boot class path — the JDK’s own, trusted because they arrived with the runtime. “Remote” means everything else, including every class in your application and its dependencies. So the verifier’s cost scales with your class count, and the several hundred JDK classes in that hello-world figure are not paying it.
The old lever for this is gone in all but name:
$ java -Xverify:none Hello
OpenJDK 64-Bit Server VM warning: Options -Xverify:none and -noverify were
deprecated in JDK 13 and will likely be removed in a future release.
On JDK 26 it still runs, it still warns, and it is not a tuning option. JDK 27 finishes the job: both options become unrecognised and the launcher refuses to start. What replaces it is an archive, because archived classes were verified when the archive was built.
The archive changes the shape of all of this
Modern JDKs ship a default class-data archive and use it unless told otherwise. Turning it off shows what it was doing:
| Run | Classes loaded | Startup, mean of 10 |
|---|---|---|
| Default (archive on) | 423 | 25 ms |
-Xshare:off |
438 | 37 ms |
Same program, same machine, startup time cut by a third. Two things account for it. The archive holds classes in a pre-parsed, pre-verified form that is mapped into memory rather than read and rebuilt, and it holds them for the JDK classes that dominate the count — 420 of my 423.
Fifteen extra classes appear without the archive: the infrastructure the JVM needs to do the reading it would otherwise have skipped.
This is the entire premise of AppCDS, and of the AOT cache that generalises it. Both are worth their own article; the point here is that the baseline is already doing this for you, and that measuring against -Xshare:off is how you find out how much.
What to do with this
Three commands, in order, on your own application:
-Xlog:class+loadand count. If the number is above twenty thousand, the dependency graph is the startup problem, and no flag will fix that.- Sort the
source:field. Classes arriving from JAR files rather than the archive are the ones being parsed and verified on every start. -Xlog:class+initand look at what initialises before your first request. Static initialisers doing I/O are the most common thing hidden inside a framework’s startup time.
None of this requires a profiler, an agent, or a rebuild. The JVM has been keeping the receipts the whole time.
Frequently asked
- Why does my application load classes I never reference?
- Most of them belong to the JDK itself, pulled in by the classes you do reference. A hello-world program loads over four hundred before reaching main, and none of them appear in your source.
- Does disabling bytecode verification speed up startup?
- There is nothing to disable for JDK classes, which are not verified by default, and the flag that skips verification for everything else has been deprecated since JDK 13, warns on every start, and is removed in JDK 27. Use a class-data archive instead.


