ConcurrentHashMap
(1.8之前的)
1.参考博客:https://www.iteye.com/topic/344876
2.参考博客:https://www.iteye.com/topic/260515
第二个博客是用来了解happen-before规则的,虽然说不了解的话不影响你大体对hashmap的了解。这个happen-before是关于个别语句的,了解了后对volatile的用法以及多线程的安全问题有更深的理解
Volatile的可见性与重排序
参考博客:https://www.cnblogs.com/leefreeman/p/7356030.html
结合happen-before和可见性,volatile的作用就明显了,首先是可见性**:volatile保证两件事情:1、 线程1工作内存中的变量更新会强制立即写入到主内存;
1、 线程1工作内存中的变量更新会强制立即写入到主内存;
2、 线程2工作内存中的变量会强制立即失效,这使得线程2必须去主内存中获取最新的变量值。**
这就保证了每次读volatile变量都是最新的值。结合happen-before规则,如果另一个数据发生在volatile变量更新之前,那么我要读这个数据的话就先读volatile变量的数据,那么就可以保证这个数据发生在最新的volatile修改之前,这个数据也会被更新到几乎最新的值。
简单来说:就是我要赚1个亿之前要先赚1千万,这个顺序不能倒过来的。那我现在赚了一个亿了,你就可以知道我肯定是先赚了1千万的。你读到1一个亿的时候,因为1千万肯定在一个亿前面,所以你就知道了1千万。 那假如你不知道我赚了一个亿呢?那你会知道我赚了1千万嘛? 所以这就是为什么要先读volatile的值的原因了
(注意:写volatile变量和它之前的读写操作是不能reorder的,读volatile变量和它之后的读写操作也是不能reorder的。)
Happen-before与重排序的关系
参考博客:https://blog.csdn.net/ma_chen_qq/article/details/82990603
具体的定义为:
1)如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
2)两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)。
具体的规则:
(1)程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
(2)监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
(3)volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
(4)传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
(5)start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
(6)Join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
(7)程序中断规则:对线程interrupted()方法的调用先行于被中断线程的代码检测到中断时间的发生。
(8)对象finalize规则:一个对象的初始化完成(构造函数执行结束)先行于发生它的finalize()方法的开始。
1.8后的,关于CAS,这篇讲得很好
参考博客:https://www.imooc.com/article/40690
CAS是基于硬件实现的。CAS是一种乐观锁的实现。CAS 的实现离不开处理器的支持。其实核心代码就是一条带lock 前缀的 cmpxchg 指令,即lock cmpxchg dword ptr [edx], ecx。
在 Intel 处理器中,有两种方式保证处理器的某个核心独占某片内存区域。第一种方式是通过锁定总线,让某个核心独占使用总线,但这样代价太大。总线被锁定后,其他核心就不能访问内存了,可能会导致其他核心短时内停止工作。第二种方式是锁定缓存,若某处内存数据被缓存在处理器缓存中。处理器发出的 LOCK# 信号不会锁定总线,而是锁定缓存行对应的内存区域。
这是加lock能原子操作的原理。
1.什么是ConcurrentHashMap?
2.为什么要用ConcurrentHashMap?
3.ConcurrentHashMap的原理是什么?结构是怎么样的?
4.ConcurrentHashMap的函数分析。
我。。。刚看完jdk1.7关于concurrenthashmap的(看了好久的),然后想组织一下语言。。发现1.8的ConcurrentHashMap居然把分段锁的概念都不用了。。。。完全不同了。。
看了1.8的发现后面有很多多线程的实现,我也没认真看(下次一定),但是基本的原理和大概还是明白的。
先说一下1.7的吧。。
1.7是基于一种叫锁分离的思想。把之前的hashmap继续细分为一个个小的hashmap。为什么要分呢?因为不分的话,你想要保持同步的话就要把整个hashmap加锁,但是这样会导致效率的很大下降。那分了多段后,操作不同的段就用不同的锁,互不干扰。这样比你锁住整个hashmap的效率就高很多,锁的粒度少了。
结构是这样的
其中一些设计:你可以看到key,hash,next都是final类型的。Final类型的话就可以保证这些不被更改。为什么next也是final类型的呢?这是为了方便后面遍历链表的时候不需要加锁。(get方法不用加锁)但是这样就会带来remove和扩容节点调整的不方便了。
这是定位段的,我们要找到某个hashentry,显然要先找到段的位置,然后再找到数组下标,最后再遍历。
这是concurrenthashmap的数据成员:
每个Segment就像一个hash表:
一些操作的实现细节
可以看到,这是委托给段的remove操作,只要不在同一段就可以同时进行。
这是段的remove方法
整个操作是在持有段锁的情况下执行的,空白行之前的行主要是定位到要删除的节点e。接下来,如果不存在这个节点就直接返回null,否则就要将e前面的结点复制一遍,尾结点指向e的下一个结点。e后面的结点不需要复制,它们可以重用。
这里的2和1节点应该是反过来的。因为它是从第一个节点开始遍历的,把一个节点指向要删除的节点的后一个节点,然后再第二个节点指向第一个节点。。。。
看put操作
该方法也是在持有段锁的情况下执行的,首先判断是否需要rehash,需要就先rehash。接着是找是否存在同样一个key的结点,如果存在就直接替换这个结点的值。否则创建一个新的结点并添加到hash链的头部,这时一定要修改modCount和count的值,同样修改count的值一定要放在最后一步。put方法调用了rehash方法,reash方法实现得也很精巧,主要利用了table的大小为2^n,这里就不介绍了。
修改count为什么要放在最后一步?在一开始就介绍了,这关于happen-before规则和volitile变量的用法。
Get操作
get操作不需要锁。第一步是访问count变量,这是一个volatile变量,由于所有的修改操作在进行结构修改时都会在最后一步写count变量,通过这种机制保证get操作能够得到几乎最新的结构更新。对于非结构更新,也就是结点值的改变,由于HashEntry的value变量是volatile的,也能保证读取到最新的值。接下来就是对hash链进行遍历找到要获取的结点,如果没有找到,直接访回null。对hash链进行遍历不需要加锁的原因在于链指针next是final的。但是头指针却不是final的,这是通过getFirst(hash)方法返回,也就是存在table数组中的值。这使得getFirst(hash)可能返回过时的头结点,例如,当执行get方法时,刚执行完getFirst(hash)之后,另一个线程执行了删除操作并更新头结点,这就导致get方法中返回的头结点不是最新的。这是可以允许,通过对count变量的协调机制,get能读取到几乎最新的数据,虽然可能不是最新的。要得到最新的数据,只有采用完全的同步。
这就是get不用加锁的原理,count,value都是volatile变量,不用担心读他们时会读到很久之前的值,能保证读到的是几乎最新的值,然后链表又是final类型的,也不担心中间会被next被修改。可能还是会有点不是最新的,但是在允许中的。
最后,如果找到了所求的结点,判断它的值如果非空就直接返回,否则在有锁的状态下再读一次。这似乎有些费解,理论上结点的值不可能为空,这是因为put的时候就进行了判断,如果为空就要抛NullPointerException。空值的唯一源头就是HashEntry中的默认值,因为HashEntry中的value不是final的,非同步读取有可能读取到空值。仔细看下put操作的语句:tab[index] = new HashEntry<K,V>(key, hash, first, value),在这条语句中,HashEntry构造函数中对value的赋值以及对tab[index]的赋值可能被重新排序,这就可能导致结点的值为空。这种情况应当很罕见,一旦发生这种情况,ConcurrentHashMap采取的方式是在持有锁的情况下再读一遍,这能够保证读到最新的值,并且一定不会为空值。
因为重新排序的话就有可能先赋值给了tab[index],但是初始化还没完成,所以导致还是用了默认值null。
下面就是size()方法,也没什么好说的,size()的实现还有一点需要注意,必须要先segments[i].count,才能segments[i].modCount,这是因为segment[i].count是对volatile变量的访问,接下来segments[i].modCount才能得到几乎最新的值。这在一开始就说明了。看开头。
size方法主要思路是先在没有锁的情况下对所有段大小求和,如果不能成功(这是因为遍历过程中可能有其它线程正在对已经遍历过的段进行结构性更新),最多执行RETRIES_BEFORE_LOCK次,如果还不成功就在持有所有段锁的情况下再对所有段大小求和。在没有锁的情况下主要是利用Segment中的modCount进行检测,在遍历过程中保存每个Segment的modCount,遍历完成之后再检测每个Segment的modCount有没有改变,如果有改变表示有其它线程正在对Segment进行结构性并发更新,需要重新计算。
剩下的也没什么好说的了。主要掌握思想吧。。
1.8后concurrenthashmap的改变
首先是结构:
他已经跟hashmap没什么不同了,并发控制使用Synchronized和CAS来操作,整个看起来就像是优化过且线程安全的HashMap。这次就把锁的粒度进一步缩小了,它锁的是table的每一个链表的表头。(红黑树先不讨论,红黑树就是等链表长度超过8的时候就用红黑树来提高查询效率而已)
先不写了,下次把多线程都看完再写吧,写几个关键点先。
1.初始化,concurrenthashmap初始化是一个空的实现,一般都等到put的时候再初始化,懒汉式实现。当然ConcurrentHashMap还提供了其他的构造函数,有指定容量大小或者指定负载因子,跟HashMap一样。
2.Put的过程。
· 如果没有初始化就先调用initTable()方法来进行初始化过程
· 如果没有hash冲突就直接CAS插入
· 如果还在进行扩容操作就先进行扩容
· 如果存在hash冲突,就加锁来保证线程安全,这里有两种情况,一种是链表形式就直接遍历到尾端插入,一种是红黑树就按照红黑树结构插入,
· 最后一个如果Hash冲突时会形成Node链表,在链表长度超过8,Node数组超过64时会将链表结构转换为红黑树的结构,break再一次进入循环
· 如果添加成功就调用addCount()方法统计size,并且检查是否需要扩容
看这里,更详细
3.扩容
CAS过程
参考博客:https://www.sohu.com/a/314272265_120104204
为什么只有无冲突的时候才用了cas呢? 可能因为cas只能操作一个变量。预期值。
有点小难,慢慢看吧。