前言
大热的《阿里巴巴 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
源码了。
通过对比翻看源码,先说下结论:
HashMap
在new
后并不会立即分配bucket
数组,而是第一次put
时初始化,类似ArrayList
在第一次add
时分配空间。HashMap
的bucket
数组大小一定是 2 的幂,如果new
的时候指定了容量且不是 2 的幂,实际容量会是最接近(大于)指定容量的 2 的幂,比如new HashMap<>(19)
,比 19 大且最接近的 2 的幂是 32,实际容量就是 32。HashMap
在put
的元素数量大于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
HashMap
的bucket
数组并不会在new
的时候分配,而是在第一次put
的时候通过resize()
函数进行分配。
JDK8 中HashMap
的bucket
数组大小肯定是 2 的幂,对于 2 的幂大小的bucket
,计算下标只需要hash
后按位与n-1
,比%
模运算取余要快。如果你通过HashMap(int initialCapacity)
构造器传入initialCapacity
,会先计算出比initialCapacity
大的 2 的幂存入threshold
,在第一次put
的resize()
初始化中会按照这个 2 的幂初始化数组大小,此后resize
扩容也都是每次乘 2,这么设计的原因后面会详细讲。
hash
JKD8 中put
和get
时,对key
的hashCode
先用hash
函数散列下,再计算下标:
具体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
函数的思路大致分以下几步:
- 对
key
的hashCode()
进行hash
后计算数组下标index
; - 如果当前数组
table
为null
,进行resize()
初始化; - 如果没碰撞直接放到对应下标的
bucket
里; - 如果碰撞了,且节点已经存在,就替换掉
value
; - 如果碰撞后发现为树结构,挂载到树上;
- 如果碰撞后为链表,添加到链表尾,并判断链表如果过长(大于等于
TREEIFY_THRESHOLD
,默认8
),就把链表转换成树结构; - 数据
put
后,如果数据量超过threshold
,就要resize
。
具体代码如下:
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,resize
后index
不变,如图所示:
如果增加的高位为 1,resize
后index
增加oldCap
,如图所示:
这个设计的巧妙之处在于,节省了一部分重新计算hash
的时间,同时新增的一位为 0 或 1 的概率可以认为是均等的,所以在resize
的过程中就将原来碰撞的节点又均匀分布到了两个bucket
里。
JDK7 中的 HashMap 实现
new
JDK7 里HashMap
的bucket
数组也不会在new
的时候分配,也是在第一次put
的时候通过inflateTable()
函数进行分配。
JDK7 中HashMap
的bucket
数组大小也一定是 2 的幂,同样有计算下标简便的优点。如果你通过HashMap(int initialCapacity)
构造器传入initialCapacity
,会先存入threshold
,在第一次put
时调用inflateTable()
初始化,会计算出比initialCapacity
大的 2 的幂作为初始化数组的大小,此后resize
扩容也都是每次乘 2。
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.stringHash32
的hash
算法;对非String
类型的key
,多一次和hashSeed
的异或,也可以一定程度上减少碰撞的概率。
JDK 7u40 以后,hashSeed
被移除,在 JDK8 中也没有再采用,因为stringHash32()
的算法基于MurMur
哈希,其中hashSeed
的产生使用了Romdum.nextInt()
实现。Rondom.nextInt()
使用AtomicLong
,它的操作是CAS的(Compare And Swap)。这个 CAS 操作当有多个 CPU 核心时,会存在许多性能问题。因此,这个替代函数在多核处理器中表现出了糟糕的性能。
具体hash
代码如下所示:
hashSeed
默认值是 0,也就是默认关闭,任何数字与 0 异或不变。hashSeed
会在capacity
发生变化的时候,通过initHashSeedAsNeeded()
函数进行计算。当capacity
大于设置值Holder.ALTERNATIVE_HASHING_THRESHOLD
后,会通过sun.misc.Hashing.randomHashSeed
产生hashSeed
值,这个设定值是通过 JVM 的jdk.map.althashing.threshold
参数来设置的,具体代码如下:
put
JKD7 的put
相比于 JDK8 就要简单一些,碰撞以后只有链表结构。具体代码如下:
resize
JDK7 的resize()
也是扩容两倍,不过扩容过程相对 JDK8 就要简单许多,由于默认initHashSeedAsNeeded
内开关都是关闭状态,所以一般情况下transfer
不需要进行rehash
,能减少一部分开销。代码如下所示:
总结
HashMap
在new
后并不会立即分配bucket
数组,而是第一次put
时初始化,类似ArrayList
在第一次add
时分配空间。
HashMap
的bucket
数组大小一定是 2 的幂,如果new
的时候指定了容量且不是 2 的幂,实际容量会是最接近(大于)指定容量的 2 的幂,比如new HashMap<>(19)
,比 19 大且最接近的 2 的幂是 32,实际容量就是 32。
HashMap
在put
的元素数量大于Capacity * LoadFactor
(默认16 * 0.75
) 之后会进行扩容。
JDK8 处于提升性能的考虑,在哈希碰撞的链表长度达到TREEIFY_THRESHOLD
(默认8
)后,会把该链表转变成树结构。
JDK8 在resize
的时候,通过巧妙的设计,减少了rehash
的性能消耗。
相对于 JDK7 的 1000 余行代码,JDK8 代码量达到了 2000 余行,对于这个大家最常用的数据结构增加了不少的性能优化。
仔细看完上面的分析和源码,对HashMap
内部的细节又多了些了解,有空的时候还是多翻翻源码吧!
《阿里巴巴 Java 开发规约》自诞生以来,一直处于挑战漩涡的最中心,从这一个规约的小条目,看出来规约也是冰冻三尺,非一日之寒,研读规约,其实能够发现很多看似简单的知识点背后,其实隐藏着非常深的逻辑知识点。
参考资料: