参考:https://blog.csdn.net/login_sonata/article/details/76598675 (有删改和补充)
题外话:最近有朋友问我一个问题,也是我曾经初学HashMap时问过老师的问题。所以我打算写一篇博客来回答,以前看过不少博客,但是没想过写。初次尝试,请多指教。
某种情况指的是下面这种情况:
HashMap<Integer,Integer> hashMap = new HashMap<>();
hashMap.put(1,1);
hashMap.put(2,2);
hashMap.put(3,3);
hashMap.put(4,4);
hashMap.put(5,5);
hashMap.put(6,6);
for (Integer s: hashMap.keySet()) {
System.out.print(hashMap.get(s) + " ");
}
如果按照上面的方式向HashMap中添加数据,那输出结果肯定是:
1 2 3 4 5 6
看到这个输出结果, 有人就会对HashMap的无序产生怀疑了,这里为什么是有序的呢?
(你也可以改变添加数据的顺序,比如先添加 “2” 再添加 “1”,最后结果还是这样)
带着这个疑问我们再来看一个例子:
HashMap<Integer,Integer> hashMap = new HashMap<>();
hashMap.put(1,1);
hashMap.put(2,2);
hashMap.put(3,3);
hashMap.put(4,4);
hashMap.put(5,5);
hashMap.put(6,6);
hashMap.put(65536,65536);
for (Integer s: hashMap.keySet()) {
System.out.print(hashMap.get(s) + " ");
}
我们思考一下,你觉得会输出什么? 还会是1 2 3 4 5 6 65536
这样的顺序吗?
运行结果是:1 65536 2 3 4 5 6
看完这个例子之后,很显然HashMap是无序的,因为它有自己的一套算法。
那我们回过来想,为什么在第一个例子中会出现HashMap有序的情况呢?
想要知道答案就来分析HashMap源码吧。
从 数据结构 和 源码实现 两个方面来讲解(以JDK 1.8为例)
HashMap的数据结构由 数组 + 链表 + 红黑树(JDK 1.8版本才加入的红黑树)
我们看这幅图应该需要明白两个问题:
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; // 用来定义数组索引位置
final K key;
V value;
Node<K,V> next; // 链表中下一个Node
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
}
例如程序执行下面代码:
hashMap.put(65536,65536);
系统将调用 “65536” 这个 key 的 hashCode() 方法得到其 hash值 (该方法适用于每个Java对象),然后再通过Hash算法的后两步运算(高位运算和取模运算,下文有介绍)来定位该键值对的存储在数组中的位置,有时两个key会定位到数组中相同的位置,表示发生了Hash碰撞(碰撞之后就会生成链表)。当然Hash算法计算结果在越分散均匀,Hash碰撞的概率就越小,map的存取效率就会越高。
如果哈希桶数组很大,即使较差的Hash算法也会比较分散,如果哈希桶数组数组很小,即使好的Hash算法也会出现较多碰撞,所以就需要在空间成本和时间成本之间权衡,其实就是在根据实际情况确定哈希桶数组(Node[] table)的大小,并在此基础上设计好的hash算法减少Hash碰撞。所以好的Hash算法和扩容机制至关重要。
在理解Hash和扩容流程之前,我们得先了解下HashMap的几个字段。
从HashMap的默认构造函数源码可知,构造函数就是对下面几个字段进行初始化:
int threshold; // 扩容阈值
final float loadFactor; // 负载因子
transient int modCount; // 出现线程问题时,负责及时抛异常
transient int size; // HashMap中实际存在的Node数量
首先,Node[] table的初始化长度length(默认值是16),Load factor为负载因子(默认值是0.75),threshold是HashMap所能容纳的最大数据量的Node(键值对)个数。threshold = length * Load factor。
也就是说,在数组定义好长度之后,负载因子越大,所能容纳的键值对个数越多。
结合负载因子的定义公式可知,threshold就是在此Load factor和length(数组长度)对应下允许的最大元素数目,超过这个数目就重新resize(扩容),扩容后的HashMap容量是之前容量的两倍。默认的负载因子0.75是对空间和时间效率的一个平衡选择,建议大家不要修改,除非在时间和空间比较特殊的情况下,比如内存空间很多而又对时间效率要求很高,可以降低负载因子Load factor的值;相反,如果内存空间紧张而对时间效率要求不高,可以增加负载因子loadFactor的值,这个值可以大于1。
size这个字段其实很好理解,就是HashMap中实际存在的键值对数量。注意和table的长度length、容纳最大键值对数量threshold的区别。而modCount字段主要用来记录HashMap内部结构发生变化的次数。强调一点,内部结构发生变化指的是结构发生变化,例如put新键值对,但是某个key对应的value值被覆盖不属于结构变化。
在HashMap中,哈希桶数组table的长度length大小必须为2的n次方(一定是合数),这是一种非常规的设计,常规的设计是把桶的大小设计为素数。相对来说素数导致冲突的概率要小于合数,具体证明可以参考 为什么一般hashtable的桶数会取一个素数。
这里存在一个问题,即使负载因子和Hash算法设计的再合理,也免不了会出现拉链过长的情况,一旦出现拉链过长,则会严重影响HashMap的性能。于是,在JDK1.8版本中,对数据结构做了进一步的优化,引入了红黑树。而当链表长度太长(默认超过8)时,链表就转换为红黑树,利用红黑树快速增删改查的特点提高HashMap的性能。
HashMap的内部功能实现很多,本文主要从根据key获取哈希桶数组索引位置、put方法的详细执行、扩容过程三个具有代表性的点深入讲解。
// 方法一,jdk1.8 & jdk1.7都有:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// 方法二,jdk1.7有,jdk1.8没有这个方法,把这个取消了,置换成了一行代码。
static int indexFor(int h, int length) {
return h & (length-1);
}
对于任意给定的对象,只要它的hashCode()返回值相同,那么程序调用方法一所计算得到的Hash码值总是相同的。我们首先想到的就是把hash值对数组长度取模运算,这样一来,元素的分布相对来说是比较均匀的。但是,模运算的消耗还是比较大的,在HashMap中是这样做的:调用方法二来计算该对象应该保存在table数组的哪个索引处。
这个方法非常巧妙,它通过h & (table.length -1)来得到该对象的保存位,而HashMap底层数组的长度总是2的n次方,这是HashMap在速度上的优化。当length总是2的n次方时,h& (length-1)运算等价于对length取模,也就是h%length,但是&比%具有更高的效率。
在JDK1.8的实现中,优化了高位运算的算法,通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,这么做可以在数组table的length比较小的时候,也能保证考虑到高低Bit都参与到Hash的计算中,同时不会有太大的开销。
HashMap对象内部的数组无法装载更多的元素时,对象就需要扩大数组的长度,以便能装入更多的元素。当然Java里的数组是无法自动扩容的,方法是使用一个新的数组代替已有的容量小的数组。
首先举个例子直观感受下扩容过程。假设了我们的hash算法就是简单的用key mod 一下表的大小(也就是数组的长度)。其中的哈希桶数组table的size=2, 所以key = 3、7、5,put顺序依次为 5、7、3。在mod 2以后都冲突在table[1]这里了。这里假设负载因子 loadFactor=1,即当键值对的实际大小size 大于 table的实际大小时进行扩容。接下来的三个步骤是哈希桶数组 resize成4,然后所有的Node重新rehash的过程。
简单说就是换一个更大的数组重新映射。下面我们讲解下JDK1.8做了哪些优化。经过观测可以发现,我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。看下图可以明白这句话的意思,n为table的长度,图(a)表示扩容前的key1和key2两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果。
元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:
因此,我们在扩充HashMap的时候,只需要看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”,可以看看下图为16扩充为32的resize示意图:
这个设计省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。
总而言之,就是出现了哈希冲突所以才导致无序。