Back to Blog
Java

Java Custom Sorting with Comparator and Comparable

Learn how to implement custom sorting in Java with Comparable, Comparator, lambda expressions, and chained comparators for multi-level sort orders.

ComparatorComparableLambda ExpressionsCollectionsSorting
Illustration of Java custom sorting showing comparator chains organizing employee records by multiple fields.

When you call Collections.sort(list) or list.sort(null) on a list of objects, Java relies on the element type's natural ordering. For built-in types like Integer and String, that works fine. But for your own domain objects—an Employee, an Order, a Product—there is no default order unless you define one. Custom sorting in Java tells the runtime how one instance compares to another.

The java.util package gives you two mechanisms for this: the Comparable interface, which defines a type's natural ordering, and the Comparator interface, which lets you define one or more external orderings without touching the domain class.

Comparable: Embedding the Natural Order

Comparable<T> declares a single method, compareTo(T other), which returns a negative integer, zero, or a positive integer depending on whether this is less than, equal to, or greater than other. Implementing it gives the class a default ordering that Collections.sort, Arrays.sort, and sorted collections like TreeSet use automatically.

public class Employee implements Comparable<Employee> { private final String name; private final int salary; public Employee(String name, int salary) { this.name = name; this.salary = salary; } public String getName() { return name; } public int getSalary() { return salary; } @Override public int compareTo(Employee other) { return Integer.compare(this.salary, other.salary); } }

Using Integer.compare instead of subtraction (this.salary - other.salary) avoids integer overflow when values are near Integer.MAX_VALUE. The same rule applies to Long.compare and Double.compare for their respective types. A List<Employee> can now be sorted with a single call:

List<Employee> employees = getEmployees(); employees.sort(null); // uses natural ordering

The natural order is now salary ascending. If you later need a different order—by name, by hire date, by department—you have a problem. Comparable gives you exactly one ordering per class. That's where Comparator comes in.

Comparator: Externalizing Sort Logic

Comparator<T> is a functional interface with a single abstract method, compare(T a, T b), which returns the same kind of negative/zero/positive result as compareTo. Because the logic lives outside the domain class, you can define as many orderings as you need without modifying the class.

Comparator<Employee> byName = new Comparator<Employee>() { @Override public int compare(Employee a, Employee b) { return a.getName().compareTo(b.getName()); } }; employees.sort(byName);

The anonymous class form is verbose, but it makes the contract explicit. In practice, you rarely write it that way. Since Comparator is a functional interface, lambda expressions and method references give you the same behavior with less ceremony.

Lambda Expressions and Method References

The lambda form reduces the comparator to a single expression:

employees.sort((a, b) -> a.getName().compareTo(b.getName()));

For ascending order on a field whose type already implements Comparable, Comparator.comparing is even more direct:

employees.sort(Comparator.comparing(Employee::getName));

Comparator.comparing accepts a key extractor function and builds a comparator that extracts a key from each element and compares the keys using their natural ordering. For numeric fields, use the specialized factories to avoid boxing overhead. If the employee class has matching getters, these compile to primitive-based comparators:

employees.sort(Comparator.comparingInt(Employee::getSalary)); employees.sort(Comparator.comparingLong(Employee::getHireDateEpoch)); employees.sort(Comparator.comparingDouble(Employee::getRating));

These variants (comparingInt, comparingLong, comparingDouble) work on primitive fields directly and avoid the allocation cost of boxing each key into a wrapper object. On large collections, that difference can be measurable.

Chaining Comparators for Multi-Level Sorting

A single field is rarely enough. You often want to sort by salary first, then by name within the same salary group. Comparator.thenComparing composes comparators so that the second one is only consulted when the first returns zero.

Comparator<Employee> bySalaryThenName = Comparator.comparingInt(Employee::getSalary) .thenComparing(Employee::getName); employees.sort(bySalaryThenName);

The chain can be arbitrarily long:

employees.sort( Comparator.comparing(Employee::getDepartment) .thenComparingInt(Employee::getSalary) .thenComparing(Employee::getName) );

Each thenComparing call returns a new comparator that delegates to the previous one and falls through to the next only on ties. This is the idiomatic way to build multi-key sort orders in modern Java, and it reads almost like a declarative specification of the ordering.

Handling Null Values in Custom Sorting

Null elements or null fields are a common source of NullPointerException during sorting. A comparator like Comparator.comparing(Employee::getName) will throw as soon as it encounters an employee whose name is null, because the key extractor returns null and the natural-order comparison on null fails.

Comparator provides two static helpers for this: nullsFirst and nullsLast. Both wrap an existing comparator and define where null keys should appear.

Comparator<Employee> byNameNullsLast = Comparator.comparing(Employee::getName, Comparator.nullsLast(Comparator.naturalOrder()));

Here the second argument to comparing is a comparator for the extracted keys themselves. nullsLast treats a null key as greater than any non-null key, so employees with null names sort to the end. nullsFirst does the reverse.

If the list itself can contain null elements, wrap the whole comparator:

Comparator<Employee> nullSafe = Comparator.nullsLast(byNameNullsLast); employees.sort(nullSafe);

This handles both null elements and null fields in one expression. Without these helpers, you would need to write manual null checks inside every comparator, which quickly becomes repetitive and error-prone across multiple sort sites.

Performance and Stability Considerations

List.sort and Collections.sort use a stable, adaptive merge sort for object arrays, so equal elements retain their original relative order. Stability matters when you chain comparators: if two elements compare equal under the primary key, their relative order from the original list is preserved. That is why thenComparing works correctly—the secondary comparator only sees elements that were tied, and stability keeps the pre-existing order intact for elements that are equal under every comparator in the chain.

The cost of a custom comparator depends on how expensive the key extraction is. If the key extractor performs a costly computation—parsing a string, dereferencing a nested object graph, or calling a remote service—that cost is paid for every comparison, roughly O(n log n) times. For repeatedly sorted collections, consider precomputing the sort key once per element and sorting on the extracted key instead.

The primitive-specialized factories (comparingInt, comparingLong, comparingDouble) avoid boxing allocation during comparison. For large collections, this can reduce garbage collection pressure; for small lists, the difference is usually negligible, and readability should win.

Common Pitfalls and Edge Cases

Subtraction-based comparators are the most common bug. return a.getSalary() - b.getSalary() can overflow silently when the values are far apart, producing a wrong ordering. Always use Integer.compare, Long.compare, or the Comparator.comparingInt factory.

Another pitfall is mutating the sort key after the element is placed in a sorted collection. A TreeSet or TreeMap that uses a comparator does not re-sort when a field changes. If an employee's salary changes after insertion, the tree's internal ordering is stale, and subsequent lookups and iterations may behave incorrectly. The safe pattern is to remove the element, mutate it, and reinsert it.

Finally, keep Comparable and Comparator consistent with equals. If a comparator reports two distinct objects as equal, sorted collections that use that ordering—such as TreeSet—will treat them as duplicates and may drop one. The Java API strongly recommends that orderings be consistent with equals, especially when the collection is expected to satisfy the Set or Map contract.

Java Custom Sorting: Comparable and Comparator Examples | RYUSLOG DEV