Java 容器(Map)

本篇文章主要讲解Map下的实现类,重点讲解HashMap,ConcurrentHashMap。容器中其它文章看下面连接:

Collection下List,Set,Queue讲解

红黑树详解

容器相关问题

1 Map

1.1 hashMap源码

javaguide-hashMap讲述

红黑树详解

数组+链表+红黑树

1.1.1 类的常量

// 序列号
private static final long serialVersionUID = 362498820763181265L;
/**	缺省的初始数组长度
     * The default initial capacity - MUST be a power of two.
     */
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

/** HashMap中数组的最大长度
     * 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.
     */
static final int MAXIMUM_CAPACITY = 1 << 30;

/**	缺省的负载因子,作用:
     * The load factor used when none specified in constructor.
     */
static final float DEFAULT_LOAD_FACTOR = 0.75f;

/** 链表树化的阈值,超过该值并且数组长度大于或等于MIN_TREEIFY_CAPACITY(64)后链表转为红黑树
     * 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.
     */
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.
     */
static final int UNTREEIFY_THRESHOLD = 6;

/**	链表结构转化为红黑树对应的table的最小值,要大于等于该值
     * 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.
     */
static final int MIN_TREEIFY_CAPACITY = 64;

1.1.2 类的字段

/** 存储元素的数组,总是2的幂次倍
     * The table, initialized on first use, and resized as
     * necessary. When allocated, length is always a power of two.
     * (We also tolerate length zero in some operations to allow
     * bootstrapping mechanics that are currently not needed.)
     */
transient Node<K,V>[] table;

/** 存放具体元素的集
     * Holds cached entrySet(). Note that AbstractMap fields are used
     * for keySet() and values().
     */
transient Set<Map.Entry<K,V>> entrySet;

/** 存放元素的个数,注意这个不等于数组的长度。
     * The number of key-value mappings contained in this map.
     */
transient int size;

/**	每次扩容和更改map结构的计数器
     * The number of times this HashMap has been structurally modified
     * Structural modifications are those that change the number of mappings in
     * the HashMap or otherwise modify its internal structure (e.g.,
     * rehash).  This field is used to make iterators on Collection-views of
     * the HashMap fail-fast.  (See ConcurrentModificationException).
     */
transient int modCount;

/**	临界值 超过临界值时,会进行扩容
     * The next size value at which to resize (capacity * load factor).
     *
     * @serial
     */
// (The javadoc description is true upon serialization.
// Additionally, if the table array has not been allocated, this
// field holds the initial array capacity, or zero signifying
// DEFAULT_INITIAL_CAPACITY.)
int threshold;

/**	负载因子
     * The load factor for the hash table.
     *
     * @serial
     */
final float loadFactor;

1.1.3 Node

static class Node<K,V> implements Map.Entry<K,V> {
   
    //key经过hash计算的值
    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.hashCode(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;
    }
}

1.1.4 构造方法

hashMap构造方法采用的都是懒加载。

/**
     * Constructs an empty <tt>HashMap</tt> with the specified initial
     * capacity and load factor.
     *
     * @param  initialCapacity the initial capacity
     * @param  loadFactor      the load factor
     * @throws IllegalArgumentException if the initial capacity is negative
     *         or the load factor is nonpositive
     */
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;
    //tableSizeFor 因为数组的长度必须是2的n次幂,该方法返回2的n次幂。
    this.threshold = tableSizeFor(initialCapacity);
}

/**
     * Constructs an empty <tt>HashMap</tt> with the specified initial
     * capacity and the default load factor (0.75).
     *
     * @param  initialCapacity the initial capacity.
     * @throws IllegalArgumentException if the initial capacity is negative.
     */
public HashMap(int initialCapacity) {
   
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

/**
     * Constructs an empty <tt>HashMap</tt> with the default initial capacity
     * (16) and the default load factor (0.75).
     */
public HashMap() {
   
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

/**
     * Constructs a new <tt>HashMap</tt> with the same mappings as the
     * specified <tt>Map</tt>.  The <tt>HashMap</tt> is created with
     * default load factor (0.75) and an initial capacity sufficient to
     * hold the mappings in the specified <tt>Map</tt>.
     *
     * @param   m the map whose mappings are to be placed in this map
     * @throws  NullPointerException if the specified map is null
     */
public HashMap(Map<? extends K, ? extends V> m) {
   
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    putMapEntries(m, false);
}

1.1.5 put方法

hashMap 在第一次put值的时候才会初始化,并不是在构造函数中初始化,所以put操作中首先会判断是否初始化过,如果没有就会执行resize()方法

插入值分为4种情况:

  1. 所在桶位置头节点是null,那么就创建一个Node 放入该位置
  2. 所在桶位置头节点不是null,判断key值是否和插入的key值是否相等,如果相等,替换value值(有一个参数决定)即可
  3. 所在桶位置头节点不是null并且是树节点,执行树的插入流程,首先找到树的根节点,然后遍历树,在遍历过程中,如果出现key相同的情况,那么直接返回该节点,后序步骤会替换value值(有参数决定),如果遍历到叶子节点,那么就创建一个新节点插入进去,然后进行树的自平衡操作。最后有一个方法保证根节点是该桶位置的头节点。
  4. 所在桶位置头节点不是null并且是链表节点,那么遍历链表节点,检查key值是否相同,相同就替换value值(有一个参数决定),如果没有找到,那么就插入到链表的结尾处,然后检查链表的长度,超过8并且数组长度达到64就会树化。判断树化流程:首先判断table数组的长度是否达到了树化阈值(默认64),如果没有达到,那么进行扩容操作。如果达到了,那么将这个桶内的链表(Node),转化为链表(TreeNode) 。然后 执行treeify方法 进行树化。树化流程:将链表(TreeNode) 树化,并且在每一次节点插入树中时执行balanceInsertion方法自平衡**,最后执行**moveRootToFront方法保证根节点是桶位置的第一个节点即头节点,
public V put(K key, V value) {
   
    return putVal(hash(key), key, value, false, true);
}
/**
     * Implements Map.put and related methods
     *
     * @param hash hash for key
     * @param key the key
     * @param value the value to put
     * @param onlyIfAbsent if true, don't change existing value
     *                     如果是true 表示如果key存在就直接返回
     *                     如果是false 表示如果key存在就直接吧value值替换掉原来的
     * @param evict if false, the table is in creation mode.
     * @return previous value, or null if none
     */
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
   
    // tab: 当前的节点数组
    // p: 当前的key的索引位置上的节点 tab[i = (n - 1) & hash]
    // n: 当前节点数组的长度
    // i: 当前key的索引位置
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    //判断节点数组是否为空,或者节点数组长度为0 如果是那么进行resize()
    // 从这里可以看出HashMap在第一次put时,才进行初始化
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    //判断key所在位置的节点是否为空,如果是,那么直接创建一个新节点放到这个位置
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
   
        //此时说明key的位置上的节点不为null,那么开始操作链表
        // e : 当前节点
        // k : 当前节点的 key值
        Node<K,V> e; K k;
        // 当前p是key的位置上的第一个节点,如果该节点和key相同,
        // 那么就可以直接将value替换掉该节点的value值(代码在下面)
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        else if (p instanceof TreeNode) //如果头节点的key和要放入的key不相同,那么判断头节点是否是树节点
            //是树节点,就进行树的操作
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
    // 这里说明头节点的key和放入的key不同并且头节点是链表节点
            //遍历操作,直到链表的最后一个节点或者找到与放入的key相同的节点停止遍历
            for (int binCount = 0; ; ++binCount) {
   
                //e等于最后一个节点,说明不存在与key相同的节点,那么直接放到链表的最后
                if ((e = p.next) == null) {
   
                    p.next = newNode(hash, key, value, null);
                    //判断 节点数量是否超过TREEIFY_THRESHOLD,树化的条件是否达到
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        //进一步判断数组长度是否达到条件,达到:进行树化,未达到:扩容
                        treeifyBin(tab, hash);
                    break;
                }
                //遍历到和key相同的节点,将该节点赋值给e
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        //e != null说明链表中存在与key相同的节点
        if (e != null) {
    // existing mapping for key
            //重复key的节点的value
            V oldValue = e.value;
            //如果onlyIfAbsent为false,那么就修改重复key的节点的value值。旧值为null直接修改为value
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            //访问后回调
            afterNodeAccess(e);
            return oldValue;
        }
    }
    //结构性修改
    ++modCount;// 修改一个节点的value值不会+1
    //实际大小,大于阈值 进行扩容
    if (++size > threshold)
        resize();
    //插入后回调
    afterNodeInsertion(evict);
    return null;

1.1.6 resize方法

首先计算capacity,thershold的值,然后创建一个新的散列表数组(长度为旧数组长度<<1),最后数据迁移。

数据迁移分为3种情况:

  1. 迁移桶位置只有一个节点,直接计算在新数组中的位置放入。
  1. 将桶位置的链表分为两个链表:1.低位链表,2.高位链表。最后直接将链表放入对应位置。原数组中一个桶位置里的链表的节点,数据迁移到新的数组中,只会进入新数组两个桶位置之一,这两个桶分别是与原数组相同桶号的位置,原数组桶号+原数组长度。
  2. 迁移桶位置是一个树结构,树结构中其实还维护了一个链表结构,所以迁移数据和链表迁移是相同的,不同的是,在得到低位高位链表之后的时候会检查这两个链表节点的数量,如果<=6 那么就转化为链表结构(Node),否则还是转化为树结构,放入对应桶位置。
final Node<K,V>[] resize() {
   
    // oldTab:旧的散列表数组,扩容之前散列表数组
    Node<K,V>[] oldTab = table;
    // oldCap: 旧的散列表的容量,扩容之前散列表容量
    //oldTab == null 时 说明还没有初始化
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    // oldThr: 旧的用于判断扩容的阈值,扩容之前散列表数组扩容的条件
    int oldThr = threshold;
    // newCap:新的散列表容量,扩容之后散列表容量
    // newThr: 新的阈值,扩容之后散列表数组再次扩容的条件
    int newCap, newThr = 0;
    // oldCap > 0 说明 说明散列表中已经有节点了,是一次正常的到达条件进行扩容
    if (oldCap > 0) {
   
        // 判断旧的散列表的容量是否大于最大容量,如果是就不在进行扩容
        if (oldCap >= MAXIMUM_CAPACITY) {
   
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 新的散列表容量 = 旧的散列表容量*2 并且小于最大容量,并且 旧的容量大于最小的容量
        // 为什么要判断旧的容量大于最小容量?因为HashMap的构造函数可以初始化容量。
        // 满足条件-> 新的阈值 = 旧的阈值 * 2;
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    // oldCap = 0  oldThr > 0; 什么时候会出现这种情况呢? 可以看HashMap的构造函数
    // 1, new HashMap<>(int initialCapacity); 这个构造方法调用的下面的构造方法。
    // 2, new HashMap<>(int initialCapacity, float loadFactor); 在这个构造方法中 threshold = 旧散列表容量(是2的n次幂)
    // 3, new HashMap<>(Map<? extends K, ? extends V> m); Map是有数据的,同上面一样,threshold = 旧散列表容量(是2的n次幂)
    // 是使用上面三种构造方法创建的HashMap 就会 进入条件,新的容量就等于旧的阈值
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;

    // oldCap = 0, oldThr = 0; //什么时候会出现这种情况呢?
    // new HashMap<>() 不传参数的构造方法;
    // 那么新的容量就等于默认值(16) 新的阈值就根据默认值计算出来
    else {
                  // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // 新的阈值等于0,说明新的阈值没有赋值,那么就根据现在新的容量赋值
    if (newThr == 0) {
   
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    // 新的阈值赋值给 threshold
    threshold = newThr;
    @SuppressWarnings({
   "rawtypes","unchecked"})
    // 创建一个新的散列表数组,并赋值给当前table
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    // 下面要进行将旧的散列表数组中的值,重新放到新的散列表数组中,如果旧的数组=null,那么就没必要执行了,直接返回新的散列表
    if (oldTab != null) {
   
        //遍历旧的散列表,进行节点迁移工作
        for (int j = 0; j < oldCap; ++j) {
   
            // e: 当前节点
            Node<K,V> e;
            //当前节点不等于null,才会进行迁移工作
            if ((e = oldTab[j]) != null) {
   
                //将旧的散列表中当前位置=null,因为当前节点要放入新的散列表数组,方便JVM垃圾回收
                oldTab[j] = null;
                //如果当前节点的下一个节点=null,说明当前桶位置只有一个节点
                if (e.next == null)
                    //直接计算当前节点在新的散列表中的位置,直接放入当前节点
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode) //当前节点不等于null,是树节点
                    // 执行树节点的操作
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else {
    // preserve order // 说明当前桶位置是一个链表结构
                    // 将该桶位置的链表的节点,放入新散列表数组中,只会有两种情况:
                    // 1, 该节点放入新散列表数组中的桶位置 = 该节点旧散列表数组中的桶位置
                    // 2, 该节点放入新散列表数组中的桶位置 = 该节点旧散列表数组中的桶位置 + 旧散列表数组长度
                    // 为什么呢? 例如:
                    // 旧数组容量:16,新数组容量:32,那么在旧数组桶位置为 15 的节点 应该放入新数组那个桶内
                    // 在旧数组桶位置为 15 的节点 说明 该节点的 hash值为  ....01111 或 ....11111
                    // 为什么呢 因为 15 = hash & 1111(16 - 1) 计算而来
                    // 那么 在新的散列表数组中 桶位置 =  hash & 11111(32 - 1) = 15 或 31(15 + 16)

                    //综上:在一个桶位置的节点,放入到新的散列表中,只会有两个桶位置1,原索引位置2,原索引+原容量位置。所以分为以下两种链表


                    // 低位链表:存放在扩容之后的数组的下标位置与旧数组的下标位置一致
                    Node<K,V> loHead = null, loTail = null;
                    // 高位链表:存放在扩容之后的数组的下标位置为 旧数组下标位置 + 旧数组的长度
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
   
                        next = e.next;
                        // 这个条件 就是判断 当前节点应该放入低位链表还是高位链表
                        // 还是上面的例子,e.hash = ....01111 || ....11111 oldCap = 10000
                        // 如果是....01111 那么 ....01111 & 11111(32 - 1) = 15; 放入低位链表
                        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.1.7 get方法

public V get(Object key) {
   
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}
//get 方法实际逻辑执行代码是 getNode()方法,传入 hash(key)和key
final Node<K,V> getNode(int hash, Object key) {
   
    // hash:hash(key)
    // tab: 当前的散列表数组
    // n: 当前散列表数组的容量
    // first: 根据key的hash算法得到的hash值与n-1 计算出的桶位置上的头节点
    // e: 用于遍历链表查找key的变量
    // k: first.Key
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    // 判断 当前散列表!=null && 容量>0 && 计算出的桶位置上的头节点!= null 如果不满足说明没有找到key
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
   
        // 根据key的hash值计算出的桶位置上的节点的key 就和查询的key相同,那么直接返回头节点。
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        // 计算出的头节点不是要查询的key,那么就说明当前桶位置是树,或者链表
        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.1.8 remove方法

删除一个键值对,先查找到之后,在执行删除操作。

//remove有两个方法,这个方法就是说:如果存在key,那么就删除这个key-value对
public V remove(Object key) {
   
    Node<K,V> e;
    return (e = removeNode(hash(key), key, null, false, true)) == null ?
        null : e.value;
}
//如果存在key并且key对应的value与传入的value相同,那么就删除这个key-value对
public boolean remove(Object key, Object value) {
   
        return removeNode(hash(key), key, value, true, true) != null;
    }

/**
     * Implements Map.remove and related methods
     *
     * @param hash hash for key
     * @param key the key
     * @param value the value to match if matchValue, else ignored
     * @param matchValue if true only remove if value is equal
     *                   是一个布尔值,true表示要删除的key-value对不但要key相同也要求value值相同
     * @param movable if false do not move other nodes while removing
     *                针对的是树节点
     * @return the node, or null if none
     */
final Node<K,V> removeNode(int hash, Object key, Object value,
                           boolean matchValue, boolean movable) {
   
    // tab: 当前table数组
    // p: 要删除的key经过计算的桶位置的头节点
    // n: table数组长度
    // index: 要删除key的桶位置的索引
    Node<K,V>[] tab; Node<K,V> p; int n, index;
    // 首先还是判断数组是否存在,长度是否大于0,桶位置是否有节点,如果不满足直接返回null
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (p = tab[index = (n - 1) & hash]) != null) {
   
        // node: 存放与要删除key相同的节点(即满足删除条件的)、
        // e: 遍历链表的临时变量
        // k: 用于和key比较
        // v: 用于和value比较
        Node<K,V> node = null, e; K k; V v;
        //如果桶位置的头节点就是要删除的,那么交给node
        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
                node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
            else {
   
                //该桶位置是链表,那么遍历查找,找到后交给node
                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 != null 说明找到了要与key相同的节点node,后面的判断是如果要求value相同那么value也要相同
        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// 如果是链表中的某一个节点,那么指向node.next
                p.next = node.next;
            //只要执行了删除,那么modCount就+1,size-1;
            ++modCount;
            --size;
            //LinkedHashMap 的后序操作
            afterNodeRemoval(node);
            return node;
        }
    }
    return null;
}

1.1.9 replace方法

/**
     * 
     * @param key 要替换value值的key
     * @param value 要替换的值
     * @return
     */
@Override
public V replace(K key, V value) {
   
    Node<K,V> e;
    //查找key的节点
    if (
  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值