Java 25 LTS for Teams Stuck on Java 8

“Java 25? We’re Doing Just Fine on 8”

Peek inside a production service and you’ll find a surprisingly common scene. It started on Java 8, got bumped to 11 once, and has been frozen there ever since. Someone thought about moving to 17, but the ‘Spring Boot 2 → 3’ migration was scary, so it got put off. Meanwhile, Java has already reached 25 LTS, and even 26 GA.

“We haven’t upgraded past Java 8/11 in ages. But 25 feels like way too big a jump…”

“Records? Sealed? Virtual threads? I’ve heard of them, but I don’t really know what they improve in practice.”

“I read a Spring Boot 4 post that said Java 25 is recommended. If we upgrade one, do we have to upgrade everything?”

This post sums up the changes modern Java has settled into since then, ‘focusing on practical impact.’ It’s Part 2 of the ‘Legacy Escape’ series, and it should directly help with the decision I deferred in Part 1 (the Spring Boot 4 upgrade): ‘whether to upgrade to Java 25 at the same time.’ It’s not a catalog of new features. It focuses on ‘what changes in my code’ and ‘why we couldn’t upgrade all this time.’

The road from Java 8 to 25 looks long, but the big changes worth examining boil down to four.

Why Are We Still on Java 8?

‘TL;DR: This isn’t a problem unique to Korean SI (system integration, the outsourced enterprise projects that dominate Korean IT). Worldwide, Java 8 is shrinking fast, and 17 and 21 are climbing fast.’

If we don’t pin down “why we put this off,” the next decision will stall in the same place. Java 8 lock-in feels worse in Korean SI, but it isn’t a uniquely Korean problem.

  • JetBrains’ ‘State of Developer Ecosystem 2025’ put the share of respondents who regularly use Java 8 at 31%, continuing a decline from 83% in 2019 and 44% in 2024. In the same report, Java 17 is at 39% and Java 21 at 40%.
  • Coverage of Azul’s 2025 State of Java report likewise notes that Java 8 usage fell from 40% in 2023 to 23% in 2025.
  • In New Relic’s 2024 production data, the share of applications running Java 17 climbed to 35%.

The direction is clear: ‘Other countries have legacy too, but companies are already moving to 17 and up, fast.’ There are specific reasons the problem feels stronger in Korean SI.

  • ‘Public-sector SI and long-term maintenance contracts’: once a deliverable is built, it lives for a long time.
  • ‘eGovFrame (Korea’s government standard Java framework) and solution compatibility’: the dependency lineup is locked together, so upgrading any one piece on its own is hard.
  • ‘The misunderstanding about Oracle JDK licensing changes’: the belief that “Java is free only up to 8” has hardened into fact.
  • ‘Blurry lines between JDK, JRE, and JVM’: many companies have no internal agreement on what needs upgrading, and where.

It’s not that people don’t know the technology. It got put off because all of these factors are tangled together. So before the feature catalog, this post starts by untying that knot.


Quick Aside: What’s the Difference Between a JDK and a JRE?

The ‘JRE’ is the runtime for running Java applications. It contains the JVM, the standard library, and the components needed for execution.

The ‘JDK’ is the bundle that also includes development tools. On top of the JRE, it comes with tools like the javac compiler, a debugger, and monitoring tools.

When you check the ‘Java version’ on a server, don’t just look at java -version. Check what each of these is: ‘the JDK used for builds, the JDK/JRE in the production container image, and the JDK in the CI/CD image.’ A surprising number of environments have these three out of sync.


Quick Aside: The Myth That “Java Is Free Only Up to 8”

Talk about Java versions in the field and you’ll often hear this: “Isn’t Java free only up to 8?”

Strictly speaking, Java itself isn’t free only up to 8. The issue isn’t ‘the Java language’ but ‘the license of the Oracle JDK distribution.’

OpenJDK-based distributions are offered for free by many vendors (Adoptium/Temurin, Amazon Corretto, Azul Zulu, Microsoft Build of OpenJDK, and others). Even the Oracle JDK is free for commercial and production use ‘for a set period’ for LTS releases like 21 and 25, under the ‘NFTC (No-Fee Terms and Conditions)’ license. For example, Oracle JDK 25 updates are slated to be provided under NFTC until September 2028. (Oracle JDK 21 is scheduled for a license change after September 2026.)

That said, ‘some updates of Oracle JDK 8/11/17’ come with different license terms, and NFTC isn’t free forever either. It runs for a fixed term, so check it against your organization’s policy.

So the question to ask in practice isn’t “Which Java versions are free?” but “Which ‘JDK distribution’ are we using, and under which ‘license’?”


Quick Aside: What Is a Java LTS, and Why Does It Matter?

An ‘LTS (Long-Term Support)’ release is a version for which Oracle and the major OpenJDK distributors commit to long-term security and bug patches. Java ships a new version every six months, but not every version gets long-term support.

As of April 2026, the LTS lineup is ‘Java 8, 11, 17, 21, and 25.’ Running production services on an LTS is usually the safe choice. The next LTS is ‘Java 29 (expected September 2027).’

In other words, the roughly two years from ‘Java 25 (September 2025) → Java 29 (September 2027)’ are the LTS stability window. As of now (April 2026), the next LTS is about a year and a half away. Once you’re on Java 25, you don’t have to keep catching up every six months.


Bottom Line First: Who Should Look at This Now?

‘TL;DR: Teams on Java 17 should evaluate 21 or 25, Java 8/11 should move step by step starting with 17, and new projects should default to 25.’

Here’s a quick rundown by situation.

  • ‘New projects’: unless you have specific constraints, make ‘Java 25 LTS’ the default candidate. Its timing lines up with Spring Boot 4.
  • ‘Production services on Java 17’: time to evaluate 21 or 25 LTS. The jump is short, so the migration cost is the lowest.
  • ‘Production services on Java 11’: a staged move, first to 17 and then to 25, is safer. When frameworks and libraries move along with it, going all the way to 25 in one shot can make it hard to isolate the cause of a problem.
  • ‘Production services on Java 8’: the heaviest case. A two-step roadmap, Java 8 → 17 → 25, is the norm.
  • ‘Environments with heavy library and framework compatibility concerns’: look at the dependency BOM before the JDK version. The Spring/Hibernate/Jackson lines move in step with the LTS releases.

One-Page Summary: What Changed Between Java 8 and 25

‘TL;DR: The core is four things: record, sealed, pattern matching, and virtual threads. The rest is a bonus.’

There are plenty of small changes, but these are the big currents that change the tone of everyday code.

ChangeIntroduced (stable)What improvesOperational impact
recordJava 16Declare an immutable data class in one line. No boilerplateFar less DTO/value object code
sealed class/interfaceJava 17Explicitly closes the inheritance hierarchy. Safer type branchingSturdier domain model and event design
pattern matching (instanceof)Java 16Type check and cast in one lineNo more instanceof casting boilerplate
pattern matching (switch) + record patternsJava 21Type branching and destructuring in a single expressionif-else chains disappear; missing cases are caught as compile errors
virtual threadsJava 21Handle lots of concurrent work at a lower cost than platform threadsThread-per-request becomes a realistic option again for I/O-bound services
Garbage Collector evolutionJava 11–25More options: ZGC, G1, Generational ZGC, and moreHandles large heaps and low-latency requirements
Startup time / deployment optimizationJava 9–25AppCDS, the JDK’s AOT work, the GraalVM Native ecosystemPotential cold-start improvements in containers and serverless
Other conveniencesJava 10–25var, text blocks, record patterns, and moreReadability and expressiveness

This post focuses on those four ‘big currents.’ The rest gets a brief look at the end.


1. record: A Data Class in One Line

‘TL;DR: An immutable class that generates getters/equals/hashCode/toString for you. Biggest payoff for DTOs and value objects.’

In Java 8

public final class User {
    private final Long id;
    private final String email;
    private final String name;

    public User(Long id, String email, String name) {
        this.id = id;
        this.email = email;
        this.name = name;
    }

    public Long getId() { return id; }
    public String getEmail() { return email; }
    public String getName() { return name; }

    @Override
    public boolean equals(Object o) { /* ... */ }
    @Override
    public int hashCode() { /* ... */ }
    @Override
    public String toString() { /* ... */ }
}

Trimming this down with Lombok’s @Value was a common pattern.

In Java 25

public record User(Long id, String email, String name) {}

That’s it. The compiler generates the final fields, the accessors (id(), email(), name()), and equals/hashCode/toString. If you need validation, add it in a ‘compact constructor.’

public record User(Long id, String email, String name) {
    public User {
        if (email == null || !email.contains("@"))
            throw new IllegalArgumentException("invalid email");
    }
}

Operational Impact

  • Moving ‘immutable data bundles’ like DTOs, events, and value objects to records cuts the code dramatically and makes the intent clear.
  • You can reduce your dependence on Lombok. Records take the place of @Value/@Data.
  • Libraries like Jackson, Hibernate, and MapStruct are mostly record-friendly in their latest lines. However, some older versions may have limits on record serialization, so check your dependency lineup.

Limitations

  • Records are ‘final’ by default and can’t be extended. Not a fit for domains that need mutability (setters).
  • JPA @Entity classes are conventionally mutable, so you can’t move them to records as is. ‘JPA entities as classes, DTOs/projections as records’ is the common pattern.

2. sealed: Explicitly Closing the Inheritance Hierarchy

‘TL;DR: Tell the compiler “exactly these types can extend this one,” so nothing gets missed when a new type is added later.’

What It Solves

Domain models often have a fixed set of possible variants, like ‘a Payment is exactly one of three things: CardPayment, BankTransfer, or Voucher.’ Up through Java 8, there was no good way to express that. An enum struggles to hold all the behavior, and an ordinary abstract class can be extended by anyone, so it can’t close off the domain boundary.

In Java 17 (When It Became Stable)

public sealed interface Payment
    permits CardPayment, BankTransfer, Voucher {}

public record CardPayment(String cardNumber, int amount) implements Payment {}
public record BankTransfer(String bankCode, String accountNo, int amount) implements Payment {}
public record Voucher(String voucherCode, int amount) implements Payment {}

The permits clause nails it down for the compiler: ‘only these three can extend Payment.’

Why It Helps

When you branch with switch or instanceof, ‘the compiler checks whether you’ve covered every case.’ If a new payment type (say, CryptoPayment) is added, the branching code fails to compile and points you to exactly where it’s missing. You get safety close to enum + switch, applied to classes that carry rich data.

Operational Impact

  • Clearly express ‘closed sets of variants’ like domain events, payment methods, notification channels, and authentication methods.
  • When you add a new type, ‘every branching point surfaces as a compile error,’ so forgotten handling points disappear.
  • Converting an ordinary abstract class to sealed takes both ‘declaring permits’ and ‘deciding final/non-sealed/sealed for each subclass.’ In a large codebase, rolling it out incrementally is safer.

3. pattern matching: Type Branching and Destructuring in One Expression

‘TL;DR: An expression that folds instanceof + casting and switch + if-else chains into something short and safe.’

In Java 8

String describe(Object obj) {
    if (obj instanceof String) {
        String s = (String) obj;
        return "string of length " + s.length();
    } else if (obj instanceof Integer) {
        Integer n = (Integer) obj;
        return "int " + n;
    } else {
        return "other";
    }
}

In Java 25 (Including Record Patterns)

String describe(Object obj) {
    return switch (obj) {
        case String s        -> "string of length " + s.length();
        case Integer n       -> "int " + n;
        case Payment p       -> describePayment(p);
        case null            -> "null";
        default              -> "other";
    };
}

String describePayment(Payment p) {
    return switch (p) {
        case CardPayment(var cardNumber, var amount) ->
            "card *" + cardNumber.substring(cardNumber.length() - 4) + " " + amount + " KRW";
        case BankTransfer(var bankCode, var account, var amount) ->
            "bank " + bankCode + " " + amount + " KRW";
        case Voucher(var code, var amount) ->
            "voucher " + code + " " + amount + " KRW";
    };
}

Put ‘sealed + record + pattern matching’ together, and you can destructure every Payment variant in a single switch, with any missing case caught as a compile error. No default needed.

Operational Impact

  • Less instanceof casting boilerplate.
  • Less risk of missed cases in ‘closed domain branching.’
  • Places where if-else chains used to grow long (event handling, response mapping) get cleaned up for readability.

Caveats

  • The more powerful pattern matching gets, the closer a switch statement comes to ‘making business branching decisions.’ If there are too many branches, that in itself is a sign the problem should be solved with polymorphism.
  • Record destructuring ((var a, var b)) is resolved by ‘component order,’ not by name. Remember that reordering a record’s components is a breaking change for callers.

4. virtual threads: Lots of Concurrent Work at a Low Cost

‘TL;DR: On servers that spend a lot of time waiting on I/O, reactive is no longer the only option.’

The Java 8 Mindset

Threads are expensive. Grab one OS thread per request, and at thousands of concurrent requests, memory and context-switching costs explode. That’s why asynchronous models like reactive (Project Reactor, RxJava) came along. The code was concise, but the cost in readability and debugging was steep.

Virtual Threads, Stable Since Java 21

The JVM provides lightweight ‘virtual threads.’ A single platform thread acts as the carrier for many virtual threads, and when one of them blocks on I/O, it’s unmounted from the carrier so another virtual thread can run.

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 10_000).forEach(i -> {
        executor.submit(() -> {
            // one virtual thread per request
            var data = httpClient.send(...);
            db.update(...);
            return data;
        });
    });
}

The code has the familiar ‘thread per request’ shape. The concurrency ceiling is set not by the number of OS threads but by memory, hardware, and the capacity of whatever sits downstream.

Operational Impact

  • On I/O-bound servers (API gateways, services that call lots of external services, dashboard backends), the thread-per-request model becomes a realistic option again.
  • You get a similar level of concurrency without the reactive learning curve. ‘Easy-to-read code’ is back on the table.
  • However, the benefit isn’t large for ‘CPU-bound work.’

synchronized Pinning and JEP 491

In the Java 21 days, the main thing to watch out for was ‘carrier pinning’ (a virtual thread being unable to let go of its carrier thread and staying stuck to it) when doing blocking I/O inside a synchronized block. Since ‘JEP 491 in Java 24,’ that problem has been greatly mitigated: virtual threads can now release their carrier even inside synchronized.

That said, compatibility with native calls, old libraries, and some APM/tracing tools still needs to be checked separately. The Spring Boot 4 docs also strongly recommend ‘Java 24 or later’ for virtual threads.

Caveats

  • If ‘downstream capacity,’ like your DB connection pool or the concurrency limits of external APIs, stays the same, adding virtual threads alone won’t help. The concurrency ceiling is set by the narrowest point in the whole system.
  • Separately verify that your tracing and logging tools follow virtual threads correctly. Modern lines generally support them, but older APM adapters may not.

Other Changes Worth Noting (Summary)

‘TL;DR: Beyond the four big currents, there are plenty of small improvements you’ll run into every day.’

  • ‘var local variable type inference’ (Java 10): the compiler infers the type. Better readability, traded off against explicitness.
  • ‘Text blocks’ (Java 15): multi-line string literals. SQL and JSON literals get much cleaner.
  • ‘String Templates’: a preview in Java 21/22, but ‘withdrawn in JDK 23.’ As of Java 25, you can’t treat it as a production feature. It’s better seen as ‘a case study in why you shouldn’t rush preview features into production code.’
  • ‘Record patterns + enhanced switch‘ (Java 21+): the heart of the pattern matching covered above.
  • ‘GC evolution’: with Generational ZGC arriving in Java 21, large-heap, low-latency scenarios got easier.
  • ‘Startup time / deployment optimization’: AppCDS and the JDK’s AOT work (as of Java 25, AOT Command-Line Ergonomics, AOT Method Profiling, and more) matter for containers and serverless. GraalVM Native belongs to the separate GraalVM ecosystem, and it’s safer to think of Project Leyden not as ‘a feature you can use in production right now’ but as ‘a long-term effort to cut JVM startup and warmup time.’
  • ‘Foreign Function & Memory API’ (Java 22): an alternative to JNI. Little impact on typical backends, but a big deal for teams doing native integration.

Migrating from Java 8 to 25: How to Approach It

‘TL;DR: Don’t do it in one jump; use 17 or 21 as a way station. And don’t upgrade just the JDK; upgrade your library BOM along with it.’

Recommended Step-by-Step Path

Starting pointFirst targetFinal targetNotes
Java 8Java 17Java 25The biggest jump. Often overlaps with the Jakarta EE transition
Java 11Java 17Java 25Settle into the module system, start using record/sealed
Java 17Java 21 or 25Java 25Make full use of virtual threads / pattern matching
Java 21Java 25Java 25The lightest jump

One More Thing for Korean SI: eGovFrame

eGovFrame (Korea’s government standard Java framework) has also moved its latest line up to Java 17 and the Spring 6 family. ‘v5.0.0 (beta), announced on March 31, 2026,’ supports Spring Boot 3.5.6, and its runtime environment’s open-source stack was upgraded from Spring Framework 5.3.37 to 6.2.11. The runtime’s GitHub README also states that it’s built on Spring Framework 6.2.11 and supports Java 17. So saying ‘eGovFrame is still on Java 7’ is inaccurate as of today.

Still, traces of legacy remain. The 4.3 Getting Started docs describe the runtime environment as Spring 5.3.37 + JDK 8 or later, and the 4.2 Boot Sample’s Gradle migration doc, even though the page was revised in March 2026, still contains Spring Boot 2.7.12 and a java.sourceCompatibility = JavaVersion.VERSION_1_8 example. And the 3.5–3.10 lines are documented for JDK 7–8 environments.

The problem is less about ‘the framework itself’ and more about ‘how long already-built projects stay in maintenance.’ In the field, a dual track is common: ‘new deliverables start on v5/Spring 6/Java 17, while maintenance of existing 3.x/4.x projects carries on as is.’ In the end, migration schedules get set ‘one maintenance contract at a time.’

Things to Check Along with It

  • ‘Spring Boot/Framework line’: Boot 4.0.6 supports ‘Java 17 or later, compatible up to Java 26,’ and ‘pairing it with Java 25 LTS is the natural combination.’ For virtual threads, ‘Java 24 or later’ is recommended. (See Part 1 of the series.)
  • ‘Hibernate/Jackson/Lombok lines’: each major version has a different Java compatibility range. Leaving it to the BOM is safest.
  • ‘Build tools’: Maven 3.6.3 or later, Gradle 8.14 or later, or 9.x. Upgrade the JDK in your build image too.
  • ‘CI/CD’: the JDK version in your GitHub Actions and GitLab CI build images. Use a matrix build to check Java 17/21/25 at the same time.
  • ‘GC/memory options’: review every option you hard-coded in the Java 8 days. Details in the developer notes below.
  • ‘Observability/APM’: check that your New Relic, Datadog, Pinpoint, or other agents are on a line that supports the new JDK and virtual threads.

Where to Start (Recommended Order)

  1. Start on a separate branch.
  2. Upgrade only the JDK first and confirm that builds and tests run. (Often the library BOM goes up at the same time.)
  3. Clean up compile warnings, then errors. Catch deprecated APIs.
  4. Adopt modern features like record/sealed/pattern matching incrementally, 'starting with the lowest-risk layers (DTOs, domain events).' There's no need to convert all your code to modern style at once.
  5. For virtual threads, upgrade the 'Boot line' first, separately from adopting new language features, then measure on a small module before expanding.
  6. Watch load, memory, and logs on staging for at least a week before rolling out to production.

Wrapping Up: Legacy Escape, Part 2

This is Part 2 of the ‘Legacy Escape’ series. It picks up what I deferred in Part 1 (the Spring Boot 4 upgrade): ‘to upgrade the framework, you eventually have to look at the JDK too.’

To sum up:

  • Java 8 lock-in isn’t a uniquely Korean problem, but it’s felt more strongly in Korean SI, because public-sector SI, long-term maintenance, eGovFrame, the Oracle licensing myth, and blurry JDK/JRE lines are all tangled together.
  • Java 25 LTS isn’t just a version bump. It’s the turning point where ‘record + sealed + pattern matching + virtual threads’ come together and change the tone of your code.
  • ‘New projects default to 25, 17 evaluates 21 or 25, 11 goes 17 → 25, and 8 goes 17 → 25 in two steps’ is the realistic path.
  • Don’t upgrade just the JDK; look at ‘Spring Boot/Hibernate/Jackson/build images/APM’ together in the same upgrade cycle.
  • Apply modern style ‘incrementally, starting with low-risk layers,’ not ‘all at once.’

The next post is ‘Spring AI 2026 Update,’ a side story to the AI Decoded series. It walks through the Spring AI 1.0/1.1/2.0-M progression in step with the Spring Boot 4 / Java 25 timeframe, and takes a deeper look at the Spring AI integration I briefly mentioned in the Boot 4 post.


📦 Extra Notes for Developers

Checklist: Adopting record

  • Jackson serialization: the latest jackson-databind lines are record-friendly. Jackson 3.x, which Boot 4 brings in, is the standard path.
  • Hibernate: @Embeddable can work with records, but @Entity is usually kept as a mutable object. Records are highly recommended for JPA projections and DTOs.
  • Living with Lombok: Lombok and records can coexist. But wherever you use records, you’ll barely need Lombok. Phasing it out gradually is recommended.
  • Serialization customization: @JsonProperty and custom serializers work on records as is. Just double-check library compatibility with the fact that accessors are named name(), not getName().

Checklist: Adopting sealed

  • Classes listed in permits must be in the same module/package (in a modular setup). Some cases also work in the unnamed module, but explicit modularization is safer.
  • Each subtype must pick one of final, non-sealed, or sealed.
  • For domains that need ORM mapping, design a separate interface together with a mapping strategy rather than relying on sealed.

Checklist: Adopting Pattern Matching

  • instanceof pattern matching became stable in Java 16. switch pattern matching and record patterns (destructuring) became stable in Java 21.
  • Handle case null explicitly. A traditional switch threw an NPE on null, but a pattern switch can handle case null, which makes it safer.
  • Recent IntelliJ and Eclipse releases offer automatic conversion. In a large codebase, lean on your IDE.

Checklist: Adopting Virtual Threads

  • On Spring Boot 3.2+, a single line, spring.threads.virtual.enabled=true, switches Tomcat’s request threads to virtual threads. Boot 4 works the same way, and ‘Java 24 or later’ is strongly recommended (pinning was mitigated by JEP 491).
  • If you leave your DB connection pool (HikariCP) size as is, there’s little point. Re-measure the balance between the number of virtual threads and the pool size.
  • Code that does I/O inside a synchronized block could cause carrier pinning on Java 21. It’s been significantly mitigated since JEP 491 in Java 24, but native calls, old libraries, and APM compatibility still need checking.
  • Tracing: the latest OpenTelemetry and Micrometer lines are virtual-thread-friendly. Check older APM agents separately.

GitHub Actions Matrix Example

strategy:
  matrix:
    java-version: [ "17", "21", "25" ]
    boot-version: [ "3.5.x", "4.0.6" ]
    exclude:
      - java-version: "17"
        boot-version: "4.0.6"  # compatible, but 21+ recommended for production

With this setup, you can verify ‘Boot 3.5 + Java 17 (current production),’ ‘Boot 4 + Java 21 (intermediate),’ and ‘Boot 4 + Java 25 (target)’ in one go.

Cleaning Up JVM Startup Options

Always review every JVM option you hard-coded back in the Java 8 days.

  • ‘CMS options’ (-XX:+UseConcMarkSweepGC and the like): the CMS GC was removed in JDK 14. It’s no longer a valid choice.
  • ‘GC logging options’: since Java 9, these have moved to the ‘Unified Logging’ (-Xlog:...) system. Migrate old options like -XX:+PrintGCDetails to the new format.
  • ‘Outdated GC combinations’ (-XX:+UseParallelOldGC and the like): on the deprecation path. In general, consolidate on -XX:+UseParallelGC or G1 (-XX:+UseG1GC, the default).
  • ‘GC recommendation’: G1 (the default) for typical backends. If you need large heaps and low latency, ‘Generational ZGC’ (Java 21+).
  • ‘Memory and thread options’ (-Xms, -Xmx, -Xss): not candidates for removal, but ‘candidates for re-review against your current container and thread model.’
  • ‘Containers only’: set the container memory ratio with -XX:MaxRAMPercentage. More flexible than a fixed -Xmx.

Modern Java in One Line

‘Java 25 isn’t about memorizing new features; it’s the turning point where the tone of your code changes. Shrink with record, close with sealed, branch safely with pattern matching, and lighten concurrency with virtual threads. The four come as a set.’

Leave a Comment