1. 基础
1.1 理论
JDK1.8 之前 HashMap
由数组+链表组成的,每个数组元素存储一个链表的头结点。数组是 HashMap
的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。
JDK1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。
HashMap
默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。 可以存储 null
的 key
和 value
。
1.2 计算哈希值
使用 key
计算。
哈希表内部数组的大小很重要,要保持一个平衡的数字,不能让哈希碰撞太频繁,也不能占用空间太大。
在哈希表使用的过程中,会不断的调整数组的容量。
-
调整后的容量是多少?之前的2倍。
-
如何调整?再散列调整数组大小。数组长度小于64,会先扩充数组。
2. 扩容源码
每个节点的定义如下:
HashMap
底层是一个数组,元素的类型是Node
节点,源码如下:
在添加元素put(key, value)
的时候,会调用putValue()
进行实际的放入过程。
putValue()
在加入一个节点之后,会判断是否超过容量,如果超了,会调用resize()
进行2倍扩容。源码如下:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 如果当前表为null,或者表为空,则初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length; // 通过resize()初始化表,默认表长为10
// 获取该hash值在表中的位置 i
// 1. 如果该table[i]为null,说明该hash值没有存放其他值,放入
if ((p = tab[i = (n - 1) & hash]) == null)
// 创建一个新节点存放在表中
tab[i] = newNode(hash, key, value, null);
// 2. 如果table[i]不为null,说明当前值与表中已有的值冲突了,那么加在该位置的链表最后
else {
Node<K,V> e; // 表中的某个节点,和当前对象的key相等
K k;
// 当前的键 key 已存在
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 如果使用的是红黑树存储,则调用相应的方法加入当前值
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 如果使用的是链表存储,则在链表最后加入当前值
else {
for (int binCount = 0; ; ++binCount) {
// 找到链表的最后一个节点,新增一个节点,放入当前值
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 如果当前链表的节点数超过设置的链表阈值(默认是8),就会将链表转化位红黑树
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;
}
}
// 表中存在某个值和当前值相等,更新为当前值
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 当前容量 +1 后超过阈值,则扩容
if (++size > threshold)
resize(); // 2倍扩容
afterNodeInsertion(evict);
return null;
}
resize()
扩容
如果进行2倍扩容,也需要将表中已有的值,重新散列到新表中,重新散列的过程是通过位运算计算新键值,而不是重新计算一遍散列函数。resize()
中相关代码如下:
链表转红黑树的方法treeifyBin()
小纸条:(n-1) & hash
n
是2的幂次数,其二进制表示是 10...0
,那么 n-1的二进制表示是01...1
。
(n-1) & hash
是一个与运算,位与位进行与运算。其结果就是将任意一个值对n的余数,相当于 hash % n
。位运算的效率更高。
3. 缺点
hashmap
高度依赖于 hash
算法,如果 key
是自定义类,需要自己重写 hashcode()
方法,写 hash
算法。
4. 并发产生的问题
在调整大小的过程中,有一步是把老数组中的全部元素转移到新数组中。这个过程在并发环境中会发生错误,导致数组链表中的链表形成循环链表。1.8之前是头插法,会导致循环链表的产生。1.8以后是尾插法,不会导致循环链表的产生。
// 在1.8中没有看到这个函数
void transfer( Entry[] newTable) {
Entry[] src = table;
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++){
Entry<K,V> e = src[j];
if (e != null){
src[j] = null;
do {
//假设第一个线程执行到这里因为某种原因挂起
Entry<K,V> next = e.next;
int i = indexFor(e.hash,newCapacity);e.next = newTable[i];
newTable[i] = e;
e = next;
}while (e != null);
}
}
}
java 8
会出现什么问题?
java 8
不会出现再散列时形成的循环链表,会造成数据覆盖。
在插入数据时会判断是否出现哈希碰撞,判断完之后正常插入。这里会出现一个线程的数据覆盖另一个线程的数据。
HashMap
在于并发下的 Rehash
再散列会造成元素的覆盖问题,所以不能在多线程下使用。
(图片来源于网络)