Java源码解读系列1—HashMap(JDK1.7 )

1 HashMap的默认构造方法

HashMap底层采用数组+链表数据结构进行存储。每次新增一个数据,就会被插入到数组中的空位。慢慢的数据多了,就会存在哈希碰撞问题,即2个key的索引一样。HashMap采用的解决方法是链地址法。数组中每个元素设置链表。key映射到数组某个元素中,而数据项本身插入到元素的链表中。

//数组的默认容量为16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 


 // 默认负载因子为0.75,建议不要设置超过 0.75,因为会显著增加冲突,降低     HashMap的性能。;过低也不行,会导致频繁扩容,导致HashMap性能降低。
static final float DEFAULT_LOAD_FACTOR = 0.75f;
   
 //生成一个Entry<K,V>类型的空数组
static final Entry<?,?>[] EMPTY_TABLE = {};
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;


//无参构造函数
public HashMap() {
     //调用有参构造函数
    this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
    

//有参构造函数
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);
    //负载因为默认为0.75
    this.loadFactor = loadFactor;

    //初始化时,扩容阀值为threshold为16
    threshold = initialCapacity;

    //空方法
    init();
}

2 新增key-value数据

2.1 put()方法

新增key-value值调用put()方法,里面是调用到很多其他方法,下面我们一一展开

         //判断是否为空的数组
         if (table == EMPTY_TABLE) {
           //首次扩容数组
            inflateTable(threshold);
        }
        
       // 若“key为null”,则将该键值对添加到数组的第0个元素中
        if (key == null)
            return putForNullKey(value);
         //求key的哈希值
        int hash = hash(key);
        //获取待插入元素在数组中的下表
        int i = indexFor(hash, table.length);
        //待插入位置table[i]不为空,则查找是否有相同key,若有则替换alue
        ,并将老的value返回
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                //空方法
                e.recordAccess(this);
                return oldValue;
            }
        }
        
        //修改次数加1
        modCount++;
        
        //“待插入位置table[i]为空” 或者 “待插入位置table[i]不为空但链表没有找到相同key的节点
        //新的key-value插入到数组中
        addEntry(hash, key, value, i);
        
        //返回空值
        return null;
    }

2.2 数组首次扩容

private void inflateTable(int toSize) {
    //获取一个数值=2^n >= toSize
    int capacity = roundUpToPowerOf2(toSize);
    
    //扩容阀值一般是容量 * 负载因子,首次添加数据时,阀值为16 * 0.75 = 12
    threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
   
   //生成一个容量为16的Entry数组
   table = new Entry[capacity];
    initHashSeedAsNeeded(capacity);
}


private static int roundUpToPowerOf2(int number) {
    return number >= MAXIMUM_CAPACITY
            ? MAXIMUM_CAPACITY
            : (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
}

  public static int highestOneBit(int i) {
    i |= (i >>  1);
    i |= (i >>  2);
    i |= (i >>  4);
    i |= (i >>  8);
    i |= (i >> 16);
    return i - (i >>> 1);
}

2.2.1 highestOneBit()方法

highestOneBit(int i)是为了获取不大于i的max(2 ^n)。其中>>n表示向右移动n位,相当于除以2^n。其中,
>>>n表示向右无符号移动n位,左边移动的空位都补0,相当于除以2^n,i |= (i >> n)等价于i = i | (i >> n)‘|’是按位或操作:如果两个操作数对应的位都是0才为0,否则为1。

int i = 1073741824;
System.out.println(i + " || " + Integer.toBinaryString(i));

 i |= (i >>  1);
 System.out.println(i + " || " +l Integer.toBinaryString(i));

 i |= (i >>  2);
 System.out.println(i + " || " + Integer.toBinaryString(i));

i |= (i >>  4);
System.out.println(i + " || " + Integer.toBinaryString(i));

 i |= (i >>  8);
 System.out.println(i + " || " + Integer.toBinaryString(i));

i |= (i >> 16);
System.out.println(i + " || " + Integer.toBinaryString(i));
        
运算结果:
1073741824 || 1000000000000000000000000000000
1610612736 || 1100000000000000000000000000000
2013265920 || 1111000000000000000000000000000
2139095040 || 1111111100000000000000000000000
2147450880 || 1111 1111 1111 1111 000000000000000
2147483647 || 1111111111111111111111111111111

执行这5次按位或运算是为了找到和i的二进制最高位一样,且其他位数位1的二进制数。

2.2.2 roundUpToPowerOf2()方法

roundUpToPowerOf2(int number) 获取不小于number的最小2^n
如果去掉(number - 1) << 1-1,则获得的值2^n > number,加上1则有可能获得2^n = number,即最小的不小于number的2^n值。

int i = 8;
System.out.println(Integer.highestOneBit(i << 1));
System.out.println(Integer.highestOneBit((i - 1) << 1));

运行结果:
16
8

2.2 空key

HahsMap的key是可以为空的,key为null则放在数组的第一个元素中,而HashTable是不允许的,否则会抛出NullPointerException。

  private V putForNullKey(V value) {
  //判断链表中是否存在空key,若有则替换value,并将balue返回
        for (Entry<K,V> e = table[0]; e != null; e = e.next) {
            if (e.key == null) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        modCount++;
        addEntry(0, null, value, 0);
        return null;
    }

2.3 重新hashcode()方法

重写 hashcode()方法是为了得更散列的哈希值。用了很多次的右移动运算,是为了让高位也参与预算。另外两个数之间的运算采用^。这是因为|&操作的结果分别趋于10,会造成数据不均问题。

    final int hash(Object k) {
        //哈希种子,默认为0
        int h = hashSeed;
        if (0 != h && k instanceof String) {
            return sun.misc.Hashing.stringHash32((String) k);
        }

        h ^= k.hashCode();
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

2.4 插入数组的索引

static int indexFor(int h, int length) {
    return h & (length-1);
}

我们第一想法是通过求余操作,但是HashMap的实现是通过&操作的方法,这种方法的效率会比较高。

length - 1代表索引的范围是[0, length-1].这里要引伸出一个点,即数组的容量capacity要设计成2^n。如果是非2^n,如下面的9capacity2 - 1后为1000,则&运算后,只能得到10000000这两个索引值,会造成数据分布不均。

int capacity = 8;
System.out.println(Integer.toBinaryString(capacity));
System.out.println(Integer.toBinaryString(capacity - 1));
运行结果:
1000
  111

int capacity2 = 9;
System.out.println(Integer.toBinaryString(capacity2));
System.out.println(Integer.toBinaryString(capacity2 - 1));
运行结果:
1001
1000

2.5 将key-value添加到Entry中

put插入数据,将KV放到Entry中,如果待插入数组的bucketIndex位置不为空,则采用头插法,新插入的数据的指针指向数组中旧的数据,形成链表,同时将待插入数据放到数组bucketIndex位置中。

key为null则放在数组的第一个元素中,而value为null则封装到节点Entry的value中。
正是这两点,使得HashMap的key-vaule都可以为空

  void addEntry(int hash, K key, V value, int bucketIndex) {
       // HahsMap中已添加元素的总数(size)超过扩容阀值且待插入索引不为空,则进行扩容
        if ((size >= threshold) && (null != table[bucketIndex])) {
           //扩容为旧的数组长度的2倍
            resize(2 * table.length);
            //判断key是否为空
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }

        createEntry(hash, key, value, bucketIndex);
    }

 void createEntry(int hash, K key, V value, int bucketIndex) {
        Entry<K,V> e = table[bucketIndex];
        table[bucketIndex] = new Entry<>(hash, key, value, e);
        size++;
    }
    
    //Entry是单链表中的一个节点
static class Entry<K,V> implements Map.Entry<K,V> {
        final K key;
        V value;
          //指向链表的下一个节点
        Entry<K,V> next;
        int hash;

        Entry(int h, K k, V v, Entry<K,V> n) {
            value = v;
            next = n;
            key = k;
            hash = h;
        }
        
        ...
 }

2.6 数组再次扩容

扩容是调用resize方法,扩容后的数组为旧容量的2倍。resize方法首先判断旧的容量大小是否等于MAXIMUM_CAPACITY(2^30) ,如果是则直接赋予int类型得最大值2^31 -1

     
    void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        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);
}

扩容后,旧的数据被全量复制到新的数组中。如果数据量大,而你的初始容量小,意味着程序需要不断扩容,复制,大大降低了程序性能。

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

2.7 哈希种子

空数组首次扩容中提到如何修改哈希种子。hashSeed默认为0,只有switching为tue才会发生改变。

final boolean initHashSeedAsNeeded(int capacity) {
    boolean currentAltHashing = hashSeed != 0;
    boolean useAltHashing = sun.misc.VM.isBooted() &&
            (capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
    boolean switching = currentAltHashing ^ useAltHashing;
    if (switching) {
        hashSeed = useAltHashing
            ? sun.misc.Hashing.randomHashSeed(this)
            : 0;
    }
    return switching;
}

跟进入Holder.ALTERNATIVE_HASHING_THRESHOLD,可以发现ALTERNATIVE_HASHING_THRESHOLD_DEFAULT的默认值是2^31 - 1,除非用户修改
系统变量jdk.map.althashing.threshold的值,否则数组的长度基本上是会比ALTERNATIVE_HASHING_THRESHOLD_DEFAUL小,哈希种子是不会改变的。


  static final int ALTERNATIVE_HASHING_THRESHOLD_DEFAULT = Integer.MAX_VALUE;

//HashMap的静态私有内部类
private static class Holder {
static{
String altThreshold = java.security.AccessController.doPrivileged(
                new sun.security.action.GetPropertyAction(
                    "jdk.map.althashing.threshold"));


threshold = (null != altThreshold)
                        ? Integer.parseInt(altThreshold)
                        : ALTERNATIVE_HASHING_THRESHOLD_DEFAULT;
                        
ALTERNATIVE_HASHING_THRESHOLD = threshold;
}
}

3 查询key-value数据

查询使用get()方法,掌握上面put()方法后,get()还是比较好理解。

public V get(Object key) {
      //key为空时,使用数组中第一个元素
      if (key == null)
          return getForNullKey();
            
       //key不为空时,找到索引位置,从链表头开始比对value,符合则返回,不符合就返回null
       Entry<K,V> entry = getEntry(key);

      return null == entry ? null : entry.getValue();
    }

    
    //空key
   private V getForNullKey() {
        if (size == 0) {
            return null;
        }
        for (Entry<K,V> e = table[0]; e != null; e = e.next) {
            if (e.key == null)
                return e.value;
        }
        return null;
    }
    
    //非空key
    final Entry<K,V> getEntry(Object key) {
        if (size == 0) {
            return null;
        }

        int hash = (key == null) ? 0 : hash(key);
        for (Entry<K,V> e = table[indexFor(hash, table.length)];
             e != null;
             e = e.next) {
            Object k;
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k))))
                return e;
        }
        return null;
    }

4 删除

删除使用rermove()方法。

public V remove(Object key) {
   //根据key找到索引位置,从链表头开始比对value,符合则返回,不符合就返回null
    Entry<K,V> e = removeEntryForKey(key);
    return (e == null ? null : e.value);
}



final Entry<K,V> removeEntryForKey(Object key) {
   //判断容量是否为空,为空则直接返回空
    if (size == 0) {
        return null;
    }
    //找到key对应的索引
    int hash = (key == null) ? 0 : hash(key);
    int i = indexFor(hash, table.length);
    
    //指向待删除节点的前驱节点
    Entry<K,V> prev = table[i];
    
    //指向待删除节点
    Entry<K,V> e = prev;


   //遍历列表
    while (e != null) {
        Entry<K,V> next = e.next;
        Object k;
        if (e.hash == hash &&
            ((k = e.key) == key || (key != null && key.equals(k)))) {
            //更改次数加1
            modCount++;
            //总数量减1
            size--;
            
            //如果待删除节点位于链表头,则prev == e
            if (prev == e)
                table[i] = next;
            else
            //待删除节点的前驱节点的指针指向待删除节点的下一个节点,完成删除过程
                prev.next = next;
            e.recordRemoval(this);
            return e;
        }
        //节点prev和e移动分别移动一位
        prev = e;
        e = next;
    }

    return e;
}

5 修改

修改和新增都是使用put()方法,如果旧的key存在,则value值会被覆盖掉,返回旧的value。

6 实现克隆接口

先看一段测试代码

   HashMap<String, String> myMap = new HashMap();

        myMap.put("1", "a");

        for (Map.Entry<String, String> entry : myMap.entrySet()) {
            System.out.println("myHashMap: {key:" + (String)entry.getKey() + ", value:" + entry.getValue() + "}");
        }

        HashMap<String, String> cloneMap = (HashMap<String, String>)myMap.clone();
        for (Map.Entry<String, String> entry : cloneMap.entrySet()) {
            System.out.println("cloneMap: {key:" + (String)entry.getKey() + ", value:" + entry.getValue() + "}");
        }


        System.out.println("");
        
        myMap.put("1", "aa");
        for (Map.Entry<String, String> entry : myMap.entrySet()) {
            System.out.println("myHashMap: {key:" + (String)entry.getKey() + ", value:" + entry.getValue() + "}");
        }

        for (Map.Entry<String, String> entry : cloneMap.entrySet()) {
            System.out.println("cloneMap: {key:" + (String)entry.getKey() + ", value:" + entry.getValue() + "}");
        }      

运行结果:

myHashMap: {key:1, value:a}
cloneMap: {key:1, value:a}

myHashMap: {key:1, value:aa}
cloneMap: {key:1, value:a}

从测试结果可以发现,当myHashMap中的value改变时,克隆后的cloneMap的value不变。从源码也可以看出,首先根据旧的HashMap对象克隆一个新的实例result,
紧接着调用putAllForCreate(),将旧的HashMap对象中的key-value赋值给result。

  public Object clone() {
        HashMap<K,V> result = null;
        try {
        //克隆当前对象
            result = (HashMap<K,V>)super.clone();
        } catch (CloneNotSupportedException e) {
            // assert false;
        }
        if (result.table != EMPTY_TABLE) {
            result.inflateTable(Math.min(
                (int) Math.min(
                    size * Math.min(1 / loadFactor, 4.0f),
                    // we have limits...
                    HashMap.MAXIMUM_CAPACITY),
               table.length));
        }
        result.entrySet = null;
        result.modCount = 0;
        result.size = 0;
        result.init();
    
       // 将全部元素添加到新生成的HashMap中
        result.putAllForCreate(this);

        return result;
    }
    
    
  private void putAllForCreate(Map<? extends K, ? extends V> m) {
       //复制旧的HashMap实例中的key-value值到克隆后的实例中
        for (Map.Entry<? extends K, ? extends V> e : m.entrySet())
            putForCreate(e.getKey(), e.getValue());
    }

7 实现序列化接口

先看一段测试代码

HashMap<String, String> myMap = new HashMap();
myMap.put("1", "a");
        
FileOutputStream fos = new FileOutputStream("a.txt");
ObjectOutputStream oo = new ObjectOutputStream(fos);
//序列化.写入文件中
oo.writeObject(myMap);

FileInputStream fis = new FileInputStream("a.txt");
ObjectInputStream oi = new ObjectInputStream(fis);
//反序列化
HashMap<String, String>  myMap2 = (HashMap<String, String> )oi.readObject();

 for (Map.Entry<String, String> entry : myMap2.entrySet()) {
            System.out.println("反序列化: {key:" + (String)entry.getKey() + ", value:" + entry.getValue() + "}");
        }
        
 //关闭流
oi.close();
fis.close();
oo.close();
fos.close();


运行结果:
反序列化: {key:1, value:a}

看完这段代码,我们会猜想:HashMap获取索引的地址是通过key做哈希算法
public native int hashCode(),是native方法,即不同平台计算的值可能不一样,
那么反序列化后通过key去查value不就乱套?跟进去源码

//序列化
private void writeObject(java.io.ObjectOutputStream s)
        throws IOException
    {
        // Write out the threshold, loadfactor, and any hidden stuff
        s.defaultWriteObject();

        // Write out number of buckets
        if (table==EMPTY_TABLE) {
            s.writeInt(roundUpToPowerOf2(threshold));
        } else {
           s.writeInt(table.length);
        }

        // Write out size (number of Mappings)
        s.writeInt(size);

        // Write out keys and values (alternating)
        if (size > 0) {
        //将key-value依次写入序列化中,不是以数组形式写入
            for(Map.Entry<K,V> e : entrySet0()) {
                s.writeObject(e.getKey());
                s.writeObject(e.getValue());
            }
        }
    }

    private static final long serialVersionUID = 362498820763181265L;

   //反序列化
    private void readObject(java.io.ObjectInputStream s)
         throws IOException, ClassNotFoundException
    {
        // Read in the threshold (ignored), loadfactor, and any hidden stuff
        s.defaultReadObject();
        if (loadFactor <= 0 || Float.isNaN(loadFactor)) {
            throw new InvalidObjectException("Illegal load factor: " +
                                               loadFactor);
        }

        // set other fields that need values
        table = (Entry<K,V>[]) EMPTY_TABLE;

        // Read in number of buckets
        s.readInt(); // ignored.

        // Read number of mappings
        int mappings = s.readInt();
        if (mappings < 0)
            throw new InvalidObjectException("Illegal mappings count: " +
                                               mappings);

        // capacity chosen by number of mappings and desired load (if >= 0.25)
        int capacity = (int) Math.min(
                    mappings * Math.min(1 / loadFactor, 4.0f),
                    // we have limits...
                    HashMap.MAXIMUM_CAPACITY);

        // allocate the bucket array;
        if (mappings > 0) {
            inflateTable(capacity);
        } else {
            threshold = capacity;
        }

        init();  // Give subclass a chance to do its thing.

        //依次读取key-value值,并写入新的数组,保证了key-value数据的一致性
        for (int i = 0; i < mappings; i++) {
            K key = (K) s.readObject();
            V value = (V) s.readObject();
            putForCreate(key, value);
        }
    }

HashMap为了避免不同平台key的哈希值不一致导致key-value数据错误,自己重写了序列化和反序列化,核心点是序列化时将key-value依次写入序列化中,反序列化时依次读取key-value值,写入新的数组,解决key-value数据错误问题。

8 死锁问题

HashMap被设计为单线程环境下的高性能容器,但是在多线程插入数据时候会发生死锁,导致一个CPU飙到100%。并发条件下建议替换成ConcurrentHashmap。陈皓老师的博文https://coolshell.cn/articles/9606.html写的非常简单易懂,这里不再累述。

9 ConcurrentModificationException异常

9.1 异常产生原因

HashMap一边遍历,一边执行删除操作,马上就抛出ConcurrentModificationException异常。

public class HashMapApp {

    public static void main(String[] args) {

        HashMap<String, String> myMap = new HashMap<>();
        myMap.put("1", "a");
        myMap.put("2", "b");

        for (Map.Entry<String, String> entry : myMap.entrySet()) {
            if(entry.getKey().equals("2"))
                myMap.remove(entry.getKey());
            System.out.println("key:" + entry.getKey() + ", value:" + entry.getValue());

        }

    }
}


key:2, value:b
Exception in thread "main" java.util.ConcurrentModificationException
at java.util.HashMap$HashIterator.nextEntry(HashMap.java:922)
at java.util.HashMap$EntryIterator.next(HashMap.java:962)
at java.util.HashMap$EntryIterator.next(HashMap.java:960)
at collection.HashMapApp.main(HashMapApp.java:20)

直接看源码,抛出异常的原因是修改次数modCount和期望修改次数expectedModCount不一致造成的。

final Entry<K,V> nextEntry() {
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
 ...
}

查看经Javac编译后的代码HashMapApp.class文件,可以发现遍历的本质还是使用迭代器Iterator。

public class HashMapApp {
    public HashMapApp() {
    }

    public static void main(String[] args) {
        HashMap<String, String> myMap = new HashMap();
        myMap.put("1", "a");
        myMap.put("2", "b");

        Entry entry;
        for(Iterator i$ = myMap.entrySet().iterator(); i$.hasNext(); System.out.println("key:" + (String)entry.getKey() + ", value:" + (String)entry.getValue())) {
            entry = (Entry)i$.next();
            if (((String)entry.getKey()).equals("2")) {
                myMap.remove(entry.getKey());
            }
        }

    }
}

因此代码可以调整为:

   public static void main(String[] args) {
   HashMap<String, String> myMap = new HashMap();
            myMap.put("1", "a");
            myMap.put("2", "b");

            Map.Entry entry;
            Iterator i$ = myMap.entrySet().iterator();

         while(i$.hasNext()) {
                entry = (Map.Entry)i$.next();
                if (((String)entry.getKey()).equals("2")) {
                    myMap.remove(entry.getKey());
                }
             System.out.println("key:" + (String)entry.getKey() + ", value:" + (String)entry.getValue());
            }
            }

在抛出异常的代码处打断点

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-o7sFY4nj-1598254811030)(en-resource://database/600:1)]
在IDEA工具debug模式下,右下方有方法调用的过程,进去next()的源码,可以发现EntryIterator是继承了HashIterator。
在这里插入图片描述

private final class EntryIterator extends HashIterator<Map.Entry<K,V>> {
    public Map.Entry<K,V> next() {
        return nextEntry();
    }
}

跟进去HashIterator,在构造函数中可以发现预期的修改次数expectedModCount是与modCount相等。从上面的put()和remove()源码可知,每次执行put()和remove()方法都会执行modCount++。这就造成了expectedModCount != modCount,nextEntry方法抛出ConcurrentModificationException()异常!!!

  private abstract class HashIterator<E> implements Iterator<E> {
        '''
        int expectedModCount;   // For fast-fail

        HashIterator() {
            expectedModCount = modCount;
           ...
        }
        
        ...
    }

9.2 fast-fail机制

问题就来了,为什么要HashMap要判断modCount != expectedModCount
这还是因为HashMap不适合并发条件的原因。一个线程在遍历,一个线程在删除,就会有数据不同步原因。HashMap采用快速失败(fast-fail)的机制,尽早将抛出异常,终止这种行为!

10 参考文献

1)疫苗:JAVA HASHMAP的死循环 https://coolshell.cn/articles/9606.html
2) JDK7在线文档
https://tool.oschina.net/apidocs/apidoc?api=jdk_7u4
3) Bruce Eckel,Java编程思想 第4版. 2007, 机械工业出版社

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值