How lambdas are compiled (invokedynamic, LambdaMetafactory, hidden classes), method references vs lambdas, recursive lambdas, lambdas in multithreaded code, lambda serialization, target typing and type inference, @FunctionalInterface and the single-abstract-method rule, composing Predicate/Function/Consumer (and, andThen, compose), Supplier for lazy evaluation, UnaryOperator, generic functional interfaces, lambdas passed as Runnable, and real uses of BiFunction/BiPredicate.
Published September 25, 2026
Functional-programming questions at this level are about how things work (invokedynamic, capture rules, target typing) and good taste: when a method reference, a composed predicate or a named class is clearer.
Short answer: Not as anonymous inner classes:
javac compiles the lambda body into a private synthetic method (lambda$main$0) in the enclosing class. It's static if it captures nothing from this.invokedynamic instruction, whose bootstrap method is LambdaMetafactory.metafactory.The benefits over anonymous classes: no .class file per lambda, lazy linkage (a faster startup footprint), a different this scoping, and more freedom for the JVM to optimise.
Learn it in depth → Lambda Expressions
Short answer: A method reference (Type::method) is a compact form of a lambda that only calls an existing method. There are four kinds:
Integer::parseInt;System.out::println, order::isPaid;String::toUpperCase, where the first argument becomes the receiver;ArrayList::new.They compile to the same invokedynamic mechanism.
Prefer them when they're clearer (map(Order::total), instead of map(o -> o.total())). Prefer lambdas when there's any extra logic, when argument order would be confusing, or when overloads make the reference ambiguous.
Common trap: a bound method reference evaluates the receiver immediately. Supplier<String> s = obj::toString; throws NullPointerException at creation if obj is null, while the lambda () -> obj.toString() throws only when called.
Learn it in depth → Method References
Short answer: A lambda can't refer to itself by name in its own initialiser: Function<Integer,Integer> f = n -> n <= 1 ? 1 : n * f.apply(n - 1); fails for a local variable ("might not have been initialized"). Workarounds:
this::fact (usually the clearest option);private static final IntUnaryOperator FACTORIAL = n -> n <= 1 ? 1 : n * Holder.FACT.applyAsInt(n - 1);
private static final class Holder { static final IntUnaryOperator FACT = FACTORIAL; }
// Clearer: write a method and reference it when a function is needed
static long fib(int n) { return n < 2 ? n : fib(n - 1) + fib(n - 2); }
IntToLongFunction f = Demo::fib;
Java has no tail-call optimisation, so deep recursion risks StackOverflowError.
Short answer:
ArrayList and adding to it from several threads is a data race. Lambdas don't make anything thread-safe.Runnable and Supplier can't throw checked exceptions. They get wrapped or lost in async callbacks, unless they're handled.ThreadLocals (MDC, security context) aren't available when the lambda runs on another thread. Wrap executors with context decorators, or use ScopedValue.lambda$0, and profilers aggregate anonymous frames.this if it uses instance members, which can retain large objects (leaks) when it's stored in long-lived queues or caches.Short answer: Only if the target interface is Serializable, or you use an intersection cast: (Runnable & Serializable) () -> .... Then LambdaMetafactory.altMetafactory generates a class with a writeReplace that produces a SerializedLambda, recording the capturing class, the implementation method name and signature, and the captured arguments. Deserialisation calls the capturing class's synthetic $deserializeLambda$ method.
The caveats: the serialised form depends on compiler-generated names. Recompiling or reordering lambdas can break deserialisation. Captured values must be serialisable, and there are security implications. Avoid serialising lambdas. Serialise data and a strategy identifier instead. (Frameworks like Spark and Flink do it, with their own care.)
Short answer: Lambdas have no type of their own. Their type comes from the target type: the functional interface expected in that context (an assignment, a method argument, a return, a cast). The compiler:
(a, b) -> a + b works without declaring types;For generic methods, inference combines the argument lambdas and the target type. For example, Comparator.comparing(Person::name) infers Comparator<Person> from the context.
Where it fails:
submit(Runnable) vs submit(Callable) with a block-bodied lambda). Fix it with explicit parameter types, or a cast;comparing(...).thenComparing(...) chains sometimes need explicit type witnesses (Comparator.<Person, String>comparing(...)).Java 11 allows var in lambda parameters, which is useful for annotations: (@NonNull var x) -> ....
@FunctionalInterface do?Short answer: A functional interface has exactly one abstract method (a SAM, single abstract method). It may also have any number of default and static methods, and abstract methods that override Object's public methods (like equals) don't count. Lambdas and method references can target it. The @FunctionalInterface annotation is optional, but makes the compiler fail if the interface doesn't have exactly one abstract method. That documents the intent, and protects against someone adding a second abstract method later.
Learn it in depth → Lambda Expressions
Short answer: No. A lambda supplies one method body, so the compiler must know unambiguously which method it implements. With two abstract methods, a lambda couldn't implement both, so the interface can't be a lambda target, and @FunctionalInterface fails to compile. The exceptions:
Object methods (Comparator declares boolean equals(Object) and is still functional);An interface that inherits the same abstract method signature from two parents still has one abstract method.
Predicate conditions?Short answer: Use the default methods and, or and negate, plus the static Predicate.not(...) (Java 11) and Predicate.isEqual(...). They short-circuit like && and ||.
Predicate<Order> isPaid = Order::isPaid;
Predicate<Order> isHighValue = o -> o.total().compareTo(new BigDecimal("10000")) > 0;
Predicate<Order> isDomestic = o -> o.country().equals("IN");
Predicate<Order> needsManualReview = isPaid.and(isHighValue).and(Predicate.not(isDomestic));
List<Order> review = orders.stream().filter(needsManualReview).toList();
// Build dynamically from configuration:
Predicate<Order> combined = rules.stream().reduce(o -> true, Predicate::and);
Function<T,R> with andThen() and compose()?Short answer:
f.andThen(g) means apply f first, then g: g(f(x)).f.compose(g) means apply g first, then f: f(g(x)).Function.identity() is the neutral element.Function<String, String> trim = String::trim;
Function<String, String> upper = String::toUpperCase;
Function<String, Integer> length = String::length;
trim.andThen(upper).apply(" hi "); // "HI"
length.compose(trim).apply(" hi "); // 2: trim first, then length
Chaining builds reusable transformation pipelines (normalising input, mapping DTOs), without intermediate variables.
Consumer.andThen() and Function.andThen()?Short answer:
Consumer.andThen(after) runs both consumers on the same input, one after the other: c1.accept(x); c2.accept(x);. There's no value flowing between them. It's for side effects (log, then publish).Function.andThen(after) pipes the output of the first function into the second: after.apply(f.apply(x)). The result type can change (Function<T,R> then Function<R,V> gives Function<T,V>).In both, if the first throws, the second doesn't run.
Supplier to implement lazy loading?Short answer: A Supplier<T> defers computation until get() is called. So you pass the recipe, not the value:
log.debug("x={}", () -> expensive()) (the SLF4J 2 fluent API or Log4j2 supports suppliers), Objects.requireNonNull(obj, () -> "missing " + id), optional.orElseGet(() -> loadDefault()).static <T> Supplier<T> memoize(Supplier<T> delegate) { // double-checked, thread-safe lazy holder
return new Supplier<>() {
private volatile T value;
public T get() {
T v = value;
if (v == null) synchronized (this) { v = value; if (v == null) value = v = delegate.get(); }
return v;
}
};
}
Supplier<ExchangeRates> rates = memoize(ratesClient::fetchAll); // fetched on first use only
Guava has Suppliers.memoize, and a JDK StableValue API is in preview in recent releases for exactly this pattern.
UnaryOperator just a specialisation of Function<T, T>?Short answer: UnaryOperator<T> extends Function<T, T>. It's a function whose input and output types are the same: an "operator" on a type (String::trim, x -> x * 2). It adds no new abstract method. It exists for readability and type-safety in APIs that require same-type transformations (List.replaceAll(UnaryOperator<E>), Stream.iterate(seed, UnaryOperator)), plus a static identity(). BinaryOperator<T> does the same for BiFunction<T,T,T>, adding minBy and maxBy.
Short answer: Yes. Almost all of the standard ones are generic (Function<T,R>, Predicate<T>). You can define your own generic functional interfaces, and use wildcards for flexible APIs (Function<? super T, ? extends R>, following PECS). The one limitation: a lambda can't implement a generic method (a method with its own type parameters), only a generic interface whose type parameters are fixed at the target.
@FunctionalInterface
interface Validator<T> {
ValidationResult validate(T value);
default Validator<T> and(Validator<? super T> other) {
return v -> { var r = validate(v); return r.isValid() ? other.validate(v) : r; };
}
}
Validator<Order> hasLines = o -> o.lines().isEmpty() ? ValidationResult.error("no lines") : ValidationResult.ok();
Validator<Object> notNull = v -> v == null ? ValidationResult.error("null") : ValidationResult.ok();
Validator<Order> orderValidator = notNull::validate; // adapt a broader validator
orderValidator = orderValidator.and(hasLines);
Runnable?Short answer: The lambda is target-typed to Runnable, so it becomes an implementation of void run(). Its body must fit that descriptor: no parameters, no returned value (an expression lambda whose value is discarded is allowed, like () -> list.add(x)), and no checked exceptions.
When overloads exist (ExecutorService.submit(Runnable) vs submit(Callable<T>)):
() -> compute()) resolves to Callable;Runnable;Callable.Ambiguity can arise with block bodies. Use an explicit cast if needed.
BiFunction and BiPredicate?Short answer:
BiFunction<T,U,R>:
Map.merge / compute / computeIfPresent re-mapping functions: counts.merge(word, 1, Integer::sum);(Product, Customer) → Price;thenCombine(other, BiFunction);reduce with an identity and an accumulator: (partial, element) → newPartial.BiPredicate<T,U>:
(User, Resource) → allowed;Files.find(path, depth, (p, attrs) -> ...);BiConsumer<T,U>: Map.forEach((k, v) -> ...).Q: Why must captured local variables be effectively final? A: Lambdas capture values, not variables. Locals live on the stack, and may be gone when the lambda runs (possibly on another thread). Requiring effective finality avoids confusing semantics and data races on locals. Mutable state has to live in fields or holder objects, which makes the sharing explicit.
Q: How are the this semantics different in lambdas and anonymous classes?
A: In a lambda, this (and super) refer to the enclosing instance: lambdas are lexically scoped. In an anonymous class, this is the anonymous instance itself, and it can shadow enclosing names.
Q: Why do the primitive specialisations exist (IntPredicate, ToLongFunction)?
A: To avoid boxing on hot paths. Function<Integer,Integer> boxes every value. IntUnaryOperator works on int directly. Streams have IntStream, mapToInt and so on for the same reason.
Q: How would you handle checked exceptions in lambdas cleanly?
A: Catch and translate inside the lambda (to a domain runtime exception, or a result type), use a small ThrowingFunction interface with an unchecked(...) adapter, or restructure the code so the checked-exception work happens outside the stream. Avoid "sneaky throws" in shared code.