Java7中的 ConcurrentHashMap存储结构

6 篇文章 0 订阅
5 篇文章 0 订阅

Java7中的 ConcurrentHashMap总体的设计思路和Java7中的HashMap差不多,但是因为需要支持并发的操作,那么就需要对其中的数据结构进行加锁处理,用以保证数据的一致性。

值得一题的是ConcurrentHashMap不支持Null值和Null键

按照Java7中hashMap的思路,我们去查找这么一个带锁的可以存储数据的成员,发现,似乎只有以下这么一个可疑对象:

    /**
     * The segments, each of which is a specialized hash table. 
     * 每一段都是一个hash表
     */
    final Segment<K, V>[] segments;

在Segment的类定义上可以看到是继承了可重入锁:

 /**
     * Segments are specialized versions of hash tables.  This
     * subclasses from ReentrantLock opportunistically, just to
     * simplify some locking and avoid separate construction.
     */
static final class Segment<K, V> extends ReentrantLock implements Serializable {...

该类的成员变量中有我们熟悉的table变量

        /**
         * The per-segment table. Elements are accessed via
         * entryAt/setEntryAt providing volatile semantics.
         */
        transient volatile HashEntry<K, V>[] table;

至此,我们找到了对应的锁,以及存储数据的数组,那么可以判断出来ConcurrentHashMap的结构大致为:

ConcurrentHashMap 由一个个 Segment 组成(简言之就是Segment的数组),Segment 代表”部分“或”一段“的意思(其通过继承ReentrantLock实现了加锁操作),所以很多地方都会将其描述为分段锁

PS:因为每次需要加锁的操作锁住的是一个 segment,这样只要保证每个 Segment 是线程安全的,也就实现了全局的线程安全。

那么ConcurrentHashMap的内部组成大致如下:

成员变量: 

    //默认初始容量
    static final int DEFAULT_INITIAL_CAPACITY = 16;

    //默认加载因子(针对Segment数组中的某个Segment中的HashEntry数组扩容)
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    //默认的并行级别 16(Segment数组的大小),也成为并发量
    static final int DEFAULT_CONCURRENCY_LEVEL = 16;

    //最大容量
    static final int MAXIMUM_CAPACITY = 1 << 30;

    //一个Segment的HashEntry数组的最小容量
    static final int MIN_SEGMENT_TABLE_CAPACITY = 2;

    //一个Segment的HashEntry数组的最大容量
    static final int MAX_SEGMENTS = 1 << 16; // slightly conservative

    // 锁之前重试次数
    static final int RETRIES_BEFORE_LOCK = 2;

构造方法:类中共提供了5个构造,其他构造都是下面构造的重载

public ConcurrentHashMap(int initialCapacity,
                          float loadFactor, int concurrencyLevel) {...//核心构造

 

初始化:构造中的核心代码如下

initialCapacity:初始容量,这个值指的是整个 ConcurrentHashMap 的初始容量,
                 实际操作的时候需要平均分给每个 Segment。

loadFactor:负载因子,因为Segment 数组不可以扩容,所以这个负载因子是给每个 Segment 内部使用的。

 

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;// 默认计算结果是 4
            ssize <<= 1; // 计算下来是16 2的4次方
        }
        //用默认值,concurrencyLevel 为 16,sshift 为 4
        // 那么计算出 segmentShift 为 28,segmentMask 为 15,后面会用到这两个值
        this.segmentShift = 32 - sshift; // 默认计算完是28
        this.segmentMask = ssize - 1; // 默认15
        if (initialCapacity > MAXIMUM_CAPACITY)// initialCapacity默认16
            initialCapacity = MAXIMUM_CAPACITY;
        // initialCapacity 是设置整个 map 初始的大小,
        // 根据 initialCapacity 计算 Segment 数组中每个位置可以分到的大小
        // 如 initialCapacity 为 64,那么每个 Segment 可以分到 4 个
        int c = initialCapacity / ssize; // 默认是1
        if (c * ssize < initialCapacity)
            ++c;
        // 默认 MIN_SEGMENT_TABLE_CAPACITY 是 2,这个值也是有讲究的,因为这样的话,对于具体的槽上,
        // 插入一个元素不至于扩容,插入第二个的时候才会扩容
        int cap = MIN_SEGMENT_TABLE_CAPACITY;
        while (cap < c)
            cap <<= 1;
        // create segments and segments[0]
        // 创建 Segment 数组,
        // 并创建数组的第一个元素 segment[0]
        Segment<K, V> s0 =
                new Segment<K, V>(loadFactor, (int) (cap * loadFactor),
                        (HashEntry<K, V>[]) new HashEntry[cap]);
        // 创建一个segment数组
        Segment<K, V>[] ss = (Segment<K, V>[]) new Segment[ssize];
        // 往数组写入 segment[0]
        UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
        this.segments = ss; //  默认构建了一个 长度为16的segment数组
    }

 put 方法分析:

    public V put(K key, V value) {
        Segment<K, V> s;
        if (value == null)
            throw new NullPointerException();
//      1.计算key的hash值
        int hash = hash(key); // 根据key找segment
//      2.根据hash值找到segment数组中的位置 j hash 是 32 位,
//        无符号右移 segmentShift(28) 位,剩下高 4 位,然后和 segmentMask(15) 做一次与操作,也就是说 j 是 hash 值的高 4 位,也就是槽的数组下标
        int j = (hash >>> segmentShift) & segmentMask;
//        初始化的时候初始化了 segment[0],但是其他位置还是 null, ensureSegment(j) 对 segment[j] 进行初始化
        if ((s = (Segment<K, V>) UNSAFE.getObject          // nonvolatile; recheck
                (segments, (j << SSHIFT) + SBASE)) == null) //  in ensureSegment
            s = ensureSegment(j);
//      3.插入新值到segment中
        return s.put(key, hash, value, false);
    }
  •  首先,计算key的hash值
  • 其次,根据hash值找到需要操作的Segment的数组位置
  • 最后,将新值插入到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;
    if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
        // 这里看到为什么之前要初始化 segment[0] 了,
        // 使用当前 segment[0] 处的数组长度和负载因子来初始化 segment[k]
        // 为什么要用“当前”,因为 segment[0] 可能早就扩容过了
        Segment<K,V> proto = ss[0];
        int cap = proto.table.length;
        float lf = proto.loadFactor;
        int threshold = (int)(cap * lf);

        // 初始化 segment[k] 内部的数组
        HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
        if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
            == null) { // 再次检查一遍该槽是否被其他线程初始化了。

            Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
            // 使用 while 循环,内部用 CAS,当前线程成功设值或其他线程成功设值后,退出
            while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
                   == null) {
                if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
                    break;
            }
        }
    }
    return seg;
}

 

       final V put(K key, int hash, V value, boolean onlyIfAbsent) {
//            在向segment 写入前,需要先获取该 segment 的独占锁
            HashEntry<K, V> node = tryLock() ? null :
                    scanAndLockForPut(key, hash, value); // put时锁定。如果当前没这条数据,则会返回新创建的HashEntry,否则为空
            V oldValue;
            try {
//              segment 内部的数组
                HashEntry<K, V>[] tab = table;
                // 再利用 hash 值,求应该放置的数组下标
                int index = (tab.length - 1) & hash;
                // 返回数组中对应位置的元素(链表头部)
                HashEntry<K, V> first = entryAt(tab, index);
            //循环链表
                for (HashEntry<K, V> e = first; ; ) {
                    if (e != null) {// 如果已经存在值,覆盖旧值
                        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 { // 如果数组对应位置为空
                        if (node != null) // 非空,则表示为新创建的值
                            node.setNext(first);
                        else
                            node = new HashEntry<K, V>(hash, key, value, first); // 否则创建一个
                        int c = count + 1;
                        //如果超过了该 segment 的阈值,这个 segment 需要扩容
                        if (c > threshold && tab.length < MAXIMUM_CAPACITY)
                            rehash(node); // 超过了容量阈值,但没达到最大限制,则扩容table
//                        没有达到阈值,将 node 放到数组 tab 的 index 位置,
                        else
                            setEntryAt(tab, index, node); // 直接用新的node,替换掉旧的first node
                        ++modCount;
                        count = c;
                        oldValue = null;
                        break;
                    }
                }
            } finally {
                unlock();//最终释放锁
            }
            return oldValue;
        }

简单理解主要分为三个部分,为实现线程安全,首先要加锁,然后操作数据(这个部分稍复杂些),最后释放锁 

 获取写入锁:在tryLock成功时,获取锁,在重试次数超过最大次数后方法阻塞直至成功获取锁

scanAndLockForPut(key, hash, value)
private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
    HashEntry<K,V> first = entryForHash(this, hash);
    HashEntry<K,V> e = first;
    HashEntry<K,V> node = null;
    int retries = -1; // negative while locating node

    // 循环获取锁
    while (!tryLock()) {//---如果tryLock成功及跳出循环
        HashEntry<K,V> f; // to recheck first below
        if (retries < 0) {
            if (e == null) {
                if (node == null) // speculatively create node
                    // 进到这里说明数组该位置的链表是空的,没有任何元素
                    // 当然,进到这里的另一个原因是 tryLock() 失败,所以该分段锁存在并发,不一定是该位置
                    node = new HashEntry<K,V>(hash, key, value, null);
                retries = 0;
            }
            else if (key.equals(e.key))
                retries = 0;
            else
                // 顺着链表往下走
                e = e.next;
        }
        // 重试次数如果超过 MAX_SCAN_RETRIES(单核1多核64),那么不抢了,进入到阻塞队列等待锁
        //    lock() 是阻塞方法,直到获取锁后返回
        else if (++retries > MAX_SCAN_RETRIES) {
            lock();
            break;
        }
        else if ((retries & 1) == 0 &&
                 // 这个时候是有大问题了,那就是有新的元素进到了链表,成为了新的表头
                 //     所以这边的策略是,相当于重新走一遍这个 scanAndLockForPut 方法
                 (f = entryForHash(this, hash)) != first) {
            e = first = f; // re-traverse if entry changed
            retries = -1;
        }
    }
    return node;
}

 Segment内部的数组扩容:扩容比例是原来的2倍,这个和Java7 HashMap一致

// 方法参数上的 node 是这次扩容后,需要添加到新的数组中的数据。
private void rehash(HashEntry<K,V> node) {
    HashEntry<K,V>[] oldTable = table;
    int oldCapacity = oldTable.length;
    // 2 倍
    int newCapacity = oldCapacity << 1;
    threshold = (int)(newCapacity * loadFactor);
    // 创建新数组
    HashEntry<K,V>[] newTable =
        (HashEntry<K,V>[]) new HashEntry[newCapacity];
    // 新的掩码,如从 16 扩容到 32,那么 sizeMask 为 31,对应二进制 ‘000...00011111’
    int sizeMask = newCapacity - 1;

    // 遍历原数组,老套路,将原数组位置 i 处的链表拆分到 新数组位置 i 和 i+oldCap 两个位置
    for (int i = 0; i < oldCapacity ; i++) {
        // e 是链表的第一个元素
        HashEntry<K,V> e = oldTable[i];
        if (e != null) {
            HashEntry<K,V> next = e.next;
            // 计算应该放置在新数组中的位置,
            // 假设原数组长度为 16,e 在 oldTable[3] 处,那么 idx 只可能是 3 或者是 3 + 16 = 19
            int idx = e.hash & sizeMask;
            if (next == null)   // 该位置处只有一个元素,那比较好办
                newTable[idx] = e;
            else { // Reuse consecutive sequence at same slot
                // e 是链表表头
                HashEntry<K,V> lastRun = e;
                // idx 是当前链表的头结点 e 的新位置
                int lastIdx = idx;

                // 下面这个 for 循环会找到一个 lastRun 节点,这个节点之后的所有元素是将要放到一起的
                for (HashEntry<K,V> last = next;
                     last != null;
                     last = last.next) {
                    int k = last.hash & sizeMask;
                    if (k != lastIdx) {
                        lastIdx = k;
                        lastRun = last;
                    }
                }
                // 将 lastRun 及其之后的所有节点组成的这个链表放到 lastIdx 这个位置
                newTable[lastIdx] = lastRun;
                // 下面的操作是处理 lastRun 之前的节点,
                //    这些节点可能分配在另一个链表中,也可能分配到上面的那个链表中
                for (HashEntry<K,V> p = e; p != lastRun; p = p.next) {
                    V v = p.value;
                    int h = p.hash;
                    int k = h & sizeMask;
                    HashEntry<K,V> n = newTable[k];
                    newTable[k] = new HashEntry<K,V>(h, p.key, v, n);
                }
            }
        }
    }
    // 将新来的 node 放到新数组中刚刚的 两个链表之一 的 头部
    int nodeIndex = node.hash & sizeMask; // add the new node
    node.setNext(newTable[nodeIndex]);
    newTable[nodeIndex] = node;
    table = newTable;
}

 该部分的扩容逻辑比起Java7 HashMap较为复杂,容许再度消化下。

get方法分析

  • 计算hash值,定位到segment数组的位置
  • 根据上面的hash值再度定位到Segment分段锁下,数据存储的位置
  • 此时已经获取到连接结构,获取逻辑至此已经明了了 
public V get(Object key) {
    Segment<K,V> s; // manually integrate access methods to reduce overhead
    HashEntry<K,V>[] tab;
    // 1. hash 值
    int h = hash(key);
    long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
    // 2. 根据 hash 找到对应的 segment
    if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
        (tab = s.table) != null) {
        // 3. 找到segment 内部数组相应位置的链表,遍历
        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;
}

 总结(摘录他人的思考):

在 put 过程和 get 过程,我们可以看到 get 过程中是没有加锁的,那自然我们就需要去考虑并发问题。

添加节点的操作 put 和删除节点的操作 remove 都是要加 segment 上的独占锁的,
所以它们之间不会有问题,那么 get 的时候在同一个 segment 中发生了 put 或 remove时会怎样呢?

put 操作的线程安全性。

初始化分段锁,使用了 CAS 来初始化 Segment 中的数组。
添加节点到链表的操作是插入到表头的,
    所以,如果这个时候 get 操作在链表遍历的过程已经到了中间,是不会影响的。
    当然,另一个并发问题就是 get 操作在 put 之后,需要保证刚刚插入表头的节点被读取,
    这个依赖于 setEntryAt 方法中使用的 UNSAFE.putOrderedObject。
扩容。
    扩容是新创建了数组,然后进行迁移数据,最后面将 newTable 设置给属性 table。
    所以,如果 get 操作此时也在进行,那么也没关系,如果 get 先行,
    那么就是在旧的 table 上做查询操作;而 put 先行,那么 put 操作的可见性保证就是 table 使用了 volatile 关键字。
remove 操作的线程安全性。

remove 操作我们没有分析源码,所以这里说的读者感兴趣的话还是需要到源码中去求实一下的。

get 操作需要遍历链表,但是 remove 操作会"破坏"链表。

如果 remove 破坏的节点 get 操作已经过去了,那么这里不存在任何问题。

如果 remove 先破坏了一个节点,分两种情况考虑。 

1、如果此节点是头结点,那么需要将头结点的 next 设置为数组该位置的元素,table 虽然使用了 volatile 修饰,
    但是 volatile 并不能提供数组内部操作的可见性保证,所以源码中使用了 UNSAFE 来操作数组,请看方法 setEntryAt。
2、如果要删除的节点不是头结点,它会将要删除节点的后继节点接到前驱节点中,
    这里的并发保证就是 next 属性是 volatile 的。

 

  • 3
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值