当前位置: 首页 > article >正文

Java-01-源码篇-04集合-05-ConcurrentHashMap(1)

1.1 加载因子

        加载因子(Load Factor)是用来决定什么时候需要扩容的一个参数。具体来说,加载因子 = 当前元素数量 / 桶的数量,当某个桶的元素个数超过了 桶的数量 × 加载因子 时,就会触发扩容。

        我们都知道 ConcurrentHashMap 的默认大小是16,默认加载因子是:0.75。解析一下什么叫做加载因子,简单的讲就是触发扩容机制的阈值。比如我的集合容量大小是 100,加载因子是:0.75,那么触发扩容的阈值就是(100 x 0.75 = 75; 当数量达到75的时候触发扩容机制。而不是在容量达到满100的时候才触发扩容机制)。通过设置加载因子的好处如下:

平衡性能与空间利用率

        较低的加载因子(如 0.1)会使得哈希表在元素较少时扩容,从而减少哈希冲突,提高查找、插入和删除操作的性能。

        较高的加载因子(如 0.9)会延迟扩容,节省内存空间,但可能会增加哈希冲突,降低性能。

        在 ConcurrentHashMap 中,默认的加载因子是 0.75。这是一个经验值,能够在大多数情况下提供较好的性能和空间利用率。

控制加载频率        扩容是一个比较耗时的操作,因为它需要重新计算元素复制到新的桶里面。通过调整加载因子,在性能与内存之间达到一个平衡。

   

1.2 面试题:加载因子默认为什么是0.75,而不是9,或者5等等之类其他数字

        所以这道题面试题问的就是加载因子的作用,从平衡性能和内存,以及控制加载频率的角度去回答即可。

        比如:如果设置的加载因子过高(例如 0.1 或 0.9),意味着 ConcurrentHashMap 会在桶中存储更多的元素,减少扩容的次数,减少内存的开销。但是这样会也导致桶的元素过多,链表的长度过长。导致访问元素时的查找效率下降,发生更多的哈希冲突(hash collision)几率也逐渐增大。
        较低的加载因子(0.1)意味着 ConcurrentHashMap 会在桶中存储少量的元素,但扩容的次数上升,内存的开销增多。频繁的扩容处理会导致性能开销增大。

        所以取一个在性能与内存之间很好的折中植就是0.75。0.75 是一个经过大量测试验证的值,适用于大多数常见场景。它在查找效率、内存使用和扩容操作之间取得了一个很好的折中。

        当然,如果具体也可以根据需求调整
        对于性能要求高的应用

      尽量减少哈希冲突,提高查找、插入和删除操作的性能。降低加载因子(例如设置为 0.5 或更低)。

        【原因】

                较低的加载因子会使得哈希表在元素数量较少时触发扩容,从而减少哈希冲突。

                每个桶中的元素数量更少,查找、插入和删除操作的时间复杂度更接近 O(1)。

        【代价】

                哈希表的容量会更大,占用更多的内存空间

                扩容操作会更频繁,可能会带来一定的性能开销

        对于内存占用比较敏感的应用

        尽量减少内存占用,提高空间利用率。

        【原因】

                较高的加载因子会延迟扩容,使得哈希表在元素数量接近容量时才触发扩容。

                内存利用率更高,适合内存资源有限的场景。

        【代价】

                哈希冲突的概率会增加,尤其是在元素数量接近容量时,性能可能会下降。

                查找、插入和删除操作的时间复杂度可能会增加。


1.3 初始化功能 initTable()

首先,初始化功能在第一次put时会触发。代码分析如下
我们进一步观察initTable() 方法

public V put(K key, V value) { return putVal(key, value, false); }

final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) throw new NullPointerException();
    int hash = spread(key.hashCode());
    int binCount = 0;
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh; K fk; V fv;
        // 当集合第一次添加元素时,触发初始化
        if (tab == null || (n = tab.length) == 0) 
            tab = initTable(); 
            ...... // 忽略其他代码


我们进一步观察initTable() 方法

/** ConcurrentHashMap 的容器*/
transient volatile Node<K,V>[] table; 
/** 
    sizeCtl的作用 
    sizeCtl (< 0) 负值时:
        sizeCtl = -1; 表示正在进行初始化
        其他负值(如 -2,-3 等):表示正在进行扩容,并且数字的大小指示了
        当前有多少个线程正在进行扩容操作。例如,
            -2 表示有一个线程在进行扩容,
            -3 表示有两个线程在进行扩容。
    sizeCtl = 0 时:
        当 sizeCtl == 0 时,表示表格的大小使用默认值。
    正值(用于表格大小的控制):
        在表格初始化完成之后,sizeCtl 变成一个正值,表示下一个阈值,
        即表格的元素数量达到此值时,需要进行扩容。
        例如,如果 sizeCtl 设置为 16,则表示当表格的元素数量达到 16 时,
        应该触发扩容操作。
    【总结】
    sizeCtl 控制表格的初始化、扩容,以及扩容过程中的线程同步。
    根据其值的不同,sizeCtl 在 ConcurrentHashMap 中承担着不同的角色
*/
private transient volatile int sizeCtl;

/** 
    Unsafe 类,里面提供CPU相关的操作指令,比如CAS。
    CAS: compareAndSet 比较并交换,CAS 通过比较变量的当前值与预期值是否相同,
        如果两值相同则将变量的值更新为新值,否则不做任何操作
        
    并且还支持跳过JVM内存管理相关操作
*/
private static final Unsafe U = Unsafe.getUnsafe();

/** 
    通过 Unsafe 不安全类,绕过JVM的内存管理机制,直接访问硬件的内存
    获取 ConcurrentHashMap.class 类里面的 sizeCtl 的字段值
*/
private static final long SIZECTL
    = U.objectFieldOffset(ConcurrentHashMap.class, "sizeCtl");

private final Node<K,V>[] initTable() {
    // 声明两个局部变量,而不直接使用全局的table, sizeCtl 
    // 避免直接多次访问全局的 table,提高代码的效率。
    Node<K,V>[] tab; int sc;

    // 进入 while 循环时,首先检查 table 是否为 null 或者是否为空(长度为 0)。
    // 如果是,表示 table 还没有初始化
    while ((tab = table) == null || tab.length == 0) {
        // sizeCtl 负值集合处于初始化(-1)或者是扩容阶段(-2,-3....)
        if ((sc = sizeCtl) < 0) 
            Thread.yield(); // 所以当前线程礼让出CPU资源(CPU时间分片)

        // U.compareAndSetInt 该方法就是int类型的CAS
        // 如果 sizeCtl 的当前值 不小于 0,我们尝试通过 CAS(Compare-And-Set)操作 
        // 来将 sizeCtl 的值从 sc 修改为 -1,表示当前线程正在进行初始化操作。
        else if (U.compareAndSetInt(this, SIZECTL, sc, -1)) {
            try {
                // 再次检查 table 是否已经初始化,如果没有,则进行初始化。
                if ((tab = table) == null || tab.length == 0) { 
                    // 如果 sc(sizeCtl) 有正值,则表示当前容量大小,否则使用默认大小16
                    int n = (sc > 0) ? sc : DEFAULT_CAPACITY;

                    // 初始化:就是创建一个指定的大小数组赋值给table,并记录sizeCtl的值。
                    @SuppressWarnings("unchecked")
                    Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                    table = tab = nt;
                    // 计算并更新 sc 的值。sc 记录下一个阈值,即元素数量达到多少时需要扩容。
                    // 此计算方法是基于表格大小的 75%(即 n - (n >>> 2),
                    // 实际上就是 n * 0.75)。当元素数量超过该阈值时,将会触发扩容。
                    sc = n - (n >>> 2); // 可以理解成这样的 0.75 加载因子是写固定的0.75。
                }
            } finally {
                sizeCtl = sc;
            }
            break;
        }
    }
    return tab;
}

public class Test {
    public static void main(String[] args) {
        ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();
        map.put("key1", "value1");
        System.out.println(map);
    }
}


 


1.4 总结

ConcurrentHashMap 的初始化,如果是第一次进行初始化,则容量大小为 16,加载因子(0.75)计算的阈值为12。ConcurrentHashMap 由于是线程安全的,在初始化的体现通过 Unsafe不安全类提供CAS操作来实现。通过CAS 判断 sizeCtl 值。
sizeCtl 用于控制集合的扩容,初始化。比如 -1 表示正在初始化,其他的负数表示正在扩容 -2 一个线程扩容,-3 两个线程,以此类推,
sizeCtl = 0 表示 初始化完成了。
sizeCtl > 0 表示触发扩容的阈值。第一次为12 。



1.5 构造方法

    /** 最大值 */
    private static final int MAXIMUM_CAPACITY = 1 << 30;
    /** 默认容量大小 */
    private static final int DEFAULT_CAPACITY = 16;
    /** 默认加载因子大小 */
    private static final float LOAD_FACTOR = 0.75f;

    public ConcurrentHashMap() {}

    /**
     * @param initialCapacity 初始容量
     */
    public ConcurrentHashMap(int initialCapacity) {
        this(initialCapacity, LOAD_FACTOR, 1);
    }

    /**
     * @param initialCapacity 初始容量
     * @param loadFactor 加载因子
     */
    public ConcurrentHashMap(int initialCapacity, float loadFactor) {
        this(initialCapacity, loadFactor, 1);
    }

    /**
     * @param initialCapacity 初始容量
     * @param loadFactor 加载因子
     * @param concurrencyLevel 内部更新线程个数,估计将同时更新映射的线程数。
     * 此值用于帮助调整内部数据结构的大小,以减少线程间的竞争。
     */
    public ConcurrentHashMap(int initialCapacity, 
                             float loadFactor, 
                             int concurrencyLevel) {
        if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0)
            throw new IllegalArgumentException(); // 非法值,比如容量是负数之类

        // 如果初始容量小于并发级别,调整初始容量
        if (initialCapacity < concurrencyLevel) 
            initialCapacity = concurrencyLevel;   // 确保桶的数量至少与并发线程数一样多

        // 根据初始容量和负载因子计算内部表格的大小
        long size = (long)(1.0 + (long)initialCapacity / loadFactor);

        // 确保计算出的大小不超过最大容量
        int cap = (size >= (long) MAXIMUM_CAPACITY) ?
            MAXIMUM_CAPACITY : tableSizeFor((int)size);
        this.sizeCtl = cap;
    }

    /** 该方法支持传入一个map */
    public ConcurrentHashMap(Map<? extends K, ? extends V> m) {
        this.sizeCtl = DEFAULT_CAPACITY; // 阈值大小为 16
        putAll(m);
    }

1.6 总结

        ConcurrentHashMap 的构造方法,提供手动设置初始容量和加载因子以及内部线程个数。限制容量的范围大小 0 到 MAXIMUM_CAPACITY(1 << 30 )。
并且从里面也可以得知,在设置阈值大小的时候都是通过 sizeCtl (size Control ) 来接收。所以从这样也可以知道初始化的时候,用它来进行一个参看和判断。正数的就表示扩容阈值大小,等触发扩容,或者是初始化时,将其sizeCtl 改为 负值。 -1 表示 初始值,-2 表示一个线程扩容,-3 表示两个线程扩容,以此类推,毕竟ConcurrentHashMap支持线程安全。扩容完之后又将计算新的阈值大小赋值给 sizeCtl.
所以才说 sizeCtl 是掌管控制ConcurrentHashMap的初始化,扩容的一个重要变量。


1.7 新增功能

public V put(K key, V value) { return putVal(key, value, false); }


1.8 面试题:直接从上到下,添加一次就可以吗?为什么要通过一个for 进行无限循环添加?

        其实这并非是集合章节的知识点,这个是多线程章节的知识点。因为ConcurrentHashMap支持多线程并发,所以需要考虑到多线程的情况下。

        作用:持续重试和处理并发操作
        在并发数据结构中,许多操作(如插入、删除、扩容等)都可能需要在多个线程同时访问的环境下执行。使用无限循环可以确保:
        重试操作:如果某个操作(如插入、更新或删除)由于并发冲突(例如,另一个线程正在修改同一个桶或节点)而无法立即成功执行,程序可以在内部重新尝试执行该操作。
        保证条件满足后继续执行:在并发环境下,操作的执行结果通常依赖于其他线程的状态,因此可能需要在条件不满足时继续尝试直到成功。
        处理扩容等需要重新计算大小的操作:
        例如,在 ConcurrentHashMap 中,当插入元素导致负载过高时,可能需要扩容哈希表。扩容通常涉及以下步骤:
        1 重新计算哈希表的大小
        2 重新分配内存
        3 重新散列所有现有元素
        这些操作往往需要多次尝试,直到哈希表的大小适合当前元素的数量。在此过程中,循环结构可以帮助处理这一过程,直到操作完成。

        确保原子性操作
        在高并发环境下,有时需要使用 CAS(比较并交换) 或类似的机制(JDK7 的线程安全是通过 分段锁 Segment )来确保线程安全。例如,可能需要反复检查某个条件(如节点是否已被更新),直到确认操作成功。

        避免死锁或竞争条件
        使用无限循环可以防止由于竞争条件引起的操作失败。通过在循环中不断尝试,确保操作的成功执行,同时避免因并发冲突或竞争条件导致程序永久卡住。

        操作的延迟执行
        有些操作可能会依赖某些状态或条件,并且需要在特定的时机才执行。例如,在表格扩容时,可能需要等到某些条件满足才继续执行下一个步骤。无限循环允许在这些条件准备好时才继续操作。


1.9下面看新增逻辑分析

final V putVal(K key, V value, boolean onlyIfAbsent) {
        // 这里可以说明 ConcurrentHashMap 不支持key,value为null
        // 这样设计是为了避免二义性:何为二义性。
        /**
            在并发环境下,null 值可能会导致语义上的歧义,无法区分以下两种情况:
            key 不存在:调用 get(key) 返回 null,可能表示该 key 不存在。
            key 存在,但 value 为 null:调用 get(key) 返回 null,也可能表示该 key 存在,但其对应的 value 是 null。
            这种二义性会导致程序逻辑难以处理,尤其是在并发场景下,可能会引发难以调试的问题。
            
         */
        if (key == null || value == null) throw new NullPointerException();

        // 获取键 key 的哈希值,用于确定元素的位置,spread()进一步优化并降低哈希冲突的概率
        int hash = spread(key.hashCode()); 

        /** 记录当前桶中的元素个数 */
        int binCount = 0;
        // 忽略添加逻辑
        addCount(1L, binCount); // 记录当前桶的元素数量+1
        return null;
    }

        补充一下ConcurrentHashMap 当中桶 (bin) 指的是什么,指定的就是 Node<K, V>[] table 。table[] 有多大,就有多少个桶,而我们知道 table 。是在初始化或者扩容的时候进行更新。所以这里的 binCount 就是指定当前桶的元素个数。而元素放在哪个桶的位置,是由计算的hash值来决定的。

        新增逻辑大概考虑到五种情况

        【情况一】:第一次新增触发初始化

        【情况二】: 矫正hash计算得出的索引没有桶

        【情况三】:新增遇到正在扩容处理,通过helpTransfer 来处理

        【情况四】:处理key值相同,value不覆盖(ConcurrentHashMap默认都是key相同,value覆盖,有参数 onlyIfAbsent来决定,onlyIfAbsent 意思就是:是否只有值缺席的时候才添加。)

        【情况五】:正常插入元素。(链表树化成红黑树也在这里处理)

final V putVal(K key, V value, boolean onlyIfAbsent) {
        if (key == null || value == null) throw new NullPointerException();
        int hash = spread(key.hashCode());
        int binCount = 0;
        for (Node<K,V>[] tab = table;;) {
            Node<K,V> f; int n, i, fh; K fk; V fv;
            // 【情况一】:第一次新增触发初始化
            if (tab == null || (n = tab.length) == 0)
                tab = initTable();
            /**
             * 【情况二】:如果计算得到索引所对应的桶为null: (f = null) == null
             *   当索引对应的桶为null,就通过CAS重新将元素添加到桶里面。并break;表示新增结束
             *  额外讲解:避免了哈希值超过桶的范围
             *     i 表示 索引,(n - 1) & hash 作用
             *     假如当桶大小只有32时,但是计算出的索引超过了32,比如 55。
             *     n = 32,n - 1 = 31(二进制 11111)。
             *	   如果 hash = 55,二进制表示为 110111。
             *     (55 & 31) 的结果是 55 & 11111 = 111,即索引为 7。
             */
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
                if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
                    break;                   // no lock when adding to empty bin
            }

            /**
             * 【情况三】:如果桶的哈希值为 MOVED,表示该位置正在进行哈希表扩容操作。
             * 此时调用 helpTransfer() 来帮助完成扩容
             */
            else if ((fh = f.hash) == MOVED)
                tab = helpTransfer(tab, f);

            /**
             * 【情况四】:
             * 如果 onlyIfAbsent 为 true
             * key 相同,value就不更新了。
             * 而put方法,put(K key, V value) { return putVal(key, value, false); }
             * 很显然是key相同,value覆盖更新 
             */
            else if (onlyIfAbsent // check first node without acquiring lock
                     && fh == hash
                     && ((fk = f.key) == key || (fk != null && key.equals(fk)))
                     && (fv = f.val) != null)
                return fv;

            // 【情况五】:正常的插入与更新
            else {
                V oldVal = null;
                synchronized (f) { //加锁 synchronized (f),确保线程安全。
                    if (tabAt(tab, i) == f) {
                        /** 
                         * 链表(fh >= 0):遍历当前桶中的链表,查找键是否存在。
                         * 如果存在,更新值;如果不存在,将新节点添加到链表末尾。
                         * 俗称:key 相同时,key不变,value覆盖
                         */
                        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 不冲突,直接添加key, value 
                                // (ConcurrentHashMap里面的元素都是以Node为基础。)
                                // 所以通过新增的put(key, value); 都会封装成一个Node 元素。
                                // 这个Node里面存储的真正key和value
                                Node<K,V> pred = e;
                                if ((e = e.next) == null) {
                                    pred.next = new Node<K,V>(hash, key, value);
                                    break;
                                }
                            }
                        }
                        // 如果当前桶已经是红黑树,则通过 putTreeVal() 方法处理。
                        else if (f instanceof TreeBin) {
                            Node<K,V> p;
                            binCount = 2;
                            if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                                           value)) != null) {
                                oldVal = p.val;
                                if (!onlyIfAbsent)
                                    p.val = value;
                            }
                        }
                        // 保留节点处理(f instanceof ReservationNode):
                        // 如果桶中是保留节点,则抛出异常
                        else if (f instanceof ReservationNode)
                            throw new IllegalStateException("Recursive update");
                    }
                }
                // 链表树化:桶中的元素数量大于等于 TREEIFY_THRESHOLD(默认 8),转红黑树
                if (binCount != 0) {
                    if (binCount >= TREEIFY_THRESHOLD)
                        treeifyBin(tab, i);
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }
        }
        addCount(1L, binCount);
        return null;
    }

2.0 扩容机制 tryPresize

        tryPresize 旨在根据给定的 size 计算新的容量,并尝试扩容哈希表。它会根据表的当前状态,所需容量和线程安全的操作来决定是否进行扩容。

在代码内部我们可以看出,两处需要进行扩容处理。一处是putAll()方法。一个是在新增时有一处是 链表树化的扩容。

public void putAll(Map<? extends K, ? extends V> m) {
    tryPresize(m.size());
    for (Map.Entry<? extends K, ? extends V> e : m.entrySet())
    putVal(e.getKey(), e.getValue(), false);
}
    private final void treeifyBin(Node<K,V>[] tab, int index) {
        Node<K,V> b; int n;
        if (tab != null) {
            if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
                tryPresize(n << 1);
            // ...... 忽略其他代码

看扩容逻辑代码

private final void tryPresize(int size) {
    /** 
     * 扩容的大小 = c
     * 根据传入的size大小来决定,如果size大于最大值MAXIMUM_CAPACITY,
     * 那么c就是最大值,调用 tableSizeFor 方法计算一个合适的容量。
     * tableSizeFor 的作用是返回大于等于给定值的最小 2 的幂次方
     */
    int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY :
        tableSizeFor(size + (size >>> 1) + 1);
    int sc;

    /** 这里我们都知道,1.3 初始化功能的时候提到过
        sizeCtl 有多重身份,
            sizeCtl < 0 时,表示正在处于初始化或者是扩容当中, 
                -1 表示初始化, -2,-3表示扩容,-2是一条线程,-3是两条线程扩容,依次类推
            sizeCtl = 0 时,初始化完成了,
            sizeCtl > 0 时,sizeCtl表示加载的阈值(有容量大小 X 加载因子得来的)
            
     */
    while ((sc = sizeCtl) >= 0) {
        Node<K,V>[] tab = table; int n;
        // 处理初始化
        if (tab == null || (n = tab.length) == 0) {
            n = (sc > c) ? sc : c; // n = 本次最终扩容的大小
            if (U.compareAndSetInt(this, SIZECTL, sc, -1)) { // 通过CAS进行更新
                try {
                    if (table == tab) {
                        @SuppressWarnings("unchecked")
                        Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                        table = nt;
                        // 重新计算 sizeCtl 新的阈值(n - (n >>> 2),即 0.75 * n)
                        // 这里是通过位运算来的。写死了 0.75 加载因子
                        sc = n - (n >>> 2);
                    }
                } finally {
                    sizeCtl = sc;
                }
            }
        }
        // 目标容量不在范围内,不处理扩容了
        // 如果目标容量 c 小于等于当前阈值 sc,或者当前容量 n 已经达到最大容量 MAXIMUM_CAPACITY
        else if (c <= sc || n >= MAXIMUM_CAPACITY)
            break;
        // 触发扩容机制
        else if (tab == table) {
            int rs = resizeStamp(n);
            if (U.compareAndSetInt(this, SIZECTL, sc,
                                    (rs << RESIZE_STAMP_SHIFT) + 2))
                transfer(tab, null);
        }
    }
}

   


http://www.kler.cn/a/559354.html

相关文章:

  • 模型评测:基于Python和PyTorch的深度学习模型性能评估
  • Redis的弊端
  • vue3 Props的使用
  • SwinTransformer 改进:添加SimAM轻量级注意力机制
  • 第十八天 WebView深度优化指南
  • PH热榜 | 2025-02-23
  • 记录一个ES分词器不生效的解决过程
  • PHP课程预约小程序源码
  • ubuntu24.04无法安装向日葵,提示依赖libgconf-2-4怎么办?
  • 孜然单授权系统V2.0PHP授权系统
  • 《Mycat核心技术》第17章:实现MySQL的读写分离
  • 无人机+DeepSeek:放飞自我的智能化技术详解!
  • 【Rust中级教程】2.7. API设计原则之灵活性(flexible) Pt.3:借用 vs. 拥有、`Cow`类型、可失败和阻塞的析构函数及解决办法
  • 【行业解决方案篇八】【DeepSeek农业遥感:作物病虫害识别指南】
  • 使用 Spark NLP 实现中文实体抽取与关系提取
  • Linux之文件系统
  • vue2的计算属性
  • 【刷题】贪心算法
  • 算法笔记 03 —— 算法初步(上)
  • Java并发编程——ThreadLocal