前言
HashMap:基于哈希表的 Map 接口的实现。此实现提供所有可选的映射操作,并允许使用 null 值和 null 键。(除了非同步和允许使用 null 之外,HashMap 类与 Hashtable 大致相同。)此类不保证映射的顺序,特别是它不保证该顺序恒久不变。
一、HashMap底层实现
- jdk1.7及之前,HashMap底层为数组+链表(即链表数组,数组中的每一个元素都是一个链表)
- jdk1.8及之后,HashMap底层为数组+链表+红黑树(在jdk1.7的基础上,当链表长度大于阈值8时,将链表转换为红黑树,有利于减少查找元素的时间)
二、什么是红黑树
- 红黑树是一种自平衡二叉查找树,是在计算机科学中用到的一种数据结构,典型的用途是实现关联数组。
- 红黑树是一种特化的AVL树(平衡二叉树),都是在进行插入和删除操作时通过特定操作保持二叉查找树的平衡,从而获得较高的查找性能。
- 红黑树虽然是复杂的,但它的最坏情况运行时间也是非常良好的,并且在实践中是高效的:它可以在O(log n)时间内做查找,插入和删除,这里的n 是树中元素的数目
三、HashMap的put方法执行流程
大致流程:
- 如果当前的table为空或长度为0,则调用resize()方法进行初始化
- 找到当前插入结点对应的table[i],如果table[i]==null,直接新建结点,跳到第6步执行。否则执行第3步。
- 如果table[i]第一个结点的key与插入结点一样,直接覆盖旧值并返回旧值即可。否则执行第4步。
- 如果当前table[i]为红黑树,直接在树中插入结点,否则执行第5步。
- 遍历table[i],如果在遍历过程中找到与插入结点key相同的结点,直接覆盖旧值并返回旧值即可。否则在链表末尾加入插入结点,此时判断链表长度是否>=8,如果满足则链表转红黑树。
- 插入成功后(如果覆盖旧值则不执行该步骤),判断实际存在的结点数size是否超过了最大容量threshold,如果超过进行扩容。最后返回null值(表示没有旧值)。
源码详细分析:
//jdk11的HashMap源码
public V put(K key, V value) {
//执行put方法时,调用最终方法putVal()
return this.putVal(hash(key), key, value, false, true);
}
static final int hash(Object key) {
int h;
//如果key存在,则返回key的哈希码和其哈希码右移16后的异或结果
return key == null ? 0 : (h = key.hashCode()) ^ h >>> 16;
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
//transient HashMap.Node<K, V>[] table;table就是HashMap最核心的底层数据结构(数组+链表+红黑树)
HashMap.Node[] tab;
int n;
//如果当前table为空或者长度为0,则对其调用resize()方法进行初始化,该次初始化会定义初始最大容量,并且之后的每次调用resize()方法,都会使得容量翻倍
if ((tab = this.table) == null || (n = tab.length) == 0) {
n = (tab = this.resize()).length;
}
Object p;
int i;
//确定当前要加入的结点的key值放在table的哪一个下标处
//i = n - 1 & hash就是根据当前数组长度和该元素的hash值确定
if ((p = tab[i = n - 1 & hash]) == null) {
//如果当前下标处结点不存在,就新建一个结点,将值放入其中
tab[i] = this.newNode(hash, key, value, (HashMap.Node)null);
} else {
Object e;
Object k;
//如果当前下标处第一个结点存在并且其hash值和要放入HashMap中的hash值相同并且key值地址相同或者值相同,则直接进行替换即可
if (((HashMap.Node)p).hash == hash && ((k = ((HashMap.Node)p).key) == key || key != null && key.equals(k))) {
//用e来记录当前数组下标处的第一个结点
e = p;
} else if (p instanceof HashMap.TreeNode) {// 如果当前数组下标处为红黑树
//将当前要加入的结点加入红黑树中
e = ((HashMap.TreeNode)p).putTreeVal(this, tab, hash, key, value);
} else {
int binCount = 0;//binCount代表当前下标处结点个数
while(true) {
//如果当前下标处的链表已经达到末尾
if ((e = ((HashMap.Node)p).next) == null) { //将要加入的结点放入链表尾部
((HashMap.Node)p).next = this.newNode(hash, key, value, (HashMap.Node)null);
//表示当前结点已经达到8个,就调用treeifBin()方法将链表转成红黑树
if (binCount >= 7) {
this.treeifyBin(tab, hash);
}
break;
}
//如果当前遍历到的结点key值和要插入的key值一样,直接退出
if (((HashMap.Node)e).hash == hash && ((k = ((HashMap.Node)e).key) == key || key != null && key.equals(k))) {
break;
}
//结点后移,结点数+1
p = e;
++binCount;
}
}
//如果当前e已经存在了(即要加入的结点的key值之前已经存在)
if (e != null) {
V oldValue = ((HashMap.Node)e).value;
if (!onlyIfAbsent || oldValue == null) {
//更新值
((HashMap.Node)e).value = value;
}
this.afterNodeAccess((HashMap.Node)e);
//返回旧值
return oldValue;
}
}
++this.modCount;
//如果e不存在,说明新加入了一个结点,则size++
//如果当前实际大小大于阈值就调用resize()方法进行扩容
if (++this.size > this.threshold) {
this.resize();
}
this.afterNodeInsertion(evict);
return null;
}
四、什么是哈希和哈希冲突
哈希:
Hash,一般翻译做散列、杂凑,或音译为哈希,是把任意长度的输入(又叫做预映射pre-image)通过散列算法变换成固定长度的输出,该输出就是散列值。这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不可能从散列值来确定唯一的输入值。简单的说就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数。
哈希冲突:
由于哈希算法被计算的数据是无限的,而计算后的结果范围有限,因此总会存在不同的数据经过计算后得到的值相同,这就是哈希冲突。
五、HashMap如何解决哈希冲突
HashMap采用链地址法(拉链法)解决哈希冲突。
- 举例说明链地址法
假设有一组key序列为{},散列函数H(k)=K %7,用链地址法处理冲突,则对应的存储如下:
- HashMap的拉链地址法
- 相比于hashCode返回的int类型,HashMap的初始容量大小DEFAULT_INITIAL_CAPACITY = 16要远小于int类型的范围,因此我们如果单纯用上面图解中的单纯取余(hashcode%初始容量),会极大增加哈希冲突的概率,因此我们需要进行优化
- HashMap中计算hash对应的数组下标时采取的是 n - 1 & hash(n代表数组长度),其中n恒为2的幂次方。(2的幂次方的原因:假设n为7,则n-1为6,即110,hash&(110)则最后一位恒为0,那么001,011,101对应位置永远不可能存储,极大浪费了空间资源)(补充:当n为2的幂时,n-1&hash==hash%n,而&运算比%运算效率高)
- HashMap的hash()方法:其思路是用hashcode的高16位不变,低16位和高16位进行异或得到hash,减少冲突(HashMap的容量大小为16~2^30,通常情况下hashMap的容量不是很大,这就导致了大多数情况下如果采用 n - 1 & hashcode则只会用到hashcode的低16位,高16位没有用到,这极大程度上加大了哈希冲突的概率,而加入了高16位的运算,可以减小冲突概率,使得数据分步更均匀)
static final int hash(Object key) { int h; return key == null ? 0 : (h = key.hashCode()) ^ h >>> 16; }