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的性能。

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

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值