如果你写过几年 Java 业务代码,大概率见过这类场景:一个方法里塞了三四个 for 循环,循环里面套 if,if 里面再往 List 或 Map 里 put 数据,看完要花好几分钟才理清楚这段逻辑到底在干嘛。这种代码不能说错,但读起来累、改起来怕、并发起来更难。Java 8 加入的 Stream 流,就是为了把这些集合操作的脏活累活,改写成一条看得见业务意图的“声明式流水线”。这篇文章不搞源码级深挖,我按自己从传统循环迁移到 Stream 的真实路线,把入门概念、常用算子、Collectors 用法、并行流和避坑经验一次讲透。不管你是刚开始接触流的小白,还是已经用了一段时间但总踩坑的开发,应该都能从这里拿走一点东西。
1. 先搞清楚:Stream 到底解决了什么问题
1.1 传统集合操作的三大痛点
先看一段非常典型的传统写法:有一批订单,要找出金额超过 100 的 VIP 客户的姓名,按订单时间排序后取前 20 条。用 for 循环写大概是这样的:
List<String> vipNames = new ArrayList<>(); for (Order order : orders) { if (order.getTotalAmount() > 100) { Customer customer = customerMap.get(order.getCustomerId()); if (customer != null && "VIP".equals(customer.getLevel())) { vipNames.add(customer.getName()); } } } vipNames.sort(Comparator.comparingInt(String::length)); List<String> result = vipNames.subList(0, Math.min(20, vipNames.size()));功能能跑,但问题很明显。第一,业务意图被埋在循环结构里,你要在脑子里把“过滤、关联、提取、排序、截断”逐步拆出来,才能明白这代码在做什么。第二,处理过程是紧耦合的,今天想在中间加一个去重,明天想改成按金额分组,都得动循环体,改的时候很容易破坏原有逻辑。第三,如果数据量上来想并发处理,还得自己切分数据、合并结果、处理线程安全问题,代码规模直接翻倍。
Stream 的解决思路是反过来:不再告诉代码“第一步遍历、第二步判断、第三步放入集合”,而是直接声明“我要哪批数据、要转换什么、最终聚合为什么”。这种表达方式更贴近业务语言,读代码的人一眼就能看到筛选条件和产出结果,维护成本自然就下来了。
1.2 从“怎么做”到“做什么”:声明式流水线
Stream 把集合处理变成一条数据流水线:数据源进入管道,经过一串中间操作,最后用一个终止操作产出结果。你可以把这条管道理解成工厂里的自动流水线:原始工件放在入口,机器按设定好的工序依次加工(过滤、打磨、组装),最后在出口拿到成品。传统循环就像每个工位都配了一个工人,手工拿起工件、判断、放到下一个筐里,工序一多,人力和沟通成本都上来了。
这个“声明式”特征还带来了一个额外好处:操作可以自由组合。filter 是过滤,map 是转换,flatMap 是展开,sorted 是排序,collect 是收集,这些都是独立的方法,像积木一样按需拼装。你要“先过滤再排序”或“先排序再过滤”,流水线顺序调整一下就行,不需要重写一整个循环。对于业务需求经常变化的项目来说,这种灵活性比微小的性能差异重要得多。
1.3 惰性求值与其他语言的误区
新手最常犯的误解是:写了list.stream().filter(...).map(...)就以为处理完了,结果发现数据没任何变化。原因是 Stream 的中间操作是惰性求值的。我见过不少同学在这里栽跟头,所以提前说重点:
List<String> result = list.stream() .filter(item -> item.startsWith("A")) // 还没真正执行 .map(String::toUpperCase); // 还没真正执行上面这段代码不会报错,也不会遍历任何元素,因为缺少终止操作。流只有在执行collect、forEach、reduce、count这类终止操作时,才会真正开始遍历数据。
惰性求值带来的最大好处是短路优化。比如limit(5)只要找到 5 个满足条件的元素就会停,anyMatch只要找到第一个匹配项就返回,不用把整个数据源跑完。这条特性在数据量很大的场景下非常有用,但也意味着你要时刻记住:流是“按需计算”的,不是创建时快照。
还有一个误区是性能。很多人以为 Stream 一定比 for 循环快,实际真不一定。Stream 内部有函数对象创建、方法调用、流水线调度等额外开销,在百万元素内的小数据集上通常和 for 循环相差无几甚至略慢。它的核心价值是可读性、可组合性和并行便捷性,别把它当性能加速器来宣传。
2. 快速掌握 Stream 管道:从数据源到终止操作
2.1 管道三要素:一个例子全说清
一条 Stream 管道永远由三部分组成:数据源、中间操作、终止操作。先看一个最典型的例子:
List<Integer> numbers = List.of(3, 1, 4, 1, 5, 9, 2, 6); List<Integer> result = numbers.stream() .filter(n -> n % 2 == 1) // 中间操作:只保留奇数 .distinct() // 中间操作:去重 .sorted() // 中间操作:升序排序 .limit(3) // 中间操作:只要前 3 个 .collect(Collectors.toList()); // 终止操作:收集为 List这段代码的执行顺序不是“filter 全部处理完再 distinct”,而是像流水线一样,每个元素依次通过所有工序,直到满足终止条件。最终结果应该是[1, 3, 5]。这一步不需要背,最关键的是理解:中间操作只负责“声明处理步骤”,终止操作才触发真正的计算。
数据源来源非常丰富,除了集合的stream()和parallelStream(),还有Arrays.stream(Object[])、Stream.of(...)、IntStream.range(...)、Files.lines(Path)读取行内容,甚至可以把Optional转成空流或单元素流。这个特性后文还会讲到。
2.2 高频中间操作:filter、map、flatMap、distinct、sorted、limit
中间操作是整个流水线的核心加工环节,实际开发里最常用的是下面几个。
filter(Predicate)按条件保留元素,就像我们常说的“筛选”。Predicate支持组合,比如condition1.and(condition2)、condition1.or(condition2),可以用来写复杂的过滤条件而不至于把 lambda 撑得太长。比如order -> order.getAmount() > 100 && "PAID".equals(order.getStatus())可以直接写,但如果你需要复用条件,提取成Predicate变量会更清晰。
map(Function)做一对一转换,比如把订单换成订单金额,把用户对象换成用户名。这里要特别提醒:map 不会展开嵌套结构。一个订单里面有多个商品行,如果直接用map(order -> order.getItems()),得到的是List<List<Item>>,这往往不是你想要的。这时候需要flatMap。
flatMap(Function<? super T,? extends Stream<? extends R>>)才是处理“一对多”的正主。它先把每个元素映射成一个小流,再把这些小流拍扁成一个大流。举个例子,订单列表下面的所有商品行:
List<Item> items = orders.stream() .flatMap(order -> order.getItems().stream()) .collect(Collectors.toList());可以这么理解:map把一张纸上的每个字描一遍,flatMap先把每一页撕成小纸条,再全部倒进同一个盒子里。null 子集合会导致 NPE,这个在第 5 节避坑部分会专门讲。
distinct()去重依赖的是元素的equals和hashCode,所以自定义对象如果没正确实现这两个方法,去重结果会和预期不一致。sorted()无参版要求元素实现Comparable;如果需要自定义排序规则,用sorted(Comparator.comparing(...)),而且可以多个条件链式比较:
List<User> sortedUsers = users.stream() .sorted(Comparator.comparing(User::getAge) .thenComparing(User::getName)) .collect(Collectors.toList());limit(n)和skip(n)经常搭配做分页:skip((page - 1) * size).limit(size)。需要注意的是 limit 是有短路行为的有状态操作,在并行流中使用时结果不一定是最前面的 N 个,如果想保证顺序场景,需要谨慎处理。peek严格来说是调试用的,后面避坑部分会展开说。
2.3 终止操作:collect、reduce、forEach、匹配与聚合
终止操作才是把流水线结果“变现”的关键。按功能可以分成四类:收集、聚合、遍历、匹配查找。
收集类最常用的是collect(Collector),把流变回List、Set、Map等集合。Java 16 以后可以直接用stream.toList(),但要注意这和Collectors.toList()有细微差异:前者返回的是不可变列表,后者返回的是可变的ArrayList。如果你后续还要增删元素,用Collectors.toList()更稳妥。
聚合类方法是reduce,相当于把流里所有元素折叠成一个值。最常见的是求和:
int total = numbers.stream().reduce(0, Integer::sum);这里第一个参数0是初始值,也是身份值,要求满足identity + x = x。如果只传一个BinaryOperator,返回的是Optional,因为流里可能没有元素。基本类型流的sum()更直接,但要先转换成IntStream等类型流。
遍历类是forEach和forEachOrdered。forEach在并行流中不保证顺序,forEachOrdered会保持流的相遇顺序,但要付出一定的并行性能代价。这里有一个很容易被忽略的原则:不要为了“往外部 List 里 add”而去用 forEach,正确做法是collect。理由在并行流部分会解释。
匹配和查找类包括anyMatch、allMatch、noneMatch、findFirst、findAny。前三者本质是短路终止操作,比如:
boolean hasExpired = orders.stream() .anyMatch(order -> order.getExpireTime().isBefore(now));一条流水线一旦遇到匹配项就会停止后续遍历。findFirst返回流中第一个元素,findAny在并行流中更友好,因为不必受限于顺序。
2.4 方法引用:让代码和说话一样自然
Lambda 表达式已经够简洁了,但方法引用能进一步去掉“样板参数”。比如customer -> customer.getName()可以直接写成Customer::getName。常见的四种形式:
// 静态方法 Stream.of("1", "2").map(Integer::parseInt); // 特定对象的实例方法 LocalDate now = LocalDate.now(); list.stream().map(now::isAfter); // 任意对象的实例方法(等价于 lambda: s -> s.toUpperCase()) list.stream().map(String::toUpperCase); // 构造器引用 Supplier<List<String>> supplier = ArrayList::new;方法引用虽好,但不要为了引用而引用。如果引用之后可读性反而更差,比如参数语义不清晰,那还是写 lambda 更好。核心原则始终是“让看代码的人少动脑”。
3. 实战:把又长又臭的循环重构成优雅流水线
3.1 案例一:多条件筛选、排序、截断一步到位
假设我们有这样一个需求:找出最近 30 天内创建、状态为“待处理”的工单,按优先级从高到低排序,取前 10 条记录用户邮箱列表。传统写法至少需要三到四步:遍历筛选、排序、截断、再提取字段。用 Stream 可以一次写完:
List<String> emails = tickets.stream() .filter(t -> t.getStatus().equals(TicketStatus.PENDING)) .filter(t -> t.getCreatedAt().isAfter(LocalDate.now().minusDays(30))) .sorted(Comparator.comparing(Ticket::getPriority, Comparator.reverseOrder())) .limit(10) .map(Ticket::getUserEmail) .collect(Collectors.toList());读者从上往下读,每一步都对应一个明确的业务条件,不需要跳来跳去。这就是声明式的好处:代码的组织结构等于业务步骤的排列顺序。
3.2 案例二:列表转 Map,重复 key 怎么办
业务里经常要把一个对象列表转成以主键为 key 的 Map,方便后续 O(1) 查找。Stream 版看起来非常简单:
Map<Long, User> userMap = users.stream() .collect(Collectors.toMap(User::getId, user -> user));但这里有一个非常经典的坑:如果列表里有两个 id 相同的 User,上面的代码会直接抛IllegalStateException: Duplicate key。原因是toMap默认不允许 key 重复,它不知道该保留哪一个。
当业务上明确存在重复 key 时,必须传第三个参数“冲突合并函数”:
Map<Long, User> userMap = users.stream() .collect(Collectors.toMap(User::getId, user -> user, (oldValue, newValue) -> newValue));如果你希望保留旧值、拼接名称、或者记录重复数量,逻辑都可以写在这个合并函数里。另外,toMap默认生成HashMap,如果需要有序 Map,还可以传第四个参数指定 Map 工厂:
Map<Long, User> userMap = users.stream() .collect(Collectors.toMap(User::getId, u -> u, (oldV, newV) -> newV, LinkedHashMap::new));3.3 案例三:分组统计一条链搞定
分组统计是 Stream 最擅长的场景之一。比如员工列表按部门统计人数:
Map<String, Long> countByDept = employees.stream() .collect(Collectors.groupingBy(Employee::getDept, Collectors.counting()));统计各部门薪资总和、平均值、最高值:
Map<String, Integer> totalByDept = employees.stream() .collect(Collectors.groupingBy(Employee::getDept, Collectors.summingInt(Employee::getSalary))); Map<String, Double> avgByDept = employees.stream() .collect(Collectors.groupingBy(Employee::getDept, Collectors.averagingDouble(Employee::getSalary)));在老代码里,这种需求要初始化 Map,遍历 List,判断 key 是否存在,再更新 value,少说十几行。用 groupingBy 加下游收集器,一行就表达完整。后面第 4 节还会继续展开groupingBy的进阶组合。
3.4 案例四:flatMap 处理嵌套集合
电商订单很常见的一个需求:给定一批订单,取出所有订单下的商品 SKU 集合。传统写法是双重循环:
List<String> skus = new ArrayList<>(); for (Order order : orders) { if (order.getItems() == null) continue; for (Item item : order.getItems()) { skus.add(item.getSku()); } }Stream 版:
List<String> skus = orders.stream() .map(Order::getItems) .filter(Objects::nonNull) .flatMap(List::stream) .map(Item::getSku) .distinct() .collect(Collectors.toList());注意中间先加了filter(Objects::nonNull),这能防止某条订单的items字段为 null 导致空指针。考虑业务健壮性时,这行不能省。
3.5 并行流:parallelStream 的正确打开方式
并行流是 Stream 的另一大卖点,但也是最容易产生坑的地方。普通场景先不考虑它,只有当数据量真的很大、每个元素的处理彼此独立且偏 CPU 密集时,才值得换成parallelStream():
long total = LongStream.rangeClosed(1, 10_000_000L) .parallel() .sum();一个很重要的概念:并行流默认使用公共的ForkJoinPool,线程数默认和处理器的核心数相关。如果流里某个操作是阻塞任务,比如调外部接口、查数据库,它会占用公共池里的线程,导致其他并行流任务整体变慢。这就是为什么有些团队明确规定:并行流只用于纯计算,不做 IO。如果你不确定,那就先写串行流,用数据测出瓶颈再优化。
4. Collectors 工具箱:这才是集合操作的终极优雅
4.1 groupingBy 与 partitioningBy:分组不止分个组
groupingBy是 Collectors 里最有价值的成员。它可以把流按某个维度切分成多个组,默认组内元素收集成List。前面已经用过基本形态,这里说两个高阶能力。
第一个是多级分组。比如先按部门分组,再按职级分组:
Map<String, Map<String, List<Employee>>> grouped = employees.stream() .collect(Collectors.groupingBy(Employee::getDept, Collectors.groupingBy(Employee::getRank)));读这段代码时要记住:第二个参数是“下游收集器”,它决定了每个分组最终长什么样。所以你可以把下游换成counting()、mapping()、filtering()、toSet(),整个分组统计体系瞬间变得非常灵活。
第二个是控制 Map 类型。默认生成HashMap,如果希望按键排序,可以指定TreeMap::new:
TreeMap<String, List<Employee>> sortedGroup = employees.stream() .collect(Collectors.groupingBy(Employee::getDept, TreeMap::new, Collectors.toList()));第三个是partitioningBy,它比groupingBy更简单,专门用于二分分组。比如按是否 VIP 分成两组:
Map<Boolean, List<Customer>> partition = customers.stream() .collect(Collectors.partitioningBy(c -> "VIP".equals(c.getLevel())));这里的 key 固定是true和false,语义上比手工拼接 “VIP 组”和“非 VIP 组”更直观。
4.2 下游收集器组合:mapping、filtering、flatMapping
有时候分组之后,每个组里的元素还需要再转换。这就需要把下游收集器串起来。
mapping可以把组内元素先映射成另一个对象,再收进集合。比如按部门分组,但只需要组内的员工姓名:
Map<String, List<String>> namesByDept = employees.stream() .collect(Collectors.groupingBy(Employee::getDept, Collectors.mapping(Employee::getName, Collectors.toList())));filtering则是在收集之前过滤组内元素。注意它和先全局过滤再分组的区别。假设有两个部门,其中一个部门全员年龄小于 18。如果先filter(age >= 18)再groupingBy,这个部门对应的 key 会直接消失;如果groupingBy后使用filtering(age >= 18, toList()),这个部门 key 保留,但 value 是空列表。业务上需要哪一种,你就选哪一种,这已经不是语法问题,而是语义问题。
Java 9 之后还有flatMapping,可以在分组后展开嵌套流。比如按客户分组,把每个客户名下的所有订单商品行拍平成一个集合。用法和flatMap类似,但作用在收集过程中。
4.3 toMap 与 joining:高频但容易忽略的细节
toMap在 3.2 节已经讲过基本用法和 key 冲突,这里再补两个实用场景。第一个是利用mergeFunction做字符串拼接。比如一个用户可能属于多个角色,要把角色名拼起来:
Map<Long, String> roleMap = userRoles.stream() .collect(Collectors.toMap(UserRole::getUserId, UserRole::getRoleName, (v1, v2) -> v1 + "," + v2));第二个是joining。它可以把字符串元素拼接成一个字符串,还可以加分隔符、前缀、后缀:
String result = users.stream() .map(User::getName) .collect(Collectors.joining(", ", "[", "]"));输出形如[张三, 李四, 王五]。joining内部用的是StringBuilder,大量拼接时不会产生额外字符串对象,处理大量元素也还好。但它要求流中的元素是字符串,如果不是,先map一道。
4.4 自定义 Collector:写一个属于你的收集器
当内置 Collectors 满足不了需求时,可以自己实现Collector接口,或者更简单点,用Collector.of(...)工厂方法快速组装。比如收集到不可变 Set:
public static <T> Collector<T, ?, Set<T>> toImmutableSet() { return Collector.of( HashSet::new, Set::add, (left, right) -> { left.addAll(right); return left; }, Collections::unmodifiableSet, Collector.Characteristics.UNORDERED ); }五个参数分别是:容器创建函数、累加函数、并行合并函数、最终转换函数、收集器特征。理解这几个参数后,你就能看懂任何自定义 Collectors 代码。这个能力不是每天都会用,但它能让你在遇到“我要收集成树”“我要统计多个指标”这类需求时不至于手足无措。
5. Stream 实战避坑指南:从踩坑到解决
5.1 流是一次性资源,不能重复消费
Stream 和集合最大的区别之一就是:流是一次性的。一个流只能消费一次,调用终止操作后,再碰它会直接抛异常:stream has already been operated upon or closed。常见错误是这样的:
Stream<String> stream = list.stream(); stream.forEach(System.out::println); long count = stream.count(); // 这里会炸正确做法是每次需要操作时就重新list.stream()。这个错误几乎每个用 Stream 的人都会遇到,原因很朴素:流代表的是“一次遍历过程”,过程结束后自然不能重来。
5.2 并行流里的共享状态是定时炸弹
很多人写并行流时会把结果放到共享集合里,比如:
List<String> result = new ArrayList<>(); list.parallelStream() .filter(...) .forEach(result::add);这段代码很可能在测试环境跑得正常,上生产后偶尔丢数据、报ArrayIndexOutOfBoundsException或者ConcurrentModificationException。原因是ArrayList不是线程安全容器,多个线程同时往里写必然出问题。并行流要求“每个元素之间独立计算,结果通过collect或reduce汇聚”,而不是把副作用撒向外部共享状态。
如果确实要收集结果,请把末尾的forEach(result::add)换成collect(Collectors.toList())。如果非要使用共享状态,也要保证容器本身线程安全且能看到一致视图,但这种情况少之又少。
5.3 null 元素与空集合:提前想清楚就不会炸
Stream 对 null 的容忍度并不高。Stream.of(null)会直接抛空指针;集合里存在 null 元素时,sorted、distinct等操作也可能出问题。处理数据之前,建议先决定 null 要怎么处理:
list.stream() .filter(Objects::nonNull) .map(...) .collect(Collectors.toList());更隐蔽的是flatMap时子列表为空。Java 9 提供了Stream.ofNullable,专门把可能为 null 的对象变成空流或单元素流:
List<Long> itemIds = orders.stream() .flatMap(order -> Stream.ofNullable(order.getItems())) .flatMap(List::stream) .map(Item::getId) .collect(Collectors.toList());这段代码比filter(Objects::nonNull)更优雅,因为它把 null 检查语义直接整合进流转换过程中。如果你还在用 Java 8,只能先filter(Objects::nonNull)。
还有一个小知识点:Optional也可以转成Stream,Java 9 的Optional::stream完美配合flatMap,把一堆可能会为空的 Optional 平铺成非空元素流。
5.4 无限流:别在 collect 时才发现 OOM
Stream.iterate和Stream.generate可以生成无限流,这是十分强大的能力,但如果你忘记截断,collect就会让程序跑到内存溢出。
// 错误示例:没有 limit,直接 OOM List<Integer> list = Stream.iterate(0, i -> i + 1) .filter(i -> i % 2 == 0) .collect(Collectors.toList());正确做法是在中间操作链里加一个limit(n),让流在满足条件后停止。之所以反复强调,是因为这类错误不是在编译期报的,而是运行到内存异常才暴露,排查成本很高。
5.5 受检异常:Lambda 里的痛苦来源
java.util.function里的大多数函数式接口方法签名都没有声明throws Exception,所以你在 lambda 里调用一个会抛受检异常的方法时,编译器会直接报错。比较自然的做法是用 try-catch 包一层:
list.stream() .map(str -> { try { return objectMapper.readValue(str, Demo.class); } catch (IOException e) { throw new RuntimeException(e); } }) .collect(Collectors.toList());这确实让代码变丑了,但受检异常本身就是为了强迫调用方考虑失败路径,Stream 没有更好的优雅解法。也可以封装一个类似unchecked的辅助工具方法,把异常转成运行时异常,但注意团队规范不同,谨慎使用。
5.6 性能不是银弹,小数据别凑热闹
很多团队做代码评审时,看到传统循环就问“为什么不改成 Stream”,这其实是不对的。Stream 有对象分配和调度开销,在几百几千个元素的集合上,性能几乎不可能比普通 for 循环快。它的优势在于表达力。
写 Stream 优先考虑的是可读性和可维护性,不是性能。只有当数据量达到十万、百万级别,并且处理逻辑是明确的 CPU 密集无状态计算时,才值得考虑并行流。用了并行流之后,还要通过压测对比真实耗时,别只看理论。
5.7 调试技巧:peek 是你最好的现场侦察兵
Stream 链条很长,中间结果看不见摸不着,调试起来确实比循环麻烦。peek就是为这个场景准备的,它可以把旁路监听器插到流水线中间:
List<String> result = list.stream() .filter(s -> s.startsWith("A")) .peek(s -> System.out.println("after filter: " + s)) .map(String::toUpperCase) .peek(s -> System.out.println("after map: " + s)) .collect(Collectors.toList());有一点必须声明:peek是中间操作,没有终止操作时它不会执行。而且它本意是做调试观察,不适合在里面写数据库更新、文件写入这类业务副作用,因为流一旦被再一次优化调度,peek 会不会执行、执行几次都可能发生变化。
6. 进阶玩家才会用到的 Stream 姿势
6.1 iterate 与 generate:自己造数据源
Stream.iterate可以从一个种子开始无限生成数列。比如生成 1 到 100 的偶数:
List<Integer> evenNumbers = Stream.iterate(0, i -> i + 2) .limit(50) .collect(Collectors.toList());Java 9 还提供了一个带条件的重载版本,类似传统的 for 循环写法:
Stream.iterate(0, i -> i <= 100, i -> i + 2) .forEach(System.out::println);Stream.generate则是从一个Supplier不断取新值,通常用于生成随机数、UUID、日志模拟数据。不管哪一种,“无限”意味着必须配limit,否则永远出不来终止操作。
6.2 takeWhile 与 dropWhile:条件截断的进阶控制
Java 9 带来了takeWhile和dropWhile。takeWhile从头开始拿元素,直到遇到第一个不满足条件的元素,然后整个停止。dropWhile正好相反,从头开始丢弃满足条件的元素,直到遇到第一个不满足条件的元素,把它和剩余元素全部留下。
举例来说,一个有序整数流[1, 2, 3, 4, 5]:
List<Integer> taken = numbers.stream() .takeWhile(n -> n < 4) .collect(Collectors.toList()); // [1, 2, 3]这和filter的区别要看清:filter 会保留所有满足条件的元素,即使后面某个元素不满足,继续往下筛;takeWhile 只要遇到一个不满足条件就立刻截断。对有序数据来说,这是语义完全不同的两个操作,选错会得出不一样的结果。
6.3 Stream.ofNullable 与 Optional::stream
Stream.ofNullable是处理可空对象的优雅工具。假设某个方法返回可能为 null 的List<String>,你希望把这个集合的元素平铺到后续处理里:
List<String> linkedValues = Stream.ofNullable(getNames()) .flatMap(Collection::stream) .collect(Collectors.toList());没错,Stream.ofNullable返回的是一个“空流或者单元素流”,单元素就是这个非 null 值。这种写法比先判断 null 再为空集合要少一层缩进,也自然融入了流水线。配合 Optional 使用更舒服,比如一堆Optional<User>要过滤掉空值再取用户城市:
List<String> cities = users.stream() .flatMap(Optional::stream) .map(User::getCity) .collect(Collectors.toList());6.4 基本类型流:别让装箱偷偷拖慢性能
对大量数值进行求和、平均值、范围遍历时,直接用Stream<Integer>会导致频繁的自动装箱拆箱,白白产生很多中间对象。Java 8 早就提供了IntStream、LongStream、DoubleStream三种专门的基本类型流:
int[] nums = {5, 3, 8, 1}; long sum = IntStream.of(nums).sum(); double avg = IntStream.of(nums).average().orElse(0);比较常见的还有IntStream.rangeClosed来遍历固定范围,配合boxed()可以转回引用流,配合mapToObj生成特定对象:
List<Integer> rangeList = IntStream.rangeClosed(1, 10) .boxed() .collect(Collectors.toList());数据量不是特别大时,用哪种区别不大;但百万级以上的数值计算,这种细节的影响就非常明显了。
最后聊点我自己的使用习惯。我见过很多同学为了“秀操作”,把一个三个 if 就能表达的逻辑硬改成七八个 stream 算子链,结果后续维护的人痛苦不堪。Stream 的核心是让代码更贴近业务,而不是炫技。我的判断标准很简单:如果一段集合处理逻辑需要两三个以上步骤,并且这些步骤是稳定的,那就值得用 Stream;如果只是一次简单遍历,直接写 for 循环没任何问题。真要大规模上并行流,先想清楚线程模型,再想一遍共享状态,最后用压测数据说话。编码这件事,优雅的前提永远是让下一个读代码的人少花时间。