面试查漏补缺--java基础-容器源码解读

前言:
本文主要是通过源码来解读一些自己还不懂的地方,一些数据结构上的东西,不做过多的解读。


文章目录


一、容器体系

容器总的来说分为两大类:
1、Collection:
存放的是单建元素(就是单个元素)

2、Map<K,V>:
存放的键值对(以Key-Value的方式存在)

  • 1、Collection下的两大接口,List 和 Set
    在这里插入图片描述
  • Map下的三大容器(都是以建值对的方式向Map中存放值)
    在这里插入图片描述

二、List容器

List定义的模板方法如下:
在这里插入图片描述

2.1 ArrayList源码

底层的数据结构是数组,所以使用idx
在这里插入图片描述

学习重点在于扩容(add方法中很重要):

// 测试代码
public class ListTest {
    public static void main(String[] args) {
        List<Object> list = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            list.add(i);
        }
        list.add(20000);
    }
}

1、使用默认的构造函数
在这里插入图片描述

2、调用add方法

  • (1)先确认是否需要扩容
    在这里插入图片描述
    -(2)这就是默认容量为10的代码
    在这里插入图片描述
    -(3)明确容量(判断是否扩容)
    1、modCount这是一个全局变量,每次添加值都会对其++,
    2、判断现有的数组长度是否已经不能容下新元素了
    3、在我的测试代码中,前十个是不会扩容的,到第11个才会进入if
    在这里插入图片描述
    -(4)扩容
    将原来的数组扩容原来的1.5倍得到一个新的数组,将原来的值copy到新数组。
    在这里插入图片描述

2.2 Vector 源码

其实看了源码可以知道:
Vector底层也一个数组,基本上和ArrayList一直

通过看源码,我们可以知道Vector是通过对每个方法添加synchronized关键字来保证这是一个线程安全的容器。

那么这样会使我们再使用这些方法时效率不高。

所以我们再日常开发中,很少使用他,但是再有竞争的情况下可以使用一下【其实使用也不是特别多,我们都是通过自己去完成的多】

在这里插入图片描述

2.3 LinkedList

1、底层是一个双向链表的数据结构

源码是定义了一个node节点,来存放前驱、后驱和元素

他的CRUD也是比较简单,主要就是基于双向链表的CRUD,要考虑好前驱指针和后驱指针指向的地址,就能弄明白。
在这里插入图片描述
在这里插入图片描述

  • List下ArrayList和LinkedList的比较

在这里插入图片描述

三、Set容器

3.1 简介:

不可重复,无序(其实是按运算出来的hash值排序的【这里不是hashCode】)

3.2 HashSet(底层同HashMap)

  • 1、HashSet简介:
    他的底层是由HashMap完成的,就是等价于HashMap
    hashMap的底层数据结构,见安琪拉大佬的图
    在这里插入图片描述

总体add流程如下图:
在这里插入图片描述
==》下图来自安琪拉大佬的流程图
在这里插入图片描述

  • 测试代码
HashSet<Object> objects1 = new HashSet<>();
        for (int i = 0; i < 3; i++) {
            objects1.add(i);
        }
        // 重复(看比较)
        objects1.add(2);
2、HashSet源码解读(关键注释点【其实就是hashMap的源码】)

1)构造方法
构造方法也很清楚就是,hashMap
在这里插入图片描述
2)add方法

  • 2.1 向hashMap中put数据,key就是我们add进来的值,Value是共享的一个填充对象
    在这里插入图片描述
    填充对像
    在这里插入图片描述
    ===========>这里往下就是HashMap的东西咯,小东西真面目流露了

  • 2.2 再往下面调用Map中的put方法了
    在这里插入图片描述

  • 2.3 将hashCode进行运算

将hashcode进行高16位和低16位进行异或运算(这样做的目的就是尽量的避免哈希冲突)==》得到hash值
在这里插入图片描述

  • 2.4 put的核心代码注释

第一段代码:

    /**
     * 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;
        // 当定义的属性,数组位null 或者长队为0时调用resize方法进行长度的重置(下面的第二段代码注释)
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
         // 使用n-1&hash计算下标值(数组长度&hash)
        if ((p = tab[i = (n - 1) & hash]) == null)
        //  当前下标为null,就放到对应的下标位置(放hash【方便做比较】,key,和value)
            tab[i] = newNode(hash, key, value, null);
        else {
        // 如果对应的下标位置不为null,那就是发生了hash碰撞
            Node<K,V> e; K k; // 辅助局部变量
            // 判断hash值是否相同(只要内存地址和equals一个比较为true,直接覆盖)      
            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);
                        // 每次都会曲判断链表的长度是不是大于8,=》准备转红黑树
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st						// 转为红黑树(这里面还必须判断数组长度是不是等于64)【下一段代码】
                            treeifyBin(tab, hash);
                        break;
                    }
                    // 判断链表中的每个节点是否k相同
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

第二段代码:
// 注意:1、阈值 2、为什么数组的长度是2次幂的

    /**
     * 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;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        // 临时辅助变量
        int newCap, newThr = 0;
        if (oldCap > 0) {
        // 必须是二次幂,判断是不是二次幂,是的话就赋值,不是的话将他改成二次幂的数
        // 二次幂也是为了避免hash碰撞,在hash表中完成散列。
        //(n-1)&hash这个是原因,如果不是二次幂,那n-1之后个位一定是0,那0和&任何数都是0,达不到完全散列
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
        else {               // zero initial threshold signifies using defaults
        // 这里是初次使用的默认值是16
            newCap = DEFAULT_INITIAL_CAPACITY;
            // 这是给扩容值进行计算(阈值是0.75*默认长度)
            //【这里之所以使用阈值计算扩容值,是为了防止并情况下扩容的问题假设你到满了才扩容,比如还剩4个位置,刚好同时来了5个,那怎么办呢】
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        threshold = newThr;
        // 构建对应容量的数组
        @SuppressWarnings({"rawtypes","unchecked"})
            Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
        if (oldTab != null) {
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                if ((e = oldTab[j]) != null) {
                    oldTab[j] = null;
                    if (e.next == null)
                        newTab[e.hash & (newCap - 1)] = e;
                    else if (e instanceof TreeNode)
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else { // preserve order
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        do {
                            next = e.next;
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

第三段代码(怎么转为红黑树还需要再看一下)

    /**
     * Replaces all linked nodes in bin at index for given hash unless
     * table is too small, in which case resizes instead.
     */
    final void treeifyBin(Node<K,V>[] tab, int hash) {
        int n, index; Node<K,V> e;
        // 判断长度是否等于64(小于的话,先resize()方法扩容数组【第二段代码】)
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
            resize();
        else if ((e = tab[index = (n - 1) & hash]) != null) {
            TreeNode<K,V> hd = null, tl = null;
            do {
                TreeNode<K,V> p = replacementTreeNode(e, null);
                if (tl == null)
                    hd = p;
                else {
                    p.prev = tl;
                    tl.next = p;
                }
                tl = p;
            } while ((e = e.next) != null);
            if ((tab[index] = hd) != null)
                hd.treeify(tab);
        }
    }

3.4 LinkedHashSet(底层同LinkHashMap)

1、LinkedHashSet简介

底层是由LinkHashMap来实现的
底层的数据结构是:数组+双向链表

  • 1、LinkedHashMap继承图(继承hashSet)
    在这里插入图片描述
    2、LinkedHashMap继承图(继承hashMap)

在这里插入图片描述

2、LinkedHashSet源码

总体来说和HashSet差不多,我们主要关注的就是双向链表

如图是LinkedHashMap中Entry是一个双向链表的节点,在原来hashMap的Node中添加了一个头指针和尾指针
在这里插入图片描述

四、Map容器

4.1 Map容器简介

  • 1、Map容器主要是存放Key-Value的键值对
    在这里插入图片描述

  • 2、继承图

在这里插入图片描述

4.2 HashMap(HashSet中讲过,这里介绍忽略,自己跟一下源码会更清楚,这一小节主要跟一下树化的源码)

1、HashMap简介
2、树化源码解读
  • (1)变成双向链表的树形节点
    /**
     * Replaces all linked nodes in bin at index for given hash unless
     * table is too small, in which case resizes instead.
     */
    final void treeifyBin(Node<K,V>[] tab, int hash) {
        int n, index; Node<K,V> e;
        // 先判断数组是否=64,否的话就先扩容
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
            resize();
         // 判断数组的当前位置是不是已经是node节点了,是的话下面的树化操作就不进行
        else if ((e = tab[index = (n - 1) & hash]) != null) {
        // 辅助变量(大神的代码永远是要用到辅助变量的话才会去定义)
            TreeNode<K,V> hd = null, tl = null;
            // 使用 do-while循环,将每个节点树化
            do {
            // 将节点树化
                TreeNode<K,V> p = replacementTreeNode(e, null);
                // 判读辅助变量(那么第一个节点一定是null)
                if (tl == null)
                // null的时候走这个节点(辅助变量赋值)
                    hd = p;
                else {
                // 当有值的时候,就将其前驱指针和后驱指针指向对应的位置
                    p.prev = tl;
                    tl.next = p;
                }
                // 将没吃树化完成的节点就赋值给tl这个辅助变量
                tl = p;
            } while ((e = e.next) != null);
            // 这时候table是一个链表,但是这时候是一个每个节点树形节点的双向链表
            if ((tab[index] = hd) != null)
            // 将树形节点的双向链表正在的树化
                hd.treeify(tab);
        }
    }

(2)真正的树化(红黑树这块是摘抄的需要深入的学习一下红黑树 https://blog.csdn.net/Saintmm/article/details/121582015)
1、遍历TreeNode双向链表,确定待插入节点x在其父节点的左边还是右边,然后将其插入节点到红黑树中;

2、插入节点之后树结构发生变化,需要通过变色和旋转操作维护红黑树的平衡;

3、因为调整了红黑树,root节点可能发生了变化,所以需要把最新的root节点放到双向链表的头部,并插⼊到table数组中。

        /**
         * Forms tree of the nodes linked from this node.
         * @return root of tree
         */
        final void treeify(Node<K,V>[] tab) {
    TreeNode<K,V> root = null;
    // 最开始的x表示TreeNode双向链表的头结点
    for (TreeNode<K,V> x = this, next; x != null; x = next) {
        next = (TreeNode<K,V>)x.next;
        x.left = x.right = null;
        // 构建树的根节点
        if (root == null) {
            x.parent = null;
            x.red = false;
            root = x;
        }
        else {
            // 第一部分
            K k = x.key;
            int h = x.hash;
            Class<?> kc = null;
            // p 表示parent节点
            for (TreeNode<K,V> p = root;;) {
                // dir表示x节点在parent节点的左侧还是右侧
                int dir, ph; // ph表示parent节点的hash值
                K pk = p.key; // pk表示parent节点的key值
                // x节点在parent节点的左侧
                if ((ph = p.hash) > h)
                    dir = -1;
                // x节点在parent节点的右侧
                else if (ph < h)
                    dir = 1;
                else if ((kc == null &&
                          (kc = comparableClassFor(k)) == null) ||
                         (dir = compareComparables(kc, k, pk)) == 0)
                    dir = tieBreakOrder(k, pk);

                // 第二部分
                TreeNode<K,V> xp = p; // xp表示x的父节点
                // 如果p节点的左节点/右节点不为空,则令p = p.left/p.right,继续循环
                // 直到p.left/p.right为空
                if ((p = (dir <= 0) ? p.left : p.right) == null) {
                    // 令待插入节点x的父节点为xp, 即p
                    x.parent = xp;
                    // 根据dir判断插入到xp的左子树(<0)还是右子树(>0)
                    if (dir <= 0)
                        xp.left = x;
                    else
                        xp.right = x;
                    // 往红黑树中插入节点后,进行树的平衡操作
                    root = balanceInsertion(root, x);
                    break;
                }
            }
        }
    }
    // 将root节点插入到table数组中
    moveRootToFront(tab, root);
}

  • 红黑树的引入原因:
    线性查找 —性能低—>二分查找— 二查叉树会出现退化成链表的问题—>出现AVL平衡二叉树—数据变化有频繁更新节点问题—>出现红黑树

  • 红黑树视频链接
    https://www.bilibili.com/video/BV1Tb4y197Fe/?spm_id_from=333.337.search-card.all.click&vd_source=3f506dfd7b2a19c74f99363591d70f4c

4.3 HashMap最变态的面试题

(总结+拓展于囧辉大神 囧辉大神链接

1、HashMap的数据结构

JDK1.8底层是由“数组+链表+红黑树”组成
在这里插入图片描述
JDK1.7的时候是数组+链表

2、1.8 为什么要改成“数组+链表+红黑树”?
  • 1、最主要的是为了提高查找效率,因为当有大量的哈希碰撞时,如果是链表的话查询效率太低了。链表的时间复杂度是 O(n);红黑树的时间复杂度是O(logn)
3、那在什么时候用链表?什么时候用红黑树?

这个源码里解释过

  • 1、当数组的长度=64并且链表的长度大于8时转为红黑树
  • 2、对于移除,当同一个索引位置的节点在移除后达到 6 个,并且该索引位置的节点为红黑树节点,会触发红黑树节点转链表节点(untreeify)。

如下图:
1、树形节点每次会调用split方法进行修剪树形结构
在这里插入图片描述
2、当树的节点小于6的时候转化为链表
在这里插入图片描述
3、调用untreeify方法,转为链表


        /**
         * Returns a list of non-TreeNodes replacing those linked from
         * this node.
         */
        final Node<K,V> untreeify(HashMap<K,V> map) {
            Node<K,V> hd = null, tl = null;
            for (Node<K,V> q = this; q != null; q = q.next) {
                Node<K,V> p = map.replacementNode(q, null);
                if (tl == null)
                    hd = p;
                else
                    tl.next = p;
                tl = p;
            }
            return hd;
        }
4、为什么链表转红黑树的阈值是8?
  • 1、是对于时间和空间上的权衡
    • 红黑树的节点大小是链表节点的两倍
    • 在数据少的情况下就不需要需要通过浪费空间来换取查询效率了,少的时候链表的查询速度是可以接受的
  • 2、也是为了避免频繁转化为红黑树,影响效率
    • 节点的分布频率会遵循泊松分布,链表长度达到8个元素的概率为0.00000006,几乎是不可能事件.(因此可以发现,链表的长度几乎不会达到8的长度,很少进行扩容,影响效率)
5、那为什么转回链表节点是用的6而不是复用8?

由上面一问,可以知道,我们做的这么多,其实很多都要尽量的避免链表和树之间的转换,因为转换的太频繁开销太大了。

那么选择6而不是8就是为了避免在一个值周围来回的徘徊,导致一直转换

6、HashMap 有哪些重要属性?分别用于做什么的(看源码)?

在这里插入图片描述
看注释就行
除了用来存储我们的节点 table 数组外,HashMap 还有以下几个重要属性:
1)size:HashMap 已经存储的节点个数;
2)threshold:扩容阈值,当 HashMap 的个数达到该值,触发扩容。
3)loadFactor:负载因子,扩容阈值 = 容量 * 负载因子。

7、HashMap 的默认初始容量是多少?HashMap 的容量有什么限制吗?
  • 初始的话:16
  • 限制:长度必须是2次幂,原因上文见源码解读中
8、2次幂是怎么算的

HashMap 会根据我们传入的容量计算一个大于等于该容量的最小的2的N次方,例如传 9,容量为16。

 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;
    }
9、ashMap 的容量必须是 2 的 N 次方,这是为什么?

上文有

10、你说 HashMap 的默认初始容量是 16,为什么是16而不是其他的?

答:
我认为是16的原因主要是:16是2的N次方,并且是一个较合理的大小。如果用8或32,我觉得也是OK的。实际上,我们在新建 HashMap 时,最好是根据自己使用情况设置初始容量,这才是最合理的方案。

11、负载因子为什么是0.75,达到数组的扩容条件

这个也是在时间和空间上权衡的结果。
如果值较高,例如1,此时会减少空间开销,但是 hash 冲突的概率会增大,增加查找成本;而如果值较低,例如 0.5 ,此时 hash 冲突会降低,但是有一半的空间会被浪费,所以折衷考虑 0.75 似乎是一个合理的值。

12、HashMap 的插入流程是怎么样的?

见源码

13、计算 key 的 hash 值,是怎么设计的?

见源码

    /**
     * Computes key.hashCode() and spreads (XORs) higher bits of hash
     * to lower.  Because the table uses power-of-two masking, sets of
     * hashes that vary only in bits above the current mask will
     * always collide. (Among known examples are sets of Float keys
     * holding consecutive whole numbers in small tables.)  So we
     * apply a transform that spreads the impact of higher bits
     * downward. There is a tradeoff between speed, utility, and
     * quality of bit-spreading. Because many common sets of hashes
     * are already reasonably distributed (so don't benefit from
     * spreading), and because we use trees to handle large sets of
     * collisions in bins, we just XOR some shifted bits in the
     * cheapest possible way to reduce systematic lossage, as well as
     * to incorporate impact of the highest bits that would otherwise
     * never be used in index calculations because of table bounds.
     */
    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
14、为什么要将 hashCode 的高16位参与运算?(其实我在在手写hash表的时候是直接用hashcode%数组的长度的,这里通过hashcode的高16位和低16位进行了异或运算)
  • 主要是为了在 table 的长度较小的时候,让高位也参与运算,并且不会有太大的开销
  • 详细见辉哥博客
15、扩容(resize)流程介绍下?

在这里插入图片描述

16、HashMap 是线程安全的吗

不是。HashMap 在并发下存在数据覆盖、遍历的同时进行修改会抛出 ConcurrentModificationException 异常等问题,JDK 1.8 之前还存在死循环问题。

17、介绍一下死循环问题?

导致死循环的根本原因是 JDK 1.7 扩容采用的是“头插法”,会导致同一索引位置的节点在扩容后顺序反掉。而 JDK 1.8 之后采用的是“尾插法”,扩容后节点顺序不会反掉,不存在死循环问题。

待跑一下流程

18、那总结下 JDK 1.8 主要进行了哪些优化?

1)底层数据结构从“数组+链表”改成“数组+链表+红黑树”,主要是优化了 hash 冲突较严重时,链表过长的查找性能:O(n) -> O(logn)。

2)计算 table 初始容量的方式发生了改变,老的方式是从1开始不断向左进行移位运算,直到找到大于等于入参容量的值;新的方式则是通过“5个移位+或等于运算”来计算。

// JDK 1.7.0
public HashMap(int initialCapacity, float loadFactor) {
    // 省略
    // Find a power of 2 >= initialCapacity
    int capacity = 1;
    while (capacity < initialCapacity)
        capacity <<= 1;
    // ... 省略
}
// JDK 1.8.0_191
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;
}

3)优化了 hash 值的计算方式,老的通过一顿瞎JB操作,新的只是简单的让高16位参与了运算。

// JDK 1.7.0
static int hash(int h) {
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}
// JDK 1.8.0_191
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

4)扩容时插入方式从“头插法”改成“尾插法”,避免了并发下的死循环。

5)扩容时计算节点在新表的索引位置方式从“h & (length-1)”改成“hash & oldCap”,性能可能提升不大,但设计更巧妙、更优雅。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值