Modern Java Features (9–21)

After Java 8, the platform shipped on a six-month cadence: language refinements, library APIs, and JVM improvements landed every release—while LTS versions (11, 17, 21) are what enterprises standardize on. This chapter maps each wave to what changes in your code, build, and runtime—and where to read more in OOP, Functional, and Concurrency.

mid senior

Release strategy & how to read this chapter

Oracle and the OpenJDK community ship a feature release every six months. Long-Term Support (LTS) releases receive years of security and critical fixes—ideal for servers and regulated environments. Non-LTS releases are stepping stones; many features appear as preview or incubating first.

VersionLTS?Highlights in this guide
8LTS (baseline)Lambdas, streams—assumed prior reading
9Modules, collection factories, stream API additions
11LTSHTTP Client, String/Files helpers, single-file launch
17LTSSealed classes, pattern instanceof, strong JDK encapsulation
21LTSVirtual threads, pattern switch, record patterns, sequenced collections

Preview features require --enable-preview and may change syntax before finalization. Enable only in experiments—not in production bytecode unless the feature is finalized in your exact JDK version.

💡 Pro Tip

Migration path many teams follow: 8 → 11 → 17 → 21. Each hop removes deprecated APIs (e.g. Java EE modules removed from JDK 11) and tightens encapsulation. Run jdeps and jdeprscan on bytecode before upgrading CI images.

Java 9

Java 9 introduced the module system and a wave of library conveniences that everyday code uses even when you never write module-info.java.

Modules (JPMS)

The Java Platform Module System groups packages into named modules with explicit dependencies. A module declares what it requires (reads from other modules) and exports (API visible to other modules). Strong encapsulation: packages not exported are inaccessible outside the module—even via reflection (unless opens is used).

Classpath applications still run on the classpath (unnamed module). Modular JARs go on the module path. The JDK itself is modular—illegal access to internal packages like sun.misc began tightening here and continued in later releases.

Java
// src/module-info.java
module com.example.app {
    requires java.logging;
    requires transitive com.example.core;  // consumers of app see core
    exports com.example.api;
    opens com.example.internal to com.fasterxml.jackson.databind;
}

When modules help: large products, strict API boundaries, custom JRE images with jlink. When to skip: typical Spring Boot fat JARs often stay on classpath unless you invest in modularization.

jshell

jshell is a Read-Eval-Print Loop: type snippets or paste method bodies, see results immediately. Supports variables, imports, tab completion, and /vars history. Classpath flags load dependencies for trying library APIs. It lowers the friction of learning Java after Python/Node REPLs—does not replace unit tests or a proper project layout for production code.

Collection factory methods

List.of, Set.of, Map.of / Map.ofEntries return immutable collections—no null elements, no structural modification. Smaller and clearer than Arrays.asList for fixed data.

Java
List<String> roles = List.of("read", "write");
Map<String, Integer> limits = Map.of("api", 100, "batch", 10);

// List.of(1, null);  // NullPointerException

Stream improvements

  • takeWhile(pred) — take from start while true (stops at first false)
  • dropWhile(pred) — skip while true, then pass rest (useful on sorted data)
  • iterate(seed, hasNext, next) — finite or controlled iteration without infinite stream
Java
List<Integer> sorted = List.of(1, 2, 3, 4, 5, 1);
List<Integer> head = sorted.stream().takeWhile(n -> n < 4).toList();  // [1,2,3]

Stream.iterate(0, n -> n < 5, n -> n + 1).forEach(System.out::println);

Optional.ifPresentOrElse

Handle both branches without nested if (opt.isPresent())—consumer if value present, runnable if empty.

Java
optionalUser.ifPresentOrElse(
    u -> sendEmail(u),
    () -> log.warn("no user"));

Java 10

A smaller release focused on local type inference and defensive copies from collections.

var — local type inference

The compiler infers the type from the initializer. Still statically typed—not dynamic. See basics: var for limits (locals only, no null alone, no array literals without new).

List.copyOf and unmodifiable collectors

List.copyOf(collection) returns an unmodifiable list backed by the elements—if the source is mutable, changes to the source do not affect the copy. Collectors.toUnmodifiableList() (and set/map variants) produce unmodifiable views from streams—Java 10+.

Java
var names = List.of("a", "b");
var copy = List.copyOf(names);

List<String> frozen = stream.collect(Collectors.toUnmodifiableList());

Java 11 (LTS)

First LTS after 8 for many enterprises. Removed Java EE modules from the JDK (JAXB, JAX-WS, etc.—add dependencies explicitly). Added practical String/Files APIs and a modern HTTP client in the standard library.

String methods

MethodPurpose
isBlank()Empty or only whitespace
lines()Stream of lines (newline-aware)
strip() / stripLeading / stripTrailingUnicode-aware trim (better than trim() for international text)
repeat(n)Repeat string n times
Java
"  hello  ".strip();           // "hello"
"line1\nline2".lines().count(); // 2
"-".repeat(40);

Files.readString / writeString

Read entire file as UTF-8 String or write in one call—replaces boilerplate with Files.readAllBytes and charset dance. Still load only reasonably sized files into heap.

HTTP Client API (java.net.http)

Replaces much HttpURLConnection usage: HTTP/2, WebSocket, async with CompletableFuture, configurable timeouts, HTTP/1.1 redirect following. Synchronous and asynchronous modes.

Java
HttpClient client = HttpClient.newBuilder()
    .connectTimeout(Duration.ofSeconds(5))
    .build();

HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://api.example.com/health"))
    .timeout(Duration.ofSeconds(10))
    .GET()
    .build();

HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode());

Running single-file programs

java Hello.java compiles and runs source without explicit javac step—great for scripts and teaching; production builds still use Maven/Gradle.

📦 Real World

Spring Boot 2.x+ targets Java 11+ widely. Libraries like Jackson and SLF4J replace removed JAXB for JSON/logging. Container images often use eclipse-temurin:11-jre or newer LTS tags.

Java 12–13

Language features matured through preview cycles—switch as expression and text blocks landed for real in later releases.

Switch expressions preview in 12–13

Switch becomes an expression that produces a value—arrow labels case X -> avoid fall-through bugs. Multi-label cases, yield for block bodies. Finalized in Java 14—see below.

Java
String tone = switch (status) {
    case OK -> "green";
    case WARN, DEGRADED -> "amber";
    case ERROR -> "red";
    default -> "gray";
};

Text blocks preview in 13, standard in 15

Text blocks use triple quotes """ to embed multi-line text. The compiler strips incidental indentation based on the closing delimiter position—align closing """ with content for predictable formatting. Escape sequences still work; embedded quotes use \". \ at end of line suppresses newline insertion between lines.

Use for JSON fixtures, OpenAPI fragments, SQL in tests, and message templates—pair with .formatted(args) or String.format for interpolation (text blocks are not templates by themselves).

Collectors.teeing Java 12

Runs two collectors over the same stream elements in one pass and merges results with a combiner function—avoids collecting twice or manual loop fusion. Typical use: compute count and sum for dashboard stats, or min and max together.

Java
record Stats(long count, double avg) {}

Stats stats = orders.stream().collect(Collectors.teeing(
    Collectors.counting(),
    Collectors.averagingInt(Order::amountCents),
    (count, avg) -> new Stats(count, avg)));

Java 14

Records and pattern matching entered the language as previews; switch expressions became standard; NPE messages became actionable.

Records preview

Immutable data carriers—canonical constructor, accessors, equals/hashCode/toString. Standard in Java 16—see OOP: Records.

Java
public record Point(int x, int y) {}

Pattern matching for instanceof preview

Binds variable when type matches—eliminates cast after instanceof. Standard in Java 16.

Java
if (obj instanceof String s) {
    System.out.println(s.toUpperCase());
}

Helpful NullPointerExceptions

JVM analyzes which reference was null in a chain like a.b().c() and reports Cannot invoke "C.m()" because the return value of "B.c()" is null instead of a bare line number. Enabled by default in recent JDKs (-XX:-ShowCodeDetailsInExceptionMessages to disable). Speeds debugging in large codebases and Lombok-heavy DTOs.

Java 15–16

Sealed types, records, and pattern instanceof graduated from preview to standard—modern algebraic modeling in Java.

Sealed classes preview in 15

Restrict which classes/interfaces may extend/implement a type—sealed + permits. Enables exhaustive switch when combined with sealed hierarchies. Standard in Java 17—see OOP: Inheritance.

Java
public sealed interface Result permits Ok, Err { }
public final record Ok(Object value) implements Result { }
public final record Err(String message) implements Result { }

Records — standard Java 16

Production-ready immutable DTOs, command objects, event payloads. Compact constructors validate invariants. Not a drop-in for JPA entities without mapping layer.

Pattern matching instanceof — standard Java 16

Scope of pattern variable is limited to where the compiler proves the match—cleaner visitor-style code before switch patterns arrived.

Text blocks — standard Java 15

Java
String sql = """
                SELECT id, name
                FROM users
                WHERE active = true
                """;

Java 17 (LTS)

The adoption anchor for “modern Java” in many enterprises today: sealed classes standard, continued pattern matching work, and stronger encapsulation of JDK internals for security and maintainability.

Sealed classes — standard

Model closed hierarchies (payment types, AST nodes, API results) with compiler-checked exhaustiveness when you switch over permitted types. Subclasses must be final, sealed, or non-sealed.

Strong encapsulation of JDK internals

Illegal reflective access to JDK-internal APIs is denied by default on Java 17+. Libraries that used sun.misc.Unsafe or deep reflection on java.base needed updates. Run with --add-opens only as a temporary migration bridge—fix dependencies rather than permanently opening packages.

Other notable 17 features: RandomGenerator hierarchy, macOS AArch64 port, deprecations (SecurityManager, Applet API). Check release notes when bumping bytecode --release 17.

🎯 Interview Tip

Contrast sealed + records + pattern switch with visitor pattern + inheritance of old Java. Explain why 17 is the minimum for many Spring Boot 3.x stacks (Jakarta EE namespace, baseline APIs).

Java 18–20

Incubation releases refining pattern matching for switch and virtual threads (Project Loom) before Java 21 LTS.

Pattern matching for switch preview iterations

Extends switch to test type patterns: case String s, guards with when, handling null as explicit case. Each 18–20 preview refined syntax and exhaustiveness rules—final standard semantics in Java 21.

Java
// Shape of Java 21+ (preview in 18–20 with --enable-preview)
static String describe(Object obj) {
    return switch (obj) {
        case null -> "null";
        case String s when s.isBlank() -> "blank string";
        case String s -> "string: " + s;
        case Integer i -> "int: " + i;
        default -> "other";
    };
}

Virtual threads preview (19–20)

Project Loom preview builds introduced Thread.ofVirtual() behind preview flags. Early adopters validated servlet containers, JDBC drivers, and thread-local-heavy frameworks for pinning issues. Preview semantics changed slightly across 19–20—production standardization waited for JDK 21. Full model: Concurrency: Virtual threads.

Other notes (18–20)

  • 18 — UTF-8 default charset, simple web server for static files (tooling)
  • 19 — Virtual threads preview, structured concurrency incubator
  • 20 — Scoped values preview (alternative to thread-local for structured sharing)

Java 21 (LTS)

The current long-term target for greenfield services: virtual threads production-ready, pattern switch and record patterns standard, sequenced collections for predictable first/last element APIs, and structured concurrency APIs for safer parallel tasks.

Virtual threads — standard

Create with Thread.ofVirtual() or Executors.newVirtualThreadPerTaskExecutor(). Ideal for I/O-bound request-per-thread servers; see concurrency chapter for pinning, pools, and Spring Boot 3.2+.

Java
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 10_000).forEach(i ->
        executor.submit(() -> handleRequest(i)));
}

Sequenced collections

New interfaces SequencedCollection, SequencedSet, SequencedMap unify “encounter order” operations: getFirst, getLast, reversed. LinkedHashMap and LinkedHashSet implement sequenced map/set; lists are sequenced collections. Removes ad-hoc list.get(list.size()-1) and documents insertion-order intent.

Java
SequencedMap<String, Integer> map = new LinkedHashMap<>();
map.put("a", 1);
map.put("b", 2);
map.firstEntry();   // a=1
map.lastEntry();    // b=2
map.reversed();     // view in reverse encounter order

Pattern matching for switch — standard

Exhaustive switch over sealed hierarchies without default; type patterns, guards, null handling. Replaces verbose instanceof chains in many parsers and API mappers.

Record patterns

Deconstruct records in instanceof and switch—bind components directly.

Java
record Point(int x, int y) { }

static void print(Object obj) {
    if (obj instanceof Point(int x, int y)) {
        System.out.println(x + "," + y);
    }
}

static String label(Object obj) {
    return switch (obj) {
        case Point(int x, int y) -> x + "," + y;
        case null -> "none";
        default -> "?";
    };
}

Structured concurrency

Before structured concurrency, fire-and-forget executor.submit from a request handler could outlive the request—orphan tasks hold references and pollute metrics. StructuredTaskScope binds child tasks to a parent scope: when the scope closes (success, failure, or cancellation), children are cancelled and joined. Policies like ShutdownOnFailure fail the scope if any child fails; ShutdownOnSuccess supports racing tasks (first success wins).

Works naturally with virtual threads—fork thousands of cheap tasks, still enforce a single failure boundary. API stabilized in the JDK 21+ timeframe; verify package and preview flags for your exact vendor JDK.

Java
// Illustrative — check JDK javadoc for your release
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    StructuredTaskScope.Subtask<User> user = scope.fork(() -> fetchUser(id));
    StructuredTaskScope.Subtask<Orders> orders = scope.fork(() -> fetchOrders(id));
    scope.join();
    scope.throwIfFailed();
    return buildResponse(user.get(), orders.get());
}
🔬 Under the Hood

Java 21 also ships Generational ZGC improvements, key agreement for crypto, and continued deprecation of old threading APIs. Always read release notes for your vendor build (Temurin, Corretto, etc.)—vendor backports may differ slightly.

📦 Real World

Greenfield Spring Boot 3.2+ on Java 21: virtual threads for servlet stack, records for DTOs, sealed + switch for domain ADTs, HTTP Client or WebClient for outbound calls. Migration from 11: run tests on 17 first, fix illegal access, then 21 + virtual thread soak test.