ConcurrentHashMap原理-面试总结

面试题:

1、HashMap的线程安全吗?有哪些替代方案

HashMap不安全,HashMap的初始容量是16,当长度超出12(16的3/4)时,Map需要扩容,每次扩容之后大小都是之前的两倍。

扩容时,需要将容器中的元素重新计算一遍,并且创建一个更长的新的数组,将元素复制进这个新数组。这个步骤很消耗性能,所以,在初始化map的时候,尽可能地按照需要指定其长度,尽量减少resize的次数,这是一个良好的习惯。

1)为什么采用两倍的扩容方式呢?

因为使用位运算比直接取模效率高很多,在JDK7中,采用的是取模来计算桶的位置,在JDK8中采用了巧妙的位运算的方式:h&(length-1),h是元素的key的哈希值,length是数组的长度。这样的好处是首先降低了运算的难度,因为位运算对于计算机来说只要判断1或0;第二点好处是,由于这种方式,在HashMap扩容的时候,不需要重新对hash值进行取模。只需要判断当前元素是保持当前位置不变,还是需要移动长度为length个位置即可,这减少了运算的次数。

2)两个线程同时执行resize操作,先来演示一下假如在单线程情况下,正确的扩容流程的图解:

如果两个线程同时对一个HashMap进行操作:

线程A:愉快地开始对数组进行操作,当处理到第一步时,时间片用尽,线程A被挂起,将资源让给其他线程。此时e指向key(3),next指向key(7)。

线程B:获取时间片,开始对数组进行操作,比较幸运的是,线程B完成了上述三个 步骤,此时key(7)指向key(3)

线程A被再度唤醒,它不知道在小休息一瞬间的时候发生了什么事儿,开始继续完成自己的任务。

相比此时大家也看出来了,key(3),key(7)之间出现了一个死循环。

总结来讲,在扩容时,jdk1.8之前采用头插法,当两个线程同时检测到HashMap需要扩容,在进行同时扩容时候有可能会造成链表的循环,主要原因就是,采用头插法,新链表与旧链表的顺序是反的,在1.8后采用尾插法就不会出现这种问题,同时1.8的链表长度如果大于8就会转变成红黑树。

这就是最经典的HashMap线程不安全原因了。

此外,在日常的put操作中,HashMap仍然存在着线程的安全隐患。

线程A将一个Node A的key计算出的hash值,并且通过取模/位运算操作后,要插入到数组的一个桶中时,时间片耗尽。

线程B获取资源,顺利完成将一个Node B插入到数组或数组某索引下的链表之中。但是无巧不成书,Node B的链表头是一样的,而这一切,A和B都不知晓

发生灾难,线程A被唤醒,它不知道之前计算出来的链表头的位置已经被B占用了,所以仍然继续操作,于是将Node B的数据覆盖了,造成了数据的丢失。

总结来讲:在put的时候,因为该方法不是同步的,假如有两个线程A,B他们的put的key的hash 值相同,不论是从头插入还是从尾插入,假如A获取了插入位置为X,但是还未插入,此时B也计算出待插入位置为X,则不论AB插入的先后顺序肯定有一个会丢失。

3)怎么才能让HashMap变成线程安全的呢?

替换成Hashtable,Hashtable通过对整个表上锁实现线程安全,因此效率比较低;

使用Collections类的synchronizedMap方法包装一下,方法如下:

public static <K,V>Map<K,V>synchronizedMap(Map<K,V> m)返回指定映射支持的同步(线程安全的)映射;

使用ConcurrentHashMap,它使用分段锁来保证线程安全,效率高,推荐使用;

2、ConcurrentHashMap的实现

1)ConcurrentHashMap使用分段技术,将数据分成一段一段的存储,给每一段数据配置一把锁,当一个线程占用锁访问其中一段数据时,其他段的数据也能被其他线程访问。

2)ConcurrentHashMap是一个线程安全的哈希表,她的主要功能是提供了一组和HashMap功能相同但是线程安全的方法。

3)工作原理:

ConcurrentHashMap为了提高本身的并发能力,在内部采用了一个叫做Segment的结构,一个Segment其实就是一个类Hash Table的结构,Segment内部维护了一个链表数组

HashTable容器在竞争激烈的并发环境下表现出效率低下的原因,是因为所有访问HashTable的线程都必须竞争同一把锁,那假如容器里有多把锁,每一把锁用于容器其中一部分数据,那么当多线程访问 容器里不同数据段的数据时,线程就不会存在锁竞争,从而可以有效的提高并发访问效率,这就是ConcurrentHashMap所使用的所分段技术。

首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个锁定数据段,操作完毕后,又顺序释放所有段的锁。这里“按顺序”是很重要的,否则极有可能出现死锁,在ConcurrentHashMap内部,段数组是final的,并且其成员变量实际上也是final的,但是,仅仅是将数组声明为final的并不保证数据成员也是final的,这需要实现上的保证。这可以确保不会出现死锁,因为获得锁的顺序是固定的。

onCurrentHashMap类中包含两个静态内部类HashEntry和Segment。

HashEntry用来封装映射表的键/值对,Segment用来充当锁的角色,每个Segment对象守护整个散列映射的若干个桶。

每个桶是由若干个HashEntry对象链接起来的链表。一个ConcurrentHashMap实例中包含由若干个Segment对象组成的数组。

每个Segment守护着一个HashEntry数组里的元素,当HashEntry数组的数据进行修改时,必须首先获得它对应的Segment锁。

3、加锁时机

HashTable容器在竞争激烈的并发环境下表现出效率低下的原因,是因为所以访问HashTable的线程都必须竞争同一把锁,那假如容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程就不会存在锁竞争,从而可以有效的提高并发访问效率,这就是ConcurrentHashMap所使用的锁分段技术,首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。有些方法需要跨段,比如size()和containsValue(),他们可能需要锁定整个表而不仅仅是某个段,这需要按顺序锁定所有段,操作完毕后,又按顺序释放所有段的锁。这里“按顺序”是很重要的,否则极有可能出现死锁,在ConcurrentHashMap内部,段数组是final的,并且其成员变量实际上也是final的,但是,仅仅是将数组声明为final的并不保证数组成员也是final的,这需要实现上的保证。这也可以确保不会出现死锁,因为获得锁的顺序是固定的。

4、应用场景

当有一个大数据组时,需要在多线程共享时就可以考虑是否把它分层给多个节点了,避免大锁,并可以考虑通过hash算法进行一些模块定位。其实不止用于线程,当设计数据表的事务时(事务某种意义上也是同步机制的提现),可以把一个表看成一个需要同步的数组,如果操作的表数据太多时就可以考虑事务分离了(这也是为什么要避免大表的出现),比如把数据进行字段拆分,水平分表等。

  • 1
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值