Building a Custom Java Stream Collector for Department‑wise Average Salary
Learn how to create a Java Stream Collector that groups employees by department and computes average salary, with notes on thread‑safety and verification.
29 Apr 2026, 08:02 UTC

Why a Custom Collector?
When you need to turn a stream of Employee objects into a map that shows the average salary per department, writing a manual loop obscures the intent and repeats boilerplate code across the codebase.
Thesis
A well‑designed Collector encapsulates the reduction logic, making the stream pipeline expressive, reusable, and easier to test while keeping the performance benefits of internal iteration.
Understanding the Collector Contract
The Collector interface defines five methods: supplier, accumulator, combiner, finisher, and characteristics. For a mutable result container we can return IDENTITY_FINISH so the finisher is unnecessary.
Worked Example: Department‑wise Average Salary
public class Employee {
private final String department;
private final double salary;
public Employee(String department, double salary) {
this.department = department;
this.salary = salary;
}
public String getDepartment() { return department; }
public double getSalary() { return salary; }
}
The collector keeps a running total of salaries and a count per department.
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.function.BiConsumer;
import java.util.function.BinaryOperator;
import java.util.function.Supplier;
import java.util.stream.Collector;
public class DepartmentAverageCollector implements Collector, Map> {
private static class Stats {
double total = 0.0;
long count = 0;
}
@Override
public Supplier> supplier() {
return HashMap::new;
}
@Override
public BiConsumer, Employee> accumulator() {
return (map, emp) -> {
String dept = emp.getDepartment();
Stats s = map.computeIfAbsent(dept, k -> new Stats());
s.total += emp.getSalary();
s.count++;
};
}
@Override
public BinaryOperator> combiner() {
return (map1, map2) -> {
map2.forEach((dept, stats2) -> {
Stats stats1 = map1.computeIfAbsent(dept, k -> new Stats());
stats1.total += stats2.total;
stats1.count += stats2.count;
});
return map1;
};
}
@Override
public java.util.function.Function, Map> finisher() {
return map -> {
Map result = new HashMap<>(map.size());
for (Map.Entry e : map.entrySet()) {
Stats s = e.getValue();
result.put(e.getKey(), s.total / s.count);
}
return result;
};
}
@Override
public Set characteristics() {
return java.util.stream.Collector.Characteristics.IDENTITY_FINISH;
}
}
Using the collector:
List staff = Arrays.asList(
new Employee(\"Engineering\", 90000),
new Employee(\"Engineering\", 110000),
new Employee(\"HR\", 60000),
new Employee(\"HR\", 65000)
);
Map avgByDept = staff.stream()
.collect(new DepartmentAverageCollector());
System.out.println(avgByDept);
// Expected output: {Engineering=100000.0, HR=62500.0}
Trade‑offs and Limitations
- Thread safety: The mutable
Mapis not thread‑safe. When the stream is parallel, the combiner must correctly merge partial results; the accumulator itself is called from multiple threads only if the collector declaresCONCURRENT, which we do not. Therefore use this collector with sequential streams or ensure the stream is not parallel. - Characteristics: We return only
IDENTITY_FINISH. Forgetting to declareUNORDEREDwhen order does not matter can prevent some optimizations, but it does not affect correctness. - Over‑engineering: For simple cases, built‑in collectors like
Collectors.groupingBycombined withCollectors.averagingDoubleare preferable. Use a custom collector only when the reduction logic cannot be expressed with existing combiners or when you need to reuse the same complex aggregation across many pipelines.
How to Verify Correctness and Performance
- Compile the example with JDK 17 (or later) and run the main method. Compare the printed map with the values obtained from a straightforward imperative loop.
- To check characteristics, invoke
new DepartmentAverageCollector().characteristics()and ensure the set containsIDENTITY_FINISHand no unexpected elements. - For a rough performance sanity check, create a list of 1 000 000
Employeeinstances, run the stream with the custom collector, and time it withSystem.nanoTime(). Compare the elapsed time to an equivalent loop that builds the same map. The custom collector should be in the same order of magnitude; large discrepancies may indicate a problem in the combiner or accumulator.
Remember that micro‑benchmarks are sensitive to JVM warm‑up; a proper JMH harness is recommended for production‑grade performance claims.
Actionable Closing
If you find yourself writing the same loop‑based aggregation in multiple places, extract the logic into a Collector. Keep the mutable container minimal, declare the correct characteristics, and test with both sequential and (if needed) parallel streams. This approach yields clearer intent, easier unit testing, and retains the efficiency of the Stream API.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.