第二十一章:并发(上)

并发

  • 并发能提高吞吐量。对于单核处理器也可以做并发编程,但是如果其没有任何线程阻塞,那样还不如顺序编程事件驱动的编程是使用并发的常见示例。
  • 实现并发最直接的方式是在操作系统级别使用进程进程是运行在它自己的地址空间的自包容的程序。多任务操作系统可以通过周期性地将CPU从一个进程切换到另一个进程,来实现同时运行多个进程(程序),而进程之间不会有任何干涉。但Java所使用的并发系统会共享内存和I/O这样的资源,因此编写多线程程序最基本的困难在于协调不同线程驱动的任务对资源的使用,避免这些资源同时被多个任务访问
  • 一个关于操作系统进程的简单示例。比如说我在写一篇文章,写完以后我要保存在本地磁盘上,我还要放到云盘里,可能还要上传到csdn等等。那么如果我一步一步来的话肯定不如我三个一起来的方便。因为他们使用的资源(网络,磁盘读写)其实不同。即使后两者相同,我总不可能要因为csdn网站崩溃了,我就放弃再存储到云盘上的计划吧?即一个进程受阻,另一个进程可以继续并行。
  • 多进程是并发编程的一个方式,但Java采取了更加传统的方式,在顺序语言的基础上提供对线程的支持。与在多任务操作系统中分叉外部进程不同,线程机制是在由执行程序表示的单一进程中创建任务。这种方式的好处就是不依赖于操作系统(例如操作系统不支持多任务),使其具有更高的可移植性。

基本的线程机制

定义任务

  • 线程可以驱动任务,因此需要一种描述任务的方式,这可以由Runnable接口来提供。要想定义任务,只需实现Runnable接口并编写run()方法,使得该任务可以执行你的命令。
public class LiftOff implements Runnable {
    private static final int DEFAULT_COUNTDOWN = 10;
    private static int taskCount = 0;
    private final int id = taskCount++;
    private int countDown;
    public LiftOff(int countDown) {
        this.countDown = countDown;
    }
    public LiftOff() {
        this(DEFAULT_COUNTDOWN);
    }
    private String status() {
        return "#" + id + "(" + 
                (countDown > 0 ? countDown : "Liftoff") + ")";
    }
    @Override
    public void run() {
        while (countDown-- > 0) {
            System.out.println(status());
            Thread.yield();//建议cpu调度其他线程。
        }
    }
    public static void main(String args[]) {
        LiftOff lauch = new LiftOff();
        lauch.run();//这个和调用普通方法没有区别。不会重新创建线程
    }
}

Thread类

  • 将Runnable对象转变为工作任务的传统方式是将它提交给一个Thread构造器,下面是一个Thread驱动ListOff对象的例子:
public static void main(String args[]) {
    for (int i = 0; i < 5; i++) {
        new Thread(new LiftOff(5)).start();
    }
    System.out.println("我执行了!");
}
--------------运行结果(不唯一):
#0(4)
#3(4)
#2(4)
#2(3)
#1(4)
#2(2)
#0(3)
#0(2)
#0(1)
#0(Liftoff)
#4(4)
#3(3)
#3(2)
#3(1)
#3(Liftoff)
我执行了!
#4(3)
#4(2)
#4(1)
#4(Liftoff)
#2(1)
#2(Liftoff)
#1(3)
#1(2)
#1(1)
#1(Liftoff)
  • 这个例子没有过多可以解析的,注意要使用start()方法开启任务。另外此处是由单线程(main线程)来创建LiftOff对象。如果是多个线程创建LiftOff对象就会导致id重复的效果,这个是后话。可以看下例子:
public LiftOff(int countDown) {
    this.countDown = countDown;
    int task = taskCount;
    for (int i = 0; i < 100000000; i++);//浪费一点时间
    id = task++;
    taskCount++;
}
public static void main(String args[]) {
    for (int i = 0; i < 5; i++) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 5; i++) {
                    new Thread(new LiftOff(1)).start();
                }
            }
        }).start();
    }
}
----------------执行结果:
#0(Liftoff)
#1(Liftoff)
#6(Liftoff)
#7(Liftoff)
#9(Liftoff)
#4(Liftoff)
#10(Liftoff)
#0(Liftoff)
#13(Liftoff)
#0(Liftoff)
#1(Liftoff)
#18(Liftoff)
#15(Liftoff)
#17(Liftoff)
#12(Liftoff)
#20(Liftoff)
#16(Liftoff)
#19(Liftoff)
#21(Liftoff)
#22(Liftoff)
#11(Liftoff)
#8(Liftoff)
#14(Liftoff)
#23(Liftoff)
#24(Liftoff)
  • 我改写了一下构造方法,是结果更加明显。可以看到有好几个#0,至于为什么后面再说。

使用Executor

  • Java SE5java.util.concurrent包中的执行器(Executor)可以管理Thread对象,从而简化并发编程。ExecutorService(具有服务生命周期的Executor)知道如何构建恰当的上下文来执行Runnable对象。在下面的示例中,CachedThreadPool将为每个任务都创建一个线程。
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class CachedThreadPool {
    public static void main(String args[]) {
        ExecutorService exec = Executors.newCachedThreadPool();
        //ExecutorService exec = Executors.newFixedThreadPool(1);
        for (int i = 0; i < 50; i++) {
            exec.execute(new LiftOff(1));
        }
        exec.shutdown();//防止提交新任务
        //exec.execute(new LiftOff(1));//throw Exception
    }
}
  • Java通过Executors提供四种线程池,分别为:
    1. newCachedThreadPool创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。
    2. newFixedThreadPool 创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待。
    3. newScheduledThreadPool 创建一个定长线程池,支持定时及周期性任务执行。
    4. newSingleThreadExecutor 创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。

从任务中返回值

  • Runnable是执行工作的独立任务,但是它不返回任何值。如果希望在任务完成时返回一个值,那么可以实现Callable接口(是一个泛型接口)。它的一个重要方法是call(),并且必须使用ExecutorService.submit()方法调用它。下面是一个例子:
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

class TaskWithResult implements Callable<String> {
    private static int idx;
    private int id = idx++;
    @Override
    public String call() {
        return "#" + id;
    }
}
public class CallableDemo {
    public static void main(String args[]) {
        ExecutorService exec = Executors.newCachedThreadPool();
        Future<String> future = exec.submit(new TaskWithResult());//这个类名很有灵性
        System.out.println(future.isCancelled());
        System.out.println(future.isDone());//是否执行完毕
        exec.shutdown();
        try {
            //不检查直接调用会阻塞直到返回
            if (future.isDone())
                System.out.println(future.get());//获得返回结果
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
    }
}

捕获异常

  • 如果我线程中抛出了异常,我能够交给某个类统一管理吗?或者说直接在开启线程的地方直接就可以catch到?让我们来试试下面的代码:
public class Runner implements Runnable {

    private static int i = 1;
    private int id = i++;
    public void run() {
        throw new RuntimeException(id + "");
    }

    public static void main(String args[]) {
        try {
            new Thread(new Runner()).start();
            new Thread(new Runner()).start();
        } catch(Exception e) {
            System.out.println("error!");
        }
    }
}
  • 我们在执行后会发现控制台会打印异常信息,但是没有打印error,这说明这个异常不是在main函数里抛出的。事实上我们无法直接捕获线程中的异常,这些异常会直接向外传播到控制台。现在我们可以通过Executor来解决这个问题。Thread.UncaughtExceptionHandlerJava SE5的新接口,它允许你在每个Thread对象上都附着一个异常处理器Thread.UncaughtExceptionHandler.uncaughtException()会在线程因发生异常而临近死亡时被调用。为了使用它,我们创建一个新类型的ThreadFactory,为每个创建的线程统一装上这个“零件”。我们通过获得ExecutorService的静态方法指定该ThreadFactory即可。
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;

public class TestThread implements Runnable {
    private static int i = 1;
    private int id = i++;
    public void run() {
        if (id % 5 == 0) {
            throw new RuntimeException(id + "");
        }
    }

    public static void main(String args[]) {
        ExecutorService exec = Executors.newCachedThreadPool(new DealFactory());
        for (int i = 0; i < 50; i++) {
            exec.execute(new TestThread());
        }
        /*Thread.setDefaultUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
            @Override
            public void uncaughtException(Thread t, Throwable e) {
                System.out.println(t + "发生线程异常啦! 异常信息:" + e.getMessage());
            }
        });
        ExecutorService exec = Executors.newCachedThreadPool();
        for (int i = 0; i < 50; i++) {
            exec.execute(new TestThread());
        }*/
        exec.shutdown();
    }
}
class DealFactory implements ThreadFactory {
    @Override //newThread 工厂的流水线
    public Thread newThread(Runnable r) {
        Thread t = new Thread(r);
        //通过setUncaughtExceptionHandler为线程指定异常捕获器。即安装零件
        t.setUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
            @Override
            public void uncaughtException(Thread t, Throwable e) {
                System.out.println(t + "发生线程异常啦! 异常信息:" + e.getMessage());
            }
        });
        return t;
    }
}
---------------执行结果
Thread[Thread-2,5,main]发生线程异常啦! 异常信息:5
Thread[Thread-4,5,main]发生线程异常啦! 异常信息:10
Thread[Thread-5,5,main]发生线程异常啦! 异常信息:15
Thread[Thread-13,5,main]发生线程异常啦! 异常信息:25
Thread[Thread-11,5,main]发生线程异常啦! 异常信息:45
Thread[Thread-9,5,main]发生线程异常啦! 异常信息:35
Thread[Thread-6,5,main]发生线程异常啦! 异常信息:30
Thread[Thread-8,5,main]发生线程异常啦! 异常信息:20
Thread[Thread-21,5,main]发生线程异常啦! 异常信息:40
Thread[Thread-26,5,main]发生线程异常啦! 异常信息:50
  • 如果我们统一使用某个异常处理器进行异常捕获处理的话,可以通过Thread.setDefaultUncaughtExceptionHandler()直接指定异常处理器。

一些零碎:

  1. sleep()休眠
  2. yield()让步
  3. setPriority()和getPriority()设置获取线程优先级。优先级有10级,1最小,10最大。常用的三个级别为:MAX_PRIORITY(10)、MIN_PRIORITY(1)、NORMAL_PRIORITY(5)。
  4. 后台线程(deamon),这些线程不是必须的。如果程序中只存在后台线程,也就意味着程序即可终止。可以通过Thread(某实例).setDeamon(true);设置后台线程。由后台线程创建的子线程默认为后台线程。
  5. 尽量不要在构造器中启动线程。因为线程可能会访问一个不稳定状态的对象。这是优选Executor而不是显式创建Thread对象的原因之一。
  6. 在一个线程里调用另外一个线程的join(),相当于就是要等待另外一个线程执行完毕或中断(interrupt())才能继续当前线程。

共享受限资源

  • 如何协调多个线程访问某个资源是并发编程的一个难点。

不正确的访问资源

  • 下面的例子是开启几个线程,获得数字生成器产生的数字。如果产生了偶数就打印信息,并且通过设置bool值中止这个程序。
import java.util.concurrent.*;

abstract class IntGenerator {
    //volatile关键字保持可视性。这个后面会讨论
    //简单理解就是避免编译器优化:直接在该线程中保存了该值的副本,而不考虑其他线程改变了这个值。
    //所以volatile每次都会去初始对象中去查找。效率不高就是它的代价。
    private volatile boolean canceled;//默认false
    public abstract int next();
    public void cancel() {canceled = true;}
    public boolean isCanceled() {return canceled;}
}
class EventChecker implements Runnable {
    private IntGenerator generator;
    public EventChecker(IntGenerator generator) {
        this.generator = generator;
    }
    @Override
    public void run() {
        while (!generator.isCanceled()) {
            int i = generator.next();
            if (i % 2 != 0) {
                System.out.printf("%d is not even\n", i);
                generator.cancel();
            }
        }
    }
}
public class EventGenerator extends IntGenerator {
    private int currentValue;//默认0
    @Override
    public int next() {
        currentValue++;//此处非常危险,当该步骤刚执行完毕的时候,可能另外一个线程刚刚调用next()
        //且自增操作未必是原子性的。假设自增是先+2再-1(一个不恰当的例子),那么我们可能在+2后还没-1时被其他线程调用了

        //如果效果不明显多运行几次,实在不行就调用一下yield()方法。
        //Thread.yield();
        currentValue++;
        return currentValue;
    }
    public static void main(String args[]) {
        EventGenerator generator = new EventGenerator();
        ExecutorService exec = Executors.newCachedThreadPool();
        for (int i = 0; i < 10; i++) {
            exec.execute(new Thread(new EventChecker(generator)));
        }
        exec.shutdown();
    }
}

解决共享资源竞争

  • 防止资源竞争冲突的方法就是当资源被一个任务使用时,在其上加锁。第一个访问某项资源的任务必须锁定这项资源,使其他任务在其被解锁之前无法访问该资源。
  • 基本上所有的并发模式在解决线程冲突问题时,都是采用序列化访问共享资源的方案。通常是通过在代码前面加上一条锁语句来实现的。因为锁语句产生了一种互相排斥的效果,所以这种机制常常被称为互斥量(mutex)
  • Java以提供关键字synchronized的形式,为防止资源冲突提供了内置支持。当任务要执行被synchronized关键字保护的代码片段的时候,它将检查锁是否可用,然后获取锁,执行代码,释放锁。如果某个任务处于对标记为synchronized的方法的调用中,那么在这个线程从该方法返回之前,其他所有要调用类中任何标记为synchronized方法的线程都会被阻塞
  • 所有的对象都自动含有单一的锁(也称为监视器)。举个例子:
    synchronized void f(){}
    synchronized void h(){}
  • 假设某个类有两个synchronized方法。那么在一个对象调用f()还没结束之前,其余任何的线程都无法对这个对象调用f()g()。换句话说,对某个对象来说,其所有的synchronized方法共享同一个锁。另外如果这个对象在调用f()中又反复调用了f()g(),这是允许的。JVM负责跟踪对象被加锁的次数。如果一个对象被解锁,其计数变为0。在任务第一次给对象加锁的时候,计数为1。另外也允许用synchronized声明静态方法。此时这个锁会加在类型信息上,也可以说是加在Class对象上。
  • 所以我们只要改写之前的一个例子,就能防止之前的错误了。
public synchronized int next() {
    currentValue++;
    //加上这句话也没问题。
    Thread.yield();
    currentValue++;
    return currentValue;
}
使用显式的Lock对象
  • Java SE5中还包含了java.util.concurrent.locks中的显式的互斥机制。Lock对象必须被显式地创建锁定释放。与内建的锁形式相比,代码缺乏优雅系,但有时更为灵活。
    private Lock lock = new ReentrantLock();
    public int next() {
        lock.lock();//如果已经被人抢走了,就会阻塞直到别的线程unlock()
        try {
            currentValue++;
            Thread.yield();
            currentValue++;
            return currentValue;
        } finally {
            lock.unlock();
            //必须在return的临界点解锁。否则依旧会导致错误
            //错误原因解析:如果执行unlock()后没有马上返回,此时被线程2抢去了锁修改了currentValue的值
            //然后线程1再返回currentValue就可能出现奇数
        }
    }
  • 大体上,在使用synchronized关键字时,需要写的代码量更少,并且用户错误出现的可能性也会降低,因此只有在解决特殊问题时,才会显式的使用Lock对象。例如,用synchronized关键字不能尝试获取锁失败后做其他动作,或者尝试获取锁一段时间,然后放弃获取锁。要实现这些,就必须使用concurrent类库:
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class AttemptLocking {
    private Lock lock = new ReentrantLock();

    public void test() {
        /*try {
            Thread.sleep(100);
        } catch (InterruptedException e1) {
            e1.printStackTrace();
        }*///实验效果不明显 就在这里加上休眠语句
        boolean captured = lock.tryLock();
        try {
            System.out.println("tryLock():" + captured);
        } finally {
            if (captured) {
                lock.unlock();
            }
        }
        try {
            captured = lock.tryLock(2, TimeUnit.SECONDS);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            System.out.println("tryLock(2, TimeUnit.SECONDS):" + captured);
            if (captured) {
                lock.unlock();
            }
        }
    }

    public static void main(String args[]) {
        final AttemptLocking at = new AttemptLocking();
        at.test();
        new Thread() {
            {setDaemon(true);}//非后台线程
            public void run() {
                at.lock.lock();//抢锁 
                System.out.println("抢到锁啦!");//上一句会阻塞直到抢到锁
            }
        }.start();
        Thread.yield();//给子线程一个机会 不过也不是一定会给它机会
        at.test();
    }
}

原子性和易变性

  • 在有关Java线程的讨论中,一个常不正确的知识是“原子操作不需要进行同步控制”。原子操作是不能被线程调度机制中断的操作。一旦操作开始,那么它一定可以在可能发生的“上下文切换”之前执行完毕。原子性可以应用于除longdouble之外的所有基本类型之上的简单操作。对于读取和写入除longdouble之外的基本类型变量这样的操作,可以保证它们会被当作不可分的操作来操作内存(可以想象32位操作为一个院子操作)。
  • 那为什么说“原子操作不需要进行同步控制”是一个错误的认知呢?在多处理器系统上,可视性问题远比原子性问题多得多。一个任务做出的修改,即使修改操作是原子性的,过程中不会被中断,对其他任务也可能不可视的(例如,修改只是暂时性地存储在本地处理器的缓存中),不同的任务对应用的状态有不同的视图。如果没有同步机制,那么修改时可视将无法确定。很遗憾我给不出这样的例子。不过我觉得这个问题肯定是存在的。下面一个例子也告诉我们不能盲目地利用原子性:
public class MyTest {
    private int i;
    public /*synchronized*/ int getI() {
        return i;
    }
    public synchronized void test() {
        i++;
        i++;
    }

    public static void main(String args[]) throws Exception {
        final MyTest test = new MyTest();
        new Thread() {
            public void run() {
                while (true) {
                    int ii = test.getI();
                    if (ii % 2 != 0) {
                        System.out.println(ii);
                        System.exit(0);
                    }
                }
            }
        }.start();
        while (true) {
            test.test();
        }
    }
}
----------执行结果:
一个奇数
  • 虽然getI()是一个原子操作,但是缺少同步,使得其数值可以在处于不稳定的中间状态时被读取。除此之外,由于i也不是volatile的,因此还存在可视性问题。
  • 下面是简单的自增操作的JVM指令:
getfield        //获取域中的值加入栈顶
iconst_1        //将常量1加入栈顶
iadd            //将栈顶两个元素相加
putfield        //将栈顶元素放入域
  • 所以你可以想象如果一个线程处第一步或第二步执行完毕的阶段。此时另外一个线程getfield了。这时第二个线程就会获得不稳定的值。导致这两个线程最后执行两次的结果只是自增1。我们来看一个例子,确定在Java中自增不是原子性操作:
public class SerialNumberChecker {
    private static CircularSet set = new CircularSet(1000);
    public static void main(String args[]) {
        for (int i = 0; i < 10; i++) {
            new Thread(new Runnable() {
                public void run() {
                    while (true) {
                        int serial = SerialNumberGenerator.nextSerialNumber();
                        if (set.contain(serial)) {
                            System.out.println("Dulpicate: " + serial);
                            System.exit(0);
                        }
                        set.add(serial);
                    }
                }
            }).start();
        }
    }
}
class SerialNumberGenerator {
    private static volatile int serialNumber = 0;

    public static int nextSerialNumber() {
        return serialNumber++;
        //不是原子性操作
    }
}
class CircularSet {
    private int[] array;
    private int len;
    private int index = 0;

    public CircularSet(int size) {
        len = size;
        array = new int[size];
        for (int i = 0; i < size; i++) {
            array[i] = -1;
        }
    }

    public synchronized void add(int i) {
        array[index] = i;
        index = ++index % len;
    }

    public synchronized boolean contain(int val) {
        for (int i = 0; i < len; i++) {
            if (array[i] == val) return true;
        }
        return false;
    }
}
  • 我创建了一个数组用于存放序列数。add和contain都是同步方法,保证对实验不会产生其他影响。运行后发现结果还是会产生重复的数字。当我们给nextSerialNumber()也加上synchronized就不会造成错误了。
  • 原子类:AtomicInteger、AtomicLong、AtomicReference等等以Atomic开头的类都是Java SE5引入的新的原子性变量类。他们的操作是原子性的。这里我就不展开了。
  • 临界区:与同步方法类似,临界区只控制部分代码的同步。形如:
synchronized (args) {

}
  • 也被称为同步控制块,在进入此段代码前,必须得到args对象的锁(一般指定为this,但是你也可以指定为别的对象,所以临界区的用法更广)。如果代码是针对类方法的。可以将args设为类的Class对象(调用类方法或类变量,相当于是调用Class对象的方法或变量,所以应该获得Class对象的锁)。临界区相比于同步方法的优点也是显而易见的。试想一下如果一个方法特别长,但是其中就一小块区域涉及共享资源,那么就应该使用临界区而不是同步方法。具体例子的话我也省略了。

线程本地存储

  • 防止任务在共享资源上产生冲突的第二种方式是根除对变量的共享。顾名思义,线程本地存储可以为不同的线程创建不同的存储。创建和管理线程本地存储可以由java.lang.ThreadLocal类来实现:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ThreadLocalTest {
    private static ThreadLocal<Integer> value = new ThreadLocal<Integer>() {
        protected synchronized Integer initialValue() {
            return 0;
        }
    };
    private static ThreadLocal<MyClass> myClass = new ThreadLocal<MyClass>() {
        protected synchronized MyClass initialValue() {
            return new MyClass();
        }
    };
    public static void main(String args[]) {
        //使用CachedThreadPool可能导致线程重用而出现值递增
        //ExecutorService exec = Executors.newCachedThreadPool();
        ExecutorService exec = Executors.newFixedThreadPool(10);
        for (int i = 0; i < 10; i++) {//线程数量不超过10,避免线程重用
            exec.execute(new Runnable() {
                @Override
                public void run() {
                    int i = 0;
                    while (i++ < 1000) {
                        value.set(value.get() + 1 );
                        MyClass mc = myClass.get();
                        mc.i = --mc.i;
                        mc.i = --mc.i;
                    }
                    System.out.println(Thread.currentThread() + " " + value.get() + "  " + myClass.get().i);
                }
            });
        }
    }
}
class MyClass {
    int i;
    String s;
}
----------------运行结果
Thread[pool-1-thread-3,5,main] 1000  -2000
Thread[pool-1-thread-8,5,main] 1000  -2000
Thread[pool-1-thread-2,5,main] 1000  -2000
Thread[pool-1-thread-5,5,main] 1000  -2000
Thread[pool-1-thread-7,5,main] 1000  -2000
Thread[pool-1-thread-10,5,main] 1000  -2000
Thread[pool-1-thread-6,5,main] 1000  -2000
Thread[pool-1-thread-4,5,main] 1000  -2000
Thread[pool-1-thread-1,5,main] 1000  -2000
Thread[pool-1-thread-9,5,main] 1000  -2000
//有兴趣的可以使用newCachedThreadPool 或者定义更少的newFixedThreadPool试试

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值