Modern Java: from 8 to 25
A new Java version comes out every six months, and every two years one of them gets long-term support — LTS. These are versions 8, 11, 17, 21 and 25. Many projects and online articles are still written in Java 8, and modern code looks noticeably different. This lesson gathers the main changes, with links to the lessons that cover them in detail.
Java 8: the foundation
Java 8 (2014) is the most important version in the language's history. It introduced:
- lambdas and method references — lambda expressions;
- the Stream API — what a stream is;
Optional— Optional;- the new
java.timedate package — dates and times.
All of this is an ordinary part of modern code, not "new stuff".
Java 9–11: less boilerplate
- The
List.of,Set.ofandMap.offactories create immutable collections in one line (Java 9) — lists. varinfers the type of a local variable from its value (Java 10) — variables.- New string methods
isBlank,strip,repeat,lines, andFiles.readString(Java 11) — reading and writing files. - The module system and the
module-info.javafile (Java 9) — modules. - The built-in
HttpClientfor requests to web servers (Java 11) — HTTP client.
void main() {
var names = List.of("Anna", "Oleg");
IO.println(names);
IO.println("ab".repeat(3));
}Output:
[Anna, Oleg]
abababJava 12–17: new shapes for data
switchexpressions with->arrows return a value (Java 14) — the switch statement.- Text blocks
"""for multi-line strings (Java 15) — text blocks and formatting. - Records — data classes in one line (Java 16) — records.
- Patterns in
instanceof(Java 16) — patterns in instanceof. sealedclasses (Java 17) — sealed classes.
Java 18–21: pattern matching and virtual threads
- Patterns in
switchand record patterns (Java 21) — patterns in switch, record patterns. - Virtual threads (Java 21) — virtual threads.
- Sequenced collections (Java 21): lists,
LinkedHashSetandLinkedHashMapgot the common methodsgetFirst(),getLast()andreversed().
void main() {
List<String> queue = List.of("Anna", "Oleg", "Ira");
IO.println(queue.getFirst());
IO.println(queue.getLast());
IO.println(queue.reversed());
}Output:
Anna
Ira
[Ira, Oleg, Anna]Before, the last element took list.get(list.size() - 1).
Java 22–25: easier to start
- The unnamed variable
_for values you don't need (Java 22) — record patterns. - Compact source files and a
mainmethod withoutstaticand parameters, theIOclass for input and output, automatic import of thejava.basepackages (Java 25) — the first program. - Code before the
super(...)call in a constructor — for example, argument checks (Java 25). - Scoped values,
ScopedValue— a replacement forThreadLocalwith virtual threads (Java 25) — scoped values.
Then and now
The same task — totalling orders per customer — in Java 8 style and in Java 25 style. First the old version:
import java.util.*;
public class Main {
static class Order {
private final String customer;
private final int amount;
Order(String customer, int amount) {
this.customer = customer;
this.amount = amount;
}
String getCustomer() { return customer; }
int getAmount() { return amount; }
}
public static void main(String[] args) {
List<Order> orders = Arrays.asList(new Order("Anna", 20), new Order("Oleg", 35), new Order("Anna", 15));
Map<String, Integer> totals = new TreeMap<>();
for (Order order : orders) {
Integer current = totals.get(order.getCustomer());
totals.put(order.getCustomer(), (current == null ? 0 : current) + order.getAmount());
}
System.out.println(totals);
}
}Output:
{Anna=35, Oleg=35}Now the modern one — a record instead of a class, List.of, a stream with grouping and a compact source file:
record Order(String customer, int amount) {}
void main() {
var orders = List.of(new Order("Anna", 20), new Order("Oleg", 35), new Order("Anna", 15));
var totals = orders.stream()
.collect(Collectors.groupingBy(Order::customer, TreeMap::new, Collectors.summingInt(Order::amount)));
IO.println(totals);
}Output:
{Anna=35, Oleg=35}Both versions run on Java 25, but the second is a third of the length and reads like a description of the task.
Common mistake
var doesn't make Java a dynamic language: the variable's type is inferred once from the initial value and never changes:
void main() {
var count = 10;
count = "ten";
}Output:
Main.java:3: error: incompatible types: String cannot be converted to int
count = "ten";
^
1 error
error: compilation failedAnd remember that new features need a new version: if a project is built for Java 17, it has records, but not record patterns or compact source files. The version is set in the build settings, for example <maven.compiler.release>25</maven.compiler.release> in Maven.