Java 生产者消费者案例——等待唤醒机制、虚假唤醒

多线程知识点

  • synchronized修饰在成员方法上,其实相当于synchronized(this) { ... },即锁在当前对象的monitor上。
public synchronized void sync {
	/* process */
}

// 相当于下面

public void sync {
	synchronized(this) {
		/* process */
	}
}
  • 在synchronized方法里,或者synchronized同步代码块中,只要正在执行中,就说明currentThread已经获得当前对象的monitor了。
  • 当synchronized方法或synchronized同步代码块 结束,currentThread会自动释放 对象的monitor。
  • wait() \ notify() \ notifyAll()这三方法必须在synchronized方法或synchronized同步代码块中执行,因为这样就说明currentThread已经获得了对象的monitor。
    • wait()使得当前线程释放已获得的对象monitor,并陷入一种等待。这种等待必须依靠别的获得同一个对象monitor的线程来调用notify() \ notifyAll()才会重新唤醒,但重新唤醒后需要继续执行没执行完的同步代码,而执行同步代码的前提是获得被调用成员方法的对象的monitor。所以,一个被notify() \ notifyAll()的调用而从wait()中被唤醒后的线程,是不一定会马上执行wait()的下一句代码的,因为它需要和其他竞争同一个对象monitor的线程进行竞争,如果竞争失败了,那么该线程还是只有阻塞在wait()这里,直到它竞争到对象monitor。
    • notify()使得wait在同一个对象monitor上的某一个线程被唤醒。另外,synchronized代码执行完毕后,会释放对象monitor,当然,这一点跟notify()无关,因为本来就是这样。
    • notifyAll()使得wait在同一个对象monitor上的所有线程被唤醒。

生产者消费者案例

未使用等待唤醒机制

// 店员类:负责进货和售货
class Clerk{
    private int num = 0; //店里当前的货物量

    public synchronized void get() { //店员进货  每次进货一个(生产者)
        if(num >= 10) {
            System.out.println(Thread.currentThread().getName()+" 库存已满,无法进货");
        } else {
            System.out.println(Thread.currentThread().getName()+" : "+ (++num));
        }
    }

    public synchronized void sale() { //店员卖货 每次卖掉一个货(消费者)
        if(num<=0) {
            System.out.println(Thread.currentThread().getName()+" 库存已空,无法卖货");
        }else {
            System.out.println(Thread.currentThread().getName()+" : "+(--num));
        }
    }
}

// 生产者 可以有很多生产者卖货给这个店员
class Producer implements Runnable{
    private Clerk clerk;

    public Producer(Clerk clerk) {
        this.clerk=clerk;
    }

    @Override
    public void run() {
        for (int i = 0; i<20; i++) {
            clerk.get();
        }
    }
}

//消费者:可以很多消费者找店员买货
class Consumer implements Runnable{
    private Clerk clerk;

    public Consumer(Clerk clerk) {
        this.clerk=clerk;
    }

    @Override
    public void run() {
        for (int i = 0; i<20; i++) {
            clerk.sale();
        }
    }
}

public class TestProductorAndConsumer {
    public static void main(String[] args) {
        Clerk clerk=new Clerk();

        Producer producer=new Producer(clerk);
        Consumer consumer=new Consumer(clerk);

        new Thread(producer,"生产者A").start();
        new Thread(consumer,"消费者B").start();
    }
}
  • Clerk类有两个同步的成员方法,且主函数中只创建了一个Clerk对象。现在,不管是get()还是sale()方法,在同一个时刻,只能有一个线程在执行get()sale()中的某一行代码,因为线程执行get()sale()中的某一行代码的前提是,当前线程获得了Clerk对象的monitor。
    • get()供生产者调用,用一个成员变量num来模拟 生产者消费者使用的消费队列,那么这个模拟队列的大小为10,即最多同时生产10个商品。当队列元素个数为10时,不能继续生产,之后打印出无法生产的信息。
    • sale()供消费者调用,当队列元素个数为0时,不能继续消费,之后打印出无法消费的信息。
  • Producer类的run方法执行20次get()。Consumer类的run方法执行20次sale()。两个线程竞争同一个Clerk对象的monitor。
  • 创建一个生产者和一个消费者,持有同一个Clerk对象。

接下来看看执行结果,由于多线程的不确定性,执行结果可能不同。

生产者A : 1
消费者B : 0
消费者B 库存已空,无法卖货
消费者B 库存已空,无法卖货
消费者B 库存已空,无法卖货
消费者B 库存已空,无法卖货
消费者B 库存已空,无法卖货
消费者B 库存已空,无法卖货
消费者B 库存已空,无法卖货
消费者B 库存已空,无法卖货
消费者B 库存已空,无法卖货
消费者B 库存已空,无法卖货
消费者B 库存已空,无法卖货
消费者B 库存已空,无法卖货
消费者B 库存已空,无法卖货
消费者B 库存已空,无法卖货
消费者B 库存已空,无法卖货
消费者B 库存已空,无法卖货
消费者B 库存已空,无法卖货
消费者B 库存已空,无法卖货
消费者B 库存已空,无法卖货
生产者A : 1
生产者A : 2
生产者A : 3
生产者A : 4
生产者A : 5
生产者A : 6
生产者A : 7
生产者A : 8
生产者A : 9
生产者A : 10
生产者A 库存已满,无法进货
生产者A 库存已满,无法进货
生产者A 库存已满,无法进货
生产者A 库存已满,无法进货
生产者A 库存已满,无法进货
生产者A 库存已满,无法进货
生产者A 库存已满,无法进货
生产者A 库存已满,无法进货
生产者A 库存已满,无法进货

可见生产者和消费者在得知无法继续生产或消费后,还是继续执行了生产或消费,这是不正确的行为。

使用等待唤醒机制

// 店员类:负责进货和售货
class Clerk{
    private int num = 0; //店里当前的货物量

    public synchronized void get() { //店员进货  每次进货一个(生产者)
        if(num >= 10) {
            System.out.println(Thread.currentThread().getName()+" 库存已满,无法进货");
            try {
                this.wait();
                System.out.println(Thread.currentThread().getName()+" wait后剩余步骤");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        } else {
            System.out.println(Thread.currentThread().getName()+" : "+ (++num));
            this.notifyAll();
        }
    }

    public synchronized void sale() { //店员卖货 每次卖掉一个货(消费者)
        if(num<=0) {
            System.out.println(Thread.currentThread().getName()+" 库存已空,无法卖货");
            try {
                this.wait();
                System.out.println(Thread.currentThread().getName()+" wait后剩余步骤");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }else {
            System.out.println(Thread.currentThread().getName()+" : "+(--num));
            this.notifyAll();
        }
    }
}

// 生产者 可以有很多生产者卖货给这个店员
class Producer implements Runnable{
    private Clerk clerk;

    public Producer(Clerk clerk) {
        this.clerk=clerk;
    }

    @Override
    public void run() {
        for (int i = 0; i<20; i++) {
            clerk.get();
        }
    }
}

//消费者:可以很多消费者找店员买货
class Consumer implements Runnable{
    private Clerk clerk;

    public Consumer(Clerk clerk) {
        this.clerk=clerk;
    }

    @Override
    public void run() {
        for (int i = 0; i<20; i++) {
            clerk.sale();
        }
    }
}

public class TestProductorAndConsumer {
    public static void main(String[] args) {
        Clerk clerk=new Clerk();

        Producer producer=new Producer(clerk);
        Consumer consumer=new Consumer(clerk);

        new Thread(producer,"生产者A").start();
        new Thread(consumer,"消费者B").start();
    }
}
  • Clerk类有两个同步的成员方法,加入了等待唤醒操作。
    • get()供生产者调用。当队列元素个数为10时,不能继续生产,且马上wait,进入等待状态;当队列元素个数<10时,生产一个商品加入队列,并notifyAll,为的是唤醒因队列为空而等待的消费者线程。
    • sale()供消费者调用,当队列元素个数为0时,不能继续消费,且马上wait,进入等待状态;当队列元素个数>0时,从队列中消费一个商品,并notifyAll,为的是唤醒因队列已满而等待的生产者线程。

接下来看看执行结果,由于多线程的不确定性,执行结果可能不同。

生产者A : 1
生产者A : 2
生产者A : 3
生产者A : 4
生产者A : 5
生产者A : 6
生产者A : 7
生产者A : 8
生产者A : 9
生产者A : 10
生产者A 库存已满,无法进货  #生产者等待在wait处,并释放锁。接下来只有消费者在竞争锁。
消费者B : 9  #这里消费者肯定能得到锁,并从函数开始处执行。打印完说明,生产者已经从wait处唤醒了,但接下来执行谁,得看竞争锁的情况。
消费者B : 8  #生产者没有竞争到锁,所以没有继续往下执行。因为被消费者竞争到锁了。
消费者B : 7
消费者B : 6
消费者B : 5
消费者B : 4
消费者B : 3
消费者B : 2
消费者B : 1
消费者B : 0
消费者B 库存已空,无法卖货  #消费者等待在wait处,并释放锁。接下来只有生产者在竞争锁。
生产者A wait后剩余步骤  #这里生产者肯定能得到锁,并从wait处执行。此句没有执行notify,消费者还是处于等待,所以下一句肯定还是生产者执行
生产者A : 1  #这里生产者肯定能得到锁,并从函数开始处执行。打印完说明,消费者已经从wait处唤醒了,但接下来执行谁,得看竞争锁的情况。
生产者A : 2  #消费者没有竞争到锁,所以没有继续往下执行。因为被生产者竞争到锁了。
生产者A : 3
生产者A : 4
生产者A : 5
生产者A : 6
生产者A : 7
生产者A : 8
生产者A : 9  #生产者线程结束,自动释放锁。接下来只有消费者在竞争锁。
消费者B wait后剩余步骤  #这里消费者肯定能得到锁,并从wait处执行。
消费者B : 8
消费者B : 7
消费者B : 6
消费者B : 5
消费者B : 4
消费者B : 3
消费者B : 2
消费者B : 1
消费者B : 0

消费队列大小为1

修改get()方法里这句为if(num >= 1) {

接下来看看执行结果,由于多线程的不确定性,执行结果可能不同。注意,此时执行程序,程序可能不会停止,下面为程序未停止时的打印效果:

...
生产者A : 1
生产者A 库存已满,无法进货   #生产者wait,释放锁
消费者B wait后剩余步骤      #此句必为消费者执行。消费者从wait唤醒
消费者B : 0                #此句必为消费者执行。此句过后,生产者已从wait处唤醒,接下来执行谁不一定。
消费者B 库存已空,无法卖货  #消费者竞争到锁,然后消费者wait,释放锁。
生产者A wait后剩余步骤     #此句必为生产者执行。生产者从wait唤醒。生产者所有循环执行完毕,生产者线程结束。
#接下来无人唤醒消费者

看来程序未停止,是因为有个线程在wait状态,且无人唤醒它造成的。使用IDEA的Dump Threads功能看一下,果然:
在这里插入图片描述
抽取关键信息:

  • "消费者B"线程处于java.lang.Thread.State: WAITING (on object monitor)
  • waiting on <0x0000000740b8a300> (a Clerk),是wait在一个Clerk的monitor上的。
  • at Clerk.sale(TestProductorAndConsumer.java:24)这行代码是sale函数的this.wait();处,说明"消费者B"线程wait在此处了。

与上一章节的运行结果对比,可发行此章节代码程序未停止的原因是:先结束的线程,最后的操作没有执行notifyAll(从打印结果看到,先结束的是生产者线程),所造成的。

让notifyAll永远都执行

为了解决上一章节的问题(保持消费队列的大小为1),把两个同步方法里的else分支去掉,变成永远执行notifyAll的逻辑。程序永远也不会出现 run后程序不停止的情况了。

// 店员类:负责进货和售货
class Clerk{
    private int num = 0; //店里当前的货物量

    public synchronized void get() { //店员进货  每次进货一个(生产者)
        if(num >= 1) {  //这里改成10,同样出现问题
            System.out.println(Thread.currentThread().getName()+" 库存已满,无法进货");
            try {
                this.wait();
                System.out.println(Thread.currentThread().getName()+" wait后剩余步骤");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println(Thread.currentThread().getName()+" : "+ (++num));
        this.notifyAll();
    }

    public synchronized void sale() { //店员卖货 每次卖掉一个货(消费者)
        if(num<=0) {
            System.out.println(Thread.currentThread().getName()+" 库存已空,无法卖货");
            try {
                this.wait();
                System.out.println(Thread.currentThread().getName()+" wait后剩余步骤");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println(Thread.currentThread().getName()+" : "+(--num));
        this.notifyAll();
    }
}

多个生产者/消费者——虚假唤醒

// 店员类:负责进货和售货
class Clerk{
    private int num = 0; //店里当前的货物量

    public synchronized void get() { //店员进货  每次进货一个(生产者)
        if(num >= 1) {
            System.out.println(Thread.currentThread().getName()+" 库存已满,无法进货");
            try {
                this.wait();
                System.out.println(Thread.currentThread().getName()+" wait后剩余步骤");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println(Thread.currentThread().getName()+" : "+ (++num));
        this.notifyAll();
    }

    public synchronized void sale() { //店员卖货 每次卖掉一个货(消费者)
        if(num<=0) {
            System.out.println(Thread.currentThread().getName()+" 库存已空,无法卖货");
            try {
                this.wait();
                System.out.println(Thread.currentThread().getName()+" wait后剩余步骤");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println(Thread.currentThread().getName()+" : "+(--num));
        this.notifyAll();
    }
}

// 生产者 可以有很多生产者卖货给这个店员
class Producer implements Runnable{
    private Clerk clerk;

    public Producer(Clerk clerk) {
        this.clerk=clerk;
    }

    @Override
    public void run() {
        for (int i = 0; i<20; i++) {
            clerk.get();
        }
    }
}

//消费者:可以很多消费者找店员买货
class Consumer implements Runnable{
    private Clerk clerk;

    public Consumer(Clerk clerk) {
        this.clerk=clerk;
    }

    @Override
    public void run() {
        for (int i = 0; i<20; i++) {
            clerk.sale();
        }
    }
}

public class TestProductorAndConsumer {
    public static void main(String[] args) {
        Clerk clerk=new Clerk();

        Producer producer=new Producer(clerk);
        Consumer consumer=new Consumer(clerk);
        Producer producer2=new Producer(clerk);
        Consumer consumer2=new Consumer(clerk);

        new Thread(producer,"生产者A").start();
        new Thread(consumer,"消费者B").start();
        new Thread(producer2,"生产者C").start();
        new Thread(consumer2,"消费者D").start();
    }
}

可能的运行结果:

...
生产者C : 1  #其他线程已被notify。接下来执行谁不一定
生产者C 库存已满,无法进货  #被生产者C竞争到了,但马上wait了。接下来除了生产者C的三个线程都在竞争,接下来执行谁不一定
消费者B wait后剩余步骤  #消费者B竞争到锁,执行剩余操作notifyAll。接下来四个线程都在竞争,接下来执行谁不一定
消费者B : 0  #消费者B竞争到锁,执行剩余操作notifyAll。接下来四个线程都在竞争,接下来执行谁不一定
消费者B 库存已空,无法卖货  #被消费者B竞争到了,但马上wait了。接下来除了消费者B的三个线程都在竞争,接下来执行谁不一定
消费者D wait后剩余步骤  #消费者D竞争到锁,执行剩余操作notifyAll。接下来四个线程都在竞争,接下来执行谁不一定
消费者D : -1  #消费者D竞争到锁,执行剩余操作notifyAll。接下来四个线程都在竞争,接下来执行谁不一定。(注意,这里出现了虚假唤醒)
生产者A wait后剩余步骤
生产者A : 0
消费者B wait后剩余步骤
消费者B : -1  #虚假唤醒
生产者C wait后剩余步骤
生产者C : 0

通过上面分析,可见虚假唤醒是怎么来的了。其实原因是:

  • 虽然notifyAll能唤醒处于wait的线程,且刚唤醒时,此时还是“真实唤醒”呢。在调用notifyAll的线程释放锁之前(即执行完synchronized代码),条件变量还是处于可执行状态的。
  • 但从wait处唤醒的线程,还需要同其他线程竞争锁以继续执行。这些线程可能包括:
    • 因在synchronized代码开始处,由于没有获得到锁的,而阻塞在synchronized代码开始处的线程。
    • 因之前的notifyAll,而从wait处唤醒的其他线程。
  • 如果被唤醒的线程和其他与它竞争的线程,接下来要做的是同一件事(都是消费动作、或生产动作),且任一线程执行完动作后,都会使得条件变量发生改变,从而使得该动作不应该再执行了,那么此时就会造成虚假唤醒。
  • 如果改成调用notify,同样可能产生虚假唤醒。不过由于notify只随机唤醒一个wait的线程,所以造成虚假唤醒的竞争线程只可能是:
    • 因在synchronized代码开始处,由于没有获得到锁的,而阻塞在synchronized代码开始处的线程。

多个生产者/消费者——解决虚假唤醒

将上一章节代码修改Clerk类,将if判断,变成while循环,则可以解决虚假唤醒问题。其实说到底“虚假”这个概念,只是我们程序员方定义的,它是指线程在条件不满足的情况下继续执行了某个动作。

按照上一章节的打印结果,如果改成while循环,那么消费者D线程打印出“wait后剩余步骤”,继续下一次循环,发现条件不满足,然后就wait了。

// 店员类:负责进货和售货
class Clerk{
    private int num = 0; //店里当前的货物量

    public synchronized void get() { //店员进货  每次进货一个(生产者)
        while(num >= 1) {
            System.out.println(Thread.currentThread().getName()+" 库存已满,无法进货");
            try {
                this.wait();
                System.out.println(Thread.currentThread().getName()+" wait后剩余步骤");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println(Thread.currentThread().getName()+" : "+ (++num));
        this.notifyAll();
    }

    public synchronized void sale() { //店员卖货 每次卖掉一个货(消费者)
        while(num<=0) {
            System.out.println(Thread.currentThread().getName()+" 库存已空,无法卖货");
            try {
                this.wait();
                System.out.println(Thread.currentThread().getName()+" wait后剩余步骤");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println(Thread.currentThread().getName()+" : "+(--num));
        this.notifyAll();
    }
}
生产者A : 1
生产者A 库存已满,无法进货
消费者D : 0
消费者D 库存已空,无法卖货
消费者B 库存已空,无法卖货
生产者C : 1
生产者C 库存已满,无法进货
消费者B wait后剩余步骤
消费者B : 0
消费者B 库存已空,无法卖货
消费者D wait后剩余步骤  #此句是虚假唤醒,此句把当前while循环执行完。接下来执行下一次while循环
消费者D 库存已空,无法卖货  #下一次while循环发现条件不满足,检测到虚假唤醒,所以又wait了
...

使用Lock/Condition

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

// 店员类:负责进货和售货
class Clerk{
    private int num = 0; //店里当前的货物量
    private Lock lock = new ReentrantLock();
    private Condition condition = lock.newCondition();

    public void get() { //店员进货  每次进货一个(生产者)
        lock.lock();

        try {
            while(num >= 1) {
                System.out.println(Thread.currentThread().getName()+" 库存已满,无法进货");
                try {
                    condition.await();
                    System.out.println(Thread.currentThread().getName()+" wait后剩余步骤");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println(Thread.currentThread().getName()+" : "+ (++num));
            condition.signalAll();
        } finally {
            lock.unlock();
        }
    }

    public synchronized void sale() { //店员卖货 每次卖掉一个货(消费者)
        lock.lock();

        try {
            while(num<=0) {
                System.out.println(Thread.currentThread().getName()+" 库存已空,无法卖货");
                try {
                    condition.await();
                    System.out.println(Thread.currentThread().getName()+" wait后剩余步骤");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println(Thread.currentThread().getName()+" : "+(--num));
            condition.signalAll();
        } finally {
            lock.unlock();
        }
    }
}

// 生产者 可以有很多生产者卖货给这个店员
class Producer implements Runnable{
    private Clerk clerk;

    public Producer(Clerk clerk) {
        this.clerk=clerk;
    }

    @Override
    public void run() {
        for (int i = 0; i<20; i++) {
            clerk.get();
        }
    }
}

//消费者:可以很多消费者找店员买货
class Consumer implements Runnable{
    private Clerk clerk;

    public Consumer(Clerk clerk) {
        this.clerk=clerk;
    }

    @Override
    public void run() {
        for (int i = 0; i<20; i++) {
            clerk.sale();
        }
    }
}

public class TestProductorAndConsumer {
    public static void main(String[] args) {
        Clerk clerk=new Clerk();

        Producer producer=new Producer(clerk);
        Consumer consumer=new Consumer(clerk);
        Producer producer2=new Producer(clerk);
        Consumer consumer2=new Consumer(clerk);

        new Thread(producer,"生产者A").start();
        new Thread(consumer,"消费者B").start();
        new Thread(producer2,"生产者C").start();
        new Thread(consumer2,"消费者D").start();
    }
}
  • 相比synchronized,使用Lock时,需要显式地获得锁和释放锁。这样,终于脱离了代码块的束缚。
  • 在调用Condition对象的await() \ signal() \ signalAll()时,此时线程必须已获得了与该Condition相关联的Lock对象的锁。
  • 同样的,从await处唤醒的线程,还需要竞争到锁(但这里的竞争不需要显式的lock.lock();),才能继续执行。

使用Lock/Condition——多条件

按照上一章节代码修改代码:

class Clerk{
    private int num = 0; //店里当前的货物量
    private Lock lock = new ReentrantLock();
    private Condition Emptycondition = lock.newCondition();
    private Condition Fullcondition = lock.newCondition();

    public void get() { //店员进货  每次进货一个(生产者)
        lock.lock();

        try {
            while(num >= 1) {
                System.out.println(Thread.currentThread().getName()+" 库存已满,无法进货");
                try {
                    Fullcondition.await();
                    System.out.println(Thread.currentThread().getName()+" wait后剩余步骤");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println(Thread.currentThread().getName()+" : "+ (++num));
            Emptycondition.signalAll();
        } finally {
            lock.unlock();
        }
    }

    public synchronized void sale() { //店员卖货 每次卖掉一个货(消费者)
        lock.lock();

        try {
            while(num<=0) {
                System.out.println(Thread.currentThread().getName()+" 库存已空,无法卖货");
                try {
                    Emptycondition.await();
                    System.out.println(Thread.currentThread().getName()+" wait后剩余步骤");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println(Thread.currentThread().getName()+" : "+(--num));
            Fullcondition.signalAll();
        } finally {
            lock.unlock();
        }
    }
}
  • 现在,通过抽取两个Condition出来,使得逻辑更加清晰了。这样,让signalAll唤醒的线程更加明确了。
  • Fullcondition.await();调用时,说明此时队列已满,当前线程等待在了Fullcondition这个满条件上;Fullcondition.signalAll();调用时,说明此时队列至少有一个空位了,即此时Fullcondition不满足了,且只唤醒之前wait在Fullcondition这个条件上。
  • Emptycondition.await();调用时,说明此时队列已空,当前线程等待在了Emptycondition这个空条件上;Emptycondition.signalAll();调用时,说明此时队列至少有一个元素了,即此时Emptycondition不满足了,且只唤醒之前wait在Emptycondition这个条件上。
  • 当然,这里还有个变量命名的问题。比如Fullcondition和Emptycondition的名字是不是应该互换,我取名现在是按照,await时符合变量名,signalAll时不符合变量名,来命名的。
  • 即使是分成两个Condition了,但由于两个Condition都是由一个Lock来的,所以,同一时刻,只能有一个线程获得了这个Lock。
  • 3
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值