重要参数
- HashMap的初始化数组长度
**DEFAULT_INITIAL_CAPACITY**
= 1 << 42^4=16 (为什么是2的n次方) - 负载因子,当HashMap中总元素数量超出当前的
**数组长度*负载因子**
则需要扩容 **DEFAULT_LOAD_FACTOR **
默认为0.75 - 链表转为红黑树的阈值,当链表长度达到阈值后则会从链表转换为红黑树 TREEIFY_THRESHOLD = 8 (原因:链表转换为红黑树是一个非常耗时的操作,因此需要阈值设定比较高,8满足泊松分布,哈希碰撞为8的概率已经非常小)
- 红黑树转为链表的阈值,当红黑树中节点的数量小于阈值后,从红黑树转换为链表 UNTREEIFY_THRESHOLD = 6 此参数仅仅用于扩容后进行判断,删除元素不根据此参数进行判断(原因:因为链表转红黑树的阈值设定是8,如果设定为7会导致红黑树和链表之间的频繁切换,因此设定为6)
- 数组长度的最大值 MAXIMUM_CAPACITY = 1<<302^30=1073741824
- 转换红黑树的阈值,数组长度需到达一定的长度才会进行红黑树的转换,在数组长度不足时,会先进行数组的扩容 MIN_TREEIFY_CAPACITY = 64
HashMap原理
为什么使用数组+链表
- 在进行参数的加入时,会先进行 扰动函数的计算出key对应的数组下标 hash&(table.length-1),在出现Hash冲突时,则会通过
**链表**
的形式加入到后面,从而**解决哈希冲突**
的问题。 - 可以用LinkedList代替数组,因为两者都是有序性的,但是因为在HashMap的使用过程中频繁的进行读取操作,而数组的读取时间复杂度是O(1),链表的时间复杂度是O(n),因此在使用过程中会使用数组而不适用链表
为什么数组的长度要保持二的倍数,以及扩容为什么也是二倍扩容
- 因为扰动函数的计算是hash&(table.length-1),利用的是key的hashcode进行取余操作得到
- 根据二进制中的计算,需要保证每一个二进制位为1,才能保证key的均匀分配
- 即保证table.length-1为的每个二进制位都是1,因此则数组的初始长度需要是2的倍数
为什么要有负载因子
- 负载因子用于计算阈值判断是否需要扩容,当HashMap中的元素到达 负载因子*数组长度时,则需要进行扩容
- 如果负载因子过大,出现哈希碰撞的情况就越多,因此数组每个下标中的元素也就越多,从而会导致查询速率较慢
- 如果负载因子过小,则会频繁的出现扩容的情况,扩容的操作涉及到申请新数组以及资源拷贝的过程,比较耗费时间,并且一定程度上需要占用更多的资源。
- 负载因子定为0.75属于折中方案
Put
JDK1.8
- 因为HashMap是懒加载形式,先判断数组是否初始化,如果是第一次调用则需要先调用 resize()方法进行HashMap的扩容
- 根据扰动函数 hash&(table.length-1)计算出对应数组的下标,如果数组下标中没有对应的元素则直接添加
- 如果下标的第一个元素与插入节点相同则直接进行覆盖,如果与第一个节点不相同则需要进行判断
- 如果下标节点为红黑树节点,则将节点插入到红黑树中
- 如果下标节点是链表节点,则从当前节点进行遍历,如果遇到相同的节点则将其覆盖,当遍历到链表的末尾时则将元素插入到末尾,如果插入后**链表的长度达到阈值 **TREEIFY_THRESHOLD = 8 此时会将链表转换为红黑树
- 转为红黑树的过程是,先调用treeifyBin将原有的单向链表的节点转换为双向链表,也就是转为TreeNode类,这个类继承了LinkedHashMap,包含的头尾指针和左右儿子和父节点,双向链表的主要作用是在扩容中使用
- 在转换为双向链表后,才会真正维护红黑树,红黑树主要作用是进行快速的查询
- 如果在HashMap中存在Key相同的值,则会在最后进行覆盖,覆盖后会返回原始节点的参数。
- 当put流程执行完毕后,最后判断HashMap中的实际大小是否大于 扩容的阈值 即(负载因子*数组长度)
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
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);
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
JDK1.7
- 因为HashMap是懒加载形式,先判断数组是否初始化,如果是第一次调用则需要先调用 resize()方法进行HashMap的扩容
- 根据扰动函数 hash&(table.length-1)计算出对应数组的下标,如果数组下标中没有对应的元素则直接添加
- 如果下标的第一个元素与插入节点相同则直接进行覆盖,如果与第一个节点不相同则需要进行判断
- 如果下标节点是链表节点,则从当前节点进行遍历,如果遇到相同的节点则将其覆盖,当遍历到链表的末尾时则将元素插入到末尾
- 如果在HashMap中存在Key相同的值,则会在最后进行覆盖,覆盖后会返回原始节点的参数。
- 当put流程执行完毕后,最后判断HashMap中的实际大小是否大于 扩容的阈值 即(负载因子*数组长度)
扩容过程
JDK1.7
- put过程中,先判断是否达到阈值(负载因子*数组长度),如果到达阈值并且发生哈希冲突则进行扩容,扩容后再添加新元素
- 创建新数组,一般新数组长度为原数组的2的次方倍
- 双层循环遍历原数组和链表中的每一个元素,需要重新计算每个元素的Hash值,再计算下标,最终将该元素利用头插法插入到数组的新下标中
- 当多线程环境下,多个线程同时扩容时会出现死循环问题
JDK1.8
- JDK1.8中,先进行元素的添加,元素添加成功后,最后判断总元素个数是否达到阈值,判断是否进行扩容
- 遍历数组,如果当前是单个节点,则直接获取该节点的hash值计算出下标进行填入
- 如果是链表节点,不需要像JDK1.7中重新计算哈希值,新元素的插入位置只有两种可能,即当前位置和当前位置+旧数组长度,根据**(e.hash & oldCap) == 0公式**,计算出插入到那个位置,即新增位数是1还是0。遍历单链表通过尾插法获取两个结果单链表,分别将新数组的下标位置赋值到单链表。当扩容后的链表长度大于等于8时,则会转化为红黑树,如果小于等于6则会转为单链表。
- 当多线程环境下,多个线程同时进行扩容,由于是直接将 转换红黑树和链表的返回值赋值给数组下标,因此会导致数据覆盖而导致丢失的问题
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1;
}
else if (oldThr > 0)
newCap = oldThr;
else {
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else {
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
Get
- 首先利用扰动函数计算key对应的下标
- 如果只有一个节点命中则直接返回,直接复杂度o(1)
- 如果节点下为链表,则查询的时间复杂度为O(n)
- 如果节点下为红黑树,则查询的时间复杂度为O(logn)
线程不安全存在的问题
多线程下扩容导致死循环
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next; }
}
}
- JDK1.7中扩容使用头插法进行扩容,在多线程环境下,如果有多个线程同时触发扩容操作,则会出现死锁情况。
- 当线程A和线程B同时进行扩容时,同时指向第一和第二个节点,当此时线程B因为某种原因而阻塞,当线程A完成扩容后,线程B进行扩容。但因为JDK1.7中使用头插法进行扩容,此时原本的第一个和第二个节点变成逆序,即第二个节点指向第一个节点,因此在线程B进行扩容时,线程B也会进行头插法进行扩容,此时第一个节点又会指向第二个节点,从而导致死循环
多线程put参数丢失
- 当多线程put过程中,在发生扩容时,节点移动到了新的Hash下标下,导致另外的线程在原来的Hash下标中找不到对应的信息
多线程put非null参数,取出时为null
- 多次put中,可能会导致参数被覆盖