← All posts

Software engineering · java · lambdas · functional-programming · jvm

Java Lambdas and Functional Interfaces: From Syntax to invokedynamic

How Java lambdas and functional interfaces work, from syntax and method references to capture rules, invokedynamic, allocation and API design, explained from the basics up to what senior engineers need to know.

One box to fill in

Think of the photo app on your phone. It already knows how to search, sort and delete photos. What it doesn’t know is which photos you mean, so it gives you a single box to fill in: “show photos where ___”. You type “taken in 2026” and the app does the rest. You don’t rewrite the search feature; you only supply the one rule that changes.

Java code works the same way. removeIf already knows how to walk a list and delete items; it only needs you to fill in the rule:

photos.removeIf(photo -> photo.year() < 2020);

The rule you hand over, photo -> photo.year() < 2020, is a lambda. The box it goes into is a functional interface, here Predicate<Photo>, which says what shape the rule must have: “given a photo, answer true or false”.

Before Java 8, filling in that box took a whole anonymous class: five lines of ceremony, with a class declaration, a method signature and an @Override, wrapped around one line of logic. Java 8 (2014) added lambda expressions and method references, so you write just the rule and the compiler fits it into the box.

Most Java developers write lambdas every day, inside streams, Comparators and CompletableFuture chains. The interesting part is what happens around them: why a lambda has no type of its own, why it can’t change a local variable, what invokedynamic does with it, when it allocates, and how to design APIs that take one. This article goes from the basics to those edges.

The basics: from anonymous classes to lambdas

Here is the same comparator written three ways:

record Order(String id, String customer, double amount, boolean paid) {}

// Java 7: an anonymous class
Comparator<Order> byAmount = new Comparator<Order>() {
    @Override
    public int compare(Order a, Order b) {
        return Double.compare(a.amount(), b.amount());
    }
};

// Java 8: a lambda expression
Comparator<Order> byAmount2 = (a, b) -> Double.compare(a.amount(), b.amount());

// Java 8: a factory method that takes a method reference
Comparator<Order> byAmount3 = Comparator.comparingDouble(Order::amount);

A lambda expression is a parameter list, an arrow -> and a body. Everything else, including the parameter types and the name of the method being implemented, comes from the context. The shapes you will see:

Form Example
No parameters () -> 42
One parameter, type inferred s -> s.length()
Several parameters (a, b) -> a + b
Explicit parameter types (String a, String b) -> a.compareTo(b)
var parameters, so you can annotate them (Java 11) (@Nonnull var s) -> s.trim()
Block body (a, b) -> { int sum = a + b; return sum * 2; }
Unused parameter (Java 22) (key, _) -> key

An expression body returns its value automatically. A block body is an ordinary method body: it needs return if it produces a value, and it can declare local variables, loop and throw.

Functional interfaces: the one-method contract

A lambda can only appear where Java expects a functional interface: an interface with exactly one abstract method. That method is the box you fill in. Runnable (run()), Comparator<T> (compare(T, T)) and Callable<V> (call()) were all functional interfaces long before Java 8; lambdas simply gave them a short way to be implemented.

“Exactly one abstract method” has three fine-print rules:

  • Default and static methods don’t count. Comparator has dozens of them (reversed(), thenComparing(), comparing()) and is still functional.
  • Public methods of Object don’t count. Comparator declares boolean equals(Object), but every implementation inherits one from Object, so it is not part of the box.
  • Inherited abstract methods do count. An interface that extends a functional interface and adds a second abstract method is no longer functional.

You can write your own, and you should mark it with @FunctionalInterface:

@FunctionalInterface
interface DiscountRule {
    double apply(Order order);

    default DiscountRule plus(DiscountRule other) {
        return order -> apply(order) + other.apply(order);
    }
}

DiscountRule loyalty = order -> order.amount() > 500 ? 25.0 : 0.0;

The annotation is optional; any single-method interface works with lambdas. What it buys you is a compile error the day someone adds a second abstract method and silently breaks every caller that passes a lambda.

The standard set in java.util.function

Java 8 added 43 functional interfaces in java.util.function. You only need to learn six shapes; the rest are variations:

Diagram: the six core functional interfaces as shapes. Supplier takes nothing and returns T. Consumer takes T and returns nothing. Function takes T and returns R. Predicate takes T and returns boolean. UnaryOperator takes T and returns T. BinaryOperator takes two Ts and returns a T

Interface Abstract method Typical use
Supplier<T> T get() Lazy values and factories: Optional.orElseGet
Consumer<T> void accept(T t) Side effects: Iterable.forEach
Function<T, R> R apply(T t) Transformations: Stream.map
Predicate<T> boolean test(T t) Conditions: Stream.filter, Collection.removeIf
UnaryOperator<T> T apply(T t) Same-type transformations: List.replaceAll
BinaryOperator<T> T apply(T a, T b) Combining: Stream.reduce

The variations follow a naming scheme:

  • Bi takes two arguments: BiFunction<T, U, R>, BiConsumer<T, U>, BiPredicate<T, U>.
  • Int, Long or Double at the start takes a primitive: IntPredicate tests an int, and IntFunction<R> turns an int into an R.
  • ToInt, ToLong or ToDouble returns a primitive: ToIntFunction<T> turns a T into an int.
  • Both together convert between primitives: IntToLongFunction, DoubleToIntFunction.

The primitive versions exist to avoid boxing. A Function<Integer, Integer> wraps every int in an Integer object on the way in and on the way out; an IntUnaryOperator doesn’t. Only int, long and double got specialisations (plus BooleanSupplier), which is why there is no CharPredicate and why chars() on a String returns an IntStream.

Why pass behaviour at all

Two reasons cover almost every use. The first is parameterising: sort, filter and retry are written once, and the caller supplies the part that varies. The second is deferring: a lambda is code that runs later, or not at all.

// orElse evaluates its argument every time, even when the Optional has a value
Customer c1 = cache.find(id).orElse(loadFromDatabase(id));

// orElseGet only calls the supplier when the Optional is empty
Customer c2 = cache.find(id).orElseGet(() -> loadFromDatabase(id));

// java.util.logging builds the message only if FINE is enabled
logger.fine(() -> "Cache state: " + cache.dump());

The difference between those first two lines is a database call on every cache hit.

Method references

When a lambda only calls an existing method, a method reference says the same thing with less noise. There are four kinds:

Kind Method reference Equivalent lambda
Static method Integer::parseInt s -> Integer.parseInt(s)
Method of a particular object (bound) System.out::println x -> System.out.println(x)
Method of any object of a type (unbound) String::toUpperCase s -> s.toUpperCase()
Constructor ArrayList::new () -> new ArrayList<>()
Array constructor int[]::new n -> new int[n]

The unbound kind is the one that surprises people: String::toUpperCase takes the string as its first argument. That is why String::compareTo fits a Comparator<String>: (a, b) -> a.compareTo(b).

A bound reference is not exactly the lambda next to it. The receiver is evaluated once, when the reference is created, not each time it is called:

Order order = null;
Supplier<String> lambda = () -> order.id();  // fine until someone calls get()
Supplier<String> ref = order::id;            // NullPointerException right here

The same rule means System.out::println keeps printing to the old stream after a later System.setOut(...), while the lambda version follows the change.

Target typing: a lambda has no type of its own

The expression x -> x.isEmpty() does not have a type by itself. It gets one from where it appears, its target type, and the same text can become different objects in different places:

Predicate<String> p = s -> s.isEmpty();
Function<String, Boolean> f = s -> s.isEmpty();
Callable<String> c = () -> "hello";
Supplier<String> s = () -> "hello";   // same text, unrelated type

Object o = () -> "hello";             // compile error: Object is not a functional interface
var v = () -> "hello";                // compile error: lambda expression needs an explicit target-type
Object ok = (Supplier<String>) () -> "hello";  // a cast provides the target

Targets come from assignments, method arguments, return statements, casts and the arms of a ?: expression. The compiler checks the lambda against the target’s one abstract method: the parameter count must match, the body must return something compatible, and it may only throw checked exceptions that the method declares.

When overloads meet lambdas

Target typing gets interesting when a method is overloaded. ExecutorService.submit accepts both a Runnable, which returns nothing and can’t throw checked exceptions, and a Callable<T>, which returns a value and can throw anything:

executor.submit(() -> {
    Thread.sleep(100);
    return "done";
});                         // Callable<String>: it returns a value, so the sleep is allowed

executor.submit(() -> {
    Thread.sleep(100);
});                         // compile error: unreported exception InterruptedException

The second call can’t be a Callable because it returns nothing, so the compiler picks Runnable, whose run() throws no checked exceptions.

Overloads can also become ambiguous:

static void handle(Consumer<String> c) { }
static void handle(Function<String, String> f) { }

handle(s -> s.toUpperCase());          // compile error: reference to handle is ambiguous
handle((String s) -> s.toUpperCase()); // compiles, picks the Function version

s.toUpperCase() can be used as a statement (fits Consumer) or as a value (fits Function). With an implicitly typed lambda the compiler doesn’t look closely enough to choose; with explicit types it does, and prefers the version that returns a value. The lesson for API authors is in Effective Java: don’t overload a method with different functional interfaces in the same argument position. Give the methods different names, like thenAccept and thenApply on CompletableFuture.

Capturing variables

A lambda can use variables from the code around it. That is called capturing, and it comes with a rule: a captured local variable must be final or effectively final, meaning it is never assigned again after it is initialised.

int threshold = 10;
Predicate<Integer> big = n -> n > threshold;  // fine

int count = 0;
orders.forEach(o -> count++);  // compile error: local variables referenced from
                               // a lambda expression must be final or effectively final

The reason is what capturing actually does. Local variables are copied into the lambda object when it is created. The lambda might run later, on another thread, after the method that declared the variable has returned and its stack frame is gone. A copy can’t stay in sync with a variable that keeps changing, so Java forbids the change instead of letting you read a stale copy.

Fields are different. A lambda that uses a field captures this, the reference to the object, and reads the field through it every time:

Diagram: a method's stack frame holds the local variable threshold equal to 10 and a reference to this. The lambda object on the heap holds its own copy of threshold and a copy of the this reference, which points to the Service object, whose fields the lambda reads live

So this.count++ inside a lambda compiles. It is also a data race the moment the lambda runs on more than one thread, which is exactly what a parallel stream does. The usual “workarounds” for the compile error make the same mistake:

int[] counter = {0};
orders.parallelStream().forEach(o -> counter[0]++);  // compiles, gives wrong answers

The fix is almost always to stop mutating and let the library combine results: orders.stream().filter(...).count(), mapToDouble(...).sum() or a Collector. If you genuinely need a shared counter, use an AtomicLong or a LongAdder.

this means the enclosing object

A lambda is not a new scope the way an anonymous class is. Inside a lambda, this is the enclosing instance, and you can’t declare a parameter or local variable with the same name as a local outside it:

class Checkout {
    private final String name = "checkout";

    void register(EventBus bus) {
        bus.subscribe(event -> System.out.println(this.name));  // the Checkout

        bus.subscribe(new Consumer<String>() {
            @Override
            public void accept(String event) {
                System.out.println(this);  // the anonymous Consumer, not the Checkout
            }
        });

        String event = "x";
        bus.subscribe(event -> {});  // compile error: variable event is already defined
    }
}

Composing functions

Because functional interfaces can have default methods, the standard ones come with methods to build bigger behaviour out of smaller pieces:

Predicate<Order> paid = Order::paid;
Predicate<Order> large = o -> o.amount() > 1_000;
Predicate<Order> needsReview = large.and(paid.negate());

Function<String, String> trim = String::strip;
Function<String, String> normalise = trim.andThen(String::toLowerCase);

Comparator<Order> byCustomerThenLargestFirst =
        Comparator.comparing(Order::customer)
                  .thenComparing(Order::amount, Comparator.reverseOrder());

List<String> nonBlank = lines.stream()
        .filter(Predicate.not(String::isBlank))   // Java 11
        .toList();

Watch the order with Function: f.andThen(g) means “f, then g”, or g(f(x)), while f.compose(g) means “g, then f”, or f(g(x)). Function.identity() is the function that returns its argument unchanged, which is handy as a key mapper in Collectors.toMap(Function.identity(), ...).

Checked exceptions and lambdas

None of the java.util.function interfaces declares a checked exception, so a lambda passed to them can’t throw one:

List<String> contents = paths.stream()
        .map(Files::readString)   // compile error: incompatible thrown types IOException
        .toList();

There are three honest ways out. The simplest is to catch the exception inside the lambda and wrap it in an unchecked one, keeping the cause:

List<String> contents = paths.stream()
        .map(path -> {
            try {
                return Files.readString(path);
            } catch (IOException e) {
                throw new UncheckedIOException(e);
            }
        })
        .toList();

If that block appears more than once, move it into a helper that adapts a throwing function:

@FunctionalInterface
interface IOFunction<T, R> {
    R apply(T t) throws IOException;
}

static <T, R> Function<T, R> uncheckedIO(IOFunction<T, R> f) {
    return t -> {
        try {
            return f.apply(t);
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    };
}

List<String> contents = paths.stream().map(uncheckedIO(Files::readString)).toList();

The third option is for your own APIs: let the caller’s exception pass through by making the exception type a type parameter. The compiler infers X from the lambda, so callers keep checked exceptions instead of losing them:

@FunctionalInterface
interface ThrowingConsumer<T, X extends Exception> {
    void accept(T t) throws X;
}

static <T, X extends Exception> void forEachChecked(
        Iterable<T> items, ThrowingConsumer<? super T, X> action) throws X {
    for (T item : items) {
        action.accept(item);
    }
}

forEachChecked(paths, Files::delete);  // this call throws IOException, checked as normal

This works for one exception type, which covers most cases. It is the same trick as Optional.orElseThrow(Supplier<? extends X>). What you should not do is catch Exception and return null, or log and carry on: the stream keeps running on bad data and the failure surfaces somewhere else.

The mistakes everyone makes

  • Mutating captured state. Adding to an outside ArrayList from forEach or map, or the int[] counter trick. It breaks under parallel streams. Use collect, toList() or count().
  • Writing 20-line lambdas. A lambda has no name to explain it and shows up as lambda$process$3 in stack traces. Past three or four lines, extract a named method and pass a method reference.
  • Expecting to remove a listener with a fresh method reference. bus.unsubscribe(this::onEvent) creates a new object that is not equal to the one you subscribed, so nothing is removed. Keep the reference you registered, or have subscribe return a handle.
  • Using Function<Integer, Integer> on hot paths. Every call boxes. Use IntUnaryOperator, ToIntFunction<T> and IntStream.
  • orElse(expensiveCall()). The argument is evaluated even when the value is present. Use orElseGet(() -> expensiveCall()).
  • Swallowing checked exceptions in a wrapper. Wrap them in an unchecked exception with the original as the cause; don’t return null.
  • Overloading on functional interfaces. handle(Consumer) and handle(Function) force every caller to add parameter types. Use different method names.

Advanced ideas

How a lambda is compiled: invokedynamic

An anonymous class compiles to its own class file, such as Checkout$1.class. A lambda doesn’t. Take this program:

public class Greeter {
    public static void main(String[] args) {
        String greeting = "Hello, ";
        Function<String, String> greet = name -> greeting + name;
        System.out.println(greet.apply("Ada"));
    }
}

javap -c -p Greeter.class shows what javac did with it:

public static void main(java.lang.String[]);
     0: ldc           #7     // String Hello,
     2: astore_1
     3: aload_1
     4: invokedynamic #9, 0  // InvokeDynamic #0:apply:(Ljava/lang/String;)Ljava/util/function/Function;
     9: astore_2
    10: getstatic     #13    // Field java/lang/System.out:Ljava/io/PrintStream;
    13: aload_2
    14: ldc           #19    // String Ada
    16: invokeinterface #21, 2  // InterfaceMethod java/util/function/Function.apply:(Ljava/lang/Object;)Ljava/lang/Object;
    21: checkcast     #26    // class java/lang/String
    ...

private static java.lang.String lambda$main$0(java.lang.String, java.lang.String);

Two things happened. The lambda’s body moved into a private static method, lambda$main$0, whose first parameter is the captured greeting and whose second is the lambda’s own name. (If the body used this, it would be an instance method instead.) And at the point where the lambda is created there is an invokedynamic instruction, which says “give me a Function, given a String”, and names a bootstrap method, LambdaMetafactory.metafactory, to work out how.

Diagram: the first time the invokedynamic instruction runs, the JVM calls the LambdaMetafactory bootstrap method, which defines a hidden class implementing Function whose apply method calls lambda$main$0, and returns a call site. The JVM links the instruction to that call site, so every later execution goes straight to it and creates or reuses the lambda object

The first time that instruction runs, the JVM calls the bootstrap method. It generates a small hidden class that implements Function, with a field for each captured value and an apply method that calls lambda$main$0. The JVM then links the instruction to the result, so every later execution skips the bootstrap entirely. Print greet.getClass() and you see the generated class: Greeter$$Lambda/0x....

Why go to this trouble instead of generating an inner class at compile time? Because the translation strategy lives in the JVM, not in your class files. There is no class file per lambda to load from the JAR, and the JDK can change how lambda classes are made without anyone recompiling. It already has: Java 8 used an internal Unsafe.defineAnonymousClass, and Java 15 switched to the supported hidden classes API. The same pattern was reused for string concatenation in Java 9 (the makeConcatWithConstants you can see inside lambda$main$0 in the full javap output), for the toString, equals and hashCode of records, and for pattern-matching switch.

Method references to an existing method usually skip the synthetic method: the bootstrap is handed a direct handle to Integer::parseInt itself.

Identity and allocation

What does creating a lambda cost? The Java Language Specification deliberately doesn’t say whether evaluating a lambda expression creates a new object. In practice, on HotSpot:

  • A non-capturing lambda is created once per call site. The call site returns the same instance every time it runs.
  • A capturing lambda allocates a new object each time it runs, holding the captured values.
for (int i = 0; i < 3; i++) {
    Runnable r = () -> {};
    System.out.println(System.identityHashCode(r));   // the same number three times
}
for (int i = 0; i < 3; i++) {
    int n = i;
    Runnable r = () -> System.out.print(n);
    System.out.println(System.identityHashCode(r));   // three different numbers
}

The allocation is small, and when the JIT can inline the code that uses the lambda, escape analysis often removes it completely. But it explains a few things: why capturing a value in a lambda inside a very hot loop can show up in allocation profiles, and why the bus.unsubscribe(this::onEvent) from the mistakes list fails. this::onEvent captures this, so every evaluation is a new object, and lambdas don’t override equals. Two different lambda expressions are always different objects, even with identical text, because each is its own call site with its own class.

Never write code that depends on lambda identity either way. It is an implementation detail, and the spec leaves it open on purpose.

Megamorphic call sites

Each lambda expression gets its own class. That matters to the JIT compiler. When HotSpot compiles a call such as predicate.test(x), it looks at which classes it has actually seen at that call. If it has seen one or two, it can inline the lambda body right into the caller and optimise the two together. Once it has seen more, the call site becomes megamorphic: it turns into an interface dispatch, and the lambda body is compiled separately, without the caller’s context.

This is why a stream pipeline can look free in a microbenchmark and slower in production. In the benchmark, the library code inside filter only ever sees your one lambda. In a real application, that same library code sees dozens of different lambdas from all over the codebase, and the profile no longer helps unless the JIT inlines the whole pipeline into your method. It usually does for short pipelines, but not always. Don’t contort code to avoid this; just be suspicious of benchmarks that test one lambda in isolation, and measure with JMH and realistic call patterns before you blame or credit lambdas for a performance difference.

Lambdas, inner classes and memory leaks

A lambda captures this only if its body uses the enclosing instance: a field, an instance method, or this itself. A lambda that uses only its parameters and captured locals holds no reference to the enclosing object.

Anonymous classes used to be worse. Up to Java 17, javac gave every anonymous class in an instance method a hidden field pointing at the enclosing object, whether it used it or not. From JDK 18, javac leaves the field out when it is unused, but only code compiled by a newer compiler benefits.

Either way, the risk is the same. A callback stored somewhere long-lived keeps everything it captured alive. Register event -> this.refresh() with an application-wide event bus, and the bus now holds the lambda, the lambda holds this, and this holds whatever it references, possibly a whole screen or request context. The fix is the same as for listeners in any language: unsubscribe when the owner is closed, return a handle from subscribe so that is easy, or use weak references in the registry if callers can’t be trusted to clean up.

Serializable lambdas

A lambda is serializable only if its target type is. You can force it with an intersection cast:

Runnable task = (Runnable & Serializable) () -> System.out.println("hi");

Frameworks that ship code to other machines, such as Apache Spark and Apache Flink, rely on this. It works differently from normal serialization. The serialized form is a SerializedLambda that records the name of the synthetic method holding the body, such as lambda$main$d63682a$1, plus the captured values. It does not contain the code. On the way back in, the class that declared the lambda looks up a method by that name.

That has two consequences. Change the lambda’s body and recompile, and bytes written by the old version deserialize into the new code, with no error. Change it in a way that changes the generated name, such as capturing a variable instead of using a literal, and the old bytes fail with an InvalidObjectException. Neither is what you want from a persistent format. Effective Java says to serialize a lambda “rarely, if ever”. If you need a serializable function, write a small named class, ideally a record, whose serialized form you control.

Generic methods can’t be lambdas

A lambda can implement a generic interface, such as Function<T, R>. It cannot implement an interface whose single method is itself generic, because lambda syntax has nowhere to declare a type parameter:

@FunctionalInterface
interface Identity {
    <T> T apply(T t);
}

Identity id = x -> x;              // compile error: invalid functional descriptor
                                   // for lambda expression: method is generic
Identity ref = Main::sameValue;    // a generic method reference is fine

static <T> T sameValue(T t) { return t; }

You rarely need this, but when you do, a method reference or an anonymous class are the only options.

Recursive lambdas

A lambda can’t refer to the variable it is being assigned to, because that variable isn’t initialised yet:

IntUnaryOperator factorial = n -> n <= 1 ? 1 : n * factorial.applyAsInt(n - 1);
// compile error: variable factorial might not have been initialized

A static field with a qualified name gets around it, as in n * Maths.FACTORIAL.applyAsInt(n - 1), but the honest answer is that recursion wants a name, and methods have names. Write a method, and pass Maths::factorial if you need a function.

Debugging lambdas

Lambdas show up in stack traces under their synthetic names, mixed in with stream machinery:

static int totals(List<String> rows) {
    return rows.stream()
            .mapToInt(row -> Integer.parseInt(row))   // line 10
            .sum();
}
Exception in thread "main" java.lang.NumberFormatException: For input string: "x"
    at java.base/java.lang.Integer.parseInt(Integer.java:662)
    at java.base/java.lang.Integer.parseInt(Integer.java:778)
    at Report.lambda$totals$0(Report.java:10)
    at java.base/java.util.stream.ReferencePipeline$4$1.accept(ReferencePipeline.java:214)
    at java.base/java.util.AbstractList$RandomAccessSpliterator.forEachRemaining(AbstractList.java:722)
    ... 6 more stream frames ...
    at Report.totals(Report.java:11)

lambda$totals$0 means “the first lambda in the method totals”, and the line number points at it, so traces are readable once you know the pattern. Debuggers can set breakpoints inside a lambda on a line of its own; putting each stream step on its own line makes that much easier. A method reference to a named method gives you a real method name in the trace instead.

How Java compares

Language Function types What a closure captures
Java None; lambdas target a named functional interface Copies of effectively final locals, plus this
Kotlin Built-in types such as (Int) -> String Variables, including mutable vars, which the compiler wraps in a shared box
C# Delegate types such as Func<int, string> Variables themselves, so changes are visible both ways
JavaScript Functions are values Variables themselves (let gives a fresh variable per loop iteration; var doesn’t)
Scala Built-in types such as Int => String Variables, boxing mutable ones like Kotlin

Java’s restriction to effectively final locals is stricter than any of these. It costs a little convenience and removes a whole class of bugs, such as the classic JavaScript loop where every callback sees the final value of i. Kotlin’s inline functions go further than Java in the other direction: the compiler pastes the lambda body into the caller, so there is no lambda object at all.

Where the design stops

  • No function types. Function<Integer, Integer>, UnaryOperator<Integer> and IntUnaryOperator describe the same shape but are unrelated types. Converting means wrapping, usually with a method reference such as f::apply.
  • Checked exceptions don’t flow through. A generic X extends Exception handles one exception type; there is no way to say “whatever this lambda throws” in general.
  • No mutable capture. Deliberate, as above.
  • Primitives multiply the interfaces. Without generics over primitives, every shape needs Int, Long and Double versions. Project Valhalla’s work on value types and specialised generics is aimed partly at this.
  • No tail calls. Recursion through lambdas, as through methods, is limited by stack depth.

Designing APIs that take lambdas

Situation Do this
A standard interface fits the shape Use it: Function, Predicate, Supplier, Consumer
The shape has a meaning or a contract of its own A named @FunctionalInterface, like Comparator or DiscountRule
Accepting a function as a parameter Wildcards: Function<? super T, ? extends R>, Predicate<? super T>
A default value that is expensive to build Take a Supplier<T>, not a T
Hot paths over primitive data IntPredicate, ToLongFunction<T> and friends
The callback needs to throw checked exceptions Your own interface with throws X and a type parameter X
Overloads that differ only in functional interface Different method names
Registering listeners or callbacks Return a handle that unregisters; don’t rely on lambda identity
Storing callbacks for a long time Document it, and remember what the callbacks capture
Persisting or sending behaviour over the wire A named class or record, not a serializable lambda

The wildcard row matters more than it looks. A method declared as filter(Predicate<T>) can’t accept a Predicate<Object> for a list of Strings; filter(Predicate<? super T>) can. This is the PECS rule from Understanding Java Generics: a function’s parameters are consumers, so they take super, and its result is a producer, so it takes extends.

Key takeaways

  • A lambda is a short way to implement a functional interface, an interface with exactly one abstract method. Mark your own with @FunctionalInterface.
  • A lambda has no type of its own. The target type decides what it becomes, which is why the same text can be a Predicate or a Function, and why overloads on functional interfaces cause trouble.
  • Learn six shapes: Supplier, Consumer, Function, Predicate, UnaryOperator and BinaryOperator. Prefixes give you two-argument and primitive versions.
  • Captured locals are copied, so they must be effectively final. Fields are read live through this, which is a data race waiting for a parallel stream.
  • Lambdas compile to a private method and an invokedynamic call site. Non-capturing lambdas are effectively free; capturing lambdas allocate a small object, which escape analysis often removes.
  • Don’t depend on lambda identity, don’t serialize lambdas, and remember that a stored callback keeps everything it captured alive.

Further reading

  • Joshua Bloch, Effective Java (3rd edition), chapter 7, “Lambdas and Streams” (items 42 to 44), and item 52, “Use overloading judiciously”
  • Brian Goetz, “State of the Lambda” and “Translation of Lambda Expressions”, the design notes from Project Lambda
  • Raoul-Gabriel Urma, Mario Fusco and Alan Mycroft, Modern Java in Action (Manning)
  • JEP 126, “Lambda Expressions and Virtual Extension Methods”, and JEP 371, “Hidden Classes”
  • The Java Language Specification, sections 9.8 (functional interfaces), 15.13 (method references) and 15.27 (lambda expressions)
  • The java.util.function package documentation, which lists all 43 interfaces