【并发编程】深入解析CurrentHashmap

本文涉及的产品
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
全局流量管理 GTM,标准版 1个月
云解析 DNS,旗舰版 1个月
简介: 【并发编程】深入解析CurrentHashmap

为什么要用CurrentHashmap

我们大家都知道,HashMap是线程不安全的,那么为了解决HashMap线程不安全有很多方案,比如

  • 使用Collections.synchronizedMap(Map)创建线程安全的map集合;
  • Hashtable
  • ConcurrentHashMap

Collections.synchronizedMap(Map)的方法,很明显都加了synchronized来保证线程安全

Hashtable是给所有方法增加synchronized,这样牺牲了并发,效率并不高,于是出现了ConcurrentHashMap,synchronized+CAS去实现线程安全,效率更高一些

环境

本文的CurrentHashmap基于JDK1.8来讲述;

CurrentHashmap在JDK1.7和JDK1.8有些差别

  • 在JDK1.7中ConcurrentHashMap采用了数组+Segment+分段锁的方式实现。
  • JDK1.8中是数组+链表,链表在数据过多时转为红黑树

存储结构图

CurrentHashmap的结构图,数组+链表,链表在数据过多时转为红黑树

  • 负载因子75%,就是说元素个数超过数组长度的0.75时就会发生扩容
  • 链表上的元素超过7时就会转成红黑树(平衡二叉树存储)

扩容

为什么要扩容?

由上图分析,当数组保存的链表越来越多时,新来的数据大概率会被保存在现有的链表中,随着链表越来越长,哈希查找的速度逐渐由O(1)趋向O(n/2),

所以就要进行扩容,在数组的元素个数超过0.75时进行扩容,才能有效解决这个问题。

另外 ConcurrentHashMap 还会有链表转红黑树的操作,以提高查找的速度,红黑树时间复杂度为 O (logn),而链表是 O (n/2),因此只在 O (logn)<O (n/2) 时才会进行转换,也就是以 8 作为分界点。

ConcurrentHashMap 1.7 源码解析

ConcurrentHashMap 的一些成员变量

//默认初始化容量,这个和 HashMap中的容量是一个概念,表示的是整个 Map的容量
static final int DEFAULT_INITIAL_CAPACITY = 16;

//默认加载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;

//默认的并发级别,这个参数决定了 Segment 数组的长度
static final int DEFAULT_CONCURRENCY_LEVEL = 16;

//最大的容量
static final int MAXIMUM_CAPACITY = 1 << 30;

//每个Segment中table数组的最小长度为2,且必须是2的n次幂。
//由于每个Segment是懒加载的,用的时候才会初始化,因此为了避免使用时立即调整大小,设定了最小容量2
static final int MIN_SEGMENT_TABLE_CAPACITY = 2;

//用于限制Segment数量的最大值,必须是2的n次幂
static final int MAX_SEGMENTS = 1 << 16; // slightly conservative

//在size方法和containsValue方法,会优先采用乐观的方式不加锁,直到重试次数达到2,才会对所有Segment加锁
//这个值的设定,是为了避免无限次的重试。后边size方法会详讲怎么实现乐观机制的。
static final int RETRIES_BEFORE_LOCK = 2;

//segment掩码值,用于根据元素的hash值定位所在的 Segment 下标。后边会细讲
final int segmentMask;

//和 segmentMask 配合使用来定位 Segment 的数组下标,后边讲。
final int segmentShift;

// Segment 组成的数组,每一个 Segment 都可以看做是一个特殊的 HashMap
final Segment<K,V>[] segments;

//Segment 对象,继承自 ReentrantLock 可重入锁。
//其内部的属性和方法和 HashMap 神似,只是多了一些拓展功能。
static final class Segment<K,V> extends ReentrantLock implements Serializable {
 
 //这是在 scanAndLockForPut 方法中用到的一个参数,用于计算最大重试次数
 //获取当前可用的处理器的数量,若大于1,则返回64,否则返回1。
 static final int MAX_SCAN_RETRIES =
  Runtime.getRuntime().availableProcessors() > 1 ? 64 : 1;

 //用于表示每个Segment中的 table,是一个用HashEntry组成的数组。
 transient volatile HashEntry<K,V>[] table;

 //Segment中的元素个数,每个Segment单独计数(下边的几个参数同样的都是单独计数)
 transient int count;

 //每次 table 结构修改时,如put,remove等,此变量都会自增
 transient int modCount;

 //当前Segment扩容的阈值,同HashMap计算方法一样也是容量乘以加载因子
 //需要知道的是,每个Segment都是单独处理扩容的,互相之间不会产生影响
 transient int threshold;

 //加载因子
 final float loadFactor;

 //Segment构造函数
 Segment(float lf, int threshold, HashEntry<K,V>[] tab) {
  this.loadFactor = lf;
  this.threshold = threshold;
  this.table = tab;
 }
 
 ...
 // put(),remove(),rehash() 方法都在此类定义
}

// HashEntry,存在于每个Segment中,它就类似于HashMap中的Node,用于存储键值对的具体数据和维护单向链表的关系
static final class HashEntry<K,V> {
 //每个key通过哈希运算后的结果,用的是 Wang/Jenkins hash 的变种算法,此处不细讲,感兴趣的可自行查阅相关资料
 final int hash;
 final K key;
 //value和next都用 volatile 修饰,用于保证内存可见性和禁止指令重排序
 volatile V value;
 //指向下一个节点
 volatile HashEntry<K,V> next;

 HashEntry(int hash, K key, V value, HashEntry<K,V> next) {
  this.hash = hash;
  this.key = key;
  this.value = value;
  this.next = next;
 }
}

可以看出1.7的1.8的区别并不大,默认容量都是16,负载因子0.75等等,区别在于1.7多了一个Segment分段锁,1.8锁的是Node,锁的粒度变小,并发性提高冲突变少

put方法

//这是Map的put方法
public V put(K key, V value) {
 Segment<K,V> s;
 //不支持value为空
 if (value == null)
  throw new NullPointerException();
 //通过 Wang/Jenkins 算法的一个变种算法,计算出当前key对应的hash值
 int hash = hash(key);
 //上边我们计算出的 segmentShift为28,因此hash值右移28位,说明此时用的是hash的高4位,
 //然后把它和掩码15进行与运算,得到的值一定是一个 0000 ~ 1111 范围内的值,即 0~15 。
 int j = (hash >>> segmentShift) & segmentMask;
 //这里是用Unsafe类的原子操作找到Segment数组中j下标的 Segment 对象
 if ((s = (Segment<K,V>)UNSAFE.getObject          // nonvolatile; recheck
   (segments, (j << SSHIFT) + SBASE)) == null) //  in ensureSegment
  //初始化j下标的Segment
  s = ensureSegment(j);
 //在此Segment中添加元素
 return s.put(key, hash, value, false);
}

ConcurrentHashMap 1.8 源码解析

put方法的源码

可以看出,实际上调用的是 putVal 方法,第三个参数传入 false,控制 key 存在时覆盖原来的值。

putVal方法的源码

/** Implementation for put and putIfAbsent */
    final V putVal(K key, V value, boolean onlyIfAbsent) {
      // 判断要添加的key或者value是否为空,为空抛出异常
        if (key == null || value == null) throw new NullPointerException();
        // key的hashCode算出hash值
        int hash = spread(key.hashCode());
        int binCount = 0;
        
        for (Node<K,V>[] tab = table;;) {
            Node<K,V> f; int n, i, fh;
            // 如果table为空,那么就调用initTable初始化table
            if (tab == null || (n = tab.length) == 0)
              //  如果table为空,那么就调用initTable初始化table
                tab = initTable();
                //通过hash 值计算table 中的索引,如果该位置没有数据,则可以put  
                // tabAt方法以volatile读的方式读取table数组中的元素 
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
               //casTabAt方法以CAS的方式,将元素插入table数组
                if (casTabAt(tab, i, null,
                             new Node<K,V>(hash, key, value, null)))
                    break;                   // no lock when adding to empty bin
            }
            // 如果table位置上的节点状态时MOVE,则表明hash 正在进行扩容搬移数据的过程中
            else if ((fh = f.hash) == MOVED)
                tab = helpTransfer(tab, f);// 帮助扩容
            // hash 表该位置上有数据,可能是链表,也可能是一颗树
            else {
                V oldVal = null;
                synchronized (f) {
                // 上锁后,只有再该位置数据和上锁前一致才进行,否则需要重新循环
                    if (tabAt(tab, i) == f) {
                     // hash 值>=0 表明这是一个链表结构
                        if (fh >= 0) {
                            binCount = 1;
                            for (Node<K,V> e = f;; ++binCount) {
                                K ek;
                                // 存在一样的key就覆盖它的value
                                if (e.hash == hash &&
                                    ((ek = e.key) == key ||
                                     (ek != null && key.equals(ek)))) {
                                    oldVal = e.val;
                                    if (!onlyIfAbsent)
                                        e.val = value;
                                    break;
                                }
                                // 不存在该key,将新数据添加到链表尾
                                Node<K,V> pred = e;
                                if ((e = e.next) == null) {
                                    pred.next = new Node<K,V>(hash, key,
                                                              value, null);
                                    break;
                                }
                            }
                        }
                        // 该位置是红黑树,是TreeBin对象(注意是TreeBin,而不是TreeNode)
                        else if (f instanceof TreeBin) {
                            Node<K,V> p;
                            binCount = 2;
                            //通过TreeBin 中的方法,将数据添加到红黑树中
                         
                            if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                                           value)) != null) {
                                oldVal = p.val;
                                if (!onlyIfAbsent)
                                    p.val = value;
                            }
                        }
                    }
                }
                if (binCount != 0) {
                   //if 成立,说明遍历的是链表结构,并且超过了阀值,需要将链表转换为树
                    if (binCount >= TREEIFY_THRESHOLD)
                        treeifyBin(tab, i);//将table 索引i 的位置上的链表转换为红黑树
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }
        }
        // ConcurrentHashMap 容量增加1,检查是否需要扩容
        addCount(1L, binCount);
        return null;
    }


目录
相关文章
|
4月前
|
缓存 Java 调度
Java并发编程:深入解析线程池与Future任务
【7月更文挑战第9天】线程池和Future任务是Java并发编程中非常重要的概念。线程池通过重用线程减少了线程创建和销毁的开销,提高了资源利用率。而Future接口则提供了检查异步任务状态和获取任务结果的能力,使得异步编程更加灵活和强大。掌握这些概念,将有助于我们编写出更高效、更可靠的并发程序。
|
9天前
|
安全 测试技术 Go
Go语言中的并发编程模型解析####
在当今的软件开发领域,高效的并发处理能力是提升系统性能的关键。本文深入探讨了Go语言独特的并发编程模型——goroutines和channels,通过实例解析其工作原理、优势及最佳实践,旨在为开发者提供实用的Go语言并发编程指南。 ####
|
5月前
|
算法 Java 调度
《面试专题-----经典高频面试题收集四》解锁 Java 面试的关键:深度解析并发编程进阶篇高频经典面试题(第四篇)
《面试专题-----经典高频面试题收集四》解锁 Java 面试的关键:深度解析并发编程进阶篇高频经典面试题(第四篇)
71 0
|
1月前
|
数据处理 Python
深入探索:Python中的并发编程新纪元——协程与异步函数解析
深入探索:Python中的并发编程新纪元——协程与异步函数解析
26 3
|
4月前
|
安全 Java 数据处理
Java并发编程:线程同步与协作的深度解析
在探索Java并发编程的海洋中,线程同步与协作的灯塔指引着航向。本文将深入挖掘线程同步机制的核心原理,揭示锁、条件变量等工具如何确保数据的一致性和线程间有序的通信。通过案例分析,我们将解码高效并发模式背后的设计哲学,并探讨现代Java并发库如何简化复杂的同步任务。跟随文章的步伐,您将获得提升多线程应用性能与可靠性的关键技能。 【7月更文挑战第24天】
45 5
|
4月前
|
数据处理 Python
深入探索:Python中的并发编程新纪元——协程与异步函数解析
【7月更文挑战第15天】Python 3.5+引入的协程和异步函数革新了并发编程。协程,轻量级线程,由程序控制切换,降低开销。异步函数是协程的高级形式,允许等待异步操作。通过`asyncio`库,如示例所示,能并发执行任务,提高I/O密集型任务效率,实现并发而非并行,优化CPU利用率。理解和掌握这些工具对于构建高效网络应用至关重要。
48 6
|
4月前
|
监控 Java API
Java并发编程之线程池深度解析
【7月更文挑战第14天】在Java并发编程领域,线程池是提升性能、管理资源的关键工具。本文将深入探讨线程池的核心概念、内部工作原理以及如何有效使用线程池来处理并发任务,旨在为读者提供一套完整的线程池使用和优化策略。
|
5月前
|
缓存 Java 编译器
必知的技术知识:Java并发编程:volatile关键字解析
必知的技术知识:Java并发编程:volatile关键字解析
26 0
|
5月前
|
安全 Java API
《面试专题-----经典高频面试题收集三》解锁 Java 面试的关键:深度解析并发编程基础篇高频经典面试题(第三篇)
《面试专题-----经典高频面试题收集三》解锁 Java 面试的关键:深度解析并发编程基础篇高频经典面试题(第三篇)
40 0
|
5月前
|
缓存 并行计算 安全
【并发编程系列一】并发编年史:线程的双刃剑——从优势到风险的全面解析
【并发编程系列一】并发编年史:线程的双刃剑——从优势到风险的全面解析

推荐镜像

更多