DEV Community

jamilxt
jamilxt

Posted on

Java 25 for Spring Boot Services: What to Turn On, What to Skip

Java 25 is the new LTS release, and that means every Spring Boot team with a "we only upgrade on LTS" policy is now having the same conversation. Most of those conversations end with a shrug and "we'll look at it next quarter," because the release notes read like a lottery: 18 JEPs, most of them previews, incubators, or crypto APIs you will never touch.

But buried in that list are four or five changes that matter enormously for exactly the kind of workload a typical Spring Boot service is: millions of small, short-lived objects, painful cold starts, and a constant need to answer "which method is slow?" without attaching a profiler in production. Java 25 is quietly the most operations-friendly LTS Java has shipped in years.

This piece is the evaluation I would run before migrating a service, and the honest notes on what each feature actually buys you. Full disclosure: what follows is assembled from the JEPs, the OpenJDK documentation, and the Oracle and Inside Java write-ups linked throughout, plus my experience keeping Spring Boot services alive for six years. It is not a report from a completed production migration, so treat the "verify this yourself" steps as the point of the article, not filler.

Why Spring Boot services are the best case for Java 25

The workloads Java 25 targets are your workloads. A typical web service allocates aggressively: every request builds a chain of DTOs, entities, validators, and framework objects, most of which die young. Nearly every headline improvement in this release, from compact object headers to the new JFR method timing, is aimed at exactly that profile. If you run batch jobs or a mostly idle CRUD app with ten users, the gains will be smaller. If you run a real service under real load, they compound.

The LTS cadence changed the stakes. Java 21 to Java 25 spans four releases of accumulated improvements that LTS-only shops skipped entirely. That means the upgrade is bigger than a usual six-month hop, in both benefit and risk. The right way to approach it is feature by feature, which is what the rest of this article does.

1. Compact Object Headers: one flag, 10 to 20 percent off your heap

This is the headline feature, and it costs you one JVM flag. JEP 519 finalizes compact object headers, which shrink the per-object header from 96 bits to 64 bits. Since every single object on your heap pays that cost, the savings scale with object count, not object size. Spring Boot services are object farms: a single JPA query can materialize thousands of entity instances plus their proxies, field accessor objects, and stream machinery.

The official numbers: Inside Java reports heap size reductions of 10 to 20 percent along with reduced GC pressure and latency, and the JEP itself documents the tradeoffs in detail. For a service running in a container with a memory limit, that is the difference between a 2 GB pod and a 1.7 GB pod, or between aggressive GC tuning and headroom.

The flag, which in JDK 24 required unlocking experimental options, is now a plain product option:

java -XX:+UseCompactObjectHeaders -jar app.jar
Enter fullscreen mode Exit fullscreen mode

How I would verify it: run your service's load test twice, once with and once without the flag, capturing heap usage and GC pause percentiles from JFR. The gain depends on your object size distribution; services with many small objects see the most.

The caveat: anything that reads object headers directly, some native agents and older bytecode instrumentation libraries included, can choke. Test your APM agent and any JNI dependencies before flipping this in production. If your observability stack is mainstream and current, odds are you are fine, but "odds are" is not a rollout plan.

2. AOT caching, now usable: faster cold starts in one command

Spring Boot's worst-kept secret is that it starts slowly. Every serverless-ish deployment, every scale-out event, every CI pipeline that boots the context to run tests pays the JVM warmup tax. Java 25 ships two JEPs that attack this directly.

JEP 514 simplifies creating an ahead-of-time class-loading and linking cache to a single step. You run the app once with a cache-output flag, and the JVM writes the cache on shutdown:

# Create the cache
java -XX:AOTCacheOutput=app.aot -cp app.jar com.example.App

# Use the cache on every subsequent start
java -XX:AOTCache=app.aot -cp app.jar com.example.App
Enter fullscreen mode Exit fullscreen mode

JEP 515 goes further and lets the cache carry method profiles, so the JIT compiler starts generating good native code immediately instead of spending the first minutes of your service's life guessing. This is the part that helps beyond raw class loading: the warmup phase where your p99 latency is embarrassing gets shorter.

How these fit with Spring's own AOT: Spring Framework and Spring Boot already have their own AOT engine used by native image builds, which transforms the context at build time. The JVM-level AOT cache is complementary: it works for regular JIT-compiled deployments with zero code changes. If you evaluated GraalVM native image and walked away because of reflection pain or build complexity, the AOT cache is the 80 percent of that startup win without rewriting anything.

3. JFR method timing and tracing: the profiler you already ship

This one deserves way more attention than it gets. JEP 520 lets Java Flight Recorder time and trace specific methods, configured at startup with no agent attached. The Inside Java demo is literally a Spring Boot example, tracing SimpleJpaRepository.findAll, which tells you who Oracle expects to use this:

java -XX:StartFlightRecording=method-timing='org.springframework.data.jpa.repository.support.SimpleJpaRepository::findAll',dumponexit=true,filename=recording.jfr -jar app.jar
Enter fullscreen mode Exit fullscreen mode

Then read it with the built-in viewer:

jfr view method-timing recording.jfr
Enter fullscreen mode Exit fullscreen mode

The output gives you invocation counts and min, average, and max wall time per method. If you have ever answered "which repository method is the slow one?" by adding StopWatch calls and redeploying, this replaces that entire ritual. You can also trace with full stacks using the jdk.MethodTrace event when you need to know who is calling the slow thing.

Two supporting JEPs round this out:

  • JEP 518, JFR cooperative sampling: rebuilds thread stack sampling for stability. No behavior change, just fewer distorted flame graphs.
  • JEP 509, CPU-time profiling (experimental): samples by CPU time rather than wall time, so a thread spinning uselessly shows up clearly. Linux only, experimental, but worth knowing about.

The reason this matters for subscribers to the "one Postgres" school of architecture: when you consolidate jobs onto one database and one app, your ability to answer "what is slow, right now, in production" without deploying anything becomes the difference between a five-minute incident and an hour of guessing. JFR was always close to free. Now it answers sharper questions.

4. Scoped Values: the ThreadLocal replacement, final at last

JEP 506 finalizes Scoped Values, the second major Project Loom API, and the one with real implications for how web frameworks manage request context. A scoped value is an immutable value bound to a lifetime, visible to everything called within that scope, and automatically cleaned up when the scope ends:

class Framework {
    private static final ScopedValue<RequestContext> CONTEXT = ScopedValue.newInstance();

    void serve(Request request, Response response) {
        var context = createContext(request);
        ScopedValue.where(CONTEXT, context)
                   .run(() -> Application.handle(request, response));
    }

    PersistedObject readKey(String key) {
        var context = CONTEXT.get();
        return getDBConnection(context).readKey(key);
    }
}
Enter fullscreen mode Exit fullscreen mode

Why an immutable cousin of ThreadLocal matters: ThreadLocal has two failure modes. It leaks when threads are pooled and nobody clears it, and it is fundamentally incompatible with virtual threads migrating between carrier threads mid-execution. Scoped Values are bound and unbound by scope, not by thread identity, so both problems disappear. The JEP documentation is explicit that they complement rather than fully replace ThreadLocal, since inheritable thread locals still have niche uses.

Today, in a stock Spring Boot app, the request context story is still ThreadLocal-based (RequestContextHolder, SecurityContextHolder). The framework migration to scoped values will take time. But the moment you write your own virtual-thread code, fan out work, and need a tenant ID or trace context to travel with it correctly, ScopedValue is the tool. Learning it now costs an afternoon and future-proofs the concurrency code you write this year.

5. Generational Shenandoah: a final, low-latency GC option

JEP 521 finalizes generational Shenandoah, the low-pause collector that now segments young and old objects, which is how it catches up to G1 and ZGC on throughput while keeping its signature sub-millisecond pauses:

java -XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational -jar app.jar
Enter fullscreen mode Exit fullscreen mode

Realistically, most Spring Boot services are well served by G1, and ZGC is the established choice when you absolutely cannot tolerate pauses. Shenandoah generational matters if you run on a JDK distribution that ships it (notably not the Oracle JDK) and you want pause behavior similar to ZGC with a different throughput profile. Put it in your "benchmark before switching collectors" bucket rather than your "turn on immediately" bucket.

What I would skip, and why

Preview features, in production, are a maintenance debt. Structured Concurrency is on its fifth preview and its API changed substantially in this very release. That is the sound of a design still moving; anything you build on it now will need rewriting. Same discipline applies to Stable Values and the PEM encoding API: interesting, preview, wait.

Most finalized language features are small-bore for server code. Module import declarations (import module java.base;) and compact source files with instance main methods are genuinely great for education and scripting, and I will happily write single-file utilities with them. But an enterprise codebase with checkstyle rules does not gain enough to justify churning imports. Flexible constructor bodies fixes a real wart, validating fields before super(), and is worth using in new code you write anyway. The KDF API is final and clean, but you only care if you do key derivation by hand, which most of us delegate to a security library.

One removal to actually check: JEP 503 deletes the 32-bit x86 port. Nobody runs 32-bit x86 servers anymore, but old Docker base images and embedded toolchains sometimes turn up surprising architectures. Glance at your base image before upgrading and move on.

The upgrade checklist

Here is the save-worthy version. If your team migrates a Spring Boot service to Java 25 this quarter, this is the sequence:

  • Before upgrading: confirm your framework's supported-JDK matrix for 25, and inventory native agents, APM integrations, and JNI dependencies. These are the only realistic breakage points.
  • Turn on first, same day: -XX:+UseCompactObjectHeaders. Verify heap drop with a load test and JFR before/after comparison.
  • Turn on if cold starts hurt: the AOT cache from JEP 514, plus JEP 515 method profiles. One extra build step, no code changes.
  • Adopt immediately as a habit: JFR method timing (JEP 520) for "which method is slow" questions. Zero risk, huge diagnostic payoff.
  • Learn now, use incrementally: Scoped Values (JEP 506) in any new virtual-thread code you write.
  • Benchmark before adopting: generational Shenandoah, if your JDK ships it and G1 pauses bother you.
  • Deliberately skip: all preview APIs, module imports in managed codebases, anything incubator.
  • Watch for: the 32-bit x86 removal biting an old base image.

The meta-lesson from six years of Java upgrades: the teams that win are not the ones that upgrade fastest, they are the ones that know which of the new flags are load-bearing. Java 25's answer is unusually clear. Two flags, one recording option, and one new API carry most of the value for a Spring Boot service.

I write about Java, Spring Boot, and AI every week. Subscribe, it's free.

Is your service still on Java 21, or have you already made the LTS jump? What is actually blocking the upgrade, tooling, dependencies, or just calendar time? I would genuinely like to know what the real-world blockers look like right now.

Top comments (2)

Collapse
 
enrique_535ac31de4ce5d114 profile image
Enrique

Java 25 is the new LTS release, and that means every Spring Boot team with a "we only upgrade on LTS" policy is now having the same conversation.

Most of those conversations end with a shrug and "we'll look at it next quarter," because the release notes read like a lottery: 18 JEPs, most of them previews, incubators, or crypto APIs you will never touch.

But buried in that list are four or five changes that matter enormously for exactly the kind of workload a typical Spring Boot service is: millions of small,

Collapse
 
enrique_535ac31de4ce5d114 profile image
Enrique

Solid breakdown. Which 2-3 of those JEPs actually mattered most for you in a Spring Boot service?

We're still on 21 LTS and trying to figure out if 25 is worth the upgrade this year, or if we just wait for more teams to battle-test it first.