多线程基础

例:

        把程序理解成一个餐厅,那么服务员就是线程,如果一个餐厅只有一个服务员,那么从客人进餐厅开始,所有的工作都是一个服务员来执行,比如,从客人进来之后的接待,点餐,炒菜,端菜,搽桌子,洗碗等等相关工作都是一个服务员来完成。如果中间任何时候有其他客人,那么这个客人就会在餐厅外面等待,这样显然是不符合我们的逻辑的。所以就有了多线程的概念。

1.概述

        几乎所有操作系统都支持同时运行多个任务,一个任务通常就是一个程序,每个运行中的程序就是一个进程。当一个程序运行时,内部可能包含了多个顺序执行流,每个顺序执行流就是一个线程。

2.线程和进程

        几乎所有的操作系统都支持进程的概念,所有运行中的任务通常对应一个进程(Process)。当一个程序进入内存运行时,即变成成一个进程。进程是出于运行过程中的程序,并且具有一定的独立功能,进程是系统进行资源分配和调度的一个独立单位。

        一般而言,进程包含了如下三个特征

  1. 独立性:进程是系统中独立存在的实体,它可以拥有自己独立的资源,每一个进程都拥有自己私有的地址空间。在没有经过进程本身允许的情况下,一个用户进程不可以直接访问其他进程的地址空间。
  2. 动态性:进程与程序的却别在于,程序只是一个静态的指令集合,而进程是一个正在系统中活动的指令集合。在进程中加入了时间的概念。进程具有自己的生命周期和各种不同的状态,这些概念在程序中多是不具备的。
  3. 并发性:多个进程可以在单个处理器上并发执行,多个进程之间是不会相互影响的。

注意:并发性(concurrency)和并行性(parallel)是两个概念,并行指在统一时刻,有多条指令在多个处理器上同时执行;并发指在同一时刻只能有一条指令执行,但多个进程指令被快速轮换执行,使得在宏观上具有多个进程同时执行的效果。

3.多线程的优势

        线程在程序中是独立的,并发的执行流,与分割的进程相比,进程中线程之间的隔离程度要小。它们共享内存,文件句柄和其他每个进程应有的状态。因为线程的划分尺度小于进程,使得多线程程序的并发性高。进程在执行过程中拥有独立的内存单元,而多个线程共享内存,从而极大地提高了程序的运行效率。

        总结起来,使用多线程变成具有如下几个有点,

  1. 进程之间不能共享内存,但是线程之间共享内存非常容易,
  2. 系统创建进程时需要为该进程重新分配系统资源,但创建线程则代价要小得多,因此使用了多线程来实现多任务并发比多进程得效率高
  3. Java语言内置了多线程功能支持,而不是单纯地作为底层操作系统的调度方式,从而简化了Java的多线程变成。
3.线程的创建和启动

        Java使用Thread来代表线程,所有的线程对象都必须是Thread类或其子类,每个线程的作用是完成一定的任务,实际上就是执行一段程序流(一段顺序执行的代码)。Java使用线程执行体来代表这段程序流。

3.1继承Thread类创建线程类

        继承Thread类,重写run()方法,该fun()方法的方法体就代表了线程需要完成的任务。因此把run()方法称为线程执行体,然后在调用它的start()方法来启动线程。

package com.yuangc.demo;

import java.util.concurrent.TimeUnit;

public class TestThread {

    public static void main(String[] args) throws InterruptedException {
        ThreadHolder holder = new ThreadHolder();
        System.err.println("执行run方法,线程名称:" + Thread.currentThread().getName());
        holder.start();
//        TimeUnit.SECONDS.sleep(3);
        System.err.println("结束run方法,线程名称:" + Thread.currentThread().getName());
    }
    /**
     * 输出的结果
     * 执行run方法,线程名称:main
     * 结束run方法,线程名称:main
     * 通过继承Thread类创建线程,线程名称:Thread-0
     */
}

class ThreadHolder extends Thread {

    @Override
    public void run() {
        System.err.println("通过继承Thread类创建线程,线程名称:" + Thread.currentThread().getName());
    }
}

是不是跟我们以前的代码输出不一样呢,就是因为我们在执行holder.start()的时候是启用了异步去执行的。

这里的输出结果不一定就是这样的,因为CPU的执行速度太快了所以会这样,线程的执行是抢占式的,谁拿到了CPU的资源谁去执行。如果把TimeUnit.SECONDS.sleep(3);放开的话,那么就会按照我们以前同步的顺序执行

3.2实现Runnable接口创建线程类

        定义Runnable接口的实现类,并重写该接口的run()方法,该run()方法的方法体同样是该线程的线程知兴替

        创建Runnable实现类的实体,并以此示例作为Thread的target来创建Thread对象,该Thread对象才是真正的线程对象

SecondThread st = new SecondThread();
new Thread(st, "demo task");     // 也可以指定线程名称

Runnable对象仅仅作为Thread对象的target,Runnable实现类里包含的run()方法仅作为线程执行体,而实际的线程对象依然是Thread实例,只是该Thread线程负责执行其target的run()方法。

public class TestRunnable {

    public static void main(String[] args) {
        RunnableHolder holder = new RunnableHolder();
        System.err.println("执行run方法,线程名称:" + Thread.currentThread().getName());
        new Thread(holder, "demo runnable task").start();
//        TimeUnit.SECONDS.sleep(3);
        System.err.println("结束run方法,线程名称:" + Thread.currentThread().getName());
    }
}

/**
 * 输出结构
 * 执行run方法,线程名称:main
 * 结束run方法,线程名称:main
 * 通过实现Runnbale接口创建线程,线程名称:demo runnable task
 */
class RunnableHolder implements Runnable {

    @Override
    public void run() {
        System.err.println("通过实现Runnbale接口创建线程,线程名称:" + Thread.currentThread().getName());
    }
}

通过实现Runnable接口创建线程,本质上和继承Thread区别不是太大,我们在new Thread()的时候,其实就是传递的一个线程执行体过去,

public Thread(Runnable target, String name) {
    init(null, target, name, 0);
}
3.3实现Callable和future创建线程

        前面已经指出,通过实现Runnable接口创建多线程时,Thread类的作用就是把run()方法包装成线程执行体。那么是否可以直接把任意方法都包装成线程执行体呢,答案肯定是不行的。但是C#可以,所以也许是受此启发,从Java5开始,Java就提供了一个Callable接口,该接口怎么看都想是Runnable接口的增强版,Callable接口提供了一个call()方法作为线程的执行体,单call()比run()方法功能更强。

1.call()方法可以有返回值

2.call()方法可以声明抛出异常

        因此完全可以提供一个Callable对象作为作为Thread的target。而该线程的线程执行体就是该Callable对象的call()方法,问题是Callable接口是Java5新增的接口,而且他不是Runnalbe接口的子接口,所以Callable对象不能直接作为Thread的target。而且call()方法还有返回值,同时call()方法并不是直接调用,它是作为线程执行体被调用的。那么如何获取call()方法的返回值呢,

Java5提供了Future接口来代表Callable接口里call()方法的返回值,并为Future接口提供了一个FutureTask实现类,该实现类实现了Future接口,并实现了Runnable接口,所以Future可以作为Thread类的target。在Future接口定义了如下几个公共方法来控制它关联的Callable任务。

// 试图取消该Future里关联的Callable任务。
boolean cancel(boolean mayInterruptIfRunning); 

// 返回Callable任务里call()方法的返回值,调用该方法将导致程序阻塞,必须等到子线程结束后才会得到返回值
V get();

// 返回Callable任务里call()方法的返回值。该方法让程序最多阻塞timeout和unit指定的时间,如果经过指定时间后Callable依然没有返回值,将会抛出TimeoutException异常。
V get(long timeout, TimeUnit unit);

// 如果在Callable任务正常完成前被取消,则返回True
boolean isCancelled();

// 如果Callable任务已完成,则返回True
boolean isDone();

Callable接口有泛型限制,Callable接口里的泛型形参类型与call()方法返回值类型相同。而且Callable接口是函数式接口,因此也可以使用函数式接口。

public class TestCallable {

    public static void main(String[] args) {
        CallableHolder holder = new CallableHolder();
        System.err.println("执行run方法,线程名称:" + Thread.currentThread().getName());
        FutureTask<String> futureTask = new FutureTask<>(holder);
        new Thread(futureTask, "demo callable task").start();
        try {
            String value = futureTask.get();
//            String s1 = futureTask.get(10, TimeUnit.SECONDS);
            System.err.println("获取到的返回值" + value);
        } catch (InterruptedException | ExecutionException e) {
            e.printStackTrace();
        }
        System.err.println("结束run方法,线程名称:" + Thread.currentThread().getName());
    }
}

/**
 * 输出结果
 * 执行run方法,线程名称:main
 * 通过实现Callable接口创建线程,线程名称:demo callable task
 * 获取到的返回值callable
 * 结束run方法,线程名称:main
 */
class CallableHolder implements Callable<String> {

    @Override
    public String call() throws Exception {
        System.err.println("通过实现Callable接口创建线程,线程名称:" + Thread.currentThread().getName());
        return "callable";
    }
}

其实使用Callable接口和Runnable接口没有太大的区别,只不过Callable有返回值还可以声明的抛出异常。

4.创建线程的三种方式对比

        通过继承Thread类或实现Runnable接口,Callable接口都可以实现多线程,不过实现Runnable接口与实现Callable接口的方式基本相同,只是Callable接口里定义的方法有返回值,可以声明抛出异常而已。因此可以将实现Runnable接口和实现Callable接口归为一种方式。这种方式与继承Thread方式之间的主要差别如下

1.线程类只是实现了Runnable接口或Callable接口,还可以继承其他类

2.在这种方式下,多个线程可以共享同一个target对象,所以非常适合多个相同线程来处理同一份资源的情况,从而可以将CPU,代码和数据分开,形成清晰的模型,较好地体现了面向对象的思想。

3.劣势是,变成稍稍的复杂,如果需要访问当前线程,则必须使用Thread.currentThread()方法。

4.劣势是,因为线程类已经继承了Thread类,所以不能再继承其他父类。

5.优势是,编写简单,如果需要访问当前线程则无需使用Thread.currentThread()方法,直接使用this即可获得当前线程。

鉴于上面分析,因此一般推荐采用实现Runnable接口或者Callable接口的方式来创建多线程。

5.线程的生命周期

        当线程被创建并启动以后,它既不是一启动就进入了执行状态,也不是一直处于执行状态,在线程的生命周期中,它要经过新建(New)  就绪(Ready)  运行(Run)  阻塞(Blocked)和死亡(Dead)5中状态。尤其是当线程启动以后,它不可能一直霸占着CPU独自运行,所以CPU需要在多线程之间切换,于是线程状态也会多次在运行和就绪之间切换。

5.1新建和就绪

         当程序使用new关键字创建了一个线程之后,该线程就处于新建状态,此时它和其他的Java对象没有任何区别,仅仅由Java虚拟机为其分配内存,并初始化其成员变量的值。此时的线程对象没有表现出任何线程的动态特征,程序也不会执行线程的线程执行体。

        当线程对象调用了start()方法后,该线程处于就绪状态,Java虚拟机会为其创建方法调用栈和程序计数器,处于这个状态中的线程线程并没有开始运行,只是表示该线程可以运行了。至于该线程何时开始运行,取决于JVM里线程调度器的调度。

启动线程使用start()方法,而不是run()方法!永远不要调用线程对象的run()方法!调用start()方法来启动线程,系统会把run()方法当成线程执行体来处理,但是如果直接调用线程对象的run()方法,则run()方法立即就会被执行,而且在run()方法返回之前其他线程无法并发执行——也就是说,如果直接调用线程对象的run()方法,系统把线程对象当成一个普通对象,而run()方法也是一个普通方法,而不是线程执行体。

public class TestNewReady {

    public static void main(String[] args) {
        for (int i = 0; i < 100; i++) {
            CommonUtils.printThreadLog(Thread.currentThread().getName());
            if (i == 20) {
                new InvokeRun().run();
                new InvokeRun().run();
            }
        }
    }
}

class InvokeRun extends Thread {

    @Override
    public void run() {

        for (int i = 0; i < 100; i++) {
            CommonUtils.printThreadLog(Thread.currentThread().getName() + i);
        }
    }
}

上面程序创建线程对象后直接调用了线程对象的run()方法,程序运行的结果是整个程序只有一个线程(同步);主线程。

所以如果是异步,启动线程的正确方法是调用Thread类中的start()方法,而不是直接调用run()方法,否则就变成单线程程序了。

注意:只能对处于新建状态的线程调用start()方法,否则将引发异

调用线程对象的start()方法之后,该线程立即进入就绪状态——就绪状态相当于“等待执行”,但该线程并未真正进入运行状态。

如果希望调用子线程的start()方法后子线程立即执行,主线程可以使用Thread.sleep(1)来主线程睡眠1毫秒——1毫秒就够了。因为CPU不会空闲,它会去执行另一个处于就绪状态的线程,这样子线程就可以立即执行了。

5.2运行和阻塞

        如果处于就绪状态的线程获得了CPU执行权,开始执行run()方法中的线程执行体,则该线程处于运行状态,如果计算机只有一个CPU,那么在任何时刻只有一个线程处于运行状态。当前,在一个多处理器的机器上,将会有多个线程并行(注意是并行:parallel)执行;当线程数大于处理器数时,依然会存在多个线程在同一个CPU上轮换的现象。

        当一个线程开始运行后,它不可能一直处于运行状态(除非它的线程执行体足够短,瞬间就执行结束了),线程在运行过程中需要被中断,目的是使其他线程获得执行的机会,线程调度的细节取决于底层平台所采用的策略,对于采用抢占式策略的系统而言,系统会给每个可执行的线程一个小时间短来处理任务;当该时间段用完后,系统就会剥夺该线程所占用的资源,让其他线程获得执行的机会。在选择下一个线程时,系统会考虑线程的优先级

所有现代的桌面和服务器操作系统基本都采用抢占式调度的策略,但一些小型设备如手机则可能采用协作式调度策略,在这样的系统中,只有当一个线程调用了它的sleep()或yield()方法后才会放弃所占用的资源——也就是必须由该线程主动放弃所占用的资源。

当发生如下情况时,线程将会进入阻塞状态。

  • 线程调用了sleep()方法主动放弃所占用的处理器资源
  • 线程调用了一个阻塞式IO方法,在该方法返回之前,该线程被阻塞
  • 线程试图获得一个同步监视器,但该同步监视器正被其他线程所持有
  • 线程在等待某个通知(notify)
  • 程序调用了线程的suspend()方法将该线程挂起。但这个方法容易导致死锁,所以应该尽量避免使用该方法

当前正在执行的线程被阻塞之后,其他线程就可以获得执行的机会,被阻塞的线程会在合适的时候重新进入就绪状态,注意是就绪状态而不是运行状态,也就是说,被阻塞的线程则色解除后,必须重新等待线程调度器再次调度它。

针对上面几种情况,当发生如下特定的情况时可以解除上面的阻塞,让线程重新进入就绪状态。

  • 调用sleep()方法的线程经过了指定时间
  • 线程调用的阻塞式IO方法已经返回
  • 线程成功的获得了试图取得的同步监视器
  • 线程正在等待某个通知,其他线程发出了一个通知
  • 处于挂起状态的线程被调用了resume()恢复方法
5.3死亡

        线程会以如下三种方法结束,结束后线程就处于死亡状态。

  1. run()或call()方法执行完成,线程正常结束
  2. 线程抛出一个未捕获的Exception或Error
  3. 直接调用该线程的stop()方法来结束该线程——该方法容易导致死锁,通常不推荐使用

当主线程结束时,其他线程不受任何影响,并不会随之结束。一旦子线程启动起来后,它就拥有和主线程相同的地位,他不会受到主线程的影响。

        检查线程是否已经死亡,可以调用线程的isAlive()方法,当线程处于就绪  运行  阻塞三种状态时返回true,当线程处于新建  死亡两种状态时,该方法将返回false。

不要试图对一个已经死亡的线程调用start()方法使它重新启动,死亡就是死亡,该线程将不可再次作为线程执行。

5.4生命周期图

6.线程控制

        Java的线程支持提供了一些便捷的工具方法,通过这些便捷的工具方法可以很好地控制线程的执行。

6.1Join线程

        Thread提供了让一个线程等待另一个线程完成的方法——join()方法。当在某个程序执行流中调用其他线程的join()方法时,调用线程将被阻塞,直到被join()方法加入的join()线程执行完为止。

        join()方法通常由使用线程的程序调用,以将大问题划分成许多小问题,每个小问题分配一个线程。当所有的小问题都得到处理后,在调用主线程来进一步操作。

public class TestJoin {

    public static void main(String[] args) throws InterruptedException {
        new JoinHolder("新线程").start();
        for (int i = 0; i < 100; i++) {
            if (i == 20) {
                JoinHolder joinHolder = new JoinHolder("被Join的线程");
                joinHolder.start();
                // main线程调用了joinHolder线程的join()方法,main线程必须要等到joinHolder线程执行结束才会向下执行
                joinHolder.join();
            }
            CommonUtils.printThreadLog(Thread.currentThread().getName() + i);
        }
    }
}

class JoinHolder extends Thread {

    public JoinHolder(String name) {
        super(name);
    }

    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {
            CommonUtils.printThreadLog(this.getName());
        }
    }
}

上面代码一共有三个线程,主方法开始时就启动了名为"新线程"的子线程,该子线程将会和main线程并发执行。当主线程的循环变量 i 等于20时,启动了名为 "被Join的线程"的线程,该线程不会和main线程并发执行,main线程必须等该线程执行结束后才可以向下执行。在名为"被Join的线程"的线程执行时,实际上只有2个线程在并发执行,而主线程(main)处于等待状态。

join()方法有三个重载方法

  1. join()  等待被join的线程执行完成
  2. join(long millis)  等待被join的线程的时间最长为millis毫秒,如果在millis毫秒内被join的线程还没有执行完成,则不在等待
  3. join(long millis, int nanos)  等待被join的线程的时间最长为millis毫秒加nanos微毫秒

通常很少使用第三种形式,原因有两个,程序时间的精度无需精确到毫微秒;计算机硬件  操作系统本身也无法精确到毫微秒。

6.2后台线程

        有一种线程,它是在后台运行的,它的任务是为其他的线程提供服务,这种线程被称为"后台线程(Daemon Thread)",又称为"守护线程"或"精灵线程"。JVM的垃圾回收线程就是典型的后台线程。

        后台线程有个特征,如果所有的前台线程都死亡,后台线程会自动死亡。

        调用Thread对象的setDaemon(true)方法可将线程设置成后台线程。下面程序将执行线程设置成后台线程,可以看到当所有的前台线程都死亡时,后台线程随之自动死亡。

public class TestDaemonThread {

    public static void main(String[] args) {
        DaemonThreadHolder daemonThreadHolder = new DaemonThreadHolder();
        daemonThreadHolder.setDaemon(true);
        daemonThreadHolder.start();

        for (int i = 0; i < 10; i++) {
            CommonUtils.printThreadLog(Thread.currentThread().getName() + i);
        }
    }
}

/**
 * 输出结果
 * <p>
 * 1691308451914 | 12 | Thread-0 | Thread-00
 * 1691308451914 | 1 | main | main0
 * 1691308451914 | 1 | main | main1
 * 1691308451914 | 1 | main | main2
 * 1691308451914 | 12 | Thread-0 | Thread-01
 * 1691308451914 | 1 | main | main3
 * 1691308451914 | 12 | Thread-0 | Thread-02
 * 1691308451914 | 1 | main | main4
 * 1691308451914 | 12 | Thread-0 | Thread-03
 * 1691308451914 | 1 | main | main5
 * 1691308451914 | 12 | Thread-0 | Thread-04
 * 1691308451914 | 1 | main | main6
 * 1691308451914 | 1 | main | main7
 * 1691308451914 | 1 | main | main8
 * 1691308451914 | 12 | Thread-0 | Thread-05
 * 1691308451914 | 1 | main | main9
 * 1691308451914 | 12 | Thread-0 | Thread-06
 * 1691308451914 | 12 | Thread-0 | Thread-07
 * 1691308451914 | 12 | Thread-0 | Thread-08
 * 1691308451914 | 12 | Thread-0 | Thread-09
 * 1691308451914 | 12 | Thread-0 | Thread-010
 * 1691308451914 | 12 | Thread-0 | Thread-011
 * 1691308451914 | 12 | Thread-0 | Thread-012
 * 1691308451914 | 12 | Thread-0 | Thread-013
 * 1691308451914 | 12 | Thread-0 | Thread-014
 * 1691308451914 | 12 | Thread-0 | Thread-015
 * 1691308451914 | 12 | Thread-0 | Thread-016
 * 1691308451914 | 12 | Thread-0 | Thread-017
 */
class DaemonThreadHolder extends Thread {

    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {
            CommonUtils.printThreadLog(Thread.currentThread().getName() + i);
        }
    }
}

上面程序可以看到,当我们main线程执行完之后 Thread-0只执行17次,我们设置的要执行100,因为设置了它为后台线程,当主线程结束了,那么后台线程也就自动结束。

前台线程死亡后,JVM会通知后台线程死亡,但从它接收指令到做出响应,需要一定的时间。而且要将某个线程设置为后台线程,必须在该线程启动之前设置,也就是说,setDaemon(true)必须在start()方法之前调用,否则会引发异常。

6.3线程睡眠

        如果需要让当前正在执行的线程暂停一段时间,并进入阻塞状态,则可以通过调用Thread类的静态sleep()方法来实现,sleep()方法有两种重载的形式。

  1. static void sleep(long millis):让当前正在执行的线程暂停millis毫秒,并进入阻塞状态,该方法受到系统计时器和线程调度器的精度与准确度的影响。
  2. static void sleep(long millis, int nanos):让当前正在执行的线程暂停millis毫秒加nanos毫微秒,并进入阻塞状态,该方法受到系统计时器和线程调度器的精度与准确度的影响

与前面类似,程序很少调用第二种形式的sleep()方法。

        当前线程调用sleep()方法进入阻塞状态后,在其睡眠时间段内,该线程不会获得执行的机会,即使系统中没有其他可执行的线程,处于sleep()中的线程也不会执行,因此sleep()方法常用来暂停程序的执行。

        此外,Thread还提供了一个和sleep()类似的yield()静态方法,它也可以让当前正在执行的线程暂停,但它不会阻塞该线程,它只是将该线程转入就绪状态。yield()只是让当前线程暂停一下,让系统的线程调度器重新调度一次,完全可能的情况是;当某个线程调用了yield()方法暂停之后,线程调度器又将其调度出来重新执行。

实际上,当某个线程调用了yield()方法暂停之后,只有优先级与当前线程相同,或者优先级比当前线程更高的处于就绪状态的线程才会获得执行的机会。

sleep()方法和yield()方法区别

  1. sleep()方法暂停当前线程后,会给其他线程执行机会,不会理会其他线程的优先级;但yield()方法只会给优先级相同,或优先级更高的线程执行机会。
  2. sleep()方法会将线程转入阻塞状态,直到经过阻塞时间才会转入就绪状态,而yield()不会将线程转入阻塞状态,它只是强制当前线程进入就绪状。因此完全有可能某个线程被yield()方法暂停之后,立即再次获得处理器执行权被执行。
  3. sleep()方法声明抛出了异常,所以调用sleep()方法时要么捕捉该异常,要么显式声明抛出该异常;而yield()方法则没有声明抛出任何异常
  4. sleep()方法比yield()方法有更好的可移植性,通常不建议使用yield()方法来控制并发线程的执行。
6.4线程的优先级

        每个线程执行时都具有一定的优先级,优先级高的线程获得较多的执行机会,而优先级低的线程则获得较少的执行机会。

        每个线程默认的优先级都与创建它的父线程的优先级相同,在默认的情况下,main线程具有普通的优先级,由main线程创建的子线程也具有普通的优先级。

        Thread类提供了setPriority(int newPriority)  getPriority()方法来设置和返回执行线程的优先级,其中setPriority()方法的参数可以是一个整数,范围是1~10之间,也可以使用Thread类的如下三个静态变量

  1. MAX_PRIORITY:其值是10
  2. MIN_PRIORITY:其值是1
  3. NORM_PRIORITY:其值是5

虽然Java提供了10个优先级级别,但这些优先级级别需要操作系统的支持。遗憾的是,不同操作系统上的优先级并不相同,而且也不能很好的和Java的10个优先级对应,例如Windows 2000 仅提供了7个优先级。因此应该尽量避免直接为线程指定优先级,而应该使用MAX_PRIORITY  MIN_PRIORITY  NORM_PRIORITY三个静态常量来设置优先级,这样才可以保证程序具有最好的可移植性

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值