由阿里巴巴 Java 开发规约 HashMap 条目引发的故事

前言

大热的《阿里巴巴 Java 开发规约》中有提到:

  • 【推荐】集合初始化时,指定集合初始值大小。

说明:HashMap使用如下构造方法进行初始化,如果暂时无法确定集合大小,那么指定默认值(16)即可:

    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

看到代码规约这一条的时候,我觉得是不是有点太 low 了,身为开发,大家都知道HashMap的原理。什么?这个要通过插件监测?没必要吧,哪个开发不知道默认大小,何时resize啊,然后我和孤尽打赌随机咨询几位同学以下几个问题:

  • HashMap默认bucket数组多大?
  • 如果new HashMap<>(19)bucket数组多大?
  • HashMap什么时候开辟bucket数组占用内存?
  • HashMap何时扩容?

抽样调查的结果出乎我的意料:

  • HashMap默认bucket数组多大?(答案是16,大概一半的同学答错)
  • 如果new HashMap<>(19)bucket数组多大?(答案是32,大多被咨询同学都不太了解这个点)
  • HashMap什么时候开辟bucket数组占用内存?(答案是第一次put时,一半同学认为是new的时候)
  • HashMap何时扩容?(答案是put的元素达到容量乘负载因子的时候,默认16*0.75,有 1/4 同学中枪)

HashMap是写代码时最常用的集合类之一,看来大家也不是全都很了解。孤尽乘胜追击又抛出问题:JDK8 中HashMap和之前HashMap有什么不同?

我知道 JDK8 中HashMap引入了红黑树来处理哈希碰撞,具体细节和源代码并没有仔细翻过,看来是时候对比翻看下 JDK8 和 JDK7 的HashMap源码了。

通过对比翻看源码,先说下结论:

  • HashMapnew后并不会立即分配bucket数组,而是第一次put时初始化,类似ArrayList在第一次add时分配空间。
  • HashMapbucket数组大小一定是 2 的幂,如果new的时候指定了容量且不是 2 的幂,实际容量会是最接近(大于)指定容量的 2 的幂,比如new HashMap<>(19),比 19 大且最接近的 2 的幂是 32,实际容量就是 32。
  • HashMapput的元素数量大于Capacity * LoadFactor(默认16 * 0.75) 之后会进行扩容。
  • JDK8 在哈希碰撞的链表长度达到TREEIFY_THRESHOLD(默认8)后,会把该链表转变成树结构,提高了性能。
  • JDK8 在resize的时候,通过巧妙的设计,减少了rehash的性能消耗。

存储结构

  • JDK7 中的HashMap还是采用大家所熟悉的数组+链表的结构来存储数据。

  • JDK8 中的HashMap采用了数组+链表或树的结构来存储数据。

重要参数

HashMap中有两个重要的参数,初始容量和负载因子:

  • Initial capacity,The capacity is the number of buckets in the hash table, The initial capacity is simply the capacity at the time the hash table is created.
  • Load factor,The load factor is a measure of how full the hash table is allowed to get before its capacity is automatically increased.

Initial capacity决定bucket的大小,Load factor决定bucket内数据填充比例,基于这两个参数的乘积,HashMap内部由threshold这个变量来表示HashMap能放入的元素个数。

  • capacity就是HashMap中数组的length
  • loadFactor一般都是使用默认的0.75
  • threshold 决定能放入的数据量,一般情况下等于capacity * LoadFactor

以上参数在 JDK7 和 JDK8 中是一致的,接下来会根据实际代码分析。

JDK8 中的 HashMap 实现

new

HashMapbucket数组并不会在new的时候分配,而是在第一次put的时候通过resize()函数进行分配。

JDK8 中HashMapbucket数组大小肯定是 2 的幂,对于 2 的幂大小的bucket,计算下标只需要hash后按位与n-1,比%模运算取余要快。如果你通过HashMap(int initialCapacity)构造器传入initialCapacity,会先计算出比initialCapacity大的 2 的幂存入threshold,在第一次putresize()初始化中会按照这个 2 的幂初始化数组大小,此后resize扩容也都是每次乘 2,这么设计的原因后面会详细讲。

hashmap-tableSizeFor

hash

JKD8 中putget时,对keyhashCode先用hash函数散列下,再计算下标:

hash-method
具体hash代码如下:

    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

由于h>>>16,高 16bit 补 0,一个数和 0 异或不变,所以hash函数大概的作用就是:高 16bit 不变,低 16bit 和高 16bit 做了一个异或,目的是减少碰撞。

按照函数注释,因为bucket数组大小是 2 的幂,计算下标index = (table.length - 1) & hash,如果不做hash处理,相当于散列生效的只有几个低 bit 位,为了减少散列的碰撞,设计者综合考虑了速度、作用、质量之后,使用高 16bit 和低 16bit 异或来简单处理减少碰撞,而且 JDK8 中用了复杂度O(logn)的树结构来提升碰撞下的性能。具体性能提升可以参考「Java 8:HashMap的性能提升」这篇文章。

put

put函数的思路大致分以下几步:

  • keyhashCode()进行hash后计算数组下标index
  • 如果当前数组tablenull,进行resize()初始化;
  • 如果没碰撞直接放到对应下标的bucket里;
  • 如果碰撞了,且节点已经存在,就替换掉value
  • 如果碰撞后发现为树结构,挂载到树上;
  • 如果碰撞后为链表,添加到链表尾,并判断链表如果过长(大于等于TREEIFY_THRESHOLD,默认8),就把链表转换成树结构;
  • 数据put后,如果数据量超过threshold,就要resize

具体代码如下:

hashmap-put
hashmap-put2

resize

resize()用来第一次初始化,或者put之后数据超过了threshold后扩容,resize的注释如下:

Initializes or doubles table size. If null, allocates in accord with initial capacity target held in field threshold. Otherwise, because we are using power-of-two expansion, the elements from each bin must either stay at same index, or move with a power of two offset in the new table.

数组下标计算:index = (table.length - 1) & hash,由于table.length也就是capacity肯定是 2 的 N 次方,使用&位运算意味着只是多了最高位,这样就不用重新计算index,元素要么在原位置,要么在原位置+oldCapacity

如果增加的高位为 0,resizeindex不变,如图所示:

jdk8-resize-1

如果增加的高位为 1,resizeindex增加oldCap,如图所示:

jdk8-resize-2

这个设计的巧妙之处在于,节省了一部分重新计算hash的时间,同时新增的一位为 0 或 1 的概率可以认为是均等的,所以在resize的过程中就将原来碰撞的节点又均匀分布到了两个bucket里。

jdk8-resize-code-1
jdk8-resize-code-2
jdk8-resize-code-3

JDK7 中的 HashMap 实现

new

JDK7 里HashMapbucket数组也不会在new的时候分配,也是在第一次put的时候通过inflateTable()函数进行分配。

JDK7 中HashMapbucket数组大小也一定是 2 的幂,同样有计算下标简便的优点。如果你通过HashMap(int initialCapacity)构造器传入initialCapacity,会先存入threshold,在第一次put时调用inflateTable()初始化,会计算出比initialCapacity大的 2 的幂作为初始化数组的大小,此后resize扩容也都是每次乘 2。

jdk7-hashmap

hash

JKD7 中,bucket数组下标也是按位与计算,但是hash函数与 JDK8 稍有不同,代码注释如下:

Retrieve object hash code and applies a supplemental hash function to the result hash, which defends against poor quality hash functions. This is critical because HashMap uses power-of-two length hash tables, that otherwise encounter collisions for hashCodes that do not differ in lower bits. Note: Null keys always map to hash 0, thus index 0.

hash为了防止只有hashCode()的低 bit 位参与散列容易碰撞,也采用了位移异或,只不过不是高低 16bit,而是如下代码中多次位移异或。

JKD7 的hash中存在一个开关:hashSeed。开关打开(hashSeed不为0)的时候,对String类型的key采用sun.misc.Hashing.stringHash32hash算法;对非String类型的key,多一次和hashSeed的异或,也可以一定程度上减少碰撞的概率。

JDK 7u40 以后,hashSeed被移除,在 JDK8 中也没有再采用,因为stringHash32()的算法基于MurMur哈希,其中hashSeed的产生使用了Romdum.nextInt()实现。Rondom.nextInt()使用AtomicLong,它的操作是CAS的(Compare And Swap)。这个 CAS 操作当有多个 CPU 核心时,会存在许多性能问题。因此,这个替代函数在多核处理器中表现出了糟糕的性能。

具体hash代码如下所示:

jdk7-hash

hashSeed默认值是 0,也就是默认关闭,任何数字与 0 异或不变。hashSeed会在capacity发生变化的时候,通过initHashSeedAsNeeded()函数进行计算。当capacity大于设置值Holder.ALTERNATIVE_HASHING_THRESHOLD后,会通过sun.misc.Hashing.randomHashSeed产生hashSeed值,这个设定值是通过 JVM 的jdk.map.althashing.threshold参数来设置的,具体代码如下:

jdk7-initHashSeedAsNeeded-1
jdk7-initHashSeedAsNeeded-2

put

JKD7 的put相比于 JDK8 就要简单一些,碰撞以后只有链表结构。具体代码如下:

jdk7-put-1
jdk7-put-2

resize

JDK7 的resize()也是扩容两倍,不过扩容过程相对 JDK8 就要简单许多,由于默认initHashSeedAsNeeded内开关都是关闭状态,所以一般情况下transfer不需要进行rehash,能减少一部分开销。代码如下所示:

jdk7-resize

总结

HashMapnew后并不会立即分配bucket数组,而是第一次put时初始化,类似ArrayList在第一次add时分配空间。

HashMapbucket数组大小一定是 2 的幂,如果new的时候指定了容量且不是 2 的幂,实际容量会是最接近(大于)指定容量的 2 的幂,比如new HashMap<>(19),比 19 大且最接近的 2 的幂是 32,实际容量就是 32。

HashMapput的元素数量大于Capacity * LoadFactor(默认16 * 0.75) 之后会进行扩容。

JDK8 处于提升性能的考虑,在哈希碰撞的链表长度达到TREEIFY_THRESHOLD(默认8)后,会把该链表转变成树结构。

JDK8 在resize的时候,通过巧妙的设计,减少了rehash的性能消耗。

相对于 JDK7 的 1000 余行代码,JDK8 代码量达到了 2000 余行,对于这个大家最常用的数据结构增加了不少的性能优化。

仔细看完上面的分析和源码,对HashMap内部的细节又多了些了解,有空的时候还是多翻翻源码吧!

阿里巴巴 Java 开发规约》自诞生以来,一直处于挑战漩涡的最中心,从这一个规约的小条目,看出来规约也是冰冻三尺,非一日之寒,研读规约,其实能够发现很多看似简单的知识点背后,其实隐藏着非常深的逻辑知识点。


参考资料

已标记关键词 清除标记
©️2020 CSDN 皮肤主题: 代码科技 设计师:Amelia_0503 返回首页
实付 9.90元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值