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 withif (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
Sorting Example
Lambdas shine when passing behavior, like a comparator for sorting.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 aT, returnsboolean. (Used for filtering).Function<T, R>: Takes aT, returnsR. (Used for transformation).Consumer<T>: Takes aT, returnsvoid. (Used for printing/saving).Supplier<T>: Takes nothing, returnsT. (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).- Readability: The code reads like a sentence.
- Parallelism: You can switch to
.parallelStream()to utilize multiple cores effortlessly (use with caution). - 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.
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.
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.
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.
Switch Pattern Matching (Java 21)
You can switch on types!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.
Interview Deep-Dive
How are lambda expressions implemented under the hood? They are not anonymous inner classes -- what is the actual mechanism?
How are lambda expressions implemented under the hood? They are not anonymous inner classes -- what is the actual mechanism?
- 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
.classfile 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, theinvokedynamicinstruction calls a bootstrap method (LambdaMetafactory.metafactory()), which generates a class at runtime usingASMbytecode generation. This generated class implements the target functional interface and captures the lambda body. Subsequent invocations reuse the generated class without regenerating it — theCallSitereturned 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.classfile loaded through the classloader. - The
invokedynamicapproach 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 choseinvokedynamic— it future-proofs the implementation.
- In most cases, no. A method reference like
String::toUpperCaseand a lambda likes -> s.toUpperCase()compile to nearly identicalinvokedynamicinstructions. 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::processcaptures a reference tothis, 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 referencethisimplicitly, but it is easier to miss with method references because the capture is less visible.
You have a legacy codebase full of null checks. How would you introduce Optional systematically without breaking everything?
You have a legacy codebase full of null checks. How would you introduce Optional systematically without breaking everything?
- First, the ground rule:
Optionalshould 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 (theOptionalobject itself), and wrapping parameters inOptionalforces 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
nullto indicate “not found” — typically repository methods (findById), service lookups, and configuration getters. Change their return type fromTtoOptional<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 theOptionalAPI:optional.map(x -> transform(x)).orElse(default)for value transformation with defaults,optional.ifPresent(x -> doSomething(x))for conditional side effects, andoptional.orElseThrow(() -> new NotFoundException())for mandatory presence. Avoidoptional.get()withoutisPresent()— 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 forif (x != null)inside method bodies. This adds object allocation overhead and is less readable than a null check for local variables.Optionalshines at API boundaries, not as a general-purpose null-replacement tool inside methods.
- Each
Optionalis 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
Optionalallocations via escape analysis and scalar replacement. If anOptionalis created, its value is extracted, and theOptionalobject 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 theOptionalflows through a call that the JIT cannot inline. - Primitive specializations avoid boxing overhead:
OptionalInt,OptionalLong, andOptionalDoublestore the primitive value directly without wrapping it in anInteger/Long/Doublefirst. Use these when returning optional primitives from stream operations or computation methods. - My practical rule: use
Optionalfreely 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.
Explain pattern matching in Java 21. How does it interact with sealed classes, and why is this combination powerful?
Explain pattern matching in Java 21. How does it interact with sealed classes, and why is this combination powerful?
- Pattern matching in Java allows you to test a value against a pattern and extract components in a single expression. The simplest form is
instanceofpattern 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 toswitchexpressions 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 overShapewith cases for all three types needs nodefault— the compiler knows the match is exhaustive. If you add a new permitted type (Pentagon), every switch overShapebecomes 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 * radiusboth 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, FunctionCallwith 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.
- Guarded patterns (Java 21) add a
whenclause 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 anifinside the case body, or worse, use multipleinstanceofchecks 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
OverdrawnAccountHandlerandNormalAccountHandlerclasses, 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
whenclauses 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.
Compare CompletableFuture pipelines with virtual threads for async programming. When does each approach win?
Compare CompletableFuture pipelines with virtual threads for async programming. When does each approach win?
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 requiresexceptionally()orhandle(). Combining multiple futures requiresallOf(),anyOf(), orthenCombine(). 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. CompletableFuturewins when: you need explicit control over concurrency composition (e.g., “fire off 5 requests in parallel and combine the results” is elegant withallOforthenCombine), you are stuck on Java 8-20 and cannot use virtual threads, or your libraries are already designed aroundCompletableFuture(many AWS SDK v2 methods returnCompletableFuture).- 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
CompletableFuturechain. - The nuance: you can combine both. Use virtual threads for the overall request handling, and use
CompletableFuture/StructuredTaskScopefor fan-out within a request. Java 21’sStructuredTaskScopeprovides a structured way to fork subtasks and join results, which is more natural thanCompletableFuture.allOf()in the virtual thread context.
- 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 aStructuredTaskScope, 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. WithShutdownOnSuccess, 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 manualclose()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.
What is the Stream.toList() vs Collectors.toList() difference that catches people in Java 16+ migrations?
What is the Stream.toList() vs Collectors.toList() difference that catches people in Java 16+ migrations?
Stream.toList()(Java 16) returns an unmodifiable list.Collectors.toList()returns a modifiableArrayList(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 mutableArrayListfromCollectors.toList(), so decades of code rely on this behavior.- The migration trap: a developer sees
stream.collect(Collectors.toList())and “simplifies” it tostream.toList(). The code compiles fine. Tests that only read from the list pass. But production code downstream that callsresult.add(extraElement)orCollections.sort(result)now throwsUnsupportedOperationException. 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, whileCollectors.toList()builds anArrayListthat may resize during collection. For large streams, this can mean fewer allocations. The unmodifiable list also has no need for the excess capacity thatArrayListmaintains. - The null handling differs too.
Stream.toList()allows null elements in the resulting list.List.of()andList.copyOf()throwNullPointerExceptionif 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 butList.copyOf(stream.collect(Collectors.toList()))does not.
- The cleanest approach: have the producing method return an unmodifiable list (
stream.toList()orCollections.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 mutableArrayList. This is verbose but unambiguous — it communicates intent and will never break regardless of future JDK changes toCollectors.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 tostream.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
Listinterface and not assume implementation details like mutability. Code that callsresult.add()on a returnedListis 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.