为啥阿里巴巴Java开发手册建议集合初始化时,指定集合容量大小?

1

为啥阿里巴巴Java开发手册建议集合初始化时,指定集合容量大小?


集合是 Java 开发日常开发中经常会使用到的。在之前的一些文章中,我们介绍过一些关于使用集合类应该注意的事项,如《为什么阿里巴巴禁止在 foreach 循环里进行元素的 remove/add 操作》。

关于集合类,还有很多地方需要注意,本文就来分析下问什么建议集合初始化时,指定集合容量大小?如果一定要设置初始容量的话,设置多少比较合适?

为什么要设置 HashMap 的初始化容量

我们先来写一段代码在 JDK 1.7 (jdk1.7.0_79)下面来分别测试下,在不指定初始化容量和指定初始化容量的情况下性能情况如何。(jdk 8 结果会有所不同,我会在后面的文章中分析)

为啥阿里巴巴Java开发手册建议集合初始化时,指定集合容量大小?


以上代码不难理解,我们创建了 3 个 HashMap,分别使用默认的容量(16)、使用元素个数的一半(5千万)作为初始容量、使用元素个数(一亿)作为初始容量进行初始化。然后分别向其中 put 一亿个 KV。

输出结果:

未初始化容量,耗时 : 14419

初始化容量5000000,耗时 : 11916

初始化容量为10000000,耗时 : 7984

从结果中,我们可以知道,在已知 HashMap 中将要存放的 KV 个数的时候,设置一个合理的初始化容量可以有效的提高性能。

当然,以上结论也是有理论支撑的。HashMap 有扩容机制,就是当达到扩容条件时会进行扩容。HashMap 的扩容条件就是当 HashMap 中的元素个数(size)超过临界值(threshold)时就会自动扩容。在 HashMap 中,threshold = loadFactor * capacity。

所以,如果我们没有设置初始容量大小,随着元素的不断增加,HashMap 会发生多次扩容,而 HashMap 中的扩容机制决定了每次扩容都需要重建 hash 表,是非常影响性能的。

从上面的代码示例中,我们还发现,同样是设置初始化容量,设置的数值不同也会影响性能,那么当我们已知 HashMap 中即将存放的 KV 个数的时候,容量设置成多少为好呢?

HashMap 中容量的初始化

默认情况下,当我们设置 HashMap 的初始化容量时,实际上 HashMap 会采用第一个大于该数值的 2 的幂作为初始化容量。

如以下示例代码:

为啥阿里巴巴Java开发手册建议集合初始化时,指定集合容量大小?


在 jdk1.7 中,初始化容量设置成 1 的时候,输出结果是 2。在 jdk1.8 中,如果我们传入的初始化容量为 1,实际上设置的结果也为 1,上面代码输出结果为 2 的原因是代码中 map.put("hahaha", "hollischuang");导致了扩容,容量从 1 扩容到 2。

那么,话题再说回来,当我们通过 HashMap(int initialCapacity)设置初始容量的时候,HashMap 并不一定会直接采用我们传入的数值,而是经过计算,得到一个新值,目的是提高 hash 的效率。(1->1、3->4、7->8、9->16)

为啥阿里巴巴Java开发手册建议集合初始化时,指定集合容量大小?


不管是 Jdk 1.7 还是 Jdk 1.8,计算初始化容量的算法其实是如出一辙的,主要代码如下:

为啥阿里巴巴Java开发手册建议集合初始化时,指定集合容量大小?


上面的代码挺有意思的,一个简单的容量初始化,Java 的工程师也有很多考虑在里面。

上面的算法目的挺简单,就是:根据用户传入的容量值(代码中的cap),通过计算,得到第一个比他大的 2 的幂并返回。

聪明的读者们,如果让你设计这个算法你准备如何计算?如果你想到二进制的话,那就很简单了。举几个例子看一下:

为啥阿里巴巴Java开发手册建议集合初始化时,指定集合容量大小?


请关注上面的几个例子中,蓝色字体部分的变化情况,或许你会发现些规律。5->8、9->16、19->32、37->64 都是主要经过了两个阶段。

为啥阿里巴巴Java开发手册建议集合初始化时,指定集合容量大小?


对应到以上代码中,Step1:

为啥阿里巴巴Java开发手册建议集合初始化时,指定集合容量大小?


对应到以上代码中,Step2:

为啥阿里巴巴Java开发手册建议集合初始化时,指定集合容量大小?


Step 2 比较简单,就是做一下极限值的判断,然后把 Step 1 得到的数值 +1。

Step 1 怎么理解呢?其实是对一个二进制数依次向右移位,然后与原值取或。其目的对于一个数字的二进制,从第一个不为 0 的位开始,把后面的所有位都设置成 1。

随便拿一个二进制数,套一遍上面的公式就发现其目的了:

为啥阿里巴巴Java开发手册建议集合初始化时,指定集合容量大小?


通过几次无符号右移和按位或运算,我们把 1100 1100 1100 转换成了1111 1111 1111 ,再把 1111 1111 1111 加 1,就得到了 1 0000 0000 0000,这就是大于 1100 1100 1100 的第一个 2 的幂。

好了,我们现在解释清楚了 Step 1 和 Step 2 的代码。就是可以把一个数转化成第一个比他自身大的 2 的幂。(可以开始佩服 Java 的工程师们了,使用无符号右移和按位或运算大大提升了效率。)

但是还有一种特殊情况套用以上公式不行,这些数字就是 2 的幂自身。如果数字 4 套用公式的话。得到的会是 8 :

为啥阿里巴巴Java开发手册建议集合初始化时,指定集合容量大小?


为了解决这个问题,JDK 的工程师把所有用户传进来的数在进行计算之前先 -1,就是源码中的第一行:

为啥阿里巴巴Java开发手册建议集合初始化时,指定集合容量大小?


HashMap 中初始容量的合理值

当我们使用HashMap(int initialCapacity)来初始化容量的时候,jdk 会默认帮我们计算一个相对合理的值当做初始容量。那么,是不是我们只需要把已知的 HashMap 中即将存放的元素个数直接传给 initialCapacity 就可以了呢?

关于这个值的设置,在《阿里巴巴 Java 开发手册》有以下建议:

为啥阿里巴巴Java开发手册建议集合初始化时,指定集合容量大小?


这个值,并不是阿里巴巴的工程师原创的,在 guava(21.0 版本)中也使用的是这个值。

为啥阿里巴巴Java开发手册建议集合初始化时,指定集合容量大小?


在return (int) ((float) expectedSize / 0.75F + 1.0F);上面有一行注释,说明了这个公式也不是 guava 原创,参考的是 JDK8 中 putAll 方法中的实现的。感兴趣的读者可以去看下 putAll 方法的实现,也是以上的这个公式。

虽然,当我们使用HashMap(int initialCapacity)来初始化容量的时候,jdk 会默认帮我们计算一个相对合理的值当做初始容量。但是这个值并没有参考 loadFactor 的值。

也就是说,如果我们设置的默认值是 7,经过 JDK 处理之后,会被设置成 8,但是,这个 HashMap 在元素个数达到 8*0.75 = 6 的时候就会进行一次扩容,这明显是我们不希望见到的。

如果我们通过expectedSize / 0.75F + 1.0F计算,7/0.75 + 1 = 10 ,10 经过 JDK 处理之后,会被设置成 16,这就大大的减少了扩容的几率。

当 HashMap 内部维护的哈希表的容量达到 75% 时(默认情况下),会触发 rehash,而 rehash 的过程是比较耗费时间的。所以初始化容量要设置成 expectedSize/0.75 + 1 的话,可以有效的减少冲突也可以减小误差。

所以,我可以认为,当我们明确知道 HashMap 中元素的个数的时候,把默认容量设置成 expectedSize / 0.75F + 1.0F 是一个在性能上相对好的选择,但是,同时也会牺牲些内存。

小结

当我们想要在代码中创建一个 HashMap 的时候,如果我们已知这个 Map 中即将存放的元素个数,给 HashMap 设置初始容量可以在一定程度上提升效率。

但是,JDK 并不会直接拿用户传进来的数字当做默认容量,而是会进行一番运算,最终得到一个 2 的幂。

但是,为了最大程度的避免扩容带来的性能消耗,我们建议可以把默认容量的数字设置成 expectedSize / 0.75F + 1.0F 。在日常开发中,可以使用

为啥阿里巴巴Java开发手册建议集合初始化时,指定集合容量大小?


来创建一个 HashMap,计算的过程 guava 会帮我们完成。

但是,以上的操作是一种用内存换性能的做法,真正使用的时候,要考虑到内存的影响。

觉得文章不错就给小老弟点个关注吧,更多内容陆续奉上。

最后,分享一份面试宝典《Java核心知识点整理.pdf》,覆盖了JVM、锁、高并发、反射、Spring原理、微服务、Zookeeper、数据库、数据结构等等。加入我的个人粉丝群(Java架构技术栈:644872653)获取免费领取方式。


猜你喜欢

转载自blog.51cto.com/14480698/2459302