🌊
Java 8

Java 8: Lambda & Streams

Writing Less, Doing More
💡 A lambda expression is like a quick "instant note" — instead of writing a full formal letter (building a class and a method), you jot down the task in one line right away. A Stream is a factory conveyor belt where data passes through stage after stage (filter -> map -> collect) to produce the final result.

A lambda expression, (param) -> { code }, implements a functional interface (one with a single abstract method) concisely.

The Stream API lets you chain operations like filter, map, and sort on collections — without writing manual loops, resulting in shorter, more readable code.

List<Integer> nums = List.of(1, 2, 3, 4, 5, 6);
List<Integer> evenSquares = nums.stream()
    .filter(n -> n % 2 == 0)
    .map(n -> n * n)
    .toList();
// [4, 16, 36]
🌊
A lambda expression is like a quick "instant note" — instead of writing a full formal letter (building a class and a method), you jot down the task in one line right away. A Stream is a factory conveyor belt where data passes through stage after stage (filter -> map -> collect) to produce the final result.
1 / 6
⚡ Quick Recap
  • Lambda = a small inline function
  • Functional Interface = exactly one abstract method
  • Stream = a filter->map->collect chain
On this page (14 subtopics)

A lambda expression is a nameless function that directly implements a functional interface. The syntax can take several forms: (a, b) -> a + b (multiple params), x -> x * x (single param, no parentheses needed), () -> 42 (no params), or multiple statements inside { }, like (a, b) -> { int sum = a+b; return sum; }.

A lambda can "capture" local variables around it, but only if they're "effectively final" — meaning their value never changes after being captured. This rule exists because a lambda might run later on a different thread, and if the original variable changed, it would cause confusion.

Interviews often ask how a lambda differs from an anonymous class — an anonymous class has its own "this" (referring to itself), while a lambda's "this" refers to the enclosing class. In real-world code, lambdas show up most often in event listeners, Comparators, and Runnables — anywhere a small piece of logic needs to be passed around.

int factor = 10;          // effectively final
Function<Integer,Integer> multiply = n -> n * factor;
System.out.println(multiply.apply(5)); // 50
// factor = 20;  <- ye line likhoge to lambda mein error aa jaayega!

Java 8 gave us ready-made functional interfaces in the java.util.function package so you don't need to write your own interface for every small thing: Predicate<T> (test(T): boolean — for filtering), Function<T,R> (apply(T): R — convert one type to another), Consumer<T> (accept(T): void — take a value and do something, no return), Supplier<T> (get(): T — produce a value with no input).

There are two-argument versions too: BiFunction<T,U,R> (two inputs, one output), BiConsumer<T,U>, BiPredicate<T,U>. UnaryOperator<T> (a Function where input and output are the same type) and BinaryOperator<T> (a BiFunction where all types are the same) are special cases.

Adding the @FunctionalInterface annotation is optional but a best practice — the compiler checks that the interface really does have just one abstract method, so if someone accidentally adds a second one, you get an immediate error instead of finding out at runtime.

InterfaceMethodJob
Predicate<T>test(T): booleanCondition check (filtering)
Function<T,R>apply(T): RConvert T to R
Consumer<T>accept(T): voidTake T, perform an action, no return
Supplier<T>get(): TNo input, produce T
UnaryOperator<T>apply(T): TFunction where input=output type
BinaryOperator<T>apply(T,T): TBiFunction where all types match
Predicate<String> isLong = s -> s.length() > 5;
Function<String, Integer> toLength = String::length;
Consumer<String> print = System.out::println;
Supplier<Double> randomVal = Math::random;
BiFunction<Integer, Integer, Integer> add = (a, b) -> a + b;

When a lambda just calls an existing method, you can write it shorter with a method reference (::). Type 1 — Static method: ClassName::staticMethod (like Integer::parseInt). Type 2 — Instance method of a particular object: object::instanceMethod (like System.out::println).

Type 3 — Instance method of an arbitrary object (referenced by class name, the first argument automatically becomes "this"): ClassName::instanceMethod (like String::toUpperCase, where the string itself becomes the first argument). Type 4 — Constructor reference: ClassName::new, for creating a new object.

In real projects, only use a method reference when it's genuinely more readable — if a lambda already has a bit of extra logic (like s -> s.trim().toUpperCase()), forcing it into a method reference doesn't actually make it clearer, so just keep the lambda.

  • Type 1 — Static: ClassName::staticMethod, like Integer::parseInt
  • Type 2 — Particular object: object::instanceMethod, like System.out::println
  • Type 3 — Arbitrary object: ClassName::instanceMethod, like String::toUpperCase
  • Type 4 — Constructor: ClassName::new, like ArrayList::new
list.forEach(System.out::println);              // Type 2
List<String> upper = list.stream().map(String::toUpperCase).toList(); // Type 3
Supplier<ArrayList<String>> maker = ArrayList::new;                    // Type 4 (constructor)

Most common: collection.stream() (from a List/Set). Arrays.stream(array) from an array. Stream.of(1, 2, 3) from direct values. IntStream.range(1, 10) for a sequence of numbers (range() excludes the end, rangeClosed() includes it).

Infinite streams are possible too: Stream.generate(supplier) (calls the supplier every time) and Stream.iterate(seed, fn) (starts from the seed value and keeps applying fn) — you need to add limit() with these, otherwise they'll run forever.

Interview trap: a stream can only be consumed once — trying to reuse it (like calling another operation after a terminal one already ran) throws an IllegalStateException. Every time you need a fresh stream, call .stream() again on the source.

Stream<Integer> s1 = List.of(1,2,3).stream();
IntStream s2 = IntStream.rangeClosed(1, 5); // 1,2,3,4,5
Stream<Integer> s3 = Stream.iterate(1, n -> n * 2).limit(5); // 1,2,4,8,16

Intermediate operations return a new stream and are "lazy" (they don't run until a terminal operation comes along). filter(predicate) — keep elements matching a condition. map(function) — transform every element. flatMap(function) — flatten nested streams (like List<List<T>>) into a single stream.

distinct() — remove duplicates. sorted() or sorted(comparator) — apply an order. limit(n) — first n elements. skip(n) — skip the first n elements. peek() — look at things in between without changing them, for debugging.

Performance tip: put filter() before map() when possible — cut down the elements first, then transform them, avoiding unnecessary work. This small ordering choice can make a noticeable speed difference on large datasets.

  • filter(predicate) — keep elements matching a condition
  • map(function) — transform every element
  • flatMap(function) — flatten nested streams into one
  • distinct() / sorted() — remove duplicates / apply an order
  • limit(n) / skip(n) — keep the first n / skip the first n
List<String> names = List.of("Riya","Aman","Riya","Zoya");
names.stream()
  .distinct()
  .filter(n -> n.length() > 3)
  .sorted()
  .forEach(System.out::println); // Aman, Riya, Zoya

Terminal operations "consume" the stream to give a final result — a stream can only be consumed once. forEach(consumer) — an action on every element. collect(collector) — turn the result into a List/Set/Map (see the next subtopic). reduce(identity, accumulator) — combine all elements into a single value (like sum, product).

count() — how many elements there are. anyMatch/allMatch/noneMatch(predicate) — boolean checks. findFirst()/findAny() — returns an Optional<T>. min()/max(comparator) — also return an Optional<T>.

anyMatch()/allMatch()/noneMatch() are "short-circuiting" operations — as soon as the result is certain (like anyMatch finding a match), the remaining elements never get processed. This can be a lot faster than scanning the whole list on large streams.

IntermediateTerminal
ReturnsA new StreamFinal result (List, int, boolean...)
Lazy?Yes (doesn't run until terminal)Executes as soon as it runs
How many times can you chain themMultiple timesOnly one terminal per stream
Examplesfilter, map, sorted, distinctcollect, reduce, forEach, count
List<Integer> nums = List.of(1,2,3,4,5);
int sum = nums.stream().reduce(0, (a,b) -> a+b);       // 15
boolean hasEven = nums.stream().anyMatch(n -> n%2==0); // true
Optional<Integer> max = nums.stream().max(Integer::compareTo);

The Collectors class gives you helper methods to collect a stream into useful shapes. toList()/toSet() — a simple collection. joining(", ") — joins Strings together (with a separator). groupingBy(fn) — splits elements into a Map<Key, List<T>> by key (like grouping students by grade).

partitioningBy(predicate) — splits into exactly two groups, true/false (Map<Boolean, List<T>>). counting(), summingInt(), averagingDouble() — aggregate calculations, often combined with groupingBy.

With toMap(keyFn, valueFn), if two elements produce the same key, you get an IllegalStateException — you can avoid this with a third argument (a merge function), like toMap(k, v, (a,b) -> a), which keeps the old value on a duplicate key.

Map<Boolean, List<Integer>> parts = nums.stream()
    .collect(Collectors.partitioningBy(n -> n % 2 == 0));
// {false=[1,3,5], true=[2,4]}

String joined = names.stream().collect(Collectors.joining(", "));
Map<Integer, List<String>> byLength = names.stream()
    .collect(Collectors.groupingBy(String::length));

collection.parallelStream() (or .stream().parallel()) automatically splits the work across multiple CPU cores — this can speed things up on large datasets. But: using it with shared mutable state (like adding to a normal ArrayList from outside) is dangerous (race conditions), and for small datasets the overhead can actually make it slower.

Rule of thumb: only use it when the dataset is large, operations are CPU-intensive, and elements don't depend on each other (independent operations).

A common interview gotcha: parallelStream() uses the JVM-wide ForkJoinPool.commonPool() by default — meaning if something else in the application (like CompletableFuture) is already using that same pool, parallel streams can end up unexpectedly slow, competing for resources with each other.

long count = hugeList.parallelStream()
    .filter(x -> isPrime(x))
    .count();

Optional<T> explicitly represents "there may or may not be a value" instead of using null. Ways to create one: Optional.of(value) (value must not be null), Optional.ofNullable(value) (can be null), Optional.empty() (empty).

Ways to use it: isPresent()/isEmpty() to check, get() (risky — throws an exception if empty, avoid it), orElse(default) to give a fallback, orElseGet(supplier) (a lazy fallback, only computed when needed), orElseThrow() (throw a custom exception). You can chain map()/filter() without manually null-checking.

Best practice: never use Optional as a class field or a method parameter — it was designed only for return types. Making it a field just adds wrapping overhead without any real benefit.

  • Optional.of(value) — value must never be null
  • Optional.ofNullable(value) — value can be null
  • Optional.empty() — an empty Optional
  • orElse() vs orElseGet() — orElseGet is lazy, only computed when needed
Optional<String> name = Optional.ofNullable(getNameOrNull());
String result = name
    .map(String::toUpperCase)
    .filter(n -> n.length() > 2)
    .orElse("GUEST");

A default method in an interface comes with a body — implementing classes can optionally override it. A static method is called directly on the interface name (Interface.method()), not through an implementing class. This didn't exist before Java 8 — if an interface needed a new method, every implementing class would break.

If a class implements two interfaces and both have the *same* default method (the "diamond problem"), Java gives a compile error — the class has to explicitly override it and say which version (or its own) to use.

A real-world example: List.sort(Comparator) is itself a default method on the List interface (before Java 8 you had to call Collections.sort(list) separately) — a good illustration of how default methods let old interfaces gain new features without breaking anything.

interface A { default void greet() { System.out.println("Hi from A"); } }
interface B { default void greet() { System.out.println("Hi from B"); } }
class C implements A, B {
  public void greet() { A.super.greet(); } // explicitly choose karna padta hai
}

The old java.util.Date and Calendar were mutable (thread-unsafe) and had a confusing API (months are 0-indexed!). Java 8 brought the java.time package: LocalDate (date only, like 2026-07-28), LocalTime (time only), LocalDateTime (both), and all of these are *immutable* — any "modify" method actually returns a new object.

Period is for date differences (years/months/days), Duration is for time differences (hours/minutes/seconds). DateTimeFormatter is for custom formatting (like "dd-MM-yyyy").

A thread-safety interview point: the old java.util.Date was mutable, so multiple threads sharing the same Date object could run into race conditions. Every java.time class is immutable, so they can be safely shared across threads without any synchronization at all.

LocalDate today = LocalDate.now();
LocalDate birthday = LocalDate.of(2010, 5, 15);
Period age = Period.between(birthday, today);
System.out.println(age.getYears() + " saal ka");

DateTimeFormatter fmt = DateTimeFormatter.ofPattern("dd-MM-yyyy");
System.out.println(today.format(fmt));

CompletableFuture makes it easy to write async (background) tasks without manually managing threads. supplyAsync(supplier) starts a task in the background and immediately returns a "future" (like a promise). thenApply() transforms the result (once ready), thenAccept() consumes the result, thenCombine() joins the results of two async tasks.

Use exceptionally() or handle() for error handling — if an exception happens inside supplyAsync() and you don't handle it, calling future.get() throws an ExecutionException that wraps the original exception.

CompletableFuture<Integer> future = CompletableFuture
    .supplyAsync(() -> slowCalculation())
    .thenApply(result -> result * 2);
System.out.println(future.get()); // wait karke final result

Java 8 added handy default/static methods to the Comparator interface: Comparator.comparing(keyExtractor) sorts by one field. thenComparing() adds a secondary sort criterion (like first by name, then by age). reversed() flips the order. naturalOrder()/reverseOrder() use the built-in Comparable order.

Comparator.nullsFirst()/nullsLast() also arrived in Java 8 — when a list might contain nulls and you want to avoid a NullPointerException while sorting, these wrap your comparator and decide where nulls should go (at the start or the end).

students.sort(
  Comparator.comparing((Student s) -> s.grade)
    .thenComparing(s -> s.name)
    .reversed()
);

StringJoiner — a utility for joining Strings with a delimiter, prefix, and suffix (Collectors.joining() actually uses this internally). Base64 class — java.util.Base64 gave us built-in encoding/decoding, no external library needed. Repeating Annotations — now you can put the same annotation on one spot multiple times.

Interview trivia: before Java 8, developers often pulled in a library like Apache Commons Codec just for Base64 encoding — having it built in removed a very common dependency for a fairly small piece of functionality.

StringJoiner sj = new StringJoiner(", ", "[", "]");
sj.add("A").add("B").add("C");
System.out.println(sj); // [A, B, C]

String encoded = Base64.getEncoder().encodeToString("Hello".getBytes());