• ConcurrentHashMap底层原理分析(put方法)


    一、简介

    1. ConcurrentHashMap是J.U.C包里提供的一个线程安全且高效的HashMap,类继承关系如图:
      ConcurrentHashMap类图
    2. ConcurrentHashMap基本结构:
      基本结构

    内部维护一个存放Node结点的数组,即table。数组的每个位置代表了一个桶,table可以包含4种不同类型的桶:Node、TreeBin、ForwardingNode、ReservationNode。

    1. ConcurrentHashMap包含五种结点:
    • Node结点:普通的Entry结点,以链表形式存储实际数据;
    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 int hashCode() {
        return key.hashCode() ^ val.hashCode();
    }
    
    //链表查找
    Node<K, V> find(int h, Object k) {...}
    
    ...
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    数据会先以链表的形式存放在Node结点中,当结点到达一定数目时,链表会转化成红黑树,因为链表查找的平均时间复杂度为O(n),而红黑树为O(logn),这样可以提高查找效率。

    • TreeNode结点:红黑树的结点,存储实际数据;
    static final class TreeNode<K, V> extends Node<K, V> {
        boolean red;
    
        TreeNode<K, V> parent;
        TreeNode<K, V> left;
        TreeNode<K, V> right;
        TreeNode<K, V> prev;
    
        TreeNode(int hash, K key, V val, Node<K, V> next,
                 TreeNode<K, V> parent) {
            super(hash, key, val, next);
            this.parent = parent;
        }
    
        //以当前结点为根结点,开始遍历查找指定key
        Node<K, V> find(int h, Object k) {...}
    
        ...
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • TreeBin结点:提供了红黑树相关的操作,以及加解锁操作;
    static final class TreeBin<K, V> extends Node<K, V> {
        TreeNode<K, V> root;                // 红黑树结构的根结点
        volatile TreeNode<K, V> first;      // 链表结构的头结点
        volatile Thread waiter;             // 最近的一个设置WAITER标识位的线程
    
        volatile int lockState;             // 整体的锁状态标识位,0表示未加锁
    
        static final int WRITER = 1;        // 二进制001,红黑树的写锁状态
        static final int WAITER = 2;        // 二进制010,红黑树的等待获取写锁状态
        static final int READER = 4;        // 二进制100,红黑树的读锁状态,读可以并发,每多一个读线程,lockState都加上一个READER值
    
        //在hashCode相等并且不是Comparable类型时,用此方法判断大小
        static int tieBreakOrder(Object a, Object b) {...}
    
        //将以b为头结点的链表转换为红黑树
        TreeBin(TreeNode<K, V> b) {...}
    
        //对红黑树的根结点加写锁
        private final void lockRoot() {...}
    
        //释放写锁
        private final void unlockRoot() {...}
    
        /**
         * 从根结点开始遍历查找,找到“相等”的结点就返回它,没找到就返回null
         * 当存在写锁时,以链表方式进行查找
         * 两种特殊情况下以链表的方式进行查找:
         *  1. 有线程正持有写锁,这样做能够不阻塞读线程
         *  2. 有线程等待获取写锁,不再继续加读锁,相当于“写优先”模式
         */
        final Node<K, V> find(int h, Object k) {...}
    
         //查找指定key对应的结点,如果未找到,则插入.
        final TreeNode<K, V> putTreeVal(int h, K k, V v) {...}
    
        /**
         * 删除红黑树的结点:
         * 1. 红黑树规模太小时,返回true,然后进行 树 -> 链表 的转化;
         * 2. 红黑树规模足够时,不用变换成链表,但删除结点时需要加写锁.
         */
        final boolean removeTreeNode(TreeNode<K, V> p) {...}
    
        ...
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • ForwardingNode结点:在扩展时使用,是一种临时结点,hash值固定为-1,不存储实际数据。扩展时如果旧table某一个桶中的结点已经全部迁移到新的table了,则在这个桶中放置一个FowardingNode结点。另外作为转发结点,在扩容期间如果遇到查询操作,会将该查询转发到nextTable,即新table上,不会阻塞查询;
    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;
        }
    
        // 在新的数组nextTable上进行查找
        Node<K, V> find(int h, Object k) {...}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    读操作碰到ForwardingNode时,将操作转发到扩容后的新table数组上去执行;写操作碰到它时,则尝试协助扩容。

    • ReservationNode结点:保留结点,hash值固定为-3,不存储实际数据,只在computeIfAbsent和compute这两个方法充当占位符加锁使用;
    1. ConcurrentHashMap提供了5个构造器,但是构造器内部只是计算了table的初始容量(赋值给sizeCtrl),并没有创建table的操作,只有在首次插入键值对的时候才会进行初始化table数组的操作。

    如果new实例时传入初始大小initialCapacity,那会调用tableSizeFor返回大于initialCapacity的最小2次幂值,并把这个值设置到sizeCtrl。

    private static final int tableSizeFor(int c) {
    	int n = c - 1;
    	n |= n >>> 1;
    	n |= n >>> 2;
    	n |= n >>> 4;
    	n |= n >>> 8;
    	n |= n >>> 16;
    	return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    代码各个阶段的结果如下图所示:
    初始化Map大小

    如果传入初始大小initialCapacity和负载因子loadFactor,则还需要通过1+initialCapacity/loadFactor计算出实际的初始化大小。一个结点在桶中出现的概率符合泊松分布,使用0.75作为负载因子,可以降低结点在某一个桶中出现的概率,降低hash冲突,也就降低了查询时间,同时也不会因为负载因子过小导致table过大,占用过多的内存。

    二、常量

    private static final int MAXIMUM_CAPACITY = 1 << 30;   //最大容量
    
    private static final int DEFAULT_CAPACITY = 16;        //默认初始容量
    
    private static final float LOAD_FACTOR = 0.75f;        //负载因子,恒定为0.75
    
    static final int TREEIFY_THRESHOLD = 8;                //链表转树的阈值,即链接结点数大于8时, 链表转换为树
    
    static final int UNTREEIFY_THRESHOLD = 6;              //树转链表的阈值,即树结点树小于6时,树转换为链表
    
    static final int MIN_TREEIFY_CAPACITY = 64;            //只有键值对数量大于MIN_TREEIFY_CAPACITY,链表才能转变成树
    
    private static final int MIN_TRANSFER_STRIDE = 16;     //只有键值对数量小于MIN_TRANSFER_STRIDE,树才能转变成链表
     
    private static int RESIZE_STAMP_BITS = 16;             //用于在扩容时生成唯一的随机数
    
    private static final int MAX_RESIZERS = (1 << (32 - RESIZE_STAMP_BITS)) - 1; // 可同时进行扩容操作的最大线程数
    
    private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS; // 扩容用
    
    static final int MOVED = -1;                           // 标识ForwardingNode结点(在扩容时才会出现,不存储实际数据)
    static final int TREEBIN = -2;                         // 标识红黑树的根结点
    static final int RESERVED = -3;                        // 标识ReservationNode结点
    static final int HASH_BITS = 0x7fffffff;               // usable bits of normal node hash
    
    static final int NCPU = Runtime.getRuntime().availableProcessors(); // CPU核心数,扩容时使用
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26

    三、字段定义

    transient volatile Node<K, V>[] table; //Node数组,标识整个Map,首次插入元素时创建,大小总是2的幂次
    
    private transient volatile Node<K, V>[] nextTable; //扩容后的新Node数组,只有在扩容时才非空
    
    /**
     * 控制table的初始化和扩容
     * 0  : 初始默认值
     * -1 : 有线程正在进行table的初始化
     * >0 : table初始化时使用的容量,或初始化/扩容完成后的阈值
     * -(1 + nThreads) : 表示有nThreads个线程正在执行扩容
     */
    private transient volatile int sizeCtl;
    
    private transient volatile int transferIndex;//扩容时需要用到的一个下标变量
    
    private transient volatile long baseCount;//计数基值,当没有线程竞争时,计数将加到该变量上
    
    private transient volatile CounterCell[] counterCells;//计数数组,出现并发冲突时使用
    
    private transient volatile int cellsBusy;//自旋标识位,用于CounterCell[]扩容时使用。
    
    // 视图相关字段
    private transient KeySetView<K, V> keySet;
    private transient ValuesView<K, V> values;
    private transient EntrySetView<K, V> entrySet;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25

    四、put方法源码分析

    从put()方法切入,实际调用了putVal()方法,主要做了以下事情:

    1. 如果key和value为null时,抛出NullPointerException
    2. 调用扰动函数获取哈希值,对key的hashCode进行高16位不变,低16位和高16位进行异或操作,然后将得出的值去掉符号位。
    	static final int HASH_BITS = 0x7fffffff;
        
        static final int spread(int h) {
            return (h ^ (h >>> 16)) & HASH_BITS;
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    1. 遍历table,进行以下判断:
    • 如果table为空,则开始初始化数组。当sizeCtrl小于0时,表示已经有线程正在进行初始化,此时让出当前线程的CPU占用权;否则通过CAS将sizeCtrl设置成-1,表示当前线程已经开始初始化table。创建一个大小为16(默认大小,如果sizeCtrl大于0,则使用sizeCtrl作为table大小,new一个ConcurrentHashMap的时候会设置sizeCtrl作为初始大小)的Node数组赋值给table,并把sizeCtrl设置成0.75,然后进行下一步。
    transient volatile Node<K,V>[] table;
    private transient volatile int sizeCtl;
    private static final int DEFAULT_CAPACITY = 16;
    
    private final Node<K,V>[] initTable() {
        Node<K,V>[] tab; int sc;
        while ((tab = table) == null || tab.length == 0) {
            if ((sc = sizeCtl) < 0)
                Thread.yield(); // lost initialization race; just spin
            else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
                try {
                    if ((tab = table) == null || tab.length == 0) {
                        int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                        @SuppressWarnings("unchecked")
                        Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                        table = tab = nt;
                        sc = n - (n >>> 2); //设置sizeCtrl = 0.75 * table.length
                    }
                } finally {
                    sizeCtl = sc;
                }
                break;
            }
        }
        return tab;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 如果已经table已经初始化,但是table中索引为(n-1)&hash的位置没有值。则将Node节点CAS进table中索引为(n-1)&hash的位置。
    private static final long ABASE;
    private static final int ASHIFT;
    
    Class<?> ak = Node[].class;
    ABASE = U.arrayBaseOffset(ak);
    int scale = U.arrayIndexScale(ak);
    if ((scale & (scale - 1)) != 0)
        throw new Error("data type scale not a power of two");
    ASHIFT = 31 - Integer.numberOfLeadingZeros(scale);
    
    transient volatile Node<K,V>[] table;
    
    static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
        //table虽然是volatile变量,但对于其他线程只是引用可见,table里的元素不可见,因此需要调用getObjectVolatile保证每次拿到的数据都是最新的。
        return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
    }
    
    static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
                                        Node<K,V> c, Node<K,V> v) {
        // 更新Node数组上的元素
        return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    由于是通过(n-1)&hash查找元素,因此需要保证n(即table长度)为2的N次幂才能让key均匀分布,减少哈希冲突。

    • 如果table中索引为(n-1)&hash的位置有值,且该位置的元素hash属性为MOVED,说明此时table正在扩容,当前线程可以协助数据迁移,参考addCount方法。
    static final int MOVED     = -1;
    
    final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) {
        Node<K,V>[] nextTab; int sc;
        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;
                //将sizeCtrl加1,然后开始扩容+数据迁移
                if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {
                    transfer(tab, nextTab);
                    break;
                }
            }
            return nextTab;
        }
        return table;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23

    扩容状态下其他线程对集合进行插入、修改、删除、合并、compute等操作时遇到 ForwardingNode 结点会调用该方法。

    • 如果插入的节点不在table上,则遍历链表或者红黑树,如果存在相同哈希值和Key的节点时更新Node/TreeNode的value,否则插入新的Node/TreeNode结点。

    使用同步锁synchronized,插入相同hash的元素时会阻塞。

    • 如果链表长度大于8,则将链表转换成红黑树,但是如果table的长度小于64,则只将table扩容一倍,不转换成红黑树,这是为了避免table建立初期,多个键值对恰好放入同一个链表中导致不必要的转换。
    static final int TREEIFY_THRESHOLD = 8;
    static final int MIN_TREEIFY_CAPACITY = 64;
    private static final int MAXIMUM_CAPACITY = 1 << 30;
    
    private final void treeifyBin(Node<K,V>[] tab, int index) {
        Node<K,V> b; int n, sc;
        if (tab != null) {
            //table的容量小于64时,直接进行table扩容,不进行红黑树转换
            if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
                tryPresize(n << 1);
                
            //table的容量大于等于64时,将链表转换成红黑树
            else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
                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;
                        }
                        //将TreeNode封装成TreeBin,并放到table[index]中
                        setTabAt(tab, index, new TreeBin<K,V>(hd));
                    }
                }
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    1. 更改计数以及扩容,这里先讨论计数的部分,扩容部分后面再做介绍。如果没有并发,直接累加baseCount,如果存在并发,则累加counterCells。
    private final void addCount(long x, int check) {
        CounterCell[] as;
        long b, s;
        if ((as = counterCells) != null ||
                // 首先尝试更新baseCount
                !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
    
            // 更新失败,说明出现并发冲突,则将计数值累加到CounterCells[ThreadLocalRandom.getProbe() & counterCells的长度]
            CounterCell a;
            long v;
            int m;
            boolean uncontended = true;
            if (as == null || (m = as.length - 1) < 0 ||
                    //根据线程安全的随机数和counterCells长度计算元素索引
                    (a = as[ThreadLocalRandom.getProbe() & m]) == null ||   
                    !(uncontended = U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
                // 计数值累加到CounterCells失败
                fullAddCount(x, uncontended);
                return;
            }
            if (check <= 1)
                return;
            s = sumCount();
        }
    
        ...(扩容的内容第七节再讨论)
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27

    如果累加counterCells失败,则会调用fullAddCount()方法,代码逻辑如下:

    • 如果线程安全的随机数为0,则重新生成随机数;
    if ((h = ThreadLocalRandom.getProbe()) == 0) {
        ThreadLocalRandom.localInit(); 
        h = ThreadLocalRandom.getProbe();
        wasUncontended = true;//表示当前不存在锁竞争
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 如果counterCells已经初始化,尝试加锁,然后将计数插入或者更新counterCells中,如果失败则进行扩容(原来的2倍);
    //counterCells[(n-1)&h]没有被使用(n为counterCells的长度),尝试加锁,然后将计数插入counterCells
    if ((a = as[(n - 1) & h]) == null) {
        if (cellsBusy == 0) {//counterCells没有加锁 
            CounterCell r = new CounterCell(x); 
            if (cellsBusy == 0 &&
                //尝试加锁,通过CAS将cellsBusy从0设置为1
                U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
                boolean created = false;
                try {             
                    CounterCell[] rs; int m, j;
                    if ((rs = counterCells) != null &&
                        (m = rs.length) > 0 &&
                        rs[j = (m - 1) & h] == null) {//重新判断一次
                        rs[j] = r;//将创建的CounterCell赋值到counterCells[(n-1)&h]
                        created = true;
                    }
                } finally {
                    cellsBusy = 0;//释放锁
                }
                if (created)//如果创建的CounterCell加到counterCells则停止自旋
                    break;
                continue; 
            }
        }
        collide = false;//counterCells扩展标识,锁被占用时不需要扩展
    }
    else if (!wasUncontended)//待研究。。。
        wasUncontended = true; 
    //如果counterCells[(n - 1) & h]已被使用,则把计数累加到该CounterCell的value值
    else if (U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))
        break;
    //当counterCells大小大于CPU核数后,不再进行扩容
    else if (counterCells != as || n >= NCPU)
        collide = false; 
    else if (!collide)
        collide = true;
    //如果更新CounterCell失败,则尝试加锁,然后扩容counterCells为原来的2倍
    else if (cellsBusy == 0 &&
             U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
        try {
            if (counterCells == as) {
                CounterCell[] rs = new CounterCell[n << 1];//扩容后的大小为原来的2倍
                for (int i = 0; i < n; ++i)//将数据迁移到新的counterCells
                    rs[i] = as[i];
                counterCells = rs;
            }
        } finally {
            cellsBusy = 0;//释放锁
        }
        collide = false;
        continue;               
    }
    h = ThreadLocalRandom.advanceProbe(h);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 如果counterCells没有初始化且没有加锁,则尝试对它进行加锁,如果加锁成功就初始化counterCells,大小为2;
    else if (cellsBusy == 0 && counterCells == as &&
                 U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
            boolean init = false;
            try {
                if (counterCells == as) {
                    //初始化大小为2的CounterCell数组
                    CounterCell[] rs = new CounterCell[2];
                    //将创建的CounterCell存入CounterCell数组,存放索引为0或者1
                    rs[h & 1] = new CounterCell(x);
                    counterCells = rs;
                    init = true;
                }
            } finally {
                //释放锁
                cellsBusy = 0;
            }
            if (init)
                break;
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 如果counterCells正在进行初始化,则尝试直接累加到baseCount上;
    else if (U.compareAndSwapLong(this, BASECOUNT, v = baseCount, v + x))
            break;
    
    • 1
    • 2

    五、结语

    本篇主要介绍了ConcurrentHashMap的基本原理,以及添加元素时如何保证一致性,写的比较零散。关于如何计数、如何获取元素、如何扩容有空再整理了。

    by alen.wen

  • 相关阅读:
    bp神经网络图像特征提取,神经网络如何提取特征
    个人商城系统开源(注册)
    【Spring】之初识
    高效数据传输:Java通过绑定快速将数据导出至Excel
    知识产权恶意侵权是什么意思
    Maven
    配置web服务器+编写简单页面+分析交互过程
    英国国家卫生服务遭受攻击,系统出现大面积故障
    大数据业务场景
    [官方培训]13-渲染输出理解及最佳实践 戴浩军 Epic 笔记
  • 原文地址:https://blog.csdn.net/vipshop_fin_dev/article/details/126682549