DEV Community

Dimitris Kyrkos
Dimitris Kyrkos

Posted on

5 More Advanced Java Tips That Senior Engineers Actually Use

Hey everyone.

Continuing with the series, here's part two: five more patterns from modern Java 21+ that are showing up in production systems and changing how senior engineers write code.

Same rules as before. These are from real codebases, not textbook examples. If you get all five on the first read, you're ahead of most Java developers working today.

1. Use Structured Concurrency Instead of CompletableFuture Fan-Out

Every Java developer has written the CompletableFuture fan-out pattern: fire off multiple async calls, combine the results, handle errors. It works, but it has a structural problem that surfaces under real load. If one task fails, the others keep running. If the parent thread gets cancelled, the child futures don't know about it. You end up leaking work, wasting resources, and writing defensive cleanup code that's hard to test.

Structured Concurrency (preview in Java 21, stable in Java 24) fixes this by treating a group of concurrent tasks as a single unit of work with a defined lifetime.

// The CompletableFuture way: tasks are independent, cleanup is manual
CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(() -> fetchUser(id));
CompletableFuture<List<Order>> ordersFuture = CompletableFuture.supplyAsync(() -> fetchOrders(id));
CompletableFuture<CreditScore> creditFuture = CompletableFuture.supplyAsync(() -> fetchCredit(id));

// If fetchOrders throws, fetchUser and fetchCredit keep running
// If we time out here, all three futures are still in flight somewhere
User user = userFuture.get(5, TimeUnit.SECONDS);
List<Order> orders = ordersFuture.get(5, TimeUnit.SECONDS);
CreditScore credit = creditFuture.get(5, TimeUnit.SECONDS);
Enter fullscreen mode Exit fullscreen mode
// The structured way: all tasks share a lifetime, failure cancels siblings
UserProfile buildProfile(long id) throws Exception {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        Subtask<User> user = scope.fork(() -> fetchUser(id));
        Subtask<List<Order>> orders = scope.fork(() -> fetchOrders(id));
        Subtask<CreditScore> credit = scope.fork(() -> fetchCredit(id));

        scope.joinUntil(Instant.now().plusSeconds(5));
        scope.throwIfFailed();

        return new UserProfile(user.get(), orders.get(), credit.get());
    }
    // When scope closes: all tasks are guaranteed complete or cancelled.
    // If fetchOrders threw: fetchUser and fetchCredit get cancelled immediately.
    // If we hit the 5s deadline: everything cancels, no leaked work.
}
Enter fullscreen mode Exit fullscreen mode

The architectural difference: with CompletableFuture, concurrency is unstructured. Tasks float independently and you're responsible for tracking their lifetimes. With StructuredTaskScope, concurrency is scoped. The parent owns the children, failure propagates, cancellation propagates, and when the try-with-resources block exits, nothing is left running.

There's also ShutdownOnSuccess for race patterns where you want the first successful result and cancel the rest:

// First successful response wins, all others cancel
<T> T firstSuccess(List<Callable<T>> tasks) throws Exception {
    try (var scope = new StructuredTaskScope.ShutdownOnSuccess<T>()) {
        for (var task : tasks) scope.fork(task);
        scope.join();
        return scope.result();
    }
}

// Use case: query three replicas, take the fastest response
var result = firstSuccess(List.of(
    () -> queryReplica("us-east"),
    () -> queryReplica("eu-west"),
    () -> queryReplica("ap-south")
));
Enter fullscreen mode Exit fullscreen mode

2. Use Record Patterns for Nested Destructuring

Tip 2 from the first post covered sealed classes with pattern matching on a single level. Record patterns (Java 21+) take this further by letting you destructure nested records directly inside the switch expression. Instead of matching a type and then pulling out fields, you match the structure itself.

// Domain model: sealed hierarchy with nested records
sealed interface ShippingEvent permits Dispatched, InTransit, Delivered, Failed {}
record Dispatched(OrderId order, Warehouse origin) implements ShippingEvent {}
record InTransit(OrderId order, Location current, Carrier carrier) implements ShippingEvent {}
record Delivered(OrderId order, Location destination, Instant timestamp) implements ShippingEvent {}
record Failed(OrderId order, FailureReason reason) implements ShippingEvent {}

record OrderId(String value) {}
record Location(double lat, double lon) {}
record Warehouse(String code, Location location) {}
record Carrier(String name, String trackingUrl) {}
sealed interface FailureReason permits AddressInvalid, DamagedInTransit, CustomsHold {}
record AddressInvalid(String detail) implements FailureReason {}
record DamagedInTransit(String inspectionId) implements FailureReason {}
record CustomsHold(String referenceNumber) implements FailureReason {}
Enter fullscreen mode Exit fullscreen mode
// Without record patterns: match the type, then extract fields manually
String describe(ShippingEvent event) {
    return switch (event) {
        case Failed f -> {
            if (f.reason() instanceof AddressInvalid a) {
                yield "Order " + f.order().value() + " failed: bad address — " + a.detail();
            } else if (f.reason() instanceof CustomsHold c) {
                yield "Order " + f.order().value() + " held at customs: " + c.referenceNumber();
            } else {
                yield "Order " + f.order().value() + " failed";
            }
        }
        default -> "...";
    };
}
Enter fullscreen mode Exit fullscreen mode
// With record patterns: destructure the entire nested structure in one line
String describe(ShippingEvent event) {
    return switch (event) {
        case Dispatched(var id, Warehouse(var code, _))
            -> "Order " + id.value() + " dispatched from warehouse " + code;

        case InTransit(var id, Location(var lat, var lon), Carrier(var name, var url))
            -> "Order " + id.value() + " in transit at [" + lat + "," + lon + "] via " + name;

        case Delivered(var id, _, var time)
            -> "Order " + id.value() + " delivered at " + time;

        case Failed(var id, AddressInvalid(var detail))
            -> "Order " + id.value() + " failed: bad address — " + detail;

        case Failed(var id, DamagedInTransit(var inspectionId))
            -> "Order " + id.value() + " damaged, inspection: " + inspectionId;

        case Failed(var id, CustomsHold(var ref))
            -> "Order " + id.value() + " customs hold: " + ref;
    };
}
Enter fullscreen mode Exit fullscreen mode

The _ (unnamed pattern, Java 22+) ignores fields you don't need without creating throwaway variables. The nested destructuring is exhaustive: add a new FailureReason subtype and every switch that destructures Failed will become a compile error until you handle it.

This is where sealed types plus records plus record patterns combine into something genuinely new. You're not just switching on types. You're pattern matching against data shapes, like how you'd match in Haskell or Scala, but with full compile-time exhaustiveness checking and zero library dependencies.

3. Sequenced Collections: Stop Guessing How to Get the First and Last Element

Before Java 21, getting the first or last element of a collection was embarrassingly inconsistent. LinkedHashMap had no way to access the last entry without iterating the entire thing. SortedSet had first() and last() but List didn't. Deque had getFirst() and getLast() but those methods didn't exist on List. Getting a reversed view of a Set required conversion to a List.

Java 21 introduced SequencedCollection, SequencedSet, and SequencedMap as new interfaces that unify encounter-order operations across all collection types that have a defined order.

// Before Java 21: inconsistent, type-specific hacks
List<String> list = List.of("a", "b", "c");
String first = list.get(0);
String last = list.get(list.size() - 1);

LinkedHashSet<String> set = new LinkedHashSet<>(List.of("x", "y", "z"));
String firstOfSet = set.iterator().next();  // no better way
// last of set? iterate the whole thing

LinkedHashMap<String, Integer> map = new LinkedHashMap<>();
map.put("alpha", 1); map.put("beta", 2); map.put("gamma", 3);
// first entry? map.entrySet().iterator().next()
// last entry? no clean way without iterating all entries
Enter fullscreen mode Exit fullscreen mode
// Java 21+: uniform interface across List, LinkedHashSet, SortedSet, LinkedHashMap, etc.
SequencedCollection<String> list = List.of("a", "b", "c");
list.getFirst();    // "a"
list.getLast();     // "c"
list.reversed();    // reversed view: ["c", "b", "a"] — no copy, just a view

SequencedSet<String> set = new LinkedHashSet<>(List.of("x", "y", "z"));
set.getFirst();     // "x"
set.getLast();      // "z"
set.reversed();     // reversed view

SequencedMap<String, Integer> map = new LinkedHashMap<>();
map.put("alpha", 1); map.put("beta", 2); map.put("gamma", 3);
map.firstEntry();   // alpha=1
map.lastEntry();    // gamma=3
map.reversed();     // reversed view of the entire map

map.putFirst("zero", 0);   // insert at the beginning
map.putLast("delta", 4);   // insert at the end
Enter fullscreen mode Exit fullscreen mode

Why this matters more than it looks: reversed() returns a view, not a copy. It's O(1) and backed by the original collection. You can pass a reversed view to any method that accepts a SequencedCollection and it reads elements in reverse order without allocating anything. This is especially useful when you're processing event logs, audit trails, or time-ordered data where "newest first" and "oldest first" are both common access patterns on the same dataset.

// Practical example: processing the last N events from an ordered set
SequencedCollection<AuditEvent> events = getAuditLog();

// Five most recent events, no copy, no subList gymnastics
events.reversed().stream()
    .limit(5)
    .forEach(this::processEvent);
Enter fullscreen mode Exit fullscreen mode

The interface hierarchy slots into the existing collections framework cleanly. List implements SequencedCollection. LinkedHashSet and SortedSet implement SequencedSet. LinkedHashMap and SortedMap implement SequencedMap. You don't need to change your collection types to get the new methods.

4. Foreign Function and Memory API: Replace Unsafe and JNI With Something That Actually Works

For decades, Java developers who needed native memory access or native function calls had two bad options. sun.misc.Unsafe gave you raw memory access but was unsupported, undocumented, and could crash the JVM with no safety net. JNI let you call native functions but required writing C boilerplate, compiling platform-specific binaries, and debugging segfaults with zero help from the Java toolchain.

The Foreign Function and Memory API (Project Panama, stable in Java 22+) replaces both with a safe, performant, pure-Java alternative.

// Allocating and using off-heap memory safely
// Use case: building a high-performance buffer for network I/O

try (Arena arena = Arena.ofConfined()) {
    // Allocate 1MB of off-heap memory, automatically freed when arena closes
    MemorySegment buffer = arena.allocate(1_048_576);

    // Write structured data using layout-based access
    MemoryLayout layout = MemoryLayout.structLayout(
        ValueLayout.JAVA_INT.withName("id"),
        ValueLayout.JAVA_LONG.withName("timestamp"),
        ValueLayout.JAVA_DOUBLE.withName("value")
    );

    VarHandle idHandle = layout.varHandle(MemoryLayout.PathElement.groupElement("id"));
    VarHandle tsHandle = layout.varHandle(MemoryLayout.PathElement.groupElement("timestamp"));
    VarHandle valHandle = layout.varHandle(MemoryLayout.PathElement.groupElement("value"));

    idHandle.set(buffer, 0L, 42);
    tsHandle.set(buffer, 0L, System.nanoTime());
    valHandle.set(buffer, 0L, 3.14159);

    int id = (int) idHandle.get(buffer, 0L);          // 42
    double value = (double) valHandle.get(buffer, 0L); // 3.14159
}
// Arena closed: all off-heap memory is freed. No manual deallocation, no leaks.
Enter fullscreen mode Exit fullscreen mode
// Calling a native C function from pure Java — no JNI, no C boilerplate
// Example: calling strlen from libc

Linker linker = Linker.nativeLinker();
SymbolLookup stdlib = linker.defaultLookup();

MethodHandle strlen = linker.downcallHandle(
    stdlib.find("strlen").orElseThrow(),
    FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.ADDRESS)
);

try (Arena arena = Arena.ofConfined()) {
    MemorySegment cString = arena.allocateFrom("Hello from Panama!");
    long length = (long) strlen.invoke(cString);  // 18
}
Enter fullscreen mode Exit fullscreen mode

Why this matters for production systems: any Java application that works with large datasets, memory-mapped files, native libraries (OpenSSL, SQLite, SIMD operations), or custom network protocols used to require Unsafe hacks or JNI. Panama gives you the same performance with deterministic deallocation (via Arena), bounds checking (the JVM catches out-of-bounds access on MemorySegments), and no native compilation step. Your entire build stays pure Java.

The Arena model is the key design decision. Confined arenas are single-threaded and automatically freed on close. Shared arenas allow multi-threaded access. Auto arenas use garbage collection. Global arenas live for the process lifetime. You pick the lifetime model that matches your use case and the API enforces it.

5. Build Custom Stream Operations with Gatherers

Java streams have had a gap since they were introduced in Java 8: you can create custom terminal operations (Collectors), but there's no equivalent for custom intermediate operations. If you needed a "sliding window," a "take while distinct," or a "batch into groups of N," you either wrote ugly hacks with reduce and mutable state or dropped out of the stream pipeline entirely.

Gatherers (stable in Java 24) fill that gap. They're the intermediate operation equivalent of Collectors.

// Problem: batch a stream of events into groups of N for bulk processing
// Before Gatherers: ugly, stateful, breaks the stream paradigm

List<Event> events = getEvents();
List<List<Event>> batches = new ArrayList<>();
for (int i = 0; i < events.size(); i += 100) {
    batches.add(events.subList(i, Math.min(i + 100, events.size())));
}
batches.forEach(this::bulkInsert);
Enter fullscreen mode Exit fullscreen mode
// With Gatherers: a clean intermediate operation inside the stream pipeline

events.stream()
    .gather(Gatherers.windowFixed(100))
    .forEach(this::bulkInsert);
Enter fullscreen mode Exit fullscreen mode

Gatherers.windowFixed(n) is one of the built-in gatherers. windowSliding(n) gives you overlapping windows. But the real power is building your own:

// Custom gatherer: deduplicate consecutive elements
// [a, a, b, b, b, c, a, a] → [a, b, c, a]
static <T> Gatherer<T, ?, T> deduplicateConsecutive() {
    return Gatherer.ofSequential(
        // State: the last element seen
        () -> new Object[]{ null, Boolean.FALSE },  // [lastValue, initialized]
        // Integrator: emit only when the value changes
        (state, element, downstream) -> {
            if (!(boolean) state[1] || !Objects.equals(state[0], element)) {
                state[0] = element;
                state[1] = Boolean.TRUE;
                return downstream.push(element);
            }
            return true;
        }
    );
}

// Usage
List.of("a", "a", "b", "b", "b", "c", "a", "a")
    .stream()
    .gather(deduplicateConsecutive())
    .toList();
// Result: [a, b, c, a]
Enter fullscreen mode Exit fullscreen mode
// Custom gatherer: emit a running average over a sliding window
static Gatherer<Double, ?, Double> movingAverage(int windowSize) {
    return Gatherer.ofSequential(
        () -> new ArrayDeque<Double>(windowSize),
        (window, element, downstream) -> {
            window.addLast(element);
            if (window.size() > windowSize) window.removeFirst();
            if (window.size() == windowSize) {
                double avg = window.stream().mapToDouble(Double::doubleValue).average().orElse(0);
                return downstream.push(avg);
            }
            return true;
        }
    );
}

// Usage: smooth noisy sensor data
sensorReadings.stream()
    .gather(movingAverage(10))
    .forEach(this::recordSmoothedReading);
Enter fullscreen mode Exit fullscreen mode

Why this matters: Gatherers complete the stream API's extensibility story. Collectors let you define how a stream ends. Gatherers let you define what happens in the middle. Stateful intermediate operations that used to require dropping out of streams (or writing unmaintainable reduce calls) now compose cleanly inside the pipeline. For data processing, ETL pipelines, event stream processing, and anything involving windowing or batching, this is a significant improvement in both readability and correctness.

Final Thought

These five patterns build on the foundations from part one. Structured Concurrency pairs naturally with Virtual Threads (part one, tip 1) because StructuredTaskScope was designed for virtual-thread-heavy workloads. Record Patterns deepen the Sealed Classes and Pattern Matching story (part one, tip 2) by adding nested destructuring. Panama replaces the exact use cases where developers historically reached for Unsafe or MethodHandles (part one, tip 4) at the native boundary.

Modern Java isn't just adding features. It's building a coherent system where concurrency, domain modeling, data access, and stream processing all fit together. The developers who see these connections across features are the ones writing the most maintainable, performant code in the ecosystem right now.

Top comments (0)