Back to Blog
Java

Java Comparator: Custom Sorting with Lambda Expressions

java comparator: Learn how to use the Java Comparator interface for custom sorting, including lambda syntax, chaining, null handling, and performance tradeoffs.

ComparatorSortingLambda ExpressionsJava CollectionsMethod References
Illustration of Java Comparator sorting objects with a lambda expression and chained comparators

java comparator requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.

When you need to sort a collection by a property that is not the natural order defined in the class, the java.util.Comparator interface lets you define that ordering externally. It is the standard tool for custom sorting in Java, and with lambda expressions, implementing a comparator is often a single line of code.

The Comparator Interface and Its Contract

Comparator<T> declares a single abstract method int compare(T o1, T o2). The method returns a negative integer, zero, or a positive integer if the first argument is less than, equal to, or greater than the second. This contract is identical to Comparable.compareTo, but it lives outside the class being compared.

Comparator<Person> byAge = new Comparator<Person>() { @Override public int compare(Person p1, Person p2) { return Integer.compare(p1.getAge(), p2.getAge()); } };

Using Integer.compare avoids the overflow risk of subtracting two int values. This is a common mistake when writing comparators manually. The same principle applies to Long.compare and Double.compare.

The comparator must be consistent with equals if you intend to use it in sorted sets or sorted maps. That is, compare(a, b) == 0 should imply a.equals(b) is true. Violating this rule causes unpredictable behavior in TreeSet and TreeMap, which use the comparator for element identity.

Lambda Expressions and Method References

Because Comparator is a functional interface, you can replace the anonymous class with a lambda:

Comparator<Person> byAge = (p1, p2) -> Integer.compare(p1.getAge(), p2.getAge());

Even more concise, the Comparator.comparing factory method extracts a sort key and builds a comparator for you:

Comparator<Person> byAge = Comparator.comparing(Person::getAge);

comparing takes a Function that maps the object to a Comparable key. The method reference Person::getAge is cleaner and less error-prone than writing the comparison logic yourself. For keys that are not naturally comparable, provide a separate comparator as the second argument.

Chaining Comparators with thenComparing

Real-world sorting often involves multiple criteria. For example, sort people by last name, then first name. The thenComparing method creates a lexicographic order:

Comparator<Person> byName = Comparator .comparing(Person::getLastName) .thenComparing(Person::getFirstName);

Each subsequent comparator is used only when the previous one returns zero. You can chain as many as needed, and each step can be a lambda or a method reference. This approach is far more readable than nested if blocks.

Handling Null Values

By default, most comparators throw NullPointerException when encountering a null element. The Comparator interface provides two adapters to handle nulls explicitly:

  • nullsFirst(comparator) places null elements before non-null ones.
  • nullsLast(comparator) places null elements after non-null ones.
Comparator<Person> byAgeWithNulls = Comparator.nullsLast(Comparator.comparing(Person::getAge));

When the comparator itself is null (meaning natural ordering is used), these methods still work. For example, Comparator.nullsFirst(null) returns a comparator that places nulls first and uses natural ordering for non-null elements. This is useful when sorting a list that may contain nulls and you want to avoid a custom null-check branch.

Performance and Runtime Considerations

Comparator instances are small and inexpensive to create, but they do add a layer of indirection during sorting. The Collections.sort and List.sort methods copy the list to an array, sort it using a modified mergesort, and copy back. The comparator's compare method is called O(n log n) times, so any overhead inside compare is multiplied.

Avoid complex logic inside compare that recomputes expensive values. If the sort key is costly to derive, consider precomputing it into a map or a temporary field. For example, sorting strings by length repeatedly calls String.length(), which is cheap, but sorting by a database-derived value would be expensive.

Reusing comparator instances is a minor optimization but can reduce allocation pressure in high-throughput scenarios. Since comparators are stateless, they are safe to share across threads as long as the underlying sort keys are thread-safe.

Common Pitfalls and Edge Cases

One frequent mistake is returning the result of subtraction without guarding against overflow:

// Dangerous: can overflow for large int values Comparator<Integer> bad = (a, b) -> a - b;

Use Integer.compare or Comparator.comparingInt instead. Another issue is ignoring the consistency with equals. If you sort objects by a mutable field, the comparator's result can change after the object is inserted into a sorted collection, breaking the collection's invariants.

Also, be aware that the Comparator contract requires transitivity: if compare(a, b) > 0 and compare(b, c) > 0, then compare(a, c) > 0. Violating this can cause the sort algorithm to produce inconsistent results, especially with TimSort, which detects such violations and throws IllegalArgumentException in some cases.

Choosing Between Comparable and Comparator

Comparable defines the natural ordering inside the class itself, while Comparator defines an external ordering. Use Comparable when there is a single, obvious way to sort instances of that class, such as String or Integer. Use Comparator when you need multiple sort orders, when you cannot modify the class, or when the ordering depends on context.

For example, a Person class might implement Comparable to sort by ID, but you still need a separate Comparator to sort by name or age. The two approaches are not mutually exclusive; you can have both a natural order and custom comparators.

A practical pattern is to define static comparator fields on the class itself:

public class Person { private final String name; private final int age; public static final Comparator<Person> BY_AGE = Comparator.comparingInt(Person::getAge); public static final Comparator<Person> BY_NAME = Comparator.comparing(Person::getName); }

This centralizes the comparators and makes them easy to reuse across the codebase. It also keeps the sorting logic close to the data it operates on, improving maintainability.

java comparator: Practical Usage and Code Examples | RYUSLOG DEV