Java 多线程

写篇博文记录下最近学习的Java多线程知识。
测试代码都在:Java基础测试项目 项目中的 package mistra.com.concurrent2;包下面。
参考博文1
参考博文2


一、明确概念

进程:执行中的程序,每个进程都有独立的代码和数据空间(进程上下文),进程间的切换会有较大的开销,一个进程包含1–n个线程。(进程是资源分配的最小单位)
多进程:指操作系统能同时运行多个任务,譬如电脑上可以同时开启多个客户端程序。
线程:进程中负责程序执行的最小执行单元,同一类线程共享代码和数据空间,每个线程有独立的运行栈和程序计数器(PC),线程切换开销小。(线程是cpu调度的最小单位)
单线程:程序中只存在一个线程(只有一个任务),实际上主方法(main)就是一个主线程
多线程:在一个程序中运行多个任务(多个顺序流在执行),目的是更好地使用CPU资源


二、多线程基础

1、实现多线程的方法(3种)
  • 继承Thread类,重写run()方法,Thread类实际上也是实现了Runnable接口的类。
  • 实现Runable接口,实现run()方法
  • 实现Callable接口,重写call()方法,并与Future、线程池ExecutorService结合使用,可以实现有返回值的线程,前面两种是没有返回值的。

继承Thread类的话,可能比实现Runnable接口看起来更加简洁,但是由于Java只允许单继承,所以如果自定义类需要继承其他类,则只能选择实现Runnable接口。多数情况下都选择实现Runnable接口。

继承Thread类
package mistra.com.concurrent2;

/**
 * Created by Administrator on 2018/1/17/017.
 */
public class ThreadTest {
    public static void main(String[] args) {
        System.out.println("主程序运行开始! 主线程名称:"+Thread.currentThread().getName()+",线程ID:"+Thread.currentThread().getId());
        ThreadSon tr1=new ThreadSon("A");
        ThreadSon tr2=new ThreadSon("B");
        tr1.start();
        tr2.start();
        System.out.println("主程序运行结束!");
    }
}
class ThreadSon extends Thread{
    private String name;
    public ThreadSon(String name) {
        this.name=name;
    }
    public void run() {
        for (int i = 0; i < 5; i++) {
            System.out.print(name + "运行:" + i + "; ");
        }
    }
}
运行结果:
主程序运行开始! 主线程名称:main,线程ID:1
主程序运行结束!
A运行:0; B运行:0; A运行:1; B运行:1; A运行:2; B运行:2; A运行:3; B运行:3; A运行:4; B运行:4; 

main()方法调用时,主线程main启动。
调用ThreadSon两个对象的start()方法,另外两个线程也启动了,整个应用就在多线程下运行。start()方法的调用后并不是立即执行多线程代码,而是使该线程变为可运行状态(Runnable),供JVM调用执行,具体什么时候执行是CPU决定的。不是调用run()方法启动线程,run方法中只是定义需要执行的任务,如果调用run方法,即相当于在主线程中执行run方法,跟普通的方法调用没有任何区别。
多线程程序是乱序执行。观察主线程,A线程,B线程的执行顺序。

实现Runable接口
package mistra.com.concurrent2;

/**
 * Created by Administrator on 2018/1/17/017.
 */
public class ThreadTest2{
    public static void main(String[] args) {
        new Thread(new ThreadSon2("A")).start();
        new Thread(new ThreadSon2("B")).start();
    }
}

class ThreadSon2 implements Runnable{
    private String name;
    public ThreadSon2(String name) {
        this.name=name;
    }
    @Override
    public void run() {
        for (int i = 0; i < 5; i++) {
            System.out.print(name + "运行:" + i + "; ");
        }
    }
}
运行结果:
A运行:0; B运行:0; A运行:1; A运行:2; A运行:3; A运行:4; B运行:1; B运行:2; B运行:3; B运行:4; 

在启动多线程的时候,需要先通过Thread类的构造方法Thread(Runnable target) 构造出对象,然后调用Thread对象的start()方法来运行多线程代码。实际上所有的多线程代码都是通过运行Thread的start()方法来运行的。

Runnable接口比继承Thread类所具有的优势:

  • 适合多个相同的程序代码的线程去处理同一个资源
  • 可以避免java中的单继承的限制
  • 增加程序的健壮性,代码可以被多个线程共享,代码和数据独立
  • 线程池只能放入实现Runable或callable类线程,不能直接放入继承Thread的类
实现Callable接口
package mistra.com.concurrent2;

import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.concurrent.*;

/**
 * Created by Administrator on 2018/1/17/017.
 */
public class ThreadTest3 {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        System.out.println("----程序开始运行----");
        Date date1 = new Date();
        int taskSize = 5;
        ExecutorService pool = Executors.newFixedThreadPool(taskSize);// 创建一个线程池
        List<Future> list = new ArrayList<Future>();
        for (int i = 0; i < taskSize; i++) { // 创建多个有返回值的任务
            Callable c = new MyCallable(i + " ");
            Future f = pool.submit(c);// 执行任务并获取Future对象
            list.add(f);
        }
        pool.shutdown();// 关闭线程池
        for (Future f : list) {// 遍历获取所有并发任务的运行结果
            System.out.println(">>>" + f.get().toString());// 从Future对象上获取任务的返回值,并输出到控制台
        }
        Date date2 = new Date();
        System.out.println("----程序结束运行----,程序运行时间【" + (date2.getTime() - date1.getTime()) + "毫秒】");
    }
}
class MyCallable implements Callable<Object> {
    private String taskNum;
    MyCallable(String taskNum) {
        this.taskNum = taskNum;
    }
    public Object call() throws Exception {
        System.out.println(">>>" + taskNum + "任务启动");
        Date dateTmp1 = new Date();
        Thread.sleep(1000);
        Date dateTmp2 = new Date();
        long time = dateTmp2.getTime() - dateTmp1.getTime();
        System.out.println(">>>" + taskNum + "任务终止");
        return taskNum + "任务返回运行结果,当前任务时间【" + time + "毫秒】";
    }
}
运行结果:
----程序开始运行----
>>>0 任务启动
>>>1 任务启动
>>>2 任务启动
>>>3 任务启动
>>>4 任务启动
>>>0 任务终止
>>>2 任务终止
>>>3 任务终止
>>>4 任务终止
>>>1 任务终止
>>>0 任务返回运行结果,当前任务时间【1001毫秒】
>>>1 任务返回运行结果,当前任务时间【1001毫秒】
>>>2 任务返回运行结果,当前任务时间【1001毫秒】
>>>3 任务返回运行结果,当前任务时间【1001毫秒】
>>>4 任务返回运行结果,当前任务时间【1001毫秒】
----程序结束运行----,程序运行时间【1005毫秒】

ExecutoreService提供了submit()方法,传递一个Callable,或Runnable,返回Future。submit()方法使线程进入可运行状态。如果Executor后台线程池还没有完成Callable的计算,这调用返回Future对象的get()方法,会阻塞直到计算完成。

  • newFixedThreadPool(int nThreads)创建固定数目线程的线程池。
  • newCachedThreadPool()创建一个可缓存的线程池
  • newSingleThreadExecutor创建一个单线程化的Executor。
  • newScheduledThreadPool(int corePoolSize)创建一个支持定时及周期性的任务执行的线程池,多数情况下可用来替代Timer类。

2、线程的状态

这里写图片描述
1、创建:新创建了一个线程对象。
2、可运行状态(Runnable):调用了start()方法, 等待CPU调度
3、运行状态(Running):可运行状态的线程获取了CPU资源,执行程序代码(run方法中的代码)。
4、阻塞状态(Blocked):阻塞状态是线程因为某种原因放弃CPU使用权,暂时停止运行。直到线程进入可运行状态,才有机会转到运行状态。阻塞的情况分三种:
(一)、等待阻塞:运行的线程执行wait()方法,JVM会把该线程放入等待池中。(wait会释放持有的对象的锁)
(二)、同步阻塞:运行的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则JVM会把该线程放入锁池中。
(三)、其他阻塞:运行的线程执行sleep()或join()方法,或者发出了I/O请求时(譬如等待用户输入),JVM会把该线程置为阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入可运行状态。(sleep不会释放持有的对象锁
5、死亡状态(Dead):线程执行完了或者因异常退出了run()方法,该线程结束生命周期。线程销毁。
sleep和wait的区别:

  • sleep是Thread类的方法,wait是Object类中定义的方法
  • Thread.sleep不会导致锁行为的改变, 如果当前线程是拥有锁的, 那么Thread.sleep不会让线程释放锁.
  • Thread.sleep和Object.wait都会暂停当前的线程. 操作系统会将CPU资源分配给其它线程.。区别是, 调用wait后, 需要别的线程执行notify/notifyAll才能够重新获得CPU执行资源

3、线程的调度
package mistra.com.concurrent2;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * Created by Administrator on 2018/1/16/016.
 */
/**
    线程优先级测试
 */
public class SimplePriorities implements Runnable{
    private int countDown = 5;
    private volatile double d;
    private int proitiy;
    public SimplePriorities(int proitiy){
        this.proitiy = proitiy;
    }
    public String toString(){//覆盖本类的toString方法,以便下面打印线程名称
        return Thread.currentThread() + ":" + countDown;
    }
    @Override
    public void run() {
        Thread.currentThread().setPriority(proitiy);//设置优先级
        while (true){
            for (int i = 1;i < 10000;i++){
                d +=(Math.PI + Math.E) / (double)i;
                if(i % 1000 == 0){
                    Thread.yield();
                }
            }
            System.out.println(this);
            if(--countDown == 0)return;
        }
    }

    public static void main(String [] args){
        ExecutorService exe = Executors.newCachedThreadPool();
        for (int i =1;i<5;i++){
            exe.execute(new SimplePriorities(Thread.MIN_PRIORITY));//为前5个线程设置最低优先级参数
        }
        exe.execute(new SimplePriorities(Thread.MAX_PRIORITY));//最后一个线程设置为最高优先级参数
        exe.shutdown();//shutdown的作用是防止新任务被提交给这个Executor
    }
}

1、线程优先级:优先级高的线程会获得较多的运行机会。
Java线程的优先级用整数表示,取值范围是1~10,Thread类有以下三个静态常量:
static int MAX_PRIORITY>>>线程可以具有的最高优先级,取值为10。
static int MIN_PRIORITY>>>线程可以具有的最低优先级,取值为1。
static int NORM_PRIORITY>>>分配给线程的默认优先级,取值为5。
Thread类的setPriority()和getPriority()方法分别用来设置和获取线程的优先级。
每个线程都有默认的优先级。主线程的默认优先级为Thread.NORM_PRIORITY。
线程的优先级有继承关系,比如A线程中创建了B线程,那么B将和A具有相同的优先级。
继承性:比如A线程启动B线程,则B线程的优先级与A是一样的。
规则性:高优先级的线程总是大部分先执行完,但不代表高优先级线程全部先执行完。
随机性:优先级较高的线程不一定每一次都先执行完。

2、线程睡眠:Thread.sleep(long millis)方法,使线程转到阻塞状态。millis参数设定睡眠的时间,以毫秒为单位。当睡眠结束后,就转为可运行(Runnable)状态。

3、线程等待:Object类中的wait()方法,导致当前的线程等待,直到其他线程调用此对象的 notify() 方法或 notifyAll() 唤醒方法。这个两个唤醒方法也是Object类中的方法。

4、线程让步:Thread.yield() 方法,暂停当前正在执行的线程对象,把执行机会让给相同或者更高优先级的线程。

5、线程加入:join()方法,等待其他线程终止。在当前线程中调用另一个线程(A)的join()方法,则当前线程转入阻塞状态,直到另一个进程(A)运行结束,当前线程再由阻塞转为可运行状态。

6、线程唤醒:Object类中的notify()方法,唤醒在此对象监视器上等待的单个线程。如果所有线程都在此对象上等待,则会选择唤醒其中一个线程。选择是任意性的,并在对实现做出决定时发生。线程通过调用其中一个 wait 方法,在对象的监视器上等待。 直到当前的线程放弃此对象上的锁定,才能继续执行被唤醒的线程。被唤醒的线程将以常规方式与在该对象上主动同步的其他所有线程进行竞争;例如,唤醒的线程在作为锁定此对象的下一个线程方面没有可靠的特权或劣势。类似的方法还有一个notifyAll(),唤醒在此对象监视器上等待的所有线程。


4、线程同步

在代码块上加上”synchronized”关键字,则此代码块就称为同步代码块

synchronized(同步对象){
 需要同步的代码块;
}

同步方法
synchronized void 方法名称(){}

synchronized 还可以加在类前面
1、synchronized关键字的作用域有二种:
1)是某个对象实例内,synchronized aMethod(){}可以防止多个线程同时访问这个对象的synchronized方法(如果一个对象有多个synchronized方法,只要一个线程访问了其中的一个synchronized方法,其它线程不能同时访问这个对象中任何一个synchronized方法)。这时,不同的对象实例的synchronized方法是不相干扰的。也就是说,其它线程照样可以同时访问相同类的另一个对象实例中的synchronized方法;
2)是某个类的范围,synchronized static aStaticMethod{}防止多个线程同时访问这个类中的synchronized static 方法。它可以对类的所有对象实例起作用。
2、除了方法前用synchronized关键字,synchronized关键字还可以用于方法中的某个区块中,表示只对这个区块的资源实行互斥访问。用法是: synchronized(this){/区块/},它的作用域是当前对象;

3、synchronized关键字是不能继承的,也就是说,基类的方法synchronized f(){} 在继承类中并不自动是synchronized f(){},而是变成了f(){}。继承类需要你显式的指定它的某个方法为synchronized方法;
总的说来,synchronized关键字可以作为函数的修饰符,也可作为函数内的语句,也就是平时说的同步方法和同步语句块。如果再细的分类,synchronized可作用于instance变量、object reference(对象引用)、static函数和class literals(类名称字面常量)身上。

  • 无论synchronized关键字加在方法上还是对象上,它取得的锁都是对象,而不是把一段代码或函数当作锁――而且同步方法很可能还会被其他线程的对象访问。
  • 每个对象只有一个锁(lock)与之相关联。
  • 实现同步是要很大的系统开销作为代价的,甚至可能造成死锁,所以尽量避免无谓的同步控制。

5、线程常用方法

currentThread():返回代码段正在被哪个线程调用的信息,本文第一个实例代码中演示了

getId():取得线程的唯一标识

getName()|setName:取得线程名称,设置线程名称

getPriority|setPriority:获取和设置线程优先级

sleep(long millis): 在指定的毫秒数内让当前正在执行的线程休眠(暂停执行),并不释放锁。**也就是说如果当前线程持有对某个对象的锁,则即使调用sleep()方法,其他线程也无法访问这个对象。**如果调用了sleep方法,必须捕获InterruptedException异常或者将该异常向上层抛出。当线程睡眠时间满后,不一定会立即得到执行,因为此时可能CPU正在执行其他的任务。所以说调用sleep()方法相当于让线程进入阻塞状态。

join():参考上面第5点:线程加入。主线程生成并起动了子线程,如果子线程里要进行大量的耗时的运算,主线程往往将于子线程之前结束,但是如果主线程处理完其他的事务后,需要用到子线程的处理结果,也就是主线程需要等待子线程执行完成之后再结束,这个时候就要用到join()方法了。

package mistra.com.concurrent2;

/**
 * Created by Administrator on 2018/1/16/016.
 */

/**
 * join()方法测试
 */
public class TestJoin extends Thread{
    public TestJoin(String name) {
        super(name);
    }
    public void run() {
        for (int i = 0; i < 5; i++) {
            System.out.println(getName() + "  " + i);
        }
    }
    public static void main(String[] args) throws InterruptedException {
        System.out.println("主线程运行开始");
        // 启动子进程
        new TestJoin("new thread").start();
        for (int i = 0; i < 10; i++) {
            if (i == 5) {
                TestJoin th = new TestJoin("joined thread");
                th.start();
                th.join();//main主线程等待joined thread线程先执行完了才技术执行的。尝试把这行注释掉看运行结果
            }
            System.out.println(Thread.currentThread().getName() + "  " + i);
        }
        System.out.println("主线程运行结束");
    }
}
运行结果:
主线程运行开始
main  0
main  1
main  2
main  3
main  4
new thread  0
joined thread  0
new thread  1
joined thread  1
new thread  2
joined thread  2
new thread  3
joined thread  3
joined thread  4
new thread  4
main  5
main  6
main  7
main  8
main  9
主线程运行结束

yield():Thread.yield()方法作用是:暂停当前正在执行的线程对象,并执行其他线程。让当前运行线程回到可运行状态(并不是阻塞状态,这一点与sleep()不一样),以允许具有相同优先级的其他线程获得运行机会。因此,使用yield()的目的是让相同优先级的线程之间能适当的轮转执行。但是,实际中无法保证yield()达到让步目的,因为优先级相同,具体哪一个线程得到SPU执行时间不确定,有可能执行yield()方法的这个线程又得到了CPU执行时间,让步的线程还有可能被线程调度程序再次选中它跟sleep方法类似,同样不会释放锁。

sleep()和yield()的区别
sleep()和yield()的区别):sleep()使当前线程进入停滞状态,所以执行sleep()的线程在指定的时间内肯定不会被执行;yield()只是使当前线程重新回到可执行状态,所以执行yield()的线程有可能在进入到可执行状态后马上又被执行。
sleep 方法使当前运行中的线程睡眼一段时间,进入不可运行状态,这段时间的长短是由程序设定的,yield 方法使当前线程让出 CPU 占有权,但让出的时间是不可设定的。实际上,yield()方法对应了如下操作:先检测当前是否有相同优先级的线程处于同可运行状态,如有,则把 CPU 的占有权交给此线程,否则,继续运行原来的线程。所以yield()方法称为“退让”,它把运行机会让给了同等优先级的其他线程
另外,sleep 方法允许较低优先级的线程获得运行机会,但 yield() 方法执行时,当前线程仍处在可运行状态,所以,不可能让出较低优先级的线程些时获得 CPU 占有权。在一个运行系统中,如果较高优先级的线程没有调用 sleep 方法,又没有受到 I\O 阻塞,那么,较低优先级线程只能等待所有较高优先级的线程运行结束,才有机会运行。

isAlive():判断当前线程是否处于活动状态

run():run()方法是不需要用户来调用的,当通过start方法启动一个线程之后,当线程获得了CPU执行时间,便进入run方法体去执行具体的任务。

setDaemon|isDaemon:设置线程是否成为守护线程和判断线程是否是守护线程。守护线程也称后台线程。JVM的垃圾回收线程就是一个后台线程。守护线程是为用户线程服务的。如果已经没有用户线程在执行,程序也就终止了,守护线程也就没有存在的必要了,也会终止。thread.setDaemon(true)必须在thread.start()之前设置,否则会跑出一个IllegalThreadStateException异常。你不能把正在运行的常规线程设置为守护线程。在Daemon线程中产生的新线程也是Daemon的。

Obeject.wait():与Obj.notify()必须要与synchronized(Obj)一起使用,也就是wait,与notify是针对已经获取了Obj锁进行操作,从语法角度来说就是Obj.wait(),Obj.notify必须在synchronized(Obj){…}语句块内。从功能上来说wait就是说线程在获取对象锁后,主动释放对象锁,同时本线程休眠。直到有其它线程调用对象的notify()唤醒该线程,才能继续获取对象锁,并继续执行。相应的notify()就是对对象锁的唤醒操作。但有一点需要注意的是notify()调用后,并不是马上就释放对象锁的,而是在相应的synchronized(){}语句块执行结束,自动释放锁后,JVM会在wait()对象锁的线程中随机选取一线程,赋予其对象锁,唤醒线程,继续执行。这样就提供了在线程间同步、唤醒的操作。Thread.sleep()与Object.wait()二者都可以暂停当前线程,释放CPU控制权,主要的区别在于Object.wait()在释放CPU同时,释放了对象锁的控制。

package mistra.com.concurrent2;

/**
 * Created by Administrator on 2018/1/17/017.
 */
/**
 * wait notify 方法测试
 * 建立三个线程,A线程打印10次A,B线程打印10次B,C线程打印10次C,要求线程同时运行,交替打印10次ABC。这个问题用Object的wait(),notify()就可以很方便的解决
 */
public class TestWaitNotify implements Runnable{
    private String name;
    private Object prev;
    private Object self;

    private TestWaitNotify(String name, Object prev, Object self) {
        this.name = name;
        this.prev = prev;
        this.self = self;
    }

    @Override
    public void run() {
        int count = 10;
        while (count > 0) {
            synchronized (prev) {
                synchronized (self) {
                    System.out.print(name);
                    count--;
                    self.notify();
                }
                try {
                    prev.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }

        }
    }

    public static void main(String[] args) throws Exception {
        Object a = new Object();
        Object b = new Object();
        Object c = new Object();
        TestWaitNotify pa = new TestWaitNotify("A", c, a);
        TestWaitNotify pb = new TestWaitNotify("B", a, b);
        TestWaitNotify pc = new TestWaitNotify("C", b, c);
        new Thread(pa).start();
        Thread.sleep(100);  //确保按顺序A、B、C执行
        new Thread(pb).start();
        Thread.sleep(100);
        new Thread(pc).start();
        Thread.sleep(100);
    }

    /**
     * 
     */
}

该问题为三线程间的同步唤醒操作,主要的目的就是ThreadA->ThreadB->ThreadC->ThreadA循环执行三个线程。为了控制线程执行的顺序,那么就必须要确定唤醒、等待的顺序, 所以每一个线程必须同时持有两个对象锁,才能继续执行。一个对象锁是prev,就是前一个线程所持有的对象锁。还有一个就是自身对象锁。主要的思想就是,为了控制执行的顺序,必须要先持有prev锁,也就前一个线程要释放自身对象锁,再去申请自身对象锁,两者兼备时打印,之后首先调用self.notify()释放自身对象锁,唤醒下一个等待线程, 再调用prev.wait()释放prev对象锁,终止当前线程,等待循环结束后再次被唤醒。运行上述代码,可以发现三个线程循环打印ABC,共10次。程序运行的主要过程就是A线程最先运行,持有C,A对象锁,后释放A,C锁,唤醒B。线程B等待A锁,再申请B锁,后打印B,再释放B,A锁,唤醒C,线程C等待B锁,再申请C锁,后打印C,再释放C,B锁,唤醒A。看起来似乎没什么问题, 但如果你仔细想一下,就会发现有问题,就是初始条件,三个线程按照A,B,C的顺序来启动,按照前面的思考,A唤醒B,B唤醒C,C再唤醒A。但是这种假设依赖于JVM中线程调度、执行的顺序。

wait和sleep区别
共同点:

  1. 他们都是在多线程的环境下,都可以在程序的调用处阻塞指定的毫秒数,并返回。
  2. wait()和sleep()都可以通过interrupt()方法 打断线程的暂停状态 ,从而使线程立刻抛出InterruptedException。如果线程A希望立即结束线程B,则可以对线程B对应的Thread实例调用interrupt方法。如果此刻线程B正在wait/sleep /join,则线程B会立刻抛出InterruptedException,在catch() {} 中直接return即可安全地结束线程。
    需要注意的是,InterruptedException是线程自己从内部抛出的,并不是interrupt()方法抛出的。对某一线程调用interrupt()时,如果该线程正在执行普通的代码,那么该线程根本就不会抛出InterruptedException。但是,一旦该线程进入到wait()/sleep()/join()后,就会立刻抛出InterruptedException 。
    不同点:
    1.sleep方法没有释放锁,而wait方法释放了锁,使得其他线程可以使用同步控制块或者方法。
    2.wait,notify和notifyAll只能在同步控制方法或者同步控制块里面使用,而sleep可以在任何地方使用
    3.sleep必须捕获异常,而wait,notify和notifyAll不需要捕获异常
    所以sleep()和wait()方法的最大区别是:
        sleep()睡眠时,保持对象锁,仍然占有该锁;
        而wait()睡眠时,释放对象锁。

相关博文:
什么是死锁?如何避免死锁?
什么是死锁及死锁的必要条件和解决方法


[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ULAcwLWd-1570866269827)(https://img-blog.csdn.net/20180117173320280?watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvQXhlbGEzMFc=/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/SouthEast)]

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值