ConcurrentHashMap(1.7+1.8)

1.7版本

在这里插入图片描述

它维护了一个 segment 数组,每个 segment 对应一把锁

  • 优点:如果多个线程访问不同的 segment,实际是没有冲突的,这与 jdk8 中是类似的(jdk8中是把锁加在链表头上,jdk7是把锁加在segment对象上)
  • 缺点:Segments 数组默认大小为16,这个容量初始化指定后就不能改变了,并且不是懒惰初始化(构造方法一执行就会创建需要用到的数组)

构造器分析

   	// 默认传入的initialCapacity为16(初始容量,即所有Segment数组存储键值对数量的总和)
	// loadFactor为0.75
	// concurrencyLevel为16(并发度,即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;
        int sshift = 0;
        int ssize = 1;
        // sshift是ssize从1向左移位的次数。默认情况下,concurrencyLevel是16,则sshift是4
        // ssize 必须是 2^n, 即 2, 4, 8, 16 ... 表示了 segments 数组的大小,默认为16
        while (ssize < concurrencyLevel) {
            ++sshift;
            ssize <<= 1;
        }
        // segmentShift 默认是 32 - 4 = 28
        // this.segmentShift 和 this.segmentMask 的作用是决定将 key 的 hash 结果匹配到哪个 segment
        this.segmentShift = 32 - sshift;
        // segmentMask是散列运算的掩码,segmentMask = 并发度 - 1,默认为15 即 0000 0000 0000 1111
        this.segmentMask = ssize - 1;
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        
        // 根据initialCapacity计算Segment数组中每个位置可以分到的大小,向上取整
        int c = initialCapacity / ssize;
        if (c * ssize < initialCapacity)
            ++c;
        
        // 默认MIN_SEGMENT_TABLE_CAPACITY是2,插入一个元素不至于扩容,插入第二个的时候才会扩容
        int cap = MIN_SEGMENT_TABLE_CAPACITY;
        while (cap < c)
            cap <<= 1;
        // 创建 segments and segments[0](segments[0]存的是一个HashEntry数组)
        // 这里说明了创建数组过程中和jdk8的不同,此处是直接创建需要用到的数组,
        // 而不是jdk8的懒加载
        Segment<K,V> s0 =
                new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
                        (HashEntry<K,V>[])new HashEntry[cap]);
        Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
        // 往数组第一个位置写入s0
        UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
        this.segments = ss;
    }

构造完成,如下图所示

1601254924533

可以看到 ConcurrentHashMap 没有实现懒惰初始化,空间占用不友好
其中 this.segmentShift 和 this.segmentMask 的作用是决定将 key 的 hash 结果匹配到哪个 segment

例如,根据某一 hash 值求 segment 位置,先将该hash值从高位向低位移动 this.segmentShift 位

1601254966706

put() 方法

	public V put(K key, V value) {
        Segment<K,V> s;
        if (value == null)
            throw new NullPointerException();
        // 计算出hash
        int hash = hash(key);
        // 计算出 segment 下标,进行移位运算之后再进行与运算
        // 我们前面已经讲到,在默认情况下,segmentShift为28,
        // segmentMask为15,hash向右无符号移动28位,
        // 是为了能够让高四位参与到散列运算中。
        int j = (hash >>> segmentShift) & segmentMask;

        // 获得 segment 对象, 判断是否为 null, 是则创建该 segment
        if ((s = (Segment<K,V>)UNSAFE.getObject
                (segments, (j << SSHIFT) + SBASE)) == null) {
            // 这时不能确定是否真的为 null, 因为其它线程也可能发现该 segment 为 null
            // 因此在 ensureSegment 里用 cas 方式保证该 segment 安全性
            // ensureSegment中将创建Segment对象
            s = ensureSegment(j);
        }
        // concurrentHashMap实际上调用的是segment的put方法,进入 segment 的put 流程
        return s.put(key, hash, value, false);
    }


	// segment 继承了可重入锁(ReentrantLock),它的 put 方法为
    final V put(K key, int hash, V value, boolean onlyIfAbsent) {
        // tryLock()尝试加锁,tryLock()方法会立刻返回一个true或者false
        HashEntry<K,V> node = tryLock() ? null :
                // 如果不成功, 进入 scanAndLockForPut 流程
                // 如果是多核 cpu 最多 tryLock 64 次, 进入 lock 流程
                // 在尝试期间, 还可以顺便看该节点在链表中有没有, 如果没有顺便创建出来
                scanAndLockForPut(key, hash, value);

        // 执行到这里 segment 已经被成功加锁, 可以安全执行
        V oldValue;
        try {
            // HashEntry数组相当于一个小的hash表
            HashEntry<K,V>[] tab = table;
            int index = (tab.length - 1) & hash;
            // 找到链表的头结点
            HashEntry<K,V> first = entryAt(tab, index);
            for (HashEntry<K,V> e = first;;) {
                // 当前桶已经有元素
                if (e != null) {
                    K k;
                    // 如果存在key相同的情况则更新
                    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 {
                    // 1) 如果之前等待锁时, node 已经被创建, next 指向链表头
                    if (node != null)
                        node.setNext(first);
                    else
                   // 2) 创建新 node
                        node = new HashEntry<K,V>(hash, key, value, first);
                    int c = count + 1;
                    // 3) 扩容
                    if (c > threshold && tab.length < MAXIMUM_CAPACITY)
                        // 注意并没有先插入再扩容,而是把将要插入的节点的next指针指向当前链表头结点
                        // 随后就开始扩容
                        rehash(node);
                    else
                        // 将 node 作为链表头,这里看到使用的是头插法
                        setEntryAt(tab, index, node);
                    ++modCount;
                    count = c;
                    oldValue = null;
                    break;
                }
            }
        } finally {
            unlock();
        }
        return oldValue;
    }

scanAndLockForPut方法

		private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
        	// 根据哈希值在当前Segment所对应的HashEntry数组
            // 中找到数组对应的HashEntry
            HashEntry<K,V> first = entryForHash(this, hash);
            HashEntry<K,V> e = first;
            HashEntry<K,V> node = null;
            int retries = -1; // negative while locating node
            // 再次尝试获取当前Segment锁,注意,每一个Segment本身就是一个ReentrantLock
            while (!tryLock()) {
                HashEntry<K,V> f; // to recheck first below
                if (retries < 0) {
                    // 如果第一次进入循环体,并且发现当前位置的HashEntry为null,还没放过数据
                    // 则把当前数据封装为HashEntry
                    if (e == null) {
                        if (node == null) // speculatively create node
                            node = new HashEntry<K,V>(hash, key, value, null);
                        retries = 0;
                    }
                    else if (key.equals(e.key))
                        retries = 0;
                    else
                        e = e.next;
                }
                // 这里是退出循环的逻辑,如果是多核CPU
                // 表示需要循环64次retries才能进入这里边
                else if (++retries > MAX_SCAN_RETRIES) {
                    lock();
                    break;
                }
                // 如果在偶数次进入循环体时
                // 当前数据hash值对应的元素与之前创建的元素不一样(发生了其他元素插入)
                // 那么需要重新进入第一轮循环的流程
                else if ((retries & 1) == 0 &&
                         (f = entryForHash(this, hash)) != first) {
                    e = first = f; // re-traverse if entry changed
                    retries = -1;
                }
            }
            // 一旦成功获取锁,则把封装的HashEntry返回出去
            return node;
        }

ensureSegment()方法

// 根据传来的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
        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);
        // 初始化segment[k]内部的数组
        HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
        if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
            == null) { // recheck
            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;
}

rehash() 方法

发生在 put 中,因为此时已经获得了锁,因此 rehash 时不需要考虑线程安全。在插入元素前会先判断HashEntry数组是否超过容量,如果是的话,则要进行扩容,segment的扩容操作比HashMap的扩容操作更加恰当,因为HashMap是在插入元素之后判断是否需要扩容,但是很有可能扩容之后没有新元素插入,那这时HashMap就进行了一次无效的扩容。

  private void rehash(HashEntry<K,V> node) {
        HashEntry<K,V>[] oldTable = table;
        int oldCapacity = oldTable.length;
        // 新容量为旧容量的两倍
        int newCapacity = oldCapacity << 1;
        threshold = (int)(newCapacity * loadFactor);
        HashEntry<K,V>[] newTable =
                (HashEntry<K,V>[]) new HashEntry[newCapacity];
        int sizeMask = newCapacity - 1;
        for (int i = 0; i < oldCapacity ; i++) {
            // 找到每一条链表
            HashEntry<K,V> e = oldTable[i];
            // 如果链表中有元素
            if (e != null) {
                HashEntry<K,V> next = e.next;
                // 求出第一个元素在扩容后的数组中的下标
                int idx = e.hash & sizeMask;
                // 1. Single node on list,如果只有一个节点,那么就直接移动到新数组中的合适位置
                if (next == null) 
                    newTable[idx] = e;
                // 2. 多个节点: Reuse consecutive sequence at same slot  在同一插槽中重复使用连续序列
                else {
                    HashEntry<K,V> lastRun = e;
                    int lastIdx = idx;
                    // 2.1 过一遍链表, 尽可能把 rehash 后 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);
                    }
                }
            }
        }
        // add the new node 扩容完成, 才加入新的节点
        int nodeIndex = node.hash & sizeMask;
        node.setNext(newTable[nodeIndex]);
        // 将新节点设置为链表头
        newTable[nodeIndex] = node;

        // 替换为新的 HashEntry table
        table = newTable;
    }

get() 方法

get 时并未加锁,用了 UNSAFE 方法保证了可见性,扩容过程中,get 先发生就从旧表取内容,get 后发生就从新
表取内容

    public V get(Object key) {
        Segment<K,V> s; // manually integrate access methods to reduce overhead
        HashEntry<K,V>[] tab;
        int h = hash(key);
        // u 为 segment 对象在数组中的偏移量
        long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
        // 1.获取到Segment,s 即为 segment
        if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
                (tab = s.table) != null) {
            // 2.获取到Segment中的HashEntry
            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;
    }

size() 方法

如果要统计整个ConcurrentHashMap里元素的数目,就必须统计所有segment里元素的数目,然后求和。Segment类中有一个count变量来记录对应segment里元素的数目,那是不是直接相加所有segment的count就可以了呢?不是的,我们在求和的时候,有可能有segment中元素的数目发生了变化,导致结果不准确。所以,最安全的做法就是在统计时,把所有segment的put、remove和clean方法都锁住,但是这种做法显然非常低效。

因为在累加count的过程中,count发生变化的概率比较小,所以ConcurrentHashMap的做法是先尝试2次通过不加锁的方式来计算各个segment的大小,若统计过程中,容器的count发生了变化,则再采用加锁的方式来统计所有segment的大小。

那ConcurrentHashMap是怎么判断在统计过程中容器是否发生变化呢?答案就是使用modCount变量,在put、remove、clean方法里操作元素前都会将modCount加1,那么在统计size前后比较modCount是否发生变化,就可以知道容器大小是否发生变化。

  1. 计算元素个数前,先不加锁计算两次,如果前后两次结果如一样,认为个数正确返回
  2. 如果不一样,进行重试,重试次数超过 3,将所有 segment 锁住,重新计算个数返回
    public int size() {
        // Try a few times to get accurate count. On failure due to
        // continuous async changes in table, resort to locking.
        final Segment<K,V>[] segments = this.segments;
        int size;
        boolean overflow; // true if size overflows 32 bits
        long sum; // sum of modCounts
        long last = 0L; // previous sum
        int retries = -1; // first iteration isn't retry
        try {
            for (;;) {
                // 重试次数是否达到了RETRIES_BEFORE_LOCK,RETRIES_BEFORE_LOCK = 2
                if (retries++ == RETRIES_BEFORE_LOCK) {
                    // 超过重试次数, 需要创建所有 segment 并加锁
                    for (int j = 0; j < segments.length; ++j)
                        ensureSegment(j).lock(); // force creation
                }
                sum = 0L;
                size = 0;
                overflow = false;
                for (int j = 0; j < segments.length; ++j) {
                    Segment<K,V> seg = segmentAt(segments, j);
                    if (seg != null) {
                        // modCount表示最近修改的次数,比如put
                        sum += seg.modCount;
                        // count表示每个segment中元素的个数
                        int c = seg.count;
                        if (c < 0 || (size += c) < 0)
                            // 表示已经溢出了
                            overflow = true;
                    }
                }
                // 如果sum == last,那么说明这段区间没有其它线程干扰
                if (sum == last)
                    break;
                last = sum;
            }
        } finally {
            // 判断如果加了锁,那么就要进行解锁
            if (retries > RETRIES_BEFORE_LOCK) {
                for (int j = 0; j < segments.length; ++j)
                    segmentAt(segments, j).unlock();
            }
        }
        return overflow ? Integer.MAX_VALUE : size;
    }

1.8版本

在这里插入图片描述

我们对JDK1.8版本的ConcurrentHashMap进行说明,1.8版本的ConcurrentHashMap相比之前的版本主要做了两处改进:

  • 使用CAS代替分段锁。
  • 红黑树,这一点和HashMap是一致的。

ConcurrentHashMap有如下五个构造方法:


// table默认大小为16
public ConcurrentHashMap() {
}
 
// 初始化容量为 >= 1.5 * initialCapacity + 1计算出的2的整数次幂
public ConcurrentHashMap(int initialCapacity) {
    if (initialCapacity < 0)
        throw new IllegalArgumentException();
    int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
                MAXIMUM_CAPACITY :
                tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
    this.sizeCtl = cap;
}
 
// 创建一个和输入参数map映射一样的map
public ConcurrentHashMap(Map<? extends K, ? extends V> m) {
    this.sizeCtl = DEFAULT_CAPACITY;
    putAll(m);
}
 
public ConcurrentHashMap(int initialCapacity, float loadFactor) {
    this(initialCapacity, loadFactor, 1);
}
 
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
    // 根据initialCapacity和loadFactor来计算size
    long size = (long)(1.0 + (long)initialCapacity / loadFactor);
    // 初始化容量应该为不小于size的2的整数次幂
    int cap = (size >= (long)MAXIMUM_CAPACITY) ?
        MAXIMUM_CAPACITY : tableSizeFor((int)size);
    this.sizeCtl = cap;
}

在ConcurrentHashMap的构造方法中,并没有初始化table(除了第三个构造方法,调用了putAll来初始化),table的初始化发生在第一次插入操作,默认大小为16的数组,在ConcurrentHashMap中,元素都被封装为了Node对象:

static class Node<K,V> implements Map.Entry<K,V> {
    // 节点的哈希值
    final int hash;
    // 键
    final K key;
    // 值
    volatile V val;
    // 下一节点
    volatile Node<K,V> next;
 
    Node(int hash, K key, V val, Node<K,V> next) {
        this.hash = hash;
        this.key = key;
        this.val = val;
        this.next = next;
    }
 
    public final K getKey()       { return key; }
    public final V getValue()     { return val; }
    public final int hashCode()   { return key.hashCode() ^ val.hashCode(); }
    public final String toString(){ return key + "=" + val; }
 
    public final V setValue(V value) {
        throw new UnsupportedOperationException();
    }
 
    public final boolean equals(Object o) {
        Object k, v, u; Map.Entry<?,?> e;
        return ((o instanceof Map.Entry) &&
                (k = (e = (Map.Entry<?,?>)o).getKey()) != null &&
                (v = e.getValue()) != null &&
                (k == key || k.equals(key)) &&
                (v == (u = val) || v.equals(u)));
    }
 
    Node<K,V> find(int h, Object k) {
        Node<K,V> e = this;
        if (k != null) {
            do {
                K ek;
                if (e.hash == h &&
                    ((ek = e.key) == k || (ek != null && k.equals(ek))))
                    return e;
            } while ((e = e.next) != null);
        }
        return null;
    }
}

红黑树TreeNode节点类型

static final class TreeNode<K,V> extends Node<K,V> {
    // 父节点
    TreeNode<K,V> parent;  // red-black tree links
    // 左子节点
    TreeNode<K,V> left;
    // 右子节点
    TreeNode<K,V> right;
    // 删除节点时,需要断开链接,这个节点记录了删除节点的前一个节点
    TreeNode<K,V> prev;    // needed to unlink next upon deletion
    boolean red;
 
    TreeNode(int hash, K key, V val, Node<K,V> next,
                TreeNode<K,V> parent) {
        super(hash, key, val, next);
        this.parent = parent;
    }
 
    Node<K,V> find(int h, Object k) {
        return findTreeNode(h, k, null);
    }
 
    final TreeNode<K,V> findTreeNode(int h, Object k, Class<?> kc) {
        if (k != null) {
            TreeNode<K,V> p = this;
            do  {
                int ph, dir; K pk; TreeNode<K,V> q;
                TreeNode<K,V> pl = p.left, pr = p.right;
                if ((ph = p.hash) > h)
                    p = pl;
                else if (ph < h)
                    p = pr;
                else if ((pk = p.key) == k || (pk != null && k.equals(pk)))
                    return p;
                else if (pl == null)
                    p = pr;
                else if (pr == null)
                    p = pl;
                else if ((kc != null ||
                            (kc = comparableClassFor(k)) != null) &&
                            (dir = compareComparables(kc, k, pk)) != 0)
                    p = (dir < 0) ? pl : pr;
                else if ((q = pr.findTreeNode(h, k, kc)) != null)
                    return q;
                else
                    p = pl;
            } while (p != null);
        }
        return null;
    }
}

关键的静态属性

// 最大容量(必须是2的幂且小于2的30次方,传入容量过大将被这个值替换)
private static final int MAXIMUM_CAPACITY = 1 << 30;

// 默认容量16
private static final int DEFAULT_CAPACITY = 16;

// 数组最大长度,在toArray等相关方法中用到
static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;

// 默认并发级别,已经不再使用的字段,之所以还存在只是为了和之前的版本兼容
private static final int DEFAULT_CONCURRENCY_LEVEL = 16;

// 此表的加载因子。在构造函数中重写此值只会影响初始表的容量,而不会使用实际的浮点值
private static final float LOAD_FACTOR = 0.75f;

// 添加当前元素,bin中元素个数若为8,则将链表转为红黑树
static final int TREEIFY_THRESHOLD = 8;

// bin中元素个数若为6个,则将红黑树转为链表
static final int UNTREEIFY_THRESHOLD = 6;

// table转为红黑树的阈值,此值最小为4*TREEIFY_THRESHOLD,默认为64
static final int MIN_TREEIFY_CAPACITY = 64;

// table扩容时,bin转移个数,最小为默认的DEFAULT_CAPACITY=16
// 因为扩容时,可以多个线程同时操作,所以16个bin会被分配给多个的线程进行转移
private static final int MIN_TRANSFER_STRIDE = 16;

// 用来控制扩容时,单线程进入的变量
private static int RESIZE_STAMP_BITS = 16;

// resize时的线程最大个数
private static final int MAX_RESIZERS = (1 << (32 - RESIZE_STAMP_BITS)) - 1;

// 用来控制扩容,单线程进入的变量
private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS;

// 节点hash域的编码
static final int MOVED     = -1; // hash for forwarding nodes
static final int TREEBIN   = -2; // hash for roots of trees
static final int RESERVED  = -3; // hash for transient reservations
static final int HASH_BITS = 0x7fffffff; // usable bits of normal node hash

// 当前可用cpu数量
static final int NCPU = Runtime.getRuntime().availableProcessors()

很多静态变量都与HashMap中的变量相似。同时,ConcurrentHashMap还有如下几个成员变量:


// Node数组,该变量只有在第一次插入元素时才会初始化
transient volatile Node<K,V>[] table;

// resize时用到的临时table,只有在resize时才不为null
private transient volatile Node<K,V>[] nextTable;

// 基本计数器值,主要用于没有争用时,也可作为表初始化期间的后备,通过CAS更新。
private transient volatile long baseCount;
/**
* 用于控制table初始化和resize的一个变量
* 值为负数:table正在初始化or正在resize
*     sizeCtl = -1:正在初始化;
*     sizeCtl = -(1 + n):当前有n个线程正在进行resize;
* 当table未初始化时,保存创建时使用的初始表大小,或默认为0
* 初始化后,保存下一个要调整table大小的元素计数值
*/
private transient volatile int sizeCtl;

// resize时,next table的索引+1,用于分割
private transient volatile int transferIndex;

//在调整大小和/或创建CounterCells时使用的自旋锁(通过CAS锁定)
private transient volatile int cellsBusy;

//这是一个计数器数组,用于保存table中每一下标对应的节点个数
private transient volatile CounterCell[] counterCells;

put

最核心的便是put方法:

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

最后一个参数为onlyIfAbsent,表示只有在key对应的value不存在时才将value加入,所以putVal是put和putIfAbsent两个方法的真正实现。

final V putVal(K key, V value, boolean onlyIfAbsent) {
    // 若key或value为null,则直接抛出NullPointerException异常
    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;
        // 如果table数组为null或长度为0,则对数组进行初始化
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();
        // 否则,按照hash值对应的数组下标,得到第一个节点f
        // 若f为null,则通过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
        }
        // 若果f节点的hash值为MOVED,此时表示数组在扩容,则帮助数据迁移
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);
        //若当前槽位已经有元素了,则加载链表尾端
        else {
            V oldVal = null;
            // 获取数组该位置的头结点的监视器锁
            synchronized (f) {
                if (tabAt(tab, i) == f) {
                    // 头结点的hash值大于0,说明是链表,因为红黑树的根节点hash值是TREEBIN(-2)
                    if (fh >= 0) {
                        // 用于累加,记录链表的长度
                        binCount = 1;
                        // 遍历链表
                        for (Node<K,V> e = f;; ++binCount) {
                            K ek;
                            // 如果发现了"相等"的key,判断是否要进行值覆盖
                            if (e.hash == hash &&
                                ((ek = e.key) == key ||
                                    (ek != null && key.equals(ek)))) {
                                oldVal = e.val;
                                // put方法传入的onlyIfAbsent默认为false,即可以覆盖
                                if (!onlyIfAbsent)
                                    e.val = value;
                                break;
                            }
                            // 到了链表的末尾,将新节点放到链表的最后面
                            Node<K,V> pred = e;
                            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;
                        // 调用红黑树的putTreeVal方法插入新节点
                        if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                                        value)) != null) {
                            oldVal = p.val;
                            if (!onlyIfAbsent)
                                p.val = value;
                        }
                    }
                }
            }
            // 插入节点后,检查数组对应下标的节点个数是否 >= TREEIFY_THRESHOLD,如果是,则由链表转为红黑树
            if (binCount != 0) {
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
 
    // 更新元素数目
    addCount(1L, binCount);
    return null;

put方法的主流程看完了,但是putVal方法中还调用到了一些其他方法,我们来看一下。首先是计算节点hash值的spread(int)方法

static final int spread(int h) {
    // HASH_BITS = 0x7fffffff
    return (h ^ (h >>> 16)) & HASH_BITS;
}

为什么采用h ^ (h >>> 16)的方式来计算hash值,前面我们介绍HashMap时,已经做了解释,这里不再叙述。计算出来的hash值还要和HASH_BITS进行与运算才是最终结果,为什么要进行与运算呢?HASH_BITS的值是0x7fffffff,一个整形数字与HASH_BITS进行与运算,其实就是将数字二进制表示的第一位设置为0,它这样做的目的是消除符号位的影响,因为在table中,有些节点的hash值是特定的负数,比如前面介绍到的节点的hash域编码:

static final int MOVED     = -1; // hash for forwarding nodes
static final int TREEBIN   = -2; // hash for roots of trees
static final int RESERVED  = -3; // hash for transient reservations

初始化数组(initTable)

如果table为空或大小为0,那么将对其进行初始化操作,我们来看源代码:


private final Node<K,V>[] initTable() {
    Node<K,V>[] tab; int sc;
    // 若table为null或者table的长度为0,则进行初始化操作
    while ((tab = table) == null || tab.length == 0) {
        // 若我们设置了初始容量,则在构造方法会设置sizeCtl的值,否则,sizeCtl为0
        // 若sizeCtl小于0,则表明已经有其他线程在初始化
        if ((sc = sizeCtl) < 0)
            Thread.yield(); // lost initialization race; just spin
        // 通过CAS操作将sizeCtl设置为-1,代表本线程来初始化,其他线程就不要初始化了
        else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
            try {
                if ((tab = table) == null || tab.length == 0) {
                    // 获取table的初始容量
                    int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                    @SuppressWarnings("unchecked")
                    // 初始化数组
                    Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                    table = tab = nt;
                    // 计算sc的值,其实sc = 0.75 * n
                    sc = n - (n >>> 2);
                }
            } finally {
                // 设置sizeCtl为sc,即下一次需要扩容数组时的元素数量阈值
                sizeCtl = sc;
            }
            break;
        }
    }
    return tab;
}

sizeCtl是ConcurrentHashMap的初始化,扩容操作中一个至关重要的控制变量,其声明:

private transient volatile int sizeCtl;

其取值可能为:

  • 0: 初始值。

  • -1: 正在进行初始化。

  • 负值(小于-1): 表示正在进行扩容,因为ConcurrentHashMap支持多线程并行扩容。

  • 正数: 表示下一次触发扩容的临界值大小,即当前值 * 0.75(负载因子)。

从源码中可以看出,ConcurrentHashMap只允许一个线程进行初始化操作,当其它线程竞争失败(sizeCtl < 0)时便会进行自旋,直到竞争成功(初始化)线程完成初始化,那么此时table便不再为null,也就退出了while循环。

Thread.yield方法用于提示CPU可以放弃当前线程的执行,当然这只是一个提示(hint),这里对此方法的调用是一个优化手段。

对SIZECTL字段CAS更新的成功便标志者线程赢得了竞争,可以进行初始化工作了,剩下的就是一个数组的构造过程,一目了然。

转为红黑树(treeifyBin)

指putVal源码中的:

if (binCount != 0) {
    if (binCount >= TREEIFY_THRESHOLD)
        treeifyBin(tab, i);
    if (oldVal != null)
        return oldVal;
    break;
}

注意,这段代码是在上述(节点添加部分)同步代码块之外执行的。

TREEIFY_THRESHOLD表示将链表转为红黑树的链表长度的临界值,默认为8.

private final void treeifyBin(Node<K,V>[] tab, int index) {
    Node<K,V> b; int n, sc;
    if (tab != null) {
        // MIN_TREEIFY_CAPACITY = 64
        // 如果数组长度小于64,则会进行数组扩容
        if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
            // 扩容
            tryPresize(n << 1);
        // 否则,b为index对应链表的首节点
        else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
            // 获取b的监视器锁
            synchronized (b) {
                if (tabAt(tab, index) == b) {
                    TreeNode<K,V> hd = null, tl = null;
                    // 下面就是遍历链表,建立一颗红黑树
                    for (Node<K,V> e = b; e != null; e = e.next) {
                        TreeNode<K,V> p =
                            new TreeNode<K,V>(e.hash, e.key, e.val,null, null);
                        if ((p.prev = tl) == null)
                            hd = p;
                        else
                            tl.next = p;
                        tl = p;
                    }
                    // 将红黑树设置到数组相应位置中
                    setTabAt(tab, index, new TreeBin<K,V>(hd));
                }
            }
        }
    }
}

从源码中我们知道,treeifyBin方法不一定就会进行红黑树转换,也可能是仅仅做数组扩容。扩容是通过tryPresize(int)方法来完成的,int参数就是扩容或的值,我们下面来看扩容操作。

扩容(tryPresize)


如果当前bin的个数未达到MIN_TREEIFY_CAPACITY,那么不再转为红黑树,转而进行扩容。MIN_TREEIFY_CAPACITY默认64

// 首先要说明的是,方法参数size传进来的时候就已经翻倍了
private final void tryPresize(int size) {
    // 尝试将table大小设定为1.5 * size + 1,以容纳元素
    //	tableSizeFor方法将传入数值处理为2的倍数返回
    int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY :
        tableSizeFor(size + (size >>> 1) + 1);
    int sc;
    // 若sizeCtl < 0,则表明已经有其他线程在扩容
    // 若sizeCtl >= 0,则本线程进行扩容
    while ((sc = sizeCtl) >= 0) {
        Node<K,V>[] tab = table; int n;
        // 若table为null或者table的长度为0,则进行初始化操作
        if (tab == null || (n = tab.length) == 0) {
            n = (sc > c) ? sc : c;
             // 通过CAS操作将sizeCtl设置为-1,代表本线程来初始化,其他线程就不要初始化了
            if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
                try {
                    if (table == tab) {
                        @SuppressWarnings("unchecked")
                        // 创建新数组
                        Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                        table = nt;
                        // 计算sc的值,其实sc = 0.75 * n
                        sc = n - (n >>> 2);
                    }
                } finally {
                    // 设置sizeCtl为sc
                    sizeCtl = sc;
                }
            }
        }
        // 若扩容值小于原阀值,或现有容量 >= 最大值,则直接退出
        else if (c <= sc || n >= MAXIMUM_CAPACITY)
            break;
        // table不为空,且在此期间,其他线程没有修改table
        else if (tab == table) {
            // 返回table的扩容标记位
            int rs = resizeStamp(n);
            // 已经有线程在进行扩容工作
            if (sc < 0) {
                Node<K,V>[] nt;
                // 条件1检查原容量为n的情况下进行扩容,保证sizeCtl与n是一块修改好的,
                // 条件2与条件3在当前RESIZE_STAMP_BITS情况下应该不会成功。
                // 条件4与条件5确保tranfer()中的nextTable相关初始化逻辑已走完。
                if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
                    sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
                    transferIndex <= 0)
                    break;
                // 有新线程参与扩容则sizeCtl加1
                if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
                    transfer(tab, nt);
            }
            // 修改sizeCtl,扩容时为-(n+1),n为当前扩容线程数
            else if (U.compareAndSwapInt(this, SIZECTL, sc,
                                            (rs << RESIZE_STAMP_SHIFT) + 2))
                transfer(tab, null);
        }
    }
}

前面提到过了,ConcurrentHashMap支持多线程并行扩容,具体来说,是支持多线程将节点从老的数组拷贝到新的数组,而新数组创建仍是一个线程完成(不然多个线程创建多个对象,最后只使用一个,这不是浪费是什么?)

竞争成功的线程为transfer方法的nextTab参数传入null,这将导致新数组的创建。竞争失败的线程将会判断当前节点转移工作是否已经完成,如果已经完成,那么意味着扩容的完成,退出即可,如果没有完成,那么此线程将会进行辅助转移。

判断是否已经完成的条件只能理解(nt = nextTable) == null || transferIndex <= 0两个。

我们看到tryPresize方法中调用了一个叫做resizeStamp的方法,我们看一看这个方法做了什么事情:

static final int resizeStamp(int n) {
    return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
}

这个方法会返回一个与table容量n大小有关的扩容标记,它的实现很简单,Integer.numberOfLeadingZeros(int n)方法是计算在n的二进制表示中,高位一共有多少个连续的0,如果是负数则返回0。然后将其与1 << (RESIZE_STAMP_BITS - 1)进行或运算。它这么做的意义是什么呢?我写了一段代码来验证该方法的作用

private static int RESIZE_STAMP_BITS = 16;
private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS;
 
public static void main(String[] args) throws UnsupportedEncodingException {
    int size = 1 << 4;
    int rs = resizeStamp(size);
 
    formatPrintNum(rs);
    
    int rs2 = (rs << RESIZE_STAMP_SHIFT) + 2;
    formatPrintNum(rs2);
    formatPrintNum(rs2 + 1);
}
 
static final int resizeStamp(int n) {
    return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
}
 
static void formatPrintNum(int n) {
    String s = Integer.toBinaryString(n);
    
    while (s.length() < 32) {
        s = "0" + s;
    }
    
    s = s.substring(0, 16) + " | " + s.substring(16, 32);
    System.out.println(s);
}

运行结果:

0000000000000000 | 1000000000011011
1000000000011011 | 0000000000000010
1000000000011011 | 0000000000000011

我把结果的输出分为了高16位和低16位。

假设table的容量为16,则通过resizeStamp方法计算出的扩容标记位是“1000000000011011”(只看低16位),(rs << RESIZE_STAMP_SHIFT) + 2(后面称为rs2)的值是“1000000000011011 | 0000000000000010”,rs2+1的值是“1000000000011011 | 0000000000000011”。

这个程序的目的是什么呢?

我们先来看rs2,rs2的值一直为负数,因为resizeStamp方法中(1 << (RESIZE_STAMP_BITS - 1))计算出来的值二进制表示为“1000000000000000”(低16位),通过或运算后得出的扩容标记位rs的二进制表示中,第16位一定为1,而rs2是通过rs左移RESIZE_STAMP_SHIFT计算得到的,则rs2的二进制表示中,最高位一定为1,即rs2的值一直为负数。

前面我们介绍了sizeCtl的作用,若rs2就是sizeCtl,那么sizeCtl表示什么呢?rs2不等于-1,那么sizeCtl的取值就只有下面一种情况了:

sizeCtl = -(1 + n):当前有n个线程正在进行resize;

rs2转成十进制表示是

-2145714174

难道我们有2145714173个线程在做resize操作吗?肯定不是的。其实-(1 + n)中的(1 + n)只是sizeCtl的低16位了,rs2的低16位表示为十进制是2,即表示当前有1个线程在做resize操作,若有其他线程参与进来则,sizeCtl的值加1,。

通过上面的分析,我们知道,在扩容时sizeCtl的意义如下图所示:

高RESIZE_STAMP_BITS位低RESIZE_STAMP_SHIFT位
扩容标记并行扩容线程数

看懂上面的分析之后,那tryPresize方法后面的程序大部分都可以看懂了,该方法最后会调用transfer()来进行真正的扩容处理

transfer方法
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
    int n = tab.length, stride;
    // stride在单核下直接等于n,多核模式下为(n >>> 3) / NCPU
    // stride可以理解为“步长”,表示每个线程处理桶的最小数目,可以看出核数越高步长越小,最小值是最小分割并行段数
    //MIN_TRANSFER_STRIDE(16)
    if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
        stride = MIN_TRANSFER_STRIDE; // subdivide range
    //nextTab初始化,CAS保证了只会有一个线程执行这里的代码
    // 如果新数组nextTab为null,先进行一次初始化,长度为旧table的2倍
    if (nextTab == null) {
        try {
            Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
            nextTab = nt;
        } catch (Throwable ex) {      // try to cope with OOME
            sizeCtl = Integer.MAX_VALUE;
            return;
        }
        nextTable = nextTab;
        transferIndex = n;
    }
    // 新table的长度
    int nextn = nextTab.length;
    // ForwardingNode就是正在被迁移的Node,ForwardingNode的hash值被设置成为了MOVED(-1),这个Node的key、value和next都为null
    // 后面我们会看到,原数组中位置i处的节点完成迁移工作后
    // 就会将位置i处的节点设置为这个ForwardingNode,用来告诉其他线程该位置已经处理过了
    // 所以它其实相当于是一个标志
    ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
 
    // 并发扩容的关键属性,如果advance等于true,说明这个节点已经处理过,可以处理下一个位置的节点了
    boolean advance = true;
    boolean finishing = false; // to ensure sweep before committing nextTab
    
    // i是位置索引,bound是边界,注意是从后往前
    for (int i = 0, bound = 0;;) {
        Node<K,V> f; int fh;
        // 这个while循环体的作用就是在控制i--
        // 通过i--可以依次遍历原hash表中的节点
        // 可以简单理解为:i指向了transferIndex,bound指向了transferIndex - stride
        while (advance) {
            int nextIndex, nextBound;
            if (--i >= bound || finishing)
                advance = false;
            else if ((nextIndex = transferIndex) <= 0) {
                i = -1;
                advance = false;
            }
            else if (U.compareAndSwapInt
                        (this, TRANSFERINDEX, nextIndex,
                        nextBound = (nextIndex > stride ?
                                    nextIndex - stride : 0))) {
                
                // 确定当前线程每次分配的待迁移桶的范围[bound, nextIndex)
                bound = nextBound;
                i = nextIndex - 1;
                advance = false;
            }
        }
        if (i < 0 || i >= n || i + n >= nextn) {
            int sc;
            // 如果所有的节点都已经完成复制工作,就把nextTable赋值给table
            if (finishing) {
                nextTable = null;
                table = nextTab;
                // 重新计算sizeCtl,n是原数组长度,所以计算得出的值将是新数组长度的0.75倍
                sizeCtl = (n << 1) - (n >>> 1);
                return;
            }
 
            // 之前我们说过,sizeCtl在迁移前会设置为(rs << RESIZE_STAMP_SHIFT) + 2
            // 然后,每有一个线程参与迁移就会将sizeCtl加1
            // 这里使用CAS操作对sizeCtl减1,代表该线程做完了属于自己的任务
            if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
                // 任务结束,方法退出
                if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
                    return;
                // (sc - 2) == resizeStamp(n) << RESIZE_STAMP_SHIFT
                // 这表明所有的线程都完成了迁移工作,设置finishing为true,下次循环就会运行上面的if(finishing){}分支了
                finishing = advance = true;
                i = n; // recheck before commit
            }
        }
        // 如果位置i处是空的,没有任何节点,那么放入刚刚初始化的ForwardingNode节点
        else if ((f = tabAt(tab, i)) == null)
            advance = casTabAt(tab, i, null, fwd);
        // 该位置处是ForwardingNode节点,代表该位置已经迁移过了
        else if ((fh = f.hash) == MOVED)
            advance = true; // already processed
        else {
            // 对数组该位置处的结点加锁,开始处理数组该位置处的迁移工作
            synchronized (f) {
                if (tabAt(tab, i) == f) {
                    Node<K,V> ln, hn;
                    // 头结点的hash值大于0,说明是链表节点
                    if (fh >= 0) {
                        // 下面这一段代码和JDK 1.7中的ConcurrentHashMap迁移差不多
                        int runBit = fh & n;
                        Node<K,V> lastRun = f;
                        for (Node<K,V> p = f.next; p != null; p = p.next) {
                            int b = p.hash & n;
                            if (b != runBit) {
                                runBit = b;
                                lastRun = p;
                            }
                        }
                        if (runBit == 0) {
                            ln = lastRun;
                            hn = null;
                        }
                        else {
                            hn = lastRun;
                            ln = null;
                        }
                        for (Node<K,V> p = f; p != lastRun; p = p.next) {
                            int ph = p.hash; K pk = p.key; V pv = p.val;
                            if ((ph & n) == 0)
                                ln = new Node<K,V>(ph, pk, pv, ln);
                            else
                                hn = new Node<K,V>(ph, pk, pv, hn);
                        }
                        // 将其中的一个链表放在新数组的位置i
                        setTabAt(nextTab, i, ln);
                        // 将另一个链表放在新数组的位置i + n
                        setTabAt(nextTab, i + n, hn);
                        // 将原数组该位置处设置为fwd,代表该位置已经处理完毕
                        // 其他线程一旦看到该位置的hash值为 MOVED,就不会进行迁移了
                        setTabAt(tab, i, fwd);
                        // advance设置为true,代表该位置已经迁移完毕
                        advance = true;
                    }
                    // 若头结点是红黑树节点,则进行红黑树的迁移
                    else if (f instanceof TreeBin) {
                        TreeBin<K,V> t = (TreeBin<K,V>)f;
                        TreeNode<K,V> lo = null, loTail = null;
                        TreeNode<K,V> hi = null, hiTail = null;
                        int lc = 0, hc = 0;
                        for (Node<K,V> e = t.first; e != null; e = e.next) {
                            int h = e.hash;
                            TreeNode<K,V> p = new TreeNode<K,V>
                                (h, e.key, e.val, null, null);
                            if ((h & n) == 0) {
                                if ((p.prev = loTail) == null)
                                    lo = p;
                                else
                                    loTail.next = p;
                                loTail = p;
                                ++lc;
                            }
                            else {
                                if ((p.prev = hiTail) == null)
                                    hi = p;
                                else
                                    hiTail.next = p;
                                hiTail = p;
                                ++hc;
                            }
                        }
 
                        // 如果一分为二后,节点数少于UNTREEIFY_THRESHOLD,那么将红黑树转换回链表
                        ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
                            (hc != 0) ? new TreeBin<K,V>(lo) : t;
                        hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
                            (lc != 0) ? new TreeBin<K,V>(hi) : t;
                        // 将ln放置在新数组的位置i
                        setTabAt(nextTab, i, ln);
                        // 将hn放置在新数组的位置i + n
                        setTabAt(nextTab, i + n, hn);
                        // 将原数组该位置处设置为fwd,代表该位置已经处理完毕
                        // 其他线程一旦看到该位置的hash值为 MOVED,就不会进行迁移了
                        setTabAt(tab, i, fwd);
                        // advance设置为true,代表该位置已经迁移完毕
                        advance = true;
                    }
                }
            }
        }
    }
}

我们这里介绍一下ForwardingNode的作用,它主要有两个:

  • 标明此节点已完成迁移
  • 为方便扩容期间的元素查找需求,里面有find()方法可以从nextTable查找元素
分片

每个线程针对一个分片来进行转移操作,所谓的一个分片其实就是bin数组的一段。默认的最小分片大小为16,如果所在机器 只有一个CPU核心,那么就取16,否则取(数组大小 / 8 / CPU核心数)与16的较大者。

transferIndex

全局变量transferIndex表示低于此值的bin尚未被转移,分片的申请便是通过对此变量的CAS操作来完成,初始值为原数组大小,减为0表示 所有桶位均已转移完毕。

ForwardingNode

从transfer方法的源码可以看出,当一个桶位(原数组)处理完时,会将其头结点设置一个ForwardingNode。简略版源码:

static final class ForwardingNode<K,V> extends Node<K,V> {
    final Node<K,V>[] nextTable;
    ForwardingNode(Node<K,V>[] tab) {
        super(MOVED, null, null, null);
        this.nextTable = tab;
    }
}

其哈希值为MOVED。到这里我们便可以理解putVal方法这部分源码的作用了:

else if ((fh = f.hash) == MOVED)
    tab = helpTransfer(tab, f);

helpTransfer方法的实现和tryPresize方法的相关代码很像

final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) {
    Node<K,V>[] nextTab; int sc;
    // 若tab不为null且首节点f是ForwardingNode节点,且f的nextTable不为null,即已经有其他线程在进行resize操作
    if (tab != null && (f instanceof ForwardingNode) &&
        (nextTab = ((ForwardingNode<K,V>)f).nextTable) != null) {
        // 计算扩容标记位
        int rs = resizeStamp(tab.length);
        // 扩容还没有完成
        while (nextTab == nextTable && table == tab &&
                (sc = sizeCtl) < 0) {
            // 扩容结束
            if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
                sc == rs + MAX_RESIZERS || transferIndex <= 0)
                break;
            // 帮助数据迁移,因为多了一个迁移线程,所以要将sizeCtl加1
            if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {
                transfer(tab, nextTab);
                break;
            }
        }
        return nextTab;
    }
    return table;
}
扩容操作总结
  • 单线程新建nextTable,扩容为原table容量的两倍。
  • 每个线程想增/删元素时,如果访问的桶是ForwardingNode节点,则表明当前正处于扩容状态,协助一起扩容完成后再完成相应的数据更改操作。
  • 扩容时将原table的所有桶倒序分配,每个线程每次最小分16个桶进行处理,防止资源竞争导致的效率下降, 每个桶的迁移是单线程的,但桶范围处理分配可以多线程,在没有迁移完成所有桶之前每个线程需要重复获取迁移桶范围,直至所有桶迁移完成。
  • 一个旧桶内的数据迁移完成但迁移工作没有全部完成时,查询数据委托给ForwardingNode结点查询nextTable完成(这个后面看find()分析)。
  • 迁移过程中sizeCtl用于记录参与扩容线程的数量,全部迁移完成后sizeCtl更新为新table的扩容阈值。

将元素添加到table中之后,put方法最后还要更新元素的个数,我们来看一下addCount方法。


看完这个方法,其实还是不是很懂baseCount和counterCells的含义。。

看注释中写道,baseCount是在没有竞争时使用的变量,所以,我感觉在计算元素数目时,如果没有产生竞争,则用baseCount来记录,否则用counterCells记录了

计数

在putVal方法的结尾通过调用addCount方法(略去大小检查,扩容部分,这里我们只关心计数)进行计数:

// 增加节点个数,如果table太小而没有resize,则检查是否需要resize。如果已经调整大小,则可以帮助复制转移节点
// 转移后重新检查占用情况,以确定是否还需要调整大小,因为resize总是比put操作滞后
private final void addCount(long x, int check) {
    CounterCell[] as; long b, s;
    // 通过CAS操作更新baseCount
    if ((as = counterCells) != null ||
        !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
        // 若counterCells不为null或者更新baseCount失败
        CounterCell a; long v; int m;
        boolean uncontended = true;
        if (as == null || (m = as.length - 1) < 0 ||
            (a = as[ThreadLocalRandom.getProbe() & m]) == null ||
            !(uncontended =
                U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
            // 调用fullAddCount方法进行初始化
            fullAddCount(x, uncontended);
            return;
        }
        if (check <= 1)
            return;
        s = sumCount();
    }
 
    // check就是binCount,有新元素加入成功才检查是否要扩容
    if (check >= 0) {
        Node<K,V>[] tab, nt; int n, sc;
        // 元素数目大于当前扩容阈值并且小于最大扩容值才扩容,如果table还未初始化则等待初始化完成
        while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
                (n = tab.length) < MAXIMUM_CAPACITY) {
            // 返回table的扩容标记位
            int rs = resizeStamp(n);
            // 如果已经有其他线程在进行扩容
            if (sc < 0) {
                if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
                    sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
                    transferIndex <= 0)
                    break;
                // 该线程参与扩容,则将sizeCtl的值加1
                if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
                    transfer(tab, nt);
            }
 
            // 没有线程在进行扩容,则该线程开始扩容,设置sizeCtl的值
            else if (U.compareAndSwapInt(this, SIZECTL, sc,
                                            (rs << RESIZE_STAMP_SHIFT) + 2))
                transfer(tab, null);
            s = sumCount();
        }
    }
}

计数的关键便是counterCells属性:

private transient volatile CounterCell[] counterCells;

CounterCell是ConcurrentHashMapd的内部类:

@sun.misc.Contended static final class CounterCell {
    volatile long value;
    CounterCell(long x) { value = x; }
}

Contended注解的作用是将类的字段以64字节的填充行包围以解决伪共享问题。其实这里的计数方式就是改编自LongAdder,以最大程度地降低CAS失败空转的几率。

条件判断:

if ((as = counterCells) != null || !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
    //...
}

非常有意思 ,如果counterCells为null,那么尝试用baseCount进行计数,如果事实上只有一个线程或多个线程单竞争的频率较低,对baseCount的CAS操作并不会失败,所以可以得到结论 : 如果竞争程度较低(没有CAS失败),那么其实用的是volatile变量baseCount来计数,只有当线程竞争严重(出现CAS失败)时才会改用LongAdder的方式

baseCount声明如下:

private transient volatile long baseCount;

再来看一下什么条件下会触发fullAddCount方法:

if (as == null || (m = as.length - 1) < 0 || (a = as[ThreadLocalRandom.getProbe() & m]) == null ||
    !(uncontended = U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
    //...
}

ThreadLocalRandom.getProbe()的返回值决定了线程和哪一个CounterCell相关联,查看源码可以发现,此方法返回的其实是Thread的下列字段的值:

@sun.misc.Contended("tlr")
int threadLocalRandomProbe;

我们暂且不管这个值是怎么算出来,将其当做一个线程唯一的值即可。所以fullAddCount执行的条件是(或):

  • CounterCell数组为null。
  • CounterCell数组大小为0.
  • CounterCell数组线程对应的下标值为null。
  • CAS更新线程特定的CounterCell失败。

fullAddCount方法的实现其实和LongAdder的父类Striped64的longAccumulate大体一致:

private final void fullAddCount(long x, boolean wasUncontended) {
    int h;
    if ((h = ThreadLocalRandom.getProbe()) == 0) {
        ThreadLocalRandom.localInit();      // force initialization
        h = ThreadLocalRandom.getProbe();
        wasUncontended = true;
    }
    boolean collide = false;                // True if last slot nonempty
    for (;;) {
        CounterCell[] as; CounterCell a; int n; long v;
        //1.
        if ((as = counterCells) != null && (n = as.length) > 0) {
            if ((a = as[(n - 1) & h]) == null) {
                if (cellsBusy == 0) {            // Try to attach new Cell
                    CounterCell r = new CounterCell(x); // Optimistic create
                    if (cellsBusy == 0 && U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
                        boolean created = false;
                        try {               // Recheck under lock
                            CounterCell[] rs; int m, j;
                            if ((rs = counterCells) != null &&
                                (m = rs.length) > 0 &&
                                rs[j = (m - 1) & h] == null) {
                                rs[j] = r;
                                created = true;
                            }
                        } finally {
                            cellsBusy = 0;
                        }
                        if (created)
                            //新Cell创建成功,退出方法
                            break;
                        continue;           // Slot is now non-empty
                    }
                }
                collide = false;
            }
            else if (!wasUncontended)       // CAS already known to fail
                wasUncontended = true;      // Continue after rehash
            else if (U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))
                break;
            else if (counterCells != as || n >= NCPU)
                collide = false;            // At max size or stale
            else if (!collide)
                collide = true;
            else if (cellsBusy == 0 && U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
                try {
                    //扩容
                    if (counterCells == as) {// Expand table unless stale
                        CounterCell[] rs = new CounterCell[n << 1];
                        for (int i = 0; i < n; ++i)
                            rs[i] = as[i];
                        counterCells = rs;
                    }
                } finally {
                    cellsBusy = 0;
                }
                collide = false;
                continue;                   // Retry with expanded table
            }
            //rehash
            h = ThreadLocalRandom.advanceProbe(h);
        }
        //2.
        else if (cellsBusy == 0 && counterCells == as && U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
            boolean init = false;
            try {
                //获得锁之后再次检测是否已被初始化
                if (counterCells == as) {
                    CounterCell[] rs = new CounterCell[2];
                    rs[h & 1] = new CounterCell(x);
                    counterCells = rs;
                    init = true;
                }
            } finally {
                //锁释放
                cellsBusy = 0;
            }
            if (init)
                //计数成功,退出方法
                break;
        }
      //3. 
        else if (U.compareAndSwapLong(this, BASECOUNT, v = baseCount, v + x))
            break;                          // Fall back on using base
    }
}

从源码中可以看出,在初始情况下probe其实是0的,也就是说在一开始的时候都是更新到第一个cell中的,直到出现CAS失败。

整个方法的逻辑较为复杂,我们按照上面列出的fullAddCount执行条件进行对应说明。

cell数组为null或empty

容易看出,这里对应的是fullAddCount方法的源码2处。cellBusy的定义如下:

private transient volatile int cellsBusy;

这里其实将其当做锁来使用,即只允许在某一时刻只有一个线程正在进行CounterCell数组的初始化或扩容,其值为1说明有线程正在进行上述操作。

默认创建了大小为2的CounterCell数组。

下标为null或CAS失败

这里便对应源码的1处,各种条件分支不再展开详细描述,注意一下几点:

rehash

当Cell数组不为null和empty时,每次循环便会导致重新哈希值,这样做的目的是用再次生成哈希值的方式降低线程竞争。

最大CounterCell数

取NCPU:

static final int NCPU = Runtime.getRuntime().availableProcessors();

不过从上面扩容部分源码可以看出,最大值并不一定是NCPU,因为采用的是2倍扩容,准确来说是最小的大于等于NCPU的2的整次幂(初始大小为2)。

注意下面这个分支:

else if (counterCells != as || n >= NCPU)
    collide = false;

此分支会将collide置为false,从而致使下次循环else if (!collide)必定得到满足,这也就保证了扩容分支不会被执行。

baseCount分支

还会尝试对此变量进行更新,有意思。

size
public int size() {
    long n = sumCount();
    return ((n < 0L) ? 0 : (n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n);
}

核心在于sumCount方法:

final long sumCount() {
    CounterCell[] as = counterCells; CounterCell a;
    long sum = baseCount;
    if (as != null) {
        for (int i = 0; i < as.length; ++i) {
            if ((a = as[i]) != null)
                sum += a.value;
        }
    }
    return sum;
}

求和的时候带上了baseCount,剩下的就 一目了然了。

get


public V get(Object key) {
    Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
    // 计算key对应节点的hash值
    int h = spread(key.hashCode());
    // 如果table不为null,table的长度不为0且对应下标存在节点
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (e = tabAt(tab, (n - 1) & h)) != null) {
        // 判断头结点是否就是我们查找的节点
        if ((eh = e.hash) == h) {
            if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                return e.val;
        }
        // 如果头结点的hash值小于0,说明table正在扩容,或者e是红黑树节点
        else if (eh < 0)
            return (p = e.find(h, key)) != null ? p.val : null;
        // 否则,遍历链表寻找匹配的节点
        while ((e = e.next) != null) {
            if (e.hash == h &&
                ((ek = e.key) == key || (ek != null && key.equals(ek))))
                return e.val;
        }
    }
    return null;
}

有意思的在于第二个分支,即哈希值小于零。从上面put方法部分可以得知,共有两种情况节点的哈希值小于0:

  • ForwardingNode,已被转移。
  • TreeBin,红黑树节点。

get方法整体还是比较简单的,如果头结点的hash值小于0,说明table正在扩容,或者e是红黑树节点,那我们来看一下,若table正在扩容时查找节点的代码,即ForwardingNode类实现的find方法:

Node<K,V> find(int h, Object k) {
    // loop to avoid arbitrarily deep recursion on forwarding nodes
    outer: for (Node<K,V>[] tab = nextTable;;) {
        Node<K,V> e; int n;
        if (k == null || tab == null || (n = tab.length) == 0 ||
            (e = tabAt(tab, (n - 1) & h)) == null)
            return null;
        for (;;) {
            int eh; K ek;
            // 如果找到了匹配节点,则返回
            if ((eh = e.hash) == h &&
                ((ek = e.key) == k || (ek != null && k.equals(ek))))
                return e;
            // 若节点的hash值小于0
            if (eh < 0) {
                // 若节点是ForwardingNode节点
                if (e instanceof ForwardingNode) {
                    // 将新创建的table赋值给tab,在新table中查找
                    tab = ((ForwardingNode<K,V>)e).nextTable;
                    continue outer;
                }
                // 否则,节点是红黑树节点
                else
                    return e.find(h, k);
            }
            if ((e = e.next) == null)
                return null;
        }
    }
}

再看一下红黑树实现的find方法,TreeBin.find:

final Node<K,V> find(int h, Object k) {
    if (k != null) {
        for (Node<K,V> e = first; e != null; ) {
            int s; K ek;
            if (((s = lockState) & (WAITER|WRITER)) != 0) {
                if (e.hash == h && ((ek = e.key) == k || (ek != null && k.equals(ek))))
                    return e;
                e = e.next;
            }
            else if (U.compareAndSwapInt(this, LOCKSTATE, s, s + READER)) {
                TreeNode<K,V> r, p;
                try {
                    p = ((r = root) == null ? null : r.findTreeNode(h, k, null));
                } finally {
                    Thread w;
                    if (U.getAndAddInt(this, LOCKSTATE, -READER) ==
                        (READER|WAITER) && (w = waiter) != null)
                        LockSupport.unpark(w);
                }
                return p;
            }
        }
    }
    return null;
}

这里使用了读写锁的方式,而加锁的方式和AQS一个套路。当可以获得读锁时,采用搜索红黑树的方法进行节点搜索,这样时间复杂度是O(LogN),而如果获得读锁失败(即表示当前有其它线程正在改变树的结构,比如进行红黑树的再平衡),那么将采用线性的搜索策略。

为什么可以进行线性搜索呢?因为红黑树的节点TreeNode继承自Node,所以仍然保留有next指针(即线性遍历的能力)。这一点可以从put-转为红黑树-红黑树一节得到反映,线性搜索的线程安全性通过next属性来保证:

volatile Node<K,V> next;

TreeBin的构造器同样对树的结构进行了改变,ConcurrentHashMap使用volatile读写来保证线程安全的发布。

从读写锁的引入可以看出,ConcurrentHashMap为保证最大程度的并行执行作出的努力。putTreeVal方法只有在更新树的结构时才会动用锁:

lockRoot();
try {
    root = balanceInsertion(root, x);
} finally {
    unlockRoot();
}

除此之外,由于读没有加锁,所以线程可以看到正在进行迁移的桶,但这其实并不会影响正确性,因为迁移是构造了新的链表,并不会影响原有的桶。

Remove


public V remove(Object key) {
    return replaceNode(key, null, null);
}
 
final V replaceNode(Object key, V value, Object cv) {
    // 计算key对应节点的hash值
    int hash = spread(key.hashCode());
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;
        if (tab == null || (n = tab.length) == 0 ||
            (f = tabAt(tab, i = (n - 1) & hash)) == null)
            break;
        // 若首节点对应hash值是MOVED,则表明该节点是ForwardingNode节点,帮助table扩容
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);
        else {
            V oldVal = null;
            boolean validated = false;
            // 获取首节点的监视器锁
            synchronized (f) {
                if (tabAt(tab, i) == f) {
                    // 节点的hash值 >= 0,则表明节点是链表节点
                    if (fh >= 0) {
                        validated = true;
                        // 查找与key匹配的节点
                        for (Node<K,V> e = f, pred = null;;) {
                            K ek;
                            // 找到了匹配节点
                            if (e.hash == hash &&
                                ((ek = e.key) == key ||
                                    (ek != null && key.equals(ek)))) {
                                V ev = e.val;
 
                                // 如果cv为null或者cv与key对应的旧值ev“相等”
                                if (cv == null || cv == ev ||
                                    (ev != null && cv.equals(ev))) {
                                    oldVal = ev;
 
                                    // 若value不等于null,则更新key节点对应的val
                                    if (value != null)
                                        e.val = value;
                                    // 否则,将该节点删除
                                    else if (pred != null)
                                        pred.next = e.next;
                                    else
                                        setTabAt(tab, i, e.next);
                                }
                                break;
                            }
                            pred = e;
                            if ((e = e.next) == null)
                                break;
                        }
                    }
                    // 节点是红黑树节点
                    else if (f instanceof TreeBin) {
                        validated = true;
                        TreeBin<K,V> t = (TreeBin<K,V>)f;
                        TreeNode<K,V> r, p;
                        if ((r = t.root) != null &&
                            (p = r.findTreeNode(hash, key, null)) != null) {
                            V pv = p.val;
                            if (cv == null || cv == pv ||
                                (pv != null && cv.equals(pv))) {
                                oldVal = pv;
                                if (value != null)
                                    p.val = value;
                                else if (t.removeTreeNode(p))
                                    setTabAt(tab, i, untreeify(t.first));
                            }
                        }
                    }
                }
            }
            if (validated) {
                if (oldVal != null) {
                    // 若删除了一个节点,则更新元素数目
                    if (value == null)
                        addCount(-1L, -1);
                    return oldVal;
                }
                break;
            }
        }
    }
    return null;
}

其他相关方法

要判断一个key在ConcurrentHashMap中是否存在,可以用get(Object)方法来判断的,因为ConcurrentHashMap中节点的key和value都不允许为null,而且,我们可以用containsKey(Object)方法来判断

public boolean containsKey(Object key) {
    return get(key) != null;
}
clear()方法

public void clear() {
    long delta = 0L; // negative number of deletions
    int i = 0;
    Node<K,V>[] tab = table;
    // 遍历table数组
    while (tab != null && i < tab.length) {
        int fh;
        // 获取table数组中下标为i的首节点
        Node<K,V> f = tabAt(tab, i);
        // 首节点为null,则更新下标i
        if (f == null)
            ++i;
        // 首节点hash值是MOVED,则帮助迁移
        else if ((fh = f.hash) == MOVED) {
            tab = helpTransfer(tab, f);
            // 迁移完成之后,将i置为0,重新开始清除table
            i = 0; // restart
        }
        else {
            // 获取首节点的监视器锁
            synchronized (f) {
                // 根据Node节点的next属性,删除对应链表或者红黑树
                if (tabAt(tab, i) == f) {
                    Node<K,V> p = (fh >= 0 ? f :
                                    (f instanceof TreeBin) ?
                                    ((TreeBin<K,V>)f).first : null);
                    while (p != null) {
                        --delta;
                        p = p.next;
                    }
                    setTabAt(tab, i++, null);
                }
            }
        }
    }
    // 更新元素数目
    if (delta != 0L)
        addCount(delta, -1);
}

相关问题

1、JDK 1.8为什么要放弃Segment?`

锁的粒度

首先锁的粒度并没有变粗,甚至变得更细了。每当扩容一次,ConcurrentHashMap的并发度就扩大一倍。

Hash冲突

JDK1.7中,ConcurrentHashMap从过二次hash的方式(Segment -> HashEntry)能够快速的找到查找的元素。在1.8中通过链表加红黑树的形式弥补了put、get时的性能差距。

扩容

JDK1.8中,在ConcurrentHashmap进行扩容时,其他线程可以通过检测数组中的节点决定是否对这条链表(红黑树)进行扩容,减小了扩容的粒度,提高了扩容的效率。

2、JDK 1.8为什么要使用synchronized而不是可重入锁?

减少内存开销

假设使用可重入锁来获得同步支持,那么每个节点都需要通过继承AQS来获得同步支持。但并不是每个节点都需要获得同步支持的,只有链表的头节点(红黑树的根节点)需要同步,这无疑带来了巨大内存浪费。

获得JVM的支持

可重入锁毕竟是API这个级别的,后续的性能优化空间很小。 synchronized则是JVM直接支持的,JVM能够在运行时作出相应的优化措施:锁粗化、锁消除、锁自旋等等。这就使得synchronized能够随着JDK版本的升级而不改动代码的前提下获得性能上的提升。

3、ConcurrentHashMap能完全替代HashTable吗?

hash table虽然性能上不如ConcurrentHashMap,但并不能完全被取代,两者的迭代器的一致性不同的,hash table的迭代器是强一致性的,而ConcurrentHashMap是弱一致的。 ConcurrentHashMap的迭代器方法都是弱一致性的。关于弱一致性的解释可以看这篇博客

在JDK1.8的ConcurrentHashMap实现中,它的迭代器有KeySetView、ValuesView和EntrySetView这三种,我们来看获取KeySetView迭代器方的法:

/**
* <p>The view's iterators and spliterators are
* <a href="package-summary.html#Weakly"><i>weakly consistent</i></a>.
*
* @return the set view
*/
public KeySetView<K,V> keySet() {
    KeySetView<K,V> ks;
    return (ks = keySet) != null ? ks : (keySet = new KeySetView<K,V>(this, null));
}

可以看到,注释中也说明该迭代器是弱一致性的,我们来看一下KeySetView类的iterator方法:

public Iterator<K> iterator() {
    Node<K,V>[] t;
    ConcurrentHashMap<K,V> m = map;
    int f = (t = m.table) == null ? 0 : t.length;
    return new KeyIterator<K,V>(t, f, 0, f, m);
}

最终是返回了一个KeyIterator类对象,在KeyIterator上调用next方法时,最终实际调用到了Traverser.advance()方法,我们来看一下Traverser的构造方法以及advance()方法:

Traverser(Node<K,V>[] tab, int size, int index, int limit) {
    this.tab = tab;
    this.baseSize = size;
    this.baseIndex = this.index = index;
    this.baseLimit = limit;
    this.next = null;
}
final Node<K,V> advance() {
    Node<K,V> e;
    if ((e = next) != null)
        e = e.next;
    for (;;) {
        Node<K,V>[] t; int i, n;  // must use locals in checks
        if (e != null)
            return next = e;
        if (baseIndex >= baseLimit || (t = tab) == null ||
            (n = t.length) <= (i = index) || i < 0)
            return next = null;
        if ((e = tabAt(t, i)) != null && e.hash < 0) {
            if (e instanceof ForwardingNode) {
                tab = ((ForwardingNode<K,V>)e).nextTable;
                e = null;
                pushState(t, i, n);
                continue;
            }
            else if (e instanceof TreeBin)
                e = ((TreeBin<K,V>)e).first;
            else
                e = null;
        }
        if (stack != null)
            recoverState(n);
        else if ((index = i + baseSize) >= n)
            index = ++baseIndex; // visit upper slots if present
    }
}

这个方法在遍历底层数组。在遍历过程中,如果已经遍历的数组上的内容变化了,迭代器不会抛出ConcurrentModificationException异常。如果未遍历的数组上的内容发生了变化,则有可能反映到迭代过程中。这就是ConcurrentHashMap迭代器弱一致的表现。

但是Hashtable的任何操作都会把整个表锁住,是阻塞的。好处是总能获取最实时的更新,比如说线程A调用putAll写入大量数据,期间线程B调用迭代器方法,线程B就会被阻塞,直到线程A完成putAll,因此线程B肯定能获取到线程A写入的完整数据。坏处是所有调用都要排队,效率较低。

所以,ConcurrentHashMap并不能完全替代HashTable。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值