Synchronized

Synchronized

Synchronized 是 Java 中解决并发问题的一种最常用的方法,也是最简单的一种方法。

Synchronized的作用主要有三个

  • 原子性:确保线程互斥的访问同步代码;

  • 可见性:保证共享变量的修改能够及时可见,其实是通过 Java内存模型(JMM) 中的 “对一个变量unlock操作之前,必须要同步到主内存中;如果对一个变量进行lock操作,则将会清空工作内存中此变量的值,在使用此变量前,需要重新从主内存中 load 操作或 assign 操作初始化变量值” 来保证的;

  • 有序性:有效解决重排序问题,即 “一个unlock操作先行发生(happen-before)于后面对同一个锁的lock操作”

从语法上讲,Synchronized可以把任何一个非null对象作为"锁",在HotSpot JVM实现中,锁有个专门的名字:对象监视器(Object Monitor)。具体有三种表现形式:

  • 1 普通同步方法时,监视器锁(monitor)便是对象实例(this)

  • 2 静态同步方法时,监视器锁(monitor)便是对象的Class实例,因为Class数据存在于永久代,因此静态方法锁相当于该类的一个全局锁

  • 3 同步代码块,监视器锁(monitor)便是括号括起来的对象实例

synchronized 内置锁 是一种 对象锁(锁的是对象而非引用变量)作用粒度是对象 ,可以用来实现对 临界资源的同步互斥访问 ,是 可重入 的。其可重入最大的作用是避免死锁,例如子类同步方法调用了父类同步方法,如没有可重入的特性,则会发生死锁。

1 Synchronized 底层原理?

数据同步需要依赖锁,那锁的同步又依赖谁 ?

synchronized给出的答案是在软件层面依赖JVM,而j.u.c.Lock给出的答案是在硬件层面依赖特殊的CPU指令

同步代码块
public class SynchronizedDemo {

    public void method1(){
        synchronized (this){
            System.out.println("同步代码块 method1");
        }
    }
    public synchronized void method2() {
        System.out.println("同步方法 method2");
    }

    public static synchronized void method3() {
        System.out.println("静态同步方法 method3");
    }
}

反编译后结果: javap -c

public class com.wunlie.demo.SynchronizedDemo {
  public com.wunlie.demo.SynchronizedDemo();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public void method1();
    Code:
       0: aload_0
       1: dup
       2: astore_1
       3: monitorenter                      <====== 实现同步的指令
       4: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
       7: ldc           #3                  // String 同步代码块 method1
       9: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      12: aload_1
      13: monitorexit                       <====== 实现同步的指令
      14: goto          22
      17: astore_2
      18: aload_1
      19: monitorexit                       <====== 实现同步的指令
      20: aload_2
      21: athrow
      22: return
    Exception table:
       from    to  target type
           4    14    17   any
          17    20    17   any

  public synchronized void method2();
    Code:
       0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
       3: ldc           #5                  // String 同步方法 method2
       5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
       8: return

  public static synchronized void method3();
    Code:
       0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
       3: ldc           #6                  // String 静态同步方法 method3
       5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
       8: return
}

1 同步代码块

当一个线程访问同步代码块时,首先是需要得到锁才能执行同步代码,当退出或者抛出异常时必须要释放锁,那么它是如何来实现这个机制的呢? 使用monitorenter和monitorexit指令实现的。

monitorenter:每个对象都是一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:

  • 1 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者;

  • 2 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1;

  • 3 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权;

monitorexit:执行monitorexit的线程必须是objectref所对应的monitor的所有者。指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。monitorexit指令出现了两次,第1次为同步正常退出释放锁;第2次为发生异步退出释放锁

通过上面两段描述,我们应该能很清楚的看出Synchronized的实现原理,Synchronized的语义底层是通过一个monitor的对象来完成,其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因

同步方法

从编译的结果来看,方法的同步并没有通过指令 monitorentermonitorexit 来完成(理论上其实也可以通过这两条指令来实现),不过相对于普通方法,其常量池中多了 ACC_SYNCHRONIZED 标示符。JVM就是根据该标示符来实现方法的同步的

当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取 monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。

两种同步方式本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。两个指令的执行是JVM通过调用操作系统的互斥原语mutex来实现,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态之间来回切换,对性能有较大影响。

2 Synchronized 锁(Monitor)是如何实现的?

2.1 java对象头

Synchronized用的锁就是存在Java对象头里的,那么什么是Java对象头呢?

在这里插入图片描述

Hotspot 虚拟机的对象头主要包括两部分数据:**Mark Word(标记字段)、Class Pointer(类型指针) **。其中 Class Pointer是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,Mark Word用于存储对象自身的运行时数据,如:哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等,它是实现轻量级锁和偏向锁的关键Mark Word会随着程序的运行发生变化,可能变化为存储以下5种数据

在这里插入图片描述

对象头的最后两位存储了锁的标志位,01是初始状态,未加锁,其对象头里存储的是对象本身的哈希码,随着锁级别的不同,对象头里会存储不同的内容。

  • 偏向锁存储的是当前占用此对象的线程ID

  • 轻量级则存储指向线程栈中锁记录(Lock Record)的指针。从这里我们可以看到,“锁”这个东西,可能是个锁记录+对象头里的引用指针(判断线程是否拥有锁时将线程的锁记录地址和对象头里的指针地址比较),也可能是对象头里的线程ID(判断线程是否拥有锁时将线程的ID和对象头里存储的线程ID比较)。

  • 重量级锁其中指针指向的是Monitor对象的起始地址

Q1: 什么是“锁记录(Lock Record)” ?

在线程进入同步代码块的时候,如果此同步对象没有被锁定,即它的锁标志位是01,则虚拟机首先在当前线程的栈中创建我们称之为“锁记录(Lock Record)”的空间,用于存储锁对象的Mark Word的拷贝,官方把这个拷贝称为Displaced Mark Word。整个Mark Word及其拷贝至关重要

Lock Record是线程私有的数据结构,每一个线程都有一个可用Lock Record列表,同时还有一个全局的可用列表。每一个被锁住的对象Mark Word都会和一个Lock Record关联(对象头的MarkWord中的Lock Word指向Lock Record的起始地址),同时Lock Record中有一个Owner字段存放拥有该锁的线程的唯一标识(或者object mark word),表示该锁被这个线程占用

Lock Record是一个线程内独享的存储,每一个线程都有一个可用Monitor Record列表。

Q2: 监视器Monitor

Monitor: 可以理解为 一个同步工具,一种同步机制,它通常被 描述为一个对象任何一个对象都有一个Monitor与之关联,当且一个Monitor被持有后,它将处于锁定状态。Synchronized在JVM里的实现都是 基于进入和退出Monitor对象来实现方法同步和代码块同步,虽然具体实现细节不一样,但是都可以通过成对的MonitorEnter和MonitorExit指令来实现。

万物皆对象一样,所有的Java对象是天生的Monitor,每一个Java对象都可以成为Monitor,因为在Java的设计中 ,每一个Java对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor锁也就是通常说Synchronized的对象锁,MarkWord锁标识位为10(重量级锁),其中指针指向的是Monitor对象的起始地址。在Java虚拟机(HotSpot)中,Monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的):

ObjectMonitor() {
    _header       = NULL;
    _count        = 0; // 记录个数
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL; // 处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; // 处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }

在这里插入图片描述

如上图所示,一个线程通过1号门进入Entry Set(入口区),如果在入口区没有线程等待,那么这个线程就会获取监视器成为监视器的Owner,然后执行监视区域的代码。如果在入口区(Entry Set)中有其它线程在等待,那么新来的线程也会和这些线程一起等待。线程在持有监视器的过程中,有两个选择,一个是正常执行监视器区域的代码,释放监视器,通过5号门退出监视器;还有可能等待某个条件的出现,于是它会通过3号门到Wait Set(等待区)休息,直到相应的条件满足后再通过4号门进入重新获取监视器再执行。

当一个线程释放监视器时,在入口区和等待区的等待线程都会去竞争监视器,如果入口区的线程赢了,会从2号门进入;如果等待区的线程赢了会从4号门进入。只有通过3号门才能进入等待区,在等待区中的线程只有通过4号门才能退出等待区,也就是说一个线程只有在持有监视器时才能执行wait操作,处于等待的线程只有再次获得监视器才能退出等待状态。

ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象 ),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时:

  • 1 首先会进入 _EntryList 集合,当线程获取到对象的monitor后,进入 _Owner区域并把monitor中的owner变量设置为当前线程,同时monitor中的计数器count加1

  • 2 若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSet集合中等待被唤醒

  • 3 若当前线程执行完毕,也将释放monitor(锁)并复位count的值,以便其他线程 (_WaitSet 和 _EntryList中的线程) 进入获取monitor(锁)

Monitor对象存在于每个Java对象的对象头Mark Word中(存储的指针的指向),Synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因,同时notify/notifyAll/wait等方法会使用到Monitor锁对象,所以必须在同步代码块中使用

Q3: 为什么这些操作线程的方法要定义在object类中呢?

简单说:因为synchronized中的这把锁可以是任意对象,所以任意对象都可以调用wait()和notify();所以wait和notify属于Object。

专业说:因为这些方法在操作同步线程时,都必须要标识它们操作线程的锁,只有同一个锁上的被等待线程,可以被同一个锁上的notify唤醒,不可以对不同锁中的线程进行唤醒。也就是说,等待和唤醒必须是同一个锁。而锁可以是任意对象,所以可以被任意对象调用的方法是定义在object类中。

监视器Monitor有两种同步方式:互斥与协作。多线程环境下线程之间如果需要共享数据,需要解决互斥访问数据的问题,监视器可以确保监视器上的数据在同一时刻只会有一个线程在访问

什么时候需要协作?

一个线程向缓冲区写数据,另一个线程从缓冲区读数据,如果读线程发现缓冲区为空就会等待,当写线程向缓冲区写入数据,就会唤醒读线程,这里读线程和写线程就是一个合作关系。JVM通过Object类的wait方法来使自己等待,在调用wait方法后,该线程会释放它持有的监视器,直到其他线程通知它才有执行的机会。一个线程调用notify方法通知在等待的线程,这个等待的线程并不会马上执行,而是要通知线程释放监视器后,它重新获取监视器才有执行的机会。如果刚好唤醒的这个线程需要的监视器被其他线程抢占,那么这个线程会继续等待。Object类中的notifyAll方法可以解决这个问题,它可以唤醒所有等待的线程,总有一个线程执行。

3 锁

从JDK6开始,就对synchronized的实现机制进行了较大调整,包括使用JDK5引进的CAS自旋之外,还增加了自适应的CAS自旋、锁消除、锁粗化、偏向锁、轻量级锁这些优化策略。由于此关键字的优化使得性能极大提高,同时语义清晰、操作简单、无需手动关闭,所以推荐在允许的情况下尽量使用此关键字,同时在性能上此关键字还有优化的空间。

锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁。但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级

JDK 1.6 中默认是开启偏向锁和轻量级锁的,可以通过-XX:-UseBiasedLocking来禁用偏向锁。

1 自旋锁

所谓自旋锁,就是指当一个线程尝试获取某个锁时,如果该锁已被其他线程占用,就一直循环检测锁是否被释放,而不是进入线程挂起或睡眠状态。

线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。同时我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的

自旋锁适用于锁保护的临界区很小的情况,临界区很小的话,锁占用的时间就很短。自旋等待不能替代阻塞,虽然它可以避免线程切换带来的开销,但是它占用了CPU处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好,反之,自旋的线程就会白白消耗掉处理的资源,它不会做任何有意义的工作,典型的占着茅坑不拉屎,这样反而会带来性能上的浪费。所以说,自旋等待的时间(自旋的次数)必须要有一个限度,如果自旋超过了定义的时间仍然没有获取到锁,则应该被挂起

2 适应性自旋锁

所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定

线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。

3 锁消除

为了保证数据的完整性,在进行操作时需要对这部分操作进行同步控制,但是在有些情况下,JVM检测到不可能存在共享数据竞争,这是JVM会对这些同步锁进行锁消除

4 锁粗化

锁粗化概念比较好理解,就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁

在使用同步锁的时候,需要让同步块的作用范围尽可能小—仅在共享数据的实际作用域中才进行同步,这样做的目的是 为了使需要同步的操作数量尽可能缩小,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。在大多数的情况下,这个观点是正确的。但是如果一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,所以引入锁粗话的概念

5 锁升级

无锁状态 ==》偏向锁状态 ==》轻量级锁状态 ==》重量级锁状态

5.1 偏向锁

如果对象头MarkWord 中的 bias位(偏向标志位,是否可偏向)为0,则该对象未被锁定,并且禁止偏向;

如果对象头MarkWord 中的 bias位(偏向标志位,是否可偏向)为1,则意味着该对象处于以下三种状态:

  • 匿名偏向(Anonymously biased)
    在此状态下thread_ptr为NULL(0),意味着还没有线程偏向于这个锁对象。第一个试图获取该锁的线程将会面临这个情况,使用原子CAS指令可将该锁对象绑定于当前线程。这是允许偏向锁的类对象的初始状态。
  • 可重偏向(Rebiasable)
    在此状态下,偏向锁的epoch字段是无效的(与锁对象对应的klass的mark_prototype的epoch值不匹配)。下一个试图获取锁对象的线程将会面临这个情况,使用原子CAS指令可将该锁对象绑定于当前线程。在批量重偏向的操作中,未被持有的锁对象都被至于这个状态,以便允许被快速重偏向。
  • 已偏向(Biased)
    这种状态下,thread ptr非空,且epoch为有效值——意味着其他线程正在只有这个锁对象。
Q1 为什么要引入偏向锁

为了让线程获得锁的代价更低:因为HotSpot作者经过研究实践发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得。偏向锁是JDK6中的重要引进

为了在没有多线程竞争的情况下尽量减少不必要的轻量级锁执行路径。因为轻量级锁的加锁解锁操作是需要依赖多次CAS原子指令的,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令。

Q2 偏向锁的获取

偏向锁的获取依靠原子CAS指令将线程指针插入MarkWord中。其先决条件是:

  • 1.该对象处于匿名偏向状态;
  • 2.该对象处于可重偏向状态(一个锁对象仅能被一个线程偏向一次)。只要锁对象被偏向,递归锁定和解锁仅仅需要读取对象头以及对应Klass的prototype去验证偏向是否被吊销。
偏向锁升级为轻量级锁
示例中:线程1当前拥有偏向锁对象,线程2是需要竞争到偏向锁。
1 线程2来竞争锁对象;
2 判断当前对象头是否是偏向锁;
3 判断拥有偏向锁的线程1是否还存在;
4 线程1不存在,直接设置偏向锁标识为0(线程1执行完毕后,不会主动去释放偏向锁);
5 使用cas替换偏向锁线程ID为线程2,锁不升级,仍为偏向锁;
6 线程1仍然存在,暂停线程1;
7 设置锁标志位为00(变为轻量级锁),偏向锁为0;
8 从线程1的空闲monitor record中读取一条,放至线程1的当前monitor record中;
9 更新mark word,将mark word指向线程1中monitor record的指针;
10 继续执行线程1的代码;
11 锁升级为轻量级锁;   
12 线程2自旋来获取锁对象;

偏向锁是在单线程执行代码块时使用的机制,如果在多线程并发的环境下(即线程A尚未执行完同步代码块,线程B发起了申请锁的申请),则一定会转化为轻量级锁或者重量级锁。

Q3: 那么偏向锁是如何来减少不必要的CAS操作呢?

现在几乎所有的锁都是可重入的,即已经获得锁的线程可以多次锁住/解锁监视对象,按照之前的HotSpot设计,每次加锁/解锁都会涉及到一些CAS操作(比如对等待队列的CAS操作),CAS操作会延迟本地调用,因此偏向锁的想法是 一旦线程第一次获得了监视对象,之后让监视对象“偏向”这个线程,之后的多次调用则可以避免CAS操作,说白了就是设置个变量(偏向线程ID==当前线程ID),如果发现为true则无需再走各种加锁/解锁流程。

Q4: CAS为什么会引起本地延迟?

在这里插入图片描述

SMP(对称多处理器)架构:其意思是 所有的CPU会共享一条系统总线(BUS),靠此总线连接主存每个核都有自己的一级缓存,各核相对于BUS对称分布,因此这种结构称为“对称多处理器”

例如:Core1和Core2可能会同时把主存中某个位置的值Load到自己的L1 Cache中,当Core1在自己的L1 Cache中修改这个位置的值时,会通过总线,使Core2中L1 Cache对应的值“失效”,而Core2一旦发现自己L1 Cache中的值失效(称为Cache命中缺失)则会通过总线从内存中加载该地址最新的值,大家通过总线的来回通信称为“Cache一致性流量”,因为总线被设计为固定的“通信能力”,如果Cache一致性流量过大,总线将成为瓶颈。而当Core1和Core2中的值再次一致时,称为“Cache一致性”,从这个层面来说,锁设计的终极目标便是减少Cache一致性流量。

CAS的全称为Compare-And-Swap,是一条CPU的原子指令,其作用是让CPU比较后原子地更新某个位置的值,经过调查发现,其实现方式是基于硬件平台的汇编指令,就是说CAS是靠硬件实现的,JVM只是封装了汇编调用,那些AtomicInteger类便是使用了这些封装后的接口。

**而CAS恰好会导致Cache一致性流量,如果有很多线程都共享同一个对象,当某个Core CAS成功时必然会引起总线风暴,**这就是所谓的本地延迟,本质上偏向锁就是为了消除CAS,降低Cache一致性流量。

5.2 轻量级锁

Lock Record用于轻量级锁优化,当解释器执行monitorenter字节码轻度锁住一个对象时,就会在获取锁的线程的栈上显式或者隐式分配一个Lock Record,这个Lock Record存储锁对象Mark Word的拷贝(Displaced Mark Word),在拷贝完成后,首先会挂起持有偏向锁的线程,因为要进行尝试修改锁记录指针,Mark Word会有变化,所有线程会利用CAS尝试将Mark Word的锁记录指针改为指向自己(线程)的锁记录,然后Lock Record的owner指向对象的Mark Word,修改成功的线程将获得轻量级锁。失败则线程升级为重量级锁。

释放时(执行monitorexit)会检查Mark Word中的Lock Record指针是否指向自己(获得锁的线程Lock Record),使用原子的CAS将Displaced Mark Word替换回对象头,如果成功,则表示没有竞争发生,如果替换失败则升级为重量级锁。整个过程中,

5.3 重量级锁

其他线程试图获取锁时,都会被阻塞,只有持有锁的线程释放锁之后才会唤醒这些线程。

5.4 优缺点对比:

偏向锁

  • 优点:加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。
  • 缺点:如果线程间存在锁竞争,会带来额外的锁撤销的消耗。
  • 适用:只有一个线程访问同步块场景。

轻量级锁

  • 优点:竞争的线程不会阻塞,提高了程序的响应速度。
  • 缺点:如果始终得不到锁竞争的线程使用自旋会消耗CPU。
  • 适用:追求响应时间。同步块执行速度非常快。

重量级锁

  • 优点:线程竞争不使用自旋,不会消耗CPU。
  • 缺点:线程阻塞,响应时间缓慢。
  • 适用:追求吞吐量。同步块执行速度较长。

synchronized 锁升级流程图

在这里插入图片描述

参考文献

https://www.zhihu.com/question/57774162/answer/154298044

https://wiki.openjdk.java.net/display/HotSpot/Synchronization

https://zhuanlan.zhihu.com/p/26475023
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值