zoukankan      html  css  js  c++  java
  • java.util.ConcurrentHashMap (JDK 1.8)

    1.1 java.util.ConcurrentHashMap继承结构

     

    ConcurrentHashMap和HashMap的实现有很大的相似性,建议先看HashMap源码,再来理解ConcurrentHashMap。

    1.2 java.util.ConcurrentHashMap属性

    这里仅展示几个关键的属性

     1     // ConcurrentHashMap核心数组
     2     transient volatile Node<K,V>[] table;
     3 
     4     // 扩容时才会用的一个临时数组
     5     private transient volatile Node<K,V>[] nextTable;
     6     
     7     /**
     8      * table初始化和resize控制字段
     9      * 负数表示table正在初始化或resize。-1表示正在初始化,-N表示有N-1个线程正在resize操作
    10      * 当table为null的时候,保存初始化表的大小以用于创建时使用,或者直接采用默认值0
    11      * table初始化之后,保存下一次扩容的的大小,跟HashMap的threshold = loadFactor*capacity作用相同
    12      */
    13     private transient volatile int sizeCtl;
    14 
    15     // resize的时候下一个需要处理的元素下标为index=transferIndex-1
    16     private transient volatile int transferIndex;
    17 
    18     // 通过CAS无锁更新,ConcurrentHashMap元素总数,但不是准确值
    19     // 因为多个线程同时更新会导致部分线程更新失败,失败时会将元素数目变化存储在counterCells中
    20     private transient volatile long baseCount;
    21 
    22     // resize或者创建CounterCells时的一个标志位
    23     private transient volatile int cellsBusy;
    24 
    25     // 用于存储元素变动
    26     private transient volatile CounterCell[] counterCells;

    1.3 java.util.ConcurrentHashMap方法

    1.3.1 Unsafe.compareAndSwapXXX方法

    Unsafe.compareAndSwapXXX方法是sun.misc.Unsafe类中的方法,因为在ConcurrentHashMap中大量使用了这些方法。其声明如下:

    public final native boolean compareAndSwapXXX(type1 object, type2 offset, type4 expect, type5 update);

    object为待修改的对象,offset为偏移量(数组可以理解为下标),expect为期望值,update为更新值。这个方法执行的逻辑伪代码如下:

    1 if (object[offset].value equal expect) {
    2     object[offset].value = update;
    3     return true;
    4 } else {
    5     return false   
    6 }

    object[offset].value 等于expect更新value值并返回true,否则不更新并且返回false。之所以不更新是因为多线程执行时有其它线程已经修改该值,expect已经不是最新的值,如果强行修改必然会覆盖之前的修改,造成脏数据。

    CAS方法都是native方法,可以保证原子性,并且效率比synchronized高。 

    1.3.2 hash方法

    1     static final int HASH_BITS = 0x7fffffff; // usable bits of normal node hash
    2     int hash = spread(key.hashCode());
    3 
    4     static final int spread(int h) {
    5         return (h ^ (h >>> 16)) & HASH_BITS;
    6     }

    上面源码为计算hash算法,h ^ (h >>> 16)在计算hash的时候key.hashCode()的高位也参与运算,这部分跟HashMap计算方法一致,不同的是h ^ (h >>> 16)计算结果“与”上 0x7fffffff,从而保证结果一定为正整数。获得hash之后,通过hash & (n -1)计算下标。 

    ConcurrentHashMap中的元素节点总结一下有这么几种可能:

    (1) null 暂无元素

    (2) Node<K, V> 普通节点,可以组成单向链表,hash > 0

    (3) TreeBin<K,V> 红黑树节点,TreeBin是对TreeNode的封装,其hash为TREEBIN = -2。

    HashMap和ConcurrentHashMap的TreeNode实现并不相同。

    在HashMap中TreeNode封装了红黑树所有的操作方法,而ConcurrentHashMap中红黑树操作的方法都封装在TreeBin中,TreeBin相当于一个红黑树容器,容器中的红黑树节点为TreeNode。

    HashMap可以直接在tab[i]存入TreeNode,而ConCurrentHashMap只能在tab[i]存入TreeBin。

    (4) ForwardingNode<K,V> key和value都为null的一个特殊节点,用于resize操作填充已经完成迁移操作的节点。FrowardingNode的hash在初始化的时候被置成MOVED = -1

    在resize过程中当发现tab[i]上是ForwardingNode的时候(通过hash判断)就可知tab[i]已经迁移完了,直接跳过该节点去处理其它节点。

    ConcurrentHashMap禁止node的key或value为null或许跟该节点的存在也是有一定关系的。

    (5)ReservationNode<K,V>只在compute和computeIfAbsent中使用,其hash为RESERVED = -3

    从上面的总结可以看出普通节点hash为正整数是有意义的,hash > 0是判断该节点是否为链表节点(普通节点)的一个重要依据。

    1.3.3 get/set/update tab[i] 方法

     1     // 获取tab[i]节点
     2     static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
     3         return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
     4     }
     5 
     6     // compare and swap tab[i],期望值是c,tab[i].value == c ? tab[i] = v : return false
     7     static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i, Node<K,V> c, Node<K,V> v) {
     8         return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
     9     }
    10 
    11     // 设置tab[i] = v
    12     static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) {
    13         U.putObjectVolatile(tab, ((long)i << ASHIFT) + ABASE, v);
    14     }

    1.3.4 size() 方法

     1     public int size() {
     2         long n = sumCount();
     3         return ((n < 0L) ? 0 : (n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n);
     4     }
     5 
     6     final long sumCount() {
     7         CounterCell[] as = counterCells; CounterCell a;
     8         long sum = baseCount;
     9         // 除了baseCount以外,部分元素变化存储在counterCells数组中
    10         if (as != null) {
    11             // 遍历数组累加获得结果
    12             for (int i = 0; i < as.length; ++i) {
    13                 if ((a = as[i]) != null)
    14                     sum += a.value;
    15             }
    16         }
    17         return sum;
    18     }

    ConcurrentHashMap中baseCount用于保存tab中元素总数,但是并不准确,因为多线程同时增删改,会导致baseCount修改失败,此时会将元素变动存储于counterCells数组内。

    当需要统计当前的size的时候,除了要统计baseCount之外,还需要统计counterCells中的元素变化。

    值得一提的是即使如此,统计出来的依旧不是当前tab中元素的准确值,在多线程环境下统计前后并不能stop the world暂停线程操作,因此无法保证准确性。

    1.3.5 put/putIfAbsent方法

     1     public V put(K key, V value) {
     2         // 核心是调用putVal方法
     3         return putVal(key, value, false);
     4     }
     5 
     6     public V putIfAbsent(K key, V value) {
     7         // 如果key存在就不更新value
     8         return putVal(key, value, true);
     9     }
    10 
    11     /** Implementation for put and putIfAbsent */
    12     final V putVal(K key, V value, boolean onlyIfAbsent) {
    13         // key或value 为null都是不允许的,因为Forwarding Node就是key和value都为null,是用作标志位的。
    14         if (key == null || value == null) throw new NullPointerException();
    15         // 根据key计算hash值,有了hash就可以计算下标了
    16         int hash = spread(key.hashCode());
    17         int binCount = 0;
    18         // 可能需要初始化或扩容,因此一次未必能完成插入操作,所以添加上for循环
    19         for (Node<K,V>[] tab = table;;) {
    20             Node<K,V> f; int n, i, fh;
    21             // 表还没有初始化,先初始化,lazily initialized
    22             if (tab == null || (n = tab.length) == 0)
    23                 tab = initTable();
    24             // 根据hash计算应该插入的index,该位置上还没有元素,则直接插入
    25             else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
    26                 if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
    27                     break;                   // no lock when adding to empty bin
    28             }
    29             // static final int MOVED = -1; // hash for forwarding nodes
    30             // 说明f为ForwardingNode,只有扩容的时候才会有ForwardingNode出现在tab中,因此可以断定该tab正在进行扩容
    31             else if ((fh = f.hash) == MOVED)  
    32                 // 协助扩容            
    33                 tab = helpTransfer(tab, f);   
    34             else {
    35                 V oldVal = null;
    36                 // 节点上锁,hash值相同的节点组成的链表头结点
    37                 synchronized (f) {
    38                     if (tabAt(tab, i) == f) {
    39                         if (fh >= 0) { // 是链表节点
    40                             binCount = 1;
    41                             for (Node<K,V> e = f;; ++binCount) {
    42                                 K ek;
    43                                 // 遍历链表查找是否包含该元素
    44                                 if (e.hash == hash && ((ek = e.key) == key || (ek != null && key.equals(ek)))) {
    45                                     oldVal = e.val;  // 保存旧的值用于当做返回值
    46                                     if (!onlyIfAbsent)
    47                                         e.val = value;  // 替换旧的值为新值
    48                                     break;
    49                                 }
    50                                 Node<K,V> pred = e;
    51                                 if ((e = e.next) == null) {
    52                                     // 遍历链表,如果一直没找到,则新建一个Node放到链表结尾
    53                                     pred.next = new Node<K,V>(hash, key, value, null);
    54                                     break;
    55                                 }
    56                             }
    57                         }
    58                         else if (f instanceof TreeBin) { // 是红黑树节点
    59                             Node<K,V> p;
    60                             binCount = 2;
    61                             // 去红黑树查找该元素,如果没找到就添加,找到了就返回该节点
    62                             if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key, value)) != null) {
    63                                 // 保存旧的value用于返回
    64                                 oldVal = p.val;
    65                                 if (!onlyIfAbsent)
    66                                     p.val = value; // 替换旧的值
    67                             }
    68                         }
    69                     }
    70                 }
    71                 if (binCount != 0) {
    72                     if (binCount >= TREEIFY_THRESHOLD)
    73                         // 链表长度超过阈值(默认为8),则需要将链表转为一棵红黑树
    74                         treeifyBin(tab, i);
    75                     if (oldVal != null)
    76                         // 如果只是替换,并未带来节点的增加则直接返回旧的value即可
    77                         return oldVal;
    78                     break;
    79                 }
    80             }
    81         }
    82         // 元素总数加1,并且判断是否需要扩容
    83         addCount(1L, binCount);
    84         return null;
    85     }

    1.3.6 addCount方法

    在putVal方法中调用了若干其它方法,下面来看下addCount方法。

     1     // check<0不检查resize, check<=1只在没有线程竞争的情况下检查resize
     2     private final void addCount(long x, int check) {
     3         CounterCell[] as; long b, s;
     4         // counterCells数组不为null       
     5         if ((as = counterCells) != null ||
     6             // CAS更新BASECOUNT失败(有其它线程更新了BASECOUNT,baseCount已经不是最新值)
     7             !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
     8             CounterCell a; long v; int m;
     9             boolean uncontended = true;
    10             // counterCells为null
    11             if (as == null || (m = as.length - 1) < 0 ||
    12                 // counterCells对应位置为null,这里不是很懂,有没有大神解答下?
    13                 // ThreadLocalRandom.getProbe() 获得线程探测值,什么用途?
    14                 (a = as[ThreadLocalRandom.getProbe() & m]) == null ||
    15                 // 更新CELLVALUE失败
    16                 !(uncontended = U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
    17                 // 初始化counterCells
    18                 fullAddCount(x, uncontended);
    19                 return;
    20             }
    21             // counterCells != null 或者 BASECOUNT CAS更新失败都是因为有线程竞争,因此不检查resize
    22             if (check <= 1)
    23                 return;
    24             // 统计下ConcurrentHashMap元素总数    
    25             s = sumCount();
    26         }
    27         if (check >= 0) {
    28             Node<K,V>[] tab, nt; int n, sc;
    29             // 元素总数大于sizeCtl
    30             while (s >= (long)(sc = sizeCtl) && (tab = table) != null && (n = tab.length) < MAXIMUM_CAPACITY) {
    31                 // 获取一个resize标志位   
    32                 int rs = resizeStamp(n);
    33                 // sizeCtl < 0 表示table正在初始化或者resize
    34                 if (sc < 0) {
    35                     if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
    36                         sc == rs + MAX_RESIZERS || (nt = nextTable) == null || transferIndex <= 0)
    37                         break;
    38                     if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
    39                         transfer(tab, nt);
    40                 }
    41                 // 当前线程是第一个发起扩容操作
    42                 else if (U.compareAndSwapInt(this, SIZECTL, sc, (rs << RESIZE_STAMP_SHIFT) + 2))
    43                     transfer(tab, null);
    44                 s = sumCount();
    45             }
    46         }
    47     }

    1.3.7 resize相关方法:resizeStamp、helpTransfer、transfer

    1     // 返回一个标志位,该标志位经过RESIZE_STAMP_SHIFT左移必定为负数
    2     static final int resizeStamp(int n) {
    3         // Integer.numberOfLeadingZeros返回n对应32位二进制数左侧0的个数,如9(1001)返回28  
    4         // 1 << (RESIZE_STAMP_BITS - 1) = 2^15,其中RESIZE_STAMP_BITS固定为16
    5         return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
    6     }
    7     

    helpTransfer方法:辅助扩容方法,直接进入transfer方法的迁移元素阶段

     1     final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) {
     2         Node<K,V>[] nextTab; int sc;
     3         // 在tab中发现了ForwardingNode,在ForwardingNode初始化的时候保存了nextTable引用
     4         // 因此可以通过f找到nextTable,并且可以断定nextTable!=null
     5         if (tab != null && (f instanceof ForwardingNode) &&
     6             (nextTab = ((ForwardingNode<K,V>)f).nextTable) != null) {
     7             int rs = resizeStamp(tab.length);
     8             while (nextTab == nextTable && table == tab && (sc = sizeCtl) < 0) {
     9                 if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
    10                     sc == rs + MAX_RESIZERS || transferIndex <= 0)
    11                     break;
    12                 if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {
    13                     transfer(tab, nextTab);
    14                     break;
    15                 }
    16             }
    17             return nextTab;
    18         }
    19         return table;
    20     }  

    transfer方法:resize的核心操作。基本思路是先new一个double capacity的nextTable数组,然后将tab中的元素一个一个迁移到nextTable中。迁移完成后将tab = nextTable操作替换掉tab。

      1     // 扩容操作方法
      2     private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
      3         int n = tab.length, stride;
      4         if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
      5             stride = MIN_TRANSFER_STRIDE; // subdivide range
      6         // 刚开始resize,需要初始化nextTab
      7         if (nextTab == null) {
      8             try {
      9                 @SuppressWarnings("unchecked")
     10                 Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];  // 扩容为两倍
     11                 nextTab = nt;
     12             } catch (Throwable ex) {      // try to cope with OOME
     13                 sizeCtl = Integer.MAX_VALUE;
     14                 return;
     15             }
     16             nextTable = nextTab;
     17             transferIndex = n;  // 倒序transfer tab
     18         }
     19         int nextn = nextTab.length; // 扩容后表的length
     20         // 预先定义一个头节点ForwardingNode,其hash被置成MOVED=-1
     21         // 当线程发现某个元素hash==MOVED则表明该节点已经被处理过
     22         ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
     23         boolean advance = true;
     24         // 是否完成元素迁移的标志
     25         boolean finishing = false; // to ensure sweep before committing nextTab
     26         for (int i = 0, bound = 0;;) {
     27             Node<K,V> f; int fh;
     28             // 这个while循环是为了找到下一个准备处理的下标
     29             while (advance) {
     30                 int nextIndex, nextBound;
     31                 // --i还未越界,准备处理tab[i]
     32                 // finishing==true,resize完成,可能处于提交前的检查阶段,检查tab[--i]
     33                 if (--i >= bound || finishing)
     34                     advance = false;
     35                 // 下一个准备处理的元素下标为transferIndex-1<0, 可以断定tab已经完成了transfer操作    
     36                 else if ((nextIndex = transferIndex) <= 0) {
     37                     i = -1;
     38                     advance = false;
     39                 }
     40                 else if (U.compareAndSwapInt(this, TRANSFERINDEX, nextIndex,
     41                           nextBound = (nextIndex > stride ? nextIndex - stride : 0))) {
     42                     bound = nextBound;
     43                     i = nextIndex - 1; // 下一个准备处理的index
     44                     advance = false;
     45                 }
     46             }
     47             // i越界,可能已经完成元素迁移操作
     48             if (i < 0 || i >= n || i + n >= nextn) {
     49                 int sc;
     50                 if (finishing) { // 扩容完成,替换table
     51                     // 扩容完成nextTable置空
     52                     nextTable = null;
     53                     // 替换table为扩容后的nextTab
     54                     table = nextTab;
     55                     // sizeCtl设置为0.75 * capacity,即为下一次需要扩容的阈值
     56                     sizeCtl = (n << 1) - (n >>> 1);
     57                     return;
     58                 }
     59                 // CAS更新sizeCtl,sc-1表示新加入一个线程参与扩容操作
     60                 if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
     61                     if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
     62                         return;
     63                     finishing = advance = true;
     64                     // 处理完成后重新遍历一遍,以免多线程操作带来遗漏
     65                     i = n; // recheck before commit
     66                 }
     67             }
     68             else if ((f = tabAt(tab, i)) == null)
     69                 // tab[i] == null则置一个ForwardingNode
     70                 advance = casTabAt(tab, i, null, fwd);
     71             else if ((fh = f.hash) == MOVED)
     72                 // ForwardingNode的hash为MOVED,说明tab[i]已经被置成ForwardingNode,已经处理过
     73                 advance = true; // already processed
     74             else {
     75                 // 对tab[i]节点加锁,锁住了tab[i]节点上所有的Node
     76                 synchronized (f) {
     77                     // 如果AB两个线程先后执行到这里,A线程获取锁,执行完迁移之后释放锁;B线程获取锁,此时tab[i]是ForwardingNode,不等于f
     78                     if (tabAt(tab, i) == f) {
     79                         Node<K,V> ln, hn;
     80                         // fh >= 0说明是链表节点。TreeBin的hash在初始化的时候被置成TREEBIN=-2
     81                         if (fh >= 0) {
     82                             // (fh = f.hash) & n 决定Node应该迁移到原下标i还是应该迁移到i+n位置
     83                             // 这种扩容方法参考HashMap的resize思想 http://www.cnblogs.com/snowater/p/7742287.html
     84                             int runBit = fh & n;
     85                             Node<K,V> lastRun = f;
     86                             // 遍历链表找到最后一个与链表头结点runBit不同的Node,并且将runBit置为该节点的 p.hash & n
     87                             for (Node<K,V> p = f.next; p != null; p = p.next) {
     88                                 int b = p.hash & n;
     89                                 if (b != runBit) {
     90                                     runBit = b;
     91                                     lastRun = p;
     92                                 }
     93                             }
     94                             if (runBit == 0) {
     95                                 // runBit == 0 表明该Node还应迁移到下标i的位置
     96                                 ln = lastRun;
     97                                 hn = null;
     98                             }
     99                             else {
    100                                 // runBit != 0 表明该Node应迁移到下标i + n的位置
    101                                 hn = lastRun;
    102                                 ln = null;
    103                             }
    104                             // 遍历链表,拆分之,拆分后基本是原链表的倒序(最后一段链表除外,它还是以顺序的方式处于链表末尾)
    105                             for (Node<K,V> p = f; p != lastRun; p = p.next) {
    106                                 int ph = p.hash; K pk = p.key; V pv = p.val;
    107                                 if ((ph & n) == 0) // 该Node应该迁移到下标i位置
    108                                     ln = new Node<K,V>(ph, pk, pv, ln);
    109                                 else // 该Node应该迁移到下标i+n位置
    110                                     hn = new Node<K,V>(ph, pk, pv, hn);
    111                             }
    112                             setTabAt(nextTab, i, ln);
    113                             setTabAt(nextTab, i + n, hn);
    114                             // 处理完后将tab[i]设置为ForwardingNode,其它线程发现tab[i] == ForwardingNode则会跳过tab[i]继续往后执行
    115                             setTabAt(tab, i, fwd); 
    116                             advance = true;
    117                         }
    118                         else if (f instanceof TreeBin) { // TreeBin的hash为-2
    119                             TreeBin<K,V> t = (TreeBin<K,V>)f;
    120                             TreeNode<K,V> lo = null, loTail = null;
    121                             TreeNode<K,V> hi = null, hiTail = null;
    122                             int lc = 0, hc = 0;
    123                             for (Node<K,V> e = t.first; e != null; e = e.next) {
    124                                 int h = e.hash;
    125                                 TreeNode<K,V> p = new TreeNode<K,V>(h, e.key, e.val, null, null);
    126                                 if ((h & n) == 0) {
    127                                     if ((p.prev = loTail) == null)
    128                                         lo = p;
    129                                     else
    130                                         loTail.next = p;
    131                                     loTail = p;
    132                                     ++lc;
    133                                 } else {
    134                                     if ((p.prev = hiTail) == null)
    135                                         hi = p;
    136                                     else
    137                                         hiTail.next = p;
    138                                     hiTail = p;
    139                                     ++hc;
    140                                 }
    141                             }
    142                             // 如果长度小于UNTREEIFY_THRESHOLD=8,则将树转换为链表,否则将lo和hi重建为红黑树
    143                             ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) : (hc != 0) ? new TreeBin<K,V>(lo) : t;
    144                             hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) : (lc != 0) ? new TreeBin<K,V>(hi) : t;
    145                             setTabAt(nextTab, i, ln);
    146                             setTabAt(nextTab, i + n, hn);
    147                             setTabAt(tab, i, fwd);
    148                             advance = true;
    149                         }
    150                     }
    151                 }
    152             }
    153         }
    154     }        

    1.3.8 get方法

     1     public V get(Object key) {
     2         Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
     3         // 根据key计算得到hash
     4         int h = spread(key.hashCode());
     5         if ((tab = table) != null && (n = tab.length) > 0 && (e = tabAt(tab, (n - 1) & h)) != null) {
     6             if ((eh = e.hash) == h) {
     7                 if ((ek = e.key) == key || (ek != null && key.equals(ek)))
     8                     return e.val;
     9             }
    10             else if (eh < 0)  // 红黑树,从红黑树中查找
    11                 return (p = e.find(h, key)) != null ? p.val : null;
    12             // 遍历链表查找
    13             while ((e = e.next) != null) {
    14                 if (e.hash == h && ((ek = e.key) == key || (ek != null && key.equals(ek))))
    15                     return e.val;
    16             }
    17         }
    18         return null;
    19     }

    参考博客:

    http://www.importnew.com/23610.html
    http://blog.csdn.net/u010723709/article/details/48007881
    http://blog.csdn.net/qq924862077/article/details/74530103
    http://www.cnblogs.com/mickole/articles/3757278.html
    http://www.techsite.cn/?p=5520
    http://blog.csdn.net/dfdsggdgg/article/details/51538601

  • 相关阅读:
    使用git fetch更新远程代码到本地仓库
    图的最短路径(C++实现)
    二叉树遍历(C++实现)
    栈的应用(C++实现)
    求25的所有本原根Python实现
    Web工作方式:浏览网页的时候发生了什么?
    Atom编辑器插件
    H5调取APP或跳转至下载
    Vue父组件传递异步获取的数据给子组件
    flex属性
  • 原文地址:https://www.cnblogs.com/snowater/p/8087166.html
Copyright © 2011-2022 走看看