java基础 - synchronized关键字

线程同步机制,主要是用来保障多个线程对同一份数据进行访问和修改的协同性和有序性,避免数据错误引发的线程安全问题。

一个简单的线程安全问题示例:

package org.example.thread;

public class ThreadTest {

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

        BookShelf bookShelf = new BookShelf();

        Producer producer1 = new Producer(bookShelf);
        Producer producer2 = new Producer(bookShelf);

        new Thread(producer1).start();
        new Thread(producer2).start();

        Thread.sleep(3000);

        System.out.println(bookShelf.getNumber());
    }
}

class Producer implements Runnable {

    private final BookShelf bookShelf;

    public Producer(BookShelf bookShelf) {
        this.bookShelf = bookShelf;
    }

    @Override
    public void run() {
        for (int i = 0; i < 2000; i++) {
            bookShelf.produce();
        }
    }
}

class BookShelf {

    private int num;

    public void produce() {
        num++;
    }

    public int getNumber() {
        return num;
    }
}

示例中,两个生产者线程共用一个 Bookshelf 书架类对象,分别往里面放2000本书(num变量自增2000次),等待任务结束后获取变量num的值,发现结果不稳定,而且总是小于预期的4000。

这是因为两个线程同时在对变量num进行自增,而自增本身并不是一个原子操作,而是一个可分割的过程:
线程从主内存中读取共享变量,在线程自己的工作内存生成一个副本 -> 在工作内存中计算加1 -> 把结果返回给主内存,更新共享变量的值。
假设线程A读到了1,还没有修改或者修改后还没写回到内存,此时恰好线程B修改成了2并返回,那么当A改完再返回的时候,实际上是无效的,所以最后会出现小于预期值的结果。

线程同步以及线程间通信

synchronized关键字

synchronized是一种锁机制,它通常用来修饰方法或者代码块,是一种对象锁(类的具体对象或者类本身的Class对象)。

多线程间竞争synchronized锁的本质,是竞争与该对象所关联的对象监视器(ObjectMonitor)的占有权,而Monitor的地址就保存在对象头中。所以通常说synchronized是对象锁,或者监视器锁。

ObjectMonitor,JVM底层使用c++语言实现,用来控制线程同步。ObjectMonitor的核心属性包括 _owner(当前的拥有者线程)、_EntryList(保存竞争锁失败而进入阻塞的线程,即通常说的锁池、同步/阻塞队列)、_WaitSet(保存处于等待状态的线程,即通常说的等待池、等待队列)等。

常见用法

1、修饰非静态方法,锁住类的具体对象。示例代码:

package org.example.thread;

public class ThreadTest {

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

        BookShelf bookShelf = new BookShelf();

        Producer producer1 = new Producer(bookShelf);
        Producer producer2 = new Producer(bookShelf);

        new Thread(producer1).start();
        new Thread(producer2).start();

        Thread.sleep(3000);

        System.out.println(bookShelf.getNumber());
    }
}

class Producer implements Runnable {

    private final BookShelf bookShelf;

    public Producer(BookShelf bookShelf) {
        this.bookShelf = bookShelf;
    }

    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + "开始处理");
        for (int i = 0; i < 2000; i++) {
            bookShelf.produce();
        }
        System.out.println(Thread.currentThread().getName() + "处理完毕");
    }
}

class BookShelf {

    private int num;

    //锁住当前调用该方法的BookShelf对象
    public synchronized void produce() {
        num++;
    }

    public int getNumber() {
        return num;
    }
}

这里synchronized关键字修饰了produce()方法,也就是说每次调用 bookShelf.produce()时,都是先获取了bookShelf的对象锁,方法执行完毕后把锁释放掉,下次循环需要再调用 bookShelf.produce()时,再去申请锁,周而复始。

2、修饰代码块。将线程中的run方法修改一下:

    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + "开始处理");
        //要想往下执行,先拿到bookShelf对象的锁
        synchronized (bookShelf) {
            for (int i = 0; i < 2000; i++) {
                bookShelf.produce();
            }
        }
        System.out.println(Thread.currentThread().getName() + "处理完毕");
    }

这里synchronized修饰一块代码,括号中表明了它要锁的是 bookShelf这个对象,可以保证每个线程完整调用bookShelf.produce() 2000次后,下个线程才能进来继续调用。
这里稍微扩大了一点加锁范围,因为bookShelf.produce()只有一行,不方便演示锁住代码段。
当然如果要锁的就是当前类的对象,括号中可以用this关键字代替。

3、修饰类的静态方法,或者synchronized (XXX.class)修饰代码块,来锁住类本身的Class对象。
将示例代码中BookShelf类的方法改成静态方法,同时让两个线程不再共用同一个BookShelf对象:

package org.example.thread;

public class ThreadTest {

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

        BookShelf bookShelf1 = new BookShelf();
        BookShelf bookShelf2 = new BookShelf();

        Producer producer1 = new Producer(bookShelf1);
        Producer producer2 = new Producer(bookShelf2);

        new Thread(producer1).start();
        new Thread(producer2).start();

        Thread.sleep(3000);

        System.out.println(BookShelf.getNumber());
    }
}

class Producer implements Runnable {

    private final BookShelf bookShelf;

    public Producer(BookShelf bookShelf) {
        this.bookShelf = bookShelf;
    }

    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + "开始处理");

        for (int i = 0; i < 2000; i++) {
            BookShelf.produce();
        }

        System.out.println(Thread.currentThread().getName() + "处理完毕");
    }
}

class BookShelf {

    private int origin;

    private static int num;

    public static void produce() {
        num++;
    }

    public static int getNumber() {
        return num;
    }
}

很显然这里再对具体的BookShelf对象加锁是没有意义的,我们需要对 BookShelf 的Class类对象加锁,也就是实现类锁。加锁方式如下:

1)修饰静态方法

    public static synchronized void produce() {
        num++;
    }

2)修饰代码段(这里同样为了方便演示,稍微扩大加锁范围,并没有只锁produce方法)

    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + "开始处理");

        synchronized (BookShelf.class) {
            for (int i = 0; i < 2000; i++) {
                BookShelf.produce();
            }
        }

        System.out.println(Thread.currentThread().getName() + "处理完毕");
    }

线程间通信(等待/通知机制)

仅仅使用synchronized关键字可以实现线程同步,但是未免有些死板,同步代码块在工作的过程中不会释放锁,别的线程的工作也无法插入进来,所以可以使用synchronized关键字配合Object对象的wait()、notify()、notifyAll()等方法,实现线程间更加灵活的协作。

线程中调用对象的wait()方法, 会立即使当前线程释放掉该对象的锁,进入等待阻塞状态,直到其他线程中调用该对象的notify() 或 notifyAll()方法,才能将当前线程再次唤醒,去竞争对象锁完成后面的操作。

notify()方法,唤醒一个由于调用了wait方法而进入等待阻塞状态的线程,使其从等待队列进入到参与锁竞争的同步队列中。notify方法只是发出一个信号,用来唤醒其他线程,并不会影响当前线程同步代码块的执行,以及锁的释放。

notifyAll()方法,唤醒所有等待阻塞状态的线程,参与对象锁的竞争。

调用对象wait()、notify()、notifyAll()等方法的前提,是当前线程必须持有该对象的锁(所谓的持锁,本质是占有该对象的对象监视器),所以为了避免错误使用,java语言强制要求这些方法必须在synchronized同步代码段中使用。

使用示例如下:

package org.example.thread;

public class ThreadTest {

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

        BookShelf bookShelf = new BookShelf();

        Producer producer1 = new Producer(bookShelf);
        Producer producer2 = new Producer(bookShelf);

        new Thread(producer1).start();
        new Thread(producer2).start();
    }
}

class Producer implements Runnable {

    private final BookShelf bookShelf;

    public Producer(BookShelf bookShelf) {
        this.bookShelf = bookShelf;
    }

    @Override
    public void run() {

        synchronized (bookShelf) {
            while (bookShelf.getNumber() < 10) {

                bookShelf.produce();
                System.out.println(Thread.currentThread().getName() + "报数:" + bookShelf.getNumber());
                bookShelf.notify();

                if (bookShelf.getNumber() < 10) {
                    try {
                        bookShelf.wait();
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                }
            }
        }
    }
}

class BookShelf {

    private int num;

    public void produce() {
        num++;
    }

    public int getNumber() {
        return num;
    }
}

实现功能:两个线程从1到10轮流进行报数。线程A拿到锁后,循环判断当前数字是否到10,如果没到就加1,然后调用notify通知正在等待的线程,如果还没有到10,就调用wait方法等待。线程B拿到锁后同样执行以上操作,唤醒线程A,如此交替执行,直到最后数字为10,跳出循环。

其他控制线程的方法

join()方法
线程中调用其他线程对象的join()方法,会使当前线程进入等待阻塞状态,待其他线程执行完毕后,当前线程才被唤醒,继续执行。
使用示例:

package org.example.thread;

public class ThreadTest {

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

        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 5; i++) {
                    System.out.println("这里是子线程");
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                }
            }
        });

        thread.start();
        thread.join();

        System.out.println("这里是父线程");
    }
}

运行结果:
在这里插入图片描述
本来主线程和内部的线程thread是互不干涉的,但是当主线程中执行了 thread.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) {
            while (isAlive()) {
                wait(0);
            }
        } else {
            while (isAlive()) {
                long delay = millis - now;
                if (delay <= 0) {
                    break;
                }
                wait(delay);
                now = System.currentTimeMillis() - base;
            }
        }
    }

程序中的join()方法,默认按照join(0)进行处理,这是一个synchronized关键字修饰的方法,也就是说主线程中调用 thread.join()方法时,要先获得thread这个对象的锁,然后走到 while (isAlive()) {wait(0);} 这段代码时,循环判断thread线程是否存活,如果存活则 调用thread对象的wait(0)方法,参数0表示没有时间限制,一直等待。
需要注意的是,wait方法是使持有当前对象锁的线程进入等待状态,这里持有thread线程对象锁的是主线程,所以主线程进入等待。
我们知道wait方法进入等待状态,必须要有对应的notify 或者 notifyAll方法来将其唤醒,这里相关的操作不在JDK代码中,而是在JVM底层的native代码中,在线程运行结束,死亡之前,会隐式调用notifyAll方法唤醒所有等待该对象锁的线程。

yield()方法
yield()是Thread类的静态方法,它的作用在JDK文档中有说明:
提示调度器当前线程愿意让出CPU资源,但是调度器可能会忽略这个提示。也就是说,是否让出CPU资源,以及何时让出CPU资源都是不确定的,需要服从系统调度。即使让出了CPU资源,该线程也会和其他线程(同优先级或更高)再次竞争,有可能结果还是该线程得到CPU资源然后继续运行,所以这个让步是不稳定的。
从线程状态的角度来看,yield()方法只是使线程从运行态转移到了就绪状态,而从锁的角度看,让步动作只是让出CPU资源,和锁无关,并不会释放锁。

sleep(long millis)方法
sleep(long millis)方法是Thread类的静态方法,调用后当前线程进入超时等待(TIMED_WAITING)状态,等待超时后线程重新进入就绪状态等待CPU调度,该方法不会释放锁。
sleep方法响应InterruptedException中断异常,捕获异常后会清空当前线程的中断标志位(调用isInterrupted()方法返回false)。

interrupt()方法
interrupt()方法用来中断线程,调用后会对当前线程设置一个中断标志位(调用isInterrupted()方法返回true),而非直接中断线程。
对于调用了wait / join / sleep方法进入等待或超时等待状态的线程,会接收到一个InterruptedException,并且自动清除中断标志位,我们需要做的是捕获这个异常,作出相应的处理。
对于正在运行中的线程,我们需要在代码中手动去判断中断标志位,做出相应的处理,否则程序将不受影响,保持原有逻辑运行。

中断运行中线程的示例:

package org.example.thread;

public class ThreadTest {

    public static void main(String[] args) {

        MyThread myThread = new MyThread();
        Thread thread = new Thread(myThread);
        thread.start();

        try {
            Thread.sleep(1);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        thread.interrupt();
    }
}

class MyThread implements Runnable {
    @Override
    public void run() {
        int n = 0;
        while (!Thread.currentThread().isInterrupted()) {
            n++;
            if (n == 50000) {
                break;
            }
        }
        System.out.println(n);
        System.out.println(Thread.currentThread().isInterrupted());
    }
}

线程类run方法的循环条件中,判断了当前线程是否被设置了中断标志位,如果是则说明需要被打断,退出循环。
需要注意的是,调用线程实例的 isInterrupted()方法不会清除中断标志位;如果想要清除它,可以使用 Thread.interrupted()静态方法,该方法返回当前线程的中断状态,同时清除中断标志(将中断状态设为false)。

线程状态图

在这里插入图片描述

synchronized锁升级

为了降低synchronized锁的性能开销,java 1.6开始对synchronized的实现进行了优化,添加了锁升级的机制,即 无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。

在探讨synchronized锁升级之前,需要先了解java对象的结构,因为synchronized的锁状态、标识等信息保存在对象头中,锁的竞争和升级等也都伴随着对象头中相关信息的变化。

java对象结构

java对象由 对象头、实例数据、对齐填充字节组成,其中对象头又包含 Mark Word、类型指针(指向方法区中类的元信息),如果是数组对象,还会包含一部分空间来记录数组长度。

对象的锁标记、GC分代年龄、GC标记、hashCode(仅在调用hashCode方法后才保存到对象头)等信息都记录在对象头的Mark Word中。

Mark Word结构(以64位虚拟机为例):
在这里插入图片描述

锁的各种状态

无锁:
对象的初始状态,无任何线程持有对象锁,mark word后三位为001,也叫不可偏向(non-biasable)状态,该状态下的竞争会直接走轻量级锁逻辑。

匿名偏向:
属于偏向锁状态,mark word后三位为101,只不过该状态下偏向的线程ID为0。对于工作线程来讲,该状态也可以视为一种初始状态,因为JVM有一种叫做延迟偏向的优化机制:
JVM启动的前4秒内(时间受JVM参数-XX:BiasedLockingStartupDelay影响),所有对象都是无锁状态,遇到竞争会直接走后面的轻量级锁逻辑,这是为了保证JVM的启动速度;而在此之后创建的对象,会被JVM预设为匿名偏向状态。
此外偏向锁撤销过程中如果触发批量重偏向,也可能将锁重置到这个状态。

偏向锁:
假设第一个来拿锁的是线程A,发现对象是匿名偏向状态,会通过CAS操作替换对象头中的mark word,替换成功就表明A拿到了偏向锁,或者说该锁偏向的是线程A。
当下一次线程A再进入同步代码块时,发现是一个偏向锁,然后进行线程ID比对(mark word与当前线程)以及epoch值比对(锁对象和其所属类),比对结果都相等,则线程A就可以直接进入同步代码块,而不再需要额外的CAS操作去拿锁,这就是偏向锁的作用,它保证了无竞争环境下访问同步代码块的效率。

Lock Record:
事实上,一个线程每次准备进入同步代码块时,JVM都会在当前线程栈中分配一块空间,称之为 lock record(锁记录),它包含两部分:
1、Displaced Mark Word,主要有两个用处,一个是用来存放锁对象的mark word副本,比如轻量级锁释放时需要用它来还原对象的mark word;另一个是用来表示偏向锁和轻量级锁的重入,重入时Displaced Mark Word为空。
2、owner指针,指向锁对象。

lock record的分配发生在同步代码块的入口处,跟要获取的锁类型无关,比如获取偏向锁时,栈中也会有对应的锁记录。
在获得可用的lock record之后,JVM会构建一个无锁状态的mark word,保存到lock record中,然后将owner指针指向要使用的锁对象。

lock record结构如下图:
在这里插入图片描述

偏向锁的释放:
在线程栈中找到最近一次持锁对应的Lock Record(owner指针指向锁对象),将其中的owner指针置为null,即完成释放。

偏向锁的撤销:
偏向锁的偏向线程,不会因为锁的释放而改变。其他线程对锁的竞争,才可能导致偏向锁的撤销,撤销过程中又可能触发批量撤销或者批量重偏向,撤销的结果可能是无锁状态、匿名偏向状态,或者直接升级成了轻量级锁。
另外,调用锁对象的Object#hashCode()方法生成hashCode时,也会导致偏向锁的撤销或升级,因为偏向锁并不保存hashCode,而轻量级锁保存在Lock Record中,重量级锁保存在ObjectMonitor中。

批量重偏向:
以类为单位,每个类维护一个偏向锁撤销计数器,该类的对象每发生一次偏向锁撤销,计数器都会加1。如果撤销次数达到批量重偏向阈值(默认值20,可通过JVM参数设置),JVM就会认为这个类的对象在使用过程中,锁的偏向是有问题的,就会执行一次批量重偏向。
批量重偏向需要用到 Epoch值,这个值在类和对象头中都会保存一份,而且初始是相等的。
批量重偏向时,主要做两件事:
1)将类中的 Epoch值加1,同时遍历所有线程栈中的锁记录(lock record)找到处于加锁状态的对象,将对象头中的 Epoch值加1;而那些不处于加锁状态(不在同步代码块)的对象,它们的 Epoch值和类中的就不相等,这些锁就可以进行重偏向,分配给其他线程。
2)撤销当前被访问对象的偏向锁,可能发生重偏向或锁升级。

批量撤销:
批量撤销是继批量重偏向之后的进一步调整措施。如果距离上一次批量重偏向25秒(默认值,可通过JVM参数设置)内,锁撤销计数达到了批量撤销阈值(默认值40,可通过JVM参数设置),则执行批量撤销。
反之,如果25秒内撤销次数没有达到批量撤销阈值,那么下一次撤销时会先将撤销计数清零,然后重新计数。也就是说支持多次批量重偏向。
主要做三件事:
1)将类的锁标志设置为无锁状态。
2)找到该类处于加锁状态的对象,撤销其偏向锁,然后升级为轻量级锁继续被原线程继续持有;
3)撤销当前被访问对象的偏向锁,重置为无锁状态或者升级为轻量级锁。
另外:
对于未处于加锁状态的对象,待其下次被抢占时,由于检测到类已经不支持偏向锁,同样会撤销偏向锁(这里仅有一个CAS拿锁操作,不计撤销次数,因为没有意义),然后升级为轻量级锁;
对于后续该类新创建的对象,均为无锁状态(不可偏向)。

轻量级锁的获取:
轻量级锁获取的核心操作是将对象头的mark word复制到线程栈对应的Lock Record中,然后通过CAS操作将mark word的锁记录指针指向该Lock Record,然后将锁标志位由01无锁状态修改为00轻量级锁状态。
如果CAS操作失败,则考虑是否为重入或者发生了锁竞争,重入情况下只需要将 Lock Record 中的 Displaced Mark Word 置为null;锁竞争情况下需要膨胀为重量级锁。

轻量级锁的释放:
首先将线程栈中对应Lock Record的owner指针置空,如果是非重入场景, 还要通过CAS操作,将对象头mark word 替换为 Lock Record中保存的信息。

偏向锁的获取和升级流程(含轻量级锁获取)

在这里插入图片描述

测试demo

1、pom文件中引入JOL依赖,用来打印对象层级结构:

        <dependency>
            <groupId>org.openjdk.jol</groupId>
            <artifactId>jol-core</artifactId>
            <version>0.14</version>
        </dependency>

2、添加测试类代码:
(JVM启动参数:-XX:BiasedLockingBulkRebiasThreshold=5 -XX:BiasedLockingBulkRevokeThreshold=10)
批量重偏向阈值为5,批量撤销阈值为10。

package org.example.thread;

import org.example.domain.Book;
import org.openjdk.jol.info.ClassLayout;

import java.util.ArrayList;
import java.util.List;

public class BiasTest {

    public static void main(String[] args) throws InterruptedException {
        //等待延迟偏向生效
        Thread.sleep(5000);
        List<Book> list = new ArrayList<>();
        for (int i = 0; i < 20; i++) {
            Book book = new Book();
            list.add(book);
            if (i == 19) {
                System.out.println("主线程:检查匿名偏向状态");
                System.out.println(ClassLayout.parseInstance(book).toPrintable());
            }
        }

        //将所有对象偏向给线程1
        new Thread(() -> {
            for (int i = 0; i < 20; i++) {
                Book book = list.get(i);
                synchronized (book) {
                    System.out.println("线程1持锁:" + i + "号对象");
                    System.out.println(ClassLayout.parseInstance(book).toPrintable());
                }
            }
            try {
                Thread.sleep(3000000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }).start();

        Thread.sleep(5000);

        //0-3号撤销升级为轻量级锁,4号撤销触发批量重偏向,重偏向给线程2,5-19号均变为可重偏向状态,5-9号不走撤销流程,直接重偏向
        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                Book book = list.get(i);
                synchronized (book) {
                    System.out.println("线程2持锁:" + i + "号对象");
                    System.out.println(ClassLayout.parseInstance(book).toPrintable());
                }
            }
            try {
                Thread.sleep(3000000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }).start();

        Thread.sleep(5000);

        //5-8号撤销升级为轻量级锁,9号撤销触发批量撤销,也升级为轻量级锁;
        //Book类已不可偏向,尽管10-14号还是可重偏向状态,后续拿锁时也会直接撤销(不计撤销次数),升级为轻量级锁
        new Thread(() -> {
            for (int i = 5; i < 15; i++) {
                Book book = list.get(i);
                synchronized (book) {
                    System.out.println("线程3持锁:这是" + i + "号对象");
                    System.out.println(ClassLayout.parseInstance(book).toPrintable());
                }
            }
            try {
                Thread.sleep(3000000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }).start();

        Thread.sleep(5000);

        //15-19号仍然是偏向锁,但是同之前的10-14号一样,没有意义。
        Book book = list.get(15);
        System.out.println("主线程:检查15号对象");
        System.out.println(ClassLayout.parseInstance(book).toPrintable());

        //批量撤销后,新建的对象为无锁状态
        book = new Book();
        System.out.println("主线程:检查新对象");
        System.out.println(ClassLayout.parseInstance(book).toPrintable());

        //检查轻量级锁释放后的状态。
        book = list.get(5);
        System.out.println("主线程:检查5号对象");
        System.out.println(ClassLayout.parseInstance(book).toPrintable());
    }
}

运行结果

初始为匿名偏向状态:
在这里插入图片描述
线程2竞争时,触发批量重偏向前升级为轻量级锁,触发后重偏向:
在这里插入图片描述
线程3又开始竞争,继续撤销,此时还未触发批量撤销:
在这里插入图片描述
已触发批量撤销,升级为轻量级锁:
在这里插入图片描述
由于不在同步代码段内,部分对象还是偏向锁状态:
在这里插入图片描述
批量撤销之后,新对象默认无锁状态:
在这里插入图片描述
轻量级锁释放后变成了无锁状态:
在这里插入图片描述
参考文档:
https://www.jianshu.com/p/4758852cbff4
https://www.nowcoder.com/discuss/375064741771812864

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值