DEV Community

Cover image for Java Without the JVM? A Journey into Spring Boot Native Images
Felipe Stanzani
Felipe Stanzani

Posted on

Java Without the JVM? A Journey into Spring Boot Native Images

The Cloud Bill That Didn’t Make Sense

In the mid-2000s, Java was considered a heavyweight. A ravenous monster devouring all available hardware resources.

Between Java 6 and Java 8, several improvements were added to Java, such as improvements to the Garbage Collector, improvements to the JIT, and improvements in memory usage.

In Java 11, many of the historical performance concerns were significantly reduced, making it competitive with native languages in many scenarios.

At this time deploying a Java application felt like a victory. You packaged your JAR, spun up a server, and everything worked. Memory was cheap, servers were predictable, and startup time was just a number nobody really cared about.

Then the cloud happened.

Suddenly, every megabyte had a price. Every second of startup time mattered. And running ten instances of your application didn’t feel like scaling—it felt like burning money.

I remember looking at a simple Spring Boot service deployed in a container. It did almost nothing. A couple of endpoints, a database connection, some basic logic.

And yet:

  • It consumed hundreds of megabytes of RAM
  • It took several seconds to start
  • It scaled slowly under load because of startup latency

It didn’t feel right.

We weren’t solving business problems anymore—we were feeding infrastructure.

That’s when I started asking a dangerous question:

What if Java didn’t need a JVM at runtime?

The Hidden Cost of the JVM

The Java Virtual Machine is one of the greatest engineering achievements in software history. It gives us portability, safety, and powerful runtime optimizations.

But in cloud environments, those strengths come with trade-offs:

  • The JVM needs time to warm up (JIT compilation)
  • It loads classes dynamically
  • It keeps metadata and runtime structures in memory
  • It assumes long-running processes

In a world of microservices, containers, and serverless functions, these assumptions don’t always hold.

Sometimes, you don’t want a long-running process.

Sometimes, you want something that starts instantly, uses minimal memory, and does one thing well.

That’s where things start to change.

A Different Approach: Ahead-of-Time Execution

Instead of compiling code during execution, what if we compiled everything before the application even runs?

This is the idea behind Native Images.

Instead of shipping:

Your Code + JVM

You ship:

A Single Native Binary

No traditional JVM at runtime—and far fewer surprises caused by dynamic behavior.

Everything your application needs is analyzed, resolved, and compiled ahead of time.

The result?

  • Near-instant startup
  • Much lower memory usage
  • Smaller container images
  • Predictable behavior

But there’s a catch.

Actually, several.

The Price of Predictability

When you move everything to build time, you lose something important: flexibility.

Java was designed to be dynamic:

  • Reflection
  • Runtime configuration
  • Classpath scanning
  • Lazy loading

Native images are much stricter about all of that.

They assume a closed world:

If something isn’t known at build time, it won’t exist—unless you explicitly tell the compiler about it.

That means:

  • Reflection must be explicitly declared
  • Some Spring features behave differently
  • Behavior that depends on runtime discovery becomes limited

At first, this feels restrictive.

But if you think about it, most production systems already behave this way.

We don’t dynamically add classes in production.

We don’t randomly change bean definitions at runtime.

We just pretend we do.

Native images force us to be honest about it.

Build time becomes significantly longer, and native compilation can require substantial memory. You’re shifting cost from runtime to build time.

While startup time improves dramatically, peak performance may still favor the JVM due to JIT optimizations.

Debugging and observability can also be more limited compared to a traditional JVM setup.

Spring Boot Meets Native

Spring Boot has always been dynamic by design. Auto-configuration, conditional beans, reflection—it’s all part of the magic.

So how does it work with native images?

Spring Boot 3 introduced something called Ahead-of-Time (AOT) processing.

Instead of figuring things out at runtime, Spring does the work during the build:

  • It analyzes your application
  • Generates code for bean creation
  • Registers reflection hints
  • Prepares everything for static compilation

What used to happen at startup… now happens at build time.

Your application becomes simpler to execute because the complexity was already resolved.

A Minimal Native Spring Boot Application (Gradle)

Let’s stop talking and build something.

We’re going to create a simple Spring Boot 4 application and compile it into a native executable using GraalVM.

1. Project Setup (Gradle)

plugins {
    id 'java'
    id 'org.springframework.boot' version '4.0.2'
    id 'io.spring.dependency-management' version '1.1.0'
    id 'org.graalvm.buildtools.native' version '1.0.0'
}

group = 'com.felipestanzani'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(25)
        vendor = JvmVendorSpec.GRAAL_VM
    }
}

graalvmNative {
    toolchainDetection = true
    binaries {
        main {
            buildArgs.add("-Os")
        }
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') {
    useJUnitPlatform()
}
Enter fullscreen mode Exit fullscreen mode

2. The Application

A simple REST endpoint:

package com.felipestanzani.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class JavaNativeApplication {

    public static void main(String[] args) {
        SpringApplication.run(JavaNativeApplication.class, args);
    }
}
Enter fullscreen mode Exit fullscreen mode
package com.felipestanzani.demo;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
class HelloController {

    @GetMapping("/hello")
    public String hello() {
        return "Hello from Native Image!";
    }
}
Enter fullscreen mode Exit fullscreen mode

That’s it.

No special annotations. No hacks.

Spring Boot handles the AOT processing for us.

3. Build the Native Image

Make sure you have GraalVM installed and configured.

Then run:

./gradlew nativeCompile
Enter fullscreen mode Exit fullscreen mode

This step takes longer than a normal build. That’s expected.

You’re not just compiling Java—you’re building a full executable.

4. Run the Application

./build/native/nativeCompile/demo
Enter fullscreen mode Exit fullscreen mode

demo can be replaced by your project name

Now test it:

curl http://localhost:8080/hello
Enter fullscreen mode Exit fullscreen mode

Response:

Hello from Native Image!
Enter fullscreen mode Exit fullscreen mode

What Just Happened?

You didn’t start a JVM.

You didn’t run a JAR.

You executed a native binary.

And if you measure:

  • Startup time: typically in milliseconds
  • Memory usage: dramatically lower than JVM
  • CPU spikes: minimal

It feels… different.

When Should You Use This?

Native images are not a silver bullet.

They shine in specific scenarios:

  • Microservices with high scaling needs
  • Serverless functions (cold start matters)
  • Containerized environments
  • Applications where memory is a constraint

They are less ideal when:

  • You rely heavily on dynamic behavior
  • You need advanced JVM tooling
  • You want maximum runtime optimization (JIT still wins here)

Final Thoughts

For years, Java has been synonymous with the JVM.

But that relationship is no longer mandatory.

With native images, Java becomes something else:

  • Less dynamic
  • More predictable
  • Closer to the metal

It’s not better or worse.

It’s just… different.

And in a world where infrastructure cost, startup time, and scalability matter more than ever, that difference can be exactly what you need.

If you’ve been feeling that your applications are heavier than they should be, maybe it’s time to ask the same question I did:

Do you really need the JVM at runtime?

See you in the next post.

Also posted on my blog, Memory Leak

Top comments (0)