高并发(六)--ConcurrentHashMap 源码解析

本文深入解析了Java并发编程中ConcurrentHashMap的实现原理,对比了HashMap与ConcurrentHashMap的区别,详细介绍了JDK1.7与JDK1.8中的不同实现方式,包括数据结构、分段锁机制、并发度、初始化过程、插入和删除操作的并发问题分析。文章探讨了JDK1.7中使用分段锁导致的效率问题以及JDK1.8中采用CAS和红黑树提升并发性能的改进。
摘要由CSDN通过智能技术生成

一. HashMapHashTableConcurrentHashMap的区别

  • HashMap是线程不安全的
  • HashTable是线程安全的,HashTable的线程安全是采用在每个方法上面都添加上了synchronized关键字来修饰,即HashTable是针对整个table的锁定。所有访问HashTable的线程都必须竞争同一把锁。当一个线程访问HashTable的同步方法的时候,其他线程再访问HashTable的同步方法的时候,可能会进入阻塞或者轮询状态。比如:线程1使用put方法来添加元素,线程2不但不能使用put方法添加元素,也不能使用get方法来获取元素,所以锁竞争越激烈,该类的效率越低。
  • ConcurrentHashMap使用分段锁技术ConcurrentHashMap是由多个Segment组成(Segment下包含了多个Node,即键值对),每个Segment都有把锁来实现线程安全,当一个线程占用锁访问其中一段数据的时候,其它段的数据也能被其他的线程访问。
    为什么HashTable
    HashTable之所以效率低下主要是因为其实现使用了synchronized关键字对put等操作进行加锁,而synchronized关键字加锁是对整个对象进行加锁,也就是说在进行put等修改Hash表的操作时,锁住了整个Hash表,从而使得其表现的效率低下。

二. JDK1.7中的ConcurrentHashMap

数据结构

jdk1.7中采用了segment+HashEntry的方式进行实现,使用了分段锁机制实现了ConcurrentHashMap
在这里插入图片描述
在这里插入图片描述

  • 一个ConcurrentHashMap中包含一个Segment<K,V>[] segments数组
  • 一个segment对象中包含一个HashEntry<K,V>[] table数组

1.7的ConcurrentHashMap是由Segment+HashEntry组成,和HashMap一样,仍然是数组+链表
ConcurrencyLevel:默认是16,也就是说ConcurrentHashMap有16个Segments,所以理论上,这个时候,最多可以同时支持16个线程并发写,只要他们的操作分别分布在不同的Segments上,Segments通过ReentrantLock来进行加锁。这个值在初始化的时候可以设置为其他值,但是一旦初始化后,它是不可以扩容的。

成员变量

ConcurrentHashMap在初始化的时候,计算出Segment数组的大小ssize和每个SegmentHashEntry数组的大小cap,并初始化Segment数组的第一个元素。

  • initialCapacity:初始容量,整个值指的是整个ConcurrentHashMap的初始容量,实际操作的时候需要平均分给每个Segment
  • ssize:大小为2的幂次方,默认为16.
  • cap:大小也为2的幂次方,最小值为2,最终结果根据初始化容量initialCapacity进行计算。

核心成员变量:
在这里插入图片描述
SegmentConcurrentHashMap的一个内部类,主要的组成结构如下:
在这里插入图片描述
首先通过Key根据hash算法定位到元素属于哪个Segment,之后在对应的Segment中进行具体的put操作。
其中HashEntry组成:
在这里插入图片描述
HashMap非常类似,唯一的区别就是其中的核心数据如value,以及链表都是volatile修饰的,保证了获取时的可见性

并发度(Concurrency Level)

并发度可以理解为程序运行时能够同时更新ConcurrentHashMap且不产生锁竞争的最大线程数,实际上就是ConcurrentHashMap中的分段锁的个数,即Segment[]的数组长度。ConcurrentHashMap默认的并发度是16,但用户也可以构造函数中设置并发度,当用户设置并发度的时候,ConcurrentHashMap会使用大于等于该值的最小2幂指数作为实际并发度(如果用户设置并发度是17,实际并发度则为32)。
运行时,通过将Key的高n位(n=32-segmentShift)和并发度-1做位与运算定位到所在的Segment

初始化

  • initialCapacity:初始容量,这个值指出的是整个CocurrentHashMap的初始容量,实际操作的时候需要平均分给每个segment
  • loadFactor:负载因子,segment数组不可以扩容,所以这个负载因子是给每个segment内部使用的。
    注意点:JDK7中除了第一个segment之外,剩余的segments采用的是延迟初始化的机制:每次put之前都要检查key对应的segment是否为null,如果是则调用ensureSegment()以确保对应的segment被创建。
    在这里插入图片描述
    初始化完成,我们可以得到一个Segment数组:
    初始化过程一共会做如下几件事:
  • Segment数组的长度为16,在初始化的时候可以指定长度,但是在初始化结束后,不可以修改这个长度。
  • Segment[i]的默认大小为2,即初始一个Segment只能装入一个HashEntry,负载因子为0.75,初始阈值为1.5,即在插入第一个元素的时候不会触发扩容,插入第二个元素的时候就会进行第一次扩容。
  • 初始化了Segment[0],其他位置的Segment数组没有初始化
  • 参数的相关说明:
    (1)ssizeSegment数组的长度
    (2)sshift:等于ssize从1左移的次数
    (3)segmentShift:用于定位参与散列运算的位数,移位数
    (4)segmentMask:散列运算的掩码
    (5)capSegment里面HashEntry数组的长度

put过程分析

在这里插入图片描述
根据hash值找到对应的SegmentSegment的数组下标是hash值的最后四位。首先是通过key定位到Segment,之后在对应的Segment中进行具体的put操作。Segment内部是由数组+链表组成的。
在这里插入图片描述
虽然HashEntry中的value是用volatile关键字修饰的,但是并不能保证并发的原子性,所以put操作是需要加锁的操作,进到put方法中首先要尝试加锁,如【第373行】代码所示。
三个关键方法

  • ensureSegment方法
    这个是【第1106行】的方法。ConcurrentHashMap初始化的时候会初始化第一个槽segment[0],对于其他槽来说,在插入第一个值的时候进行初始化,这里需要考虑并发,因为很可能会有多个线程同时初始化同一个槽segment[k],不过只要有一个初始化成功就可以了,对于并发操作,使用CAS进行控制。
    在这里插入图片描述
  • scanAndLockForPut方法
    获取写入锁,首先第一步的时候尝试获取锁,如果获取失败肯定就有其他线程存在竞争,则利用scanAndLockForPut自旋获取锁。

private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
   
    HashEntry<K,V> first = entryForHash(this, hash);
    HashEntry<K,V> e = first;
    HashEntry<K,V> node = null;
    int retries = -1; // negative while locating node

    // 循环获取锁
    while (!tryLock()) {
   
        HashEntry<K,V> f; // to recheck first below
        if (retries < 0) {
   
            if (e == null) {
   
                if (node == null) // speculatively create node
                    // 进到这里说明数组该位置的链表是空的,没有任何元素
                    // 当然,进到这里的另一个原因是 tryLock() 失败,所以该槽存在并发,不一定是该位置
                    node = new HashEntry<K,V>(hash, key, value, null);
                retries = 0;
            }
            else if (key.equals(e.key))
                retries = 0;
            else
                // 顺着链表往下走
                e = e.next;
        }
        // 重试次数如果超过 MAX_SCAN_RETRIES(单核1多核64),那么不抢了,进入到阻塞队列等待锁
        //    lock() 是阻塞方法,直到获取锁后返回
        else if (++retries > MAX_SCAN_RETRIES) {
   
            lock();
            break;
        }
        else if ((retries & 1) == 0 &&
                 // 这个时候是有大问题了,那就是有新的元素进到了链表,成为了新的表头
                 //     所以这边的策略是,相当于重新走一遍这个 scanAndLockForPut 方法
                 (f = entryForHash(this, hash)) != first) {
   
            e = first = f; // re-traverse if entry changed
            retries = -1;
        }
    }
    return node;
}
  

scanAndLockForPut自旋获取锁的过程:

  • 尝试自旋获取锁
  • 如果重试的次数达到了MAX_SCAN_RETRIES则改为阻塞锁获取&#x
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值