1. What is the Collectors Class?
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:
- Supplier — how to create the result container (e.g.,
new ArrayList<>()) - Accumulator — how to add an element to the container (e.g.,
list.add(element)) - Combiner — how to merge two containers (needed for parallel streams)
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<String> names = List.of("Alice", "Bob", "Charlie", "Alice", "David");
// Without Collectors -- manual collect (verbose)
List<String> 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<String> 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]
}
}2. Basic Collectors: toList(), toSet(), toCollection()
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<String> names = List.of("Charlie", "Alice", "Bob", "Alice", "David", "Bob");
// toList() -- ArrayList (mutable)
List<String> 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<String> 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<String> 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<String> immutableSet = names.stream()
.collect(Collectors.toUnmodifiableSet());
System.out.println("toUnmodifiableSet: " + immutableSet);
// toCollection() -- specify the exact collection type
TreeSet<String> treeSet = names.stream()
.collect(Collectors.toCollection(TreeSet::new));
System.out.println("TreeSet (sorted): " + treeSet);
// Output: TreeSet (sorted): [Alice, Bob, Charlie, David]
LinkedList<String> linkedList = names.stream()
.collect(Collectors.toCollection(LinkedList::new));
System.out.println("LinkedList: " + linkedList);
// Java 16+: Stream.toList() shorthand (returns unmodifiable list)
List<String> java16List = names.stream()
.filter(n -> n.startsWith("A"))
.toList();
System.out.println("Stream.toList(): " + java16List);
// Output: Stream.toList(): [Alice, Alice]
}
}3. toMap()
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.
3.1 Basic toMap
import java.util.*;
import java.util.stream.Collectors;
public class ToMapBasic {
public static void main(String[] args) {
List<String> names = List.of("Alice", "Bob", "Charlie", "David");
// Basic: name -> length
Map<String, Integer> 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<String> colors = List.of("Red", "Green", "Blue");
Map<Integer, String> 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<Integer, String> indexedStream = colors.stream()
.collect(Collectors.toMap(
color -> counter.getAndIncrement(),
color -> color
));
System.out.println("Indexed: " + indexedStream);
// Output: Indexed: {0=Red, 1=Green, 2=Blue}
}
}3.2 Handling Duplicate Keys (Merge Function)
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<String> names = List.of("Alice", "Anna", "Bob", "Brian", "Charlie");
// Group by first letter -- FAILS if duplicate keys
// Map<Character, String> 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<Character, String> 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<Character, String> 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<Character, String> 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<String> words = List.of("apple", "banana", "apple", "cherry", "banana", "apple");
Map<String, Integer> 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}
}
}3.3 Controlling the Map Type
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<String> names = List.of("Charlie", "Alice", "Bob", "David");
// TreeMap -- keys sorted alphabetically
TreeMap<String, Integer> 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<String, Integer> 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}
}
}4. joining()
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<String> 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<String> 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<Integer> 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<String> items = List.of("Home", "About", "Contact");
String html = items.stream()
.map(item -> " <li>" + item + "</li>")
.collect(Collectors.joining("\n", "<ul>\n", "\n</ul>"));
System.out.println(html);
// joining() on empty stream returns empty prefix+suffix
String empty = List.<String>of().stream()
.collect(Collectors.joining(", ", "[", "]"));
System.out.println("Empty: " + empty);
// Output: Empty: []
}
}5. groupingBy()
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.
5.1 Single-Level Grouping
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<Employee> 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<String, List<Employee>> 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<String> words = List.of("cat", "dog", "fish", "bird", "ant", "fox", "bear");
Map<Integer, List<String>> 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]}
}
}5.2 groupingBy with Downstream Collectors
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<Employee> 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<String, Long> 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<String, Double> avgByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.averagingDouble(Employee::salary)
));
System.out.println("Average salary: " + avgByDept);
// Sum of salaries per department
Map<String, Double> totalByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.summingDouble(Employee::salary)
));
System.out.println("Total salary: " + totalByDept);
// Max salary per department
Map<String, Optional<Employee>> 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<String, List<String>> 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<String, String> 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<String, Set<String>> nameSetByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.mapping(Employee::name, Collectors.toSet())
));
System.out.println("Name sets: " + nameSetByDept);
}
}5.3 Multi-Level Grouping
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<Employee> 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<String, Map<String, List<Employee>>> 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<String, Map<String, Long>> 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<String, Map<String, Double>> avgSalary = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.groupingBy(
Employee::level,
Collectors.averagingDouble(Employee::salary)
)
));
System.out.println("\nAvg salary: " + avgSalary);
}
}6. partitioningBy()
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<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
// Partition into even and odd
Map<Boolean, List<Integer>> 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<Boolean, Long> 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<Student> 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<Boolean, List<Student>> 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<Boolean, List<String>> 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<Boolean, List<Integer>> 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)
}
}7. Counting and Statistics
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<Product> 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<String, Long> 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<String, DoubleSummaryStatistics> 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<Product> cheapest = products.stream()
.collect(Collectors.minBy(Comparator.comparingDouble(Product::price)));
cheapest.ifPresent(p -> System.out.println("\nCheapest: " + p.name() + " $" + p.price()));
Optional<Product> mostExpensive = products.stream()
.collect(Collectors.maxBy(Comparator.comparingDouble(Product::price)));
mostExpensive.ifPresent(p -> System.out.println("Most expensive: " + p.name() + " $" + p.price()));
}
}8. Reducing and Mapping
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.
8.1 Collectors.reducing()
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<Employee> 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<String, Double> 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<String, Optional<Employee>> 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<String, String> 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);
}
}8.2 Collectors.mapping() and Collectors.flatMapping()
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<String> skills) {}
public static void main(String[] args) {
List<Employee> 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<String, List<String>> 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<String, String> 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<String, Set<String>> 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<String, List<String>> 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]}
}
}9. Custom Collectors
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<Integer> 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<String> names = List.of("Alice", "Bob", "Charlie");
List<String> 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<String> items = List.of("A", "B", "C", "D", "E", "F");
Map<String, List<String>> evenOddIndex = items.stream()
.collect(Collector.of(
() -> {
Map<String, List<String>> 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]
}
}Reusable Custom Collector
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 <T> Collector<T, ?, LinkedList<T>> toLinkedList() {
return Collector.of(
LinkedList::new,
LinkedList::add,
(list1, list2) -> { list1.addAll(list2); return list1; }
);
}
// Custom collector: toReversedList()
public static <T> Collector<T, ?, List<T>> 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<String> names = List.of("Alice", "Bob", "Charlie", "David");
// Use custom toLinkedList()
LinkedList<String> 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<String> reversed = names.stream()
.collect(toReversedList());
System.out.println("Reversed: " + reversed);
// Output: Reversed: [David, Charlie, Bob, Alice]
}
}10. Complete Practical Example: Sales Analytics
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<Sale> 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<String, Double> revenueByCategory = sales.stream()
.collect(Collectors.groupingBy(
Sale::category,
Collectors.summingDouble(s -> s.amount() * s.quantity())
));
revenueByCategory.entrySet().stream()
.sorted(Map.Entry.<String, Double>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.<String, Double>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<String, Double> 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<Month, Double> 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<String, Map<String, Integer>> 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<Boolean, Long> 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<String, Map<String, Double>> 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.<String, Double>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);
}
}Collectors Quick Reference
| 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) |
Key Takeaways
- Collectors are the bridge between streams and concrete data structures. The
collect()terminal operation relies on them entirely. - toList(), toSet(), toMap() cover 80% of use cases. Learn these first.
- groupingBy() is the Swiss army knife for analytics. Combined with downstream collectors (counting, summing, averaging, mapping), it replaces complex manual grouping code.
- partitioningBy() is a specialized groupingBy for boolean splits — always has both true and false keys.
- Downstream collectors (counting, mapping, reducing, filtering, flatMapping) compose inside groupingBy to build powerful data transformations.
- toMap() merge functions handle duplicate keys gracefully — decide whether to keep first, keep last, or combine.
- Custom collectors via
Collector.of()give you full control when built-in options are insufficient. - summarizingDouble() is efficient — it computes count, sum, min, max, and average in a single pass.