ConcurrentHashMap底层原理

一、JDK1.7 ConcurrentHashMap底层原理

ConcurrentHashMap和HashMap的思路差不多,但是因为它支持并发锁,所以引入了分段锁,复杂一些。并发控制使用ReentrantLock来进行获取锁。
数据结构:
在这里插入图片描述
整个ConcurrentHashMap是由一个一个的Segment组成,Segment代表一个分段,一个Segment里面包含一个HashEntry数组,每个HashEntry是一个链表结构,当对HashEntry数据的数据进行修改时,必须先获取与它对应的Segment锁。

核心代码如下:

public class ConcurrentHashMap<K,V> extends AbstractMap<K,V> implements ConcurrentMap<K,V>, 
        Serializable {
 //默认初始容量
 static final int DEFAULT_INITIAL_CAPACITY = 16;
 //默认加载因子(针对Segment数组中的某个Segment中的HashEntry数组扩容)
 static final float DEFAULT_LOAD_FACTOR = 0.75f;
 //默认Segment数组的大小,也成为并发量
 static final int DEFAULT_CONCURRENCY_LEVEL = 16;
 //最大容量
 static final int MAXIMUM_CAPACITY = 1 << 30;
 //一个Segment的HashEntry数组的最小容量
 static final int MIN_SEGMENT_TABLE_CAPACITY = 2;
 //一个Segment的HashEntry数组的最大容量
 static final int MAX_SEGMENTS = 1 << 16;
 // 锁之前重试次数
 static final int RETRIES_BEFORE_LOCK = 2;


 //构造方法
 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;
        // Find power-of-two sizes best matching arguments
        int sshift = 0;//segment的偏移
        int ssize = 1;//segment的size  = ssize * 2^sshift
        //计算并行级别,保持并行级别是2的n次方
        while (ssize < concurrencyLevel) {
        	//探讨默认情况下concurrencyLevel=16,sshift=4,ssize经过4此左移,和并行度相等=16
            ++sshift;
            ssize <<= 1;
        }
        //下边这两个变量是为了put方法中的计算key对应Segment数组的索引
        this.segmentShift = 32 - sshift;//-->默认为28
        this.segmentMask = ssize - 1;//-->默认为15
        //initialCapacity 是设置整个map初始的大小
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
       //这里根据initialCapacity 计算segment数组中的每个segment中的HashEntry数组可以分到的大小
       //如initialCapacity =64,那么每个segment中的HashEntry数组就可以分到4个
        int c = initialCapacity / ssize;
        //当不能整除的时候,则让c+1
        if (c * ssize < initialCapacity)
            ++c;
        //默认MIN_SEGMENT_TABLE_CAPACITY=2.这个值也是有用的,因为这样的话,对于具体的HashEntry上,插入一个元素不至于扩容,插入第二个的时候才会扩容
        int cap = MIN_SEGMENT_TABLE_CAPACITY;
        while (cap < c)
            cap <<= 1;
        // 创建Segment数组,并创建数组的第一个元素,segment[0]
        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写入segment[0]
        UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
        this.segments = ss;
 }

 public V put(K key, V value) {
        Segment<K,V> s;
        //判断value是否为null,如果为null则抛出空指针异常
        if (value == null)
            throw new NullPointerException();
        //计算key的hash值
        int hash = hash(key);
        //根据key的hash值计算出在Segment数组中的位置j
        //hash值是32位的,默认情况下先无符号右移28位,剩下高四位,然后&15,还是hash的高四位
        //也就是说j是hash的高4位的值,也就是对应的segment数组中的下标
        int j = (hash >>> segmentShift) & segmentMask;
        //判断该位置是否为null,
        if ((s = (Segment<K,V>)UNSAFE.getObject          // nonvolatile; recheck
             (segments, (j << SSHIFT) + SBASE)) == null) //  in ensureSegment
       		//如果为null,初始化该位置segment[j],通过ensureSegment(j)
            s = ensureSegment(j);
        //调用Segment的put方法将数据插入到HashEntry中
        //见下方
        return s.put(key, hash, value, false);
  }
 
  final V put(K key, int hash, V value, boolean onlyIfAbsent) {
 			//在往segment写之前,需要先获取该segment的独占锁
 			//获取到了直接返回null
			//获取不到就会进入scanAndLockForPut()方法获取锁,初始化node,,具体我也没看懂
            HashEntry<K,V> node = tryLock() ? null :
                scanAndLockForPut(key, hash, value);
            V oldValue;
            try {
            	//table是segment内部的数组,HashEntry类型的
                HashEntry<K,V>[] tab = table;
                //再利用hash,求应该放置的数组下标,和hashmap的一样
                int index = (tab.length - 1) & hash;
                //获取该位置的链表的表头,赋给first
                HashEntry<K,V> first = entryAt(tab, index);
                //一个死循环
                //判断当前位置的链表是否为null,并针对两种情况具体操作
                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 {
                    	//如果当前链表中没有元素
                    	//判断node是否为null
                        if (node != null)
                        	//如果不为null则将元素添加在链表的头节点,并指向当前链表的头结点
                            node.setNext(first);
                        else
                        	//如果node为null则初始化node,并将value传入,next指向当前链表的头节点
                            node = new HashEntry<K,V>(hash, key, value, first);
                        //计数+1
                        int c = count + 1;
                        //判断是否需要扩容
                        //如果当前segment中的元素个数大于扩容阈值并且HashEntry数组的长度小于规定的map最大容量,则进行扩容
                        if (c > threshold && tab.length < MAXIMUM_CAPACITY)
                        	//具体后边说
                            rehash(node);
                        else
                        	//如果没有达到扩容的条件,将node放到数组HashEntry数组的index位置
                            setEntryAt(tab, index, node);
                        ++modCount;
                        count = c;
                        oldValue = null;
                        break;
                    }
                }
            } finally {
            	//释放锁
                unlock();
            }
            //返回旧值
            return oldValue;
   }
   
   //在初始化ConcurrentHashMap的时候,会初始化第一个分段segment[0],对于其他分段,当put的时候才会进行初始化,通过ensureSegment()方法     
   private Segment<K,V> ensureSegment(int k) {
 		//获取到当前的Segment数组
        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]
        	//用来当做一个模板来初始化其他的segmengt
            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数组
            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,当前线程成功设值或其他线程成功设值后,退出
                //对于并发操作使用CAS控制
                while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
                       == null) {
                    if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
                        break;
                }
            }
        }
        return seg;
    }


    //扩容,put的时候,如果判断该值的插入会导致segment中的元素个数超过阈值,那么先会进行扩容,再插值。
    //该方法不需要考虑并发,因为到这里的时候,是持有该segment的独占锁
    //扩容是segment里的HashEntry[]数组进行扩容,每个分段里的扩容都是独立的。
     private void rehash(HashEntry<K,V> node) {
 			HashEntry<K,V>[] oldTable = table;
            int oldCapacity = oldTable.length;
            //新的容量为旧的容量的2倍
            int newCapacity = oldCapacity << 1;
            //计算新的扩容阈值
            threshold = (int)(newCapacity * loadFactor);
            //创建新数组
            HashEntry<K,V>[] newTable =
                (HashEntry<K,V>[]) new HashEntry[newCapacity];
            //新的掩码,为容量-1
            int sizeMask = newCapacity - 1;
            //遍历数组,将原数组位置i处的链表拆分到新数组位置i和i+oldCapacity两个位置
            for (int i = 0; i < oldCapacity ; i++) {
            	//e为链表的第一个元素
                HashEntry<K,V> e = oldTable[i];
                if (e != null) {
                	//如果e不为Null
                    HashEntry<K,V> next = e.next;
                    //计算应该放置在新数组中的位置
                    //假设原数组长度为16,e在oldTable[3]处,那么idx只可能是3或者3+16=19
                    //因为大多数HashEntry中的节点在扩容前后可以保持不变,rehash方法中会定位第一个后续所有节点在扩容后index都保持不变的节点,然后将这个节点之前的所有节点重排即可
                    int idx = e.hash & sizeMask;
                    if (next == null)   //  Single node on list
                        newTable[idx] = e;
                    else { // 重复利用一些扩容后,位置不变的节点,这些节点在原先链表的尾部
                        HashEntry<K,V> lastRun = e;
                        int lastIdx = idx;
                        //这个for循环就是找到第一个后续节点新的index不变的节点。
                        for (HashEntry<K,V> last = next;
                             last != null;
                             last = last.next) {
                            int k = last.hash & sizeMask;
                            if (k != lastIdx) {
                                lastIdx = k;
                                lastRun = last;
                            }
                        }
                        newTable[lastIdx] = lastRun;
                        // 第一个后续节点新index不变节点前的所有节点都需要重新创建分配
                        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);
                        }
                    }
                }
            }
            //将新来的node放到新数组中刚刚的两个链表之一的头部
            int nodeIndex = node.hash & sizeMask; // add the new node
            node.setNext(newTable[nodeIndex]);
            newTable[nodeIndex] = node;
            table = newTable;
        }


}

1、初始化
ConcurrentHashMap的初始化是会通过位与运算来初始化Segment的大小,用size来表示,因为size用位于运算来计算( size <<=1 ),所以Segment的大小取值都是以2的N次方,无关concurrencyLevel的取值,当然concurrencyLevel 最大只能用16位的二进制来表示,即65536,换句话说,Segment的大小最多65536个,没有指定concurrencyLevel元素初始化,Segment的大小size默认为16初始化后,segment数组的长度就不会变化了。扩容的时候其实是对segment中的HashEntry[]数组进行扩容。

每一个Segment元素下的HashEntry的初始化也是按照位于运算来计算,用cap来表示,HashEntry大小的计算也是2的N次方(cap <<=1), cap的初始值为1,所以HashEntry最小的容量为2

2、put操作
从Segment的继承体系可以看出,Segment继承了ReentrantLock, 也就带有锁的功能,当执行put操作时,会进行第一次key的hash来定位Segment的位置,如果该Segment还没有初始化,即通过CAS操作进行赋值,然后进行第二次hash操作,找到相应的HashEntry的位置,这里会利用继承过来的锁的特性,在将数据插入指定的HashEntry位置时(链表的尾端),会通过继承ReentrantLock的tryLock()方法尝试去获取锁,如果获取成功就直接插入相应的位置,如果已经有线程获取该Segment的锁,那当前线程会以自旋的方式去继续的调用tryLock()方法去获取锁,超过指定次数就挂起,等待唤醒。

3、get操作
ConcurrentHashMap的get操作跟HashMap类似,只是ConcurrentHashMap第一次需要经过一次hash定位到Segment的位置,然后再hash定位到指定的HashEntry,遍历该HashEntry下的链表进行对比,成功就返回,不成功就返回null。

4、size操作
计算ConcurrentHashMap的元素大小是一个有趣的问题,因为他是并发操作的,就是在你计算size的时候,他还在并发的插入数据,可能会导致你计算出来的size和你实际的size有相差(在你return size的时候,插入了多个数据),要解决这个问题,JDK1.7版本用两种方案


for(;;) {

	if(retries++ == RETRIES_BEFORE_LOCK) {
		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) { 
 			sum += seg.modCount;  
 			int  c = seg.count;  
 			if  (c <  0  || (size += c) <  0 )  
				overflow =  true;  

 		} 
 	}  

 	if  (sum == last)  
 		break;  
 	last = sum; 
 } 
 }finally  {  

 	if  (retries > RETRIES_BEFORE_LOCK) {  
		 for  ( int  j =  0; j < segments.length; ++j)  
 			segmentAt(segments, j).unlock();  
 	}  
 } 

第一种方案他会使用不加锁的模式去尝试多次计算ConcurrentHashMap的size,最多三次,比较前后两次计算的结果,结果一致就认为当前没有元素加入,计算的结果是准确的。
第二种方案是如果第一种方案不符合,他就会给每个Segment加上锁,然后计算ConcurrentHashMap的size返回。

5、并发问题分析
添加节点的操作put和删除节点的操作remove都是加上segment上的独占锁的,所以它们之前自然不会有问题,我们需要考虑的问题就是,get的时候在同一个segment中发生了put或remove操作。

(1)、put操作的线程安全性

  • 添加节点到链表的操作时插入到表头的,所以如果这个时候get操作在链表的过程中已经到了中间是不会被影响的。另一个并发问题就是get操作在put之后,需要保证刚刚插入表头的节点被读取,这个依赖于setEntryAt方法中使用的UNSAFE.putOrderedObject。
  • 扩容:扩容是新创建了数组,然后进行迁移数据,最后将newTable设置给属性table, 所以如果get操作此时也在进行,那么也没关系,如果get先行,那么就是在旧的table上做查询操作;而put先行,那么put操作的可见性保证就是table使用了volatile关键字.

(2)、remove操作的线程安全性

  • 如果remove破坏的节点get操作已经过去了,那么这里不存在任何问题。
  • 如果此节点是头结点,那么需要将头结点的 next 设置为数组该位置的元素,table 虽然使用了 volatile 修饰,但是 volatile 并不能提供数组内部操作的可见性保证,所以源码中使用了 UNSAFE 来操作数组,请看方法 setEntryAt。2、如果要删除的节点不是头结点,它会将要删除节点的后继节点接到前驱节点中,这里的并发保证就是 next 属性是 volatile 的。

总结:
JDK1.7中的ConcurrentHashMap是如何保证线程安全的
在这里插入图片描述
JDK1.7中的ConcurrentHashMap的底层原理
在这里插入图片描述

二、JDK1.8 ConcurrentHashMap底层原理

JDK1.8中摒弃了Segment的概念,而是直接通过Node数组+ 链表+红黑树的数据结构来实现,并发控制使用Synchronized和CAS来操作。
在这里插入图片描述
核心源码如下:

public class ConcurrentHashMap<K,V> extends AbstractMap<K,V> implements ConcurrentMap<K,V>, 
        Serializable {

    /* --------- 常量及成员变量的设计 几乎与HashMap相差无几 -------- */

    /**
     * 最大容量
     */
    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;

    /**
     * 默认并发等级,也就分成多少个单独上锁的区域
     */
    private static final int DEFAULT_CONCURRENCY_LEVEL = 16;

    /**
     * 扩容因子
     */
    private static final float LOAD_FACTOR = 0.75f;

    /**
     * 
     */
    transient volatile Node<K,V>[] table;

    /**
     * 
     */
    private transient volatile Node<K,V>[] nextTable;

    /* --------- 系列构造方法,依然推荐在初始化时根据实际情况设置好初始容量 -------- */
    public ConcurrentHashMap() {
    }

    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;
    }

    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
        long size = (long)(1.0 + (long)initialCapacity / loadFactor);
        int cap = (size >= (long)MAXIMUM_CAPACITY) ?
            MAXIMUM_CAPACITY : tableSizeFor((int)size);
        this.sizeCtl = cap;
    }
    
   //Node数据结构很简单,是一个链表,但是只允许对数据进行查找,不允许进行修改
   static  class  Node<K,V>  implements  Map.Entry<K,V> {  
  		//链表的数据结构  
  		final  int  hash;  
  		final  K key;  
  		//val和next都会在扩容时发生变化,所以加上volatile来保持可见性和禁止重排序  
  		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; 
  		}  

  		//不允许更新value   

 		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)));  
  		}  

  		//用于map中的get()方法,子类重写  

 		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);  
  			}  
  		r	eturn  null    ;  
  		}  

    }  

    /** 
     * ConcurrentHashMap 的核心就在于其put元素时 利用synchronized局部锁 和 
     * CAS乐观锁机制 大大提升了本集合的并发能力,比JDK7的分段锁性能更强
     */
    public V put(K key, V value) {
        return putVal(key, value, false);
    }

    /**
     * 当前指定数组位置无元素时,使用CAS操作 将 Node键值对 放入对应的数组下标。
     * 出现hash冲突,则用synchronized局部锁锁住,若当前hash对应的节点是链表的头节点,遍历链表,
     * 若找到对应的node节点,则修改node节点的val,否则在链表末尾添加node节点;倘若当前节点是
     * 红黑树的根节点,在树结构上遍历元素,更新或增加节点
     */
    final V putVal(K key, V value, boolean onlyIfAbsent) {
        if (key == null || value == null) throw new NullPointerException();
        int hash = spread(key.hashCode());
        int binCount = 0;
        for (Node<K,V>[] tab = table;;) {
            Node<K,V> f; int n, i, fh;
            if (tab == null || (n = tab.length) == 0)
                tab = initTable();
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
                // 注意!这是一个CAS的方法,将新节点放入指定位置,不用加锁阻塞线程
                // 也能保证并发安全
                if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
                    break;                   // no lock when adding to empty bin
            }
            // 当前Map在扩容,先协助扩容,在更新值
            else if ((fh = f.hash) == MOVED)
                tab = helpTransfer(tab, f);
            else { // hash冲突
                V oldVal = null;
                // 局部锁,有效减少锁竞争的发生
                synchronized (f) { // f 是 链表头节点/红黑树根节点
                    if (tabAt(tab, i) == f) {
                        if (fh >= 0) {
                            binCount = 1;
                            for (Node<K,V> e = f;; ++binCount) {
                                K ek;
                                // 若节点已经存在,修改该节点的值
                                if (e.hash == hash && ((ek = e.key) == key ||
                                         (ek != null && key.equals(ek)))) {
                                    oldVal = e.val;
                                    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;
                            if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                                           value)) != null) {
                                oldVal = p.val;
                                if (!onlyIfAbsent)
                                    p.val = value;
                            }
                        }
                    }
                }
                // 链表节点超过了8,链表转为红黑树
                if (binCount != 0) {
                    if (binCount >= TREEIFY_THRESHOLD)
                        treeifyBin(tab, i);
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }
        }
        // 统计节点个数,检查是否需要resize
        addCount(1L, binCount);
        return null;
    }
}

与JDK1.7在同步机制上的区别 总结如下:
JDK1.7 使用的是分段锁机制,其内部类Segment 继承了 ReentrantLock,将 容器内的数组划分成多段区域,每个区域对应一把锁,相比于HashTable确实提升了不少并发能力,但在数据量庞大的情况下,性能依然不容乐观,只能通过不断的增加锁来维持并发性能。而JDK1.8则使用了 CAS乐观锁 + synchronized局部锁 处理并发问题,锁粒度更细,即使数据量很大也能保证良好的并发性。

  • 2
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值