在聊HashMap的原理前,我们先来熟悉两种数据结构:
数组(array):数组对象用下标进行定位,索引速度很快,但是缺点也比较明显,一旦定义不能动态扩容。
链表(list):链表物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的,因此链表有着可扩容的天然特性,由此带来的缺点是索引较慢。
有没有办法将两者的优点结合起来,取长补短?聪明的jdk coder们定义了一种将两者相结合的数据结构:散列表(又名HashTable)。
散列表将多个Node节点作为一个数组,既HashTable = Node[ ],同时在Node节点上又可以存储多个元素形成链表或者红黑树(两者的区别以及出现条件我们后续讨论)。
了解完HashMap的基本结构之后,由此引出思考:一个元素经过HashMap被put进去之后发什了什么呢?上图:
我们看一下HashMap的put方法源码:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
put方法中将key传入了hash()方法中,并作为参数传入了putVal中,这里的hash()方法就是我们上图所指的哈希值扰动函数。
哈希值扰动函数hash()区别于hashCode(),hashCode方法返回初代哈希值,而在put进入HashMap中的key值并不是初代哈希值,而是将哈希值经过扰动函数计算之后得到的二代哈希值,来看hash()的源码:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
例如对象经过object.hashCode()得到的哈希值为: 0010 0110 0001 0100 1101 1010 0010 0111
先将对象哈希值右移16位,即为:0000 0000 0000 0000 0010 0110 0001 0100
再将两者异或:
0010 0110 0001 0100 1101 1010 0010 0111
异或 0000 0000 0000 0000 0010 0110 0001 0100
————————————————————————————
0010 0110 0001 0100 1111 1100 0011 0011
最后得到的数值即为经过哈希扰动函数计算之后的二代哈希值。
为什么需要这样设计???事出反常必有妖,我们还得深挖,先来看putVal的源码:
/**
* Implements Map.put and related methods
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent if true, don't change existing value
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//延迟初始化逻辑,第一次调用putVal时会初始化hashMap对象中的最耗费内存的散列表
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//最简单的一种情况:寻址找到的Node节点刚好时null,这个时候直接创建新的节点
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
//
else {
Node<K,V> e; K k;
//表示Node节点中的该院告诉,与当前插入的原元素的key完全一致,表示后续需要进行替换操作
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//插入红黑树
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//链表的情况,而且链表的头元素与我们要插入的key不一致
for (int binCount = 0; ; ++binCount) {
//条件成立的话,说明迭代到最后一个元素了,也没找到一个与你要插入的key一致的node节点,说明需要加入到当前链表的末尾
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//条件成立的话,说明当前链表的长度,达到了树化标准,需要进行树化
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
//modeCount:表示散列表结构被修改的次数,替换Node元素的value不计数
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
putVal方法中对不同情况的put情况作了判断处理,单纯的看源码晦涩难懂,取其精华,我们可以截取到HashMap的节点寻址算法,它将确定键值对被存储的具体节点位置:
tab[i = (n - 1) & hash]
节点下标 = (哈希表长度 - 1) [相与] 扰动之后的哈希值
先得出一个结论:扰动函数结果直接关系到哈希表的键值对存放节点位置
不妨用反证法来论证扰动函数的意义,假设我们直接使用哈希值来对元素进行定位,即把HashMap节点寻址算法假设为:
节点下标 = (哈希表长度 - 1) [相与] 原始哈希值
考虑下面一种情况:
对象object1的hashCode值假设为: 0101 0110 0001 0010 1011 0011 1010 1100
对象object2的hashCode值假设为:0010 0111 0110 0019 1011 0011 1010 1100
假设此时hashMap的初始长度为16(tips:缺省初始长度也为16),不经过扰动函数,计算出他的节点位置:
object1:
0101 0110 0001 0010 1011 0011 1010 1100
相与 0000 0000 0000 0000 1111 1111 1111 1111
———————————————————————————
0000 0000 0000 0000 1011 0011 1010 1100
object2:
0010 0111 0110 0010 1011 0011 1010 1100
相与 0000 0000 0000 0000 1111 1111 1111 1111
———————————————————————————
0000 0000 0000 0000 1011 0011 1010 1100
两个不同的hashCode,得出来的节点位置竟然相等?
这种情况称为哈希碰撞,既不同的元素得到一样的节点值,节点将被迫由单元素节点变为节点链表或成为红黑树,导致HashMap效率下降。
我们仔细分析上述计算过程会发现,由于HashMap长度较短,导致HashCode的高16位(table.length – 1)都为0,0与任何数相与都得0,既可以认为hashCode的高16位没有参与运算,由此导致发生哈希碰撞的概率增加一倍。
为了解决这个问题,由此引入了扰动算法hash(),将hashCode异或HashCode的高16位,以最终的结果作为HashMap元素节点定位的依据:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
再来使用object1、object2在有扰动函数hash()的情况下计算一遍:
object1二代哈希值,将hashCode异或hashCode的高16位:
0101 0110 0001 0010 1011 0011 1010 1100
异或 0000 0000 0000 0000 0101 0110 0001 0010
———————————————————————————
0101 0110 0001 0010 1110 0101 1011 1111
hashMap长度为16时的object1的最终节点定位:
0101 0110 0001 0010 1110 0101 1011 1111
相与 0000 0000 0000 0000 1111 1111 1111 1111
———————————————————————————
0000 0000 0000 0000 1110 0101 1011 1111
object2二代哈希值,将hashCode异或hashCode的高16位:
0010 0111 0110 0019 1011 0011 1010 1100
异或 0000 0000 0000 0000 1111 1111 1111 1001
———————————————————————————
0010 0111 0110 0010 0100 1100 0101 0101
hashMap长度为16时的object2的最终节点定位:
0010 0111 0110 0010 0100 1100 0101 0101
相与 0000 0000 0000 0000 1111 1111 1111 1111
———————————————————————————
0000 0000 0000 0000 0100 1100 0101 0101
经过hash()扰动函数计算后,由于有了哈希值高16位的参与,object1和object2的最终节点定位不同。
我们可以得出结论:为了避免在HashMap长度较短时哈希碰撞概率增加,Hash函数对hashcode右移16位再先相异或,得到的值作为最终的hashcode(右移16位的目的就是为了让高16位参与运算),在HashMap长度很小的情况下,这种方式可以显著减少哈希碰撞。
以上即为HashMap存储元素的原理。
原创不易,如对您有帮助,记得点赞~