欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

HashMap为什么是线程不安全的?

程序员文章站 2024-03-26 11:57:59
...

如果有人问你,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 不会出现任何问题

HashMap为什么是线程不安全的?

多线程 rehash 详细演示

假设这里有两个线程同时执行了put()操作,并进入了transfer()环节。线程1,执行到#1挂起,那么现在的情况如下:

HashMap为什么是线程不安全的?

线程2获取cpu时间片,并执行完了resize过程,那么现在线程2看到的内存如下:

HashMap为什么是线程不安全的?

现在线程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。

现在状态如图:
HashMap为什么是线程不安全的?

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)
HashMap为什么是线程不安全的?

很明显,环形链表出现了!!当然,现在还没有事情,因为下一个节点是 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的性能。

引用:http://www.importnew.com/22011.html