Command Palette

Search for a command to run...

[Advanced Java] Lambda Expressions in Java: Syntax, Target Typing and Method References

A lambda expression has no type. The text x -> x.length() is not a Function, not a ToIntFunction and not anything else until you write it somewhere that already expects one of them; the compiler then reads that expectation and builds the lambda to fit it. Delete the surrounding context and the expression stops being legal Java.

That one fact explains almost everything that surprises people about lambdas: why var f = x -> x.length(); does not compile, why two overloads can make an obviously unambiguous lambda ambiguous, why a checked exception cannot escape a lambda body even when the enclosing method declares it, and why ArrayList::new picks a different constructor in different places. This article works through every syntactic form, the capture rules and the compiler errors that enforce them, what this means inside a lambda body, and the four kinds of method reference.

One lambda text fanning out into three different interface types

Every error message and every line of output below was produced by compiling and running the code on OpenJDK 21.0.6 (arm64). Where a form depends on the language level, the boundary was found by recompiling the same source with --release and the release where it changes is named.

An earlier article in this series already compared a lambda against an anonymous class in detail — the different this, the class file one writes and the other does not, invokedynamic against new — so that comparison is not repeated here. This article starts from the syntax and goes forward.

Every form a lambda can take

A lambda is a parameter list, an arrow, and a body. Everything else is a variation on those three parts.

Supplier<String> zero = () -> "no parameters";
Runnable noResult = () -> System.out.println("runnable body, no result");

Function<String, Integer> oneBare = s -> s.length();
Function<String, Integer> oneParen = (s) -> s.length();

BinaryOperator<Integer> two = (a, b) -> a + b;
TriString three = (a, b, c) -> a + b + c;

Function<Integer, Integer> expr = n -> n * 2;
Function<Integer, Integer> block = n -> {
    int doubled = n * 2;
    return doubled;
};

BiFunction<String, Integer, String> inferred = (s, n) -> s.repeat(n);
BiFunction<String, Integer, String> explicit = (String s, Integer n) -> s.repeat(n);
BiFunction<String, Integer, String> withVar = (var s, var n) -> s.repeat(n);
BiFunction<String, Integer, String> finalVar = (final var s, final var n) -> s.repeat(n);
zero      -> no parameters
runnable body, no result
oneBare   -> 6
oneParen  -> 6
two       -> 5
three     -> abc
expr      -> 42
block     -> 42
inferred  -> ababab
explicit  -> ababab
withVar   -> xyxy
finalVar  -> xyxy

TriString there is a three-argument interface written by hand, because the standard library stops at two. Nothing about the lambda syntax cares how many parameters there are.

Zero, one and several parameters

The parentheses around the parameter list are mandatory except in exactly one case: a single parameter whose type is inferred. s -> s.length() and (s) -> s.length() are the same lambda. As soon as you write a type, a modifier or an annotation, or as soon as there is more than one parameter, the parentheses come back.

ParametersWrittenParentheses
none() -> "x"required
one, inferreds -> s.length() or (s) -> s.length()optional
one, explicit(String s) -> s.length()required
several(a, b) -> a + brequired

Getting the count wrong is a type error, not a syntax error, because the count has to match the abstract method of the target:

E17.java:4: error: incompatible types: incompatible parameter types in lambda expression
    static Function<String, Integer> f = (a, b) -> 1;
                                         ^
1 error

Expression body versus block body

An expression body is a single expression with no braces and no return; its value is the value of the lambda. A block body is braces, statements, and an explicit return where the target expects a result.

The compiler checks the body against the target's return type, and the two failure directions have their own messages. Returning a value where the target returns void:

E15.java:2: error: incompatible types: bad return type in lambda expression
    static Runnable r = () -> { return 1; };
                                       ^
    unexpected return value
1 error

And falling off the end of a block where the target expects a result:

E16.java:4: error: incompatible types: bad return type in lambda expression
    static Supplier<String> s = () -> { };
                                ^
    missing return value
1 error

There is one asymmetry worth knowing. An expression body whose expression is a method call is void-compatible: the same text fits a target that wants the result and a target that discards it. List.add returns boolean, and both of these compile:

List<String> list = new ArrayList<>(List.of("a"));
Consumer<String> asConsumer = s -> list.add(s);
Function<String, Boolean> asFunction = s -> list.add(s);
Consumer discards the result -> [a, b]
Function keeps it            -> true

In a block body targeting a void method, return; on its own is legal and return value; is not — which is the error above.

Inferred types, explicit types and var

Three ways to write the parameter types, and they cannot be mixed:

BiFunction<String, Integer, String> inferred = (s, n) -> s.repeat(n);
BiFunction<String, Integer, String> explicit = (String s, Integer n) -> s.repeat(n);
BiFunction<String, Integer, String> withVar = (var s, var n) -> s.repeat(n);

Inferred is the default and the one you should write. Explicit types are worth it when the inferred type is genuinely unclear to a reader, or when you need a wildcard-free type to help overload resolution.

var parameters exist for one reason: a parameter with no declaration has nowhere to put a modifier or an annotation. final s and @NonNull s are not grammatical — the parser gives up before it gets to the arrow:

E5.java:4: error: <identifier> expected
    static Function<String, Integer> f = (final s) -> s.length();
                                                 ^
1 error
E4.java:8: error: illegal start of expression
    static Function<String, Integer> f = (@NonNull s) -> s.length();
                                          ^
E4.java:8: error: ')' expected
    static Function<String, Integer> f = (@NonNull s) -> s.length();
                                                  ^
E4.java:8: error: ';' expected
    static Function<String, Integer> f = (@NonNull s) -> s.length();
                                                    ^
E4.java:8: error: <identifier> expected
    static Function<String, Integer> f = (@NonNull s) -> s.length();
                                                                 ^
4 errors

Write var and both become legal, while the type is still inferred:

Function<String, Integer> ok = (@NonNull var s) -> s.length();
BiFunction<String, Integer, String> finalVar = (final var s, final var n) -> s.repeat(n);

That is the whole purpose of the feature, added in Java 11. On an older language level the syntax is rejected outright, and the message names the release:

V.java:4: error: var syntax in implicit lambdas are not supported in -source 10
    static Function<String, Integer> f = (var s) -> s.length();
                                              ^
  (use -source 11 or higher to enable var syntax in implicit lambdas)
1 error

You cannot mix the three styles inside one parameter list, and javac has a separate note for each combination:

E1.java:4: error: invalid lambda parameter declaration
    static BiFunction<String, Integer, String> f = (String s, n) -> s.repeat(n);
                                                   ^
  (cannot mix implicitly-typed and explicitly-typed parameters)
1 error
E2.java:4: error: invalid lambda parameter declaration
    static BiFunction<String, Integer, String> f = (var s, Integer n) -> s.repeat(n);
                                                   ^
  (cannot mix 'var' and explicitly-typed parameters)
1 error
E3.java:4: error: invalid lambda parameter declaration
    static BiFunction<String, Integer, String> f = (var s, n) -> s.repeat(n);
                                                   ^
  (cannot mix 'var' and implicitly-typed parameters)
1 error

All or nothing, per lambda.

Target typing: a lambda has no type of its own

This is the idea the rest of the article depends on. A lambda expression is not a value with a type that the compiler then checks against the context. It is a poly expression: the context is read first, and the lambda is compiled into whatever that context requires.

One lambda text compiled into three different interface types, and rejected when there is no target

The same text, three unrelated types

Three declarations, one lambda text, character for character identical:

interface Sizer { int sizeOf(String s); }

Function<String, Integer> asFunction = x -> x.length();
ToIntFunction<String>     asToInt    = x -> x.length();
Sizer                     asSizer    = x -> x.length();
one lambda text, three types:
  Function
  ToIntFunction
  Sizer
asFunction.apply("target")  -> 6
asToInt.applyAsInt("target")-> 6
asSizer.sizeOf("target")    -> 6
Function boxes  -> java.lang.Integer

Three different interfaces, three differently named abstract methods, and one of them boxes the int into an Integer while the other two do not. Function and ToIntFunction are not related by inheritance; neither is Sizer, which is a hand-written interface with no connection to the standard library. Nothing about the lambda decided any of this. The variable's declared type did.

A lambda with nothing to infer from

If the context has no type to give, there is nothing to compile the lambda into. var is exactly that context, and the compiler says so in as many words:

E6.java:3: error: cannot infer type for local variable f
        var f = x -> x.length();
            ^
  (lambda expression needs an explicit target-type)
1 error

Object fails too, for the same reason spelled differently — Object is a class, not a functional interface, so there is no abstract method for the lambda to become:

E7.java:2: error: incompatible types: Object is not a functional interface
    static Object o = () -> System.out.println("x");
                      ^
1 error

A cast is a context, so a cast fixes both:

var f = (Function<String, Integer>) x -> x.length();
var with a cast -> 4

When two overloads both match

If a lambda can satisfy more than one overload, there is no rule that prefers one, and the call does not compile. Two single-method interfaces with compatible shapes are enough:

interface Callable { String call(); }

static void run(Supplier<String> s) { ... }
static void run(Callable c)         { ... }

run(() -> "ambiguous");
E8.java:10: error: reference to run is ambiguous
        run(() -> "ambiguous");
        ^
  both method run(Supplier<String>) in E8 and method run(Callable) in E8 match
1 error

Supplier<String> and Callable are unrelated types with the same shape — no parameters, returns a String — and the lambda fits both equally well. The fix is to supply the context yourself:

run((Supplier<String>) () -> "picked by the cast");
run((Callable) () -> "picked by the cast");
supplier -> picked by the cast
callable -> picked by the cast

The design lesson points the other way, though: if you are writing the API, do not overload a method on two functional interface types with the same shape. Every caller will have to write a cast.

Capture: which variables a lambda may read

A lambda body can read the enclosing method's local variables. It does that by copying them into the object the lambda becomes, which is why the copy has to be a snapshot of something that will not move.

The same counter written as a local and as a one-element array, showing what capture copies

Effectively final, and the error you get

A captured local must be effectively final: never assigned after its initialisation, whether or not you wrote final. Assign to it anywhere in the method and the capture stops compiling — the error points at the use inside the lambda, not at the assignment:

int count = 0;
Supplier<Integer> s = () -> count;
count = 1;
E9.java:6: error: local variables referenced from a lambda expression must be final or effectively final
        Supplier<Integer> s = () -> count;
                                    ^
1 error

Assigning from inside the lambda is the same violation, reported twice because count++ both reads and writes:

E10.java:6: error: local variables referenced from a lambda expression must be final or effectively final
        Supplier<Integer> s = () -> { count++; return count; };
                                      ^
E10.java:6: error: local variables referenced from a lambda expression must be final or effectively final
        Supplier<Integer> s = () -> { count++; return count; };
                                                      ^
2 errors

Note the wording: a lambda expression. The equivalent error for an anonymous or local class says "an inner class" instead, so the message alone tells you which construct the compiler is complaining about.

The classic trap is the basic for loop, which reuses one variable and increments it:

for (int i = 0; i < 3; i++) {
    out.add(() -> i);
}
E11.java:8: error: local variables referenced from a lambda expression must be final or effectively final
            out.add(() -> i);
                          ^
1 error

Copy the value into a fresh local inside the body and it compiles:

for (int i = 0; i < 3; i++) {
    int captured = i;
    out.add(() -> captured);
}
0 1 2

The enhanced for needs no such copy, because it declares a new variable on each iteration.

Fields and array elements are not restricted

The rule is about locals, and only locals. Fields are not captured at all — the lambda captures this (or nothing, for a static field) and reads the field through it, at call time. So a field can be assigned as much as you like:

private int instanceField = 0;
private static int staticField = 0;

void fields() {
    Runnable r = () -> { instanceField++; staticField++; };
    r.run(); r.run(); r.run();
}
instanceField -> 3
staticField   -> 3

Array elements behave the same way, and this is where the one-element-array workaround comes from. The reference counter is never reassigned, so the effectively-final rule is satisfied; the element it points at is not a local variable and is not protected:

int[] counter = { 0 };
Runnable r = () -> counter[0]++;
r.run(); r.run(); r.run();
counter[0]    -> 3

That compiles and it works. It is still a smell rather than a trick, for two reasons. It defeats a check the language put there on purpose — a mutable cell shared between a method and a lambda that may run later, on another thread, with no memory-visibility guarantee at all. And it usually means the code is fighting the shape of the problem: a counter accumulated inside a forEach is nearly always a count, a sum or a reduce written the wrong way round. When you genuinely need a shared mutable cell, use AtomicInteger or a field, both of which say what they mean.

⚠️ The one-element array is not thread safe. counter[0]++ is a read, an add and a write with no synchronisation, and nothing in the lambda machinery makes it atomic.

Capture copies the reference, not the object

Capture copies the value of the variable. For an object that value is a reference, so the lambda and the enclosing method end up pointing at the same object — and it can change under you between the moment you write the lambda and the moment it runs:

List<String> seen = new ArrayList<>();
Supplier<String> s = () -> "seen = " + seen;
seen.add("added after the lambda was created");
System.out.println(s.get());
seen = [added after the lambda was created]

seen was empty when the lambda was created. The lambda never sees an empty list, because it never captured a list — it captured a reference to one. Freezing the variable and freezing the object are different things, and Java only does the first.

this inside a lambda is the enclosing object

Inside a lambda body, this is the enclosing instance. There is no new object for it to refer to — the earlier article in this series showed that with a getClass() printout on both sides. Two consequences of that fact are worth their own examples.

A lambda stored in a field runs later

A lambda created in a constructor and kept in a field is a piece of code that will execute at some unknown later time, with this and every field read at that moment:

static class Widget {
    String name = "widget-1";
    final Supplier<String> describe;

    Widget() {
        describe = () -> "this is a " + this.getClass().getSimpleName() + " named " + name;
    }
}
stored lambda, invoked later -> this is a Widget named widget-1
after the field changed      -> this is a Widget named renamed

getSimpleName() returns Widget, not a generated lambda name: this inside the body is the Widget, exactly as it would be in an ordinary method. And name is read on each call, so renaming the field changes what the stored lambda says. Neither of those is true of a captured local, which is frozen at creation.

There is a second-order trap here. A lambda assigned in a constructor is created before the constructor finishes, so if the body reads a field that a later line of the constructor initialises, the lambda will see the value that exists when it runs, not when it was created. That is usually what you want, and it is the opposite of what people expect from capture.

A lambda that reads a field keeps its object alive

Because reading a field means capturing this, a lambda that touches one instance field holds a strong reference to the entire enclosing object — array, cache, buffer and all. Two Runnables that print the same string make the difference visible:

Runnable readsField() { return () -> System.out.println(name); }

Runnable copiesValue() {
    String copy = name;
    return () -> System.out.println(copy);
}

Drop the only reference to the Widget, keep only the Runnable, and ask a WeakReference whether the Widget survived:

lambda reading the field   -> STILL REACHABLE
lambda holding a copy      -> collected
readsField  captured a Widget
copiesValue captured a String

The last two lines are the captured field types, read back with reflection. One lambda captured a Widget; the other captured a String. A lambda handed to an executor, an event bus or a static registry keeps whatever it captured for as long as the registry holds it, so read the field into a local first when the lambda will outlive the object.

The four kinds of method reference

A method reference is a lambda whose body does nothing but call one method with the parameters it was given. Integer::parseInt and s -> Integer.parseInt(s) compile to the same thing and mean the same thing. Almost every article lists the four kinds; the useful question is what separates them, and the answer is always where the receiver comes from.

Bound and unbound method references shown as two ways of filling the receiver slot

KindWrittenEquivalent lambdaReceiver
staticInteger::parseInts -> Integer.parseInt(s)none
bound instanceprefix::startsWitha -> prefix.startsWith(a)fixed when the reference is created
unbound instanceString::startsWith(r, a) -> r.startsWith(a)the first parameter
constructorArrayList::new() -> new ArrayList<>()created by the call
Function<String, Integer> parse = Integer::parseInt;

String prefix = "java.util.List";
Predicate<String> startsWithIt = prefix::startsWith;

BiPredicate<String, String> startsWith = String::startsWith;
ToIntFunction<String> length = String::length;

Supplier<List<String>> newList = ArrayList::new;
IntFunction<int[]> newArray = int[]::new;
Function<String, StringBuilder> newSb = StringBuilder::new;
static   Integer::parseInt   -> 42
bound    prefix::startsWith  -> true
unbound  String::startsWith  -> true
unbound  String::length      -> 14
ctor     ArrayList::new      -> []
ctor     int[]::new          -> [0, 0, 0]
ctor     StringBuilder::new  -> seeded

int[]::new is the array-constructor form, and it means n -> new int[n]. It is the reason toArray takes one: List.of("x", "y").toArray(String[]::new) gives a String[].

Constructor references are target-typed like everything else, so the same text selects a different constructor depending on where it is written:

Supplier<ArrayList<String>> empty = ArrayList::new;
IntFunction<ArrayList<String>> sized = ArrayList::new;
Function<Collection<String>, ArrayList<String>> copy = ArrayList::new;
Supplier    -> []
IntFunction -> []
Function    -> [a, b]

Three constructors of ArrayList — the no-arg one, the capacity one and the copy one — picked purely by the declared type on the left.

Bound versus unbound

This is the pair that confuses people, and the same method name written both ways makes the difference obvious:

String prefix = "java.util.List";
Predicate<String> bound = prefix::startsWith;
BiPredicate<String, String> unbound = String::startsWith;

bound.test("java.util");                        // true
unbound.test("java.util.List", "java.util");    // true

In prefix::startsWith, the receiver is the value of prefix, decided once. The functional method takes the arguments startsWith takes: one String. In String::startsWith, String is a type, not a value, so there is no receiver yet; it is supplied as the first argument on every call. The functional method therefore takes one parameter more.

That extra parameter is the whole signature difference: a bound reference to an n-argument method fits a functional method of n parameters, an unbound one fits a functional method of n+1. String::length takes no arguments, so unbound it becomes a one-parameter ToIntFunction<String>.

Read the part before the :: to tell them apart. If it is a value — a variable, a field, this, a method call — the reference is bound. If it is a type name, it is unbound, unless the method is static.

A bound reference evaluates its receiver immediately

Here is the difference that actually bites, and it is the one case where a method reference is not equivalent to the lambda it looks like. The receiver expression of a bound method reference is evaluated at the point the reference is created, not each time it is called. The lambda form evaluates it on every call.

static StringBuilder target = new StringBuilder("first");

Supplier<String> viaMethodRef = target::toString;
Supplier<String> viaLambda    = () -> target.toString();

target = new StringBuilder("second");
target::toString    -> first
() -> target.toString() -> second

Two expressions that read identically, and they disagree. The method reference captured the object target pointed at when the reference was made; the lambda reads the field each time.

The sharp edge is a null receiver. A bound method reference dereferences it immediately:

with target = null:
  creating target::toString threw java.lang.NullPointerException
  at java.base/java.util.Objects.requireNonNull(Objects.java:233)
  creating () -> target.toString() threw nothing
  calling it threw java.lang.NullPointerException: Cannot invoke "java.lang.StringBuilder.toString()" because "Eager.target" is null

The stack frame names it: the runtime calls Objects.requireNonNull on the receiver while building the reference. So map.get(k)::toString throws the moment the map misses, not when something later calls the function — and the exception carries no helpful message, because it comes from the plumbing rather than from your code. Where the receiver may be null or may change, write the lambda.

When a method reference is ambiguous

A type may have both a static and an instance method of the same name, and then the reference form cannot decide which one you meant. Integer is the standard example, with toString(int) and toString():

E18.java:4: error: incompatible types: invalid method reference
    static Function<Integer, String> f = Integer::toString;
                                         ^
    reference to toString is ambiguous
      both method toString(int) in Integer and method toString() in Integer match
1 error

Both readings produce a one-argument function from an integer to a String, so the target type cannot break the tie either. Write the lambda, or pick a target that only one of them fits:

Function<Integer, String> unbound = i -> i.toString();
IntFunction<String> staticOne = Integer::toString;
unbound   -> 42
staticOne -> 42

Checked exceptions cannot escape a lambda

A lambda body may only throw what the target's abstract method declares. Function.apply declares nothing, so a checked exception inside it is a compile error:

Function<String, String> reader = p -> Files.readString(Path.of(p));
E12.java:6: error: unreported exception IOException; must be caught or declared to be thrown
    static Function<String, String> reader = p -> Files.readString(Path.of(p));
                                                                  ^
1 error

Adding throws IOException to the enclosing method does not help, and this catches people out. The lambda body is not executed by the enclosing method — it is executed by whoever calls apply, possibly much later, possibly on another thread — so the enclosing method's throws clause is irrelevant:

E13.java:7: error: unreported exception IOException; must be caught or declared to be thrown
        return p -> Files.readString(Path.of(p));
                                    ^
1 error

There are two honest fixes. Both are shown below; the dishonest ones — sneakyThrows tricks and swallowing the exception — are worse than either.

Catch inside the lambda

Handle it where it happens, and if there is nothing sensible to do, translate it to an unchecked exception that keeps the cause:

static Function<String, String> wrapping = p -> {
    try {
        return Files.readString(Path.of(p));
    } catch (IOException e) {
        throw new UncheckedIOException(e);
    }
};
wrapping  -> the file contents
wrapping on a missing file -> java.nio.file.NoSuchFileException: missing.txt

UncheckedIOException exists for exactly this and is the right wrapper for IOException; for anything else, RuntimeException with the cause attached is fine. The important part is the cause: e.getCause() is still the original NoSuchFileException, so no diagnostic information is lost.

Declare an interface that throws

If the exception genuinely belongs to the caller, the problem is that you picked a target type that forbids it. Write one that does not:

interface IoFunction<T, R> { R apply(T t) throws IOException; }

static IoFunction<String, String> declaring = p -> Files.readString(Path.of(p));
declaring -> the file contents

Now the checked exception propagates normally, and every caller of declaring.apply(...) has to handle it — which is what a checked exception is for. java.util.concurrent.Callable is the standard library's version of this idea: its call() method declares throws Exception, which is why a lambda passed to an ExecutorService may throw whatever it likes.

The trade-off is real: an interface of your own does not compose with the standard library, so a Stream or a Comparator will not take it. Use the first fix at the boundary where you hand code to a standard-library method, and the second when the whole call chain is yours.

Writing lambdas someone else can read

A few rules that hold up in practice.

Keep the body to one expression. A lambda is at its best when the reader can take it in without scanning. Once the body has braces and more than two or three statements, it is a method that happens to be written inside an argument list.

If the body needs a comment, extract it. The comment is telling you the code has a name. Give it one and pass a method reference instead:

static boolean isBillable(Order o) {
    return o.paid() && o.cents() >= 10_00;
}

billable.removeIf(Predicate.not(Practice::isBillable));
billable -> [A-1, A-4]

The version with the predicate inline needs a comment to explain what "billable" means. The version with a named method does not, and the name is now testable, reusable and greppable.

A lambda you cannot name is often a lambda you do not understand. Try naming the method you would extract. If no name comes, the body is doing more than one thing, and that is the actual problem.

Prefer a method reference when it is exactly equivalentOrder::cents reads better than o -> o.cents() — but not when the receiver may be null or may change, for the reason shown above.

Do not capture more than you need. A lambda that reads one field of a big object captures the whole object. Read it into a local first if the lambda outlives the method.

Applied to a small piece of real code, the whole vocabulary of this article fits in six lines:

sorted.sort(Comparator.comparingInt(Order::cents).reversed());
billable.removeIf(Predicate.not(Practice::isBillable));
ORDERS.forEach(o -> totals.merge(o.customer(), o.cents(), Integer::sum));
sorted by cents desc:
  A-2 9900
  A-4 4200
  A-1 1250
  A-3 500
billable -> [A-1, A-4]
totals   -> {kim=1750, linh=9900, minh=4200}

Order::cents is unbound, Practice::isBillable and Integer::sum are static, and the one lambda left is the one that does something a named method would not describe better.

FAQ

What is a lambda expression in Java?

A parameter list, an arrow and a body, written where a functional interface is expected: s -> s.length(). It is not an object with a type of its own — it is a poly expression that takes the type of the context it appears in, which is why the identical text can become a Function, a ToIntFunction or a hand-written interface depending only on the variable it is assigned to. Java gained lambdas in Java 8.

Why do I get "lambda expression needs an explicit target-type"?

Because you wrote a lambda somewhere that supplies no type to infer from — almost always var. var f = x -> x.length(); fails with cannot infer type for local variable f and that note, because var copies the type of the initialiser and the initialiser has no type until something tells it what to be. Declare the variable with a functional interface type, or cast the lambda: var f = (Function<String, Integer>) x -> x.length();.

Why must variables used in a lambda be final or effectively final?

Because capture copies the value into a field of the object the lambda becomes, at the moment the lambda is created. That object outlives the method's stack frame, so there is no shared storage to keep the field and the local in step. If the local could be reassigned afterwards, the two would silently disagree. The compiler refuses instead, with local variables referenced from a lambda expression must be final or effectively final. The restriction is on reassigning the variable only — the object it points at can still be mutated.

Can a lambda modify a variable from the enclosing method?

Not a local variable. It can modify a field, because a field is read through this at call time rather than captured; and it can modify the contents of an object or an array it captured, because only the reference is frozen. That is why int[] counter = { 0 }; followed by () -> counter[0]++ compiles while int count = 0; followed by () -> count++ does not. The array version works but is not thread safe and usually indicates the loop should have been an accumulation.

What does this refer to inside a lambda?

The enclosing instance — the same object this refers to on the line above the lambda. A lambda body is not a new scope for this, unlike an anonymous class body, where this is the new anonymous object. The practical consequence is that a lambda reading an instance field captures the whole enclosing object, so a lambda registered with something long-lived keeps that object reachable.

What is the difference between a bound and an unbound method reference?

A bound reference names a value before the ::prefix::startsWith — and that value is the receiver, evaluated once when the reference is created. An unbound reference names a type — String::startsWith — so the receiver is supplied as the first argument on every call, and the functional method takes one parameter more: Predicate<String> versus BiPredicate<String, String>. Read what is to the left of the ::: a value means bound, a type means unbound unless the method is static.

When is a method reference not the same as the equivalent lambda?

When the receiver expression matters. target::toString evaluates target when the reference is created; () -> target.toString() evaluates it on every call. If target is reassigned in between, the two return different things, and if target is null, the method reference throws NullPointerException immediately — from Objects.requireNonNull inside the reference machinery — while the lambda throws only when it is called. Use the lambda whenever the receiver may change or may be null.

How do I throw a checked exception from a lambda?

You cannot, unless the target's abstract method declares it. Function.apply declares nothing, so p -> Files.readString(Path.of(p)) fails with unreported exception IOException, and adding throws to the enclosing method does not help because the enclosing method is not what runs the body. Either catch inside the lambda and rethrow unchecked — UncheckedIOException for IOException — or define your own interface whose method declares the exception, the way Callable.call() declares throws Exception.

Conclusion

A lambda is not shorthand and it is not an object literal. It is a request to the compiler: build me something of whatever type this position needs, with this body. Take the position away and there is nothing left to compile, which is what var f = x -> ... proves. Everything else follows from that. The parameter styles cannot be mixed because they are one declaration, not several. The same text becomes Function, ToIntFunction or your own interface with no cast, and becomes ambiguous when two overloads both fit. Locals are captured by value and must be effectively final; fields are not captured at all, they are read through the this the lambda kept — which is also why a lambda in a registry can keep a whole object alive.

Method references are the same machinery with the body inferred, and the four kinds differ only in where the receiver comes from: nowhere, a value fixed at creation, the first argument, or a fresh object. The one place the equivalence breaks is that fixed receiver, evaluated eagerly, null-checked on the spot.

Next in this series: functional interfacesSupplier, Consumer, Function and Predicate, what @FunctionalInterface actually enforces, composing them with andThen and compose, and the primitive specialisations that exist to keep boxing out of hot code.

Related Posts

[Advanced Java] Set Implementations in Java: HashSet, LinkedHashSet and TreeSet

HashSet, LinkedHashSet and TreeSet on OpenJDK 21: what a Set actually guarantees, the HashMap hiding inside HashSet, NavigableSet lookups, the two different rules that decide a duplicate, compareTo that disagrees with equals, ClassCastException and null in a TreeSet, EnumSet, and why removeAll can go quadratic.

[Advanced Java] SOLID Principles in Java: Five Rules and When to Break Them

The five SOLID principles in Java on OpenJDK 21, each with a before and after that compiles and runs: a class split by its reasons to change, a growing switch replaced by an interface, a subclass that breaks its caller with no warning, an UnsupportedOperationException the compiler could have prevented, a class that cannot run without a file, and where each principle stops paying for itself.

[Advanced Java] Comparable vs Comparator in Java: Natural Ordering and Custom Sorting

Comparable and Comparator on OpenJDK 21: the full compareTo contract, the Comparator factory and combinator API, why reversed flips every key composed so far, the int subtraction overflow bug, the TimSort IllegalArgumentException that only fires on large inputs, null keys, and sorting stability measured in comparator invocations.

[Advanced Java] Common Design Patterns in Java: Singleton, Factory, Builder, Observer and Strategy

Five design patterns in Java on OpenJDK 21, each one demonstrated inside the JDK itself and each one shown where it does damage: singleton initialisation and the race an unsynchronised null check loses, Integer.valueOf and its cache, a staged builder the compiler checks, a listener that leaks and a listener that stops the broadcast, and Comparator as the strategy type you have already been using.