ConcurrentHashMap简介

本文详细剖析了ConcurrentHashMap的内部结构,包括Segment和HashEntry的作用及其实现原理。介绍了get、put、remove和size等核心操作的具体实现,并对rehash操作进行了深入解读。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

1.ConcurrentHashMap结构

一个ConcurrentHashMap里包含一个Segment数组,Segment也是一种可重入锁ReentrantLock,在ConcurrentHashMap里扮演锁的角色, 一个Segment里包含一个HashEntry数组,每个HashEntry是一个链表结构的元素,HashEntry则用于存储键值对数据,每个Segment守护者一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先获得它对应的Segment锁。

ConcurrentHashMap逻辑结构图:

这里写图片描述

ConcurrentHashMap类图:

这里写图片描述

Segment类:

static final class Segment<K,V> extends ReentrantLock implements Serializable {
    transient volatile int count;
    transient int modCount;
    transient int threshold;
    transient volatile HashEntry<K,V>[] table;
    final float loadFactor;
}

Segment类的成员变量的意义:

  • count:Segment中元素的数量
  • modCount:对table的大小造成影响的操作的数量(比如put或者remove操作)
  • threshold:阈值,Segment里面元素的数量超过这个值依旧就会对Segment进行扩容
  • table:链表数组,数组中的每一个元素代表了一个链表的头部
  • loadFactor:负载因子,用于确定threshold

HashEntry类:

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

2.ConcurrentHashMap的put、get、remove、size操作

2.1 ConcurrentHashMap的get操作

ConcurrentHashMap的get操作是不用加锁的,我们这里看一下其实现:

public V get(Object key) {  
    int hash = hash(key.hashCode());  
    return segmentFor(hash).get(key, hash);  
}  

看第三行,segmentFor这个函数用于确定操作应该在哪一个segment中进行,几乎对ConcurrentHashMap的所有操作都需要用到这个函数,我们看下这个函数的实现:

    final Segment<K,V> segmentFor(int hash) {  
        return segments[(hash >>> segmentShift) & segmentMask];  
    }  

这个函数用了位操作来确定Segment,根据传入的hash值右移segmentShift位,然后和segmentMask进行与操作,结合segmentShift和segmentMask的值,就可以得出以下结论:假设Segment的数量是2的n次方,根据元素的hash值的高n位就可以确定元素到底在哪一个Segment中。

在确定了需要在哪一个segment中进行操作以后,接下来的事情就是调用对应的Segment的get方法:

V get(Object key, int hash) {  
        if (count != 0) { // read-volatile  
            HashEntry<K,V> e = getFirst(hash);  
            while (e != null) {  
                if (e.hash == hash && key.equals(e.key)) {  
                    V v = e.value;  
                    if (v != null)  
                        return v;  
                    return readValueUnderLock(e); // recheck  
                }  
                e = e.next;  
            }  
        }  
        return null;  
    }  

先看第二行代码,这里对count进行了一次判断,其中count表示Segment中元素的数量,我们可以来看一下count的定义:

transient volatile int count;  

可以看到count是volatile的,实际上这里里面利用了volatile的语义,因为实际上put、remove等操作也会更新count的值,所以当竞争发生的时候,volatile的语义可以保证写操作在读操作之前,也就保证了写操作对后续的读操作都是可见的,这样后面get的后续操作就可以拿到完整的元素内容。

然后,在第三行,调用了getFirst()来取得链表的头部:

HashEntry<K,V> getFirst(int hash) {  
        HashEntry<K,V>[] tab = table;  
        return tab[hash & (tab.length - 1)];  
    }  

同样,这里也是用位操作来确定链表的头部,hash值和HashTable的长度减一做与操作,最后的结果就是hash值的低n位,其中n是HashTable的长度以2为底的结果。

在确定了链表的头部以后,就可以对整个链表进行遍历,看第4行,取出key对应的value的值,如果拿出的value的值是null,则可能这个key,value对正在put的过程中,如果出现这种情况,那么就加锁来保证取出的value是完整的,如果不是null,则直接返回value。


2.2 ConcurrentHashMap的put操作

put操作的前面也是确定Segment的过程,这里不再赘述,直接看关键的segment的put方法:

V put(K key, int hash, V value, boolean onlyIfAbsent) {  
        lock();  
        try {  
            int c = count;  
            if (c++ > threshold) // ensure capacity  
                rehash();  
            HashEntry<K,V>[] tab = table;  
            int index = hash & (tab.length - 1);  
            HashEntry<K,V> first = tab[index];  
            HashEntry<K,V> e = first;  
            while (e != null && (e.hash != hash || !key.equals(e.key)))  
                e = e.next;  

            V oldValue;  
            if (e != null) {  
                oldValue = e.value;  
                if (!onlyIfAbsent)  
                    e.value = value;  
            }  
            else {  
                oldValue = null;  
                ++modCount;  
                tab[index] = new HashEntry<K,V>(key, hash, first, value);  
                count = c; // write-volatile  
            }  
            return oldValue;  
        } finally {  
            unlock();  
        }  
    }

首先对Segment的put操作是加锁完成的,然后在第五行,如果Segment中元素的数量超过了阈值(由构造函数中的loadFactor算出)这需要进行对Segment扩容,并且要进行rehash。

第8和第9行的操作就是getFirst的过程,确定链表头部的位置。

第11行这里的这个while循环是在链表中寻找和要put的元素相同key的元素,如果找到,就直接更新更新key的value,如果没有找到,则进入21行这里,生成一个新的HashEntry并且把它加到整个Segment的头部,然后再更新count的值。


2.3 ConcurrentHashMap的remove操作

remove操作的前面一部分和前面的get和put操作一样,都是定位Segment的过程,然后再调用Segment的remove方法:

V remove(Object key, int hash, Object value) {
    lock();
    try {
        int c = count - 1;
        HashEntry<K,V>[] tab = table;
        int index = hash & (tab.length - 1);
        HashEntry<K,V> first = tab[index];
        HashEntry<K,V> e = first;
        while (e != null && (e.hash != hash || !key.equals(e.key)))
            e = e.next;

        V oldValue = null;
        if (e != null) {
            V v = e.value;
            if (value == null || value.equals(v)) {
                oldValue = v;
                // All entries following removed node can stay
                // in list, but all preceding ones need to be
                // cloned.
                ++modCount;
                HashEntry<K,V> newFirst = e.next;
                for (HashEntry<K,V> p = first; p != e; p = p.next)
                    newFirst = new HashEntry<K,V>(p.key, p.hash,
                                                  newFirst, p.value);
                tab[index] = newFirst;
                count = c; // write-volatile
            }
        }
        return oldValue;
    } finally {
        unlock();
    }
}

首先remove操作也是确定需要删除的元素的位置,不过这里删除元素的方法不是简单地把待删除元素的前面的一个元素的next指向后面一个就完事了,我们之前已经说过HashEntry中的next是final的,一经赋值以后就不可修改,在定位到待删除元素的位置以后,程序就将待删除元素前面的那一些元素全部复制一遍,然后再一个一个重新接到链表上去,如图:
这里写图片描述
假设链表中原来的元素如上图所示,现在要删除元素3,那么删除元素3以后的链表就如下图所示:
这里写图片描述


2.4 ConcurrentHashMap的size操作

如果我们要统计整个ConcurrentHashMap里元素的大小,就必须统计所有Segment里元素的大小后求和。Segment里的全局变量count是一个volatile变量,那么在多线程场景下,我们是不是直接把所有Segment的count相加就可以得到整个ConcurrentHashMap大小了呢?不是的,虽然相加时可以获取每个Segment的count的最新值,但是拿到之后可能累加前使用的count发生了变化,那么统计结果就不准了。所以最安全的做法,是在统计size的时候把所有Segment的put,remove和clean方法全部锁住,但是这种做法显然非常低效。 因为在累加count操作过程中,之前累加过的count发生变化的几率非常小,所以ConcurrentHashMap的做法是先尝试2次通过不锁住Segment的方式来统计各个Segment大小,如果统计的过程中,容器的count发生了变化,则再采用加锁的方式来统计所有Segment的大小。

那么ConcurrentHashMap是如何判断在统计的时候容器是否发生了变化呢?使用modCount变量,在put , remove和clean方法里操作元素前都会将变量modCount进行加1,那么在统计size前后比较modCount是否发生变化,从而得知容器的大小是否发生变化。


3.ConcurrentHashMap的rehash操作

ConrruentHashMap需要扩容时,ConcurrentHashMap不会增加Segment的数量,而只会增加Segment中链表数组的容量大小,这样的好处是扩容过程不需要对整个ConcurrentHashMap做rehash,而只需要对Segment里面的元素做一次rehash就可以了。

void rehash() {  
    HashEntry<K,V>[] oldTable = table;  
    int oldCapacity = oldTable.length;  
    if (oldCapacity >= MAXIMUM_CAPACITY)  
        return;  

    /* 
     * Reclassify nodes in each list to new Map.  Because we are 
     * using power-of-two expansion, the elements from each bin 
     * must either stay at same index, or move with a power of two 
     * offset. We eliminate unnecessary node creation by catching 
     * cases where old nodes can be reused because their next 
     * fields won't change. Statistically, at the default 
     * threshold, only about one-sixth of them need cloning when 
     * a table doubles. The nodes they replace will be garbage 
     * collectable as soon as they are no longer referenced by any 
     * reader thread that may be in the midst of traversing table 
     * right now. 
     */  
     /* 
     * 其实这个注释已经解释的很清楚了,主要就是因为扩展是按照2的幂次方 
     * 进行扩展的,所以扩展前在同一个桶中的元素,现在要么还是在原来的 
     * 序号的桶里,或者就是原来的序号再加上一个2的幂次方,就这两种选择。 
     * 所以原桶里的元素只有一部分需要移动,其余的都不要移动。该函数为了 
     * 提高效率,就是找到最后一个不在原桶序号的元素,那么连接到该元素后面 
     * 的子链表中的元素的序号都是与找到的这个不在原序号的元素的序号是一样的 
     * 那么就只需要把最后一个不在原序号的元素移到新桶里,那么后面跟的一串 
     * 子元素自然也就连接上了,而且序号还是相同的。在找到的最后一个不在 
     * 原桶序号的元素之前的元素就需要逐个的去遍历,加到和原桶序号相同的新桶上 
     * 或者加到偏移2的幂次方的序号的新桶上。这个都是新创建的元素,因为 
     * 只能在表头插入元素。这个原因可以参考 
     * 《探索 ConcurrentHashMap 高并发性的实现机制》中的讲解 
     */  

    HashEntry<K,V>[] newTable = HashEntry.newArray(oldCapacity<<1);  
    threshold = (int)(newTable.length * loadFactor);  
    int sizeMask = newTable.length - 1;  
    for (int i = 0; i < oldCapacity ; i++) {  
        // We need to guarantee that any existing reads of old Map can  
        //  proceed. So we cannot yet null out each bin.  
        HashEntry<K,V> e = oldTable[i];  

        if (e != null) {  
            HashEntry<K,V> next = e.next;  
            int idx = e.hash & sizeMask;  

            //  Single node on list  
            if (next == null)  
                newTable[idx] = e;  

            else {  
                // Reuse trailing consecutive sequence at same slot  
                HashEntry<K,V> lastRun = e;  
                int lastIdx = idx;  
                for (HashEntry<K,V> last = next;  
                     last != null;  
                     last = last.next) {  
                    int k = last.hash & sizeMask;  
                    // 这里就是遍历找到最后一个不在原桶序号处的元素  
                    if (k != lastIdx) {  
                        lastIdx = k;  
                        lastRun = last;  
                    }  
                }  
                // 把最后一个不在原桶序号处的元素赋值到新桶中  
                // 由于链表本身的特性,那么该元素后面的元素也都能连接过来  
                // 并且能保证后面的这些元素在新桶中的序号都是和该元素是相等的  
                // 因为上面的遍历就是确保了该元素后面的元素的序号都是和这个元素  
                // 的序号是相等的。不然遍历中还会重新赋值lastIdx  
                newTable[lastIdx] = lastRun;  

                // Clone all remaining nodes  
                // 这个就是把上面找到的最后一个不在原桶序号处的元素之前的元素赋值到  
                // 新桶上,注意都是把元素添加到新桶的表头处  
                for (HashEntry<K,V> p = e; p != lastRun; p = p.next) {  
                    int k = p.hash & sizeMask;  
                    HashEntry<K,V> n = newTable[k];  
                    newTable[k] = new HashEntry<K,V>(p.key, p.hash,  
                                                     n, p.value);  
                }  
            }  
        }  
    }  
    table = newTable;  
}

参考资料

聊聊并发(四)——深入分析ConcurrentHashMap

Java并发编程之ConcurrentHashMap

ConcurrentHashMap中rehash函数理解

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值