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.Comparableandjava.util.Comparatorboth date to the original Java 1.2 Collections Framework (1998); thecomparing/comparingInt/comparingLong/comparingDouble,thenComparing,reversed,naturalOrder,reverseOrder,nullsFirstandnullsLastfactory and default methods onComparatorall 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.
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.
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.pricelooks identical toDouble.comparefor small, nearby values and is wrong in general — it loses precision for doubles, and theintequivalent 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 allx,y. Ifxthrows comparing toy,ymust throw comparing tox. - Transitive:
x.compareTo(y) > 0 && y.compareTo(z) > 0impliesx.compareTo(z) > 0. - Consistent:
x.compareTo(y) == 0impliessgn(x.compareTo(z)) == sgn(y.compareTo(z))for allz. - Strongly recommended, not required:
(x.compareTo(y) == 0) == x.equals(y). The JDK itself calls outBigDecimalas a class that deliberately violates this —new BigDecimal("1.0").compareTo(new BigDecimal("1.00"))is0, butequalsisfalsebecause 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.
Factory methods worth knowing by name (all Java 8+):
| Method | What it does |
|---|---|
Comparator.comparing(keyExtractor) | Compare by a key pulled out with a function. |
comparingInt / comparingLong / comparingDouble | Type-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)andcomparing(B).thenComparing(A)are different comparators whenever a tie onAis 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/nullsLast need a non-null comparator to delegate to.Comparator.nullsFirst(Comparator.naturalOrder())is the usual default when the key type is itselfComparable; pass an explicit comparator (as above,String::compareToorCASE_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.
TreeSet and TreeMap use compareTo/compare to decide equality, not equals(). If two different elements return0from the comparator, the second one silently fails to be added (Set.addreturnsfalse) — it is treated as a duplicate of the first, whateverequals()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.
The fingerprint of this bug: aSetorMapkeyed by a sorted collection that is mysteriously smaller than the number ofadd()/put()calls you made, with no exception anywhere. Check whether the comparator looks at every fieldequals()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.
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: anyComparatororcompareTobody that contains a bare-between two numeric fields, with no call toInteger.compare/Long.compare/Double.compareanywhere nearby. It passes every test written with small, hand-picked numbers, which is most tests.
A short reproduction list will not save you. Java’sCollections.sort/List.sortuse TimSort, which only runs its own “Comparison method violates its general contract!” detector while merging runs — and for arrays below TimSort’sMIN_MERGEthreshold (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 throwIllegalArgumentException: 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.sortandList.sort(TimSort, onObject[]) 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 equalints 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).
| Scenario | Use Comparable | Use 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) |
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
comparebodies. - 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/TreeMapconsistent withequals().
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 useInteger.compare/Long.compare/Double.compareor the type-specific factory methods, and when the comparator feeds aTreeSetorTreeMap, make sure it agrees withequals()on every field that participates in either — particularly on a record, whereequals()was generated for you and easy to forget about.
Further reading
- The companion repository — all source, every captured transcript, and the test suite that pins the overflow bug and the record/equals trap as regression tests, one script (
scripts/run-all.sh) regenerates everything - How to Sort a HashMap by Value (and Key) in Java — the Map-specific companion to this guide, using the same Comparator API against
Map.Entry - Sequenced Collections in Java 21+ —
reversed()as a collection view, distinct fromComparator.reversed()covered here - Immutable Collections in Java — what
list.sort()does to aList.of()result (throws, rather than no-op) - Top 40 Java Collections Interview Questions — several of which are this exact Comparable/Comparator distinction, asked a different way
- Java Streams API: The Complete Reference Guide —
Stream.sorted()and the rest of the pipeline this guide’s comparators plug into - Java Collections Framework: Choosing the Right Data Structure — where TreeSet/TreeMap fit against the rest of the framework
- Java BufferedReader Tutorial — unrelated to sorting, kept from the earlier version of this guide for readers who followed that link
- Comparator — Java SE 25 & JDK 25
- Comparable — Java SE 25 & JDK 25
- JEP 395: Records
No Comments yet!