Stream 的中间操作原来是固定的一套:map、filter、distinct、sorted 这些。想按固定大小分组、算滑动窗口、按某个字段去重,要么退回 for 循环,要么拿 collect 硬凑一堆临时集合。

JEP 485 在 JDK 24 把 Stream Gatherers 定稿了,之前在 JDK 22、23 预览过两轮。它给 Stream 加了一个 gather 方法,中间操作从此可以自己写。java.util.stream.Gatherers 里自带 windowFixed、windowSliding、fold、scan、mapConcurrent 五个实现,不够用就拿 Gatherer.ofSequential 或 Gatherer.of 自己拼。

下面用一个 Spring Boot 4.1 应用把这几种都跑一遍,JDK 用的是 27 GA。应用启动日志:

openjdk version "27" 2026-09-15
OpenJDK Runtime Environment (build 27+35-2325)
OpenJDK 64-Bit Server VM (build 27+35-2325, mixed mode, sharing)
08:12:12.125 INFO  DemoApplication - Started DemoApplication in 1.318 seconds (process running for 1.733)

下面每个接口的输出都来自这个进程。

先过几个内置的

windowFixed(n) 把流按每 n 个切成一组,最后不足 n 个的单独成组。攒够一批再批量写库,是最常见的用法。

@GetMapping("/window-fixed")
public List<List<Integer>> windowFixed() {
    return Stream.of(10, 20, 30, 40, 50, 60, 70)
            .gather(Gatherers.windowFixed(3))
            .toList();
}
$ curl -s localhost:18088/window-fixed
[[10,20,30],[40,50,60],[70]]

windowSliding(n) 每次只往前挪一格,相邻两组重叠 n-1 个。算移动平均直接套上去。

@GetMapping("/moving-average")
public List<Double> movingAverage() {
    return Stream.of(10, 20, 30, 40, 50)
            .gather(Gatherers.windowSliding(3))
            .map(w -> w.stream().mapToInt(Integer::intValue).average().orElse(0))
            .toList();
}
$ curl -s localhost:18088/moving-average
[20.0,30.0,40.0]

scan 和 reduce 不一样,它把每一步的中间结果都发下去,不是只给最后一个值。做运行总和、逐步累积状态正合适。

@GetMapping("/running-sum")
public List<Integer> runningSum() {
    return Stream.of(1, 2, 3, 4, 5)
            .gather(Gatherers.scan(() -> 0, Integer::sum))
            .toList();
}
$ curl -s localhost:18088/running-sum
[1,3,6,10,15]

fold 可以看成 scan 的单值版,只在流结束时发出一个累积结果,这里不单独演示。mapConcurrent 用虚拟线程并发执行映射函数,可以限定最大并发数,属于另一类用法,这里也不展开。

自己写一个带状态的 Gatherer

自定义才是重点。Gatherer.ofSequential 有几个重载,参数最全的一版收三样:initializer 提供初始状态,Integrator 处理每个元素,finisher 在流结束时收尾。initializer 和 finisher 都可以省。名字里的 Sequential 表示它只按顺序执行。要支持并行流,得用 Gatherer.of,再多给一个 combiner 合并各段的状态。

先看按 key 去重。状态就是一个已见过的 key 集合。

public static <T, K> Gatherer<T, ?, T> distinctBy(Function<? super T, ? extends K> keyFn) {
    return Gatherer.ofSequential(
            () -> new java.util.HashSet<K>(),
            (seen, element, downstream) -> {
                if (seen.add(keyFn.apply(element))) {
                    return downstream.push(element);
                }
                return true;
            });
}

integrator 的第三个参数 downstream 就是下游。push 返回 false 表示下游不再接收元素。把这个返回值原样返回,流就能提前停下。标准库的 Stream.distinct 只能按 equals 去重,按某个字段去重以前得自己想办法,现在写成一个 gatherer 就能到处复用。

@GetMapping("/distinct-by")
public List<Reading> distinctBy() {
    return Stream.of(
                    new Reading("a", 1), new Reading("b", 2),
                    new Reading("a", 3), new Reading("c", 4),
                    new Reading("b", 5))
            .gather(Gatherers485.distinctBy(Reading::sensor))
            .toList();
}
$ curl -s localhost:18088/distinct-by
[{"sensor":"a","value":1},{"sensor":"b","value":2},{"sensor":"c","value":4}]

a 和 b 的第二条都被丢掉了,每个传感器只留第一条。

带收尾逻辑的分批

windowFixed 只能按个数切。要按累计金额切批,就得自己攒状态。下面这个累计到 100 就切一批,剩下不满 100 的,在流结束时由 finisher 补发出去。

public static Gatherer<Integer, ?, List<Integer>> batchUntilSum(int limit) {
    class Acc {
        final List<Integer> items = new ArrayList<>();
        int sum;
    }
    return Gatherer.ofSequential(
            Acc::new,
            (acc, element, downstream) -> {
                acc.items.add(element);
                acc.sum += element;
                if (acc.sum >= limit) {
                    boolean ok = downstream.push(List.copyOf(acc.items));
                    acc.items.clear();
                    acc.sum = 0;
                    return ok;
                }
                return true;
            },
            (acc, downstream) -> {
                if (!acc.items.isEmpty()) {
                    downstream.push(List.copyOf(acc.items));
                }
            });
}

第三个 lambda 就是 finisher。没有它,最后那批不满 100 的元素会被悄悄吞掉。

$ curl -s localhost:18088/batch-until-sum
[[30,40,50],[10,5,90],[20]]

30+40+50 到 120,切一批。10+5+90 到 105,再切一批。最后剩下的 20 由 finisher 补上。

gatherer 可以串起来

gather 后面能接着 gather,和普通中间操作一样往下链。下面先按传感器去重,再每 2 个一组。想把两个 gatherer 合成一个复用,也可以用 Gatherer.andThen。

@GetMapping("/compose")
public Map<String, Object> compose() {
    var result = Stream.of(
                    new Reading("a", 1), new Reading("a", 9),
                    new Reading("b", 2), new Reading("c", 3),
                    new Reading("c", 8), new Reading("d", 4))
            .gather(Gatherers485.distinctBy(Reading::sensor))
            .gather(Gatherers.windowFixed(2))
            .toList();
    Map<String, Object> out = new LinkedHashMap<>();
    out.put("jdk", Runtime.version().toString());
    out.put("groups", result);
    return out;
}
$ curl -s localhost:18088/compose
{"jdk":"27+35-2325","groups":[[{"sensor":"a","value":1},{"sensor":"b","value":2}],[{"sensor":"c","value":3},{"sensor":"d","value":4}]]}

去重后剩 a、b、c、d 四条,再两两成组。

和 mapMulti 的分工

之前那篇不要使用 Stream 的 filter().map(),试试 mapMulti()讲的是一对多展开:一个元素摊成零到多个,元素之间不共享状态。Gatherers 管的是另一类事:处理当前元素之前,得记住前面看过什么,比如分组、去重、累积、滑窗。两者各管一摊,并不冲突。

— 感谢阅读 —
广告

一起交流

分享你的思考,让讨论更进一步。