Java Evolution: What Changed from Java 8 to Java 24 and Why It Matters
A comprehensive guide to Java's evolution from version 8 to 24 — covering lambdas, streams, records, sealed classes, virtual threads, pattern matching, and every major feature that changed how we write Java.
From Verbose to Elegant: A Decade of Java
Java 8 rewrote the rules in 2014 with lambdas and streams. Since then, every six months a new version ships. Most developers jumped from Java 8 to 11, then 17, now 21. Here is everything that changed — version by version — with the features you will actually use.
Java 8 (2014) — LTS — The Big Bang of Modern Java
Java 8 introduced functional programming to Java. This is the version most developers know deeply.
Lambda Expressions & Functional Interfaces
Replaced anonymous inner classes with concise syntax. Core interfaces: Predicate<T>, Function<T,R>, Consumer<T>, Supplier<T>.
// Lambda + Predicate composition
Predicate<Product> inStock = p -> p.getQuantity() > 0;
Predicate<Product> isExpensive = p -> p.getPrice() > 100.0;
List<Product> premiumAvailable = products.stream()
.filter(inStock.and(isExpensive))
.collect(Collectors.toList());
// All 4 core functional interfaces
Predicate<Employee> isSenior = e -> e.getYears() > 5; // T -> boolean
Function<Employee, String> fullName = e -> e.getFirstName() + " " + e.getLastName(); // T -> R
Consumer<Employee> sendWelcome = e -> emailService.send(e); // T -> void
Supplier<Employee> defaultEmp = () -> new Employee("Guest"); // () -> T
Stream API
Declarative data processing with lazy evaluation. Key operations: filter, map, flatMap, collect, reduce, groupingBy.
// Filter + Map + Collect
List<String> activeNames = employees.stream()
.filter(e -> e.getStatus() == Status.ACTIVE)
.map(Employee::getName)
.sorted()
.collect(Collectors.toList());
// GroupingBy + downstream counting
Map<Department, Long> headcount = employees.stream()
.collect(Collectors.groupingBy(Employee::getDepartment, Collectors.counting()));
// FlatMap — flatten nested collections
List<String> allTags = articles.stream()
.flatMap(a -> a.getTags().stream())
.distinct()
.collect(Collectors.toList());
// PartitioningBy — split into true/false groups
Map<Boolean, List<Order>> shipped = orders.stream()
.collect(Collectors.partitioningBy(Order::isShipped));
Optional
Container for nullable values. Use for return types only, never for fields or parameters.
// Safe navigation through nested nullables
String cityName = Optional.ofNullable(customer)
.map(Customer::getAddress)
.map(Address::getCity)
.orElse("UNKNOWN");
// orElseGet is lazy — supplier only runs if empty
Product fallback = products.stream()
.filter(Product::isAvailable)
.findFirst()
.orElseGet(() -> new Product("Default", 0.0));
CompletableFuture
Non-blocking async operations with composition.
CompletableFuture<Double> priceCheck = CompletableFuture.supplyAsync(() -> fetchPrice(sku));
CompletableFuture<Integer> stockCheck = CompletableFuture.supplyAsync(() -> fetchStock(sku));
// Combine two async results
CompletableFuture<String> summary = priceCheck
.thenCombine(stockCheck, (price, stock) ->
"Price: $" + price + ", Stock: " + stock)
.exceptionally(ex -> "Error: " + ex.getMessage()); // fallback on error
java.time API & Default Methods
Immutable, thread-safe date/time classes: LocalDate, LocalDateTime, ZonedDateTime, Instant, Duration. Interfaces gained default methods for backward-compatible evolution.
Java 9 (2017) — Modules & Collection Factories
Immutable Collection Factories
List<String> colors = List.of("red", "green", "blue"); // immutable
Set<Integer> primes = Set.of(2, 3, 5, 7, 11); // immutable
Map<String, Integer> scores = Map.of("Alice", 95, "Bob", 87); // immutable
Optional & Stream Enhancements
// Optional: ifPresentOrElse, or(), stream()
opt.ifPresentOrElse(v -> process(v), () -> logMissing());
Optional<String> result = opt.or(() -> Optional.of("default"));
// Stream: takeWhile, dropWhile
List<Integer> prefix = Stream.of(1, 2, 3, 10, 4)
.takeWhile(x -> x < 5) // [1, 2, 3] — stops at first false
.toList();
Private Interface Methods
Helper methods in interfaces for code reuse across default methods.
Java 10 (2018) — var Keyword
// Local variable type inference
var products = new ArrayList<Product>(); // inferred as ArrayList<Product>
var stream = products.stream(); // inferred as Stream<Product>
// Works in for loops
for (var product : products) {
System.out.println(product.getName());
}
// Unmodifiable copies
var snapshot = List.copyOf(mutableList); // immutable copy
Constraints: var works only for local variables — not fields, method parameters, or return types. Cannot initialize with null.
Java 11 (2018) — LTS — First LTS After Java 8
// String enhancements
" hello ".isBlank(); // false
" hello ".strip(); // "hello" (Unicode-aware trim)
"line1\nline2".lines(); // Stream<String>
"ha".repeat(3); // "hahaha"
// Optional.isEmpty() — opposite of isPresent()
Optional.empty().isEmpty(); // true
// var in lambda parameters (useful for adding annotations)
list.stream()
.map((@NonNull var s) -> s.toUpperCase())
.toList();
// Predicate.not() — cleaner negation
list.stream().filter(Predicate.not(String::isBlank)).toList();
Java 12–13 (2019) — Switch Expressions & Text Blocks
// Switch expressions — arrow syntax, no fall-through, yields values
String label = switch (priority) {
case HIGH, CRITICAL -> "URGENT";
case MEDIUM -> "REVIEW";
case LOW -> "BACKLOG";
};
// Text blocks — multi-line strings
String query = """
SELECT id, name, department
FROM employees
WHERE status = 'ACTIVE'
ORDER BY hire_date
""";
// String.indent() and String.transform()
String result = input.transform(String::strip)
.transform(String::toUpperCase);
Java 14 (2020) — Records & Pattern Matching
Records — Immutable Data Classes
Auto-generates constructor, accessors, equals(), hashCode(), toString().
// One line replaces 50+ lines of boilerplate
public record OrderSummary(String orderId, String customerName,
double total, Status status) {}
// Compact constructor for validation
public record Price(double value, String currency) {
public Price {
if (value < 0) throw new IllegalArgumentException("Negative price");
currency = currency.toUpperCase();
}
}
Pattern Matching for instanceof
// Before: cast manually
if (obj instanceof String) {
String s = (String) obj;
System.out.println(s.length());
}
// After: binding variable in one step
if (obj instanceof String s && s.length() > 5) {
System.out.println(s.toUpperCase());
}
Java 15 (2020) — Sealed Classes Preview & ZGC Production
Text blocks finalized. ZGC garbage collector moved to production-ready. Sealed classes introduced in preview. Nashorn JavaScript engine removed.
// ZGC — now production-ready, no experimental flag needed
// java -XX:+UseZGC MyApp
// Hidden classes — used internally by frameworks like Spring/Hibernate
// for runtime-generated proxy classes
Java 16 (2021) — Stream.toList() & Records Finalized
// Stream.toList() — cleaner than Collectors.toList()
List<String> names = employees.stream()
.map(Employee::getName)
.toList(); // returns unmodifiable list
// mapMulti — alternative to flatMap for conditional one-to-many
Stream.of(1, 2, 3, 4).<String>mapMulti((num, consumer) -> {
if (num % 2 == 0) {
consumer.accept(num + " is even");
consumer.accept(num + " doubled = " + (num * 2));
}
}).toList();
Java 17 (2021) — LTS — Sealed Classes & Pattern Switch
The current enterprise standard. Sealed classes and pattern matching for switch finalized.
Sealed Classes — Controlled Inheritance
public sealed interface PaymentResult
permits Success, Failure, Pending {
}
public record Success(String txnId, double amount) implements PaymentResult {}
public record Failure(String error, int code) implements PaymentResult {}
public record Pending(String txnId) implements PaymentResult {}
// Exhaustive switch — compiler ensures all cases covered
String describe(PaymentResult result) {
return switch (result) {
case Success s -> "Paid $" + s.amount();
case Failure f -> "Failed: " + f.error();
case Pending p -> "Pending: " + p.txnId();
};
}
Algebraic Data Types (ADTs)
Sealed interface + records = sum types from functional programming. The compiler guarantees exhaustiveness — no default case needed.
Java 18 (2022) — UTF-8 Default & Simple Web Server
// UTF-8 is now the default charset on ALL platforms
// No more "works on my machine" charset bugs
System.out.println(Charset.defaultCharset()); // "UTF-8" everywhere
// Built-in simple web server for testing/prototyping
// Command line: jwebserver -p 9000 -d /path/to/files
var server = SimpleFileServer.createFileServer(
new InetSocketAddress(8080),
Path.of("/www"),
OutputLevel.INFO
);
server.start();
// @snippet in Javadoc replaces <pre>{@code ...}</pre>
Java 19 (2022) — Virtual Threads & Structured Concurrency Preview
First preview of Project Loom — the biggest concurrency change since Java 5.
// Virtual threads: lightweight (~KB vs ~MB for platform threads)
Thread.ofVirtual().start(() -> {
System.out.println("Running in virtual thread");
});
// Handle 100,000 concurrent tasks — impossible with platform threads
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 100_000).forEach(i ->
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
return i;
})
);
}
// Structured concurrency — fan-out pattern
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<User> user = scope.fork(() -> fetchUser());
Subtask<Account> acct = scope.fork(() -> fetchAccount());
scope.join().throwIfFailed();
return new Profile(user.get(), acct.get());
}
Java 20 (2023) — Refinement Release
All 7 JEPs are previews/incubators. Key addition: Scoped Values — a lightweight, immutable replacement for ThreadLocal designed for virtual threads.
// ScopedValue — replaces ThreadLocal for virtual threads
static final ScopedValue<String> CURRENT_USER = ScopedValue.newInstance();
void handleRequest(String username) {
ScopedValue.runWhere(CURRENT_USER, username, () -> {
processRequest(); // all called methods can read CURRENT_USER.get()
});
}
// Why better? Immutable, auto-inherited by child virtual threads, bounded lifetime
Java 21 (2023) — LTS — Virtual Threads, Record Patterns, Sequenced Collections
The latest LTS. Virtual threads, pattern matching for switch, and record patterns all finalized.
Virtual Threads (Finalized)
// M:N scheduling — millions of virtual threads on few OS threads
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000_000; i++) {
executor.submit(() -> {
// Each task gets its own virtual thread
return callExternalService();
});
}
}
Record Patterns — Destructuring
record Point(int x, int y) {}
record Line(Point start, Point end) {}
// Nested destructuring in switch
String describe(Object obj) {
return switch (obj) {
case Line(Point(var x1, var y1), Point(var x2, var y2))
-> "Line from (%d,%d) to (%d,%d)".formatted(x1, y1, x2, y2);
case Point(var x, var y) -> "Point at (%d,%d)".formatted(x, y);
default -> "Unknown";
};
}
Sequenced Collections
// New methods on ordered collections
List<String> list = List.of("a", "b", "c");
list.getFirst(); // "a"
list.getLast(); // "c"
list.reversed(); // ["c", "b", "a"]
// Also works on LinkedHashSet, LinkedHashMap, Deque
Java 22 (2024) — Unnamed Variables & FFM API
Unnamed Variables (_)
// Intentionally unused variable — no more "unused variable" warnings
try (var _ = ScopedContext.acquire()) { /* don't need the ref */ }
for (var _ : collection) { count++; }
map.forEach((_, value) -> process(value));
try { riskyOp(); } catch (Exception _) { log("failed"); }
// In pattern matching
if (obj instanceof Point(var x, _)) { /* only care about x */ }
Stream Gatherers (Preview)
// Sliding window
List<List<Integer>> windows = Stream.of(1, 2, 3, 4, 5)
.gather(Gatherers.windowSliding(3)).toList();
// [[1,2,3], [2,3,4], [3,4,5]]
// Fixed-size batches
List<List<Integer>> batches = Stream.of(1,2,3,4,5,6,7)
.gather(Gatherers.windowFixed(3)).toList();
// [[1,2,3], [4,5,6], [7]]
Foreign Function & Memory API (Finalized)
Safe replacement for JNI. Call native C functions directly from Java with memory safety guarantees.
Java 23 (2024) — Markdown Javadoc & ZGC Default
Primitive Types in Patterns (Preview)
String describe(int statusCode) {
return switch (statusCode) {
case 200 -> "OK";
case 404 -> "Not Found";
case int i when i >= 500 -> "Server Error: " + i;
case int i -> "Other: " + i;
};
}
Markdown Documentation Comments
/// Returns the **full name** of the user.
///
/// This method combines:
/// - First name
/// - Last name
///
/// @param user the user object
/// @return the concatenated full name
public String getFullName(User user) { ... }
Module Import Declarations (Preview)
// One import for an entire module
import module java.base; // imports java.util, java.io, java.nio, etc.
Note: String Templates (previewed in 21–22) were withdrawn in Java 23 due to design issues.
Java 24 (2025) — Stream Gatherers Finalized, AOT, Virtual Thread Unpinning
The largest release ever with 24 JEPs.
Stream Gatherers (Finalized)
// Built-in: windowFixed, windowSliding, fold, scan, mapConcurrent
List<Response> responses = urls.stream()
.gather(Gatherers.mapConcurrent(5, this::httpGet))
.toList();
// Custom gatherer: distinct by key
static <T, K> Gatherer<T, ?, T> distinctBy(Function<T, K> keyExtractor) {
return Gatherer.ofSequential(HashSet::new,
(seen, element, downstream) -> {
if (seen.add(keyExtractor.apply(element)))
return downstream.push(element);
return true;
});
}
// Usage: people.stream().gather(distinctBy(Person::lastName)).toList();
Ahead-of-Time Class Loading (Project Leyden)
# Record → Build cache → Launch with up to 42% faster startup
java -XX:AOTMode=record -XX:AOTConfiguration=app.aotconf -cp app.jar Main
java -XX:AOTMode=create -XX:AOTConfiguration=app.aotconf -XX:AOTCache=app.aot -cp app.jar
java -XX:AOTCache=app.aot -cp app.jar Main
Virtual Thread Unpinning
Virtual threads no longer get pinned to carrier threads in synchronized blocks. This was the biggest limitation of virtual threads in Java 21–23.
// Java 21–23: synchronized + blocking I/O PINNED the carrier thread
// Java 24: virtual thread unmounts — carrier is free for other work
synchronized (sharedResource) {
return fetchFromDatabase(); // no longer pins! Major perf improvement
}
Class-File API (Finalized)
Standard API for parsing and generating .class files. Replaces third-party libraries like ASM.
Other Notable Changes
Security Manager permanently disabled. Compact object headers (experimental, 10–20% heap reduction). Generational Shenandoah GC (experimental).
LTS Roadmap & Which Version to Target
| Version | Year | Type | Headline Feature |
|---|---|---|---|
| Java 8 | 2014 | LTS | Lambdas, Streams, Optional |
| Java 11 | 2018 | LTS | String enhancements, var in lambda, HttpClient |
| Java 17 | 2021 | LTS | Sealed classes, pattern matching, records finalized |
| Java 21 | 2023 | LTS | Virtual threads, record patterns, sequenced collections |
| Java 25 | Sep 2025 | LTS | Next LTS — expected to finalize structured concurrency, scoped values |
Recommendation: For new projects, target Java 21 (current LTS). For enterprises still on Java 8 or 11, plan migration to 21. Wait for Java 25 (September 2025) if you need the next LTS.
Key Takeaways
Java 8→11: Functional foundations — lambdas, streams, var, string helpers.
Java 12→17: Boilerplate elimination — records, sealed classes, switch expressions, text blocks, pattern matching.
Java 18→24: Concurrency revolution — virtual threads, structured concurrency, scoped values, stream gatherers, AOT compilation.
Java is no longer the verbose language it was. Modern Java (17+) is concise, expressive, and performs better than ever.