HashMap

基本概述

HashMap,基于哈希表实现,继承了AbstractMap并且实现了Map接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class HashMap<K,V> extends AbstractMap<K,V> 
implements Map<K,V>,Cloneable, Serializable{
//...
//负载因子,默认大小是0.75
final float loadFactor;
//用于记录HashMap所能容忍的键值对数量的临界值
int threadhold;
//记录HashMap实际存在的键值对的数量
transient int size;
//记录HashMap内部结构发生变化的次数
transient int modCount;
//..
}
  • threshold,用于确定在哈希表中触发扩容操作的阈值。也就是说,当实际存储的键值对数量超过了threshold时,HashMap将触发扩容操作,重新调整内部的桶的大小

  • loadFactor,是一个比例值,表示哈希表在多满的状态下会触发扩容,默认0.75
    $$
    threshold=initialCapacity*loadFactor
    $$

JDK1.8之前HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决Hash冲突(两个对象调用的hashCode方法计算的哈希码值一致导致计算的数组索引值相同)而存在的(“拉链法”)

JDK1.8之后再解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,会首先调用treeifyBin()方法。这个方法会根据HashMap数组来决定是否转换为红黑树。只有当数组长度大于或者等于64的情况下,才会执行转换红黑树操作,以减少搜索时间。否则,就是执行resize()方法对数组扩容。

Put方法

JDK1.8

HashMap只提供了put用于添加元素,putVal方法只是给put方法调用的一个方法,并没有提供给用户使用

1
2
3
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}

putVal流程

  1. 如果定位到的数组位置没有元素就直接插入

  2. 如果定位到的数组位置有元素,要和插入元素的key进行比较:

    1. 如果key相同,就直接覆盖
    2. 不相同判断p是否是一个树节点,如果是就调用上面的方法将元素插入树中,如果不是就尾插法插入
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// table未初始化或者长度为0,进行扩容
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// (n - 1) & hash 确定元素存放在哪个桶中,桶为空,新生成结点放入桶中(此时,这个结点是放在数组中)
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
// 桶中已经存在元素(处理hash冲突)
else {
Node<K,V> e; K k;
//快速判断第一个节点table[i]的key是否与插入的key一样,若相同就直接使用插入的值p替换掉旧的值e。
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 ),执行 treeifyBin 方法
// 这个方法会根据 HashMap 数组来决定是否转换为红黑树。
// 只有当数组长度大于或者等于 64 的情况下,才会执行转换红黑树操作,以减少搜索时间。否则,就是只是对数组扩容。
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
// 跳出循环
break;
}
// 判断链表中结点的key值与插入的元素的key值是否相等
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
// 相等,跳出循环
break;
// 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表
p = e;
}
}
// 表示在桶中找到key值、hash值与插入元素相等的结点
if (e != null) {
// 记录e的value
V oldValue = e.value;
// onlyIfAbsent为false或者旧值为null
if (!onlyIfAbsent || oldValue == null)
//用新值替换旧值
e.value = value;
// 访问后回调
afterNodeAccess(e);
// 返回旧值
return oldValue;
}
}
// 结构性修改
++modCount;
// 实际大小大于阈值则扩容
if (++size > threshold)
resize();
// 插入后回调
afterNodeInsertion(evict);
return null;
}

JDK1.7,put方法

  1. 如果定位到数组位置没有元素就直接插入
  2. 有元素,遍历链表依次比较,如果相同则覆盖,不同采用头插法进行覆盖
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public V put(K key, V value)
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
if (key == null)
return putForNullKey(value);
int hash = hash(key);
int i = indexFor(hash, table.length);
for (Entry<K,V> e = table[i]; e != null; e = e.next) { // 先遍历
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}

modCount++;
addEntry(hash, key, value, i); // 再插入
return null;
}

扩容

  • HashMap的默认容量大小是16.当实际存储的键值对数量超过了threshold时,HashMap将触发扩容操作,重新调整内部的桶数组大小,以保持哈希表的性能
  • HashMap采用整数扩容,简单来说就是,新建一个更长的数组,然后遍历原数组的每个元素,将其重新计算哈希值并放入新的数组中
  • 扩容操作是一个相对耗时的过程,因为需要重新计算元素的哈希值和重新插入新元素到新数组中。为了减少扩容操作的频率,可以通过在创建HashMap对象时指定初始容量和负载因子来进行调优

HashMap在进行扩容时,使用的rehash方式十分巧妙,因为每次扩容都是翻倍,与原来计算的(n-1)&hash的结果相比,只多了一个bit位,所以同一个桶中的节点要么在原来的位置,要么被分配到“原位置+旧容量”的位置

正式因为这样巧妙地rehash方式,既省去了重新计算hash值的时间,同时,由于新增的1bit是0还是1可以认为是随机的,在resize的过程中保证了rehash之后的每个桶上的节点一定小于等于原来的桶的节点数,保证rehash之后不会出现更严重的hash冲突

死循环问题

JDK1.7之前,对冲突数据采用头插法。也就是新插入的数据会处于链表的表头。在并发扩容的时候,会出现死循环问题

对底层链表进行扩容时,会出现并发死链问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Void transfer(Entry[] newTable,boolean rehash){
int newCapacity=new Table.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=e.next;
}
}
}

JDK8虽然将扩容算法做了调整,不再将元素加入链表头(而是保持与扩容前一样的顺序),担任不意味着能够在多线程环境下能够安全扩容,还会出现其他问题(比如扩容丢数据)

存在的线程安全的问题

  • 扩容死循环:在JDK1.7中,HashMap使用头插法插入元素,当多个线程同时进行扩容操作时,可能会导致环形链表的形成,从而陷入死循环。
  • 元素丢失:当多个线程同时执行put操作时,如果它们计算出的索引位置相同,就会造成前一个key被后一个key覆盖的情况,从而导致元素丢失
  • get为null:当一个线程执行put操作导致扩容时,而另一个线程同时执行get操作,有可能导致get返回null的情况。因为在扩容过程中,HashMap的结构发生了变化,get操作可能会在旧的结构中查找元素而导致找不到