深究线程状态及切换

线程状态

1.知识补充

1.1 并行和并发
  • 并发:两个或多个事件在用一个时间段内发生

  • 并行:两个或多个事件在同一时刻发生(同时发生)

在这里插入图片描述
在我们的计算机中,如果你的CPU是多核的,这个核的数量就是处理任务的线程的数量,比如你是双核的,那你的计算机便能并行处理两个任务。如果你只有单核,但又有多个任务或者双核有两个以上的任务,这时候怎么办呢?这时候就是并发处理任务了。

并发处理时,我们的CPU会在多个线程间反复横跳,一下子执行A线程,一下子执行B线程。由于CPU操作的时间是毫秒甚至是纳秒级别的,所以对我们来说可以忽略不记,可以理解把他们当作是同时发生的,但对于某一个时间点,都只有一个线程在工作。


1.2 锁池、等待池

大家有没有想过,当一个线程没抢到锁的时候在干嘛呢?调用了wait()后在干嘛呢?答案就是:它们都存在某个地方里,静静等待着,上面两种情况分别对应两个地方,竞争锁失败后就进入了锁池;调用wait()后就进入了等待池。那什么是锁池和等待池呢?

锁其实就是一个对象的对象头里维护了一个monitor对象,而其实对象还维护了两个set:EntrySet 和 WaitSet,但他们也被叫做锁池和等待池。

  • 锁池
    当一个线程竞争锁失败后,就会在锁池里挂起,当锁被释放时,锁池里的线程会再重新来争夺这把锁。

    注意一点:这里指竞争锁指的是使用synchronized关键字,使用lock并不会进入锁池。

  • 等待池
    当一个线程调用wait()方法后就会进入等待池,而进入了等待池要么是超时等待然后苏醒,要么就要用notify()方法去唤醒等待池里的线程。

    同时也要注意,等待池并非是指线程处于等待状态就会进入等待池,线程只有使用wait()方法才能进入等待池,sleep()join()等方法虽然也会进入等待状态,但不会进入等待池。

    且一个线程从等待池醒来,不会直接回到运行状态,而是会进入锁池队列去竞争锁,只有竞争到锁才会进入运行状态。

在这里插入图片描述

看图应该就很好理解了,阻塞状态的线程就是位于锁池里的线程,等待状态的线程却不一定是等待池里的线程。

且还有一个点需要注意:如同锁是一个对象的所特有的,不同对象的锁不是同一个,这里不同对象的锁池和等待池也是不相同的wait()方法不是线程的,而是Object类里的方法,这里也可以看出来这一点。

且对于锁池和等待池,它们名字都有set,但事实上也可以看成队列,虽然不一定满足FIFO(先进先出):

  1. 当锁释放时,CPU调度程序就会在锁池队列里随机挑选一个线程来运行,这里就不满足FIFO了。其实也不能说随机,线程也是可以设置优先级的,高优先级被挑选可能性比较大,但也要注意不是高优先级就一定会被调用,这个也只是可能性变大而已。
  2. 当线程调用了notify()方法时,就会唤醒等待池队列里的第一个线程,这里就满足FIFO了。网上有一些说法是说唤醒是“随机的”,但这里的随机指的是JDK版本,在JDK1.8是唤醒队列头的线程,其他版本可能会有不同的操作,所以说是随机的。

2.线程状态

对于线程的状态,网上众说纷纭,有的说五种,有的说七种,但实际上应该是六种,这一点可参考并发编程神书《Java并发编程的艺术》,也可以从源码中得出,线程的状态在Java中是有规定的,由一个枚举类规定:

public enum State {
        /**
         * Thread state for a thread which has not yet started.
         */
        NEW,

        /**
         * Thread state for a runnable thread.  A thread in the runnable
         * state is executing in the Java virtual machine but it may
         * be waiting for other resources from the operating system
         * such as processor.
         */
        RUNNABLE,

        /**
         * Thread state for a thread blocked waiting for a monitor lock.
         * A thread in the blocked state is waiting for a monitor lock
         * to enter a synchronized block/method or
         * reenter a synchronized block/method after calling
         * {@link Object#wait() Object.wait}.
         */
        BLOCKED,

        /**
         * Thread state for a waiting thread.
         * A thread is in the waiting state due to calling one of the
         * following methods:
         * <ul>
         *   <li>{@link Object#wait() Object.wait} with no timeout</li>
         *   <li>{@link #join() Thread.join} with no timeout</li>
         *   <li>{@link LockSupport#park() LockSupport.park}</li>
         * </ul>
         *
         * <p>A thread in the waiting state is waiting for another thread to
         * perform a particular action.
         *
         * For example, a thread that has called {@code Object.wait()}
         * on an object is waiting for another thread to call
         * {@code Object.notify()} or {@code Object.notifyAll()} on
         * that object. A thread that has called {@code Thread.join()}
         * is waiting for a specified thread to terminate.
         */
        WAITING,

        /**
         * Thread state for a waiting thread with a specified waiting time.
         * A thread is in the timed waiting state due to calling one of
         * the following methods with a specified positive waiting time:
         * <ul>
         *   <li>{@link #sleep Thread.sleep}</li>
         *   <li>{@link Object#wait(long) Object.wait} with timeout</li>
         *   <li>{@link #join(long) Thread.join} with timeout</li>
         *   <li>{@link LockSupport#parkNanos LockSupport.parkNanos}</li>
         *   <li>{@link LockSupport#parkUntil LockSupport.parkUntil}</li>
         * </ul>
         */
        TIMED_WAITING,

        /**
         * Thread state for a terminated thread.
         * The thread has completed execution.
         */
        TERMINATED;
    }

下面来详细看下这六个状态:
在这里插入图片描述

2.1 NEW

初始状态,new一个实例出来,线程就进入了初始状态。

2.2 RUNNABLE

运行状态,这是一个复合状态,里面包括了就绪状态(READY)和运行中状态(RUNNING)。

  • 就绪(READY):就绪状态只是说你资格运行,但还必须和其他线程竞争CPU的调度,才能真正执行。

    1. 这里比较混乱需解释一下,因为线程是并发的,即某一个是时间点只能存在一个线程运行,所以多个线程竞争CPU调度的时候,成功获取CPU调度的那个线程就是运行中状态,其他失败的或者说还没被调度到的就是就绪状态
    2. 调用start()、当前线程sleep()方法结束、调用当前线程的yield()方法、其他线程join()结束、等待用户输入完毕、锁池里的线程获得锁等都可以进入就绪状态。
  • 运行中(RUNNING):即被调度程序选中的READY状态的线程,会自动调用run()方法。

2.3 BLOCKED

阻塞状态,线程被锁阻塞。

  1. 特别要注意,这里指的锁是指synchronized关键字,使用Lock锁住线程是会进入等待状态。
  2. 线程遇到同步代码块或同步方法就会进入阻塞状态,也就是进入了锁池。
2.4 WAITING

等待状态,处于这种状态的线程不会被分配CPU执行时间,它们要等待被显式地唤醒,否则会处于无限期等待的状态。

网上对线程的状态分类有许多说法,其中一种说法就是将BLOCKED和WAITING总结到一起,都称为BLOCKED,不过一种是同步阻塞,一种是等待阻塞,这种说法其实也可以接受,能理解即可。

有许多种方法可以进入WAITING状态,比如wait()、sleep()、join()等,这些方法还有很多值得细说的地方,有很多细节,下面会细讲,所以个人觉得WAITING也是最复杂的一个状态,很多方法都有相关。

这里还要注意一个点,上面讲BLOCKED状态的时候,讲到一点就是使用LOCK锁不会使线程进入BLOCKED状态,而是会进入WAITING状态,因为LOCK锁底层使用的使用的是一个LockSupport.park()方法,这个方法是进入WAITING状态。

2.5 TIME_WAITING

超时等待状态,处于这种状态的线程不会被分配CPU执行时间,不过无须无限期等待被其他线程显示地唤醒,在达到一定时间后它们会自动唤醒。

这个状态其实和WAITING状态差不多,只不过是多了一点,能设置线程等待的时间,超时会自动苏醒而已。

2.6 TERMINATED

终止状态,当线程的run()方法完成时,或者主线程的main()方法完成时,我们就认为它终止了。这个线程对象也许是活的,但是它已经不是一个单独执行的线程。线程一旦终止了,就不能复生,在一个终止的线程上调用start()方法,会抛出异常。

这里有一个小问题?我们该如何关闭一个线程呢?或许你会去找线程的API,你会看到有一个方法stop(),但很遗憾它是过期的,jdk不推荐我们使用它,具体原因下面讲解方法的时候会解释。

这里用一个例子来理解下以上这几个状态:

public class Test implements Runnable{

    private Object o = new Object();

    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName());
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        synchronized (o){
            System.out.println("synchronized:"+Thread.currentThread().getName());
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Test test = new Test();
        Thread thread1 = new Thread(test,"A");
        Thread thread2 = new Thread(test,"B");
        thread1.start();
        thread2.start();
    }
}

看以上两个线程,首先线程被new出来处于NEW状态,然后调用start()方法变成READY状态,然后线程1被调度变成RUNNING,线程2是READY状态。这里没有演示出两个线程互相争夺CPU调度,因为线程1是先start的,所以是线程1先获得调度,如果有同时多个线程start的话就会出现争抢调度的情况。

然后线程1执行run()方法,遇到sleep()变成WAITING,然后线程2变成RUNNING,执行run()方法,遇到sleep()变成WAITING。然后线程1醒来变成RUNNING,遇到同步代码块变成BLOCKED,然后线程2也醒来变成RUNNING,遇到同步代码块变成BLOCKED。

它们一起争抢锁,最终获得锁的线程变成READY,再变成RUNNING,最后是TERMINATED,而未获得锁的线程就会一直处于BLOCKED状态。


3.线程状态切换

我们都知道,线程中有许多方法,比如sleep()join()yield()等,这些方法其实本质上就是切换线程的状态而已。

  • suspend()resume()stop()

    对于这三个方法,大家可能会比较陌生,因为很少用,也因为它们是过期的。那它们有什么作用呢?通俗一点来说,它们分别对应我们播放视频中对应的暂停、恢复、停止的操作

    它们的功能看起来还行呀,挺人性化的,为什么会过期呢?主要原因是因为它们在切换线程状态的时候不能正常的释放资源,可能会引发一系列问题:

    • suspend()resume()
      调用suspend()方法后,线程不会释放已经占有的资源(比如锁),而是会占有资源进入别的线程状态,这样可能会导致死锁问题。

      那这样的话,这两个方法已经过期了,假如我们要使线程暂停恢复的话要怎么做呢?答案就是使用等待唤醒机制来代替这两个方法实现这个功能。

    • stop()
      调用stop)方法后,会终结一个线程但不会保证线程资源的正常释放,通常是没有给予线程释放资源的工作机会,就把它给杀死了,这样会导致程序出现一些意想不到的错误。

      同理,这个方法不能使用,那我们该如何正确的关闭线程呢?答案就是使用标志位来给线程一个标志,标志线程是否应该继续执行,具体做法有两种:一种是使用线程提供给我们的interrupt(),这个下面会细说;另一种就是我们自己手动写一个修改标志位的关闭方法,这里讲一下后面那种,看代码:

      public class Test{
          public static void main(String[] args) throws InterruptedException {
              Shutdown shutdown = new Shutdown();
              new Thread(shutdown).start();
              Thread.sleep(100);
              shutdown.shutdown();
          }
      
          private static class Shutdown implements Runnable{
              private long i;
              private volatile boolean stop = false;
      
              @Override
              public void run() {
                  while (!stop){
                      i++;
                  }
                  System.out.println(i);
              }
      
              public void shutdown(){
                  stop = true;
              }
          }
      }
      

      这里设置了一个状态标志位stop,当stop是false的时候,线程就会一直运行while循环,我们也设置了一个shutdown()方法,作用是把stop改为true,这样便可使线程退出while循环,从而关闭掉线程,这里要注意:线程不是马上就被关闭的,它还能执行while循环后面的一部分东西,所以假如我们要释放资源或者一些必须的操作就可以放在while循环下面,且状态标记量要用volatile修饰!!!


  • interrupt()interrupted()isinterrupted()

    承接上文,我们说我们要中断线程会设置一个状态标记量,线程其实就已经帮我们维护好了一个标记状态量了,我们可以使用这三个方法来调整和判断它的标记状态量,从而来中断线程,但这三个方法其实也挺复杂的,有很多细节需要我们注意。

    先来讲下这三个方法分别有什么作用:

    1. interrupt():其作用是中断此线程(此线程不一定是当前线程,而是指调用该方法的Thread实例所代表的线程),但实际上只是给线程设置一个中断标志,线程仍会继续运行。
    2. interrupted():注意这是Thread类的静态方法,用法和sleep()相同,作用是测试当前线程是否被中断(检查中断标志),返回一个boolean并清除中断状态,第二次再调用时中断状态已经被清除,将返回一个false,除非第一次调用这个方法后,在第二次调用之前又再次中断了线程一次,这样就会第二次调用就会返回true。
    3. isinterrupted():作用是只测试此线程是否被中断 ,不清除中断状态。

    或许你会很奇怪,用标志量来中断线程,为什么还要有我们手动设置一个或者使用 interrupt()两种方式?只用其中一种不香吗?答案只有一个,它们其实是有所不同的。

    来假设一种场景,假如一个线程在运行,然后运行一半他去睡觉了(sleep)或者去等待队列了(wait),这时要中断他,就会有两种处理结果:要么睡完再断,要么马上断别睡了。这里两种处理结果就分别对应我们的两种中断方式,很明显手动设置状态量是前者,interrupt()是后者。

    是的,当一个线程处于等待状态(这里是指wait、sleep、join方法,其他方法或许也可以,但没试过不敢妄下结论)时,是能被interrupt()打断的。下面看下细节:

    1. sleep()
      线程A正在使用sleep()暂停着,此时外面调用(比如主方法)threadA.interrupt()打断了线程A,线程A就会从等待状态中退出,然后直接抛出异常,且此时状态标记量依旧是false,即使它调用了interrupt()方法。

    2. wait()
      线程A调用了wait()进入了等待状态,也可以用interrupt()取消。不过这时候要注意锁定的问题。当对等待中的线程调用interrupt()时,会先重新获取锁后,再抛出异常。在获取锁定之前,是无法抛出异常的,标记量也依旧是false。

    3. join()
      当线程以join()等待其他线程结束时,当它被调用interrupt(),它与sleep()时一样,会马上跳到catch块里.。标记量也依旧是false。

    还有一个小细节:中断一个“已终止的线程”不会产生任何操作。结合以上特点,因为线程在等待状态被打断会抛出异常,所以中断线程的方法就和我们手动设置标记量有一丢丢不同,看一下代码:

    public class Test{
        public static void main(String[] args) throws InterruptedException {
            RunTest rt = new RunTest();
            Thread t = new Thread(rt);
            t.start();
            TimeUnit.SECONDS.sleep(2);
            t.interrupt();
        }
    }
    //不同的run方法设计
    class RunTest implements Runnable{
    	//适用于线程没有进入等待状态的情况
    	public void run1(){
    		//接收到中断信号时,由于while循环判断不成立退出
            while(!Thread.interrupted()){
                ...
            }
            System.out.println("正常退出");
        }
    
    	//错误方法
    	public void run2(){
    	        double d = 1;
    	        //接收到中断信号时,不会中断正在运行的操作,只有当操作完成后,检查中断状态时会退出
    	        while(!Thread.interrupted()){
    	            while(d<3000){
    	                d = d + (Math.PI+Math.E)/d;
    	            }
    	        }
    	        System.out.println("Exit "+d);
    	    }
    
        //最优写法,即使线程会进入等待状态也可以解决
        public void run3(){
            try{
                while(!Thread.interrupted()){
                   Thread.sleep(100);
                    //接收到中断信号时,由于while循环判断不成立退出,不抛出异常
                }
                System.out.println("正常退出");
            }catch(Exception e){
                System.out.println("被打断");
            }
        }
    
       //此种设计不好,try-catch要在while外
        public void run4(){
            while(!Thread.interrupted()){
                try{
                    TimeUnit.SECONDS.sleep(1);
                    //接收到中断信号,捕获异常并清除中断状态,所以不退出,所以这种不是良好的设计方式,如果想要退出,需要在catch语句中
                }catch(Exception e){
                    System.out.println("被打断");
                }
            }
            System.out.println("正常退出");
        }
    }
    

  • yield()

    礼让方法,这个方法比较简单,就是将一个正处于运行中的线程给切换成就绪状态,让出当前CPU执行权。

    但要注意一点:就是不一定礼让成功,也就是说不一定线程调用yield()就一定能成功让出CPU执行权,有可能在再次的竞争CPU执行权的时候这个线程又再次获胜yield()方法只是给了其他线程一个竞争的机会。


  • sleep()

    大家都很熟悉的一个方法了,作用大家应该都很清楚,但有一个细节需要注意:这是一个静态方法,作用是让当前线程沉睡,注意是当前线!!!

    接下来就是这个方法的核心:使线程进入超时等待状态,sleep方法只会释放CPU使用权,不会释放同步锁,也不会进入等待队列,醒来的时候进入就绪状态。


  • wait()notify()

    这两个方法也是老朋友了,承接上文我们说到的suspend()resume(),我们说他们已经过期,但是可以用等待唤醒机制来代替它们,这里的等待唤醒机制就是由wait()notify()两个方法组成的,下面先讲下这两个方法具体作用。

    • wait()wait(long mills)
      等待方法,将线程从运行状态转为等待状态或者超时等待状态,将线程置于等待队列中。且线程将释放同步锁,线程只有被通知唤醒或则被中断才能醒来。即释放CPU执行权,也释放同步锁。

    • notify()notifyAll()
      唤醒方法,网上很多说法是说随机唤醒,前面也已经讲过了,这里的“随机”是指根据不同的JDK版本可能会有不同的唤醒顺序,在JDK1.8,唤醒方法就是唤醒等待队列头的线程notifyAll() 就不必说了,唤醒所有线程。

      这里还要注意被notify()或者notifyAll()唤醒的线程,都会从wait()方法后开始执行,而不是重新执行,且执行的前提是要获得锁,被唤醒只是代表从等待队列进入了锁池队列。

    接下来讲一下这两个方法共有的两个特点:

    1. 这两个方法都要用在synchronized方法获得代码块里,否则会报错
      //必须要这样写
      Object o  = new Object();
          synchronized (o){
              o.wait();
          }
      
      //这样会报异常
      Object o  = new Object();
      o.wait();
      
    2. 注意一个细节,这两个方法并不属于线程,而是属于拥有锁的对象的,它们都是Object里的方法。所以它们是通过同一个对象来交互的,注意是同一个!!!假如调用o1.wait(),是无法用o2.notify()来唤醒的。

    最后来讲一下,这两个方法的实际运用:等待唤醒机制,也叫消费者生产者模式。

    • 等待唤醒机制(生产者消费者模式)
      先说下运用场景:有两个线程,其中线程1在工作前需要一些准备工作,只有准备工作做好才允许它运行,而线程2的工作就是来完成线程1的准备工作,于是就形成了以下这种机制:

      有两个角色生产者、消费者分别对应线程2、线程1,消费者消费之前要等生产者生成东西,这两个角色要分别满足以下原则:

      • 消费者
        1. 获取对象的锁
        2. 如果条件不满足,调用wait(),被通知后依然要检查条件(while循环)
        3. 条件满足执行消费者的逻辑
        synchronized(对象){
        	while(条件不满足){
        		对象.wait();
        	}	
        	doSomething(); //执行消费者逻辑
        }
        
      • 生产者
        1. 获取对象的锁
        2. 改变条件,使其满足消费者的启动条件
        3. 通知消费者
        synchronized(对象){
        	doSomething(); //改变条件
        	对象.notify();
        }
        

  • join()join(long mills)

    个人认为,这个是最难理解的方法了。它的作用就是插队!!!首先我们先来看下它的运用场景:两个线程AB,都调用start()方法了,这一点很重要,线程要想使用join()就必须已经启动了。然后现在是线程A在执行,然后线程B想插队的话,只需要在线程A中使用线程B调用join()就可以完成插队。

    看下代码,来理解下:

    public static void main(String[] args) throws Exception {
        System.out.println("start");
    
        Thread t = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                System.out.println(i);
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        t.start();
        t.join();
    
        System.out.println("end");
    }
    
    结果输出:
    	start
    	0
    	1
    	2
    	3
    	4
    	end
    

    线程t开始后,接着加入t.join()方法,t线程里面程序在主线程end输出之前全部执行完了,说明t.join()阻塞了主线程直到t线程执行完毕。如果没有t.join(),end可能会在0~5之间输出。

    OK,那join()方法的原理以及调用join()方法后线程的状态又发生了什么样的变化呢?先看下join()方法源码:

    public final synchronized void join(long millis) throws InterruptedException {
    	long base = System.currentTimeMillis();//获取当前时间
    	long now = 0;
    
    	if (millis < 0) {//判断不说了
    	    throw new IllegalArgumentException("timeout value is negative");
    	}
    
    	if (millis == 0) {//这个分支是无限期等待直到b线程结束
    		while (isAlive()) {
    			wait(0);
    		}
    	} else {//这个分支是等待固定时间,如果b没结束,那么就不等待了。。。
    		while (isAlive()) {
    			long delay = millis - now;
    			if (delay <= 0) {
    				break;
    			}
    			wait(delay);
    			now = System.currentTimeMillis() - base;
    		}
    	}
    }
    

    首先我们要注意到join()方法是用synchronized修饰的一个同步方法,且我们看它的源码,除去一些判断,join()方法其实就是等待唤醒机制。我们其实可以把代码看成以下这样,比较容易理解:

    public class Test{
    
    public static void main(String[] args) throws Exception {
        System.out.println("start");
    
        Thread t = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                System.out.println(i);
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        t.start();
    
        synchronized (t){
            while (t.isAlive()){
                t.wait();
            }
        }
    
        System.out.println("end");
    	}
    }
    

    使用上面那个例子来讲解一下,首先在主方法中开启了t线程,然后调用了t.join(),我们由源码已经知道join()方法是一个同步方法,那同步方法是用的哪个锁?this对象咯,那这里t.join()使用的this对象是谁?没错就是线程t这个对象,也就是说主线程获得了t线程这个对象的锁。

    while (isAlive()) {
    	wait(0);
    }
    

    再看源码,join()方法里那个循环,只要t线程活着,持有t线程对象锁的线程就要去等待,那这里说的持有t线程对象锁的线程是谁呢?没错正是main线程,所以main线程进入了等待状态。

    然后由于主线程进入了等待状态,于是它让出了CPU执行权,由于前面t线程已经开启了,所以这个时候就会执行t线程,这也是为什么线程的start()要写在join()前面的原因。

    这个时候你可能会问了,咦那又是怎么唤醒主线程的呢?t线程里面也没写唤醒主线程呀,怎么t线程执行完了主线程就醒了呢?这就是一个藏得很深的点了,这个是JVM底层的实现,一个线程结束的时候,就会自动调用一下notifyAll()的方法,所以这样t线程执行完,main线程也就醒了过来了。

    讲到这里应该就很清楚了吧,总结一下:假如有线程为A, join线程为B。 A不会释放已经持有的对象obj1、obj2…的对象锁。在同步代码块发生join的时候线程A请求获得线程B的对象锁,获得后再通过wait方法释放A持有的B锁,同时令A进入B的等待队列。最后线程B执行完毕,自动调用唤醒方法唤醒线程A。

    其实就相当于线程A获得了obj1的锁,然后进入同步代码块,在里面执行了B.join(),然后又获得了一个锁,这个锁是B,然后调用了B.wait()进入了B的等待队列里,等待被唤醒。

    还有一个小细节就是,线程A里面调用了线程B后,它是进入了等待状态,但是线程B执行完毕以后,它一般都会直接回到就绪状态,而不是阻塞状态,因为线程A所存在的等待队列的锁是线程B的,一般这个锁只给了线程A,也就是说一般只有线程A会获得线程B对象锁,所以它无需竞争直接进入就绪状态。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值