HashMap源码分析

转自https://blog.csdn.net/duoduo18up/article/details/79503463

1.HashMap定义

/*基于Map接口实现,允许null值和null键。 
* HashMap和Hashtable很相似,只是Hashtable是同步的,以及不能为null的键 
* HashMap有两个重要参数,capacity和load factor 默认的load factor大小为0.75 
* iterator是fail-fast的。 
* 
*/
public class HashMap<K, V> extends AbstractMap<K, V> implements Map<K, V>,Cloneable, Serializable {

1.1图解:

HashMap 实现了Map接口,是一个基于散列表的Map类。

Map接口的特性是存储键值对。

散列表是一种存储结构,可通过散列值直接访问到目标数据值。——定位下标O(1)

HashMap继承自AbstractMap类的同时实现了Cloneable,Serializable两个接口

  • Cloneable:为了实现clone()机制
  • Serializable:为了实现序列化机制

HashMap用到了泛型来实现参数化类型(其实,Java中的全部集合框架都用到了泛型

2.HashMap 重要字段

//Hash的默认大小      
       static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//HashMap最大存储容量
       static final int MAXIMUM_CAPACITY = 1 << 30; 
//增长因子,意思就是当table已经用到table.length*0.75时,就需要扩容 
	static final float DEFAULT_LOAD_FACTOR = 0.75f;    
//由链表存储转变为由树存储的门限,最少是8
	static final int TREEIFY_THRESHOLD = 8;    
//由树存储节点转化为树的节点,默认是6,即从8到6时,重新转化为链表存储
	static final int UNTREEIFY_THRESHOLD = 6;
//当由链表转为树时候,此时Hash表的最小容量。 也就是如果没有到64的话,就会进行resize的扩容操作。
// 这个值最小要是TREEIFY_THRESHOLD的4倍。
	static final int MIN_TREEIFY_CAPACITY = 64;

由于在Java8的实现中,当经过hash函数计算得出的下标地址冲突到一定范围时,就会把冲突的数据用链表的形式连起来。

而当链表数据大于一定范围时,就会将链表转化为红黑树存储

所以HashMap是Map+Hash+红黑树

static final float DEFAULT_LOAD_FACTOR=0.75f;这个属性

  • 该属性为装载因子;
  • 本质上就是数据结构中解决hash冲突的填充因子;
  • 默认值是0.75,即如果实际元素所占容量占份额容量的75%时就需要扩容。

3.HashMap 结构概括

3.1 有一个基准数组

// 存储数据的table集合,长度一定为2的倍数
    transient Node<K, V>[] table;  //用来存储数据的数组,每个元素都是Node即链表  

transient:用于让该域在整个类被序列化的时候不包含该内容,即该域不被序列化。

  1. table是一个数组,所以会有下标。HashMap首先会根据传入每个节点的<key,value>中的key,计算出应该方法哪一个下标的数组中;
  2. 如果此下标数组为null?直接插入;否则,转3
  3. 不为null,说明冲突了。检查key的equals(),看是否和原节点的key相同?相同就直接替换,否则转4;
  4. 冲突了,而且是不相等的冲突,此时检查是否需要将此下标的存储结构换为红黑树(判断标准:8个节点)不需要,就在链表末尾直接插入节点,否则转5;
  5. 原有的链表结构不足以支撑存储了,所以换为红黑树存储。此时就是往红黑树中插入该节点。

上述步骤省略了链表和红黑树的转换。

整个存储结构如上图所示(未放红黑树结构,省略了value值)。

4.HashMap 内部实现原理

4.1HashMap 构造器

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

public HashMap(int initialCapacity){
	this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

public HashMap(){
	this.loadFactor = DEFAULT_LOAD_FACTOR;
	// all other fields defaulted
}      

public HashMap(Map<? extends K, ? extends V> m){
	this.loadFactor = DEFAULT_LOAD_FACTOR;
	putMapEntries(m, false);
} 

Java设计者重载了4个HashMap构造器

 

5.Table的容量只能是2的倍数

因为这样有利于下一步计算table的下标

虽然在HashMap中,提供了一个构造函数:public HashMap(int initialCapacity, float loadFactor),看似提供了初始容量的方法,但是这个方法最后一行调用了另一个方法this.threshold = tableSizeFor(initialCapacity);来确定table的容量。

tableSizeFor()保证:函数返回值>=给定参数initialCapacity最小的2的幂次方的数值。

//hashMap大小只能为map的倍数。 最终会返回一个最适合cap的2的倍数*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;
}

解释

  • a|=b     a=a|b  二进制位或
  • n|=n>>>16    n继续无符号右移16位,导致n二进制表示高17~32位经过运算值均为1。目前n的高1~32位均为1

无论给定cap(cap<MAXIMUM_CAPACITY)的值是多少,经过以上运算,其二进制所有位都会是1,再将其加1,这时候这个值一定是2的幂次方。当cap>MAXIMUM_CAPACITY,直接选MAXIMUM_CAPACITY。

所以table最终的length,即后来的n,只能是2的幂次。

6.hash值的计算方法

注意区分hashCode和hash两个方法。

static final int hash(Object key) {  //高低16位进行异或操作  
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);  
} 

hashCode返回的是一个32位的2进制数值

7.table下标计算方法

HashMap中存储数据的table是由key的hash值决定的。

在HashMap存放数据的时候,期望数据能够均匀分布,避免哈希冲突。————用取余%操作来实现

tab[index] =tab[(n - 1) & e.hash]     //即等同于index=e.hash % n; 

其中hash=hash(key),n=table.length.

由于n是2的幂次方,那么n-1的高位应该全部为0。

如果e.hash值只用自身的hashcode的话,那么index只会和e.hash低位做&操作。这样一来,index就只有低位参与运算,高位毫无存在感。从来会带来哈希冲突的发展。

所以在计算key的哈希值的时候,用其自身的hashcode值与其低位16位做异或操作。就让高位参与到index的计算中可,即降低了哈希冲突的风险又不会带来太大的性能问题。

最终会截取到hash的后log(n)-1位,会得到一个范围在0~table.length的值。这个值就是数组的下标。

由于hash是由key的hashCode的高16位与低16位经过异或而得,混合了原始哈希码的高低位,大大提升了随机性,也让碰撞的几率大大降低。

综上,也可得到为什么table的荣来那个只能是2的倍数。

n为2的整数幂保证了n-1最后一位(二进制表示)为1,从而保证了取索引操作h&(n-1)的最后一位同时有为0和为1的可能性,保证了散列的均匀性。

反过来讲,当hash表长度n为奇数时,n-1最后为0,这样与h按位与的最后一位肯定为0,即索引位置肯定是偶数,这样数组的技术位置全部没有防止元素,浪费了大量空间。

总之:n为2的幂次保证了按位与最后一位的有效性,使哈希表散列更均匀

7.1 putmapEntries()

final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict){
    int s = m.size();
    if (s > 0){
        if (table == null){
            // pre-size
            float ft = ((float)s / loadFactor) + 1.0F;
            int t = ((ft < (float)MAXIMUM_CAPACITY)?(int)ft : MAXIMUM_CAPACITY);
            if (t > threshold)
            threshold = tableSizeFor(t);//确定table容量
        }
        else if (s > threshold)
            resize();       //当容量超过阈值时候需要扩容
        for (Map.Entry<? extends K, ? extends V> e : m.entrySet()){
            K key = e.getKey();
            V value = e.getValue();
            putVal(hash(key), key, value, false, evict);//建表存链表节点
        }
    }    
}

首先获得传入的map实例的大小s,然后存在一个将大小s与临界值比较的过程:

      如果map实例>threshold,则调用resize()方法,即扩容。

7.2 resize()源码

/**
     * 初始化使用,
     * 或者将hashmap大小调整为2的倍数级使用。
     */
    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) {
            // 如果当前size大于最大容量,则下一次就是int的最大值
            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)
                        // 当这个位置没有东西时候,就直接取模放在这里。,重新计算hash值以便。
                        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;
    }

即扩容机制包含了两部分:

  • HahMap中table数组的容量的扩充;
  • 成员属性threshold(即扩容的临界值)的更改。

注意:加载因子默认0.75

7.3为什么需要使用加载因子,为什么需要扩容?

因为,如果填充比很大,说明利用的空间很多,如果一直不进行扩充,链表就会越来越长,使得查找效率很低(新版本使用红黑树会改进很多)。

扩容之后,将原来链表数组中的每一个链表分成奇偶两个自联表分别挂在新数组的散列位置,这样就减少了每个链表的长度,提高查找效率。

HashMap本来是以空间换时间,所以填充比没必要太大。但是填充比太小又会浪费空间。

如果关注内存,填充比可以稍大;如果需要关注查找性能,填充比可以稍小。

7.3.1HashMap扩容机制

构造hash表时,如果不指明初始大小,默认大小为16(即Node数组大小为16),如果Node数组中的元素达到(填充比*table.length),重新调整HashMap大小,变为原来的2倍大小,扩容很耗时

所涉及的数据结构:链表Node和红黑树TreeNode,这两个数据结构为HashMap中的内部类

7.3.2链表Node源码如下:

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    V value;
    Node<K,V> next;
    Node(int hash, K key, V value, Node<K,V> next){ 
        this.hash = hash;
        this.key = key;
        this.value = value;
        this.next = next;
    }
    public final K getKey(){
        return key;
    }
    public final V getValue(){
        return value;
    }
    public final String toString() {
        return key + "=" + value;
    }
    public final int hashCode() {
        return Objects.hashCode(key) ^ Objects.hashCod(value);
    }
    public final V setValue(V newValue) {
        V oldValue = value;
        value = newValue;
        return oldValue;
    }
    public final boolean equals(Object o) {
        if (o == this)
            return true;
        if (o instanceof Map.Entry) {
            Map.Entry<?,?> e = (Map.Entry<?,?>)o;
            if (Objects.equals(key, e.getKey()) &&  Objects.equals(value,e.getValue()))
                return true;
        }
        return false;
    }
}

该链表Node是一个单向链表,(因为只存在一个Node<K,V> next属性)。

它实现了Map.Entry<K,V>接口。

7.3.3红黑树TreeNode源码:

static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V>{
    TreeNode<K,V> parent;  // red-black tree links
    TreeNode<K,V> left;
    TreeNode<K,V> right;
    TreeNode<K,V> prev;    // needed to unlink next upon deletion
    boolean red;         //表示颜色的属性
    //红黑树是一种自平衡二叉查找树,用red与black来标识某个节点,它可以在O(logn)内进行查找,插入与删除
    TreeNode(int hash, K key, V val, Node<K,V> next) {
        super(hash, key, val, next);
    } 
} 

TreeNode实现了LinkedHashMap.Entry<K,V>接口。

8.HashMap底层实现原理:

HashMap采用数组Node<K,V>[ ] table来存储<K,V>的。

  • 数组中的每个元素是Node类型(可能会将Node类型转换为TreeNode类型)

通常称这种方式为位桶+链表/红黑树

  • 当某个位桶的链表的长度达到TREEIFY_THRESHOLD临界值时,此链表就将转换为红黑树。

本质上是一个hash表,用来解决冲突的(这一点将在HashMap中的put<K,V>()方法中看到)。

图示如下:

在JDK8中,HashMap处理“碰撞”增加了红黑树这种数据结构。

  • 当碰撞节点较少时,采用链表存储;
  • 当较大(>8)时,采用红黑树存储,特点是:查询时间是O(logn)
  • 有一个阈值控制,大于阈值(8)时,将立案表存储转换为红黑树存储。

9.HashMap实现了Map接口

  • Map接口设置一系列操作Map集合的方法,如put、get、remove等,
  • HashMap针对这些方法有其自身对应的实现。

9.1 put(K,V)源码

在构造函数中最多也只是设置了initialCapacity、loadFactor的值,并没有初始化table。

table的初始化工作实在put()方法中进行的。

public V put(K key, V value) {  
    return putVal(hash(key), key, value, false, true);  
} 
/**
* 插入值, onlyIfAbsent,为真的话,就是不替换,无就插,有就不插 Implements Map.put and related
* methods evict,表示需要调整二叉树结构,LinkedHashMap中需要
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    Node<K, V>[] tab;   //存放table
    Node<K, V> p;      //存放以前存放在table[(n-1)&hash]的节点,如果有
	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))))
			// 一模一样,连key也equals后相等时
			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;
				//其中,如果key的equals也相等,就直接替换
				p = e;
			}
		}
		// 替换操作,key一样,旧值换为新值
		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();
	//LinkedHashMap使用
	afterNodeInsertion(evict);
	return null;
} 

9.1.1 put()方法主要包括两大部分:

  1. 根据传入的key计算hash值,得到插入的数组索引i;
    1. 如果tab[i]==null,则此下标处无元素存在,可直接添加元素,否则出现冲突。
    2. 使用链表或者红黑树来解决冲突,可参考上面的图理解。
  2. 如果出现冲突,则扫描链表或者红黑树。
    1. 在此过程中,可以使用equals()方法来确定是否存在该元素;
    2. 如果存在,则直接更新;
    3. 否则,采用链表或红黑树的方式将元素添加在tab[i]对应的链表或红黑树中。可参考上图理解。

9.1.2 通过hash值判断元素是否存在,

  • 如果hash值不存在(tab[i]==null),则一定不存在该元素;
  • 如果hash值存在,则可能存在该key元素,需通过equals()方法来确定。
  • 如果hash值存在且key.equlas(k)则表明存在该key元素,直接更新其值;
  • 否则则表明不存在,采用链表或红黑树的方式将元素插入到tab[i]对应的链表或红黑树中。

注意:如果key=null,那么hash(key)=0,tab[0]位置存在,且初始时,存放的是null值(有判断是否存放null值的步骤)。

9.2 V get(Object K)源码

public V get(Object key) {  
	Node<K,V> e;  
	return (e = getNode(hash(key), key)) == null ? null : e.value;   //null值返回
}  
/**
* 根据key返回值。 也就是先算hash,在找到其位置,在看是否有因冲突而产生的链表或者二叉树。
*/
final Node<K, V> getNode(int hash, Object key) {
    Node<K, V>[] tab;   //指向table,这样如果对table加锁,自己还是能够只读的
    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 && // 总是检查是否为头节点。
        		((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;
}

get某个元素与puy某个元素是一一对应的关系:

  • 先通过key得到对应的hash值
  • 然后通过该hash值与table长度n-1相与得到数组下表的索引first
  • 然后先判断传入的hash值是否与数组索引first节点对应的hash值相等
    • 如果是,则直接返回该数组元素first
    • 否则,通过first.next不断查找该数组元素所对应的链表/红黑树中是否存在hash与key均和传入的hash与key相等的节点
  • 如果存在,则代表在HashMap结合中找到了该元素,返回其对应的value。

9.3 remove(Object key)

  /**
     * 根据key,删掉这个节点。
     */
public V remove(Object key) {
    Node<K, V> e;
    return (e = removeNode(hash(key), key, null, false, true)) == null ? null
            : e.value;
}
/**
 * 删除某一个节点。
 * @param matchValue
 *            如果为真,那么只有当value也想等时,才能删除。
 * @param movable 能否删除

 */
final Node<K, V> removeNode(int hash,Object key,Object value,boolean matchValue, oolean movable) {
    Node<K, V>[] tab;
    Node<K, V> p;
    int n, index;
    if ((tab=table)!=null&&(n=tab.length)>0&&(p=tab[index=(n-1)&hash])!=null) {
        //寻找node节点过程
        Node<K, V> node = null, e;
        K k;
        V v;
        if (p.hash==hash&&((k=p.key)==key||(key!=null&&key.equals(k))))
            node = p;
        else if ((e = p.next) != null) {
            if (p instanceof TreeNode)
                node = ((TreeNode<K, V>) p).getTreeNode(hash, key);
            else {
                do {
                    if (e.hash==hash&&((k=e.key)==key||(key!=null&& key.equals(k)))) {
                        node = e;
                        break;
                    }
                    p = e;
                } while ((e = e.next) != null);
            }
        }
        //node节点就是已经找到的,符合条件的要删除的节点。
        if (node!=null&&(!matchValue||(v=node.value)==value||(value!=null&&value.equals(v)))) {
            if (node instanceof TreeNode)
                ((TreeNode<K, V>) node).removeTreeNode(this, tab, movable);
            else if (node == p)
                tab[index] = node.next;
            else
                p.next = node.next;
            ++modCount;
            --size;
            afterNodeRemoval(node);
            return node;
        }
    }
    return null;
}

10.总结

10.1 HashMap内部是基于Hash表实现的

Hash表为Node类型的数组+链表/红黑树

其中,链表与红黑树是用来解决冲突的。即,当往HashMap中put某个元素时,相同的hash值的两个元素会被放到数组中的同一个位置上形成的链表/红黑树中。

10.2 HashMap存在扩容机制

通过resize()方法实现的。

即,当HashMap中的元素个数超过数组大小*loadFactor时,就会进行数组扩充。

loadFactor默认为0.75,数组大小*loadFactor=threshold(扩容的临界值).

默认情况下,数组大小为16,那么当HashMap中的元素个数超过16*0.75=12时,就把数组大小扩充为16*2=32.即扩大一倍。

10.3 HashMap不是线程安全的

从源码可以看出,HashMap中的Key与Value都允许为空

同时HashMap的get()和put()均无synchronized关键字修饰,即 HashMap不是线程安全的。

10.4 HashMap中的元素是唯一的

即,同一个Key只存在唯一value与之对应。

因为在put()过程中,如果可能出现相同的元素(K同,V不同),则原来的V将会被新的V替换。

11.问题分析

11.1 Hash碰撞会对HashMap的性能带来灾难性的影响。

  • 如果多个hashCode()的值落在同一个桶内的时候,这些值是存储到一个链表中的。
  • 最坏的情况下,这样的HashMap就退化成了一个链表———查找时间:由O(1)-->O(n).

11.2 随着HashMap的大小的增长,get()方法的开销也越来越大。

  • 由于所有的记录都在同一个桶里,平均查找一条记录的时间就需要遍历一半的列表。

11.3 JDK1.8 HashMap中的红黑树是这样解决的

如果某个桶中的记录数过大时(>8),HashMap就会动态地使用一个专门的treemap实现来替换掉它。

这样做,结果会是O(logn),而不是O(n)

工作原理

前面产生冲突的那些key对应的记录只是简单地追加到链表的表尾,这些记录只能通过遍历查找。

但是超过这个阈值后,HashMap开始将链表升级为一个二叉树。

使用哈希值作为数的分支变量。

  • 如果两个哈希值不等,但指向同一个桶的话,较大的那个会插入到右子树中。
  • 如果哈希值相等,HashMap希望key值最好是实现了Comparable接口的,这样就可以按照顺序来插入。
  • 这对HashMap的key来说挺不是必须的。不过如果实现了当然最好。
  • 如果没有实现这个接口,再出现严重的哈希碰撞时,就无法期待性能的提升了。

 

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值