6 Modern Java (8–21) — Architect-Level Deep Dive
6.1 Lambda Expressions and Functional Interfaces
Functional interface — an interface with exactly one abstract method (SAM — Single Abstract Method); may have any number of default/static methods without affecting that count.
@FunctionalInterface
interface Calculator {
int calculate(int a, int b); // the single abstract method
default Calculator andThen(Calculator other) { /* ... */ } // allowed
}
@FunctionalInterface isn’t required by the compiler but is best practice — it makes the intent explicit and causes a compile error if a second abstract method is accidentally added, catching a design regression early.
Core built-in functional interfaces (java.util.function):
| Interface | Signature | Use |
|---|---|---|
Function<T,R> |
R apply(T t) |
Transform input to output |
Consumer<T> |
void accept(T t) |
Side-effecting operation, no return |
Supplier<T> |
T get() |
Lazy value production, no input |
Predicate<T> |
boolean test(T t) |
Boolean-valued test |
BiFunction<T,U,R> |
R apply(T t, U u) |
Two-arg transform |
UnaryOperator<T> |
T apply(T t) |
Function<T,T> specialization |
BinaryOperator<T> |
T apply(T t1, T t2) |
BiFunction<T,T,T> specialization, used in reduce() |
Lambda syntax and variable capture:
Function<Integer,Integer> square = x -> x * x;
Function<Integer,Integer> add = (Integer x) -> { return x + 1; }; // explicit type + block body
- Lambdas capture effectively final local variables from the enclosing scope — the captured variable’s reference (or value, for primitives) is fixed at lambda-creation time; attempting to reassign a captured local after lambda creation is a compile error. This is a deliberate design choice avoiding the data-race ambiguity that mutable capture would introduce across potentially-different threads executing the lambda later.
- Unlike anonymous inner classes, lambdas don’t introduce a new scope for
this—thisinside a lambda refers to the enclosing instance, not the lambda itself (a frequent point of confusion when porting old anonymous-class code to lambdas).
Method references — four forms:
String::toUpperCase // unbound instance method reference: (s) -> s.toUpperCase()
list::add // bound instance method reference: (x) -> list.add(x)
Integer::parseInt // static method reference
ArrayList::new // constructor reference
Why this matters architecturally: Functional interfaces + lambdas are the foundation enabling Stream API, CompletableFuture composition, and the Strategy/Command patterns to be expressed without verbose anonymous-class boilerplate — this is the single biggest API-design shift from Java 7→8 and underlies nearly everything else in this module.
6.2 Stream API (Intermediate vs Terminal Operations, Lazy Evaluation)
Pipeline structure: Source → zero or more intermediate operations → exactly one terminal operation.
long count = list.stream()
.filter(x -> x > 10) // intermediate — returns Stream<T>
.map(x -> x * 2) // intermediate — returns Stream<R>
.distinct() // intermediate
.count(); // terminal — triggers actual execution, returns non-stream result
Intermediate operations (filter, map, sorted, distinct, limit, skip, peek, flatMap) are lazy — calling them builds up a pipeline definition but executes nothing. Nothing actually runs until a terminal operation (collect, forEach, reduce, count, sum, anyMatch, findFirst, toArray) is invoked.
Why laziness matters architecturally:
- Single-pass fusion — the entire pipeline processes each element through all stages before moving to the next element (rather than materializing intermediate collections at each stage), minimizing memory overhead and enabling short-circuiting.
- Short-circuiting — operations like
findFirst(),anyMatch(),limit()can terminate the pipeline early without processing the entire source, which is impossible if each stage eagerly materialized a full intermediate list.
list.stream()
.filter(x -> { System.out.println("filtering " + x); return x > 5; })
.findFirst(); // stops as soon as ONE element passes the filter — doesn't scan the rest of the list
Streams are single-use: Once a terminal operation runs, the stream is “consumed” — calling another operation on it throws IllegalStateException: stream has already been operated upon or closed. This is a deliberate design constraint that surprises engineers trying to reuse a stream variable across multiple terminal operations — the fix is constructing a fresh stream from the source each time.
flatMap vs map: map produces one output element per input (Stream<Stream<T>> if the mapper itself returns a stream — rarely useful directly). flatMap flattens nested structures — each input maps to a stream, and those streams are concatenated into a single flat result stream. Classic use: flattening List<List<T>> to List<T>, or expanding one entity into multiple derived entities (e.g., an Order → its List<LineItem>).
peek() caveat: Intended for debugging only (side-effect inspection mid-pipeline) — its execution is not guaranteed if the pipeline short-circuits before reaching that element, and using it for actual business-logic side effects is explicitly discouraged by the Javadoc since stream implementations are free to optimize/elide calls in ways that would make side-effect-dependent logic unreliable.
Collectors and reduction:
Map<Dept, List<Emp>> grouped = stream.collect(Collectors.groupingBy(Emp::getDept));
int total = stream.reduce(0, Integer::sum);
reduce() is the general-purpose fold operation underlying sum/count/max/min — useful to understand for cases not covered by a built-in Collector.
Parallel stream note: Already covered in depth in 5.7 — same common-pool caveats apply when .parallel() or .parallelStream() is invoked on a stream.
6.3 Optional Class — Best Practices
Optional<T> exists to make the possible absence of a value an explicit part of a method’s type signature, replacing the ambiguity of “does null here mean absent, or is it a bug?” with an unambiguous container type.
Correct construction and consumption:
Optional<User> findUser(String id) {
User user = repository.lookup(id);
return Optional.ofNullable(user); // wraps possibly-null value
}
Optional<String> name = Optional.of(actualNonNullValue); // throws NPE immediately if null — use only when you KNOW it's non-null
Optional<String> empty = Optional.empty();
Idiomatic consumption (avoid isPresent()/get() pairs — that’s just null-check boilerplate with extra ceremony):
// Bad — defeats the purpose of Optional
if (optUser.isPresent()) {
process(optUser.get());
}
// Good — functional style
optUser.ifPresent(this::process);
String name = optUser.map(User::getName).orElse("Unknown");
User user = optUser.orElseThrow(() -> new UserNotFoundException(id));
optUser.ifPresentOrElse(this::process, () -> log.warn("not found"));
orElse() vs orElseGet() — a subtle but important performance distinction:
optUser.orElse(createDefaultUser()); // createDefaultUser() ALWAYS executes eagerly, even if optUser is present
optUser.orElseGet(() -> createDefaultUser()); // createDefaultUser() only executes if optUser is empty (lazy, Supplier-based)
This is a common production-code mistake — using orElse() with an expensive method call (e.g., a database fallback query) means that expensive call runs on every invocation regardless of whether the optional was actually empty, silently wasting work. Always use orElseGet() when the fallback computation isn’t a trivial constant.
Explicit anti-patterns architects should flag in code review:
- Optional as a field type —
Optionalwas designed for return types, not fields; it doesn’t implementSerializable, adds an unnecessary wrapper allocation, and signals confused intent (a field’s nullability should typically be a class-design/validation decision, not deferred to callers viaOptional). - Optional as a method parameter — forces every caller to wrap values in
Optionaljust to call the method; method overloading or simply accepting a nullable parameter (with clear documentation) is preferable. Optional.get()without a presence check — throwsNoSuchElementExceptionif empty; functionally no better than a raw NPE-pronenulldereference, defeating the entire purpose of usingOptionalin the first place.- Wrapping collections in
Optional— an emptyList/Mapalready unambiguously represents “nothing here”; return the empty collection directly rather thanOptional<List<T>>. - Using
Optionalfor general-purpose “value or error” semantics —Optionalonly signals presence/absence, not why something is absent. For richer error context, prefer exceptions or an explicitResult/Either-style type (not in the standard library, but a common pattern in larger codebases).
Where Optional genuinely earns its place: Return types of methods where “no result” is a normal, expected outcome (e.g., a repository’s findById()), and the caller is expected to explicitly handle that case via the functional chain above rather than risk an unchecked NPE.
6.4 Records, Sealed Classes, Pattern Matching (Java 14–21)
Records (Java 16+, preview in 14) — concise syntax for immutable data carriers.
record Point(int x, int y) { }
This single line generates:
private finalfields for each component.- A canonical constructor matching the component list.
- Public accessor methods (
x(),y()— note: nogetX()/getY()JavaBean naming). equals()/hashCode()based on all components.toString()in a readablePoint[x=1, y=2]format.
Customization points:
record Point(int x, int y) {
Point { // compact constructor — validation/normalization only
if (x < 0 || y < 0) throw new IllegalArgumentException("negative coordinates");
}
double distanceFromOrigin() { return Math.sqrt(x*x + y*y); } // additional instance methods allowed
static Point origin() { return new Point(0, 0); } // static factory methods allowed
}
Architect-relevant constraints: Records are implicitly final (cannot be extended), cannot extend another class (they implicitly extend Record), and all fields are implicitly final — records are a deliberate, deep commitment to immutability, not just a syntax shortcut. They’re an excellent fit for DTOs, value objects, and API request/response payloads, but the wrong tool when you need mutable state or an inheritance hierarchy.
Sealed Classes/Interfaces (Java 17+) — restrict which classes/interfaces can implement/extend a type.
sealed interface Shape permits Circle, Square, Triangle { }
record Circle(double radius) implements Shape { }
record Square(double side) implements Shape { }
record Triangle(double base, double height) implements Shape { }
- Permitted subtypes must be
final,sealed, ornon-sealed, and must reside in the same module (or same package, if no module system) — this closes the inheritance hierarchy to a known, finite set. - The architectural payoff is exhaustiveness checking in pattern matching (below) — the compiler can verify a
switchover a sealed type’s subtypes covers every case, with nodefaultbranch needed, catching missing-case bugs at compile time when a new subtype is added later rather than at runtime.
Pattern Matching for switch (Java 21 standard) + Record Patterns (Java 21 standard):
sealed interface Shape permits Circle, Square, Triangle { }
double area(Shape shape) {
return switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Square s -> s.side() * s.side();
case Triangle t -> 0.5 * t.base() * t.height();
// no default needed — compiler verifies exhaustiveness over the sealed hierarchy
};
}
Record deconstruction patterns (Java 21):
record Point(int x, int y) { }
record Line(Point start, Point end) { }
String describe(Object obj) {
return switch (obj) {
case Line(Point(var x1, var y1), Point(var x2, var y2)) when x1 == x2 ->
"vertical line at x=" + x1;
case Line(Point p1, Point p2) -> "line from " + p1 + " to " + p2;
case Point(var x, var y) -> "point at " + x + "," + y;
default -> "unknown";
};
}
varin deconstruction patterns binds the nested component directly — no manual accessor calls needed.whenclauses add guard conditions to a case without requiring nestediflogic inside the branch.
instanceof pattern matching (Java 16+) — eliminates the redundant explicit cast that pattern matching makes unnecessary:
if (obj instanceof String s && s.length() > 5) { // s is in scope here, already cast
System.out.println(s.toUpperCase());
}
Architectural significance: This trio (records + sealed types + pattern matching) gives Java genuine, compiler-checked algebraic data type modeling — something previously requiring verbose visitor-pattern boilerplate or unchecked instanceof chains. It’s directly relevant to designing typed API response hierarchies, parser/AST node modeling, and any domain with a closed set of variant cases (e.g., payment method types, event types in an event-sourced system).
6.5 Virtual Threads (Project Loom) and Structured Concurrency
Virtual Threads — standard feature in Java 21 (Thread.ofVirtual()):
The core problem virtual threads solve: traditional Java threads map 1:1 to OS threads, which are expensive to create (typically ~1MB stack reservation each) and limited in number (thousands, not millions, before exhausting OS resources). This forced the “thread-per-request” model to be abandoned for high-concurrency I/O-bound services in favor of complex async/reactive programming (callbacks, CompletableFuture chains, reactive streams) purely to avoid thread exhaustion — at a significant cost to code readability and debuggability (stack traces fragmented across async boundaries).
Thread vt = Thread.ofVirtual().start(() -> doWork());
// More commonly, via the executor abstraction:
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (Request req : requests) {
executor.submit(() -> handle(req)); // one virtual thread per task, cheaply
}
}
How virtual threads work:
- Virtual threads are M:N scheduled — many virtual threads run on top of a small pool of carrier threads (real OS/platform threads, typically sized to CPU core count).
- When a virtual thread performs a blocking operation that the JDK has been updated to recognize (I/O,
Thread.sleep(), blocking on aj.u.clock/queue), the JVM unmounts it from its carrier thread — freeing the carrier thread to run a different virtual thread — and remounts it onto some carrier thread once the blocking operation completes. This is transparent to application code: you write straightforward, synchronous-looking blocking code, but the runtime achieves the scalability previously reserved for async/reactive frameworks. - Result: you can spin up millions of virtual threads, since each is a lightweight JVM-managed object (heap-allocated, no dedicated OS stack) rather than an OS resource.
Critical caveat — “pinning”: A virtual thread doesn’t unmount from its carrier thread under certain conditions — most notably inside a synchronized block/method, or while executing native code (JNI) — pinning the carrier thread for that duration. Under heavy use of legacy synchronized blocks around blocking I/O, this can erode virtual threads’ scalability benefit. The architectural guidance (an active area of JDK evolution) is to prefer java.util.concurrent.locks.ReentrantLock over synchronized in new virtual-thread-heavy code where the lock guards a section that might block.
What virtual threads are NOT for: CPU-bound parallel computation — they don’t add CPU parallelism (still bounded by core count via carrier threads); their entire value proposition is cheap concurrency for blocking/I/O-bound workloads, not faster computation. Fork/Join and parallel streams remain the right tool for CPU-bound parallelism.
Structured Concurrency (preview, Java 21+ via jdk.incubator.concurrent / java.util.concurrent.StructuredTaskScope):
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<User> userTask = scope.fork(() -> fetchUser(id));
Subtask<List<Order>> ordersTask = scope.fork(() -> fetchOrders(id));
scope.join(); // wait for both subtasks
scope.throwIfFailed(); // propagate failure from either subtask
return new UserProfile(userTask.get(), ordersTask.get());
} // scope close ensures BOTH subtasks are completed or cancelled before exiting — no leaked threads
- Solves a real correctness problem with unstructured async code (raw thread spawning, unmanaged
CompletableFuturechains): if one of several concurrent subtasks fails, it’s easy to accidentally leave sibling tasks running unsupervised (a thread/resource leak) or to lose the failure’s relationship to its “parent” logical operation. StructuredTaskScopeenforces that the lifetime of child tasks is strictly bound to the scope’s lexical block — when the scope closes (including via exception), all forked subtasks are guaranteed joined or cancelled. This gives async/concurrent code the same reasoning structure as sequential code’s call stack (a parent operation can’t “return” while orphaned children silently still execute) — hence “structured.”- Still a preview feature as of Java 21 — worth flagging to teams that production adoption requires
--enable-previewand awareness that the API may still evolve before finalization.
6.6 Switch Expressions and Text Blocks
Switch Expressions (Java 14+ standard) — switch as an expression producing a value, not just a statement.
// Old statement-style switch — fall-through risk, no value production
int numDays;
switch (month) {
case 1: case 3: case 5: numDays = 31; break;
default: numDays = 30;
}
// New expression-style switch — arrow syntax, no fall-through, exhaustiveness-checked
int numDays = switch (month) {
case 1, 3, 5, 7, 8, 10, 12 -> 31;
case 4, 6, 9, 11 -> 30;
case 2 -> isLeapYear ? 29 : 28;
default -> throw new IllegalArgumentException("invalid month");
};
- Arrow (
->) syntax eliminates the classic fall-through footgun entirely — each case is a self-contained branch, nobreakneeded (andbreakisn’t even valid in arrow form). - Multiple labels per case (
case 1, 3, 5 ->) reduce repetition that previously required deliberate fall-through. yield— for multi-statement case blocks needing to produce a value:
int result = switch (status) {
case ACTIVE -> 1;
case PENDING -> {
log.info("pending case hit");
yield 2; // yield produces the switch expression's value from a block
}
default -> 0;
};
- When switching over an
enumor asealedtype, the compiler enforces exhaustiveness — if every case is covered, nodefaultis required, and adding a new enum constant/sealed subtype later without updating the switch produces a compile error rather than a silent runtime gap. This is a meaningful safety improvement for domain modeling versus the old statement-switch, where a missed case silently fell through to nothing.
Text Blocks (Java 15+ standard) — multi-line string literals without escape-character noise.
String json = """
{
"name": "%s",
"role": "Architect"
}
""".formatted(name);
String sql = """
SELECT id, name, email
FROM users
WHERE status = 'ACTIVE'
""";
- Delimited by
"""on its own line, followed by a newline — the opening"""line’s content (if any) isn’t part of the string. - Incidental whitespace stripping: the compiler determines a common minimum indentation across all lines (based on the closing
"""placement) and strips it — meaning you can indent the text block to match your code’s nesting without that indentation polluting the actual string content. This is a frequent source of confusion for developers who don’t realize the closing-delimiter column controls the stripped-indentation baseline. - Embedded
"doesn’t need escaping unless it would create ambiguity with the block’s own"""delimiter;\line-continuation (\at end of line, no newline inserted) and\s(explicit space, protected from trailing-whitespace stripping) are text-block-specific escape additions. - Primary architectural benefit: dramatically improves readability of embedded SQL, JSON, HTML, or any multi-line literal content directly in source — previously requiring either ugly string concatenation with
\nand+operators, or external resource files for anything non-trivial.