☕ Java

Comparator

Comparator is a functional interface in java.util that defines an ordering for objects that is separate from the objects themselves. Unlike Comparable, which bakes the natural ordering into the class itself, Comparator is an external ordering strategy that can be defined anywhere, by anyone, for any purpose. Java 8 enriched Comparator with a comprehensive set of static and default factory methods that compose, reverse, and chain comparators into complex orderings with minimal code. Understanding Comparator means understanding the compare() contract, the full library of factory methods, how to compose comparators for multi-key sorting, null handling, and the powerful integration with Stream.sorted(), TreeSet, TreeMap, and PriorityQueue. This entry covers every aspect of modern Comparator usage.

The compare() Contract and Basic Comparators

Comparator<T> is a functional interface with one abstract method: int compare(T a, T b). The return value follows the same sign convention as compareTo: negative if a < b, zero if a == b, positive if a > b. The same contract requirements apply: antisymmetry, transitivity, and consistency. The consistency-with-equals recommendation also applies — a Comparator that returns 0 for two objects that are not equals() will cause sorted collections to treat them as duplicates. Because Comparator is a functional interface, any lambda or method reference that takes two T arguments and returns an int is a valid Comparator<T>. This allows inline comparators with minimal syntax: (a, b) -> a.getName().compareTo(b.getName()), or Integer::compare as a Comparator<Integer>. The conciseness of lambda-based comparators is one of the reasons Comparator usage dramatically increased with Java 8. The reversed() default method returns a comparator that imposes the reverse ordering. negated() is not a method — reversed() is the name. Combining reversed() with other comparators enables descending sorts: Comparator.comparing(Product::getPrice).reversed() gives highest-price-first ordering. The thenComparing() default methods return a lexicographic comparator that first applies the original comparator and, only when the original returns 0 (elements compare equal), applies the secondary comparator. Any number of levels can be chained. The chain evaluates in order: primary key, then secondary, then tertiary, stopping as soon as any level produces a non-zero result.
Java
// ── Basic comparator implementations ─────────────────────────────────
// Lambda:
Comparator<String> byLength = (a, b) -> Integer.compare(a.length(), b.length());

// Method reference (for types with existing compare methods):
Comparator<Integer> intComp = Integer::compare;
Comparator<String>  strComp = String::compareTo;

// ── Comparator.comparing — extract key, use natural ordering ──────────
record Person(String name, int age, String city) {}

Comparator<Person> byName = Comparator.comparing(Person::name);
Comparator<Person> byAge  = Comparator.comparingInt(Person::age);
Comparator<Person> byCity = Comparator.comparing(Person::city);

// ── thenComparing — multi-key sorting ────────────────────────────────
Comparator<Person> byAgeThenName =
    Comparator.comparingInt(Person::age)
              .thenComparing(Person::name);

Comparator<Person> byAgeThenNameThenCity =
    Comparator.comparingInt(Person::age)
              .thenComparing(Person::name)
              .thenComparing(Person::city);

List<Person> people = new ArrayList<>(List.of(
    new Person("Bob",   30, "Paris"),
    new Person("Alice", 25, "London"),
    new Person("Carol", 30, "Berlin"),
    new Person("Dave",  25, "London")));

people.sort(byAgeThenName);
people.forEach(p -> System.out.printf("%s %d%n", p.name(), p.age()));
// Alice 25, Dave 25, Bob 30, Carol 30

// ── reversed — descending order ───────────────────────────────────────
Comparator<Person> oldestFirst = Comparator.comparingInt(Person::age).reversed();
people.sort(oldestFirst);
// Bob 30, Carol 30, Alice 25, Dave 25

// ── Reversed with multi-key: oldest first, then alphabetical name ──────
Comparator<Person> complex =
    Comparator.comparingInt(Person::age).reversed()
              .thenComparing(Person::name);
people.sort(complex);
// Bob 30, Carol 30, Alice 25, Dave 25  (30s before 25s, alphabetical within)

Factory Methods — The Complete Comparator Toolkit

Java 8 provided a comprehensive set of static factory methods on Comparator that cover the vast majority of comparison scenarios without requiring manual comparison code. Comparator.comparing(keyExtractor) extracts a Comparable key from each element and compares by natural ordering. The overload Comparator.comparing(keyExtractor, keyComparator) extracts a key and applies a specified Comparator for comparing keys — useful for comparing by a key that is not Comparable or that needs a non-natural ordering. The primitive-specialised variants avoid boxing: comparingInt(ToIntFunction), comparingLong(ToLongFunction), comparingDouble(ToDoubleFunction). These extract primitive keys directly into int, long, or double comparison without creating Integer, Long, or Double wrapper objects. For high-performance sorting of large collections with numeric keys, the primitive variants reduce allocation and boxing overhead. Comparator.naturalOrder() returns the natural ordering (equivalent to Comparable::compareTo). Comparator.reverseOrder() returns the reverse of the natural ordering. These are useful when you need to pass a comparator to a method that requires one but want natural or reverse-natural ordering — for example, new PriorityQueue<>(Comparator.reverseOrder()) for a max-heap. Comparator.nullsFirst(comparator) wraps a comparator so that null elements compare as less than non-null elements. Comparator.nullsLast(comparator) makes null compare as greater than non-null. Both return a Comparator that handles null arguments without throwing NullPointerException, delegating to the wrapped comparator for non-null pairs.
Java
// ── Primitive-specialised comparators — no boxing ────────────────────
record Product(String name, int stock, double price, long id) {}

// comparingInt — ToIntFunction, no Integer boxing:
Comparator<Product> byStock = Comparator.comparingInt(Product::stock);

// comparingDouble — ToDoubleFunction:
Comparator<Product> byPrice = Comparator.comparingDouble(Product::price);

// comparingLong — ToLongFunction:
Comparator<Product> byId = Comparator.comparingLong(Product::id);

// ── comparing with key comparator ─────────────────────────────────────
// Compare by name using case-insensitive ordering:
Comparator<Product> byCaseInsensitiveName =
    Comparator.comparing(Product::name, String.CASE_INSENSITIVE_ORDER);

// Compare by name length, then alphabetically:
Comparator<Product> byLengthThenAlpha =
    Comparator.comparing(Product::name,
        Comparator.comparingInt(String::length)
                  .thenComparing(Comparator.naturalOrder()));

// ── naturalOrder and reverseOrder ─────────────────────────────────────
Comparator<String> natural  = Comparator.naturalOrder();
Comparator<String> reversed = Comparator.reverseOrder();

// Max-heap priority queue using reverseOrder:
PriorityQueue<Integer> maxHeap =
    new PriorityQueue<>(Comparator.reverseOrder());
maxHeap.addAll(List.of(3, 1, 4, 1, 5, 9, 2, 6));
System.out.println(maxHeap.poll());   // 9 — maximum at head

// ── nullsFirst and nullsLast ───────────────────────────────────────────
List<String> withNulls = new ArrayList<>(
    Arrays.asList("banana", null, "apple", null, "cherry"));

withNulls.sort(Comparator.nullsFirst(Comparator.naturalOrder()));
System.out.println(withNulls);
// [null, null, apple, banana, cherry]

withNulls.sort(Comparator.nullsLast(Comparator.naturalOrder()));
System.out.println(withNulls);
// [apple, banana, cherry, null, null]

// nullsFirst with reversed inner comparator:
withNulls.sort(Comparator.nullsFirst(Comparator.reverseOrder()));
System.out.println(withNulls);
// [null, null, cherry, banana, apple]

// ── Complete example: sort employees ──────────────────────────────────
record Employee(String dept, String name, Integer salary) {}

List<Employee> employees = new ArrayList<>(List.of(
    new Employee("Eng",  "Alice", 90_000),
    new Employee("Eng",  "Bob",   null),    // null salary
    new Employee("HR",   "Carol", 75_000),
    new Employee("Eng",  "Dave",  85_000),
    new Employee(null,   "Eve",   80_000)   // null department
));

employees.sort(
    Comparator.comparing(Employee::dept,
                         Comparator.nullsLast(Comparator.naturalOrder()))
              .thenComparing(Employee::name)
              .thenComparing(Employee::salary,
                             Comparator.nullsLast(Comparator.naturalOrder())));

Comparator in Streams, Collections, and Maps

Comparator integrates with every sorting and ordering context in the Java platform. Stream.sorted(comparator) produces a stream of elements in the order defined by the comparator. Stream.min(comparator) and Stream.max(comparator) find the minimum and maximum elements. Stream.sorted() with no argument uses natural ordering. TreeSet and TreeMap accept a Comparator at construction that defines the ordering for all elements. A TreeSet<Person> with a by-age comparator stores and iterates Persons in age order. PriorityQueue accepts a Comparator to define what "highest priority" means — by default it is the natural minimum; with a reversed comparator it becomes the maximum. The comparing factory methods integrate cleanly with stream operations because key extractors are Function instances and many useful key extractors are method references. Comparator.comparing(String::toLowerCase) extracts the lowercase version as the comparison key without modifying the original string — the comparison is case-insensitive but the original strings are preserved. This separation of key extraction from key comparison is one of the most powerful aspects of the comparing factory. When a Comparator is used with TreeMap or TreeSet, it replaces equals() for determining key equality. Two keys that compare as 0 by the comparator are treated as the same key — one of them will be dropped (in TreeSet) or the existing entry updated (in TreeMap). This is the same comparator-consistency-with-equals concern discussed in TreeSet. Using a Comparator.comparing(Person::getName) for a TreeMap means two Persons with the same name are the same key, regardless of other fields.
Java
// ── Stream operations with Comparator ────────────────────────────────
record Student(String name, double gpa, int year) {}

List<Student> students = List.of(
    new Student("Alice", 3.9, 3),
    new Student("Bob",   3.5, 2),
    new Student("Carol", 3.9, 2),
    new Student("Dave",  3.7, 3));

// Sort stream by GPA descending, then name:
students.stream()
    .sorted(Comparator.comparingDouble(Student::gpa).reversed()
                      .thenComparing(Student::name))
    .forEach(s -> System.out.printf("%s %.1f%n", s.name(), s.gpa()));
// Alice 3.9, Carol 3.9, Dave 3.7, Bob 3.5

// Max GPA:
Optional<Student> topStudent = students.stream()
    .max(Comparator.comparingDouble(Student::gpa));
System.out.println(topStudent.map(Student::name).orElse("none"));  // Alice

// Min by year then name (youngest year first):
Optional<Student> first = students.stream()
    .min(Comparator.comparingInt(Student::year)
                   .thenComparing(Student::name));
System.out.println(first.map(Student::name).orElse("none"));  // Bob (year 2)

// ── TreeMap with custom key ordering ──────────────────────────────────
// String keys sorted by length then alphabetically (not default lexicographic)
TreeMap<String, Integer> wordCount = new TreeMap<>(
    Comparator.comparingInt(String::length)
              .thenComparing(Comparator.naturalOrder()));

wordCount.put("banana", 5);
wordCount.put("apple", 3);
wordCount.put("fig", 1);
wordCount.put("cherry", 7);

wordCount.forEach((word, count) ->
    System.out.println(word + " = " + count));
// fig=1, apple=3, banana=5, cherry=7 — sorted by length then alpha

// ── TreeSet with comparator — equality by comparator ──────────────────
// Persons considered same if same name (regardless of other fields)
TreeSet<Student> byName = new TreeSet<>(
    Comparator.comparing(Student::name));
byName.add(new Student("Alice", 3.9, 3));
byName.add(new Student("Alice", 3.5, 2));  // same name → not added (duplicate by comparator)
System.out.println(byName.size());  // 1 — Alice considered duplicate

// ── Combining Comparators for complex queries ─────────────────────────
// Sort by multiple criteria, some ascending some descending:
Comparator<Student> examOrder =
    Comparator.comparingInt(Student::year)           // ascending year
              .thenComparingDouble(Student::gpa)      // ascending GPA within year
              .reversed()                             // flip BOTH keys: desc year, desc GPA
              .thenComparing(Student::name);          // then ascending name (after reversal)

// Note: reversed() flips ALL previously chained comparisons
// If you want mixed asc/desc, chain them separately:
Comparator<Student> mixed =
    Comparator.comparingInt(Student::year)            // ascending year
              .thenComparing(
                  Comparator.comparingDouble(Student::gpa).reversed()) // desc GPA
              .thenComparing(Student::name);           // ascending name

Related Topics in Collections Framework

Collections Overview
The Java Collections Framework (JCF) is a unified architecture for representing and manipulating groups of objects. It provides a set of interfaces that define the operations a collection must support, a set of abstract classes that provide partial implementations, and a set of concrete implementations optimised for different use cases. Every Java developer uses collections daily — lists for sequences, sets for uniqueness, maps for key-value pairs, and queues for ordering — and choosing the right implementation for the right use case is one of the most fundamental practical skills in Java.
Iterable
Iterable<E> is the root interface of the Java Collections hierarchy. Any class that implements Iterable can be used in a for-each loop. It declares a single abstract method: iterator(), which returns an Iterator<E> that the for-each loop uses to traverse the elements. Implementing Iterable is all that is required to make a custom data structure work with Java's enhanced for loop, the Stream API, and any method that accepts an Iterable.
Collection Interface
Collection<E> is the root interface of the main collection hierarchy, extending Iterable<E>. It defines the common operations that all collection types must support: adding elements, removing elements, checking containment, querying size, clearing, converting to an array, and bulk operations. List, Set, and Queue all extend Collection. Map does not extend Collection because a map operates on key-value pairs rather than individual elements.
ArrayList
ArrayList is a resizable-array implementation of the List interface. It is the most commonly used collection in Java, providing dynamic sizing on top of a standard array. Elements are stored in contiguous memory, enabling O(1) random access by index. When the internal array is full, ArrayList automatically allocates a larger array and copies all elements. ArrayList is not thread-safe and preserves insertion order, allowing duplicates.