Lessons
1 · Java Foundations
2 · Object-Oriented Java
3 · Core Java
4 · Collections and Generics
5 · Functional Java
6 · The JVM and Concurrency
7 · Tools and Craft
- 21File I/O and Serialization
- 22Debugging: Errors, Stack Traces, the Debugger and Logging
- 23Maven: What It Does and How to Use It
- 24Git for Java Projects: Commits, Branches, Merging and Pull Requests
- 25Testing with JUnit and Mockito: Unit Tests, Mocks and Their Limits
- 26Project 1: Build an Expense Tracker CLI in Plain Java
8 · Databases and JDBC
9 · Web and Spring
10 · Persistence with JPA
11 · Secure, Tested, Production-Ready
- 36Security Fundamentals and Spring Security: Passwords, JWT, Access Control, CORS and Secrets
- 37Testing Spring Boot Applications: Slice Tests, MockMvc, Real Databases and the Test Pyramid
- 38Production Practices: Configuration, Logging, Docker and Health Checks for Spring Boot
- 39Project 3: The Job Application Tracker API, Assembled and Reviewed
Lesson 15 · Functional Java
Lambdas, Functional Interfaces and Method References
How Java lets you pass behaviour as a value: lambda expressions, functional interfaces and the standard Predicate, Function, Consumer and Supplier, the four kinds of method reference, composing functions, why captured variables must be effectively final, and how lambdas replace the older anonymous classes.
For
Learners who know interfaces and generics and want to understand lambdas and the standard functional interfaces before using streams.
Level
Before you start
Interfaces, Polymorphism and Composition (article 6) and Generics (article 14).
On this page
Key takeaways
- A lambda expression is a short anonymous function that you can pass as a value. It can be used wherever a functional interface, an interface with exactly one abstract method, is expected.
- Learn the four core shapes in java.util.function: Predicate (T to boolean), Function (T to R), Consumer (T to nothing) and Supplier (nothing to T), plus their two-argument and primitive versions.
- A method reference is a shorthand for a lambda that only calls an existing method. There are four kinds: static, bound instance, unbound instance and constructor references.
- A lambda can use local variables of the enclosing method only if they are final or effectively final. Keep lambdas short, stateless and free of side effects where you can.
- Lambdas replaced most anonymous classes, but you will still meet the older style in existing code and interviews.
Suppose you write a method that filters a list of job applications. Today the rule is "status is APPLIED". Tomorrow someone wants "applied more than 14 days ago". The day after, "company name contains 'Tech'". If the rule is hard-coded, you write a new method each time, with the same loop around a different if.
What you want is to pass the rule itself as an argument. Java supports that through lambda expressions, which arrived in Java 8 and changed how everyday Java is written. They are the foundation of streams (next article), Optional, many Spring APIs and modern test code.
What you will learn
#- What a lambda expression is, and how it relates to the interfaces you know.
- What a functional interface is.
- The standard functional interfaces you will use constantly.
- The four kinds of method reference.
- How to combine small functions.
- The rules about captured variables and exceptions.
The problem: passing behaviour
#Before Java 8, the way to pass behaviour was an anonymous class: an unnamed class defined and instantiated in one expression. Here is the older style for sorting by length and running a task:
// File: Main.javaimport java.util.ArrayList;import java.util.Collections;import java.util.Comparator;import java.util.List;public class Main { public static void main(String[] args) { List<String> words = new ArrayList<>(List.of("kiwi", "fig", "banana", "apple")); // Older style: an anonymous class, five lines for one idea Collections.sort(words, new Comparator<String>() { @Override public int compare(String a, String b) { return Integer.compare(a.length(), b.length()); } }); System.out.println(words); // Modern: a lambda words.sort((a, b) -> Integer.compare(b.length(), a.length())); System.out.println(words); Runnable task = new Runnable() { @Override public void run() { System.out.println("anonymous class runs"); } }; Runnable shorter = () -> System.out.println("lambda runs"); task.run(); shorter.run(); }}[fig, kiwi, apple, banana][banana, apple, kiwi, fig]anonymous class runslambda runsThe anonymous class Comparator<String> needs a lot of ceremony around a single line of logic. Oracle's tutorial describes exactly that: when an anonymous class is very simple, such as an interface containing only one method, its syntax is unwieldy and unclear. A lambda expression keeps only the essential part: the parameters and the body.
Lambda syntax
#(parameters) -> expression(parameters) -> { statements; return value; }Form | Example |
|---|---|
No parameters |
|
One parameter (parentheses optional) |
|
Several parameters |
|
Explicit types |
|
Block body |
|
You can think of a lambda as an anonymous method: parameters, an arrow, and a body. The compiler works out the parameter types from the context.
Where can you use a lambda? Functional interfaces
#A lambda can be used wherever a functional interface is expected. A functional interface is an interface with exactly one abstract method (it may also have default and static methods, as in article 6). Comparator and Runnable are functional interfaces. The compiler matches the lambda to that single method, using the target type (the type expected by the context): in words.sort(...), the parameter is a Comparator<String>, so the lambda must be a compare method for strings. That is why a lambda is not a new kind of object but a compact way to write an implementation of an interface you already understand.
You can declare your own and mark it with @FunctionalInterface, which makes the compiler verify that it really has a single abstract method:
@FunctionalInterfaceinterface Discount { long apply(long paise);}The standard functional interfaces
#You rarely need to write your own, because the java.util.function package provides ready-made ones. The package documentation states the principle: each functional interface has a single abstract method (the functional method), and a lambda's parameter and return types are matched to it. There are four core shapes to memorise.
Interface | Method | Shape | Example |
|---|---|---|---|
|
| T to boolean |
|
|
| T to R |
|
|
| T to nothing |
|
|
| nothing to T |
|
Variants build on these: BiFunction<T, U, R>, BiPredicate, BiConsumer take two arguments. UnaryOperator<T> is a Function<T, T>, and BinaryOperator<T> combines two Ts into one. There are also primitive specialisations (IntPredicate, ToIntFunction<T>, IntBinaryOperator, ...) that avoid boxing int values into Integer objects, which matters in large loops.
// File: Main.javaimport java.util.List;import java.util.function.BiFunction;import java.util.function.BinaryOperator;import java.util.function.Consumer;import java.util.function.Function;import java.util.function.IntPredicate;import java.util.function.Predicate;import java.util.function.Supplier;import java.util.function.UnaryOperator;public class Main { public static void main(String[] args) { Predicate<String> isEmpty = s -> s.isEmpty(); Function<String, Integer> length = s -> s.length(); Consumer<String> shout = s -> System.out.println(s.toUpperCase() + "!"); Supplier<List<String>> newList = () -> new java.util.ArrayList<>(); UnaryOperator<String> trim = String::strip; BinaryOperator<Integer> add = (a, b) -> a + b; BiFunction<String, Integer, String> repeat = (text, times) -> text.repeat(times); IntPredicate even = n -> n % 2 == 0; System.out.println(isEmpty.test("") + " " + isEmpty.test("x")); System.out.println(length.apply("lambda")); shout.accept("hello"); System.out.println(newList.get().size()); System.out.println("[" + trim.apply(" padded ") + "]"); System.out.println(add.apply(2, 3) + " " + repeat.apply("ab", 3) + " " + even.test(7)); }}true false6HELLO!0[padded]5 ababab falseWhen you design your own methods, accept the most general standard interface that fits: a method that filters should take a Predicate<T>, one that transforms should take a Function<T, R>, and one that needs a lazily created value should take a Supplier<T>. Callers can then pass a lambda, a method reference or an existing object.
// File: Main.javaimport java.util.ArrayList;import java.util.List;import java.util.function.Function;import java.util.function.Predicate;public class Main { record Application(String company, String status, int daysOld) {} static <T> List<T> filter(List<T> items, Predicate<T> rule) { List<T> result = new ArrayList<>(); for (T item : items) { if (rule.test(item)) { result.add(item); } } return result; } static <T, R> List<R> mapAll(List<T> items, Function<T, R> transform) { List<R> result = new ArrayList<>(); for (T item : items) { result.add(transform.apply(item)); } return result; } public static void main(String[] args) { List<Application> apps = List.of( new Application("Acme", "APPLIED", 20), new Application("Globex", "INTERVIEW", 5), new Application("Initech", "APPLIED", 3)); System.out.println(filter(apps, a -> a.status().equals("APPLIED")).size()); System.out.println(filter(apps, a -> a.daysOld() > 14)); System.out.println(mapAll(apps, Application::company)); }}2[Application[company=Acme, status=APPLIED, daysOld=20]][Acme, Globex, Initech]One filter method now serves every rule anyone can think of. This is the strategy pattern from object-oriented design, with the ceremony removed: the strategy is a lambda instead of a class.
Method references
#When a lambda does nothing except call an existing method, you can write a method reference with :: instead. Oracle's tutorial lists four kinds:
Kind | Syntax | Equivalent lambda |
|---|---|---|
Static method |
|
|
Method of a particular object (bound) |
|
|
Instance method of an arbitrary object of a type (unbound) |
|
|
Constructor |
|
|
// File: Main.javaimport java.util.List;import java.util.function.BiFunction;import java.util.function.Function;import java.util.function.Supplier;public class Main { record Person(String name) { static Person parse(String text) { return new Person(text.strip()); } } public static void main(String[] args) { Function<String, Integer> staticRef = Integer::parseInt; System.out.println(staticRef.apply("42") + 1); List<String> names = List.of("Asha", "Ravi"); names.forEach(System.out::println); // bound: System.out is a particular object Function<String, Integer> unbound = String::length; // the first argument is the receiver BiFunction<String, String, Boolean> startsWith = String::startsWith; System.out.println(unbound.apply("method") + " " + startsWith.apply("reference", "ref")); Supplier<StringBuilder> make = StringBuilder::new; // constructor Function<String, Person> parse = Person::parse; Function<String, Person> create = Person::new; System.out.println(make.get().append("built").toString() + " " + parse.apply(" Meena ") + " " + create.apply("Dev")); }}43AshaRavi6 truebuilt Person[name=Meena] Person[name=Dev]The unbound form is the one that confuses people: String::length as a Function<String, Integer> means "take the argument and call length() on it". Use a method reference when it is clearer than the lambda, which is usually when the lambda would just repeat the method's name. If you need to do something extra, write the lambda.
Composing small functions
#Because functions are values, you can build bigger ones from smaller ones. The functional interfaces provide methods for this:
// File: Main.javaimport java.util.function.Function;import java.util.function.Predicate;public class Main { public static void main(String[] args) { Predicate<String> notBlank = s -> !s.isBlank(); Predicate<String> shortEnough = s -> s.length() <= 8; Predicate<String> validName = notBlank.and(shortEnough); Predicate<String> invalid = validName.negate(); Predicate<String> special = s -> s.startsWith("#"); System.out.println(validName.test("Asha") + " " + validName.test(" ") + " " + validName.test("Bartholomew")); System.out.println(invalid.test("Bartholomew") + " " + validName.or(special).test("#hashtag-too-long")); Function<String, String> trim = String::strip; Function<String, String> upper = String::toUpperCase; Function<String, String> exclaim = s -> s + "!"; System.out.println(trim.andThen(upper).andThen(exclaim).apply(" hello ")); System.out.println(upper.compose(trim).apply(" same result ")); }}true false falsetrue trueHELLO!SAME RESULTand, or and negate combine predicates, and andThen and compose chain functions (f.andThen(g) runs f first, f.compose(g) runs g first). The Comparator methods you saw earlier (thenComparing, reversed) work the same way. This is a different style from loops with ifs inside: you describe the rule as data and combine rules.
Captured variables must be effectively final
#A lambda can use variables from the method around it, which is called capturing. There is a rule: Oracle's tutorial says a lambda can access only local variables and parameters of the enclosing block that are final or effectively final. A variable is effectively final if its value never changes after it is initialised.
// File: Main.javaimport java.util.List;public class Main { public static void main(String[] args) { List<Integer> numbers = List.of(1, 2, 3); int total = 0; numbers.forEach(n -> total += n); System.out.println(total); }}Main.java:8: error: local variables referenced from a lambda expression must be final or effectively final numbers.forEach(n -> total += n); ^1 errorThe compiler rejects the assignment. The reason is that a lambda may run later or on another thread, after the method has returned and its local variables are gone, so it works with a copy of the captured value. Allowing the lambda to modify a variable that the method also sees would create two diverging copies, or race conditions. Java forbids it. Note that this concerns local variables: a lambda can freely call methods on a captured object, including methods that change it (such as list.add), and can read and write fields.
How to fix the example: do not accumulate through side effects at all. Compute the total with reduce or a stream's sum, which the next article shows. (Using an AtomicInteger or a one-element array to get around the rule compiles, but it is usually a sign that you are using the wrong tool.)
Also, inside a lambda, this refers to the enclosing object, unlike in an anonymous class where this is the anonymous object itself. A lambda introduces no new scope: its parameter names must not clash with local variables of the enclosing method.
Lambdas and checked exceptions
#The standard functional interfaces do not declare checked exceptions. A lambda body that calls a method throwing a checked exception, such as Files.readString, does not compile unless the lambda handles it:
paths.stream().map(p -> Files.readString(p)); // compile error: unreported IOExceptionPractical options: catch the exception inside the lambda and wrap it in an unchecked one (catch (IOException e) { throw new UncheckedIOException(e); }), move the risky code into a helper method that does that, or avoid the lambda and use a plain loop. This is one reason that many modern libraries use unchecked exceptions, as discussed in article 10.
Laziness with Supplier
#A Supplier also lets you defer work until it is needed. Compare Optional.orElse(expensive()), which computes the fallback even when it is not needed, with orElseGet(() -> expensive()), which computes it only when required (article 17). Logging libraries use the same trick: the message supplier runs only if that log level is enabled.
Writing good lambdas
#- Keep them short. If a lambda needs more than a few lines, give it a name: write a method and use a method reference.
- Prefer pure functions: no side effects, results depend only on the arguments. They are easier to understand, test and run in parallel.
- Do not mutate captured state.
- Name your intent.
isOverdue(app)as a method reference reads better than a long inline condition. - Do not over-compose. A chain of five
andThencalls is harder to read than a method with a few lines. - Use the primitive specialisations (
IntPredicate,ToIntFunction) in tight loops over numbers.
Common mistakes
#- Trying to modify a captured local variable.
- Writing a multi-line lambda where a named method would be clearer.
- Confusing the unbound method reference (
String::length) with a bound one (someString::length). - Ignoring checked exceptions until the compiler complains, then swallowing them.
- Expecting
thisto mean the lambda itself. - Creating your own functional interface when a standard one exists.
- Using a lambda where an object with state is needed, for example something that must keep a counter between calls.
Exercises
#Exercise 1
Convert
#Rewrite the following anonymous class as a lambda, and then as a method reference if possible: new Function<String, Integer>() { public Integer apply(String s) { return Integer.valueOf(s); } }.
Exercise 2: Choose the interface
#Which standard functional interface fits each: (a) "decide whether an order is overdue", (b) "turn an order into its total", (c) "print an order", (d) "create a new empty order", (e) "add two money values to get a third of the same type"?
Exercise 3: Predict
#What does this print? Function<Integer, Integer> doubleIt = x -> x * 2; Function<Integer, Integer> addTen = x -> x + 10; System.out.println(doubleIt.andThen(addTen).apply(5) + " " + doubleIt.compose(addTen).apply(5));
Exercise 4: Fix the compile error
#int count = 0; list.forEach(item -> { if (item.isBlank()) count++; }); does not compile. Explain why and rewrite it without mutation.
Exercise 5: Design a method
#Write a method static <T> void forEachMatching(List<T> items, Predicate<T> test, Consumer<T> action) that runs action on every item for which test is true. Call it to print all blank-free names from a list.
Show solutionHide solution
As a lambda: s -> Integer.valueOf(s). As a method reference: Integer::valueOf. The method reference is clearer, because the lambda just forwards its argument to an existing static method. The interface is Function<String, Integer>, so both forms are interchangeable wherever it is expected.
Exercise 2
Show solutionHide solution
(a) Predicate<Order>. (b) Function<Order, Money> (or ToLongFunction<Order> for a primitive total). (c) Consumer<Order>. (d) Supplier<Order>, for example Order::new. (e) BinaryOperator<Money>, which is BiFunction<Money, Money, Money>. The pattern is to ask "what goes in and what comes out?": in-and-boolean, in-and-out, in-and-nothing, nothing-and-out.
Exercise 3
Show solutionHide solution
It prints 20 30. doubleIt.andThen(addTen).apply(5) doubles first (10) and then adds ten, giving 20. doubleIt.compose(addTen).apply(5) runs addTen first (15) and then doubles, giving 30. The key is the order: f.andThen(g) means "f, then g", while f.compose(g) means "g, then f".
Exercise 4
Show solutionHide solution
count is modified inside the lambda, but a captured local variable must be effectively final, so the compiler rejects it. Rewrite without mutation: long count = list.stream().filter(String::isBlank).count(); (streams are covered in article 16), or use a simple loop: for (String item : list) { if (item.isBlank()) count++; }, which has no lambda and so no restriction.
Exercise 5
Show solutionHide solution
static <T> void forEachMatching(List<T> items, Predicate<T> test, Consumer<T> action) { for (T item : items) { if (test.test(item)) { action.accept(item); } }}forEachMatching(names, name -> !name.isBlank(), System.out::println);The method combines the two shapes you learned: a Predicate to decide, and a Consumer to act. The call passes a lambda for the test and a bound method reference for the action.
Where to go next
#- Next: Streams in Practice (and When Not to Use Them) uses everything in this article to process collections.
- For lambdas as values that may be absent or deferred: Null and Optional.
- For lambdas in concurrency: Executors, Futures, Concurrent Collections and Virtual Threads.
Sources & further reading
- Technology
- Software Engineering
- #functional programming
- #interview preparation
- #Java
- #Java backend
- #Java interview
- #JPA
- #learn Java
- #modern Java
- #Spring Boot
- #SQL
Connected lessons
Linked from 5 guides
Other pages in the library that point here.
Keep reading
More in Technology →Guide · Technology
Heaps and Priority Queues
Guide · Technology
Dynamic Programming Practice: From 1D to 2D
Course · Technology
DSA From Zero to Job-Ready: A Practical Guide with Java

