Java · Java 8 Features

Java 8: Lambdas, Functional Interfaces and Default Methods

What a lambda captures and what this means inside one, checked exceptions in lambdas, the four kinds of method reference, composing functions, and how default and static interface methods resolve.

  • 6 min read
  • 12 practice questions
  • 1 graded problem
On this page
  1. Description
  2. From anonymous class to lambda
  3. Functional interfaces: the shapes a lambda fits
  4. Method references
  5. What a lambda can use from around it
  6. Combining functions
  7. Default methods in interfaces
  8. Common mistakes
  9. Check yourself
  10. Key takeaways
  11. Quick revise
  12. Practice

Description

Java 8 (2014) changed how Java is written more than any release before or since. Its big idea was letting you pass behavior around as easily as data: "sort these orders by total", "keep only the large ones", "run this when the payment completes". Before Java 8, each of those needed a bulky anonymous class. After it, a one-line lambda does the job, and streams, Optional, CompletableFuture and most modern libraries are built around that idea.

Interviewers still ask "what came in Java 8?", and much production code still targets it. This skill covers the language half: lambdas, the interfaces they implement, method references, and default methods in interfaces. The next two skills cover streams, then Optional and java.time.

Here's the path, from easiest to most technical:

  1. From anonymous class to lambda.
  2. Functional interfaces: the "shapes" a lambda can fit.
  3. Method references: an even shorter lambda.
  4. What a lambda can use from around it, and why checked exceptions get in the way.
  5. Combining functions, and default methods in interfaces.

From anonymous class to lambda

Here's "sort orders by total" before and after Java 8:

// Before: an anonymous class
orders.sort(new Comparator<Order>() {
    @Override
    public int compare(Order a, Order b) {
        return Long.compare(a.total(), b.total());
    }
});

// After: a lambda
orders.sort((a, b) -> Long.compare(a.total(), b.total()));

A lambda is written as (parameters) -> body. The body is either one expression (its value is returned) or a block in braces (with an explicit return if it returns something). Java works out the parameter types from where the lambda is used, so you rarely write them.

Functional interfaces: the shapes a lambda fits

A lambda can be used anywhere Java expects a functional interface: an interface with exactly one abstract method. The lambda becomes the implementation of that method. Java 8 added a standard set in java.util.function, and you'll see these everywhere:

InterfaceMethodTakes → givesExample
Predicate<T>testT → booleano -> o.total() > 10_000
Function<T, R>applyT → Ro -> o.id()
Consumer<T>acceptT → nothingo -> log.info("{}", o)
Supplier<T>getnothing → T() -> new ArrayList<>()
UnaryOperator<T>applyT → Ts -> s.trim()
BiFunction<T, U, R>applyT, U → R(a, b) -> a + b
Runnablerunnothing → nothing() -> sendEmail()

A few details:

  • Primitive versions such as IntPredicate, ToIntFunction and IntUnaryOperator work with plain ints. A Predicate<Integer> boxes every number it tests, which matters in hot loops.
  • @FunctionalInterface on your own interface makes the compiler check that it has exactly one abstract method.
  • Default methods, and methods like equals that come from Object, don't count toward the "one abstract method".

Method references

When a lambda just calls one existing method, a method reference says the same thing more briefly:

KindMethod referenceSame as the lambda
Static methodInteger::parseInts -> Integer.parseInt(s)
Method of a particular objectlogger::flush() -> logger.flush() (almost: see below)
Method of the argumentString::lengths -> s.length()
ConstructorArrayList::new() -> new ArrayList<>()

The "almost": logger::flush looks up logger once, when the reference is created, and keeps calling that object. The lambda () -> logger.flush() reads the logger field each time it runs. If the field is later replaced, the two behave differently. And if logger is null, the method reference throws a NullPointerException immediately, while the lambda would throw only when called.

What a lambda can use from around it

A lambda can read the variables around it, with one rule that trips everyone up at first:

int skipped = 0;
orders.forEach(o -> {
    if (o.isTest()) skipped++;    // doesn't compile: local variables referenced from a lambda
});                               // expression must be final or effectively final

A lambda copies the values of the local variables it uses, so Java requires those variables never to change after they're set (they must be effectively final, whether or not you write final). The fix is usually to stop mutating and compute the answer instead:

long skipped = orders.stream().filter(Order::isTest).count();

Two more rules:

  • Fields aren't copied. A lambda reads instance fields when it runs, so it sees their current values.
  • this means the surrounding object. Inside a lambda, this is the object whose method created the lambda. Inside an anonymous class, this is the anonymous object itself.

Checked exceptions are the other common wall. Consumer.accept and Function.apply don't declare any, so this doesn't compile:

paths.forEach(p -> process(Files.readString(p)));   // unreported exception IOException

Either catch inside the lambda and rethrow as an unchecked exception (UncheckedIOException for I/O), or write a small functional interface of your own whose method declares throws IOException, or keep a plain for loop.

Combining functions

Functional interfaces come with methods for combining them:

Function<Integer, Integer> plusOne = x -> x + 1;
Function<Integer, Integer> timesTwo = x -> x * 2;

plusOne.andThen(timesTwo).apply(5);   // 12: plusOne first, then timesTwo
plusOne.compose(timesTwo).apply(5);   // 11: timesTwo first, then plusOne

Predicate<Order> large = o -> o.total() > 10_000;
Predicate<Order> test = Order::isTest;
Predicate<Order> realLargeOrder = large.and(test.negate());

andThen reads in the order things happen; compose runs its argument first. Mixing them up is a classic bug.

Default methods in interfaces

Before Java 8, adding a method to an interface broke every class that implemented it. Default methods (interface methods with a body) fixed that, and that's how forEach, stream() and getOrDefault were added to Java's existing collection interfaces.

When a class could inherit the same method from more than one place, three rules decide which one runs:

  1. The class wins. A method from a superclass beats an interface's default.
  2. The more specific interface wins. If B extends A and both have the default, B's is used.
  3. Otherwise it doesn't compile, and the class must override the method itself (it can call one of them with A.super.greet()).

Interfaces can also have static methods, such as Comparator.naturalOrder(). They belong to the interface only and aren't inherited, so you always call them through the interface name.

Common mistakes

MistakeWhat happensDo this instead
Changing a local variable inside a lambdadoesn't compilecompute the value, e.g. with count() or sum()
"Fixing" that with a one-element arrayshared mutable state, wrong in parallela stream operation that returns the result
Throwing a checked exception in forEach/mapdoesn't compilewrap in an unchecked exception, or a loop
A method reference to a field that's later replacedkeeps calling the old objecta lambda that reads the field each time
Predicate<Integer> in a hot loopboxing on every callIntPredicate
Mixing up andThen and composesteps run in the wrong orderandThen = this, then that

Check yourself

Try to answer before opening each one. Recalling beats re-reading.

1. Why can a lambda read a local variable but not increment it?

Show answer

A lambda captures the variable's value, so Java requires the variable to be effectively final. Otherwise the copy inside the lambda and the variable outside could disagree.

2. What's the difference between logger::flush and () -> logger.flush() when logger is a field that later gets replaced?

Show answer

The method reference looks up logger once, when it's created, and keeps calling that object. The lambda reads the field each time it runs, so it uses the new logger.

3. What do plusOne.andThen(timesTwo).apply(5) and plusOne.compose(timesTwo).apply(5) return?

Show answer

12 and 11. andThen runs plusOne first ((5 + 1) × 2); compose runs timesTwo first (5 × 2 + 1).

4. A class extends Base (which has greet()) and implements Greeter (which has a default greet()). Which one runs?

Show answer

Base.greet(). A method from the class hierarchy always beats an interface's default method.

Key takeaways

  • A lambda is a short implementation of a functional interface: one abstract method, such as Predicate, Function, Consumer or Supplier.
  • Method references shorten lambdas that call one method; a reference to an object fixes that object when it's created.
  • Lambdas capture effectively final locals, read fields live, and use the enclosing this. Checked exceptions need wrapping.
  • andThen runs this then that; compose runs that then this.
  • Default methods let interfaces grow: the class wins, the more specific interface wins, and clashes must be overridden.

Quick revise

The whole skill on one page, for the night before an interview.

Everything from the lesson and the deep dive on one page. Skim it before practice or an interview, and open the full lesson for anything that doesn't ring a bell.

The standard functional interfaces

InterfaceShapeCombine with
Predicate<T>T → booleanand, or, negate
Function<T, R>T → RandThen (this, then that), compose (that, then this)
UnaryOperator<T>T → T
BiFunction<T, U, R>T, U → RandThen
Consumer<T>T → nothingandThen
Supplier<T>nothing → T
Runnable / Callable<V>nothing → nothing / V (may throw)
IntPredicate, ToIntFunction, ...primitives, no boxing

Method references

Integer::parseInt (static) · logger::flush (bound: receiver fixed at creation) · String::length (unbound: the argument is the receiver) · ArrayList::new (constructor).

Capture rules

  • Locals must be effectively final: compute results instead of mutating counters.
  • Fields are read when the lambda runs.
  • this is the enclosing object (an anonymous class has its own this).
  • Checked exceptions don't fit Function/Consumer: wrap (UncheckedIOException) or use your own interface with throws.

Default methods

  1. A class's method beats an interface default.
  2. The more specific interface beats the less specific.
  3. Otherwise override it (A.super.m() to delegate).

Static interface methods aren't inherited: call Comparator.naturalOrder(), not through an implementing class.

The traps

  • skipped++ inside forEach; a one-element array "fix".
  • Files.readString inside map/forEach.
  • A bound method reference to a field that changes, or to null.
  • andThen and compose swapped.
  • Predicate<Integer> in hot loops.

Staff-level points, one line each

  • javac moves the body into a private lambda$method$N and emits invokedynamic; LambdaMetafactory builds a hidden class (Main$$Lambda/0x...).
  • Non-capturing lambdas: one shared instance, no allocation. Capturing: one object per evaluation (often removed by escape analysis). Anonymous classes: always new.
  • Megamorphic call sites in generic stream code limit inlining: benchmark with JMH.
  • lambda$...$N numbers shift as lambdas are added; named methods make traces readable.
  • Target typing: submit(() -> { throw new IOException(); }) picks Callable; avoid overloading on two functional interfaces of the same arity.
  • Serialized lambdas depend on synthetic method names and carry captured values: store data, not behavior.
  • Defaults can't override equals/hashCode/toString, have no state, and can still break implementers and decorators.

Say it in an interview

  • "I'd replace the counter with stream().filter(...).count(), so nothing is mutated inside the lambda."
  • "That method reference captured the old logger; a lambda would read the field each time."
  • "A non-capturing lambda is a single shared instance, so it doesn't allocate."

Practice

This skill comes with 12 multiple-choice questions that tell you which misconception a wrong answer reveals and 1 graded problem. Your answers are graded and your mastery is tracked, so you'll know when the skill holds up. Running code and saving progress need a free account.

Practice Java 8: Lambdas, Functional Interfaces and Default Methods free

A Text Cleanup Pipeline

MediumProCoding problemJava

A help-center team in Dublin cleans up article text with configurable steps: load a file, trim it, squash repeated spaces, lowercase it, redact digits. Each step is a Function<String, String>, and the configuration lists them by name. Write two helpers in Pipeline:

The full statement, the examples and the hidden test suite are part of AI Proof Engineers Pro.

Practice Java 8: Lambdas, Functional Interfaces and Default Methods free

No card needed. Lessons, practice questions and easy problems are free forever.

Free plan, no card · 7-day Pro trial · Cancel anytime