目录
HashMap
底层结构:哈希表
HashMap底层是一个哈希表(又称散列表),哈希表的主干是一个数组。
先过一下哈希表数据结构的相关概念:
- 为什么:
数组和链表各有自己的优势和劣势,那么我们能不能综合两者的特性,做出一种寻址容易,插入删除也容易的数据结构?哈希表就是为此而实现的。 - 是什么:
- 哈希表:根据结点的 关键字 通过 Hash函数 直接计算出该结点的存储地址,时间复杂度为O(1)
- 关键字:数据元素中唯一标识该元素的某个数据项的值,比如学号
- 哈希函数:关键字——>对应地址(即哈希值)的函数,构造函数的原则有控制定义域和值域,地址均匀分布和尽量简单。常用散列函数有:直接定址法,除留取余数法,数字分析法,平方取中法,折叠法等,
- 哈希冲突:也叫哈希碰撞,多个关键字通过散列函数得到相同地址,称作冲突。
- 装载因子:描述所有关键字填充哈希表后饱和的程度,它等于 关键字总数/哈希表 的长度。
为的是减缓哈希冲突,在初始桶还不满的时候就进行扩容。
- 装载因子:描述所有关键字填充哈希表后饱和的程度,它等于 关键字总数/哈希表 的长度。
- 冲突处理:任何设计出的散列函数都不可避免会产生冲突,因此必须考虑冲突发生后如何处理。
即为产生冲突的关键字寻找下一个“空”的Hash地址。常用的方法有如下:- 开放地址法:发生冲突,继续寻找下一块未被占用的存储地,又分线性探测法,平方探测法,再散列法等
- 链地址法:数组连接链表,使散列值相同的都在同一链表中。HashMap即是采用了该方法
- 哈希表:根据结点的 关键字 通过 Hash函数 直接计算出该结点的存储地址,时间复杂度为O(1)
底层实现:数组+链表+红黑树
那么Java中的哈希表Hashmap是如何实现的呢?
- 冲突处理:采用链地址法处理冲突,HashMap底层采取了 数组+链表 的实现,
JDK1.8之后,若同值元素超过8个则变为 数组+红黑树 以提高查询速度
接下来通过源码来详细了解和验证一下(JDK8)
首先,数组+链表+红黑树 体现在哪里?
// 主体是一个Node类型的数组
transient Node<K,V>[] table;
// 链表结点,继承自Entry
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; //对key的hashcode值进行hash运算后得到的值
final K key;
V value;
Node<K,V> next; //存储指向下一个Entry的引用,单链表结构
// ...
}
// 红黑树节点
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent;
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev;
boolean red;
// ...
}
一些基本参数
// 默认容量16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认负载因子0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 链表节点转换红黑树节点的阈值, 9个节点转
static final int TREEIFY_THRESHOLD = 8;
// 红黑树节点转换链表节点的阈值, 6个节点转
static final int UNTREEIFY_THRESHOLD = 6;
// 转红黑树时, table的最小长度
static final int MIN_TREEIFY_CAPACITY = 64;
HashMap的构造方法
- public HashMap(int initialCapacity, float loadFactor)
- 重要参数:初始容量(哈希桶数)和装载因子
- initialCapacity默认为16,loadFactory默认为0.75
- 初始容量的设置:2的N次方就可以,实际可以根据自己使用情况进行设置,以避免扩容带来的开销。
- 为什么?一是在哈希中结合或运算(hash & (n-1))可以达到和取模同样的效果,实现了均匀分布。
二是当 n 不为 2 的 N 次方时,hash 冲突的概率明显增大。 - 怎么做?主要是通过位运算和或运算来实现的,计算机底层是二进制的,移位和或运算是非常快的,所以这个方法的效率很高。源码如下:
static final int tableSizeFor(int cap) { int n = cap - 1; n |= n >>> 1; n |= n >>> 2; n |= n >>> 4; n |= n >>> 8; n |= n >>> 16; return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
- 为什么?一是在哈希中结合或运算(hash & (n-1))可以达到和取模同样的效果,实现了均匀分布。
- 装载因子的设置:要在时间和空间上进行权衡。如果值较高,例如1,此时会减少空间开销,但是 hash 冲突的概率会增大,增加查找成本;而如果值较低,例如 0.5 ,此时 hash 冲突会降低,但是有一半的空间会被浪费,所以初始为 0.75 似乎是一个合理的值。
- public HashMap(int initialCapacity)
- public HashMap()
- public HashMap(Map<? extends K,? extends V> m):
构造一个映射关系与指定 Map 相同的新 HashMap。
/**
* Constructs an empty <tt>HashMap</tt> with the default initial capacity
* (16) and the default load factor (0.75).
*/
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
从上面这段代码我们可以看出,在常规构造器中,没有为数组table分配内存空间(有一个入参为指定Map的构造器例外),而是在执行put操作的时候才真正构建table数组
put方法的工作流程
- 判断table[]是否为空,如果是空的就创建一个table
- 根据hashcode()计算索引位置,判断table[i]处是否插入过值(有的话看看是key相同还是Hash冲突)
- 判断链表长度是否大于8,如果大于就转换为红黑二叉树,并插入树中(详见转换红黑树)
- 判断key是否和原有key相同,如果相同就覆盖原有key的value,并返回原有value
- 如果key不相同,就插入一个key,记录结构变化一次
- 最后进行扩容判断(详见扩容机制)
接下来看一下put操作的具体实现
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 1.校验table是否为空或者length等于0,如果是则调用resize方法进行初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 2.通过hash值计算索引位置,将该索引位置的头节点赋值给p,如果p为空则直接在该索引位置新增一个节点即可
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
// table表该索引位置不为空,则进行查找
Node<K,V> e; K k;
// 3.判断p节点的key和hash值是否跟传入的相等,如果相等, 则p节点即为要查找的目标节点,将p节点赋值给e节点
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 4.判断p节点是否为TreeNode, 如果是则调用红黑树的putTreeVal方法查找目标节点
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 5.走到这代表p节点为普通链表节点,则调用普通的链表方法进行查找,使用binCount统计链表的节点数
for (int binCount = 0; ; ++binCount) {
// 6.如果p的next节点为空时,则代表找不到目标节点,则新增一个节点并插入链表尾部
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 7.校验节点数是否超过8个,如果超过则调用treeifyBin方法将链表节点转为红黑树节点,
// 减一是因为循环是从p节点的下一个节点开始的
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
break;
}
// 8.如果e节点存在hash值和key值都与传入的相同,则e节点即为目标节点,跳出循环
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e; // 将p指向下一个节点
}
}
// 9.如果e节点不为空,则代表目标节点存在,使用传入的value覆盖该节点的value,并返回oldValue
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e); // 用于LinkedHashMap
return oldValue;
}
}
++modCount;
// 10.如果插入节点后节点数超过阈值,则调用resize方法进行扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict); // 用于LinkedHashMap
return null;
}
存储位置的确定流程
key——>hashcode——>hash()——>index
- hashCode():由系统随机给出的一个十进制的整数
- hash():拿到 key 的 hashCode,并将 hashCode 的高16位和 hashCode 进行异或(XOR)运算,得到最终的 hash 值,
- 为什么要将 hashCode 的高16位参与运算?为了让高位也参与运算,不能让索引结果只取决于低位。
- 计算索引位置的公式为:(n - 1) & hash,当 n 为 2 的 N 次方时,n - 1 为低位全是 1 的值,此时任何值跟 n - 1 进行 & 运算会等于其本身,达到了和取模同样的效果,但比 mod 具有更高的效率,实现了均匀分布。
//方法一:
static final int hash(Object key) { //jdk1.8 & jdk1.7
int h;
// h = key.hashCode() 为第一步 取hashCode值
// h ^ (h >>> 16) 为第二步 高位参与运算
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
//方法二:
static int indexFor(int h, int length) { //jdk1.7的源码,jdk1.8没有这个方法,但是实现原理一样的
return h & (length-1); //第三步 取模运算
}
HashMap中Key是可以存入null值的,那此时如何存储呢?
答:在 hash() 中就对这种情况进行处理了,key等于null时,存在0索引处。
关于红黑树的优化
- 为什么优化:链表过长会使哈希表性能下降,所以当链表过长时候,将链表转为搜索效率更高的红黑树。
- 为什么不一直使用树:这是空间与时间之间的权衡。大多数哈希函数将产生非常少的冲突,因此为大小不到8的桶维护树将是不划算的。
- 为什么会用红黑树而不选择AVL树?
- 概念:
- 二叉排序树:元素有大小顺序的二叉树,左子树小,右子树大。
- AVL树:平衡二叉树是二叉排序树的改进,为了避免树的高度增长过快,降低排序树的性能,规定在插入和删除结点时,要保证任意结点左右子树高度差不超过1。
- 红黑树:红黑树不追求"完全平衡",红黑是用非严格的平衡来换取增删节点时候旋转次数的降低,任何不平衡都会在三次旋转之内解决
- 平衡判定:红黑树使用红黑二色进行“着色”,只要插入的节点“着色”满足红黑二色的规定,最短路径与最长路径不会相差的太远,红黑树的节点分布就能大体上达至均衡。
- 比较:总体各有所长,AVL查询更快,RB增删更快。
- 原因(不确定):
- 红黑树综合性能更好
- 概念:
- 怎么做:
- 什么时候链表会转红黑树
- 链表—>红黑树:统一索引位置链表长度超过8,并且数组长度长度超过64时
- 为什么选择8:时间和空间上权衡的结果,红黑树节点大小约为链表节点的2倍,在节点太少时,红黑树的查找性能优势并不明显;也是根据概率统计决定的,按照泊松分布,链表长度为8的概率非常小,这点源码有解释。
- 红黑树—>链表:结点数小于6(见前面的基本参数)
- 为什么选择6不是8:6到8有一个过渡,如果也选8,那么当节点个数在8徘徊时,就会频繁进行红黑树和链表的转换,造成性能的损耗。
- 链表—>红黑树:统一索引位置链表长度超过8,并且数组长度长度超过64时
- 如何将链表转换为二叉树:
- treeifyBin:先将链表结点转换为树节点,然后点用treeify方法
- treeify:按构造搜索树的原则插入尾搜索树;插入完之后在调整为红黑树。
- 二叉搜索树:一个一个结点比较,一般是左子树小,右子树大
- 什么时候链表会转红黑树
treeifyBin源码
static final int MIN_TREEIFY_CAPACITY = 64;
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
//这里还有一个限制条件,当table的长度小于MIN_TREEIFY_CAPACITY(64)时,只是进行扩容
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize(); //而扩容的时候,链表的长度有可能会变短
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
//将链表中的结点转换为树结点,形成一个新链表
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
if ((tab[index] = hd) != null) //将新的树结点链表赋给第index个桶
hd.treeify(tab); //执行 TreeNode中的treeify()方法
}
}
TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) {
return new TreeNode<>(p.hash, p.key, p.value, next);
}
treeify( )源码
时间复杂度的优化:原来查找最坏是O(n),现在最坏是O(logn)
HashMap的扩容机制
- 初始16,每次扩容是原来的2倍,索引要重新计算
- 为什么要重新计算:为了使分布始终均匀,避免哈希冲突。
- 1.8的优化:扩容时插入方式从“头插法”改成“尾插法”,避免了并发下的死循环,因为JDK 1.8 之前存在死循环的根本原因是在扩容后同一索引位置的节点顺序会反掉。
resize()方法源码
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
// 1.老表的容量不为0,即老表不为空
if (oldCap > 0) {
// 1.1 判断老表的容量是否超过最大容量值:如果超过则将阈值设置为Integer.MAX_VALUE,并直接返回老表,
// 此时oldCap * 2比Integer.MAX_VALUE大,因此无法进行重新分布,只是单纯的将阈值扩容到最大
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 1.2 将newCap赋值为oldCap的2倍,如果newCap<最大容量并且oldCap>=16, 则将新阈值设置为原来的两倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
// 2.如果老表的容量为0, 老表的阈值大于0, 是因为初始容量被放入阈值,则将新表的容量设置为老表的阈值
else if (oldThr > 0)
newCap = oldThr;
else {
// 3.老表的容量为0, 老表的阈值为0,这种情况是没有传初始容量的new方法创建的空表,将阈值和容量设置为默认值
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 4.如果新表的阈值为空, 则通过新的容量*负载因子获得阈值
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 5.将当前阈值设置为刚计算出来的新的阈值,定义新表,容量为刚计算出来的新容量,将table设置为新定义的表。
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// 6.如果老表不为空,则需遍历所有节点,将节点赋值给新表
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) { // 将索引值为j的老表头节点赋值给e
oldTab[j] = null; // 将老表的节点设置为空, 以便垃圾收集器回收空间
// 7.如果e.next为空, 则代表老表的该位置只有1个节点,计算新表的索引位置, 直接将该节点放在该位置
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
// 8.如果是红黑树节点,则进行红黑树的重hash分布(跟链表的hash分布基本相同)
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
// 9.如果是普通的链表节点,则进行普通的重hash分布
Node<K,V> loHead = null, loTail = null; // 存储索引位置为:“原索引位置”的节点
Node<K,V> hiHead = null, hiTail = null; // 存储索引位置为:“原索引位置+oldCap”的节点
Node<K,V> next;
do {
next = e.next;
// 9.1 如果e的hash值与老表的容量进行与运算为0,则扩容后的索引位置跟老表的索引位置一样
if ((e.hash & oldCap) == 0) {
if (loTail == null) // 如果loTail为空, 代表该节点为第一个节点
loHead = e; // 则将loHead赋值为第一个节点
else
loTail.next = e; // 否则将节点添加在loTail后面
loTail = e; // 并将loTail赋值为新增的节点
}
// 9.2 如果e的hash值与老表的容量进行与运算为1,则扩容后的索引位置为:老表的索引位置+oldCap
else {
if (hiTail == null) // 如果hiTail为空, 代表该节点为第一个节点
hiHead = e; // 则将hiHead赋值为第一个节点
else
hiTail.next = e; // 否则将节点添加在hiTail后面
hiTail = e; // 并将hiTail赋值为新增的节点
}
} while ((e = next) != null);
// 10.如果loTail不为空(说明老表的数据有分布到新表上“原索引位置”的节点),则将最后一个节点
// 的next设为空,并将新表上索引位置为“原索引位置”的节点设置为对应的头节点
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 11.如果hiTail不为空(说明老表的数据有分布到新表上“原索引+oldCap位置”的节点),则将最后
// 一个节点的next设为空,并将新表上索引位置为“原索引+oldCap”的节点设置为对应的头节点
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
// 12.返回新表
return newTab;
}
如何实现数据迁移:
元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:
因此,我们在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”。这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。这一块就是JDK1.8新增的优化点。
1.7与1.8的区别总结
(如何查看不同版本的源码:https://blog.csdn.net/hblock/article/details/78863214,需要有不同版本的免安装文件)
1)底层数据结构从“数组+链表”改成“数组+链表+红黑树”,主要是优化了 hash 冲突较严重时,链表过长的查找性能:O(n) -> O(logn)。
2)计算 table 初始容量的方式发生了改变,老的方式是从1开始不断向左进行移位运算,直到找到大于等于入参容量的值;新的方式则是通过“5个移位+或等于运算”来计算。
3)优化了 hash 值的计算方式,老的比较复杂,新的只是简单的让高16位参与了运算。
4)扩容时插入方式从“头插法”改成“尾插法”,避免了并发下的死循环。
在扩容的时候,jdk1.8之前是采用头插法,当两个线程同时检测到hashmap需要扩容,在进行同时扩容的时候有可能会造成链表的循环,主要原因就是,采用头插法,新链表与旧链表的顺序是反的,在1.8后采用尾插法就不会出现这种问题。
5)扩容时计算节点在新表的索引位置方式从“h & (length-1)”改成“hash & oldCap”,性能可能提升不大,但设计更巧妙、更优雅。
与其他Map的比较
与HashTable的区别
HashTable:底层也是哈希表,除了线程 安全和允许使用 null 之外 与 HashMap 大致相同,已被取代。
- HashMap 允许 key 和 value 为 null,Hashtable 不允许。
- HashMap 的默认初始容量为 16,Hashtable 为 11。
- HashMap 的扩容为原来的 2 倍,Hashtable 的扩容为原来的 2 倍加 1。
- HashMap 是非线程安全的,Hashtable是线程安全的。
- HashMap 的 hash 值重新计算过,Hashtable 直接使用 hashCode。
- HashMap 去掉了 Hashtable 中的 contains 方法。
- HashMap 继承自 AbstractMap 类,Hashtable 继承自 Dictionary 类。
参考资料
- https://blog.csdn.net/v123411739/article/details/78996181
- https://blog.csdn.net/v123411739/article/details/106324537
- https://blog.csdn.net/ThinkWon/article/details/104588551
- https://blog.csdn.net/pange1991/article/details/82347284
- https://www.bilibili.com/video/BV1ye411s7z8