一:摘要概述
- HashMap底层数据结构
- HashMap容量要求2的幂
- HashMap的扩容机制
- HashMap链表节点数量到达8转红黑树
- HashMap1.7的死循环问题
- HashMap的线程安全问题
二:HashMap底层数据结构
关注HashMap的底层数据结构都知道会根据JDK版本进行描述,其中JDK1.7结构为数组+链表
到了JDK1.8演变为数组+链表+红黑树
。那么其具体的结构怎么实现维护呢?
2.1 链表
数组其实就是Hash桶,每个桶内存储链表节点,链表节点就是HashMap的内部类Node,其主要属性如下:链表结构为单向链表
// key的hash值
final int hash;
// 键值对key
final K key;
// 键值对value
V value;
// 链表下一个节点指针
Node<K,V> next;
2.2 红黑树
红黑树是一种特殊的AVL即平衡二叉树,具备优秀的搜索效率,当单个Hash桶内链表长度超过默认值TREEIFY_THRESHOLD=8
时就会将链表转换为红黑树,当然如果红黑树节点数量到达默认值UNTREEIFY_THRESHOLD=6
红黑树会转换为链表
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;
三:容量要求
HashMap提供了诸多的构造函数重载,其中包括无参构造、容量构造、容量+负载因子构造
3.1 无参构造
无参构造函数默认的对负载因子进行赋值,默认值DEFAULT_LOAD_FACTOR=0.75
,这个0.75
属于一个经验值,建议不进行改动。数组容量将会在插入函数调用时采用默认值DEFAULT_INITIAL_CAPACITY=1<<4=16
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
3.2 容量构造
当调用HashMap的容量赋值构造函数时会调用容量+负载因子构造函数实例化对象。当然负载因子为默认值0.75
。主要关注最后一行调用函数tableSizeFor
,该函数将会重新计算数组容量,计算接过值为构造函数传入参数相邻最小2的整数幂
,强制性保障了数组容量永远是2的整数幂
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
3.3 2的整数幂好处
默认容量为16,就算最终传入的初始容量非2的整数幂也会被转换为2的整数幂,每次扩容都是*2翻倍。费尽心思的保证这个效果有什么好处?众所周知键值对存储计算哈希桶位置时算法为(n-1)&hash
,根据&的计算规则你会发现如下几点:
- 2的整数幂-1以后的二进制数全为1,如16-1=15转换为二进制
1111
- &的计算结果就是存储键值对key值的hash后几位
- 最终结论为存储键值对key值hash分布均匀,存储到HashMap中就会分布均匀
四:HashMap的扩容机制
HashMap中扩容算法的实现集中在函数resize()中,该函数对于哈希桶容量的算法主要代码如下所示:总结起来如果排除超过最大值特殊情况的话就是将现有哈希桶容量翻倍
// 现有哈希桶容量大于0
// 这种情况只能是哈希桶已经插入过元素,即哈希桶已经完成初始化
if (oldCap > 0) {
// 哈希桶现有容量超过限定最大值不进行操作
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 现有哈希桶容量扩容一倍后下于最大值
// 现有哈希桶容量大于默认值16
// 翻倍现有扩容阈值
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1;
}
// 现有哈希桶容量下于0且扩容阈值大于0
// 这种情况只会出现在带参构造函数
else if (oldThr > 0)
newCap = oldThr;
// 哈希桶容量与扩容阈值小于0
// 这种情况只会出现在无参构造
else {
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 新的扩容阈值等于0
// 有参构造函数的情况
// 哈希桶扩容两倍超过最大限度或者是扩容后容量不大于默认值16
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft <
(float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
五:红黑树与链表转换
当单个哈希桶内节点数量大于等于8时链表转红黑树,节点数量小于等于6时转链表。这些数值是怎么给出的默认值呢?6、8的默认值在HashMap中使用属性UNTREEIFY_THRESHOLD
、TREEIFY_THRESHOLD
维护
链表与红黑树的转换其实就是空间与时间的较量,红黑树所占空间是链表两倍,当链表节点数量过小此时遍历性能还不是太差
,牺牲两倍空间换一些时间不明智。所以规定了当链表节点数量为8时转红黑树,8是根据泊松分布
计算出到达此条件概率为千万分之一
六:JDK1.7并发死循环
并发死循环指的发生在进行哈希桶扩容时需要迁移旧数据,因为JDK1.7采用头插法插入数据,在并发条件下如果相同哈希桶上的链表在新的哈希桶中存放位置还是在相同的哈希桶位置,就有可能会产生死循环。JDK1.8中已经将元素插入方式修改为尾插法,并发死循环问题得到解决
七:线程安全问题
JDK1.8中没有了并发死循环后HashMap就并发安全了么?看一段putVal函数代码:当解决哈希冲突使用链表,循环遍历链表实现尾插。试想下面注释场景就会造成数据的覆盖
for (int binCount = 0; ; ++binCount) {
// 1:线程A通过判断失去CPU资源
// 2:线程B抢到CPU资源执行完完成方法
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;
}