首页 > 其他分享 >ConcurrentHashMap从入门到入睡

ConcurrentHashMap从入门到入睡

时间:2023-12-25 17:32:37浏览次数:39  
标签:ConcurrentHashMap 入门 int 入睡 并发 线程 null Segment

ConcurrentHashMap

为什么使用ConcurrentHashMap

前文提到,HashMap无论任何版本都是线程不安全的

但 Hashtable 会给整张表加悲观锁,仅允许单个线程独占,效率低下。

synchronizedMap加入了互斥锁 mutex ,在方法上加上 synchronized,效率同样不高。

所以需要更低粒度的锁以换取更好的并发性能。

ConcurrentHashMap不允许null

无论是键还是值,ConcurrentHashMap都不允许使用null。

final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) throw new NullPointerException();
    ....
}

为什么不能为null

HashMap的二义性。

public class Test {
    public static void main(String[] args) {
        Map<String, String> map = new HashMap<>();
        map.put("erick", null);
        System.out.println(map.get("erick")); // -> null
        System.out.println(map.get("yunliyunwai")); // -> null
    }
}

由此可知,此时两个返回虽然都是null,但实际上两种结果所代表的意义完全不同。

前一个代表值为null,后一个则是没有该键。

ConcurrentHashMap 作者 Doug Lea 认为在并发条件下这种二义性是无法容忍的。

数据结构

JDK1.7

在JDK1.7,HashMap主要使用数组+链表方式实现。

ConcurrentHashMap主要采用Segment分段锁的形式实现。

Segment分段锁继承自ReentrantLock,可以最高同时支持 Segment 数量大小的写操作(即写入操作完全平分在每个Segment上)。

1.jpg

在并发时,对每个Segment加锁,不允许对其进行非查询操作。

每个Segment包含若干链表。

简单来说,每个Segment就是分割的"小HashMap"。

相关参数
/**
 * 默认的并发度,这里所谓的并发度就是能同时操作ConcurrentHashMap的线程的最大数量,
 * 由于ConcurrentHashMap采用的存储是分段存储,即多个Segment,加锁的单位为Segment,所以一个map的并行度就是Segments数组的长度,
 * 故在构造函数里指定并发度时同时会影响到Segments数组的长度,因为数组长度必须是大于并行度的最小的2的幂。
 */
static final int DEFAULT_CONCURRENCY_LEVEL = 16;

/**
 * 每个Segment最小容量
 */
static final int MIN_SEGMENT_TABLE_CAPACITY = 2;

/**
 * 每个Segment最大的容量
 */
static final int MAX_SEGMENTS = 1 << 16;

/**
 * 默认自旋次数,超过这个次数直接加锁,防止在size方法中由于不停有线程在更新map
 * 导致无限的进行自旋影响性能,当然这种会导致ConcurrentHashMap使用了这一规则的方法
 * 如size、clear是弱一致性的。
 */
static final int RETRIES_BEFORE_LOCK = 2;

在加锁时,首先尝试对Segment加锁,如果失败,则通过自旋方式获取锁。

如果重试次数达到MAX_SCAN_RETRIES则改为阻塞获取锁。

static final class HashEntry<K,V>{
    final int hash;
    final K key;
    volatile V value;
    volatile HashEntry<K,V> next;
}

其中,HashEntry中值与下一节点被volatile修饰,保证了多线程环境下数据获取时的可见性

JDK1.8

在JDK1.8下,HashMap采用数组+链表+红黑树的结构。ConcurrentHashMap在原有基础上,抛弃了Segement分段锁思路,采用CAS+synchronized实现更低粒度的锁。

2.jpg

加锁时会锁住头结点

3.jpg

锁头结点的优势是,理论上可以最高同时支持头节点数量大小的写操作(即写入操作完全平分在每个链表或红黑树上)。

为什么抛弃重入锁ReentrantLock
  • 减少内存开销:如果使用ReentrantLock则需要节点继承AQS(AbstractQueuedSynchronizer)来获得同步支持,增加内存开销,而1.8中只有头节点需要进行同步。
  • 内部优化:synchronized则是JVM直接支持的,JVM能够在运行时作出相应的优化措施:锁粗化锁消除锁自旋等等。

并发度

并发度是什么

简单来说,并发度是程序运行时能够同时更新ConccurentHashMap且不产生锁竞争的最大线程数

当并发度过小,则会导致严重的锁竞争,而并发度过大,会导致CPU缓存命中率下降,影响性能。

JDK1.7

理论上来说,JDK1.7下,每个Segment加分段锁,并发度即为Segment数量,默认为16。

// 默认并发度
static final int DEFAULT_CONCURRENCY_LEVEL = 16;

用户也可以在构造函数中设置并发度。当用户设置并发度时,ConcurrentHashMap会使用大于等于该值的最小2幂指数作为实际并发度(假如用户设置并发度为17,实际并发度则为32)。

JDK1.8

理论上来说,JDK1.8下,每个头结点加锁,并发度即为头节点数量。

即:ConcurrentHashMap的容量。

ConcurrentHashMap是弱一致的

CAS(Compare and swap:比较与交换)

CAS是对乐观锁的一种实现,假设要改变的值为V,预期为E,更新后的值应为U。则当且仅当V==U时,才会将V更新为E。整个操作过程不需要加锁,比加锁的效率更高。

初始化并发处理

JDK1.7

由源码:

private Segment<K,V> ensureSegment(int k) {
    final Segment<K,V>[] ss = this.segments;
    long u = (k << SSHIFT) + SBASE;
    Segment<K,V> seg;
    if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
        // 检查该Segment是否被其他线程初始化
        Segment<K,V> proto = ss[0];
        int cap = proto.table.length;
        float lf = proto.loadFactor;
        int threshold = (int)(cap * lf);
        HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
        // 并发初始化核心
        if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) { 
		   // 再次检查一遍该Segment是否被其他线程初始化
            Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
            // 使用 while 循环,内部用 CAS,当前线程成功设值或其他线程成功设值后,退出
            while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
                if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
                    break;
            }
        }
    }
    return seg;
}

可知:JDK1.7对于并发操作使用 CAS 进行控制。

JDK1.8

源码:

private final Node<K,V>[] initTable() {
    Node<K,V>[] tab; int sc;
    while ((tab = table) == null || tab.length == 0) {
        // 如果 sizeCtl < 0 ,说明由其他的线程执行 CAS 成功,正在进行初始化。
        if ((sc = sizeCtl) < 0)
            // 操作线程礼让
            Thread.yield();
        else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
            try {
                if ((tab = table) == null || tab.length == 0) {
                    int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                    @SuppressWarnings("unchecked")
                    Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                    table = tab = nt;
                    sc = n - (n >>> 2);
                }
            } finally {
                sizeCtl = sc;
            }
            break;
        }
    }
    return tab;
}

在JDK1.8中,初始化是通过自旋和 CAS 操作完成的。

需要注意 sizeCtl 的值,它决定当前的初始化状态。

  • -1 说明正在初始化
  • -N 说明有 N-1 个线程正在进行扩容
  • 0 表示 table 初始化大小,
  • > 0 表示 table 扩容的阈值。

get方法

在ConcurrentHashMap中,get方法比较简单,主要是根据 key 计算下标,然后通过链表或红黑树遍历即可。

需要注意的是 get 方法不需要加锁,它不对数据做任何更改。

扩容方式

在JDK1.8下,ConcurrentHashMap的扩容分两步:

  • 构建一个 nextTable,它的容量是原来的两倍,这个操作单线程完成。新建 table 数组的代码为:Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1],在原容量大小的基础上右移一位。
  • 将原来 table 中的元素复制到 nextTable 中。通过 rehash, 旧数组里的数据移动到新的数组时,位置要么不变,要么变为 index + oldSize
int n = tab.length;
int b = p.hash & n;

在这其中,核心是 forwardingNode 占位符,来确认节点是否被处理过,以此来控制并发有序。

在目前实现方式下ConcurrentHashMap的缺点

ConcurrentHashMap 是设计为非阻塞的。

在更新时会局部锁住某部分数据,但不会把整个表都锁住。

读取操作是完全非阻塞的,这代表读取操作不能保证反映最近的更新

一个线程put了大量数据,期间另一个线程是只能得到当前已经插入成功的数据。

标签:ConcurrentHashMap,入门,int,入睡,并发,线程,null,Segment
From: https://blog.51cto.com/ErickRen/8970518

相关文章

  • 网络入门初学第三期
    本期重点围绕网络基础协议进行简单讲解回顾一下上期,我们讲解了各个网络设备的工作原理那么本期就是在解决我们网络设备直接所存在的问题,和拓展的功能举个例子,我们一开始手机只作为可以通讯的要求,后面因为时代需求,手机内部有了很多的功能可以拍照、可以玩游戏等等你也可以这......
  • winforms入门简介
    原文链接:https://upimg.baike.so.com/doc/9995803-10343583.htmlwinforms脚本都是基于c#,winforms是做客户端软件,WinForm是.Net开发平台中对WindowsForm的一种称谓。简单来说:WinForms和ASP.NET的平台支持C#和VB.NET编程语言。WinForms是做客户端软件,ASP.NET是基于网络开发的......
  • C++U3-第06课-算法入门
    学习目标求和符号  连乘符号  指数     对数    算法概念与复杂度计算           vector向量容器      遍历 【思路分析】1、定义vector容器和变量n2、输入n3、输入n个......
  • 华为交换机最简单的入门配置
    一、配置vlan先来温习下华为交换机的基础配置命令。对于一个企业网络,通常需要根据部分划分不同的VLAN,用于隔离广播域和网络隔离。首先配置交换机全局的VLAN,根据端口所连接的终端,给端口分配不同的VLAN,端口类型配置成access模式;交换机和交换机之间的互联端口配置成trunk模式,并且允许......
  • FineReport 11.0参数查询入门示例操作记录
    参数的主要作用是实现用户与数据的实时交互,即进行数据的过滤。我们可以在很多情况下使用参数,比如在单元格中引用参数来实现动态标题、根据参数值的不同显示不同值等等。如下图所示:links:https://help.fanruan.com/finereport/doc-view-166.html?source=0&from=base......
  • Kernel Memory 入门系列:生成并获取文档摘要
    KernelMemory入门系列:生成并获取文档摘要前面在RAG和文档预处理的流程中,我们得到一个解决方案,可以让用户直接获取最终的问题答案。但是实际的业务场景中,仍然存在一些基础的场景,不需要我们获取文档的所有详情的,而只是了解的文档的大概信息,得到文章整体的摘要或者总结,此时仍然可......
  • kettle从入门到精通 第二十六课 再谈 kettle Transformation executor
     1、前面文章有学习过Transformationexecutor ,但后来测试kettle性能的时候遇到了很大的问题,此步骤的处理性能太慢,导致内存溢出等问题。所以再次一起学习下此步骤的用法。 2、 如下图中rds-sametable-同步逻辑处理使用的是Transformationexecutor步骤,最后Speed列表示处理速......
  • Cesium基础入门教程
    Cesium中的坐标系以及坐标转换Cesium中常用的坐标Cesium中坐标转换经纬度坐标转换成世界坐标两种方式将经纬度转换成世界坐标1.直接转换varcartesian3=Cesium.Cartesian3.fromDegrees(lng,lat,height);2.借助ellipsoid对象,先转换成弧度再进行转换varcartograp......
  • 深度学习从入门到不想放弃-4
    最近再玩SuperMarioBros Wonder,真的好玩,我给9分,强烈推荐   今天继续,其实断更以后我都忘记我上次讲到哪了,现回去查的前一篇文章再讲什么...    今天的内容是导数,大家都学过,我就简单给过一过了,不细讲了    先说微积分的由来:       上面这个图,是......
  • 人工智能入门实战:人工智能在教育的应用
    1.背景介绍人工智能(ArtificialIntelligence,AI)是一门研究如何让计算机模拟人类智能的学科。人工智能的主要目标是让计算机能够理解自然语言、学习从经验中、推理、解决问题、认识自身以及与人类互动。人工智能的应用在各个领域中都有着广泛的发展,教育领域不例外。教育领域中的人......