java.util.concurrent.ConcurrentHashMap深度解析

本文详细解析了JDK8 ConcurrentHashMap中基于CAS算法的线程安全实现,涉及put和get操作流程,以及关键的扩容transfer过程。重点讨论了如何处理哈希冲突,红黑树转换,以及null键值的限制。
摘要由CSDN通过智能技术生成

java.util.concurrent.ConcurrentHashMap<K,V> JDK1.8

本文的分析的源码是JDK8的版本,与JDK6的版本有很大的差异。实现线程安全的思想也已经完全变了,它摒弃了Segment(锁段)的概念,而是启用了一种全新的方式实现,利用CAS算法。它沿用了与它同时期的HashMap版本的思想,底层依然由“数组”+链表+红黑树的方式思想,大于8个转换为红黑树。默认初始大小16,负载因子也是0.75,定位元素的方法也是先hashCode(),再无符号右移16位异或,再(n-1)&hash
取消segments字段,直接采用transient volatile Node<K,V>[] table;保存数据,采用table数组元素作为锁,从而实现了对每一行数据进行加锁,进一步减少并发冲突的概率。
put函数流程:
1、判断put进来的key和value是否为null,如果为null抛异常。(ConcurrentHashMap的key、value不能为null)。
2、随后进入无限循环(没有判断条件的for循环),何时插入成功,何时退出。
3、在无限循环中,若table数组为空(底层数组加链表),则调用initTable(),初始化table;
4、若table不为空,先hashCode(),再无符号右移16位异或,再(n-1)&hash,定位到table中的位置,如果该位置为空(说明还没有发生哈希冲突),则使用CAS将新的节点放入table中。
5、如果该位置不为空,且该节点的hash值为MOVED(即为forward节点,哈希值为-1,其中含有指向nextTable的指针,class ForwardingNode中有nexttable变量),说明此时正在扩容,且该节点已经扩容完毕,如果还有剩余任务(任务没分配完)该线程执行helpTransfer方法,帮助其他线程完成扩容,如果已经没有剩余任务,则该线程可以直接操作新数组nextTable进行put。
6、如果该位置不为空,且该节点不是forward节点。对桶中的第一个结点(即table表中的结点,哈希值相同的链表的第一个节点)进行加锁(锁是该结点,如果此时还有其他线程想来put,会阻塞)(如果不加锁,可能在遍历链表的过程中,又有其他线程放进来一个相同的元素,但此时我已经遍历过,发现没有相同的,这样就会产生两个相同的),对该桶进行遍历,桶中的结点的hash值与key值与给定的hash值和key值相等,则根据标识选择是否进行更新操作(用给定的value值替换该结点的value值),若遍历完桶仍没有找到hash值与key值和指定的hash值与key值相等的结点,则直接新生一个结点并赋值为之前最后一个结点的下一个结点。
7、若binCount值达到红黑树转化的阈值,则将桶中的结构转化为红黑树存储,最后,增加binCount的值。最后调用addcount方法,将concurrenthashmap的size加1,调用size()方法时会用到这个值。
扩容transfer()函数流程:
整个扩容操作分为两个部分
第一部分是构建一个nextTable,它的容量是原来的两倍,这个操作是单线程完成的。
第二个部分就是将原来table中的元素复制到nextTable中,这里允许多线程进行操作。
其他线程调用helptransfer方法来协助扩容时,首先拿到nextTable数组,再调用transfer方法。给新来的线程分配任务(默认是16个桶一个任务)。
遍历自己所分到的桶:
1、桶中元素不存在,则通过CAS操作设置桶中第一个元素为ForwardingNode,其Hash值为MOVED(-1),同时该元素含有新的数组引用
此时若其他线程进行put操作,发现第一个元素的hash值为-1则代表正在进行扩容操作(并且表明该桶已经完成扩容操作了,可以直接在新的数组中重新进行hash和插入操作),该线程就可以去帮助扩容,或者没有任务则不用参与,此时可以去直接操作新的数组了
2、桶中元素存在且hash值为-1,则说明该桶已经被处理了(本不会出现多个线程任务重叠的情况,这里主要是该线程在执行完所有的任务后会再次进行检查,再次核对)
3、桶中为链表或者红黑树结构,则需要获取桶锁,防止其他线程对该桶进行put操作,然后处理方式同HashMap的处理方式一样,对桶中元素分为2类,分别代表当前桶中和要迁移到新桶中的元素。设置完毕后代表桶迁移工作已经完成,旧数组中该桶可以设置成ForwardingNode了,已经完成从table复制到nextTable的节点,要设置为forward
get函数流程:
1、根据k计算出hash值,找到对应的数组index
2、如果该index位置无元素则直接返回null
3、如果该index位置有元素
如果第一个元素的hash值小于0,则该节点可能为ForwardingNode或者红黑树节点TreeBin
如果是ForwardingNode(表示当前正在进行扩容,且已经扩容完成),使用新的数组来进行查找
如果是红黑树节点TreeBin,使用红黑树的查找方式来进行查找
如果第一个元素的hash大于等于0,则为链表结构,依次遍历即可找到对应的元素,也就是读的时候不会加锁,同时有put,不会阻塞。
读不加锁是因为使用了volatile(用在transient volatile Node<K,V>[] table),happens-before

ConcurrentHashmap和Hashtable不允许key和value为null:
ConcurrentHashmap和Hashtable都是支持并发的,这样会有一个问题,当你通过get(k)获取对应的value时,如果获取到的是null时,你无法判断,它是put(k,v)的时候value为null,还是这个key从来没有做过映射。HashMap是非并发的,可以通过contains(key)来做这个判断。而支持并发的Map在调用m.contains(key)和m.get(key),m可能已经不同了。
ConcurrentHashmap和Hashtable都不允许key和value为null,Collections.synchronizedMap和HashMap的key和value都可以为null(因为就是包装了hashmap),TreeMap的key不可为空(非线程安全,需要排序),value可以。
参考链接:
https://blog.csdn.net/jianghuxiaojin/article/details/52006118

说一下HashMap和TreeMap的区别?
基于红黑树的实现,线程非安全,存入TreeMap的元素应当实现Comparable接口或者实现Comparator接口,会按照排序后的顺序迭代元素,key不能为null,因为null没有实现comparable。线程不同步。有序散列表,实现SortedMap 接口。也有modCount
HashMap采用链地址的哈希表,无序的。

Vector

vector线程安全的,有modCount,在每个方法上加synchronized,初始大小10,底层维护了一个object类型的数组,在构造函数中初始化。实现了list接口。

Comparable和Comparator

Comparable和Comparator都是用来实现集合中元素的比较、排序的。
Comparable是在集合内部定义的方法实现的排序,位于java.lang下。
Comparator是在集合外部实现的排序,位于java.util下。
Comparable是一个对象本身就已经支持自比较所需要实现的接口,如String、Integer自己就实现了Comparable接口,可完成比较大小操作。自定义类要在加入list容器中后能够排序,也可以实现Comparable接口,在用Collections类的sort方法排序时若不指定Comparator,那就以自然顺序排序。所谓自然顺序就是实现Comparable接口设定的排序方式。
Comparator是一个专用的比较器,当这个对象不支持自比较或者自比较函数不能满足要求时,可写一个比较器来完成两个对象之间大小的比较。Comparator体现了一种策略模式(strategy design pattern),就是不改变对象自身,而用一个策略对象(strategy object)来改变它的行为。
总而言之Comparable是自已完成比较,Comparator是外部程序实现比较。

CAS(UnSafe包)

java.util.concurrent包完全建立在CAS之上的
CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。
要实现无锁(lock-free)的非阻塞算法有多种实现方法,其中CAS(比较与交换,Compare and swap)是一种有名的无锁算法。CAS, CPU指令,在大多数处理器架构,包括IA32、Space中采用的都是CAS指令,CAS的语义是“我认为V的值应该为A,如果是,那么将V的值更新为B,否则不修改并告诉V的值实际为多少”,CAS是项乐观锁技术,当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。是一条CPU的原子指令,其实现方式是基于硬件平台的汇编指令,就是说CAS是靠硬件实现的
拿出AtomicInteger来研究在没有锁的情况下是如何做到数据正确性的。
private volatile int value;
在没有锁的机制下可能需要借助volatile原语,保证线程间的数据是可见的(共享的)。
这样才获取变量的值的时候才能直接读取。
public final int get() {
return value;
}
然后来看看++i是怎么做到的。
public final int incrementAndGet() {
for (;😉 {//无限循环直到成功
int current = get();
int next = current + 1;
if (compareAndSet(current, next))//相同返回true,不同返回false
return next;
}
}
在这里采用了CAS操作,每次从内存中读取数据然后将此数据和+1后的结果进行CAS操作,如果成功就返回结果,否则重试直到成功为止。
而compareAndSet利用JNI来完成CPU指令的操作。
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
整体的过程就是这样子的,利用CPU的CAS指令,同时借助JNI来完成Java的非阻塞算法。其它原子操作都是利用类似的特性完成的。
CAS虽然很高效的解决原子操作,但是CAS仍然存在三大问题。ABA问题,循环时间长开销大和只能保证一个共享变量的原子操作

  1. ABA问题。因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A-2B-3A。
    从Java1.5开始JDK的atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法作用是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
  2. 循环时间长开销大。自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。CAS自旋volatile变量
  3. 只能保证一个共享变量的原子操作。当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值