Course contents

Sorting, distinct and distinctBy

Sorting streams, removing duplicates with distinct, building distinctBy safely, comparator choices, stability, stateful barriers and cost trade-offs.

PremiumOften askedLesson14 min readCoreReviewed Oct 2026 · Java 21
On this page
  1. Picture it first: a sorting tray, then a stamp check
  2. Sorting under the hood: natural order and comparator chains
  3. distinct() under the hood: equality, first occurrence and memory
  4. Advanced: distinctBy options in Java 21
  5. How to say it in the interview

🧹 What are

sorted, distinct and distinctBy? sorted puts stream elements into a defined order, while distinct removes duplicate elements according to equals and hashCode. distinctBy is the common name for de-duplicating by a derived key, such as keeping one Customer per email, but Java 21 does not have a built-in Stream.distinctBy method. Think of it like arranging invoices on a desk: first you choose the sorting rule, then you decide whether two invoices are the same paper or only share the same customer number.

For this lesson, we'll focus on sorting and de-duplicating object streams in Java 21: natural order, comparator chains, stability, stateful buffering, distinct() internals, and the practical ways to write distinctBy. We'll link back to How a stream pipeline executes for the general lazy pipeline model instead of re-teaching it here.

Below the line (out of scope)

The 30-second answer

sorted() uses natural order, so elements must implement Comparable; sorted(comparator) uses the comparator you pass, often built with Comparator.comparing, thenComparing, reversed and nullsLast. Sorting is stable for ordered streams, but it is a stateful intermediate operation: it may buffer the whole stream before sending anything downstream. distinct() keeps one element per equals and hashCode result, and for ordered streams it keeps the first occurrence. In the JDK that means a set-like structure, often a LinkedHashSet shape for ordered de-duplication. Java 21 has no distinctBy, so choose deliberately: a stateful filter with ConcurrentHashMap.newKeySet() for a local sequential pipeline, toMap with a merge function when you want a map rule, groupingBy when you need buckets, a TreeSet collector when comparator uniqueness is the rule, or a library such as Eclipse Collections or StreamEx if your codebase already uses it.

What you’ll learn

  • Choose between sorted() natural order and **Comparator chains** with comparing, thenComparing, reversed and nullsLast
  • Explain why sorted is a stateful barrier that may buffer the whole stream before emitting output
  • Use distinct() correctly with equals and hashCode, including first-occurrence behavior on ordered streams
  • Implement distinctBy with a ConcurrentHashMap.newKeySet() predicate and explain why it is stateful, not reusable and delicate in parallel
  • Compare toMap, groupingBy, TreeSet and library distinctBy options for domain-key uniqueness
  • Estimate the cost of sorting, de-duplication and key-based de-duplication before choosing a pipeline

01Picture it first: a sorting tray, then a stamp check

Picture a mailroom tray. sorted is the clerk who must see the mail before arranging it by postcode, priority and name. distinct is the stamp checker who keeps the first envelope with each identity and sets aside later repeats.

That difference matters. Sorting needs global order; distinct needs memory of what it has seen. Both can interrupt the neat one-element-at-a-time mental model from stateless operations.

Fig. 1

A stream of orders reaches sorted, which buffers and orders all elements, then distinct keeps the first equal order before the terminal operation receives results

A stream of orders reaches sorted, which buffers and orders all elements, then distinct keeps the first equal order before the terminal operation receives results

Notice the barrier. A filter can decide on one element and move on. sorted cannot know the first sorted element until it has enough input, and for a finite unsorted source that usually means all of it.

02Sorting under the hood: natural order and comparator chains

stream.sorted() means natural order. For String, Integer, LocalDate and other Comparable types, that is fine. For your own domain objects, I usually pass a comparator even if the class implements Comparable, because the call site then says what business order this report needs.

Try it yourself first

Before reading on, predict the order of these shipments. Which field breaks ties, and where do null cities go?

import java.time.LocalDate;
import java.util.Comparator;
import java.util.List;

record Shipment(String id, String city, int priority, LocalDate createdAt) { }

final class SortingDemo {
    public static void main(String[] args) {
        List<Shipment> shipments = List.of(
                new Shipment("s-1", "Pune", 2, LocalDate.parse("2026-10-03")),
                new Shipment("s-2", "Delhi", 1, LocalDate.parse("2026-10-01")),
                new Shipment("s-3", "Delhi", 3, LocalDate.parse("2026-10-02")),
                new Shipment("s-4", null, 1, LocalDate.parse("2026-10-04"))
        );

        List<String> ids = shipments.stream()
                .sorted(Comparator
                        .comparing(Shipment::city, Comparator.nullsLast(String::compareTo))
                        .thenComparing(Comparator.comparingInt(Shipment::priority).reversed())
                        .thenComparing(Shipment::createdAt))
                .map(Shipment::id)
                .toList();

        System.out.println(ids); // [s-3, s-2, s-1, s-4]
    }
}

Notice three things:

  • Comparator.comparing(Shipment::city, Comparator.nullsLast(...)) handles nullable sort keys explicitly.
  • thenComparing is the tie-breaker chain. It only runs when the previous comparison returns 0.
  • reversed() applies to the comparator you call it on. Parentheses matter when only one field should be descending.

Natural ordering is shorter, but it hides the rule inside the element type. A comparator is noisier, but it puts the report order next to the stream pipeline. My default in service code is use a named comparator once the rule has more than one field.

import java.util.Comparator;
import java.util.List;

record User(String username, int reputation) { }

final class NamedComparatorDemo {
    private static final Comparator<User> BY_REPUTATION_THEN_NAME = Comparator
            .comparingInt(User::reputation).reversed()
            .thenComparing(User::username);

    public static void main(String[] args) {
        List<User> users = List.of(
                new User("mina", 12),
                new User("asha", 21),
                new User("dev", 21)
        );

        System.out.println(users.stream().sorted(BY_REPUTATION_THEN_NAME).toList());
    }
}

The printout is [User[username=asha, reputation=21], User[username=dev, reputation=21], User[username=mina, reputation=12]]. That reads like the business rule: high reputation first, then username.

Stability: equal sort keys keep encounter order

For ordered streams, sorted is stable. If two elements compare equal, their encounter order is preserved. This is one reason chained sorts can work when you sort by the least important key first and the most important key last, although a single comparator chain is clearer.

import java.util.Comparator;
import java.util.List;

record Ticket(String id, String queue) { }

final class StableSortDemo {
    public static void main(String[] args) {
        List<Ticket> tickets = List.of(
                new Ticket("t-1", "billing"),
                new Ticket("t-2", "support"),
                new Ticket("t-3", "billing")
        );

        List<String> ids = tickets.stream()
                .sorted(Comparator.comparing(Ticket::queue))
                .map(Ticket::id)
                .toList();

        System.out.println(ids); // [t-1, t-3, t-2]
    }
}

t-1 and t-3 both have queue billing, so they keep their original relative order. In an unordered stream, do not sell this as a guarantee to your interviewer. Stability needs an encounter order to preserve.

03distinct() under the hood: equality, first occurrence and memory

distinct() removes duplicates by object equality. It does not look at a field unless equals and hashCode look at that field. If two Order objects have the same customerId but different id, they are distinct unless the record or class says otherwise.

Premium lesson

Keep reading with the Java Pass

The rest of this lesson is part of Java One-Stop Interview Prep: in-depth explanations, diagrams, model answers and how to say them in the interview.

  • Advanced: distinctBy options in Java 21
  • How to say it in the interview

From ₹199 · one-time payment · No auto-renewal

Practise this topicOperations with streams: interview questions & scenarios
Further reading (8)