HashMap扩容

1、HashMap扩容机制

HashMap在JDK1.8的时候使用数组+链表+红黑树---也叫哈希桶。静态内部类Node就是一个节点,多个Node节点构成链表,当链表长度大于8(在树化前会判断:若存放数据(entry)总量超过64转为红黑树。若数据总量未超过64,则先进行数组扩容)

注意:

HashMap 默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。并且,HashMap 总是使用 2 的幂作为哈希表的大小

当链表长度大于阈值(默认为 8)时,会首先调用 treeifyBin() 方法。这个方法会根据HashMap 数组来决定是否转换为红黑树。只有当数组长度大于或者等于 64 的情况下, 才会执行转换红黑树操作,以减少搜索时间。否则,就是只是执行 resize() 方法对数组扩容。重点关注 treeifyBin() 方法即可!

hashMap内存结构图

关于参数的源码设置

/**
     * The default initial capacity - MUST be a power of two.
   * 桶的容量,默认16
     */
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16,HashMap初始值为16

    /**
     * The maximum capacity, used if a higher value is implicitly specified
     * by either of the constructors with arguments.
     * MUST be a power of two <= 1<<30.
     * 桶的最大容量2^(30)
     */    
   static final int MAXIMUM_CAPACITY = 1 << 30

/** 
     * The load factor used when none specified in constructor.
     * 负载因子为0.75
*/
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
     * The bin count threshold for using a tree rather than list for a
     * bin.  Bins are converted to trees when adding an element to a
     * bin with at least this many nodes. The value must be greater
     * than 2 and should be at least 8 to mesh with assumptions in
     * tree removal about conversion back to plain bins upon
     * shrinkage.
     * 树化阀值为8
   */
    static final int TREEIFY_THRESHOLD = 8;

    /**
     * The bin count threshold for untreeifying a (split) bin during a
     * resize operation. Should be less than TREEIFY_THRESHOLD, and at
     * most 6 to mesh with shrinkage detection under removal.
   * 树退化阀值6
     */
    static final int UNTREEIFY_THRESHOLD = 6;

    /**
     * The smallest table capacity for which bins may be treeified.
     * (Otherwise the table is resized if too many nodes in a bin.)
     * Should be at least 4 * TREEIFY_THRESHOLD to avoid conflicts
     * between resizing and treeification thresholds.
   * 最小树化容量阀值即容量必要大于64才开始树化
    * 避免树化和扩容冲突
    */
    static final int MIN_TREEIFY_CAPACITY = 64;

关于HashMap初始值为2^(4)=16

hash桶最大容量为2^(30)

转化因子为0.75

当链表长度为8时,且数组最大数据量为64时会产生树化,由链表转为红黑树

当链表长度小于6时,会从红黑树退回链表

threshold = capacity * loadFactor,当 Size>=threshold的时候,那么就要考虑对 数组的扩增了,也就是说,这个的意思就是 衡量数组是否需要扩增的一个标准

2 HashMap和HashTable异同点

相同点:首先二者都是自己与K-V键值对存储数据的

主要不同点:

HashTable是同步的、线程安全的、稳定的(因为是全局加Synchronized锁),所以势必性能低。

HashTable不允许Key为null

HashMap未考虑同步问题,是线程不安全的、不稳定的,性能要优于HashTable---现阶段主要是使用HashMap,HashTable被淘汰。HashMap可以允许Key为null,值存放在index=0的位置

其他补充不同点:

  • HashMap继承于AbstractMap类,hashTable继承与Dictionary类。
  • 迭代器(Iterator)。HashMap的迭代器(Iterator)是fail-fast迭代器,而Hashtable的enumerator迭代器不是fail-fast的。所以当有其它线程改变了HashMap的结构(增加或者移除元素),将会抛出ConcurrentModificationException。
  • 容量的初始值和增加方式都不一样:HashMap默认的容量大小是16;增加容量时,每次将容量变为"原始容量x2"。Hashtable默认的容量大小是11;增加容量时,每次将容量变为"原始容量x2 + 1";
  • 添加key-value时的hash值算法不同:HashMap添加元素时,是使用自定义的哈希算法。Hashtable没有自定义哈希算法,而直接采用的key的hashCode()。

原理机制

基于hashing的原理,jdk8后采用数组+链表+红黑树的数据结构。我们通过put和get存储和获取对象。当我们给put()方法传递键和值时,先对键做一个hashCode()的计算来得到它在bucket数组中的位置来存储Entry对象。当获取对象时,通过get获取到bucket的位置,再通过键对象的equals()方法找到正确的键值对,然后在返回值对象

JDK1.7

JDK1.7中我们可以看出:

一上来就直接满足条件就可以扩容,然后在插入数据(先扩容+后插入)

扩容条件:当前数量大于容量* 负载因子并且数组下标的值不为空,即假如新插入的数据位置在一个数组位置而不是链表上,则插入成功而不扩容(有人只看前面条件,后面条件被忽视)

我们看例1,假设hash为1011 1101 即349,数组容量为16,但是我们数组是从0开始计算,则数组下标实际长度为15即0000 1111,通过&运算,得到数组下标值为13。扩容后,数组容量为32,通过&运算得到数组下标值为29。

   我们再看例2,假设hash为1010 1101即317,&运算得到数组下标为13。扩容后,得到数组下标值为13。

   我们可以看到表格中红色标注部分,扩容后,原值的hash受原数组容量影响。新值的下标是原下标或原下标+数组容量,如果数组存在链表,因为他们hash值相同,所以链表上的值 也跟着相应移动且位置发生倒转(即原来链表顺序是1,2,3 在新数组编程3,2,1)。

JDK1.8

仔细看第一个图,我们发现++size,即JDK1.8先存储,后扩容。扩容条件只有大于容量*负载因子

  JDK1.8的数据结构是数组+链表+红黑树,Node<K,V>中存储着链表节点next 也是Node<K,V>结构。

  我们可以看出图片标记1处 如果旧值不存在链表,则根据hash值和新容量&计算数组下标并赋值。但是存在链表

  如果旧值的hash和旧的容量计算&为0,则扩容后的位置等于原来坐标。

  如果旧值的hash和旧的容量计算&为1,则扩容后的位置等于原来坐标+旧的容量

自己简单手写一个HashMap

HashMap的构造方法有四种

 

HashMap常见面试题

1、JDK1.7和JDK1.8Hashmap区别?
   JDK1.7用的是头插法,而JDK1.8及之后使用的都是尾插法。因为JDK1.7是用单链表进行的纵向延伸,当采用头插法时会容易出现逆序且环形链表死循环问题。但是在JDK1.8之后是因为加入了红黑树使用尾插法,能够避免出现逆序且链表死循环的问题。

    扩容后数据存储位置的计算方式也不一样

2、为什么负载因子不是0.5或1?
  如果是0.5,临界值是8 则很容易就触发扩容,而且还有一半容量还没用
  如果是1,当空间被占满时候才扩容,增加插入数据的时间
  0.75即3/4,capacity值是2的幂,相乘得到结果是整数

3、为什么在JDK1.8中进行对HashMap优化的时候,把链表转化为红黑树的阈值是8,而不是7或者5呢?
   根据注释中写到,理想情况下,在随机哈希码和默认大小调整阈值为 0.75 的情况下,存储桶中元素个数出现的频率遵循泊松分布,平均参数为 0.5,有关 k 值下,随机事件出现频率的计算公式为 (exp(-0.5) * pow(0.5, k) /factorial(k)))大体得到一个数值是8,那么退化树阀值为什么是6?如果退化树阀值也是8,则会陷入树化和退化的死循环中。如果退化阀值是7,假如对hash进行频繁的增删操作,同样会进入死循环中。如果退化树阀值小于5,我们知道红黑树在低元素查询效率并不比链表高,而且红黑树会存储很多索引,占有内存。所以退化阀值设为6比较合理

4、JDK1.7是先扩容再插入(存储),而JDK1.8是先插入(存储)再扩容。为什么?
  可能原因是JDK1.7采用头插法,扩容后,计算hash,只需要插入链表头部就行。而JDK1.8采用尾插法,如果先扩容,扩容后需要遍历一遍,再找到尾部进行插入

5、HashMap的底层数据结构是什么?

在JDK1.7 中,由“数组+链表”组成,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的。
在JDK1.8 中,由“数组+链表+红黑树”组成。当链表过长,则会严重影响 HashMap 的性能,红黑树搜索时间复杂度是 O(logn),而链表是糟糕的 O(n)。因此,JDK1.8 对数据结构做了进一步的优化,引入了红黑树,链表和红黑树在达到一定条件会进行转换:

首先JDK1.7的HashMap当出现Hash碰撞的时候,最后插入的元素会放在前面,这个称为 “头插法”
在JDK1.8以后,由头插法改成了尾插法,因为头插法还存在一个死链的问题

HashMap面试题整理_黄泥川水猴子的博客-CSDN博客

史上最全Hashmap面试总结,51道附带答案,持续更新中..._androidstarjack的博客-CSDN博客

https://www.cnblogs.com/zengcongcong/p/11295349.html

关于散列(hash)函数的理解

https://www.cnblogs.com/lyr612556/p/8006950.html

1、哈希表设计目的就是希望尽量的随机散射不希望这些在同一列上的元素(也就是会冲突的元素)之间具有关系,所以我们都采用最大素数作为哈希表的大小,从而避免模数相同的数之间具备公共因数

2、哈希表装填因子(负载因子)定义为:α= 填入表中的元素个数 / 哈希表的长度

以上表达式说明负载因子受数据个数和哈希表长度两个变量影响

α是哈希表装满程度的标志因子。由于表长是定值,α与“填入表中的元素个数”成正比,所以,α越大,填入表中的元素较多,产生冲突的可能性就越大;α越小,填入表中的元素较少,产生冲突的可能性就越小

3、哈希函数构造主要有以下几种:

1:直接寻址法;


2:取模法;


3:数字分析法;


4:折叠法;


5:平方取中法;


6:除留余数法;
7:随机数法

总的原则是尽可能少的产生冲突。

通常考虑的因素有关键字的长度和分布情况、哈希值的范围等。

4、解决Hash冲突(也被称为同义词)

1.链接法(拉链法)

拉链法解决冲突的做法是: 将所有关键字为同义词的结点链接在同一个单链表中。

若选定的散列表长度为 m,则可将散列表定义为一个由 m 个头指针组成的指针数组 T[0..m-1] 。

凡是散列地址为 i 的结点,均插入到以 T[i] 为头指针的单链表中。 
T 中各分量的初值均应为空指针。

在拉链法中,装填因子 α 可以大于 1,但一般均取 α ≤ 1

这里写图片描述

一句话:所谓 “拉链法” 就是:将链表和数组相结合。也就是说创建一个链表数组,数组中每一格 就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可

2.开放定址法

用开放定址法解决冲突的做法是:

用开放定址法解决冲突的做法是:当冲突发生时,使用某种探测技术在散列表中形成一个探测序列。沿此序列逐个单元地查找,直到找到给定的关键字,或者碰到一个开放的地址(即该地址单元为空)为止(若要插入,在探查到开放的地址,则可将待插入的新结点存人该地址单元)。查找时探测到开放的地址则表明表中无待查的关键字,即查找失败

一句话:当冲突发生时,使用某种探查(亦称探测)技术在散列表中寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到

按照形成探查序列的方法不同,可将开放定址法区分为线性探查法二次探查法双重散列法等。

a.线性探查法

hi=(h(key)+i) % m ,0 ≤ i ≤ m-1 

基本思想是: 
探查时从地址 d 开始,首先探查 T[d],然后依次探查 T[d+1],…,直到 T[m-1],此后又循环到 T[0],T[1],…,直到探查到 有空余地址 或者到 T[d-1]为止。

b.二次探查法

hi=(h(key)+i*i) % m,0 ≤ i ≤ m-1 

基本思想是: 
探查时从地址 d 开始,首先探查 T[d],然后依次探查 T[d+1^2],T[d+2^2],T[d+3^2],…,等,直到探查到 有空余地址 或者到 T[d-1]为止。

缺点是无法探查到整个散列空间。

c.双重散列法

hi=(h(key)+i*h1(key)) % m,0 ≤ i ≤ m-1 

基本思想是: 
探查时从地址 d 开始,首先探查 T[d],然后依次探查 T[d+h1(d)], T[d + 2*h1(d)],…,等。

该方法使用了两个散列函数 h(key) 和 h1(key),故也称为双散列函数探查法。

定义 h1(key) 的方法较多,但无论采用什么方法定义,都必须使 h1(key) 的值和 m 互素,才能使发生冲突的同义词地址均匀地分布在整个表中,否则可能造成同义词地址的循环计算。

该方法是开放定址法中最好的方法之一。

5、Hash应用

哈希表(散列表)
 

哈希表是实现关联数组(associative array)的一种数据结构,广泛应用于实现数据的快速查找用哈希函数计算关键字的哈希值(hash value),通过哈希值这个索引就可以找到关键字的存储位置,即桶(bucket)。哈希表不同于二叉树、栈、序列的数据结构一般情况下,在哈希表上的插入、查找、删除等操作的时间复杂度是 O(1)

这里写图片描述

查找过程中,关键字的比较次数,取决于产生冲突的多少,产生的冲突少,查找效率就高,产生的冲突多,查找效率就低。因此,影响产生冲突多少的因素,也就是影响查找效率的因素。 
影响产生冲突多少有以下三个因素:

  • 哈希函数是否均匀;
  • 处理冲突的方法;
  • 哈希表的加载因子。


哈希表的加载因子和容量决定了在什么时候桶数(存储位置)不够,需要重新哈希。

加载因子太大的话桶太多,遍历时效率变低;太大的话频繁 rehash,导致性能降低。所以加载因子的大小需要结合时间和空间效率考虑。 

在 HashMap 中的加载因子为 0.75,即四分之三

分布式缓存
网络环境下的分布式缓存系统一般基于一致性哈希(Consistent hashing)。简单的说,一致性哈希将哈希值取值空间组织成一个虚拟的环,各个服务器与数据关键字K使用相同的哈希函数映射到这个环上,数据会存储在它顺时针“游走”遇到的第一个服务器。可以使每个服务器节点的负载相对均衡,很大程度上避免资源的浪费。

在动态分布式缓存系统中,哈希算法的设计是关键点。使用分布更合理的算法可以使得多个服务节点间的负载相对均衡,可以很大程度上避免资源的浪费以及部分服务器过载。 使用带虚拟节点的一致性哈希算法,可以有效地降低服务硬件环境变化带来的数据迁移代价和风险,从而使分布式缓存系统更加高效稳定

ConcurrentHashMap---高并发下的HashMap

链式哈希表---本质是一组链表构成。每个链表可以看做是一个“‘桶’”,将所有元素通过散列的方式放到具体不同桶中。插入元素时,首先将其键传入哈希函数(哈希键),函数通过散列的方式告知元素属于哪个桶,再相应立案表头插入元素。查找或删除元素时,用同样方式找到元素的"桶",遍历相应链表,直至发现我们想要的元素。

因为每个桶都是一个链表,所以链式哈希表并不限制包含元素个数。但是表变得太大,性能会降低!

应用场景:缓存技术(Redis、MemCached)的核心就是维护一张巨大的哈希表。HashMap、CurrentHashMap

ConcurrentHashMap---性能+安全的结合


主要就是为了应对hashmap在并发环境下不安全而诞生的,ConcurrentHashMap的设计与实现非常精巧,大量的利用了volatile,final,CAS等lock-free技术来减少锁竞争对于性能的影响。
我们都知道Map一般都是数组+链表结构(JDK1.8该为数组+红黑树)。ConcurrentHashMap避免了对全局加锁改成了局部加锁操作,这样就极大地提高了并发环境下的操作速度。

JDK1.7中ConcurrentHashMap采用了数组+Segment+分段锁的方式实现。

1.Segment(分段锁)
ConcurrentHashMap中的分段锁称为Segment,它即类似于HashMap的结构,即内部拥有一个Entry数组,数组中的每个元素又是一个链表,同时又是一个ReentrantLock(Segment继承了ReentrantLock)。

2.内部结构

ConcurrentHashMap使用分段锁技术,将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问,能够实现真正的并发访问。如下图是ConcurrentHashMap的内部结构图

 从上面的结构我们可以了解到,ConcurrentHashMap定位一个元素的过程需要进行两次Hash操作

第一次Hash定位到Segment,第二次Hash定位到元素所在的链表的头部

3.该结构的优劣势

坏处

这一种结构的带来的副作用是Hash的过程要比普通的HashMap要长

好处

写操作的时候可以只对元素所在的Segment进行加锁即可,不会影响到其他的Segment,这样,在最理想的情况下,ConcurrentHashMap可以最高同时支持Segment数量大小的写操作(刚好这些写操作都非常平均地分布在所有的Segment上)。


所以,通过这一种结构,ConcurrentHashMap的并发能力可以大大的提高。

JDK1.8版本的CurrentHashMap的实现原理

JDK8中ConcurrentHashMap参考了JDK8 HashMap的实现,采用了数组+链表+红黑树的实现方式来设计,内部大量采用CAS操作。

CAS是compare and swap的缩写,即我们所说的比较交换。cas是一种基于锁的操作,而且是乐观锁。在java中锁分为乐观锁和悲观锁。悲观锁是将资源锁住,等一个之前获得锁的线程释放锁之后,下一个线程才可以访问。而乐观锁采取了一种宽泛的态度,通过某种方式不加锁来处理资源,比如通过给记录加version来获取数据,性能较悲观锁有很大的提高。

CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。如果内存地址里面的值和A的值是一样的,那么就将内存里面的值更新成B。CAS是通过无限循环来获取数据的,若果在第一轮循环中,a线程获取地址里面的值被b线程修改了,那么a线程需要自旋,到下次循环才有可能机会执行。 


JDK8中彻底放弃了Segment转而采用的是Node

Node:保存key,value及key的hash值的数据结构。其中value和next都用volatile修饰,保证并发的可见性。


classNode<K,V>implementsMap.Entry<K,V>{finalint hash;final K key;volatile V val;volatileNode<K,V> next;//... 省略部分代码}</strong>

Java8 ConcurrentHashMap结构基本上和Java8的HashMap一样,不过保证线程安全性。

为什么是红黑树?不是其他二叉树?

首先,选取红黑树是因为它的搜索性能很好,可读性也比链表好,时间复杂度为O(logN),相较于早期完全链表实现O(N)复杂度大大降低

其实可以看出JDK1.8版本的ConcurrentHashMap的数据结构已经接近HashMap,相对而言,ConcurrentHashMap只是增加了同步的操作来控制并发,从JDK1.7版本的ReentrantLock+Segment+HashEntry,到JDK1.8版本中synchronized+CAS+HashEntry+红黑树。

1.数据结构:取消了Segment分段锁的数据结构,取而代之的是数组+链表+红黑树的结构。
2.保证线程安全机制:JDK1.7采用segment的分段锁机制实现线程安全,其中segment继承自ReentrantLock。JDK1.8采用CAS+Synchronized保证线程安全。
3.锁的粒度:原来是对需要进行数据操作的Segment加锁,现调整为对每个数组元素加锁(Node)。
4.链表转化为红黑树:定位结点的hash算法简化会带来弊端,Hash冲突加剧,因此在链表节点数量大于8时,会将链表转化为红黑树进行存储。
5.查询时间复杂度:从原来的遍历链表O(n),变成遍历红黑树O(logN)。

put方法

//这是Map的put方法
public V put(K key, V value) {
 Segment<K,V> s;
 //不支持value为空
 if (value == null)
  throw new NullPointerException();
 //通过 Wang/Jenkins 算法的一个变种算法,计算出当前key对应的hash值
 int hash = hash(key);
 //上边我们计算出的 segmentShift为28,因此hash值右移28位,说明此时用的是hash的高4位,
 //然后把它和掩码15进行与运算,得到的值一定是一个 0000 ~ 1111 范围内的值,即 0~15 。
 int j = (hash >>> segmentShift) & segmentMask;
 //这里是用Unsafe类的原子操作找到Segment数组中j下标的 Segment 对象
 if ((s = (Segment<K,V>)UNSAFE.getObject          // nonvolatile; recheck
   (segments, (j << SSHIFT) + SBASE)) == null) //  in ensureSegment
  //初始化j下标的Segment
  s = ensureSegment(j);
 //在此Segment中添加元素
 return s.put(key, hash, value, false);
}

实际上put()调用putVal()

/** Implementation for put and putIfAbsent */
    final V putVal(K key, V value, boolean onlyIfAbsent) {
    	// 判断要添加的key或者value是否为空,为空抛出异常
        if (key == null || value == null) throw new NullPointerException();
        // key的hashCode算出hash值
        int hash = spread(key.hashCode());
        int binCount = 0;
        
        for (Node<K,V>[] tab = table;;) {
            Node<K,V> f; int n, i, fh;
            // 如果table为空,那么就调用initTable初始化table
            if (tab == null || (n = tab.length) == 0)
            	//  如果table为空,那么就调用initTable初始化table
                tab = initTable();
                //通过hash 值计算table 中的索引,如果该位置没有数据,则可以put  
                // tabAt方法以volatile读的方式读取table数组中的元素 
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
       		     //casTabAt方法以CAS的方式,将元素插入table数组
                if (casTabAt(tab, i, null,
                             new Node<K,V>(hash, key, value, null)))
                    break;                   // no lock when adding to empty bin
            }
            // 如果table位置上的节点状态时MOVE,则表明hash 正在进行扩容搬移数据的过程中
            else if ((fh = f.hash) == MOVED)
                tab = helpTransfer(tab, f);// 帮助扩容
            // hash 表该位置上有数据,可能是链表,也可能是一颗树
            else {
                V oldVal = null;
                synchronized (f) {
                // 上锁后,只有再该位置数据和上锁前一致才进行,否则需要重新循环
                    if (tabAt(tab, i) == f) {
                     // hash 值>=0 表明这是一个链表结构
                        if (fh >= 0) {
                            binCount = 1;
                            for (Node<K,V> e = f;; ++binCount) {
                                K ek;
                                // 存在一样的key就覆盖它的value
                                if (e.hash == hash &&
                                    ((ek = e.key) == key ||
                                     (ek != null && key.equals(ek)))) {
                                    oldVal = e.val;
                                    if (!onlyIfAbsent)
                                        e.val = value;
                                    break;
                                }
                                // 不存在该key,将新数据添加到链表尾
                                Node<K,V> pred = e;
                                if ((e = e.next) == null) {
                                    pred.next = new Node<K,V>(hash, key,
                                                              value, null);
                                    break;
                                }
                            }
                        }
                        // 该位置是红黑树,是TreeBin对象(注意是TreeBin,而不是TreeNode)
                        else if (f instanceof TreeBin) {
                            Node<K,V> p;
                            binCount = 2;
                            //通过TreeBin 中的方法,将数据添加到红黑树中
                         
                            if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                                           value)) != null) {
                                oldVal = p.val;
                                if (!onlyIfAbsent)
                                    p.val = value;
                            }
                        }
                    }
                }
                if (binCount != 0) {
                   //if 成立,说明遍历的是链表结构,并且超过了阀值,需要将链表转换为树
                    if (binCount >= TREEIFY_THRESHOLD)
                        treeifyBin(tab, i);//将table 索引i 的位置上的链表转换为红黑树
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }
        }
        // ConcurrentHashMap 容量增加1,检查是否需要扩容
        addCount(1L, binCount);
        return null;
    }

ConcurrentHashmap 不支持 key 或者 value 为 null 的原因

ConcurrentHashmap 和 Hashtable 都是支持并发的,当通过 get(k) 获取对应的 value 时,如果获取到的是 null 时,无法判断是 put(k,v) 的时候 value 为 null,还是这个 key 从来没有做过映射。
2.HashMap 是非并发的,可以通过 contains(key) 来做这个判断。
3.支持并发的 Map 在调用 m.contains(key) 和 m.get(key) 时,m 可能已经发生了更改。
因此 ConcurrentHashmap 和 Hashtable 都不支持 key 或者 value 为 null。

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值