Skip to content

How to Implement Comparable and Comparator in Java 21

Sort a warehouse stock list by SKU, name, price and expiry date with Comparable and Comparator on Java 21, then prove the order with 28 runnable checks.

how-to-tutorials java-collections java-tutorials
Gopi Gorantala
Reading Progress

On This Page

Sort the warehouse three ways: what you'll build

Here's the bug that made me write this one down. Two stock lines for the same SKU go into a TreeSet. One line has 5 units, the other has 9. The set reports a size of 1, keeps the 5, and says nothing:

TreeSet size after adding two SKU-1 lines: 1 (quantity kept: 5)

No exception, no warning. Four units of tea are gone from the report because compareTo said the two lines were the same item. That's the kind of mistake you only avoid once you know how Java decides that two objects are "equal" for sorting purposes.

By the end of this tutorial you'll know how to implement Comparable and Comparator in Java the way I'd write them at work. You'll have a small inventory project with a StockItem that has a natural order, a handful of named comparators, one chained "restock report" order that handles missing expiry dates, and 28 checks that prove each of them follows the sorting contract. This is for you if you've called Collections.sort a hundred times and never been sure what happens when two items tie.

Here's what the finished program prints. Items are sorted by category, then soonest expiry, then name, with undated items last in each category:

restock report:
  dairy  Yoghurt       2026-10-30   SKU-400
  dairy  Almond milk   2026-11-02   SKU-100
  dairy  oat milk      2026-11-10   SKU-300
  pantry Lentils       2027-03-01   SKU-250
  pantry Basmati rice  null         SKU-200
  pantry Brown rice    null         SKU-150

The whole thing is four small Java files and no libraries. The build itself went quickly. Most of my time went on one stubborn failure: a broken comparator that refused to throw an exception on small lists, which took three attempts to reproduce. More on that in the troubleshooting section.

Tools I used and how to check yours

Tool Version I used Check it with
JDK OpenJDK 21.0.12.1 (Ubuntu build) java -version
javac 21.0.12.1 javac -version
IntelliJ IDEA / Eclipse optional, not run for this article Help → About

You need Java 21 or any release from 16 up, because StockItem is a record. If you're on an older JDK, the compiler stops you with records are not supported in -source 11. I'll show that message later. You should already know how to write a class and a List, and it helps to have read how to use Java records first. No framework, no database, no container. Podman and Docker have no part in this one.

I didn't use Maven or Gradle this time. My build environment can't reach Maven Central, so I compiled with plain javac and wrote the checks as an ordinary main method instead of JUnit. I'd rather tell you that than quietly drop in a JUnit version I didn't run. The project layout below is the standard Maven/Gradle one, so everything moves over unchanged when you want it there.

Create the inventory folders and compile once

Open a terminal and make the folders:

mkdir -p inv/src/main/java/com/example/inventory inv/src/test/java/com/example/inventory
cd inv

The tree you're aiming for:

inv/
└── src/
    ├── main/java/com/example/inventory/
    │   ├── StockItem.java
    │   ├── StockOrders.java
    │   └── Main.java
    └── test/java/com/example/inventory/
        └── Checks.java

In IntelliJ IDEA: New Project, choose a plain Java project (Maven or Gradle if you like), pick JDK 21, then create the com.example.inventory package under src/main/java. In Eclipse: File → New → Java Project, set the execution environment to JavaSE-21, and add the package the same way. I wrote those two paths from the IDE docs and didn't click through them for this article. The Java code is identical in both.

Everything below compiles from the command line with one command, which I'll repeat at each checkpoint:

mkdir -p out
javac --release 21 -d out src/main/java/com/example/inventory/*.java

How to implement Comparable and Comparator in Java: five steps

Two interfaces, two jobs. Comparable<T> lives inside the class and defines its one natural order. Comparator<T> lives outside and defines any other order you need. Most real code uses one Comparable and several Comparators, and that's what we'll build.

Step 1: Give StockItem a natural order with Comparable

Goal: make StockItem sortable on its own, so Collections.sort(list) and TreeSet work without being told how.

src/main/java/com/example/inventory/StockItem.java

package com.example.inventory;

import java.math.BigDecimal;
import java.time.LocalDate;

/**
 * One line in the warehouse. The natural order is by SKU, because a SKU
 * identifies an item. Nothing else about the item is allowed to decide it.
 */
public record StockItem(String sku, String name, String category,
                        int quantity, BigDecimal price, LocalDate expiry)
        implements Comparable<StockItem> {

    public StockItem {
        if (sku == null || sku.isBlank()) throw new IllegalArgumentException("sku is required");
        if (name == null || category == null || price == null) {
            throw new IllegalArgumentException("name, category and price are required");
        }
    }

    @Override
    public int compareTo(StockItem other) {
        return sku.compareTo(other.sku);
    }
}

Why this way: compareTo returns a negative number if this item sorts first, zero if the two are tied, and a positive number if this one sorts later. The sign is all that matters. I compare the SKU only, because the natural order should answer the question "which item is this?" and nothing more. expiry is allowed to be null (rice doesn't expire in any way we track), so the compact constructor doesn't check it.

There's a catch hiding in this file and we'll trigger it on purpose in Step 5: compareTo ignores everything except the SKU, but a record's generated equals compares every field. The two methods disagree. Hold that thought.

Checkpoint: you can't run anything yet, because Main doesn't exist. Compile only this file to be sure it parses:

javac --release 21 -d out src/main/java/com/example/inventory/StockItem.java

No output means success.

Step 2: Write the first named comparators

Goal: sort by name, price, and quantity without touching StockItem.

src/main/java/com/example/inventory/StockOrders.java

package com.example.inventory;

import java.util.Comparator;

public final class StockOrders {

    private StockOrders() { }

    public static final Comparator<StockItem> BY_NAME =
            Comparator.comparing(StockItem::name, String.CASE_INSENSITIVE_ORDER);

    public static final Comparator<StockItem> BY_PRICE =
            Comparator.comparing(StockItem::price);

    // Highest stock first. Integer.compare, never a subtraction.
    public static final Comparator<StockItem> BY_QUANTITY_DESC =
            Comparator.comparingInt(StockItem::quantity).reversed();
}

Why: Comparator.comparing takes a "key extractor" (a method reference that pulls out the field to sort on) and builds the comparator for you. Passing String.CASE_INSENSITIVE_ORDER as a second argument matters more than it looks. My sample data has oat milk in lower case, and plain String ordering puts every lower-case word after every capitalised one. With the case-insensitive comparator oat milk lands between Lentils and Yoghurt, which is where a human expects it. comparingInt avoids boxing, and .reversed() flips the result.

Checkpoint: compile both files together.

javac --release 21 -d out src/main/java/com/example/inventory/*.java

Step 3: Chain comparators and handle null dates

Goal: build the restock report order: category, then soonest expiry, then name, then SKU, with missing expiry dates last.

Replace StockOrders.java with this complete file:

package com.example.inventory;

import java.util.Comparator;

public final class StockOrders {

    private StockOrders() { }

    public static final Comparator<StockItem> BY_NAME =
            Comparator.comparing(StockItem::name, String.CASE_INSENSITIVE_ORDER);

    public static final Comparator<StockItem> BY_PRICE =
            Comparator.comparing(StockItem::price);

    // Highest stock first. Integer.compare, never a subtraction.
    public static final Comparator<StockItem> BY_QUANTITY_DESC =
            Comparator.comparingInt(StockItem::quantity).reversed();

    // Items with no expiry date (null) go last.
    public static final Comparator<StockItem> BY_EXPIRY_NULLS_LAST =
            Comparator.comparing(StockItem::expiry,
                    Comparator.nullsLast(Comparator.naturalOrder()));

    // Category A-Z, then soonest expiry, then name, then SKU as the final tie-break.
    public static final Comparator<StockItem> RESTOCK_REPORT =
            Comparator.comparing(StockItem::category)
                    .thenComparing(BY_EXPIRY_NULLS_LAST)
                    .thenComparing(BY_NAME)
                    .thenComparing(Comparator.naturalOrder());
}

Why: thenComparing only runs when everything before it tied. The last line uses the natural order (the SKU) as a tie-break, so two items never compare as equal unless they share a SKU. That makes the report order fully deterministic. nullsLast wraps a comparator so that null never reaches it. Without the wrapper, the first null date throws a NullPointerException, and I check that in Step 5.

Checkpoint: compile again. Same command, no output.

Step 4: Wire up a program and sort with it

Goal: use all three ways of sorting: natural order, a comparator on a list, and a comparator on a TreeSet.

src/main/java/com/example/inventory/Main.java

package com.example.inventory;

import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.TreeSet;

public class Main {

    static StockItem item(String sku, String name, String cat, int qty, String price, String expiry) {
        return new StockItem(sku, name, cat, qty, new BigDecimal(price),
                expiry == null ? null : LocalDate.parse(expiry));
    }

    public static List<StockItem> sample() {
        return new ArrayList<>(List.of(
                item("SKU-300", "oat milk",    "dairy",  40, "2.49", "2026-11-10"),
                item("SKU-100", "Almond milk", "dairy",  12, "2.99", "2026-11-02"),
                item("SKU-200", "Basmati rice","pantry", 75, "3.50", null),
                item("SKU-150", "Brown rice",  "pantry", 75, "3.10", null),
                item("SKU-400", "Yoghurt",     "dairy",  12, "1.20", "2026-10-30"),
                item("SKU-250", "Lentils",     "pantry",  5, "1.80", "2027-03-01")));
    }

    public static void main(String[] args) {
        List<StockItem> items = sample();

        items.sort(null); // null = natural order
        System.out.println("natural (SKU):");
        items.forEach(i -> System.out.println("  " + i.sku() + "  " + i.name()));

        items.sort(StockOrders.BY_NAME);
        System.out.println("by name, ignoring case:");
        items.forEach(i -> System.out.println("  " + i.name()));

        items.sort(StockOrders.RESTOCK_REPORT);
        System.out.println("restock report:");
        items.forEach(i -> System.out.printf("  %-6s %-13s %-12s %s%n",
                i.category(), i.name(), i.expiry(), i.sku()));

        TreeSet<StockItem> byName = new TreeSet<>(StockOrders.BY_NAME);
        byName.addAll(items);
        System.out.println("first alphabetically: " + byName.first().name());
    }
}

Why: List.of is immutable, so the sample is copied into an ArrayList that sort can rearrange. items.sort(null) is documented to mean "use the natural order". It reads oddly and I'd normally write Collections.sort(items) or Comparator.naturalOrder() in real code. I used null here once so you've seen it. The TreeSet takes the comparator in its constructor and uses it for everything: ordering, lookups, and deciding what counts as a duplicate.

Checkpoint:

javac --release 21 -d out src/main/java/com/example/inventory/*.java
java -cp out com.example.inventory.Main

You should see exactly this:

natural (SKU):
  SKU-100  Almond milk
  SKU-150  Brown rice
  SKU-200  Basmati rice
  SKU-250  Lentils
  SKU-300  oat milk
  SKU-400  Yoghurt
by name, ignoring case:
  Almond milk
  Basmati rice
  Brown rice
  Lentils
  oat milk
  Yoghurt
restock report:
  dairy  Yoghurt       2026-10-30   SKU-400
  dairy  Almond milk   2026-11-02   SKU-100
  dairy  oat milk      2026-11-10   SKU-300
  pantry Lentils       2027-03-01   SKU-250
  pantry Basmati rice  null         SKU-200
  pantry Brown rice    null         SKU-150
first alphabetically: Almond milk

If you see that, the hard part's done. (If you've got a Java 21 JDK, java -version printed a line starting with openjdk version "21 and you can ignore any Picked up JAVA_TOOL_OPTIONS noise that your environment adds above the output. I had it on every command.)

Step 5: Break things on purpose

Goal: see the three comparator mistakes that cost real money in real code, so you recognise them.

Add this comparator to StockOrders.java, above the closing brace. It's the version almost every beginner writes, and I've written it myself:

    // The classic bug, kept here on purpose so the checks can catch it.
    public static final Comparator<StockItem> BROKEN_BY_QUANTITY =
            (a, b) -> a.quantity() - b.quantity();

Now sort two items, one with 2,147,483,647 units and one with -5 (a data-entry error, but it happens). Put this in a scratch class next to Main, say Scratch.java, with new StockItem("SKU-2", "big", "x", Integer.MAX_VALUE, BigDecimal.ONE, null) and new StockItem("SKU-3", "neg", "x", -5, BigDecimal.ONE, null) in an ArrayList, then call list.sort(StockOrders.BROKEN_BY_QUANTITY) and print the quantities. I got:

broken ascending by quantity: [2147483647, -5]
fixed ascending by quantity:  [-5, 2147483647]

2147483647 - (-5) overflows int and wraps to a large negative number, so the comparator claims the big item is smaller. Use Integer.compare(a, b) or Comparator.comparingInt. Never subtract.

The second mistake is the TreeSet that opened this article. compareTo returned 0 for two lines with the same SKU, so the set treated the second as a duplicate and refused it. HashSet keeps both, because it uses equals. I cover that rule properly in how to implement equals() and hashCode(). The short version: if two objects compare as 0, they should also be equals, or you need to know exactly why they aren't.

The third mistake is a comparator that breaks the contract so badly the JDK notices. That one gets its own entry in the troubleshooting section.

Run it and read the report

You've already run it. Read the three blocks of output once more with a specific question in mind: where did the stable sort matter? Basmati rice and Brown rice both have 75 units. Sorting with BY_QUANTITY_DESC leaves them in their incoming order, because List.sort is stable (it never swaps tied elements). That's why the report needs the SKU tie-break at the end. A stable sort hides the problem; a TreeSet doesn't.

Prove the contract with 28 checks

A comparator has three rules. compare(a, b) and compare(b, a) must have opposite signs. If a <= b and b <= c then a <= c. And a tie should mean the same thing everywhere. I wanted each of those tested, not assumed, so the check class runs the first two over every comparator in the project against 60 random items.

src/test/java/com/example/inventory/Checks.java

package com.example.inventory;

import java.util.*;
import java.math.BigDecimal;
import java.time.LocalDate;

public class Checks {
    static int passed = 0, failed = 0;

    static void check(String name, boolean ok) {
        if (ok) { passed++; System.out.println("PASS  " + name); }
        else { failed++; System.out.println("FAIL  " + name); }
    }

    static StockItem it(String sku, String name, int qty, String price) {
        return new StockItem(sku, name, "x", qty, new BigDecimal(price), null);
    }

    public static void main(String[] args) {
        List<StockItem> s = Main.sample();

        // natural order
        List<StockItem> nat = new ArrayList<>(s); Collections.sort(nat);
        check("natural order is ascending by sku",
                nat.stream().map(StockItem::sku).toList()
                   .equals(List.of("SKU-100","SKU-150","SKU-200","SKU-250","SKU-300","SKU-400")));

        // sign symmetry and transitivity over random items for every comparator
        Random r = new Random(42);
        List<StockItem> many = new ArrayList<>();
        String[] cats = {"dairy","pantry","frozen"};
        for (int i = 0; i < 60; i++) {
            many.add(new StockItem("SKU-" + r.nextInt(30), (r.nextBoolean() ? "a" : "B") + r.nextInt(8),
                    cats[r.nextInt(3)], r.nextInt(5), new BigDecimal(r.nextInt(4) + ".50"),
                    r.nextInt(3) == 0 ? null : LocalDate.of(2026, 11, 1 + r.nextInt(5))));
        }
        Map<String, Comparator<StockItem>> all = new LinkedHashMap<>();
        all.put("natural", Comparator.naturalOrder());
        all.put("BY_NAME", StockOrders.BY_NAME);
        all.put("BY_PRICE", StockOrders.BY_PRICE);
        all.put("BY_QUANTITY_DESC", StockOrders.BY_QUANTITY_DESC);
        all.put("BY_EXPIRY_NULLS_LAST", StockOrders.BY_EXPIRY_NULLS_LAST);
        all.put("RESTOCK_REPORT", StockOrders.RESTOCK_REPORT);
        for (var e : all.entrySet()) {
            Comparator<StockItem> c = e.getValue();
            boolean sym = true, trans = true;
            for (StockItem a : many) for (StockItem b : many) {
                if (Integer.signum(c.compare(a, b)) != -Integer.signum(c.compare(b, a))) sym = false;
            }
            for (int i = 0; i < 20000; i++) {
                StockItem a = many.get(r.nextInt(60)), b = many.get(r.nextInt(60)), d = many.get(r.nextInt(60));
                if (c.compare(a, b) <= 0 && c.compare(b, d) <= 0 && c.compare(a, d) > 0) trans = false;
            }
            check(e.getKey() + ": sgn(compare(a,b)) == -sgn(compare(b,a))", sym);
            check(e.getKey() + ": transitive on 20,000 random triples", trans);
        }

        // report order
        List<StockItem> rep = new ArrayList<>(s); rep.sort(StockOrders.RESTOCK_REPORT);
        check("restock report: Yoghurt (expires first) leads",
                rep.get(0).name().equals("Yoghurt"));
        check("restock report: null expiry sorts after dated items within a category",
                rep.get(3).name().equals("Lentils") && rep.get(4).expiry() == null);

        // sort is stable
        List<StockItem> st = new ArrayList<>(s); st.sort(StockOrders.BY_QUANTITY_DESC);
        int iB = st.indexOf(s.stream().filter(x -> x.sku().equals("SKU-200")).findFirst().get());
        int iR = st.indexOf(s.stream().filter(x -> x.sku().equals("SKU-150")).findFirst().get());
        check("List.sort is stable (equal quantities keep input order: 200 before 150)", iB < iR);

        // case-insensitive name
        List<StockItem> n = new ArrayList<>(s); n.sort(StockOrders.BY_NAME);
        check("BY_NAME puts 'oat milk' between Lentils and Yoghurt",
                n.get(4).name().equals("oat milk"));
        List<StockItem> plain = new ArrayList<>(s); plain.sort(Comparator.comparing(StockItem::name));
        check("plain String order puts 'oat milk' last (lower case after upper)",
                plain.get(5).name().equals("oat milk"));

        // consistency with equals
        StockItem a1 = it("SKU-1", "tea", 5, "1.00");
        StockItem a2 = it("SKU-1", "tea", 9, "1.00");
        check("compareTo == 0 while equals is false (record equals uses every field)",
                a1.compareTo(a2) == 0 && !a1.equals(a2));
        TreeSet<StockItem> ts = new TreeSet<>(List.of(a1, a2));
        check("TreeSet silently keeps one of two 'equal by compareTo' items", ts.size() == 1);
        HashSet<StockItem> hs = new HashSet<>(List.of(a1, a2));
        check("HashSet keeps both (equals disagrees)", hs.size() == 2);

        // subtraction overflow
        StockItem big = it("SKU-2", "x", Integer.MAX_VALUE, "1");
        StockItem neg = it("SKU-3", "y", -5, "1");
        check("subtraction comparator is WRONG on MAX_VALUE vs -5",
                StockOrders.BROKEN_BY_QUANTITY.compare(big, neg) < 0);
        check("Integer.compare gets it right", Integer.compare(big.quantity(), neg.quantity()) > 0);

        // reversed() and thenComparing
        check("reversed comparator flips sign",
                StockOrders.BY_PRICE.reversed().compare(it("A","a",1,"1"), it("B","b",1,"2")) > 0);

        // Comparator.nullsFirst vs NPE
        boolean npe = false;
        try { Comparator.comparing(StockItem::expiry).compare(s.get(0), s.get(2)); }
        catch (NullPointerException e) { npe = true; }
        check("comparing(expiry) without nullsLast throws NPE on a null date", npe);

        // BigDecimal scale
        StockItem p1 = it("P1","p",1,"2.0"), p2 = it("P2","p",1,"2.00");
        check("BigDecimal compareTo ignores scale (2.0 vs 2.00 -> 0) but equals does not",
                p1.price().compareTo(p2.price()) == 0 && !p1.price().equals(p2.price()));

        // Collections.max/min and binarySearch
        check("Collections.min with BY_PRICE finds Yoghurt", Collections.min(s, StockOrders.BY_PRICE).name().equals("Yoghurt"));
        List<StockItem> sorted = new ArrayList<>(s); Collections.sort(sorted);
        int pos = Collections.binarySearch(sorted, it("SKU-250","whatever",0,"0"));
        check("binarySearch uses compareTo, so a SKU-only probe finds the item", pos == 3);

        System.out.println(passed + " passed, " + failed + " failed");
        if (failed > 0) System.exit(1);
    }
}

Compile and run it. The classpath includes out, where the main classes already sit:

javac --release 21 -d out -cp out src/test/java/com/example/inventory/Checks.java
java -cp out com.example.inventory.Checks
PASS  natural order is ascending by sku
PASS  natural: sgn(compare(a,b)) == -sgn(compare(b,a))
PASS  natural: transitive on 20,000 random triples
PASS  BY_NAME: sgn(compare(a,b)) == -sgn(compare(b,a))
PASS  BY_NAME: transitive on 20,000 random triples
[... lines cut: the same pair of lines for BY_PRICE, BY_QUANTITY_DESC, BY_EXPIRY_NULLS_LAST, RESTOCK_REPORT]
PASS  restock report: Yoghurt (expires first) leads
PASS  restock report: null expiry sorts after dated items within a category
PASS  List.sort is stable (equal quantities keep input order: 200 before 150)
PASS  BY_NAME puts 'oat milk' between Lentils and Yoghurt
PASS  plain String order puts 'oat milk' last (lower case after upper)
PASS  compareTo == 0 while equals is false (record equals uses every field)
PASS  TreeSet silently keeps one of two 'equal by compareTo' items
PASS  HashSet keeps both (equals disagrees)
PASS  subtraction comparator is WRONG on MAX_VALUE vs -5
PASS  Integer.compare gets it right
PASS  reversed comparator flips sign
PASS  comparing(expiry) without nullsLast throws NPE on a null date
PASS  BigDecimal compareTo ignores scale (2.0 vs 2.00 -> 0) but equals does not
PASS  Collections.min with BY_PRICE finds Yoghurt
PASS  binarySearch uses compareTo, so a SKU-only probe finds the item
28 passed, 0 failed

This is plain Java rather than JUnit, because of the Maven Central problem I mentioned. When you move it into a build tool, each check(...) becomes an assertThat(...) call in a @Test method, and the random-triple loop becomes a @RepeatedTest or a plain loop inside one test. I haven't run that translation, so I won't show output for it. The JUnit 5 tutorial in this series is where that lands.

One check deserves a second look: BigDecimal compareTo ignores scale. new BigDecimal("2.0").compareTo(new BigDecimal("2.00")) is 0, while equals says false. It's the same trap as the TreeSet, built into the JDK. If you key a TreeMap on BigDecimal prices, 2.0 and 2.00 are one key.

What sort() actually asks your code

When you call list.sort(comparator), the JDK runs TimSort, a merge sort that takes advantage of runs that are already ordered. It never asks you for a "position". It only asks one question over and over: here are two items, which comes first? Your answer has to be consistent, because TimSort uses earlier answers to skip later comparisons. If a < b and b < c but you say c < a, the algorithm can end up in a state it believes is impossible.

Comparable is what a class says about itself, and the JDK already supplies it for String, the wrapper types, BigDecimal, LocalDate and enums. A TreeSet or TreeMap with no comparator calls compareTo and casts to Comparable, which is why putting a non-comparable object in one fails at runtime rather than at compile time. A Comparator is an outside opinion, passed in when you need a different order. That's what makes the same list sortable by price today and by expiry tomorrow without changing the class.

My rule: if there's one clearly right order (SKU, timestamp, version number), make the class Comparable. If there are several reasonable orders or the order depends on the screen, write comparators. When in doubt, comparators; you can always add Comparable later, but you can't take it away from callers who relied on it.

If you want the fuller picture of how a stream pipeline uses these, how to filter and transform a list with Java streams uses sorted(Comparator) in a few places.

Sorting bugs I caused on purpose

Each of these comes from something I ran. Exact messages, cause, then fix.

A TreeSet quietly drops items.

TreeSet size after adding two SKU-1 lines: 1 (quantity kept: 5)

Cause: the comparator returned 0 for items you consider different. Fix: include every field that makes two items distinct in the comparison (add thenComparing for them), or use a List and not a Set.

The set rejects a plain class with a ClassCastException.

class Traps$Plain cannot be cast to class java.lang.Comparable (Traps$Plain is in unnamed module of loader 'app'; java.lang.Comparable is in module java.base of loader 'bootstrap')

Cause: new TreeSet<>().add(x) without a comparator, where x's class doesn't implement Comparable. The compiler can't see this when the set is declared as TreeSet<Object>. Fix: implement Comparable<T> or pass a Comparator to the constructor.

Collections.sort(list) won't compile.

C.java:6: error: no suitable method found for sort(List<P>)
    method Collections.<T#1>sort(List<T#1>) is not applicable
      (inference variable T#1 has incompatible bounds
        equality constraints: P
        upper bounds: Comparable<? super T#1>)

Cause: P doesn't implement Comparable. This one's the friendly failure because it happens at compile time. Fix: implement Comparable<P>, or call list.sort(someComparator) instead.

compareTo(Object) instead of compareTo(P).

A.java:3: error: P is not abstract and does not override abstract method compareTo(P) in Comparable
A.java:4: error: name clash: compareTo(Object) in P and compareTo(P) in Comparable have the same erasure, yet neither overrides the other

Cause: the old raw-type signature. Fix: use compareTo(P other) and add @Override so the compiler checks it for you.

compareTo without public.

D.java:4: error: compareTo(P) in P cannot implement compareTo(T) in Comparable
  attempting to assign weaker access privileges; was public

Cause: interface methods are public. Fix: add public.

Comparator.comparing(...).reversed() loses its type.

B.java:6: error: cannot find symbol
        l.sort(Comparator.comparing(p -> p.s()).reversed());
  symbol:   method s()
  location: variable p of type Object

Cause: when you chain .reversed() the compiler no longer knows what T is for the lambda, so p becomes Object. This is the compile error that trips up more people than any other here. Fix: use a method reference (Comparator.comparing(P::s).reversed()), give the lambda a type ((P p) -> p.s()), or use Comparator.comparing(P::s, Comparator.reverseOrder()).

Comparison method violates its general contract!

n=1000 IllegalArgumentException: Comparison method violates its general contract!
never-zero n=1000 IllegalArgumentException: Comparison method violates its general contract!

This was my stubborn one. I wrote a comparator that returned random numbers, then one that never returns 0 (x < y ? -1 : 1), and sorted 200 and then 100 items. Nothing happened: no exception, wrong order, silently. The JDK only catches a broken comparator when TimSort happens to merge in a way that exposes it. A cyclic comparator (0 < 1 < 2 < 0) threw at 1,000 elements and not at 40 or 100. The never-zero comparator did the same. Third attempt, and I got the message.

Cause: the comparator isn't symmetric or isn't transitive. Fix: make it follow the three rules, test symmetry and transitivity like Checks.java does, and don't count on the JDK to catch it. The message appearing in production under load, on one customer's data, is the classic way people find out.

A null date throws. Comparator.comparing(StockItem::expiry) is fine until the first null:
the check comparing(expiry) without nullsLast throws NPE proves it. Fix: wrap the key comparator in Comparator.nullsFirst or nullsLast.

records are not supported in -source 11. You compiled with an older --release:

StockItem.java:10: error: records are not supported in -source 11
  (use -source 16 or higher to enable records)

Fix: use JDK 16 or later. If you must stay on 11, write StockItem as a normal final class with equals and hashCode. The comparators don't change.

Beyond the basics: version notes and next reads

Comparator.comparing, thenComparing, reversed, nullsFirst/nullsLast and naturalOrder are all Java 8. Comparator.comparingInt is Java 8 too. List.sort(Comparator) is Java 8; before that you used Collections.sort. Nothing in this article changed between Java 17 and 21, apart from records needing 16 or later.

A few things I'd think about before this reaches a real service:

  • Sort once, near the edge. Resorting a large list on every request is a quiet cost. Keep a sorted copy, or push the order into the database query.
  • TreeMap with a comparator is also a definition of equality. Anything that should be one key in the map must compare as 0. This is how BigDecimal prices and case-insensitive names surprise people.
  • Locale-aware sorting needs Collator. String.CASE_INSENSITIVE_ORDER works for English-style data. For names with accents, use java.text.Collator. I didn't test Collator in this build, so check its Javadoc before you rely on it.
  • Mutable keys break sorted collections. If you change quantity on an item that's already inside a TreeSet ordered by quantity, the set won't know. That's one more reason to keep items immutable, as in how to create an immutable class in Java.

Next reads, some already live: how to implement the strategy pattern (a comparator is a strategy), how to use sealed classes, and how to use Optional for the null-handling side. Coming in this series: chaining comparators in more depth, PriorityQueue and TreeMap, and collecting stream results.

Six things to remember

  • Make a class Comparable only when there's one obvious order, and compare only the fields that identify the object.
  • Put every other order in a named Comparator constant, built with Comparator.comparing and thenComparing.
  • Never subtract in a comparator. Use Integer.compare or comparingInt.
  • Wrap nullable keys with nullsFirst or nullsLast.
  • A tie in a sorted set or map means "same item". End the chain with a unique field if that's not what you want.
  • Test symmetry and transitivity yourself. The JDK throws the contract error only sometimes.

Questions people ask about Comparable and Comparator

What's the difference between Comparable and Comparator in Java? Comparable is implemented by the class being sorted and gives it one natural order through compareTo. Comparator is a separate object with a compare(a, b) method, and you can have as many as you like. Use Comparable for the order that's obvious and a Comparator for everything else.

How do I sort a list by multiple fields in Java? Build a comparator with Comparator.comparing(first).thenComparing(second) and pass it to list.sort. Each later comparator runs only when all the earlier ones returned 0. End with a unique field, such as an ID, so the order is deterministic.

Why does my TreeSet remove elements? A TreeSet decides what counts as a duplicate by compareTo or the comparator, and not by equals. If your comparator returns 0 for two different objects, the second one is never added. Add the missing fields to the comparator.

How do I sort in descending order? Call .reversed() on the comparator, or use Comparator.reverseOrder() for natural order. With Comparator.comparing(...) and a lambda, .reversed() can lose the type, so use a method reference.

References

how-to-tutorialsjava-collectionsjava-tutorials

Staff engineer architecting Kafka and Flink platforms for banking, where a dropped event is a reconciliation break, not a retry. Through javahandbook.com, I share production-tested guidance on building reliable event-driven systems.

Comments