Method references
Method references in Java 21: static, bound, unbound, constructor and array constructor forms, signature matching, readability defaults, overload ambiguity, and eager receiver evaluation.
On this page
🔗 What is a method reference?
A method reference is shorthand for a lambda whose whole job is to call an existing method or constructor. Order::totalCents can mean order -> order.totalCents(), and ArrayList::new can mean () -> new ArrayList<>(). It is still typed by a functional interface; the reference itself does not carry a standalone function type.
For this lesson, we'll focus on the four forms you need for interviews: static, bound instance, unbound instance, and constructor references, including array constructors like int[]::new. We'll also cover how each form maps to a target method signature, when it reads better than a lambda, and the two traps I see in code reviews: overload ambiguity and eager evaluation of a bound receiver.
Below the line (out of scope)
- The functional-interface rule and
java.util.functionfamilies are covered in Lambdas and functional interfaces - Pure-function style is covered in Functional programming in Java
- Predicate and function composition is covered later in Composing functions and chaining predicates
The 30-second answer
A method reference is a compact lambda that delegates to an existing method. The four forms are static (Integer::parseInt), bound instance (printer::print), unbound instance (String::length) and constructor (OrderDto::new, including int[]::new). The target functional interface decides the parameter and return types, so String::trim can be a Function<String,String> and String::isBlank can be a Predicate<String>. I use a method reference when the method name says the whole story; I keep a lambda when I need argument reordering, constants, conditions or a clearer parameter name. The subtle runtime rule is that obj::method evaluates obj immediately, so a null receiver throws right away, while () -> obj.method() throws only when invoked.
What you’ll learn
- Name the four method-reference forms and translate each one to an equivalent lambda
- Map a method reference to a functional-interface signature, including unbound receivers and constructors
- Use constructor references and array constructors such as
int[]::newcorrectly - Decide when a method reference is clearer than a lambda and when a lambda should stay
- Handle overload and target-typing ambiguity with explicit types or casts
- Explain why
obj::methodevaluates the receiver eagerly and can throwNullPointerExceptionimmediately
Interview radar
Asked often: Name the four kinds and translate each into a lambda.
Core follow-up: Why is String::length unbound, and where does the receiver parameter go?
Production angle: A null collaborator fails during pipeline construction because service::call evaluates the receiver immediately.
01Picture it first: address labels for existing work
A method reference is like putting an address label on work that already exists. You are not writing the work again; you are saying "when the functional interface asks, call that method". The label is only valid if the target slot has the right shape.
The four labels differ mainly in where the receiver comes from. Sometimes the receiver is already fixed (logger::info), and sometimes the receiver becomes the first parameter (String::trim).
The four method-reference forms show static, bound instance, unbound instance and constructor references alongside the equivalent lambda shape and where the receiver comes from
Keep that receiver question in your head. Most method-reference confusion is really receiver confusion.
02Method references under the hood: the four forms
Try it yourself first
Before reading on, translate each reference into a lambda and say which functional interface it fits.
Function<String, Integer> parse = Integer::parseInt;
Consumer<String> print = System.out::println;
ToIntFunction<String> length = String::length;
Supplier<ArrayList<Order>> newList = ArrayList::new;
IntFunction<int[]> newIntArray = int[]::new;
Here is the mapping I use on a whiteboard.
| Kind | Example | Target shape | Equivalent lambda |
|---|---|---|---|
| Static | Integer::parseInt |
Function<String,Integer> |
s -> Integer.parseInt(s) |
| Bound instance | System.out::println |
Consumer<String> |
s -> System.out.println(s) |
| Unbound instance | String::length |
ToIntFunction<String> |
s -> s.length() |
| Constructor | OrderDto::new |
Function<Order,OrderDto> |
o -> new OrderDto(o) |
| Array constructor | int[]::new |
IntFunction<int[]> |
size -> new int[size] |
A static reference is the easiest: all functional-interface arguments become method arguments. A bound instance reference stores the receiver first, then the functional-interface arguments become the method arguments. An unbound instance reference is the one to slow down for: the first functional-interface argument becomes the receiver.
Constructor references follow constructor signatures. If new OrderDto(Order) exists, then OrderDto::new fits Function<Order, OrderDto>. If new Money(long, Currency) exists, then Money::new fits BiFunction<Long, Currency, Money> with boxing on Long; for a primitive-heavy path you would usually write a domain interface like MoneyFactory { Money create(long cents, Currency currency); }.
Signature matching, not name matching
A method reference does not match by method name alone. It matches the target interface's single abstract method after overload resolution.
record Order(String id, long totalCents) {}
record OrderDto(String id, long totalCents) {
OrderDto(Order order) {
this(order.id(), order.totalCents());
}
}
Function<Order, OrderDto> toDto = OrderDto::new;
Predicate<String> blank = String::isBlank;
UnaryOperator<String> trim = String::trim;
BiPredicate<String, String> startsWith = String::startsWith;
String::startsWith looks like one method, but as an unbound reference it needs two inputs for BiPredicate<String, String>: first the receiver string, then the prefix. That maps to (text, prefix) -> text.startsWith(prefix).
Notice three things:
- The target return type matters.
String::lengthfitsToIntFunction<String>cleanly andFunction<String, Integer>with boxing. It does not fitPredicate<String>. - The target arity matters.
System.out::printlnas a bound reference can fitConsumer<String>, but notBiConsumer<PrintStream, String>because the receiver is already fixed. - Constructors are overloaded too.
ArrayList::newcan fitSupplier<ArrayList<String>>for the no-arg constructor orIntFunction<ArrayList<String>>for the initial-capacity constructor.
Bound versus unbound, said slowly
The quickest way to decide between bound and unbound is to ask, "do I already have the object that will receive the call?" If yes, the receiver is bound. If no, the receiver must arrive as one of the functional-interface arguments.
PrintStream out = System.out;
Consumer<String> bound = out::println; // text -> out.println(text)
ToLongFunction<Order> unbound = Order::totalCents; // order -> order.totalCents()
Supplier<Long> alsoBound = order::totalCents; // () -> order.totalCents()
The method name is the same in the last two lines, but the shape is not. Order::totalCents still needs an Order, so it fits ToLongFunction<Order>. order::totalCents already has the Order, so it fits Supplier<Long> or LongSupplier depending on whether you want boxing.
This is also why ClassName::instanceMethod feels odd at first. It is not static dispatch. It is a compact way to say "take the first argument, use it as the receiver, and pass the remaining arguments to the instance method".
03When a reference is clearer, and when it is not
Try it yourself first
Which version would you keep in code review, and why?
orders.stream()
.filter(Order::isPaid)
.map(Order::customerEmail)
.forEach(mailer::sendReceipt);
orders.stream()
.filter(order -> order.totalCents() >= minimumCents)
.map(order -> normalize(order.customerEmail()))
.forEach(email -> mailer.sendReceipt(email));
In the first pipeline, method references read like a list of operations: is paid, get email, send receipt. The names carry the whole meaning, so the syntax gets out of the way.
In the second pipeline, I would keep the lambdas. The first captures a threshold and tells you which argument is compared. The second wraps a call. The third is equivalent to mailer::sendReceipt, but the named email parameter may be clearer if the type is not obvious nearby.
Here is my default: use a method reference when it is exactly the method you would have named anyway. Use a lambda when you transform arguments, add constants, reorder parameters, handle nulls, branch, or need a parameter name to make the code readable.
| Prefer method reference | Prefer lambda |
|---|---|
Order::isPaid |
order -> order.isPaid() && !order.isRefunded() |
String::trim |
s -> s.trim().toLowerCase() |
BigDecimal::new |
text -> new BigDecimal(text, MathContext.DECIMAL64) |
this::send |
receipt -> send(receipt, RetryPolicy.DEFAULT) |
int[]::new |
n -> new int[Math.max(0, n)] |
Many candidates repeat "prefer method references" as if it is a law. Effective Java says to prefer them when they are shorter and clearer. If the lambda explains intent better, keep the lambda.
04Real world: eager bound receivers
I once reviewed a startup failure caused by this exact distinction. A metrics client was optional in local development, and someone passed metrics::record into a scheduler during application wiring. In environments where metrics was null, the service failed at startup before the scheduler even ran. The lambda version would still have needed a null policy, but it would have moved the failure to execution time, where the team expected it.
Bound instance references evaluate the receiver immediately. That is true even though the method call itself happens later.
class Scheduler {
void everyMinute(Runnable task) { /* store task and run it later */ }
}
Metrics metrics = null;
Scheduler scheduler = new Scheduler();
scheduler.everyMinute(metrics::flush); // NullPointerException now
scheduler.everyMinute(() -> metrics.flush()); // accepted now, throws when run
A bound reference metrics::flush checks the metrics receiver during task creation and throws immediately when null, while a lambda stores code and reads metrics later when run
This also affects mutable static fields. System.out::println captures the current PrintStream. x -> System.out.println(x) reads System.out each time it runs. Most code should not depend on that, but it is a great interview detail because it proves you understand bound references.
05Advanced: overloads, ambiguity and constructors
Overloads are where method references stop feeling magical. Because the reference has no type until the target is known, two possible targets can leave the compiler unable to choose.
void handle(Function<String, Integer> parser) {}
void handle(ToIntFunction<String> parser) {}
// handle(Integer::parseInt); // ambiguous
handle((ToIntFunction<String>) Integer::parseInt);
void log(Consumer<String> writer) {}
void log(Predicate<String> checker) {}
// log(String::isBlank); // picks Predicate if available alone, ambiguous with another boolean target
log((Predicate<String>) String::isBlank);
The clean fixes are boring and reliable: assign the reference to a typed variable, cast it at the call site, or avoid overloading methods on functional interfaces with similar shapes. As an API designer, I avoid overload sets like handle(Function<...>) and handle(ToIntFunction<...>) unless callers truly benefit.
There is a team-level lesson here too. If a public API accepts callbacks, give each overload a different name when the callback shapes are close. handleObject and handleInt look less elegant than one overloaded handle, but they save every caller from sprinkling casts around lambdas and method references. Readability at the call site wins.
Constructor references have the same overload story:
import java.util.*;
import java.util.function.*;
Supplier<ArrayList<String>> empty = ArrayList::new;
IntFunction<ArrayList<String>> sized = ArrayList::new;
IntFunction<int[]> ints = int[]::new;
String[] names = List.of("a", "b").toArray(String[]::new);
String[]::new is the common collection-to-array pattern because Collection.toArray(IntFunction<T[]>) asks for a function from size to array. For primitive arrays, int[]::new fits IntFunction<int[]>, which is int -> int[].
Many candidates describe Type::instanceMethod as "calling a static method on the type". That is frequently wrong. String::length is unbound: the first runtime argument becomes the receiver, so the shape is String -> int. If the receiver is already present, as in someString::length, then the shape is () -> int.
A method reference uses the same lambda machinery as a lambda expression: an invokedynamic call site linked by LambdaMetafactory. Do not choose it for speed. Choose it for clarity. The performance choice that can matter is the target interface: String::length as ToIntFunction<String> avoids boxing the int result, while Function<String, Integer> may box unless the JIT removes it.
There is one more readability edge case: generic methods. A method reference can sometimes express a generic target that a lambda cannot, because lambda expressions cannot declare their own type parameters. You rarely need that in application code, but it is why "method references are only shorter lambdas" is not quite complete.
06How to say it in the interview
When I ask this, I'm listening for the receiver story. A good answer names all four forms, maps at least one unbound reference correctly, and mentions the eager-null behavior of bound references without being prompted.
In the interview
"A method reference is shorthand for a lambda that only calls an existing method or constructor. The four forms are static, like Integer::parseInt; bound instance, like printer::println; unbound instance, like String::length; and constructor, like ArrayList::new or int[]::new. The target functional interface supplies the type, so String::length can be ToIntFunction<String> and the first argument becomes the receiver. I use method references when the method name is clearer than spelling out the lambda, but I keep lambdas for argument changes, constants, null handling or branching. The trap is obj::method: obj is evaluated when the reference is created, so a null receiver throws immediately."
On the whiteboard
method reference = lambda that delegates
target functional interface still supplies the type
static: Integer::parseInt == s -> Integer.parseInt(s)
bound: out::println == x -> out.println(x)
unbound: String::length == s -> s.length()
constructor: ArrayList::new == () -> new ArrayList<>()
array ctor: int[]::new == n -> new int[n]
unbound receiver = first parameter
bound receiver evaluated immediately
overload ambiguity: type variable | cast | avoid overloads
clarity rule: reference when name says it all, lambda when logic changes