【HashMap】源码分析

HashMap源码分析

我们先大致的了解一下HashMap。

从使用的角度上看,它使用起来非常简单,put操作时,保存的是键值对,get操作时,通过键就即可拿到值,它的键值对都是支持null的,这在一定程度上提升了容错率,另外,HashMap实际上是非线程安全的,从内在角度看,它使用了hash算法+数组+链表的结构存储数据,而在JDK 1.8及之后的版本增加红黑树的数据结构来存储数据。

构造方法(1.8)

HashMap的构造方法有四个,分别如下:

1public HashMap(int initialCapacity, float loadFactor)
2public HashMap(int initialCapacity)
3public HashMap()
4public HashMap(Map<? extends K, ? extends V> m)

以上四个构造方法,第二个实际上调用了第一个,而第三个,没啥好讲的,第四个嘛,就是传入一个map,将整个map存储的数据赋值给构建出来的HashMap,也没有啥好讲的。

那么我们就直接看第一个构造方法:

public HashMap(int initialCapacity, float loadFactor) {
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                           initialCapacity);
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);
}

它传入两个参数并将它们赋值给内部成员变量,分别是初始容量和加载因子,方法的最后通过tableSizeFor(initialCapacity)计算出阈值threshold的大小,注意哦,是threshold !我们看看tableSizeFor方法:

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

这个方法用于找到大于等于initialCapacity的最小的2的幂(initialCapacity如果就是2的幂,则返回的还是这个数)。 我们举个例子大概看看它的流程是怎样的:

假设 cap = 10,则 n = 9
n = 9 ——> 二进制1001, 
右移1位为0100 
n = 1001 | 0100, 结果是 1101
右移2位为0011 
n = 1101 | 0011, 结果是 1111
右移4位为0000
n = 1111 | 0000, 结果是 1111
右移8位为0000
n = 1111 | 0000, 结果是 1111 ——> 转为10进制,即为15

所以,方法最后会返回:
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; 也就是16

由此,我们可以看出tableSizeFor(10)将会return 16。为什么要用位运算呢?当然是为了提高运行效率啦,据说,位运算能比取模运算快了大概27倍。

PUT方法(1.8)

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

可以看到,HashMap的put方法需要传入两个参数,分别是key和value,然后put方法又调用了putVal方法,而putVal方法的传参中还有一个hash(key)方法,那我们就先看看hash(key)方法:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

hash(Object key)方法中,如果key为null,则直接return 0,否则执行(h = key.hashCode()) ^ (h >>> 16),这个段稍微有点长,我们对它分解一下,流程如下:

1、通过key.hashCode()计算key的hashCode,并保存在h变量中

2、将hh的右移16位的值进行亦或计算

3、返回步骤2计算得到的值

其中,hashCode()方法是Object.java类的方法,它是用来计算对象的hashCode值,这里要明确一下:

  • 一个对象有且只有一个hashCode
  • 两个对象的hashCode不同,它们肯定不是同一个对象
  • 两个对象的hashCode相同,它们可能是同一个对象,也可能不是同一个对象

hashCode是int型,我们知道,int的范围是[-2147483648, 2147483647],因此hashCode是有限的,而对象是无限的。

那么可想而知,有限的hashCode映射在无限的对象上,那么必定会有不同的对象使用同一个hashCode的情况。

h的右移16位怎么理解呢?举个简单的例子:

比如一个intHashCode数据h,它的二进制如下:
h1: 0000 0010 0011 1001 1101 1111 1010 0101
执行>>>16,右移16位后,它二进制如下: 
h2: 0000 0000 0000 0000 0000 0010 0011 1001

h1 ^ h2,执行h1和h2的亦或后(同为0不同为1),得到hash值,它的二进制如下:
h3: 0000 0010 0011 1001 1101 1101 1001 1100

可以看到,亦或的过程中,h3的高16位恒等于h1的高16位,h3的低16位本质上是h1自己的高16位和自己的低16位进行亦或得到值。为什么要这么做呢,其实是为了让key的hashCode的32位都参与到运算中,增加随机性,会让HashMap计算得到的数组下标更加散列

由此,我们已经完了对hash(Object key)方法的分析。

那么我们继续回到put(K key, V value)方法:

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

我们跟进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;
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        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);
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                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;
}

1、如果数组tab为null或者数组的大小为0,则执行resize()方法进行扩容

2、将数组的大小-1和hash值进行运算,得到元素应存放的数组下标i

假设数组的容量是16,则15的二进制为:
15: 0000 0000 0000 0000 0000 0000 0000 1111
h3即为h1和h2的亦或运算得到的hash值,二进制表示如下:
h3: 0000 0010 0011 1001 1101 1101 1001 1100
将h3和hash值进行与运算(都为1时为1,否则就为0)的结果如下:
h4: 0000 0000 0000 0000 0000 0000 0000 1100  ——>  转为十进制为12

可见,通过&运算得到的HashMap数组的下标,必定在数组的大小以内,因为当数组容量为15时,整个hash值只有低4位能参与运算。

3、如果数组下标i对应的元素p为null,则新建一个链表节点,放置在该数组下标处

4、如果数组下标i对应的元素p不为null,分四种情况:

  • 如果p的hash等于传入的hash,且p的key等于传入的key,或传入的key的值和p的key的值相同,则将元素p赋值给元素e。
  • 如果p的hash不等于传入的hash,或p的key不等于传入的key,且传入的key的值和p的key的值不相同,且p为树节点,
  • 如果p的hash不等于传入的hash,或p的key不等于传入的key,且传入的key的值和p的key的值不相同,且p不为树节点,则进入for循环,遍历链表,找到p的next节点为null,则新建一个链表节点赋值给p的next结点,从这里也可以看出,是以尾插法的形式添加链表新结点的,如果链表结点的数量大于等于树化阈值TREEIFY_THRESHOLD-1的值,则将链表转化为红黑树,其中TREEIFY_THRESHOLD的为8。也就是说,添加新链表结点后,如果链表结点数量大于等于7,则将链表转化为红黑树

5、把新添加的元素的value值赋值为oldValue,然后put方法传入的value赋值为新添加的元素,然后将oldValue返回。

6、判断此时HashMap的数组大小是否大于扩容阈值threshold,是的话就对数组进行扩容。其中threshold=容量 * 加载因子,加载因子默认为0.75

GET方法(1.8)

再来看它的get方法:

public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

可以看到,先根据key计算出key的hash值,然后将其传入getNode(hash(key)方法:

final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        if ((e = first.next) != null) {
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

1、可以看到,跟put方法一样,get方法也有将数组的大小-1的值和hash值进行与运算,得到元素所在的数组下标,我们可以暂且认为该下标对应的元素即为链表的头结点first

2、如果first的hash值与传入的hash值,且first的key与传入的key是同一个对象或它们值相等,则直接将first返回

3、如果头结点不是想要拿到的结点,则进入循环,遍历整个链表(或红黑树),找到符合hash值相同,且key相同的元素,然后返回

4、如果经历以上流程都找不到,则return null。

HashMap的扩容机制

HashMap在什么时候需要扩容呢?在put方法的流程中我也看到了,有两个地方可能会发生扩容:

  • 执行put方法时,如果结点数组tab为null或者它的大小为0,则执行resize()方法进行扩容(一般来说,刚初始化完HashMap,结点数组tab就会为null),所以这次扩容发生在初始化HashMap后的第一次执行put方法。

  • 将结点元素添加到HashMap后,如果此时结点数组大于扩容阈值threshold,就进行扩容

扩容是指哪里的扩容呢?

其实就是对结点数组这个容器进行扩容,我们都知道,数组在初始化的时候就已经固定大小了,当存储满了,就无法继续存放元素了,而链表和树并没有扩容这个概念,因为它们的容量就是无限的。

为什么要扩容呢?

我们可能会有个疑问:HashMap的结构是数组+链表,每个数组下标都对应着一条链表,那么我们只要保证每个新添加的元素都在数组下标对应的链表上,就永远不会发生数组越界问题,为啥还要扩容呢?其实,HashMap的扩容更多的是从性能方面上考虑的,我们知道,链表的查询效率是O(n),一旦链表过长,那么HashMap查找效率将会变得很低,当然,jdk1.8后引入了红黑树,使得链表达到一定长度后就树化,优化了一定的查找效率。但是,为了保证较高的查找效率,对结点数组进行扩容仍是必不可少的

但是,扩容是个特别耗时的操作,所以在我们使用HashMap时,最好能估算出它的大致容量并使用这个容量来创建HashMap,避免HashMap内部频繁进行扩容

JDK1.7的扩容

下面我们进入JDK1.7的HashMap源码,看看它是如何扩容的:

Jdk1.7:
void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        //如果旧容量已经达到了最大,将阈值设置为最大值,与1.8相同
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
        }

        //创建新哈希表
        Entry[] newTable = new Entry[newCapacity];
        //将旧表的数据转移到新的哈希表
        transfer(newTable, initHashSeedAsNeeded(newCapacity));
        table = newTable;
        //更新阈值
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}

resize方法的大致流程如下:

1、旧数组存入oldTable变量,旧容量大小存入oldCapacity变量

2、如果旧容量已经达到了最大,将阈值threshold设置为最大值,并且return,说明无法继续扩容了。与1.8相同

3、根据oldCapacity值创建新结点数组newTable

4、执行transfer方法将旧数据转移到新的哈希表上

5、更新扩容阈值

下面重点来了,我们继续跟进transfer方法:

void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        //遍历旧表
        for (Entry<K,V> e : table) {
            while(null != e) {
                Entry<K,V> next = e.next;
                //如果hashSeed变了,需要重新计算hash值
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                //得到新表中的索引
                int i = indexFor(e.hash, newCapacity);
                //将新节点作为头节点添加到桶中
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }
}

1、先获取到新数组的大小

2、遍历旧的HashMap

3、每遍历到一个HashMap中的一个结点数组索引,就对该索引下的链表进行遍历

4、判断链表结点 e 是否需要重新计算hash值

5、计算得到链表结点 e 应该放在数组中的哪个索引处,即索引 i

6、将结点 e头插法的形式插入该数组索引下

正是因为1.7的扩容使用了头插法的形式进行,因此在多线程环境使用HashMap将发生死循环问题,我们在下面的篇幅也会重点讲解这个点!

JDK1.8的扩容

那么如何扩容呢?下面我们进入源码看看,定位到resize()方法:

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;
        }
        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);
    }
    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) {
        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 { // preserve order
                    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;
                        }
                        //扩容后不需要移动的链表
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

1、将当前的结点数组赋值给oldTab

2、获取旧的结点数组的容量oldCap,如果oldCap已经已经大于HashMap支持的最大容量,则直接返回oldTab,也就是说不能再扩容了,因为已经达到最大容量。

3、新的容量大小等于oldCap << 1的值,即容量扩大1倍后,新容量如果小于最大容量,将新的扩容阈值newThr在旧的扩容阈值基础上扩大一倍。

4、如果旧容量大小小于等于0且旧扩容阈值大小oldThr大于0,则将oldThr赋值给newCap,注意这里是将阈值大小赋值给结点数组的容量大小。我们回忆一下,在初始化HashMap时,传入的map容量,最终会找到大于等于容量的最小的2的幂,赋值给扩容阈值,而这个扩容阈值的值就是在这个时候赋值给数组容量的

5、如果旧容量大小小于等于0且旧扩容阈值大小oldThr小于等于0,那么newCap将被赋值为默认容量大小,newThr将被赋值为默认加载因子*默认容量大小的值,即newThr = 0.75 * 16 = 12

6、HashMap的成员变量threshold赋值为newThr。构造一个容量大小为newCap的结点数组newTab,并将newTab赋值给HashMap的成员变量table。

7、进入一个循环,这个循环是用来遍历结点数组的,先拿到oldTab[0]的链表的头结点e,如果该结点不为null,将oldTab[0]赋值为null,然后分三种情况处理:

(1)如果头结点的next结点为null,也就是说该链表只有一个结点的情况,则执行newTab[e.hash & (newCap - 1)] = e;,计算得到一个新的数组下标,把链表头结点e放置到这个下标对应的数组位置。

(2)如果头结点的next结点不为null,且e是一个树节点,说明链表已变成红黑树,则执行split()方法进行扩容

(3)如果头结点的next结点不为null,且e不是一个树节点,也就是说该链表结点不止一个的情况下,再开一个循环,这个循环就是遍历链表结点,如果(e.hash & oldCap) == 0,则表示扩容后结点在数组中的位置跟扩容前一样,而当(e.hash & oldCap) != 0,则表示扩容后结点在数组中的位置在扩容前的(原索引+oldCap)处,例如扩容前结点所在索引是4,容量是16,则扩容后将在20处。这是二进制与运算的一个规律,这也是HashMap在Java8才用到的一个新的规律,运用这个规律,省去了重新计算hash值的时间,一定程度上提高了扩容的效率。另外,扩容前后,链表结点的顺序跟扩容前是一致的(1.7扩容会出现链表结点倒置)。这部分我们单独举个例子理解一下。

我觉得第7点的第三种情况有必要单独讲一下,它是扩容机制种需要重点理解的东西:

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;
    }
    else {
        if (hiTail == null)
            hiHead = e;
        else
            hiTail.next = e;
        hiTail = e;
    }
} while ((e = next) != null);
if (loTail != null) {
    loTail.next = null;
    newTab[j] = loHead;
}
if (hiTail != null) {
    hiTail.next = null;
    newTab[j + oldCap] = hiHead;
}

首先我们看这个操作e.hash & oldCap,为什么它只有0和非0两种情况呢?我们举个简单的例子:

假设e结点此时的hash是1234,oldCap为16:
hash二进制为  :0000 0000 0000 0000 0000 0010 1001 1100
oldCap二进制为:0000 0000 0000 0000 0000 0000 0001 0000
它们进行
&运算的结果   :0000 0000 0000 0000 0000 0000 0001 0000,其10进制表示为16

可见,由于oldCap的低5位为"10000",所以无论hash值为多少,它都只有倒数第5位能参与运算,其他位数无论如何只能是0,而倒数第5位,有可能是0,也有可能是1。因此e.hash & oldCap计算出来的值,只有非0和0两种结果,并且我们可以认为0和非0出现的概率是相等的,这样就可以将扩容前的链表均匀的分散在两个数组索引下了,同时也省去了重新计算hash值的时间,一定程度上提高了扩容的效率

doWhile循环的代码怎么理解呢?

首先看到四个变量

  • loHead:低位头指针,指向低位数组索引下的链表头结点
  • loTail:低位尾指针,指向低位数组索引下的链表尾结点
  • hiHead:高位头指针,指向高位数组索引下的链表头结点
  • hiTail:低位尾指针,指向高位数组索引下的链表头结点

我们依旧是举个例子看看:

假设当前正在比遍历到数组的某索引,该索引下有如下链表:
e:n1 -> n2 -> n3 -> n4 -> null
现在开始对这个链表进行遍历,
遍历到n1, 计算(e.hash & oldCap) == 0的值为true,
此时,loHead指向n1,loTail也指向n1,

遍历到n2, 计算(e.hash & oldCap) == 0的值为false,
此时,hiHead指向n2,hiTail也指向n2,

遍历到n3, 计算(e.hash & oldCap) == 0的值为true,
此时,loTail.next指向n3,因为上次循环后,loTail和loHead的指向是一样的,因此,loHead.next也指向n3,最后,loTail再指向n3

遍历到n4, 计算(e.hash & oldCap) == 0的值为false,
此时,hiTail.next指向n4,因为上次循环后,hiTail和hiHead的指向是一样的,因此,hiHead.next也指向n4,最后,hiTail再指向n4

循环结束..

当经历完这个doWhile循环后,loHead对应的链表为n1 -> n3,hiHead对应的链表为n2 -> n4,然后再执行loTail.next = null;hiTail.next = null;此时loHead对应的链表为n1 -> n3 -> null,hiHead对应的链表为n2 -> n4 -> null

最后,执行newTab[j] = loHead;newTab[j + oldCap] = hiHead;将loHead赋值给newTab[j],hiHead赋值给newTab[j + oldCap]。

至此,扩容结束。

哈希冲突(哈希碰撞)

什么是哈希冲突呢?

它的意思是在计算不同元素的key哈希值(hash)时,得到多个相同哈希值,相同的哈希值,就意味着元素所在的数组索引位置也相同,这种情况,就叫哈希冲突。

那么应该怎样解决哈希冲突的呢?

有两种常用的解法:链表法和开放地址法。而HashMap就是使用链表法来解决哈希冲突的,

链表法:当结点数组的索引上没有元素时,新添加的元素将会被添加到数组的指定索引位置上,而当结点数组的索引上已经有元素了,则元素将会以链表的形式存放在数组的这个索引位置上,其中链表的头结点的指针指向数组这个索引位置的地址。

线程不安全的问题

官方介绍文档上已经明确说过了,HashMap是线程不安全的,那么为啥会线程不安全?

首先是JDK1.7的HashMap上,在多线程环境下操作HashMap可能引起死循环。

原因是在HashMap扩容时,链表转移后,前后链表顺序倒置(尾插法导致),在转移过程中修改了原来链表中节点的引用关系,导致链表结点互相引用,即形成了环,这种情况下,当我们使用get曹操获取到环形链表处的数据,就会发生死循环。

JDK1.8中,同样的前提下并不会引起这个死循环,原因是扩容转移后前后链表顺序不变,保持了之前节点的引用关系。

但是即使1.8不会出现死循环,但是由于put、get方法都没有加同步锁,多线程操作仍是不安全的。

例如,我们无法保证上一秒put的值,下一秒get的时候还是原值,这就是数据不一致的问题,所以线程安全是无法保证。

那么,死循环问题是如何产生的呢?虽然平时的开发场景我们几乎不能遇见,因为我们一般都不会在单线程环境下使用HashMap,但这并不影响我们理解其中的原理。

我之前写过一篇文章就对其产生过程进行了详细分析:

【图文并茂】讲解HashMap引发的死循环

感兴趣的大佬可以去观摩观摩,并且如果发现本人有理解不对的地方,感谢帮我及时指出~

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

一场雪ycx

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值