并发常用技术点二

Java 守护线程 ( Daemon Thread )

守护线程和用户线程的区别

Java 提供了两种类型的线程: 守护线程 和 用户线程

守护线程 是高优先级线程。JVM 会在终止之前等待任何用户线程完成其任务。
用户线程 是低优先级线程。其唯一作用是为用户线程提供服务。

由于守护线程的作用是为用户线程提供服务,并且仅在用户线程运行时才需要,因此一旦所有用户线程完成执行,JVM 就会终止。也就是说 守护线程不会阻止 JVM 退出。
这也是为什么通常存在于守护线程中的无限循环不会导致问题,因为任何代码(包括 finally 块 )都不会在所有用户线程完成执行后执行。

这也是为什么我们并不推荐 在守护线程中执行 I/O 任务 。因为可能导致无法正确关闭资源。

但是,守护线程并不是 100% 不能阻止 JVM 退出的。守护线程中设计不良的代码可能会阻止 JVM 退出。例如,在正在运行的守护线程上调用Thread.join() 可以阻止应用程序的关闭。

守护线程能用来做什么?

常见的做法,就是将守护线程用于后台支持任务,比如垃圾回收、释放未使用对象的内存、从缓存中删除不需要的条目。
按照这个解释,那么大多数 JVM 线程都是守护线程。

如何创建守护线程 ?

守护线程也是一个线程,因此它的创建和启动其实和普通线程没什么区别?

要将普通线程设置为守护线程,方法很简单,只需要调用 Thread.setDaemon() 方法即可。

例如下面这段代码,假设我们继承 Thread 类创建了一个新类 NewThread 。那么我们就可以创建这个类的实例并设置为守护线程

NewThread daemonThread = new NewThread();
daemonThread.setDaemon(true);
daemonThread.start();

在Java 语言中,线程的状态是自动继承的。任何线程都会继承创建它的线程的守护程序状态。怎么理解呢?

1、 如果一个线程是普通线程(用户线程),那么它创建的子线程默认也是普通线程(用户线程);
2、 如果一个线程是守护线程,那么它创建的子线程默认也是守护线程;

因此,我们可以推演出: 由于主线程是用户线程,因此在 main() 方法内创建的任何线程默认为用户线程。

需要注意的是调用 setDaemon() 方法的时机,该方法只能在创建 Thread 对象并且在启动线程前调用。在线程运行时尝试调用 setDaemon() 将抛出 IllegalThreadStateException 异常。

@Test(expected = IllegalThreadStateException.class)
public void whenSetDaemonWhileRunning_thenIllegalThreadStateException() {
    NewThread daemonThread = new NewThread();
    daemonThread.start();
    daemonThread.setDaemon(true);
}

如何检查一个线程是守护线程还是用户线程?

检查一个线程是否是守护线程,可以简单地调用方法 isDaemon() ,如下代码所示

@Test
public void whenCallIsDaemon_thenCorrect() {
    NewThread daemonThread = new NewThread();
    NewThread userThread = new NewThread();
    daemonThread.setDaemon(true);
    daemonThread.start();
    userThread.start();

    assertTrue(daemonThread.isDaemon());
    assertFalse(userThread.isDaemon());
}

Thread 生命周期

Java 中的多线程

Java 语言中, 多线程是由 Thread 的核心概念驱动的。因为多线程中的每一个线程都相互独立,有着自己的生命周期和状态转换。

我们先来看一张草图,这图描述了 Java 线程的各种状态和转换过程。
在这里插入图片描述
是不是很杂乱无章? 看不懂没关系,我们接下来会详细介绍各个状态。

Java 线程中的生命周期

Java 中,每一个线程都是 java.lang.Thread 类的实例。而且,Java 个线程生命周期中的各个状态都定义在 Thread 类的一个静态的 State 枚举中。

State 枚举定义了线程的所有潜在状态。总共有 6 个,分别对应者上图中的 6 个绿色背景的矩形和椭圆型。

NEW : 新创建的,且未调用 start() 方法开始执行的线程。
RUNNABLE : 已经在运行中的线程或正在等待资源分配的准备运行的线程。
BLOCKED : 等待获取进入或重新进入同步块或方法的监视器锁的线程。
WAITING : 等待其他一些线程执行特定操作,没有任何时间限制。
TIMED_WAITING: 等待某个其他线程在指定时间段内执行特定操作
TERMINATED : 线程完成了它的任务。

需要注意的是: 在任何给定的时间点,线程只能处于这些状态之一。

NEW 状态,应该很好理解,比如,车,厂家生产出来,只要还没被卖出过,那么它就是新的 ( NEW )
RUNNABLE 只要线程不出于其它状态,它就是 RUNNABLE 状态。怎么理解呢? 车买来了,只要它没坏没出什么毛病没借给别人,那么它就出于可开状态,不管是呆在家里吃灰还是已经在上路运行。
WAITING : 无时间显示的等待其它线程完成任务时就处于这个状态,怎么理解呢?比如长假告诉公路大堵车,要等待别人前进了几个蜗牛步我们才能往前几个蜗牛步,有时候一等就是昏天暗地,可能长达几天,也可能,一辈子吧。
TIMED_WAITING : 一直处于 WAITING 总不是办法,所以可能会设置一个超时时间,如果过了时间,就不等待了。同样的,如果可以后退,那么我们在堵车的时候可能会等待那么十几分钟,发现确实走不了,就等了呗。
TERMINATED : 当一个线程结束了它的任务(可能完成了,也可能没完成)就会处于这个状态。如果拿车做比喻,那么当车彻底报废,已经再也不能上路了,就处于这个状态。

NEW 状态

NEW状态的线程(或已经创建的新线程)是已创建但尚未启动的线程。线程会一直保持这个 NEW 状态,直到在该线程上调用了 start() 方法启动它。

下面的代码,我们创建了一个 NEW 状态的线程

Runnable runnable = new NewState();
Thread t = new Thread(runnable);
Log.info(t.getState());

由于我们没有启动线程,因此 t.getState() 方法将打印输出 NEW

RUNNABLE 状态

当在一个 NEW 状态的线程上调用 start() 方法时,该线程的状态会从 NEW 转换为 RUNNABLE。处于该状态的线程要么是已经在运行中,那么是在处于正在等待系统的资源分配(准备运行)。

在多线程环境中,线程调度器 ( Thread-Scheduler,它是 JVM 的一部分)会为每个线程分配固定的时间。线程并不是一直都在执行的,调度器会把暂时空闲的线程的 CPU ( 还是在 RUNNABLE 状态 )让出来,让其它需要的线程去运行。因此它会运行一段特定的时间,然后将控制权放弃给其他 RUNNABLE 线程。

注意: 这里的等待资源,不是等待其它线程,而是等待 CPU 排队。打个比方,新车上路。要等待的是有没有路,如果没有路,就开不了,这是本质的问题。
例如,让我们将 t.start() 方法添加到我们之前的代码中并尝试访问其当前状态

Runnable runnable = new NewState();
Thread t = new Thread(runnable);
t.start();
Log.info(t.getState());

此代码最有可能返回输出 RUNNABLE
为什么说是最有可能呢?如果是一个空转线程,除了 CPU 不需要其它资源,那么很大概率就是 RUNNABLE ,但如果需要其它资源,可能会因为竞争资源而处于其它状态。还有一种情况,可能还没运行到 t.getState() ,线程任务就执行完毕了,那么也不会是 RUNNABLE 状态。

BLOCKED 状态

当一个线程当前没有资格运行时,它处于 BLOCKED 状态。如果线程在尝试访问由某个其他线程锁定的代码段时,那它会因为需要等待获取监视器锁进入此状态。

我们使用一小段代码来重现下这个状态

public class BlockedState {
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(new DemoThreadB());
        Thread t2 = new Thread(new DemoThreadB());

        t1.start();
        t2.start();

        Thread.sleep(1000);

        Log.info(t2.getState());
        System.exit(0);
    }
}

class DemoThreadB implements Runnable {
    @Override
    public void run() {
        commonResource();
    }

    public static synchronized void commonResource() {
        while(true) {
            // Infinite loop to mimic heavy processing
            // 't1' won't leave this method
            // when 't2' try to enters this
        }
    }
}

在上面这段代码中

我们创建了两个不同的线程-- t1 和 t2 。
t1 启动后就进入了同步的 commonResource()方法,同步方法意味着一次只能有一个线程可以访问它。尝试访问此方法的所有其他后续线程将被阻止进一步执行,直到当前线程完成处理。
当 t1 进入这个方法时,它保持了无限循环,这只是为了模仿繁重的处理,以便所有其他线程都无法进入此方法。
接着我们开启 t2 ,它尝试输入已经被 t1 访问的 commonResource() 方法,这时,因为 commonResource() 被 t1 锁定,所以 t2 将保持在 BLOCKED 状态

在这个状态上,当我们使用 t.getState() 时将输出 BLOCKED

WAITTING 状态

线程在等待某个其他线程执行特定操作时处于 WAITING 状态。根据 Oracle 官方文档,任何线程都可以通过调用以下三种方法中的任何一种来进入此状态:

1、 object.wait();
2、 thread.join();
3、 LockSupport.park();

请注意,我们没有为 wait() 和 join() 定义任何超时时间,因为下一节将介绍该方案。

我们以后会写一个单独的教程,详细讨论了 wait()、notify() 和 notifyAll() 的使用。

下面,我们写一段代码尝试重现这种状态

public class WaitingState implements Runnable {
    public static Thread t1;

    public static void main(String[] args) {
        t1 = new Thread(new WaitingState());
        t1.start();
    }

    public void run() {
        Thread t2 = new Thread(new DemoThreadWS());
        t2.start();

        try {
            t2.join();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            Log.error("Thread interrupted", e);
        }
    }
}

class DemoThreadWS implements Runnable {
    public void run() {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            Log.error("Thread interrupted", e);
        }

        Log.info(WaitingState.t1.getState());
    }
}

我们来讨论一下上面的代码做的事情

1、 首先,我们创建并启动了t1;
2、 其次,t1创建了t2并启动它;
3、 当t2的处理继续时,我们调用t2.join(),这使t1处于WAITING状态,直到t2完成执行;
4、 由于t1正在等待t2完成,我们从t2调用t1.getState();

输出结果一般为 WAITING
请留意在哪里调用 t1.getState() 。

所以,WAITING 和 BLOCKED 两个状态的区别是什么?

BLOCKED 是因为线程竞争不到资源而处于 BLOCKED 状态。这个是被动的。因为别无选择。
WAITING 是因为线程主动等待别人完成而处于 WAITING 状态。这个是主动的。因为它可以不调用那三个方法,不用等待其它人完成。它可以选择挥一挥衣袖,不不带走一片云彩

TIMED_WAITING 状态

线程在等待另一个线程在规定的时间内执行特定操作时处于 TIMED_WAITING 状态。根据 Java Docs 文档,有五种方法可以将线程置于TIMED_WAITING 状态:

1、 thread.sleep(longmillis);
2、 wait(inttimeout)orwait(inttimeout,intnanos);
3、 thread.join(longmillis);
4、 LockSupport.parkNanos;
5、 LockSupport.parkUntil;

下面,我们写一段代码尝试重现这种状态

public class TimedWaitingState {
    public static void main(String[] args) throws InterruptedException {
        DemoThread obj1 = new DemoThread();
        Thread t1 = new Thread(obj1);
        t1.start();

        // The following sleep will give enough time for ThreadScheduler
        // to start processing of thread t1
        Thread.sleep(1000);
        Log.info(t1.getState());
    }
}

class DemoThread implements Runnable {
    @Override
    public void run() {
        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            Log.error("Thread interrupted", e);
        }
    }
}

整体代码和 WAITING 状态的差不多,我们创建并启动了一个线程 t1,并它进入睡眠状态,超时时间为 5 秒。

输出结果为 TIMED_WAITING

TERMINATED 状态

这是一个 「 已死 」 线程的状态。当一个线程已经完成执行或异常终止时,它处于 TERMINATED 状态。
这个状态没什么好讨论的,我们写一段代码尝试重现这种状态

public class TerminatedState implements Runnable {
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(new TerminatedState());
        t1.start();
        // The following sleep method will give enough time for 
        // thread t1 to complete
        Thread.sleep(1000);
        Log.info(t1.getState());
    }

    @Override
    public void run() {
        // No processing in this block
    }
}

上面这段代码中,我们启动线程 t1 时,下一个语句 Thread.sleep(1000) 为 t1 提供了足够的时间来完成。

因此,上面这个示例输出结果为 TERMINATED

Runnable 还是 Thread ?

写Java 代码的时候,我们经常会有这样的疑问:我到底是实现一个 Runnable 呢,还是扩展一个 Thread 类?
我们先来分析下,看看哪种方法在实践中更有意义以及为什么?

扩展一个线程 ( Thread 类 )

简单起见,我们就来定义一个扩展自 Thread 的 SimpleThread 类

public class SimpleThread extends Thread {

    private String message;

    // standard logger, constructor

    @Override
    public void run() {
        log.info(message);
    }
}

代码也真是简单了,然后我们看看如何运行这个 SimpleThread 类

@Test
public void givenAThread_whenRunIt_thenResult()
  throws Exception {

    Thread thread = new SimpleThread(
      "SimpleThread executed using Thread");
    thread.start();
    thread.join();
}

我们也可以把这个 SimpleThread 放到前面章节ExecutorService 中提到的 ExecutorService 中运行。

@Test
public void givenAThread_whenSubmitToES_thenResult()
  throws Exception {

    executorService.submit(new SimpleThread(
      "SimpleThread executed using ExecutorService")).get();
}

看起来感觉是不是有点复杂,我们只想在单独的线程中运行单个日志操作而已,使用 Thread 的方式看起来有点复杂化了,要么是 start() 和 join() ,要么是 ExecutorService。

当然,这不是最糟糕的,更糟糕的是,SimpleThread 再也不能扩展任何其它类,因为 Java 不支持多重继承。

实现 ( implements) 一个 Runnable

同样的简单起见,我们创建一个实现了 java.lang.Runnable 接口的简单任务。

class SimpleRunnable implements Runnable {

    private String message;

    // standard logger, constructor

    @Override
    public void run() {
        log.info(message);
    }
}

这段代码是不是和上面的 SimpleThread 很相似?

因为这个 SimpleRunnable 只是一个任务,一个在一个单独的线程中运行的任务。

为了运行这个任务,有多种方式可供选择,其中之一,就是使用一个 Thread 类。

@Test
public void givenRunnable_whenRunIt_thenResult()
 throws Exception {
    Thread thread = new Thread(new SimpleRunnable(
      "SimpleRunnable executed using Thread"));
    thread.start();
    thread.join();
}

同样的,还可以使用 ExecutorService:

@Test
public void givenARunnable_whenSubmitToES_thenResult()
 throws Exception {

    executorService.submit(new SimpleRunnable(
      "SimpleRunnable executed using ExecutorService")).get();
}

看到这里,你是不是很疑惑?Runnable 和继承一个 Thread 没有什么区别啊 ?同样多的代码,同样多的步骤。

别急,哈哈,重点来了。

由于我们的 SimpleRunnable 实现了一个接口,因此,如果需要,我们可以自由扩展自另一个基类。

更简单的是,一个几行代码的 Runnable 还可以写成一个简单的 Lambda 表达式

@Test
public void givenARunnableLambda_whenSubmitToES_thenResult() 
  throws Exception {

    executorService.submit(
      () -> log.info("Lambda runnable executed!"));
}

这才是Runnable 的杀手锏。真的是简单的不要太多。

Runnable or Thread?

看到这里,你想要的是 Runnable 还是 Thread ?

看我上文的描述,肯定是倾向使用 Runnable 多过 Thread:

在扩展 Thread 类时,我们并没有被要求覆盖它的任何方法。相反,我们需要覆盖 Runnablerun() 方法( Thread 类已经实现了 )。这显然违反了 IS-A Thread 原则。
我们可以创建一个 Runnable 的实现并将其传递给 Thread 类。这利用的是组合而不是继承。这更灵活。
在扩展了 Thread 类之后,我们无法扩展任何其他类。
从 Java 8 开始,Runnables 可以重写为 lambda 表达式。

wait() 和 notify() 方法

Java 中的线程同步 ( Thread Synchronization )

在并发编程中,在多线程环境下,多个线程可能会尝试修改同一资源。如果线程管理不当,这显然会导致一致性问题。

Java 中的哨兵块 ( guarded block )

Java 中,可以用来协调多个线程操作的一个工具是 「 哨兵块 」。这个哨兵块会在恢复执行前检查特定条件。

基于这种哨兵检查的思想,Java 在所有类的基类 Object 中提供了两个方法

Object.wait()	暂停一个线程
Object.notify()	唤醒一个线程

当我们调用 wait() 时会强制当前线程等待,直到某个其它线程在同一个对象上调用 notify() 或 notifyAll() 方法。

因此,当前线程必须拥有对象的监视器。根据 Java docs 的说法,这可能发生在

我们已经为给定对象执行了同步实例方法
我们已经在给定对象上执行了 synchronized 块的主体
通过为 Class 类型的对象执行同步静态方法

请注意,一次只有一个活动线程可以拥有对象的监视器。
除了无参数 wait() 方法外,Java 还重载了另一个 wait() 方法
wait() 方法导致当前线程无限期地等待,直到另一个线程调用此对象的 notify() 或 notifyAll() 方法
wait(long timeout) 方法
使用此方法,我们可以指定一个超时,在此之后将自动唤醒线程。

当然了,我们可以在到达超时之前使用 notify() 或 notifyAll() 提前唤醒线程。

请注意,调用 wait(0) 与调用 wait() 相同

wait(long timeout, int nanos)
这是与wait(long timeout) 提供相同功能的签名,唯一的区别是我们可以提供更高的精度。

该方法计算超时之间的方式为:

总超时时间(以纳秒为单位)= 1_000_000 * 超时 + nanos

notify() 或 notifyAll() 方法

notify() 和 notifyAll() 方法用于唤醒等待访问此对象监视器的线程。
它们以不同的方式通知等待线程。

notify() 方法

对于在此对象的监视器上等待的所有线程(通过使用任何一个重载 wait() 方法 ),notify() 通知将会随机唤醒任何一个线程。

也就是说,我们并不能确切知道唤醒了哪个线程,这取决于实现。

因为notify() 提供了唤醒一个随机线程的机制,因此它可用于实现线程执行类似任务的互斥锁定。

但在大多数情况下,使用 notifyAll() 会是一个更可行的方案。

notifyAll() 方法

notifyAll() 方法用于唤醒正在此对象的监视器上等待的所有线程。唤醒的线程将以常规的方式完成 - 就像任何其他线程一样。

但,有一点要注意的是,对于任意一个线程,但在我们允许其继续执行之前,请始终快速检查继续执行该线程所需的条件。因为在某些情况下线程被唤醒而没有收到通知(这个场景将在后面的例子中讨论 )

发送者 - 接收者同步问题

线程同步的问题,我们已经有了个大概的了解,接下来,我们看一个简单的 Sender-Receiver ( 发送者 - 接收者 ) 应用程序,这个应用程序将利用wait() 和 notify() 方法建立它们之间的同步。

发送者应该向接收者发送数据包
在发送方完成发送之前,接收方无法处理数据包
同样,发送方不得尝试发送另一个数据包,除非接收方已处理过上一个数据包

我们首先创建一个 Data 类,用于包含将从 Sender 发送到 Receiver 的数据包,同时,我们将使用 wait() 和 notifyAll() 来设置它们之间的同步。

public class Data {
    private String packet;

    // True if receiver should wait
    // False if sender should wait
    private boolean transfer = true;

    public synchronized void send(String packet) {
        while (!transfer) {
            try { 
                wait();
            } catch (InterruptedException e)  {
                Thread.currentThread().interrupt(); 
                Log.error("Thread interrupted", e); 
            }
        }
        transfer = false;

        this.packet = packet;
        notifyAll();
    }

    public synchronized String receive() {
        while (transfer) {
            try {
                wait();
            } catch (InterruptedException e)  {
                Thread.currentThread().interrupt(); 
                Log.error("Thread interrupted", e); 
            }
        }
        transfer = true;

        notifyAll();
        return packet;
    }
}

范例有点小长,我们一步一步分析下代码

1、 私有属性packet用于表示通过网络传输的数据;
2、 布尔类型的私有属性transfer用于Sender和Receiver之间的同步;

如果此变量为 true,则 Receiver 应等待 Sender 发送消息
如果它是 false ,那么 Sender 应该等待 Receiver 接收消息
3、 Sender使用send()方法将数据发送给Receiver:

如果 transfer 为 false ,我们将在此线程上调用 wait()
但如果它为 true ,我们需要切换状态,设置我们的消息并调用 notifyAll() 来唤醒其他线程以指定发生了重大事件,然后这些线程它们自己可以自查是否可以继续执行。
4、 同样的,Receiver将使用receive()方法接收数据;

如果 Sender 将传输设置为 false,那么继续,否则将在此线程上调用 wait()
满足条件时,我们切换状态,通知所有等待的线程唤醒并返回 Receiver 的数据包

为什么在 while 循环中包含 wait()

由于notify() 和 notifyAll() 随机唤醒正在此对象监视器上等待的线程,因此满足条件并不总是很重要。有时可能会发生线程被唤醒,但实际上并没有满足条件。

当然了,跟进一步说,我们还可以定义一个检查来避免虚假唤醒 - 线程可以从等待中醒来而不会收到通知。

我们为什么需要同步 send() 和 receive() 方法

我们将这些方法放在 synchronized 方法是为了提供内部锁。

如果调用 wait() 方法的线程不拥有固有锁,则会抛出错误。

现在,是时候创建 Sender 和 Receiver 并在两者上实现 Runnable 接口,以便它们的实例可以由线程执行。

我们先来看看 Sender 将如何工作

public class Sender implements Runnable {
    private Data data;

    // standard constructors

    public void run() {
        String packets[] = {
          "First packet",
          "Second packet",
          "Third packet",
          "Fourth packet",
          "End"
        };

        for (String packet : packets) {
            data.send(packet);

            // Thread.sleep() to mimic heavy server-side processing
            try {
                Thread.sleep(ThreadLocalRandom.current().nextInt(1000, 5000));
            } catch (InterruptedException e)  {
                Thread.currentThread().interrupt(); 
                Log.error("Thread interrupted", e); 
            }
        }
    }
}

对于这个 Sender :

我们正在创建一些随机数据包,这些数据包将通过网络以 packet[] 数组的形式发送
对于每个数据包,我们只是调用 send() 而不做其它动作
然后我们用随机时间间隔调用 Thread.sleep() 来模仿繁重的服务器端处理
接下来,我们来看看如何实现 Receiver

public class Receiver implements Runnable {
    private Data load;

    // standard constructors

    public void run() {
        for(String receivedMessage = load.receive();
          !"End".equals(receivedMessage);
          receivedMessage = load.receive()) {

            System.out.println(receivedMessage);

            // ...
            try {
                Thread.sleep(ThreadLocalRandom.current().nextInt(1000, 5000));
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt(); 
                Log.error("Thread interrupted", e); 
            }
        }
    }
}

上面这段代码很简单,只是在循环中调用 load.receive() ,直到我们得到最后一个 “End” 数据包。

最后,我们就可以写一个 main() 方法来运行它们了

public static void main(String[] args) {
    Data data = new Data();
    Thread sender = new Thread(new Sender(data));
    Thread receiver = new Thread(new Receiver(data));

    sender.start();
    receiver.start();
}

运行范例,输出结果如下

First packet
Second packet
Third packet
Fourth packet

完美!

我们在这里 - 我们以正确的顺序接收所有数据包,并成功建立了发送方和接收方之间的正确通信。

  • 42
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值