Of course! "Collectors" in Java almost always refers to the java.util.stream.Collectors class. It's an incredibly powerful and essential utility for working with streams, introduced in Java 8.
Let's break it down into a comprehensive guide.
What are Collectors?
At its core, a Collector is a utility that transforms elements from a stream into a different form, like a List, Set, Map, or even a custom object.
Think of a stream as a pipeline of data. A Collector is the tool you place at the end of that pipeline to "collect" the processed elements into a useful container.
The primary way to use a Collector is with the Stream.collect() method, which has a signature like this:
<R, A> R collect(Collector<? super T, A, R> collector);
This looks complex, but the key takeaway is: you pass a Collector to collect(), and it returns the final collection (R).
The Collectors Factory Class
java.util.stream.Collectors is a final utility class that provides dozens of static factory methods to create common, ready-to-use Collector instances. You almost never implement the Collector interface yourself; you just use the factory methods from this class.
Here are the most important categories of collectors, with examples.
Collecting into Simple Collections (Lists, Sets, Maps)
These are the most basic and frequently used collectors.
toList()
Collects the stream elements into a new List.
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
List<String> names = Stream.of("Alice", "Bob", "Charlie")
.collect(Collectors.toList());
// names will be ["Alice", "Bob", "Charlie"]
toSet()
Collects the stream elements into a new Set. This automatically removes any duplicate elements.
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
Set<Integer> uniqueNumbers = Stream.of(1, 2, 2, 3, 4, 4, 5)
.collect(Collectors.toSet());
// uniqueNumbers will be [1, 2, 3, 4, 5] (order is not guaranteed)
toMap()
Collects stream elements into a Map. You must provide two functions:
- A mapper function to create the key for each element.
- A mapper function to create the value for each element.
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;
class Person {
String name;
int age;
// constructor, getters...
}
Stream<Person> peopleStream = Stream.of(
new Person("Alice", 30),
new Person("Bob", 25)
);
Map<String, Integer> nameToAgeMap = peopleStream
.collect(Collectors.toMap(
Person::getName, // Key mapper: person -> person.getName()
Person::getAge // Value mapper: person -> person.getAge()
));
// nameToAgeMap will be {"Alice": 30, "Bob": 25}
Handling Duplicates: What if two people have the same name? toMap() will throw an IllegalStateException. To handle this, you can provide a third argument: a merge function.
// If two people have the same name, keep the first one encountered.
Map<String, Integer> nameToAgeMapKeepFirst = peopleStream
.collect(Collectors.toMap(
Person::getName,
Person::getAge,
(existingVal, newVal) -> existingVal // Merge function
));
// If two people have the same name, add their ages.
Map<String, Integer> nameToAgeMapSumAges = peopleStream
.collect(Collectors.toMap(
Person::getName,
Person::getAge,
Integer::sum // Merge function
));
Aggregation Collectors (Sum, Average, Count, Max, Min)
These are powerful reduction operations that summarize a stream into a single value.
counting()
Counts the number of elements in the stream.
long count = Stream.of("a", "b", "c")
.collect(Collectors.counting());
// count is 3
summingInt(), summingLong(), summingDouble()
Calculates the sum of elements.
import java.util.stream.Stream;
int sum = Stream.of(1, 2, 3, 4, 5)
.collect(Collectors.summingInt(Integer::intValue));
// sum is 15
averagingInt(), averagingLong(), averagingDouble()
Calculates the average of elements.
double average = Stream.of(1, 2, 3, 4, 5)
.collect(Collectors.averagingInt(Integer::intValue));
// average is 3.0
maxBy() and minBy()
Finds the maximum or minimum element in the stream, based on a Comparator.
import java.util.Comparator;
import java.util.Optional;
Optional<Integer> max = Stream.of(1, 2, 3, 4, 5)
.collect(Collectors.maxBy(Comparator.naturalOrder()));
// max is Optional[5]
Optional<Integer> min = Stream.of(1, 2, 3, 4, 5)
.collect(Collectors.minBy(Comparator.naturalOrder()));
// min is Optional[1]
Grouping and Partitioning
This is where Collectors truly shines, allowing for complex data summarization.
groupingBy()
This is the most powerful collector. It groups elements based on a classification function.
- Simple Grouping: Group a list of people by their city.
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
class Person {
String name;
String city;
// constructor, getters...
}
List<Person> people = List.of(
new Person("Alice", "New York"),
new Person("Bob", "London"),
new Person("Charlie", "New York")
);
// Group by city
Map<String, List<Person>> peopleByCity = people.stream()
.collect(Collectors.groupingBy(Person::getCity));
/*
Result:
{
"New York": [Alice, Charlie],
"London": [Bob]
}
*/
- Grouping with a Downstream Collector: You can perform a secondary aggregation on each group. For example, count the number of people in each city.
// Group by city and count the people in each group
Map<String, Long> peopleCountByCity = people.stream()
.collect(Collectors.groupingBy(
Person::getCity, // Classifier
Collectors.counting() // Downstream collector
));
/*
Result:
{
"New York": 2,
"London": 1
}
*/
- Multi-level Grouping: You can group by multiple criteria. For example, group by city, and then by age range.
// Group by city, then by age group
Map<String, Map<String, List<Person>>> complexGrouping = people.stream()
.collect(Collectors.groupingBy(
Person::getCity,
Collectors.groupingBy(p -> p.getAge() > 30 ? "Senior" : "Junior")
));
partitioningBy()
A special case of groupingBy() that splits a stream into two groups based on a predicate (a condition that returns true or false). It always returns a Map<Boolean, ...>.
// Partition people into adults (age >= 18) and minors
Map<Boolean, List<Person>> adultsAndMinors = people.stream()
.collect(Collectors.partitioningBy(p -> p.getAge() >= 18));
/*
Result:
{
true: [Alice, Bob, Charlie], // Assuming they are all adults
false: []
}
*/
You can also use a downstream collector with partitioningBy.
// Partition adults and minors, then count them
Map<Boolean, Long> adultAndMinorCounts = people.stream()
.collect(Collectors.partitioningBy(
p -> p.getAge() >= 18,
Collectors.counting()
));
Joining Collectors
Useful for working with String streams.
joining()
Concatenates the elements of a stream into a single String.
import java.util.stream.Stream;
String allNames = Stream.of("Alice", "Bob", "Charlie")
.collect(Collectors.joining());
// allNames is "AliceBobCharlie"
String namesWithComma = Stream.of("Alice", "Bob", "Charlie")
.collect(Collectors.joining(", "));
// namesWithComma is "Alice, Bob, Charlie"
String formattedNames = Stream.of("Alice", "Bob", "Charlie")
.collect(Collectors.joining(", ", "[", "]"));
// formattedNames is "[Alice, Bob, Charlie]"
Collecting to Custom Objects (Collectors.toCollection())
Sometimes you need a specific type of collection, like a LinkedList or an ImmutableList. You can achieve this by providing a Supplier that creates the desired collection.
import java.util.LinkedList;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
// Collect into a LinkedList instead of an ArrayList
LinkedList<String> nameList = Stream.of("Alice", "Bob", "Charlie")
.collect(Collectors.toCollection(LinkedList::new));
// For an immutable list (Java 10+)
List<String> immutableList = Stream.of("Alice", "Bob", "Charlie")
.collect(Collectors.toUnmodifiableList());
Summary Table
| Goal | Collector Method | Example |
|---|---|---|
To List |
Collectors.toList() |
.collect(Collectors.toList()) |
To Set (removes duplicates) |
Collectors.toSet() |
.collect(Collectors.toSet()) |
To Map |
Collectors.toMap() |
.collect(Collectors.toMap(Person::getName, Person::getAge)) |
| To count elements | Collectors.counting() |
.collect(Collectors.counting()) |
| To sum elements | Collectors.summingInt() |
.collect(Collectors.summingInt(Person::getAge)) |
| To find average | Collectors.averagingInt() |
.collect(Collectors.averagingInt(Person::getAge)) |
| To find max/min | Collectors.maxBy() / Collectors.minBy() |
.collect(Collectors.maxBy(Comparator.comparing(...))) |
| To group elements | Collectors.groupingBy() |
.collect(Collectors.groupingBy(Person::getCity)) |
| To partition by a condition | Collectors.partitioningBy() |
.collect(Collectors.partitioningBy(p -> p.getAge() > 30)) |
| To join strings | Collectors.joining() |
.collect(Collectors.joining(", ")) |
| To a custom collection type | Collectors.toCollection() |
.collect(Collectors.toCollection(LinkedList::new)) |
Best Practices
- Prefer
Collectorsover manual loops: It leads to more concise, readable, and often more performant code. - Use method references: They make the code cleaner.
Person::getNameis better thanp -> p.getName(). - Be aware of
null: Most collectors will throw aNullPointerExceptionif the stream containsnullvalues and your mapper functions don't handle it. UseObjects.requireNonNullElseor filter outnulls first if necessary. - For immutable collections: Use
toUnmodifiableList(),toUnmodifiableSet(), ortoUnmodifiableMap()when you want to ensure the resulting collection cannot be modified after creation. This is a great practice for thread safety and defensive programming.
