JAVA并发编程

创建线程

线程和任务一起

new Thread(() -> {
    //任务
});

 线程和任务分开

lamuda

future可以返回结果给主线程

查看进程

线程原理

一个线程分配一个栈,一个栈有多个栈帧,栈帧对应方法,一个线程只能有一个活动栈帧(只能调用一个方法)

常见方法

t1.join():让当前线程(main)等待t1线程的结束

两阶段终止

线程在睡眠过程中被interrupt,会把interrupt标记重置为false(清除标记)

守护线程

setDaemon

垃圾回收器

线程六种状态

RUNNINT->WAITING:  wait(),  join(), park()  

RUNNINT->Timed_WAITING:wait(long t),  join(long  t),sleep(long t),parkNanos(long t),parkUntil(long t)   

RUNNINT->BLOCK:竞争锁失败

waiting和block区别

阻塞状态是线程阻塞在进入synchronized关键字修饰的方法或代码块(获取锁)时的状态。等待获取锁,互斥

等待状态的线程不会被分配CPU执行时间,它们要等待被显式地唤醒,否则会处于无限期等待的状态。等待其他资源后进入block,同步

共享问题

解决---互斥

synchronized

对象是锁(自定义的)

加在方法上

synchronized用在实例方法或this上,锁是加在单个锁对象上,不同的对象没有竞争关系;

synchronized用在静态方法或类上,锁加在锁类上,这个类所有的对象竞争一把锁

注意!!

类对象 != 实例对象

类对象可以用来访问静态属性和方法

实例对象是一个具体的对象

线程安全

局部变量

一般线程安全

如果局部变量暴露给其它方法(局部变量对象逃离方法),还要看该方法是否会修改局部变量(例如通过形参传递给其他方法,其他方法内部开启新线程操作这个局部变量)

//局部变量线程不安全例子
public static void main(String[] args) {
        ArrayList<Integer> list = new ArrayList<>();//局部变量
        for(int i=0;i<100;i++){
            list.add(i);
            test2(list);//将局部变量list暴露给test2方法
        }
        System.out.println(list);
    }
    public static void test2(ArrayList<Integer> list) {
        new Thread(()->list.remove(0)).start();//test2中的线程和原线程共享一个局部变量
    }

成员变量

线程不安全<——共享+写操作(状态可变/可被修改)+临界区(多个线程对一个类对象进行写操作)

线程安全类

单个方法内部是线程安全的,多个方法组合不一定安全

悲观锁(阻塞)

悲观锁就是 一个线程要访问共享资源,就必须先获得锁,否则就会一直阻塞直到上个占有锁的线程释放锁。

缺点:

高并发阻塞导致系统的上下文切换 性能低,死锁

Monitor(监视器/管程)

执行synchronized代码。每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级锁)之后,该对象头的 Mark Word 中就被设置指向 Monitor 对象的指针

1. 检查obj对象(synchronized锁住的对象)中的MarkWord是否指向一个Monitor对象
    MarkWord保存了锁信息

2. 检查Monitor的Owner,如果没有则当前线程成为Owner

3. 有则以链表形式加入EntryList

4. Thread2执行完后释放锁,唤醒其它线程来竞争锁

5. 如果线程获得锁但不满足条件,进入WaitSet

synchronized原理

当一个代码块被synchronized修饰时,Java编译器会在生成的字节码中加入 monitorenter和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。

当执行 monitorenter 指令时,线程试图获取锁也就是获取 对象监视器 monitor 的持有权,如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 。

对象锁的的拥有者线程才可以执行 monitorexit 指令来释放锁。在执行 monitorexit 指令后,将锁计数器设为 0,表明锁被释放。

synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

如果是实例方法,JVM 会尝试获取实例对象的锁。如果是静态方法,JVM 会尝试获取当前 class 的锁。

不过两者的本质都是对对象监视器 monitor 的获取。

synchronized优化原理

轻量级锁

原理:

在线程的栈帧里创建锁记录,内部可以锁定对象的MarkWord

锁重入

同一个线程对同一对象加锁

锁膨胀

不同线程对同一对象加锁时要进行锁膨胀

轻量级锁:object 指向 thread 的锁记录,thread 锁指向 object

重量级锁:object 指向 monitor,              thread 锁指向 object,monitor 指向 thread

 自旋优化

线程的阻塞唤醒 需要从用户态切换到内核态,然后内核态切换tcb,切到另一个线程的内核态,再从内核态进入用户态,这是一个重量级的操作,效率低

 偏向锁

  • hashcode方法会撤销掉偏向锁?

hashcode会生成哈希码存在MarkWord中,此时没有位置为偏向锁存放thread id了

  • 为什么轻量级、重量级锁不会?

 轻量级锁的hashcode存放在MarkWord中的锁记录里,重量级存在MarkWord中的monitor里

批量重偏向

撤销偏向

 wait notify

wait会释放锁,等待需要的资源到来,等到条件满足时,调用notify唤醒

也就是说,必须先获得了锁进入了房间,才有资格进入等待室wait

 wait和sleep区别

正确使用

notify会随机唤醒一个wait线程

应该使用notifyall+while判断

设计模式

保护性暂停(同步)

要等待另一个线程的结果

guided类里包含一个get方法(等待结果wait),一个complete类(接收结果,唤醒线程)

 生产消费者(异步)

犹豫模式(同步)

 park unpark

 和wait、notify区别

活跃性

线程因为某种原因执行不完,死锁活锁饥饿

定位死锁

线程相互等待对方的资源

 活锁

死循环

 饥饿

 ReentrantLock

实现了 Lock 接口,是一个可重入且独占式的锁,和 synchronized 关键字类似。不过,ReentrantLock 更强大,增加了以下高级功能,底层是由AQS实现的。

可打断:可由其他线程来打断,可以破坏死锁

公平锁:先进先出,锁的获取顺序是按照请求顺序来进行的

多个条件变量:多个休息室

 语法

获取锁(可被打断):

 获取锁(可定时):

公平锁:

条件变量:

可以实现“选择性通知”


 JMM

JAVA内存模型,是一组规范,定义了Java程序中多线程 并发访问 内存时的行为。(比如说线程之间的共享变量必须存储在主内存中)

JMM确保了在不同的平台(跨平台) 上,Java程序对内存的访问表现出一致的行为。

解决CPU多级缓存和指令重排导致的问题,确保多线程程序的原子性、可见性和有序性。

原子性:一次操作或者多次操作,要么所有的操作全部都得到执行并且不会受到任何因素的干扰而中断,要么都不执行。

可见性:当一个线程对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值。

有序性:保证本线程内的代码不被重排序。

可见性(volatile)

一个线程对主存中数据进行了修改,对另一个线程不可见

线程可以把变量保存 本地内存 (比如机器的寄存器、高速缓存)中,而不是直接在主存中进行读写。这就可能造成 一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在本地内存中的变量值的拷贝,造成数据的不一致。

原因:

解决:

1. synchronized

2. 

volatile是线程同步的轻量级实现

有序性

volatile原理

保证可见性

指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。

保证有序性

在对这个变量进行读写操作的时候,会通过插入特定的 内存屏障 的方式来禁止指令重排序。

问题

只能保证单线程,不能保证其它线程

double-checked locking

第一个INSTANCE判断是为了  一个线程创建实例对象后,后面不用每个线程都上锁再判断(上锁影响性能)

第二个INSTANCE判断是为了  实现单例(懒汉式:真正需要使用对象的时候才去创建该类的对象),一个线程创建实例对象后,后面的线程就无需再次创建

synchronized不加在getInstance方法上是因为创建完实例对象后,后面的线程就可以并发获取这个instance了,不需要再阻塞获取,阻塞只是为了创建instance


问题

指令重排交错:t1线程在new Singleton后但未完成调用构造函数时,t2获取到了t1已经创建的这个INSTANCE并进行了返回

解决

在INSTANCE变量上加volatile

无锁实现(乐观锁 非阻塞)

乐观锁 多个线程可以同时访问一个共享资源,只在最后提交修改的时候判断该资源是否被其他线程修改过(通过版本号或者CAS法)

缺点:

写操作多时,频繁失败重试

ABA

只能保证一个共享变量的原子操作

cas原理

compareAndSet(获取的修改前的值,要修改成的值)

CAS 是一个原子操作,底层依赖于一条 CPU 的原子指令。

CAS操作是一种乐观锁机制,适用于多线程环境下对共享变量的并发访问控制。

主要包括三个参数:

  • V:原来的变量值(Var)
  • E:预期值(Expected)
  • N:新值(New)

CAS操作会比较V和E,相同的话将V更新为N,否则失败重试

线程在设置值前设置值后,要先比较自己获取的值(100、90、80)跟对象的最新值(90、80、80)是否一致,如果不一致就获取这个最新值,重新cas

为什么无锁效率高

总结:减少了上下文切换(特殊情况:如果线程时间片用完被打断的话,还是需要切换,所以适用于线程数少、多核cpu的场景)

 cas特点

工具类

原子类型

以下工具类使用cas实现

  • 原子整数类型 内部使用了volatile

        

  • 原子引用类型 用于保护引用类型数据,例如BigDecimal、String,使用了泛型

       语法:AtomicReference<String> ref = new AtomicReference<>("a");

  • 原子数组 改变的是引用类型对象内部的值,而不是地址,保证多线程安全性
  • 对象的属性修改类型/字段更新器

        域:对象的成员变量等

ABA问题

  • AtomicStampedReference

       添加版本号可以追踪被更改过几次

  • AtomicMarkableReference

       添加一个布尔值,可当成标记,通过对比该标记来判断是否被更改过

原子累加器

累加器 性能比 原子基本类型 高?

基本类型在有竞争时,通过cas不断重试

累加器,可以看成是分治思想,分成多个单元进行累加处理,最后再合并

LongAdder原理

多线程的情况下,同一份内存数据(cell数组元素)可映射到多个core(处理多个线程)的缓存中。当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,会导致其它core的缓存行失效。

缓存行:

        Cache 中的数据是按块读取的,当CPU访问某个数据时,会假设该数据附近的数据以后会被访问到,因此,第一次访问这一块区域时,会将该数据连同附近区域的数据(共64字节)一起读取进缓存中,那么这一块数据称为一个Cache Line 缓存行

伪共享:

        当两个以上CPU都要访问同一个缓存行大小的内存区域时,就会引起冲突,这种情况就叫“伪共享”

不可变 

保护性拷贝

享元模式

不可变类使用保护性拷贝,会创建很多重复对象,需要关联一种设计模式进行改进

应用:

包装类缓存机制

字符串常量池

BigDecimal、BigInteger

线程池

无状态

⭐线程池

自定义线程池

BlockingQueue:阻塞队列,用于存放等待处理的task

dequeue、emptyWaitSet(条件变量即没有任务时的等待室)、fullWaitSet(条件变量即队列满时的等待室)、capacity

poll(timeout) / take() :取出一个任务

offer(task,timeout) / put(task):放入一个任务

tryPut(rejectPolicy,task):对put的改进,如果队列满了可以自己选择拒绝策略

poll设置超时时间,即如果超过指定时间,那么线程将放弃从队列中获取任务,可以防止队列中长时间没有任务时 线程一直循环等待任务的到来。

offer设置超时时间,即如果超过指定时间,那么任务将放弃进入队列,可以防止队列长时间满时 任务一直循环等待进入。

Worker:单个线程处理任务

task

run(): 处理task,如果处理完了再从taskqueue中take等待的任务进行处理

ThreadPool:线程池

taskqueue(任务阻塞队列)、coreSize(最大核心线程数)、workers(正在执行的线程集合)、rejectPolicy(拒绝策略)

execute(task):多线程处理,有空闲线程时调用worker.run,没有则将task put或tryPut到queue中

rejectPolicy:拒绝策略,表示 当前同时运行的线程数量达到最大线程数量 并且 队列也已经被放满了任务时   要采取的行动

ThreadPoolExecutor

线程池处理任务流程

1. 有无空闲线程?

2. 运行线程 < 核心线程:创建线程

3.                >=              :等待队列?

4. 达到最大线程数:拒绝策略

    没达到              :创建线程

tomcat线程池:核心线程->救急线程->等待队列(tomcat等待队列是整数最大值Integer.MAX_VALUE,相当于无界)

线程池状态

构造方法

  • 最大线程数目 = 核心线程数目 + 救急线程数目
  • 生存时间:救急线程执行完之后经过多长时间被销毁
  • 核心线程没有生存时间,一旦被创建就一直存在
  • JDK拒绝策略

        其它

  • 阻塞队列

LinkedBlockingQueue(无界队列):容量为 Integer.MAX_VALUE 。FixedThreadPool 和 SingleThreadExector,二者的任务队列永远不会被放满。

SynchronousQueue(同步队列):CachedThreadPool 。SynchronousQueue 没有容量,不存储元素,目的是保证对于提交的任务,如果有空闲线程,则使用空闲线程来处理;否则新建一个线程来处理任务。也就是说,CachedThreadPool 的最大线程数是 Integer.MAX_VALUE ,可以理解为线程数是可以无限扩展的,可能会创建大量线程,从而导致 OOM。

DelayQueue(延迟阻塞队列):ScheduledThreadPool 和 SingleThreadScheduledExecutor。其中的元素只有到了其指定的延迟时间,才能够从队列中出队。DelayQueue 的内部元素并不是按照放入的时间排序,而是会按照 延迟的时间长短 排序,内部采用的是“堆”的数据结构,可以保证每次出队的任务都是当前队列中执行时间最靠前的。DelayQueue 添加元素满了之后会自动扩容原来容量的 1/2,即永远不会阻塞,最大扩容可达 Integer.MAX_VALUE,所以最多只能创建核心线程数的线程

PriorityBlockingQueue 是一个支持优先级的无界阻塞队列,可以看作是线程安全的 PriorityQueue,两者底层都是使用小顶堆形式的二叉堆,即值最小的元素优先出队。不过,PriorityQueue 不支持阻塞操作,无界

一些包装类

通过工具类Executors创建,例如:

ExecutorService pool = Executors.newFixedThreadPool (...)

newFixedThreadPool(固定大小)

newCachedThreadPool(带缓冲)

newSingleThreadPool(单线程)

方法

提交任务(执行任务)

关闭线程池

其它

ScheduledExecutorService

设置延迟时间

设置间隔,周期性执行 (如果间隔时间到了但是上一个任务还没完成,会等上一个任务执行完成才开启下一个周期的任务)

设置间隔(会等上一个任务执行完成才开启下一个周期的计时)

AQS 

AQS原理

AQS 就是一个抽象类,主要用来构建锁和同步器。

核心思想:如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套 线程阻塞等待 以及 被唤醒时 锁分配 的机制,这个机制 AQS 是基于 CLH 锁 (Craig, Landin, and Hagersten locks) 实现的。

CLH 锁是一个虚拟的双向队列,用于存储阻塞线程。AQS 将每条请求共享资源的线程封装成一个 CLH 队列锁的一个结点(Node)来实现锁的分配。

AQS内部还维护一个state变量,表示锁的占有状态,共享变量,使用volatile保证可见性。state被初始化为0,表示释放,被占有时,state被设置为1,还可以通过累加state实现重入。

概述

isHeldExclusively:判断是否持有独占锁

reentrantlock原理

非公平原理

没有竞争加锁

有竞争加锁

   -1代表该节点的后驱节点阻塞,该节点有职责去唤醒它的后继结点

释放锁

公平锁原理

可重入原理

发生了锁重入,将state自增

释放锁时,将state自减,判断state是否为0,为0才释放锁

可打断原理

不可打断:仅仅设置了标记,没有其它处理

可打断:抛出异常

条件变量原理---await

条件变量原理---signal

ReentrantReadWriteLock

读-读不会阻塞

  • 一般锁进行并发控制的规则:读读互斥、读写互斥、写写互斥
  • 读写锁进行并发控制的规则:读读不互斥、读写互斥、写写互斥

缓存更新策略

读:先查询缓存,如果缓存没有则查询数据库,并将查询结果写入缓存

写:清空缓存并更新

先清空缓存再更新数据库

在清空和更新之间进行查询,会把旧值写入缓存,导致之后的读一直是旧值

先更新数据库再清空缓存

 在清空和更新之间进行查询,查到的是旧值

stampedlock

Semaphore

共享锁,用来控制同时访问特定资源的线程数量

CountDownLatch

共享锁

CyclicBarrier

和countdownlatch类似,区别是cyclicbarrier可以重用,即如果一次计数变为0后,下次如果再调用会重新初始化为初始值,而countdownlatch只能调用一次,计数变为0后就无法再调用了

线程安全类

ConcurrentHashMap1.8

扩展:判断key是否存在,不存在则设为默认值(类似于HashMap的getOrDefault())

死链

出现在HashMap、JDK7(头插法)中

死链出现在并发扩容时,由于两个线程同时对哈希表扩容时,由于头插法时原来链表会逆序插入新的节点中(此时导致了链表指针反向)

当一个线程使得链表中部分节点指针的顺序逆向; 部分:表示没有插完;另一个线程在扩容时再次插入,就很可能产生循环链;

比如 某一节点处存储链表A->B->C->null;

1. 线程2 遍历旧链表准备插入到新节点中,此时指向A节点

2. 此时 线程1占领cpu,遍历旧链表,完成节点的插入:C->B->A->null (头插法,先来的插在链表头)

3. 线程2 占领cpu准备继续插入,此时线程2当前节点为A(next节点还是为B),导致 A->B->A,产生死链

        因为T1执行完扩容之后B节点的下一个节点是A,而T2线程指向的首节点是A,第二个节点是B,这个顺序刚好和T1扩完容完之后的节点顺序是相反的。T1执行完之后的顺序是B到A,而T2的顺序是A到B,这样A节点和B节点就形成死循环了,这就是HashMap死循环导致的原因。

源码分析

属性

sizeCtl:阈值,下一次扩容的大小(0.75 × 值)

Node[]:链表

table:旧哈希表

newTable:新哈希表

ForwardingNode:一个用于标记链表已经迁移的节点,哈希值为负数MOVED

TreeBin:红黑树头节点

TreeNode:红黑树节点,哈希值为负数

tabAt():获取链表头节点

casTabAt() / setTabAt():修改链表节点

 get

1. 若table为空或者头节点为空 -> return null

2. 判断头节点:

    2.1 哈希值匹配

          2.1.1 key匹配(是同一个对象或者key的内容相等)

                   return 值

    2.2 哈希值<0   (表示ForwardingNode,链表已迁移到新表中)

          到新表中查找值

    2.3 其它情况

          遍历链表或树,同2.1(比较哈希值、key)

put

onlyIfAbsent:如果为true,只有第一次put才放入map,如果已经有了就不再放入放入(即不会用新值覆盖旧值)

1. 如果要放入的key或value为空,抛出异常

2. 死循环

    2.1 table为空

          初始化,创建table(底层使用cas)

    2.2 头节点为空(表中没有该key,无冲突)

          创建节点并添加到table中

    2.3 哈希值 == MOVED(ForwardingNode)  即别的线程正在进行扩容
          帮忙扩容

    2.4 其他情况(桶下标冲突

          2.4.1 给头节点上锁

                   2.4.1.1 哈希值 ≥ 0(普通节点

                               遍历链表(比较哈希值、key)

                               2.4.1.1.1 找到 key

                                              如果 !onlyIfAbsent ,更新

                               2.4.1.1.2 找到末尾(没有找到

                                              追加到末尾

                   2.4.1.2 红黑树节点

                               添加

          2.4.2 如果链表数量(binCount)大于 TREEIFY_THRESHOLD 则要执行树化方法,                     在 treeifyBin 中会首先判断当前数组长度 ≥64 时才会将链表转换为红黑树

3. 因为添加了一个节点,所以要让整个表的长度Size加一,加一之后再判断是否需要进行扩容,若需要则进行扩容操作

initTable

1. sizeCtl为 -1 (表示其他线程正在初始化)

    让出CPU

2. 尝试将sizeCtl 设为 -1(cas)

    2.1 成功

          创建空table,设置初始容量或默认容量

    2.2 失败

          进入下一个循环

addCount

1. 表的长度加一

    看注释

2. 判断是否需要扩容 

size

1. 计算总数,将base和所有cell值累加

2. 判断是否超过最大值 Integet.MAX_VALUE

transfer

1.  如果新table为空,则创建新table(原有容量左移一位,即乘二)

 2. 循环链表

     2.1 如果头节点为空(else if开始)

           将该节点设为forwardingnode

     2.2 如果为forwardingnode(已经迁移完成)

           处理下一个节点

     2.3 其它情况(还没有迁移)

           2.3.1 哈希值 >= 0(普通节点)

           2.3.1 树节点

ConcurrentHashMap1.7 

 属性

segmentShift:移位

segmentMask:掩码

将key移位后再和掩码进行与运算,得到segment下标

put

1. 计算哈希值,通过hash值移位和掩码得到segment下标

2. 如果segment对象为空,则创建

3. 加锁

4. 计算在hashtable中的下标和该位置的链表头节点

    4.1 头节点不为空(该hashtable位置已经有链表存在)

          循环链表(比较hash、key)
          找到key,如果 !onlyIfAbsent ,更新

    4.2 头节点为空(还没有链表存在,进行新增操作)

          4.2.1 如果node(要插入的节点)为空,创建node

          4.2.2 让node指向原来的头节点(头插法

          4.2.3 如果超过阈值,进行扩容

5. 解锁

 rehash

1. 创建一个新的hashtable(容量为原来的两倍,阈值为 新容量×负载因子 )

2. 遍历旧table

    2.1 如果该位置只有一个元素

          直接搬迁

    2.2 如果该位置有多个元素

          2.2.1 遍历该位置的链表,找到最后一个rehash后下标不变的元素

          2.2.2 该元素之后的元素直接搬迁,之前的元素需要在新table重新创建

3. 扩容完成,加入新的节点(因为rehash方法是在put方法使用到的,扩容后要put节点)

get

segment、table不为空

      循环table对应下标的链表(比较hash、key),找到元素 

size 

 1. 遍历segment

        count表示segment中元素的个数

        modCount表示进行修改(put等操作)的次数

        如果count<0,表示溢出,标记

2. 如果modCount==上一次的modCount,表示在这期间没有其他线程干扰,计数完成

    否则重新计数

3. 解锁

4. 溢出则设为最大值

CopyOnWriteArrayList

`CopyOnWriteArrayList` 是 Java 并发包(java.util.concurrent)中提供的线程安全的 List 实现。其核心原理是 写时复制(Copy-On-Write)策略:当需要修改 `CopyOnWriteArrayList` 的内容时,不会直接修改原数组,而是会先创建底层数组的副本,对副本数组进行修改,修改完成后再将修改后的数组赋值回去。这种方式保证了写操作不会影响读操作,从而实现了读写分离,提高了读操作的性能。

主要特点和优势:
1. 读操作无需加锁
2. 写操作不阻塞读操作:写操作会对底层数组进行复制和修改,写操作完成后再将修改后的数组赋值回去,这样写操作不会阻塞读操作。
3. 线程安全:提供了线程安全的操作,适合在读操作频繁、写操作较少的场景下使用。

ThreadLocal

ThreadLocal让每个线程绑定自己的值,实现了线程之间的隔离

内部维护了一个ThreadLocalMap对象,key存放的是当前线程,value存放的是线程本地变量

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值