[Interview系列 知识储备回顾] 集合篇 - Map[1]

本文详细解析了ConcurrentHashMap在JDK1.7与JDK1.8中的实现原理,对比了分段锁与无锁化设计,阐述了不同版本中解决线程安全的方法。

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

concurrentHashMap在JDK 1.7怎么解决线程安全

    // 分段数组
    final Segment<K,V>[] segments;
    static final class Segment<K,V> extends ReentrantLock implements Serializable {
        transient volatile HashEntry<K,V>[] table;
        transient int count;  
        transient int modCount;
        transient int threshold;
        final float loadFactor;
    //  hash节点
    static final class HashEntry<K,V> {
        final int hash;
        final K key;
        volatile V value;
        volatile HashEntry<K,V> next;

在JDK 1.7版本, 底层将数组分为多段进行存储/查找.

	/**
     * The default initial capacity for this table,
     * used when not otherwise specified in a constructor.
     * 默认容量大小
     */
    static final int DEFAULT_INITIAL_CAPACITY = 16;

    /**
     * The default load factor for this table, used when not
     * otherwise specified in a constructor.
     * 默认负载因子
     */
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    /**
     * The default concurrency level for this table, used when not
     * otherwise specified in a constructor.
     * 默认的并发级别(这里是segment的默认容量大小)
     */
    static final int DEFAULT_CONCURRENCY_LEVEL = 16;

	public ConcurrentHashMap(int initialCapacity,
                             float loadFactor, int concurrencyLevel) {
        if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
            throw new IllegalArgumentException();
        // max_segments 是2^16, 意思是segment数组长度至少为2.
        if (concurrencyLevel > MAX_SEGMENTS)
            concurrencyLevel = MAX_SEGMENTS;
        // Find power-of-two sizes best matching arguments
        int sshift = 0;
        int ssize = 1;
        // 默认传入的concurrencyLevel为16
        // 如果传入的concurrencyLevel不为2的幂次方, ssize很巧妙地获取了2的幂次方值.
        while (ssize < concurrencyLevel) {
            ++sshift;
            ssize <<= 1;
        }
        this.segmentShift = 32 - sshift;
        this.segmentMask = ssize - 1;
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        int c = initialCapacity / ssize;
        if (c * ssize < initialCapacity)
            ++c;
        int cap = MIN_SEGMENT_TABLE_CAPACITY;
        while (cap < c)
            cap <<= 1;
        // create segments and segments[0]
        Segment<K,V> s0 =
            new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
                             (HashEntry<K,V>[])new HashEntry[cap]);
        // 这里可以了解到segment数组默认的容量大小为16
        Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
        UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
        this.segments = ss;
    }

put API 详解

public V put(K key, V value) {
    Segment<K,V> s;
    if (value == null)
        throw new NullPointerException();
    int hash = hash(key);
    int j = (hash >>> segmentShift) & segmentMask;
    // 通过内存偏移量来获取Segment数组
    if ((s = (Segment<K,V>)UNSAFE.getObject          // nonvolatile; recheck
         (segments, (j << SSHIFT) + SBASE)) == null) //  in ensureSegment and
       // 如果对应index的segment为空, 需要创建并且通过cas赋值到table.
        s = ensureSegment(j);
    return s.put(key, hash, value, false);
}
// 确保segment初始化
private Segment<K,V> ensureSegment(int k) {
    final Segment<K,V>[] ss = this.segments;
    long u = (k << SSHIFT) + SBASE; // raw offset
    Segment<K,V> seg;
    // segment如果没有初始化
    if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
        Segment<K,V> proto = ss[0]; // use segment 0 as prototype
        int cap = proto.table.length;
        float lf = proto.loadFactor;
        int threshold = (int)(cap * lf);
        // 创建一个tab, 通过cas赋值到对应的seg
        HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
        if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
            == null) { // recheck
            // 创建seg
            Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
            // 自旋去赋值seg
            while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
                   == null) {
                if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
                    break;
            }
        }
    }
    return seg;
}
// segment#put API
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
    // 第一次尝试获取lock
    // 如果为true, node为null
    // 如果为false, scanAndLockForPut 尝试自旋去获取锁, 如果在超出自旋次数, 就挂起当前线程.
    HashEntry<K,V> node = tryLock() ? null :
        scanAndLockForPut(key, hash, value);
    V oldValue;
    try {
        HashEntry<K,V>[] tab = table;
        int index = (tab.length - 1) & hash;
        // 获取对应下标的HashEntry
        HashEntry<K,V> first = entryAt(tab, index);
        for (HashEntry<K,V> e = first;;) {
            // 如果e不为空
            if (e != null) {
                K k;
                // 判断hash和equals
                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 {
                // 如果第一次tryLock没获取到锁, 但是通过自旋获取到node之后, 判断node是否为null
                if (node != null)
                    node.setNext(first);
                else
                    node = new HashEntry<K,V>(hash, key, value, first);
                int c = count + 1;
                if (c > threshold && tab.length < MAXIMUM_CAPACITY)
                    rehash(node);
                else
                    setEntryAt(tab, index, node);
                ++modCount;
                count = c;
                oldValue = null;
                break;
            }
        }
    } finally {
        unlock();
    }
    return oldValue;
}

简单概括一下put的流程

  • 第一次进行hash运算, 通过hashsegment偏移量进行右移运算并与segmentmask值进行位与运算.
  • 通过其结果内存偏移量来获取Segment数组, 如果segment数组为空, 创建tab并通过cas自旋的方式赋值给seg
  • 尝试tryLock获取锁, 如果获取成功, 取模获取下标值, 获取对应下标的HashEntry
  • 进行循环判断当前HashEntry是否存在, 如果存在就判断其节点的hashequals是否一致, 如果不存在就新建一个HashEntry, 并判断是否需要扩容, 如果没有超出阈值, 就直接赋值到对应的下标.
  • 如果tryLock获取锁失败, scanAndLockForPut内部尝试不断自旋获取锁, 如果超出自旋次数就挂起, 等待唤醒获取锁. 直到获取到锁, 并查到对应的HashEntry节点.
  • 跟上述的循环判断差不多一致, 只不过node不为null, 可以直接赋值, 不需要通过new来创建新的HashEntry, 这个步骤在scanAndLockForPut中已经执行过了.

segment分段锁, 有效地减小了并发冲突的概率, 但是相应的 get/put 性能不高.

concurrentHashMap在JDK 1.8怎么解决线程安全

在JDK 1.8版本中, 底层不再使用segment分段锁, 但是这个类并没有在JDK1.8中移除, 官方考虑到兼容性, 而保留了部分属性和API.

/**
     * Table initialization and resizing control.  When negative, the
     * table is being initialized or resized: -1 for initialization,
     * else -(1 + the number of active resizing threads).  Otherwise,
     * when table is null, holds the initial table size to use upon
     * creation, or 0 for default. After initialization, holds the
     * next element count value upon which to resize the table.
     * 这个是非常重要的一个成员变量.
     * 作为数组的控制符, 控制数组的初始化和扩容操作.
     * 比如该变量为负值时, -1代表正在进行初始化操作, -N代表有N-1个线程正在进行扩容.
     * 
     * 是的..你没听错, 是多个线程共同参与扩容操作, 而不是单个线程扩容, 其他线程等待其扩容完毕.
     * 有点像是forkjoin的工作窃取事项, 保证了多核运算的并行效率最大化.
     * 
     * 如果该变量为0时, 代表数组还未进行初始化.
     * 如果该变量为正数时, 代表数组下一次要进行扩容的阈值.
     */
    private transient volatile int sizeCtl;

还是老规矩, 先看构造函数, 再看put 方法

 public ConcurrentHashMap(int initialCapacity, float loadFactor, int concurrencyLevel) {
    if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0)
        throw new IllegalArgumentException();
    if (initialCapacity < concurrencyLevel)   // Use at least as many bins
        initialCapacity = concurrencyLevel;   // as estimated threads
    // 默认容量大小根据负载因子计算容量
    long size = (long)(1.0 + (long)initialCapacity / loadFactor);
    int cap = (size >= (long)MAXIMUM_CAPACITY) ?
        // tableSizeFor跟HashMap一致
        MAXIMUM_CAPACITY : tableSizeFor((int)size);
    this.sizeCtl = cap;
}

跟JDK 1.7相比, 1.8版本通俗易懂了更多.

/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) throw new NullPointerException();
    // hash运算
    int hash = spread(key.hashCode());
    int binCount = 0;
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;
        if (tab == null || (n = tab.length) == 0)
        // 初始化table
            tab = initTable();
        // 如果对应的tab下标为空, 通过cas的方式赋值, 成功就退出循环, 否则自旋重试, 当然第二次估计已经不为空了.
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            if (casTabAt(tab, i, null,
                         new Node<K,V>(hash, key, value, null)))
                break;                   // no lock when adding to empty bin
        }
        // 判断是否正在扩容, 如果是则让当前线程参与扩容操作.
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);
        else {
            V oldVal = null;
            // 这里考虑到竞争比较强烈, 不考虑cas自旋, 会占用太多cpu的资源.
            synchronized (f) {
                // 有点像double-check?
                if (tabAt(tab, i) == f) {
                    if (fh >= 0) {
                        binCount = 1;
                        for (Node<K,V> e = f;; ++binCount) {
                            K ek;
                            // hash和equals判断
                            if (e.hash == hash &&
                                ((ek = e.key) == key ||
                                 (ek != null && key.equals(ek)))) {
                                oldVal = e.val;
                                if (!onlyIfAbsent)
                                    e.val = value;
                                break;
                            }
                            Node<K,V> pred = e;
                            // 链表next节点为null, 直接挂上.
                            if ((e = e.next) == null) {
                                pred.next = new Node<K,V>(hash, key,
                                                          value, null);
                                break;
                            }
                        }
                    }
                    // 为红黑树
                    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;
                        }
                    }
                }
            }
            if (binCount != 0) {
            // 超出树阈值
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
    // size + 1, 并且判断是否需要扩容(#resizeStamp, #transfer)
    addCount(1L, binCount);
    return null;
}

参与扩容的过长, 主要是通过ForwardingNode这个对象来判断当前数组对应下标的节点是否已经处理过或者为空, 如果节点都已经处理完, 则会通过cas方式更新下一次需要扩容的阈值, 并将当前的sizeCtl赋值为下一次需要扩容的阈值, 这里扩容的阈值是当前tab数组长度的1.5倍.
源码中是这样的 sizeCtl = (n << 1) - (n >>> 1);

在最后提一下, 为什么JDK 1.8版本之后不再使用segment分段锁吧.
笔者的个人见解是这样的.
分段锁的确提升了并行效率, 但是由于需要获取两次下标(segment[]HashEntry[]), 在性能上并不高效. 而且在JDK 1.8版本中, 对于链表和红黑树的遍历插入操作, 采取的是synchronized加锁, 考虑到读写场景的竞争, cas采取的策略是减小用户态和内核态之间切换的开销, 代表是占用当前的cpu资源不释放, 当然超出自旋次数会挂起. synchronized作为jvm锁, 在jdk版本的不断迭代过长中, jvm锁的性能也逐渐跟了上来, 对应的也有锁升级过长, 从无锁偏向锁自旋锁重量级锁, 性能和Lock锁并没有差距太大.更何况在写场景竞争大的场景, cas的表现并没有synchronized表现的更加优秀.
还有一点, lock锁最大的优点就是在粒度比较大的场景, condition非常灵活, 但是在这种粒度比较小的场景, 拿单个condition和单个monitor来体现的, 并没有表现出特别大的优势.
以上, 是笔者的见解.

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值