Java Lambda Expressions

Lambda expressions arrived in Java 8 and changed how developers write code. They let you pass behavior as a parameter, write more concise code, and work with the Streams API effectively. If you’ve used JavaScript arrow functions or Python lambdas, the concept will feel familiar.

This tutorial covers lambda syntax, functional interfaces, and practical examples you’ll encounter in real Java code.

What is a Lambda Expression?

A lambda expression is a short block of code that takes parameters and returns a value. Think of it as an anonymous method, a method without a name that you can pass around like data.

Before lambdas, passing behavior required creating anonymous inner classes. Here’s how you’d sort a list of strings by length before Java 8:

List<String> names = Arrays.asList("Charlie", "Alice", "Bob");

Collections.sort(names, new Comparator<String>() {
    @Override
    public int compare(String a, String b) {
        return Integer.compare(a.length(), b.length());
    }
});

System.out.println(names);  // [Bob, Alice, Charlie]

That’s a lot of code just to say “compare by length.” With a lambda, the same logic becomes one line:

List<String> names = Arrays.asList("Charlie", "Alice", "Bob");

names.sort((a, b) -> Integer.compare(a.length(), b.length()));

System.out.println(names);  // [Bob, Alice, Charlie]

The lambda (a, b) -> Integer.compare(a.length(), b.length()) replaces the entire anonymous class. Same behavior, far less noise.

Lambda Syntax

A lambda expression has three parts: parameters, an arrow, and a body.

(parameters) -> expression
// or
(parameters) -> { statements; }

Here are the syntax variations you’ll see:

No Parameters

() -> System.out.println("Hello")

() -> 42

() -> { return Math.random(); }

One Parameter

Parentheses are optional with a single parameter:

x -> x * 2

(x) -> x * 2

name -> name.toUpperCase()

s -> {
    String result = s.trim();
    return result.toLowerCase();
}

Multiple Parameters

Parentheses are required with two or more parameters:

(a, b) -> a + b

(x, y, z) -> x + y + z

(String first, String second) -> first.concat(second)

Expression vs Block Body

If the body is a single expression, you don’t need braces or a return statement. The expression’s value is returned automatically:

// Expression body - implicit return
x -> x * 2

// Block body - explicit return required
x -> {
    int result = x * 2;
    return result;
}

Use expression bodies when possible. They’re cleaner. Use block bodies when you need multiple statements.

Functional Interfaces

Lambdas work with functional interfaces. A functional interface is any interface with exactly one abstract method. The @FunctionalInterface annotation marks these explicitly, though it’s optional.

@FunctionalInterface
public interface Calculator {
    int calculate(int a, int b);
}

You can use a lambda anywhere a functional interface is expected:

Calculator add = (a, b) -> a + b;
Calculator multiply = (a, b) -> a * b;
Calculator subtract = (a, b) -> a - b;

System.out.println(add.calculate(5, 3));       // 8
System.out.println(multiply.calculate(5, 3));  // 15
System.out.println(subtract.calculate(5, 3));  // 2

The lambda’s signature must match the interface’s abstract method. The Calculator interface expects two ints and returns an int, so the lambda must do the same.

Built-in Functional Interfaces

Java provides many functional interfaces in the java.util.function package. You’ll use these constantly instead of creating your own.

Predicate<T> – Takes one argument, returns boolean. Used for filtering.

Predicate<String> isEmpty = s -> s.isEmpty();
Predicate<Integer> isPositive = n -> n > 0;
Predicate<String> startsWithA = s -> s.startsWith("A");

System.out.println(isEmpty.test(""));        // true
System.out.println(isPositive.test(-5));     // false
System.out.println(startsWithA.test("Alice")); // true

Function<T, R> – Takes one argument of type T, returns type R. Used for transformations.

Function<String, Integer> stringLength = s -> s.length();
Function<Integer, String> intToString = n -> "Number: " + n;
Function<String, String> toUpper = s -> s.toUpperCase();

System.out.println(stringLength.apply("Hello"));  // 5
System.out.println(intToString.apply(42));        // Number: 42
System.out.println(toUpper.apply("hello"));       // HELLO

Consumer<T> – Takes one argument, returns nothing. Used for side effects like printing.

Consumer<String> printer = s -> System.out.println(s);
Consumer<List<String>> clearList = list -> list.clear();

printer.accept("Hello World");  // prints: Hello World

Supplier<T> – Takes no arguments, returns a value. Used for lazy generation.

Supplier<Double> randomValue = () -> Math.random();
Supplier<LocalDate> today = () -> LocalDate.now();

System.out.println(randomValue.get());  // 0.7234... (random)
System.out.println(today.get());        // 2026-01-02

BiFunction<T, U, R> – Takes two arguments, returns a value.

BiFunction<String, String, String> concat = (a, b) -> a + b;
BiFunction<Integer, Integer, Integer> max = (a, b) -> a > b ? a : b;

System.out.println(concat.apply("Hello, ", "World"));  // Hello, World
System.out.println(max.apply(10, 20));                 // 20

Method References

When a lambda just calls an existing method, you can use a method reference instead. It’s shorter and often clearer.

// Lambda
names.forEach(name -> System.out.println(name));

// Method reference - equivalent
names.forEach(System.out::println);

The :: operator creates a method reference. There are four types:

Reference to a Static Method

// Lambda
Function<String, Integer> parser = s -> Integer.parseInt(s);

// Method reference
Function<String, Integer> parser = Integer::parseInt;

System.out.println(parser.apply("123"));  // 123

Reference to an Instance Method of a Particular Object

String prefix = "Hello, ";

// Lambda
Function<String, String> greeter = name -> prefix.concat(name);

// Method reference
Function<String, String> greeter = prefix::concat;

System.out.println(greeter.apply("World"));  // Hello, World

Reference to an Instance Method of an Arbitrary Object

// Lambda
Function<String, String> upper = s -> s.toUpperCase();

// Method reference
Function<String, String> upper = String::toUpperCase;

System.out.println(upper.apply("hello"));  // HELLO

Reference to a Constructor

// Lambda
Supplier<ArrayList<String>> listMaker = () -> new ArrayList<>();

// Method reference
Supplier<ArrayList<String>> listMaker = ArrayList::new;

List<String> newList = listMaker.get();

Method references aren’t always better than lambdas. Use them when they improve readability. If a method reference looks confusing, stick with the lambda.

Variable Capture

Lambdas can access variables from the enclosing scope. But there’s a catch: those variables must be effectively final, meaning they’re never reassigned after initialization.

String greeting = "Hello";  // effectively final

Consumer<String> greeter = name -> System.out.println(greeting + ", " + name);

greeter.accept("Alice");  // Hello, Alice

This works because greeting is never changed. But this won’t compile:

String greeting = "Hello";
greeting = "Hi";  // Reassigned - no longer effectively final

// Compile error: local variables referenced from a lambda must be final or effectively final
Consumer<String> greeter = name -> System.out.println(greeting + ", " + name);

The restriction exists because lambdas might execute later, possibly on a different thread. Capturing a changing variable would create race conditions and unpredictable behavior.

Common Use Cases

Sorting Collections

List<String> names = new ArrayList<>(Arrays.asList("Charlie", "Alice", "Bob"));

// Sort alphabetically
names.sort((a, b) -> a.compareTo(b));

// Or using method reference
names.sort(String::compareTo);

// Sort by length
names.sort((a, b) -> Integer.compare(a.length(), b.length()));

// Using Comparator helper
names.sort(Comparator.comparingInt(String::length));

Filtering with removeIf

List<Integer> numbers = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10));

// Remove even numbers
numbers.removeIf(n -> n % 2 == 0);

System.out.println(numbers);  // [1, 3, 5, 7, 9]

Iterating with forEach

List<String> items = Arrays.asList("Apple", "Banana", "Cherry");

items.forEach(item -> System.out.println("Item: " + item));

// With method reference
items.forEach(System.out::println);

Running Threads

// Old way with anonymous class
Thread thread1 = new Thread(new Runnable() {
    @Override
    public void run() {
        System.out.println("Running in thread");
    }
});

// With lambda
Thread thread2 = new Thread(() -> System.out.println("Running in thread"));

thread2.start();

Event Handlers (JavaFX/Swing)

// JavaFX button click
button.setOnAction(event -> System.out.println("Button clicked"));

// Swing action listener
button.addActionListener(e -> handleClick());

Lambdas and the Streams API

Lambdas really shine with the Streams API. Most stream operations accept functional interfaces as parameters:

List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David");

List<String> result = names.stream()
    .filter(name -> name.length() > 3)      // Predicate
    .map(name -> name.toUpperCase())        // Function
    .sorted((a, b) -> a.compareTo(b))       // Comparator
    .collect(Collectors.toList());

System.out.println(result);  // [ALICE, CHARLIE, DAVID]

The Streams API tutorial covers this in depth.

When Not to Use Lambdas

Lambdas aren’t always the right choice.

Complex logic. If a lambda needs more than 3-4 lines, extract it into a named method. Lambdas should be simple and readable at a glance.

// Too complex for a lambda
items.forEach(item -> {
    validate(item);
    transform(item);
    if (item.isSpecial()) {
        handleSpecialCase(item);
    }
    save(item);
    log(item);
});

// Better: extract to a method
items.forEach(this::processItem);

private void processItem(Item item) {
    validate(item);
    transform(item);
    if (item.isSpecial()) {
        handleSpecialCase(item);
    }
    save(item);
    log(item);
}

Need to throw checked exceptions. Lambdas don’t play nicely with checked exceptions. The built-in functional interfaces don’t declare any. You can work around this, but it’s awkward.

Debugging. Lambdas can make stack traces harder to read. Named methods have clear names in the stack trace. Lambdas show up as synthetic names like lambda$main$0.

Summary

Lambda expressions make Java code more concise and expressive. They work wherever a functional interface is expected, which includes sorting, filtering, iteration, threading, and the entire Streams API.

Start with simple lambdas in places you’d otherwise use anonymous classes. As you get comfortable, you’ll find them useful throughout your code.


Next: Java Streams API

Related: Java Collections Framework Overview | Java Interfaces | Introduction to Java Generics

Sources

  • Oracle. “Lambda Expressions.” docs.oracle.com/javase/tutorial/java/javaOO/lambdaexpressions.html
  • Oracle. “java.util.function Package.” docs.oracle.com/javase/8/docs/api/java/util/function/package-summary.html
  • Bloch, Joshua. “Effective Java.” 3rd Edition, 2018
Scroll to Top