Contents
- Comparable — Natural Ordering
- The compareTo Contract
- Comparator — Custom Ordering
- Comparator.comparing() & Chaining
- Reversing & Null Handling
- Sorting Collections & Streams
- TreeMap & TreeSet Ordering
- When to Use Which
Implementing Comparable<T> adds a natural ordering to your class — the order that makes intuitive sense for that type. String sorts alphabetically, Integer sorts numerically, LocalDate sorts chronologically. When you implement Comparable, your objects can be sorted by Collections.sort() and Arrays.sort() without any extra instructions, and they can be placed in TreeSet and used as TreeMap keys without supplying a comparator.
import java.util.*;
public class Employee implements Comparable<Employee> {
private final String lastName;
private final String firstName;
private final int salary;
public Employee(String lastName, String firstName, int salary) {
this.lastName = lastName;
this.firstName = firstName;
this.salary = salary;
}
// Natural ordering: alphabetical by last name, then first name
@Override
public int compareTo(Employee other) {
int cmp = this.lastName.compareTo(other.lastName);
if (cmp != 0) return cmp;
return this.firstName.compareTo(other.firstName);
}
@Override
public String toString() {
return lastName + ", " + firstName + " ($" + salary + ")";
}
}
public class Main {
public static void main(String[] args) {
List<Employee> employees = new ArrayList<>(List.of(
new Employee("Smith", "Charlie", 75_000),
new Employee("Adams", "Zara", 90_000),
new Employee("Smith", "Alice", 80_000),
new Employee("Johnson", "Bob", 70_000)
));
Collections.sort(employees); // uses compareTo — no comparator needed
employees.forEach(System.out::println);
// Adams, Zara ($90000)
// Johnson, Bob ($70000)
// Smith, Alice ($80000)
// Smith, Charlie ($75000)
}
}
The natural ordering defined by Comparable should be consistent with equals(): if a.compareTo(b) == 0 then a.equals(b) should return true. Violating this causes confusing behaviour in sorted sets and maps — an element may appear to be "missing" even though contains() returns false for it.
The compareTo() method must return a negative integer, zero, or a positive integer as the receiver is less than, equal to, or greater than the argument. Three rules govern correct implementation:
- Antisymmetry: sgn(x.compareTo(y)) == -sgn(y.compareTo(x))
- Transitivity: if x.compareTo(y) > 0 and y.compareTo(z) > 0 then x.compareTo(z) > 0
- Consistency with equals (strongly recommended): x.compareTo(y) == 0 implies x.equals(y)
// WRONG — integer subtraction overflows for large values
public int compareTo(MyNumber other) {
return this.value - other.value; // OVERFLOW BUG if values differ by > Integer.MAX_VALUE
}
// CORRECT — use Integer.compare for int fields
public int compareTo(MyNumber other) {
return Integer.compare(this.value, other.value); // safe, no overflow
}
// CORRECT — use Double.compare for double fields
public int compareTo(Price other) {
return Double.compare(this.amount, other.amount); // safe, handles NaN
}
// CORRECT — use String.compareTo for strings (already implements Comparable)
public int compareTo(Person other) {
return this.name.compareTo(other.name);
}
// CORRECT — multi-field with early exit
public int compareTo(Event other) {
int cmp = this.date.compareTo(other.date); // LocalDate
if (cmp != 0) return cmp;
return this.title.compareTo(other.title);
}
A Comparator<T> is a standalone comparison strategy — separate from the class being compared. It is a functional interface with one abstract method, compare(T a, T b), so it can be expressed as a lambda or method reference. You use a Comparator when:
- You don't own the class (you can't modify String to add a case-insensitive natural order)
- You need multiple different orderings for the same class
- The natural ordering exists but you want to sort differently for a particular operation
import java.util.*;
List<String> words = new ArrayList<>(List.of("Banana", "apple", "Cherry", "date"));
// Lambda comparator — case-insensitive sort
words.sort((a, b) -> a.compareToIgnoreCase(b));
System.out.println(words); // [apple, Banana, Cherry, date]
// Method reference comparator
words.sort(String::compareToIgnoreCase);
System.out.println(words); // [apple, Banana, Cherry, date]
// Sorting by string length
words.sort((a, b) -> Integer.compare(a.length(), b.length()));
System.out.println(words); // [date, apple, Banana, Cherry]
// Sorting integers in reverse
List<Integer> nums = new ArrayList<>(List.of(5, 2, 8, 1, 9, 3));
nums.sort((a, b) -> Integer.compare(b, a)); // reversed by swapping args
System.out.println(nums); // [9, 8, 5, 3, 2, 1]
Java 8 introduced static factory methods on Comparator that build comparators declaratively from key extractors. Comparator.comparing() takes a function that extracts a Comparable key from the object, and returns a Comparator that sorts by that key. thenComparing() adds a secondary sort criterion when the primary is equal.
import java.util.*;
import java.util.stream.*;
record Product(String name, String category, double price, int stock) {}
List<Product> products = List.of(
new Product("Laptop", "Electronics", 999.99, 50),
new Product("Phone", "Electronics", 699.99, 200),
new Product("Desk", "Furniture", 299.99, 30),
new Product("Chair", "Furniture", 149.99, 75),
new Product("Monitor", "Electronics", 399.99, 120)
);
// Sort by category, then by price ascending
Comparator<Product> byCategoryThenPrice =
Comparator.comparing(Product::category)
.thenComparingDouble(Product::price);
products.stream()
.sorted(byCategoryThenPrice)
.forEach(p -> System.out.printf("%-12s %-15s $%.2f%n",
p.name(), p.category(), p.price()));
// Chair Furniture $149.99
// Desk Furniture $299.99
// Phone Electronics $699.99
// Monitor Electronics $399.99
// Laptop Electronics $999.99
// Sort by stock descending, then by name ascending
Comparator<Product> byStockDescThenName =
Comparator.comparingInt(Product::stock).reversed()
.thenComparing(Product::name);
products.stream()
.sorted(byStockDescThenName)
.map(p -> p.name() + " (" + p.stock() + ")")
.forEach(System.out::println);
// Phone (200)
// Monitor (120)
// Chair (75)
// Laptop (50)
// Desk (30)
Prefer Comparator.comparingInt(), comparingLong(), and comparingDouble() over comparing() when the key is a primitive — they avoid boxing and run faster. Similarly use thenComparingInt() etc. in chains.
Comparator provides built-in methods to reverse an ordering and to handle null values safely — which would otherwise throw NullPointerException during sorting.
import java.util.*;
import java.util.stream.*;
List<String> names = new ArrayList<>(
Arrays.asList("Charlie", null, "Alice", null, "Bob")
);
// WRONG — throws NullPointerException
// names.sort(Comparator.naturalOrder());
// CORRECT — nulls first, then alphabetical
names.sort(Comparator.nullsFirst(Comparator.naturalOrder()));
System.out.println(names); // [null, null, Alice, Bob, Charlie]
// CORRECT — nulls last, then reverse alphabetical
names.sort(Comparator.nullsLast(Comparator.reverseOrder()));
System.out.println(names); // [Charlie, Bob, Alice, null, null]
// Reversing any comparator with .reversed()
Comparator<String> byLength = Comparator.comparingInt(String::length);
names.removeIf(Objects::isNull);
names.sort(byLength.reversed());
System.out.println(names); // [Charlie, Alice, Bob] (longest first)
// Min/max with a custom comparator
Optional<String> shortest = names.stream()
.min(Comparator.comparingInt(String::length));
System.out.println(shortest.orElse("none")); // Bob
Both Collections.sort() and List.sort() modify the list in place. Stream.sorted() returns a new sorted stream without touching the original collection. All three accept either no comparator (uses natural ordering via Comparable) or an explicit Comparator.
import java.util.*;
import java.util.stream.*;
record Person(String name, int age) {}
List<Person> people = new ArrayList<>(List.of(
new Person("Alice", 30),
new Person("Bob", 25),
new Person("Carol", 30),
new Person("Dave", 25)
));
// In-place sort — modifies the list
people.sort(Comparator.comparingInt(Person::age).thenComparing(Person::name));
people.forEach(System.out::println);
// Person[name=Bob, age=25]
// Person[name=Dave, age=25]
// Person[name=Alice, age=30]
// Person[name=Carol, age=30]
// Stream sorted — original list unchanged, returns new sorted stream
List<Person> byName = people.stream()
.sorted(Comparator.comparing(Person::name))
.collect(Collectors.toList());
System.out.println(byName);
// [Person[name=Alice, age=30], Person[name=Bob, age=25],
// Person[name=Carol, age=30], Person[name=Dave, age=25]]
// min / max via Comparator
Person youngest = people.stream()
.min(Comparator.comparingInt(Person::age))
.orElseThrow();
System.out.println(youngest); // Person[name=Bob, age=25]
// Arrays.sort with Comparator
Person[] arr = people.toArray(Person[]::new);
Arrays.sort(arr, Comparator.comparing(Person::name).reversed());
System.out.println(Arrays.toString(arr));
TreeSet and TreeMap keep their elements/keys in sorted order. By default they use the natural ordering of the elements (via Comparable). You can override this by passing a Comparator to the constructor — which is required when the elements do not implement Comparable or when you want a different order.
import java.util.*;
// TreeSet with natural ordering (String implements Comparable)
TreeSet<String> alphabetical = new TreeSet<>();
alphabetical.addAll(List.of("banana", "apple", "cherry", "date"));
System.out.println(alphabetical); // [apple, banana, cherry, date]
// TreeSet with custom comparator — reverse alphabetical
TreeSet<String> reversed = new TreeSet<>(Comparator.reverseOrder());
reversed.addAll(List.of("banana", "apple", "cherry", "date"));
System.out.println(reversed); // [date, cherry, banana, apple]
// TreeSet with length comparator (secondary by natural order for ties)
TreeSet<String> byLength = new TreeSet<>(
Comparator.comparingInt(String::length).thenComparing(Comparator.naturalOrder())
);
byLength.addAll(List.of("banana", "fig", "apple", "kiwi", "date"));
System.out.println(byLength); // [fig, date, kiwi, apple, banana]
// TreeMap sorted by key length
TreeMap<String, Integer> wordCount = new TreeMap<>(
Comparator.comparingInt(String::length).thenComparing(Comparator.naturalOrder())
);
wordCount.put("hello", 5);
wordCount.put("hi", 2);
wordCount.put("hey", 3);
System.out.println(wordCount); // {hi=2, hey=3, hello=5}
When you supply a Comparator to a TreeSet, the set uses it for all operations including contains(), remove(), and add(). If the comparator considers two elements equal (compare() == 0), the set treats them as the same element — even if their equals() methods return false. Always ensure your comparator is consistent with equals() to avoid losing elements silently.
| Scenario | Use |
| One obvious "natural" sort order for your class (alphabetical, chronological, numerical) |
Comparable |
| You want TreeSet/TreeMap to work without extra code |
Comparable |
| Multiple sort orders for the same class |
Comparator |
| Sorting a class you don't own (String, Integer, etc.) |
Comparator |
| Sort order changes at runtime (user chooses column) |
Comparator |
| You need null-safe sorting |
Comparator.nullsFirst/nullsLast |
| Complex multi-key sort with readable code |
Comparator.comparing().thenComparing() |
You can implement both. A class that implements Comparable for its natural ordering can still be sorted with a Comparator for a different order. The comparator always takes precedence when explicitly provided.