HashMap笔记

综述(官方文档翻译):

HashMap是基于哈希表的Map接口实现。该实现提供了所有可选的映射操作,并允许null值和null键。(HashMap类大致等同于Hashtable,不同的是它是不同步的,并且允许为空。) 这个类不保证映射的顺序;特别是,它不能保证顺序随时间保持不变。

这个实现为基本操作(get和put)提供恒定的时间性能,假设散列函数将元素适当地分散在存储桶中。迭代集合视图需要的时间与HashMap实例的“容量”(桶的数量)加上它的大小(键-值映射的数量)成比例。因此,如果迭代性能很重要,那么不要将初始容量设置得太高(或者负载系数设置得太低),这一点非常重要。

如果许多映射要存储在一个HashMap实例中,那么使用足够大的容量创建它将使映射能够更有效地存储,而不是根据需要执行自动重新散列来增长表。注意,使用多个键具有相同的hashCode()肯定会降低任何哈希表的性能。为了减轻影响,当键具有可比性时,该类可以使用键之间的比较顺序来帮助打破联系。

注意,这个实现是不同步的。

HashMap 通常表现为一张哈希表,每个桶里面放一个元素,当有多个元素的时候,变为链表,这时候每个桶里面都是放链表;但是当链表的长度达到一个临界的时候,链表转换为树,每个树的结构就像 TreeMap 一样,这时候,每个桶里面就是一个树形结构;

大多数方法只是用普通的桶,即里面只是链表;但是在合适的时候,链表会被转为树,比如检查每个节点的时候;因为这时候转换为树,不但支持原来链表的遍历和使用,同时还能获得更快的查找;
但是由于大多数时候,每个桶都没有被过度填充,即里面都是链表,还达不到转换为树的条件,因此 HashMap 的方法可能会延迟检查桶里面到底是链表还是树形结构 ;

当桶里面的结构是树形结构的时候,通常情况下是按照 HashCode 来算下标位置的;但是如果要插入的元素实现了 Comparable 接口,则使用接口的 compareTo 方法,计算排序的位置 ;

树形结构(HashMap中的树是红黑树)保证了在元素具有 不同的哈希码或者可以排序 的情况下,插入元素复杂度在最坏的情况下是 O log(n) ,因此,将桶中的链表在一定情况下转成树是值得的;

因此,如果恶意的将 hashCode 方法的返回值故意分布在一起,比如返回同一个哈希码,或者实现了 Comparable 接口,但是 compareTo 永远返回 0,这时候 HashCdoe 的性能会下降 ;

如果这两种方法都不适用(不同的哈希码或者可以排序),与不采取预防措施相比,我们可能会浪费大约两倍的时间和空间。
在这里插入图片描述

serialVersionUID 用来在序列化验证版本一致`

常量说明:

在这里插入图片描述

HashMap实例有两个影响其性能的参数:初始容量和负载系数。 容量是哈希表中的桶数,初始容量只是创建哈希表时的容量。负载系数是一种衡量在自动增加容量之前哈希表允许的满度的度量。当哈希表中的条目数超过负载因子和当前容量的乘积时,哈希表被重新哈希(扩容),以便哈希表的桶数大约是桶数的两倍。

默认容量 :容量相关参数都必须是2的幂,原因在下文 “确定哈希桶数组索引” 中提到
在这里插入图片描述

最大容量:
在这里插入图片描述

默认负载因子:

作为一般规则**,默认负载系数(0.75)在时间和空间成本之间提供了很好的权衡。**较高的值会减少空间开销,但会增加查找成本(反映在HashMap类的大多数操作中,包括get和put)。在设置map的初始容量时,应该考虑map的预期条目数和它的负载因子,以减少重新哈希操作的数量。如果初始容量大于条目的最大数量除以负载系数,则不会发生重新哈希操作。

默认负载因子的推导:

https://segmentfault.com/a/1190000023308658

在这里插入图片描述

这也就是为什么stackoverflow上说接近于ln2的原因了。然后再去考虑hashmap一些内置的要求:
乘16可以最好一个整数。
那么在0.5~1之间找一个小数,满足这要求的只有0.625(5/8),0.75(3/4),0.875(7/8)。这三个数让我选,从审美角度,还是从中位数角度,我都会挑0.75。毕竟碰撞是个概率问题,这个0.75我觉得不错,我没办法预知使用者的数据到底什么样子的,0.75是最为折中的一个选择。

树化的长度阈值:

文档解释:

 * Because TreeNodes are about twice the size of regular nodes, we
 * use them only when bins contain enough nodes to warrant use
 * (see TREEIFY_THRESHOLD). And when they become too small (due to
 * removal or resizing) they are converted back to plain bins.  In
 * usages with well-distributed user hashCodes, tree bins are
 * rarely used.  Ideally, under random hashCodes, the frequency of
 * nodes in bins follows a Poisson distribution
 * (http://en.wikipedia.org/wiki/Poisson_distribution) with a
 * parameter of about 0.5 on average for the default resizing
 * threshold of 0.75, although with a large variance because of
 * resizing granularity. Ignoring variance, the expected
 * occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
 * factorial(k)). The first values are:
 *
 * 0:    0.60653066
 * 1:    0.30326533
 * 2:    0.07581633
 * 3:    0.01263606
 * 4:    0.00157952
 * 5:    0.00015795
 * 6:    0.00001316
 * 7:    0.00000094
 * 8:    0.00000006
 * more: less than 1 in ten million

因为树节点的大小大约是普通节点的两倍,所以我们只在桶中包含足够的节点(TREEIFY_THRESHOLD = 8)时才进行链表转换成树的操作;
当树因为删除节点变得很小的时候,会再次转换回链表;
如果 HashCode 方法设计的很好的话,哈希冲突降低,链表的长度基本就不会很长,树是很少用到的;
理想情况下,随机的哈希码,遵循 Poisson (泊松)分布;
同时给出了桶中元素个数和概率的对照表。
从上面的表中可以看到当桶中元素到达8个的时候,概率已经变得非常小,也就是说用0.75作为加载因子,每个碰撞位置的链表长度超过8个是几乎不可能的。

取消树化的还原阈值:

即 红黑树转为链表的阈值,当在扩容(resize())时(此时HashMap的数据存储位置会重新计算),在重新计算存储位置后,当原有的红黑树内数量 < 6时,则将 红黑树转换成链表。

最小树化的容量阈值:

当哈希表中的容量 > 该值时,才允许树形化链表 (即 将链表 转换成红黑树。否则,若桶内元素太多时,则直接扩容,而不是树形化。 为了避免进行扩容、树形化选择的冲突,这个值不能小于 4 * TREEIFY_THRESHOLD
因为如果桶的数量过少,又发生了严重的hash碰撞,那么根本问题其实是桶的数量太少了,所以此时树化的意义就不大,就会先优先扩容。

指定容量:tableSizeFor()
首先,通过源码的注释我们可以简单的了解到tableSizeFor()的功能:
该方法是如果给定目标容量小于最大容量时,返回一个大于等于给定目标容量的最小的2次方幂;否则,返回最大容量。

/**
 * Returns a power of two size for the given target capacity.
 */
static final int tableSizeFor(int cap) {
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

在这里插入图片描述

确定哈希桶数组索引位置:

对于任意给定的对象,只要它的 hashCode() 返回值相同,那么计算得到的 hash 值总是相同的。我们首先想到的就是把 hash 值对 table 长度取模运算,这样一来,元素的分布相对来说是比较均匀的。

JDK 1.7:

但是模运算消耗还是比较大的,我们知道计算机比较快的运算为位运算,因此 JDK 团队对取模运算进行了优化,位与运算来代替模运算。这个方法非常巧妙,它通过(table.length -1) & h来得到该对象的索引位置,这个优化是基于以下公式:x mod 2^n = x & (2^n - 1)。我们知道 HashMap 底层数组的长度总是 2 的 n 次方(这容量为什么是2的次幂的原因),并且取模运算为 “h mod table.length”,对应上面的公式,可以得到该运算等同于“h & (table.length - 1)”。这是 HashMap 在速度上的优化,因为 & 比 % 具有更高的效率。

static int indexFor(int h, int length) {  //jdk1.7的源码,jdk1.8没有这个方法,但是实现原理一样的
     return h & (length-1);  //第三步 取模运算
}

JDK 1.8:

在1.7的基础上,将高位部分与低位部分做异或运算,主要是为了在 table 的 length 较小的时候,让高位也参与运算,并且不会有太大的开销。在结构上,删除了jdk1.7的indexFor方法hash()计算高位和低位的异或,当需要确定索引index时,直接计算而不调用indexFor:
index = (n - 1) & hash;
在这里插入图片描述
这里的Hash算法本质上就是三步:取key的hashCode值、高位运算、取模运算。

在这里插入图片描述

Put 方法
在这里插入图片描述

public V put(K key, V value) {
    // 对key的hashCode()做hash
    return putVal(hash(key), key, value, false, true);
}

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K, V>[] tab;
    Node<K, V> p;
    int n, i;
    // 步骤①:tab为空则创建
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 步骤②:计算index,并对null做处理 
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K, V> e;
        K k;
        // 步骤③:节点key存在,直接覆盖value
        if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
            // 步骤④:判断该链为红黑树
        else if (p instanceof TreeNode)
            e = ((TreeNode<K, V>) p).putTreeVal(this, tab, hash, key, value);
            // 步骤⑤:该链为链表
        else {
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    //链表长度大于8转换为红黑树进行处理
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st 30 treeifyBin(tab, hash);
                        break;
                }
                // key已经存在直接覆盖value
                if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k)))) break;
                p = e;
            }
        }

        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }

    ++modCount;
    // 步骤⑥:超过最大容量 就扩容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

HashMap 扩容机制

JDK 1.7
扩容(resize)就是重新计算容量,向HashMap对象里不停的添加元素,而 HashMap 对象内部的数组无法装载更多的元素时,对象就需要扩大数组的长度,以便能装入更多的元素。当然Java里的数组是无法自动扩容的,方法是使用一个新的数组代替已有的容量小的数组,就像我们用一个小桶装水,如果想装更多的水,就得换大水桶。
我们分析下resize的源码,鉴于JDK1.8融入了红黑树,较复杂,为了便于理解我们仍然使用JDK1.7的代码,好理解一些,本质上区别不大,具体区别后文再说。

void resize(int newCapacity) {   //传入新的容量
    Entry[] oldTable = table;    //引用扩容前的Entry数组
    int oldCapacity = oldTable.length;
    if (oldCapacity == MAXIMUM_CAPACITY) {  //扩容前的数组大小如果已经达到最大(2^30)了
        threshold = Integer.MAX_VALUE; //修改阈值为int的最大值(2^31-1),这样以后就不会扩容了
        return;
    }

    Entry[] newTable = new Entry[newCapacity];  //初始化一个新的Entry数组
    transfer(newTable);                         //!!将数据转移到新的Entry数组里
    table = newTable;                           //HashMap的table属性引用新的Entry数组
    threshold = (int) (newCapacity * loadFactor);//修改阈值
}

这里就是使用一个容量更大的数组来代替已有的容量小的数组,transfer()方法将原有Entry数组的元素拷贝到新的Entry数组里。

void transfer(Entry[] newTable) {
    Entry[] src = table;                   //src引用了旧的Entry数组
    int newCapacity = newTable.length;
    for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组
        Entry<K, V> e = src[j];             //取得旧Entry数组的每个元素
        if (e != null) {
            src[j] = null;//释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)
            do {
                Entry<K, V> next = e.next;
                int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置
                e.next = newTable[i]; //标记[1]
                newTable[i] = e;      //将元素放在数组上
                e = next;             //访问下一个Entry链上的元素
            } while (e != null);
        }
    }
}

newTable[i] 的引用赋给了 e.next,也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置;这样先放在第一个索引上的元素终会被放到Entry链的尾部(如果发生了hash冲突的话),这一点和JDK 1.8有区别,下文详解。在旧数组中同一条Entry链上的元素,通过重新计算索引位置后,有可能被放到了新数组的不同位置上(倒置)。
在这里插入图片描述

JDK1.8
经过观测可以发现,我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。看下图可以明白这句话的意思,n为table的长度,图(a)表示扩容前的key1和key2两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果。
在这里插入图片描述

元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:

因此,我们在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”,可以看看下图为16扩充为32的resize示意图:

这个设计省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。这一块就是JDK1.8新增的优化点。有一点注意区别,JDK1.7中rehash的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置,但是从上图可以看出,JDK1.8不会倒置。

final Node<K, V>[] resize() {
    Node<K, V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    if (oldCap > 0) {
        // 超过最大值就不再扩充了,就只好随你碰撞去吧
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 没超过最大值,就扩充为原来的2倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    } else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int) (DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // 计算新的resize上限
    if (newThr == 0) {

        float ft = (float) newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float) MAXIMUM_CAPACITY ?
                (int) ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    @SuppressWarnings({"rawtypes""unchecked"})
    Node<K, V>[] newTab = (Node<K, V>[]) new Node[newCap];
    table = newTab;
    if (oldTab != null) {
        // 把每个bucket都移动到新的buckets中
        for (int j = 0; j < oldCap; ++j) {
            Node<K, V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode)
                    ((TreeNode<K, V>) e).split(this, newTab, j, oldCap);
                else { // 链表优化重hash的代码块
                    Node<K, V> loHead = null, loTail = null;
                    Node<K, V> hiHead = null, hiTail = null;
                    Node<K, V> next;
                    do {
                        next = e.next;
                        // 原索引
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        // 原索引+oldCap
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    // 原索引放到bucket里
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    // 原索引+oldCap放到bucket里
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

线程安全性

JDK 1.7 死循环问题:

在多线程使用场景中,应该尽量避免使用线程不安全的HashMap,而使用线程安全的ConcurrentHashMap。那么为什么说HashMap是线程不安全的,下面举例子说明在并发的多线程使用场景中使用HashMap可能造成死循环。

public class HashMapInfiniteLoop {

    private static HashMap<Integer,String> map = new HashMap<Integer,String>(20.75f);
    public static void main(String[] args) {
        map.put(5"C");

        new Thread("Thread1") {
            public void run() {
                map.put(7, "B");
                System.out.println(map);
            };
        }.start();
        new Thread("Thread2") {
            public void run() {
                map.put(3, "A);  
                        System.out.println(map);
            };
        }.start();
    }
}

为了方便,把上文提到的 JDK1.7 扩容时调用的 transfer 方法拿过来

void transfer(Entry[] newTable) {
    Entry[] src = table;                   //src引用了旧的Entry数组
    int newCapacity = newTable.length;
    for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组
        Entry<K, V> e = src[j];             //取得旧Entry数组的每个元素
        if (e != null) {
            src[j] = null;//释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)
            do {
                Entry<K, V> next = e.next; //-------断点
                int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置
                e.next = newTable[i]; //标记[1]
                newTable[i] = e;      //将元素放在数组上
                e = next;             //访问下一个Entry链上的元素
            } while (e != null);
        }
    }
}

其中,map初始化为一个长度为2的数组,loadFactor=0.75threshold=2*0.75=1,也就是说当put第二个key的时候,map就需要进行resize
通过设置断点让线程1和线程2同时debug到transfer方法的首行。注意此时两个线程已经成功添加数据。 放开thread1的断点至transfer方法的“Entry next = e.next;” 这一行;然后放开线程2的的断点,让线程2进行resize。结果如下图。

注意,Thread1的 e 此时指向了key(3),而next指向了key(7)Thread 2 rehash后,指向了重组后的链表。
Thread1 被调度回来执行,先是执行 newTalbe[i] = e, 然后是e = next,导致了e指向了key(7),而下一次循环的 next = e.next 导致了next指向了key(3)

e.next = newTable[i] 导致key(3).next指向了key(7)。注意:此时的key(7).next 已经指向了key(3), 环形链表就这样出现了。

JDK1.8
上文JDK1.8 resize()方法中,声明两对指针,维护两个链表,依次在末端添加新的元素。(在多线程操作的情况下,无非是第二个线程重复第一个线程一模一样的操作)

                ..........
                else if (e instanceof TreeNode)
                    ((TreeNode<K, V>) e).split(this, newTab, j, oldCap);
                else { // 链表优化重hash的代码块
                    Node<K, V> loHead = null, loTail = null;
                    Node<K, V> hiHead = null, hiTail = null;
                    Node<K, V> next;
                    do {
                        next = e.next;
                        // 原索引
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        // 原索引+oldCap
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    // 原索引放到bucket里
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    // 原索引+oldCap放到bucket里
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

JDK 1.8中HashMap的确不会出现 JDK1.7 中的死循环问题,但是依然死循环的可能或数据丢失等等。因此多线程情况下还是建议使用ConcurrentHashMap

JDK1.8与JDK1.7的性能对比
HashMap中,如果key经过hash算法得出的数组索引位置全部不相同,即Hash算法非常好,那样的话,getKey方法的时间复杂度就是O(1),如果Hash算法技术的结果碰撞非常多,假如Hash算法极其差,所有的Hash算法结果得出的索引位置一样,那样所有的键值对都集中到一个桶中,或者在一个链表中,或者在一个红黑树中,时间复杂度分别为O(n)和O(lgn)。 鉴于JDK1.8做了多方面的优化,总体性能优于JDK1.7。

Hash较均匀的情况
在测试中会查找不同的值,然后度量花费的时间,为了计算getKey的平均时间,我们遍历所有的get方法,计算总的时间,除以key的数量,计算一个平均值,主要用来比较,绝对值可能会受很多环境因素的影响。结果如下:

JDK1.8的性能要高于JDK1.7 15%以上,在某些size的区域上,甚至高于100%。由于Hash算法较均匀,JDK1.8引入的红黑树效果不明显,下面我们看看Hash不均匀的的情况。

Hash极不均匀的情况

从表中结果中可知,随着size的变大,JDK1.7的花费时间是增长的趋势,而JDK1.8是明显的降低趋势,并且呈现对数增长稳定。当一个链表太长的时候,HashMap会动态的将它替换成一个红黑树这话的话会将时间复杂度从O(n)降为O(logn)。hash算法均匀和不均匀所花费的时间明显也不相同,这两种情况的相对比较,可以说明一个好的hash算法的重要性。

补充:HashTable 与 HashMap 的区别
  • HashMap是线程不安全的,HashTable是线程安全的;
  • 由于线程安全,所以HashTable的效率比不上HashMap;
  • HashMap最多只允许一条记录的键为null,允许多条记录的值为null,而HashTable不允许;
  • HashMap默认初始化数组的大小为16,HashTable为11,前者扩容时,扩大两倍,后者扩大两倍+1;
  • HashMap需要重新计算hash值,而HashTable直接使用对象的hashCode;

小结:

  • 扩容是一个特别耗性能的操作,所以当程序员在使用HashMap的时候,估算map的大小,初始化的时候给一个大致的数值,避免map进行频繁的扩容。
  • 当哈希表中的条目数超过负载因子和当前容量的乘积时,哈希表被扩容
  • 容量必须是2的次幂,如果设定的值不满足,将被转为比设定值大的最接近的2的次幂
  • 负载因子是可以修改的,也可以大于1,但是建议不要轻易修改,除非情况非常特殊。
  • HashMap 是线程不安全的,不要在并发的环境中同时操作HashMap,建议使用ConcurrentHashMap。
  • JDK1.8 引入红黑树大程度优化了HashMap的性能。
    • 转换为树的条件:
    1. 链表长度大于 TREEIFY_THRESHOLD(默认 = 8)
    2. 容量大于 MIN_TREEIFY_CAPACITY (默认 = 64)
      还原为链表的条件:
      当原有的红黑树内数量 < UNTREEIFY_THRESHOLD(默认 = 6)时,则将 红黑树转换成链表。

参考:

  1. Java 8系列之重新认识HashMap
  2. HashMap的负载因子为什么是0.75
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值