Java Collectors Class: Methods with Examples
Try to predict the output of this snippet:
Map<Integer, List<Integer>> byTens = Stream.of(30, 10, 20, 10, 50)
.collect(Collectors.groupingBy(n -> n / 10));
System.out.println(byTens); // {1=[10, 10], 2=[20], 3=[30], 5=[50]} The very first element produced the key 3, yet 3 comes third in the printout. The reason is that groupingBy() without an explicit factory collects into a HashMap, whose iteration order follows key hashes, not the order elements arrived in. If you need a predictable order, use the three-argument form: groupingBy(classifier, TreeMap::new, downstream) or LinkedHashMap::new.
java.util.stream.Collectors is a utility class with ready-made implementations of the Collector interface that are passed to the terminal method Stream.collect(). A collector describes how stream elements are accumulated into a final object: a collection, a Map, a string, a number or any structure you like. What collect() itself is, and how the basic Collectors.toList(), toMap() and groupingBy() work, is covered in the lesson Stream API Methods. This page starts where that one stops: downstream collectors, Map factories, one-pass statistics and writing your own collector.
All examples below use the same data set:
record Employee(String name, String department, int salary) { }
List<Employee> staff = List.of(
new Employee("Anna", "IT", 180_000),
new Employee("Ben", "IT", 150_000),
new Employee("Clara", "HR", 120_000),
new Employee("Dave", "Sales", 140_000),
new Employee("Emma", "Sales", 160_000)); 1. Collecting into collections: toSet, toCollection, unmodifiable
Collectors.toSet() gathers elements into a set, dropping duplicates by equals() on the way. The exact class is not part of the contract — today it happens to be a HashSet, so neither the iteration order nor the mutability of the result is guaranteed.
Set<Integer> lengths = Stream.of("sun", "sea", "sand", "surf")
.map(String::length)
.collect(Collectors.toSet());
System.out.println(lengths); // [3, 4] - four words, only two distinct lengths When you need a specific collection type, reach for toCollection(Supplier): it takes a constructor reference and puts the elements exactly there.
// TreeSet - unique values, already sorted
TreeSet<String> sorted = Stream.of("sun", "sea", "sand", "sea")
.collect(Collectors.toCollection(TreeSet::new));
System.out.println(sorted); // [sand, sea, sun]
// LinkedList - when you need fast insertions at both ends
LinkedList<String> queue = Stream.of("sun", "sea", "sand")
.collect(Collectors.toCollection(LinkedList::new));
queue.addFirst("surf");
System.out.println(queue); // [surf, sun, sea, sand]
// ArrayList - when the list must be guaranteed mutable
List<String> mutable = Stream.of("sun", "sea")
.collect(Collectors.toCollection(ArrayList::new));
mutable.add("sand"); // always works Unmodifiable collectors (Java 10+)
toUnmodifiableList(), toUnmodifiableSet() and toUnmodifiableMap() return unmodifiable collections: any attempt to change them throws UnsupportedOperationException.
List<String> names = staff.stream()
.map(Employee::name)
.collect(Collectors.toUnmodifiableList());
names.add("Frank"); // java.lang.UnsupportedOperationException These collectors have one more property that is easy to overlook: they reject null. A single null element makes the collection phase throw NullPointerException, while the ordinary toList() stores it without complaining.
Stream.of("sun", null).collect(Collectors.toList()); // [sun, null] - fine
Stream.of("sun", null).collect(Collectors.toUnmodifiableList()); // NullPointerException
Stream.of("sun", null).toList(); // [sun, null] - Java 16+, null allowed Important
The javadoc for Collectors.toList() guarantees neither the type nor the mutability of the result — it is an ArrayList today, but that is an implementation detail, not a promise. If the list definitely has to be edited, write toCollection(ArrayList::new); if you want the opposite, use toUnmodifiableList() or the short stream().toList() from Java 16.
2. toMap(): merge function and your own Map implementation
The two-argument toMap(keyMapper, valueMapper) works beautifully right up to the first duplicate key. Our data set has two people in the IT department, so this code blows up:
Map<String, Employee> byDept = staff.stream()
.collect(Collectors.toMap(Employee::department, e -> e));
// java.lang.IllegalStateException: Duplicate key IT
// (attempted merging values Employee[name=Anna, ...] and Employee[name=Ben, ...]) The third argument is a merge function. It receives the old and the new value and decides which one stays in the map.
// keep whoever earns more
Map<String, Employee> topByDept = staff.stream()
.collect(Collectors.toMap(
Employee::department,
e -> e,
(oldValue, newValue) -> oldValue.salary() >= newValue.salary() ? oldValue : newValue));
// add the salaries up per department
Map<String, Integer> payroll = staff.stream()
.collect(Collectors.toMap(
Employee::department,
Employee::salary,
Integer::sum));
// IT=330000, HR=120000, Sales=300000 (HashMap decides the key order)
// keep the first value seen
(oldValue, newValue) -> oldValue
// overwrite with the last one
(oldValue, newValue) -> newValue The fourth argument is a Map factory. It pins down the implementation: TreeMap::new sorts by key, LinkedHashMap::new preserves encounter order, and EnumMap is the right choice for enum keys.
TreeMap<String, Integer> sortedPayroll = staff.stream()
.collect(Collectors.toMap(
Employee::department,
Employee::salary,
Integer::sum,
TreeMap::new));
System.out.println(sortedPayroll); // {HR=120000, IT=330000, Sales=300000}
System.out.println(sortedPayroll.firstKey()); // HR 3. joining(): turning a stream into a string
joining() only accepts a stream of CharSequence, so objects have to be converted to strings with map() first. Internally the collector uses a StringBuilder, which means the concatenation does not create the pile of intermediate strings a naive reduce("", String::concat) would.
List<String> names = staff.stream().map(Employee::name).toList();
// no arguments - everything glued together
names.stream().collect(Collectors.joining());
// AnnaBenClaraDaveEmma
// with a delimiter
names.stream().collect(Collectors.joining(", "));
// Anna, Ben, Clara, Dave, Emma
// with a delimiter, a prefix and a suffix
names.stream().collect(Collectors.joining(", ", "[", "]"));
// [Anna, Ben, Clara, Dave, Emma]
// a ready-made SQL fragment in one statement
staff.stream()
.map(Employee::department)
.distinct()
.map(d -> "'" + d + "'")
.collect(Collectors.joining(", ", "WHERE department IN (", ")"));
// WHERE department IN ('IT', 'HR', 'Sales') On an empty stream joining(", ", "[", "]") returns [] — the prefix and the suffix are always added, even when there is nothing to glue.
4. groupingBy() with a downstream collector and a Map factory
groupingBy() has three overloads, and all of its power sits in the last two. The second (or third) argument is a downstream collector — a collector nested inside another one, which processes the elements that already landed in a given group.
// 1 argument: group values are lists of objects
Map<String, List<Employee>> byDept = staff.stream()
.collect(Collectors.groupingBy(Employee::department));
// IT=[Anna, Ben], HR=[Clara], Sales=[Dave, Emma] (HashMap decides the key order)
// 2 arguments: how many people are in each department
Map<String, Long> headcount = staff.stream()
.collect(Collectors.groupingBy(Employee::department, Collectors.counting()));
// IT=2, HR=1, Sales=2
// 2 arguments: payroll per department
Map<String, Integer> payroll = staff.stream()
.collect(Collectors.groupingBy(Employee::department,
Collectors.summingInt(Employee::salary)));
// IT=330000, HR=120000, Sales=300000
// 2 arguments: names only, not whole objects
Map<String, List<String>> namesByDept = staff.stream()
.collect(Collectors.groupingBy(Employee::department,
Collectors.mapping(Employee::name, Collectors.toList())));
// IT=[Anna, Ben], HR=[Clara], Sales=[Dave, Emma]
// 2 arguments: distinct salaries inside each group
Map<String, Set<Integer>> salariesByDept = staff.stream()
.collect(Collectors.groupingBy(Employee::department,
Collectors.mapping(Employee::salary, Collectors.toSet()))); The third overload inserts a Map factory between the classifier and the downstream collector. That is exactly what fixes the unpredictable key order from the opening example.
// TreeMap - keys sorted
TreeMap<String, Long> sortedHeadcount = staff.stream()
.collect(Collectors.groupingBy(Employee::department,
TreeMap::new,
Collectors.counting()));
System.out.println(sortedHeadcount); // {HR=1, IT=2, Sales=2}
// LinkedHashMap - keys in order of first appearance in the stream
Map<String, Long> insertionOrder = staff.stream()
.collect(Collectors.groupingBy(Employee::department,
LinkedHashMap::new,
Collectors.counting()));
System.out.println(insertionOrder); // {IT=2, HR=1, Sales=2} The lists inside the groups always keep the source order — only the keys get shuffled.
Groupings nest: the downstream collector can be another groupingBy().
Map<String, Map<Boolean, List<String>>> nested = staff.stream()
.collect(Collectors.groupingBy(Employee::department, TreeMap::new,
Collectors.groupingBy(e -> e.salary() >= 150_000,
Collectors.mapping(Employee::name, Collectors.toList()))));
System.out.println(nested);
// {HR={false=[Clara]}, IT={true=[Anna, Ben]}, Sales={false=[Dave], true=[Emma]}} 5. partitioningBy(): splitting into exactly two groups
partitioningBy(Predicate) is a special case of grouping, driven by a boolean condition. The result is a Map<Boolean, List<T>> with exactly two keys: false and true.
Map<Boolean, List<String>> byHighSalary = staff.stream()
.collect(Collectors.partitioningBy(
e -> e.salary() >= 150_000,
Collectors.mapping(Employee::name, Collectors.toList())));
System.out.println(byHighSalary); // {false=[Clara, Dave], true=[Anna, Ben, Emma]} The crucial difference from groupingBy(): both keys are always present, even when one half is empty. Compare the two calls on a stream that contains no even numbers at all:
List<Integer> odd = List.of(1, 3, 5);
Map<Boolean, List<Integer>> partitioned = odd.stream()
.collect(Collectors.partitioningBy(n -> n % 2 == 0));
System.out.println(partitioned); // {false=[1, 3, 5], true=[]}
System.out.println(partitioned.get(true).size()); // 0
Map<Boolean, List<Integer>> grouped = odd.stream()
.collect(Collectors.groupingBy(n -> n % 2 == 0));
System.out.println(grouped); // {false=[1, 3, 5]} - no true key at all
System.out.println(grouped.get(true).size()); // NullPointerException So whenever the condition is binary, partitioningBy() is the safer tool: no null checks and no getOrDefault() calls downstream. It takes a downstream collector too, and then the empty half gets that collector's "zero" value:
Map<Boolean, Long> counts = odd.stream()
.collect(Collectors.partitioningBy(n -> n % 2 == 0, Collectors.counting()));
System.out.println(counts); // {false=3, true=0} 6. Counting and statistics: counting, summing, averaging, summarizing
These collectors are rarely used on their own — a stream already has count(), sum() and average() for the same job. Their real home is the downstream position inside groupingBy() or partitioningBy(), where stream methods are simply not available.
long headcount = staff.stream().collect(Collectors.counting()); // 5 (a Long!)
int total = staff.stream().collect(Collectors.summingInt(Employee::salary)); // 750000
double average = staff.stream().collect(Collectors.averagingInt(Employee::salary)); // 150000.0 Each numeric type has its own trio: summingInt/summingLong/summingDouble and averagingInt/averagingLong/averagingDouble. Note that all three averaging* variants return a Double, and on an empty stream they return 0.0 rather than an empty Optional — from the result alone you cannot tell "there was no data" from "the average is zero".
When you want the minimum, maximum, sum, average and count at once, use summarizingInt/summarizingLong/summarizingDouble. One pass over the stream, one object holding every figure.
IntSummaryStatistics stats = staff.stream()
.collect(Collectors.summarizingInt(Employee::salary));
System.out.println(stats.getCount()); // 5
System.out.println(stats.getSum()); // 750000 (a long)
System.out.println(stats.getMin()); // 120000
System.out.println(stats.getMax()); // 180000
System.out.println(stats.getAverage()); // 150000.0
System.out.println(stats);
// IntSummaryStatistics{count=5, sum=750000, min=120000, average=150000.000000, max=180000} The same collector shines as a downstream one, giving you statistics per department:
Map<String, IntSummaryStatistics> statsByDept = staff.stream()
.collect(Collectors.groupingBy(Employee::department, TreeMap::new,
Collectors.summarizingInt(Employee::salary)));
statsByDept.forEach((dept, s) ->
System.out.println(dept + ": max=" + s.getMax() + ", avg=" + s.getAverage()));
// HR: max=120000, avg=120000.0
// IT: max=180000, avg=165000.0
// Sales: max=160000, avg=150000.0 7. minBy, maxBy, mapping, reducing, collectingAndThen
These are adapter collectors: on their own they duplicate Stream methods, but inside a grouping they become indispensable.
minBy() and maxBy()
The counterparts of Stream.min() and Stream.max(). They return an Optional, because a stream or a group could in principle be empty.
Optional<Employee> topPaid = staff.stream()
.collect(Collectors.maxBy(Comparator.comparingInt(Employee::salary)));
System.out.println(topPaid.map(Employee::name).orElse("no data")); // Anna
// the best paid employee of every department
Map<String, Optional<Employee>> topByDept = staff.stream()
.collect(Collectors.groupingBy(Employee::department, TreeMap::new,
Collectors.maxBy(Comparator.comparingInt(Employee::salary))));
// {HR=Optional[Employee[name=Clara, ...]], IT=Optional[...Anna...], Sales=Optional[...Emma...]} collectingAndThen(): getting rid of the Optional
An Optional sitting in map values is awkward, and inside groupingBy() a group can never be empty by definition: a key appears only when something landed in it. collectingAndThen() applies a final transformation to the result of another collector:
Map<String, String> topNames = staff.stream()
.collect(Collectors.groupingBy(Employee::department, TreeMap::new,
Collectors.collectingAndThen(
Collectors.maxBy(Comparator.comparingInt(Employee::salary)),
opt -> opt.map(Employee::name).orElseThrow())));
System.out.println(topNames); // {HR=Clara, IT=Anna, Sales=Emma}
// the other common use - freeze the collected list
List<String> frozen = staff.stream()
.map(Employee::name)
.collect(Collectors.collectingAndThen(Collectors.toList(), List::copyOf)); mapping(): transform elements before collecting them
mapping(mapper, downstream) applies a function to every element and hands the result to the nested collector. It is effectively map() moved inside the group.
Map<String, String> namesByDept = staff.stream()
.collect(Collectors.groupingBy(Employee::department, TreeMap::new,
Collectors.mapping(Employee::name, Collectors.joining(", "))));
System.out.println(namesByDept.get("IT")); // Anna, Ben Two neighbours arrived in Java 9: filtering(predicate, downstream) discards elements once they are already inside a group, and flatMapping(mapper, downstream) flattens nested streams. The difference between filtering before and filtering inside the grouping is immediately visible:
// filter() before grouping: the HR department disappears from the result entirely
staff.stream()
.filter(e -> e.salary() >= 150_000)
.collect(Collectors.groupingBy(Employee::department, TreeMap::new, Collectors.counting()));
// {IT=2, Sales=1}
// filtering() inside the grouping: HR stays, with a count of zero
staff.stream()
.collect(Collectors.groupingBy(Employee::department, TreeMap::new,
Collectors.filtering(e -> e.salary() >= 150_000, Collectors.counting())));
// {HR=0, IT=2, Sales=1} reducing(): reduce() in collector form
reducing() comes in three forms that mirror the three forms of Stream.reduce().
// 1 argument: an operator only - the result is an Optional
Optional<Integer> product = Stream.of(1, 2, 3, 4)
.collect(Collectors.reducing((a, b) -> a * b)); // Optional[24]
// 2 arguments: identity value plus operator
int sum = Stream.of(1, 2, 3, 4)
.collect(Collectors.reducing(0, Integer::sum)); // 10
// 3 arguments: identity value, extractor function, operator
Map<String, Integer> payroll = staff.stream()
.collect(Collectors.groupingBy(Employee::department, TreeMap::new,
Collectors.reducing(0, Employee::salary, Integer::sum)));
System.out.println(payroll); // {HR=120000, IT=330000, Sales=300000} That last example is equivalent to summingInt(Employee::salary) and reads worse. The rule of thumb is simple: if a specialised collector already exists, use it, and save reducing() for unusual folds such as a product or a bitwise OR.
8. teeing(): two collectors in a single pass
Collectors.teeing() arrived in Java 12 and solves a problem that used to require two streams: computing two different aggregates over the same data. It takes two collectors and a merger function that receives both results.
record Payroll(long headcount, int total) { }
Payroll payroll = staff.stream().collect(Collectors.teeing(
Collectors.counting(), // first result
Collectors.summingInt(Employee::salary), // second result
(count, sum) -> new Payroll(count, sum))); // how to merge them
System.out.println(payroll); // Payroll[headcount=5, total=750000] The stream is traversed exactly once, which matters a great deal when the source is one-shot — a file, a network response, a query result — and a second pass is physically impossible.
// lowest and highest salary in one pass
String range = staff.stream().collect(Collectors.teeing(
Collectors.minBy(Comparator.comparingInt(Employee::salary)),
Collectors.maxBy(Comparator.comparingInt(Employee::salary)),
(min, max) -> min.get().salary() + " - " + max.get().salary()));
System.out.println(range); // 120000 - 180000
// teeing can be nested into groupingBy
Map<String, Payroll> byDept = staff.stream()
.collect(Collectors.groupingBy(Employee::department, TreeMap::new,
Collectors.teeing(
Collectors.counting(),
Collectors.summingInt(Employee::salary),
Payroll::new)));
System.out.println(byDept);
// {HR=Payroll[headcount=1, total=120000], IT=Payroll[headcount=2, total=330000],
// Sales=Payroll[headcount=2, total=300000]} 9. Writing a custom collector with Collector.of()
When no ready-made collector fits, you do not have to implement the Collector interface in a separate class — the static factory Collector.of() is enough. It takes four functions:
- supplier — creates an empty accumulation container;
- accumulator — adds the next stream element to the container;
- combiner — merges two containers (needed by parallel streams only);
- finisher — converts the container into the final result.
A collector that multiplies numbers:
Collector<Integer, long[], Long> multiplying = Collector.of(
() -> new long[]{1L}, // supplier: an array as a mutable cell
(acc, n) -> acc[0] *= n, // accumulator
(a, b) -> { a[0] *= b[0]; return a; }, // combiner
acc -> acc[0]); // finisher
long result = Stream.of(1, 2, 3, 4, 5).collect(multiplying);
System.out.println(result); // 120 Pay attention to the type parameters: Collector<T, A, R>, where T is the stream element type, A the intermediate container type and R the result type. The container has to be mutable, which is why the example uses a one-element array instead of a Long.
If the container and the result have the same type, no finisher is needed — there is a three-function overload:
Collector<String, StringJoiner, StringJoiner> toJoiner = Collector.of(
() -> new StringJoiner(" | ", "<", ">"),
StringJoiner::add,
StringJoiner::merge);
StringJoiner joiner = staff.stream().map(Employee::name).collect(toJoiner);
System.out.println(joiner); // <Anna | Ben | Clara | Dave | Emma> For a one-off accumulation a custom Collector is often overkill — collect() has a three-argument form that takes the same supplier, accumulator and combiner directly:
List<String> list = Stream.of("sun", "sea", "sand")
.collect(ArrayList::new, ArrayList::add, ArrayList::addAll); Worth knowing
In a sequential stream the combiner is never called. A bug hiding in it can stay invisible for years and surface the day somebody adds .parallel(). Test your own collector against a parallel stream as well: list.parallelStream().collect(myCollector).
10. Collectors methods: summary table
Full signatures and collector characteristics can always be checked in the official javadoc for the Collectors class.
| Method | What it does | Example |
|---|---|---|
toList() | Collects into a List; neither the type nor the mutability is guaranteed | collect(toList()) |
toSet() | Collects into a Set, removing duplicates by equals() | collect(toSet()) |
toCollection() | Collects into the collection implementation you name | toCollection(TreeSet::new) |
toUnmodifiableList(), toUnmodifiableSet(), toUnmodifiableMap() (Java 10+) | Unmodifiable collections; null elements are rejected | collect(toUnmodifiableList()) |
toMap() | Builds a Map; 3rd argument is the merge function, 4th is the Map factory | toMap(Employee::department, Employee::salary, Integer::sum, TreeMap::new) |
toConcurrentMap() | The same for parallel streams: accumulates into a ConcurrentHashMap | toConcurrentMap(Employee::name, e -> e) |
joining() | Concatenates CharSequence values: bare, with a delimiter, or with a prefix and suffix | joining(", ", "[", "]") |
counting() | Counts the elements and returns a Long | groupingBy(Employee::department, counting()) |
summingInt(), summingLong(), summingDouble() | Sum of the values extracted by a function | summingInt(Employee::salary) |
averagingInt(), averagingLong(), averagingDouble() | Arithmetic mean; always a Double, 0.0 on an empty stream | averagingInt(Employee::salary) |
summarizingInt(), summarizingLong(), summarizingDouble() | Count, sum, min, max and average in a single pass | summarizingInt(Employee::salary) |
minBy(), maxBy() | Smallest and largest element by comparator, wrapped in Optional | maxBy(comparingInt(Employee::salary)) |
groupingBy() | Groups by key; 2nd argument is the downstream collector, 3rd the Map factory | groupingBy(Employee::department, TreeMap::new, counting()) |
groupingByConcurrent() | Grouping into a ConcurrentMap for a parallel stream | groupingByConcurrent(Employee::department) |
partitioningBy() | Splits the stream in two by a predicate; the false and true keys are always there | partitioningBy(e -> e.salary() >= 150_000) |
mapping() | Applies a function to elements before passing them downstream | mapping(Employee::name, toList()) |
filtering() (Java 9+) | Filters elements inside a group, keeping empty groups alive | filtering(e -> e.salary() >= 150_000, counting()) |
flatMapping() (Java 9+) | Flattens nested streams inside a group | flatMapping(e -> e.skills().stream(), toSet()) |
reducing() | A fold inside a collector: three forms, just like Stream.reduce() | reducing(0, Employee::salary, Integer::sum) |
collectingAndThen() | Applies a final transformation to another collector's result | collectingAndThen(toList(), List::copyOf) |
teeing() (Java 12+) | Merges the results of two collectors in a single pass | teeing(counting(), summingInt(Employee::salary), Payroll::new) |
Collector.of() | A custom collector from supplier, accumulator, combiner and finisher | Collector.of(() -> new long[]{1L}, ..., acc -> acc[0]) |
Tip
The groupingByConcurrent() and toConcurrentMap() variants only make sense together with parallelStream(), and only when order is irrelevant: they write into one shared ConcurrentMap instead of paying for the merge of intermediate maps. In a sequential stream they add overhead and nothing else — stick to plain groupingBy() and toMap().
11. Quirks in how collectors behave
groupingBy()without a factory returns aHashMap. The key order relates neither to the stream order nor to sorting. If you need a predictable result, use the three-argument form withTreeMap::neworLinkedHashMap::new. The lists inside the groups do keep the source order.toMap()without a merge function fails on a duplicate key.IllegalStateException: Duplicate keyonly shows up on real data, which is why this bug reaches production so often. Unless the key is guaranteed unique, write the third argument from the start.toMap()cannot store anullvalue. It callsMap.merge()internally, and that throwsNullPointerExceptiononnull.groupingBy()has no such restriction on values, but anullkey from the classifier will break it too.partitioningBy()always returns both keys,groupingBy()only the ones it met. Hence the difference:partitioned.get(true)gives an empty list,grouped.get(true)givesnull.counting()returns aLong, not anInteger. DeclaringMap<String, Integer>withcounting()simply will not compile, and comparing the result with==above 127 yieldsfalse: you are comparing references, and theLongcache no longer covers them.summingInt()overflows silently. It accumulates in anint: a sum aboveInteger.MAX_VALUEwraps into a negative number with no exception at all. For money and large counters usesummingLong().averagingInt()returns0.0on an empty stream. That is not the same asIntStream.average(), which honestly returns an emptyOptionalDouble. An empty data set and a set of zeros are indistinguishable by the result.joining()only acceptsCharSequence. On aStream<Employee>the code will not compile — you needmap(Employee::name)ormap(Object::toString)first.- Where you filter changes the answer.
filter()before the grouping removes whole groups;filtering()inside the grouping keeps them with a zero or empty value.
Frequently asked questions
What is the difference between groupingBy and partitioningBy?
groupingBy takes a classifier function and creates as many keys as there are distinct values it returned; a group that never occurred simply has no key in the map. partitioningBy takes a predicate and always returns a Map with exactly two keys, false and true, even when one half is empty. The practical conclusion: for a binary condition partitioningBy is safer, because get(true) gives you an empty list instead of null.
How do I sort the result of groupingBy by key?
Use the three-argument form with a Map factory: groupingBy(Employee::department, TreeMap::new, Collectors.toList()). TreeMap sorts keys by natural order or by the comparator you pass to it, while LinkedHashMap::new preserves the order in which keys first appeared in the stream. Calling sorted on the stream before grouping is pointless: a HashMap will reorder the keys by hash anyway.
What should I do when toMap throws Duplicate key?
Add the third argument, a merge function. It receives the old and the new value and returns the one that stays in the map: the first seen, the last one, or a combination of both, for example a sum via Integer::sum. If you actually need every value that shares a key rather than a single one, use groupingBy instead of toMap.
Can I modify the list returned by Collectors.toList()?
Formally no: the javadoc guarantees neither the concrete class nor the mutability of the result, even though an ArrayList is what you get today. If the list definitely has to be edited, write toCollection(ArrayList::new). Both stream().toList() from Java 16 and the toUnmodifiableList() collector return unmodifiable lists, and the difference between them is that Stream.toList allows null elements while toUnmodifiableList throws NullPointerException on null.
When do I need a custom collector instead of a built-in one?
Almost never: the combination of groupingBy, mapping, collectingAndThen, reducing and teeing covers the vast majority of tasks. A custom collector written with Collector.of earns its place when the result accumulates into a non-standard mutable structure such as your own builder, a histogram or a buffer, and that logic has to be reused in several places. For a one-off case the three-argument collect with supplier, accumulator and combiner is simpler. The one thing to get right in a custom implementation is the combiner: a sequential stream never calls it, so a mistake there only surfaces after someone switches to parallel.
Comments