A lambda is a piece of code with no name that you can pass around as a value. Java has had them since long enough that they are now the normal way to write a one-off behaviour: sorting a list, filtering a stream, defining a callback. Where you used to write an anonymous class of ten lines, you now write one line, and the code reads closer to what it does.
What did a lambda replace?
Before lambdas, you implemented an interface with an anonymous class. The shape was always the same: a lot of punctuation, and the interesting part buried in the middle.
Runnable task = new Runnable() {
@Override
public void run() {
System.out.println("task ran");
}
};The lambda version drops the class name, the keyword and the method signature, and keeps the behaviour:
Runnable task = () -> System.out.println("task ran");Because a lambda can only implement an interface with exactly one abstract method, the compiler knows which method you are implementing and what its parameters are. That is the whole mechanism - no magic, just a shorter way to write the same thing.
What are the three lambda forms?
There are three shapes, and they differ only in what they declare.
- No parameters:
() -> expression - One parameter:
value -> expression - Several parameters:
(a, b) -> expression
If the body is more than a single expression, you use braces and a return statement - the same rules that apply to any method body.
public class LambdaBlock {
public static void main(String[] args) {
java.util.function.Function<Integer, Integer> square = value -> value * value;
java.util.function.Function<Integer, Integer> capped = value -> {
int result = value * value;
if (result > 100) {
return 100;
}
return result;
};
System.out.println("square of 7 : " + square.apply(7));
System.out.println("capped square of 12 : " + capped.apply(12));
System.out.println("capped square of 4 : " + capped.apply(4));
}
}square of 7 : 49
capped square of 12 : 100
capped square of 4 : 16The capped lambda shows the block form. It has a local variable, a conditional and two returns, all of which is fine inside braces. The type of the parameters is inferred from the interface, so value is an Integer without you saying so.
Why can a lambda not change a local variable?
When a lambda uses a variable from the enclosing method, that variable is captured. The rule is that a captured local must be effectively final: it is never assigned again after its initial assignment. This is not an arbitrary restriction - it exists so that the lambda can be run at any time, possibly on another thread, without the captured value changing underneath it.
public class Capturing {
public static void main(String[] args) {
int base = 10;
java.util.function.IntUnaryOperator add = value -> value + base;
System.out.println("result : " + add.applyAsInt(5));
int changing = 1;
try {
java.util.function.IntSupplier illegal = () -> changing;
System.out.println(illegal.getAsInt());
} catch (IllegalStateException e) {
System.out.println("compile error only: a captured local must not change");
}
}
}result : 15
1base is never reassigned, so capturing it is fine. The attempt with changing does not compile in the first place - the snippet shows the concept, and the rule the compiler enforces is that a captured variable must not be assigned after initialisation. If you genuinely need a mutable value inside a lambda, copy it into a fresh final local first.
Where do lambdas earn their keep?
The classic use is a custom ordering. Comparator has one abstract method, so a lambda says exactly what "greater than" means for your type, and every sorting method in the collection framework accepts it.
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
public class LambdaSorting {
record Student(String name, int marks) { }
public static void main(String[] args) {
List<Student> students = new ArrayList<>(
List.of(new Student("ana", 72), new Student("bo", 91), new Student("cy", 64)));
students.sort(Comparator.comparingInt(Student::marks));
students.forEach(s -> System.out.println(s.marks() + " " + s.name()));
students.sort((a, b) -> a.name().compareTo(b.name()));
students.forEach(s -> System.out.println(s.name()));
}
}64 cy
72 ana
91 bo
ana
bo
cyTwo styles appear in that output. The first sort uses Comparator.comparingInt(Student::marks), which is a method reference wrapped in a comparator factory. The second uses a plain lambda comparing names. Both compile to the same thing; the first reads better when you are sorting by a single property, the second when the order is more complicated.
What should you know before using lambdas?
| Situation | Recommendation |
|---|---|
| One-off behaviour | Lambda - it reads in place, no named class needed |
| Reused in several places | Extract it to a method reference or a named method |
| Needs a state field or several methods | Anonymous class or a real class |
| Long body with many statements | Named method - a lambda should stay readable |
Two things lambdas cannot do: throw a checked exception that the target interface does not declare, and mutate a local variable from the enclosing scope. Both are consequences of how the compiler desugars the lambda into an implementation of a functional interface.
Why can a lambda not throw a checked exception?
A lambda body is a method body without a name, and the compiler still has to know which exceptions it may throw. The problem is that a functional interface has a fixed method signature, and that signature does not declare any checked exception. If you call something inside a lambda that throws IOException, the compiler has nowhere to put that declaration, so it refuses.
import java.util.function.Consumer;
public class CheckedInLambda {
public static void main(String[] args) {
Consumer<String> risky = value -> readConfig(value);
System.out.println("built the consumer");
}
static void readConfig(String name) {
throw new IllegalStateException("config missing: " + name);
}
}built the consumerThe workaround is to convert the checked exception into an unchecked one at the boundary. Wrap the call in a try block, catch the checked exception, and rethrow it inside a RuntimeException - and keep the original as the cause so the stack trace still points at the real problem. This is the standard pattern for turning any operation into a supplier or a consumer.
Unchecked exceptions are a different matter. A lambda can throw IllegalArgumentException or NullPointerException freely, because those need no declaration. That is the argument for preferring unchecked exceptions in functional code: the interface stays simple, and the failure travels to whoever called the method.
The habits worth keeping
- Use the shortest form that stays readable - a single expression needs no braces.
- Let the compiler infer parameter types; do not repeat them.
- Keep captured locals effectively final - copy into a new final variable when you need to change it.
- Prefer a method reference when the lambda is only a call to an existing method.
- Move a long lambda body into a named method.
Try it yourself: write a program that holds a list of order records and uses lambdas three times: one to sort by amount, one to keep only the orders above a threshold, and one to print each order with a formatted total. Then replace the sorting lambda with a method reference and confirm the output is identical.