Java多线程:基础篇(一)

一、什么是线程?

在讨论什么是线程前有必要先说下什么是进程,因为线程是进程中的一个实体,线程本身是不会独立存在的。进程是代码在数据集合上的一次运行活动, 是系统进行资源分配和调度的基本单位, 线程则是进程的一个执行路径, 一个进程中至少有一个线程,进程中的多个线程共享进程的资源。操作系统在分配资源时是把资源分配给进程的, 但是CPU 资源比较特殊, 它是被分配到线程的, 因为真正要占用CPU 运行的是线程, 所以也说线程是CPU 分配的基本单位

进程是资源分配的最小单位,线程是CPU调度的最小单位

在Java 中,当我们启动main 函数时其实就启动了一个口JVM 的进程, 而main 函数所在的线程就是这个进程中的一个线程,也称主线程。
在这里插入图片描述
一个进程中有多个线程,多个线程共享进程的堆和方法区资源,
但是每个线程有自己的程序计数器和技区域。程序计数器是一块内存区域,用来记录线程当前要执行的指令地址。那么为何要将程序计数器设计为线程私有的呢?前面说了线程是占用CPU 执行的基本单位,而CPU一般是使用时间片轮转方式让线程轮询占用的,所以当前线程CPU 时间片用完后,要让出CPU ,等下次轮到自己的时候再执行。那么如何知道之前程序执行到哪里了呢?其实程序计数器就是为了记录该线程让出CPU 时的执行地址的,待再次分配到时间片时线程就可以从自己私有的计数器指定地址继续执行。另外需要注意的是,如果执行的是native 方法,那么pc 计数器记录的是undefined 地址,只有执行的是Java 代码时pc 计数器记录的才是下一条指令的地址。
另外每个线程都有自己的独占资源,用于存储该线程的局部变量,这些局部变量是该线程私有的,其他线程是访问不了的,除此之外枝还用来存放线程的调用技帧。堆是一个进程中最大的一块内存,堆是被进程中的所有线程共享的,是进程创建时分配的,堆里面主要存放使用new 操作创建的对象实例。方法区则用来存放JVM 加载的类、常量及静态变量等信息,也是线程共享的。

生命周期
在这里插入图片描述

二、线程三种创建方式及使用

Java 中有三种线程创建方式,分别为:
1、实现Runnable 接口的run 方法。
2、使用FutureTask 方式(实现Callable接口)。
3、继承Thread 类并重写run 的方法。

访问线程名,前两种使用Thread.currentThread().getName(),后一种直接使用this.getName()。

线程创建的三种方式中继承Thread和实现Runnable接口是没有返回值,使用FutureTask 方式(实现Callable)会有返回值

2.1 第一种继承Thread

代码如下:

public class ThreadTest {
    
    public static class MyThread extends Thread{
        @Override
        public void run () {
            System.out.println("I am a child thread");
        }
    }

    public static void main(String[] args ){
        MyThread thread = new MyThread();
        thread.start();
    }
}

如上代码中的MyThread 类继承了Thread 类,并重写了run() 方法。在main 函数里面创建了一个MyThread 的实例,然后调用该实例的start 方法启动了线程。需要注意的是,当创建完thread 对象后该线程并没有被启动执行,直到调用了start 方法后才真正启动了线程。其实调用start 方法后线程并没有马上执行而是处于就绪状态, 这个就绪状态是指该线程已经获取了除CPU 资源外的其他资源,等待获取CPU 资源后才会真正处于运行状态。一旦run 方法执行完毕, 该线程就处于终止状态。

使用继承方式
优点
在run() 方法内获取当前线程直接使用this 就可以了,无须使用Thread. currentThread() 方法;
缺点
不好的地方是Java 不支持多继承,如果继承了Thread 类,那么就不能再继承其他类。另外任务与代码没有分离, 当多个线程执行一样的任务时需要多份任务代码,而Runable 则没有这个限制。

2.3 第二种实现Runnable接口

代码如下:

public class ThreadTest1 {

    public static class RunableTask implements Runnable {
        @Override
        public void run() {
            System.out.println("I am a child thread");
        }
    }

    public static void main(String[] args) throws InterruptedException {
        RunableTask task = new RunableTask();
        new Thread(task).start();
        new Thread(task).start();
    }
}

如上面代码所示,两个线程共用一个task 代码逻辑,如果需要,可以给RunableTask添加参数进行任务区分。另外, RunableTask 可以继承其他类。但是上面介绍的两种方式都有一个缺点,就是任务没有返回值

2.3 第三种使用FutureTask 方式

代码如下:

public class ThreadTest2 {

    public static class CallerTask implements Callable<String> {
        @Override
        public String call() throws Exception {
            Thread.sleep(5000);
            return "hello";
        }
    }

    public static void main(String[] args) throws InterruptedException {
        //创建异步任务
        FutureTask<String> futureTask = new FutureTask<>(new CallerTask());
        //启动线程
        new Thread(futureTask).start();
        try {
            //等待任务执行完毕,并返回结果
            String result = futureTask.get();
            System.out.println(result);
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
    }
}

如上代码中的CallerTask类实现了Callable 接口的call()方法。在main函数内首先创建了一个FutrueTask对象(构造函数为CallerTask的实例),然后使用创建的FutrueTask对象作为任务创建了一个线程并且启动它,最后通过futureTask.get() 等待任务执行完毕并返回结果。

三、线程的状态变化

要想实现多线程,必须在主线程中创建新的线程对象。任何线程一般具有5种状态,即创建,就绪,运行,阻塞,销毁。下面分别介绍一下这几种状态:

创建状态

在程序中用构造方法创建了一个线程对象后,新的线程对象便处于新建状态,此时它已经有了相应的内存空间和其他资源,但还处于不可运行状态。新建一个线程对象可采用Thread 类的构造方法来实现,例如 “Thread thread=new Thread()”。

就绪状态

新建线程对象后,调用该线程的 start() 方法就可以启动线程。当线程启动时,线程进入就绪状态。此时,线程将进入线程队列排队,等待拿到CPU 服务,这表明它已经具备了运行条件。

运行状态

当就绪状态被调用并获得处理器资源时,线程就进入了运行状态。此时,自动调用该线程对象的 run() 方法。run() 方法定义该线程的操作和功能。

阻塞状态

一个正在执行的线程在某些特殊情况下,如被人为挂起或需要执行耗时的输入/输出操作,会让 CPU 暂时中止自己的执行,进入阻塞状态。在可执行状态下,如果调用sleep(),wait() 等方法,线程都将进入阻塞状态,发生阻塞时线程不能进入排队队列,只有当引起阻塞的原因被消除后,线程才可以转入就绪状态。

销毁状态

线程调用 stop() 方法时或 run() 方法执行结束后,即处于销毁状态。处于死亡状态的线程不具有继续运行的能力。

每当使用 Java 命令执行一个类时,实际上都会启动一个 JVM,每一个JVM实际上就是在操作系统中启动一个线程,Java 本身具备了垃圾的收集机制。所以在 Java 运行时至少会启动两个线程一个是 main 线程,另外一个是垃圾收集线程

四、线程等待和通知

4.1 wait() 函数

当一个线程调用一个共享变量的wait()方法时,该调用线程会被阻塞挂起,直到发生下面几件事情之一才返回:
(1)其他线程调用了该共享对象的notify()或者notifyAIl()方法;
(2)其他线程调用了该线程的interrupt()方法,该线程抛出InterruptedException 异常返回。另外需要注意的是,如果调用wait() 方法的线程没有事先获取该对象的监视器锁,则调用wait()方法时调用线程会抛出IllegalMonitorStateException 异常

那么一个线程如何才能获取一个共享变量的监视器锁呢?

(1) 执行synchronized同步代码块时,使用该共享变量作为参数。

synchronized (共享变量) {
//doSomething
}

(2)调用该共享变量的方法,并且该方法使用了synchronized 修饰。

synchronized void add(int a,int b) {
// doSomething
}

另外需要注意的是,一个线程可以从挂起状态变为可以运行状态(也就是被唤醒),即使该线程没有被其他线程调用notify()、notifyAll() 方法进行通知,或者被中断,或者等待超时,这就是所谓的虚假唤醒
虽然虛假唤醒在应用实践中很少发生,但要防患于未然,做法就是不停地去测试该线程被唤醒的条件是否满足,不满足则继续等待,也就是说在一个循环中调用wait()方法进行防范。退出循环的条件是满足了唤醒该线程的条件。

synchronized (obj ){
	while (条件不满足) {
		obj .wait() ;
	}
}

如上代码是经典的调用共享变量wait()方法的实例,首先通过同步块获取obj上面的监视器锁,然后在while循环内调用obj的wait() 方法。下面从一个简单的生产者和消费者例子来加深理解。如下面代码所示,其中queue为共享变量,生产者线程在调用queue的wait()方法前,使用synchronized关键字拿到了该共享变量queue的监视器锁,所以调用wait()方法才不会抛出IllegaMonitorStateException异常。如果当前队列没有空闲容量则会调用queue的wait()方法挂起当前线程,这里使用循环就是为了避免上面说的虚假唤醒问题。假如当前线程被虚假唤醒了,但是队列还是没有空余容量,那么当前线程还是会调用wait()方法把自己挂起。
实例:

public class CorrespondedThreadTest {

    private static BlockingQueue<String> queue = new LinkedBlockingDeque<>();

    static int MAX_SIZE = 1;

    static class Producer extends Thread {
        @Override
        public void run() {
            //生产线程
            synchronized (queue) {
                //消费队列满,则等待队列空闲
                while (queue.size() == MAX_SIZE) {
                    try {
                        //挂起当前线程,并释放通过同步块获取的queue上的锁,让消费者线程可以获取该锁,然后获取队列里面的元素
                        queue.wait();
                    } catch (Exception ex) {
                        ex.printStackTrace();
                    }

                }
                //空闲则生成元素,并通知消费者线程
                queue.add("元素");
                queue.notifyAll();
            }
        }
    }
    static class Consumer extends Thread {
        @Override
        public void run() {
            //生产线程
            synchronized (queue) {
                //消费队列满,则等待队列空闲
                while (queue.size() == 0) {
                    try {
                       //挂起当前线程,并释放通过同步块获取的queue.上的锁,让生产者线程可以获取该锁,将生
                        //产元素放入队列
                        queue.wait();
                    } catch (Exception ex) {
                        ex.printStackTrace();
                    }
                }
                try {
                    queue.take();
                    ///消费元素,并通知唤醒生产者线程
                    queue.notifyAll();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

            }
        }
    }

    public static void main(String[] args) throws InterruptedException {

        Consumer c = new Consumer();
        Producer p = new Producer();

        c.start();
        p.start();
    }
}

在如上代码中假如生产者线程A首先通过synchronized获取到了queue. 上的锁,那么后续所有企图生产元素的线程和消费线程将会在获取该监视器锁的地方被阻塞挂起。线程A获取锁后发现当前队列已满会调用queue.wait()方法阻塞自己,然后释放获取的queue上的锁,这里考虑下为何要释放该锁? 如果不释放,由于其他生产者线程和所有消费者线程都已经被阻塞挂起,而线程A也被挂起,这就处于了死锁状态。这里线程A挂起自己后释放共享变量上的锁,就是为了打破死锁必要条件之一的持有并等待原则。
线程A释放锁后,其他生产者线程和所有消费者线程中会有一个线程获取queue.上的锁进而进入同步块,这就打破了死锁状态
死锁条件
1.互斥条件。一个资源只能被一个进程占用
2.不可剥夺条件。某个进程占用了资源,就只能他自己去释放。
3.请求和保持条件。某个进程之前申请了资源,我还想再申请资源,之前的资源还是我占用着,别人别想动。除非我自己不想用了,释放掉。
4.循环等待条件。一定会有一个环互相等待

例子:A占用锁A,未释放锁A,去获取锁B,B占有锁B,未释放锁B,去获取锁A

最后再举一个例子进行说明。当一个线程调用共享对象的wait()方法被阻塞挂起后,
如果其他线程中断了该线程,则该线程会抛出InterruptedException异常并返回。

public class WaitNotifyInterupt {
    static Object obj = new Object();

    public static void main(String[] args) throws InterruptedException {
        //创建线程
        Thread threadA = new Thread(new Runnable() {
            public void run() {
                try {
                    System.out.println("---begin---");
                    //阻塞当前线程
                    synchronized (obj) {
                        obj.wait();
                    }
                    System.out.println("---end---");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        threadA.start();
        Thread.sleep(1000);
        System.out.println("---begin interrupt threadA---");
        threadA.interrupt();
        System.out.println("---end interrupt threadA---");
    }
}

在如上代码中,threadA 调用共享对象obj的wait() 方法后阻塞挂起了自己,然后主线程在休眠1s后中断了threadA 线程,中断后threadA在obj.wait()处抛出java.lang.InterruptedException异常而返回并终止。

4.2 wait(long timeout)函数

该方法相比wait()方法多了一个超时参数,它的不同之处在于,如果一个线程调用共享对象的该方法挂起后,没有在指定的timeoutms时间内被其他线程调用该共享变量的notify(或者notifyAll()方法唤醒,那么该函数还是会因为超时而返回。如果将timeout设置为0则和wait方法效果一样,因为在wait方法内部就是调用了wait(0)。 需要注意的是,如果在调用该函数时,传递了一个负的timeout则会抛出IllegalArgumentException异常

4.3 notify() 函数

一个线程调用共享对象的notify()方法后,会唤醒一个在该共享变量上调用wait系列方法后被挂起的线程。一个共享变量上可能会有多个线程在等待,具体唤醒哪个等待的线程是随机的。此外,被唤醒的线程不能马上从wait方法返回并继续执行,它必须在获取了共享对象的监视器锁后才可以返回,也就是唤醒它的线程释放了共享变量上的监视器锁后,被唤醒的线程也不一定会获取到共享对象的监视器锁,这是因为该线程还需要和其他线程起竞争该锁,只有该线程竞争到了共享变量的监视器锁后才可以继续执行。类似wait系列方法,只有当前线程获取到了共享变量的监视器锁后,才可以调用共享变量的notify()方法,否则会抛出llgalMonitorStateException异常。

4.4 notifyAll() 函数

不同于在共享变量上调用notify()函数会唤醒被阻塞到该共享变量上的一个线程,notifyAll()方法则会唤醒所有在该共享变量上由于调用wait系列方法而被挂起的线程
总结
1.wait和notify必须要在synchronized代码块之内

4.5 LockSupport

在java并发包下各种同步组件的底层实现中,LockSupport的身影处处可见。JDK中的定义为用来创建锁和其他同步类的线程阻塞。
我们可以使用它来阻塞和唤醒线程,功能和wait,notify有些相似,但是LockSupport比起wait,notify功能更强大,也好用的多。
LockSupport.park(); 加锁
LockSupport.unpark(parkThread); 解锁

4.6 wait、notify的缺点

1.由上面的例子可知,wait和notify都是Object中的方法,在调用这两个方法前必须先获得锁对象,这限制了其使用场合:只能在同步代码块中
• 2.另一个缺点可能上面的例子不太明显,当对象的等待队列中有多个线程时,notify只能随机选择一个线程唤醒,无法唤醒指定的线程

public class LockSupportTest {

    public static void main(String[] args) {
        Thread parkThread = new Thread(new ParkThread());
        parkThread.start();
        LockSupport.unpark(parkThread);

    }

    static class ParkThread implements Runnable{
        @Override
        public void run() {
            System.out.println("开始线程阻塞");
            LockSupport.park();
            System.out.println("结束线程阻塞");
        }
    }
}

LockSupport.park();可以用来阻塞当前线程,park是停车的意思,把运行的线程比作行驶的车辆,线程阻塞则相当于汽车停车,相当直观。该方法还有个变体LockSupport.park(Object blocker),指定线程阻塞的对象blocker,该对象主要用来排查问题。方法LockSupport.unpark(Thread thread)用来唤醒线程,因为需要线程作参数,所以可以指定线程进行唤醒

4.7 Join 方法

public class JoinTest {

    public static void main(String[] args) throws InterruptedException {
        Thread threadOne = new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("child threadOne over!");
            }
        });

        Thread threadTwo = new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("child threadTwo over!");
            }
        });

         //启动子线程
        threadOne. start() ;
        threadTwo.start() ;
        System.out.println("wait all child thread over!") ;
        //等待子线程执行完毕,返回
        threadOne. join() ;
        threadTwo. join() ;
        System.out.println ("all child thread over") ;
    }
}

如上代码在主线程里面启动了两个子线程,然后分别调用了它们的join()方法,那么主线程首先会在调用threadOne.join()方法后被阻塞,等待threadOne执行完毕后返回。threadOne执行完毕后hreadOne.join() 就会返回,然后主线程调用threadTwo.join()方法后再次被阻塞,等待threadTwo执行完毕后返回。这里只是为了演示join方法的作用。
在这里插入图片描述
另外,线程A调用线程B的join方法后会被阻塞,当其他线程调用了线程A的interrupt()方法中断了线程A时,线程A会拋出InterruptedException异常而返回主线程会等到调用join线程执行完毕后在执行

4.8 线程睡眠的sleep 方法

Thread类中有一个静态的sleep方法,当一个执行中的线程调用了Thread的sleep方法后,调用线程会暂时让出指定时间的执行权,也就是在这期间不参与CPU的调度,但是该线程所拥有的监视器资源,比如锁还是持有不让出的指定的睡眠时间到了后该函数会正常返回,线程就处于就绪状态,然后参与CPU的调度,获取到CPU资源后就可以继续运行了。如果在睡眠期间其他线程调用了该线程的interrupt()方法中断了该线程,则该线程会在调用sleep方法的地方抛出InterruptedException异常而返回

public class SleepTest {

    // 创建一个独 占锁
    private static final Lock lock = new ReentrantLock();

    public static void main(String[] args) throws InterruptedException {
        //创建线程A
        Thread threadA = new Thread(new Runnable() {
            public void run() {
                //获取独占锁
                lock.lock();
                try {
                    System.out.println("child threadA is in sleep");
                    Thread.sleep(10000);
                    System.out.println("child threadA is in awaked");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    //释放锁
                    lock.unlock();
                }
            }
        });

        //创建线程B
        Thread threadB = new Thread(new Runnable() {
            public void run() {
                //获取独占锁
                lock.lock();
                try {
                    System.out.println("child threadB is in sleep");
                    Thread.sleep(10000);
                    System.out.println("child threadB is in awaked");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    //释放锁
                    lock.unlock();
                }
            }

        });
        //启动线程
        threadA.start();
        threadB.start();
    }
}

在这里插入图片描述
如上代码首先创建了一个独占锁,然后创建了两个线程,每个线程在内部先获取锁,然后睡眠,睡眠结束后会释放锁。首先,无论你执行多少遍上面的代码都是线程A先输出或者线程B先输出,不会出现线程A和线程B交叉输出的情况。从执行结果来看,线程A先获取了锁,那么线程A会先输出一行,然后调用sleep 方法让自己睡眠10s,在线程A睡眠的这10s内那个独占锁lock还是线程A自己持有,线程B会一直阻塞直到线程A醒来后执行unlock释放锁。

下面再来看一下, 当一个线程处于睡眠状态时,如果另外一个线程中断了它,会不会在调用sleep方法处抛出异常?

public class SleepTest1 {

    public static void main(String[] args) throws InterruptedException {
        //创建线程A
        Thread threadA = new Thread(new Runnable() {
            public void run() {

                try {
                    System.out.println("child threadA is in sleep");
                    Thread.sleep(5000);
                    System.out.println("child threadA is in awaked");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        //启动线程
        threadA.start();
        Thread.sleep(2000);
        threadA.interrupt();
    }
}

在这里插入图片描述
子线程在睡眠期间,主线程中断了它,所以子线程在调用sleep方法处抛出了InterruptedException异常。

4.9 Yield方法

Thread类中有一个静态的yield方法,当一个线程调用yield方法时,实际就是在暗示线程调度器当前线程请求让出自己的CPU使用,但是线程调度器可以无条件忽略这个暗示。我们知道操作系统是为每个线程分配一个时间片来占有CPU的,正常情况下当一个线程把分配给自己的时间片使用完后,线程调度器才会进行下一轮的线程调度,而当一个线程调用了Thread类的静态方法yield时,是在告诉线程调度器自己占有的时间片中还没有使用完的部分自己不想使用了,这暗示线程调度器现在就可以进行下一轮的线程调度。当一个线程调用yield方法时,当前线程会让出CPU使用权,然后处于就绪状态,线程调度器会从线程就绪队列里面获取一个线程优先级最高的线程,当然也有可能会调度到刚刚让出CPU的那个线程来获取CPU执行权
实例

public class YieldTest implements Runnable{
    YieldTest() {
        //创建并启动线程
        Thread t = new Thread(this);
        t.start();
    }

    public void run() {
        for (int i = 0; i < 5; i++) {
            //当i=0时让出CPU执行权,放弃时间片,进行下一轮调度
            if ((i % 5) == 0) {
                System.out.println(Thread.currentThread() + "yield cpu...");
                //当前线程让出CPU执行权,放弃时间片,进行下一轮调度
                // Thread.yield() ;
            }
        }
        System.out.println(Thread.currentThread() + " is over");
    }

    public static void main(String[] args) {
        new YieldTest();
        new YieldTest();
        new YieldTest();
    }
}

在这里插入图片描述
如上代码开启了三个线程,每个线程的功能都一样,都是在for循环中执行5次打印。运行多次后,上面的结果是出现次数最多的。解开Thread.yield()注释再执行,结果如下:
在这里插入图片描述
从结果可知,Thread.yield() 方法生效了,三个线程分别在i=0时调用了Thread.yield()方法,所以三个线程自己的两行输出没有在一起,因为输出了第一行后当前线程让出了CPU执行权。

4.10 中断

Java中的线程中断是一种线程间的协作模式,通过设置线程的中断标志并不能直接终止该线程的执行,而是被中断的线程根据中断状态自行处理

●void interrupt()方法:中断线程,例如,当线程A运行时,线程B可以调用线程A的interrupt()方法来设置线程A的中断标志为true并立即返回。设置标志仅仅是设置标志,线程A实际并没有被中断,它会继续往下执行。如果线程A因为调用了wait系列函数、join方法或者sleep方法而被阻塞挂起,这时候若线程B调用线程A的interrupt()方法,线程A会在调用这些方法的地方抛出InterruptedException 异常而返回

●boolean isInterrupted()方法:检测当前线程是否被中断,如果是返回true,否则返回false。注意:现在处于执行中,不能是结束状态,否则返回false。
在这里插入图片描述
●boolean interrupted() 方法:检测当前线程是否被中断,如果是返回true, 否则返回false。与islnterrupted不同的是,该方法如果发现当前线程被中断,则会清除中断标志,并且该方法是static 方法,可以通过Thread类直接调用。另外从下面的代码可以知道,在interrupted()内部是获取当前调用线程的中断标志而不是调用interrupted()方法的实例对象的中断标志
在这里插入图片描述
示例:下面看一个根据中断标志判断线程是否终止的例子

public class InterruptTest {

    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                //如果当前线程被中断则退出循环
                while (!Thread.currentThread().isInterrupted())
                    System.out.println(Thread.currentThread() + "hello");
            }
        });
        //启动子线程
        thread.start();
        //主线程休眠1s,以便中断前让子线程输出.
       Thread.sleep(1000);
        //中断子线程
        System.out.println("main thread interrupt thread");
        thread.interrupt();
        //等待子线程执行完毕
        thread.join();
        System.out.println("main is over");
    }
}

在这里插入图片描述
在如上代码中,子线程thread通过检查当前线程中断标志来控制是否退出循环,主线程在休眠1s后调用thread的interrupt()方法设置了中断标志,所以线程thread退出了循环。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值