咱们都知道HashMap是线程不安全的,可是HashMap的使用频率在全部map中确实属于比较高的。由于它能够知足咱们大多数的场景了。java
Map类继承图
复制代码
Map是一个接口,咱们经常使用的实现类有HashMap、LinkedHashMap、TreeMap,HashTable。HashMap根据key的hashCode值来保存value,须要注意的是,HashMap不保证遍历的顺序和插入的顺序是一致的。HashMap容许有一条记录的key为null,可是对值是否为null不作要求。HashTable类是线程安全的,它使用synchronize来作线程安全,全局只有一把锁,在线程竞争比较激烈的状况下hashtable的效率是比较低下的。由于当一个线程访问hashtable的同步方法时,其余线程再次尝试访问的时候,会进入阻塞或者轮询状态,好比当线程1使用put进行元素添加的时候,线程2不但不能使用put来添加元素,并且不能使用get获取元素。因此,竞争会愈来愈激烈。相比之下,ConcurrentHashMap使用了分段锁技术来提升了并发度,不在同一段的数据互相不影响,多个线程对多个不一样的段的操做是不会相互影响的。每一个段使用一把锁。因此在须要线程安全的业务场景下,推荐使用ConcurrentHashMap,而HashTable不建议在新的代码中使用,若是须要线程安全,则使用ConcurrentHashMap,不然使用HashMap就足够了。算法
LinkedHashMap属于HashMap的子类,与HashMap的区别在于LinkedHashMap保存了记录插入的顺序。TreeMap实现了SortedMap接口,TreeMap有能力对插入的记录根据key排序,默认按照升序排序,也能够自定义比较强,在使用TreeMap的时候,key应当实现Comparable。数组
java7和java8在实现HashMap上有所区别,固然java8的效率要更好一些,主要是java8的HashMap在java7的基础上增长了红黑树这种数据结构,使得在桶里面查找数据的复杂度从O(n)降到O(logn),固然还有一些其余的优化,好比resize的优化等。 介于java8的HashMap较为复杂,本文将基于java7的HashMap实现来讲明,主要的实现部分仍是一致的,java8的实现上主要是作了一些优化,内容仍是没有变化的,依然是线程不安全的。安全
HashMap的实现使用了一个数组,每一个数组项里面有一个链表的方式来实现,由于HashMap使用key的hashCode来寻找存储位置,不一样的key可能具备相同的hashCode,这时候就出现哈希冲突了,也叫作哈希碰撞,为了解决哈希冲突,有开放地址方法,以及链地址方法。HashMap的实现上选取了链地址方法,也就是将哈希值同样的entry保存在同一个数组项里面,能够把一个数组项当作一个桶,桶里面装的entry的key的hashCode是同样的。bash
HashMap的结构模型(java8)
复制代码
上面的图片展现了咱们的描述,其中有一个很是重要的数据结构Node<K,V>,这就是实际保存咱们的key-value对的数据结构,下面是这个数据结构的主要内容:数据结构
final int hash;
final K key;
V value;
Node<K,V> next;
复制代码
一个Node就是一个链表节点,也就是咱们插入的一条记录,明白了HashMap使用链地址方法来解决哈希冲突以后,咱们就不难理解上面的数据结构,hash字段用来定位桶的索引位置,key和value就是咱们的数据内容,须要注意的是,咱们的key是final的,也就是不容许更改,这也好理解,由于HashMap使用key的hashCode来寻找桶的索引位置,一旦key被改变了,那么key的hashCode极可能就会改变了,因此随意改变key会使得咱们丢失记录(没法找到记录)。next字段指向链表的下一个节点。多线程
HashMap的初始桶的数量为16,loadFact为0.75,当桶里面的数据记录超过阈值的时候,HashMap将会进行扩容则操做,每次都会变为原来大小的2倍,直到设定的最大值以后就没法再resize了。并发
下面对HashMap的实现作简单的介绍,具体实现还得看代码,对于java8中的HashMap实现,还须要能理解红黑树这种数据结构。app
一、根据key的hashCode来决定应该将该记录放在哪一个桶里面,不管是插入、查找仍是删除,这都是第一步,计算桶的位置。由于HashMap的length老是2的n次幂,因此可使用下面的方法来作模运算:函数
h&(length-1)
复制代码
h是key的hashCode值,计算好hashCode以后,使用上面的方法来对桶的数量取模,将这个数据记录落到某一个桶里面。固然取模是java7中的作法,java8进行了优化,作得更加巧妙,由于咱们的length老是2的n次幂,因此在一次resize以后,当前位置的记录要么保持当前位置不变,要么就向前移动length就能够了。因此java8中的HashMap的resize不须要从新计算hashCode。咱们能够经过观察java7中的计算方法来抽象出算法,而后进行优化,具体的细节看代码就能够了。
二、HashMap的put方法
HashMap的put方法处理逻辑(java8)
复制代码
上图展现了java8中put方法的处理逻辑,比java7多了红黑树部分,以及在一些细节上的优化,put逻辑和java7中是一致的。
三、resize机制
HashMap的扩容机制就是从新申请一个容量是当前的2倍的桶数组,而后将原先的记录逐个从新映射到新的桶里面,而后将原先的桶逐个置为null使得引用失效。后面会讲到,HashMap之因此线程不安全,就是resize这里出的问题。
上面说到,HashMap会进行resize操做,在resize操做的时候会形成线程不安全。下面将举两个可能出现线程不安全的地方。
一、put的时候致使的多线程数据不一致。 这个问题比较好想象,好比有两个线程A和B,首先A但愿插入一个key-value对到HashMap中,首先计算记录所要落到的桶的索引坐标,而后获取到该桶里面的链表头结点,此时线程A的时间片用完了,而此时线程B被调度得以执行,和线程A同样执行,只不过线程B成功将记录插到了桶里面,假设线程A插入的记录计算出来的桶索引和线程B要插入的记录计算出来的桶索引是同样的,那么当线程B成功插入以后,线程A再次被调度运行时,它依然持有过时的链表头可是它对此一无所知,以致于它认为它应该这样作,如此一来就覆盖了线程B插入的记录,这样线程B插入的记录就凭空消失了,形成了数据不一致的行为。
二、另一个比较明显的线程不安全的问题是HashMap的get操做可能由于resize而引发死循环(cpu100%),具体分析以下:
下面的代码是resize的核心内容:
这是jdk7的实现方式,jdk8不是这样的。
// 这个方法的功能是将原来的记录从新计算在新桶的位置,而后迁移过去。
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;
}
}
}
复制代码
多线程HashMap的resize
复制代码
咱们假设有两个线程同时须要执行resize操做,咱们原来的桶数量为2,记录数为3,须要resize桶到4,原来的记录分别为:[3,A],[7,B],[5,C],在原来的map里面,咱们发现这三个entry都落到了第二个桶里面。 假设线程thread1执行到了transfer方法的Entry next = e.next这一句,而后时间片用完了,此时的e = [3,A], next = [7,B]。线程thread2被调度执行而且顺利完成了resize操做,须要注意的是,此时的[7,B]的next为[3,A]。此时线程thread1从新被调度运行,此时的thread1持有的引用是已经被thread2 resize以后的结果。线程thread1首先将[3,A]迁移到新的数组上,而后再处理[7,B],而[7,B]被连接到了[3,A]的后面,处理完[7,B]以后,就须要处理[7,B]的next了啊,而经过thread2的resize以后,[7,B]的next变为了[3,A],此时,[3,A]和[7,B]造成了环形链表,在get的时候,若是get的key的桶索引和[3,A]和[7,B]同样,那么就会陷入死循环。
若是在取链表的时候从头开始取(如今是从尾部开始取)的话,则能够保证节点之间的顺序,那样就不存在这样的问题了。 综合上面两点,能够说明HashMap是线程不安全的。
根据上面JDK1.7出现的问题,在JDK1.8中已经获得了很好的解决,若是你去阅读1.8的源码会发现找不到transfer函数,由于JDK1.8直接在resize函数中完成了数据迁移。另外说一句,JDK1.8在进行元素插入时使用的是尾插法。
为何说JDK1.8会出现数据覆盖的状况喃,咱们来看一下下面这段JDK1.8中的put操做代码:
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) // 若是没有hash碰撞则直接插入元素
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) // -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;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
复制代码
其中第六行代码是判断是否出现hash碰撞,假设两个线程A、B都在进行put操做,而且hash函数计算出的插入下标是相同的,当线程A执行完第六行代码后因为时间片耗尽致使被挂起,而线程B获得时间片后在该下标处插入了元素,完成了正常的插入,而后线程A得到时间片,因为以前已经进行了hash碰撞的判断,全部此时不会再进行判断,而是直接进行插入,这就致使了线程B插入的数据被线程A覆盖了,从而线程不安全。
除此以前,还有就是代码的第38行处有个++size,咱们这样想,仍是线程A、B,这两个线程同时进行put操做时,假设当前HashMap的zise大小为10,当线程A执行到第38行代码时,从主内存中得到size的值为10后准备进行+1操做,可是因为时间片耗尽只好让出CPU,线程B快乐的拿到CPU仍是从主内存中拿到size的值10进行+1操做,完成了put操做并将size=11写回主内存,而后线程A再次拿到CPU并继续执行(此时size的值仍为10),当执行完put操做后,仍是将size=11写回内存,此时,线程A、B都执行了一次put操做,可是size的值只增长了1,全部说仍是因为数据覆盖又致使了线程不安全。
总结: HashMap的线程不安全主要体如今下面两个方面:
1.在JDK1.7中,当并发执行扩容操做时会形成环形链和数据丢失的状况。
2.在JDK1.8中,在并发执行put操做时会发生数据覆盖的状况。