When you use the Stream API, the collect() terminal operation is how you transform a stream back into a concrete data structure — a List, a Set, a Map, a String, or even a custom container. But collect() does not know how to build these structures on its own. That is where Collectors come in.
The java.util.stream.Collectors class is a utility class (introduced in Java 8) that provides dozens of factory methods for creating Collector objects. Each Collector defines three things:
new ArrayList<>())list.add(element))You rarely need to think about these three parts because the pre-built Collectors handle it. Here is the big picture:
| Category | Collectors |
|---|---|
| Collection builders | toList(), toSet(), toMap(), toCollection(), toUnmodifiableList() |
| String builders | joining() |
| Grouping | groupingBy(), partitioningBy() |
| Aggregation | counting(), summingInt(), averagingDouble(), summarizingInt() |
| Reduction | reducing(), mapping(), flatMapping(), filtering() |
| Min/Max | minBy(), maxBy() |
| Custom | Collector.of() |
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
public class CollectorsIntro {
public static void main(String[] args) {
List names = List.of("Alice", "Bob", "Charlie", "Alice", "David");
// Without Collectors -- manual collect (verbose)
List manual = names.stream()
.filter(n -> n.length() > 3)
.collect(
() -> new java.util.ArrayList<>(), // supplier
(list, item) -> list.add(item), // accumulator
(list1, list2) -> list1.addAll(list2) // combiner
);
// With Collectors -- one method call
List easy = names.stream()
.filter(n -> n.length() > 3)
.collect(Collectors.toList());
System.out.println("Manual: " + manual);
System.out.println("Easy: " + easy);
// Both output: [Alice, Charlie, Alice, David]
}
}
These are the workhorses of the Collectors class — used in the vast majority of stream pipelines.
| Collector | Returns | Duplicates | Order |
|---|---|---|---|
toList() |
List<T> |
Allowed | Preserved |
toSet() |
Set<T> |
Removed | Not guaranteed |
toUnmodifiableList() (Java 10+) |
List<T> |
Allowed | Preserved, immutable |
toUnmodifiableSet() (Java 10+) |
Set<T> |
Removed | Not guaranteed, immutable |
toCollection(Supplier) |
Any Collection | Depends on type | Depends on type |
import java.util.*;
import java.util.stream.Collectors;
public class BasicCollectors {
public static void main(String[] args) {
List names = List.of("Charlie", "Alice", "Bob", "Alice", "David", "Bob");
// toList() -- ArrayList (mutable)
List list = names.stream()
.filter(n -> n.length() > 3)
.collect(Collectors.toList());
list.add("Extra"); // works -- list is mutable
System.out.println("toList: " + list);
// Output: toList: [Charlie, Alice, Alice, David, Extra]
// toSet() -- HashSet (no duplicates)
Set set = names.stream()
.collect(Collectors.toSet());
System.out.println("toSet: " + set);
// Output: toSet: [Alice, Bob, Charlie, David] (order may vary)
// toUnmodifiableList() -- Java 10+ (immutable)
List immutable = names.stream()
.filter(n -> n.length() > 3)
.collect(Collectors.toUnmodifiableList());
System.out.println("toUnmodifiableList: " + immutable);
// immutable.add("Fail"); // throws UnsupportedOperationException
// toUnmodifiableSet() -- Java 10+ (immutable, no duplicates)
Set immutableSet = names.stream()
.collect(Collectors.toUnmodifiableSet());
System.out.println("toUnmodifiableSet: " + immutableSet);
// toCollection() -- specify the exact collection type
TreeSet treeSet = names.stream()
.collect(Collectors.toCollection(TreeSet::new));
System.out.println("TreeSet (sorted): " + treeSet);
// Output: TreeSet (sorted): [Alice, Bob, Charlie, David]
LinkedList linkedList = names.stream()
.collect(Collectors.toCollection(LinkedList::new));
System.out.println("LinkedList: " + linkedList);
// Java 16+: Stream.toList() shorthand (returns unmodifiable list)
List java16List = names.stream()
.filter(n -> n.startsWith("A"))
.toList();
System.out.println("Stream.toList(): " + java16List);
// Output: Stream.toList(): [Alice, Alice]
}
}
Collectors.toMap() builds a Map from stream elements. It requires two functions: one to extract the key, and one to extract the value. It has three overloaded forms to handle duplicate keys and specify the Map implementation.
import java.util.*;
import java.util.stream.Collectors;
public class ToMapBasic {
public static void main(String[] args) {
List names = List.of("Alice", "Bob", "Charlie", "David");
// Basic: name -> length
Map nameLengths = names.stream()
.collect(Collectors.toMap(
name -> name, // key mapper
String::length // value mapper
));
System.out.println("Name lengths: " + nameLengths);
// Output: Name lengths: {Alice=5, Bob=3, Charlie=7, David=5}
// Map from index to value
List colors = List.of("Red", "Green", "Blue");
Map indexed = new HashMap<>();
for (int i = 0; i < colors.size(); i++) {
indexed.put(i, colors.get(i));
}
// Or with streams using an AtomicInteger
java.util.concurrent.atomic.AtomicInteger counter = new java.util.concurrent.atomic.AtomicInteger(0);
Map indexedStream = colors.stream()
.collect(Collectors.toMap(
color -> counter.getAndIncrement(),
color -> color
));
System.out.println("Indexed: " + indexedStream);
// Output: Indexed: {0=Red, 1=Green, 2=Blue}
}
}
If two elements produce the same key, toMap() throws IllegalStateException by default. To handle duplicates, provide a merge function as the third argument.
import java.util.*;
import java.util.stream.Collectors;
public class ToMapDuplicates {
public static void main(String[] args) {
List names = List.of("Alice", "Anna", "Bob", "Brian", "Charlie");
// Group by first letter -- FAILS if duplicate keys
// Map bad = names.stream()
// .collect(Collectors.toMap(n -> n.charAt(0), n -> n));
// IllegalStateException: Duplicate key A (attempted merging Alice and Anna)
// Handle duplicates: keep the first value
Map keepFirst = names.stream()
.collect(Collectors.toMap(
n -> n.charAt(0), // key
n -> n, // value
(existing, replacement) -> existing // merge: keep first
));
System.out.println("Keep first: " + keepFirst);
// Output: Keep first: {A=Alice, B=Bob, C=Charlie}
// Handle duplicates: keep the last value
Map keepLast = names.stream()
.collect(Collectors.toMap(
n -> n.charAt(0),
n -> n,
(existing, replacement) -> replacement // merge: keep last
));
System.out.println("Keep last: " + keepLast);
// Output: Keep last: {A=Anna, B=Brian, C=Charlie}
// Handle duplicates: concatenate values
Map concat = names.stream()
.collect(Collectors.toMap(
n -> n.charAt(0),
n -> n,
(a, b) -> a + ", " + b // merge: join with comma
));
System.out.println("Concatenated: " + concat);
// Output: Concatenated: {A=Alice, Anna, B=Bob, Brian, C=Charlie}
// Count occurrences using toMap
List words = List.of("apple", "banana", "apple", "cherry", "banana", "apple");
Map wordCount = words.stream()
.collect(Collectors.toMap(
w -> w,
w -> 1,
Integer::sum
));
System.out.println("Word counts: " + wordCount);
// Output: Word counts: {apple=3, banana=2, cherry=1}
}
}
By default, toMap() returns a HashMap. The four-argument form lets you specify a different Map implementation, such as TreeMap (sorted) or LinkedHashMap (insertion order).
import java.util.*;
import java.util.stream.Collectors;
public class ToMapType {
public static void main(String[] args) {
List names = List.of("Charlie", "Alice", "Bob", "David");
// TreeMap -- keys sorted alphabetically
TreeMap sorted = names.stream()
.collect(Collectors.toMap(
n -> n,
String::length,
(a, b) -> a, // merge function (required for 4-arg form)
TreeMap::new // map factory
));
System.out.println("TreeMap: " + sorted);
// Output: TreeMap: {Alice=5, Bob=3, Charlie=7, David=5}
// LinkedHashMap -- preserves insertion order
LinkedHashMap ordered = names.stream()
.collect(Collectors.toMap(
n -> n,
String::length,
(a, b) -> a,
LinkedHashMap::new
));
System.out.println("LinkedHashMap: " + ordered);
// Output: LinkedHashMap: {Charlie=7, Alice=5, Bob=3, David=5}
}
}
Collectors.joining() concatenates CharSequence elements (Strings) into a single String. It has three overloaded forms.
import java.util.List;
import java.util.stream.Collectors;
public class JoiningCollector {
public static void main(String[] args) {
List languages = List.of("Java", "Python", "Go", "Rust", "C++");
// joining() -- no separator
String noSep = languages.stream().collect(Collectors.joining());
System.out.println("No separator: " + noSep);
// Output: No separator: JavaPythonGoRustC++
// joining(delimiter) -- with separator
String commaSep = languages.stream().collect(Collectors.joining(", "));
System.out.println("Comma: " + commaSep);
// Output: Comma: Java, Python, Go, Rust, C++
// joining(delimiter, prefix, suffix)
String formatted = languages.stream()
.collect(Collectors.joining(" | ", "Languages: [", "]"));
System.out.println(formatted);
// Output: Languages: [Java | Python | Go | Rust | C++]
// Practical: CSV row
List fields = List.of("John", "Doe", "john@example.com", "555-1234");
String csvRow = fields.stream().collect(Collectors.joining(","));
System.out.println("CSV: " + csvRow);
// Output: CSV: John,Doe,john@example.com,555-1234
// Practical: SQL IN clause
List ids = List.of(101, 205, 310, 422);
String sql = ids.stream()
.map(String::valueOf)
.collect(Collectors.joining(", ", "SELECT * FROM users WHERE id IN (", ");"));
System.out.println("SQL: " + sql);
// Output: SQL: SELECT * FROM users WHERE id IN (101, 205, 310, 422);
// Practical: HTML list
List items = List.of("Home", "About", "Contact");
String html = items.stream()
.map(item -> " " + item + " ")
.collect(Collectors.joining("\n", "\n", "\n
"));
System.out.println(html);
// joining() on empty stream returns empty prefix+suffix
String empty = List.of().stream()
.collect(Collectors.joining(", ", "[", "]"));
System.out.println("Empty: " + empty);
// Output: Empty: []
}
}
Collectors.groupingBy() is the stream equivalent of SQL’s GROUP BY. It classifies elements by a key and groups them into a Map<K, List<T>>. With downstream collectors, you can perform aggregations within each group.
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class GroupingByBasic {
record Employee(String name, String department, double salary) {}
public static void main(String[] args) {
List employees = List.of(
new Employee("Alice", "Engineering", 95000),
new Employee("Bob", "Engineering", 110000),
new Employee("Charlie", "Marketing", 72000),
new Employee("Diana", "Sales", 68000),
new Employee("Eve", "Engineering", 125000),
new Employee("Frank", "Marketing", 78000),
new Employee("Grace", "Sales", 71000)
);
// Group by department
Map> byDept = employees.stream()
.collect(Collectors.groupingBy(Employee::department));
byDept.forEach((dept, emps) -> {
System.out.println(dept + ":");
emps.forEach(e -> System.out.println(" " + e.name()));
});
// Output:
// Engineering:
// Alice
// Bob
// Eve
// Marketing:
// Charlie
// Frank
// Sales:
// Diana
// Grace
// Group strings by length
List words = List.of("cat", "dog", "fish", "bird", "ant", "fox", "bear");
Map> byLength = words.stream()
.collect(Collectors.groupingBy(String::length));
System.out.println("By length: " + byLength);
// Output: By length: {3=[cat, dog, ant, fox], 4=[fish, bird, bear]}
}
}
The real power of groupingBy() comes from the second argument — a downstream collector that processes each group. Instead of getting List<T>, you can count, sum, average, or further transform each group.
import java.util.*;
import java.util.stream.Collectors;
public class GroupingByDownstream {
record Employee(String name, String department, double salary) {}
public static void main(String[] args) {
List employees = List.of(
new Employee("Alice", "Engineering", 95000),
new Employee("Bob", "Engineering", 110000),
new Employee("Charlie", "Marketing", 72000),
new Employee("Diana", "Sales", 68000),
new Employee("Eve", "Engineering", 125000),
new Employee("Frank", "Marketing", 78000),
new Employee("Grace", "Sales", 71000)
);
// Count per group
Map countByDept = employees.stream()
.collect(Collectors.groupingBy(Employee::department, Collectors.counting()));
System.out.println("Count: " + countByDept);
// Output: Count: {Engineering=3, Marketing=2, Sales=2}
// Average salary per department
Map avgByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.averagingDouble(Employee::salary)
));
System.out.println("Average salary: " + avgByDept);
// Sum of salaries per department
Map totalByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.summingDouble(Employee::salary)
));
System.out.println("Total salary: " + totalByDept);
// Max salary per department
Map> topEarner = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.maxBy(Comparator.comparingDouble(Employee::salary))
));
topEarner.forEach((dept, emp) ->
emp.ifPresent(e -> System.out.println(dept + " top earner: " + e.name())));
// Map to names only (mapping downstream)
Map> namesByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.mapping(Employee::name, Collectors.toList())
));
System.out.println("Names: " + namesByDept);
// Output: Names: {Engineering=[Alice, Bob, Eve], Marketing=[Charlie, Frank], Sales=[Diana, Grace]}
// Join names as a comma-separated string
Map joinedNames = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.mapping(Employee::name, Collectors.joining(", "))
));
System.out.println("Joined: " + joinedNames);
// Output: Joined: {Engineering=Alice, Bob, Eve, Marketing=Charlie, Frank, Sales=Diana, Grace}
// Collect names into a Set (no duplicates)
Map> nameSetByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.mapping(Employee::name, Collectors.toSet())
));
System.out.println("Name sets: " + nameSetByDept);
}
}
You can nest groupingBy() calls to create multi-level groupings — similar to SQL’s GROUP BY col1, col2.
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class MultiLevelGrouping {
record Employee(String name, String department, String level, double salary) {}
public static void main(String[] args) {
List employees = List.of(
new Employee("Alice", "Engineering", "Senior", 120000),
new Employee("Bob", "Engineering", "Junior", 75000),
new Employee("Charlie", "Engineering", "Senior", 115000),
new Employee("Diana", "Marketing", "Junior", 60000),
new Employee("Eve", "Marketing", "Senior", 85000),
new Employee("Frank", "Sales", "Junior", 55000),
new Employee("Grace", "Sales", "Senior", 80000),
new Employee("Hannah", "Sales", "Junior", 58000)
);
// Two-level grouping: department -> level -> list of employees
Map>> twoLevel = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.groupingBy(Employee::level)
));
twoLevel.forEach((dept, levels) -> {
System.out.println(dept + ":");
levels.forEach((level, emps) -> {
System.out.println(" " + level + ":");
emps.forEach(e -> System.out.println(" " + e.name() + " - $" + e.salary()));
});
});
// Two-level grouping with counting
Map> headcount = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.groupingBy(Employee::level, Collectors.counting())
));
System.out.println("\nHeadcount: " + headcount);
// Output: Headcount: {Engineering={Junior=1, Senior=2}, Marketing={Junior=1, Senior=1}, Sales={Junior=2, Senior=1}}
// Two-level grouping with average salary
Map> avgSalary = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.groupingBy(
Employee::level,
Collectors.averagingDouble(Employee::salary)
)
));
System.out.println("\nAvg salary: " + avgSalary);
}
}
Collectors.partitioningBy() is a special case of groupingBy() that splits elements into exactly two groups based on a predicate: true and false. The result is always a Map<Boolean, List<T>> with both keys present (even if one group is empty).
import java.util.*;
import java.util.stream.Collectors;
public class PartitioningByExample {
public static void main(String[] args) {
List numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
// Partition into even and odd
Map> evenOdd = numbers.stream()
.collect(Collectors.partitioningBy(n -> n % 2 == 0));
System.out.println("Even: " + evenOdd.get(true));
System.out.println("Odd: " + evenOdd.get(false));
// Output: Even: [2, 4, 6, 8, 10]
// Odd: [1, 3, 5, 7, 9]
// Partition with downstream counting
Map evenOddCount = numbers.stream()
.collect(Collectors.partitioningBy(
n -> n % 2 == 0,
Collectors.counting()
));
System.out.println("Even count: " + evenOddCount.get(true)); // 5
System.out.println("Odd count: " + evenOddCount.get(false)); // 5
// Partition students into pass/fail
record Student(String name, int score) {}
List students = List.of(
new Student("Alice", 85), new Student("Bob", 42),
new Student("Charlie", 91), new Student("Diana", 58),
new Student("Eve", 73), new Student("Frank", 35)
);
int passingScore = 60;
Map> passFail = students.stream()
.collect(Collectors.partitioningBy(s -> s.score() >= passingScore));
System.out.println("\nPassing:");
passFail.get(true).forEach(s ->
System.out.println(" " + s.name() + ": " + s.score()));
System.out.println("Failing:");
passFail.get(false).forEach(s ->
System.out.println(" " + s.name() + ": " + s.score()));
// Partition with mapping downstream
Map> passFailNames = students.stream()
.collect(Collectors.partitioningBy(
s -> s.score() >= passingScore,
Collectors.mapping(Student::name, Collectors.toList())
));
System.out.println("Pass names: " + passFailNames.get(true));
System.out.println("Fail names: " + passFailNames.get(false));
// Empty partition -- both keys still present
Map> allPass = List.of(100, 90, 80).stream()
.collect(Collectors.partitioningBy(n -> n >= 60));
System.out.println("True: " + allPass.get(true)); // [100, 90, 80]
System.out.println("False: " + allPass.get(false)); // [] (empty, but key exists)
}
}
Collectors provides several methods for numeric aggregation. These are most commonly used as downstream collectors inside groupingBy().
| Collector | Returns | Description |
|---|---|---|
counting() |
Long |
Count of elements |
summingInt/Long/Double() |
Integer/Long/Double |
Sum of extracted values |
averagingInt/Long/Double() |
Double |
Average of extracted values |
summarizingInt/Long/Double() |
IntSummaryStatistics |
Count, sum, min, max, average in one pass |
minBy(Comparator) |
Optional<T> |
Minimum element |
maxBy(Comparator) |
Optional<T> |
Maximum element |
import java.util.*;
import java.util.stream.Collectors;
public class CountingStatsExample {
record Product(String name, String category, double price, int quantity) {}
public static void main(String[] args) {
List products = List.of(
new Product("Laptop", "Electronics", 999.99, 50),
new Product("Phone", "Electronics", 699.99, 200),
new Product("Tablet", "Electronics", 449.99, 100),
new Product("Desk", "Furniture", 299.99, 30),
new Product("Chair", "Furniture", 199.99, 80),
new Product("Notebook", "Office", 4.99, 1000),
new Product("Pen", "Office", 1.99, 5000)
);
// counting() -- total number of products
long totalProducts = products.stream()
.collect(Collectors.counting());
System.out.println("Total products: " + totalProducts);
// counting() as downstream -- products per category
Map countPerCategory = products.stream()
.collect(Collectors.groupingBy(Product::category, Collectors.counting()));
System.out.println("Per category: " + countPerCategory);
// Output: Per category: {Electronics=3, Furniture=2, Office=2}
// summingDouble() -- total inventory value
double totalValue = products.stream()
.collect(Collectors.summingDouble(p -> p.price() * p.quantity()));
System.out.printf("Total inventory value: $%,.2f%n", totalValue);
// averagingDouble() -- average price
double avgPrice = products.stream()
.collect(Collectors.averagingDouble(Product::price));
System.out.printf("Average price: $%,.2f%n", avgPrice);
// summarizingDouble() -- all stats in one pass
DoubleSummaryStatistics priceStats = products.stream()
.collect(Collectors.summarizingDouble(Product::price));
System.out.println("\nPrice statistics:");
System.out.printf(" Count: %d%n", priceStats.getCount());
System.out.printf(" Sum: $%,.2f%n", priceStats.getSum());
System.out.printf(" Min: $%,.2f%n", priceStats.getMin());
System.out.printf(" Max: $%,.2f%n", priceStats.getMax());
System.out.printf(" Avg: $%,.2f%n", priceStats.getAverage());
// summarizingDouble per category
Map statsByCategory = products.stream()
.collect(Collectors.groupingBy(
Product::category,
Collectors.summarizingDouble(Product::price)
));
System.out.println("\nStats by category:");
statsByCategory.forEach((cat, stats) ->
System.out.printf(" %s: count=%d, avg=$%,.2f, max=$%,.2f%n",
cat, stats.getCount(), stats.getAverage(), stats.getMax()));
// minBy and maxBy
Optional cheapest = products.stream()
.collect(Collectors.minBy(Comparator.comparingDouble(Product::price)));
cheapest.ifPresent(p -> System.out.println("\nCheapest: " + p.name() + " $" + p.price()));
Optional mostExpensive = products.stream()
.collect(Collectors.maxBy(Comparator.comparingDouble(Product::price)));
mostExpensive.ifPresent(p -> System.out.println("Most expensive: " + p.name() + " $" + p.price()));
}
}
Collectors.reducing() and Collectors.mapping() are general-purpose downstream collectors that give you fine-grained control when the built-in aggregation collectors are not enough.
reducing() is the Collector equivalent of Stream.reduce(). It is typically used as a downstream collector inside groupingBy() when you need a custom reduction per group.
import java.util.*;
import java.util.stream.Collectors;
public class ReducingCollector {
record Employee(String name, String department, double salary) {}
public static void main(String[] args) {
List employees = List.of(
new Employee("Alice", "Engineering", 95000),
new Employee("Bob", "Engineering", 110000),
new Employee("Charlie", "Marketing", 72000),
new Employee("Diana", "Marketing", 78000),
new Employee("Eve", "Sales", 68000)
);
// reducing() with identity -- total salary per department
Map totalByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.reducing(0.0, Employee::salary, Double::sum)
));
System.out.println("Total by dept: " + totalByDept);
// Output: Total by dept: {Engineering=205000.0, Marketing=150000.0, Sales=68000.0}
// reducing() without identity -- highest earner per department
Map> topPerDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.reducing((e1, e2) ->
e1.salary() > e2.salary() ? e1 : e2)
));
topPerDept.forEach((dept, emp) ->
emp.ifPresent(e -> System.out.println(dept + " top: " + e.name())));
// reducing() to concatenate names per department
Map namesByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.reducing("",
Employee::name,
(a, b) -> a.isEmpty() ? b : a + ", " + b)
));
System.out.println("Names by dept: " + namesByDept);
}
}
mapping() transforms elements before passing them to a downstream collector. flatMapping() (Java 9+) does the same but flattens the result. Both are used as downstream collectors.
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class MappingCollector {
record Employee(String name, String department, List skills) {}
public static void main(String[] args) {
List employees = List.of(
new Employee("Alice", "Engineering", List.of("Java", "Python", "SQL")),
new Employee("Bob", "Engineering", List.of("Java", "Go", "Docker")),
new Employee("Charlie", "Marketing", List.of("SEO", "Analytics")),
new Employee("Diana", "Marketing", List.of("Content", "SEO", "Social"))
);
// mapping() -- extract names per department
Map> namesByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.mapping(Employee::name, Collectors.toList())
));
System.out.println("Names: " + namesByDept);
// Output: Names: {Engineering=[Alice, Bob], Marketing=[Charlie, Diana]}
// mapping() with joining downstream
Map joinedByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.mapping(Employee::name, Collectors.joining(", "))
));
System.out.println("Joined: " + joinedByDept);
// flatMapping() (Java 9+) -- collect all skills per department
Map> skillsByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.flatMapping(
e -> e.skills().stream(),
Collectors.toSet()
)
));
System.out.println("Skills: " + skillsByDept);
// Output: Skills: {Engineering=[Docker, Go, Java, Python, SQL], Marketing=[Analytics, Content, SEO, Social]}
// filtering() (Java 9+) -- filter within each group
Map> seniorDevs = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.filtering(
e -> e.skills().size() >= 3,
Collectors.mapping(Employee::name, Collectors.toList())
)
));
System.out.println("3+ skills: " + seniorDevs);
// Output: 3+ skills: {Engineering=[Alice, Bob], Marketing=[Diana]}
}
}
When the built-in collectors do not fit your needs, you can create a custom collector using Collector.of(). You provide four components:
| Component | Type | Description |
|---|---|---|
| Supplier | Supplier<A> |
Creates the mutable result container |
| Accumulator | BiConsumer<A, T> |
Adds an element to the container |
| Combiner | BinaryOperator<A> |
Merges two containers (for parallel streams) |
| Finisher | Function<A, R> |
Transforms the container into the final result |
import java.util.*;
import java.util.stream.Collector;
import java.util.stream.Collectors;
public class CustomCollectorExample {
public static void main(String[] args) {
List numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
// Custom collector: collect into a comma-separated string with brackets
String result = numbers.stream()
.collect(Collector.of(
StringBuilder::new, // supplier
(sb, num) -> { // accumulator
if (sb.length() > 0) sb.append(", ");
sb.append(num);
},
(sb1, sb2) -> { // combiner
if (sb1.length() > 0 && sb2.length() > 0) sb1.append(", ");
return sb1.append(sb2);
},
sb -> "[" + sb.toString() + "]" // finisher
));
System.out.println("Custom string: " + result);
// Output: Custom string: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
// Custom collector: compute running average
double avg = numbers.stream()
.collect(Collector.of(
() -> new double[]{0, 0}, // [sum, count]
(acc, num) -> { acc[0] += num; acc[1]++; }, // accumulate
(a1, a2) -> { // combine
a1[0] += a2[0];
a1[1] += a2[1];
return a1;
},
acc -> acc[1] == 0 ? 0.0 : acc[0] / acc[1] // finish
));
System.out.println("Average: " + avg); // Output: Average: 5.5
// Custom collector: toImmutableList using Collector.of with UNORDERED characteristic
List names = List.of("Alice", "Bob", "Charlie");
List immutable = names.stream()
.filter(n -> n.length() > 3)
.collect(Collector.of(
ArrayList::new,
ArrayList::add,
(list1, list2) -> { list1.addAll(list2); return list1; },
Collections::unmodifiableList
));
System.out.println("Immutable: " + immutable);
// immutable.add("Fail"); // throws UnsupportedOperationException
// Custom collector: group into even-indexed and odd-indexed elements
List items = List.of("A", "B", "C", "D", "E", "F");
Map> evenOddIndex = items.stream()
.collect(Collector.of(
() -> {
Map> map = new HashMap<>();
map.put("even-index", new ArrayList<>());
map.put("odd-index", new ArrayList<>());
map.put("_counter", new ArrayList<>()); // track index
return map;
},
(map, item) -> {
int index = map.get("_counter").size();
map.get("_counter").add("x");
if (index % 2 == 0) {
map.get("even-index").add(item);
} else {
map.get("odd-index").add(item);
}
},
(m1, m2) -> { m1.get("even-index").addAll(m2.get("even-index"));
m1.get("odd-index").addAll(m2.get("odd-index")); return m1; },
map -> { map.remove("_counter"); return map; }
));
System.out.println("Even-index: " + evenOddIndex.get("even-index"));
System.out.println("Odd-index: " + evenOddIndex.get("odd-index"));
// Output: Even-index: [A, C, E]
// Odd-index: [B, D, F]
}
}
For collectors you use frequently, define them as static methods so they can be imported and reused like the built-in collectors.
import java.util.*;
import java.util.stream.Collector;
public class ReusableCollector {
// Custom collector: toLinkedList()
public static Collector> toLinkedList() {
return Collector.of(
LinkedList::new,
LinkedList::add,
(list1, list2) -> { list1.addAll(list2); return list1; }
);
}
// Custom collector: toReversedList()
public static Collector> toReversedList() {
return Collector.of(
ArrayList::new,
(list, item) -> list.add(0, item),
(list1, list2) -> { list2.addAll(list1); return list2; }
);
}
public static void main(String[] args) {
List names = List.of("Alice", "Bob", "Charlie", "David");
// Use custom toLinkedList()
LinkedList linked = names.stream()
.filter(n -> n.length() > 3)
.collect(toLinkedList());
System.out.println("LinkedList: " + linked);
System.out.println("Type: " + linked.getClass().getSimpleName());
// Output: LinkedList: [Alice, Charlie, David]
// Type: LinkedList
// Use custom toReversedList()
List reversed = names.stream()
.collect(toReversedList());
System.out.println("Reversed: " + reversed);
// Output: Reversed: [David, Charlie, Bob, Alice]
}
}
Let us combine everything into a realistic scenario. We have a list of sales transactions and need to perform various analytics using the full power of the Collectors class.
import java.time.LocalDate;
import java.time.Month;
import java.util.*;
import java.util.stream.Collectors;
public class SalesAnalytics {
record Sale(String product, String category, String region,
double amount, int quantity, LocalDate date) {}
public static void main(String[] args) {
List sales = List.of(
new Sale("Laptop", "Electronics", "North", 999.99, 5, LocalDate.of(2024, 1, 15)),
new Sale("Phone", "Electronics", "South", 699.99, 12, LocalDate.of(2024, 1, 20)),
new Sale("Tablet", "Electronics", "North", 449.99, 8, LocalDate.of(2024, 2, 5)),
new Sale("Desk", "Furniture", "East", 299.99, 3, LocalDate.of(2024, 2, 10)),
new Sale("Chair", "Furniture", "West", 199.99, 15, LocalDate.of(2024, 2, 14)),
new Sale("Laptop", "Electronics", "East", 999.99, 7, LocalDate.of(2024, 3, 1)),
new Sale("Phone", "Electronics", "North", 699.99, 20, LocalDate.of(2024, 3, 5)),
new Sale("Notebook", "Office", "South", 4.99, 500,LocalDate.of(2024, 3, 10)),
new Sale("Pen", "Office", "West", 1.99, 1000, LocalDate.of(2024, 3, 15)),
new Sale("Chair", "Furniture", "North", 199.99, 10, LocalDate.of(2024, 4, 1)),
new Sale("Desk", "Furniture", "South", 299.99, 5, LocalDate.of(2024, 4, 10)),
new Sale("Laptop", "Electronics", "West", 999.99, 3, LocalDate.of(2024, 4, 20)),
new Sale("Phone", "Electronics", "East", 699.99, 15, LocalDate.of(2024, 5, 1)),
new Sale("Tablet", "Electronics", "South", 449.99, 6, LocalDate.of(2024, 5, 10)),
new Sale("Chair", "Furniture", "East", 199.99, 20, LocalDate.of(2024, 5, 15))
);
// ===== 1. Revenue by Category =====
System.out.println("=== Revenue by Category ===");
Map revenueByCategory = sales.stream()
.collect(Collectors.groupingBy(
Sale::category,
Collectors.summingDouble(s -> s.amount() * s.quantity())
));
revenueByCategory.entrySet().stream()
.sorted(Map.Entry.comparingByValue().reversed())
.forEach(e -> System.out.printf(" %-15s $%,12.2f%n", e.getKey(), e.getValue()));
// ===== 2. Top Products by Total Revenue =====
System.out.println("\n=== Top Products by Revenue ===");
sales.stream()
.collect(Collectors.groupingBy(
Sale::product,
Collectors.summingDouble(s -> s.amount() * s.quantity())
))
.entrySet().stream()
.sorted(Map.Entry.comparingByValue().reversed())
.limit(3)
.forEach(e -> System.out.printf(" %-15s $%,12.2f%n", e.getKey(), e.getValue()));
// ===== 3. Average Order Value by Region =====
System.out.println("\n=== Average Order Value by Region ===");
Map avgByRegion = sales.stream()
.collect(Collectors.groupingBy(
Sale::region,
Collectors.averagingDouble(s -> s.amount() * s.quantity())
));
avgByRegion.forEach((region, avg) ->
System.out.printf(" %-10s $%,10.2f%n", region, avg));
// ===== 4. Monthly Revenue Trend =====
System.out.println("\n=== Monthly Revenue ===");
Map monthlyRevenue = sales.stream()
.collect(Collectors.groupingBy(
s -> s.date().getMonth(),
TreeMap::new, // sorted by month
Collectors.summingDouble(s -> s.amount() * s.quantity())
));
monthlyRevenue.forEach((month, rev) ->
System.out.printf(" %-12s $%,12.2f%n", month, rev));
// ===== 5. Products per Category with Quantities =====
System.out.println("\n=== Products per Category ===");
Map> productsByCat = sales.stream()
.collect(Collectors.groupingBy(
Sale::category,
Collectors.groupingBy(
Sale::product,
Collectors.summingInt(Sale::quantity)
)
));
productsByCat.forEach((cat, products) -> {
System.out.println(" " + cat + ":");
products.forEach((prod, qty) ->
System.out.printf(" %-15s %,d units%n", prod, qty));
});
// ===== 6. Partition: High-Value vs Low-Value Transactions =====
System.out.println("\n=== High vs Low Value Transactions ===");
double threshold = 3000;
Map highLow = sales.stream()
.collect(Collectors.partitioningBy(
s -> s.amount() * s.quantity() >= threshold,
Collectors.counting()
));
System.out.println(" High-value (>= $" + threshold + "): " + highLow.get(true));
System.out.println(" Low-value (< $" + threshold + "): " + highLow.get(false));
// ===== 7. Region with Highest Total Revenue =====
sales.stream()
.collect(Collectors.groupingBy(
Sale::region,
Collectors.summingDouble(s -> s.amount() * s.quantity())
))
.entrySet().stream()
.max(Map.Entry.comparingByValue())
.ifPresent(e -> System.out.printf(
"%n=== Top Region ===%n %s with $%,.2f total revenue%n",
e.getKey(), e.getValue()));
// ===== 8. Summary Statistics per Category =====
System.out.println("\n=== Revenue Stats per Category ===");
sales.stream()
.collect(Collectors.groupingBy(
Sale::category,
Collectors.summarizingDouble(s -> s.amount() * s.quantity())
))
.forEach((cat, stats) -> System.out.printf(
" %s: %d transactions, avg=$%,.2f, min=$%,.2f, max=$%,.2f, total=$%,.2f%n",
cat, stats.getCount(), stats.getAverage(),
stats.getMin(), stats.getMax(), stats.getSum()));
// ===== 9. Category-Region Cross Report =====
System.out.println("\n=== Category-Region Revenue Matrix ===");
Map> matrix = sales.stream()
.collect(Collectors.groupingBy(
Sale::category,
Collectors.groupingBy(
Sale::region,
Collectors.summingDouble(s -> s.amount() * s.quantity())
)
));
matrix.forEach((cat, regions) -> {
System.out.println(" " + cat + ":");
regions.entrySet().stream()
.sorted(Map.Entry.comparingByValue().reversed())
.forEach(e -> System.out.printf(" %-10s $%,10.2f%n", e.getKey(), e.getValue()));
});
// ===== 10. Full Report as Formatted String =====
System.out.println("\n=== Full Sales Report ===");
String report = sales.stream()
.sorted(Comparator.comparing(Sale::date))
.map(s -> String.format(" %s | %-10s | %-12s | %-6s | %3d units | $%,10.2f",
s.date(), s.product(), s.category(), s.region(),
s.quantity(), s.amount() * s.quantity()))
.collect(Collectors.joining("\n"));
System.out.println(String.format(" %-10s | %-10s | %-12s | %-6s | %9s | %12s",
"Date", "Product", "Category", "Region", "Qty", "Revenue"));
System.out.println(" " + "-".repeat(75));
System.out.println(report);
}
}
| Collector | Use Case | Example |
|---|---|---|
toList() |
Collect to mutable List | stream.collect(Collectors.toList()) |
toSet() |
Collect to Set (no duplicates) | stream.collect(Collectors.toSet()) |
toMap() |
Build key-value pairs | Collectors.toMap(k, v, merge) |
joining() |
Concatenate strings | Collectors.joining(", ", "[", "]") |
groupingBy() |
Group by classifier | Collectors.groupingBy(f, downstream) |
partitioningBy() |
Split into true/false | Collectors.partitioningBy(pred) |
counting() |
Count elements in group | groupingBy(f, counting()) |
summingDouble() |
Sum numeric values | groupingBy(f, summingDouble(g)) |
averagingDouble() |
Average numeric values | groupingBy(f, averagingDouble(g)) |
summarizingDouble() |
All stats in one pass | groupingBy(f, summarizingDouble(g)) |
minBy() / maxBy() |
Min/max in group | groupingBy(f, minBy(comp)) |
reducing() |
Custom reduction per group | groupingBy(f, reducing(id, mapper, op)) |
mapping() |
Transform before downstream | groupingBy(f, mapping(g, toList())) |
flatMapping() |
Flatten before downstream | groupingBy(f, flatMapping(g, toSet())) |
filtering() |
Filter within each group | groupingBy(f, filtering(pred, toList())) |
Collector.of() |
Build a custom collector | Collector.of(supplier, acc, comb, fin) |
collect() terminal operation relies on them entirely.Collector.of() give you full control when built-in options are insufficient.