从根本上理解Synchronized的加锁过程

伸手摘星,即使一无所获,亦不致满手污泥

请关注公众号:星河之码

作为一个Java开发,对于Synchronized这个关键字并不会陌生,无论是并发编程,还是与面试官对线,Synchronized可以说是必不可少。

在JDK1.6之前,都认为Synchronized是一个非常笨重的锁,就是在之前的《谈谈Java中的锁》中提到的重量级锁。但是在JDK1.6对Synchronized进行优化后,Synchronized的性能已经得到了巨大提升,也算是脱下了重量级锁这一包袱。本文就来看看Synchronized的使用与原理。

JDK1.6后优化点:

锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等技术来减少锁操作的开销,synchronized的并发性能已经基本与J U C包提供的Lock持平

一、Synchronized的使用

在Java中,synchronized有:【修饰实例方法】、【修饰静态方法】、【修饰代码块】三种使用方式,分别锁住不同的对象。这三种方式,获取不同的锁,锁定共享资源代码段,达到互斥(mutualexclusion)效果,以此保证线程安全。

共享资源代码段又被称之为临界区,锁的作用就是保证临界区互斥,即同一时间临界区的只能有一个线程执行,其他线程阻塞等待,排队等待前一个线程释放锁

1.1 修饰实例方法

作用于当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁

public synchronized void methodA() {
    System.out.println("作用于当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁");
}

1.2 修饰静态方法

给当前类加锁,作用于当前类的所有对象实例,进入同步代码前要获得 当前 class 的锁

当被static修饰时,表明被修饰的代码块或者变量是整个类的一个静态资源,属于类成员,不属于任何一个实例对象,也就是说不管 new 了多少个对象,都只有一份,

public synchronized static void methodB() {
    System.out.println("给当前类加锁,作用于当前类的所有对象实例,进入同步代码前要获得 **当前 class 的锁**。");
}

如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,是不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。

1.3 修饰代码块

指定加锁对象,对给定对象/类加锁

  • synchronized(this|object) 表示进入同步代码库前要获得给定对象的锁

    public void methodc() {
        synchronized(this) {
            System.out.println("锁住的是当前对象,对象锁");
        }
    }
    
  • synchronized(类.class) 表示进入同步代码前要获得 当前 class 的锁

    javapublic void methodd() {
        synchronized(SynchronizedDome.class) {
            System.out.println("给当前类加锁,SynchronizedDome class 的锁");
        }
    }
    

二、Synchronized案例说明

了解了Synchronized的使用方法以后,接下来结合案例的方式,来详细看看Synchronized的加锁,多线程下是怎么执行的,我这里将按照上面三个使用方法来分别使用案例描述

2.1 修饰实例方法案例

  • 案例说明

    两个线程同时对一个共享变量sum进行累加3000,输出其最终结果,我们期望的结果最终应该是6000,接下来看看不加Synchronized修饰和加Synchronized修饰的情况下分别输出什么。

2.1.1 案例
  • 不加Synchronized修饰

    package com.upup.edwin.sync;
    
    public  class SynchronizedDome {
    
        //定义来了一个共享变量
        public static int sum = 0;
    
        // 进行累加3000次
        public void add() {
            for (int i = 0; i < 3000; i++) {
                sum = sum + 1;
            }
        }
    
        public static void main(String[] args) throws InterruptedException {
            SynchronizedDome dome = new SynchronizedDome();
    
            Thread thread1 = new Thread(() -> dome.add());
            Thread thread2 = new Thread(() -> dome.add());
            thread1.start();
            thread2.start();
            //join()  方法是让main线程等待线程 thread1 和thread2 都执行完成之后在继续执行下面的输出
            thread1.join();
            thread2.join();
            System.out.println("两个线程执行完成之后的累加结果:sum = " + sum);
        }
    
    }
    
    

    从结果上看,当我们不加synchronized修饰的时候,输出结果并不是我们锁期待的6000,这说明两个线程之间在执行的时候相互干扰了,也就是线程不安全

  • 加Synchronized修饰

    我们对上面的add方法进行改造,在方法上加上synchronized关键字,也就是加上了锁,来看看它的执行结果

    // 进行累加3000次
    public synchronized void add() {
        for (int i = 0; i < 3000; i++) {
            sum = sum + 1;
        }
    }
    

    加上synchronized修饰后,发现输出结果与我们预期的是一致的,说明加上锁,两个线程是排队顺序执行的

2.1.2 案例执行过程

通过以上的两个案例对比,可以发现在synchronized修饰方法的时候,能够让结果正常输出,保证了线程安全,那么它是怎么做到的吗,两个线程的执行过程是怎么样的呢?

在前面我们提到了,当synchronized修饰实例方法的时候获取的是当前对象实例的锁,我们在代码中new出了一个SynchronizedDome对象,因此本质上锁住的是这个对象

SynchronizedDome dome = new SynchronizedDome();

所有线程要执行同步函数都要先获取锁(synchronized里面叫做监视器锁),获取到锁的线程才能执行同步函数,没有获取到的线程只能等待,抢到锁的线程执行完同步函数后会释放锁并通知唤醒其他等待的线程再次获取锁。流程如下

2.2 修饰静态方法案例

修饰静态方法与修饰实例方法基本一致,唯一的区别就是锁的不是当前对象,而是整个Class对象。我们只需要把上述案例中同步函数改成静态的就可以了

package com.upup.edwin.sync;

public  class SynchronizedDome {

    //定义来了一个共享变量
    public static int sum = 0;

    // 进行累加3000次
    public synchronized static void add() {
        for (int i = 0; i < 3000; i++) {
            sum = sum + 1;
        }
    }

    public static void main(String[] args) throws InterruptedException {

        Thread thread1 = new Thread(() -> add());
        Thread thread2 = new Thread(() -> add());
        thread1.start();
        thread2.start();
        //join()  方法是让main线程等待线程 thread1 和thread2 都执行完成之后在继续执行下面的输出
        thread1.join();
        thread2.join();
        System.out.println("两个线程执行完成之后的累加结果:sum = " + sum);
    }

}

可以看到当我们改成静态方法之后,就不需要在main方法中new SynchronizedDome()了,直接调用add即可,这也说明锁的不是当前对象了,

我们知道在Java中静态资源是属于Class的,不属于任何一个实例对象,而每个Class对象在Jvm中都是唯一的,所以我们锁住Class对象后,其他线程无法获取其静态资源了,从而进入等待阶段,本质上,锁住静态资源的执行过程与锁住实例方法的执行过程是一致的,只是锁的对象不一样而已

2.3 修饰代码块案例

静态资源锁Class,实例方法锁对象,还有一种就是锁住一个方法的某一段代码,也就是代码块。比如我们在上述的add 方法中调用了一个print方法

 public static void print(){
        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println("这是一个不需要加锁的方法,当前执行的线程是 : " + Thread.currentThread().getName());
    }

如上,print就是睡眠了5秒钟后输出一句话,不涉及到线程安全问题,如果使用synchronized修饰整个Add方法,并且在add中调用 print(),如下

public synchronized static void add() {
    print();
    for (int i = 0; i < 3000; i++) {
        sum = sum + 1;
    }
}

这种方式synchronized就会把add()整个包裹,使整个程序执行时间变长,完整案例如下

package com.upup.edwin.sync;

public  class SynchronizedDome {

    //定义来了一个共享变量
    public static int sum = 0;

    // 进行累加3000次
    public synchronized static void add() {
        print();
        for (int i = 0; i < 3000; i++) {
            sum = sum + 1;
        }
    }
    // 可以异步执行的方法
    public static void print(){
        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println("这是一个不需要加锁的方法,当前执行的线程是 : " + Thread.currentThread().getName());
    }


    public static void main(String[] args) throws InterruptedException {
        long l1 = System.currentTimeMillis();
        Thread thread1 = new Thread(() -> add());
        Thread thread2 = new Thread(() -> add());
        thread1.start();
        thread2.start();
        //join()  方法是让main线程等待线程 thread1 和thread2 都执行完成之后在继续执行下面的输出
        thread1.join();
        thread2.join();
        long l2 = System.currentTimeMillis();
        System.out.println("两个线程执行完成的时间是:l2 - l1 = " + (l2 - l1) + " 毫秒");
        System.out.println("两个线程执行完成之后的累加结果:sum = " + sum);

    }

}

以上案例执行结果如下

很明显,两个线程在排队执行Add方法时,连print方法一起等待,但是实际上print是一个线程安全的方法,不需要获取锁,并且print方法还比较耗时,这就拖慢了整个程序的执行总时长,其执行过程如下

这种方式会将线程安全的方法也锁住,导致排队执行代码变多,时间变长,其本质就是synchronized锁住的是整个Add方法,粒度比较大,我们可以对add进行改造一下,让它只锁累计的那一段代码

public static void add() {
    print();
    synchronized(SynchronizedDome.class){
        for (int i = 0; i < 3000; i++) {
            sum = sum + 1;
        }
    }
}

如上,synchronized只锁了这for循环段代码,print()是可以并行执行的,这样就可以提升整个方法的执行效率,完整代码如下

package com.upup.edwin.sync;

public  class SynchronizedDome {

    //定义来了一个共享变量
    public static int sum = 0;

    // 进行累加3000次
    public static void add() {
        print();
        synchronized(SynchronizedDome.class){
            for (int i = 0; i < 3000; i++) {
                sum = sum + 1;
            }
        }
    }
    // 可以异步执行的方法
    public static void print(){
        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println("这是一个不需要加锁的方法,当前执行的线程是 : " + Thread.currentThread().getName());
    }


    public static void main(String[] args) throws InterruptedException {
        long l1 = System.currentTimeMillis();
        Thread thread1 = new Thread(() -> add());
        Thread thread2 = new Thread(() -> add());
        thread1.start();
        thread2.start();
        //join()  方法是让main线程等待线程 thread1 和thread2 都执行完成之后在继续执行下面的输出
        thread1.join();
        thread2.join();
        long l2 = System.currentTimeMillis();
        System.out.println("两个线程执行完成的时间是:l2 - l1 = " + (l2 - l1) + " 毫秒");
        System.out.println("两个线程执行完成之后的累加结果:sum = " + sum);
    }

}

修改之后,整个方法的执行时间只有5秒多,我们休眠的时间也是5秒,说明两个线程是一起进入的休眠,并不是排队的,其执行过程如下

在上述案例中我使用的是锁住整个class的方法:‘synchronized(SynchronizedDome.class)’,如果要改成锁住对象只需要改成’synchronized(this)'即可。其他执行流程都是一样的,只是获取的锁不一样

三、Synchronized原理剖析

以Hotspot(是Jvm的一种实现)为例,在Jvm中每个Class都有一个对象,对象又由 【对象头 + 实例数据 + 对齐填充(java对象必须是8byte的倍数)】三部分组成,每个对象都有一个对象头,synchronized的锁就是存在对象头中的

3.1 对象头

既然synchronized的锁是存在对象头中的,那就先来了解一下对象头,Hotspot 有两种对象头:

  • 数组类型:如果对象是数组类型,则虚拟机用3字节存储对象头
  • 非数组类型:如果对象是非数组类型,则用2字节存储对象头

一般对象头由两部分组成

  • Mark Word

    存储自身的运行时数据,比如:对象的HashCode,分代年龄和锁标志位信息

    Mark Word存储的信息与对象自身定义无关,所以Mark Word是一个一个非固定的数据结构,Mark Word里存储的数据会在运行期间随着锁标志位的变化而变化。

  • Klass Pointer

    类型指针指向它的类元数据的指针。

Mark Word在不同的虚拟机下的bit位不一样,以下是32位与64位虚拟机的对比图


3.2 Monitor

在了解Monitor之前,先思考一个问题,前面我们说synchronized修饰方法和代码块的时候,加锁业务流程的执行过程是一样的,那么他们内部加锁实现是不是一样的呢?

其实加锁过程肯定是不一样的,不然加锁过程一样,锁一样,加锁业务流程的执行过程是一样,那就没必要分成方法和代码块了

我们可以找到上述案例中的SynchronizedDome类的Class文件,然后再命令行中执行javap -c -s -v -l SynchronizedDome.class就可以看到编译后指令集。可以分别查看synchronized修饰方法和代码块指令集的区别

  • synchronized代码块

    上图中的指令集中有monitorenter、monitorexit两个指令,当synchronized修饰代码块时,JVM就是使用monitorenter和monitorexit两个指令实现同步的,当线程执行到monitorenter的时候要先获得monitor锁,才能执行后面的方法。当线程执到monitorexit的时候则要释放锁

  • synchronized修饰方法

上图中的指令集中有一个ACCSYNCHRONIZED标记,当synchronized修饰方法时,JVM通过在方法访问标识符(flags)中加入ACCSYNCHRONIZED来实现同步功能,当线程执行有ACCSYNCHRONIZED标志的方法,需要获得monitor锁。每个对象都与一个monitor相关联,线程可以占有或者释放monitor


3.1什么是Monitor锁

从上面的描述无论是修饰代码块还是修饰方法,都要获取一个Monitor锁,那么什么是Monitor锁呢?

Monitor即监视器,可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。每一个Java对象就有一把看不见的锁,称为内部锁或者Monitor锁

Monitor锁与对象的关系图:

任何一个对象都有一个Monitor与之关联,当且一个Monitor被持有后,它将处于锁定状态。Synchronized在JVM中基于进入和退出Monitor对象,通过成对的MonitorEnter和MonitorExit指令来实现方法同步和代码块同步。

  • MonitorEnter

    插入在同步代码块的开始位置,当代码执行到该指令时,将会尝试获取该对象Monitor锁;

  • MonitorExit

    插入在方法结束处和异常处,JVM保证每个MonitorEnter必须有对应的MonitorExit,释放Monitor锁;

3.2 Monitor锁的工作原理

每一个对象都会有一个monitor锁,Monitor锁的MarkWord锁标识位为10,其中指针指向的是Monitor对象的起始地址

在Java虚拟机(HotSpot)中,Monitor是由ObjectMonitor实现的,ObjectMonitor中维护了一个锁池(EntryList)和等待池(WaitSet)

ObjectMonitor工作模型图如下:

ObjectMonitor工作模型图大致描述了以下几个步骤

  • 所有新的线程都会进入(①号入口)EntryList中去竞争锁

  • 当有线程通过CAS把monitor的owner字段设置为自己时,说明这个线程获取到了锁,也就是进入图中的(②号入口)owner区域,其他线程进入阻塞状态

    如果当前线程是第一次进入该monitor,将recursions由0设置为1,_owner为当前线程,该线程成功获得锁并返回

    如果当前线程不是第一次进入该monitor,说明当前线程再次进入monitor,即重入锁,执行recursions ++ ,记录重入的次数

  • 如果获取到锁的线程(owner)执行了wait等方法,就会释放锁,并进入(③号入口)waitset中,于此同时通知waitset中其他线程重新竞争锁,获取到锁(④号入口)进入owner区域

  • 当线程执行完同步代码,会释放锁(由⑤号口出),于此同时通知waitset和EntryList中其他线程重新竞争锁

    释放锁线程执行monitorexitmonitor的进入数-1,执行过多少次monitorenter,最终要执行对应次数的monitorexit

四、Synchronized锁优化

在之前的《谈谈Java中的锁》中介绍了锁优化的一些基本原理, 接下来看看Synchronized的锁优化。锁优化主要包含:锁粗化、锁消除、锁升级三部分。

4.1 锁粗化

同步代码块要求我们将同步代码的范围尽量缩小,这样可以使同步的操作数量尽可能缩小,缩短阻塞时间,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁

比如上述案例add的循环中,如果将Synchronized防止for循环里面不是范围更小吗?

for (int i = 0; i < 3000; i++) {
    synchronized(SynchronizedDome.class){
        sum = sum + 1;
    }
}

这样虽然缩小了范围,但是未必缩短了时间,因为在加锁过程中也会消耗资源,如果频繁的加锁释放锁,可能会导致性能损耗

基于此,JVM会对这种情况进行锁粗化,锁粗化就是将【多个连续的加锁、解锁操作连接在一起】,扩展成一个范围更大的锁,避免频繁的加锁解锁操作

J V M在检测到上述for循环再频繁获取同一把锁的到时候,就会将加锁的范围粗化到循环操作的外部,使其只需要获取一次锁就可以,减小加锁释放锁的开销。

4.2 锁消除

Java虚拟机在JIT编译时,会进行逃逸分析(对象在函数中被使用,也可能被外部函数所引用,称为函数逃逸),通过对运行上下文的扫描,分析synchronized锁对象是不是只被一个线程加锁,不存在其他线程来竞争加锁的情况。这样就可以消除该锁了,提升执行效率。

锁消除的经典案例就是StringBuffer 了,StringBuffer 是线程安全的,其内部的append方法就是通过synchronized加锁的,源码如下

@Override
public synchronized StringBuffer append(String str) {
    toStringCache = null;
    super.append(str);
    return this;
}

当我们调用StringBuffer 的append时,就会加锁,但是当我们使用的对象经过逃逸分析后,认为该对象不会被其他线程共享的时候,就会将append方法的synchronized去掉,编译不加入monitorenter和monitorexit指令。比如下面这个方法

public static String appendStr(String str, int i) {
    StringBuffer sb= new StringBuffer();
    sb.append(str);
    sb.append(i);
    return sb.toString();
}

StringBuffer的append虽然是同步方法。但appendStr中的sb对象没有传递到方法外,不会被其他线程引用,不存在锁竞争的情况,因此可以进行锁消除

五、Synchronized锁升级

在之前的《谈谈Java中的锁》中介绍了锁的类型,其中介绍了无锁、偏向锁 、轻量级锁、 重量级锁等几种锁的实现。而我们常说的锁升级其实就是这几种锁的升级跃迁。锁升级过程:【无锁】—>【偏向锁】—>【轻量级锁】—>【 重量级锁】。

而锁的变化其实就是一个标志位的变化,在前面提到的对象头中Mark World时有提到它存储的就是对象的HashCode,分代年龄和锁标志位信息。因此锁的升级变化,本质上就是Mark World中锁标志位的变化。以上几种锁的标志位信息如下

锁状态存储内容存储内容
无锁对象的hashCode、对象分代年龄、是否是偏向锁(0)01
偏向锁偏向线程ID、偏向时间戳、对象分代年龄、是否是偏向锁(1)01
轻量级锁指向栈中锁记录的指针00
重量级锁指向互斥量(重量级锁)的指针10

注意:

锁可以升级不可以降级,但是偏向锁状态可以被重置为无锁状态!!!

锁可以升级不可以降级,但是偏向锁状态可以被重置为无锁状态!!!

锁可以升级不可以降级,但是偏向锁状态可以被重置为无锁状态!!!

5.1 无锁升级为偏向锁

  • 为啥要有偏向锁

    大多数情况下是一个线程多次获得同一个锁,不存在锁竞争的,而竞争锁会增大资源消耗,,为了降低获取锁的代价,才引入的偏向锁


当线程第一次执行到同步代码块的时候,锁对象变成就会偏向锁(通过CAS修改对象头里的锁标志位),其目标就是在只有一个线程执行同步代码块时,降低获取锁带来的消耗,提高性能

偏向锁是默认开启的,而且开始时间一般是比应用程序启动慢几秒,可以通过JVM配置成没有延迟

-XX:BiasedLockingStartUpDelay=0

可以通过J V M参数关闭偏向锁,关闭之后程序默认会进入轻量级锁状态

-XX:-UseBiasedLocking=false

无锁升级为偏向锁,其本质是判断对象头的Mark Word中线程ID与当前线程ID是否一致以及偏向锁标识,如果一致直接执行同步代码或方法,具体流程如下

  • 无锁状态,存储内容「是否为偏向锁(0)」,锁标识位01

    • CAS设置当前线程ID到Mark Word存储内容中,并且将是否为偏向锁0 修改为 是否为偏向锁1
    • Mark Word和栈帧中记录获取到偏向的锁的threadID
    • 执行同步代码或方法
  • 偏向锁状态,存储内容「是否为偏向锁(1)、线程ID」,锁标识位01

    • 对比线程ID是否一致,如果一致无需使用CAS来加锁、解锁,直接执行同步代码或方法

      因为偏向锁不会自动释放锁,因此后续线程A再次获取锁的时候,需要比较当前线程的threadID和Java对象头中的threadID是否一致

    • 如果不一致,CASMark Word的线程ID设置为当前线程ID,设置成功,执行同步代码或方法

      其他线程,如线程B要竞争锁对象,而偏向锁不会主动释放,因此Mark Word还是存储的线程A的threadID

      此时会检查Mark Word的线程A是否存活,如果没有存活,那么锁对象被重置为无锁状态,其它线程(线程B)可以竞争将其设置为偏向锁;

    • CAS设置失败,证明存在多线程竞争情况,触发撤销偏向锁,当到达全局安全点,偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后在安全点的位置恢复继续往下执行。

      如果Mark Word的线程A是存活,则线程B的CAS会失败,此时会暂停线程A,撤销偏向锁,升级为轻量级锁,

5.2 偏向锁升级为轻量级锁

轻量级锁又称自旋锁,一般在竞争锁对象的线程比较少,持有锁时间也不长的场景中,由于阻塞线程、唤醒线程需要C P U从用户态转到内核态,时间比较长,如果同步代码块执行的时间比这更时间短,那就本末倒置了,所以这种情况一般不阻塞线程,让其自旋一段时间等待锁其他线程释放锁,通过自旋换取线程在用户态和内核态之间切换的开销

  • 锁竞争

    如果多个线程轮流获取一个锁,但是每次获取锁的时候没有发生阻塞,就不存在锁竞争。只有当某线程尝试获取锁的时候,发现该锁已经被占用,只能等待其释放,这才发生了锁竞争。


当前线程持有的锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能

升级为轻量级锁有两种情况:

  • 当关闭偏向锁功能时,会由无锁直接升级为轻量级锁
  • 多个线程竞争偏向锁导致偏向锁升级为轻量级锁

这两种情况下偏向锁升级为轻量级锁过程如下

  • 无锁状态:存储内容「是否为偏向锁(0)」,锁标识位01

    关闭偏向锁功能时

    • CAS设置当前线程栈中锁记录的指针到Mark Word存储内容
    • 锁标识位设置为00
    • 执行同步代码或方法
  • 轻量级锁状态:存储内容「线程栈中锁记录的指针」,锁标识位00

    • CAS设置当前线程栈中锁记录的指针到Mark Word存储内容,设置成功获取轻量级锁,执行同步块代码或方法
    • 设置失败,证明多线程存在一定竞争,线程自旋上一步的操作,自旋一定次数后还是失败,轻量级锁升级为重量级锁
    • Mark Word存储内容替换成重量级锁指针,锁标记位10

5.3 轻量级锁升级为重量级锁

轻量级锁在自旋一定次数之后还没获取到锁,就升级为重量级锁,重量级锁是依赖操作系统的MutexLock(互斥锁)来实现的,需要从用户态转到内核态,成本非常高,等待锁的线程都会进入阻塞状态,防止CPU空转

计数器记录自旋次数,默认允许循环10次,可以通过虚拟机参数更改

  • 3
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值