HashMap为什么是线程不安全的?
如果有人问你,HashMap是不是线程安全的,大部分人都不会答错,当然是线程不安全的了。线程安全的是Hashtable和ConcurrentHashMap。如果再问你,为什么说HashMap是线程不安全的,估计很多人就答不出来了。网上找了篇很好的文章,稍作修改。
对于HashMap底层数据结构不清楚的,可以参考这篇文章:HashMap底层实现原理
resize
我们都知道HashMap初始容量大小为16,一般来说,当有数据要插入时,都会检查容量有没有超过设定的thredhold,如果超过,需要增大Hash表的尺寸,但是这样一来,整个Hash表里的元素都需要被重算一遍。这叫rehash,这个成本相当的大。
看一下resize方法,它先判断Map是否已经到了系统支持的最大长度,如果是,就不再扩容,直接返回。如果没有,则执行扩容操作。
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
扩容操作通过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;
}
}
}
它通过外循环,遍历数组中每个约束,内循环遍历每个元素的单链表,以此实现数据的重新组织。
单线程rehash演示
为了思路更清晰,我们只将关键代码展示出来
while(null != e) {
Entry<K,V> next = e.next; \#1
e.next = newTable[i]; \#2
newTable[i] = e; \#3
e = next;
}
#1 因为是单链表,如果要转移头指针,一定要保存下一个结点,不然转移后链表就丢了
#2 e.next = newTable[i];——e 要插入到链表的头部,所以要先用 e.next 指向新的 Hash 表第一个元素(为什么不加到新链表最后?因为复杂度是 O(N))
#3 newTable[i] = e;——现在新 Hash 表的头指针仍然指向 e 没转移前的第一个元素,所以需要将新 Hash 表的头指针指向 e
# e = next——转移 e 的下一个结点
这种链表的构造方法,叫做头插法,也就是,在链表的头部,插入新元素。通过下图,我们可以看出,在单线程情况下,rehash 不会出现任何问题
多线程 rehash 详细演示
假设这里有两个线程同时执行了put()操作,并进入了transfer()环节。线程1,执行到#1挂起,那么现在的情况如下:
线程2获取cpu时间片,并执行完了resize过程,那么现在线程2看到的内存如下:
现在线程1被唤醒,继续执行。 e.next = newTable[i];
newTable是局部变量,线程私有,因此,newTable[i]为空,也就是e.next为null。
newTable[i] = e;
e为对象引用,可以理解为指针。这样,线程1中3的位置的第一个元素,就是Key(3),Key(3)的next是null。
e = next;
e为Key(7)。
循环上面操作
Entry<K,V> next = e.next; \#1
e.next = newTable[i]; \#2
newTable[i] = e; \#3
e = next;
结果上面代码以及线程二的状态图看。由于线程2的resize,现在Key(7)的next指向Key(3),那么next为Key(3)。
让Key(7)的next指向newTable[3],也就是Key(3),
e变为3。
现在状态如图:
ps:原作者画的图,我理解,其实线程2中数组3处,应该也指向Key(7)的。
现在的 e 节点是 key(3),首先执行Entry<K,V> next = e.next
,那么 next 就是 null
执行e.next = newTable[i],于是key(3) 的 next 就成了 key(7)
执行newTable[i] = e,那么线程1的新 Hash 表第一个元素变成了 key(3)
执行e = next,将 e 指向 next,所以新的 e 是 key(7)
很明显,环形链表出现了!!当然,现在还没有事情,因为下一个节点是 null,所以transfer()就完成了,等put()的其余过程搞定后,HashMap 的底层实现就是线程1的新 Hash 表了。
fail-fast
如果在使用迭代器的过程中有其他线程修改了map,那么将抛出ConcurrentModificationException,这就是所谓fail-fast策略。
这个异常意在提醒开发者及早意识到线程安全问题,具体原因请查看ConcurrentModificationException的原因以及解决措施
顺便再记录一个HashMap的问题:
为什么String, Interger这样的wrapper类适合作为键? String, Interger这样的wrapper类作为HashMap的键是再适合不过了,而且String最为常用。因为String是不可变的,也是final的,而且已经重写了equals()和hashCode()方法了。其他的wrapper类也有这个特点。不可变性是必要的,因为为了要计算hashCode(),就要防止键值改变,如果键值在放入时和获取时返回不同的hashcode的话,那么就不能从HashMap中找到你想要的对象。不可变性还有其他的优点如线程安全。如果你可以仅仅通过将某个field声明成final就能保证hashCode是不变的,那么请这么做吧。因为获取对象的时候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的。如果两个不相等的对象返回不同的hashcode的话,那么碰撞的几率就会小些,这样就能提高HashMap的性能。
上一篇: HashMap为什么是线程不安全的
下一篇: Hibernate面试题分析