Software Engineer · Java · Spring Boot · Microservices
JVM Startup Process Internals: Bootstrap, Class Loading Lifecycle & Native Interface (JNI/JNA)
Every Java application hides a remarkable amount of machinery behind the deceptively simple java MyApp command. Before main() is ever invoked, the OS has loaded native libraries, the JVM has initialized its garbage collector, the bootstrap ClassLoader has pulled in hundreds of JDK core classes, and any attached agents have transformed bytecode on the fly. Understanding this process is essential for diagnosing cold-start latency, debugging class-loading errors, writing safe JNI code, and building instrumentation agents. This deep dive covers the full JVM startup sequence: from libjvm.so initialization through class loading lifecycle phases, JNI native bridging, and JVM TI agent instrumentation.
Table of Contents
- Why JVM Startup Internals Matter for Performance and Observability
- JVM Initialization: libjvm.so, JNI_CreateJavaVM(), and VM Thread Creation
- Bootstrap ClassLoader: Loading the JDK Core Classes
- Class Loading Lifecycle: Loading, Linking, and Initialization
- Class Initialization Pitfalls: Circular Dependencies and Deadlocks
- Java Native Interface (JNI): Bridging Java and Native Code
- JVM TI: Instrumentation and Bytecode Manipulation Agents
- Project CRaC and GraalVM Native Image: Changing JVM Startup
- Key Takeaways
- Conclusion
1. Why JVM Startup Internals Matter for Performance and Observability
In the serverless and cloud-native era, the JVM's historically slow startup has become a first-class operational problem. An AWS Lambda function with a Spring Boot application can take 3–8 seconds on a cold start — a cold start triggered every time a new execution environment is provisioned. During a traffic spike, dozens of new Lambda instances spin up simultaneously, each suffering that full cold-start penalty. Users experience elevated latency spikes, and tail-latency p99 metrics blow out, even if steady-state throughput is excellent.
The cold start breakdown is instructive: roughly 200–400ms is JVM initialization itself (GC setup, class subsystem init), another 500ms–2s is loading and initializing the Spring application context (scanning components, initializing beans), and the remainder is JIT warm-up until the JVM reaches peak throughput. Kubernetes pod startup has the same problem: the liveness probe fails until the application fully initializes, and readiness probes gate traffic until startup is complete, delaying rolling deployment completion.
GraalVM native image compiles Java ahead-of-time into a native binary with no JVM interpreter overhead. A native Spring Boot application starts in 10–50ms with a tiny memory footprint. The trade-off is that native image requires careful ahead-of-time configuration for reflection, dynamic proxies, and serialization — Spring Boot 3.x's @RegisterReflectionForBinding and GraalVM native hints exist precisely to address this. CRaC (Coordinated Restore at Checkpoint), now supported in OpenJDK builds from Azul and BellSoft, takes a different approach: warm up the JVM normally, take a process checkpoint, and restore from that snapshot in milliseconds on subsequent starts.
APM agents add their own startup cost. The New Relic Java agent, Dynatrace OneAgent, and the OpenTelemetry Java Agent all use JVM TI to attach at startup, instrument hundreds of library classes via bytecode transformation, and initialize their own telemetry pipelines. A fully configured OpenTelemetry agent can add 300–800ms to startup time and increase initial heap pressure by 50–150MB. Understanding what happens during that startup window lets you tune agent configuration, disable unused instrumentation modules, and make informed trade-offs between observability coverage and startup cost.
- Use
spring.main.lazy-initialization=trueto defer bean initialization until first use, cutting startup time 20–40%. - Enable
spring-context-indexer(spring-context-indexerMaven/Gradle plugin) to pre-compute component scan results at build time. - Use
@ImportRuntimeHintsand GraalVM native hints instead of runtime reflection for native image builds. - Profile startup with
-Dspring.startup.actuator.enabled=trueand the/actuator/startupendpoint to find slow bean initializations. - Virtual threads (Java 21+) don't improve startup but dramatically improve throughput under I/O load during warmup.
2. JVM Initialization: libjvm.so, JNI_CreateJavaVM(), and VM Thread Creation
When you execute java MyApp, the OS doesn't directly run Java bytecode — it runs a small native launcher executable (the java binary in your JDK's bin/ directory). This launcher is a thin C program that dynamically loads libjvm.so (Linux/macOS) or jvm.dll (Windows) — the actual JVM implementation — and then calls its primary entry point: JNI_CreateJavaVM().
JNI_CreateJavaVM() is a public JNI Invocation API function. It is also used by any application that embeds a JVM — Apache Tomcat's jsvc daemon, Android Runtime (historically), and tools like JetBrains IntelliJ IDEA's process launcher all use this entry point. The function accepts a JavaVMInitArgs struct containing parsed JVM arguments, allocates the VM data structures, starts the main VM thread, and returns pointers to the JavaVM and JNIEnv interfaces.
The initialization sequence inside JNI_CreateJavaVM() proceeds through these stages in order:
# JVM startup sequence (simplified from HotSpot source)
# Stage 1: Parse and validate JVM arguments
java -Xmx512m -Xss512k -XX:+UseG1GC \
--add-opens java.base/java.lang=ALL-UNNAMED \
-javaagent:opentelemetry-javaagent.jar \
-cp app.jar com.example.Main
# Stage 2: GC subsystem initialization
# - Select GC: G1GC, ZGC, Shenandoah, SerialGC (based on flags or ergonomics)
# - Allocate heap: young gen + old gen regions
# - Initialize GC threads (concurrent marking threads, etc.)
# Stage 3: Class subsystem initialization
# - Allocate Method Area / Metaspace
# - Initialize constant pool structures
# - Prepare class hierarchy data structures
# Stage 4: Bootstrap ClassLoader activation
# - Load java.base module (Java 9+): Object, String, Class, Throwable, etc.
# - Initialize primitive type representations
# Stage 5: System initialization (runs Java code for the first time)
# - java.lang.System.initializeSystemClass()
# - Initialize java.lang.Thread for the main thread
# - Set up standard I/O streams (System.out, System.err, System.in)
# Stage 6: Agent premain() invocation (if -javaagent specified)
# - Load agent JAR, call premain(String agentArgs, Instrumentation inst)
# Stage 7: Main class loading and main() invocation
# - Load com.example.Main via Application ClassLoader
# - Invoke main(String[] args)Understanding this sequence reveals why certain errors occur at specific points. A NoClassDefFoundError on a core JDK class during Stage 4 usually indicates a corrupted JDK installation. A ClassNotFoundException during Stage 7 means the application classpath is misconfigured. An agent crashing in Stage 6 will abort startup before main() is ever called — which is why agent compatibility testing against JDK upgrades is critical.
Startup flags are parsed very early — before GC initialization — and invalid flags cause an immediate VM abort. The --add-opens and --add-exports module system flags configure the module boundary enforcement that takes effect when the module system is initialized in Stage 3.
3. Bootstrap ClassLoader: Loading the JDK Core Classes
The bootstrap ClassLoader is unique: it is implemented entirely in native C++ code inside HotSpot, not as a Java class. This is a necessary bootstrapping constraint — you can't load java.lang.ClassLoader itself with a ClassLoader that hasn't been loaded yet. The bootstrap loader is responsible for loading all classes in the java.base module in Java 9+ (or rt.jar in Java 8 and earlier): java.lang.Object, java.lang.String, java.lang.Class, java.lang.Throwable, and hundreds of other foundational classes.
In the Java API, the bootstrap ClassLoader is represented by null. Calling String.class.getClassLoader() returns null — not a NullPointerException, but the actual sentinel value indicating the bootstrap loader. The three-tier ClassLoader hierarchy in modern Java is:
// Demonstrating the ClassLoader hierarchy
public class ClassLoaderHierarchy {
public static void main(String[] args) {
// Bootstrap ClassLoader — returns null (native, no Java representation)
ClassLoader bootstrapCL = String.class.getClassLoader();
System.out.println("String ClassLoader: " + bootstrapCL); // null
// Platform ClassLoader (Java 9+) — loads java.se and extension modules
ClassLoader platformCL = ClassLoader.getPlatformClassLoader();
System.out.println("Platform CL: " + platformCL);
// jdk.internal.loader.ClassLoaders$PlatformClassLoader@...
// Application (System) ClassLoader — loads from -cp / --class-path
ClassLoader appCL = ClassLoader.getSystemClassLoader();
System.out.println("App CL: " + appCL);
// jdk.internal.loader.ClassLoaders$AppClassLoader@...
// The parent delegation chain:
System.out.println("App CL parent: " + appCL.getParent()); // Platform CL
System.out.println("Platform CL parent: " + platformCL.getParent()); // null (Bootstrap)
// Your application classes use the Application ClassLoader
ClassLoader myCL = ClassLoaderHierarchy.class.getClassLoader();
System.out.println("My class CL: " + myCL); // AppClassLoader
}
}The parent delegation model is the critical safety mechanism: before any ClassLoader attempts to load a class, it first delegates to its parent. The Application ClassLoader asks the Platform ClassLoader, which asks the Bootstrap ClassLoader. Only if the bootstrap loader cannot find the class does control return down the chain. This ensures that java.lang.String always resolves to the bootstrap-loaded version — no user code can inject a rogue String class into the bootstrap namespace.
java.lang.String or java.security.SecureRandom on the classpath does not override the bootstrap-loaded version — the delegation model prevents it. However, tools that abuse -Xbootclasspath/a: (appending to bootstrap classpath) or use Instrumentation's redefineClasses() on bootstrap classes can introduce subtle security vulnerabilities, bypass security managers, or cause JVM instability. The JPMS module system in Java 9+ makes this harder by enforcing strong encapsulation by default.
4. Class Loading Lifecycle: Loading, Linking, and Initialization
The JVM specification defines a precise three-phase lifecycle that every class goes through before its code can execute. These phases are sequential within a class but happen concurrently across classes, and the JVM aggressively defers work to minimize startup overhead.
Phase 1 — Loading: The ClassLoader finds the binary representation of the class (a .class file, a JAR entry, or dynamically generated bytecode) and creates a java.lang.Class object in the Method Area (Metaspace). The class's constant pool, field descriptors, and method descriptors are parsed and stored. Custom ClassLoaders override findClass() to provide the raw bytes from any source — a database, a network endpoint, or an encrypted archive.
Phase 2 — Linking has three sub-phases:
- Verification: The bytecode verifier performs extensive static analysis — type safety checks, stack discipline validation, detection of illegal jumps and illegal accesses. A class that fails verification throws a
VerifyError. This is the JVM's primary defense against malformed or malicious bytecode. - Preparation: The JVM allocates memory for static fields and sets them to their type-default values (
int→ 0,boolean→ false, object references → null). Crucially, no user-written code runs during preparation — your static field initializers have not executed yet. - Resolution: Symbolic references in the constant pool (like the string
"java/util/ArrayList") are resolved to direct references (actual memory pointers to the loaded class). Resolution is typically lazy — it happens on first use rather than at link time.
Phase 3 — Initialization: The JVM invokes the class's <clinit> method (the class initializer), which the compiler synthesizes from all static field initializers and static initializer blocks in source order. This is the first moment user-written code actually runs for a class.
// Demonstrating class initialization order
public class InitializationOrder {
// Static field with initializer — runs in source order inside <clinit>
static int A = computeA();
static {
System.out.println("Static block 1: A=" + A);
// A is already 10 (computeA() already ran above this block)
}
static int B = computeB();
static {
System.out.println("Static block 2: A=" + A + ", B=" + B);
}
static int computeA() {
System.out.println("Computing A");
return 10;
}
static int computeB() {
System.out.println("Computing B");
return A * 2; // A is already 10 here
}
public static void main(String[] args) {
System.out.println("main: A=" + A + ", B=" + B);
}
}
// Output:
// Computing A
// Static block 1: A=10
// Computing B
// Static block 2: A=10, B=20
// main: A=10, B=20new Foo()), first access to a static field or method, or explicit Class.forName(). Simply having a class on the classpath does not cause it to be loaded. This is why unused Spring beans with slow @PostConstruct methods can be deferred with spring.main.lazy-initialization=true — the underlying class initialization is deferred too.
5. Class Initialization Pitfalls: Circular Dependencies and Deadlocks
The JVM specification guarantees that class initialization is thread-safe: only one thread initializes a given class, and other threads that request the class block until initialization completes. The JVM implements this with a per-class initialization lock. While this guarantee prevents data races during initialization, it opens the door to class initialization deadlocks when two classes have circular static dependencies.
// Circular class initialization deadlock scenario
// Thread 1 triggers initialization of ClassA
// Thread 2 simultaneously triggers initialization of ClassB
public class ClassA {
// ClassA's <clinit> acquires ClassA's init lock, then tries to initialize ClassB
public static final ClassB INSTANCE_B = new ClassB();
static {
System.out.println("ClassA <clinit> — holds ClassA lock, needs ClassB lock");
}
}
public class ClassB {
// ClassB's <clinit> acquires ClassB's init lock, then tries to initialize ClassA
public static final ClassA INSTANCE_A = new ClassA();
static {
System.out.println("ClassB <clinit> — holds ClassB lock, needs ClassA lock");
}
}
// DEADLOCK:
// Thread 1: acquired ClassA init lock → waiting for ClassB init lock
// Thread 2: acquired ClassB init lock → waiting for ClassA init lock
// JVM detects this as a circular initialization; behavior is undefined per specThe JVM specification (JVMS §5.5) acknowledges circular initialization but does not guarantee deadlock detection or clean recovery. HotSpot's behavior in practice is that the second thread to arrive at an already-in-progress initialization sees the partially-initialized class (fields still at their default values from the Preparation phase), which can lead to NullPointerExceptions or incorrect behavior that is extremely difficult to reproduce and diagnose.
The pattern is more common than it seems. Any two singleton classes in a static factory pattern that reference each other, framework-level registries that eagerly populate themselves by referencing other registries, and constant classes that depend on each other can all trigger this scenario. The root cause is always the same: side effects in <clinit> that trigger the initialization of another class that hasn't finished initializing yet.
- Never call external services, open network connections, or acquire external locks inside
static {}blocks or static field initializers — a startup failure here is unrecoverable. - Avoid circular class dependencies between classes with complex static initializers; use lazy initialization patterns (
Holderidiom orSupplier) instead. - Keep
<clinit>fast — static initializers run under the class initialization lock and block other threads trying to use the same class. - Prefer
enumfor singleton patterns; the JVM guarantees enum constant initialization is thread-safe and handles circular dependencies more gracefully.
6. Java Native Interface (JNI): Bridging Java and Native Code
JNI is the standard mechanism for Java code to call native (C/C++) functions, and for native code to call back into the JVM. The JVM exposes a function table through the JNIEnv pointer — a struct of function pointers covering every possible Java-native interaction: FindClass(), GetMethodID(), CallObjectMethod(), NewStringUTF(), array manipulation, and more. Every JNI call goes through this table, giving the JVM control over type safety and garbage collector coordination.
Here is a complete end-to-end JNI example. First, the Java class declaring the native method:
// Java side: NativeGreeter.java
public class NativeGreeter {
// Declare native method — implementation is in C
public native String greet(String name);
// Load the shared library at class initialization time
static {
System.loadLibrary("greeter"); // loads libgreeter.so (Linux) / greeter.dll (Windows)
}
public static void main(String[] args) {
NativeGreeter ng = new NativeGreeter();
System.out.println(ng.greet("World")); // Hello from C, World!
}
}
// Compile and generate JNI header:
// javac NativeGreeter.java
// javac -h . NativeGreeter.java <-- generates NativeGreeter.h
// C implementation: NativeGreeter.c
#include <jni.h>
#include <stdio.h>
#include "NativeGreeter.h" // auto-generated header
// JNI naming convention: Java_ClassName_methodName
JNIEXPORT jstring JNICALL Java_NativeGreeter_greet(JNIEnv *env, jobject obj, jstring name) {
// Convert jstring to C string
const char *nameStr = (*env)->GetStringUTFChars(env, name, NULL);
char result[256];
snprintf(result, sizeof(result), "Hello from C, %s!", nameStr);
// Release the C string — critical to avoid memory leak
(*env)->ReleaseStringUTFChars(env, name, nameStr);
// Return new Java String
return (*env)->NewStringUTF(env, result);
}
// Compile shared library:
// gcc -shared -fPIC -o libgreeter.so NativeGreeter.c \
// -I${JAVA_HOME}/include -I${JAVA_HOME}/include/linux
// java -Djava.library.path=. NativeGreeterJNA (Java Native Access) provides a safer, higher-level alternative to raw JNI: no C code required, no manual header generation, and no risk of crashing the JVM with a bad pointer dereference in your glue code. JNA uses a dynamic proxy and runtime reflection to map Java interface method calls to native functions:
// JNA example — calling native libc functions without writing C code
// Maven: <dependency><groupId>net.java.dev.jna</groupId>
// <artifactId>jna</artifactId><version>5.14.0</version></dependency>
import com.sun.jna.Library;
import com.sun.jna.Native;
import com.sun.jna.Platform;
public class JNAExample {
// Define interface matching the native library's exported functions
public interface CLibrary extends Library {
// Load libc (Linux/macOS) or msvcrt (Windows)
CLibrary INSTANCE = Native.load(
Platform.isWindows() ? "msvcrt" : "c",
CLibrary.class
);
// Map to C's getpid() — no C code needed
int getpid();
// Map to C's getenv()
String getenv(String name);
}
public static void main(String[] args) {
int pid = CLibrary.INSTANCE.getpid();
System.out.println("Native PID: " + pid);
String home = CLibrary.INSTANCE.getenv("HOME");
System.out.println("HOME env var: " + home);
}
}hs_err_pid<N>.log). Always pin Java objects before passing their address to native code, release all JNI references to prevent GC-invisible object retention, and use -Xcheck:jni during development to catch JNI misuse early.
7. JVM TI: Instrumentation and Bytecode Manipulation Agents
The JVM Tool Interface (JVM TI) is a native programming interface for development tools — debuggers, profilers, monitoring agents, and coverage tools. Java agents exposed through java.lang.instrument.Instrumentation are the Java-level abstraction built on top of JVM TI, and they are the mechanism behind every APM tool in the Java ecosystem.
A Java agent JAR must declare a Premain-Class manifest attribute pointing to a class with a premain() method. At JVM startup (Stage 6 in our earlier sequence), the JVM calls this method before main(), passing an Instrumentation instance that grants the agent power to transform every class as it is loaded:
// Complete minimal Java agent with method entry/exit logging
// Manifest-Version: 1.0
// Premain-Class: com.example.agent.LoggingAgent
// Can-Retransform-Classes: true
// Can-Redefine-Classes: true
package com.example.agent;
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.Instrumentation;
import java.security.ProtectionDomain;
public class LoggingAgent {
// Called by JVM before main() when using -javaagent:logging-agent.jar
public static void premain(String agentArgs, Instrumentation inst) {
System.out.println("[Agent] Attaching logging transformer...");
// Register a ClassFileTransformer — called for every class loaded
inst.addTransformer(new MethodLoggingTransformer(), true);
// Retransform already-loaded classes (e.g., bootstrap classes)
// inst.retransformClasses(String.class, ArrayList.class);
}
// Called when dynamically attaching to a running JVM (e.g., via jattach)
public static void agentmain(String agentArgs, Instrumentation inst) {
premain(agentArgs, inst);
}
}
// ClassFileTransformer receives raw bytecode for every loaded class
class MethodLoggingTransformer implements ClassFileTransformer {
@Override
public byte[] transform(ClassLoader loader, String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer) {
// Return null = no transformation (class loads as-is)
// In a real agent, use ASM or Byte Buddy to modify classfileBuffer here
// Only instrument classes in com.example.myapp package
if (className != null && className.startsWith("com/example/myapp/")) {
System.out.println("[Agent] Transforming: " + className);
// Use ASM ClassReader/ClassWriter or Byte Buddy to inject logging
return instrumentWithASM(classfileBuffer);
}
return null; // no transformation for other classes
}
private byte[] instrumentWithASM(byte[] originalBytecode) {
// ASM bytecode manipulation would go here
// Inject INVOKESTATIC to a logging method at method entry/exit
return originalBytecode; // placeholder — return original unchanged
}
}In practice, raw ASM manipulation is error-prone. Production agents use higher-level libraries: ASM (the low-level bytecode manipulation library used internally by the JDK itself), Byte Buddy (a fluent DSL for type-safe bytecode generation, used by OpenTelemetry and Mockito), and Javassist (provides a source-code-like API for bytecode editing). The OpenTelemetry Java Agent uses Byte Buddy exclusively, defining instrumentation as ElementMatcher rules that match method signatures and inject advice classes:
// Byte Buddy instrumentation pattern (similar to OpenTelemetry agent internals)
import net.bytebuddy.agent.builder.AgentBuilder;
import net.bytebuddy.asm.Advice;
import net.bytebuddy.matcher.ElementMatchers;
public class ByteBuddyAgent {
public static void premain(String args, Instrumentation inst) {
new AgentBuilder.Default()
// Match any class named HttpClient
.type(ElementMatchers.named("java.net.http.HttpClient"))
.transform((builder, typeDescription, classLoader, module, domain) ->
builder.method(ElementMatchers.named("send"))
.intercept(Advice.to(HttpClientAdvice.class))
)
.installOn(inst);
}
// Advice class: @OnMethodEnter runs BEFORE the method, @OnMethodExit AFTER
static class HttpClientAdvice {
@Advice.OnMethodEnter
static long onEnter(@Advice.Origin String method) {
System.out.println("[Trace] Entering: " + method);
return System.nanoTime(); // passed to @OnMethodExit as @Advice.Enter
}
@Advice.OnMethodExit(onThrowable = Throwable.class)
static void onExit(@Advice.Enter long startNs,
@Advice.Origin String method,
@Advice.Thrown Throwable thrown) {
long durationMs = (System.nanoTime() - startNs) / 1_000_000;
System.out.println("[Trace] Exiting: " + method + " (" + durationMs + "ms)" +
(thrown != null ? " THREW: " + thrown : ""));
}
}
}AgentBuilder rule. At startup, the agent loads only the instrumentation modules relevant to libraries present on the classpath (via SPI discovery), minimizing unnecessary overhead. You can disable individual instrumentations with -Dotel.instrumentation.<name>.enabled=false.
8. Project CRaC and GraalVM Native Image: Changing JVM Startup
Two complementary technologies are reshaping Java's startup story for cloud workloads: CRaC (Coordinated Restore at Checkpoint) and GraalVM Native Image. They take fundamentally different approaches but solve the same problem.
CRaC works by allowing a running JVM to take a full process checkpoint — a snapshot of heap, thread state, open file descriptors, and JIT-compiled code — using Linux's CRIU (Checkpoint/Restore In Userspace). On subsequent starts, the JVM restores from the checkpoint image in milliseconds rather than going through the full startup sequence. The application is already warm: JIT compilations are intact, Spring application context is fully initialized, connection pools are open. The trade-off is that resources like open sockets, database connections, and file handles must be cleanly closed before the checkpoint and re-established after restore:
// CRaC Resource interface — implement to handle checkpoint/restore lifecycle
import org.crac.Context;
import org.crac.Core;
import org.crac.Resource;
@Component
public class DatabaseConnectionPool implements Resource {
private HikariDataSource dataSource;
@PostConstruct
public void init() {
dataSource = buildDataSource();
// Register this bean for CRaC notifications
Core.getGlobalContext().register(this);
}
@Override
public void beforeCheckpoint(Context<? extends Resource> context) throws Exception {
// Called before JVM checkpoint is taken
// Close all connections — they won't be valid after restore on a different host
System.out.println("[CRaC] Closing connection pool before checkpoint");
dataSource.close();
}
@Override
public void afterRestore(Context<? extends Resource> context) throws Exception {
// Called after JVM is restored from checkpoint
// Re-initialize connections to the (potentially different) database endpoint
System.out.println("[CRaC] Re-initializing connection pool after restore");
dataSource = buildDataSource();
}
// Take checkpoint: java -XX:CRaCCheckpointTo=/checkpoint-dir -jar app.jar
// Restore: java -XX:CRaCRestoreFrom=/checkpoint-dir
// Typical restore time: 50–200ms vs 3–8s cold start
}GraalVM Native Image uses ahead-of-time (AOT) compilation: the entire application, including the JDK classes, Spring framework, and your code, is compiled to a standalone native binary using the Substrate VM. The binary has no interpreter, no JIT compiler, and no JVM startup overhead. Spring Boot 3.x has first-class support for native image via the spring-boot-buildpacks Gradle/Maven plugin and GraalVM build tools:
# GraalVM native image build (Spring Boot 3.x + GraalVM 22+)
# Maven:
./mvnw -Pnative native:compile
# Gradle:
./gradlew nativeCompile
# The build analyzes all reachable code statically and produces a native binary
# Build time: 2–10 minutes (uses significant CPU/RAM)
# Output: target/my-spring-app (Linux), ~50–100MB standalone binary
# Run it — no JVM required:
./target/my-spring-app
# Started MySpringApp in 0.052 seconds (process running for 0.076) ← ~50ms startup!
# For classes used via reflection (Spring @Configuration, JPA entities),
# register them in a native hints config:
# src/main/resources/META-INF/native-image/reflect-config.json
# [
# {
# "name": "com.example.MyEntity",
# "allDeclaredConstructors": true,
# "allDeclaredMethods": true,
# "allDeclaredFields": true
# }
# ]
# Or use Spring's annotation-based hints:
@RegisterReflectionForBinding(MyEntity.class)
@Configuration
public class NativeHintsConfig {
// Spring Boot 3.x processes these annotations at build time
// and generates the reflect-config.json automatically
}The trade-offs between native image and CRaC are substantial. Native image loses the JIT compiler entirely, which means peak throughput is lower for CPU-intensive long-running workloads (JIT-compiled code is typically 20–50% faster than AOT for complex business logic). CRaC preserves all JIT optimizations because the checkpoint is taken after warm-up, but it requires CRaC-compatible JDK builds (currently Azul Zulu CRaC, BellSoft Liberica CRaC) and careful management of non-serializable resources. For serverless and short-lived container workloads, native image is generally the better fit. For long-running containers that restart infrequently, CRaC's preserved JIT warmup makes it attractive.
Key Takeaways
- JVM startup is a multi-stage native process — libjvm.so is loaded by the OS,
JNI_CreateJavaVM()initializes the GC, class subsystem, and bootstrap loader before any Java code runs. - Bootstrap ClassLoader is native and returns null in Java — it loads the
java.basemodule; Platform ClassLoader and Application ClassLoader form the parent delegation chain above it. - Class loading has three distinct phases — Loading (find bytes, create Class object), Linking (verify bytecode → prepare static memory → resolve symbols), and Initialization (run
<clinit>). - Circular static dependencies cause initialization deadlocks — never perform blocking I/O, external service calls, or cross-class initialization in static initializers.
- JNI bypasses JVM safety — native code errors crash the entire JVM; prefer JNA for simple native interop and use
-Xcheck:jniduring development. - Java agents use JVM TI to instrument bytecode —
premain()is called beforemain(), giving agents an opportunity to transform every class as it loads; Byte Buddy makes this safe and readable. - CRaC and GraalVM native image both solve cold start — native image excels for serverless/short-lived containers (50ms startup), CRaC preserves JIT warmup for restored containers (100ms restore).
- APM agents have real startup cost — profile agent startup impact with
-verbose:classand JFR recordings; disable unused instrumentation modules to reduce overhead.
Conclusion
The JVM startup process is a carefully orchestrated sequence of native initialization, class loading, and bytecode transformation that most Java engineers take entirely for granted — until cold start latency becomes a production SLA violation, a circular static dependency silently corrupts singleton state, or a JNI crash takes down an entire service instance. Understanding the internals transforms these opaque problems into diagnosable, fixable issues.
The class loading lifecycle — loading, linking (verification, preparation, resolution), and initialization — is the foundational contract that every Java class satisfies before its code runs. Respecting this contract means keeping <clinit> fast and side-effect-free, avoiding circular class dependencies, and understanding that class loading is lazy by design. For native interop, JNA removes the most dangerous aspects of raw JNI while preserving the ability to call system-level functions without recompiling the JVM. For instrumentation, Byte Buddy transforms the previously arcane JVM TI API into a composable, type-safe tool that production-grade APM agents depend on.
As the Java ecosystem evolves, CRaC and GraalVM native image are making startup performance a first-class language concern rather than an afterthought. Spring Boot 3.x's native image support and CRaC integration mean these technologies are available today without framework-level compromises. The engineers who invest in understanding the startup machinery — from JNI_CreateJavaVM() through premain() to main() — are the ones best equipped to make the right architectural trade-offs in cloud-native, serverless, and latency-sensitive Java deployments.
Leave a Comment
Related Posts
Software Engineer · Java · Spring Boot · Microservices