A method reference is compiler shorthand for a lambda that does nothing but call one existing method. String::toUpperCase and s -> s.toUpperCase() compile to the same invokedynamic call site. The value of method references is not brevity for its own sake — it is that they force the parameter mapping to be exact, so the compiler rejects a whole class of argument-order mistakes that a lambda would happily accept.
The four kinds
Every method reference is one of four shapes. The distinction matters because it changes what becomes the receiver and what becomes an argument:
| Kind | Syntax | Equivalent lambda |
|---|---|---|
| Static | Integer::parseInt | s -> Integer.parseInt(s) |
| Bound instance | logger::info | msg -> logger.info(msg) |
| Unbound instance | String::toUpperCase | s -> s.toUpperCase() |
| Constructor | ArrayList::new | () -> new ArrayList<>() |
The subtle pair is bound vs unbound. In a bound reference the receiver is fixed at the point you write the reference (logger is captured now). In an unbound reference the receiver is supplied later as the first functional-interface argument — which is why String::toUpperCase satisfies Function<String,String>: the single input string becomes the receiver of toUpperCase().
How the compiler resolves one
A method reference is resolved against the target type — the functional interface the expression is being assigned to. The compiler takes that interface's single abstract method's signature and looks for a method that can be invoked with those parameters, threading the receiver in for the unbound case.
// target type Function<String,Integer>: (String) -> Integer
Function<String,Integer> f = Integer::parseInt; // matches static int parseInt(String)
// target type BiFunction<String,String,Boolean>: (String,String) -> Boolean
BiFunction<String,String,Boolean> eq = String::equals; // unbound: 1st arg is receiverBecause resolution depends on the target type, the same reference text can mean different things in different contexts — and an unresolvable one is a compile error, never a runtime surprise.
When you cannot use one
A method reference only works when the call is a pure pass-through. The moment you need to touch the arguments, add a constant, reorder, or call more than one method, you must drop back to a lambda:
list.stream().map(String::trim) // OK: pure pass-through
list.stream().map(s -> s.trim().toLowerCase()) // needs a lambda: two calls
list.stream().map(s -> "id-" + s) // needs a lambda: transforms the arg
list.stream().filter(s -> s.length() > 3) // needs a lambda: uses a literalThis is the actual decision rule — not style preference. If the body is exactly one existing method applied to the parameters in order, a method reference is available; otherwise it is not.
Gotchas that bite in production
The bound receiver is evaluated eagerly. obj::method captures obj at the point the reference is created, exactly like a lambda closing over it. If obj is null there, you get an immediate NullPointerException — not a deferred one when the function is finally called.
Runnable r = maybeNull::run; // NPE thrown HERE if maybeNull is null,
// even though run() is never invokedOverload ambiguity. If the target type could match more than one overload of the referenced method, the reference is rejected as ambiguous and you must disambiguate with an explicit lambda. Side effects in the receiver expression run once, at capture time — a common trap with getService()::call when getService() is not idempotent.
obj::m) is captured eagerly, so a null there fails at creation, not at call.