Command Palette

Search for a command to run...

[Advanced Java] Functional Interfaces: Supplier, Consumer, Function and Predicate

A functional interface is an interface with exactly one abstract method, and that is the entire definition. The reason it matters is that a lambda has no type of its own: it is converted to a functional interface, so an interface with one abstract method is the only thing a lambda can be assigned to.

The java.util.function package supplies the ones you would otherwise keep rewriting. It looks like a wall of 43 names, and most tutorials present it as a list to memorise. It is not a list. It is a small grid, and once you can locate a name on the grid you can derive the rest instead of remembering them.

Four cards showing the four core shapes: nothing to a value, a value to nothing, a value to a value, a value to a boolean

Every program, every number and every compiler message below was produced by compiling and running the code on OpenJDK 21.0.6 (arm64). The interface counts were read out of that JDK's own module image rather than quoted from memory.

Every functional interface is a point on one grid

Two questions describe a shape completely: how many arguments does the single abstract method take, and what does it give back. Answer both and the interface name follows.

A three by three grid, rows for zero, one and two arguments, columns for returns nothing, returns a value, returns boolean

The four cells people use every day:

import java.util.function.*;

public class Shapes {
    public static void main(String[] args) {
        Supplier<String> today = () -> "2026-10-17";
        Consumer<String> log = s -> System.out.println("[log] " + s);
        Function<String, Integer> length = String::length;
        Predicate<String> blank = String::isBlank;

        System.out.println(today.get());
        log.accept("order created");
        System.out.println(length.apply("functional"));
        System.out.println(blank.test("   ") + " " + blank.test(" x "));
    }
}
2026-10-17
[log] order created
10
true false

The rest of the grid is the same four ideas with the arity or the types changed:

import java.util.function.*;

public class Shapes2 {
    public static void main(String[] args) {
        UnaryOperator<String> trim = String::trim;
        BinaryOperator<Integer> max = Integer::max;
        BiFunction<String, Integer, String> repeat = String::repeat;
        BiConsumer<String, Integer> show = (k, v) -> System.out.println(k + "=" + v);
        BiPredicate<String, Integer> fits = (s, n) -> s.length() <= n;
        Runnable task = () -> System.out.println("ran");

        System.out.println("[" + trim.apply("  padded  ") + "]");
        System.out.println(max.apply(2, 3));
        System.out.println(repeat.apply("ab", 3));
        show.accept("retries", 3);
        System.out.println(fits.test("abc", 3));
        task.run();
    }
}
[padded]
3
ababab
retries=3
true
ran

Those are the nine reference shapes — the ones whose type parameters are ordinary objects. Note that each family uses a different method name, which is deliberate: get, accept, apply and test tell you the shape at the call site even when the variable name does not.

InterfaceMethodSignatureReach for it when
Supplier<T>get() -> Ta value has to be produced later, or not at all
Consumer<T>acceptT -> voidsomething is done with a value and nothing comes back
Function<T,R>applyT -> Ra value is transformed into another value
Predicate<T>testT -> booleana value is being asked a yes-or-no question
UnaryOperator<T>applyT -> Tthe output type is the input type
BiConsumer<T,U>accept(T, U) -> voidtwo values in, nothing out
BiFunction<T,U,R>apply(T, U) -> Rtwo values in, a third out
BiPredicate<T,U>test(T, U) -> booleantwo values in, a yes-or-no answer out
BinaryOperator<T>apply(T, T) -> Ttwo of a type combine into one of that type

UnaryOperator<T> and BinaryOperator<T> add nothing at all beyond a narrower name: they extend Function<T,T> and BiFunction<T,T,T> and declare zero abstract methods of their own. Reflection on OpenJDK 21.0.6 confirms it — UnaryOperator.class.getDeclaredMethods() reports one non-synthetic member, the static identity.

The nothing-in, nothing-out corner has no entry in java.util.function because java.lang.Runnable has occupied it since Java 1.0. It was retrofitted with @FunctionalInterface in Java 8 and works as a lambda target like any other.

Counting the package on this JDK by listing /modules/java.base/java/util/function in the jrt: filesystem gives 43 types, all of them interfaces, all of them with exactly one abstract method, all of them annotated @FunctionalInterface. Nine are the reference shapes above. The other 34 are primitive specialisations of those same nine shapes, and they are covered further down.

The @FunctionalInterface annotation is a compile-time check and nothing else

The annotation does not make an interface functional. The single-abstract-method rule does that, and the compiler applies it whether or not you wrote the annotation:

interface Rule {                    // no @FunctionalInterface anywhere
    boolean holds(int n);
}

public class NoAnnotation {
    public static void main(String[] args) {
        Rule even = n -> n % 2 == 0;
        System.out.println(even.holds(4));
        System.out.println(Rule.class.isAnnotationPresent(FunctionalInterface.class));
    }
}
true
false

The lambda compiles, the interface is functional, and the annotation is absent. What the annotation buys is a guard on the declaration side: it fails the build if the interface stops being functional, so a colleague adding a second abstract method finds out immediately instead of breaking every caller.

@FunctionalInterface
interface TwoJobs {
    void start();
    void stop();
}
E1.java:1: error: Unexpected @FunctionalInterface annotation
@FunctionalInterface
^
  TwoJobs is not a functional interface
    multiple non-overriding abstract methods found in interface TwoJobs
1 error

An interface with no abstract method at all fails the same way, with a different second line:

E2.java:1: error: Unexpected @FunctionalInterface annotation
@FunctionalInterface
^
  Marker is not a functional interface
    no abstract method found in interface Marker
1 error

And so does putting it on a class:

E3.java:1: error: Unexpected @FunctionalInterface annotation
@FunctionalInterface
^
  NotAnInterface is not a functional interface
1 error
@FunctionalInterface presentabsent
Lambda may target ityes, if it has one abstract methodyes, if it has one abstract method
Second abstract method addedbuild fails at the declarationbuild fails at every lambda instead
Runtime behaviouridenticalidentical
RetentionRUNTIME, readable by reflectionnothing to read

The JDK is not consistent about applying it, and that is informative. All 43 interfaces in java.util.function carry it, as do java.lang.Runnable, java.util.concurrent.Callable and java.util.Comparator. But java.lang.Iterable, java.lang.AutoCloseable and java.util.concurrent.Executor each have exactly one abstract method — iterator, close, execute — and are functional interfaces in the language's eyes while carrying no annotation. The annotation is a statement of intent: this type exists to be implemented by a lambda. Iterable does not.

What does not count as an abstract method

Four kinds of member can sit in a functional interface without spending its one abstract slot.

default, static and private methods have bodies, so they are not abstract and never counted:

@FunctionalInterface
interface Validator<T> {
    boolean validate(T value);

    default Validator<T> andAlso(Validator<T> other) {
        return v -> validate(v) && other.validate(v);
    }

    static <T> Validator<T> alwaysTrue() {
        return v -> true;
    }

    private String tag() { return "validator"; }

    default String describe() { return tag() + " with one abstract method"; }
}

The fourth kind is the one that surprises people: a public abstract method that overrides a public method of java.lang.Object does not count either. Every implementing class inherits an implementation from Object, so there is nothing for a lambda to supply. This interface declares four abstract methods and is still functional:

@FunctionalInterface
interface Renderer {
    String render();
    @Override String toString();
    @Override int hashCode();
    @Override boolean equals(Object other);
}

public class Sam {
    public static void main(String[] args) {
        Renderer r = () -> "rendered";
        System.out.println(r.render());

        Validator<String> notEmpty = s -> !s.isEmpty();
        Validator<String> under8 = s -> s.length() < 8;
        System.out.println(notEmpty.andAlso(under8).validate("hello"));
        System.out.println(notEmpty.andAlso(under8).validate("hello world"));
        System.out.println(Validator.<String>alwaysTrue().validate(""));
        System.out.println(notEmpty.describe());
    }
}
rendered
true
false
true
validator with one abstract method

This is not a curiosity kept alive for quiz questions. java.util.Comparator uses it in the JDK itself: reflection on OpenJDK 21.0.6 shows Comparator declaring two abstract methods, compare(Object, Object) and equals(Object), while carrying @FunctionalInterface. The redeclared equals is there to document that a comparator's equality has a stricter meaning than Object's; it costs the interface nothing.

The rule is narrower than "any method Object has". It applies only to methods that are public in Object. clone() is protected, so redeclaring it is a genuine second abstract method:

@FunctionalInterface
interface Copyable {
    void apply();
    Object clone();
}
E5.java:1: error: Unexpected @FunctionalInterface annotation
@FunctionalInterface
^
  Copyable is not a functional interface
    multiple non-overriding abstract methods found in interface Copyable
1 error
MemberCounts against the one abstract slot?
default methodno
static methodno
private or private static methodno
public abstract override of equals, hashCode, toStringno
public abstract override of clone or finalizeyes — they are protected in Object
a second unrelated abstract methodyes
an abstract method inherited from a superinterface with the same signatureno — it is the same method

andThen and compose: the same two functions, two answers

Function carries two default methods that glue two functions into one. They differ only in which end the argument enters, and getting them the wrong way round is a silent bug rather than a compile error, because both return a Function of the right type.

Two pipelines built from the same two functions in opposite orders, one producing 40 and the other 31

import java.util.function.*;

public class Compose {
    public static void main(String[] args) {
        Function<Integer, Integer> plus1 = n -> n + 1;
        Function<Integer, Integer> times10 = n -> n * 10;

        System.out.println("plus1.andThen(times10).apply(3) = " + plus1.andThen(times10).apply(3));
        System.out.println("plus1.compose(times10).apply(3) = " + plus1.compose(times10).apply(3));
        System.out.println("mirror image: "
                + (plus1.andThen(times10).apply(3) == times10.compose(plus1).apply(3)));

        Function<String, Integer> len = String::length;
        Function<Integer, String> stars = n -> "*".repeat(n);
        System.out.println(len.andThen(stars).apply("hello"));
        System.out.println(Function.<String>identity().andThen(String::toUpperCase).apply("id"));
    }
}
plus1.andThen(times10).apply(3) = 40
plus1.compose(times10).apply(3) = 31
mirror image: true
*****
ID

Read f.andThen(g) as "f, and then g" — left to right, in the order written. Read f.compose(g) the way mathematics writes composition — the argument goes into g first and its result is handed to f, so the code reads inside out. f.andThen(g) and g.compose(f) build the same pipeline, which is why the third line printed true.

f.andThen(g)f.compose(g)
Runs firstfg
Reading orderleft to rightright to left
Equivalent tog.compose(f)g.andThen(f)
Signature on Function<T,R>takes a Function<? super R, ? extends V>takes a Function<? super V, ? extends T>
Available onFunction, UnaryOperator, Consumer (andThen only)Function, UnaryOperator

andThen is where the types are allowed to change: len.andThen(stars) turns a Function<String,Integer> and a Function<Integer,String> into a Function<String,String>. Function.identity() returns a function that gives back its argument, which is the neutral element of both operations and mostly useful as a placeholder in a chain that is built conditionally.

One asymmetry worth knowing: UnaryOperator<T> inherits andThen and compose from Function<T,T>, and those methods return a Function, not a UnaryOperator. Composing two UnaryOperator values gives you a Function<T,T> back, so a variable declared UnaryOperator<String> will not accept the result without an explicit lambda around it.

The Predicate and Consumer combinators

Predicate is the richest of the shapes, with three default methods and two static factories:

import java.util.function.*;

public class Preds {
    public static void main(String[] args) {
        Predicate<String> notEmpty = s -> !s.isEmpty();
        Predicate<String> under6 = s -> s.length() < 6;

        System.out.println(notEmpty.and(under6).test("java"));
        System.out.println(notEmpty.and(under6).test("javascript"));
        System.out.println(notEmpty.or(under6).test(""));
        System.out.println(notEmpty.negate().test(""));
        System.out.println(Predicate.isEqual("java").test("java"));
        System.out.println(Predicate.not(under6).test("javascript"));

        // isEqual delegates to equals() and tolerates a null target
        Predicate<Object> isNull = Predicate.isEqual(null);
        System.out.println(isNull.test(null) + " " + isNull.test("x"));

        // and/or short-circuit, so the second predicate is never called here
        Predicate<String> boom = s -> { throw new IllegalStateException("never runs"); };
        System.out.println(under6.negate().and(boom).test("java"));
    }
}
true
false
true
true
true
true
true false
false

The last two lines are the ones to notice. Predicate.isEqual(null) produces a predicate that is true only for null, because the factory checks for a null target and returns Objects::isNull rather than calling equals on nothing. And and short-circuits exactly like &&: under6.negate() is false for "java", so boom is never invoked and the IllegalStateException never happens.

MemberSinceWhat it gives you
p.and(q)Java 8short-circuiting logical AND, q skipped when p is false
p.or(q)Java 8short-circuiting logical OR, q skipped when p is true
p.negate()Java 8the logical ! of p
Predicate.isEqual(target)Java 8target.equals(x), and x == null when target is null
Predicate.not(p)Java 11the same as p.negate(), but usable on a method reference

Predicate.not exists because negate() needs a Predicate-typed receiver, and a bare method reference has no type until it is assigned. not(String::isBlank) compiles; String::isBlank.negate() does not. Compiling the same file with --release 10 reports cannot find symbol: method not(Predicate<String>), which places it in Java 11.

Consumer has one combinator, and it behaves differently from Function.andThen in a way that matters:

import java.util.*;
import java.util.function.*;

public class Cons {
    public static void main(String[] args) {
        List<String> log = new ArrayList<>();
        Consumer<String> record = log::add;
        Consumer<String> shout = s -> log.add(s.toUpperCase());

        record.andThen(shout).accept("ping");
        System.out.println(log);

        // andThen returns void, so a throw in the first consumer skips the second
        Consumer<String> bad = s -> { throw new IllegalStateException("first"); };
        try {
            bad.andThen(record).accept("pong");
        } catch (IllegalStateException e) {
            System.out.println("threw: " + e.getMessage() + ", log still " + log);
        }
    }
}
[ping, PING]
threw: first, log still [ping, PING]

Consumer.andThen does not chain a result — there is no result — it runs both consumers on the same input, in order. If the first one throws, the second never runs, which is exactly the behaviour of two statements in a row and is worth knowing before you build an audit trail out of it.

The primitive specialisations, and the boxing they remove

The other 34 interfaces in the package exist for one reason: generics cannot be instantiated with int, so Function<Integer, Integer> boxes on the way in and unboxes on the way out. The specialisations replace the erased Object signature with a primitive one.

The name of a primitive specialisation split into coloured prefix segments, with the allocation cost of boxing measured beside it

The naming scheme has three pieces, and knowing them beats memorising 34 names:

PieceMeaningExampleSignature
Int / Long / Double prefixthe argument is that primitiveIntPredicateint -> boolean
ToInt / ToLong / ToDouble prefixthe return value is that primitiveToIntFunction<T>T -> int
both, in orderprimitive in, primitive outIntToLongFunctionint -> long
Obj prefixan object argument comes firstObjIntConsumer<T>(T, int) -> void
no prefix on UnaryOperatorsame primitive in and outIntUnaryOperatorint -> int

Two consequences fall out of the scheme. Only int, long and double get specialisations — there is no ByteFunction, no CharPredicate, no FloatUnaryOperator, and code working with those either boxes or widens. And a boolean return needs no ToBoolean prefix, because Predicate already returns boolean; the single boolean specialisation is BooleanSupplier, for the nullary case that Predicate cannot express.

The method names change with the return type as well: IntFunction.apply returns an object, but ToIntFunction.applyAsInt, IntSupplier.getAsInt and BooleanSupplier.getAsBoolean all rename themselves so the primitive return is visible at the call site.

The cost of not specialising is visible in the bytecode. Both of these methods do the same work:

import java.util.function.*;

public class Bytes {
    static int boxedCall(Function<Integer, Integer> f, int n) { return f.apply(n); }
    static int primCall(IntUnaryOperator f, int n) { return f.applyAsInt(n); }
}
  static int boxedCall(java.util.function.Function<java.lang.Integer, java.lang.Integer>, int);
    Code:
       0: aload_0
       1: iload_1
       2: invokestatic  #7    // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
       5: invokeinterface #13,  2 // InterfaceMethod java/util/function/Function.apply:(Ljava/lang/Object;)Ljava/lang/Object;
      10: checkcast     #8    // class java/lang/Integer
      13: invokevirtual #19   // Method java/lang/Integer.intValue:()I
      16: ireturn

  static int primCall(java.util.function.IntUnaryOperator, int);
    Code:
       0: aload_0
       1: iload_1
       2: invokeinterface #23,  2 // InterfaceMethod java/util/function/IntUnaryOperator.applyAsInt:(I)I
       7: ireturn

Four extra instructions: a valueOf to box, a checkcast because erasure left the interface method returning Object, and an intValue to unbox. The valueOf is the one that allocates.

That allocation can be counted rather than timed. com.sun.management.ThreadMXBean.getThreadAllocatedBytes reports how many bytes a thread has allocated, so reading it either side of a loop gives an exact figure:

import com.sun.management.ThreadMXBean;
import java.lang.management.ManagementFactory;
import java.util.function.*;

public class Boxing {
    static final ThreadMXBean BEAN = (ThreadMXBean) ManagementFactory.getThreadMXBean();
    static final int N = 1_000_000;
    static long sink;

    static long allocated(Runnable body) {
        long id = Thread.currentThread().threadId();
        long before = BEAN.getThreadAllocatedBytes(id);
        body.run();
        return BEAN.getThreadAllocatedBytes(id) - before;
    }

    public static void main(String[] args) {
        Function<Integer, Integer> boxed = n -> n + 1;
        IntUnaryOperator prim = n -> n + 1;

        long a = allocated(() -> { for (int i = 0; i < N; i++) sink += boxed.apply(1000 + i); });
        long b = allocated(() -> { for (int i = 0; i < N; i++) sink += prim.applyAsInt(1000 + i); });

        System.out.println("Function<Integer,Integer> : " + a + " bytes");
        System.out.println("IntUnaryOperator          : " + b + " bytes");
        System.out.println("per boxed call            : " + a / N + " bytes");
    }
}

Run it under java -Xint, which turns the JIT off so the numbers do not depend on what the optimiser felt like doing:

Function<Integer,Integer> : 32000168 bytes
IntUnaryOperator          : 0 bytes
per boxed call            : 32 bytes

Thirty-two bytes per call, identical on every run: two Integer objects of sixteen bytes each — one for the boxed argument, one for the boxed result. The primitive version allocates nothing at all, because there is nothing to allocate.

That the 32 bytes really is boxing is easy to confirm, because Integer.valueOf returns a cached instance for values in -128..127 and allocates outside that range:

import com.sun.management.ThreadMXBean;
import java.lang.management.ManagementFactory;
import java.util.function.*;

public class Cache {
    static final ThreadMXBean BEAN = (ThreadMXBean) ManagementFactory.getThreadMXBean();
    static final int N = 1_000_000;
    static long sink;

    static long allocated(Runnable r) {
        long id = Thread.currentThread().threadId();
        long b = BEAN.getThreadAllocatedBytes(id);
        r.run();
        return BEAN.getThreadAllocatedBytes(id) - b;
    }

    public static void main(String[] args) {
        Function<Integer, Integer> f = n -> n;
        System.out.println("values 0..126, inside the Integer cache : "
                + allocated(() -> { for (int i = 0; i < N; i++) sink += f.apply(i % 127); }) + " bytes");
        System.out.println("values 1000..1126, outside it           : "
                + allocated(() -> { for (int i = 0; i < N; i++) sink += f.apply(1000 + i % 127); }) + " bytes");
    }
}
values 0..126, inside the Integer cache : 168 bytes
values 1000..1126, outside it           : 16000000 bytes

Same loop, same function, same number of calls. The only difference is whether the value is in the cache, and the difference is exactly sixteen bytes a call.

⚠️ None of this is an argument for reaching past Function by default. With the JIT on, escape analysis removes a large share of these allocations, and a boxed lambda called a few thousand times is not a problem worth naming. The specialisations earn their place in hot numeric loops and in stream pipelines over millions of elements — which is precisely where the JDK uses them.

Writing your own when the JDK shape is wrong

Three situations are not covered by the 43, and all three are legitimate reasons to declare an interface of your own.

A meaningful name. Function<Integer, Long> says nothing about what the number is. A one-method interface with a real name documents the intent and gives the parameter a name that shows up in an IDE:

@FunctionalInterface
interface PricingRule {
    long priceInCents(int quantity);
}

More than two parameters. The JDK stops at two, so a three-argument shape has to be declared. Adding your own andThen is a few lines and makes it composable:

@FunctionalInterface
interface TriFunction<A, B, C, R> {
    R apply(A a, B b, C c);

    default <V> TriFunction<A, B, C, V> andThen(Function<? super R, ? extends V> after) {
        Objects.requireNonNull(after);
        return (a, b, c) -> after.apply(apply(a, b, c));
    }
}

A checked exception. This is the common one, and it is the one where the workaround needs to be described honestly. Function.apply declares no checked exceptions, so a method reference that throws one does not fit:

A2.java:7: error: incompatible thrown types IOException in functional expression
        Function<Path, String> read = Files::readString;
                                      ^
1 error

The fix is a functional interface with a throws clause. That works perfectly as long as your own code is the caller:

@FunctionalInterface
interface ThrowingFunction<T, R, E extends Exception> {
    R apply(T t) throws E;
}

The trouble starts when a JDK method wants a real Function, and the only way through is an adapter that converts the checked exception into an unchecked one:

import java.io.*;
import java.nio.file.*;
import java.util.*;
import java.util.function.*;

public class Own {
    static <T, R, E extends Exception> Function<T, R> unchecked(ThrowingFunction<T, R, E> f) {
        return t -> {
            try {
                return f.apply(t);
            } catch (RuntimeException e) {
                throw e;
            } catch (Exception e) {
                throw e instanceof IOException io
                        ? new UncheckedIOException(io)
                        : new IllegalStateException(e);
            }
        };
    }

    public static void main(String[] args) throws IOException {
        Path p = Files.createTempFile("fi", ".txt");
        Files.writeString(p, "line one\nline two\n");

        ThrowingFunction<Path, String, IOException> read = Files::readString;
        System.out.println(read.apply(p).lines().count() + " lines read");

        Function<Path, String> adapted = unchecked(Files::readString);
        System.out.println(adapted.apply(p).strip().replace("\n", " | "));

        try {
            adapted.apply(Path.of("/no/such/file.txt"));
        } catch (UncheckedIOException e) {
            System.out.println("caught " + e.getClass().getSimpleName()
                    + ", cause " + e.getCause().getClass().getName());
        }

        PricingRule flat = qty -> 250L * qty;
        System.out.println("3 units = " + flat.priceInCents(3) + " cents");

        TriFunction<String, String, String, String> path =
                (a, b, c) -> a + "/" + b + "/" + c;
        System.out.println(path.andThen(String::toUpperCase).apply("api", "v2", "orders"));

        Files.deleteIfExists(p);
    }
}
2 lines read
line one | line two
caught UncheckedIOException, cause java.nio.file.NoSuchFileException
3 units = 750 cents
API/V2/ORDERS

Call this what it is: a workaround, with three costs you are accepting.

The first is that the checked type vanishes from the signature. adapted is a plain Function<Path,String> and nothing tells a caller it can fail; the compiler that would have forced them to handle IOException no longer says anything.

The second is that the exception arrives wrapped, so the call site has to catch the wrapper and unwrap it, and the stack trace gains a frame that means nothing.

The third shows up while writing the adapter. Making the type parameter a wildcard instead of a named E makes the wrapper look tidier and does not compile:

Wrap.java:13: error: exception IOException is never thrown in body of corresponding try statement
            } catch (IOException e) {
              ^
Wrap.java:12: error: unreported exception CAP#1; must be caught or declared to be thrown
                return f.apply(t);
                              ^
  where CAP#1 is a fresh type-variable:
    CAP#1 extends Exception from capture of ? extends Exception
2 errors

The captured type is not IOException, so catch (IOException e) is dead code as far as the compiler is concerned. The adapter has to catch Exception and sort the cases out at runtime, which is one more piece of static safety traded away. If the operation can genuinely fail, a plain loop with a try block around it is usually the better answer.

Traps that survive code review

Two overloads with the same shape are ambiguous. A lambda has no type of its own, so the compiler cannot use it to choose between two overloads whose parameters are structurally identical functional interfaces:

import java.util.function.*;

public class A1 {
    static void run(Supplier<String> s) { System.out.println("supplier " + s.get()); }
    static void run(Callable s)         { System.out.println("callable"); }

    interface Callable { String call(); }

    public static void main(String[] args) {
        run(() -> "hello");
    }
}
A1.java:10: error: reference to run is ambiguous
        run(() -> "hello");
        ^
  both method run(Supplier<String>) in A1 and method run(Callable) in A1 match
1 error

Casting the lambda — run((Supplier<String>) () -> "hello") — resolves it, but the real fix is not to publish two overloads with the same shape. Note that overloads of different shapes are fine: a Runnable and a Supplier<String> overload coexist happily, because a void-bodied lambda fits only the first and a value-bodied one only the second.

A Consumer silently discards a returned value. A lambda body that is a single method call is a statement expression, which makes it void-compatible, which means it compiles as a Consumer and throws the result away:

import java.util.*;
import java.util.function.*;

public class Traps {
    public static void main(String[] args) {
        Consumer<String> shout = s -> s.toUpperCase();
        shout.accept("ada");
        System.out.println("nothing happened");

        Set<String> seen = new LinkedHashSet<>();
        Consumer<String> record = s -> seen.add(s);
        record.accept("x");
        record.accept("x");
        System.out.println(seen + " (the second add returned false and nobody saw it)");

        List<String> queue = new ArrayList<>(List.of("a"));
        Supplier<String> size = () -> "size=" + queue.size();
        queue.add("b");
        queue.add("c");
        System.out.println(size.get());

        int[] calls = { 0 };
        Supplier<Integer> counter = () -> ++calls[0];
        System.out.println(counter.get() + " " + counter.get() + " " + counter.get());
        System.out.println("the body ran " + calls[0] + " times");

        Map<String, String> cache = new HashMap<>(Map.of("k", "cached"));
        System.out.println(cache.getOrDefault("k", expensive("eager")));
        System.out.println(cache.computeIfAbsent("k", key -> expensive("lazy")));
    }

    static String expensive(String tag) {
        System.out.println("  [expensive(" + tag + ") ran]");
        return "computed";
    }
}
nothing happened
[x] (the second add returned false and nobody saw it)
size=3
1 2 3
the body ran 3 times
  [expensive(eager) ran]
cached
cached

s -> s.toUpperCase() compiles, runs, and does nothing at all, because String is immutable and the new string goes nowhere. s -> seen.add(s) drops the boolean that would have told you the element was already present. The compiler only objects when the body is not a statement expression:

A3.java:5: error: incompatible types: lambda body is not compatible with a void functional interface
        Consumer<String> c = s -> s.length() + 1;
                                             ^
    (consider using a block lambda body, or use a statement expression instead)

A Supplier is a recipe, not a value. It captures a reference and reads through it when get() is called, which is why size.get() printed size=3 and not size=1 — the list had grown between the lambda being created and being invoked. And the body runs on every call, not once: counter.get() printed 1 2 3 and the counter reached 3. A Supplier is not memoised; if you want that, compute the value once and hold it.

That laziness is the whole point of the shape, and the last two lines show why. getOrDefault("k", expensive("eager")) evaluates its second argument before the call is even made, so expensive ran despite the key being present and the value being thrown away. computeIfAbsent("k", key -> ...) never invoked the lambda at all. The same distinction is the reason Optional.orElseGet takes a Supplier while Optional.orElse takes a plain value.

FAQ

What is a functional interface in Java?

An interface with exactly one abstract method. That single method is what a lambda or a method reference supplies a body for, so a functional interface is the only kind of type a lambda expression can be converted to. default, static and private methods do not count against the limit, and neither do public abstract redeclarations of equals, hashCode or toString, since every implementing class already inherits those from java.lang.Object.

Does an interface need @FunctionalInterface to be used with a lambda?

No. The annotation is a compile-time assertion, not a requirement: the Rule interface above has no annotation, and Rule even = n -> n % 2 == 0; compiles and runs. What the annotation adds is a build failure on the declaration the moment a second abstract method appears, instead of a build failure at every lambda that used it. The JDK applies it selectively — Runnable, Callable and Comparator carry it, while Iterable, AutoCloseable and Executor are functional interfaces without it, because they are not meant to be written as lambdas.

Can a functional interface have more than one abstract method?

It can declare more than one, as long as only one of them is left over after the exemptions are applied. java.util.Comparator declares compare and equals, and is a functional interface on OpenJDK 21.0.6, because equals(Object) overrides a public method of Object. The exemption is limited to Object's public methods: declaring Object clone() — which is protected in Object — is a real second abstract method and produces multiple non-overriding abstract methods found in interface Copyable.

What is the difference between andThen and compose?

The order the two functions run in. f.andThen(g) runs f first and feeds its result to g; f.compose(g) runs g first and feeds its result to f. With plus1 and times10 and an input of 3, plus1.andThen(times10) gives 40 and plus1.compose(times10) gives 31. They are mirror images: f.andThen(g) and g.compose(f) build the same pipeline. Consumer has only andThen, and it runs both consumers on the same input rather than chaining a result.

When should I use IntFunction instead of Function<Integer, R>?

When the calls are numerous enough for the allocation to matter. Measured on OpenJDK 21.0.6 with java -Xint, a million calls through Function<Integer,Integer> allocate 32,000,168 bytes — two 16-byte Integer objects per call, one for the argument and one for the result — while IntUnaryOperator allocates zero. In ordinary application code that difference is invisible and the JIT's escape analysis removes much of it anyway. In a hot numeric loop, or a stream over millions of elements, it is the difference the specialisations exist to buy.

Why is there no TriFunction in Java?

The JDK stops at two arguments because the combinatorics get out of hand: three arities across the reference shapes and the int, long and double specialisations would multiply the package well past its current 43 types for a shape that is rare in practice. Writing your own is four lines, and a three-argument function is often a hint that two of the arguments belong together in a record.

How do I write a lambda that throws a checked exception?

You cannot, for a JDK functional interface — Files::readString as a Function gives incompatible thrown types IOException in functional expression. Declare your own interface with a throws clause and use that where you control the calling API. Where you do not, an adapter that catches and rewraps is the usual workaround, and it costs you the checked type in the signature, an extra wrapper on the exception, and the ability to write catch (IOException e) inside the adapter itself when the exception type is a type variable.

Conclusion

Forty-three interfaces is a lot to remember and almost nothing to understand. They are nine shapes, defined by how many arguments go in and what comes back, plus 34 mechanical primitive specialisations whose names you can derive from three prefixes. The one abstract method is the whole contract; @FunctionalInterface only checks it, and default, static, private and Object-overriding methods are all free — which is how Comparator gets to declare equals and stay a lambda target.

The rest is composition and cost. andThen reads forwards and compose reads backwards, and the compiler will not catch the mix-up because both type-check. Predicate.and and or short-circuit, Consumer.andThen fans out rather than chains, and a Supplier is a recipe that runs on every get() and reads its captured references at that moment, not at the moment it was written. Where the JDK's shapes do not fit — a checked exception, three parameters, a name that carries meaning — declaring your own is cheap and usually clearer than bending a Function into place.

Next in this series: Optional — what it is actually for, why get() is the method you should almost never call, map, flatMap and filter, the difference between orElse and orElseGet that this article's last trap already hinted at, and the places Optional does not belong.

Related Posts

[Advanced Java] The Java Stream API: map, filter, reduce and collect

The Java Stream API on OpenJDK 21: the source-intermediate-terminal pipeline, laziness proved with an interleaved println trace, map, filter, all three reduce overloads, collect and the Collectors factory, primitive streams and the allocation cost of boxing, and the traps around peek, findAny, stateful lambdas and parallelStream.

[Advanced Java] Generics in Java: Type Parameters, Bounded Types, Wildcards and Type Erasure

Generics in Java on OpenJDK 21: the pre-generics Object container and the ClassCastException it produced, writing generic classes and generic methods, bounded and multiply-bounded type parameters, wildcards and PECS with the exact javac errors, type erasure proved with javap, the Signature attribute, bridge methods, and what SuppressWarnings unchecked really promises.

[Advanced Java] Advanced Enums in Java: Constructors, Constant Bodies, EnumMap and the Enum Singleton

Advanced enums in Java on OpenJDK 21: what javap shows an enum actually compiles to, fields and the implicitly private constructor, constant-specific class bodies and the extra class files they emit, abstract methods, enums implementing interfaces, EnumMap and EnumSet, exhaustive switch, the enum singleton reflection refuses to break, an enum state machine, and the ordinal and values() traps.

[Advanced Java] Advanced Map Implementations: TreeMap, LinkedHashMap, Hashtable and ConcurrentHashMap

The Map implementations beyond HashMap on OpenJDK 21 - TreeMap and the NavigableMap methods floorEntry, ceilingKey, headMap and subMap, LinkedHashMap access order and a five-line LRU cache with removeEldestEntry, why Hashtable is legacy, and what ConcurrentHashMap actually promises about locking, weakly consistent iterators and atomic compound operations.