Module 7 · Collections & Generics ⏱ 19 min

Comparators and Sorting (Comparable vs Comparator)

By the end of this lesson you will be able to:
  • Implement Comparable to give a class a natural sort order
  • Write Comparator lambdas and chains for custom ordering
  • Predict the consequences of the subtraction overflow trap in compareTo

Sorting a list of numbers is easy — every number already knows whether it is less than another. But what about a list of Person objects? Should they sort by last name, by age, or by employee ID? Java refuses to guess. It gives you two tools for teaching a collection what comes first: Comparable, for the one natural order a class possesses, and Comparator, for every custom order you invent later. Choosing the right tool, and writing it without hidden bugs, is what separates working code from code that sorts silently wrong or crashes in production with an overflow you never saw coming. Understanding the difference now will save you hours of debugging later.

flowchart TD
  A["Comparable"] --> B["compareTo"]
  B --> C["Collections.sort(list)"]
  D["Comparator"] --> E["compare"]
  E --> F["list.sort(comparator)"]
  C --> G["sorted list"]
  F --> G
  style A fill:#3776ab,color:#fff
  style D fill:#c2410c,color:#fff
Comparable is baked into the class; Comparator is an external rule you hand to the sort method.

Comparable: the natural order

When a class knows its own natural sorting rule — alphabetical by name, chronological by date, numerical by ID — it implements Comparable<T> and defines compareTo. The method returns a negative integer if this should come before the other object, zero if they are equal for sorting purposes, and a positive integer if this should come after.

The contract is strict and the Collections framework relies on it. If a.compareTo(b) is negative, then b.compareTo(a) must be positive. If a.compareTo(b) is zero, then a.equals(b) should ideally be true as well; when the two disagree, sorted collections like TreeSet behave unpredictably, treating objects as equal for sorting but not for lookup. Keep compareTo and equals consistent. Breaking the contract — for example, making compareTo asymmetric — does not trigger a compiler error, but it corrupts the output of sorted sets and maps in ways that are maddening to debug.

Main.java — Person implements Comparable by age. Real, compiling Java.
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class Main {
    static class Person implements Comparable<Person> {
        String name;
        int age;

        Person(String name, int age) {
            this.name = name;
            this.age = age;
        }

        public int compareTo(Person other) {
            return Integer.compare(this.age, other.age);
        }
    }

    public static void main(String[] args) {
        List<Person> people = new ArrayList<>();
        people.add(new Person("Mara", 30));
        people.add(new Person("Jules", 25));
        Collections.sort(people);
        for (Person p : people) {
            System.out.println(p.name + ":" + p.age);
        }
    }
}

What sort actually does

Collections.sort and list.sort both delegate to TimSort, a hybrid sorting algorithm derived from merge sort and insertion sort. It guarantees O(n log n) time in the worst case and performs well on real-world data that is already partially ordered. For arrays of primitives, Arrays.sort uses a dual-pivot quicksort. You do not choose the algorithm; you choose the rule, and the library chooses the algorithm.

Comparator: order without changing the class

Sometimes you cannot edit the class — it comes from a library — or you need more than one ordering: users by name for a directory, by join date for a report, by reputation for a leaderboard. A Comparator<T> is a separate object that holds the comparison rule. You pass it to list.sort(comparator) or Collections.sort(list, comparator). The method is compare(a, b) and returns the same signed integers as compareTo.

The cleanest way to build a Comparator is a lambda: Comparator<Person> byName = (a, b) -> a.name.compareTo(b.name). Because the compiler infers types from the variable declaration, the lambda stays short. If the logic grows beyond one line, extract it into a static method and obtain a Comparator with Comparator.comparing. That separation is powerful: the same Person class can coexist in a name-sorted directory and an age-sorted leaderboard without either knowing about the other.

flowchart LR
  L["List of objects"] -->|"natural order"| N["Collections.sort(list)"]
  L -->|"custom rule"| C["list.sort(comparator)"]
  N --> R["sorted list"]
  C --> R
  style N fill:#3776ab,color:#fff
  style C fill:#c2410c,color:#fff
Sorting can use the built-in natural order or an external Comparator.
Main.java — sorting with a Comparator lambda. Real, compiling Java.
import java.util.ArrayList;
import java.util.List;

public class Main {
    static class Person {
        String name;
        int age;
        Person(String name, int age) { this.name = name; this.age = age; }
    }

    public static void main(String[] args) {
        List<Person> people = new ArrayList<>();
        people.add(new Person("Mara", 30));
        people.add(new Person("Jules", 25));
        people.sort((a, b) -> a.name.compareTo(b.name));
        for (Person p : people) {
            System.out.println(p.name);
        }
    }
}

Chaining: sort by one thing, then another

Real sorting rarely stops at a single field. You want employees by department first, then by seniority within each department, then by name within each seniority level. Writing that as one nested ternary is readable only to the person who wrote it and sometimes not even then.

Comparator offers thenComparing to chain rules left to right: the first comparator decides, and if it returns zero the next one breaks the tie. Each link only runs when every link before it could not decide. The result is a stable, readable sort order that mirrors the business rule. For primitive fields, use thenComparingInt and thenComparingLong to avoid boxing overhead. You can also fall back to a manual chain inside a lambda when you need to compare calculated values that do not map neatly to a field getter.

Main.java — a chained comparator that sorts by priority, then by name. Real, compiling Java.
import java.util.ArrayList;
import java.util.List;

public class Main {
    static class Task {
        int priority;
        String name;
        Task(int p, String n) { priority = p; name = n; }
    }

    public static void main(String[] args) {
        List<Task> list = new ArrayList<>();
        list.add(new Task(2, "B"));
        list.add(new Task(1, "C"));
        list.add(new Task(1, "A"));
        list.sort((a, b) -> {
            int c = Integer.compare(a.priority, b.priority);
            if (c != 0) return c;
            return a.name.compareTo(b.name);
        });
        for (Task t : list) {
            System.out.println(t.name);
        }
    }
}
flowchart LR
  A["compare priority"] -->|"not zero"| R1["use that result"]
  A -->|"zero"| B["compare name"]
  B -->|"not zero"| R2["use that result"]
  B -->|"zero"| R3["equal"]
  style A fill:#3776ab,color:#fff
  style B fill:#c2410c,color:#fff
Chain comparators when the first field ties; each link only runs if the previous one returned zero.

Comparing with method references

Once you are comfortable with lambdas, you can shorten further with a method reference: Comparator.comparing(Person::getName) builds a comparator that calls getName on each object and compares the results. For primitives, comparingInt, comparingLong, and comparingDouble avoid boxing the result. The chained example from earlier becomes Comparator.comparingInt(Task::getPriority).thenComparing(Task::getName) — the same logic, expressed as a pipeline.

Stable sorts preserve original order

Java's sort is stable: if two elements compare as equal, their original relative order is preserved. That matters when you sort in stages — first by date, then by status — because the second sort will not scramble the ordering the first sort established within each status group. Without stability, multi-pass sorting becomes unpredictable.

Exercise

Comparable sort. This list of Person is sorted by age using Comparable. Predict the exact output (two lines).

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class Main {
    static class Person implements Comparable<Person> {
        String name;
        int age;

        Person(String name, int age) {
            this.name = name;
            this.age = age;
        }

        public int compareTo(Person other) {
            return Integer.compare(this.age, other.age);
        }
    }

    public static void main(String[] args) {
        List<Person> people = new ArrayList<>();
        people.add(new Person("Mara", 30));
        people.add(new Person("Jules", 25));
        Collections.sort(people);
        for (Person p : people) {
            System.out.println(p.name + ":" + p.age);
        }
    }
}
Exercise

You are using a class from a library and need to sort its instances by two different criteria in different parts of your program. Which approach fits best?

Exercise

Comparator sort. The list is sorted by name using a lambda Comparator. Predict the exact output (two lines).

import java.util.ArrayList;
import java.util.List;

public class Main {
    static class Person {
        String name;
        int age;
        Person(String name, int age) { this.name = name; this.age = age; }
    }

    public static void main(String[] args) {
        List<Person> people = new ArrayList<>();
        people.add(new Person("Mara", 30));
        people.add(new Person("Jules", 25));
        people.sort((a, b) -> a.name.compareTo(b.name));
        for (Person p : people) {
            System.out.println(p.name);
        }
    }
}
Exercise

Chained comparator. Tasks are sorted by priority, then by name within the same priority. Predict the exact output (three lines).

import java.util.ArrayList;
import java.util.List;

public class Main {
    static class Task {
        int priority;
        String name;
        Task(int p, String n) { priority = p; name = n; }
    }

    public static void main(String[] args) {
        List<Task> list = new ArrayList<>();
        list.add(new Task(2, "B"));
        list.add(new Task(1, "C"));
        list.add(new Task(1, "A"));
        list.sort((a, b) -> {
            int c = Integer.compare(a.priority, b.priority);
            if (c != 0) return c;
            return a.name.compareTo(b.name);
        });
        for (Task t : list) {
            System.out.println(t.name);
        }
    }
}
Exercise

What is the main risk of writing return this.age - other.age inside compareTo?

Recap

  • Comparable gives a class its natural order via compareTo.
  • Comparator provides external sort rules via compare without touching the class.
  • Return negative, zero, or positive to say before, equal, or after.
  • Chain rules with thenComparing when the first field ties.
  • Never subtract int fields in compareTo — overflow corrupts order. Use Integer.compare.
  • Keep compareTo consistent with equals so sorted collections behave predictably.

Next you will learn to measure the speed of these operations and choose the right collection for the work at hand.

Checkpoint quiz

What does compareTo return when two objects are equal for sorting purposes?

Which method flips a Comparator so it sorts in the opposite direction?

Go deeper — technical resources