ConcurrentHashMap

ConcurrentHashMap是线程安全且高效的HashMap,不了解HashMap的童鞋可以戳这篇文章HashMap源码解析(JDK 1.7)

一、使用ConcurrentHashMap的原因

1、 HashMap不能搞保证线程安全,在并发编程中使用HashMap进行put操作会导致程序进入死循环

  多线程会导致HashMap中的Entry形成环形链表,那么Entry.next永远不为null,就会产生死循环;

下图为HashMap的put()方法:
在这里插入图片描述
2、 HashTable可以保证线程安全,但是它的效率非常低

 HashTable使用synchronized来保证线程安全,但是所有访问HashTable的线程都必须竞争同一把锁,当一个线程访问HashTable的同步方法时会对整个容器加锁,其他线程访问HashTable的同步方法时就被阻塞,多个线程访问时HashTable的效率非常低下;

3、 ConcurrentHashMap的锁分段技术可以有效提高并发访问率

  • ConcurrentHashMap使用的锁分段技术简单来说就是容器里有多把锁,每一把锁用于锁容器其中一部分数据,当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效提高并发访问效率;
  • 首先数据分成一段一段地存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据可以被其他线程访问。
二、ConcurrentHashMap的结构

  ConcurrentHashMap从JDK1.5开始随java.util.concurrent包一起引入JDK包中,JDK1.7之前的ConcurrentHashMap使用锁分段机制来实现,而在JDK1.8则使用数组+链表+红黑树结构和CAS原子操作实现;

  ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成。 Segment是一种可重入锁(ReentrantLock),在ConcurrentHashMap里扮演锁的角色;HashEntry则用于存储键值对数据。一个ConcurrentHashMap里包含一个Segment数组。Segment的结构和HashMap类似,是一种数组和链表结构。一个Segment里包含一个HashEntry数组,每个HashEntry是一个链表结构的元素,每个Segment守护着一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先获得与它对应的Segment锁;

在这里插入图片描述
ConcurrentHashMap类中包含两个静态内部类:Segment和HashEntry:

  • HashEntry用来存储Key-Value键值对;
  • Segment可以看作是一个table数组+若干个HashEntry对象组成的链表(哈希表),Segment继承了ReentranLock类,充当了锁的角色;
  • 一个ConcurrentHashMap对象中包含了若干个Segment对象组成的segments数组;
    在这里插入图片描述
      可以看出ConcurrentHashMap定位一个元素需要进行两次hash,第一次hash要定位到Segment,第二次hash定位到元素所在table数组的位置,也就是元素所在链表的头节点的位置;
三、源码角度分析ConcurrentHashMap

1. 继承关系

继承自AbstractMap类,实现了ConcurrentMap和序列化Serializable接口;

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

2. 默认值

    //ConcurrentHashMap的默认初始容量为16
    static final int DEFAULT_INITIAL_CAPACITY = 16; 

	//默认加载因子loadfactor为0.75f
    static final float DEFAULT_LOAD_FACTOR = 0.75f; 

	//默认的并行级别为16,这个属性用来计算Segment数组的长度大小ssize
    static final int DEFAULT_CONCURRENCY_LEVEL = 16; 

	//Segment的最大容量
    static final int MAXIMUM_CAPACITY = 1 << 30;
    
    //Segment中table数组的最小容量为2
    static final int MIN_SEGMENT_TABLE_CAPACITY = 2; 
    
	// segments的最大容量
    static final int MAX_SEGMENTS = 1 << 16; 

	//锁之前重试次数
    static final int RETRIES_BEFORE_LOCK = 2;

3. 构造函数

3.1 创建一个带有指定容量、加载因子和并行级别的ConcurrentHashMap

  • initialCapacity为整个ConcurrentHashMap的初始容量,需要平均分配给每个Segment;
  • loadFactor为加载因子,但是segments数组并不能扩容,所以这里的loadFactor是给每个Segment中的table数组使用的;
  • concurrencyLevel为并行级别,用于计算最终Segment数组的容量大小,并且该值一经指定不能再被更改,若ConcurrentHashMap中的元素数量增加需要进行扩容操作,ConcurrentHashMap不会增加Segment数组的长度,只是增加Segment中table数组的大小,这样就不需要对整个ConcurrentHashMap进行重哈希,只需要对Segment里面的元素进行一次rehash操作。

注意:原理上ConcurrentHashMap可以同时支持Segment数量大小的并发操作;

    public ConcurrentHashMap(int initialCapacity,
                             float loadFactor, int concurrencyLevel) {
        if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
            throw new IllegalArgumentException(); //参数不合法(加载因子 >0 或者初始容量 <=0 或者并行级别 <= 0),则抛出异常
            
        if (concurrencyLevel > MAX_SEGMENTS) //并行级别>segments数组最大容量,则指定concurrencyLevel为segments数组的最大容量 1 << 16
            concurrencyLevel = MAX_SEGMENTS;
            
        //两个参数用于计算Segment数组的长度,它必须是2的n次方且不小于并行级别concurrencyLevel
        int sshift = 0;
        int ssize = 1;
        //默认情况下concurrencyLevel为16,所以ssize的默认大小也为16,此时的sshift为4
        while (ssize < concurrencyLevel) {
            ++sshift; //sshift就是ssize左移的次数
            ssize <<= 1; //在ssize < concurrencyLevel时,ssize每次左移一位
        }
        
        this.segmentShift = 32 - sshift; //segmentShift为段偏移量,默认时为28
        this.segmentMask = ssize - 1; //segmentMask为散列算法的掩码,默认情况下为15
        
        if (initialCapacity > MAXIMUM_CAPACITY) //如果初始容量 > table数组的最大容量,将table数组的初始容量指定为MAXIMUM_CAPACITY
            initialCapacity = MAXIMUM_CAPACITY;
            
        int c = initialCapacity / ssize; //默认情况下c = 16/16 = 1
        if (c * ssize < initialCapacity) //因为默认ssize为16,c*ssize = 16 ,此时c依然为1
            ++c;
        int cap = MIN_SEGMENT_TABLE_CAPACITY; //cap为table数组的最小容量
        while (cap < c) //如果table数组的最小容量小于c,则cap左移
            cap <<= 1;
        // create segments and segments[0]
        Segment<K,V> s0 =
            new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
                             (HashEntry<K,V>[])new HashEntry[cap]);
        //创建ssize长度的Segment数组                     
        Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
        UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
        this.segments = ss;
    }

3.2 创建一个带有指定容量、加载因子和默认并行级别的ConcurrentHashMap

    public ConcurrentHashMap(int initialCapacity, float loadFactor) {
        this(initialCapacity, loadFactor, DEFAULT_CONCURRENCY_LEVEL);
    }

3.3 创建一个带有指定容量、默认加载因子和默认并行级别的ConcurrentHashMap

    public ConcurrentHashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL);
    }

3.4 使用三个默认的参数,调用上面重载的构造函数来创建ConcurrentHashMap

    public ConcurrentHashMap() {
        this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL);
    }

3.5 使用集合来创建

    public ConcurrentHashMap(Map<? extends K, ? extends V> m) {
        this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
                      DEFAULT_INITIAL_CAPACITY),
             DEFAULT_LOAD_FACTOR, DEFAULT_CONCURRENCY_LEVEL);
        putAll(m);
    }

初始化完成则得到了一个Segment数组,假设调用无参的构造函数,那么初始化完成后,

  • Segment数组的长度为16,不可以扩容;
  • table数组的长度为2,加载因子是0.75f,初始阈值为1.5,所以当插入第二个元素时会触发扩容;
  • 只对segment[0]进行了初始化,其他位置为null;
  • 段偏移量segmentShift的值为28,散列算法的掩码segmentMask的值为15;

4. 静态内部类 Segment

Segment继承于ReentranLock类,使Segment对象可以充当锁的角色,每个Segment可以守护成员对象table中包含的若干个桶;

4.1 继承关系

Segment类继承了ReentranLock类并且实现了序列化接口;

static final class Segment<K,V> extends ReentrantLock implements Serializable 

4.2 属性

table是一个HashEntry类型的对象数组,其中每一个数据成员就是哈希表的一个桶;

   static final int MAX_SCAN_RETRIES =
           Runtime.getRuntime().availableProcessors() > 1 ? 64 : 1;
   transient volatile HashEntry<K,V>[] table; //链表数组,数组中每个元素都是一个链表的头节点
   transient int count; //每个Segment都具有的属性,表示一个Segment对象管理的table数组中所包含的hashEntry对象的个数
   transient int modCount; //版本号,记录table被更新的次数
   transient int threshold; //扩容阈值,当table中的元素个数超过该值时触发扩容
   final float loadFactor; //加载因子

4.3 构造方法

//传入加载因子,阈值,HashEntry<K,V>类型的table数组进行初始化
Segment(float lf, int threshold, HashEntry<K,V>[] tab) { 
	this.loadFactor = lf;
	this.threshold = threshold;
	this.table = tab;
}

5. 静态内部类 HashEntry

  • 与HashMap类似,在ConcurrentHashMap中,散列时若发生哈希碰撞,会采用“分离链接法”来处理碰撞,把碰撞的HashEntry链接成一个单链表,
  • value和next都是用volatile所修饰的,来保证属性获取时的可见性;
    static final class HashEntry<K,V> {
        final int hash; //hash值
        final K key; //键
        volatile V value; //值
        volatile HashEntry<K,V> next; 

        HashEntry(int hash, K key, V value, HashEntry<K,V> next) { //HashEntry的构造函数
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }

        /**
         * Sets next field with volatile write semantics.  (See above
         * about use of putOrderedObject.)
         */
         //设置具有写语义的volatile的next字段
        final void setNext(HashEntry<K,V> n) {
            UNSAFE.putOrderedObject(this, nextOffset, n);
        }

        // Unsafe mechanics
        static final sun.misc.Unsafe UNSAFE;
        static final long nextOffset; //下一个HashEntry的偏移量
        static {
            try {
                UNSAFE = sun.misc.Unsafe.getUnsafe();
                Class k = HashEntry.class;
                nextOffset = UNSAFE.objectFieldOffset //获取HashEntry.next在内存中的偏移量
                    (k.getDeclaredField("next"));
            } catch (Exception e) {
                throw new Error(e);
            }
        }
    }

6. put()方法

主要是对key进行第一次hash定位到对应的Segment

    public V put(K key, V value) {
        Segment<K,V> s; //Segment对象
        if (value == null) //ConcurrentHashMap中存储的key和value都不能为null,否则将抛出空指针异常
            throw new NullPointerException();
        int hash = hash(key); //对key进行hash,得到key的哈希值
        int j = (hash >>> segmentShift) & segmentMask; //通过key的hash值、segmentShift和segmentMask定位到键值对所在Segment数组的下标位置
        if ((s = (Segment<K,V>)UNSAFE.getObject          // nonvolatile; recheck
             (segments, (j << SSHIFT) + SBASE)) == null) //  in ensureSegment
            s = ensureSegment(j); //初始化槽(在构造函数中对segment[0]进行了初始化,其他位置在插入第一个值时进行初始化)
        通过索引位置找到Segments存储的Segment对象    
        return s.put(key, hash, value, false); //将键值对存储到Segment中
    }

put(key, hash, value, false);是去到对应的Segment中进行put操作

final V put(K key, int hash, V value, boolean onlyIfAbsent) {
	//尝试获取锁,未获取就用scanAndLockForPut()自旋来获取锁(重试加锁机制,到达上限上限直接阻塞直到获取到锁)
	HashEntry<K,V> node = tryLock() ? null :scanAndLockForPut(key, hash, value);
	V oldValue;
    try {
    	HashEntry<K,V>[] tab = table; //拿到Segment对象HashEntry类型的table数组
        int index = (tab.length - 1) & hash; //用key的hash值和tab.length - 1计算出对应table数组的下标index
        HashEntry<K,V> first = entryAt(tab, index); //拿到index位置的HashEntry节点,也就是插入元素所在链表的头节点
        for (HashEntry<K,V> e = first;;) { //遍历链表
        	if (e != null) { 
        		K k;
        		//若链表中有一个元素的key值与待插入元素的key相等或者他们的hash值相等并且key.equals(e.key),说明在这个链表中key已经存在了
                if ((k = e.key) == key ||(e.hash == hash && key.equals(k))) {
                	oldValue = e.value; //将原来位置的value赋给oldValue
                    if (!onlyIfAbsent) {
                    	e.value = value; //用新的value值替换原来键值对中的value
                        ++modCount; //对table数组操作后版本号进行+1操作
                    }
                    break;  //此时说明新的key-value已经添加成功,跳出遍历链表的循环
                }
                 e = e.next; //e向后遍历链表
			} else { //说明e == null,有两种情况,但添加结点采用的是头插法
				if (node != null) { //1、如果node不为null,说明链表为空,修改node的next,(node节点在获取锁时已被创建)这里的first为null
					node.setNext(first);
				} else { //2、node为null则说明原来的链表不为空,创建新的节点,新节点的next指向原来的头节点first
                	node = new HashEntry<K,V>(hash, key, value, first);
				}
                int c = count + 1; //count为当前Segment中的元素个数
				//判断插入node节点后,Segment中的元素个数若大于扩容阈值并且table数组的长度还小于Segment容量的最大值
				if (c > threshold && tab.length < MAXIMUM_CAPACITY) {
					rehash(node); //扩容
				} else { //没有到达扩容阈值则将node节点插入table数组的index位置(node节点指向原来链表的头节点,相当于将node节点插入在了链表的表头)
					setEntryAt(tab, index, node);
				}
                ++modCount; //版本号+1
                count = c; //c为旧的count+1,插入元素之后相当于count进行了+1操作
				oldValue = null; //将oldValue置为null
				break; //put操作成功跳出循环
			}
		}
	} finally {
		unlock(); //释放锁
    }
    return oldValue;
}

在上面有两句代码是这样的,

if (node != null) {
	node.setNext(first);
} else { 
	node = new HashEntry<K,V>(hash, key, value, first);
}

而其中的node 是这样定义的:HashEntry<K,V> node = tryLock() ? null :scanAndLockForPut(key, hash, value);

再看下scanAndLockForPut这个获取锁的方法,

退出方法返回有两种情况:

  • tryLock()成功循环终止;
  • 重试次数到达上限进入阻塞队列直到成功拿到锁,跳出循环;

  可以说这个方法就是来拿锁的,拿不到锁就不返回的那种!而拿锁跟node节点有什么关系呢?方法内部其实是在第一次tryLock时会判断e这个头节点是否存在,存在的话node返回值就为null,但是如果e为空的话,就会在这里用传进来的参数(key、hash、value)创建一个新的节点,next为null,最后返回的就是这个节点对象。

       private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
            HashEntry<K,V> first = entryForHash(this, hash);
            HashEntry<K,V> e = first; //e为该位置的头节点
            HashEntry<K,V> node = null;
            int retries = -1; //重试的条件参数
            while (!tryLock()) { //循环获取锁
                HashEntry<K,V> f; 
                if (retries < 0) { //只有在retries == -1时可以进入到这个if语句中
                    if (e == null) { //e为null说明原来位置为空
                        if (node == null) //node == null,创建node节点,next为null
                            node = new HashEntry<K,V>(hash, key, value, null); 
                        retries = 0; //将retries置为0
                    } else if (key.equals(e.key)) { //有与之相等的key时同样将retries置为0
                        retries = 0;
                    } else {
                        e = e.next; 
                    } 
                } else if (++retries > MAX_SCAN_RETRIES) { //retries超过最大重试次数
                    lock(); //阻塞,直到抢到锁返回
                    break;
                } else if ((retries & 1) == 0 &&
                         (f = entryForHash(this, hash)) != first) { //表示链表被其他线程操作过
                    e = first = f; //重置first节点和retries,重新开始尝试
                    retries = -1;
                }
            }
            return node;
        }

count大于扩容阈值时的rehash操作对Segment进行扩容:

private void rehash(HashEntry<K,V> node) {
    HashEntry<K,V>[] oldTable = table; //oldTable为原来的数组
    int oldCapacity = oldTable.length; //旧的容量为原来数组的长度
    int newCapacity = oldCapacity << 1; //新的容量为原来的2倍
    threshold = (int)(newCapacity * loadFactor); //新的扩容阈值为新容量*加载因子
    //创建newCapacity大小的HashEntry类型数组newTable
    HashEntry<K,V>[] newTable = (HashEntry<K,V>[]) new HashEntry[newCapacity]; 
    int sizeMask = newCapacity - 1; //新的掩码
    for (int i = 0; i < oldCapacity ; i++) {
        HashEntry<K,V> e = oldTable[i]; //e为每个位置链表的头节点
        if (e != null) {
            HashEntry<K,V> next = e.next; //头节点不为null获取next节点
            int idx = e.hash & sizeMask; //头节点重新hash位置,计算e对应newTable的新位置idx
            //该索引位置只有一个节点,直接将其插入到新表的idx位置
            if (next == null) {
                newTable[idx] = e; 
            } else { //说明该索引位置不止一个节点
                HashEntry<K,V> lastRun = e; //lastRun为链表头节点
                int lastIdx = idx; //lastIdx置为当前头节点e对应newTable的idx位置
                
                //遍历旧的数组table[i]处的链表,目的是找到一个lastRun节点,它后面的所有节点重新哈希对应的都是新表的同一个位置
                for (HashEntry<K,V> last = next;last != null;last = last.next) { 
                    int k = last.hash & sizeMask; //每个节点的hash值与新的掩码计算出新的位置k
                    //若k不等于头节点lastRun对应的lastIdx位置,用last和k替换lastIdx和lastRun
                    if (k != lastIdx) { 
                        lastIdx = k; //将lastIdx的值置为k
                        lastRun = last; //lastRun置为当前节点
                    }
                }
                //lastRun及其后面的节点元素放到newTable[lastIdx]
                newTable[lastIdx] = lastRun; 
                // 遍历处理lastRun之前的节点,对所有节点进行重hash计算出在新表中的位置k
                for (HashEntry<K,V> p = e; p != lastRun; p = p.next) {
                    V v = p.value;
                    int h = p.hash; 
                    int k = h & sizeMask;
                    //依旧是头插到新表的k位置
                    HashEntry<K,V> n = newTable[k]; //k索引下的头节点
                    newTable[k] = new HashEntry<K,V>(h, p.key, v, n);
                }
            }  /*end else*/
        } /*end if e! = null*/
    } /*end for*/
    //将put操作中未插入的新节点插入
    int nodeIndex = node.hash & sizeMask; //新的位置nodeIndex
    node.setNext(newTable[nodeIndex]); //修改node的next,指向新表的nodeIndex索引位置的头节点
    newTable[nodeIndex] = node; //将node插入新表
    table = newTable; //将table置为newTable
} /*end rehash*/

  上面对table中每个位置的链表重hash,有两个for循环,假如原来的table容量为16,扩容之后变为32,在重hash时,如原来的table[0]位置的链表,在重hash时在新表中的索引只可能是0号索引或者16号索引,就是说table[0]中的元素在新表中只会对应两个位置,大概就是下图这样(0/16是指其节点在新表中的位置k),第一个循环好像是有点多余,直接用第二个for循环将每个节点复制到新表中就行了呀,其实第一个循环可以说是某种程度上的优化,找到一个lastRun,它后面节点的idx都是对应新表的同一个位置,一次性将后面的链表移过去,当然也有可能出现lastRun.next为null的情况,就是只移过去一个节点。在这里插入图片描述
7. get()方法

  get操作的高效之处在于整个get过程不需要加锁,那它的安全性是如何保证的呢?

例如要对Segment下的table[3]进行一次get操作,而在多线程下这个链表被其他线程修改了(put或者remove操作),有两种情况:

  • get()先于修改链表的线程拿到头节点,它拿到头节点之后就向后遍历寻找key对应的value了,可以说这种情况对get()来说没有影响;
  • 如果在get()方法拿到头节点之前有别的线程对这个链表进行了修改导致头节点发生了改变,它线程的工作内存中保留的是头节点没被修改之前的值,这种情况对get来说可能是有影响的;

但是可以看到,HashEntry中的共享变量value、next等值都是用volatile修饰的变量,能够在线程之间保持可见性,所以能够保证被多线程同时读到最新的值,不了解volatile的童鞋可以戳这里volatile关键字

在ConcurrentHashMap的每个Segment中只能被单线程写,但在get操作里只需要读不需要写,而其安全性可以用volatile来保证,所以可以不用加锁,这样一来减少了锁的使用次数效率也被提高了。

    public V get(Object key) {
        Segment<K,V> s; // manually integrate access methods to reduce overhead
        HashEntry<K,V>[] tab;
        int h = hash(key); //通过key进行hash拿到key的hash值h
        //根据h找到存储key的Segment
        long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE; 
        if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
            (tab = s.table) != null) {
            //找到Segment内部数组对应位置的链表,进行查找
            for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile
                     (tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);
                 e != null; e = e.next) {
                K k;
                if ((k = e.key) == key || (e.hash == h && key.equals(k))) //判断条件
                    return e.value; //返回key对应的value
            }
        }
        return null; //不存在则返回null
    }

8. size操作

  如果要统计整个ConcurrentHashMap里元素的大小,就必须统计所有Segment里元素的大小后求和。虽然Segment里的全局变量count是一个volatile变量,统计时可以获取到每个Segment最新的count值,但是可能累加前使用的count发生了变化,统计结果会受到影响;

  最安全的做法是在统计size的时候把所有Segment的put、remove和clean方法全部锁住,但是这种做法显然非常低效。

在累加count操作过程中,之前累加过的count发生变化的几率非常小

  • ConcurrentHashMap的做法是先尝试3次通过不锁住Segment的方式来统计各个Segment大小;
  • 如果统计的过程中,容器的count发生了变化,则再采用加锁的方式来统计所有Segment的大小;
  • 只需要在统计count前后比较modCount是否发生变化,从而得知容器的大小是否发生变化;
四、HashMap、HashTable和ConcurrentHashMap的区别
  1. 继承父类

HashMap:继承于AbstractMap,实现了Map接口
HashTable:继承于Dictionary,实现了Map接口
ConcurrentHashMap:继承于AbstractMap,实现了ConcurrentMap接口

  1. 安全性

HashMap:非线程安全
HashTable:线程安全
ConcurrentHashMap:线程安全

HashTable和ConcurrentHashMap的安全机制也不同:

HashTble提供的同步方法一旦被一个线程占用,将会锁住整个HashTable,此时其他线程来访问将会被阻塞;
ConcurrentHashMap依赖于ReentranLock,其中有多把锁,使用锁分段来保证安全性,一个线程访问一个数据段会占用当前Segment的锁,而不会影响其他线程对其他数据段的访问,提高了并发效率,最高可支持Segment大小的并发量;

  1. key、value的null值性

HashMap:key、value都可以为null
HashTable:key、value都不能为null
ConcurrentHashMap:key、value都不能为null

  1. 数组默认容量

HashMap:16,必须是2的n次方
HashTable:11
ConcurrentHashMap:16,必须是2的n次方

  1. 扩容方式

HashMap:2table.length
HashTable:2
table.length+1
ConcurrentHashMap:2*table.length

  1. 效率

单线程下HashMap高于HashTable
多线程下ConcurrentHashMap高于HashTable

  1. hash算法不同,而ConcurrentHashMap也需要进行两次hash
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值