【靶点突破】Java HashMap探究

【靶点突破】Java HashMap探究

  • 哈希算法 & 哈希表 & 哈希冲突 & 哈希冲突的解决方案
  • HashMap是什么 & 如何使用 & 缺点
  • 基于JDK 1.8 的HashMap实现原理
  • 聊聊JDK 1.7它存在的一个bug

  Hello,大家好,我是Ellen,这是Android靶点突破系列文章,旨在帮助你更加了解Android技术开发的同时,把业务做到精致。思考自己的职业生涯,想成为怎样的技术人,想追求怎么样的生活。

当你遇到瓶颈时,不妨试着总结自己所学精华,与友人分享切磋,接下来的路就在脚下了。
| from Ellen缘言/2月

1.哈希算法 & 哈希表 & 哈希冲突 & 哈希冲突的解决方案

1.1 什么是哈希算法

哈希算法:哈希(Hash)也称为散列,就是把任意长度的输入,通过散列算法,变换成固定长度的输出,这个输出值就是散列值。

1.2 什么是哈希表

哈希表:散列表(Hash table,也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。
给定表M,存在函数f(key),对任意给定的关键字值key,代入函数后若能得到包含该关键字的记录在表中的地址,则称表M为哈希(Hash)表,函数f(key)为哈希(Hash) 函数。

  上面一段来自度娘对哈希表的定义。简单来说哈希表就是一个方便查找数据的表,它有以下几个关键点:

  • 1.Key:操作哈希表的钥匙
  • 2.Value:哈希表里某个key对应的值
  • 3.Value位置:哈希表Value的位置都是通过哈希算法,将key通过哈希算法获取一个散列值,最终确定该key对应哈希表存放的位置。
  • 4.哈希函数:哈希算法,将key哈希算法之后获取一个散列值。

  因此我们平时所说的键值对的键对应这里的key,键值对的值对应的value。

1.3 什么是哈希冲突

哈希冲突:当存放一个新的键值对时,key通过哈希算法之后获取的散列值,根据散列值查到对应的位置已经有value存放了,这就是所谓的哈希冲突。

1.4 哈希冲突的解决方案

  解决哈希冲突的核心最终能让key找到对应的一个位置,去存放Value。那么哈希冲突有哪些方案呢?HashMap采用的哪种呢?其有四种方案如下所示:

  • 1.开放定址法
  • 2.再哈希法
  • 3.链地址法
  • 4.建立公共溢出区

  下面我们分别聊聊这四种方案,再聊聊HashMap采用的哈希冲突方案以及为什么:

1.4.1 开放定址法

  开发地址法的做法是,当冲突发生时,使用某种探测算法在散列表中寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到。按照探测序列的方法,一般将开放地址法区分为线性探查法、平方探查法、双重散列法等。

  下面我们分别演示一下线性探查法&平方探查法&双哈希探查法:

  线性探查法:当发生哈希冲突时,按照公式:f(i) = i进行探查,f(i)计算出的是偏移量,i为探查次数{1,2,3,4…},如果探查的位置为空,那么就确定了该key存放的位置。 缺点:需要不断处理冲突,无论是存入还是査找效率都会大大降低。

  平方探查法:当发生哈希冲突时,按照公式:f(i) = i * i(i的平方)进行探查,f(i)计算出的是偏移量,i为探查次数{1,2,3,4…},如果探查的位置为空,那么就确定了该key存放的位置。 缺点:不能探查到所有位置,而且需要不断处理冲突,无论是存入还是査找效率都会大大降低。

  双哈希探查法:当发生哈希冲突时,按照公式:f(i) = i⋅hash2(x),hash2(x),i为探查次数{1,2,3,4…},先计算出偏移量确定探查位置是否为空,如果不为空则需要用公式f(i) = i⋅hash2(x)进行探查,直到探查的位置不为空存放该key为止,如果hash2(x)计算的偏移量对应的位置为空,那么不需要在进行f(i) = i⋅hash2(x)公式的计算了,hash2(x)计算出的偏移量位置就为存放该key的位置。对于hash2(x)函数而言,它也是一个有关探查次数i有关的函数。缺点:计算十分耗时,不如平方探查法。

  还有一些其它的开放定址法,例如:二次探查法等,笔者这里就不啰嗦了,其实就是发生冲突时去执行一个算法公式,确定下一次探查的位置,如果这个位置是空的,那么就确定了该key存放的位置,从而解决哈希冲突,计算过程耗时跟算法公式有关。

1.4.2 再哈希法

  再哈希法的意思是当出现哈希冲突时,利用第二个哈希函数计算出位置,如果位置还是存放有value,那么就会用第三个哈希函数进行计算,直到不出现冲突为止。缺点特别明显,就是计算可能比较耗时。

1.4.3 链地址法

  链地址法的意思就是每个哈希结点都存在一个next的指针,当出现冲突时,在冲突的位置每个结点通过next指针来构成一个链状数据结构,例如:单链表。JDK中HashMap采用的就是这种方式解决的哈希冲突。

1.4.4 建立公共溢出区

  建立公共溢出区意思将哈希表分为基本表和溢出表两部分,凡是和基本表发生冲突的元素,一律填入溢出表

仔细阅读上面四种哈希冲突的解决方案,下面我们来分析分析JDK 中HashMap为什么采用链地址法:开放寻址和再哈希法会耗损计算时长,建立公共溢出区,显然也不是最好方案,溢出区去寻找冲突的元素也是个难题,更不提其它缺点的地方了。所以JDK中采用了链地址法,从增删改查操作来看,它的增加很简单,出现冲突时,用链表或者红黑树来组织冲突的键值对,它的查询,基本也是很简单,通过哈希算法计算出key对应的位置,然后在那个位置的链表或者红黑树顺腾摸瓜(JDK1.8 链表在某一条件下转化为红黑树,目的是为了加强查找效率,下章我们具体讲解),逐一比较,删和改也就不多讲解了,

2.HashMap是什么 & 如何使用

2.1 HashMap是什么?如何使用

  对于Android程序员来说,HashMap真的熟悉的再熟悉不过的了,它是什么,很简单,不就是存储键值对关系集合嘛,它的键是唯一性的,可以为null,而值可以为任意的,甚至可以为null,下面笔者来演示下HashMap的增删改查等操作:

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

//增
for (int i = 0; i < 100; i++) {
    hashMap.put(String.valueOf(i),String.valueOf(i));
}

//删
hashMap.remove("1");

//改
hashMap.put("2","two");

//查
String s3 = hashMap.get("3");

//清空
hashMap.clear();

  HashMap的5种遍历方式:

  方式一:key方式遍历

        HashMap<String,String> hashMap = new HashMap<>();
        for (int i = 0; i < 100; i++) {
            hashMap.put("key:"+i,"value:"+i);
        }

        //key方式遍历
        for(String key: hashMap.keySet()){
            String value = hashMap.get(key);
        }

  方式二:Entry迭代器方式遍历

        HashMap<String,String> hashMap = new HashMap<>();
        for (int i = 0; i < 100; i++) {
            hashMap.put("key:"+i,"value:"+i);
        }

        //迭代器方式遍历
        Iterator<Map.Entry<String, String>> ite = hashMap.entrySet().iterator();
        while (ite.hasNext()){
            Map.Entry<String,String> entry = ite.next();
            String key = entry.getKey();
            String value = entry.getValue();
        }

  方式三:Entry方式遍历

        HashMap<String,String> hashMap = new HashMap<>();
        for (int i = 0; i < 100; i++) {
            hashMap.put("key:"+i,"value:"+i);
        }

        //迭代器方式遍历
        for(Map.Entry<String,String> entry: hashMap.entrySet()){
            String key = entry.getKey();
            String value = entry.getValue();
        }

  方式四:Value方式遍历,但是不能遍历key

        HashMap<String,String> hashMap = new HashMap<>();
        for (int i = 0; i < 100; i++) {
            hashMap.put("key:"+i,"value:"+i);
        }

        //迭代器方式遍历
        for(String value: hashMap.values()){
           
        }

  方式五:key 迭代器方式遍历

        HashMap<String,String> hashMap = new HashMap<>();
        for (int i = 0; i < 100; i++) {
            hashMap.put("key:"+i,"value:"+i);
        }

        Iterator<String> iterator = hashMap.keySet().iterator();
        //key 的迭代器方式遍历
        while (iterator.hasNext()){
            String key = iterator.next();
            String value = hashMap.get(key);
        }

  这五种方式有什么区别呢?请读者自行探究,分别测试往里面添加100条数据遍历,和1w条数据遍历时间作对比。

  此外还需要注意的是HashMap的key可以为null的,但是只能存在一个,因为HashMap的键是唯一的嘛,Value可以为null。

2.2 缺点

  HashMap存在以下缺点:

  • 线程不安全:HashMap每个操作都是非线程安全的,所以它不太适合去完成并发业务。
  • 不支持缩容,造成内存空间浪费

3.基于JDK 1.8 的HashMap实现原理

  研究HashMap我们需要把以下几个问题逐一弄明白,只要清晰了下面几个问题,那么HashMap的实现原理就非常清楚啦

  • 实现HashMap的哈希算法是怎样的
  • HashMap的哈希表实现的数据结构是什么?
  • HashMap处理哈希冲突的方案是什么?& 如何确保唯一性呢
  • HashMap 构造器执行流程 & 增删改查过程
  • HashMap扩容
  • JDK中1.7 & 1.8 的HashMap区别

3.1 实现HashMap的哈希算法是怎样的

  了解一个哈希表的实现,我们首先要了解的是它的核心,也就是它的哈希算法如何实现的,哈希算法的目的就是确定键值对的键对应哈希表中的哪个位置。我们先来看看下面这段代码:

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

  这是HashMap中的hash方法,我们看到当key为null的时候,计算出 hash值为0,当key不为null的时候,key计算出的hash值就为(h = key.hashCode()) ^ (h >>> 16),^操作符为异或计算,>>> 16表示无符号右移16位,那么key不为空的时候,计算出的结果很明显看出与key的hashCode方法有关,hashCode方法是干嘛的呢?官方文档中有这样的意思:hashcode方法返回该对象的哈希码值。支持该方法是为哈希表提供一些优点。我们这里先不探讨hashCode方法内部逻辑是怎样的,我们现在只是认为听过这个方法,它可以获取到当前对象对应的哈希值,这对哈希结构的集合实现具有特殊的作用。

  你认为上面已经聊完了HashMap的哈希算法吗?其实并没有,哈希算法最终的目的是为了计算出key对应HashMap中对应的位置,所以上面的hash(key)方法只是计算出key对应哈希值而已,还没有确定其位置呢?接下来我们就聊聊key通过hash(key)方法获取hash值之后确定位置的过程:

//n为数组容量
index = (n - 1) & hash

  上述片段代码是JDK 1.8中HashMap用于计算存放的键值对在数组中的位置,HashMap底层数据结构有什么构成,我们暂且不聊,下个节点会聊到,从以上代码可以看出,最终确定存放的这个键值对的位置是有数组容量减1在与hash()方法计算的哈希值进行与运算得到最终的放入的位置,为什么这么做呢?

  我们现在已经知道hash方法计算出来是一个整形数值,我们现在来探讨一个数学问题,给定一个大小为100的数组,你如何将它分类为16种?聪明的你立马就想到取余运算对吧,请看以下代码:

       //待分类数组
       int[] array = new int[100];
       //分类集合
       List<List<Integer>> sortList = new ArrayList<>(16);

       //随机产生0~10000的整形数给数组
       for(int i=0;i<100;i++){
           array[i] = (int)(Math.random() * 10000);
       }

       //进行分类
       for(int i=0;i<100;i++){
           int index = array[i] % 16;
           List<Integer> integerList = sortList.get(index);
           if(integerList == null){
               integerList = new ArrayList<>();
           }
           integerList.add(array[i]);
       }

  明白上述代码之后,我们要想想如何优化上述代码呢?我们的取余运算是不是可以用其它算法替代呢?没错,我们想到取余运算可以位运算中的"&"运算进行优化,我们举个栗子,比如132,进行16的取余运算,我们可以这么计算 132 & 15,就可以求得余数啦,我们来看看:

  132转成二进制为"10000100",15的二进制为"1111",他们的"&"运算结果为:“00000100”,也就是为4

10000100
00001111
--------
00000100

  而132 = 16*8 + 4,取余运算的结果的确为4,由此可见我们可以用"&“运算来替代取余运算,为什么能替代呢?首先替代的条件是除数必须是2的整数倍,因为只有2的整数倍,它的取余运算才能被”&“运算取代,它减1之后转化为二进制之后,对应的每个位置的数字都是1,只有全都是1,”&"运算算出的结果才是余数。这也就是为什么HashMap数组的大小必须是2的n次幂的原因所在。

  我们接着聊,那个数学问题我们优化之后的代码如下所示:

        //待分类数组
        int[] array = new int[100];
        //分类集合
        List<List<Integer>> sortList = new ArrayList<>(16);

        //随机产生0~10000的整形数给数组
        for (int i = 0; i < 100; i++) {
            array[i] = (int) (Math.random() * 10000);
        }

        //进行分类
        for (int i = 0; i < 100; i++) {
            int index = array[i] & 15;//这个地方的15实际上是由16-1计算出来的
            List<Integer> integerList = sortList.get(index);
            if (integerList == null) {
                integerList = new ArrayList<>();
            }
            integerList.add(array[i]);
        }

  你也许会问了,为什么"&“运算比取余运算更好呢?”&“运算只计算了低位,而取余运算所有位置都进行了计算,自然”&“运算效率更高些,明白了这些,我们就可以总结啦,HashMap的哈希算法过程是这样的,当你往它里面放键值对(key,value)时,先通过hash()方法算出key的hash值,再由hash & (n-1)进行位运算,实际上是获取取余的结果,不过”&"运算比取余运算效率高,得到的结果最终为该键值对(key,value)最终存放的位置,如果出现冲突,就会采用拉链法进行解决,哈希冲突方案具体过程我们之后聊。

3.2 HashMap的哈希表实现的数据结构是什么?

  HashMap实现的数据结构由三种支持:数组,链表,红黑树

  • 数组:大小必须为2的幂次方,原因3.1中已经聊过,它存放的是链表或者是红黑树,它的索引含义是键值对(key,value),key进行哈希算法中"&"运算结果对应的位置。
  • 链表:存放键值对结点Node的数据结构,它也是HashMap实现哈希冲突方案拉链法的关键。
  • 红黑树:为提高HashMap查找效率引入的,它会在某些特定条件下将链表树化为红黑树,增加查找效率。它的每个结点通过TreeNode方式存储。

  我们暂时不聊这些数据结构的细致配合过程来实现HashMap的一些功能,我们慢慢分析,慢慢体会。请继续往后看。

3.3 HashMap处理哈希冲突的方案是什么?& 如何确保唯一性呢

  HashMap处理哈希冲突的方案采用的拉链法,前面我们已经聊过了它的哈希算法,接下来我们聊聊HashMap处理哈希冲突的具体细节。

  当我们向HashMap里放键值对的时候执行了以下方法:

/**
     * 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
     * @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) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
		//如果map还是空的,则先开始初始化,table是map中用于存放索引的表
        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;
			//如果存放的减值对已经存在,那么就直接赋值就可以了,这里也是保障HashMap键值唯一的关键
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
			//HashMap中没有存放该键值对,且当前结点Node对应的是红黑树结点类型TreeNode,那么就走红黑树存放TreeNode流程	
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
				//HashMap中没有存放该键值对,且当前结点Node对应的是链表结点类型Node
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
						//这里是处理哈希冲突方案拉链法的关键,看到next指针没有
                        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;
    }

  认真阅读上述代码,可以明显看到,当存放一个键值对时,首先会通过哈希算法得到该键值对存放的数组索引位置,然后再判断该索引位置是否存在结点Node,如果不存在,则没有发生哈希冲突,就会创建一个Node结点存放在该索引位置,如果该索引位置存在结点,接着就会判断该结点是否是当前键值对对应的结点,如果是,直接进行赋值value即可,如果不是,则继续判断该结点是不是红黑树结点,如果是红黑树结点,就走遍历红黑树流程,如果该键值对对应的TreeNode结点已经存在,则进行value赋值,如果遍历过程走完不存在对应该键值对的结点,那么就会创建新的TreeNode结点挂到红黑树上。

  如果当前不是红黑树结点,那么它就一定属于Node结点,即链表对应的结点,接着就要遍历链表,逐一进行比对,在遍历过程中如何确定了该键值对对应了某个结点,那么就会跳出循环,走value赋值过程即可,如果遍历到某个结点的next指针为null时,说明已经遍历到链表尾部了,说明该键值对之前没有存放在HashMap中,此时就会新建Node结点,挂到链表尾部即可。

  通过以上分析,HashMap保证键值唯一性的关键就是以下的判断:

p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))

  从以上片段代码可以看出,它存在三个关键的判断:

  • 哈希值比较
  • "=="内存地址比较
  • equals比较

  就是说哈希值不一样的,非常确定的是这两个键值对的键是不一样的,如果哈希值相同,还有比较内存地址和equals方法,这二者任一结果为true,则说明这二者是相同的键,否则,二者的键不一样。

3.4 HashMap 构造器执行流程 & 增删改查过程

  HashMap的构造器一共分为以下3种:

  • HashMap()
    /**
     * Constructs an empty <tt>HashMap</tt> with the default initial capacity
     * (16) and the default load factor (0.75).
     */
    public HashMap() {
		//加载因子为DEFAULT_LOAD_FACTOR,也就是0.75
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }
  • HashMap(int initialCapacity)
    public HashMap(int initialCapacity) {
		//调用了HashMap(int initialCapacity, float loadFactor) 构造器
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }
  • HashMap(int initialCapacity, float loadFactor)
    /**
     * 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)
		    //如果初始化容量 小于0则抛出异常
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
		//如果指定的容量超过MAXIMUM_CAPACITY,则赋值当前容量为initialCapacity									   
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
		//如果加载因子小于0或者loadFactor不为Float.NaN,则抛出异常 
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
		//赋值加载因子									    
        this.loadFactor = loadFactor;
		//确定最终的初次扩容容量标准
        this.threshold = tableSizeFor(initialCapacity);
    }
	
	/**
	 * 返回给定目标容量的 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;
    }

  以上是HashMap的3个构造器的解析,要明白的是HashMap的构造器作用在于确定加载因子,如果没有传入自定义的加载因子的参数,加载因子loadFactor的值均为0.75,那么这个加载因子的作用是什么呢?

加载因子的作用:当HashMap存储的条目个数超过HashMap和加载因子的乘积,这时候就触发了HashMap的resize,rehash操作,也就是扩容。在某方面来讲,加载因子决定了HashMap的数据密度。那么为什么它默认情况下偏偏是0.75呢?
主要原因如下所示:

  • (1)加载因子越大hash表数据密度越大,发生碰撞的几率越高,数组中的链表越容易长,造成查询或插入时的比较次数增多,性能会下降。
  • (2)加载因子越小,就越容易触发扩容,数据密度也越小,意味着发生碰撞的几率越小,数组中的链表也就越短,查询和插入时比较的次数也越小,性能会更高。但是会浪费一定的内存空间。而且经常扩容也会影响性能(扩容会重新构造底层结构,以及原有数据的转移,非常耗时),建议初始化预设大一点的空间。
  • (3)按照其他语言的参考及研究经验,会考虑将负载因子设置为0.7~0.75,此时平均检索长度接近于常数。
  • (4)0.75作为加载因子,每个碰撞位置的链表长度超过8个是几乎不可能的。

以上四点是我照搬的结论,不需要记,只需要明白0.75是最佳的加载因子的值,会提升HashMap性能即可。

  接下来聊聊它的增删改查过程:

  增加 & 修改:由于增加修改,都是调用的HashMap的put(key,value)方法,这里我就一同分析啦:

  首先我们来看看put方法:

    /**
     * Associates the specified value with the specified key in this map.
     * If the map previously contained a mapping for the key, the old
     * value is replaced.
     *
     * @param key key with which the specified value is to be associated
     * @param value value to be associated with the specified key
     * @return the previous value associated with <tt>key</tt>, or
     *         <tt>null</tt> if there was no mapping for <tt>key</tt>.
     *         (A <tt>null</tt> return can also indicate that the map
     *         previously associated <tt>null</tt> with <tt>key</tt>.)
     */
    public V put(K key, V value) {
		//调用了putVal方法
        return putVal(hash(key), key, value, false, true);
    }

  调用了putVal方法,接着我们来看看putVal方法:

    /**
     * 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
     * @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) {
        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,然后走value赋值流程
                e = p;
            else if (p instanceof TreeNode)
			    /**
				 * 根节点和当前存放的结点对应的键不一样,且当前的结点类型是红黑树对应的TreeNode,
				 * 说明当前的此处的链状结构为红黑树,那么走红黑树遍历,一一比对,如果存在二者键是
				 * 一样的,那么就走赋值流程,如果没有与存放的键值一样,那么就创建一个TreeNode结点
				 * 找到红黑树中存放的位置存放即可
				 */
				e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
				/**
				 * 根节点跟当前存放的结点对应的键一样,且当前的结点类型是链表对应的Node,
				 * 就对该链表进行逐一遍历比较是否键值一样
				 */
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
						/**
						 * 当遍历到某个结点的next指针为null时,说明已经遍历到链表尾部, 
						 * 说明逐一比较之后没有发现与当前存放的键值对相同的键
						 * 那么就创建一个新的Node结点挂到链表尾部即可 
						 */
                        p.next = newNode(hash, key, value, null);
						/**
						 * 判断当前链表数量是否达到转化为红黑树的标准,
						 * 它的判断条件是数量达到8个且整体元素大小超过64就进行树化,
						 * TREEIFY_THRESHOLD为final静态int类型,值为8
						 * static final int TREEIFY_THRESHOLD = 8;
						 */
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
						    //方法里面还进行了整体元素大小是否超过64的判断
                            treeifyBin(tab, hash);
                        break;
                    }
					/**
					 * 如果逐一比较时,发现二者的键是一样的,那么就跳出循环,走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)
				    //赋值value
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
		//将当前操作数+1
        ++modCount;
		//将size+1,判断是否需要扩容
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

  请仔细看以上代码和注释,最好多阅读几遍,你就会非常清晰明白HashMap的put过程,我们将它分为几个步骤,我们再来整理一遍逻辑:

  • 1.判断当前表是否为null,为null就进行首次扩容
  • 2.根据哈希算法确定键值对存放的位置,判断根节点是否为null,如果为null,就新建Node结点存放即可,如果不为null,就判断节点是否和当前存放的键值对的键一样,一样的话走value赋值流程,不一样就接着以下步骤
  • 3.判断根结点是否是TreeNode类型,如果是的,那么当前此处的链状结构已经红黑树化,那么就走红黑树遍历过程,比较是否存在与当前键值对的键一样的,存在则走value赋值流程,不存在,则新创建TreeNode结点,然后挂到红黑树对应的位置。如果当前结点不是TreeNode类型,那么它肯定是链表对应的结点Node类型,接着以下步骤:
  • 4.逐一遍历链表,是否存在与当前键值对一样的键,如果存在,则走赋值流程,如果不存在,则新创建Node结点挂到链表尾部,接着就会判断该链表数目是否达到了8且整体元素大小超过64,如果达到了,就将当前链表树化为红黑树。
  • 5.将当前存放元素的条目size+1,再判读是否达到扩容的条件,如果达到,则进行扩容。modCount为操作数

  HashMap的增加和修改,大致步骤是以上5个,对于HashMap的扩容细节,我们在下一节聊。我们还有个没有分析,那就是红黑树的遍历过程,这里呢,笔者暂时就不分析了,读者自行分析,这部分是数据结构相关的,我会将红黑树作为一篇文章进行讲解。接下来我们改改删除和查询过程:

  删除 & 查询:由于删除过程是先查到位置,然后再进行删除,所以删除和查询过程可以放在一起讲解:

    public V get(Object key) {
        Node<K,V> e;
		//通过getNode方法进行查询到Node结点,然后返回Node的value
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }

  getNode方法如下:

    /**
     * Implements Map.get and related methods
     *
     * @param hash hash for key
     * @param key the key
     * @return the node, or null if none
     */
    final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
		/**
		 * 判断哈希表是否为null && 哈希表的容量是否大于0 && 查询的key对应的位置的根节点不为null
		 */
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
			//判断根结点是否为查询的key,如果是,则返回根结点即可	
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
			//判断根结点是否存在next的结点	
            if ((e = first.next) != null) {
                if (first instanceof TreeNode)
				    /**
					 * 如果根结点的类型是TreeNode,那么说明当前链状结构为红黑树,
					 * 接着通过TreeNode的getTreeNode方法遍历红黑树,直到查询到
					 * key对应的TreeNode结点并返回
					 */
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
				/**
				 * 如果根结点的类型不是TreeNode,,那肯定是Node,说明当前链状结构为链表,
				 * 接着就会遍历链表,直到查询到当前key对应的结点Node并返回
				 */	
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
		//如果上面都未曾找到key对应的结点,那么直接返回null,HashMap里没有存放该key对应的键值对
        return null;
    }

  HashMap通过key查找value的步骤其实挺简单的,步骤如下:

  • 1.判断哈希表是否为null && 判断哈希表容量是否大于0 && 根据哈希算法确定key对应存放位置的根结点是否为null,三个判断一个不满足,则代表该key在HashMap没有存放,返回null。
  • 2.判断根结点是否为key对应的存放的Node,如果是,则返回根结点,如果不是,接着以下步骤。
  • 3.判断根结点的类型是否为TreeNode,如果是,说明当前的链状结构为红黑树,那么接下来就是遍历红黑树,逐一比对是否是同一个key,如果是,则返回,如果遍历所有都没有发现同一个key的结点,那么就会返回null。
  • 4.如果根结点不是TreeNode,那么它肯定是Node类型,说明当前的链状结构为链表,那么接下来就是遍历链表,逐一比对是否是同一个key,如果是,则返回,如果遍历所有都没有发现同一个key的结点,那么就会返回null。

  下面我们看看删除过程:

    public V remove(Object key) {
        Node<K,V> e;
        return (e = removeNode(hash(key), key, null, false, true)) == null ?
            null : e.value;
    }

  接着我们看看removeNode方法:

    /**
     * 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
     * @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) {
        Node<K,V>[] tab; Node<K,V> p; int n, index;
		/**
		 * 表是否为null && 表的容量是否大于0 && 根结点不为null
		 */
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (p = tab[index = (n - 1) & hash]) != null) {
            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) {
				//判断根结点类型是否为TreeNode类型
                if (p instanceof TreeNode)
				    //红黑树上查找对应key的结点
                    node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
                else {
					//如果根结点不为TreeNode,那么它就为Node类型,对应链表
					//遍历链表找到key对应的结点Node
                    do {
                        if (e.hash == hash &&
                            ((k = e.key) == key ||
                             (key != null && key.equals(k)))) {
                            node = e;
                            break;
                        }
						//这里的p = e,记录的是当前遍历的位置,对之后查找的Node结点移除有至关作用 
                        p = e;
                    } while ((e = e.next) != null);
                }
            }
			//如果找到的结点node不为null,matchValue之前传入的是false,所以后面的肯定返回true
            if (node != null && (!matchValue || (v = node.value) == value ||
                                 (value != null && value.equals(v)))) {
                //如果找到的结点类型为TreeNode									 
                if (node instanceof TreeNode)
				    //红黑树的结点移除 
                    ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
                else if (node == p)
				    //如果查找到是根结点,那么根结点就换成根结点的next指针之后的结点
                    tab[index] = node.next;
                else
				    /**
					 * 如果是Node结点,那么就将寻找到的结点位置前的结点
					 * 的next指针指向寻找的结点的next指针指向的结点,即可删除node结点
					 */
                    p.next = node.next;
                ++modCount;//操作数+1
                --size;//size-1
                afterNodeRemoval(node);
                return node;
            }
        }
        return null;
    }

  从以上代码总结删除步骤如下:

  • 1.判断哈希表是否为null && 判断哈希表容量是否大于0 && 根据哈希算法确定key对应存放位置的根结点是否为null,三个判断一个不满足,则代表该key在HashMap没有存放,返回null。
  • 2.判断根结点是否为key对应的存放的Node,如果是,则记录移除的结点为根结点。如果不是,接着以下步骤
  • 3.判断根结点的类型是否为TreeNode,如果是,说明当前的链状结构为红黑树,那么接下来就是遍历红黑树,逐一比对是否是同一个key,如果是,则记录移除的结点为找到的TreeNode,如果遍历所有都没有发现同一个key的结点,那么就记录移除的结点node为null。
  • 4.如果根结点不是TreeNode,那么它肯定是Node类型,说明当前的链状结构为链表,那么接下来就是遍历链表,逐一比对是否是同一个key,如果是,则记录移除的结点为找到的Node,如果遍历所有都没有发现同一个key的结点,那么就记录移除的结点node为null。
  • 5.判断记录移除的结点是否为null,如果为null,则说明HashMap中没有存放该key,结束移除操作。如果移除的结点node类型为TreeNode,则调用TreeNode的removeTreeNode方法移除这个结点,接着就会判断当前移除的结点是否为根结点,如果是,只需要将根结点换成根结点的next指针指向的结点即可,不管它是否为null,如果不是,则只能说明移除的结点node为链表对应的结点,链表移除结点的方式很简单,就是把移除结点的上一个结点next指针指向移除结点的next对应的指针即可移除该结点。接着将操作数modCount+1,容量size-1即可。

  以上便讲完了HashMap的增删改查操作,值得注意的是笔者没有讲解链表如何树化为红黑树,红黑树如何退化为链表的过程。这部分我放在了3.6节进行讲解。还有就是笔者没有把扩容过程非常细致讲解,接着来我们就来聊聊HashMap的扩容过程。

3.5 HashMap扩容

  要分析HashMap的扩容,我们首先要分析清除HashMap的resize方法:

    /**
     * Initializes or doubles table size.  If null, allocates in
     * accord with initial capacity target held in field threshold.
     * Otherwise, because we are using power-of-two expansion, the
     * elements from each bin must either stay at same index, or move
     * with a power of two offset in the new table.
     *
     * @return the table
     */
    final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
		//获取当前HashMap数组容量
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
		//获取当前扩容容量标准
        int oldThr = threshold;
        int newCap, newThr = 0;
        if (oldCap > 0) {
			/**
			 * 如果当前HashMap数组大小达到了最大容量MAXIMUM_CAPACITY
			 * 无法继续扩容,只能让threshold赋值为Integer.MAX_VALUE最大
			 * 数组无法进行扩容,整个HashMap的存放的元素大小为Integer.MAX_VALUE
			 */
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
			/**
			 * 否则就将数组容量扩大为原来的2倍,扩大2倍之后必须小于 MAXIMUM_CAPACITY
			 * && 当前数组容量小于默认初始容量16
			 */
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
				//新的扩容容量标准 = 旧的扩容容量标准 * 2	  
                newThr = oldThr << 1; // double threshold
        }
		//如果当前数组容量等于0同时,当前的扩容容量标准大于0
        else if (oldThr > 0) // initial capacity was placed in threshold
		    //那么新的数组容量就等于当前扩容容量标准
            newCap = oldThr;
        else {     
			//如果当前数组容量等于0同时,当前的扩容容量标准等于0
			// zero initial threshold signifies using defaults
			//新的数组容量就为DEFAULT_INITIAL_CAPACITY,即HashMap的数组初始容量为16
            newCap = DEFAULT_INITIAL_CAPACITY;
			//新的扩容容量标准为= 0.75 * 16 = 12
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
		//如果新的扩容容量标准为0
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
			/**
			 * 判断新的容量 & 新的容量标准 是否达到了最大容量MAXIMUM_CAPACITY
			 * 如果达到了,新的数组容量标准为Integer.MAX_VALUE
			 */
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
		//当前容量扩容标准 = 新的容量扩容标准
        threshold = newThr;
        @SuppressWarnings({"rawtypes","unchecked"})
		    //创建容量为newCap的数组
            Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
		//赋值新的数组	
        table = newTab;
		//如果当前数组不为null
        if (oldTab != null) {
			/**
			 * 逐一遍历扩容前旧数组的每个结点,将每个结点重新进行哈希计算,
			 * 确定它在新的扩容数组的中位置,然后出现冲突还是用拉链法解决
			 * 注意这里重新整理的链表如果大于8且整体元素大小超过64是没有进行树化的,只有下一次在put
			 * 到那个位置时,链表长度大于8且整体元素大小超过64再进行树化
			 */
            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.HashMap初始数组容量为16
  • 2.HashMap每次扩容数组容量都会以2倍进行增长,但是不是无限增长呢?并不是,它最大只能增长到Integer.MAX_VALUE
  • 3.当没有指定加载因子时,首次扩容为16时,扩容容量标准为数组容量的0.75倍,之后随着数组容量2倍增长而2倍增长,它最大也同样只能增长到Integer.MAX_VALUE。
  • 4.扩容的操作是新创建一个新的容量数组,然后遍历旧数组的每个结点,然后重新针对每个结点重新进行哈希算法计算,确定它在新数组中的位置,逐一在这个过程中链表等于大于8时并没有进行树化为红黑树,而是下次put时再进行树化。

  HashMap的扩容我们已经知道的很清晰了,那么HashMap什么时候进行扩容呢?

    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)
		    //当前数组为null或者数组容量为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;
		//当前容量已经大于扩容容量标准threshold,进行扩容,调用resize方法
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

  从以上代码可以看出,HashMap扩容的2个条件:

  • 1.HashMap的数组为null 或者 数组容量为0时(注意这里是数组容量)
  • 2.当向里面添加键值对后,当前size已经大于扩容容量标准threshold时(注意这里是元素size)

3.6 JDK中1.7 & 1.8 的HashMap区别

  JDK 1.7的HashMap实现数据结构为数组+链表,而JDK 1.8的HashMap实现的数据结构为数组+链表/红黑树

  • 1.JDK 1.7 出现哈希冲突时拉链法采用的是头插法,而JDK 1.8采用的是尾插法
  • 2.JDK 1.8 引入红黑树提高某个位置的查找效率,当链表的大小大于等于8且当前HashMap存放的元素大小超过64,就把链表树化为红黑树。

4.聊聊 JDK 1.7 HashMap存在的一个bug

  在JDK 1.7中存在一个多线程扩容导致死循环的bug,我们来分析分析这个bug产生的场景。

  多线程同时put键值对时,如果同时触发了扩容操作,可能会导致循环链表产生,进而使得后面get的时候,会死循环。为什么呢?

void transfer(Entry[] newTable, boolean rehash) {
	    //获取新表的容量
        int newCapacity = newTable.length;
		//遍历旧表
        for (Entry<K,V> e : table) {
            while(null != e) {
				//标记点1,next指针指向的下个结点
                Entry<K,V> next = e.next;
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity);
				//这里用头插法完成将旧表Entry填充到新表
				//标记点2
                e.next = newTable[i];
				//标记点3
                newTable[i] = e;
				//标记点4
                e = next;
            }
        }
}

  举个例子,假设现在有2个线程,HashMap的数组大小为4,加载因子为1,假设现在往里面先插入3个[c,b,a],现在HashMap的情况如下:

[0]
[1]
[2]
[3]->a->b->c

  注意JDK 1.7的HashMap采用的头插法。此时如果接下来有两个线程1,2分别往里面插入了d和e,由于已经达到了4(数组容量)*1(加载因子)=4的元素个数,此时两个线程同时触发扩容操作,它们分别新建了新的数组如下:

     线程1     线程2
      [0]      [0]
      [1]      [1]
      [2]      [2]
      [3]      [3]
      [4]      [4]
      [5]      [5]
      [6]      [6]
      [7]      [7]

  此时当线程2获取CPU执行权,执行到标记点1"Entry<K,V> next = e.next;",此时线程2的e指向了a,next指向了b,此时线程2失去了CPU执行权,线程1开始进行扩容操作,假设扩容到如下情况:

     线程1              线程2
      [0]               [0]
      [1]               [1]
      [2]               [2]
      [3]               [3]
      [4]               [4]
      [5]               [5]
      [6]               [6]
      [7]->c->b->a      [7]

  此时线程1释放了CPU执行权,现在该线程2工作,由于内部的Table还没有设置成新的Table,此时线程2里e指向了a,next指向了b,接着会执行以下代码:

 while(null != e) {
    //标记点1,next指针指向的下个结点
    Entry<K,V> next = e.next;
    if (rehash) {
        e.hash = null == e.key ? 0 : hash(e.key);
    }
    int i = indexFor(e.hash, newCapacity);
    //这里用头插法完成将旧表Entry填充到新表
    //标记点2
    e.next = newTable[i];
    //标记点3
    newTable[i] = e;
    //标记点4
     e = next;
 };

  接着执行标记点2,3,4,执行后,e指向了b结点,情况如下:

     线程1              线程2
      [0]               [0]
      [1]               [1]
      [2]               [2]
      [3]               [3]
      [4]               [4]
      [5]               [5]
      [6]               [6]
      [7]->c->b->a      [7]->b

因为e指向b结点之后不为null,所以跳不出while循环,又循环执行一次之后,e指向了a,情况如下:

     线程1              线程2
      [0]               [0]
      [1]               [1]
      [2]               [2]
      [3]               [3]
      [4]               [4]
      [5]               [5]
      [6]               [6]
      [7]->c->b->a      [7]->a->b

  因为e指向a结点之后不为null,所以又进入下一次循环,循环之后e执行了null,结束了真个while循环,最终情况如下:

     线程1              线程2
      [0]               [0]
      [1]               [1]
      [2]               [2]
      [3]               [3]
      [4]               [4]
      [5]               [5]
      [6]               [6]
      [7]->c->b->a      [7]->a->b

  惊喜且意外的发现,结合线程1和线程2,线程1中b的next指针指向a,线程2中a的next指针指向了b,形成了一个小圈,假设1线程最后执行完,那么线程1和线程2的扩容最终导致HashMap的内部结构如下:

[0]              
[1]               
[2]               
[3]              
[4]               
[5]               
[6]              
[7]->c->b->a->b->a...(这里是个a和b构成的小环)   

  此时如果进行get操作时就有可能进入死循环,而且当线程2最后执行完,还有可能让c结点消失,这也是bug。
  曾经有人把这个问题报给了Sun,不过Sun不认为这是一个bug,因为在HashMap本来就不支持多线程使用,要并发就用ConcurrentHashmap。
  面试时如何你能将此场景给面试官说清晰,相信你对HashMap的了解面试官已了然于胸。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值