HashMap 通常会用一个指针数组(假设为 table[])来做分散所有的 key,当一个 key 被加入时,会通过 Hash 算法通过 key 算出这个数组的下标 i,然后就把这个 插到 table[i] 中,如果有两个不同的 key 被算在了同一个 i,那么就叫冲突,又叫碰撞,这样会在 table[i] 上形成一个链表
如果 table[] 的尺寸很小,比如只有 2 个,如果要放进 10 个 keys 的话,那么碰撞非常频繁,于是一个 O(1) 的查找算法,就变成了链表遍历,性能变成了 O(n),这是 Hash 表的缺陷
所以,Hash 表的尺寸和容量非常的重要。一般来说,Hash 表这个容器当有数据要插入时,都会检查容量有没有超过设定的 threshold,如果超过,需要增大 Hash 表的尺寸,但是这样一来,整个 Hash 表里的元素都需要被重算一遍。这叫 rehash,这个成本相当的大
Put 一个 Key, Value 对到 Hash 表中:
public V put(K key, V value) { ...... // 计算 Hash 值 int hash = hash(key.hashCode()); int i = indexFor(hash, table.length); // 如果该 key 已被插入,则替换掉旧的 value(链接操作) 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++; // 该 key 不存在,需要增加一个结点 addEntry(hash, key, value, i); return null; }
检查容量是否超标
void addEntry(int hash, K key, V value, int bucketIndex) { Entry<K,V> e = table[bucketIndex]; table[bucketIndex] = new Entry<K,V>(hash, key, value, e); //查看当前的 size 是否超过了设定的阈值 threshold,如果超过,需要 resize if (size++ >= threshold) resize(2 * table.length); }
新建一个更大尺寸的 hash 表,然后把数据从老的 Hash 表中迁移到新的 Hash 表中
void resize(int newCapacity) { Entry[] oldTable = table; int oldCapacity = oldTable.length; ...... // 创建一个新的 Hash Table Entry[] newTable = new Entry[newCapacity]; // 将 Old Hash Table 上的数据迁移到 New Hash Table 上 transfer(newTable); table = newTable; threshold = (int)(newCapacity * loadFactor); }
迁移的源代码
void transfer(Entry[] newTable) { Entry[] src = table; int newCapacity = newTable.length; //下面这段代码的意思是: // 从OldTable里摘一个元素出来,然后放到NewTable中 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); } } }
该方法实现的机制就是将每个链表转化到新链表,并且链表中的位置发生反转,而这在多线程情况下是很容易造成链表回路,从而发生 get() 死循环。所以只要保证建新链时还是按照原来的顺序的话就不会产生循环(JDK 8 的改进)
1)假设有两个线程
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);
而线程二执行完成了。于是有下面的这个样子
注意,因为 Thread1 的 e 指向了 key(3),而 next 指向了 key(7),其在线程二 rehash 后,指向了线程二重组后的链表。可以看到链表的顺序被反转
2)线程一被调度回来执行
3)线程一接着工作。把 key(7) 摘下来,放到 newTable[i] 的第一个,然后把 e 和 next 往下移
4)环形链接出现
e.next = newTable[i] 导致 key(3).next 指向了 key(7)
此时的 key(7).next 已经指向了 key(3), 环形链表就这样出现了
JDK 8 中采用的是位桶 + 链表/红黑树的方式,当某个位桶的链表的长度超过 8 的时候,这个链表就将转换成红黑树
HashMap 不会因为多线程 put 导致死循环(JDK 8 用 head 和 tail 来保证链表的顺序和之前一样;JDK 7 rehash 会倒置链表元素),但是还会有数据丢失等弊端(并发本身的问题)。因此多线程情况下还是建议使用 ConcurrentHashMap
HashMap 在并发时可能出现的问题主要是两方面:
如果多个线程同时使用 put 方法添加元素,而且假设正好存在两个 put 的 key 发生了碰撞(根据 hash 值计算的 bucket 一样),那么根据 HashMap 的实现,这两个 key 会添加到数组的同一个位置,这样最终就会发生其中一个线程 put 的数据被覆盖
如果多个线程同时检测到元素个数超过数组大小 * loadFactor,这样就会发生多个线程同时对 Node 数组进行扩容,都在重新计算元素位置以及复制数据,但是最终只有一个线程扩容后的数组会赋给 table,也就是说其他线程的都会丢失,并且各自线程 put 的数据也丢失