Java 开发中Stream的toMap与Map 使用技巧

简介: 本文深入解析了 Java 中 `toMap()` 方法的三大问题:重复键抛出异常、`null` 值带来的风险以及并行流中的性能陷阱,并提供了多种替代方案,如使用 `groupingBy`、`toConcurrentMap` 及自定义收集器,帮助开发者更安全高效地进行数据处理。

一、toMap () 的三大致命伤

1. 重复键:双胞胎键的世纪难题

(1)默认行为:一视同仁,直接炸毛

toMap () 的默认行为是,如果遇到重复的键,就直接抛出IllegalStateException。这就好比你在玩消消乐,好不容易凑齐三个相同的元素,结果游戏直接闪退了。这种设计在大多数情况下是合理的,因为 Map 的键必须唯一。但在实际开发中,数据重复的情况并不少见,比如从数据库查询数据时,可能会因为业务逻辑问题导致重复记录。

举个栗子:

less

体验AI代码助手

代码解读

复制代码

List<Product> products = Arrays.asList(
    new Product(1L, "苹果"),
    new Product(1L, "香蕉")
);
Map<Long, String> productMap = products.stream()
    .collect(Collectors.toMap(Product::getId, Product::getName));

这段代码会抛出Duplicate key异常,因为两个 Product 对象的 id 都是 1L。这时候,你可能会想:“我只是想保留最后一个出现的值,或者合并它们,难道就这么难吗?”

(2)合并策略:教 toMap () 做人

为了应对重复键的问题,toMap () 提供了一个三参数的重载方法,允许你自定义合并策略。例如,你可以选择保留旧值、替换新值,或者将两个值合并。

  • 保留旧值:

rust

体验AI代码助手

代码解读

复制代码

Map<Long, String> productMap = products.stream()
    .collect(Collectors.toMap(
        Product::getId,
        Product::getName,
        (oldValue, newValue) -> oldValue // 保留旧值
    ));

  • 替换新值:

rust

体验AI代码助手

代码解读

复制代码

Map<Long, String> productMap = products.stream()
    .collect(Collectors.toMap(
        Product::getId,
        Product::getName,
        (oldValue, newValue) -> newValue // 替换新值
    ));

  • 合并值:

rust

体验AI代码助手

代码解读

复制代码

Map<Long, String> productMap = products.stream()
    .collect(Collectors.toMap(
        Product::getId,
        Product::getName,
        (oldValue, newValue) -> oldValue + "," + newValue // 合并值
    ));

这样,当遇到重复键时,toMap () 就会按照你定义的合并策略来处理,而不是直接抛出异常。但问题来了,这种方法需要你在代码中显式处理重复键,增加了代码的复杂性。而且,如果你的业务逻辑比较复杂,合并策略可能会变得难以维护。

2. null 值:隐形杀手

(1)键为 null:Map 的禁区

Map 的键是不允许为 null 的(HashMap 允许,但 ConcurrentHashMap 不允许)。如果你在使用 toMap () 时,某个元素的键映射结果为 null,就会抛出NullPointerException。

举个栗子:

sql

体验AI代码助手

代码解读

复制代码

List<User> users = Arrays.asList(
    new User(null, "张三"),
    new User(1L, "李四")
);
Map<Long, String> userMap = users.stream()
    .collect(Collectors.toMap(User::getId, User::getName));

这段代码会抛出NullPointerException,因为第一个 User 对象的 id 为 null。这时候,你可能会想:“我只是想过滤掉 id 为 null 的用户,难道就这么难吗?”

(2)值为 null:无声的陷阱

Map 的值是允许为 null 的,但在某些情况下,值为 null 可能会导致后续操作出现问题。例如,当你使用map.get(key)获取值时,如果值为 null,就需要进行判空处理。

举个栗子:

sql

体验AI代码助手

代码解读

复制代码

List<User> users = Arrays.asList(
    new User(1L, null),
    new User(2L, "李四")
);
Map<Long, String> userMap = users.stream()
    .collect(Collectors.toMap(User::getId, User::getName));
String name = userMap.get(1L); // name为null,需要判空

为了避免这种情况,你可以在映射值时进行非空处理,或者在收集完成后过滤掉值为 null 的键值对。

3. 性能问题:并行流中的 “陷阱”

(1)并行流的 “甜蜜陷阱”

Stream 的并行流可以充分利用多核 CPU 的优势,提高数据处理效率。但在使用 toMap () 时,并行流可能会导致性能问题,甚至数据混乱。

举个栗子:

ini

体验AI代码助手

代码解读

复制代码

List<User> users = generateLargeUserList(10_000_000);
Map<Long, User> userMap = users.parallelStream()
    .collect(Collectors.toMap(User::getId, Function.identity()));

这段代码在并行流中使用 toMap (),可能会因为线程安全问题导致数据混乱。因为 toMap () 默认使用的是 HashMap,而 HashMap 在多线程环境下是非线程安全的。这时候,你可能会想:“我只是想提高处理效率,难道就这么难吗?”

(2)解决方案:toConcurrentMap ()

为了解决并行流中的线程安全问题,Java 提供了Collectors.toConcurrentMap()方法。这个方法返回的是 ConcurrentHashMap,支持并发操作,性能更好。

举个栗子:

ini

体验AI代码助手

代码解读

复制代码

Map<Long, User> userMap = users.parallelStream()     .collect(Collectors.toConcurrentMap(User::getId, Function.identity()));

这样,即使在并行流中使用 toConcurrentMap (),也能保证数据的一致性和线程安全。但需要注意的是,toConcurrentMap () 的性能并不一定比 toMap () 好,具体取决于数据量和并发程度。

二、替代方案:toMap () 的 “平替” 们

既然 toMap () 有这么多坑,那有没有更好的替代方案呢?答案是肯定的。下面,我将为大家介绍几种常用的替代方法。

1. groupingBy:分组处理的 “瑞士军刀”

(1)基本用法:按字段分组

Collectors.groupingBy()是一个非常强大的收集器,它可以将流中的元素按照某个字段进行分组,返回一个 Map,其中键是分组字段的值,值是该分组下的元素列表。

举个栗子:

ini

体验AI代码助手

代码解读

复制代码

List<Order> orders = ...;
Map<String, List<Order>> orderMap = orders.stream()
    .collect(Collectors.groupingBy(Order::getUserId));

这样,orderMap 的键是用户 id,值是该用户的所有订单列表。这种方法不仅可以避免重复键的问题,还可以方便地进行后续的统计和分析。

(2)进阶用法:多级分组

groupingBy () 还支持多级分组,即先按一个字段分组,再按另一个字段分组,返回一个嵌套的 Map。

举个栗子:

vbnet

体验AI代码助手

代码解读

复制代码

Map<String, Map<String, List<Order>>> orderMap = orders.stream()
    .collect(Collectors.groupingBy(
        Order::getUserId,
        Collectors.groupingBy(Order::getStatus)
    ));

这样,orderMap 的结构是Map<用户id, Map<订单状态, List<订单>>>,可以方便地统计每个用户不同状态的订单数量。

(3)统计聚合:与其他收集器结合

groupingBy () 还可以与其他收集器结合使用,进行统计聚合操作。例如,统计每个用户的订单总数、总金额等。

举个栗子:

less

体验AI代码助手

代码解读

复制代码

Map<String, Long> orderCountMap = orders.stream()
    .collect(Collectors.groupingBy(
        Order::getUserId,
        Collectors.counting()
    ));

Map<String, Double> totalAmountMap = orders.stream()
    .collect(Collectors.groupingBy(
        Order::getUserId,
        Collectors.summingDouble(Order::getAmount)
    ));

这样,orderCountMap 的键是用户 id,值是该用户的订单总数;totalAmountMap 的键是用户 id,值是该用户的订单总金额。

2. toMap 的安全变种:处理重复键和 null 值

(1)处理重复键:三参数 toMap ()

前面已经介绍过,toMap () 的三参数重载方法可以自定义合并策略,处理重复键的问题。例如,保留旧值、替换新值或合并值。

(2)处理 null 值:过滤或默认值

为了避免键或值为 null 的问题,可以在流处理过程中进行过滤,或者在映射时提供默认值。

  • 过滤 null 键:

sql

体验AI代码助手

代码解读

复制代码

Map<Long, String> userMap = users.stream()
    .filter(user -> user.getId() != null)
    .collect(Collectors.toMap(User::getId, User::getName));

  • 提供默认值:

rust

体验AI代码助手

代码解读

复制代码

Map<Long, String> userMap = users.stream()
    .collect(Collectors.toMap(
        User::getId,
        User::getName,
        (oldValue, newValue) -> oldValue,
        () -> new HashMap<>()
    ));

这里,第四个参数() -> new HashMap<>()是一个 Map 的供应商,用于指定返回的 Map 类型。如果不指定,默认返回的是 HashMap。

3. 自定义收集器:灵活应对复杂需求

(1)为什么需要自定义收集器?

虽然 Java 提供的内置收集器已经能够满足大多数需求,但在某些情况下,我们可能需要更灵活的收集逻辑。例如,将流中的元素收集到一个自定义的 Map 中,或者在收集过程中进行复杂的转换和聚合操作。

(2)自定义收集器的实现步骤

自定义收集器需要实现Collector接口,该接口定义了四个方法:supplier()、accumulator()、combiner()和finisher(),以及一个characteristics()方法。

举个栗子:

typescript

体验AI代码助手

代码解读

复制代码

public class CustomCollector<T, K, V> implements Collector<T, Map<K, V>, Map<K, V>> {
    privatefinalFunction<T, K> keyMapper;
    privatefinalFunction<T, V> valueMapper;
    privatefinal BinaryOperator<V> mergeFunction;

    public CustomCollector(Function<T, K> keyMapper, Function<T, V> valueMapper, BinaryOperator<V> mergeFunction) {
        this.keyMapper = keyMapper;
        this.valueMapper = valueMapper;
        this.mergeFunction = mergeFunction;
    }

    @Override
    public Supplier<Map<K, V>> supplier() {
        return HashMap::new;
    }

    @Override
    public BiConsumer<Map<K, V>, T> accumulator() {
        return (map, element) -> {
            K key = keyMapper.apply(element);
            V value = valueMapper.apply(element);
            map.merge(key, value, mergeFunction);
        };
    }

    @Override
    public BinaryOperator<Map<K, V>> combiner() {
        return (map1, map2) -> {
            map2.forEach((key, value) -> map1.merge(key, value, mergeFunction));
            return map1;
        };
    }

    @Override
    publicFunction<Map<K, V>, Map<K, V>> finisher() {
        returnFunction.identity();
    }

    @Override
    public Set<Characteristics> characteristics() {
        return Collections.unmodifiableSet(EnumSet.of(Characteristics.IDENTITY_FINISH));
    }
}

这个自定义收集器可以将流中的元素收集到一个 Map 中,支持自定义键映射、值映射和合并策略。使用时,可以像这样调用:

rust

体验AI代码助手

代码解读

复制代码

Map<Long, String> userMap = users.stream()
    .collect(new CustomCollector<>(User::getId, User::getName, (oldValue, newValue) -> oldValue));

这样,就可以避免使用 toMap () 时的重复键和 null 值问题,同时保持代码的灵活性和可读性。

三、实战案例:toMap () 的 “坑” 与 “避坑指南”

1. 案例一:用户行为分析

(1)需求描述

某电商平台需要分析用户的购买行为,统计每个用户的购买次数和总金额。要求将结果存储到一个 Map 中,其中键是用户 id,值是一个包含购买次数和总金额的对象。

(2)使用 toMap () 的实现

css

体验AI代码助手

代码解读

复制代码

List<Order> orders = ...;
Map<Long, PurchaseStats> purchaseStatsMap = orders.stream()
    .collect(Collectors.toMap(
        Order::getUserId,
        order -> new PurchaseStats(1, order.getAmount()),
        (oldStats, newStats) -> new PurchaseStats(
            oldStats.getCount() + newStats.getCount(),
            oldStats.getTotalAmount() + newStats.getTotalAmount()
        )
    ));

这段代码使用 toMap () 的三参数重载方法,自定义了合并策略,将每个用户的购买次数和总金额进行累加。

(3)问题分析

  • 重复键处理:如果同一个用户有多个订单,合并策略会正确累加购买次数和总金额。
  • null 值处理:如果某个订单的用户 id 为 null,会抛出NullPointerException。
  • 性能问题:如果订单量很大,并行流可能会导致性能问题。

(4)优化方案

  • 过滤 null 用户 id:

css

体验AI代码助手

代码解读

复制代码

Map<Long, PurchaseStats> purchaseStatsMap = orders.stream()
    .filter(order -> order.getUserId() != null)
    .collect(Collectors.toMap(
        Order::getUserId,
        order -> new PurchaseStats(1, order.getAmount()),
        (oldStats, newStats) -> new PurchaseStats(
            oldStats.getCount() + newStats.getCount(),
            oldStats.getTotalAmount() + newStats.getTotalAmount()
        )
    ));
  • 使用并行流和 toConcurrentMap ():

css

体验AI代码助手

代码解读

复制代码

Map<Long, PurchaseStats> purchaseStatsMap = orders.parallelStream()
    .filter(order -> order.getUserId() != null)
    .collect(Collectors.toConcurrentMap(
        Order::getUserId,
        order -> new PurchaseStats(1, order.getAmount()),
        (oldStats, newStats) -> new PurchaseStats(
            oldStats.getCount() + newStats.getCount(),
            oldStats.getTotalAmount() + newStats.getTotalAmount()
        )
    ));

这样,可以提高处理效率,同时避免线程安全问题。

2. 案例二:日志分析

(1)需求描述

某系统需要分析日志数据,统计每个日志级别(如 INFO、WARN、ERROR)的日志数量,并将结果存储到一个 Map 中,其中键是日志级别,值是日志数量。

(2)使用 groupingBy 的实现

ini

体验AI代码助手

代码解读

复制代码

List<Log> logs = ...;
Map<String, Long> logCountMap = logs.stream()
    .collect(Collectors.groupingBy(
        Log::getLevel,
        Collectors.counting()
    ));

这段代码使用 groupingBy () 和 counting () 收集器,简单高效地统计了每个日志级别的日志数量。

(3)问题分析

  • 无需处理重复键:因为日志级别是唯一的,所以不会出现重复键的问题。
  • 性能问题:如果日志量很大,并行流可以提高处理效率。

(4)优化方案

ini

体验AI代码助手

代码解读

复制代码

Map<String, Long> logCountMap = logs.parallelStream()
    .collect(Collectors.groupingByConcurrent(
        Log::getLevel,
        Collectors.counting()
    ));

使用groupingByConcurrent()可以在并行流中高效地进行分组统计,提高处理效率。

四、总结:toMap () 的正确打开方式

1. 什么时候可以用 toMap ()?

  • 数据明确唯一:当你确定流中的元素不会产生重复键时,可以使用 toMap ()。
  • 简单映射需求:当你只需要将元素映射到 Map 中,不需要复杂的合并策略或统计聚合时,可以使用 toMap ()。
  • 非并行处理:当你不需要使用并行流时,可以使用 toMap ()。

2. 什么时候应该避免使用 toMap ()?

  • 可能存在重复键:如果流中的元素可能产生重复键,应该使用三参数的 toMap () 或其他替代方法。
  • 需要处理 null 值:如果键或值可能为 null,应该在流处理过程中进行过滤或提供默认值。
  • 并行处理需求:如果需要使用并行流,应该使用 toConcurrentMap () 或其他支持并发的收集器。

3. 替代方案推荐

  • 分组处理:使用 groupingBy () 进行分组统计,避免重复键和 null 值问题。
  • 自定义收集器:当内置收集器无法满足需求时,使用自定义收集器实现灵活的收集逻辑。
  • 并行处理:使用 toConcurrentMap () 或 groupingByConcurrent () 在并行流中进行高效处理。

4. 最后的忠告

toMap () 是一个强大的工具,但也是一个危险的工具。它的简单易用性可能会掩盖潜在的问题,导致代码在生产环境中出现意想不到的错误。因此,在使用 toMap () 时,一定要谨慎处理重复键、null 值和性能问题,或者选择更合适的替代方案。


转载来源:https://juejin.cn/post/7520169578446766089

相关文章
|
2天前
|
监控 安全 Java
Java8真的变老!JDK21时代到了。看看朴朴660 个项目从 JDK8 到 JDK21 的零故障升级之路
Java8真的变老!JDK21时代到了。看看朴朴660 个项目从 JDK8 到 JDK21 的零故障升级之路
|
13天前
|
JSON API 数据格式
淘宝商品评论API接口,json数据示例参考
淘宝开放平台提供了多种API接口来获取商品评论数据,其中taobao.item.reviews.get是一个常用的接口,用于获取指定商品的评论信息。以下是关于该接口的详细介绍和使用方法:
|
3天前
|
缓存 监控 搜索推荐
301重定向实现原理全面解析:从HTTP协议到SEO最佳实践
301重定向是HTTP协议中的永久重定向状态码,用于告知客户端请求的资源已永久移至新URL。它在SEO中具有重要作用,能传递页面权重、更新索引并提升用户体验。本文详解其工作原理、服务器配置方法(如Apache、Nginx)、对搜索引擎的影响及最佳实践,帮助实现网站平稳迁移与优化。
116 67
|
13天前
|
人工智能 Java API
Java 生态大模型应用开发全流程实战案例与技术路径终极对决
在Java生态中开发大模型应用,Spring AI、LangChain4j和JBoltAI是三大主流框架。本文从架构设计、核心功能、开发体验、性能扩展性、生态社区等维度对比三者特点,并结合实例分析选型建议。Spring AI适合已有Spring技术栈团队,LangChain4j灵活性强适用于学术研究,JBoltAI提供开箱即用的企业级解决方案,助力传统系统快速AI化改造。开发者可根据业务场景和技术背景选择最适合的框架。
85 2
|
5月前
|
人工智能 JavaScript 测试技术
通义灵码 2.0 体验报告:AI 赋能智能研发的新范式
通义灵码 2.0 是阿里云基于通义大模型推出的先进开发工具,具备代码智能生成、研发问答、多文件修改和自主执行等核心功能。本文通过亲身体验,展示了其在新功能开发、跨语言编程和单元测试生成等方面的实际效果,并对比了 1.0 版本的改进。结果显示,2.0 版在代码生成完整度、跨语言支持和单元测试自动化上有显著提升,极大提高了开发效率,但仍需进一步优化安全性和个性化风格。推荐指数:⭐⭐⭐⭐⭐。
|
12月前
|
存储 监控 NoSQL
Celery是一个基于分布式消息传递的异步任务队列/作业队列
Celery是一个基于分布式消息传递的异步任务队列/作业队列
|
11月前
|
机器学习/深度学习 人工智能 TensorFlow
使用Python和TensorFlow实现图像识别
【8月更文挑战第31天】本文将引导你了解如何使用Python和TensorFlow库来实现图像识别。我们将从基本的Python编程开始,逐步深入到TensorFlow的高级功能,最后通过一个简单的代码示例来展示如何训练一个模型来识别图像。无论你是初学者还是有经验的开发者,这篇文章都将为你提供有价值的信息。
290 53
|
9月前
|
存储 前端开发 JavaScript
Flux 架构模式
Flux 是一种用于构建用户界面的架构模式,主要用于管理应用程序的状态。它通过单向数据流将应用的不同部分(视图、存储和调度器)解耦,确保状态更新的可预测性和数据的一致性。
|
10月前
|
Java API 开发者
【Java字节码操控新篇章】JDK 22类文件API预览:解锁Java底层的无限可能!
【9月更文挑战第6天】JDK 22的类文件API为Java开发者们打开了一扇通往Java底层世界的大门。通过这个API,我们可以更加深入地理解Java程序的工作原理,实现更加灵活和强大的功能。虽然目前它还处于预览版阶段,但我们已经可以预见其在未来Java开发中的重要地位。让我们共同期待Java字节码操控新篇章的到来!
|
10月前
|
Java API 开发者
【Java字节码的掌控者】JDK 22类文件API:解锁Java深层次的奥秘,赋能开发者无限可能!
【9月更文挑战第8天】JDK 22类文件API的引入,为Java开发者们打开了一扇通往Java字节码操控新世界的大门。通过这个API,我们可以更加深入地理解Java程序的底层行为,实现更加高效、可靠和创新的Java应用。虽然目前它还处于预览版阶段,但我们已经可以预见其在未来Java开发中的重要地位。让我们共同期待Java字节码操控新篇章的到来,并积极探索类文件API带来的无限可能!