ConcurrentHashMap

目录

并发死链现象

我们首先先展示头插法,单线程下的扩容:

多线程下的扩容:

1.8下的ConcurrentHashMap

1.8ConcurrentHashMap源码分析:

构造方法: 

get方法:

put流程

size计算流程:

 扩容流程transfer方法:

JDK7 ConcurrentHashMap

JDK1.7和1.8下的ConcurrentHashMap区别

HashMap

ConcurrentHashMap:

构造方法提问: 为什么默认设置并发度为16(concurrencyLevel)?

get是加锁的吗?为啥不加?

讲讲put的流程

讲讲JDK1.7中ConcurrentHashMap底层原理

JDK1.7情况下如何保证线程安全的?

问:为啥ConcurrentHashMap不允许key,value是null?


与HashMap类似,由Segment,HashEntry组成,是数组+链表构成;

Segment是ConcurrentHashMap中的一个内部类:

原理:ConcurrentHashMap采用了分段锁技术,其中Segment(可以理解为一小段数组,里面含有n个HashEntry)继承ReentrantLock,但是put和get操作都会被同步处理——>每当一个线程占用锁访问Segment时,不会影响其他的Segment(可以理解为:有多把锁,一把锁锁住的临界区不会影响其他的地方)

 volatile修饰了HashEntry的数据和value,从而保证了多线程环境下数据的可见性;


问:分段锁Segment怎么被定位的?

通过key进行定位,准确来说与HashMap类似,通过hash值&数组长度-1确定位置,value值是被volatile关键字修饰的;

我们在对应的Segment可以进行put、get操作:

 1    public V put(K key, V value) { 2        Segment<K,V> s; 3        if (value == null) 4            throw new NullPointerException(); 5        int hash = hash(key); 6        int j = (hash >>> segmentShift) & segmentMask; 7        if ((s = (Segment<K,V>)UNSAFE.getObject          // nonvolatile; recheck 8             (segments, (j << SSHIFT) + SBASE)) == null) //  in ensureSegment 9            s = ensureSegment(j);10        return s.put(key, hash, value, false);11    }

因为value是被volatile关键字修饰的,所以并不能保证原子性——>所以put需要加锁;

扩容:

阈值是0.75,默认长度好似16,达到16*0.75=12,就会进行扩容;

注意:扩容以后,因为数组长度发生变化,所以元素的放入会发生改变->index=hash&length-1;

put流程:

Segment通过key的hash定位HashEntry,遍历HashEntry,判断传入的key与当前遍历的key是否相等,相等就覆盖旧的value,不为空就会创建一新的HashEntry加入到Segment中(并且会判断是否要扩容);


并发死链现象

桶下标index相同会产生链表——>才会产生如此现象,

我们首先先展示头插法,单线程下的扩容:

进行节点转移前,先吧数组长度进行扩容*2

节点(Entry)转移

void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;       //得到新数组的长度        
        for (Entry<K,V> e : table) {             //第一步:遍历整个数组对应下标下的链表,e代表一个节点
            while(null != e) {                   //当e==null时,则该链表遍历完了,继续遍历下一数组下标的链表 
                Entry<K,V> next = e.next;        //第二步:先把e节点的下一节点存起来
                if (rehash) {                    //得到新的hash值
                    e.hash = null == e.key ? 0 : hash(e.key);  
                }
                int i = indexFor(e.hash, newCapacity);      //在新数组下得到新的数组下标
                e.next = newTable[i];                       //第三步:将e的next指针指向新数组下标的位置
                newTable[i] = e;                            //第四步:将该数组下标的节点变为e节点
                e = next;                                   //第五步:遍历链表的下一节点
            }
        }
    }

在这里插入图片描述

首先e遍历,得到第一个节点,保存下一个节点;

在这里插入图片描述

e指向的节点指向新数组下标:

在这里插入图片描述

该数组下标变为e节点

在这里插入图片描述

然后遍历到链表的下一个节点

在这里插入图片描述

 按照以上步骤,我们可以将节点2和节点1都移到新的数组中,全部移到新数组后如图所示:

在这里插入图片描述

总结:会发现转移后元素的位置反了; 

多线程下的扩容:

我这里假设有两个线程分别为t1和t2,而且两个线程同时读到了需要扩容,这时候,就会导致会创建两个新的数组,而且同时要去操作这个老的数组的节点1,2,3,假设这时候两个线程都执行了第一步和第二步,这时候的状态如图所示:
在这里插入图片描述

2. 假设这时候操作系统的时间片没有分到t2线程,只有t1线程在运行,那么按照单线程情况下转移的过程得到t1线程完成转移后如图所示:

在这里插入图片描述

3、接下来是t2线程运行了,那么此时移动的便是t1线程新创建的表中的节点1,2,3;现在来详细说明一下这些步骤;

  • 1)上面讲了,t2线程已经完成了源码内容的第一步和第二步的操作,接下来进行第三步:将e2的next2指针指向t2线程创建的新数组下标的位置,如图所示:

在这里插入图片描述

 2)接下来执行第四步操作:将该数组下标的节点变为e2节点,如图所示:

在这里插入图片描述

 接下来执行第五步操作:遍历链表的下一节点,如图所示:

在这里插入图片描述

 接下来执行第三步操作,结果如图所示:(注意这里next指向了3

在这里插入图片描述

 接下来执行第四步操作,结果如图所示:(e2指向2,占据3的位置

在这里插入图片描述

 接下来执行第五步操作遍历下一节点(重点来了),如图所示:(发现又到了3

在这里插入图片描述

 总结:接下来也不用演示了,节点2和节点3会一直循环下去,可以导致程序一直执行,问题就很严重了,电脑直接卡死,可能还会抛出OutOfMemoryException;而且你们有没有发现,节点1给丢弃了,导致丢值的现象

JDK1.8的HashMap采用了尾插法避免了并发死链问题;


1.8下的ConcurrentHashMap

之前是头插法,后面是尾插;

结构与HashMap1.8下相同:数组+链表+红黑树

抛弃了原来的Segment分段锁,采用了CAS+synchronized

好处:synchronized有多种锁状态,进行优化,每个节点通过继承AQS同步器来保证同步支持;

1.8ConcurrentHashMap源码分析:

构造方法 

 initalCapacity:初始化大小;loadFactor:阈值; 

初始大小:1+初始大小/阈值

因为后面的hash算法需要是2的n次方,所以会经过tableSizeFor()将初始大小变为2的n次方;

——>所以我们看到的初始大小和我们设置的会不一样; 

get方法:

put流程

对比与HashMap,不有null的key和value

spread:保证数是正整数

如果哈希表没有创建或者长度为0,就会调用initTable来创建哈希表(底层调用CAS操作保证哈希表创建的安全,保证原子性)

casTabAt(tab,i,null,new Node()):给链表头部添加节点,本质就是CAS将null->new Node(),如果CAS失败就进入下一次循环;(头节点没下标进入这个分支)

 扩容情况:

tab=helpTransfer(tab,f):对哈希表进行扩容;

如果出现哈希冲突的时候进入最后一个场景,节点被synchronized上锁;

1.先判断有没有被移动节点:tabAt

2.判断链表长度是否>0,然后进行遍历,找链表中的key有没有一样的,如果不一样,就追加;

key一样会直接覆盖;

总得来说,就是先看链表长度,然后遍历链表,看key是否有一样的,有的话直接覆盖,(毕竟这里都是hash冲突的),所以如果说key不一样直接追加,如果链表长度>8,转为红黑树;

 如果链表的长度>8(树化阈值),链表被转为红黑树;

initTable创建哈希表:

创建哈希表的状态是sc值,sc为-1表示创建成功(红框字体为创建哈希表

本质也是CAS操作

其他线程在获取哈希表的时候,会进行判断sc的状态,如果已经创建了,就会进行Thread.yield(),当前线程谦让状态,但是并不会阻塞;

 addCount:本质就是利用LongAddr的思想进行计数

利用Cells和base

size计算流程:

size的计算实际发生在put和remove改变集合元素的操作中;

没有竞争发生,向baseCount累加计数

有竞争发生,新建counterCells,向其中的一个cell累加计数

1.counterCells初始有两个cell;

2.如果计数竞争比较激烈,会创建新的cell来累加计数;

 扩容流程transfer方法:

传入的旧新哈希表,有两种转移机制:根据hash值是否<0来判断->是根据普通的链表进行操作还是根据红黑树;


JDK7 ConcurrentHashMap

目的:用分段锁(类似多把锁)实现并发度,提高并发度(Segment分而治之)——>不同的线程用的是不一样的Segment(如果套上线程池,个人认为线程池中核心线程数也应该有要求);

每一个Segment有一个HashEntry(里面就跟Hash表一样:数组+链表)

缺点:Segments数组默认大小16,而且不是lazy,你一启动就会创建Segments,不是用到时才创建,而是直接创建,内存消耗较大;


JDK1.7和1.8下的ConcurrentHashMap区别

HashMap

在多线程高并发情况下会出现线程不安全的情况:

在jdk1.7,并发执行会出现并发死链现象和数据丢失现象;

jdk1.8会出现数据覆盖;

解决:使用HashTable保证线程安全,但是他是会给整个集合加锁,会导致同一时间其他线程阻塞;

ConcurrentHashMap:

对数据加了volatile关键字使所有数据在内存中都是可见的。

使用了Segment分段锁(里面是HashEntry)来进行 数组+链表。

Segment+HashEntry+ReentrantLock

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-H6DBphFE-1607670185963)(D:\software\typora\workplace\imgs_concurrentHashMap\1.png)]

 如果A线程Put,B线程Get也是可以的,相当于写读操作,是并发的;

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-z4eJlc1l-1607670185966)(D:\software\typora\workplace\imgs_concurrentHashMap\2.png)]

Put:当放入元素时,我们不是对整个HashMap直接就加值,而是先通过hash值确定在哪个Segment,然后对这个分段进行加锁(ReentrantLock),这样大大提高了高并发环境下的处理能力;

但是,要计算两次hash值,第二次要求Segment中的具体位置;

构造方法提问: 为什么默认设置并发度为16(concurrencyLevel)?

因为如果并发度过小,会导致严重的锁竞争(线程较多时);

较大的话,Segment的范围较小了,容易查到不同的Segment,(类似10个靶子突然变成100个靶子),更难筛选命中了,导致性能降低;

public ConcurrentHashMap(int initialCapacity,
                             float loadFactor, int concurrencyLevel) {
    if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
        throw new IllegalArgumentException();
    if (concurrencyLevel > MAX_SEGMENTS)
        concurrencyLevel = MAX_SEGMENTS;
    // 找的2的整数次幂
    int sshift = 0;  //ssize从1变成大于等于concurrencyLevel的2次幂需要左移的次数
    int ssize = 1;  
    //依据给定的concurrencyLevel并行度,找到最适合的segments数组的长度,
    // 该长度为大于concurrencyLevel的最小的2的n次方
    while (ssize < concurrencyLevel) {
        ++sshift;
        ssize <<= 1;
    }
    this.segmentShift = 32 - sshift;
    this.segmentMask = ssize - 1;
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
        
  
    int c = initialCapacity / ssize;
    //如果使用默认参数,也就是initialCapacity是16,concurrencyLevel是16,那么ssize也就是16,c是1,下面c++这句就不会执行
    if (c * ssize < initialCapacity)
        ++c;
    //最后计算出来的c相当于initialCapacity / ssize向上取整
    
     //cap是每个分段锁中HashEntry数组的长度
    int cap = MIN_SEGMENT_TABLE_CAPACITY
    while (cap < c)
        cap <<= 1;
    // 新建segments 数组,初始化 segments[0]
    Segment<K,V> s0 =
        new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
                         (HashEntry<K,V>[])new HashEntry[cap]);
    Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
    UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
    this.segments = ss;
}


get是加锁的吗?为啥不加?

其实就相当于ReentrantReadWriteLock中的读锁,所以是可以不用加锁的用一个Volatile就行保证读可见(用的UnsafegetObjectVolatile()读取HashEntry)->具有Volatile的意义,保证多线程环境下获取最新的Segment;

讲讲put的流程

首先是通过key得到hash值,确定Segment(尝试获取片段锁,如果没获取到就自旋),拿到锁后,然后再通过hash值确定对应的HashEntry,对链表进行遍历,如果说key相同,则覆盖,没有的话,会进行cas操作——>将null设置为new Node();

讲讲JDK1.7中ConcurrentHashMap底层原理

首先他是有两层数组实现的:Segment[]+HashEntry[];——>HashEntry里面就是HashTable(数组+链表);

Put:首先是通过key得到hash值,确定Segment(尝试获取片段锁,如果没获取到就自旋),拿到锁后,然后再通过hash值确定对应的HashEntry,对链表进行遍历,如果说key相同,则覆盖,没有的话,会进行cas操作——>将null设置为new Node();

JDK1.7情况下如何保证线程安全的?

主要利用Unsafe中CAS和getObjectVolatile(get方法时有利用保证共享资源的读可见)

+ReentrantLock+分段思想(提高并发度);

ReentrantLock体现在分段锁Segment上,Segment继承ReentrantLock;

然后HashMap是允许key,value为null的,但是ConcurrentHashMap不允许;

问:为啥ConcurrentHashMap不允许key,value是null?

因为在应用多线程环境下的时候,null值可能是他没被映射,本身就是null,但是还可能是被其他线程修改为null,所以不能实null值,无法确定;

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Fairy要carry

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值