java常用数据结构源码

TreeMap源码 非线程安全 (结合synchronizedMap()可变为线程安全)

  1. 继承于AbstractMap[k-v集合],实现了NavigableMap接口【支持一系列的导航方法getFirstEntry】、Cloneable接口、Serializable接口
  2. 基于红黑树进行排序,key比较大小是根据比较器comparator来进行判断;红黑树节点是Entry类型
  3. TreeMap的基本操作 containsKey、get、put 和 remove 的时间复杂度是 log(n) 。
  4. TreeMap是非同步的。 它的iterator 方法返回的迭代器是fail-fastl的

Entry

Entry是红黑数的节点,它包含了红黑数的6个基本组成成分:key(键)、value(值)、left(左孩子)、right(右孩子)、parent(父节点)、color(颜色)

Entry节点根据key进行排序,Entry节点包含的内容为value

TreeMap的Entry相关函数  NavigableMap接口

firstEntry()、 lastEntry()、 lowerEntry()、 higherEntry()、 floorEntry()、 ceilingEntry()、 pollFirstEntry() 、 pollLastEntry()

firstEntry() 和 getFirstEntry() 都是用于获取第一个节点

firstEntry() 是对外接口,返回的Entry不能被修改; getFirstEntry() 是内部接口,返回的Entry可以被修改

还有key、value

TreeMap的entrySet()函数

  1. contains(Object o):比较equals比较是否有相同的value
  2. remove(Object o):类似contains如果有相同,则删除
  3. size(),clear

 

if (p != null && valEquals(p.getValue(), value)) { deleteEntry(p); return true; }

TreeMap实现java.io.Serializable,分别实现了串行读取、写入功能。

串行写入函数是writeObject(),它的作用是将TreeMap的“容量,所有的Entry”都写入到输出流中。

而串行读取函数是readObject(),它的作用是将TreeMap的“容量、所有的Entry”依次读出。

readObject() 和 writeObject() 正好是一对,通过它们,我能实现TreeMap的串行传输。

 

遍历TreeMap的键值对 entrySet()

第一步:根据entrySet()获取TreeMap的“键值对”的Set集合

第二步:通过Iterator迭代器遍历“第一步”得到的集合

// 假设map是TreeMap对象 // map中的key是String类型,value是Integer类型 Integer integ = null; Iterator iter = map.entrySet().iterator(); while(iter.hasNext()) { Map.Entry entry = (Map.Entry)iter.next(); // 获取key key = (String)entry.getKey(); // 获取value integ = (Integer)entry.getValue(); }

map.keySet()获取TreeMap的“键”的Set集合

map.values()获取TreeMap的“值”的集合

 

顺序遍历和逆序遍历

 

由于TreeMap中的元素是从小到大的顺序排列的。因此,顺序遍历,就是从第一个元素开始,逐个向后遍历;

而倒序遍历则恰恰相反,它是从最后一个元素开始,逐个往前遍历。

我们可以通过 keyIterator() 和 descendingKeyIterator()来实现

  1. keyIterator()的作用是返回顺序的KEY的集合,先getFirstEntry(),再nextEntry().key
  2. descendingKeyIterator()的作用是返回逆序的KEY的集合。先getLastEntry(),再prevEntry().key

 

红黑树插入删除

为什么要旋转:防止树退化成链,导致O(n)(而不是logn)的高度

第一步: 将红黑树当作一颗二叉查找树,将节点插入

根据树的键的顺序进行插入,保证插入后仍然有序

第二步:将插入的节点着色为"红色"

(1) 每个节点或者是黑色,或者是红色。

(2) 根节点是黑色。

(3) 每个叶子节点是黑色。 [注意:这里叶子节点,是指为空的叶子节点!]

(4) 如果一个节点是红色的,则它的子节点必须是黑色的。

(5) 从一个节点到该节点的子孙节点的所有路径上包含相同数目的黑节点。

       将插入的节点着色为红色,不会违背"特性(5)"!少违背一条特性,就意味着我们需要处理的情况越少。接下来,就要努力的让这棵树满足其它性质即可;满足了的话,它就又是一颗红黑树了。o(∩∩)o...哈哈

第三步: 通过一系列的旋转或着色等操作,使之重新成为一颗红黑树。

第二步中,将插入节点着色为"红色"之后,不会违背"特性(5)"。那它到底会违背哪些特性呢?

       对于"特性(1)",显然不会违背了。因为我们已经将它涂成红色了。

       对于"特性(2)",显然也不会违背。在第一步中,我们是将红黑树当作二叉查找树,然后执行的插入操作。而根据二叉查找数的特点,插入操作不会改变根节点。所以,根节点仍然是黑色。

       对于"特性(3)",显然不会违背了。这里的叶子节点是指的空叶子节点,插入非空节点并不会对它们造成影响。

       对于"特性(4)",是有可能违背的!

       那接下来,想办法使之"满足特性(4)",就可以将树重新构造成红黑树了。

 

Java并发包 java.util.concurrent(简称JUC):并发包、阻塞队列、线程池Executor、.locks 、原子类.atomic

  1. ConcurrentHashMap
  2. CopyOnWriteArrayList
    1. 线程安全版本的ArrayList,每次增加的时候,需要新创建一个比原来容量+1大小的数组
    2. 拷贝原来的元素到新的数组中,同时将新插入的元素放在最末端。
    3. 然后切换引用;
    4. 迭代时生成快照数组;适合读多写少
  3. CopyOnWriteArraySet
    1. 基于CopyOnWriteArrayList实现;
    2. 不能插入重复数据,每次add的时候都要遍历数据,性能略低于CopyOnWriteArrayList
  4. 阻塞队列:
    1. ArrayBlockingQueue 数组 组成的有界阻塞队列
    2. synchronizedQueue 不存储元素的BlockingQueue。每一个put操作必须要等待一个take操作,否则不能继续添加元素;适合做交换工作
  5. Atomic类,如AtomicInteger、AtomicBoolean i++变成原子操作, 底层是CAS,别的线程自旋等到该方法执行完成
  6. locks Lock lock = new ReentrantLock();

 

synchronizedMap() map范围的锁

SynchronizedMap类是定义在集合Collections中的一个静态内部类。

实现了Map接口,通过synchronized关键字对map实现同步控制,可以传各类Map,如TreeMap,hashMap

 

HashMap简介 非线程安全

JDK7-Entry数组+链表的方式

JDK8-Entry数组+链表/红黑树

  1. 链表的长度达到阀值 8 ,链表就将转换成红黑树;小于6,转链表;防止链表和树频繁转换
  2. 实现了Serializable接口,支持序列化;实现了Cloneable接口,能被克隆

hashSet 底层也是hashMap,存储在key上,v是个固定值,重写equls方法判断相同

结构

HashMap底层维护一个Entry数组,数组中的存储Entry对象,组成的链表;链表是为了解决Hash冲突

Map中的key,value则以Entry的形式存放在数组中,通过key的hashCode计算,允许null值,存储在table[0]

transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;

Entry是HashMap中的一个静态内部类 static class Entry<K,V> implements Map.Entry<K,V>

final K key; V value; Entry<K,V> next;//存储指向下一个Entry的引用,单链表结构 int hash;//对key的hashcode值进行hash运算后得到的值,存储在Entry,避免重复计算

 

构造器

快速响应失败 final float loadFactor;由于HashMap非线程安全,在对HashMap进行迭代时,如果期间其他线程的参与导致HashMap的结构发生变化了,抛出异常

默认初始容量16,因子0.75

 

PUT

常规构造器中,执行put操作的时候才真正构建table数组

如果空数组,分配空间

检查key

如果key为null,存储位置为table[0]或table[0]的链表

计算Hash,计算实际位置 i = indexFor(hash, table.length) h & (length-1);

插入操作 链头插入

addEntry:

  1. 判断是否需要扩容
  2. 判断table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向下面;
  3. 遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;插入到前面;
  4. 遍历中若发现key已经存在直接覆盖value;

 

hash冲突(碰撞):链地址法; 先找到下标 i,KEY值找Entry对象,新值存放在数组中,旧值在新值的链表上,将存放在数组中的Entry设置为新值的next

GET

对key进行null检查:如果key是null,索引为table[ i ]

key的hashcode()方法被调用,然后计算hash值。

indexFor(hash,table.length)用来计算要获取的Entry对象在table数组中的精确的位置 index = h & (length-1)

遍历链表,调用equals()方法检查key相等性,如果equals()方法返回true,get方法返回Entry对象的value,否则,返回null。

 

扩容:到达加载因子时,resize(2 * table.length);

新建一个HashMap的底层数组,而后调用transfer方法,就HashMap的全部元素添加到新HashMap,

遍历旧数组,重新计算indexFor,链表逆序指向新数组

 

红黑树是一种近似平衡的二叉查找树(binary search tree) O(log2 n)

树节点特征:满足如下条件:

  1. 每个节点要么是红色,要么是黑色。
  2. 根节点必须是黑色
  3. 红色节点不能连续(也即是,红色节点的孩子和父亲都不能是红色)。
  4. 对于每个节点,从该点至叶子的任何路径,都含有相同个数的黑色节点
  5. 确保节点的左右子树的高度差,不会超过二者中较低那个的一倍

插入:

插入检查会不会破坏树的特征,如果破坏了,程序就会进行纠正,根据需要改变树的结构

GET

O(logN)来搜索一棵树

 

维持平衡【旋转】的原因:

  1. 解决了二叉查找树退化成链表的问题
  2. 把插入,查找,删除的时间复杂度最好情况和最坏情况都维持在O(logN)

与AVL数的比较:

红黑是用近似的平衡来换取增删节点时候旋转次数的降低,任何不平衡都会在三次旋转之内解决,

而AVL是严格平衡树,因此在增加或者删除节点的时候,根据不同情况,旋转的次数比红黑树要多。

所以红黑树的增删效率更高

 

HashMap共有四个构造方法 构造方法中两个很重要的参数:初始容量16和加载因子0.75

初始容量表示哈希表的长度,初始是16

链表的Node节点数量 超出了加载因子与当前容量的乘积时,则要对该哈希表进行 resize 操作(即扩容) 默认0.75

加载因子太大,对空间的利用更充分,但是查找效率会降低(链表长度会越来越长);

加载因子太小,那么表中的数据将过于稀疏,对空间造成严重浪费(很多空间还没用,就开始扩容了)

加入键值对时,先判断当前已用数组长度是否大于等于阀值(容量*加载因子),如果大于等于,则进行扩容,容量扩为原容量2倍

散列算法 hash&(length-1) 二进制

Hashtable:key的hash值对length-1取模(即除法散列法),基本能保证元素在哈希表中散列的比较均匀,但除法运算,效率很低;

HashMap改进,&操作:通过h&(length-1)的方法来代替取模,同样实现了均匀的散列,但效率要高很多,h = key的hash

h = 101100110,length = 1000,h%(length-1)就是保留h右边的3位二进制=110 (也是为什么数组从0开始)

为什么数组的容量一定要是2的整数次幂

h&(length-1),散列的均匀、空间利用充足

  • 2的整数次幂为偶数,length-1为奇数,最后一位是1,保证h&(length-1)的最后一位可能为0或1,保证散列的均匀性,且空间利用充足
  • length为奇数的话,length-1为偶数,最后一位是0,h&(length-1)的最后一位为0,浪费了近一半的空间

resize方法

新建一个HashMap的底层数组,而后调用transfer方法,将就HashMap的全部元素添加到新HashMap,

要重新计算元素在新的数组中的索引位置,即重新调用indexFor

containsKey方法和containsValue方法

前者直接可以通过key的哈希值将搜索范围定位到对应的链表;

后者要对哈希数组的每个链表进行搜索

 

多线程情况下不安全 扩容时候,两个线程扩容 指针逆序导致 循环指针

  1. 对索引数组中的元素遍历
  2. 对链表上的每一个节点遍历:用 next 取得要转移那个元素的下一个,将 e 转移到新 Hash 表的头部,使用头插法插入节点。

 

转移的时候是逆序的。假如转移前链表顺序是1->2->3,那么转移后就会变成3->2->1。

死锁问题就是因为1->2的同时2->1造成的吗?所以,HashMap 的死锁问题就出在这个transfer()函数上

 

HashMap是非线程安全,死锁一般都是产生于并发情况下。我们假设有二个进程T1、T2,HashMap容量为2,T1线程放入key A、B、C、D、E。在T1线程中A、B、C Hash值相同,于是形成一个链接,假设为1->2->3,而4、5 Hash值不同,于是容量不足,需要新建一个更大尺寸的hash表,然后把数据从老的Hash表中 

迁移到新的Hash表中(refresh)。这时T2进程闯进来了,T1暂时挂起,T2进程也准备放入新的key,这时也 

发现容量不足,也refresh一把。refresh之后原来的链表结构假设为3->2,之后T1进程继续执行,链接结构 

为2->3,这时就形成2.next=3,3.next=2的环形链表。一旦取值进入这个环形链表就会陷入死循环。

 

如何减少碰撞?

使用不可变的、声明作final的对象,并且采用合适的equals()和hashCode()方法的话,将会减少碰撞的发生,提高效率。不可变性使得能够缓存不同键的hashcode,这将提高整个获取对象的速度,使用String,Interger这样的wrapper类作为键是非常好的选择

hashTable:当一个线程使用put方法时,另一个线程不但不可以使用put方法,连get方法都不可以,效率低

 

concurrenthashmap 源码 JDK1.8版 synchronizedMap

特点

  1. 1.8抛弃Segment分段锁机制,
  2. 利用CAS+Synchronized来保证并发更新的安全,
  3. 底层依然采用数组+链表+红黑树的存储结构
  4. null值空指针异常

概念

table:初始为null,第一次put操作时初始化,默认大小为16的数组,存储Node节点数据,扩容时大小总是2的幂次方 加倍,直到2^31-1

nextTable:默认为null,扩容时新生成的数组,其大小为原数组的两倍。

sizeCtl :默认为0,用来控制table的初始化和扩容操作,具体应用在后续会体现出来;CAS修改控制并发问题

  • -1 代表table正在初始化(保证只有一个put触发初始化)
  • -N 表示有N-1个线程正在进行扩容操作
  • 其余情况:
  • 如果table未初始化,表示table需要初始化的大小16 this.sizeCtl = cap;
  • 如果table初始化完成,默认是table大小的0.75倍

Node:只读节点(不提供修改方法),构造table[]

保存key,value、key的hash值 class Node<K,V> implements Map.Entry<K,V> ;value和next都用volatile修饰,保证并发的可见性

final int hash; final K key; volatile V val;//volatile,保证可见性; volatile Node<K,V> next;

 

ForwardingNode:特殊的Node节点,hash值为-1,其中存储nextTable的引用;f头节点为null

table发生扩容的时候,ForwardingNode作为一个占位符放在table尾端,表示当前节点为null

延迟初始化、独占

延迟初始化:只会初始化sizeCtl值,并不会直接初始化table,而是延缓到第一次put操作

多线程下的初始化机制:执行第一次put操作的线程会CAS方法修改sizeCtl为-1,且只有一个线程能够修改成功,

put操作

  1. 参数校验,key value不能为null;
  2. 若table[]未创建,则初始化;
  3. 当table[i]无链表无节点时,利用CAS操作直接创建Node并存储
  4. 如果当前正在扩容 -1,则帮助扩容并返回最新table[]。
  5. 然后在链表或者红黑树中追加节点;需要对索引对象(头)节点加同步锁:synchronized (f)
  6. 判断节点个数是否到达阀值,若为>=8,变为红黑树结构

假设table已经初始化完成,put操作采用CAS+synchronized实现并发插入或更新操作

  • 定位索引位置,index = hash &(lengh - 1)
  • 获取table[i]中对应索引的对象f,node类型:Unsafe.getObjectVolatile来获取 tabAt[index],获取指定内存的数据,保证每次拿到数据都是最新;因为线程都有一个工作内存,里面存储着table的副本,虽然table是volatile修饰的,但不能保证线程每次都拿到table中的最新元素
  • 如果f为null,说明table中该位置第一次插入元素,利用Unsafe.compareAndSwapObject(CAS)方法插入Node节点
    • CAS成功,说明Node节点已经插入,随后检查是否需要进行扩容
    • CAS失败,说明有其它线程提前插入了节点,自旋重新尝试插入节点
  • 如果f的hash值为-1,说明当前f是ForwardingNode节点,意味有其它线程正在扩容,,且当前槽位已经处理过了,则一起进行扩容操作
  • f不为null的其余情况把新的Node节点按链表或红黑树的方式插入到合适的位置,这个过程采用同步内置锁实现并发(Synchronized锁住f,减少了锁粒度)

            // 获取数组该位置的头结点的监视器锁 synchronized (f) {

    • 在节点f上进行同步,节点插入之前,再次检查节点是否改变,【利用CAS tabAt(tab, i) == f 判断】,防止被其它线程修改
    • 如果f.hash >= 0,说明f是链表结构的头结点,遍历链表,如果找到对应的node节点,则修改value,否则在链表尾部加入节点
    • 如果f是TreeBin类型节点 if(f instanceof TreeBin),说明f是红黑树根节点,则在树结构上遍历元素,更新或增加节点
    • 如果链表中节点数 binCount >= TREEIFY_THRESHOLD(默认是8),则把链表转化为红黑树结构

新增节点之后,会调用addCount方法记录元素个数

// 数组计数增加1,有可能触发transfer操作(扩容);当数组元素个数达到阈值时,会触发transfer方法,重新调整节点的位置 addCount(1L, binCount);

 

get操作:getObjectVolatile 保证可见性获取索引的对象f=tabAt[index],遍历key,找到相等的,cas来保证变量的原子性读取

 

table扩容

元素数量达到容量阈值sizeCtl(长度*0.75),扩容分为两部分:

构建一个nextTable,大小为table的两倍

Unsafe.compareAndSwapInt(CAS)修改sizeCtl值-1,保证只有一个线程初始化,扩容后的数组长度为原来的两倍,但是容量是原来的1.5(2*0.75)

把table的数据复制到nextTable中:

扩容操作支持并发插入,支持节点的并发复制,性能提升

初始化ForwardingNode节点,其中保存了新数组nextTable的引用,在处理完每个槽位的节点之后当做占位节点,表示该槽位已经处理过了;

如果槽位15已经被线程A处理了,那么线程B处理到这个节点时,取到该节点的hash值应该为MOVED,值为-1,即为fwd节点,则直接跳过

2.构造反序列表,放入nextTable(旧数据放在数组后面,即新扩容的地方)

如果f是链表的头节点,就构造一个反序链表,移动对应位置[需要重新计算hash],

移动完成,采用Unsafe.putObjectVolatile方法给table原位置赋值fwd节点

3.遍历过所有的节点,把table指向nextTable,更新sizeCtl为新数组大小的0.75

 

synchronizedMap()

SynchronizedMap类是定义在Collections中的一个静态内部类。实现了Map接口,通过synchronized关键字对map实现同步控制

map范围(map-wide)的锁,保证插入、删除或者检索操作的完整性

区别及应用场景

1.ConcurrentHashMap的实现更加精细,在性能以及安全性方面更优

同步操作精确控制到node,其他线程,仍然可以对node执行某些操作

多个读操作几乎总可以并发地执行

例如:在遍历map时,其他线程试图对map进行数据修改,不会抛出ConcurrentModificationException【正在修改错误】

2.synchronizedMap()可以接收任意Map实例,实现Map的同步,如TreeMap实现排序,ConcurrentHashMap只能是HashMap

Map<String, Object> map2 = Collections.synchronizedMap(new TreeMap<String, Object>());

Map<String, Object> map3 = new ConcurrentHashMap<String, Object>();

 

ConcurrentLinkedQueue

  1. 一个基于链接节点的无界非阻塞线程安全队列。此队列按照 FIFO(先进先出)原则对元素进行排序
  2. ConcurrentLinkedQueue中有两个volatile类型的Node节点分别用来存在列表的首尾节点,其中head节点存放链表第一个item为null的节点,tail则并不是总指向最后一个节点
  3. Node节点内部则维护一个变量item用来存放节点的值,next用来存放下一个节点,从而链接为一个单向无界列表

应用:多线程方位一个集合

 

ConcurrentLinkedQueue

使用CAS非阻塞算法实现使用CAS解决了当前节点与next节点之间的安全链接和对当前节点值的赋值。由于使用CAS没有使用锁,所以获取size的时候有可能进行offer,poll或者remove操作,导致获取的元素个数不精确,所以在并发情况下size函数不是很有用。另外第一次peek或者first时候会把head指向第一个真正的队列元素。

下面总结下如何实现线程安全的,可知入队出队函数都是操作volatile变量:head,tail。所以要保证队列线程安全只需要保证对这两个Node操作的可见性和原子性,由于volatile本身保证可见性,所以只需要看下多线程下如果保证对着两个变量操作的原子性。

对于offer操作是在tail后面添加元素,也就是调用tail.casNext方法,而这个方法是使用的CAS操作,只有一个线程会成功,然后失败的线程会循环一下,重新获取tail,然后执行casNext方法。对于poll也是这样的。

 

offer操作

public boolean offer(E e) {

    //e为null则抛出空指针异常

    checkNotNull(e);

   //构造Node节点构造函数内部调用unsafe.putObject,后面统一讲

    final Node<E> newNode = new Node<E>(e);

    //从尾节点插入

    for (Node<E> t = tail, p = t;;) {

        Node<E> q = p.next;

        //如果q=null说明p是尾节点则插入

        if (q == null) {

            //cas插入(1)

            if (p.casNext(null, newNode)) {

                //cas成功说明新增节点已经被放入链表,然后设置当前尾节点(包含head,1,3,5.。。个节点为尾节点)

                if (p != t) // hop two nodes at a time

                    casTail(t, newNode);  // Failure is OK.

                return true;

            }

            // Lost CAS race to another thread; re-read next

        }

        else if (p == q)//(2)

            //多线程操作时候,由于poll时候会把老的head变为自引用,然后head的next变为新head,所以这里需要

            //重新找新的head,因为新的head后面的节点才是激活的节点

            p = (t != (t = tail)) ? t : head;

        else

            // 寻找尾节点(3)

            p = (p != t && t != (t = tail)) ? t : q;

    }

}

 

poll操作

public E poll() {

restartFromHead:

    //死循环

    for (;;) {

        //死循环

        for (Node<E> h = head, p = h, q;;) {

            //保存当前节点值

            E item = p.item;

            //当前节点有值则cas变为null(1)

            if (item != null && p.casItem(item, null)) {

                //cas成功标志当前节点以及从链表中移除

                if (p != h) // 类似tail间隔2设置一次头节点(2)

                    updateHead(h, ((q = p.next) != null) ? q : p);

                return item;

            }

            //当前队列为空则返回null(3)

            else if ((q = p.next) == null) {

                updateHead(h, p);

                return null;

            }

            //自引用了,则重新找新的队列头节点(4)

            else if (p == q)

                continue restartFromHead;

            else//(5)

                p = q;

        }

    }

}

    final void updateHead(Node<E> h, Node<E> p) {

        if (h != p && casHead(h, p))

            h.lazySetNext(h);

    }

peek操作

peek操作是获取链表头部一个元素(只读取不移除),下面看看实现原理。

代码与poll类似,只是少了castItem.并且peek操作会改变head指向,offer后head指向哨兵节点,第一次peek后head会指向第一个真的节点元素。

public E peek() {

    restartFromHead:

    for (;;) {

        for (Node<E> h = head, p = h, q;;) {

            E item = p.item;

            if (item != null || (q = p.next) == null) {

                updateHead(h, p);

                return item;

            }

            else if (p == q)

                continue restartFromHead;

            else

                p = q;

        }

    }

}

 

队列的头部 是队列中时间最长的元素。队列的尾部 是队列中时间最短的元素。

新的元素插入到队列的尾部,队列获取操作从队列头部获得元素。当多个线程共享访问一个公共 collection 时,ConcurrentLinkedQueue 是一个恰当的选择。此队列不允许使用 null 元素。

offer(E e) 

          将指定元素插入此队列的尾部。

poll() 

          获取并移除此队列的头,如果此队列为空,则返回 null。 CAS

peek() 

          获取但不移除此队列的头;如果此队列为空,则返回 null

remove(Object o) 

          从队列中移除指定元素的单个实例(如果存在)

contains(Object o) 

          如果此队列包含指定元素,则返回 true

 

如图ConcurrentLinkedQueue中有两个volatile类型的Node节点分别用来存在列表的首尾节点,其中head节点存放链表第一个item为null的节点,tail则并不是总指向最后一个节点。Node节点内部则维护一个变量item用来存放节点的值,next用来存放下一个节点,从而链接为一个单向无界列表

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值