Java Comparable and Comparator Interface Examples
Java 12 min read
Natural ordering against external ordering, the subtraction bug that overflows, why an inconsistent compareTo silently loses elements from a TreeSet, and the Java 8 builders that replace most custom comparators.
Two interfaces, one job, and the difference is where the ordering lives.
Comparable puts it inside the class: the type has one natural order, and compareTo defines it.
Comparator puts it outside: any number of orderings, none of which the class needs to know
about.
A type gets at most one Comparable. That is the whole basis for choosing between them.
Written against Java 17.
Comparable: the natural order
public class Version implements Comparable<Version> {
private final int major;
private final int minor;
private final int patch;
@Override
public int compareTo(Version other) {
int result = Integer.compare(major, other.major);
if (result != 0) return result;
result = Integer.compare(minor, other.minor);
if (result != 0) return result;
return Integer.compare(patch, other.patch);
}
}
The contract is a sign, not a magnitude: negative if this sorts before the argument, zero if they tie, positive if after. Nobody should care whether you return −1 or −4000.
Implementing it makes the type work with Collections.sort, Arrays.sort, list.sort(null),
TreeMap, TreeSet, and stream().sorted() with no argument. That is why it belongs on types with
an obvious single ordering (version numbers, money, dates) and not on types where “sorted” is a
question rather than a fact. A Person has no natural order; a Person sorted by surname is a
Comparator.
Never subtract
// wrong
public int compareTo(Item other) {
return this.price - other.price;
}
This is correct for small numbers and wrong at the edges. Integer.MIN_VALUE - 1 overflows to a
positive number, so an item priced at Integer.MIN_VALUE compares as greater than one priced at 1.
It is a genuine production bug in code handling timestamps, byte offsets or ids.
// correct
return Integer.compare(this.price, other.price);
Integer.compare, Long.compare, Double.compare. Double.compare additionally handles NaN and
±0.0 consistently, which a subtraction cannot: NaN compared by subtraction produces NaN, whose
sign is meaningless.
The three consistency rules
The contract has requirements beyond returning a sign, and violating them does not throw. It corrupts data structures quietly.
Antisymmetry. a.compareTo(b) and b.compareTo(a) must have opposite signs.
Transitivity. If a > b and b > c then a > c.
Consistency with equals, recommended, not required, and the one that bites:
public class Money implements Comparable<Money> {
private final long cents;
private final String currency;
@Override
public int compareTo(Money other) {
return Long.compare(cents, other.cents); // ignores currency
}
@Override
public boolean equals(Object o) {
return o instanceof Money m && cents == m.cents && currency.equals(m.currency);
}
}
compareTo says 100 EUR and 100 USD are equal; equals says they are not. Now:
Set<Money> hash = new HashSet<>();
hash.add(new Money(100, "EUR"));
hash.add(new Money(100, "USD"));
hash.size(); // 2 — uses equals
Set<Money> tree = new TreeSet<>();
tree.add(new Money(100, "EUR"));
tree.add(new Money(100, "USD"));
tree.size(); // 1 — uses compareTo, second silently dropped
The same two objects, two different sizes. A TreeSet and TreeMap use compareTo and never call
equals, so anything your comparison treats as tied is one element. No exception, no warning. The
data is simply gone.
If a total order must ignore a field, use a Comparator for that view and keep compareTo consistent
with equals.
Comparator: ordering from outside
Before Java 8 this meant an anonymous class. Now it is a builder:
Comparator<Person> bySurname = Comparator.comparing(Person::surname);
Comparator<Person> full = Comparator
.comparing(Person::surname, String.CASE_INSENSITIVE_ORDER)
.thenComparing(Person::firstName)
.thenComparingInt(Person::age)
.reversed();
people.sort(full);
Reading that top to bottom gives the sort specification in order, which a hand-written compare does
not.
Four details worth knowing.
reversed() applies to everything before it, not just the last key. comparing(a).thenComparing(b).reversed()
reverses both. To reverse one key only, pass a reversed comparator to that key:
Comparator.comparing(Note::category)
.thenComparing(Note::updatedAt, Comparator.reverseOrder());
Use the primitive variants (comparingInt, comparingLong, comparingDouble) when the key is
a primitive. comparing boxes on every comparison, and sorting a large list does O(n log n) of them.
Nulls throw unless you say otherwise:
Comparator.comparing(Person::middleName, Comparator.nullsLast(Comparator.naturalOrder()));
people.sort(Comparator.nullsFirst(bySurname)); // for null elements
nullsFirst/nullsLast wrap either the whole comparator (null elements) or a key extractor’s
comparator (null keys). Those are different problems and it is easy to fix the wrong one.
A key extractor is called repeatedly. Comparator.comparing(p -> expensive(p)) evaluates
expensive on both operands of every comparison. If the key is costly, compute it once:
record Keyed<T>(String key, T value) { }
List<Person> sorted = people.stream()
.map(p -> new Keyed<>(expensiveKey(p), p))
.sorted(Comparator.comparing(Keyed::key))
.map(Keyed::value)
.toList();
That is a Schwartzian transform, and it turns O(n log n) key computations into n.
Where each one goes
list.sort(comparator); // in place
list.sort(null); // natural order — needs Comparable
stream.sorted() // natural order
stream.sorted(comparator)
stream.max(comparator) // returns Optional
stream.min(Comparator.comparing(Note::updatedAt))
new TreeMap<>(comparator) // ordering fixed at construction
new TreeSet<>(comparator)
new PriorityQueue<>(comparator)
Collections.max(collection, comparator)
Arrays.sort(array, comparator)
list.sort mutates; stream().sorted() produces a new list. Pick according to whether the original
is shared.
A TreeMap’s comparator is fixed when the map is built and cannot be changed afterwards, that
ordering is the map’s notion of key equality.
Sorting text for people
List<String> names = new ArrayList<>(List.of("Zoe", "adam", "Ärger", "Bea"));
names.sort(null);
// [Bea, Zoe, adam, Ärger] — by char value: all uppercase before all lowercase
Natural String order is by UTF-16 code unit, which is not alphabetical in any language. For
user-facing output use a Collator:
Collator collator = Collator.getInstance(Locale.GERMAN);
names.sort(collator);
// [adam, Ärger, Bea, Zoe]
Collator handles case, accents and locale-specific rules: in German Ä sorts with A, in Swedish
it sorts after Z. String.CASE_INSENSITIVE_ORDER fixes only the case half of the problem.
Stability
Collections.sort, List.sort and Arrays.sort on objects are stable: elements comparing equal
keep their relative order. That is what makes sequential sorting work: sort by name, then by
department, and within a department names remain ordered.
Arrays.sort on primitives uses a dual-pivot quicksort and is not stable, which does not matter,
because equal primitives are indistinguishable.
Frequently asked questions
What is the difference between Comparable and Comparator?
Comparable defines a type’s single
natural order from inside the class. Comparator defines an ordering outside it, and a type can have
any number of them.
Which should I implement?
Comparable when the type has one obvious order, version, money,
date. Comparator when “sorted” depends on context, or when you cannot modify the class.
Why should I not subtract in compareTo?
Integer subtraction overflows. Integer.MIN_VALUE - 1 is
positive, so the comparison inverts. Use Integer.compare.
Why did my TreeSet lose an element?
compareTo returned 0 for two objects that equals considers
different. Tree collections define equality by comparison, so a tie is one element. Keep compareTo
consistent with equals.
Why does reversed() reverse all my sort keys?
It reverses the comparator built so far. To reverse
one key, pass Comparator.reverseOrder() to that key’s thenComparing.
How do I handle nulls?
Comparator.nullsFirst/nullsLast: around the whole comparator for null
elements, or around a key’s comparator for null keys.
Why is comparingInt better than comparing?
comparing boxes the key on every comparison. The
primitive variants avoid O(n log n) allocations.
Is Java’s sort stable?
For objects, yes: Arrays.sort, Collections.sort and List.sort
preserve the order of equal elements. Primitive Arrays.sort is not, which is unobservable.
How do I sort strings alphabetically?
Not with natural order, which is by code unit and puts all
uppercase first. Use a Collator for the relevant locale.
Can I change a TreeMap’s comparator later?
No. It is fixed at construction and defines the map’s key equality. Build a new map.