Skip to content

Java · Streams & Functional

Java Lambdas & Functional Interfaces — A Practical Guide

9 min read Updated 2026-06-20 Share:

Practice Lambdas & Functional Interfaces interview questions

Functional programming, the Java way

Before Java 8, passing behavior around meant writing a verbose anonymous class for every callback, comparator, or event handler. Lambdas changed that: behavior became a value you could store in a variable, pass as an argument, and return from a method. But a lambda is not a free-floating closure like in other languages — it is always tied to a functional interface, and understanding that link explains almost every rule that follows. This guide walks through the syntax, the standard interface library, composition, method references, and the subtle differences from the anonymous classes lambdas replaced.

What a lambda is

A lambda expression is an anonymous function: a parameter list, an arrow, and a body. It is compact syntax for implementing the single abstract method of a functional interface. The compiler matches the lambda's shape to that method, so the body becomes the method implementation — no class name, no boilerplate.

// parameters -> body
Runnable r = () -> System.out.println("hi");   // no params, expression body
Function<Integer,Integer> sq = x -> x * x;     // one param, parens optional
Comparator<String> c = (a, b) -> a.length() - b.length(); // multi-param needs parens

// block body needs braces AND an explicit return
BinaryOperator<Integer> add = (a, b) -> { return a + b; };

The two rules worth memorising: a single-expression body has no braces and no return (the value is implicit), while a block body needs both braces and an explicit return if it produces a value. Parameter types are almost always inferred from the target, so you omit them.

Functional interfaces, SAM, and @FunctionalInterface

A functional interface is an interface with exactly one abstract method — a SAM (Single Abstract Method). That method is the target a lambda implements. The interface can still declare any number of default and static methods, and methods inherited from Object (like equals) don't count toward the limit.

@FunctionalInterface
interface Transformer {
  String apply(String s);                                  // the one abstract method
  default String twice(String s) { return apply(apply(s)); } // doesn't break SAM
  // String other(String s);   // adding this would FAIL the build
}

Transformer upper = s -> s.toUpperCase();

The @FunctionalInterface annotation is optional and has no runtime effect — a lambda works on any SAM interface whether or not it's annotated. What it buys you is a compile error if someone later adds a second abstract method, protecting every caller who passed a lambda. Treat it as documentation that the compiler enforces.

The java.util.function family

You rarely need to write your own functional interface, because java.util.function ships the general-purpose ones. They cluster into four families — transform, produce, consume, test — plus operator and two-argument variants.

InterfaceAbstract methodShapeUsed by
Function<T,R>R apply(T)T → RStream.map
BiFunction<T,U,R>R apply(T,U)(T,U) → RMap.merge
Supplier<T>T get()() → TOptional.orElseGet
Consumer<T>void accept(T)T → voidforEach
Predicate<T>boolean test(T)T → booleanStream.filter
UnaryOperator<T>T apply(T)T → TList.replaceAll
BinaryOperator<T>T apply(T,T)(T,T) → TStream.reduce
Function<String,Integer> length = String::length;   // transform
Supplier<Double>         rng    = Math::random;      // produce
Consumer<String>         print  = System.out::println; // consume
Predicate<Integer>       isEven = n -> n % 2 == 0;   // test

UnaryOperator and BinaryOperator are just Function/BiFunction specialised so input and output share a type — reach for them when that's true (String::trim, Integer::sum) because they read more clearly than the general form.

Composing functions and predicates

Functional interfaces come with default methods that combine instances into new ones, leaving the originals untouched. For Function, andThen runs left-to-right (this, and then that) while compose runs inside-out, matching the maths f(g(x)). For Predicate, and, or, and negate build compound conditions.

Function<Integer,Integer> times2 = x -> x * 2;
Function<Integer,Integer> plus3  = x -> x + 3;
times2.andThen(plus3).apply(5);   // (5*2)+3 = 13   — f then g
times2.compose(plus3).apply(5);   // (5+3)*2 = 16   — g then f

Predicate<String> notBlank = s -> !s.isBlank();
Predicate<String> shortStr = s -> s.length() < 10;
Predicate<String> valid = notBlank.and(shortStr); // && short-circuits

Composition keeps small, named pieces reusable and lets you assemble complex logic without nesting lambdas. Java 11 added a static Predicate.not(...) so you can negate a method reference directly: filter(Predicate.not(String::isBlank)).

Method references: four kinds

When a lambda does nothing but forward its arguments to a single existing method, a method reference (::) says the same thing with less noise. The compiler infers which method from the target interface's signature. There are exactly four kinds.

KindSyntaxEquivalent lambda
StaticInteger::parseInts -> Integer.parseInt(s)
Bound instanceSystem.out::printlns -> System.out.println(s)
Unbound instanceString::toUpperCases -> s.toUpperCase()
ConstructorArrayList::new() -> new ArrayList<>()
String prefix = "Hello, ";
Function<String,String>  greet = prefix::concat;   // BOUND   — receiver captured now
Function<String,Integer> len   = String::length;   // UNBOUND — receiver is the 1st arg

The trap is bound vs unbound. A bound reference puts a specific object (a value or variable) on the left of :: and uses it as the receiver. An unbound reference puts a type name on the left, and the lambda's first argument becomes the receiver — so the interface has one extra leading parameter.

Effectively-final capture and why it exists

A lambda may capture local variables from the enclosing method, but only ones that are final or effectively final — assigned exactly once and never reassigned, even without the final keyword. Reassigning a captured local is a compile error. Crucially, this rule applies only to locals; instance and static fields are fair game.

int factor = 3;                                  // effectively final
Function<Integer,Integer> f = x -> x * factor;   // OK — captures the value 3

int total = 0;
// list.forEach(n -> total += n);                // COMPILE ERROR — total reassigned
int[] box = {0};                                 // mutate object state instead
list.forEach(n -> box[0] += n);

The reason is lifetime and safety: a lambda can outlive the method that created it (be stored, queued, or run on another thread), and Java captures locals by value. Allowing reassignment would create two diverging copies of the "same" variable. The idiomatic fix isn't the array trick or AtomicInteger — it's to avoid mutation and let a stream do the work: list.stream().mapToInt(Integer::intValue).sum().

Lambdas vs anonymous classes

Lambdas look like terser anonymous classes, but the differences run deep — and they are classic interview material. The headline gotcha is this: inside a lambda it refers to the enclosing instance, because a lambda introduces no new scope. Inside an anonymous class, this is the anonymous object itself.

AspectLambdaAnonymous class
thisthe enclosing instancethe anonymous object
Scopeshares the method's scopeintroduces its own
Compilationinvokedynamic, no extra .classa separate .class file
Stateonly the one SAMfields and multiple methods
class Widget {
  String name = "panel";
  Runnable make() {
    return () -> System.out.println(this.name); // "panel" — Widget's this
  }
}

Compilation differs too. An anonymous class generates a Widget$1.class at build time; a lambda compiles to an invokedynamic instruction plus a private synthetic method, and the JVM's LambdaMetafactory builds the implementation lazily at runtime. That means no class-file explosion, smaller jars, and a stateless capture-free lambda can even be reused as a singleton.

Target typing

A lambda has no type of its own — its type is decided by the context it appears in, called the target type. The compiler reads that context, finds the single abstract method, and checks the lambda's parameters and return against it.

Runnable       r = () -> doWork();                     // target type Runnable
Callable<Void> c = () -> { doWork(); return null; };   // target type Callable
Comparator<String> byLen = (a, b) -> a.length() - b.length();

This is why the same arrow shape can implement different interfaces, and why a lambda cannot be assigned to var or to Object — there's no abstract method to infer against.

Primitive specializations

Generic interfaces only hold reference types, so Function<Integer,Integer> autoboxes every int into a heap Integer — wasteful in hot loops and streams. The primitive specializations work directly on int, long, and double to eliminate that overhead.

SpecializationShape
IntFunction<R>int → R
ToIntFunction<T>T → int
IntUnaryOperatorint → int
IntPredicateint → boolean
IntSupplier / IntConsumer() → int / int → void
IntUnaryOperator square = x -> x * x;   // no boxing anywhere
square.applyAsInt(5);                    // 25

IntStream, LongStream, and DoubleStream are built on these, which is exactly why mapToInt(...).sum() outperforms mapping to boxed Integers. When you control the type, prefer the primitive specialization in any performance-sensitive path.

Recap

A lambda is anonymous-function syntax for the single abstract method of a functional interface; @FunctionalInterface makes that contract compiler-enforced. The java.util.function family (Function, Supplier, Consumer, Predicate, and their operator and bi-arg variants) covers almost every case, and their andThen/compose/ and/or/negate defaults let you compose small pieces into big behavior. Method references in their four kinds (static, bound, unbound, constructor) trim lambdas that just forward arguments. Captured locals must be effectively final because lambdas capture by value and can outlive their method. Finally, lambdas differ from anonymous classes in lexical this, scope, and invokedynamic compilation — and the primitive specializations exist to dodge autoboxing. Master these and the entire streams API becomes readable.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel