并发集合ConcurrentHashmap

        HashMap是使用频率很高的一种数据结构,经常被用作本地缓存,但是在多线程环境下,对其操作是不安全的,所以ConcurrentHashMap是J.U.C包里面提供的一个线程安全并且高效的HashMap。
    Map结构是平时用的最多的数据结构, ConcurrentHashMap是Map的派生类,所以api的使用基本和Hashmap是类似。下面主要学习一下JDK1.8版本的ConcurrentHashMap的原理。

1、ConcurrentHashmap的使用

    其使用比较简单,其API和 Hashmap类似。
public class ConcurrentHashMapTest {

    public static void main(String[] args) {

        ConcurrentHashMap<String, Integer> concurrentHashMap = new ConcurrentHashMap<String, Integer>();
        concurrentHashMap.put("a", 1); // 存储值
        concurrentHashMap.put("b", 2); 

        concurrentHashMap.putIfAbsent("king", 3);        //存储有则放弃,无则设置
        concurrentHashMap.remove("a");        //删除key为a的值;
        System.out.println(concurrentHashMap.get("b"));  //获取key=“b”的值;

    }
}

2、数据结构与设计思想

数据结构
  • 数组Node table[]
  • 单链表
  • 红黑树
设计思想
  1. 数组与链表的珠联璧合:结合了数组的查找效率为O(1) 与链表的插入和删除效率为O(1)的特性,互相取长补短,达到高效;
  2. 红黑树:为了解决冲突元素过多>8 单链表的0(N)查询问题,引入了红黑树提高查询效率到O(logN);
  3. 空间换时间:经常当应用缓存使用,其实是避免硬盘io,使用内存空间换时间得到了O(1)的时间查询;
  4. 粒度锁:使用数组Table[]分段锁来减小锁的粒度,提高程序的并发度;
具体数据结构与源码体现:

2.1、四类重要节点

//node节点数据
transient volatile Node<K,V>[] table;

//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; //从这里可以看到采用单链表解决数据冲突;
}

//红黑树节点信息
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;
}
  • node >0:链表节点,单链表个数小6 或者 单链表个数大于8 + 总节点个数小于64
  • treeNode -TreeBin -2,二叉树的节点;
  • ForwardingNode -1 占位节点, 避免了读数据的阻塞操作;协助转移,增加了并发度,并发扩容;
  • computeIfAbsent -3 
/**
* The maximum number of threads that can help resize.
* Must fit in 32 - RESIZE_STAMP_BITS bits.
* 最大的并行扩容线程的数量;
*/
private static final int MAX_RESIZERS = (1 << (32 - RESIZE_STAMP_BITS)) - 1;

//节点正在迁移
static final int MOVED     = -1; // hash for forwarding nodes 
//红黑树节点
static final int TREEBIN   = -2; // hash for roots of trees
//保留节点,只有在computerIfAbsent和computer时出现
static final int RESERVED  = -3; // hash for transient reservations

2.2、ConcurrentHashMap中的常量值

/* ---------------- Constants -------------- */
//表的最大长度;
private static final int MAXIMUM_CAPACITY = 1 << 30;

//表的初始化大小
private static final int DEFAULT_CAPACITY = 16;

//虚拟机中数组的最大值
static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;

//并发度
//在1.8中这个并发度是随着容量的变化而变化,可以认为是hash桶的数量;不再是固定值
private static final int DEFAULT_CONCURRENCY_LEVEL = 16;

//装载因子
private static final float LOAD_FACTOR = 0.75f;

//转化为红黑树的阈值
static final int TREEIFY_THRESHOLD = 8;

//红黑树转化为链表的阈值
static final int UNTREEIFY_THRESHOLD = 6;

/*最小树型化阈值,即当哈希表中的容量table.length() > 该值的时候,才允许树型化链表;否则桶内数据过多时直接resize();避免了树型化与扩容的选择冲突;
*/
static final int MIN_TREEIFY_CAPACITY = 64;
//使用实例:
if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
    tryPresize(n << 1);


// 扩容操作中,transfer这个步骤是允许多线程的,这个常量表示一个线程执行transfer时的最小任务量,单位为一个hash桶,这就是线程的transfer的步进(stride)也就是一个线程最少要对连续的16个hash桶进行transfer(不足16就按16算,多控制下正负号就行);
private static final int MIN_TRANSFER_STRIDE = 16;


// 用于生成每次扩容都唯一的生成戳的数,最小是6。很奇怪,这个值不是常量,但是也不提供修改方法。
private static int RESIZE_STAMP_BITS = 16;

/** Number of CPUS, to place bounds on some sizings */
//获取cpu的个数
static final int NCPU = Runtime.getRuntime().availableProcessors();

3、索引定位

hash函数 设计目标:所有的处理的根本目的,都是为了快速定位到key的索引位置,提高存储key-value的数组下标位置的随机性 & 分布均匀性,尽量避免出现hash碰撞。
即:对于不同key,存储的数组下标位置要尽可能不一样。
想要提高hashmap的性能, 减少冲突的次数和 空间的占用率,主要取决于:
  • 好的hash算法;
  • 好的扩容机制;
hash算法三部曲 - 定位hash桶索引
  • 1 取key的 hashcode
  • 2 高位参与运算,减少冲突:   扰动函数,扰动过多,适得其反;
  • 3 取模变位运算,提高效率;
hash函数
//JDK 1.7 一次hash + 5次异或 + 4次移位
final int hash(Object k) {
        int h = hashSeed;
        if (0 != h && k instanceof String) {
            return sun.misc.Hashing.stringHash32((String) k);
        }
        h ^= k.hashCode();
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

//JDK1.8 高位参与运算 一次hash + 一次移位(h >>> 16) + 一次异或h ^ (h >>> 16)

static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

4、get()方法

    ConcurrentHashMap的获取逻辑和hashmap相差不大,逻辑比较简单,基本一样,因为相关变量都加上了volatile修饰,所以 get操作自身几乎无需考虑线程安全性和变量可见性,效率很高;
jdk1.8的get方法流程如下:
  1. 通过key得到hash值,得到数组索引index值;
  2. 如果Table[]为null,直接返回null;
  3. 判断Table[]首个节点的key.hash和key值是否相等,相等则返回;若不相等:
  4. 如果是红黑树:在排序树中查找;
  5. 如果是链表,则在单链表头开始查找;
  6. 没有找到则直接返回null;
源码如下:
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());
    // tab不为空,table的长度大于0;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (e = tabAt(tab, (n - 1) & h)) != null) {
        if ((eh = e.hash) == h) {
            // 获取key的hash值,key的hash值相同且值也相同,找到返回value;
            if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                return e.val;
        }
        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;
        }
    }
    // 没找到返回null;
    return null;
}
可以看到jdk1.8中通过key获取value只需要一次hash定位就可以获取到table[]的位置,效率有所提升;
相比jdk1.7中的g et()方法 实现  需要2次定位
  • 1.为输入的Key做Hash运算,得到hash值。
  • 2.通过hash值, 定位到对应的Segment对象
  • 3.获取可重入锁ReentrantLock
  • 4.再次通过hash值 定位到Segment当中table[]数组的具体位置。
  • 5.插入或覆盖HashEntry对象。
  • 6.释放锁。
jdk1.7获取value的简单总结:
  1. 定位segment[]:通过key的hash的再散列的值高位和段掩码进行与运算得到该值在段中的位置;
  2. 定位table[]:通过key的hash再散列值和table的长度进行与运算得到该值在table中的位置;
  3. 获取锁,进行相关更新操作;

5、put()方法

put()方法的源码分析:
//放入元素
public V put(K key, V value) {
    return putVal(key, value, false);
}

//onlyIfAbsent: 表示当map中存在这个元素的时候:false:替换;true:不替换
final V putVal(K key, V value, boolean onlyIfAbsent) {
    //key和value不能为空,否则抛异常;
    if (key == null || value == null) 
            throw new NullPointerException();  
    //第一步:得到hashCode
    int hash = spread(key.hashCode());         
    int binCount = 0;
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;
        //第二步:判断如果table为空,第一次插入新值,则初始化一个大小为16的Table数组;
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();                 
        //第三步:判断如果table不为空,利用hash算法得到的Table数组位置的首个Node元素判断是否为空;
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { 
            //如果首节点Node为空,进行cas做原子操作,将值放入到node节点中;
            if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
                break;                   // no lock when adding to empty bin
        }
        //第四步:如果得到的数组首节点不为空,判断这个节点的 hash 是不是等于 MOVED(-1),如果事,说明当前节点是 ForwardingNode 节点,意味着有其他线程正在进行扩容,则当前线程参与帮助扩容数据迁移工作,一个线程负责至少16个hsh桶;
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);
        else {
            V oldVal = null;
            //第五步:/检测到桶结点是ForwardingNode类型不为空且节点没有迁移,则使用Synchronized锁住首个元素,保证该节点链上的数据的更新操作是线程安全的;
            synchronized (f) { 
                if (tabAt(tab, i) == f) {
                    //5.1 如果是普通的链表 Node节点,向链表添加元素;
                    if (fh >= 0) { 
                        binCount = 1;
                        for (Node<K,V> e = f;; ++binCount) {
                            K ek;
                            if (e.hash == hash &&       //判断hash值是否相同    
                                ((ek = e.key) == key || //key是否相同
                                 (ek != null && key.equals(ek)))) {
                                oldVal = e.val;     //key相同,value就被覆盖
                                if (!onlyIfAbsent)  //key相同选择是否覆盖,默认为false,这里会覆盖;
                                    e.val = value;
                                break;
                            }
                            Node<K,V> pred = e;
                            if ((e = e.next) == null) { //key不同,有冲突,循环链表加入到尾节点;
                                pred.next = new Node<K,V>(hash, key,
                                                          value, null);
                                break;
                            }
                        }
                    }//5.2 向红黑树添加元素;
                    else if (f instanceof TreeBin) {
                        Node<K,V> p;
                        binCount = 2;
                        if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                                       value)) != null) {
                            oldVal = p.val;
                            if (!onlyIfAbsent)
                                p.val = value; //如果key存在,则覆盖旧值;
                        }
                    }
                }
            }
            //binCount != 0 说明向链表或者红黑树中添加或修改一个节点成功
            //binCount  == 0 说明 put 操作将一个新节点添加成为某个桶的首节点
            if (binCount != 0) {
                //第六步:判断链表的节点是否大于8,链表长度大于8 且总节点个数大于64,将链表转化为红黑树;
                if (binCount >= TREEIFY_THRESHOLD) 
                    treeifyBin(tab, i); 
                if (oldVal != null)
                //更新完毕,返回旧的值
                    return oldVal;      
                break;
            }
        }
    }
    //分段计数统计-使用了分而治之的思想 - cas 
    //虽然 synchronized 可以完成,但是性能有瓶颈?
    addCount(1L, binCount);
    return null;
}

put方法简单总结:

  • 第一步:判断key和value不能为空,否则抛异常;然后判断数组是否为空,为空则进行初始化initTable;
  • 第二步:不为空,则判断Table的头节点是否为空,为空则将值存储在头节点;
  • 第三步;不为空,检查节点状态,判断是否需要帮助扩容;
  • 第四步:如果不需要扩容,判断是链表还是红黑树,加锁Synchronized后在相应的的数据结构上插入或者更新覆盖value的值;
  • 第五步:如果是插入新值,则统计数据的个数,看是否需要扩容,或者是链表和红黑树的转换;
  • 第六步:返回value的值;

5.1、table的初始化

sizeCtl:这个标志是在Node数组初始化或者扩容的时候的一个控制位标识,这个也是看了半天才看懂,非常重要sizeCtl.
  • >0: 扩容已经结束,sizeCtl= n* 0.75 = 12,表示下一次扩容的大小;
  • 0:标识 Node 数组还没有被初始化,
  • -1:表示当前的有线程抢到了初始化Table表的权限,正在初始化;
  • 负数-非-1:-2 代表有一个线程正在扩容

//tab==null 判断使用了doubleCheck机制,保证了线程安全;
private final Node<K,V>[] initTable() {
    Node<K,V>[] tab; int sc;
    while ((tab = table) == null || tab.length == 0) {
        //如果发现有线程在初始化table,则让出cpu的使用权让支持其优先完成初始化;
        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) {
                    //sc 大于零说明容量已经初始化了,就使用传入的值,否则使用默认容量16
                    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);
                }
            } finally {
                //设置sizeCtl为sc, 如果默认是16的话,那么这个时候sc=16*0.75=12
                sizeCtl = sc;
            }
            break;
        }
    }
    return tab;
}

5.2、addCount()思想 -先乐观后悲观

当竞争不激烈的时候使用cas机制来更新baseCount的值;当竞争激烈的时候,采用分片的方式来来记录元素个数,减少冲突阻塞;
  • 先乐观baseCount 记录元素数量,通过CAS更新;
  • 后悲观:counterCells[] 记录元素变化个数,cas操作baseCount失败时使用;
例如可能的方案如下:
  • 方案一:通过加锁实现串行化,可行但是效率低;
  • 方案二:CAS;当竞争激烈的时候,cpu的性能浪费严重;
  • 方案三:分布式思维, ConcurrentHashMap结合了方案1和2,使用了 CAS + 数组 CountCell[]分片统计元素的个数,提高并发能力;
  • 数组 countCell[] : 分而治之的思想
  • 初始化时大小为2,也应用到了分段锁的思想机制:
  • 利用cas来增加数组中元素的个数,最后将所有的数组的值加起来;

6、并发扩容

6.1、并发扩容时机

  • 1、单个链表大于8且总个数小于64, 触发扩容;
  • 2、调用addCount()方法后总节点数大于64,达到 扩容阈值后触发扩容,装载因子0.75 * totalCount;
  • 3、当总个数达到最大值2^30就不再扩容了,任其碰撞;
并发扩容标记
resizeStamp的设计:通过32高低位来标记设计来实现唯一性及多线程的协助扩容记录;
  • 高16位:代表扩容的标记,表示此次扩容的操作时唯一的;
  • 低16位:表示扩容的线程数量;
  • 让一个cpu去执行一段数组,大小为16;

6.2、2倍扩容法

二倍扩容法思想 数据迁移-高低位链:采用高低位链的方式来解决多次hash计算的问题,只需计算一次,提升了效率;
2倍扩容法的具体实现:数据因为是2倍的扩容,所以低位的数据不变,高位的迁移即可;数据和数组大小进行&操纵:得到高位,
  • 高位为0:       无需迁移,索引没变;
  • 高位为1:     需要迁移,索引变成“原索引+oldCap;
可以生成两个链:  
  • 高位链:增加n的位置就OK了 (fn & n == 0 )
  • 低位链 - 位置无需改变:           (fn & n != 0 )
例如2扩容为4,通过位运算,原hash槽table[0]: 0-4-8和table[1]:1-5-9都不用变。提高了迁移的效率;

6.3、并发扩容

并行辅助扩容,可以通过多个线程来并行实现数据的迁移,但一个线程至少负责16个头节点的扩容;
transfer()具体步骤(源码较长这里不贴了)
  • 1. 当resize正在进行时,构建一个nextTable,大小为table两倍,当前节点为链表节点或红黑树,重新计算链表节点的hash值,移动到nextTable相应的位置;
  • 2. 当更新一个key-value的时候,如果发现map正在扩容,则当前线程参与扩容操作;遍历table时,已经转移或者正在转移到新数组的Bucket节点,会在原来的位置采用CAS放进一个ForwardingNode结点,并且它的hash值为-1(MOVED),是一个占位符,标示该节点已经处理完成,保证了线程安全;处理完成以后会使用finishing标示,是否推进下一个节点的迁移使用advance标示;
  • 3. 当前节点已经为ForwardingNode,则表示已经有有线程处理完了,直接跳到新的Table表去读取或者更新操作;
这样保证了在resize的时候,不会阻塞对concurrenthashmap的操作,可以 并发的读取与更新
转移节点形态

7、JDK8相比与JDK7的改进

改进一:将原来的 【数组 + 单向链表】的方式改为 【数组 + 单向链表 + 红黑树】的数据结构;
  • 红黑树 的增加主要为了解决H ash表的链地址法冲突严重个数>8且总数据个数大于64的时候,查找效率低退化为O(n) 的查询问题,变为了红黑树O(logN);
  • 当链表的长度小于6的时候退化为链表;
改进二 取消了segment分段设计 ,直接使用 Node 数组来保存数据,好处有两个:
(1)采用 Node 数组首元素作 为锁来实现每一行数据进行加锁,进一步降低了锁的粒度,减少并发冲突的概率;
(2)直接使用Node数组来扩容,提高了程序的并发度,jdk1.7之前Segment是固定的, segment不扩容,并发度最大为16,但是1.8就没有限制了;
改进三: 锁粒度的改变 使用jvm优化后的Synchronized代替原来的ReentrantLock锁,提高性能,在竞争不激烈的时候没必要每次都阻塞自己;
jdk1.7的数据结构图:

8、问题思考

问题一: C oncurrnetHashMap 为什么高效?
  1. 纯内存操作,本地缓存,无磁盘IO;
  2. 优秀的数据结构,使时间复杂度降到了最低O(1);
  3. HashtEntry的不变性final + 读写可见性volatile,降低锁的需求,使读效率高;
  4. 锁优化Synchronized代替ReentrantLock锁,cas机制和源码中的 自旋操作for(;;),读多写少的场景避免了线程的阻塞;
  5. 采用粒度锁分离实现多个线程间的并发写操作,提高效率;
  6. 多线程并发扩容,并且支持无阻塞读和更新;
  7. 优秀的hash算法,使数据尽量分散均匀;
  8. 细节设计的也好,例如元素个数的统计,位操作代替&运算,链表和红黑树的转换等等决定了效率高;
问题2: 为什么hashmap的容量一定是2^n次幂呢?
2^n的妙用:
  • (1) 实现均匀分布(length-1)之后的二进制全部是1,所以进行&运算后,等同于hash值的后几位数据,只要hashcode是均匀的,数据会均匀的分布到各个节点上,如果不是2^n 会导致一些节点永远分配不到数据,导致空间的浪费。
  • (2) 提高运算:这里主要是为了用&运算代替取模运算,提高运算效率; h & (lenght-1) == h%length , 它俩结果等价不等效,位运算的效率更高;
  • (3) 减少碰撞:使数据分布的更均匀,增加查询效率;
  • (4) 扩容更加高效,避免了hash值的计算,一次运算得到高低位即可;
解释(1):
如果不是2^n,则计算的结果 h & (lenght-1) == h%length是不等价的,&冲突严重;
例如:当数组长度为15的时候hashcode的值会与14(1110)进行与操作,那么最后一位永远是0,导致 0001,0011,0101,1001,1011,0111,1101这几个位置 永远都不会存放元素了,空间浪费相当大,更糟糕的是在这种情况下,数组可以 使用的位置比数组长度小很多,这样就进一步增加了碰撞的几率,减慢了查询的效率;所以只有数组大小是 2^n时才能 通过用&位运算代替取余操作来直接定位桶的位置,保证数据均匀的分布在每个数组节点上:如果初始化hashmap的时候传入的值不是2^n次幂,则会通过tablesizeFor来将其调整为2的n次方;
问题3: 为啥选择8为链表和红黑树的转换条件?为啥选择6为退化为链表而不是8?
链表和红黑树之间的转换关系:
  • 链表长度为8;小于6退化为链表
  • table长度达到:64;如果table的size达不到会先出发resize操作;
//转化为红黑树的阈值
static final int TREEIFY_THRESHOLD = 8;

//红黑树转化为链表的阈值
static final int UNTREEIFY_THRESHOLD = 6;

/*最小树型化阈值,即当哈希表中的容量table.length() > 该值的时候,才允许树型化链表;否则桶内数据过多时直接resize();避免了树型化与扩容的选择冲突;
*/
static final int MIN_TREEIFY_CAPACITY = 64;
//使用实例:
if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
    tryPresize(n << 1);

    因为根据泊松分布,出现链表的个数为8的概率已经很低了,只有在极端的情况下才会发生,如果发生了,就将其转换为红黑树,提高查询性能;

由于链表和红黑树的转换也是要消耗性能的,所以如果临界值大于8转换为红黑树,小于8变链表,特殊情况则会出现频繁的转化,为了避免这种情况,所以设置阈值为6,方式频繁转换消耗,一切从性能出发。
问题4: 装载因子的作用?
  • 扩容 :提高查询和存储效率; 
  • 减少冲突:让数据均匀的分布在数组上;

9、使用建议

    1、频繁的 扩容是一个耗时的操作,如果确定存储的值比较大,可以给定初始值,避免频繁的扩容;但是不要轻易的修改;
比如说我们Spring启动的时候存储单实例Bean的容器map因为数据量比较大,初始值设置的也比较大为256
/** Cache of singleton objects: bean name to bean instance. */
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256);

    2、不要在并发环境中使用hashmap,可以使用可以使用:

Map map = Collections.synchronizedMap(new HashMap(…)) 或者 ConcurrentHashMap();

    3、重写equals时重写hashcode;否则会引起值的丢失及不唯一性;尽量使用不变类型的变量,例如String类型作为key;

10、小结

其实HashMap真的还是一个值得研究的数据结构,有很多细节和思想都应用的很好:
  • (1). 数据结构的完美结合:散列表+ 链表 + 红黑树 ;
    • 链表和红黑树的转换,细节的考虑,链表长度>8  && 节点个数> 64;
  • (2). 粒度锁(node锁)的设计, 在高并发的场景非常实用;
  • (3). 数据个数的统计思想通过数组的分片方式来实现并发增加元素的个数来记录个素,减少阻塞, 类似分布式的思想;
  • (4). 二倍扩容法思想:
    • resizeStamp的设计:高低位的设计来实现唯一性及多线程的协助扩容记录;
    • 数据迁移-高低位链:采用高低位链的方式来解决多次hash计算的问题,只需计算一次,提升了效率;
    • 并发扩容:辅助扩容,可以通过多个线程来并行实现数据的迁移;一个线程负责16个头节点的扩容;
    • 2^n的巧妙使用:位运算;
  • (5). sizeCtr的设计,3种表示状态-状态机控制;
    • -1 表示一个占位符,如果sizeCtl= -1 ,表示当前的有线程抢到了表Table[]初始化的权限;
    • >0: sizeCtl= n* 0.75 = 12,表示下一次扩容的大小;
    • 负数 -1  :-2 代表有一个线程正在扩容;
 
 
水滴石穿,积少成多。学习笔记,内容简单,用于复习,梳理巩固。
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值