JAVA / STREAMS, LAMBDAS AND OPTIONAL
Sorting, distinct and peeking at pipeline flow
Trace element flow with peek, sort with stable comparators, and dedupe with distinct, knowing which stages buffer the whole stream first.
What you will learn
- Read a peek trace to tell streaming stages from stages that buffer everything
- Sort with Comparator.comparing(...).thenComparing(...) and rely on sorted() stability
- Know that distinct() dedupes by equals/hashCode and keeps the first occurrence
- Keep side effects out of peek, since an implementation may skip it entirely
Understanding Sorting, distinct and peeking at pipeline flow
A sequential stream does not run one stage at a time over the whole collection; it pulls a single element from the source and pushes it through every stateless stage before it touches the next element. peek exists to make that invisible order visible: it hands each element to a Consumer and then passes the same reference on, so a print statement inside it fires from the middle of the flow. Put one peek before a stage and one after it, and the way the two sets of lines interleave tells you whether that stage forwards elements as they arrive or holds them back.
sorted() has to hold them back, because the element that must come first could be the last one read from the source. It collects everything into an internal buffer as elements arrive, and only when upstream signals that the source is exhausted does it sort the buffer and push the elements downstream in order. That is why a peek after sorted() stays silent until the peek before it has printed every element, and it is also why sorted() on an infinite stream never returns and why a filter placed before the sort is cheaper than the same filter after it.
distinct() is stateful too, but it needs no barrier: it keeps a hash set of the elements it has already forwarded and passes each new one straight through, which for an ordered stream means the first occurrence wins and later duplicates vanish. Membership is decided by hashCode and equals, not by the fields you can see, so distinct() on a class that inherits identity equality from Object removes nothing at all. peek is passive by contrast: it is an intermediate stage, so it runs only when a terminal operation pulls elements through, and the specification lets an implementation skip it when the terminal operation can produce its answer without the elements, count() over a sized source being the standard example.
import java.util.List;
public class PipelineFlow {
public static void main(String[] args) {
List<String> words = List.of("delta", "alpha", "delta", "charlie", "bravo");
List<String> result = words.stream()
.peek(w -> System.out.println("1 source " + w))
.distinct()
.peek(w -> System.out.println("2 distinct " + w))
.sorted()
.peek(w -> System.out.println("3 sorted " + w))
.toList();
System.out.println("result " + result);
}
}peek is a window onto the pipeline, and what it reveals is that stateless stages carry one element at a time while sorted() buffers the entire stream before emitting anything.
Worked examples
Stable sorting and comparator chains
Shows that sorted() keeps tied elements in source order, and how thenComparing supplies the missing tiebreak.
import java.util.Comparator;
import java.util.List;
public class SortStability {
public static void main(String[] args) {
List<String> names = List.of("Ida", "Bob", "Alexandra", "Eve", "Cai", "Dan");
System.out.println(names.stream()
.sorted(Comparator.comparingInt(String::length))
.toList());
System.out.println(names.stream()
.sorted(Comparator.comparingInt(String::length)
.thenComparing(Comparator.naturalOrder()))
.toList());
}
}Example explained
Line 1Comparator.comparingInt(String::length) looks only at length, so the five three-letter names all compare equal.
Line 2sorted() is stable for object streams, so those ties leave the buffer in source order: Ida before Bob, not the other way round.
Line 3thenComparing(Comparator.naturalOrder()) is consulted only when the length comparison returns 0, which sorts each length group alphabetically.
Line 4Both terminal calls read the same immutable source list; sorting happens in the stream's buffer and never reorders names.
distinct() only sees equals()
Demonstrates that duplicate removal depends entirely on equals and hashCode, not on visible field values.
import java.util.List;
public class DistinctEquals {
record Point(int x, int y) { }
static class Pixel {
final int x;
final int y;
Pixel(int x, int y) {
this.x = x;
this.y = y;
}
@Override
public String toString() {
return "Pixel[" + x + "," + y + "]";
}
}
public static void main(String[] args) {
List<Point> points = List.of(new Point(1, 1), new Point(1, 1), new Point(2, 3));
System.out.println(points.stream().distinct().count());
List<Pixel> pixels = List.of(new Pixel(1, 1), new Pixel(1, 1), new Pixel(2, 3));
System.out.println(pixels.stream().distinct().toList());
}
}Example explained
Line 1Point is a record, so equals and hashCode compare x and y, and the two (1,1) instances collapse into one.
Line 2count() genuinely traverses the pipeline here, because distinct() discards the source's known size and the answer can no longer be read off the list.
Line 3Pixel overrides only toString, inheriting Object's identity equality, so no two Pixel instances are ever equal.
Line 4distinct() therefore forwards all three pixels and the duplicate survives with no exception and no warning.
Important notes
sorted() before limit() still sorts every element that reached the buffer, so filter upstream when you can; on an infinite stream sorted() never returns at all.
In a parallel stream a peek trace is interleaved across threads and no longer shows a meaningful order, and the action passed to peek must be thread-safe.
Common mistakes
Calling the no-argument sorted() on elements that do not implement Comparable: it compiles, and the ClassCastException appears later from inside the terminal operation, so the stack trace points at toList() rather than at sorted().
Doing real work in peek, such as peek(list::add) or peek(o -> o.setActive(true)): a pipeline like list.stream().peek(System.out::println).count() answers from the source size without traversing, so the side effect silently never happens.
Assuming distinct() compares field values: for an ordinary class without equals and hashCode every instance is unique, so the duplicates survive and nothing reports the problem.
Try it yourself
Change, predict, then run
Build a stream from List.of("pear", "fig", "pear", "apple", "fig", "kiwi") with a peek before distinct(), one between distinct() and sorted(Comparator.comparingInt(String::length)), and one after sorted(). Write down the exact order of printed lines before running it, then run it and check where the interleaving stops.
Open the Java workspaceCheck your understanding
A sequential pipeline tags each element as it passes: Stream.of("b", "a", "b") .peek(s -> System.out.print("1" + s + " ")) .distinct() .sorted() .peek(s -> System.out.print("2" + s + " ")) .forEach(s -> System.out.print("3" + s + " ")); What does it print?
- 1b 2b 3b 1a 2a 3a 1b
- 1b 1a 1b 2a 3a 2b 3b
- 1b 1a 1b 2a 2b 3a 3b
- 1b 1a 2a 3a 1b 2b 3b
Show answer
sorted() cannot emit anything until the source is exhausted, so all three "1" tags print first, including the one for the duplicate, because that peek sits upstream of distinct() and still sees it. The option ending 2a 2b 3a 3b is tempting because it assumes the barrier releases its buffer to the next stage as a batch, but once sorted() flushes, each element again travels alone through the remaining stages, so 3a prints before 2b.