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

相关文章
|
5月前
|
Java API 数据处理
Java新特性:使用Stream API重构你的数据处理
Java新特性:使用Stream API重构你的数据处理
|
5月前
|
Java 大数据 API
Java Stream API:现代集合处理与函数式编程
Java Stream API:现代集合处理与函数式编程
323 100
|
5月前
|
Java API 数据处理
Java Stream API:现代集合处理新方式
Java Stream API:现代集合处理新方式
342 101
|
5月前
|
并行计算 Java 大数据
Java Stream API:现代数据处理之道
Java Stream API:现代数据处理之道
291 101
|
4月前
|
安全 前端开发 Java
《深入理解Spring》:现代Java开发的核心框架
Spring自2003年诞生以来,已成为Java企业级开发的基石,凭借IoC、AOP、声明式编程等核心特性,极大简化了开发复杂度。本系列将深入解析Spring框架核心原理及Spring Boot、Cloud、Security等生态组件,助力开发者构建高效、可扩展的应用体系。(238字)
|
6月前
|
存储 Java API
Java Stream API:现代数据处理之道
Java Stream API:现代数据处理之道
405 188
|
6月前
|
存储 Java API
Java Stream API:现代数据处理之道
Java Stream API:现代数据处理之道
314 92
|
4月前
|
Java Unix Go
【Java】(8)Stream流、文件File相关操作,IO的含义与运用
Java 为 I/O 提供了强大的而灵活的支持,使其更广泛地应用到文件传输和网络编程中。!但本节讲述最基本的和流与 I/O 相关的功能。我们将通过一个个例子来学习这些功能。
223 1
|
5月前
|
消息中间件 人工智能 Java
抖音微信爆款小游戏大全:免费休闲/竞技/益智/PHP+Java全筏开源开发
本文基于2025年最新行业数据,深入解析抖音/微信爆款小游戏的开发逻辑,重点讲解PHP+Java双引擎架构实战,涵盖技术选型、架构设计、性能优化与开源生态,提供完整开源工具链,助力开发者从理论到落地打造高留存、高并发的小游戏产品。
|
5月前
|
存储 数据可视化 Java
Java Stream API 的强大功能
Java Stream API 是 Java 8 引入的重要特性,它改变了集合数据的处理方式。通过声明式语法,开发者可以更简洁地进行过滤、映射、聚合等操作。Stream API 支持惰性求值和并行处理,提升了代码效率和可读性,是现代 Java 开发不可或缺的工具。
118 0
Java Stream API 的强大功能