Skip to content
CalliCoder

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.

Where should I go next?

HashMap covers the hash-based counterpart where equals and hashCode matter instead, and ArrayList covers the list these comparators sort.