写在前面,只是为了面试而准备的笔记,其实之前就有在分析HashMap源码,但是那篇只写了一部分,还躺在草稿箱,不过那篇写的感觉很舒服,等稳定下来想好好写。这篇写的不怎么满意,内容也只有一部分,做个存稿吧。
有错误请指出,谢谢
HashMap
数据结构
jdk1.7 数组 + 链表
jdk1.8 数组 + 链表 + 红黑树
当一个结点的链表长度大于8时,链表会转换成红黑树,提高查询效率,而链表长度小于6时又会退化成链表。
为什么要引入红黑树?
将查找的时间复杂度从o(n)提升到o(logn)。在二叉查找树的基础上增加了着色和相关的性质使得红黑树相对平衡,从而保证了红黑树的查找、插入、删除的时间复杂度最坏为O(log n),加快检索速率。
为什么是8?
- trade-off,时间和空间的权衡。TreeNodes占用空间是普通Nodes的两倍,而链表很短时遍历速度也很快,因此需要达到一个阈值8才能够转换,当节点数变少6时,又会转回链表。
- 概率统计。源码注释里也写了,理想情况下,随机hashCode算法下所有节点的分布频率会遵循泊松分布,而链表长度达到8时的概率为0.00000006,趋近于零,因此这是根据概率统计决定的。
为什么是6?
emmmm还不知道。
初始化
我们知道,HashMap的默认初始化长度为16,而自定义长度则会取大于等于自定义长度的2的幂次数(例如输入27,最终长度为32)。
为什么默认初始化长度为16?/为什么长度一定要是2的幂次?
为了得到低位掩码从而计算下标,为了服务于从Key映射到index的Hash算法。
首先要知道HashMap的下标计算方法:
下标计算方法
为了增强散列性,使元素尽可能的分散开来,从而减少碰撞去使用链表/红黑树,HashMap的发明者采用了位运算的方式来实现一个均匀分布且高效率的函数来求下标。
index = HashCode(Key) & (Length - 1)
(先不讨论这个HashCode方法)
而只有当HashMap的长度为2的幂时,length-1 正好相当于一个低位掩码,所有二进制位都为1,才能将哈希值的高位全部归零,只保留低位值用来做数组下标访问,且保证范围在length内。
jdk1.7
static int indexFor(int h, int length) {
return h & (length-1);
}
jdk1.8
i = (n - 1) & hash
怎么计算容量(2的幂次数)?
jdk1.7
private static int roundUpToPowerOf2(int number) {
// assert number >= 0 : "number must be non-negative";
return number >= MAXIMUM_CAPACITY
? MAXIMUM_CAPACITY // 超出最大容量直接返回最大容量
: (number > 1) ?
// -1后左移一位,-1应该是为了number本身就是2的幂次数的情况考虑,否则会得到更大的2的幂次数
Integer.highestOneBit((number - 1) << 1)
: 1; // 小于等于1直接返回1
}
Integer.highestOneBit()
这个自己画一下就知道,通过右移和或运算返回i的二进制最高位,后面全补0,比如输入7返回8。每一次右移或运算都是为了得到一个纯1串,最后减去后面的1得到最高位为1后面补0的2的幂次数。
public static int highestOneBit(int i) {
// HD, Figure 3-1
i |= (i >> 1);
i |= (i >> 2);
i |= (i >> 4);
i |= (i >> 8);
i |= (i >> 16);
// 到这里,i的二进制应该是形如111111
return i - (i >>> 1);
}
jdk1.8
原理相同又有点出入,1.7是i - (i >>> 1)
,1.8则是得到一个小于目标值的全1串再+1得到目标值。
举个栗子,比如输入自定义容量7 = (0111)2:
- 在1.7中调用highestOneBit(12 = (1100)2),通过一系列右移和或运算得到 i = (1111)2,减去(i>>>1) = (0111)2 后得到结果 (1000)2 = 8
- 在1.8中调用tableSizeFor(7 = (0111)2),-1后通过一系列右移和或运算得到 n = (0111)2,+1后得到结果 (1000)2 = 8
/**
* Returns a power of two size for the given target capacity.
* 返回比已给目标容量大的2的幂次数,输入0返回1
*/
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;
}
前面我们知道了下标的运算方式,是通过低位掩码与哈希值相与得到哈希值的低位来做下标,这就意味着这个哈希值的计算方式几乎决定了HashMap的效率(散列值、碰撞率、分散情况),所以哈希算法的实现很关键。扰动函数就是为此而引入。
(jdk1.7中首次引入扰动函数,但是共做了四次扰动,1.8做了优化只用了一次)
哈希算法 及 扰动函数
先看看源码的哈希算法实现
jdk1.7
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
// This function ensures that hashCodes that differ only by
// constant multiples at each bit position have a bounded
// number of collisions (approximately 8 at default load factor).
// 这里是扰动函数
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
jdk 1.8
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
可以看出扰动函数就是将key的哈希值h与右移16位后的h进行异或运算。
用一句话来概括扰动函数的作用就是:将h的hashCode右移16位并与自身相异或 相当于 使自己的高16位和低16位 相异或,得到的值既包含了自己高位的特性又包含了自己低位的特性,从而增加了之后得到的下标的不确定性,降低了碰撞的概率。
用一张图来演示这个扰动函数就是:图源
Peter Lawley的一篇专栏文章《An introduction to optimising a hashing strategy》里的的一个实验:他随机选取了352个字符串,在他们散列值完全没有冲突的前提下,对它们做低位掩码,取数组下标。
结果显示,当HashMap数组长度为512的时候,也就是用掩码取低9位的时候,在没有扰动函数的情况下,发生了103次碰撞,接近30%。而在使用了扰动函数之后只有92次碰撞。碰撞减少了将近10%。这就是扰动函数的功效。
jdk1.7中还有个哈希种子,还没有仔细研究,先放着
put
数组的第0个位置 固定放置key为null的value
- 通过扰动函数计算key的哈希值
- 如果哈希表为空,初始化
- 如果哈希表不为空,计算下标
- 如果table[i]为空,创建新节点存入
- 如果table[i]不为空,根据HashCode和key值在链表/红黑树中寻找目标位置并更新其value值
- 如果没有发生碰撞
- jdk1.7 将新节点插在当前链表头部的前面,然后再下移(头插法)
- jdk1.8
- 如果是红黑树,调用红黑树的插入
- 如果是链表,遍历到链表尾部并插入新结点(尾插法),遍历的同时进行计数,当插入新节点后的计数达到阈值,就把链表转化为红黑树
- 如果发生碰撞,通过比较key的地址或者key的值(equals)遍历链表/红黑树获取旧值,覆盖后返回旧值
- 如果HashMap容量达到阈值
initialCapacity*loadFactor
,则进行扩容
- 如果没有发生碰撞
jdk1.8 新增链表转红黑树的阈值,因此在插入的时候必须知道链表的长度,如果长度超出这个阈值就将其转化为红黑树,因此在插入式必须遍历链表得到链表长度,于是在jdk1.8里插入结点时选择直接插在链表尾部,反正都要遍历一次,这样还保证了在扩容的时候对元素进行transfer时链表的顺序不会像1.7一样倒转,也就不会出现死循环链表。
resize
扩容条件
jdk1.7 (size >= threshold) && (null != table[bucketIndex])
存放的键值对数超出阈值,并且新增结点要插入的地方不为空
jdk1.8 ++size > threshold
只要存放键值对数超出阈值就扩容
扩容方式
默认扩容16,原数组长度,构建一个原数组长度两倍的新数组,并调用transfer将原数组的数组通过重新计算哈希值得到下标再转移到新增数组。
jdk1.7调用indexFor方法重新计算下标,并采用跟插入结点时一致的方式(头插法)挨个移动结点
jdk1.8则是根据规律将原链表拆分为两组,分别记录两个头结点,移动时直接移动头结点
我们会发现 HashMap扩容后,原来的元素要么在原位置,要么在原位置+原数组长度 那个位置上
举个栗子来说:
原来的HashMap长度为4,table[2]上存放了A。现在要进行扩容,先创建了一个长度为8的新数组,现在要进行transfer,那么这个A要放到哪里呢?
我们先来根据他原本所在的位置2来倒推,我们知道index = HashCode(Key) & (Length - 1)
,那么就有
Hash(key) 可能为010,可能为110。
我们用新的长度(8 = (111)2)和这两个数分别再去通过Hash算法来计算新的下标会发现
-
010 & 111 = 010 在原位置
-
110 & 111 = 110 在原位置+4,当前下标+旧数组长度
在转移链表时,结点的转移和插入是一致的,jdk1.7将采用头插法(转移完后链表反转),jdk1.8在分解完链表后直接移动头结点
为什么HashMap是线程不安全的?
主要体现
-
jdk1.7中,当多线程操作同一map时,在扩容的时候会因链表反转发生循环链表或丢失数据的情况
-
jdk1.8中,当多线程操作同一map时,会发生数据覆盖的情况
在put的时候,由于put的动作不是原子性的,线程A在计算好链表位置后,挂起,线程B正常执行put操作,之后线程A恢复,会直接替换掉线程b put的值 所以依然不是线程安全的
为什么会遇到ConcurrentModificationException异常
首先我们要知道,HashMap中有个属性modCount
,用于记录当前map的修改次数,在对map进行put、remove、clear等操作时都会增加modCount。
他的作用体现在对map进行遍历的时候,我们知道HashMap不是线程安全的,当对其进行遍历的时候,会先把modCount赋给迭代器内部的expectedModCount属性。当我们对map进行迭代时,他会时时刻刻比较expectedModCount和modCount是否相等,如果不相等,则说明有其他的线程对同一map进行了修改操作,于是迭代器抛出ConcurrentModificationException异常。
这就是Fail-Fast 机制。
在系统设计中,快速失效系统一种可以立即报告任何可能表明故障的情况的系统。快速失效系统通常设计用于停止正常操作,而不是试图继续可能存在缺陷的过程。这种设计通常会在操作中的多个点检查系统的状态,因此可以及早检测到任何故障。快速失败模块的职责是检测错误,然后让系统的下一个最高级别处理错误。
(你也会看到源码中有
int expectedModCount