Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

Modern Java (Java 8 to 21)

Java has evolved significantly over the last decade. If you are still writing anonymous inner classes or checking for nulls with if (x != null), you are living in the past. “Modern Java” is concise, expressive, and functional.

1. Lambda Expressions (Java 8)

Lambdas allow you to treat code as data. You can pass functions as arguments to other functions. This is the foundation of functional programming in Java.

The Old Way vs. The New Way

// Before Java 8: Anonymous Inner Class (Verbose)
Runnable r = new Runnable() {
    @Override
    public void run() {
        System.out.println("Old way");
    }
};

// Java 8+: Lambda Expression (Concise)
Runnable r = () -> System.out.println("New way");

Sorting Example

Lambdas shine when passing behavior, like a comparator for sorting.
List<String> names = Arrays.asList("Bob", "Alice", "Charlie");

// Sort by length
Collections.sort(names, (a, b) -> a.length() - b.length());

Functional Interfaces

A Lambda can be used anywhere a Functional Interface is expected. A Functional Interface is simply an interface with one abstract method.
  • Predicate<T>: Takes a T, returns boolean. (Used for filtering).
  • Function<T, R>: Takes a T, returns R. (Used for transformation).
  • Consumer<T>: Takes a T, returns void. (Used for printing/saving).
  • Supplier<T>: Takes nothing, returns T. (Used for factories).

2. Stream API (Java 8)

The Stream API allows you to process collections of data in a declarative way. It abstracts away the “how” (loops, iterators) and lets you focus on the “what” (filter, map, reduce).
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6);

// Goal: Sum the squares of all even numbers
int sum = numbers.stream()
    .filter(n -> n % 2 == 0)       // Keep evens
    .mapToInt(n -> n * n)          // Square them
    .sum();                        // Sum result

System.out.println(sum); // 4 + 16 + 36 = 56
Why use Streams?
  1. Readability: The code reads like a sentence.
  2. Parallelism: You can switch to .parallelStream() to utilize multiple cores effortlessly (use with caution).
  3. No Side Effects: Encourages writing stateless functions.

3. Optional (Java 8)

NullPointerException is the “billion-dollar mistake”. Optional<T> is a container object which may or may not contain a non-null value. It forces you to think about the case where a value might be missing.
public Optional<String> findUser(int id) {
    if (id == 1) return Optional.of("Alice");
    return Optional.empty(); // Explicitly return "nothing"
}

// Usage
Optional<String> user = findUser(2);

// Safe handling
user.ifPresent(name -> System.out.println(name));

// Provide a default
String name = user.orElse("Unknown User");

// Throw if missing
String n = user.orElseThrow(() -> new RuntimeException("User not found"));

4. Local Variable Type Inference (Java 10)

Java is known for being verbose. var helps reduce that verbosity without sacrificing type safety. The compiler infers the type from the right-hand side.
// Explicit type
ArrayList<String> list = new ArrayList<String>();

// Inferred type
var list = new ArrayList<String>(); 
Use var when the type is obvious (e.g., constructors, return values from clearly named methods). Avoid it if it makes the code harder to understand (e.g., var x = getX()).

5. Text Blocks (Java 15)

Writing multi-line strings (like JSON, SQL, or HTML) used to be a nightmare of \n and +. Text Blocks solve this.
// Old way
String json = "{\n" +
              "  \"name\": \"Alice\",\n" +
              "  \"age\": 25\n" +
              "}";

// Text Block
String json = """
    {
        "name": "Alice",
        "age": 25
    }
    """;

6. Pattern Matching (Java 16+)

Pattern matching allows you to test an object against a structure and extract data from it in one go.

instanceof

No more casting after checking the type.
Object obj = "Hello";

// Before
if (obj instanceof String) {
    String s = (String) obj; // Redundant cast
    System.out.println(s.length());
}

// After
if (obj instanceof String s) {
    System.out.println(s.length()); // 's' is already a String
}

Switch Pattern Matching (Java 21)

You can switch on types!
String result = switch (obj) {
    case Integer i -> "It's an integer: " + i;
    case String s  -> "It's a string: " + s;
    case null      -> "It's null";
    default        -> "Unknown type";
};

Summary

Modern Java is not the verbose, boilerplate-heavy language of the early 2000s.
  • Lambdas & Streams: Functional programming style.
  • Optional: Null safety.
  • Records: Concise data classes.
  • Pattern Matching: Expressive control flow.
Embrace these features to write cleaner, safer, and more maintainable code.

Interview Deep-Dive

Strong Answer:
  • This is a common misconception. Before Java 8, you would assume lambdas are syntactic sugar for anonymous inner classes. They are not. Anonymous inner classes generate a separate .class file for each occurrence (e.g., MyClass$1.class), create a new object on every invocation, and carry a reference to the enclosing instance (which can prevent garbage collection). Lambdas avoid all of this.
  • Lambdas use invokedynamic (introduced in Java 7 for dynamic languages on the JVM). At the first invocation of a lambda, the invokedynamic instruction calls a bootstrap method (LambdaMetafactory.metafactory()), which generates a class at runtime using ASM bytecode generation. This generated class implements the target functional interface and captures the lambda body. Subsequent invocations reuse the generated class without regenerating it — the CallSite returned by the bootstrap method is cached.
  • The performance advantage: for non-capturing lambdas (lambdas that do not reference any variables from the enclosing scope), the JVM can return a singleton instance — no allocation at all, ever. For capturing lambdas (that reference local variables or this), the JVM creates a small object to hold the captured values, but this is still lighter than an anonymous inner class because there is no separate .class file loaded through the classloader.
  • The invokedynamic approach also gives the JVM freedom to optimize lambda allocation in future versions without changing the bytecode. The bytecode says “give me something that implements this functional interface with this behavior” and the JVM decides how to do it. In contrast, anonymous inner classes are committed to a specific implementation strategy (new object, new class) at compile time. This is why Oracle chose invokedynamic — it future-proofs the implementation.
Follow-up: Does the choice between a lambda and a method reference affect performance?
  • In most cases, no. A method reference like String::toUpperCase and a lambda like s -> s.toUpperCase() compile to nearly identical invokedynamic instructions. The JIT compiler inlines both equally well, and the generated functional interface implementation is equivalent.
  • The edge case where method references can be slightly more efficient: for non-capturing static method references (Integer::parseInt), the JVM can always use a singleton because there is nothing to capture. A lambda that does the same thing (s -> Integer.parseInt(s)) is also non-capturing and gets the same singleton treatment, so in practice the difference is zero.
  • Where the choice matters is readability and debugging. Method references produce slightly cleaner stack traces (the method name appears directly), while lambda stack traces include synthetic names like lambda$main$0. In production debugging, this matters more than any performance difference.
  • The one case where lambdas and method references diverge meaningfully: when the method reference is to an instance method and captures this. this::process captures a reference to this, which can prevent the enclosing object from being garbage collected. If you are registering callbacks that outlive the creating object, this is a subtle memory leak. The same applies to lambdas that reference this implicitly, but it is easier to miss with method references because the capture is less visible.
Strong Answer:
  • First, the ground rule: Optional should be used as a return type to communicate “this method may not have a result.” It should not be used as a field type, method parameter, or collection element type. Using it as a field adds 16 bytes of overhead per instance (the Optional object itself), and wrapping parameters in Optional forces callers into unnecessary ceremony. The JDK architects (Brian Goetz specifically) have stated this is the intended use.
  • My migration strategy: start at the API boundaries and work inward. Identify methods that return null to indicate “not found” — typically repository methods (findById), service lookups, and configuration getters. Change their return type from T to Optional<T>. This forces all callers to handle the absent case explicitly, converting potential NPEs from runtime surprises to compile-time errors. Do not change internal private methods unless they are widely called.
  • For the callers, replace if (result != null) chains with the Optional API: optional.map(x -> transform(x)).orElse(default) for value transformation with defaults, optional.ifPresent(x -> doSomething(x)) for conditional side effects, and optional.orElseThrow(() -> new NotFoundException()) for mandatory presence. Avoid optional.get() without isPresent() — it is just a NullPointerException with extra steps and defeats the purpose.
  • A critical anti-pattern to avoid during migration: Optional.ofNullable(x).ifPresent(...) as a replacement for if (x != null) inside method bodies. This adds object allocation overhead and is less readable than a null check for local variables. Optional shines at API boundaries, not as a general-purpose null-replacement tool inside methods.
Follow-up: What is the performance overhead of Optional, and when does it actually matter?
  • Each Optional is a heap-allocated object (12-byte header + 4-8 byte reference = 16-24 bytes). For methods called a few thousand times, this is negligible. For methods called millions of times in tight loops (data processing pipelines, serialization code, inner loops of algorithms), the allocation pressure adds up: more young gen GC pressure, more cache pollution.
  • The JIT compiler can sometimes eliminate Optional allocations via escape analysis and scalar replacement. If an Optional is created, its value is extracted, and the Optional object never escapes the method, the JIT replaces the allocation with stack variables. In practice, this optimization is fragile — it depends on inlining depth, method size, and whether the Optional flows through a call that the JIT cannot inline.
  • Primitive specializations avoid boxing overhead: OptionalInt, OptionalLong, and OptionalDouble store the primitive value directly without wrapping it in an Integer/Long/Double first. Use these when returning optional primitives from stream operations or computation methods.
  • My practical rule: use Optional freely at API boundaries (method return types, especially public APIs) where correctness and expressiveness matter more than nanoseconds. Avoid it in hot paths that are demonstrably allocation-sensitive (benchmark first, do not assume). In 99% of business applications, the overhead is irrelevant compared to the bug-prevention benefit.
Strong Answer:
  • Pattern matching in Java allows you to test a value against a pattern and extract components in a single expression. The simplest form is instanceof pattern matching (Java 16): if (obj instanceof String s) both tests and casts in one step, eliminating the redundant explicit cast. Switch pattern matching (Java 21) extends this to switch expressions and statements, enabling type-based dispatch with exhaustiveness checking.
  • Sealed classes (Java 17) restrict which classes can extend or implement a type. Combined with switch pattern matching, the compiler can verify that you have handled every possible subtype. This is the Java equivalent of algebraic data types (ADTs) or tagged unions from ML/Haskell/Rust. For example: sealed interface Shape permits Circle, Rectangle, Triangle. A switch over Shape with cases for all three types needs no default — the compiler knows the match is exhaustive. If you add a new permitted type (Pentagon), every switch over Shape becomes a compile error until you add the new case. This is enormously powerful for correctness.
  • Record patterns (Java 21) add deconstruction: case Circle(double radius) -> Math.PI * radius * radius both matches the type and extracts the components in one step. Combined with nesting, you can match deep structures: case Pair(Circle(var r), Rectangle(var w, var h)) deconstructs a nested structure in a single pattern. This eliminates entire classes of visitor pattern boilerplate.
  • The real-world application: compilers, interpreters, and expression evaluators. An AST defined as sealed interface Expr permits Literal, BinaryOp, UnaryOp, FunctionCall with record patterns in a switch lets you write an evaluator that is: exhaustive (compiler-checked), concise (no visitor classes), and safe (adding a new AST node forces updates everywhere). Before sealed classes and pattern matching, this required the Visitor pattern — typically 3-5x more code with weaker compile-time guarantees.
Follow-up: What are guarded patterns, and how do they replace complex if-else chains inside switch cases?
  • Guarded patterns (Java 21) add a when clause to a pattern: case String s when s.length() > 10 -> handleLongString(s). This combines type matching, variable binding, and a boolean condition in a single case label. Without guards, you would match the type and then add an if inside the case body, or worse, use multiple instanceof checks in an if-else chain.
  • The power is in combining guards with sealed types and records. For example: case Account a when a.balance() < 0 -> handleOverdrawn(a); case Account a -> handleNormal(a);. The first case matches only overdrawn accounts, the second catches the rest. The ordering matters — Java evaluates cases top-to-bottom and takes the first match, so more specific guarded patterns must come before less specific ones.
  • Guards replace what used to require the Strategy pattern or complex polymorphic dispatch in many codebases. Instead of creating OverdrawnAccountHandler and NormalAccountHandler classes, you express the logic inline with guarded patterns. For simple dispatch logic, this is dramatically more readable. For complex behavior, the Strategy pattern still has its place, but guards eliminate the ceremony for straightforward cases.
  • A practical caution: the compiler cannot verify exhaustiveness of guard conditions (it cannot prove that your when clauses cover all possible values of a type). So even with a sealed type, if all your cases use guards, you may still need a catch-all case. The compiler enforces that all types are handled, but within a type, the guards are your responsibility.
Strong Answer:
  • CompletableFuture (Java 8) enables non-blocking async programming via callback chains: supplyAsync().thenApply().thenCompose().thenAccept(). The code is non-blocking — no thread is blocked waiting for I/O. But the programming model is fundamentally callback-based. Error handling requires exceptionally() or handle(). Combining multiple futures requires allOf(), anyOf(), or thenCombine(). Complex control flow (loops, conditionals over async results) becomes deeply nested and hard to read. Debugging is painful because stack traces span multiple lambda invocations on different threads.
  • Virtual threads (Java 21) let you write sequential, blocking code: var user = userService.getUser(id); var orders = orderService.getOrders(user.id()); return buildResponse(user, orders);. Each blocking call suspends the virtual thread without blocking the carrier. The code looks like synchronous Java, is debuggable with normal stack traces, uses standard try-catch for error handling, and is straightforward to reason about. The JVM handles the async machinery transparently.
  • CompletableFuture wins when: you need explicit control over concurrency composition (e.g., “fire off 5 requests in parallel and combine the results” is elegant with allOf or thenCombine), you are stuck on Java 8-20 and cannot use virtual threads, or your libraries are already designed around CompletableFuture (many AWS SDK v2 methods return CompletableFuture).
  • Virtual threads win when: the logic is primarily sequential with I/O calls (the vast majority of business logic), you want simple error handling and debugging, or you are building a new service and can target Java 21+. For the common pattern of “fetch data from service A, use it to fetch from service B, transform, and return,” virtual threads produce code that is half the length and 10x more readable than the equivalent CompletableFuture chain.
  • The nuance: you can combine both. Use virtual threads for the overall request handling, and use CompletableFuture/StructuredTaskScope for fan-out within a request. Java 21’s StructuredTaskScope provides a structured way to fork subtasks and join results, which is more natural than CompletableFuture.allOf() in the virtual thread context.
Follow-up: What is Structured Concurrency, and why does it matter for virtual threads?
  • Structured Concurrency (preview in Java 21, StructuredTaskScope) ensures that the lifetime of concurrent tasks is bounded by a syntactic block, similar to how structured programming bounded control flow with blocks. When you open a StructuredTaskScope, fork subtasks, and close the scope, the scope guarantees that all subtasks complete (or are cancelled) before execution continues past the scope.
  • Without structured concurrency, virtual threads can leak just like threads always could. You start 10 virtual threads to call 10 microservices, one returns an error, so you want to cancel the other 9 and return the error. With raw virtual threads, you need to manually track and interrupt all 9. With StructuredTaskScope.ShutdownOnFailure, the scope automatically cancels surviving subtasks when any subtask fails. With ShutdownOnSuccess, it cancels the rest when the first subtask succeeds (useful for hedged requests).
  • The deeper benefit: structured concurrency makes concurrent code traceable. A virtual thread forked inside a scope is a child of the scope. Thread dumps and monitoring tools can show the task hierarchy, making it possible to understand which request spawned which subtasks. This is a step toward making concurrent Java as debuggable as sequential Java.
  • The practical analogy: structured concurrency is to Thread.start() what try-with-resources is to manual close() calls. It moves resource lifecycle management (in this case, task lifecycle) from manual tracking to automatic scope-based management. It does not add new capabilities — it prevents the most common class of concurrency bugs: leaked threads, unhandled subtask exceptions, and orphaned work.
Strong Answer:
  • Stream.toList() (Java 16) returns an unmodifiable list. Collectors.toList() returns a modifiable ArrayList (though the specification does not guarantee mutability — it says “no guarantees on the type, mutability, serializability, or thread-safety of the List returned”). In practice, every JDK implementation returns a mutable ArrayList from Collectors.toList(), so decades of code rely on this behavior.
  • The migration trap: a developer sees stream.collect(Collectors.toList()) and “simplifies” it to stream.toList(). The code compiles fine. Tests that only read from the list pass. But production code downstream that calls result.add(extraElement) or Collections.sort(result) now throws UnsupportedOperationException. This is a behavioral change disguised as a refactoring, and I have seen it cause production incidents in code that was otherwise well-tested.
  • The other difference: Stream.toList() has slightly better performance characteristics because the JVM knows the size from the stream’s size hint and can allocate the exact-sized array, while Collectors.toList() builds an ArrayList that may resize during collection. For large streams, this can mean fewer allocations. The unmodifiable list also has no need for the excess capacity that ArrayList maintains.
  • The null handling differs too. Stream.toList() allows null elements in the resulting list. List.of() and List.copyOf() throw NullPointerException if any element is null. This is an inconsistency in the unmodifiable collections API that trips people up. If your stream might produce nulls and you want an unmodifiable list, Stream.toList() works but List.copyOf(stream.collect(Collectors.toList())) does not.
Follow-up: What is the correct way to handle this in a codebase where some callers need mutable results and others do not?
  • The cleanest approach: have the producing method return an unmodifiable list (stream.toList() or Collections.unmodifiableList()), and let callers that need mutability create their own copy: new ArrayList<>(immutableResult). This follows the principle of least privilege — the default is immutable, and mutability is explicitly opted into.
  • If you are in a migration and cannot change all callers at once, use stream.collect(Collectors.toCollection(ArrayList::new)) to explicitly guarantee a mutable ArrayList. This is verbose but unambiguous — it communicates intent and will never break regardless of future JDK changes to Collectors.toList() behavior.
  • For library code: if your method’s contract promises a mutable list, document it and use Collectors.toCollection(ArrayList::new). If your method’s contract is just “returns a list” without mutability promises, switch to stream.toList() and let the callers deal with it. The sooner you make the contract explicit, the fewer surprises you get during upgrades.
  • A broader lesson: this is an example of why you should program to the List interface and not assume implementation details like mutability. Code that calls result.add() on a returned List is relying on an undocumented behavior. The migration pain is a symptom of this — stream.toList() just made the implicit contract explicit and broke code that was already fragile.