Java 25 is the new LTS. Java 26 just dropped. Are you still writing Java 8?
Java has changed more in the last four years than in the previous decade. Records, sealed classes, pattern matching, virtual threads, scoped values, structured concurrency — the language is practically unrecognizable from Java 8. And yet, the codebase you're shipping today? Probably still full of habits from 2015.
Here are 7 things devs consistently get wrong when writing modern Java — and what to do instead.
1. Using Optional as a return type... everywhere
Optional was a great idea. It's also one of the most abused APIs in modern Java.
What devs do:
public Optional<String> getUserName(long id) {
return Optional.ofNullable(db.findUser(id))
.map(User::getName);
}
Then they chain .get() on it anyway:
String name = getUserName(123).get(); // ← defeats the entire purpose
Or worse, they use Optional as a field type, a method parameter, or inside a collection — all of which the Optional Javadoc explicitly warns against.
What to do instead:
Optional belongs at the boundary — as a return type when absence is meaningful and you want to force the caller to handle it.
// Good — forces the caller to handle absence
getUserName(id).ifPresentOrElse(
name -> render(name),
() -> render("Anonymous")
);
If you're writing isPresent() followed by get(), you've completely bypassed the point.
2. Ignoring Records for simple data carriers
How many DTOs and POJOs in your codebase look like this?
public class UserDTO {
private final String name;
private final String email;
public UserDTO(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) { ... }
@Override public int hashCode() { ... }
@Override public String toString() { ... }
}
That's ~30 lines. Here it is as a Record (finalized in Java 16, standard since Java 21+):
public record UserDTO(String name, String email) {}
Done. Immutable. Has equals, hashCode, toString, and accessor methods — all generated. It also signals intent: this is a data carrier, not a service object.
In 2026, if you're not using Records for your DTOs, you're writing boilerplate the compiler already knows how to write for you.
3. Writing instanceof checks like it's 2010
Before Java 16, pattern matching for instanceof didn't exist. That excuse expired four years ago.
Old way:
if (shape instanceof Circle) {
Circle c = (Circle) shape;
return Math.PI * c.radius() * c.radius();
}
Modern Java:
if (shape instanceof Circle c) {
return Math.PI * c.radius() * c.radius();
}
And with Java 25/26, pattern matching now extends to all primitive types too (JEP 530, fourth preview in Java 26). You can match on int, long, double directly in switch expressions:
double result = switch (value) {
case int i -> i * 2.0;
case double d -> d * 1.5;
case String s -> Double.parseDouble(s);
};
Combine sealed classes with switch expressions and the compiler enforces exhaustiveness at compile time. Add a new subclass and forget to handle it? Won't compile. That's the kind of safety you used to need a framework for.
4. Using ThreadLocal in a world that has ScopedValues
This one costs you in subtle ways that only surface under load.
ThreadLocal has been the go-to for passing context (user sessions, request IDs, tracing info) down a call stack for 20+ years. The problem: its lifetime is unclear, it leaks in thread pools, and it's hazardous with virtual threads.
Java 25 finalized ScopedValue (JEP 487) — a proper replacement:
// Old: ThreadLocal — mutable, leaks, unclear lifetime
private static final ThreadLocal<User> CURRENT_USER = new ThreadLocal<>();
CURRENT_USER.set(user);
processRequest();
CURRENT_USER.remove(); // easy to forget
// New: ScopedValue — immutable, bounded, safe with virtual threads
private static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();
ScopedValue.where(CURRENT_USER, user).run(() -> processRequest());
// automatically cleaned up when the lambda exits
ScopedValue is immutable within its scope, its lifetime is strictly bounded by the runtime, and it composes cleanly with structured concurrency and virtual threads. If you're still reaching for ThreadLocal in new code in 2026, stop.
5. Spawning platform threads for everything concurrent
This one's a performance trap that's hard to see until you're at scale.
Classic pattern: you have 10,000 concurrent HTTP requests on a ThreadPoolExecutor. Each platform thread = ~1MB of memory. At 10K concurrent requests you're looking at ~10GB of heap just for threads, and throughput flatlines.
Virtual threads (finalized Java 21, now standard) change the equation:
// Old: fixed-size thread pool, expensive context switches
ExecutorService executor = Executors.newFixedThreadPool(200);
// New: virtual threads — JVM manages scheduling
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Virtual threads are cheap. Millions of them on a modest machine. You write normal blocking code; the JVM handles cooperative scheduling. No callback hell, no reactive chains, no .flatMap() soup.
The 2026 add-on: pair virtual threads with Structured Concurrency (still in preview as of Java 26, JEP 525). It makes concurrent subtasks feel like sequential code and guarantees clean shutdown:
try (var scope = StructuredTaskScope.open()) {
var user = scope.fork(() -> fetchUser(id));
var order = scope.fork(() -> fetchOrder(id));
scope.join();
return new Dashboard(user.get(), order.get());
}
If one subtask fails, the scope cancels the others automatically. No manual Future wrangling.
The caveat: virtual threads shine on I/O-bound workloads. CPU-bound tasks still benefit from platform threads. If you're using synchronized blocks heavily, use ReentrantLock instead to avoid pinning.
6. Skipping SequencedCollection for first/last element access
This one flies under the radar. Java 21 introduced SequencedCollection, SequencedSet, and SequencedMap — interfaces that unify first/last element access across collection types. It's been standard for two LTS releases and devs still aren't using it.
Before this, getting the last element of a list was embarrassing:
list.get(list.size() - 1); // verbose, brittle
((LinkedList<T>) list).getLast(); // only works if you know the concrete type
Now:
list.getFirst(); // works on any SequencedCollection
list.getLast();
list.reversed(); // returns a reversed view, no copying
Clean, safe, no index math. Update your collection-handling code.
7. Treating Stream as a one-size-fits-all tool
Streams are elegant. They're also overused to the point where devs write convoluted pipelines for things a simple for loop would express more clearly.
This is fine:
List<String> names = users.stream()
.filter(u -> u.getAge() > 18)
.sorted(Comparator.comparing(User::getName))
.map(User::getName)
.toList(); // .toList() is cleaner than .collect(Collectors.toList()) — use it
This is too much:
Optional<Integer> total = IntStream.range(0, list.size())
.filter(i -> list.get(i).isActive())
.mapToObj(i -> list.get(i).getValue())
.reduce(Integer::sum);
When this is clearer:
int total = 0;
for (var item : list) {
if (item.isActive()) total += item.getValue();
}
Streams are ideal for: transforming and filtering collections declaratively, parallel processing with parallelStream(), and composing pipelines that read naturally. They're not ideal for: stateful operations, index-aware iteration, or anything where a for loop communicates intent more clearly.
Also: stop writing .collect(Collectors.toList()). It's been .toList() since Java 16.
The Bottom Line
Java 25 is the LTS you should be targeting in 2026. Java 26 dropped March 17th with more pattern matching improvements, lazy constants, and Valhalla value classes inching toward production. The ecosystem has moved.
Here's the migration priority list:
| Old habit | Modern replacement | Since |
|---|---|---|
| Boilerplate DTO | record |
Java 16 |
instanceof cast |
Pattern matching | Java 16 |
ThreadLocal |
ScopedValue |
Java 25 (final) |
| Thread pool for I/O | Virtual threads | Java 21 (final) |
| Manual futures | Structured Concurrency | Java 25 (preview) |
| Index math on collections | SequencedCollection |
Java 21 |
.collect(Collectors.toList()) |
.toList() |
Java 16 |
The fastest path to cleaner Java in 2026 isn't a new framework. It's actually using what the language already ships.
What's the Java 25/26 feature that's changed your code the most? Drop it in the comments — genuinely curious what the community is shipping.
Follow me for more on modern Java, Rust, and building production AI systems on the JVM.
Tags: #java #programming #webdev #todayilearned
Top comments (0)