ConcurrentHashMap学习笔记

ConcurrentHashMap学习笔记

简介

ConcurrentHashMap是线程安全高效的HashMap。我们都知道HashMap不是线程安全的,在并发编程中使用HashMap可能导致死循环。那么除了ConcurrentHashMap,还有Hashtable和Collections.synchronizedMap()方法都是线程安全的Map。

Hashtable和Collections.synchronizedMap()

看Hashtable的源码就知道了,基本上所有的public方法都加上了synchronized关键字,即对该对象上锁了,那么,效率自然就下来了。

再看看Collections.synchronizedMap()方法,该方法里面调用的下面这个方法。

SynchronizedMap(Map<K,V> m) {
   
this.m = Objects.requireNonNull(m);
   
mutex = this;
}

先确保map不为null,然后将this赋值给mutex这个对象。然后再看看其他方法,在方法中全部是类似下面这样,即给this加锁,和Hashtable加锁方式没什么不同,效率自然也不会好到哪去。

synchronized (mutex) {return m.size();}

ConcurrentHashMap实现原理(JDK8之前)

Hashtable和Collections.synchronizedMap()在竞争激烈的并发环境下效率低是因为竞争同一把锁,假如容器里有多把锁,每把锁用于锁容器中一部分数据,那么当线程访问不同数据段的数据时就不会产生竞争锁,从而有效提高并发访问效率,这就是ConcurrentHashMap锁分段技术。首先将数据分成一段一段地存储,然后给每一段数据配一把锁,当一个线程占用锁访问一段数据时,其他线程可以访问其他段的数据。

ConcurrentHashMap的结构

ConcurrentHashMap由Segment数组结构和HashEntry数组结构组成。Segment是一种可重入锁(ReentrantLock),Segment结构和HashMap类似,是一种数组和链表结构,每个Segment守护着一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,需要获得相应的segment锁。

Segment定位

ConcurrentHashMap使用分段锁Segment来锁住不同段的数据,那么在插入和读取元素的时候必须先通过散列算法定位到segment。为了减少散列冲突,ConcurrentHashMap会对元素hashCode再次散列,使得元素能够均匀分布在不同的Segment中。

ConcurrentHashMap实现原理(JDK8)

这里参考http://blog.csdn.net/u010723709/article/details/48007881,理解实现的原理,后面有时间一定好好阅读下源码。

改进

改进一:取消segments字段,直接采用transient volatileHashEntry<K,V>[] table保存数据,采用table数组元素作为锁,从而实现了对每一行数据进行加锁,进一步减少并发冲突的概率。

改进二:将原先table数组+单向链表的数据结构,变更为table数组+单向链表+红黑树的结构。对于hash表来说,最核心的能力在于将key hash之后能均匀的分布在数组中。如果hash之后散列的很均匀,那么table数组中的每个队列长度主要为0或者1。但实际情况并非总是如此理想,虽然ConcurrentHashMap类默认的加载因子为0.75,但是在数据量过大或者运气不佳的情况下,还是会存在一些队列长度过长的情况,如果还是采用单向列表方式,那么查询某个节点的时间复杂度为O(n);因此,对于个数超过8(默认值)的列表,jdk1.8中采用了红黑树的结构,那么查询的时间复杂度可以降低到O(logN),可以改进性能。

 

ConcurrentHashMap在JDK8中进行了巨大改动。摒弃了Segment(锁段)的概念,而是启用了一种全新的方式实现,利用CAS(Compare and Swap)算法。它沿用了与它同时期的HashMap版本的思想,底层依然由“数组”+链表+红黑树的方式思想(JDK7与JDK8中HashMap的实现),但是为了做到并发,又增加了很多辅助的类,例如TreeBin,Traverser等对象内部类。

JDK8中的实现也是锁分离的思想,只是锁住的是一个Node,而不是JDK7中的Segment,而锁住Node之前的操作是无锁的并且也是线程安全的,建立在3个原子操作上。

//获得在i位置上的Node节点
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
    return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}
//利用CAS算法设置i位置上的Node节点。之所以能实现并发是因为他指定了原来这个节点的值是多少
//CAS算法中,会比较内存中的值与你指定的这个值是否相等,如果相等才接受你的修改,否则拒绝你的修改
//因此当前线程中的值并不是最新的值,这种修改可能会覆盖掉其他线程的修改结果  有点类似于SVN
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
                                    Node<K,V> c, Node<K,V> v) {
    return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}
//利用volatile方法设置节点位置的值
static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) {
    U.putObjectVolatile(tab, ((long)i << ASHIFT) + ABASE, v);
}

Put方法

ConcurrentHashMap最常用的就是put和get两个方法。现在来介绍put方法,这个put方法依然沿用HashMap的put方法的思想,根据hash值计算这个新插入的点在table中的位置i,如果i位置是空的,直接放进去,否则进行判断,如果i位置是树节点,按照树的方式插入新的节点,否则把i插入到链表的末尾。ConcurrentHashMap中依然沿用这个思想,有一个最重要的不同点就是ConcurrentHashMap不允许keyvaluenull。另外由于涉及到多线程,put方法就要复杂一点。在多线程中可能有以下两个情况:

1、如果一个或多个线程正在对ConcurrentHashMap进行扩容操作,当前线程也要进入扩容的操作中。这个扩容的操作之所以能被检测到,是因为transfer方法中在空结点上插入forward节点,如果检测到需要插入的位置被forward节点占有,就帮助进行扩容;

2、如果检测到要插入的节点是非空且不是forward节点,就对这个节点加锁,这样就保证了线程安全。尽管这个有一些影响效率,但是还是会比hashTable的synchronized要好得多。

整体流程就是首先定义不允许key或value为null的情况放入  对于每一个放入的值,首先利用spread方法对key的hashcode进行一次hash计算,由此来确定这个值在table中的位置。

如果这个位置是空的,那么直接放入,而且不需要加锁操作。

如果这个位置存在结点,说明发生了hash碰撞,首先判断这个节点的类型。如果是链表节点(fh>0),则得到的结点就是hash值相同的节点组成的链表的头节点。需要依次向后遍历确定这个新加入的值所在位置。如果遇到hash值与key值都与新加入节点是一致的情况,则只需要更新value值即可。否则依次向后遍历,直到链表尾插入这个结点。如果加入这个节点以后链表长度大于8,就把这个链表转换成红黑树。如果这个节点的类型已经是树节点的话,直接调用树节点的插入方法进行插入新的值。

get方法

get方法比较简单,给定一个key来确定value的时候,必须满足两个条件key相同hash值相同,对于节点可能在链表或树上的情况,需要分别去查找。

Size相关的方法

对于ConcurrentHashMap来说,这个table里到底装了多少东西其实是个不确定的数量,因为不可能在调用size()方法的时候像GC的“stop the world”一样让其他线程都停下来让你去统计,因此只能说这个数量是个估计值。对于这个估计值,ConcurrentHashMap也是大费周章才计算出来的。

mappingCount与size方法的类似,从Java工程师给出的注释来看,应该使用mappingCount代替size方法。两个方法都没有直接返回basecount 而是统计一次这个值,而这个值其实也是一个大概的数值,因此可能在统计的时候有其他线程正在执行插入或删除操作。

在put方法结尾处调用了addCount方法,把当前ConcurrentHashMap的元素个数+1这个方法一共做了两件事,更新baseCount的值,检测是否进行扩容。

总结

JDK6,7中的ConcurrentHashmap主要使用Segment来实现减小锁粒度,把HashMap分割成若干个Segment,在put的时候需要锁住Segment,get时候不加锁,使用volatile来保证可见性,当要统计全局时(比如size),首先会尝试多次计算modcount来确定,这几次尝试中,是否有其他线程进行了修改操作,如果没有,则直接返回size。如果有,则需要依次锁住所有的Segment来计算。

jdk7中ConcurrentHashmap中,当长度过长碰撞会很频繁,链表的增改删查操作都会消耗很长的时间,影响性能,所以jdk8 中完全重写了concurrentHashmap,代码量从原来的1000多行变成了6000多行,实现上也和原来的分段式存储有很大的区别。

主要设计上的变化有以下几点:

1.       不采用segment而采用node,锁住node来实现减小锁粒度。

2.       设计了MOVED状态 当resize的中过程中 线程2还在put数据,线程2会帮助resize。

3.       使用3个CAS操作来确保node的一些操作的原子性,这种方式代替了锁。

4.       sizeCtl的不同值来代表不同含义,起到了控制的作用。

至于为什么JDK8中使用synchronized而不是ReentrantLock,我猜是因为JDK8中对synchronized有了足够的优化吧。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值