jdk 1.8 以前, 并发下使用 HashMap 会造成死循环
以前我们写代码时因为一些原因使用了 HashMap, 当时我们还是用的单线程, 一切都 ok, 但是后来单线程无法满足我们的需求, 于是我们的程序变成了多线程. 变成了多线程后的程序发布到线上, 产生了 程序占 cpu 100% 的问题, 查看堆栈, 我们发现问题出在了 get()
操作上, 重启程序后, 问题消失, 但是没多久问题又发生了, 而且这个问题在测试环境很难复现.
我们看下代码就会发现, HashMap被多个线程操作, 而 HashMap 是线程不安全的, 应该用线程安全的 ConcurrentHashMap.
在 jdk 1.8 以前, 并发下使用 HashMap 会造成死循环, 因为并发下的 Rehash会使得元素间形成一个循环链表, 不过, jdk 1.8 之后, 解决了这个问题, 但是多线程下使用 HashMap还是会存在其他的问题, 比如数据丢失, 因为多线程put的时候,当索引相同而又同时达到链表的末尾时,另一个线程put的数据会把之前线程put的数据覆盖掉,就会产生数据丢失.
曾经有网友向 sun 建议 HashMap 支持并发下使用, 但是被 Oracle 拒绝了, 他们认为要在 并发下使用 HashMap 就用 java.util.concurrent 包下的 ConcurrentHashMap.
更多详情请查看: 疫苗:JAVA HASHMAP的死循环
HashMap 和 ConcurrentHashMap 的区别
- HashMap 的设计目标就是简单高效, 没有采取任何的措施来保证 put remove 操作的多线程安全. 下面这张图是 jdk 1.8 的 HashMap 的 put 操作逻辑, 可以看出 put 操作的要么是整个散列表, 要么是某个hash 桶里的链表或者红黑树, 而这些过程都没有采取任何措施保证多线程的安全. 在这个复杂的逻辑中, 任何一个线程改动了散列表的结构, 都有可能造成另一个线程的操作失败.
-
ConcurrentHashMap 与 HashMap 相比,对多线程安全有了保证,为了这个多线程安全,ConcurrentHashMap 并没有采用 HashMap 的单散列表设计,而是用了 (jdk 1.8 以下采用了分段数组 + 链表实现, jdk 1.8 采用的数据结构和 HashMap 一样, 数组 + 链表 / 红黑二叉树), HashTable 和 jdk 1.8 以前的 HashMap 一样, 底层的数据结构都是采用的 数组 + 链表, 链表是主体, 链表是为了解决 hash 冲突而存在的.
为什么不建议在并发下使用 HashTable
HashTable 相比 ConcurrentHashMap, 对并发的处理就粗糙很多, 单纯的通过锁住整个散列表来保证多线程的安全性. 虽然在设计和实现上比 ConcurrentHashMap 简单许多, 但是在效率上远远不及 ConcurrentHashMap, 所以目前多线程环境下不建议使用 HashTable, 而是建议使用 ConcurrentHashMap
为什么 jdk 1.8 后 避免了并发下 HashMap死循环的问题
我们来看下 HashMap resize 的源码
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; // double threshold } else if (oldThr > 0) // initial capacity was placed in threshold newCap = oldThr; else { // zero initial threshold signifies using defaults 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 { // preserve order 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; }
我们重点看 39 行后的代码(仅考虑链表部分), 它声明了两队指针, 维护了两个链表, 在末端添加新的元素(在多线程情况下, 第二个线程重复第一个线程的操作). 这样就避免了死循环问题.