What you'll learn
Quick Answer
A Java stream is a lazy pipeline: filter and map only record instructions, and nothing executes until a terminal operation such as collect, count or findFirst pulls elements through. Each stream can be consumed exactly once. Streams shine for filtering, grouping and summarising collections, where the intent reads clearly in one glance. Plain loops still win when you need an index, must throw a checked exception, or must mutate local state. Use collect, never reduce, for anything mutable.
A stream is a recipe, not a result
The biggest surprise for people moving from for loops is that a stream pipeline does nothing when you build it. Calling filter or map only records an instruction and hands back another stream. Nothing runs until a terminal operation such as collect, forEach, count, findFirst or reduce pulls values through it.
List<String> names = List.of("Aarav", "Ishita", "Rohan");
Stream<String> pipeline = names.stream()
.filter(n -> {
System.out.println("checking " + n);
return n.length() > 5;
});
System.out.println("built the pipeline");
List<String> result = pipeline.collect(Collectors.toList());
System.out.println(result);
"built the pipeline" prints before any "checking" line. That ordering catches people who add a debug print inside a map, see nothing at all, and conclude the data is empty. The data is fine; there was no terminal operation, so the map never ran.
Laziness is not a curiosity, it is what makes streams cheap. Elements are pushed through depth-first: one element travels the whole pipeline before the next one starts. So list.stream().map(expensive).filter(cheap).findFirst() may call expensive exactly once, not once per element. The short-circuiting operations include findFirst, findAny, anyMatch, allMatch, noneMatch, limit and takeWhile: they stop the moment they can answer.
The other half of the rule catches everyone once. A stream can be consumed exactly one time. Touch it again and you get IllegalStateException: stream has already been operated upon or closed.
Stream<String> s = names.stream();
long howMany = s.count();
List<String> again = s.collect(Collectors.toList()); // IllegalStateException
If you need the data twice, keep the List and call stream() twice. Storing a Stream in a field, or returning one from a method whose callers might loop over it more than once, guarantees this crash appears in production rather than in your testing. Collections are reusable; streams are not.
filter, map and collect in real code
Most day-to-day stream work is three verbs. filter keeps the elements that match a predicate, map converts each element into something else, and collect gathers the survivors into a collection.
record Student(String name, String city, int marks) {}
List<Student> students = List.of(
new Student("Aarav", "Pune", 78),
new Student("Ishita", "Kochi", 91),
new Student("Rohan", "Pune", 64),
new Student("Meera", "Surat", 88));
List<String> puneToppers = students.stream()
.filter(s -> s.city().equals("Pune"))
.filter(s -> s.marks() >= 70)
.map(Student::name)
.collect(Collectors.toList()); // [Aarav]
Records and Stream.toList() both need Java 16 or later. On an older JDK, write Student as an ordinary class with getters and stay with collect(Collectors.toList()), which works everywhere.
The stage people confuse is flatMap. map is one in, one out. flatMap is one in, many out: you return a stream from the lambda and the results are flattened into a single stream.
List<List<String>> sections = List.of(
List.of("Aarav", "Ishita"),
List.of("Rohan", "Meera"));
List<String> everyone = sections.stream()
.flatMap(List::stream)
.sorted()
.collect(Collectors.toList()); // [Aarav, Ishita, Meera, Rohan]
The real leverage is in Collectors. groupingBy replaces the whole loop-plus-map-plus-computeIfAbsent dance that fills so much beginner Java, and it composes with a second collector that decides what each group holds.
Map<String, List<String>> namesByCity = students.stream()
.collect(Collectors.groupingBy(Student::city,
Collectors.mapping(Student::name, Collectors.toList())));
// Pune=[Aarav, Rohan], Kochi=[Ishita], Surat=[Meera] (map order not guaranteed)
Map<String, Double> avgByCity = students.stream()
.collect(Collectors.groupingBy(Student::city,
Collectors.averagingInt(Student::marks)));
Map<Boolean, List<Student>> passFail = students.stream()
.collect(Collectors.partitioningBy(s -> s.marks() >= 70));
One difference worth remembering: partitioningBy always returns both a true and a false key even when a bucket is empty, while groupingBy simply has no entry for a group with no members. Code that does namesByCity.get("Nagpur").size() is a NullPointerException waiting for the first city that has no students. Use getOrDefault, or ask why you are indexing a grouped map by a key you have not verified.
reduce, collect and the accumulator trap
reduce folds a stream down to a single value. For numbers you rarely need it directly, because the primitive streams already expose the operations you want.
int total = students.stream().mapToInt(Student::marks).sum();
Optional<Student> topper = students.stream()
.max(Comparator.comparingInt(Student::marks));
int product = Stream.of(2, 3, 4).reduce(1, (a, b) -> a * b); // 24
The one-argument form of reduce returns an Optional because an empty stream has no answer. The two-argument form takes an identity value and can never be empty, which makes choosing the wrong identity a silent bug: reduce(0, (a, b) -> a * b) cheerfully returns 0 for every input.
Now the trap that gets written into real code. reduce is designed for immutable folding, where the accumulator function returns a new value rather than modifying one. The moment your accumulator mutates something, it is the wrong tool.
// compiles, and is quietly broken
String joined = students.stream()
.reduce(new StringBuilder(),
(sb, s) -> sb.append(s.name()).append(", "),
StringBuilder::append)
.toString();
The identity here is one shared StringBuilder. Run it sequentially and you get away with it. Switch the source to parallelStream() and every split reuses that same identity object, so text is appended twice or interleaved between threads. The bug appears only under the conditions you did not test.
Mutable accumulation belongs in collect, which is given a supplier and therefore creates a fresh container per thread, then merges them.
String joined = students.stream()
.map(Student::name)
.collect(Collectors.joining(", "));
The same rule covers appending to a list you declared outside the pipeline. stream().forEach(x -> results.add(x)) works by accident on a sequential stream and corrupts an ArrayList on a parallel one. If the answer is a collection, collect it.
For numeric work, prefer mapToInt, mapToLong and mapToDouble. They avoid boxing every element into an Integer object, and they hand you sum(), average(), min(), max() and a statistics object for nothing.
IntSummaryStatistics stats = students.stream()
.mapToInt(Student::marks)
.summaryStatistics();
System.out.println(stats.getMin() + " " + stats.getMax() + " " + stats.getAverage());
When a plain loop is the better answer
Streams are not faster by default, and they are not always clearer. Four situations where a loop is the right call, in the order you will meet them.
First, checked exceptions. A lambda cannot throw a checked exception unless the functional interface declares it, and none of the standard ones do.
// does not compile: readString throws IOException
// List<String> all = paths.stream().map(p -> Files.readString(p)).collect(Collectors.toList());
List<String> all = new ArrayList<>();
for (Path p : paths) {
all.add(Files.readString(p)); // IOException travels up the method as normal
}
You can wrap the call in a try/catch that rethrows a RuntimeException, but you have then made your caller's error handling worse to keep a one-liner. The loop is the honest version.
Second, local state. A lambda may only read local variables that are effectively final. Anything that needs a running index, two accumulators updated together, or a break halfway through the body ends up as an int[] counter = new int[1] hack. That is a loop wearing a costume, and reviewers will say so.
Third, debugging. A stack trace from a deep pipeline is a wall of internal frames with your lambda buried as something like lambda$process$2. Stepping through a lazy pipeline in a debugger jumps around in an order that does not match the source. A loop puts a breakpoint exactly where the value changes.
Fourth, parallel(). Adding it is one word, which is why it gets added without thought. What actually happens: the work is split across the shared common ForkJoinPool, which every parallel stream in your JVM uses. On a small collection the splitting and merging cost more than the work. Sources that cannot split evenly, such as a LinkedList or an iterator, split badly, while arrays and ArrayList split well. Order-sensitive stages like sorted, findFirst and forEachOrdered add coordination. Worst of all, a blocking network or database call inside a parallel stream occupies a shared pool thread and slows down unrelated code elsewhere in the application. For blocking work use an ExecutorService you control.
Where streams genuinely win is a multi-step transformation of a collection: filter, group, summarise, sort. There, the pipeline states the intent in one readable block, and that is worth real money in a code review or a placement interview where someone has to follow your logic quickly.
The mistakes that show up in review
Collectors.toMap is stricter than HashMap.put. A duplicate key throws instead of overwriting, and a null value throws a NullPointerException that a plain map would have accepted.
Map<String, Integer> marksByCity = students.stream()
.collect(Collectors.toMap(Student::city, Student::marks));
// IllegalStateException: Duplicate key Pune (attempted merging values 78 and 64)
Map<String, Integer> bestByCity = students.stream()
.collect(Collectors.toMap(Student::city, Student::marks, Integer::max));
The three-argument version takes a merge function and is almost always what you meant. If you find yourself merging into a list, you wanted groupingBy.
Mutability of the result depends on how you asked for it. Stream.toList() returns an unmodifiable list, so a later add throws UnsupportedOperationException in code far from the pipeline. collect(Collectors.toList()) gives you a list with no documented guarantee, in practice an ArrayList. If you intend to modify it, say so explicitly with collect(Collectors.toCollection(ArrayList::new)).
peek is a debugging aid and nothing else. Because the pipeline is lazy, elements that are never pulled are never peeked, and a terminal operation that can compute its answer without visiting every stage may skip the peek entirely. A peek that writes an audit row to a database is a bug that looks like it works.
Do not modify the source collection while a pipeline over it is running. You will usually get a ConcurrentModificationException, and occasionally something worse and quieter. Build a new collection, or use the collection's own method.
List<Student> roll = new ArrayList<>(students);
roll.removeIf(s -> s.marks() < 35); // no stream needed
Order your stages deliberately. sorted and distinct have to buffer, so filter first and sort second: sorting ten thousand rows in order to keep three is work you never needed to do. limit after sorted still sorts everything, which surprises people expecting a top-N shortcut.
Finally, treat the Optional from findFirst, max or reduce as a real branch in your logic. orElseThrow(() -> new NoSuchStudentException(roll)) tells the next person what went wrong. A bare get() throws NoSuchElementException: No value present, which tells them nothing at all.
