底层之JUC

JUC就是java.util.concurrent工具包的简称。是一个处理线程的工具包,JDK1.5之后出现的。

一:volatile和内存可见性

内存可见性

    当多个线程操作共享变量时,一个线程修改了它的值,其他线程马上能看到最新修改的值。java中可以通过synchronized和volatile实现可见性。

public class TestVolatile {
    public static void main(String[] args){ //这个线程是用来读取flag的值的
        ThreadDemo threadDemo = new ThreadDemo();
        Thread thread = new Thread(threadDemo);
        thread.start();
        while (true){
            if (threadDemo.isFlag()){
                System.out.println("主线程读取到的flag = " + threadDemo.isFlag());
                break;
            }
        }
    }
}
class ThreadDemo implements Runnable{ 
    public  boolean flag = false;
    @Override
    public void run() {
        try {
            Thread.sleep(200);//模拟线程没来的及把数据写进主存
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        flag = true;
        System.out.println("ThreadDemo线程修改后的flag = " + isFlag());
    }
}

结果:
在这里插入图片描述

可以看到执行之后是一个死循环,因为对于非volatile变量进行读写的时候,每个线程先从主存中把变量拷贝到CPU缓存中,在不同的CPU上被处理。在这里插入图片描述
也就是说ThreadDemo线程先从主存中把数据flag=false拷贝到CPU1缓存中,此时主线程也从主存中把数据拷贝到CPU2缓存中,之后ThreadDemo线程把主存中的flag改成true时,main线程看不到它的更改(CPU2缓存中的flag没有发生改变),就发生了死循环。要想解决这个问题 ,有如下方法:
1:加锁(只要在此线程内锁住任何东西都回去更新主存)

while (true){
        synchronized (threadDemo){
            if (threadDemo.isFlag()){
                System.out.println("主线程读取到的flag = " + threadDemo.isFlag());
                break;
            }
        }
 }

加锁之后,每次while循环就会去主存中读取数据,但是加锁时,每次只能有一个线程访问,其他线程阻塞,效率很低。
2:使用volatile关键字

public  volatile boolean flag = false;

volatile和synchronized的区别

  1. volatile只能用在变量上,synchronized可以用在变量、方法和类级别上。
  2. volatile不会造成线程阻塞,sychronized可能会造成线程阻塞。
  3. volatile仅能实现变量的修改可见性,而synchronized则可以保证变量的修改可见性和原子性。
  4. volatile本质是在告诉jvm当前变量在寄存器中的值是不确定的,需要从主存中读取,synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。

volatile的使用场景:

  1. 只有一个线程修改此变量的值
  2. 变量的状态不需要与其他变量共同参与不变约束
  3. 访问变量不需要加锁

二:原子性

原子性:一个操作或多个操作要么全部执行,要么全都不执行

public class TestIcon {
    public static void main(String[] args){
        AtomicDemo atomicDemo = new AtomicDemo();
        for (int x = 0;x < 10; x++){
            new Thread(atomicDemo).start();
        }
    }
}

class AtomicDemo implements Runnable{
    private int i = 0;
    public int getI(){
        return i++;
    }
    @Override
    public void run() {
        try {
            Thread.sleep(200);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(getI());
    }
}

结果:
在这里插入图片描述
启动十个线程去执行i++操作,可以发现,出现了重复的数据,因为i++可以细分为以下三个操作:

int temp = i;
i = i+1;
i = temp;

有多个线程同时获取了数据,然后同时自加,同时写入了内存,就引发原子性问题。

原子变量

JDK1.5 以后, java.util.concurrent.atomic包下,提供了常用的原子变量

  • 原子变量中的值,使用 volatile 修饰,保证了内存可见性。
  • CAS(Compare-And-Swap) 算法保证数据的原子性。

原子变量的使用(以AtomicInteger为例):
在这里插入图片描述

CAS

    CAS是一种非阻塞算法的实现,也是一种乐观锁,它能在不使用锁的情况下实现多线程安全,所以CAS也是一种无锁算法。

  • 内存值V
  • 预估值A
  • 更新值B

当且仅当V==A时,才把V的值更新为B,否则不做任何操作。
CAS的缺点:

  1. 循环时间长,开销很大。比如使用getAndAddInt执行时,如果CAS失败,会一直进行尝试。如果CAS长时间尝试但是一直不成功,可能会给CPU带来很大的开销。
  2. 只能保证一个共享变量的原子操作。如果是操作多个共享变量时,循环CAS就无法保证操作的原子性,这个时候就需要用锁来保证原子性。
  3. 存在ABA问题。先将预期值A改成B,再改回A,那CAS操作就会误认为A的值从来没有被改变过,然后把V的值改成了B。

ABA问题的危害
现有一个用单向链表实现的堆栈,栈顶为A,这时线程T1已经知道A.next为B,然后希望用CAS将栈顶替换为B:

在这里插入图片描述

head.compareAndSet(A,B);

在T1执行上面这条指令之前,线程T2介入,将A、B出栈,再pushD、C、A,B变成了游离态,此时轮到线程T1执行CAS操作,检测发现栈顶仍为A,所以CAS成功,栈顶变为B,但实际上B.next为null,其中堆栈中只有B一个元素,C和D组成的链表不再存在于堆栈中,平白无故就把C、D丢掉了。
在这里插入图片描述
ABA问题解决方案
java并发包提供了AtomicStampedReference这个类,变量前面添加版本号,每次变量更新的时候都把版本号+1

三:锁分段机制

JDK1.5之后,再java.util.concurrent包中提供了多种并发容器类来改进同步类容器的性能。其中主要的就是ConcurrentHashMap。

JDK1.5 ConcurrentHashMap

ConcurrentHashMap 同步容器类是Java 5 增加的一个线程安全的哈希表。对与多线程的操作,介于HashMap 与Hashtable 之间。内部采用“锁分段”机制替代Hashtable 的独占锁。进而提高性能。
ConcurrentHashMap 类中包含两个静态内部类 HashEntry 和 Segment。
HashEntry:用来封装映射表的键/值对;
Segment:默认分成了16个Segment,一个 Segment 其实就是一个类 Hash Table 的结构,Segment 内部维护了一个链表数组。每个Segment都继承了ReentrantLock(可重入锁)。

在这里插入图片描述
从上图可以看出,ConcurrentHashMap采用了二次hash的方式,第一次hash将key映射到对应的Segment,而第二次hash则是映射到Segment对应的不同桶(bucket)中。这样做的好处在于写数据时不会锁住整个容器,而只会锁住数据所在的那个Segment,其他Segment不受影响。缺点就是整个hash的过程比hashmap单次hash的时间要长。
使用:

public class TestConCurrentHashMap {
  
    public static void main(String[] args) {
        for(int i=0;i<3;i++){
            new Thread(new ConcurrentHashM()).start();
        }
        ConcurrentHashMap concurrentHashMap = ConcurrentHashM.concurrentHashMap;
        concurrentHashMap=ConcurrentHashM.concurrentHashMap;
        Iterator iterator = concurrentHashMap.entrySet().iterator();
        while(iterator.hasNext()){
            Map.Entry next = (Map.Entry)iterator.next();
            String key = (String) next.getKey();
            String value = (String) concurrentHashMap.get(key);
            System.out.println("当前可以"+key+"的值为"+value);
        }
    }
}
class ConcurrentHashM implements Runnable{
    public static ConcurrentHashMap concurrentHashMap=new ConcurrentHashMap();
    static {
        concurrentHashMap.put("a","a");
        concurrentHashMap.put("b","b");
        concurrentHashMap.put("c","c");
    }
    @Override
    public void run() {
        concurrentHashMap.put(Thread.currentThread().getName(),Thread.currentThread().getName());
    }
}

java.util.concurrent包还提供了设计用于多线程上下文中的 Collection 实现: ConcurrentHashMap、ConcurrentSkipListMap、ConcurrentSkipListSet、CopyOnWriteArrayList 和 CopyOnWriteArraySet。当期望许多线程访问一个给定 collection 时,ConcurrentHashMap 通常优于同步的 HashMap,ConcurrentSkipListMap 通常优于同步的 TreeMap。当读操作次数多于写操作次数时,CopyOnWriteArrayList 优于同步的 ArrayList。

JDK1.8 ConcurrentHashMap

    在JDK1.5 的ConcurrentHashMap中,虽然HashEntry中的value使用volatile关键词修饰的,但是不能保证并发的原子性,所以put操作仍然需要加锁处理。
     JDK1.8 中ConcurrentHashMap摒弃了锁分段机制,采用了synchronized+CAS+HashEntry+红黑树的数据结构,数据结构上已经接近HashMap。

四:闭锁

只有其他所有线程的全部运算完成,当前运算才能继续执行。使用CountDownLatch来实现(同步辅助类)。

CountDownLatch

它实现了一个“共享锁”,可以阻塞一个或多个线程,以等待另一组事件的发生后,继续执行被阻塞的一个或多个线程。其核心实现就是基于AQS,另外CountDownLatch闭锁是可中断锁。通过一个计数器来实现,计数器的初始值为线程的数量。每当一个线程完成了自己的任务后,计数的值就会减1,当计数器值到达0时,它所表示所有的线程已经完成了任务,然后在闭锁上等待的线程就可以恢复执行任务。

public class TestCountDownLatch{
    /*
    * 模拟五个人加载游戏页面
    * 需要同时加载,同时进入游戏
    * */
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch initial = new CountDownLatch(1);
        CountDownLatch beginGame = new CountDownLatch(5);
        InitialHtml initialHtml = new InitialHtml(initial,beginGame);
        for (int i=0;i<5;i++){
            new Thread(initialHtml).start();
        }
        Thread.sleep(2000);
        initial.countDown();
        beginGame.await();
        System.out.println("全军出击");
    }
}
class InitialHtml implements Runnable{
    CountDownLatch initial,beginGame;
    public InitialHtml(CountDownLatch initial, CountDownLatch beginGame) {
        this.initial = initial;
        this.beginGame = beginGame;
    }

    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName()+"等待队友加载完成页面");
        try {
            initial.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }finally {
            System.out.println(Thread.currentThread().getName() + "加载完成,还有" + beginGame.getCount()+"人未完成");
            beginGame.countDown();
        }
    }
}

CountDownLatch的不足:

  • CountDownLatch是一次性的,计数器的值只能在构造方法中初始化一次,之后没有任何机制再次对其设置值,当CountDownLatch使用完毕后,它不能再次被使用

五:创建线程的方式----实现Callable接口

java中实现多线程的方式有三种:

  1. 继承Thread类
  2. 实现Runnable接口
  3. 实现Callable接口

Callable

Callable位于java.util.concurrent包下,它也是一个接口,在它里面也只声明了一个call()方法。

使用

1:Callable+Future

public class TestCallable {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        ExecutorService executorService = Executors.newCachedThreadPool();
        CallableDemo callableDemo=new CallableDemo();
        Future<Integer> submit = executorService.submit(callableDemo);
        Integer integer = submit.get();
        executorService.shutdown();
        System.out.println("sum="+integer);
    }
}
class CallableDemo implements Callable<Integer> {
    int sum=0;
    @Override
    public Integer call() throws Exception {
        for(int i=0;i<10;i++){
            sum+=i;
        }
        return sum;
    }
}

2:Callable+FutureTask

public class TestCallable {
    public static void main(String[] args) {
        CallableDemo callableDemo=new CallableDemo();
        //执行callable方式,需要FutureTask实现类的支持,用来接收运算结果
        FutureTask<Integer> result=new FutureTask<>(callableDemo);
        new Thread(result).start();
        Integer integer = null;
        try {
            integer = result.get();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
        System.out.println("sum="+integer);
    }
}
class CallableDemo implements Callable<Integer> {
    int sum=0;
    @Override
    public Integer call() throws Exception {
        for(int i=0;i<10;i++){
            sum+=i;
        }
        return sum;
    }
}

上述案例中使用了FutureTask和Future用来接收Callable接口实现类的返回值。

Future

Future就是对于具体的Runnable或者Callable任务的执行结果进行取消、查询是否完成、获取结果。必要时可以通过get方法获取执行结果,该方法会阻塞直到任务返回结果。

Future类中有五个方法:

  • cancel方法用来取消任务,如果取消任务成功则返回true,如果取消任务失败则返回false。参数mayInterruptIfRunning表示是否允许取消正在执行却没有执行完毕的任务,如果设置true,则表示可以取消正在执行过程中的任务。如果任务已经完成,则无论mayInterruptIfRunning为true还是false,此方法肯定返回false,即如果取消已经完成的任务会返回false;如果任务正在执行,若mayInterruptIfRunning设置为true,则返回true,若mayInterruptIfRunning设置为false,则返回false;如果任务还没有执行,则无论mayInterruptIfRunning为true还是false,肯定返回true。
  • isCancelled方法表示任务是否被取消成功,如果在任务正常完成前被取消成功,则返回 true。
  • isDone方法表示任务是否已经完成,若任务完成,则返回true;
  • get()方法用来获取执行结果,这个方法会产生阻塞,会一直等到任务执行完毕才返回;
  • get(long timeout, TimeUnit unit)用来获取执行结果,如果在指定时间内,还没获取到结果,就直接返回null。

FutureTask

父类RunnableFuture实现了Runnable和Future接口。它既可以作为Runnable被线程执行,又可以作为Future得到Callable的返回值。

Callable和Runnable的区别:

  1. call()方法可以有返回值
  2. call()可以抛出异常,被外面的操作捕获,获取异常的信息
  3. Callable是支持泛型的
  4. Callable和Runnable都可以应用于ExecutorService。而Thread类只支持Runnable。

六:Lock同步锁

Lock是java5提供的一个强大的线程同步机制,通过显式定义同步锁来实现同步。
ReentrantLock 实现了Lock 接口,并提供了与synchronized 相同的互斥性和内存可见性。但相较于synchronized 提供了更高的处理锁的灵活性。

public class TestLock {
    public static void main(String[] args) {
        LockDemo lockDemo = new LockDemo();
        new Thread(new FutureTask(lockDemo),"1号窗口").start();
        new Thread(new FutureTask(lockDemo),"2号窗口").start();
        new Thread(new FutureTask(lockDemo),"3号窗口").start();
    }
}
class LockDemo implements Callable{
    private int ticket=3;
    AtomicInteger integer=new AtomicInteger(ticket);
    Lock lock=new ReentrantLock();
    @Override
    public Object call() throws Exception {
        lock.lock();
        try {
            System.out.println(Thread.currentThread().getName()+"卖出一张票,当前还剩"+(--ticket));
        }finally {
            lock.unlock();
        }
        return null;
    }
}

synchronized和Lock的区别

  1. 首先synchronized是java内置关键字,在jvm层面,Lock是个java类;
  2. synchronized方法或者代码块执行完之后会自动释放,而Lock需要手动释放锁,如果不释放可能会造成死锁问题;
  3. synchronized的锁可重入、不可中断、非公平,而Lock锁可重入、可判断、可公平(两者皆可);
  4. Lock锁适合大量同步的代码的同步问题,synchronized锁适合代码少量的同步问题。
  5. synchronized无法判断是否获取锁的状态,Lock可以判断是否获取到锁;
  6. 用synchronized关键字的两个线程1和线程2,如果当前线程1获得锁,线程2线程等待。如果线程1阻塞,线程2则会一直等待下去,而Lock锁就不一定会等待下去,如果尝试获取不到锁,线程可以不用一直等待就结束了;

七:等待唤醒机制

等待唤醒机制就是指线程等待时释放锁,并且加入等待队列,当需要唤醒时,再把当前线程加入就绪队列。等待唤醒机制有两种实现方式:

  1. 通过Object得wait和notify与对象监视器配合完成线程间得等待/唤醒机制。
  2. 通过Lock和Condition配合完成等待/唤醒。

wait和notify

这种方式需要wait和notify在同步块中。

/*
* 货不满就可以进货,货>0就能卖货
* */
public class TestProduceAndConsumer {
    public static void main(String[] args) {
        Clear clear = new Clear();
        new Thread(new Product(clear)).start();
        new Thread(new Consumer(clear)).start();
    }
}
//店员
class Clear{
    private int repertory=0;
    //进货
    public synchronized void get(){
            while(repertory>10){
                System.out.println("产品已满");
                try {
                    this.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println(Thread.currentThread().getName() + "购入产品,库存还剩:" + (++repertory));
            //唤醒
            this.notifyAll();
    }
    //卖货
    public synchronized void sell(){
        while(repertory<=0){
            System.out.println("缺货");
            try {
                this.wait();//缺货就等待
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
            System.out.println(Thread.currentThread().getName()+"卖出一件,库存还有:"+ (--repertory));
            this.notifyAll();
    }
}
//消费者
class Consumer implements Runnable{
    private Clear clear;

    public Consumer(Clear clear) {
        this.clear = clear;
    }

    @Override
    public void run() {
        for(int i=0;i<20;i++){
            clear.sell();
        }
    }
}
//生产者
class Product implements Runnable{
    private Clear clear;

    public Product(Clear clear) {
        this.clear = clear;
    }
    @Override
    public void run() {
        for(int i=0;i<20;i++){
            clear.get();
        }
    }
}

await和signal

这种机制通过Lock和Condition得搭配使用来实现等待唤醒机制。

public class TestProduceAndConsumer{
    public static void main(String[] args) {
        Clerk clerk = new Clerk();

        new Thread(new Product(clerk),"B").start();
        new Thread(new Consumer(clerk),"A").start();
    }
}
class Clerk{
    private int repertory=0;
    Lock lock = new ReentrantLock();
    Condition condition = lock.newCondition();
    //售货
    public void sell(){
        try{
            lock.lock();
            while(repertory<=0){
                System.out.println(Thread.currentThread()+"----------货源不足");
                try {
                    condition.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println(Thread.currentThread()+"卖出一件,库存还有"+(--repertory));
            //唤醒进货线程
            condition.signalAll();
        }finally {
            lock.unlock();
        }
    }
    //进货
    public void get(){
        try {
            lock.lock();
            while (repertory >= 3) {
                System.out.println("库存已满,当前库存" + repertory);
                try {
                    condition.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println(Thread.currentThread()+"进货成功"+(++repertory));
            //唤醒售货线程来消费
            condition.signalAll();
        }finally {
            lock.unlock();
        }
    }
}
class Product implements Runnable{
    private Clerk clerk;

    public Product(Clerk clerk) {
        this.clerk = clerk;
    }

    @Override
    public void run() {
        for(int i=0;i<20;i++){
            clerk.get();
        }
    }
}
class Consumer implements Runnable{
    private Clerk clerk;

    public Consumer(Clerk clerk) {
        this.clerk = clerk;
    }

    @Override
    public void run() {
        for(int i=0;i<20;i++){
            clerk.sell();
        }
    }
}

上述两种方法除了使用方式上不同外,在功能特性上也有很多得不同:

  1. Condition能够支持不响应中断,而Object不支持。
  2. Condition支持多个等待队列(new 多个Condition对象),而Object只能支持一个。
  3. Condition能够支持超时间得设置,而Object不支持。

八:ReadWriterLock读写锁

读写允许同时有多个读者来访问共享资源,最大可能的读者数为实际的逻辑CPU数。写者是排他性的,一个读写锁同时只能有一个写者或多个读者(与CPU数相关),但不能同时既有读者又有写者。

public class TestReadWriteLock {
    public static void main(String[] args) {
        ReadWriteLDemo readWriteLDemo=new ReadWriteLDemo();
        for(int i=0;i<100;i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    readWriteLDemo.write(10);
                }
            }).start();
            new Thread(new Runnable() {
                @Override
                public void run() {
                    System.out.println(readWriteLDemo.read());
                }
            }).start();
        }
        for(int i=0;i<10000;i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    System.out.println(readWriteLDemo.read());
                }
            }).start();
        }
    }
}
class ReadWriteLDemo{
    Lock lock=new ReentrantLock();
    private ReentrantReadWriteLock readWriteLock=new ReentrantReadWriteLock();
    private int num=0;
    //模拟写操作
    public void write(int num){
        //写锁
        try {
            /*
            * 模拟写锁中的线程
            * */
            Thread.sleep(1100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        readWriteLock.writeLock().lock();
        try {
            this.num = num;
        }finally {
            readWriteLock.writeLock().unlock();
        }
    }
    //模拟读操作
    public int read(){
        readWriteLock.readLock().lock();
        try{
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            return num;
        }finally {
            readWriteLock.readLock().unlock();
        }
    }
}

读写锁不能直接从读锁升级成写锁,如果读锁还没有释放,此时获取写锁,会导致永久等待,最终导致相关线程都阻塞。锁的降级是允许的,即写锁还没有释放时,获取读锁。

九:线程池

线程池就是提前创建一些线程,通过重用这些已经创建好的线程来减少系统频繁创建和销毁线程的目的,从而提高系统的性能。目前有四种常见的线程池
在这里插入图片描述

  1. newCacheThreadPool:创建一个可以无限扩大的线程池,用于负载教轻,执行时间较短的异步任务。
  2. newFixedThreadPool:创建一个固定数量的线程池,因为采用无界的阻塞队列,所以实际线程数量永远不会变化,适用于负载较重的场景,对当前线程数量进行限制。(保证线程数可控,不会造成线程过多,导致系统负载更严重)
  3. newSingleThreadExecutor:创建一个单线程的线程池,适用于需要保证顺序执行各个任务的场景。
  4. newScheduleThreadPool:创建一个可以执行周期性或有延时任务的线程池。

线程池工作流程

在这里插入图片描述
判断线程池中的线程数量是否达到corePoolSize,若未达到,则创建一个新的线程,任务结束后,该线程会保留在线程池中。判断线程池中的线程是否达到maximumPoolSize,如果未达到,则新建一个线程,任务结束后,当线程空闲时间超过了keepAliveTime,则这个线程就会被销毁,如果达到了则使用饱和策略来处理这个任务。

饱和策略

AbortPolicy:默认策略;新任务提交时直接抛出未检查的异常RejectedExecutionException,该异常可由调用者捕获。
CallerRunsPolicy:既不抛弃任务也不抛出异常,使用调用者所在线程运行新的任务。
DiscardPolicy:丢弃新的任务,且不抛出异常。
DiscardOldestPolicy:调用poll方法丢弃工作队列队头的任务,然后尝试提交新任务。

如何配置线程池

CPU密集型:
尽量使用较小的线程池,一般为CPU核心数+1,因为CPU密集型任务使得CPU使用率很高,若开过多的线程数,会造成CPU过度切换。

IO密集型
可以使用稍大的线程池,一般为2*CPU核心数,IO密集型任务CPU使用率不高,因此可以让CPU在等待IO的时候有其他线程去处理别的任务,充分利用CPU时间。

混合型任务
可以将任务分成CPU密集型和IO密集型任务,然后分别用不同的线程池去处理。只要分完之后两个任务的执行时间相差不大,那么就会比串行执行起来的高效。如果两个的执行时间有较大差距,那就没有拆分的意义。

execute和submit方法的区别

execute方法参数为Runnable接口的实现者,而submit方法参数既可以是Runnable接口的实现者也可以是Callable接口的实现者,这就使得submit方法可以接收返回值(Future.get()获取)。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值