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

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

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来体现的, 并没有表现出特别大的优势.
以上, 是笔者的见解.

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值