📌 JDK 版本要求:JDK 8+(推荐 JDK 21)
一、为什么需要自定义收集器?
Java 提供了丰富的内置收集器:
java
List<String> list = stream.collect(Collectors.toList());
Map<String, Integer> map = stream.collect(Collectors.toMap(k, v));
String joined = stream.collect(Collectors.joining(", "));
但当遇到以下场景,内置收集器就无能为力了:
- 按固定大小分页(如每 3 个元素一组);
- 收集到不可变集合(如
ImmutableList); - 同时计算多个统计量(如 min + max + count);
- 将流转换为自定义对象(如
PageResult<T>)。
这时,Collector.of() 就派上用场了。
二、Collector 的四要素
一个 Collector<T, A, R> 由四个函数组成:
| 组件 | 类型 | 作用 | 关键约束 |
|---|---|---|---|
| supplier | () → A |
创建累加器容器(Accumulator) | 必须返回新实例(无状态) |
| accumulator | (A, T) → void |
将元素 T 合并到容器 A |
必须是副作用操作(修改 A) |
| combiner | (A, A) → A |
合并两个容器(用于并行流) | 必须满足结合律,且不修改输入 |
| finisher | (A) → R |
将容器 A 转换为最终结果 R |
若 A == R 且不可变,可省略 |
⚠️ 重要概念区分:
A(Intermediate Accumulator) :中间累加器,通常是可变容器 (如ArrayList);R(Final Result) :最终结果,可以是不可变对象 (如String、ImmutableList)。
三、基础示例:手写 toList()
我们先用 Collector.of() 重写 Collectors.toList(),以理解基本结构。
正确实现(可变容器 + 无 finisher)
java
Collector<String, List<String>, List<String>> myToList = Collector.of(
ArrayList::new, // supplier: 创建新列表
List::add, // accumulator: 添加元素
(left, right) -> { // combiner: 合并两个列表
left.addAll(right);
return left;
}
// 无 finisher:因为 A (ArrayList) 可直接作为 R (List)
);
List<String> result = Stream.of("a", "b", "c")
.collect(myToList);
// result = ["a", "b", "c"]
✅ 为什么没有 finisher?
因为累加器
ArrayList本身就是合法的最终结果类型List,且我们接受其可变性。
四、进阶示例:收集到不可变集合
若希望结果是不可变列表 (如 Guava 的 ImmutableList 或 JDK 21 的 List.copyOf()),就必须使用 finisher。
正确实现(带 finisher)
java
Collector<String, List<String>, List<String>> toImmutableList = Collector.of(
ArrayList::new,
List::add,
(left, right) -> {
left.addAll(right);
return left;
},
Collections::unmodifiableList // finisher: 包装为不可变视图
// 或 JDK 10+: list -> List.copyOf(list)
);
🔍 关键点:
- 累加器
A仍是可变的ArrayList(高效添加);- 最终结果
R是不可变的List(通过 finisher 转换);- 不要 在 supplier 中直接返回
Collections.emptyList()------ 它不可变,无法 add!
五、实战 1:按固定大小分页
需求 :将 [1,2,3,4,5,6,7] 按每页 3 个元素,收集为 [[1,2,3], [4,5,6], [7]]。
正确实现
java
public static <T> Collector<T, ?, List<List<T>>> toPages(int pageSize) {
if (pageSize <= 0) throw new IllegalArgumentException("pageSize > 0");
return Collector.of(
() -> new ArrayList<List<T>>(), // A = List<List<T>>
(pages, item) -> {
if (pages.isEmpty() || pages.get(pages.size() - 1).size() >= pageSize) {
pages.add(new ArrayList<>()); // 开新页
}
pages.get(pages.size() - 1).add(item); // 加入最后一页
},
(left, right) -> {
if (left.isEmpty()) return right;
if (right.isEmpty()) return left;
// 合并:将 right 第一页追加到 left 最后一页(若未满)
List<T> lastPage = left.get(left.size() - 1);
List<T> firstPageOfRight = right.get(0);
if (lastPage.size() < pageSize) {
int space = pageSize - lastPage.size();
int take = Math.min(space, firstPageOfRight.size());
lastPage.addAll(firstPageOfRight.subList(0, take));
if (take == firstPageOfRight.size()) {
left.addAll(right.subList(1, right.size()));
} else {
List<T> remaining = new ArrayList<>(firstPageOfRight.subList(take, firstPageOfRight.size()));
List<List<T>> newRight = new ArrayList<>();
newRight.add(remaining);
newRight.addAll(right.subList(1, right.size()));
left.addAll(newRight);
}
} else {
left.addAll(right);
}
return left;
}
// 无 finisher:A 已是所需 R
);
}
使用
java
List<Integer> input = Arrays.asList(1, 2, 3, 4, 5, 6, 7);
List<List<Integer>> pages = input.stream().collect(toPages(3));
// pages = [[1,2,3], [4,5,6], [7]]
💡 注意 :combiner 逻辑复杂,因需处理"跨分区页合并"。若仅用于顺序流,可简化(但失去并行能力)。
六、实战 2:自定义字符串拼接(带前缀/后缀/分隔符)
虽然 Collectors.joining() 已很强大,但假设我们需要在拼接前对每个元素做特殊处理(如加引号)。
正确实现
java
Collector<String, StringBuilder, String> quoteJoining = Collector.of(
() -> new StringBuilder(),
(sb, item) -> {
if (sb.length() > 0) sb.append(", ");
sb.append('"').append(item).append('"');
},
(left, right) -> left.append(", ").append(right), // 注意:right 已含内容
StringBuilder::toString
);
String result = Stream.of("apple", "banana")
.collect(quoteJoining);
// result = "\"apple\", \"banana\""
⚠️ 常见错误:在 combiner 中重复添加分隔符。
✅ 正确做法:combiner 应直接拼接两个完整片段,假设它们已正确格式化。
七、实战 3:同时计算 min、max、count(自定义统计对象)
需求:一次遍历,得到最小值、最大值、总数。
定义结果类
java
public class Stats {
private final int min;
private final int max;
private final long count;
public Stats(int min, int max, long count) {
this.min = min; this.max = max; this.count = count;
}
// getters...
}
实现收集器
java
Collector<Integer, ?, Stats> statsCollector = Collector.of(
() -> new int[]{Integer.MAX_VALUE, Integer.MIN_VALUE, 0}, // A = [min, max, count]
(acc, value) -> {
acc[0] = Math.min(acc[0], value);
acc[1] = Math.max(acc[1], value);
acc[2]++;
},
(a1, a2) -> {
return new int[]{
Math.min(a1[0], a2[0]),
Math.max(a1[1], a2[1]),
a1[2] + a2[2]
};
},
acc -> new Stats(acc[0], acc[1], acc[2]) // finisher
);
✅ 优势:单次遍历,并行安全,内存高效。
八、Collector.of() vs Collectors.reducing()
| 场景 | 推荐方案 |
|---|---|
| 简单归约(如 sum、max) | Collectors.reducing() |
| 需要可变中间容器(如 list、map) | Collector.of() |
| 需要自定义结果类型 | Collector.of() |
| 并行流 + 复杂合并逻辑 | Collector.of()(显式控制 combiner) |
💡
reducing本质是 immutable fold,而Collector.of更适合 mutable accumulation。
九、Spring Boot / 生产环境建议
- 优先使用内置收集器 :
toList(),toUnmodifiableList(),groupingBy()等已高度优化; - 自定义收集器务必单元测试:覆盖顺序/并行、空流、单元素等边界;
- 避免在 Web 层滥用复杂收集器:可读性 > 性能微优化;
- 考虑使用第三方库 :如 Guava 的
ImmutableList.toImmutableList()。
十、代码在哪?
本篇涉及到的代码已上传至 GitHub:
https://github.com/iweidujiang/java-tricks-lab
欢迎 star & fork !