一、HashMap
1.1 JDK 7
底层数据结构:数组 + 链表
调用构造器初始化:初始化的时候会创建一个长度为 16 的数组
存储过程:通过 hashCode 计算出该元素在数组中的存储位置,如果该位置没有元素,就直接添加(如果 map 中的元素个数大于临界值,就扩容);如果有元素,先和该元素比较 hashCode 是否相同,如果相同,改变原位置的 value;如果不同,就调用 equals 方法,如果 equals 返回 true,就改变原位置的 value,如果返回 false,就比较该位置后面的链表,重复上述方法,直到把该桶中的链表全部比较完,如果一路都没有遇到相同的,就把该元素放到链表头部(头插法)。
1.2 JDK 8
底层数据结构:数组 + 链表 + 红黑树
调用构造器初始化:初始化的时候没有创建长度为 16 的数组
存储过程:与 JDK 7 的区别在于在首次添加的时候会创建一个底层为 16 的数组;在链表中添加元素时,首先判断该链表是不是红黑树,如果是红黑树,就调用红黑树的添加方法;如果不是,就和 jdk 7 中的比较过程一样,但最后是把元素添加到了链表尾部(尾插法);如果某个桶中的数据个数大于 8 且当前数组的长度大于 64时,该桶就改用红黑树来存储;如果没有大于 64,就实施扩容(2倍)。
注意:扩容之后还得重新计算元素的位置,这个过程非常消耗性能。
1.3 HashMap 源码中的重要常量
数组长度:
HashMap 的数组长度默认为 16,有两方面的考虑:
- index = hashcode & (table.length-1),16 是 2 的次幂,使用 (2 的次幂 - 1)与 hashCode 做位与操作计算 index 可以全部利用 hashCode 的最后几位,使得只要 hashcode 本身均匀的,hash 算法的结果就是均匀的。
- 长度为 16 而不是其他 2 的次幂是碰撞几率和空间利用率的综合考虑。
负载因子:
- 负载因子决定了 HashMap 的数据密度;
- 负载因子越大,数据密度越大,发生碰撞的几率就越高,数组中的链表就容易变长,造成查询或插入时的比较次数增多,性能下降;
- 负载因子越小,越容易触发扩容,数据密度也越小 (有些位置可能永远用不到) ,发生碰撞的几率越小,查询和插入的效率越高;但是扩容会导致浪费一定的空间,也会导致频繁地重新计算元素的位置,也会影响性能;
- 根据研究经验,会将负载因子设置为 0.7—0.75,这是碰撞几率和空间利用率的综合考虑。
红黑树的转换和还原阈值:
链表转换成红黑树的阈值为 8,红黑树还原回链表的阈值为 6。
在负载因子为 0.75 的情况下,根据泊松分布,链表长度为 8 的概率为百万分之一,基本不太可能发生,所以使用 7 作为临界,大于 7 转成红黑树,小于 7 转回链表。
转成红黑树还有一个条件就是数组长度大于等于 64,因为小于 64 时发生碰撞的概率较大,所以首先考虑的应该时扩容,而不是转成红黑树。
1.4 JDK 7 中的头插法在多线程扩容的时候容易成环
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
//e为空时循环结束
while(null != e) {
Entry<K,V> next = e.next; //1
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
// 成环的代码主要是在这三行代码
// 首先插入是从头开始插入的
e.next = newTable[i]; //2
newTable[i] = e; //3
e = next; //4
}
}
}
两个线程 t1,t2 同时进行扩容,
线程 t1 执行到 1 时被挂起了,此时 e 为 A,next 为 B
线程 t2 开始从头执行,完成了 2,3,4,因为是头插法,所以最后结果就是 B.next = A,A.next = null
线程 t1 继续执行,执行到 2 的时候,把 A 插入,是 A.next = null,下一轮 e = B,next = e.next = A,插入完后就是 B.next = A, A.next = null,此时因为线程 t2 导致 B.next 指向了 A,所有 next 又不为空,在第二步时 A.next = B,这就成环了。