HashMap的结构就是哈希表,底层是由数组加链表组成,这个数组中尽可能地分散所有的key,通过key的hash值得到数组下标,然后把entry插到该数组,假如有两个不同的key被分到相同的下标,也就是哈希冲突,那么该数组在该下标下就会形成链表。
如果链表长度太大,会进行扩容,也就是构建一个更长的数组来存放链表,链表里面的各个元素会根据哈希算法计算新的hashcode,也就是散列值,然后每个元素根据新的hashcode存放到新的数组的对应位置。
以上是哈希表的简介。
这里举例2种情况可能导致线程不安全,第二种情况讨论的更多一点。
1、多线程同时put
这个问题比较好想象,比如有两个线程A和B,首先A希望插入一个key-value对到HashMap中,首先计算记录所要落到的桶的索引坐标,然后获取到该桶里面的链表头结点,此时线程A的时间片用完了,而此时线程B被调度得以执行,和线程A一样执行,只不过线程B成功将记录插到了桶里面,假设线程A插入的记录计算出来的桶索引和线程B要插入的记录计算出来的桶索引是一样的,那么当线程B成功插入之后,线程A再次被调度运行时,它依然持有过期的链表头但是它对此一无所知,以至于它认为它应该这样做,如此一来就覆盖了线程B插入的记录,这样线程B插入的记录就凭空消失了,造成了数据不一致的行为。
2、多线程扩容时
首先了解一下单线程里面,hashmap是 如何新建扩容的。
transfer方法是其中的核心内容。
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;
}
}
}
这个方法的大意就是,会计算原来的桶(table)里面每个元素的新的hashcode,以此来判断他要放在那个新桶里面,放的方法是头插法:也就是把元素插入到新表指定位置的表头,如下图所示:
这里新的计算hashcode的方法为key mod 新表长度,比如key是3,新表长度为4,那么他在新表里面的索引就是1,如果key为5,他在新表里面的索引也是1。
比如一开始线程1刚把next元素赋值,就中断了,线程2也执行了扩容操作,并且成功了,那么此时结果如下:
线程1执行的时候被中断:
线程2完成执行了transfer:
之后线程1,恢复,继续执行,但是它里面的e和next的指向还是原来的,没有变,于是他继续根据transfer函数里面的内容来执行,也就是把e表示的元素插到新索引的链表的开头,然后e表示元素的next指向原来的开头,那么就会变成下面这样:
于是就形成了链表里面的环形结构,这样e的next永远都不会是null,也就会在transfer里面形成死循环。