实战:用构建器模式优雅创建动态数据流)
最近在代码评审时我看到不少同事处理“按条件动态生成数据集合”时还是老一套先new一个ArrayList然后在一堆if判断里add最后再list.stream()。这套写法运行起来没有问题但它有个隐藏的别扭之处——明明整个代码风格已经走到函数式了开头却必须摆一个命令式的可变集合总让人觉得多了一层不必要的“中间人”。后来我养成了一个习惯这类场景优先用Stream.builder()。Java Stream API 里其实藏着一个专门为“逐条添加、最终成流”准备的构建器也就是Stream.BuilderT。今天这篇就围绕“使用构建器模式创建 Stream”这件事把它的完整用法、内部原理、最容易踩的坑以及和Stream.of、集合.stream()之间的选择边界一次讲透。先说清楚这篇不是泛泛讲设计模式里的 Builder 理论而是聚焦 JDK 内置的Stream.builder()这个隐藏小工具。我会从实际编码场景出发掰开揉碎讲它适合干什么、不适合干什么。1. 为什么我们的代码里会需要“现攒现用”的 Stream1.1 一个日常开发中的典型痛点条件收集先看一个我最近在风控系统里遇到的真实场景。系统需要根据前端传入的查询条件从内存中的风险事件列表里捞数据参与后续的规则计算。条件是不固定的可能只查高危事件可能只查已阻断的事件也可能两个都要还要叠加时间范围。用传统写法代码大概率长这样ListRiskEvent result new ArrayList(); for (RiskEvent event : sourceList) { boolean matched false; if (query.includeHighRisk() event.riskLevel() 80) { matched true; } if (query.includeBlocked() event.status() Blocked) { matched true; } if (query.beginTime() ! null event.time().isAfter(query.beginTime())) { matched true; } if (matched) { result.add(event); } } return result.stream();这段代码可读性不差但你仔细观察会发现一个尴尬result这个集合变量从头到尾只做了一件事——承载“还没变成流的元素”。它存在的唯一意义就是过渡。而且这种写法在大型方法里到处都是时代码会充满“为了收集而收集”的临时变量。同一个场景用Stream.builder()写Stream.BuilderRiskEvent builder Stream.builder(); for (RiskEvent event : sourceList) { boolean matched false; if (query.includeHighRisk() event.riskLevel() 80) { matched true; } if (query.includeBlocked() event.status() Blocked) { matched true; } if (query.beginTime() ! null event.time().isAfter(query.beginTime())) { matched true; } if (matched) { builder.add(event); } } return builder.build();对比一下两者的循环逻辑完全相同但后者不再需要创建一个额外的ArrayList变量。builder从命名上就传达了它的角色“我在构建一个流”。代码意图比result更明确——result是什么是列表还是流读者还得往下看才能确认。1.2 集合中转与函数式风格的冲突很多团队现在写代码都已经习惯了filter、map、collect这一套流水线思维但动态构造数据源时却总会退回到“先建集合、再转流”的老路上。这不仅仅是风格问题还牵扯到一个实际考虑集合中转意味着你至少要经历一次“把元素塞进数组/链表”的过程如果元素数量大你还要预估初始容量否则扩容阶段会有额外开销。Stream.Builder内部实现用的是SpinedBuffer它不是一个固定大小的数组而是一组分段式缓冲区按需增长。好处在于你不需要提前知道最终会有多少个元素它不会像ArrayList那样频繁扩容拷贝也不会像链表那样每个节点都产生额外对象。对“数量不确定、平均规模中等偏大”的收集场景它其实比临时ArrayList更贴合。另一个隐性收益是惰性。builder.build()返回的流被消费时元素才真正开始流动。如果你的收集过程本身是轻量的只是遍历内存对象这个差异不明显但如果收集过程绑定了 IO 或其他资源惰性就能体现出价值——你可以把这个流继续往下传由下游决定到底要不要消费、消费多少。2. Stream.Builder 的完整拆解三个方法、一套状态2.1 accept、add、build 的分工Stream.BuilderT这个接口总共只有三个方法但每一个的定位都不同Stream.BuilderT add(T t); void accept(T t); StreamT build();add是链式入口它返回Builder本身。源代码实现非常简单default Stream.BuilderT add(T t) { accept(t); return this; }所以你能写出这种链式调用Stream.Stringbuilder() .add(2024-01) .add(2024-02) .add(2024-03) .build() .forEach(System.out::println);accept返回void这一点经常被忽略。它存在的真实目的是让Stream.Builder可以作为一个ConsumerT直接传入其他流操作中。比如你想把好几个不同来源的流合并到一个构建过程里Stream.BuilderOrder builder Stream.builder(); paidOrders.stream().limit(10).forEach(builder::accept); pendingOrders.stream().filter(Order::urgent).forEach(builder::accept); historyOrders.stream().map(Order::fromArchive).forEach(builder::accept); return builder.build();这里写builder::accept和builder::add都能通过编译因为方法引用中返回值会被自动忽略。但从语义上说accept更准确——它明确表达“我正在把这个流里的元素喂给构建器”而add更像是“我在主动追加一个元素”。build()是收尾动作返回一个顺序流。注意官方文档有个容易误读的表述build()被调用后builder 进入“已构建”状态此后不能再调用accept或add否则会抛出IllegalStateException。这个状态规则特别重要我后面专门讲。2.2 状态机build 之后会发生什么Stream.Builder本质上是一个小型状态机。它的内部会记录两个关键信息一是已经收集了多少元素二是否已经被build过。我见过不少同学在使用时写出这样的代码Stream.BuilderString builder Stream.builder(); builder.add(a); StreamString first builder.build(); builder.add(b); // 这里会抛 IllegalStateException第一次build()之后的任何添加操作都是非法的。原因也很直观builder 已经把内部的元素状态移交给了流你再去修改它前面构建出来的流内容就可能被破坏这违背了不可变流的设计原则。至于“同一个 builder 能否多次调用build()”——理论上官方文档允许每次调用都会返回一个新的Stream但这些流共享同一个内部Spliterator。这意味着如果你先消费了第一个流再消费第二个流第二个流可能已经拿不到元素了。我实测过多次结论是不要依赖这种复用。如果你需要同一份数据产生两个流正确做法是先用.toList()或.toArray()把它物化再从物化结果重新生成。或者干脆每次新建一个 builder成本极低。2.3 add 与 accept 的选择细节回到add和accept的区别有一个容易被忽略的坑如果你把builder.add作为方法引用传给一个要求ConsumerT的地方编译能过但阅读代码的人可能会困惑——add明明有返回值为什么这里当void用我自己的习惯是在链式表达、主动追加场景中使用add在作为Consumer、被动接收元素的场景中使用accept。这样代码的意图更清晰。评审时看到混用的情况我也会顺手建议统一。这不算大问题但一致性好的代码维护成本会低很多。3. 构建器模式与 Stream 的其他构造方式选择边界在哪3.1 五种常见构造方式一张表说清Java 里创建 Stream 的方式远不止一种很多人一上来就Stream.of导致代码在特定场景下显得很别扭。我把常用方式整理成一张对比表方便对照选择构造方式适用场景特点局限Stream.of(T...)元素数量确定且较少写法最简洁适合直接把已知常量/变量组成流元素多时参数列表冗长不支持动态追加集合.stream()已经有Collection最自然零学习成本必须已有集合对象绕不开中间集合Arrays.stream(T[])已有数组数组专用效率高只针对数组基本类型有专属重载Stream.builder()条件动态、逐条添加、数量未知构建器模式add 后 build语义清晰构建过程需要显式管理 builder 状态Stream.iterate / generate规则生成无限/有限序列配合limit使用适合算法场景不好表达“任意条件中断”Stream.concat两个流拼接简洁惰性多个流嵌套可读性差层数深了很难维护从这个表能看出Stream.builder()的独特定位就是“动态收集”。它既不需要你事先准备好一个集合也不要求元素数量已知而是允许你按照任意逻辑逐个“投喂”元素。3.2 什么时候不该用 Stream.builder很多技巧的困境在于“会了以后到处用”。Stream.builder()虽好但它在一些场景下反而是多余动作。第一种情况元素已知且少。直接Stream.of(A, B, C)一行结束你非要builder().add(...)写三行纯属冗余。第二种情况已经有现成集合。list.stream()是更直接的选择。非要为了“构建器风格”把 list 元素一个个塞进 builder等于把简单事情复杂化。第三种情况元素非常大而且你已经知道大致数量。这时候普通循环加ArrayList反而更直白因为ArrayList可以预设容量避免扩容Stream.Builder的分段缓冲虽然不会有大问题但 API 表达上没有foradd直观。第四种情况并行场景。Stream.Builder并没有承诺线程安全多个线程并发add没有任何保证。如果需要并行收集先用线程安全的集合如ConcurrentLinkedQueue聚合再统一转流才是稳妥做法。第五种情况可能投喂null。虽然Stream.Builder允许添加nullStream本身也允许元素为null但后续流水线上的绝大多数操作比如map、reduce、collect到某些容器都会因为null抛出难懂的NullPointerException。与其在流里排查不如在 add 之前就过滤掉。3.3 它和经典设计模式 Builder 的关系Stream.Builder是 JDK 内部一个最典型的 Builder 模式实例。经典 Builder 模式的核心是“把对象的构造过程与对象本身分离支持分步设置参数最后统一构建”Stream.Builder的分步操作是add/accept统一构建是build()骨架完全吻合。但它和常规 Builder 模式有一个明显差异Stream.Builder不涉及参数校验也不产生一个包含多个属性的复杂对象它只做一件事——“攒元素”。你可以把它理解为一个自助餐厅Stream.of是提前定好的套餐菜品已经列好了Stream.builder则是拿着餐盘一道一道夹菜最后一起结账。这个类比能帮你快速判断场景如果数据像菜单一样从一开始就是确定的一组别用 builder如果数据是“根据条件从多个来源各取一点”builder 就是最贴合的表达工具。4. 两个实战场景动态筛选与树结构扁平化4.1 场景一动态条件拼接数据流回到文章开头那个风控场景我给出完整的可运行版本。假设我们有一个订单列表要根据参数决定哪些订单进入后续统计管道public StreamOrder buildConditionalStream( ListOrder allOrders, boolean filterPaid, boolean filterAmount) { Stream.BuilderOrder builder Stream.builder(); for (Order order : allOrders) { if (filterPaid !order.isPaid()) { continue; } if (filterAmount order.amount() 100) { continue; } builder.add(order); } return builder.build(); }调用方完全可以在返回的 Stream 上继续串联操作StreamOrder stream buildConditionalStream(dbOrders, true, true); stream.filter(o - o.shippedFrom().equals(上海)) .map(Order::getSummary) .forEach(System.out::println);那你可能会问这个功能用filter不也能实现吗为什么非要 builder区别在于filter只能基于已有元素做横向过滤而 builder 允许你在收集元素时执行更复杂的组合逻辑。举个具体差异当某个开关打开时你不仅要保留原有数据还要额外注入几条系统补齐的辅助记录或者当某个分支命中时你需要连续追加一串相关元素。这种“额外补充、按需注入”的逻辑用filter写会很别扭但用 builder 就是顺手的事。Stream.BuilderOrder builder Stream.builder(); for (Order order : queryFromPrimarySource()) { builder.add(order); } if (config.enableSupplementary()) { for (Order extra : loadSupplementaryOrders()) { if (notDuplicateWith(extra, builder)) { builder.add(extra); } } } return builder.build();注意这里我只是示意“在收集阶段处理一些综合判断”真正做去重还是建议收集完流之后统一用distinct()或者下游幂等逻辑处理避免在 collect 阶段写太多业务判断。4.2 场景二递归树结构扁平化另一个非常适合Stream.builder()的场景是树结构转平铺流。我们经常需要处理部门树、菜单树、商品类目树。大多数人的第一反应是“先用一个栈或递归把节点收集到 List再返回”。用 builder 写起来更直接递归时把每个节点投喂进去即可static StreamCategoryNode flattenTree(CategoryNode root) { Stream.BuilderCategoryNode builder Stream.builder(); walk(root, builder); return builder.build(); } private static void walk(CategoryNode node, Stream.BuilderCategoryNode builder) { builder.add(node); for (CategoryNode child : node.children()) { walk(child, builder); } }如果需要对每个节点带上路径稍微改造一下static StreamString flattenPaths(CategoryNode root) { Stream.BuilderString builder Stream.builder(); walk(root, , builder); return builder.build(); } private static void walk(CategoryNode node, String prefix, Stream.BuilderString builder) { String path prefix.isEmpty() ? node.getName() : prefix / node.getName(); builder.add(path); for (CategoryNode child : node.children()) { walk(child, path, builder); } }调用效果根节点办公用品的子节点笔类会生成办公用品 / 笔类这样的路径全部元素进入同一个流后续可以随意filter、limit、skip。这里有一个很重要的经验build()返回的流是惰性的。也就是说调用flattenPaths(root)的时候递归其实没有真正把大量字符串拼出来——只有下游消费流时walk才开始执行。所以在树特别大、且有limit(10)这类短路操作时builder 方案能自然减少不必要的递归生成。反过来如果你在walk方法里绑定了数据库查询、远程调用等重操作就要小心这一行为避免“明明只取 10 条却仍把整棵树查了一遍”。4.3 顺带一提测试数据生成器写单元测试时构造一批符合条件的数据也是 builder 的舒适区。比起手动Arrays.asListbuilder 允许你在循环里动态决定哪些数据进入集合Stream.BuilderLong ids Stream.builder(); for (long id 1; id 100; id) { if (id % 10 ! 0) { ids.add(id); } } StreamLong idStream ids.build();这也是一个典型的“数量未知、条件动态”场景用它替代for ArrayList stream()会让测试代码少一层临时变量。5. 细节陷阱与代码评审中的常见问题5.1 build 之后再 add崩溃现场这是使用Stream.builder()时最容易踩的坑专门演示一下Stream.BuilderString builder Stream.builder(); builder.add(a); StreamString stream builder.build(); builder.add(b); // IllegalStateException: stream has already been operated upon or closed异常信息非常容易和“流已被操作或关闭”混淆让人摸不着头脑。实际原因就是 builder 已经进入“已构建”状态。在代码评审时我关注的重点是build()和add是否出现在同一个作用域的不同分支里。比如有人在if分支里调用了build()后续又尝试add这种问题测试时不一定能全覆盖到但线上数据一旦走到那个分支就会立刻炸。建议build()只出现在方法最后一行返回位置中间过程全都只做add。5.2 类型推断问题链式调用的写法Stream.builder()是泛型方法在下面两种写法中推断机制不一样// 方式一通过左侧变量声明推断类型没问题 Stream.BuilderString builder Stream.builder(); builder.add(a); // 方式二链式调用时左侧没有类型信息需要显式指定 Stream.Stringbuilder() .add(a) .add(b) .build();方式二如果忘了写String会退化成Stream.BuilderObject后续add什么都收返回的流类型也是StreamObject赋值给StreamString时编译直接报错。这种错误虽然不复杂但每次都会浪费几分钟排查不如一开始就写出显式类型参数。5.3 null 与并发的双重雷区前面提到过 builder 不禁止null也不保证线程安全。这两个问题单独出现还能接受一旦结合起来就会非常难排查。想象一个场景你用 parallelStream 从多个线程往同一个 builder 里add(null)——并发写入本身就可能破坏内部状态再加上null元素后续处理时的 NPE异常栈根本定位不到源头。我的建议是两条底线不在 builder 上并发操作。所谓“并行场景”正确姿势是先并发收集到一个并发容器再统一转流在add前统一做空值过滤。哪怕你需要处理空值语义也请在元素进入流之前把它转换成业务上有意义的默认值比如、0L或Optional.empty()而不是直接把null放进 stream。5.4 性能心态builder 不是银弹有同学问我Stream.builder()是不是比ArrayList stream()快很多我在自己机器上用 JMH 简单跑过对比百万级元素的收集两者差距通常在几毫秒到十几毫秒之间远小于后续filter、map等操作带来的开销。真正选型时核心依据应该是代码表达力而不是微基准性能。我的心态是Stream.builder()的价值在于让“动态攒流”这件事显式化——它告诉读者“这里在构造一个流而不是在维护一个有状态集合”。当代码评审者看到 builder 时第一反应就是“后续会把它构造成 stream”意图传输效率非常高。5.5 评审时怎么给人提修改建议如果在代码里看到下面这种写法ListOrder temp new ArrayList(); for (Order order : orders) { if (condition(order)) { temp.add(order); } } return temp.stream();我会建议改成Stream.BuilderOrder temp Stream.builder(); for (Order order : orders) { if (condition(order)) { temp.add(order); } } return temp.build();改动幅度很小行为完全一致但代码的自解释性提升了一截。如果同事们还没有用过 builder这往往是一个最容易接受、最不容易引入争议的切入点。我自己现在写这类工具方法默认就是Stream.Builder。用习惯了之后再回头看List中转会感觉代码多了一层“我是谁”的中间态——明明要的是流却先造了一个列表白白让读者多绕一个弯。最后分享一个小技巧如果 builder 只在单个方法里临时使用可以直接用Stream.Orderbuilder()...build()的链式形式省掉局部变量但凡是逻辑复杂、分支较多的场景还是老老实实声明一个 builder 变量逐步 add可读性好得多后面维护的人会感谢你。