Java streams & lambdas
Functional interfaces and lambdas
A functional interface has exactly one abstract method, the method a lambda supplies. Default methods and redeclared Object methods like equals do not count toward that one, which is why Comparator stays functional. The @FunctionalInterface annotation is optional; it only asks the compiler to reject the type if it does not have a single abstract method.
Four interfaces from java.util.function cover most needs:
Function<T,R>hasapply(takes a T, returns an R).Predicate<T>hastest(returns a boolean).Supplier<T>hasget(takes nothing, returns a T).Consumer<T>hasaccept(returns void).
A lambda can read a local variable only if it is final or effectively final, meaning its value never changes after assignment. Reassign a captured local and the code will not compile. The restriction is on the reference: you can still mutate the object it points at, though sharing mutable state that way invites trouble in parallel streams.
The four kinds of method reference
A method reference is shorthand for a lambda that calls one method.
Integer::parseInt // static method
System.out::println // instance method of a specific object (bound)
String::toUpperCase // instance method of an arbitrary object (unbound)
ArrayList::new // constructor
The pair interviewers probe is bound versus unbound. greeting::concat fixes greeting as the receiver and takes the argument as a parameter. String::toUpperCase leaves the receiver open, so the stream element becomes the receiver, and it fits Function<String,String>.
The stream pipeline and terminal operations
A stream is not a collection. It carries elements from a source through operations and stores nothing. A pipeline is a source, some intermediate operations, and one terminal operation.
Intermediate operations (filter, map, sorted, limit) return a stream and are lazy: they build up work and run nothing on their own. The terminal operation (collect, forEach, count, reduce) starts the traversal and produces a result. Laziness lets filter then map then sum run in one pass, element by element, instead of three passes over three temporary lists.
int total = widgets.stream()
.filter(w -> w.color() == RED) // lazy
.mapToInt(Widget::weight) // lazy
.sum(); // terminal, runs the whole pipeline
Wrong "The map transforms my list even without a terminal call." Without a terminal operation nothing is pulled through and no lambda runs. Right add a terminal op, or the pipeline stays inert.
Collectors: toList, groupingBy, toMap
collect gathers a stream into a container.
Map<Dept, Long> countByDept = emps.stream()
.collect(groupingBy(Employee::dept, counting())); // Map<Dept, Long>
groupingBy(classifier) builds Map<K, List<T>>; a downstream collector like counting() or mapping(fn, toSet()) changes the value type. toMap(key, value) builds one entry per element but throws on duplicate keys unless you pass a merge function such as Integer::sum.
Using Optional well
Optional models a return value that may be absent. Build a chain with map, filter, and a default, and avoid get().
String city = findUser(id) // Optional<User>
.map(User::address)
.map(Address::city)
.orElse("unknown");
get() throws NoSuchElementException when empty, so calling it without a presence check is a dressed-up null pointer. Prefer orElse, orElseGet, or orElseThrow.
Why streams are single-use
A stream can be operated on only once. After a terminal operation runs, the pipeline is consumed, and touching that stream again (a second terminal op, or even adding an intermediate op) throws IllegalStateException. The reference implementation reports it as "stream has already been operated upon or closed," though the spec only promises that an implementation may throw.
Stream<String> s = names.stream();
long n = s.count(); // terminal, consumes s
List<String> l = s.toList(); // IllegalStateException
Wrong "I will hold the stream in a variable and reuse it like a list." A stream holds no elements to revisit; it is a one-shot view over its source. Right go back to the source for a fresh stream, or store a Supplier<Stream<T>> and call get() each time you need one.
Parallel streams and shared mutable state
parallelStream() or .parallel() splits the work across threads. The correctness rule: the result must not depend on whether the stream ran sequentially or in parallel, apart from explicitly nondeterministic ops like findAny(). Two things break that rule.
The first is shared mutable state. Calling .forEach(list::add) on a plain ArrayList from several threads is a data race that produces lost updates, nulls, or an exception. Collect into a result instead, or reduce with an associative operator. Associativity is what lets (a op b) and (c op d) compute on different threads and merge; addition and max qualify, subtraction does not.
The second is cost. Every parallel stream in the JVM shares one fork/join common pool, so a blocking task in one can starve the others, which is why CPU-bound work is the intended fit. Parallelism tends to lose on small inputs, on sources that split poorly (LinkedList, Stream.iterate), on cheap per-element work, and on order-sensitive ops like limit and findFirst that force coordination.
orElse vs orElseGet and other Optional traps
orElse evaluates its argument every time, even when the value is present.
opt.orElse(expensive()); // expensive() ALWAYS runs
opt.orElseGet(this::expensive); // runs only when opt is empty
Reach for orElseGet when the fallback is costly or has side effects. Keep Optional as a return type; the API note steers you away from fields, parameters, and collection elements, and a variable of type Optional should never itself be null. It is a value-based class, so query it with isPresent/isEmpty rather than comparing references with ==.
Collectors.toList vs Stream.toList
They differ. Collectors.toList() returns some List with no promise of type, mutability, or thread safety, and in current runtimes it happens to be mutable. Stream.toList() (Java 16 and later) returns an unmodifiable List, so a later add throws UnsupportedOperationException, and it does permit null elements. Collectors.toUnmodifiableList() is also unmodifiable but rejects nulls with a NullPointerException. That same "no guarantees on type" caveat covers toSet, toMap, and groupingBy, so pass a supplier (toCollection, or groupingBy(fn, TreeMap::new, downstream)) when the concrete implementation matters.
beta
The interviewer part is in the works.
The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.