掘金 后端 ( ) • 2024-06-27 09:30

在前几天我写了一篇文章分享了为何避免使用 Collectors.toMap(),感兴趣的可以去瞧一眼:Stream很好,Map很酷,但答应我别用toMap()

评论区也有小伙伴提到自己也踩过同样的坑,在那篇文章里介绍了 toMap() 有哪些的易踩的坑,今天就让我们好好的扒一扒 Map 的底裤,看看这背后不为人知的故事。

要讲 Map,可以说 HashMap 是日常开发使用频次最高的,我愿称其为古希腊掌管性能的神。

举个简单的例子,如何判断两个集合是否存在交集?最简单也最粗暴的方式,两层 for 遍历暴力检索,别跟我提什么时间空间复杂度,给我梭哈就完事。

public void demo() {  
    List<Integer> duplicateList = new ArrayList<>();  
    List<Integer> list1 = List.of(1, 2, 3, 4);  
    List<Integer> list2 = List.of(3, 4, 5, 6);  
    for (Integer l1 : list1) {  
        for (Integer l2 : list2) {  
            if (Objects.equals(l1, l2)) {  
                duplicateList.add(l1);  
            }  
        }  
    }  
    System.out.println(duplicateList);  
}

image.png

敲下回车提交代码之后,当还沉浸在等待领导夸你做事又稳又快的时候,却发现领导黑着脸向你一步步走来。

刚准备开始摸鱼的你吓得马上回滚了提交,在一番资料查询之后你发现了原来可以通过 Map 实现 O(n) 级的检索效率,你意气风发的敲下一段新的代码:

public void demo() {  
    List<Integer> duplicateList = new ArrayList<>();  
    List<Integer> list1 = List.of(1, 2, 3, 4);  
    List<Integer> list2 = List.of(3, 4, 5, 6);  

    Map<Integer, Integer> map = new HashMap<>();  
    list2.forEach(it -> map.put(it, it));  
    for (Integer l : list1) {  
        if (Objects.nonNull(map.get(l))) {  
            duplicateList.add(l);  
        }  
    }  
    System.out.println(duplicateList);  
}

重新提交代码起身上厕所,你昂首挺胸的特地从领导面前路过,领导回了你一个肯定的眼神。

image.png

让我们回到 HashMap 的身上,作为八股十级选手而言的你,什么数据结构红黑树可谓信手拈来,但我们今天不谈八股,只聊聊背后的一些设计理念。

众所周知,在 HashMap 中有且仅允许存在一个 keynull 的元素,当 key 已存在默认的策略是进行覆盖,比如下面的示例最终 map 的值即 {null=2}

Map<Integer, Integer> map = new HashMap<>();  
map.put(null, 1);  
map.put(null, 2);  
System.out.println(map);

同时 HashMap 对于 value 的值并没有额外限制,只要你愿意,你甚至可以放几百万 value 为空的元素像下面这个例子:

Map<Integer, Integer> map = new HashMap<>();  
map.put(1, null);
map.put(2, null);
map.put(3, null);
map.put(4, null);
map.put(5, null);
System.out.println(map);

这也就引出了今天的重点!

stream 中使用 Collectors.toMap() 时,如果你不注意还是按照惯性思维那么它就会让你感受一下什么叫做暴击。就像上一篇文章提到的其异常触发机制,但却并不知道为什么要这么设计?

作为网络冲浪小能手,我反手就是在 stackoverflow 发了提问,咱虽然笨但主打一个好学。

image.png

值得一提的是,评论区有个老哥回复的相当戳我,他的回复如下:

image.png

用我三脚猫的英语水平翻译一下,大概意思如下:

因为人家 toMap() 并没有说返回的是 HashMap,所以你凭什么想要人家遵循跟 HashMap 一样的规则呢?

我滴个乖乖,他讲的似乎好有道理的样子。

image.png

我一开始也差点信了,但其实你认真看 toMap() 的内部实现,你会发现其返回的不偏不倚正好就是 HashMap

image.png

如果你还不信,以上篇文章的代码为例,执行后获取其类型可以看到输出就是 HashMap

image.png

这时候我的 CPU 又烧了,这还是我认识的 HashMap,怎么开始跟 stream 混之后就开始六亲不认了,是谁说的代码永远不会变心的?

image.png

一切彷佛又回到了起点,为什么在新的 stream 中不遵循大家已经熟悉规范,而是要改变习惯对此做出限制?

stackoverflow 上另外的一个老哥给出的他的意见:

image.png

让我这个四级 751 分老手再给大家做个免费翻译官简化一下观点:

Collectors.toMap() 的文档中已经标注其并不保证返回 Map 的具体类型,以及是否可变、序列化性以及是否线程安全,而 JDK 拥有众多的版本,可能在你的环境已经平稳运行了数年,但换个环境之后在不同的 JDK 下可能程序就发生了崩溃。因此,这些额外的保障实际上还帮了你的忙。

回头去看 toMap() 方法上的文档说明,确实也像这位老哥提到的那样。

image.png

而在 HashMap 中允许 KeyValue 为空带来的一个问题在此时也浮现了出来,当存入一个 value 为空的元素时,再后续执行 get() 再次读取时,存在一个问题那就是二义性。

很显然执行 get() 返回的结果将为空,那这个空究竟是 Map 中不存在这个元素?还是我存入的元素其 value 为空?这一点我想只有老天爷知道,而这种二义性所带来的问题在设计层面显然是一个失误。

那么到这里,我们就可以得到一个暴论:HashMap 允许 key 和 value 为空就是 JDK 留下的“屎山”!

为了验证这一结论,我们可以看看在新的 ConcurrentHashMapJDK 是怎么做的?查看源码可以看到,在 put() 方法的一开始就执行了 keyvalue 的空值校验,也验证了上面的猜想。

image.png

这还原不够支撑我们的结论,让我们继续深挖这背后还有什么猫腻。

首先让我看看是谁写的 ConcurrentHashMap,在 openjdkGitHub 仓库类文档注释可以看到主要的开发者是 Doug Lea

image.png

Doug Lea 又是何方大佬,通过维基百科的可以看到其早期是 Java 并发社区的主席,他参与了一众的 JDK 并发设计工作,可谓吾辈偶像。

image.png

在网络搜罗相关的资讯找到对应的话题,虽然图中的链接已经不存在了,但还是能从引用的内容看出其核心的原因正是为了规避的结果的模糊性,与前文我们讨论的二义性不尽相同。

image.png

那为什么 JDK 不同步更新 HashMap 的设计理念,在新版 HashMap 中引入 keyvalue 的非空校验?

我想剩下的理由只有一个:HashMap 的使用范围实在太广,就算是 JDK 自己也很难在不变更原有结构的基础上进行改动,而在 JDK 1.2 便被提出并广泛应用,对于一个发展了数十年的语言而言,兼容性是十分重要的一大考量。

因此,我们可以看到,在后续推出的 Map 中,往往对 keyValue 都作了进一步的限制,而对于 HashMap 而言,可能 JDK 官方也是有心无力吧。

到这里基本也就盖棺定论了,但本着严谨的态度大胆假设小心求证,让我们再来看看大家伙的意见,万一不小心就被人网暴了。

image.png

stackoverflow 上另外几篇有关 Map 回答下可以看到,许多人都认为 HashMap 支持空值是一个存在缺陷的设计。

image.png

感兴趣的小伙伴可以去原帖查看,这里我就不再展开介绍了,原帖链接:Why does Map.of not allow null keys and values?

看到这里,下次别人或者老板再说你写的代码是屎山的时候,请昂首挺胸自信的告诉他 JDk 都会犯错,我写的这点又算得了什么?

image.png