一、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 值和性能问题,或者选择更合适的替代方案。