Umur Inan/ Books/ Modern Java in Practice: Java 21 and 25 LTS for Working Engineers/ Free Sample
Free sample. This is the Preface and Chapter 1 of Modern Java in Practice: Java 21 and 25 LTS for Working Engineers by Umur Inan. The full book is available below.

Preface

Who this book is for

You’ve been writing Java since 8 or 11. You ship Spring Boot services for a living. You’ve heard the names: virtual threads, records, pattern matching, sealed types, gatherers, scoped values. You’ve maybe even used a few of them. But you don’t reach for them by reflex, and you can’t always tell when one of them is the right tool versus when you’re just chasing a new language feature for its own sake.

That’s the gap this book closes. If you write Java professionally and you want to know what idiomatic Java looks like in 2026, you’re in the right place.

What this book is

A feature tour of Java 21 LTS and Java 25 LTS, with strong opinions on when to use what. Each chapter takes one capability that the language or runtime gained between Java 9 and Java 25, explains the problem it solves, shows production code that uses it well, and tells you when not to reach for it.

Code targets Java 25. Where a feature finalized between 21 and 25, the chapter says so up front. Examples are plain Java. Spring Boot is your mental model for what real services look like, but it isn’t the canvas we paint on.

What this book is not

Not a Java tutorial. If you’re learning the language, start somewhere else and come back when generics and lambdas feel native.

Not a Spring Boot book. The framework shows up in passing, never as the subject.

Not exhaustive of every JEP. The release train ships dozens of changes a year, and most of them don’t matter for production code. We cover the ones that do.

Not chronological by Java version. Features are grouped by purpose: syntax, type modeling, concurrency, APIs, runtime. You’ll see Java 14 and Java 25 features in the same chapter when they belong together.

How to read it

Chapter 1 sets the baseline. Read it first. After that, the chapters are independent. If you came for virtual threads, jump to Chapter 10. If pattern matching is the thing keeping you up at night, Chapter 7 is waiting. The book is structured by purpose, not by Java version, so you can pick the chapter that maps to your current problem.

Companion code

Every chapter has a runnable Maven project at github.com/umur/modern-java-example. One module per chapter, named chapter-NN-<slug>/. The modules are independent, so you can clone the repo and jump straight to the chapter you’re reading.

Build with JDK 25 and Maven 3.9+. The preview-feature chapters have --enable-preview configured in their pom; you don’t need to add the flag yourself.

git clone https://github.com/umur/modern-java-example
cd modern-java-example/chapter-02-var
mvn test

A note on preview features

A handful of chapters cover features still in preview as of Java 25. Structured Concurrency, Scoped Values, Stable Values, and the Project Leyden AOT story all sit in that bucket. The APIs may shift before they finalize.

I include them anyway. They’re close to stable, the design has settled, and the code you write today will mostly carry forward. Each preview chapter flags the status at the top, names the JEP, and reminds you to compile with --enable-preview. If you want to wait for the final release before you write production code, fine. Read the chapter so you’ll know what you’re stepping into when the flag goes away.

Acknowledgments

The bugs are mine. The good ideas are everyone else’s, often without attribution because I forgot where I first heard them. If you recognize one of yours, please write to me; I would like to credit you in the next edition.


1 Why Modern Java?

1.1 Overview

“Java is a blue collar language. It’s not PhD thesis material but a language for a job.” James Gosling

Java 8 shipped in March 2014. As of this writing, it still runs roughly a third of the Spring Boot services in production. Twelve years of newer Java sitting unused on the shelf.

Pick one of those services. Every modern Java feature you might want to use, var, records, switch expressions, sealed types, pattern matching, text blocks, gets rejected at code review with the same five-word comment: “we’re on 8, can’t use that.” The codebase quietly accumulates workarounds for features that landed in 2014, 2017, 2020. The obvious question, the one nobody on the team has actually asked out loud, is why they are still on 8.

Nobody has a real answer. “We just haven’t upgraded.” “It works.” “Nobody’s asked us to.”

Open the codebase and you can date it from across the room. Optional.ofNullable(...).map(...).orElse(...) chains four levels deep. Anonymous inner classes for every callback. synchronized blocks guarding HashMap. A DateUtils class with thirty static methods. Meanwhile, the rest of the JVM has spent half a decade somewhere else.

This isn’t about taste. The cost of staying on 8 is real, and most teams have never sat down and counted it.

Read on.

Tip

Chapter 1 is conceptual. There’s a small “Hello, modern Java” example under code/chapter-01-why-modern-java/ that you can run with Java 25, using the new void main() entry point. From Chapter 2 onward, every chapter has a full Maven project.

This chapter answers five questions in order. How did Java’s release model change in 2018 when Oracle dropped the multi-year cycle? What does “LTS” actually mean now, and why are 21 and 25 the two versions worth targeting? Why do production Java codebases stay on 8 long after they should have moved on? What changes when we call a Java codebase “modern”: records, sealed types, virtual threads, pattern matching? And where does this book fit in: a feature tour, not a tutorial.

1.2 The Java 8 Anchor

If you had to bet on the version of Java a random production Spring Boot service is running, the smart money is still Java 8 or 11. Walk into any bank, any insurer, any logistics company that’s been writing Java since before the iPhone, and the odds barely move. The version that landed in March 2014 is the version a lot of paychecks still depend on.

There’s a reason for that, and it’s not laziness.

Java 8 was the version where the language stopped feeling like a museum exhibit. Lambdas arrived. The Stream API arrived with them, and suddenly looping over a collection didn’t require a five-line for block with an index variable nobody needed. Optional showed up to give us a vocabulary for “this might not be there” that wasn’t null. Default methods on interfaces meant you could evolve an API without breaking every implementor in the world. And java.time finally replaced the Date and Calendar mess that had been embarrassing the language since 1997. Overnight, Java felt like a language someone was still designing, not just maintaining.

That was the watershed. Open a Spring Boot codebase whose first commit landed in 2014 and you can watch the upgrade happen in the git log. forEach and map and filter start showing up in pull requests. Anonymous inner classes for callbacks disappear. Senior engineers who had been writing new Runnable() { @Override public void run() { ... } } since 2005 learned to write a lambda in an afternoon. Life was good.

And then everyone stopped.

Java 9 shipped in 2017 with the module system, JPMS. Whatever you think of Jigsaw on its merits, the upgrade story was painful. Code that did anything reflective broke. Libraries that had been quietly using sun.misc.Unsafe started spitting warnings, then errors. Build tools needed updates. Maven plugins needed updates. The plugins those plugins depended on needed updates. JAXB and the rest of the Java EE modules got pulled out of the JDK in Java 11, and if you’d been getting them for free your build snapped. Vendors slow-walked support: there were banks still on Java 6 in 2017, on machines no one wanted to touch.

Against all of that, Java 8 had one advantage that beat every argument for upgrading. It worked. The service shipped. Customers paid. Why bring risk into the building when nobody in the room was asking for it?

So teams stayed. And then they stayed some more.

Twelve years on, in 2026, that decision has compounded in a way nobody planned for. The senior engineers who learned lambdas in 2014 have written nothing but Java 8 since. The idioms are baked into their fingers. Optional.ofNullable(...).map(...).orElse(...) is how they reach for a nullable field, even when a record with a nonNull accessor would be three lines shorter and clearer. They write Map.Entry::getKey because that’s what they know. When a junior opens a PR with var, the comment writes itself: “we’re on 8, can’t use that.” Nobody asks the next question, which is whether they should still be on 8 in the first place.

Open the codebase and you can date it from across the room. It reads like 2014.

The cost of staying isn’t what people assume. Security isn’t the issue: Java 8 has long-term support from multiple vendors, patches keep coming, you can run it for another decade if you want to. Features aren’t the issue either. Records are nice. Pattern matching is nice. You can write any of those patterns in Java 8 with enough scaffolding. People did, for years. The cost is something quieter.

It’s idiom rot. An engineer who has only ever written Java 8 stops being able to read modern Java when they hit it. They open a file from another team and see a sealed interface, a switch with a pattern, a virtual thread executor, and the page might as well be in another language. They get slower. The team that’s hiring loses the candidates who learned Java in school in 2024 and have never written a stream the old way. The codebase becomes legible only to people who chose to specialize in old Java, and the pool of those people is not growing.

That’s the trap. Not danger, not missing features. Drift.

This book is not here to talk you into upgrading. That’s a different conversation, with different stakeholders, and the answer depends on things I don’t know about your situation. The job here is narrower. When you do upgrade, or when you read code from another team that already did, or when you interview someone who learned Java on 21, you should know what idiomatic Java looks like now. Not the version your fingers remember. The version the language actually became while you were busy shipping.

To understand why Java looks so different in 2026, we have to talk about what Oracle changed in 2018.

1.3 The Six-Month Release Train

The thing Oracle changed in 2018 sounds bureaucratic when you write it down. They picked a calendar. From Java 10 onward, a new release would ship every six months, in March and September, on the dot. That was it. No grand vision statement. No new logo. Just a clock.

It changed the language more than any single feature ever did.

Java 9 had landed in September 2017, three and a half years after Java 8. That gap had been the norm for a decade. Big releases. Long waits. Enormous JEPs piling up behind one another, blocking each other, slipping. Java 10 came out the following March, six months later, and the contract was new. The pattern held: 11 in September 2018, 12 the next March, 13 the September after. Twelve more, on schedule, through Java 25 in September 2025. The trains started running.

What that did, mostly, was take the drama out of release planning. Before 2018, a JEP that wasn’t ready for the next big release missed by years. After 2018, it missed by six months. The cost of saying “not yet” dropped to almost nothing. JDK Enhancement Proposals started graduating when they were ready, not when there was a marketing window. Records didn’t have to wait for a flagship release. They got one when they were done. Same with sealed classes. Same with switch expressions. The contrast with the lambda era is hard to overstate. Java 8 introduced lambdas in 2014. Java 9 introduced modules in 2017. That was the pace. Now something lands every six months, and the surprise is when it doesn’t.

The other thing that calendar did was create room for half-finished features to ship anyway. Honestly.

Preview features are the term. A feature ships in a release, in the actual JDK binary, but it’s marked unstable. Code that wants to use it has to compile and run with --enable-preview. The compiler adds a warning. The API can change between previews, sometimes meaningfully. After one to three preview cycles, the JEP either gets finalized or pulled back for more work. Pattern matching for switch previewed across several releases before it finalized in Java 21. Virtual threads previewed in 19, previewed again in 20, and finalized in 21. Record patterns took a similar path. The point of the mechanism is to let real teams put a feature in front of real code, on real workloads, with the language designers watching. You get production-adjacent feedback without committing to an API that turns out to be wrong.

Incubator modules are the same idea, scaled up. A whole API, gated behind a module flag, in the JDK but not part of the standard. The Vector API has been incubating for years and is still incubating as of Java 25, because SIMD intrinsics are genuinely hard and the team is in no hurry to lock the surface. The HTTP Client started life as an incubator in Java 9 before becoming standard in Java 11. Incubators are louder than previews about being unfinished. You’d reach for one if you wanted to experiment with where the JDK is heading, not to ship a feature next sprint.

Both mechanisms exist because the six-month cadence forced a question the old model never had to ask. If you can’t bundle features into a flagship release every three years, how do you give yourself the freedom to get a design wrong and fix it? The answer is: ship it labeled, get feedback, change it, ship it again, and only finalize when the noise dies down. It’s iteration, and it’s how the language has been evolving for the past seven years.

That changes how this book is going to read.

Every chapter notes when a feature was introduced and when it was finalized. Not as trivia. As a contract with you. If a chapter is teaching pattern matching for switch, you should know it previewed in Java 17, 18, 19, 20 and finalized in 21, because the difference matters when you read older code. Some of what we cover is still preview as of Java 25, and those chapters say so up front. Primitive type patterns are one example. String templates are another, and they’re a useful warning: they previewed in 21 and 22, then got pulled out in 23 for a redesign, and the redesign wasn’t back in 25. That’s the deal. The trade-off for getting features early is that the API you learn in a preview can shift before it lands. The chapters that touch preview features will be honest about what’s stable and what isn’t.

Shipping every six months means twenty-plus releases since 2017. You can’t run them all in production. Which ones actually matter?

1.4 LTS: The New Rhythm

Twenty-plus releases since 2017, and you can’t run them all. You don’t even want to. The release train ships every six months, but production isn’t a hobby project, and somebody has to decide which versions get the security patches, the vendor SLAs, and the sign-off from a compliance team that doesn’t read JEPs. The answer Oracle and the rest of the ecosystem landed on is LTS.

LTS stands for Long-Term Support. The label means a vendor has committed to backporting security patches and bug fixes to a specific release for years past its launch date. Not features. Patches. The release stays where it is, frozen at a known feature set, while CVEs and regressions get fixed and shipped as point updates. Oracle is one vendor offering this. Eclipse Adoptium ships builds branded as Temurin and supports the same versions on a similar window. Azul backs Zulu. Amazon ships Corretto and runs it on every Lambda you’ve ever invoked. Microsoft ships its own OpenJDK builds for Azure customers. Red Hat keeps a JDK as part of its enterprise stack. BellSoft sells Liberica into the same market. None of them pick the LTS versions. The OpenJDK community does, and the vendors line up behind those numbers.

A non-LTS release gets six months of support and then nothing. When Java 26 ships in March 2026, support for Java 25 stays in place, but Java 24 falls off. If your build is on a non-LTS version, your runway is exactly one cycle. Either you upgrade by the next March or September, or you keep running a JDK that nobody is patching.

The LTS versions, in order: Java 11 in September 2018, Java 17 in September 2021, Java 21 in September 2023, Java 25 in September 2025. The gap between 11 and 17 was three years. The gap between 17 and 21 was two years. So was the gap between 21 and 25, and the gap to whatever lands in September 2027 will be two years too. The rhythm has settled. Every two Septembers, a new LTS, and the version after that becomes the one your platform team starts planning for.

This is why most enterprises target LTS and only LTS. The patch stream is stable. The vendor SLA is something a procurement team can put on a contract. Internal compliance departments at banks, insurers, and regulated platforms write policies that read “supported LTS releases only” because that’s a clause they can audit. Running a non-LTS release in production means committing, in writing, to upgrading the JDK on a six-month clock or running unsupported. Most platform teams aren’t willing to make that bet, and they shouldn’t be.

Which brings us to this book.

The version pair this book targets is 21 and 25. Twenty-one is the floor. It’s the lowest version most teams will be running through 2026, because the upgrade from 17 has been the dominant migration of the last two years and most of the holdouts have either moved or are about to. Twenty-five is the ceiling. It’s what teams that are already current should be planning toward, and it’s where new projects that start in 2026 should land by default. Picking 21 as the floor means the code in this book runs on a version your operations team will sign off on. Picking 25 as the ceiling means we get to cover the features that finalized between those two LTS releases without pretending they don’t exist. Pattern matching for switch. Records and record patterns. Virtual threads. Sequenced collections. Scoped values. Module imports. The new entry-point form. Compact source files. All of it landed in that window, and all of it is honest content for both audiences.

A note on the non-LTS releases. If you read the JDK release notes you’ll see preview features arriving in 22, 23, and 24, the ones nobody is supposed to deploy. Primitive type patterns. The redesigned string templates that didn’t make it back in time. Stable values. The earlier sketches of structured concurrency. None of those releases are an LTS target, and you won’t run them in production, but they’re not wasted. The non-LTS releases are where the language designers iterate in public. Features show up there in preview, get sharpened across one or two cycles, and graduate. When Java 25 shipped in September 2025, it consumed everything that had previewed and stabilized since 21. That’s how the LTS rhythm and the six-month rhythm fit together. The trains run every six months for the designers. The LTS landings happen every two years for the rest of us.

1.5 What “Modern Java” Actually Means

“Modern Java” gets thrown around a lot, and most of the time it means whatever the speaker wants it to mean. In this book it has one definition. Modern Java is the language, the standard library, and the runtime as they stand at Java 21 and 25, used the way the people designing them intend. That’s the working term for the rest of these pages.

It is not Java 8 with newer syntax sprinkled on top. A codebase that adds var here and a record there, then keeps reaching for the same patterns it learned in 2014, is not a modern Java codebase. It’s a Java 8 codebase wearing a hat. The center of gravity has actually moved. Code written by someone fluent in 25 looks different on the page, runs different in production, and fails differently when it fails. Four shifts explain most of that distance.

The first shift is in how you describe data and intent in the type system. Records, sealed types, and pattern matching changed what you can say in a few lines. A domain that used to be a class hierarchy with a visitor, three abstract methods, and a switch over a String discriminator collapses into a sealed interface with three records and a switch expression that the compiler proves exhaustive. You write the shape of the problem and the language carries the rest. Less plumbing on the page. Less ceremony between the reader and the domain.

Concurrency is the second shift. Deeper than syntax. Virtual threads brought thread-per-request back as a serious model. The argument against it for the last decade was scale: a real platform thread is expensive, you can’t have a million of them, so you have to reach for a reactive stack to handle the load. That argument doesn’t hold on Java 21. Virtual threads are scheduled by the JVM onto a small pool of carriers, you can have millions of them in a single process, and the code reads like the synchronous code your team already knows how to debug. Structured concurrency, which finalized in Java 25, takes the next step. A scope owns a set of concurrent tasks, the scope completes when they all do or when one fails, and what was a tangle of futures and cancellation propagation becomes a single unit of work with a clear lifetime.

Then there’s the runtime. Generational ZGC arrived in Java 21 and changed the kind of pause times you can promise on a multi-hundred-gigabyte heap. JFR, the Java Flight Recorder, ships in every JDK build, on by default, and gives you production profiling without a separate agent. AOT compilation has graduated from a Project Leyden experiment into a preview surface in Java 25, with CDS and AppCDS already cutting cold-start time on every startup that uses them. None of those individual pieces are revolutions. Together they shift what the JVM is comfortable doing, from steady-state servers that have a minute to warm up to short-lived processes that need to be useful in milliseconds.

The fourth shift is the standard library. Sequenced collections gave the core collection types a notion of first and last that they were missing for twenty-five years. Stream gatherers, finalized in 24, fill in the operations that Stream couldn’t express on its own. The HTTP client has been part of java.net.http since Java 11 and is good enough that pulling in a third-party HTTP library now needs a justification it didn’t need before. The Foreign Function & Memory API replaced Unsafe and JNI for most native interop and is finalized as of 22. The list of reasons to reach for an external dependency for something the JDK should handle is shorter than it has been at any point in Java’s history.

That’s a lot of words. Here’s what one of those shifts looks like on the page.

A data carrier in Java 8:

public class Customer {
    private final String name;
    private final String email;

    public Customer(String name, String email) {
        this.name = name;
        this.email = email;
    }
    public String getName() { return name; }
    public String getEmail() { return email; }
    @Override public boolean equals(Object o) { /* ... */ return false; }
    @Override public int hashCode() { /* ... */ return 0; }
    @Override public String toString() { return "Customer{...}"; }
}

The same data carrier in Java 25:

record Customer(String name, String email) {}

The second example is a complete, working class. equals, hashCode, and toString are generated for you, with the contracts you’d write by hand. The canonical constructor is generated. Accessors name() and email() are generated. Run them side by side and the behavior matches. One of these you read in a glance. The other you scroll through.

Records are not a replacement for every class in your codebase. They’re a data carrier, and chapter 5 covers exactly when reaching for one is the wrong choice: identity matters, mutability is required, the class has real behavior beyond holding values. The teaser above is fair when the type’s job is to carry a name and an email. It’s a misuse waiting to happen the moment the type starts doing something else.

The rest of the book takes those four shifts and unpacks them. Type modeling has its own chapters on records, sealed types, and pattern matching. Concurrency gets virtual threads, structured concurrency, and scoped values. The runtime chapters cover ZGC, JFR, CDS, and AOT. The standard-library chapters cover sequenced collections, gatherers, the HTTP client, and FFM. You can read them in order. You can also jump to whichever pillar you need first and circle back.

That’s the lay of the land. Before moving on, here are the most common ways teams trip over this transition.

1.6 Common Mistakes

  1. The syntax-only refactor. A team flips the build from 17 to 21, the test suite passes, and they call it done. The compiler is newer, the bytecode is newer, the code on the page is the same. Most of the value of moving to modern Java is in rewriting idioms, not in changing the JDK. If the diff after an upgrade is one line in pom.xml, you didn’t upgrade the codebase. You upgraded the build.

  2. Adopting features in isolation. Records show up without sealed types. Pattern matching shows up without records. Virtual threads show up while the rest of the service still composes CompletableFuture chains. The features were designed to compound. A sealed interface plus three records plus an exhaustive switch is one shape of code, not three. Pick a chapter, learn the pieces in it together, and use the whole stack.

  3. “Preview means experimental, right?” Not quite. Preview means the API may shift before it finalizes, but the implementation is real and the JEP has the language designers’ attention. Incubator is the looser bucket, gated behind a module flag and louder about being unfinished. Worth using preview features behind --enable-preview in non-critical paths so your team forms an opinion before the API lands. Worth being careful where you reach for an incubator.

  4. Skipping the runtime side. New language features without ZGC, JFR, and AppCDS leave half the upgrade on the table. The chapters on records and pattern matching get the headlines, but the chapters on the runtime are the ones that change what you can promise on a Friday afternoon. A modern codebase running on default GC settings and no flight recordings is a modern codebase that hasn’t met production yet.

  5. Reading release notes instead of code. Release notes describe features. They tell you what shipped, not when to reach for it. Idiomatic code from a team that’s been on 21 for two years teaches you the second part. Read open-source projects that target a current LTS. Read the JDK’s own libraries. Read other people’s pull requests. The shape of fluent modern Java is something you absorb, not something you memorize from a JEP list.

1.7 Summary

  • The Java 8 era is still alive in production code, even on newer JVMs. Modernizing the runtime without modernizing the idioms misses the point of the upgrade.
  • The six-month release train is what made modern Java possible. Features bake under preview across two or three cycles, then ship when they’re ready, not when there’s a marketing window.
  • LTS strategy gives you two stable targets in 2026: 21 and 25. This book treats them as the floor and the ceiling, and every chapter is honest about what’s stable and what’s still preview.
  • “Modern Java” is a vibe shift, not a feature list. Expressive types, simple concurrency, a runtime that’s comfortable with short-lived processes, a standard library that closes the gap on a third-party dependency you used to need.

The book from here is one feature per chapter, in roughly the order an upgrading team meets them. Read straight through, or jump to the one your codebase will hit next. Either works.

Buy the full book on Leanpub Google Play Books