Passing Behaviour, Not Just Data
Everything you have written so far passes data into methods. A lambda lets you pass behaviour — a small piece of code that the receiving method will run when it decides to.
The need is easy to see with sorting. Collections.sort knows how to sort but does not know how you want the elements ordered, so you have to hand it that decision. Before Java 8 the only way was an anonymous inner class: five lines of ceremony wrapped around one line of actual logic. Read the first example below and count how much of it is meaningful — the answer is the single line comparing lengths. Everything else is scaffolding.
A lambda expression throws the scaffolding away. (a, b) -> a.length() - b.length() means the same thing. The compiler already knows from the parameter type that it needs a Comparator<String> with a compare method taking two Strings, so repeating all of that adds nothing.
This works because of functional interfaces: interfaces with exactly one abstract method. When a lambda appears where such an interface is expected, the compiler treats the lambda as the implementation of that one method. That is also why lambdas cannot be used for an interface with two abstract methods — there would be no way to tell which one you meant.
import java.util.*;
List<String> names = new ArrayList<>(List.of("Ananya", "Raj", "Meera"));
// ---- Before Java 8: an anonymous inner class ----
Comparator<String> byLengthOld = new Comparator<String>() {
@Override
public int compare(String a, String b) {
return Integer.compare(a.length(), b.length()); // the only real line
}
};
// ---- With a lambda: the same thing ----
Comparator<String> byLength = (a, b) -> Integer.compare(a.length(), b.length());
names.sort(byLength);
System.out.println(names); // [Raj, Meera, Ananya]
// Usually written inline, where it is used
names.sort((a, b) -> Integer.compare(a.length(), b.length()));
// ---- The syntax forms ----
// One parameter: brackets optional
Runnable r1 = () -> System.out.println("no parameters");
// Single expression: no braces, no return, the value is returned
java.util.function.Function<Integer, Integer> square = n -> n * n;
// Several statements: braces and an explicit return
java.util.function.Function<Integer, String> grade = marks -> {
if (marks >= 90) return "A";
if (marks >= 40) return "Pass";
return "Fail";
};
System.out.println(square.apply(7)); // 49
System.out.println(grade.apply(85)); // Pass - A lambda can only use local variables from the surrounding method if they are
finalor effectively final — never reassigned after being set. Trying to modify a captured variable giveslocal variables referenced from a lambda expression must be final or effectively final. Fields of an object are not restricted this way.
The Built-In Functional Interfaces
You could define a functional interface for every situation, but you would end up redefining the same handful of shapes over and over. Java ships them in java.util.function, and four of them cover the vast majority of real code. Learning their names is worth the ten minutes, because every modern Java API is written in terms of them.
Predicate<T> takes a value and answers true or false — its method is test. That is what filter and removeIf take. Function<T, R> takes a value and returns a different one, via apply; that is what map takes. Consumer<T> takes a value and returns nothing, via accept; that is what forEach takes. Supplier<T> takes nothing and produces a value, via get, which is how you defer creating something expensive until it is actually needed.
Several of these compose. Predicate has and, or and negate, so you can build a complex condition from simple named pieces instead of one long boolean expression. Function has andThen and compose for chaining transformations.
The one detail to know beyond the names: primitive versions exist — IntPredicate, IntFunction, ToIntFunction and so on. They exist to avoid boxing every int into an Integer, which matters when processing large amounts of data.
Predicate<T>—boolean test(T t). A yes/no question. Used byfilter,removeIf,anyMatch.Function<T, R>—R apply(T t). Converts one thing into another. Used bymap.Consumer<T>—void accept(T t). Does something with a value. Used byforEach.Supplier<T>—T get(). Produces a value on demand. Used byorElseGet,computeIfAbsent.UnaryOperator<T>— aFunctionwhose input and output are the same typeBinaryOperator<T>— takes two of a type and returns one, as inInteger::sumComparator<T>andRunnable— functional interfaces you already met, which is why they accept lambdas
import java.util.*;
import java.util.function.*;
Predicate<Integer> isEven = n -> n % 2 == 0;
Predicate<Integer> isPositive = n -> n > 0;
System.out.println(isEven.test(4)); // true
System.out.println(isEven.and(isPositive).test(-4)); // false
System.out.println(isEven.negate().test(4)); // false
Function<String, Integer> length = s -> s.length();
Function<Integer, String> describe = n -> n + " characters";
System.out.println(length.andThen(describe).apply("Ananya")); // 6 characters
Consumer<String> print = s -> System.out.println("-> " + s);
print.accept("hello");
Supplier<List<String>> newList = () -> new ArrayList<>();
List<String> fresh = newList.get();
// Where you will actually meet them
List<Integer> marks = new ArrayList<>(List.of(45, 92, 33, 78));
marks.removeIf(m -> m < 40); // Predicate
marks.forEach(m -> System.out.println(m)); // Consumer
marks.replaceAll(m -> m + 5); // UnaryOperator — grace marks
System.out.println(marks); // [97, 83]
Map<String, List<String>> groups = new HashMap<>();
groups.computeIfAbsent("Pune", k -> new ArrayList<>()).add("Ananya"); // Function - You can define your own functional interface whenever none of the built-in shapes fits, or when a domain-specific name makes the code clearer. Mark it
@FunctionalInterfaceso the compiler stops anyone adding a second abstract method later and breaking every lambda written against it.
Method References
When a lambda does nothing except call an existing method, you can name that method directly with the :: operator. s -> s.toUpperCase() becomes String::toUpperCase, and x -> System.out.println(x) becomes System.out::println. It is the same code with the ceremony removed.
There are four forms and it is worth being able to recognise them, because they look similar and mean different things. A static method reference, Integer::parseInt, calls that static method with the lambda's argument. A reference to an instance method of a particular object, System.out::println, calls that method on that one object. A reference to an instance method of an arbitrary object, String::toUpperCase, uses each element as the receiver — this is the form that confuses people, because the argument becomes the object the method is called on. And a constructor reference, ArrayList::new, creates a new object.
Use a method reference when it genuinely reads better, which is most of the time. Keep a lambda when the parameters clarify what is happening, or when the body is not a single call. Both compile to the same thing, so this is purely about readability.
import java.util.*;
import java.util.function.*;
import java.util.stream.Collectors;
List<String> names = new ArrayList<>(List.of("ananya", "rahul", "meera"));
// 1. Static method
Function<String, Integer> parse = Integer::parseInt; // s -> Integer.parseInt(s)
System.out.println(parse.apply("42")); // 42
// 2. Instance method of a PARTICULAR object
Consumer<String> print = System.out::println; // s -> System.out.println(s)
names.forEach(System.out::println);
// 3. Instance method of an ARBITRARY object of the type
// the element becomes the receiver
List<String> upper = names.stream()
.map(String::toUpperCase) // s -> s.toUpperCase()
.collect(Collectors.toList());
System.out.println(upper); // [ANANYA, RAHUL, MEERA]
// 4. Constructor
Supplier<ArrayList<String>> maker = ArrayList::new; // () -> new ArrayList<>()
// Comparators read beautifully with method references
record Student(String name, int marks) { }
List<Student> batch = new ArrayList<>(List.of(
new Student("Rahul", 87), new Student("Ananya", 92)));
batch.sort(Comparator.comparing(Student::name));
batch.sort(Comparator.comparingInt(Student::marks).reversed());
System.out.println(batch.get(0).name()); // Ananya
// Keep the lambda when it is clearer
names.removeIf(n -> n.length() < 5); // clearer than any method reference String::toUpperCaseworks as aFunction<String, String>because the single argument becomes the object the method runs on. That is the one form worth staring at until it clicks — it explains most of what you will see in stream code.
Streams: Describing What, Not How
A stream is a pipeline for processing a sequence of values. It is not a collection: it stores nothing, and it does not modify the collection it came from. It describes a series of steps to perform.
Every pipeline has three parts. A source — usually collection.stream(). Zero or more intermediate operations such as filter, map and sorted, each returning a new stream so they chain. And exactly one terminal operation such as collect, forEach or count, which produces the final result.
The reason to use them is readability. A loop that filters, transforms and totals mixes three concerns into one block, and you must read every line to work out its intent. The stream version names each step, so the pipeline reads as a sentence: take the students, keep those who passed, take their marks, add them up.
Two behaviours matter and both trip up beginners. Streams are lazy: intermediate operations do nothing until a terminal operation runs. A pipeline with no terminal operation executes zero code, which is why a stream that "does not work" is often a stream that was never finished. And a stream is single-use: once a terminal operation has run, that stream is spent, and reusing it throws IllegalStateException: stream has already been operated upon or closed. Create a new stream from the source instead.
import java.util.*;
import java.util.stream.Collectors;
record Student(String name, String city, int marks) { }
List<Student> batch = List.of(
new Student("Ananya", "Pune", 92),
new Student("Rahul", "Pune", 38),
new Student("Meera", "Kochi", 78),
new Student("Vikram", "Kochi", 85)
);
// ---- The loop version ----
List<String> passedLoop = new ArrayList<>();
for (Student s : batch) {
if (s.marks() >= 40) {
passedLoop.add(s.name().toUpperCase());
}
}
Collections.sort(passedLoop);
// ---- The stream version: each step is named ----
List<String> passed = batch.stream()
.filter(s -> s.marks() >= 40) // intermediate
.map(Student::name) // intermediate
.map(String::toUpperCase) // intermediate
.sorted() // intermediate
.toList(); // terminal (Java 16+)
System.out.println(passed); // [ANANYA, MEERA, VIKRAM]
// ---- Laziness: nothing runs without a terminal operation ----
batch.stream().filter(s -> {
System.out.println("checking " + s.name());
return true;
});
// prints nothing at all
// ---- Single use ----
var stream = batch.stream();
System.out.println(stream.count());
// System.out.println(stream.count());
// IllegalStateException: stream has already been operated upon or closed stream.toList()(Java 16+) is the modern way to finish a pipeline and returns an unmodifiable list.collect(Collectors.toList())is the older form you will still see everywhere; usecollect(Collectors.toCollection(ArrayList::new))when you specifically need a list you can modify afterwards.
The Stream Operations Worth Knowing
A dozen operations cover almost everything. filter keeps elements matching a condition. map transforms each element. sorted orders them. distinct removes duplicates using equals. limit and skip take or drop from the front.
For finishing a pipeline: collect gathers into a collection, count counts, anyMatch / allMatch / noneMatch answer questions about the whole stream, and findFirst returns the first match. That last one returns an Optional rather than the value directly, because the stream might be empty — orElse supplies a fallback.
Numbers get their own treatment. mapToInt converts to an IntStream, which has sum(), average() and max() that ordinary streams lack. average() returns an OptionalDouble, again because an empty stream has no average.
The most powerful collector is groupingBy, which builds a Map from a classifying function. Grouping students by city, or counting words by first letter, is one line rather than fifteen. It combines with other collectors — counting(), averagingInt(), joining() — to answer real reporting questions directly.
The one rule to hold to: do not modify anything outside the stream from inside a lambda. Adding to an external list, or updating a counter variable, defeats the design, breaks completely if the stream is ever made parallel, and is usually a sign that collect would have done the job properly.
import java.util.*;
import java.util.stream.Collectors;
record Student(String name, String city, int marks) { }
List<Student> batch = List.of(
new Student("Ananya", "Pune", 92),
new Student("Rahul", "Pune", 38),
new Student("Meera", "Kochi", 78),
new Student("Vikram", "Kochi", 85)
);
// Numbers
int total = batch.stream().mapToInt(Student::marks).sum(); // 293
double avg = batch.stream().mapToInt(Student::marks).average().orElse(0);
System.out.printf("Total %d, average %.2f%n", total, avg);
// Questions about the whole stream
System.out.println(batch.stream().anyMatch(s -> s.marks() >= 90)); // true
System.out.println(batch.stream().allMatch(s -> s.marks() >= 40)); // false
System.out.println(batch.stream().noneMatch(s -> s.marks() < 0)); // true
// Finding — returns an Optional, because there may be nothing
String topper = batch.stream()
.max(Comparator.comparingInt(Student::marks))
.map(Student::name)
.orElse("none");
System.out.println(topper); // Ananya
// Joining into a String
String nameList = batch.stream().map(Student::name)
.collect(Collectors.joining(", "));
System.out.println(nameList); // Ananya, Rahul, Meera, Vikram
// Grouping — the reporting workhorse
Map<String, List<Student>> byCity =
batch.stream().collect(Collectors.groupingBy(Student::city));
Map<String, Long> countByCity =
batch.stream().collect(Collectors.groupingBy(Student::city,
Collectors.counting()));
System.out.println(countByCity); // {Pune=2, Kochi=2}
Map<String, Double> avgByCity =
batch.stream().collect(Collectors.groupingBy(Student::city,
Collectors.averagingInt(Student::marks)));
System.out.println(avgByCity); // {Pune=65.0, Kochi=81.5}
// ---- Do NOT do this ----
// List<String> collected = new ArrayList<>();
// batch.stream().forEach(s -> collected.add(s.name())); // use collect instead filter(Predicate)— keep matching elementsmap(Function)/mapToInt— transform each elementsorted()/sorted(Comparator)— order the elementsdistinct(),limit(n),skip(n)— deduplicate and slicecollect(...),toList(),count()— finish the pipelineanyMatch,allMatch,noneMatch,findFirst— ask questionsCollectors.groupingBy,joining,counting,averagingInt— build reports
- Streams are not always the right answer. A simple loop over ten items is clearer as a loop, and a stream that needs an index or has to modify elements in place is fighting the design. Use streams where they make the intent obvious, not because they look modern.
parallelStream()spreads the work across CPU cores. It helps only for large data and genuinely independent work, and it makes any shared mutable state an immediate bug. Measure before using it; on small collections it is usually slower.
