DEV Community

Ashish Sharda
Ashish Sharda

Posted on

7 Things Java Devs Still Get Wrong in 2026 (Java 25/26 Edition)

Java 25 is the new LTS. Java 26 just dropped. Are you still writing Java 8?

Modern Java has moved on. The question is: has your codebase?

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);
}
Enter fullscreen mode Exit fullscreen mode

Then they chain .get() on it anyway:

String name = getUserName(123).get(); // ← defeats the entire purpose
Enter fullscreen mode Exit fullscreen mode

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")
);
Enter fullscreen mode Exit fullscreen mode

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() { ... }
}
Enter fullscreen mode Exit fullscreen mode

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) {}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

Modern Java:

if (shape instanceof Circle c) {
    return Math.PI * c.radius() * c.radius();
}
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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());
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Now:

list.getFirst();   // works on any SequencedCollection
list.getLast();
list.reversed();   // returns a reversed view, no copying
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

When this is clearer:

int total = 0;
for (var item : list) {
    if (item.isActive()) total += item.getValue();
}
Enter fullscreen mode Exit fullscreen mode

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)