Skip to main content

Java Comparator & Comparable: The Complete Guide

A complete, from-first-principles guide to Java’s Comparable and Comparator: natural vs. external ordering, the comparing/thenComparing/reversed chaining API, nullsFirst/nullsLast, TreeSet, records as sort keys (and the equals-consistency trap they create), and a real reproduction of the integer-subtraction-overflow comparator bug with a failing test, not just a warning. Every example runs against a companion repository.

You reach for Collections.sort() or List.sort(), and Java immediately asks a question most tutorials skip past: how should these objects be ordered? Sorting a list of strings or numbers is trivial because Java already knows what “smaller” means for them. Sorting a list of your own Employee or Product objects is not — nothing about the class tells the JVM whether one employee comes “before” another. You have to say so, and Java gives you two different ways to say it: Comparable and Comparator.

This guide builds both from first principles with real, compiled, executed code — a companion repository you can run yourself, not prose describing what the code probably does. It covers the classic natural-vs-external distinction, the modern comparing/thenComparing/reversed chaining API, null handling, records as sort keys (and a trap that is specific to records), and ends with the single most repeated comparator bug in real codebases — the integer-subtraction trick — reproduced with a real failing comparison, not just a warning.

Versions. JDK 25.0.4.1+1 (Temurin, LTS) — every code sample in this guide was compiled and run against it. java.lang.Comparable and java.util.Comparator both date to the original Java 1.2 Collections Framework (1998); the comparing/comparingInt/comparingLong/comparingDouble, thenComparing, reversed, naturalOrder, reverseOrder, nullsFirst and nullsLast factory and default methods on Comparator all shipped together in Java 8 (March 2014, JSR 335). Records, used here as sort keys, finalized in Java 16 (March 2021, JEP 395). Nothing in this guide has changed shape since. JUnit Jupiter 5.11.0, Maven 3.9.11.

Sorting your own objects has no single right answer

A Product has one obvious order: ascending price is what every price-sorted catalog page already does, and nobody expects a second opinion. An Employee does not — sort by name, by age, by salary, by hire date are all equally reasonable, and which one is “right” depends entirely on which screen you are building. Java gives you one interface for each situation.

Product implements Comparable compareTo(Product other)Double.compare(price, other.price)one opinion, lives inside the class Employeeno compareTo – no natural order by name by age by salary three external Comparators, same class, untouched Comparable: the order is part of what the object IS. Comparator: the order is chosen at the call site.

That picture is the entire guide in one sentence: Comparable bakes one ordering into the class; Comparator supplies an ordering from outside, as many as you want, without changing the class at all. Everything below works out the consequences of that one difference — including where it quietly breaks.

Comparable: give the class one opinion about its own order

java.lang.Comparable<T> has exactly one method:

public interface Comparable<T> {
    int compareTo(T other);
}

The contract: return a negative integer if this comes before other, zero if they are equal for ordering purposes, and a positive integer if this comes after other. Nothing enforces the exact magnitude — only the sign is a promise.

negativethis before other zeroequal for ordering positivethis after other int compareTo(T other) – only the SIGN is a promise, never the exact number

Here is a complete class whose ordering is genuinely intrinsic — a Product sorted by price:

public class Product implements Comparable<Product> {

    private final String name;
    private final double price;

    public Product(String name, double price) {
        this.name  = name;
        this.price = price;
    }

    @Override
    public int compareTo(Product other) {
        // Double.compare, not (this.price - other.price): subtraction on
        // floating-point values loses precision and is not even a reliable sign.
        return Double.compare(this.price, other.price);
    }
}

Source: Product.java. Run against it:

Before sort: [Keyboard($49.99), Monitor($299.0), Mouse($29.5), Webcam($79.99)]
After Collections.sort(products): [Mouse($29.5), Keyboard($49.99), Webcam($79.99), Monitor($299.0)]

Output: 02-comparable-natural-order.txt, from ComparableNaturalOrderDemo.java. Collections.sort(products) takes no Comparator argument at all — it finds compareTo() because Product declared implements Comparable<Product>.

Never subtract to compare. return this.price - other.price looks identical to Double.compare for small, nearby values and is wrong in general — it loses precision for doubles, and the int equivalent can overflow and report the exact opposite order. Hold that thought; the “subtraction trick” section later in this guide reproduces the integer version with a real failing comparison, not a warning.
Going deeper: the full compareTo contract — antisymmetry, transitivity, and the equals recommendation

The Javadoc contract for compareTo is stricter than “return the right sign once”:

  • Antisymmetric: sgn(x.compareTo(y)) == -sgn(y.compareTo(x)) for all x, y. If x throws comparing to y, y must throw comparing to x.
  • Transitive: x.compareTo(y) > 0 && y.compareTo(z) > 0 implies x.compareTo(z) > 0.
  • Consistent: x.compareTo(y) == 0 implies sgn(x.compareTo(z)) == sgn(y.compareTo(z)) for all z.
  • Strongly recommended, not required: (x.compareTo(y) == 0) == x.equals(y). The JDK itself calls out BigDecimal as a class that deliberately violates this — new BigDecimal("1.0").compareTo(new BigDecimal("1.00")) is 0, but equals is false because the scale differs. The “Records as sort keys” section further down shows exactly what breaking this recommendation costs in a running program, not just in the abstract.

The integer-overflow bug later in this guide is, underneath, an antisymmetry violation: a correct comparator must satisfy sgn(compare(a,b)) == -sgn(compare(b,a)), and the subtraction trick fails exactly that property once the subtraction overflows.

Going deeper: the no-arg TreeSet/TreeMap constructors call compareTo automatically — see the TreeSet section below — and Comparator.naturalOrder() (Java 8+) wraps any Comparable type as a reusable Comparator object for APIs that insist on one explicitly.

Comparator: give the call site the opinion instead

java.util.Comparator<T> is also a single-method interface, marked @FunctionalInterface:

@FunctionalInterface
public interface Comparator<T> {
    int compare(T o1, T o2);
}

Same sign contract as compareTo, but as a free-standing object you pass to a sort call rather than a method the class itself implements. Because it is a functional interface, the shortest useful Comparator is a lambda:

employees.sort((e1, e2) -> Integer.compare(e1.getAge(), e2.getAge()));

That line, and the factory-method API about to replace it (next section), both run against Employee.java — a class that deliberately does not implement Comparable, because no single field is the “right” order for an employee.

Going deeper: how this looked before Java 8 — a named Comparator class and an anonymous inner class

Before lambdas, there were two ways to hand a sort call a comparison strategy, and both still compile and run today:

// A dedicated, reusable, named class
public final class EmployeeNameComparator implements Comparator<Employee> {
    @Override
    public int compare(Employee e1, Employee e2) {
        return e1.getName().compareTo(e2.getName());
    }
}

// Usage
Collections.sort(employees, new EmployeeNameComparator());
// One-off: an anonymous inner class, no separate file needed
Collections.sort(employees, new Comparator<Employee>() {
    @Override
    public int compare(Employee e1, Employee e2) {
        return Integer.compare(e1.getAge(), e2.getAge());
    }
});

Source: EmployeeNameComparator.java and OldSchoolComparatorDemo.java. Real output, same four employees used throughout this guide:

=== 1. Named class: new EmployeeNameComparator() ===
Employee{id=102, name='Alex', age=45, salary=120000.0}
Employee{id=104, name='Alex', age=35, salary=110000.0}
Employee{id=103, name='Bob', age=29, salary=85000.0}
Employee{id=101, name='Zoe', age=29, salary=80000.0}

=== 2. Anonymous inner class, sorting by age ===
Employee{id=101, name='Zoe', age=29, salary=80000.0}
Employee{id=103, name='Bob', age=29, salary=85000.0}
Employee{id=104, name='Alex', age=35, salary=110000.0}
Employee{id=102, name='Alex', age=45, salary=120000.0}

Output: 01-old-school-comparator.txt. Both approaches still work and are not deprecated — they are simply more ceremony than the same logic needs once Comparator gained factory methods, next.

Going deeper: a named Comparator class still earns its keep when the same comparison is reused across many call sites and deserves a documented name — java.util.Collections.reverseOrder() in the JDK source ships as exactly that kind of named class.

comparing, thenComparing, reversed: the chaining API

Java 8 added static and default factory methods directly on Comparator, and they are what almost all real code should use instead of hand-written compare bodies. Comparator.comparing(keyExtractor) takes a function that pulls a sort key out of an object and hands back a ready-to-use Comparator for that key:

employees.sort(Comparator.comparing(Employee::getName));

// Type-specific variants avoid autoboxing for primitives
employees.sort(Comparator.comparingInt(Employee::getAge));
employees.sort(Comparator.comparingDouble(Employee::getSalary));

Real run, same four employees, by salary descending (comparingDouble + reversed()):

=== 3. comparingInt/comparingDouble - avoid boxing for primitives ===
Employee{id=102, name='Alex', age=45, salary=120000.0}
Employee{id=104, name='Alex', age=35, salary=110000.0}
Employee{id=103, name='Bob', age=29, salary=85000.0}
Employee{id=101, name='Zoe', age=29, salary=80000.0}

Source: ComparatorFactoryMethodsDemo.java, output: 03-comparator-factory-methods.txt.

thenComparing chains a second comparator that only runs when the first one returns zero — a tie. The sample data has two employees both named “Alex” (ages 35 and 45), so sorting by name alone leaves their relative order to chance; thenComparingInt breaks that tie explicitly:

Comparator<Employee> byNameThenByAge = Comparator.comparing(Employee::getName)
                                                 .thenComparingInt(Employee::getAge);
employees.sort(byNameThenByAge);
=== 4. thenComparing - break ties left to right ===
(Two employees are both named "Alex" - watch thenComparing break that tie by age)
Employee{id=104, name='Alex', age=35, salary=110000.0}
Employee{id=102, name='Alex', age=45, salary=120000.0}
Employee{id=103, name='Bob', age=29, salary=85000.0}
Employee{id=101, name='Zoe', age=29, salary=80000.0}

Output: 03-comparator-factory-methods.txt — the 35-year-old Alex now reliably sorts before the 45-year-old one, every run, not just this one.

comparing(Employee::getName)Alex vs Alex -> returns 0 (tie) only runs on a tieif name already differed, stop here thenComparingIntby age Alex, age 35 Alex, age 45 35 sorts before 45 – the tie is broken deterministically, every run

Factory methods worth knowing by name (all Java 8+):

MethodWhat it does
Comparator.comparing(keyExtractor)Compare by a key pulled out with a function.
comparingInt / comparingLong / comparingDoubleType-specific variants that avoid boxing.
.thenComparing(keyExtractor)Break ties with a secondary comparator.
.reversed()Flip the current comparator to descending order.
Comparator.naturalOrder()Returns a Comparator that delegates to compareTo().
Comparator.reverseOrder()Descending natural order.
Comparator.nullsFirst(c)Treat null values as smaller than any non-null value.
Comparator.nullsLast(c)Treat null values as larger than any non-null value.
Order matters in a chain. comparing(A).thenComparing(B) and comparing(B).thenComparing(A) are different comparators whenever a tie on A is possible — the first key you name is the one that wins unless it is tied. Put the field users actually expect to sort by first.
Going deeper: comparing() with an explicit key comparator, and comparingInt vs comparing(Integer::valueOf)

Comparator.comparing has a two-argument overload — comparing(keyExtractor, keyComparator) — for when the extracted key is not itself naturally comparable the way you want (string case-insensitive order is the most common case, used in the nulls section next). comparingInt(Employee::getAge) and comparing(Employee::getAge) (which autoboxes to Integer) behave identically for correctness; the int variant only exists to skip the boxing allocation, which matters at JMH-measurable scale and not otherwise.

Nulls break both interfaces by default

Neither compareTo() nor a hand-written compare() handles null for you — calling someString.compareTo(null) or null.compareTo(x) throws NullPointerException, and a lambda that calls a method on a possibly-null key does the same. Comparator.nullsFirst(c) and Comparator.nullsLast(c) wrap an existing (non-null-safe) Comparator and handle the null case before ever calling it:

employees.sort(Comparator.comparing(Employee::getName, Comparator.nullsFirst(String::compareTo)));
=== 5. nullsFirst - an Employee whose name is null, sorted to the front ===
Employee{id=105, name='null', age=40, salary=90000.0}
Employee{id=102, name='Alex', age=45, salary=120000.0}
Employee{id=104, name='Alex', age=35, salary=110000.0}
Employee{id=103, name='Bob', age=29, salary=85000.0}
Employee{id=101, name='Zoe', age=29, salary=80000.0}

Output: 03-comparator-factory-methods.txt — note the printed name='null' is String.valueOf(null) inside Employee.toString(), not a literal string; the field itself is a real null reference.

nullsLast combines with CASE_INSENSITIVE_ORDER and thenComparing exactly like any other comparator:

Comparator<Employee> nameInsensitiveNullsLastThenAge =
    Comparator.comparing(Employee::getName, Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER))
              .thenComparingInt(Employee::getAge);
=== 6. nullsLast + CASE_INSENSITIVE_ORDER, chained with thenComparing ===
Employee{id=104, name='Alex', age=35, salary=110000.0}
Employee{id=102, name='Alex', age=45, salary=120000.0}
Employee{id=103, name='Bob', age=29, salary=85000.0}
Employee{id=101, name='Zoe', age=29, salary=80000.0}
Employee{id=106, name='null', age=22, salary=60000.0}

Output: same file, 03-comparator-factory-methods.txt.

nullsFirst(String::compareTo), applied to the name key null ( somewhere in an unsorted list ) ↓ sort with nullsFirst ↓ null at index 0, every time – not left to chance
nullsFirst/nullsLast need a non-null comparator to delegate to. Comparator.nullsFirst(Comparator.naturalOrder()) is the usual default when the key type is itself Comparable; pass an explicit comparator (as above, String::compareTo or CASE_INSENSITIVE_ORDER) when you additionally want a non-default order among the non-null values.

Going deeper: Objects.requireNonNullElse and Optional-based defaulting are the usual alternative when “sort nulls somewhere predictable” is actually “nulls should never have reached this list” — worth asking before reaching for nullsFirst as the fix.

TreeSet and TreeMap: where natural and custom order meet automatically

TreeSet is the clearest place to see Comparable and Comparator meet in one class: its no-arg constructor uses compareTo() (natural order); its Comparator-accepting constructor overrides that — same element type, two different orderings, neither one touching the element class’s source.

// Uses Product.compareTo() -- natural order (ascending price)
TreeSet<Product> byNaturalOrder = new TreeSet<>();
byNaturalOrder.add(new Product("Monitor", 299.0));
byNaturalOrder.add(new Product("Mouse", 29.5));
byNaturalOrder.add(new Product("Keyboard", 49.99));

// Pass a Comparator to override order (descending price) - Product's source is untouched
TreeSet<Product> byPriceDesc =
    new TreeSet<>(Comparator.comparingDouble(Product::getPrice).reversed());
Natural (no-arg TreeSet ctor, uses Product.compareTo): [Mouse($29.5), Keyboard($49.99), Monitor($299.0)]
Custom  (Comparator ctor, descending price)       : [Monitor($299.0), Keyboard($49.99), Mouse($29.5)]

Source: TreeSetOrderingDemo.java, output: 04-treeset-ordering.txt.

same 3 Product objects new TreeSet<>()uses compareTo – ascending new TreeSet<>(comparator)descending, Comparator wins Mouse, Keyboard, Monitor Monitor, Keyboard, Mouse
TreeSet and TreeMap use compareTo/compare to decide equality, not equals(). If two different elements return 0 from the comparator, the second one silently fails to be added (Set.add returns false) — it is treated as a duplicate of the first, whatever equals() says. The next section shows exactly what this costs with a class that otherwise looks completely safe: a record.

Going deeper: TreeMap follows the identical rule for keys, and both classes document it directly: “the ordering must be consistent with equals if this sorted set is to correctly implement the Set interface” — a “must” that the compiler cannot check and that the JDK itself does not always follow (see the BigDecimal note above).

Records as sort keys — and the trap only records have

A record can implement Comparable exactly like a class. Two things are different in practice. First, the accessor Java generates for a component named price is called price(), not getPrice() — so a method reference reads StockPrice::price, not StockPrice::getPrice. Second, and far more consequential: a record’s equals() and hashCode() are generated from every component, whether your compareTo looks at all of them or not.

public record StockPrice(String ticker, double price, int volume) implements Comparable<StockPrice> {

    public StockPrice {
        if (price < 0) {
            throw new IllegalArgumentException("price cannot be negative: " + price);
        }
    }

    @Override
    public int compareTo(StockPrice other) {
        return Double.compare(this.price, other.price);
    }
}

Source: StockPrice.java. Sorting with a plain accessor method reference works exactly like any POJO:

quotes.sort(Comparator.comparing(StockPrice::ticker));
=== 1. Comparator.comparing(StockPrice::ticker) - record accessor, no "get" prefix ===
StockPrice[ticker=AAPL, price=189.5, volume=42000000]
StockPrice[ticker=MSFT, price=410.2, volume=18000000]
StockPrice[ticker=NVDA, price=118.75, volume=210000000]

Output: 05-record-ordering.txt, from RecordOrderingDemo.java.

Now the trap. compareTo above only looks at price. Two records with the same price but a different ticker and volume compare as equal for ordering — compareTo returns 0 — even though the record’s generated equals(), which checks all three components, correctly says they are different objects:

=== 3. Two DIFFERENT records, same price: compareTo==0 but equals()==false ===
StockPrice[ticker=AAPL, price=150.0, volume=1000000].compareTo(StockPrice[ticker=MSFT, price=150.0, volume=2000000]) = 0
StockPrice[ticker=AAPL, price=150.0, volume=1000000].equals(StockPrice[ticker=MSFT, price=150.0, volume=2000000]) = false

That by itself is only a curiosity — until it reaches a TreeSet, which (previous section) decides duplicates by compareTo, not equals():

=== 4. That inconsistency reaches a TreeSet: natural order (price-only compareTo) ===
treeSet.add(appleAt150) -> true (first element)
treeSet.add(msftAt150)  -> false  (TreeSet used compareTo==0 to call this a duplicate)
treeSet now contains 1 element(s): [StockPrice[ticker=AAPL, price=150.0, volume=1000000]]
-> MSFT silently never entered the set, even though msftAt150.equals(appleAt150) is false

No exception. No warning. A second, genuinely distinct stock quote at the same price simply never makes it into the set — treeSet.add(...) returns false, which most code never checks. The fix is a Comparator that stays consistent with equals() by looking at every component, not just the one you happen to want to sort by first:

Comparator<StockPrice> consistentWithEquals =
    Comparator.comparing(StockPrice::ticker)
              .thenComparingDouble(StockPrice::price)
              .thenComparingInt(StockPrice::volume);
TreeSet<StockPrice> treeSetConsistent = new TreeSet<>(consistentWithEquals);
=== 5. Fix: a Comparator that is consistent with equals (ticker, then price, then volume) ===
treeSet.add(msftAt150) -> true  (now distinguishes the two records)
treeSet now contains 2 element(s): [StockPrice[ticker=AAPL, price=150.0, volume=1000000], StockPrice[ticker=MSFT, price=150.0, volume=2000000]]

Output: 05-record-ordering.txt. This is a regression test, not just a printout — see priceOnlyCompareToIsInconsistentWithRecordEqualsAndDropsAnElementFromATreeSet in SortingTest.java.

AAPL @ $150 MSFT @ $150add() returns false add() -> true, enters the set compareTo == 0 vs AAPL -> treated as duplicate TreeSet now has1 element, not 2 equals() said they were different. compareTo() is what the TreeSet actually asked.
The fingerprint of this bug: a Set or Map keyed by a sorted collection that is mysteriously smaller than the number of add()/put() calls you made, with no exception anywhere. Check whether the comparator looks at every field equals() does before looking anywhere else.
Going deeper: why records are more exposed to this than ordinary classes

An ordinary class only gets this bug if you deliberately override equals()/hashCode() to match a hand-written compareTo and then let them drift apart during a later edit — two methods, two chances to diverge, but at least both were something someone wrote. A record generates equals()/hashCode() from all components automatically and silently, every time, including components added later by someone who has never read the compareTo method at all. Adding a field to a record is exactly the kind of change that looks safe in a diff and is not.

The subtraction trick: the bug that waits for a big number

This is the single most repeated piece of wrong Comparator advice, and it is wrong in a way that does not announce itself:

// DO NOT DO THIS
Comparator<Ticket> buggy = (a, b) -> a.val - b.val;

For small numbers this “works” — which is exactly why it survives code review, ships, and sits in production for years before a value near Integer.MAX_VALUE or Integer.MIN_VALUE reaches it. int arithmetic in Java wraps silently on overflow; there is no exception, no warning, just a number with the wrong sign. Ticket (Ticket.java) is a two-field holder with a public final int val, chosen so the buggy comparator can be written exactly as it appears in real review threads. Here it is in isolation, computed for real, not asserted in prose:

public static int buggyCompare(Ticket a, Ticket b) {
    return a.val - b.val;
}

public static int correctCompare(Ticket a, Ticket b) {
    return Integer.compare(a.val, b.val);
}
=== 1. The contract violation in isolation ===
buggyCompare(BIG=2147483647, NEG=-2) = -2147483647   (claims BIG < NEG: true)
correctCompare(BIG=2147483647, NEG=-2) = 1   (correctly says BIG > NEG: true)
BIG.val - NEG.val overflows int range because 2147483647 - (-2) = 2147483647 + 2 > Integer.MAX_VALUE, and int arithmetic wraps silently.

Output: 06-integer-overflow-bug.txt. 2147483647 is unambiguously larger than -2; the subtraction comparator claims the opposite. That is not a rounding error — the sign is flipped.

MAX_VALUE int range ends here -> MIN_VALUE MAX_VALUE – (-2) wraps PAST the top, lands back near MIN_VALUE a.val – b.val is NOT the same operation as “is a bigger than b” once it overflows

Now the real sort — the same seven-element list run through both comparators:

List<Ticket> tickets = List.of(
    new Ticket("A", 10), new Ticket("B", Integer.MAX_VALUE), new Ticket("C", -20),
    new Ticket("D", Integer.MIN_VALUE), new Ticket("E", 0), new Ticket("F", 5), new Ticket("G", -1)
);
buggyResult.sort(IntegerOverflowBugDemo::buggyCompare);
correctResult.sort(IntegerOverflowBugDemo::correctCompare);
=== 2. Sorting a real list with each comparator ===
Input (unsorted): [A(10), B(2147483647), C(-20), D(-2147483648), E(0), F(5), G(-1)]
Sorted with (a,b) -> a.val - b.val        : [G(-1), E(0), F(5), A(10), B(2147483647), D(-2147483648), C(-20)]
Sorted with Integer.compare(a.val, b.val) : [D(-2147483648), C(-20), G(-1), E(0), F(5), A(10), B(2147483647)]

=== 3. Checking each result WITHOUT trusting either comparator ===
buggyResult is actually ascending by .val?   false
correctResult is actually ascending by .val? true
First inversion: B(2147483647) appears immediately before D(-2147483648), but 2147483647 > -2147483648

Source: IntegerOverflowBugDemo.java, output: 06-integer-overflow-bug.txt. Read the buggy result again: B(2147483647) — the single largest value in the list — sits in the middle, directly in front of D(-2147483648), the smallest. C(-20) ends up dead last, after the smallest value in the list. The list looks plausibly shuffled, not obviously broken, which is exactly the danger: nobody glancing at seven numbers notices a sort is wrong unless they check, and the “Checking without trusting either comparator” step above is the check — counting inversions directly against the real int values rather than trusting the comparator that produced them.

That check is pinned as a real test, not just a demo print statement:

@Test
void subtractionComparatorProducesWrongOrderOnOverflow() {
    Ticket big = new Ticket("BIG", Integer.MAX_VALUE);
    Ticket negative = new Ticket("NEG", -2);

    assertTrue(IntegerOverflowBugDemo.buggyCompare(big, negative) < 0,
        "the subtraction comparator must overflow and wrongly report BIG < NEG");
    assertTrue(IntegerOverflowBugDemo.correctCompare(big, negative) > 0,
        "Integer.compare must correctly report BIG > NEG");

    // ... sorts the real 7-element list with both comparators ...

    assertFalse(IntegerOverflowBugDemo.isAscendingByVal(buggyResult),
        "the subtraction comparator's result must NOT actually be ascending by .val - this is the bug");
    assertEquals(Integer.MIN_VALUE, correctResult.get(0).val);
    assertNotEquals(Integer.MIN_VALUE, buggyResult.get(0).val,
        "the buggy comparator must fail to place Integer.MIN_VALUE first");
}
-------------------------------------------------------------------------------
Test set: com.ankurm.sorting.SortingTest
-------------------------------------------------------------------------------
Tests run: 9, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.122 s -- in com.ankurm.sorting.SortingTest

Full test source: SortingTest.java, output: 07-tests.txt. Nine assertions pass, including this one — the test is not “red” in the CI sense, it is a passing regression test that pins the buggy comparator’s wrong behaviour as a checked, committed fact, exactly the way the other eight tests pin correct behaviour.

The fingerprint of this bug in review: any Comparator or compareTo body that contains a bare - between two numeric fields, with no call to Integer.compare/Long.compare/Double.compare anywhere nearby. It passes every test written with small, hand-picked numbers, which is most tests.
A short reproduction list will not save you. Java’s Collections.sort/List.sort use TimSort, which only runs its own “Comparison method violates its general contract!” detector while merging runs — and for arrays below TimSort’s MIN_MERGE threshold (32 elements by default) there is nothing to merge, so the detector never fires. The seven-element list above sorted “successfully” into the wrong order with no exception at all. A longer list built from the same buggy comparator can instead throw IllegalArgumentException: Comparison method violates its general contract! instead — which is the one scenario where this bug announces itself, and only past a size most unit tests do not bother reaching.
Going deeper: the same mistake with doubles, and why Double.compare is not just a style preference

return this.price - other.price for two double values does not overflow the way int subtraction does — double has a vastly larger range — but it has its own failure: floating-point subtraction of two close values loses precision, and subtracting values near Double.MAX_VALUE or -Double.MAX_VALUE can itself produce Infinity or NaN, and NaN compared with anything by subtraction behaves inconsistently with the Comparable contract. Double.compare (used by Product.compareTo earlier in this guide) additionally defines a total order for NaN and the two zeros (-0.0 is less than 0.0 under Double.compare, but -0.0 == 0.0 is true) that a raw </>-based subtraction does not get right either. The rule is the same for both types: never subtract, always call the boxed type’s static compare method.

What the defaults do not do

Three assumptions worth checking explicitly, because none of them are enforced by the compiler and all three have shown up already in this guide:

  • Nothing checks that compareTo is consistent with equals. The “Records as sort keys” section above is the concrete cost; it applies identically to a hand-written class.
  • Collections.sort / List.sort are stable; Arrays.sort on primitives is not. Collections.sort and List.sort (TimSort, on Object[]) guarantee that elements comparing equal keep their original relative order — which is why the two tied “Zoe/Bob” (age 29) employees earlier in this guide always came out in input order. Arrays.sort(int[]) uses a dual-pivot quicksort with no such guarantee — harmless for primitives, since two equal ints are indistinguishable anyway, but worth knowing before assuming the same stability for a parallel array of indices.
  • A Comparator that violates its own contract may compile, run, and only fail later. The overflow section above showed a small list sorting “successfully” into the wrong order with no exception; a large enough list with the same buggy comparator can instead throw IllegalArgumentException: Comparison method violates its general contract! from inside TimSort’s merge step. Neither outcome is guaranteed by list size alone — both are symptoms of the same underlying defect.

Going deeper: Collections.unmodifiableList/List.copyOf interact with sorting in their own way — list.sort(...) on an immutable list throws UnsupportedOperationException rather than silently no-op’ing, which is a separate pitfall covered in the Immutable Collections in Java guide.

Comparable or Comparator? A decision checklist

Use Comparable for the one obvious, universally agreed natural order that belongs to the class itself, and only when you own the class’s source. Use Comparator for everything else: multiple orderings, a class you cannot modify, or an order that depends on context (a UI sort column vs. a storage order).

ScenarioUse ComparableUse Comparator
There is one obvious, logical ordering✔
You own the class and can modify it✔
You need multiple sort orders✔
You cannot modify the class (third-party, generated, a record you do not control)✔
The order is context-dependent (e.g. UI column vs. storage order)✔
You want TreeSet/TreeMap to sort automatically✔✔ (pass to the constructor)
One obvious order,and you own the class? yes no / need >1 order implement Comparableone compareTo in the class use Comparatorat each call site, as many as you need e.g. Product by price e.g. Employee by name / age / salary
Going deeper: using both together, and the Comparator you get for free

Implementing Comparable does not stop you from also writing Comparators for the same class — Product throughout this guide has exactly one compareTo and was still sorted by Comparator.comparingDouble(Product::getPrice).reversed() in the TreeSet section. Comparator.naturalOrder() turns any Comparable type into an explicit Comparator object for APIs that want one by type rather than by relying on an overload that defaults to natural order.

Should you even hand-roll this?

In short:

  • Comparator is for external, custom sorting logic — it does not touch the class being sorted.
  • Prefer Comparator over Comparable whenever you need multiple sort orders, or cannot modify the class’s source.
  • For modern Java, favor Comparator.comparing() with method references over hand-written compare bodies.
  • Chain comparators with thenComparing() for multi-level sorting, and reach for .reversed() / nullsFirst()/nullsLast() instead of re-deriving them.
  • Never subtract to compare numbers, and keep any comparator that feeds a TreeSet/TreeMap consistent with equals().
For a one-off sort in a stream pipeline, list.stream().sorted(Comparator.comparing(...)) is almost always enough, and writing a dedicated named Comparator class for it is usually over-engineering. Reach for a named, reusable Comparator (or a field on the class as a public constant) when the same ordering is used in more than a couple of places, or when a code reviewer should be able to see its name without reading its body. And whichever you choose: never subtract, always use Integer.compare/Long.compare/Double.compare or the type-specific factory methods, and when the comparator feeds a TreeSet or TreeMap, make sure it agrees with equals() on every field that participates in either — particularly on a record, where equals() was generated for you and easy to forget about.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.