程序猿成长之路番外篇-- ConcurrentHashmap介绍

上次的番外篇介绍了一下hashmap的实现原理,那么这次就来介绍一下concurrentHashmap的实现原理。

什么是concurrentHashmap?

顾名思义,concurrent = 同时发生的, 所以concurrentHashmap大概可以翻译为并发hashmap。它也确实可以用于处理高并发的场景。如果说Hashmap是一个公共无密码的储物箱,那么ConcurrentHashmap就是一个带锁的储物箱。concurrentHashmap 最大的特点在于锁分段技术,也就是segment,这是其区别于Hashmap的最大的特点。此外,concurrentHashmap修改了其基本存储单元Entry,其中的一些参数使用volatile进行修饰保证其可见性。(版本jdk7.0)

为什么我们要用ConcurrentHashmap?

在上次的Hashmap的介绍中我们粗略的介绍了一下Hashmap的问题点,就是在并发的情况下会出现环形链表导致死循环的出现。那经过了这些天的研究,我又更深层次的理解了一下环形链表的出现的原因。(版本为jdk7)

首先,我们知道hashmap是一个链表数组,其hash冲突的解决办法为链地址法。如图所示
在这里插入图片描述
在一般情况下该存储结构不会发生问题,发生问题导致产生环路的条件如下:

  1. 存在并发访问
  2. 需要进行扩容

我们结合源码看一下:

public V put(K key, V value) {
    if (key == null)
        return putForNullKey(value);
    int hash = hash(key.hashCode());
    // 找节点位置
    int i = indexFor(hash, table.length);
    //遍历数组,查找到了就返回原值如果没有就添加新entry节点
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        Object k;
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }
    modCount++;
    addEntry(hash, key, value, i); // 注意这里,进行新的节点的添加
    return null;
}

上述为put一个元素的源码

void addEntry(int hash, K key, V value, int bucketIndex) {
    Entry<K,V> e = table[bucketIndex];
    // 新增一个节点并将节点头指针改为这个新增的节点,因为第四个参数表示next对象
    table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
    if (size++ >= threshold) //注意这里,进行扩容
        resize(2 * table.length);
}

上述为添加新entry节点的源码,注意这里还是先添加节点后扩容的。而ConcurrentHashmap则是先判断是否需要扩容,如果需要扩容先扩容再添entry节点,这样的好处可以避免无效扩容(即扩容后不添加节点元素)

 void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
        }
        Entry[] newTable = new Entry[newCapacity]; //1.0
        boolean oldAltHashing = useAltHashing;
        useAltHashing |= sun.misc.VM.isBooted() &&
                (newCapacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
        boolean rehash = oldAltHashing ^ useAltHashing;
        transfer(newTable, rehash); //1.1
        table = newTable;
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);  //1.2
}

这是jdk7.0版本的扩容代码,主要分为三步:
1.0 --创建新数组
1.1 --复制原数组中的数据
1.2 --重新确定threshold(阈值)

再看一下transfer代码

    void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        for (Entry<K,V> e : table) {
            while(null != e) {
                Entry<K,V> next = e.next; //step 1.0
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity);//查找位置
                e.next = newTable[i];//注意这里,将节点指向新节点
                newTable[i] = e;//注意这里,头插法插入新数组
                e = next;//注意这里,进行下一次的插入
            }
        }
    }

这里的代码就比较有意思,它是将原数组中的值通过头插法插入到新数组中。接下来我们看一张图,
在这里插入图片描述

线程1还没开始扩容但准备扩容,也就是到了step 1.0的代码位置。这时该死的线程2来了,由于transfer函数中的table是公用的,也就是说每一个线程都有一个原数组的备份,table中e所指的对象都是同一个对象。有了这个前提,我们来看下面这张图
在这里插入图片描述

线程2完成了扩容,此时线程1才开始扩容(这个只是偶然情况,专业术语叫做竞态条件),那么线程1开始时指向了A 这个节点,执行后面的语句后A.next -> 新数组中的B, 又有B.next -> A ,于是就存在了环形链路。
在这里插入图片描述
出现了环形链表之后,在之后的读取(get)中会存在死循环
此外hashmap在多线程put非NULL元素后,get操作得到NULL值;多线程put操作,会导致元素丢失。有兴趣的技术人可以自行查阅一下。

相比于hashmap的问题,concurrentHashmap则有了更多的优势,首先它支持并发访问,也称作并发容器,其次,它先判断扩容后存储元素避免了无效扩容(扩容了但不插入节点的问题),再次,concurrentHashmap优化了entry的结构,用volatile修饰value和next使其保持可见性。最牛逼的一点,concurrentHashmap使用了锁分段技术,使得每一段(segment)都配上了一把锁,使得可以并发访问不同的分段。(JDK 7)

ConcurrentHashmap 源码分析

在这里插入图片描述
jdk7中的ConcurrentHashmap的类图大致如上图所示,下面开始源码分析:
先看一下hashEntry的代码

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

注意这里的value用volatile 修饰,保证其可见性,其他的成员变量都用final修饰,可以防止链表结构被破坏。

static final class Segment<K,V> extends ReentrantLock implements Serializable {
    transient volatile int count; //Segment中元素的数量
    transient int modCount; // 修改次数
    transient int threshold;// 阈值(扩容临界值)
    transient volatile HashEntry<K,V>[] table; // hashentry节点数组
    final float loadFactor; //负载因子
}

了解完了节点的结构后,下面我们来看一下concurrentHashmap中的初始化方法

初始化concurrentHashmap


    /* ---------------- Constants -------------- */

    /**
     * 最大的容量,是2的幂次方(java 数组索引和分配的最大值约为 1<<30,32位的hash值前面两位用于控制)
     */
    private static final int MAXIMUM_CAPACITY = 1 << 30;

    /**
     * 数组容量,为2的幂次方, 
     * 1<=DEFAULT_CAPACITY<=MAXIMUM_CAPACITY
     */
    private static final int DEFAULT_CAPACITY = 16;

    /**
     * 最大的数组容量(被toArray和其他数组方法调用时获取所需要)
     */
    static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;

    /**
     * 默认的并发等级.
     * 为12、13、14、15、16表示segment数组大小默认为16
     */
    private static final int DEFAULT_CONCURRENCY_LEVEL = 16;

    /**
     * 负载因子,考虑到红黑树和链表的平均检索时间,取0.75为宜。
     * 这样接近O(1)
     */
    private static final float LOAD_FACTOR = 0.75f;

    /**
     * 红黑树化链表的阈值,即当前hashentry中桶链表节点的对象长度
     * >=8时进行扩容该节点会红黑树化
     */
    static final int TREEIFY_THRESHOLD = 8;

    /**
     * 红黑树化链表的阈值,即当前hashentry中桶链表节点的对象长度
     * <= 6 时进行扩容该节点仍为链表
     */
    static final int UNTREEIFY_THRESHOLD = 6;

    /**
     * 最小的链表数组容量(至少为4倍的TREEIFY_THRESHOLD。即32)
     * 以防止扩容和红黑树化阈值的冲突
     */
    static final int MIN_TREEIFY_CAPACITY = 64;

    /**
     * Minimum number of rebinnings per transfer step. Ranges are
     * subdivided to allow multiple resizer threads.  This value
     * serves as a lower bound to avoid resizers encountering
     * excessive memory contention.  The value should be at least
     * DEFAULT_CAPACITY.
     */
    private static final int MIN_TRANSFER_STRIDE = 16;

    /**
     * 扩容戳,和resizeStamp函数有关
     * Must be at least 6 for 32bit arrays.(至少6位以满足32位的数组)
     * rs(RESIZE_STAMP_BITS) = 1 << (RESIZE_STAMP_BITS - 1)
     * rs(6) = 1 << (6-1) = 32
     */
    private static int RESIZE_STAMP_BITS = 16;

    /**
     * 最大的可扩容线程数
     * 线程在扩容时会将高RESIZE_STAMP_BITS作为扩容后的标记,高 32- RESIZE_STAMP_BITS 为作为扩容线程数
     */
    private static final int MAX_RESIZERS = (1 << (32 - RESIZE_STAMP_BITS)) - 1;

    /**
     * The bit shift for recording size stamp in sizeCtl.
     * 扩容戳的位偏移
     */
    private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS;
	// ...

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;
        
    // Find power-of-two sizes best matching arguments
    int sshift = 0;
    int ssize = 1;
    while (ssize < concurrencyLevel) {
        ++sshift;
        ssize <<= 1;
    }
    segmentShift = 32 - sshift;
    segmentMask = ssize - 1;
    this.segments = Segment.newArray(ssize);

    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    int c = initialCapacity / ssize;
    if (c * ssize < initialCapacity)
        ++c;
    //最小Segment中存储元素的个数为2
    int cap = MIN_SEGMENT_TABLE_CAPACITY;
    while (cap < c)
        cap <<= 1;
 	//创建segments数组并初始化第一个Segment,其余的Segment延迟初始化
    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); 
    this.segments = ss;
}

在该初始化中有一些参数:

  1. loadFactor 负载因子
  2. initialCapacity 初始化容量大小,等于段* 段的容量
  3. concurrencyLevel 并发级别,用于确定段的长度,如concurrencyLevel = 13,14,15,16 时segment段大小均为16
  4. sshift 表示并发级别(段个数)所占的位数,用于确定段偏移的大小。段偏移 = 32 - sshift 表示之后再散列时向右移位的位数,这个之后会讲到
  5. ssize 表示segment的大小,为不低于concurrencyLevel的2的幂次方。
  6. segmentShift 段偏移,之后会讲到,用于段的再散列
  7. segmentMask 段掩码,之后会讲到,用于段的再散列去取高位n位。
  8. MAXIMUM_CAPACITY 为最大的段个数
  9. c,cap 用于确定每个segment的容量,也为2的幂次方,loadfactor也适用于每个segment中的对象。

初始化的过程介绍:

  • 进行参数验证
  • 判断并发等级是否超过最大值,如果超过就设置并发等级为最大值、
  • 根据并发等级获取ssize(段的长度)以及sshift
  • 计算segmentshift(段偏移) = 32 - sshift,之后在再散列时确定需要与运算的高位数据偏移量(高位向右移动的位数,使得高位变低位)。
  • 计算segmentmask(段掩码) = ssize -1, 即取偏移后低位segmentmask位进行再散列。(即原高位n为的数据可以决定段位置)
  • 计算每个segment中hashEntry的容量。即为cap,默认情况下initialCapacity等于16,loadFactor等于0.75,通过计算cap=1,threshold=0。

往concurrentHashmap插入元素

首先我们来看一下Segment的结构
图片来源:
https://blog.csdn.net/m0_37135421/article/details/80551884
在这里插入图片描述

static final class Segment<K, V> extends ReentrantLock implements Serializable {
 
	/**
	 * scanAndLockForPut中自旋循环获取锁的最大自旋次数。
	 */
	static final int MAX_SCAN_RETRIES = Runtime.getRuntime().availableProcessors() > 1 ? 64 : 1;
 
	/**
	 * 链表数组,数组中的每一个元素代表了一个链表的头部
	 */
	transient volatile HashEntry<K, V>[] table;
 
	/**
	 * 用于记录每个Segment桶中键值对的个数
	 */
	transient int count;
 
	/**
	 * 对table的修改次数
	 */
	transient int modCount;
 
	/**
	 * 阈值,Segment里面元素的数量超过这个值依旧就会对Segment进行扩容
	 */
	transient int threshold;
 
	/**
	 * 负载因子,用于确定threshold,默认是1
	 */
	final float loadFactor;
}
 
static final class HashEntry<K, V> {
	final int hash;
	final K key;
	volatile V value; //设置可见性
	volatile HashEntry<K, V> next; //不再用final关键字,采用unsafe操作保证并发安全
}

segment使用了可重入锁reentrantlock来保证每次对段的操作具有原子性,每次在对某一个段进行操作时,首先获取段的锁,之后进行操作。并且段与段之间的操作由于存在不同的锁因此互不干扰。

下面来看一下put方法

// ConcurrentHashMap类的put()方法
public V put(K key, V value) {
    Segment<K,V> s;
    //concurrentHashMap不允许key/value为空
    if (value == null)
        throw new NullPointerException();
    //hash函数对key的hashCode重新散列,避免差劲的不合理的hashcode,保证散列均匀
    int hash = hash(key);
    //返回的hash值无符号右移segmentShift位与段掩码进行位运算,定位segment,即进行再散列操作
    int j = (hash >>> segmentShift) & segmentMask;
    if ((s = (Segment<K,V>)UNSAFE.getObject 
         (segments, (j << SSHIFT) + SBASE)) == null)        s = ensureSegment(j);
    // 调用Segment类的put方法
    return s.put(key, hash, value, false);  
}
 
// Segment类的put()方法
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
    // 注意这里,这里进行加锁
    HashEntry<K,V> node = tryLock() ? null :
        scanAndLockForPut(key, hash, value); //如果加锁失败,则调用该方法
    V oldValue;
    try {
        HashEntry<K,V>[] tab = table;
        // 根据hash计算在table[]数组中的位置
        int index = (tab.length - 1) & hash;
        HashEntry<K,V> first = entryAt(tab, index);
        for (HashEntry<K,V> e = first;;) {
            if (e != null) { //若不为null,则持续查找,知道找到key和hash值相同的节点,将其value更新
                K k;
                if ((k = e.key) == key ||
                    (e.hash == hash && key.equals(k))) {
                    oldValue = e.value;
                    if (!onlyIfAbsent) {
                        e.value = value;
                        ++modCount;
                    }
                    break;
                }
                e = e.next;
            }
            else { //如果在链表中没有找到对应的node
                if (node != null) //如果scanAndLockForPut方法中已经返回的对应的node,则将其插入first之前
                    node.setNext(first);
                else //否则,new一个新的HashEntry
                    node = new HashEntry<K,V>(hash, key, value, first);
                int c = count + 1;
                // 判断table[]是否需要扩容,并通过rehash()函数完成扩容
                if (c > threshold && tab.length < MAXIMUM_CAPACITY)
                    rehash(node);
                else  //设置node到Hash表的index索引处
                    setEntryAt(tab, index, node);
                ++modCount;
                count = c;
                oldValue = null;
                break;
            }
        }
    } finally {
        unlock();
    }
    return oldValue;
}

put操作的步骤:

  1. 判断value是否为空
  2. 对key进行散列
  3. 根据key的散列值确定数据存储的segment(段)的位置
  4. 将key,value 键值对插入到segment中的hashEntry中,如果存在就返回旧值,如果不存在就创建新节点。注意这里加锁进行插入。

从concurrentHashmap获取元素

public V get(Object key) {
    Segment<K,V> s; 
    HashEntry<K,V>[] tab;
    int h = hash(key);
    long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
    //先定位Segment,再定位HashEntry
    if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
        (tab = s.table) != null) {
        for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile
                 (tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);
             e != null; e = e.next) {
            K k;
            if ((k = e.key) == key || (e.hash == h && key.equals(k)))
                return e.value;
        }
    }
    return null;
}

get操作就相对比较简单,只要根据再散列的值确定段位置,之后根据键值对确定hashentry的位置即可。

concurrentHashmap如何实现扩容?

  1. 首先判断segment 里面的hashentry数组是否达到阈值,如果超过了,就进行扩容,之后插入元素
  2. 扩容一般为2倍扩容,将原数组中的元素进行再散列后插入新数组。为了高效,concurrentHashmap只对某个segment 进行扩容而不对整个容器进行扩容。
    -------------------------- 未完待续 -----------------------------
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

zygswo

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

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

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

打赏作者

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

抵扣说明:

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

余额充值