【JAVA基础】并发&多线程

1 多线程基础

1.1 实现多线程三种方式

  1. 继承java.lang.Thread类:在类的run()方法中写要执行的内容,主线程创建线程对象A,并使用A.start()来运行多线程代码。注意start()方法的调用后并不是立即执行多线程代码,而是使得该线程变为可运行态(Runnable),什么时候运行是由操作系统决定的。
  2. 实现 java.lang.Runable(推荐) 接口: 重写run方法,所有的多线程代码都在run方法里面。Thread类实际上也是实现了Runnable接口的类。
AccountingSync instance=new AccountingSync(); 
> Thread t1=new Thread(instance); 
> Thread t2=new Thread(instance); 
> t1.start();
> t2.start();
  1. 实现Callable接口:和runnable类似,但是有返回值。例如Callable就返回一个Integer对象

实现Runnable接口比继承Thread类所具有的优势

  • 适合多个相同的程序代码的线程去处理同一个资源;
  • 可以避免java中的单继承的限制;
  • 增加程序的健壮性,代码可以被多个线程共享,代码和数据独立;
  • 线程池只能放入实现Runable或callable类线程,不能直接放入继承Thread的类

1.2 线程调度

  1. 线程优先级:Java线程有优先级,优先级高的线程会获得较多的运行机会。JVM提供了10个线程优先级,建议使用Thread类三个静态常量( MAX_PRIORITY, MIN_PRIORITY,NORM_PRIORITY(主线程默认优先级))作为优先级,保证同样的优先级采用了同样的调度方式。
  2. 线程让步Thread.yield() 方法,让当前运行线程回到 可运行状态,以允许具有相同或更高优先级的其他线程获得运行机会, 但实际中无法保证yield()达到让步目的。——有可能下一个运行的又是自己…
  3. 线程睡眠Thread.sleep(long millis), 使线程转到阻塞状态不会释放锁。 允许较低优先级的线程获得运行机会。Thread类的Static(静态)的方法;因此他不能改变对象的锁。
  4. 线程等待:Object.wait()( Object.wait(long timeout))方法,使线程转到 阻塞状态会释放锁。直到其他线程调用此对象的 notify() 方法或 notifyAll() 唤醒方法。 必须要与synchronized(Obj)一起使用,也就是wait,与notify是针对已经获取了Obj锁进行操作,从语法角度来说就是Obj.wait(),Obj.notify必须在同步控制块synchronized(Obj){…}语句块内使用
  5. 线程唤醒Object.notify() 方法, 随机唤醒在此对象监视器上等待的单个线程。 notifyAll(),唤醒在此对象监视器上等待的所有线程;
  6. 线程加入Thread.join() 方法, 使线程转到 阻塞状态,等待子线程终止。在当前线程中调用另一个线程的join()方法,则当前线程转入阻塞状态,直到另一个进程运行结束,当前线程再由阻塞转为就绪状态。一般主线程需要某子线程的运算结果的时候使用。

1.3 中断线程

interrupt(): 不要以为它是中断某个线程!它只是线程发送一个中断信号,让线程在无限等待时(如死锁时)能抛出,从而结束线程,但是如果你吃掉了这个异常,那么这个线程还是不会中断的!

对某一线程调用 interrupt()时,如果该线程正在执行普通的代码,那么该线程根本就不会抛出InterruptedException。但是,一旦该线程进入到 wait()/sleep()/join() 后,就会立刻抛出InterruptedException

1.4 线程5大状态

  1. 新建状态:新建线程对象,并没有调用start()方法之前;
  2. 就绪状态:调用start()方法之后线程就进入就绪状态,但是并不是说只要调用start()方法线程就马上变为当前线程,在变为当前线程之前都是就绪状态。值得一提的是,线程在睡眠和挂起中恢复的时候也会进入就绪状态。
  3. 运行状态:线程被设置为当前线程,开始执行run()方法。就是线程进入运行状态
  4. 阻塞状态:线程被暂停,比如说调用sleep()方法后线程就进入阻塞状态
  5. 死亡状态:线程执行结束
    在这里插入图片描述

1.5 线程重要方法

  1. start() 方法,调用该方法开始执行该线程;
  2. stop() 方法,调用该方法强制结束该线程执行;
  3. join() 方法,调用该方法等待该线程结束。
  4. sleep() 方法,调用该方法该线程进入等待。
  5. run() 方法,调用该方法直接执行线程的run()方法,但是线程调用start()方法时也会运行run()方法,区别就是一个是由线程调度运行run()方法,一个是直接调用了线程中的run()方法

2 并发编程的3个基本概念

序号定义介绍
原子性一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。具有原子性的量,同一时刻只能有一个线程来对它进行操作。Java中的原子性操作包括:(1)基本类型的读取和赋值操作,且赋值必须是值赋给变量,变量之间的相互赋值不是原子性操作。(2)所有引用reference的赋值操作(3)java.concurrent.Atomic.* 包中所有类的一切操作
可见性当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。在多线程环境下,一个线程对共享变量的操作对其他线程是不可见的。Java提供了 volatile来保证可见性,当一个变量被volatile修饰后,表示着线程本地内存无效,当一个线程修改共享变量后他会立即被更新到主内存中,其他线程读取共享变量时,会直接从主内存中读取。当然,synchronize和Lock都可以保证可见性。synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
有序性程序执行的顺序按照代码的先后顺序执行。 Java内存模型中的有序性可以总结为:如果在本线程内观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有操作都是无序的。前半句是指“线程内表现为串行语义”,后半句是指“指令重排序”现象和“工作内存主主内存同步延迟”现象。在Java内存模型中,为了效率是允许编译器和处理器对指令进行重排序,当然重排序不会影响单线程的运行结果,但是对多线程会有影响。Java提供 volatile来保证一定的有序性。另外,可以通过 synchronized和Lock来保证有序性,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。

每个锁对象(JLS中叫monitor)都有两个队列,一个是就绪队列,一个是阻塞队列,就绪队列存储了将要获得锁的线程,阻塞队列存储了被阻塞的线程,当一个线程被唤醒(notify)后,才会进入到就绪队列,等待CPU的调度,反之,当一个线程被wait后,就会进入阻塞队列,等待下一次被唤醒。

3 Lock&volatile&synchronize

3.1 LOCK—— ReentrantLock是Lock接口的实现。

Lock类中的主要方法:

  1. lock():获取锁,如果锁被暂用则一直等待;
  2. unlock():释放锁;
  3. tryLock(): 注意返回类型是boolean,如果获取锁的时候锁被占用就返回false,否则返回true;
  4. tryLock(long time, TimeUnit unit):比起tryLock()就是给了一个时间期限,保证等待参数时间;
  5. lockInterruptibly():用该锁的获得方式,如果线程在获取锁的阶段进入了等待,那么可以中断此线程,先去做别的事

3.2 synchronize——阻塞,保证原子性,可见性,有序性

参考:https://blog.csdn.net/javazejian/article/details/72828483

造成线程安全问题的主要诱因有两点,一是存在共享数据(也称临界资源),二是存在多条线程共同操作共享数据

互斥锁—— 达到互斥访问目的的锁,保证同一时刻有且只有一个线程在操作共享数据,其他线程必须等到该线程处理完数据后再进行

在 Java 中,关键字 synchronized可以保证在同一个时刻,只有一个线程可以执行某个方法或者某个代码块(主要是对方法或者代码块中存在共享数据的操作)。同时我们还应该注意到synchronized另外一个重要的作用,synchronized可保证一个线程的变化(主要是共享数据的变化)被其他线程所看到(保证可见性,完全可以替代Volatile功能)。

3.2.1 synchronized的三种应用方式

1. 修饰【实例方法】,作用于【当前实例】加锁,进入同步代码前要获得当前实例的锁
2. 修饰【静态方法】,作用于【当前类】对象加锁,进入同步代码前要获得当前类对象的锁
3. 修饰【代码块】,指定加锁对象,对【给定对象】加锁,进入同步代码库前要获得给定对象的锁。
3.2.1.1 作用于实例方法

当一个线程正在访问一个对象的 synchronized 实例方法,那么其他线程不能访问该对象的其他 synchronized 方法,毕竟一个对象只有一把锁,当一个线程获取了该对象的锁之后,其他线程无法获取该对象的锁,所以无法访问该对象的其他synchronized实例方法,但是其他线程还是可以访问该实例对象的其他非synchronized方法。如图1所示。

当然如果是一个线程 A 需要访问实例对象 obj1 的 synchronized 方法 f1(当前对象锁是obj1),另一个线程 B 需要访问实例对象 obj2 的 synchronized 方法 f2(当前对象锁是obj2),这样是允许的,因为两个实例对象锁并不同相同,如果两个线程操作的是共享数据,那么线程安全就有可能无法保证了,如图2所示。
图1
图一 ——结果为2000000

图2
图2 ——结果为1452317

3.2.1.2 作用于静态方法

当synchronized作用于静态方法时,其锁就是当前类的class对象锁。由于静态成员不专属于任何一个实例对象,是类成员,因此通过class对象锁可以控制静态成员的并发操作。需要注意的是如果一个线程A调用一个实例对象的非static synchronized方法,而线程B需要调用这个实例对象所属类的静态 synchronized方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的class对象,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。如图三。
图3
图三:不同的对象访问synchronized静态方法会互斥,保证线程安全。两个方法均操作了共享静态变量i,可能会发现线程安全问题

3.2.1.3 作用于同步代码块

使用同步代码块的方式对需要同步的代码进行包裹
在这里插入图片描述
在这里插入图片描述

3.2.2 Java虚拟机对synchronized的优化

lock引入后性能比synchronized好,所以虚拟机对synchronized进行了优化。
锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。

1、偏向锁

——没有锁竞争,每次都是同一线程获取锁,无需做任何同步操作,直接获取,省去了大量有关锁申请的操作。失败后升级为轻量级锁。

Java 6之后加入的新锁,它是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁。

偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提高程序的性能。

所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。

2、轻量级锁

——对绝大部分的锁,在整个同步周期内都不存在竞争。失败后升级为重量级锁。

倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的),此时Mark Word 的结构也变为轻量级锁的结构。轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。

3、自旋锁

——线程持有锁的时间都不会太长,让当前想要获取锁的线程做几个空循环。

轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。最后没办法也就只能升级为重量级锁了。

4、锁消除

——Java虚拟机在JIT编译时通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁。

消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间,例如StringBuffer的append是一个同步方法,但是在add方法中的StringBuffer属于一个局部变量,并且不会被其他线程所使用,因此StringBuffer不可能存在共享资源竞争的情景,JVM会自动将其锁消除。

3.2.3 关于synchronized 需要了解的关键点

3.2.3.1 可重入性

——一个线程得到一个对象锁后再次请求该对象锁,是允许的,这就是synchronized的可重入性。

从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁,请求将会成功。在java中synchronized是基于原子性的内部锁机制,是可重入的,因此在一个线程调用synchronized方法的同时在其方法体内部调用该对象另一个synchronized方法,也就是说一个线程得到一个对象锁后再次请求该对象锁,是允许的,这就是synchronized的可重入性。

当子类继承父类时,子类也是可以通过可重入锁调用父类的同步方法。
在这里插入图片描述

3.2.3.2 线程中断

(1)线程中断

正如中断二字所表达的意义,在线程运行(run方法)中间打断它,在Java中,提供了以下3个有关线程中断的方法:
在这里插入图片描述
(1) 当一个线程处于被阻塞状态或者试图执行一个阻塞操作时,使用实例方法 Thread.interrupt() 方式中断该线程,注意此时将会抛出一个InterruptedException的异常(该异常必须捕捉无法向外抛出),同时中断状态将会被复位(由中断状态改为非中断状态)。

(2) 处于运行期且非阻塞的状态的线程,这种情况下,直接调用实例方法Thread.interrupt()中断线程是不会得到任响应,但中断状态置位,必须手动判断中断状态,并编写中断线程的代码(其实就是结束run方法体的代码)。

兼顾以上两种情况可以如下编写:
在这里插入图片描述
(3) 线程的中断操作对于正在等待获取的锁对象的synchronized方法或者代码块并不起作用,也就是对于synchronized来说,如果一个线程在等待锁,那么结果只有两种,要么它获得这把锁继续执行,要么它就保存等待,即使调用中断线程的方法,也不会生效。

3.2.3.3 等待唤醒机制(notify/notifyAll和wait)

所谓等待唤醒机制本篇主要指的是notify/notifyAll和wait方法,在使用这3个方法时,必须处于synchronized代码块或者synchronized方法中,否则就会抛出IllegalMonitorStateException异常。这是因为调用这几个方法前必须拿到当前对象的监视器monitor对象,也就是说notify/notifyAll和wait方法依赖于monitor对象。monitor 存在于对象头的Mark Word 中(存储monitor引用指针),而synchronized关键字可以获取 monitor ,这也就是为什么notify/notifyAll和wait方法必须在synchronized代码块或者synchronized方法调用的原因。

需要特别理解的一点是,与sleep方法不同的是wait方法调用完成后,线程将被暂停,但wait方法将会释放当前持有的监视器锁(monitor),直到有线程调用notify/notifyAll方法后方能继续执行,而sleep方法只让线程休眠并不释放锁。同时notify/notifyAll方法调用后,并不会马上释放监视器锁,而是在相应的synchronized(){}/synchronized方法执行结束后才自动释放锁。
在这里插入图片描述

3.3 volatile——保证可见性,且提供了一定的有序性,无法保证原子性

使用synchronized在某些情况下会造成死锁,会一直等待锁的到来。使用synchronized修饰的方法或者代码块可以看成是一个原子操作。

volatile是一种弱的同步手段,相对于synchronized来说,某些情况下使用,可能效率更高,因为它不是阻塞的,尤其是读操作时,加与不加貌似没有影响,处理写操作的时候,可能消耗的性能更多些。volatile和final不能同时修饰一个字段。

要使 volatile 变量提供理想的线程安全,必须同时满足下面两个条件:

1.对变量的写操作不依赖于当前值。
2.该变量没有包含在具有其他变量的不变式中。

实际上,这些条件表明,可以被写入 volatile 变量的这些有效值独立于任何程序的状态,包括变量的当前状态。事实上就是保证操作是原子性操作,才能保证使用volatile关键字的程序在并发时能够正确执行。

3.3.1 volatile变量的特性

3.3.1.1 保证可见性,不保证原子性

(1)当写一个volatile变量时,JMM会把该线程本地内存中的变量强制刷新到主内存中去;
(2)这个写操作会导致其他线程中的volatile变量缓存无效。

3.3.1.2 禁止指令重排-保证一定的有序性

重排序是指编译器和处理器为了优化程序性能而对指令序列进行排序的一种手段。重排序需要遵守一定规则:

(1)重排序操作不会对存在数据依赖关系的操作进行重排序。
(2)重排序是为了优化性能,但是不管怎么重排序,单线程下程序的执行结果不能被改变

重排序在单线程下一定能保证结果的正确性,但是在多线程环境下,可能发生重排序,影响结果。

使用volatile关键字修饰共享变量便可以禁止这种重排序。若用volatile修饰共享变量,在编译时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序,volatile禁止指令重排序也有一些规则:

(1)当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
(2)在进行指令优化时,不能将对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。

即执行到volatile变量时,其前面的所有语句都执行完,后面所有语句都未执行。且前面语句的结果对volatile变量及其后面语句可见。

3.3.2 volatile原理

volatile可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在JVM底层volatile是采用“内存屏障”来实现的。观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令,lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:

(1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
(2)它会强制将对缓存的修改操作立即写入主存
(3)如果是写操作,它会导致其他CPU中对应的缓存行无效。

3.3.3 单例模式的双重锁为什么要加volatile

在这里插入图片描述
需要volatile关键字的原因是,在并发情况下,如果没有volatile关键字,在第5行会出现问题。instance = new TestInstance();可以分解为3行伪代码:
在这里插入图片描述
上面的代码在编译运行时,可能会出现重排序从a-b-c排序为a-c-b。在多线程的情况下会出现以下问题。当线程A在执行第5行代码时,B线程进来执行到第2行代码。假设此时A执行的过程中发生了指令重排序,即先执行了a和c,没有执行b。那么由于A线程执行了c导致instance指向了一段地址,所以B线程判断instance不为null,会直接跳到第6行并返回一个未初始化的对象。

3.4 synchronized与Lock的区别

类别synchronizedLock
存在层次Java的关键字,在jvm层面上是一个
锁的释放1、已获取锁的线程执行完同步代码,释放锁 2、线程执行发生异常,jvm会让线程释放锁finally中必须释放锁,不然容易造成线程死锁
锁的获取假设A线程获得锁,B线程等待。如果A线程阻塞,B线程会一直等待分情况而定,Lock有多个锁获取的方式,大致就是可以尝试获得锁,线程可以不用一直等待
锁状态无法判断可以判断
锁类型可重入 不可中断(等待锁的过程中不能中断) 非公平可重入 可中断 可公平(两者皆可)
性能少量同步大量同步
类别悲观锁乐观锁
底层实现synchronized映射成字节码指令就是增加来两个指令:monitorenter和monitorexitvolatileCAS操作实现
使用范围synchronized能锁住类、方法和代码块Lock是块范围内的

3.5 synchronized与volatile的区别

类别synchronizedvolatileLock
存在层次Java的关键字,在jvm层面上Java的关键字,在jvm层面上是一个
原子性×
有序性一定的有序性,volatile前面是一定在其前执行,后面的一定在其后面执行
可见性
阻塞性阻塞非阻塞自己选择方式

java并发包中的原子操作类,原子操作类是通过CAS循环的方式来保证其原子性。CAS会映射到一个处理器操作,比使用锁的速度快。例如:

do{ 
    oldValue = largest.get();
    newValue = Max.max(oldVaue, observed);
} while (!largest.compareAndSet(oldValue, newValue));

//等价于
largest.updateAndGet( x -> Math.max(x, observed) );

//或
largest.accumulateAndGet( observed, Math::max );

4 ThreadLocal——在一个线程内共享变量,同时使得各个线程之间的变量互相隔离

参考链接1
参考链接2

  • ThreadLocal表示线程的“局部变量”,它确保每个线程的ThreadLocal变量都是各自独立的;
  • ThreadLocal适合在一个线程的处理流程中保持上下文(避免了同一参数在所有方法中传递);
  • 使用ThreadLocal要用try … finally结构,并在finally中清除。(或者通过AutoCloseable接口配合 try (resource) {…} 结构,让编译器自动为我们关闭)

ThreadLocal是通过将变量设置成Thread的局部变量,即使用该变量的线程提供一个独立的副本,可以独立修改,不会影响其他线程的副本,这样来解决多线程的并发问题。ThreadLocal主要是线程不安全的类在多线程中使用,一般常用于数据库连接和Session管理

变量值的共享可以使用public static的形式,所有线程都使用同一个变量,如果想实现每一个线程都有自己的共享变量该如何实现呢?JDK中的ThreadLocal类正是为了解决这样的问题。

ThreadLocal类并不是用来解决多线程环境下的共享变量问题,而是用来 提供线程内部的共享变量,在多线程环境下,可以保证各个线程之间的变量互相隔离、相互独立。在线程中,可以通过**get()/set()**方法来访问变量。ThreadLocal实例通常来说都是private static类型的,它们希望将状态与线程进行关联。这种变量在线程的生命周期内起作用,可以减少同一个线程内多个函数或者组件之间一些公共变量的传递的复杂度。

实现:
ThreadLocal的实现离不开ThreadLocalMap类,ThreadLocalMap类是ThreadLocal的静态内部类。每个Thread维护一个ThreadLocalMap映射表,这个映射表的key是ThreadLocal实例本身,value是真正需要存储的Object。这样的设计主要有以下几点优势:

1.这样设计之后每个Map的Entry数量变小了:之前是Thread的数量,现在是ThreadLocal的数量,能提高性能;
2.当Thread销毁之后对应的ThreadLocalMap也就随之销毁了,能减少内存使用量。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值