Back to Blog
Java

Java Stream Sorted: Ordering Streams with Comparators

Understand how to use Java's sorted() stream operation for natural and custom ordering, including comparators, null handling, and performance tradeoffs.

Java StreamsComparatorSortingFunctional ProgrammingCollections
Illustration of Java stream sorted operation ordering elements with a comparator

When you need to order stream elements, sorted() is the intermediate operation to use. It returns a stream whose elements are ordered according to natural ordering or a provided comparator. Like other intermediate operations, it is lazy: no sorting happens until a terminal operation such as collect(), forEach(), or toList() triggers the pipeline.

List<String> names = List.of("carol", "alice", "bob"); List<String> sorted = names.stream() .sorted() .collect(Collectors.toList()); // [alice, bob, carol]

The no-argument variant requires that the element type implements Comparable. For String, Integer, Long, and other standard types, natural ordering is well-defined. If the element type does not implement Comparable, the pipeline throws a ClassCastException at terminal operation time, not when sorted() is called.

Sorting with a Custom Comparator

When natural ordering is not what you need, pass a Comparator to sorted(Comparator). This covers reverse order, case-insensitive string comparison, and ordering by a property of the element.

List<String> names = List.of("carol", "Alice", "bob"); List<String> byNatural = names.stream().sorted().collect(Collectors.toList()); // [Alice, bob, carol] — uppercase sorts before lowercase List<String> caseInsensitive = names.stream() .sorted(String.CASE_INSENSITIVE_ORDER) .collect(Collectors.toList()); // [Alice, bob, carol]

For reversing natural order, Comparator.reverseOrder() is clearer than writing a lambda that negates the comparison result. For reversing an existing comparator, Comparator.reversed() works on any comparator instance.

Chaining Comparators for Multi-Field Sorting

Real data rarely sorts by a single field. Comparator.comparing() and thenComparing() let you build a comparator that orders by one field and breaks ties with subsequent fields.

The next example uses a Java record, which is available from Java 16, to keep the data simple:

record Employee(String name, String department, int salary) {} List<Employee> employees = List.of( new Employee("alice", "eng", 120000), new Employee("bob", "eng", 95000), new Employee("carol", "sales", 110000) ); List<Employee> sorted = employees.stream() .sorted(Comparator.comparing(Employee::department) .thenComparing(Employee::salary)) .collect(Collectors.toList());

The resulting stream orders employees by department first, then by salary within each department. thenComparing can be chained further for a third or fourth key. Each key can use its own comparator, so a mix of ascending and descending fields is possible:

Comparator<Employee> byDeptThenSalaryDesc = Comparator.comparing(Employee::department) .thenComparing(Comparator.comparingInt(Employee::salary).reversed());

Null Handling in sorted()

By default, sorted() throws NullPointerException if any element is null, because compareTo cannot be invoked on a null reference. When a stream may contain nulls, use Comparator.nullsFirst() or Comparator.nullsLast() to define where nulls belong.

List<String> values = Arrays.asList("b", null, "a"); List<String> nullsLast = values.stream() .sorted(Comparator.nullsLast(Comparator.naturalOrder())) .collect(Collectors.toList()); // [a, b, null]

nullsFirst places nulls at the beginning; nullsLast places them at the end. Both wrap an existing comparator, so they compose with comparing() and thenComparing() chains. Without one of these wrappers, a null element aborts the terminal operation with an exception.

Performance and Memory Behavior

sorted() is a stateful intermediate operation. Unlike filter() or map(), it must buffer the entire stream contents before producing any output. For a stream backed by an in-memory collection, the operation typically materializes the elements into an array and sorts them.

The practical consequence is that sorted() has O(n log n) time complexity and O(n) additional memory. For large streams, especially those backed by external data such as a database cursor or a file, buffering the whole stream can be expensive. If the data source can already return ordered rows—for example, a SQL query with ORDER BY—applying sorted() in the stream duplicates that work and adds memory pressure.

Parallel streams change the sorting behavior. When a parallel stream reaches sorted(), the framework splits the work into chunks that can be sorted concurrently and then merges the results. This can improve throughput on multi-core machines for large collections, but the merge step still requires buffering the full result. For small collections, the overhead of splitting and merging usually outweighs any parallelism benefit.

Common Pitfalls with sorted()

One recurring mistake is expecting sorted() to mutate the original collection. Streams do not modify their source. The sorted result is a new stream; the original list remains in its original order. If the goal is to reorder a List in place, use Collections.sort() or List.sort() instead of a stream pipeline.

Another pitfall is sorting a stream of Optional values. Optional does not implement Comparable, so sorted() without a comparator throws ClassCastException. The same applies to custom classes that do not implement Comparable. When the element type is not comparable, always supply a comparator.

A third issue is a comparator that is inconsistent with equals(). The Comparator contract recommends that compare(a, b) == 0 have the same result as a.equals(b). When this does not hold, sorted collections such as TreeSet and TreeMap can behave unexpectedly. Stream.distinct() uses equals(), not the comparator, so it will not remove elements merely because a comparator considers them equal. For example, a comparator that only compares one field of a multi-field record will return 0 for two records with the same field value, even when the records differ in other fields.

Choosing Between Stream Sorting and List Sorting

The decision between stream().sorted() and List.sort() depends on whether the pipeline continues after sorting. If the only goal is an ordered list, List.sort() with a comparator is more direct and avoids the overhead of building a stream and collecting the result. List.sort() mutates the list in place, so it needs a mutable list; copy a List.of() result into an ArrayList if necessary:

List<Employee> employees = new ArrayList<>(List.of( new Employee("alice", "eng", 120000), new Employee("bob", "eng", 95000) )); employees.sort(Comparator.comparing(Employee::department));

If the pipeline continues with map(), filter(), or a grouping operation, stream().sorted() fits naturally into the chain and avoids an intermediate mutable list. The two approaches produce the same ordering; the choice is about pipeline composition and code clarity rather than a difference in sorting algorithm.

For primitive streams, IntStream, LongStream, and DoubleStream each provide a no-argument sorted(). These variants do not accept a comparator because primitive types have fixed natural ordering. Converting to a boxed stream to apply a custom comparator is rarely useful, since primitives have no properties to compare beyond their values.

Java Stream sorted(): Comparators, Null Handling, and Performance | RYUSLOG DEV