ThreadLocal的源码浅析

3 篇文章 0 订阅
2 篇文章 0 订阅
ThreadLocal的源码浅析
  • 首先来看看ThreadLocal

  • 有什么作用呢?

  • 从RequestContextHolder的实现来看,就知道它的作用是什么了。每个线程有自己本地变量,不管这个线程运行到什么位置,如webmvc里的service层、dao层,都可以通过ThreadLocal这个对象来获得该线程存储的一些信息,在使用上很方便。

  • 另一方面呢,就是在线程同步的时候可以起到作用,能达到和使用synchronized关键字一样的效果,只不过ThreadLocal对象是给每个线程存了一份共享资源(原因在读源码的时候会提到),而synchronized是在同一时刻只能有一个线程使用共享资源。这两种方式正好对应了一句话,一个是以时间换空间,另一个是以空间换时间。

  • 以上这些都是我自己的理解,那看看官方对这个类的描述是什么。
    This class provides thread-local variables. These variables differ from their normal counterparts in that each thread that accesses one (via its get or set method) has its own, independently initialized copy of the variable. ThreadLocal instances are typically private static fields in classes that wish to associate state with a thread (e.g., a user ID or Transaction ID).

  • 说的也差不多,提供线程本地变量,和普通变量的区别是什么以及在类中出现的方式。

  • 在实际的工作中可能还有其他的用途,目前笔者也只能了解到这了,接下来看看它的源码吧。

1.png

  • 可以看到方法不是很多,对外接口只有四个常用的,get、set、remove、构造方法。

    /**
         * Sets the current thread's copy of this thread-local variable
         * to the specified value.  Most subclasses will have no need to
         * override this method, relying solely on the {@link #initialValue}
         * method to set the values of thread-locals.
         *
         * @param value the value to be stored in the current thread's copy of
         *        this thread-local.
         */
        public void set(T value) {
            Thread t = Thread.currentThread();
            ThreadLocalMap map = getMap(t);
            if (map != null)
                map.set(this, value);
            else
                createMap(t, value);
        }
    
  • 在set值的时候,key是ThreadLocal的实例变量引用,值就是要给每个线程的值。get的时候就拿这个引用来获取即可。要注意的是,在实际存储中,key并不是引用,而是ThreadLocal实例变量的一个唯一标识符threadLocalHashCode

    /**
         * ThreadLocals rely on per-thread linear-probe hash maps attached
         * to each thread (Thread.threadLocals and
         * inheritableThreadLocals).  The ThreadLocal objects act as keys,
         * searched via threadLocalHashCode.  This is a custom hash code
         * (useful only within ThreadLocalMaps) that eliminates collisions
         * in the common case where consecutively constructed ThreadLocals
         * are used by the same threads, while remaining well-behaved in
         * less common cases.
         */
        private final int threadLocalHashCode = nextHashCode();
    
        /**
         * The next hash code to be given out. Updated atomically. Starts at
         * zero.
         */
        private static AtomicInteger nextHashCode =
            new AtomicInteger();
    
        /**
         * The difference between successively generated hash codes - turns
         * implicit sequential thread-local IDs into near-optimally spread
         * multiplicative hash values for power-of-two-sized tables.
         */
        private static final int HASH_INCREMENT = 0x61c88647;
    
        /**
         * Returns the next hash code.
         */
        private static int nextHashCode() {
            return nextHashCode.getAndAdd(HASH_INCREMENT);
        }
    
  • 从代码中可以看到这个threadLocalHashCode值是来自静态方法的,说明每个ThreadLocal的实例变量的threadLocalHashCode都不一样,这也正好符合唯一标识符的作用。

  • 代码中还有一个有趣的地方就是HASH_INCREMENT = 0x61c88647,为什么是这个值呢,下面我们会介绍。

    /**
         * Returns the value in the current thread's copy of this
         * thread-local variable.  If the variable has no value for the
         * current thread, it is first initialized to the value returned
         * by an invocation of the {@link #initialValue} method.
         *
         * @return the current thread's value of this thread-local
         */
        public T get() {
            Thread t = Thread.currentThread();
            ThreadLocalMap map = getMap(t);
            if (map != null) {
                ThreadLocalMap.Entry e = map.getEntry(this);
                if (e != null) {
                    @SuppressWarnings("unchecked")
                    T result = (T)e.value;
                    return result;
                }
            }
            return setInitialValue();
        }
    
    /**
         * Removes the current thread's value for this thread-local
         * variable.  If this thread-local variable is subsequently
         * {@linkplain #get read} by the current thread, its value will be
         * reinitialized by invoking its {@link #initialValue} method,
         * unless its value is {@linkplain #set set} by the current thread
         * in the interim.  This may result in multiple invocations of the
         * {@code initialValue} method in the current thread.
         *
         * @since 1.5
         */
         public void remove() {
             ThreadLocalMap m = getMap(Thread.currentThread());
             if (m != null)
                 m.remove(this);
         }
    
  • 可以看出这三个方法都调用了getMap这个方法,

    /**
         * Get the map associated with a ThreadLocal. Overridden in
         * InheritableThreadLocal.
         *
         * @param  t the current thread
         * @return the map
         */
        ThreadLocalMap getMap(Thread t) {
            return t.threadLocals;
        }
    
  • 说明每次都是操作当前线程的t.threadLocals变量,而这个变量的类型就是ThreadLocal的内部类ThreadLocalMap,所以每个线程保存的变量其实是在自己的属性里的,只不过通过ThreadLocal来使用而已,这样就能保证每个线程都有自己的本地变量。

  • 这么看来ThreadLocal还是比较好理解的,下面我们看看它的核心实现ThreadLocalMap

    /**
         * ThreadLocalMap is a customized hash map suitable only for
         * maintaining thread local values. No operations are exported
         * outside of the ThreadLocal class. The class is package private to
         * allow declaration of fields in class Thread.  To help deal with
         * very large and long-lived usages, the hash table entries use
         * WeakReferences for keys. However, since reference queues are not
         * used, stale entries are guaranteed to be removed only when
         * the table starts running out of space.
         */
        static class ThreadLocalMap {...}
    
  • 从描述来看,这个ThreadlocalMap内部静态类是一个只适合维护线程本地变量的并且是自定义的hash map,可以看到它的方法全都是私有的,至于为什么可以在Thread里去访问ThreadLocalMap(但是访问不到这些私有方法,只有在ThreadLocal里才能访问到这些私有方法)是因为它们是在同一个包下的,ThreadLocalMap是包私有的访问级别,基本上不动源码的话,我们是使用不到这个类的,而且从它的位置来看-静态内部类,也没有想过让别人使用。

  • 除此之外呢,还提到了weakreference的使用,这个以后再说。但是我们可以从下面的注释了解它的作用。

    	static class ThreadLocalMap {
    		/**
             * The entries in this hash map extend WeakReference, using
             * its main ref field as the key (which is always a
             * ThreadLocal object).  Note that null keys (i.e. entry.get()
             * == null) mean that the key is no longer referenced, so the
             * entry can be expunged from table.  Such entries are referred to
             * as "stale entries" in the code that follows.
             */
            static class Entry extends WeakReference<ThreadLocal<?>> {
                /** The value associated with this ThreadLocal. */
                Object value;
    
                Entry(ThreadLocal<?> k, Object v) {
                    super(k);
                    value = v;
                }
            }
            ...
         }
    
  • 如果entry的key为null时,这个entry就不再被引用,自然就会被从table中剔除。同时这个entry称作过期的entry。

  • 接下来为了在整体上有个印象,先来看看ThreadLocalMap的get、set方法,它们是如何实现的?实现的过程中做了什么事情?首先是getEntry()

    /**
             * Get the entry associated with key.  This method
             * itself handles only the fast path: a direct hit of existing
             * key. It otherwise relays to getEntryAfterMiss.  This is
             * designed to maximize performance for direct hits, in part
             * by making this method readily inlinable.
             *
             * @param  key the thread local object
             * @return the entry associated with key, or null if no such
             */
            private Entry getEntry(ThreadLocal<?> key) {
                int i = key.threadLocalHashCode & (table.length - 1);
                Entry e = table[i];
                if (e != null && e.get() == key)
                    return e;
                else
                    return getEntryAfterMiss(key, i, e);
            }
    
  • 一眼看过来,代码比较少,过程也比较简单,先根据ThreadLocal对象的hashcode取模获得索引(至于为什么是key.threadLocalHashCode & (table.length - 1),之后再讲),然后根据索引获得值,如果直接命中,就结束,不命中,就调用getEntryAfterMiss这个方法。

    /**
             * Version of getEntry method for use when key is not found in
             * its direct hash slot.
             *
             * @param  key the thread local object
             * @param  i the table index for key's hash code
             * @param  e the entry at table[i]
             * @return the entry associated with key, or null if no such
             */
            private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
                Entry[] tab = table;
                int len = tab.length;
    
                while (e != null) {
                    ThreadLocal<?> k = e.get();
                    if (k == key)
                        return e;
                    if (k == null)
                        expungeStaleEntry(i);
                    else
                        i = nextIndex(i, len);
                    e = tab[i];
                }
                return null;
            }
    
  • 从这个方法的实现可以看出,它在一直循环,直到获得一个对应key的值,在这个循环中,不甘心只做一件事,还剔除了一些过期的entry。就是调用了expungeStaleEntry(i)这个方法,这个方法很重要,在很多地方都会被调用,顺便看看它怎么实现的吧:

    /**
             * Expunge a stale entry by rehashing any possibly colliding entries
             * lying between staleSlot and the next null slot.  This also expunges
             * any other stale entries encountered before the trailing null.  See
             * Knuth, Section 6.4
             *
             * @param staleSlot index of slot known to have null key
             * @return the index of the next null slot after staleSlot
             * (all between staleSlot and this slot will have been checked
             * for expunging).
             */
            private int expungeStaleEntry(int staleSlot) {
                Entry[] tab = table;
                int len = tab.length;
    
                // expunge entry at staleSlot
                tab[staleSlot].value = null;
                tab[staleSlot] = null;
                size--;
    
                // Rehash until we encounter null
                Entry e;
                int i;
                for (i = nextIndex(staleSlot, len);
                     (e = tab[i]) != null;
                     i = nextIndex(i, len)) {
                    ThreadLocal<?> k = e.get();
                    if (k == null) {
                        e.value = null;
                        tab[i] = null;
                        size--;
                    } else {
                        int h = k.threadLocalHashCode & (len - 1);
                        if (h != i) {
                            tab[i] = null;
    
                            // Unlike Knuth 6.4 Algorithm R, we must scan until
                            // null because multiple entries could have been stale.
                            while (tab[h] != null)
                                h = nextIndex(h, len);
                            tab[h] = e;
                        }
                    }
                }
                return i;
            }
    
  • 从注释就可以知道,它是把那些在过期实体所处位置到下一个为null的实体的位置之间的所有可能冲突了的实体都重新哈希了一遍,同时在这个过程中把那些过期的实体剔除。在代码中,主要看for循环,这两件事就是分别在if...else...中做的。接下来再回到set方法上来,如下:

    /**
             * Set the value associated with key.
             *
             * @param key the thread local object
             * @param value the value to be set
             */
            private void set(ThreadLocal<?> key, Object value) {
    
                // We don't use a fast path as with get() because it is at
                // least as common to use set() to create new entries as
                // it is to replace existing ones, in which case, a fast
                // path would fail more often than not.
    
                Entry[] tab = table;
                int len = tab.length;
                int i = key.threadLocalHashCode & (len-1);
    
                for (Entry e = tab[i];
                     e != null;
                     e = tab[i = nextIndex(i, len)]) {
                    ThreadLocal<?> k = e.get();
    
                    if (k == key) {
                        e.value = value;
                        return;
                    }
    
                    if (k == null) {
                        replaceStaleEntry(key, value, i);
                        return;
                    }
                }
    
                tab[i] = new Entry(key, value);
                int sz = ++size;
                if (!cleanSomeSlots(i, sz) && sz >= threshold)
                    rehash();
            }
    
  • 也还比较简单,逻辑很清楚。首先由key获得索引i,然后从这个i位置遍历直到下个位置没有entry结束,如果有个位置的entry的key和入参key一致,说明之前set过,直接覆盖,如果key为null,说明这个entry是过期的,那么就调用replaceStaleEntry替换掉这个过期的entry,这个方法也是做了很多事情,以前set过呢,要清理一下,没有set过呢,也要清理一下,来看看它的实现吧:

/**
     * Replace a stale entry encountered during a set operation
     * with an entry for the specified key.  The value passed in
     * the value parameter is stored in the entry, whether or not
     * an entry already exists for the specified key.
     *
     * As a side effect, this method expunges all stale entries in the
     * "run" containing the stale entry.  (A run is a sequence of entries
     * between two null slots.)
     *
     * @param  key the key
     * @param  value the value to be associated with key
     * @param  staleSlot index of the first stale entry encountered while
     *         searching for key.
     */
    private void replaceStaleEntry(ThreadLocal<?> key, Object value,
                                   int staleSlot) {
        Entry[] tab = table;
        int len = tab.length;
        Entry e;

        // Back up to check for prior stale entry in current run.
        // We clean out whole runs at a time to avoid continual
        // incremental rehashing due to garbage collector freeing
        // up refs in bunches (i.e., whenever the collector runs).
        int slotToExpunge = staleSlot;
        for (int i = prevIndex(staleSlot, len);
             (e = tab[i]) != null;
             i = prevIndex(i, len))
            if (e.get() == null)
                slotToExpunge = i;

        // Find either the key or trailing null slot of run, whichever
        // occurs first
        for (int i = nextIndex(staleSlot, len);
             (e = tab[i]) != null;
             i = nextIndex(i, len)) {
            ThreadLocal<?> k = e.get();

            // If we find key, then we need to swap it
            // with the stale entry to maintain hash table order.
            // The newly stale slot, or any other stale slot
            // encountered above it, can then be sent to expungeStaleEntry
            // to remove or rehash all of the other entries in run.
            if (k == key) {
                e.value = value;

                tab[i] = tab[staleSlot];
                tab[staleSlot] = e;

                // Start expunge at preceding stale entry if it exists
                if (slotToExpunge == staleSlot)
                    slotToExpunge = i;
                cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
                return;
            }

            // If we didn't find stale entry on backward scan, the
            // first stale entry seen while scanning for key is the
            // first still present in the run.
            if (k == null && slotToExpunge == staleSlot)
                slotToExpunge = i;
        }

        // If key not found, put new entry in stale slot
        tab[staleSlot].value = null;
        tab[staleSlot] = new Entry(key, value);

        // If there are any other stale entries in run, expunge them
        if (slotToExpunge != staleSlot)
            cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
    }
  • 从注释可以看出,它是在set操作中调用的,别的地方没有调过。staleSlot是过期entry的位置索引,从这个位置开始向前找到最靠前过期entry的位置,直到遇到空的entry终止,然后向后找找看是不是以前set过这个key,如果set过,那么覆盖,并把这个entry位置提前,接着就是清理了,cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);这行代码清理两次,一次是expungeStaleEntry,上面提到过这个方法,从当前位置到下一个null之间的过期entry清理掉,连续清理一段,叫做连续段清理,另一次是cleanSomeSlots,这个方法看下图:

    /**
             * Heuristically scan some cells looking for stale entries.
             * This is invoked when either a new element is added, or
             * another stale one has been expunged. It performs a
             * logarithmic number of scans, as a balance between no
             * scanning (fast but retains garbage) and a number of scans
             * proportional to number of elements, that would find all
             * garbage but would cause some insertions to take O(n) time.
             *
             * @param i a position known NOT to hold a stale entry. The
             * scan starts at the element after i.
             *
             * @param n scan control: {@code log2(n)} cells are scanned,
             * unless a stale entry is found, in which case
             * {@code log2(table.length)-1} additional cells are scanned.
             * When called from insertions, this parameter is the number
             * of elements, but when from replaceStaleEntry, it is the
             * table length. (Note: all this could be changed to be either
             * more or less aggressive by weighting n instead of just
             * using straight log n. But this version is simple, fast, and
             * seems to work well.)
             *
             * @return true if any stale entries have been removed.
             */
            private boolean cleanSomeSlots(int i, int n) {
                boolean removed = false;
                Entry[] tab = table;
                int len = tab.length;
                do {
                    i = nextIndex(i, len);
                    Entry e = tab[i];
                    if (e != null && e.get() == null) {
                        n = len;
                        removed = true;
                        i = expungeStaleEntry(i);
                    }
                } while ( (n >>>= 1) != 0);
                return removed;
            }
    
  • 从注释知道这个叫启发式清理,时间是log(n),从while ( (n >>>= 1) != 0)可以看出。简单,快速,效果好。

  • 这次清理是紧接着连续段清理来做清理的,所以两次清理的范围还是比较大的。

  • 再回过头来,如果向后找没有发现set过,那么就在当前过期(staleSlot)的entry上存储传进来的value值,之后呢,如果向前找过期entry找到的话就清理一下,也是两次清理。

  • 总的来说,这个replaceStaleEntry会清理两个null位置的所有过期entry。

  • 再回到set上来,当遍历结束后,也就是既没有set过,也没有过期的entry,就找到最近的一个空位置存储值,然后根据情况看是否要重新hash一下,这个情况要两个条件同时满足才行,一个是启发式清理到了过期的entry,另一个是entry的数量大于阈值;对于这个rehash,它的实现如下图:

    /**
             * Re-pack and/or re-size the table. First scan the entire
             * table removing stale entries. If this doesn't sufficiently
             * shrink the size of the table, double the table size.
             */
            private void rehash() {
                expungeStaleEntries();
    
                // Use lower threshold for doubling to avoid hysteresis
                if (size >= threshold - threshold / 4)
                    resize();
            }
    
  • rehash第一件做的事呢就是清理所有的过期entry,实现也简单,就是遍历每一个entry,如下图:

    /**
             * Expunge all stale entries in the table.
             */
            private void expungeStaleEntries() {
                Entry[] tab = table;
                int len = tab.length;
                for (int j = 0; j < len; j++) {
                    Entry e = tab[j];
                    if (e != null && e.get() == null)
                        expungeStaleEntry(j);
                }
            }
    
  • 清理完之后如果entry的数量还是没有缩减到小于阈值的四分之三,说明这次清理的过期的entry有点少,那么可以肯定table装的东西还是挺多的,都是真货,既然这样,就给它扩容吧,调用resize方法,如下:

    /**
             * Double the capacity of the table.
             */
            private void resize() {
                Entry[] oldTab = table;
                int oldLen = oldTab.length;
                int newLen = oldLen * 2;
                Entry[] newTab = new Entry[newLen];
                int count = 0;
    
                for (int j = 0; j < oldLen; ++j) {
                    Entry e = oldTab[j];
                    if (e != null) {
                        ThreadLocal<?> k = e.get();
                        if (k == null) {
                            e.value = null; // Help the GC
                        } else {
                            int h = k.threadLocalHashCode & (newLen - 1);
                            while (newTab[h] != null)
                                h = nextIndex(h, newLen);
                            newTab[h] = e;
                            count++;
                        }
                    }
                }
    
                setThreshold(newLen);
                size = count;
                table = newTab;
            }
    
  • 扩容两倍,实现也比较简单。

  • 好的,到现在呢,set方法是分析完了,前面也把get讲了,应该还有一个remove,来看看源码:

    /**
         * Removes the current thread's value for this thread-local
         * variable.  If this thread-local variable is subsequently
         * {@linkplain #get read} by the current thread, its value will be
         * reinitialized by invoking its {@link #initialValue} method,
         * unless its value is {@linkplain #set set} by the current thread
         * in the interim.  This may result in multiple invocations of the
         * {@code initialValue} method in the current thread.
         *
         * @since 1.5
         */
         public void remove() {
             ThreadLocalMap m = getMap(Thread.currentThread());
             if (m != null)
                 m.remove(this);
         }
    
  • 实现也清晰,遍历找到key相同的entry,然后调用entry到clear方法,把key设置为null,然后调用expungeStaleEntry清理一次;这个Entry继承了WeakReference类,说明每个entry对象是一个弱引用对象,只不过这个对象还维护着一个值。

  • OK!ThreadLocalMap到这呢,基本上是分析完了,七百多行的代码(jdk8),都是围绕get,set,remove来写的,大部分代码都是在给这个数据结构做维护工作,什么时候该扩容啊、什么时候该清理呀、该做怎样的清理啊,等等,最终完成了一个可用的数据结构。

  • 接下来要提到的呢就是实现ThreadLocalMap用到的一些技术,也是从网上收集的一些,供大家参考。

  • 首先来看看ThreadLocalMap的两个索引操作的方法:

    /**
             * Increment i modulo len.
             */
            private static int nextIndex(int i, int len) {
                return ((i + 1 < len) ? i + 1 : 0);
            }
    
            /**
             * Decrement i modulo len.
             */
            private static int prevIndex(int i, int len) {
                return ((i - 1 >= 0) ? i - 1 : len - 1);
            }
    
  • 可以看出对于冲突的解决,它是使用了开放定址法的线性探测方法,

  • 再来看看ThreadLocal的hashcode,注意到0x61c88647这个数字,每次new了一个ThreadLocal后,这个新ThreadLocal对象的threadLocalHashCode就是基于前面ThreadLocal对象的threadLocalHashCode加上0x61c88647之后的值,从这个值的注释看到,要求table的大小为2的n次方;为什么是这个值呢?这是有讲究的,这个数字和斐波那契散列法以及黄金分割有关。

  • 那这个threadLocalHashCode用来干嘛呢,上面也提到过,int i = key.threadLocalHashCode & (table.length - 1);是用来取模计算索引,产生哈希码的,由于使用了0x61c88647,产生的哈希码比较均匀;另外这种&式取模比%式取模要快。

  • 参考资料,比较好的还可以参考ThreadLocal源码解读

  • 关于ThreadLocal的推荐用法就是在使用完后调用remove方法。关于内存泄露的问题可参考深入分析 ThreadLocal 内存泄漏问题

ThreadLocal源码Java中一个关键的类,它提供了一种在多线程环境下实现线程本地变量的机制。在JDK 8之前和之后,ThreadLocal的内部结构有所变化。ThreadLocal源码分为两部分:ThreadLocal类和ThreadLocalMap类。 ThreadLocal类是一个泛型类,它包含了两个核心方法:set()和get()。set()方法用于将一个值与当前线程关联起来,get()方法用于获取当前线程关联的值。 ThreadLocalMap类是ThreadLocal的内部类,它用于存储每个线程的本地变量。在JDK 8之前,ThreadLocalMap是通过线性探测法解决哈希冲突的,每个ThreadLocal对象都对应一个Entry对象,Entry对象包含了ThreadLocal对象和与之关联的值[2]。 在JDK 8之后,ThreadLocalMap的实现方式发生了改变。使用了类似于HashMap的方式,采用了分段锁的机制来提高并发性能。每个线程维护一个ThreadLocalMap对象,其中的Entry对象也是采用链表的形式来解决哈希冲突。 总结起来,ThreadLocal源码主要由ThreadLocal类和ThreadLocalMap类组成。ThreadLocal类提供了set()和get()方法来管理线程本地变量,而ThreadLocalMap类则负责存储每个线程的本地变量,并解决哈希冲突的问题。 史上最全ThreadLocal 详解 ThreadLocal源码分析_02 内核(ThreadLocalMap) 【JDK源码】线程系列之ThreadLocal 深挖ThreadLocal ThreadLocal原理及内存泄露预防 ThreadLocal原理详解——终于弄明白了ThreadLocal ThreadLocal使用与原理 史上最全ThreadLocal 详解。 ThreadLocal源码分析,主要有ThreadLocal源码以及ThreadLocal的内部结构在jdk8前后的变化。 使用方式非常简单,核心就两个方法set/get public class TestThreadLocal { private static final ThreadLocal<String> threadLocal = new ThreadLocal<>(); public static void main(String[] args) { new Thread(new Runnable() { @Override public void run() { try { threadLocal.set("aaa"); Thread.sleep(500); System.out.println("threadA:" threadLocal.get()); } catch (InterruptedException e) { e.printStackTrace(); } } }).start(); new Thread(new Runnable() { @Override public void run() { threadLocal.set("bbb"); System.out.println("threadB:" threadLocal.get()); } }).start(); } }
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值