Map集合之ConcurrentHashMap

本文详细对比了HashMap与ConcurrentHashMap的区别,深入剖析了ConcurrentHashMap的版本变化、原理、数据结构以及源码细节,探讨了为何在多线程环境下选择ConcurrentHashMap的原因,并总结了其在并发控制上的优化策略。
摘要由CSDN通过智能技术生成

HashtableHashMap 的比较

  • 底层的数据结构:Hashtable 是数组 + 链表,而 HashMap 是数组 + 链表 + 红黑树
  • 默认的初始容量:Hashtable11,而 HashMap16
  • 扩容大小:Hashtable 扩容后的大小为原来的 2+ 1,而 HashMap 是原来大小的 2
  • 数组的懒加载:Hashtable 在初始化时就创建了数组,HashMap 对底层数组采取的懒加载,即当执行第一次插入时才会创建数组
  • 键和值是否允许为 nullHashtable 不允许,HashMap 中键和值均允许为 null
  • 线程安全:Hashtable 是线程安全的,而 HashMap 是线程不安全的

关于 HashMap 为什么是线程不安全的,可以查看:https://blog.csdn.net/weixin_38192427/article/details/108478615

查看 Hashtable 的源码,可以看到 Hashtable 处理线程安全问题过于简单粗暴,是将所有的方法都加上了 synchronized 关键字,在竞争激烈的并发场景中性能就会非常差。鉴于这个问题在 jdk 1.5 时,增加了 ConcurrentHashMap 这个类,在并发情况下保证了线程安全,同时提供了更高的并发效率

ConcurrentHashMap 概述

  • ConcurrentHashMap 是线程安全的
  • ConcurrentHashMap 不允许为 nullkey 或者 value
  • 底层的数据结构:数组 + 单链表 + 红黑树
  • 默认的初始容量:和 HashMap 相同都是 16
  • 创建数组时机:当执行第一次插入时才会创建数组

jdk1.7 版本中

使用了分段锁技术:容器中有多把锁,每一把锁锁一段数据,这样在多线程访问时不同段的数据时,就不会存在锁竞争了,这样便可以有效地提高并发效率

这就是 ConcurrentHashMap 所采用的分段锁思想,见下图:
在这里插入图片描述

  • 主要使用的是 Segment 分段锁
  • 内部拥有一个 Entry 数组,每个数组的每个元素又有一个链表
  • 同时 Segment 继承 ReetrantLock(独占锁或互斥锁) 来进行加锁
  • 默认 Segment16 个,也就是说可以支持 16 个线程的并发,在初始化是可以进行设置,一旦初始化就无法修改(Segment 不可扩容),但是 Segment 内部的 Entry 数组是可扩容的

jdk1.8 版本中

在这里插入图片描述

  • 摒弃了分段锁的概念,使用 CAS + Synchronized + volatile 代替 Segment
  • 对于锁的粒度,调整为对每个数组元素加锁
  • 如果没有 hash 冲突,就直接 CAS 插入
  • 如果产生 hash 冲突时,先使用 synchronized 加锁,在锁的内部处理 hash 冲突与 HashMap 是相同的,即使用 keyequals() 方法进行比较
  • 内部的数据结构和 HashMap 相同,使用数组 + 链表 + 红黑树

ConcurrentHashMap 的原理概览

ConcurrentHashMap 中通过一个 Node<K,V>[] 数组来保存添加到 map 中的键值对,而在同一个数组位置是通过链表和红黑树的形式来保存的。但是这个数组只有在第一次添加元素的时候才会初始化,否则只是初始化一个 ConcurrentHashMap 对象的话,只是设定了一个 sizeCtl 变量,这个变量用来判断对象的一些状态和是否需要扩容

  • 第一次添加元素的时候,默认初始长度为 16,当往 map 中继续添加元素的时候,通过 hash 值跟数组长度取与来决定放在数组的哪个位置,如果出现放在同一个位置的时候,优先以链表的形式存放,在同一个位置的个数又达到了 8 个以上,如果数组的长度还小于 64 的时候,则会扩容数组。如果数组的长度大于等于 64 了的话,在会将该节点的链表转换成树
  • 通过扩容数组的方式来把这些节点给分散开。然后将这些元素复制到扩容后的新的数组中,同一个链表中的元素通过 hash 值的数组长度位来区分,是还是放在原来的位置还是放到扩容的长度的相同位置去。在扩容完成之后,如果某个节点的是树,同时现在该节点的个数又小于等于 6 个了,则会将该树转为链表
  • 取元素的时候,相对来说比较简单,通过计算 hash 值来确定该元素在数组的哪个位置,然后在通过遍历链表或树来判断 keykeyhash 值,取出 value

ConcurrentHashMap 源码

ConcurrentHashMap 的基本属性

注意下面几个属性使用了 volatile 修饰,保证了元素在并发情况下的可见性

// node 数组最大容量:2^30=1073741824
private static final int MAXIMUM_CAPACITY = 1 << 30;

// 默认初始容量,也可以指定,必须是2的幂次方
private static final int DEFAULT_CAPACITY = 16;

// 并发级别,这是JDK1.7遗留下来的,为兼容以前的版本
private static final int DEFAULT_CONCURRENCY_LEVEL = 16;

// 加载因子,默认0.75
private static final float LOAD_FACTOR = 0.75f;

// 链表红黑树的阈值,当存储数据之后,当链表长度大于 8 时,则将链表转换成红黑树
static final int TREEIFY_THRESHOLD = 8;

/**
 * 红黑树还原为链表的阈值,当在扩容时,resize()方法的split()方法中使用到该字段
 * 在重新计算红黑树的节点存储位置后,当拆分成的红黑树链表内节点数量 小于等于6 时,则将红黑树节点链表转换成普通节点链表。
 * <p>
 * 该字段仅仅在split()方法中使用到,在真正的remove删除节点的方法中时没有用到的,实际上在remove方法中,
 * 判断是否需要还原为普通链表的个数不是固定为6的,即有可能即使节点数量小于6个,也不会转换为链表,因此不能使用该变量!
 */
static final int UNTREEIFY_THRESHOLD = 6;

// 链表红黑树的阈值,即当哈希表中的容量大于等于 64 时,才允许树形化链表,否则不进行树形化,而是扩容
static final int MIN_TREEIFY_CAPACITY = 64;

// 用在transfer方法中,transfer可以并发,每个CPU(线程)所需要处理的连续的桶的个数,最少16
private static final int MIN_TRANSFER_STRIDE = 16;

/**
 * 用于辅助生成扩容版本唯一标记,最小是6。这里是一个非final的变量,但是也没有提供修改的方法
 * 每次扩容都会有一个唯一的标记,一次扩容完毕之后,才会进行下一次扩容
 */
private static int RESIZE_STAMP_BITS = 16;

// 扩容的最大线程数, 2^15-1
private static final int MAX_RESIZERS = (1 << (32 - RESIZE_STAMP_BITS)) - 1;

// 扩容版本标记移位之后会保存到sizeCtl中当作扩容线程的基数,然后在反向移位可以获取到扩容版本标记
private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS;

// ForwardingNode的hash值,一种临时节点,用于扩容时辅助扩容,相当于标志节点,不存储数据
static final int MOVED = -1;

/**
 * TreeBin结点的hash值,用于代理红黑树根节点,会存储数据
 * 红黑树添加删除节点时,树结构可能发生改变,因此额外维护了一个读写锁
 */
static final int TREEBIN = -2;
/**
 * ReservationNode的hash值,也相当于标志节点,不存储数据
 * 也是相当于占位符,在JDK1.8才出现的新属性,用于computeIfAbsent、compute方法,一般用不到
 */
static final int RESERVED = -3;

// 可用CPU数量
static final int NCPU = Runtime.getRuntime().availableProcessors();

// 存放 node 的数组
transient volatile Node<K, V>[] table;

// 扩容后的新的table数组,只有在扩容时才会用到(才会非null)
private transient volatile ConcurrentHashMap.Node<K, V>[] nextTable;

/**
 * JDK1.8的新属性
 * 控制标识符,用来控制table的出于初始化、扩容等操作,不同的值有不同的含义:
 * 当为0时:代表当时的table还没有被初始化
 * 当为负数时:
 *      -1代表线程正在初始化哈希表;
 *      其他负数,表示正在进行扩容操作,此时sizeCtl=(rs << RESIZE_STAMP_SHIFT )+ n + 1,即此时的sizeCtl由 版本号rs左移16位 + 并发扩容的线程数n +1 组成,并不是由所谓的-(n+1)简单组成!
 * 当为正数时:表示初始化容量或者下一次进行扩容的阈值,即如果hash表的实际大小>=sizeCtl,则进行扩容,阈值是当前ConcurrentHashMap容量的0.75倍,不能改变
 */
private transient volatile int sizeCtl;

// CAS的标志位。在初始化或者counterCells数组扩容的时候会用到
private transient volatile int cellsBusy;

// 元素个数基本计数器,只会记录CAS更新成功的数值,可能不准确
private transient volatile long baseCount;

/**
 * 添加/删除元素时如果如果使用baseCountCAS计算失败
 * 那么使用CounterCell[]数组保存CAS失败的个数
 * 最后size()方法统计出来的大小是baseCount和counterCells数组的总和
 */
private transient volatile CounterCell[] counterCells;

/**
 * transfer方法用于扩容或者协助扩容,允许多个线程同时操作,但是为了防止重复操作,ConcurrentHashMap将数组一段连续的桶位分给一条线程进行操作
 * 下一条线程进来帮助扩容的时候需要知道上一条线程是操作了哪些桶位,这里的transferIndex就是记录了下一个将要执行transfer任务的线程的起始数组下标索引+1
 * transfer分配桶位的方式是从最后的索引向前分配,直到0索引位置,每次一条新线程分配了桶位,transferIndex都需要更新,
 * 因此如果一条线程想要帮助扩容那么需要判断transferIndex <= 0,如果成立,那么表示所有的桶位都被分配完了,不需要新来的线程帮助了
 */
private transient volatile int transferIndex;

ConcurrentHashMap 的数据存储结构

数组

// 存放 node 的数组
transient volatile Node<K, V>[] table;

链表

static class Node<K,V> implements Map.Entry<K,V> {
   
    final int hash;    
    final K key;      
    // val 和 next 都会在扩容时发生变化,所以加上 volatile 来保持可见性和禁止重排序
    volatile V val; // get 操作全程不需要加锁是因为 Node 的成员 val 是用volatile 修饰
    volatile Node<K,V> next; // 表示链表中的下一个节点,数组用volatile修饰主要是保证在数组扩容的时候保证可见性
    Node(int hash, K key, V val, Node<K,V> next) {
   
        this.hash = hash;
        this.key = key;
        this.val = val;
        this.next = next;
    }
    public final K getKey()       {
    return key; }
    public final V getValue()     {
    return val; }
    public final int hashCode()   {
    return key.hashCode() ^ val.hashCode(); }
    public final String toString(){
    return key + "=" + val; }
    // 不允许更新value 
    public final V setValue(V value) {
   
        throw new UnsupportedOperationException();
    }
    public final boolean equals(Object o) {
   
        Object k, v, u; Map.Entry<?,?> e;
        return ((o instanceof Map.Entry) &&
                (k = (e = (Map.Entry<?,?>)o).getKey()) != null &&
                (v = e.getValue()) != null &&
                (k == key || k.equals(key)) &&
                (v == (u = val) || v.equals(u)));
    }
    // 用于map中的get()方法,子类重写
    Node<K,V> find(int h, Object k) {
   
        Node<K,V> e = this;
        if (k != null) {
   
            do {
   
                K ek;
                if (e.hash == h &&
                    ((ek = e.key) == k || (ek != null && k.equals(ek))))
                    return e;
            } while ((e = e.next) != null);
        }
        return null;
    }
}

红黑树

static final class TreeNode<K,V> extends Node<K,V> {
   
    TreeNode<K,V> parent;  // 父节点
    TreeNode<K,V> left; // 左子树
    TreeNode<K,V> right; // 右子树
    TreeNode<K,V> prev; // 删除时需要取消下一个链接
    boolean red; // 标志红黑树的红节点
    TreeNode(int hash, K key, V val, Node<K,V> next,
             TreeNode<K,V> parent) {
   
        super(hash, key, val, next);
        this.parent = parent;
    }
    Node<K,V> find(int h, Object k) {
   
        return findTreeNode(h, k, null);
    }
    // 根据 key 查找 从根节点开始找出相应的 TreeNode
    final TreeNode<K,V> findTreeNode(int h
  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值