Java内存模型 -底层原理

【声明】:本篇文章来自本人gitee仓库搬运至CSDN,https://gitee.com/genmers/md-notes

文章目录

一、底层原理
1.1 从Java代码到CPU指令
  1. 最开始,我们编写Java代码,是*.java文件
  2. 在编译(javac命令)后,从*.java文件会生成一个*java字节码文件(.class)
  3. JVM会执行刚才生成的字节码文件(*.class),并把字节码文件转化为机器指令
  4. 机器指令可以直接在CPU上执行,也是最终的应用程序
1.2 重点向下钻研

在早期,没有内存模型之前,不同的CPU平台机器指令千差万别,没有一套规范去管理JVM**“翻译”**的过程,无法保证并发安全效果一致

因为急需一套转化过程的规范、原则。以便于一套Java代码在不同平台实现的效果是一样的

二、自顶向下的好处
  • 先讲场景,再讲用法,最后讲原理
  • 直观的了解、具体而感性的认识,有助于加深理解,最后分析源码
  • 兴趣:如果我们知道用的这个工具或者方法是什么原理的话,我们肯定会更有兴趣的
  • 例子:《计算机网络(自顶向下方法)》
三、JVM内存结构(和Java虚拟机运行时区域有关)
3.1 一图胜千言
image-20201019210605035 AC4C7A60-3961-45CE-A0B3-9E4D21F9F130

3.2 堆区(所有线程共享)

3.3 栈区(每个线程私有)

3.4 方法区(所有线程共享)

3.5 本地方法栈(每个线程私有)

3.6 程序计数器(每个线程私有)

四、Java内存模型(和Java的并发编程有关)

Java Memory Model, 跳转到 JMM是什么

五、Java对象模型(和Java对象在虚拟机中的表现形式有关)
5.1 一图胜千言
image-20201019210955506
5.2 Java对象自身的存储模型
5.3 OOP-KIass Model
六、JMM是什么
6.1 为什么需要JMM

​ 早期的比如C语言由于不存在内存模型的概念,就导致了很多的问题,就比如说,由于它本身不存在这样的概念,所以很多行为是依赖于处理器本身的内存一致性模型,这种模型是依赖处理器的,而处理器来自各个不同的厂商,他们的模型可能也不一样,也可能差异很大,所以C++程序很可能在这个电脑运行正常,到另一个电脑运行结果就不一样了,这就导致了很大问题,即无法保证并发安全

​ 这样的情况下,迫切需要一个标准来让多线程的运行结果达到一个可预期的效果。

6.2 是规范

​ 综上所述,JMM是一组规范,需要各个JVM的实现来遵守JMM规范,以便于普通开发者可以利用这些规范,更方便地开发多线程程序。这个规范的目的和受益者是我们这样的顶层(应用层)的开发者,而不是JVM开发者。

​ 如果没有这样一个JMM内存模型来规范,那么很可能经过了不同的JVM的不同规则的重排序后,导致不同的虚拟机上运行的结果不一样,那是很大的问题。

6.3 是关键字和工具类的原理
  • volatile、synchronied、Lock等的原理都是JMM

  • 如果没有JMM,那就需要我们自己指定什么时候用内存栅栏(可以简单理解为工作内存和主内存之间的拷贝和同步)等,那是相当麻烦的。

    • 内存屏障或内存栅栏,也就是让一个CPU处理单元中的内存状态对其它处理单元可见的一项技术。CPU使用了很多优化技术来实现一个目标:CPU执行单元的速度要远超主存访问速度。
    • 再往下走,涉及两个概念
      • Store Barrier:Store屏障,是x86的”sfence“指令,强制所有在store屏障指令之前的store指令,都在该store屏障指令执行之前被执行,并把store缓冲区的数据都刷到CPU缓存。
      • Load Barrier : Load屏障,是x86上的”ifence“指令,强制所有在load屏障指令之后的load指令,都在该load屏障指令执行之后被执行,并且一直等到load缓冲区被该CPU读完才能执行之后的load指令。
      • Java内存模型volatile变量在写操作之后会插入一个store屏障,在读操作之前会插入一个load屏障。一个类的final字段会在初始化后插入一个store屏障,来确保final字段在构造函数初始化完成并可被使用时可见。
  • 有了JMM,我们只需要会用同步工具和关键字就可以开发并发程序了。

6.4 最重要的3点内容

​ 重排序、可见性、原子性,分别在七、八、九点重点阐述

七、重排序

CPU避免内存访问延迟最常见的技术是将指令管道化,然后尽量重排这些管道的执行以最大化利用缓存,从而把因为缓存未命中引起的延迟降到最小。

当一个程序执行时,只要最终的结果是一样的,指令是否被重排并不重要。

7.1 重排序的例子、什么是重排序
例子1:演示重排序的现象,"直到达到某个条件才停止",测试小概率事件
public class OutOfOrderExecution {
    private static int x=0,y=0;
    private static int a=0, b=0;
    public static void main(String[] args) throws InterruptedException {
       //创建两个线程 one two
        Thread one = new Thread(new Runnable(){
            @Override
            public void run() {
                a = 1;
                x = b;
            }
        });

        Thread two = new Thread(new Runnable(){
            @Override
            public void run() {
                b=1;
                y = a;
            }
        });

        one.start();
        two.start();
        // 让主线程等待
        one.join();
        two.join();

        System.out.println("x="+x+"  y="+y);
    }
}

  • 9、10 和 17、18行代码的执行顺序决定了最终x和y的效果,一共有3种情况:
    1. 先执行a=1; b=0; 后执行 b=1; y=a; 最终结果是x=0,y=1
    2. 先执行b=1; y=a;, 后执行a=1; b=0; 最终结果是x=1,y=0
    3. 分别先执行两个线程的b=1, a=1 后执行 x=b,y=a; 最终结果是x=0,y=1

​ 要达到3的那个效果,就需要让两个线程几乎同时执行才有机会使xy都为1,这时候可以使用CountDownLatch在需要停止的地方设置闸门 latch.await

例子2:使用CountDownLatch实现3的效果,有小概率实现xy都为1

public class OutOfOrderExecution {
    private static int x=0,y=0;
    private static int a=0, b=0;
    public static void main(String[] args) throws InterruptedException {
        //使用工具类使两个线程同时开始
        CountDownLatch latch = new CountDownLatch(1);
        //创建两个线程 one two
        Thread one = new Thread(new Runnable(){
            @Override
            public void run() {
                try {
                   //设置闸门
                    latch.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                a = 1;
                x = b;
            }
        });
        Thread two = new Thread(new Runnable(){
            @Override
            public void run() {
                try {
                   //设置闸门
                    latch.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                b=1;
                y = a;
            }
        });
        two.start();
        one.start();
        //放开闸门,两个线程开始同时执行
        latch.countDown();
        // 让主线程等待
        one.join();
        two.join();

        System.out.println("x="+x+"  y="+y);
    }
}

例子3:自动执行计算循环次数

public class OutOfOrderExecution {
    private static int x=0,y=0;
    private static int a=0, b=0;
    public static void main(String[] args) throws InterruptedException {
        //设置循环计数器i
        int i=0;
        //为了更方便的实现 "直到达到某个条件才停止"
        for (;;){
            i++;
            x = 0;
            y = 0;
            a = 0;
            b = 0;


        //使用工具类使两个线程同时开始
        CountDownLatch latch = new CountDownLatch(1);
        //创建两个线程 one two
        Thread one = new Thread(new Runnable(){
            @Override
            public void run() {
                try {
                    //设置闸门
                    latch.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                a = 1;
                x = b;
            }
        });
        Thread two = new Thread(new Runnable(){
            @Override
            public void run() {
                try {
                    //设置闸门
                    latch.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                b=1;
                y = a;
            }
        });
        two.start();
        one.start();
        //放开闸门,两个线程开始同时执行
        latch.countDown();
        // 让主线程等待
        one.join();
        two.join();

        String result = "第"+i+"次, ("+x+","+y+")" ;

        if (x == 1 && y == 1){
            System.out.println(result);
            break;
        } else {
            System.out.println(result);
        }

        } // end of for
    }
}

.....7208次, (0,1)7209次, (1,0)7210次, (1,0)7211次, (0,1)7212次, (1,1)
	最后我们似乎是实现了自动化的把xy都为1的情况找出来了,我们是实现了3种方法,但是有没有可能有第4种?

我们推断出3种的基础上两个线程内部的代码执行循序不会变

// one线程先执行a的赋值再执行x的赋值 
a = 1;
x = b;
//two线程先执行b的赋值再执行y的赋值
b=1;
y = a;

基本上不会出现 x=0, y=0的情况,但是,我们试着将55行代码条件改下,执行却出现了这样的结果

55      if (x == 0 && y == 0){
...
第8212次, (0,1)
第8213次, (1,0)
第8214次, (0,0)
7.1.1 重排序分析

​ 会出现x=0,y=0是因为重排序发生了,那4行代码有这样的一种可能是这样的执行顺序

y=a;
a=1;
x=b;
b=1;
7.1.2 结论:什么是重排序

​ 经过上方的分析,相信大家对重排序的发生应该有很大的体会了,在线程1内部的两行代码的实际执行顺序和代码在Java文件中顺序不一致,代码指令并不是严格按照代码语句顺序执行的,他们顺序被改变了,这就是重排序,这里被颠倒的是y=a和b=1折两行语句。

7.2 重排序的好处

image-20201020022522970

7.3 重排序的3种情况
  • 编译器优化:包括JVM,JIT编译器等 (情况如上,比如编译器可能会认为把两个a放在一起进行读写的优化的,如果没特别进行关联,这样的重排序是没影响的)
  • CPU指令重排:就算编译器不重排,CPU也可能对指令进行重排
  • 内存的“重排序”:线程A的修改B线程看不到,引出可见性问题
八、可见性
8.1 什么是可见性问题

可见性是指:当一个线程修改了线程共享变量的值,其它线程能够立即得知这个修改。

Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方法来实现可见性的,无论是普通变量还是volatile变量都是如此。

可见性问题是,有可能线程A将某个值读到自己的工作内存修改,还没来的及传回主内存的时候,线程B读取了这个值,这时候线程B就不知道自己读的值是不是最新的了,线程A和线程B的各自的工作内存不可见

8.2 为什么会有可见性问题
image-20201020030559553

RAM:内存

L3\L2\L1 3、2、1级缓存,越靠近CPU,缓存越小,速度越快(计算机基础知识了)

registers: 寄存器

​ 由图上可见,整个内存与CPU通信分许许多多的通道,除了主内存和L3,再往上彼此每个缓存块都彼此不可见,就很容易出现明明数值修改了,只是还没来的及写回主内存,就已经被另一个线程读取到了更改之前的值。

  • CPU有多级缓存,导致读的数据过期
    • 高速缓存的容量比主存小,但是速度仅次于寄存器,所以在CPU和主内存之间多了Cache层
    • 线程间对于共享变量的可见性不是直接由多核引起的,而是由多级缓存引起的。
    • 如果所有核心都只用一个缓存,那么也就不存在内存可见性的问题了。
    • 每个核心都会将自己需要的数据读到独占缓存中,数据修改后也是写入到缓存中,然后等待刷入到主存中。所以导致有些核心读取到的值是一个过期的值。
8.3 JMM的抽象:主内存和本地内存
8.3.1 什么是主内存和本地内存
  • Java作为高级语言,屏蔽了这些底层细节,由于Java定位就是要处理并发问题的,JMM定义了一套读写内存的规范,虽然我们不再需要关心一级缓存和二级缓存的问题,但是,JMM抽象了主内存和本地内存的概念。

  • 这里说的本地内存并不是真的一块给每个线程分配的内存,而是JMM的抽象,对寄存器、一级缓存、二级缓存的抽象。

    image-20201020033231437 7CBB1548D65BEB42ED8C47F5159E8173
    8.3.2 主内存和本地内存的关系

    ​ 所有的共享变量存在于主内存中,每个线程有自己的本地内存,而且线程读写共享数据也是通过主内存共享数据的,所以才导致了可见性问题

8.4 happens-before原则
8.4.1 什么是happens-before(两种解释,一个意思)
  • happens-before规则是用来解决可见性问题的,在时间上,动作A发生在动作B之前,B保证能看见A,这就是happens-before。
  • 两个操作可以用happens-before来确定他们的执行顺序:如果一个操作happens-before于另一个操作,那么我们说第一个操作对于第二个操作上可见的
8.4.2 什么不是happens-before
  • 两个线程没有相互配合的机制,所以代码X和Y的执行结果并不能保证总被对方看到的,这就具备happens-before。
8.4.3 happens-before规则有哪些
  1. 单线程规则(不影响重排序)

  2. 锁操作(synchronied和Loc)

    image-20201020035705008
  3. volatile变量

    image-20201020035808030
  4. 线程start(默认子线程都能看见主线程之前所有的操作)

  5. 线程Join

    image-20201020040125838
  6. 传递性:如果hb(A,B)而且hb(B,C),那么可以推断出hb(A,C)

  7. 中断(一个线程被中断了,检测中断isInterrupted或者抛出InterruptedException一定能被看到)

  8. 构造方法(不推荐:finalize()方法一定能看到构造方法的最后一行指令)

  9. 工具类的Happens—Before原则

    1. 线程安全的容器get一定能看到在此之前put等存入动作
    2. CountDownLatch
    3. Semaphore
    4. Future
    5. 线程池
    6. CyclicBarrier
8.4.4 happens-before演示

​ 两个数a和b,如果要实现内存可见性可以加volatile,单独对b加volatile也是可以的,只要a的变化在b之前

8.5 volatile关键字

Java内存模型volatile变量在写操作之后会插入一个store屏障,在读操作之前会插入一个load屏障。一个类的final字段会在初始化后插入一个store屏障,来确保final字段在构造函数初始化完成并可被使用时可见。(store和load可见【JMM是什么】的【工具类和关键字的原理】)

8.5.1 volatile是什么

​ volatile是一种同步机制,比起synchronied或者Lock相关类更轻量,因为适用volatile并不会发生上下文切(很耗时很耗资源的操作)换等开销很大的行为。只会把值刷到内存,并不会产生线程的切换。

​ 如果一个变量被修饰成volatile,那么JVM就知道了这个变量可能会被并发修改,就会禁止重排序。

​ 因为volatie开销小,相应能力也小,虽然说volatile是用来同步的保证线程安全的,但是volatile做不到synchronied那样的原子保护,volatile仅在有限的场景下才能发挥作用。

8.5.2 volatile的适用场合

适用场合1: boolean flag 标志位,如果一个共享变量自始自终都只是被各个线程赋值,而没有其他操作,那么就可以用volatile来代替synchronied或者代替原子变量,因为赋值自身是有原子性的,而volatile又保证了可见性,所以就足以保证线程安全

适用场合2: 作为刷新之前的触发器,因为hapens-before原则,之前发生的值都能被读取。

不适用场合1:首先volatile不适用a++的问题,会出现丢失,因为a++不是一个原子操作,是【取出a,再++】两个操作。

不适用场合2: 不适用的第二个点是赋值操作不能依赖于之前的操作,比如 flag = !flag , 这是线程不安全的,会导致线程不安全。

8.5.3 volatile的作用:可见性、禁止重排序
  1. 可见性:读一个volatile变量之前,需要先使相应的本地缓存失效,这样就必须到主内存读取最新值,写一个volatile属性会立即刷入到主内存
  2. 禁止指令重排序优化:解决单例双重锁乱序问题
8.5.4 volatile和synchronied的关系

​ volatile在这方面可以看作是轻量版的synchronied:如果一个共享变量自始自终都只是被各个线程赋值,而没有其他操作,那么就可以用volatile来代替synchronied或者代替原子变量,因为赋值自身是有原子性的,而volatile又保证了可见性,所以就足以保证线程安全

8.5.5 学以致用:用volatile修正重排序的问题

​ 我们看回到 7.1节的例子2的x=0,y=0的情况,在把xy使用volatile修饰后,x=0,y=0的情况就再也不会发生了。

 private volatile static int x=0,y=0;
 private volatile static int a=0, b=0;
8.5.6 volatile小结
  1. volatile修饰符适用以下场景:某个属性被多个线程共享,其中有一个线程直接修改(没有读取操作)了此属性,其他线程可以立即得到修改后的值,比如boolean flag;或者作为触发器,实现轻量级同步。
  2. volatile属性的读写操作都是无锁的,它不能代替synchronied,因为它没有提供原子性互斥性。因为无锁,不需要花费时间在获取锁和释放锁上,所以说它是低成本的。
  3. volatile只能作用于属性而不能像synchronied一样去修饰方法或者代码块,我们用volatile修饰属性,这样compilers(编译器)就不会对这个属性做指令重排序
  4. volatile提供了可见性,任何一个线程对其的修改将立马对其他线程可见。volatile属性不会被线程缓存,始终从主存中读取。
  5. volatile提供了happens-before保证,与第4点一样,一旦写入,后续的线程一定能读取到最新的值。
  6. volatile可以使得long和double的赋值说原子的(原本long和double不具备原子性),后面马上会讲long和double的原子性。
8.6 能保证可见性的措施
  • 除了volatile可以让变量保证可见性外,synchronied、Lock、并发集合、Thread.join()和Thread.start()等都可以保证可见性

  • 具体看happens-before原则的规定

8.7 升华:对synchronied可见性的正确理解
  • 因为happens-before原则,synchronied不仅保证了原子性,还保证了可见性
  • 因为happens-before原则,synchronied不仅保证代码的安全,还让之前的代码可见了 - 近朱者赤
九、原子性

主内存和工作内存之间的交互

操作作用对象解释
lock主内存把一个变量标识为一条线程独占的状态
unlock主内存把一个处于锁定状态的变量释放出来,释放后才可被其他线程锁定
read主内存把一个变量的值从主内存传输到线程工作内存中,以便 load 操作使用
load工作内存把 read 操作从主内存中得到的变量值放入工作内存中
use工作内存把工作内存中一个变量的值传递给执行引擎, 每当虚拟机遇到一个需要使用到变量值的字节码指令时将会执行这个操作
assign工作内存把一个从执行引擎接收到的值赋接收到的值赋给工作内存的变量, 每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作
store工作内存把工作内存中的一个变量的值传送到主内存中,以便 write 操作
write工作内存把 store 操作从工作内存中得到的变量的值放入主内存的变量中
9.1 什么是原子性

​ 就是一个或某几个操作只能在一个线程执行完之后,另一个线程才能开始执行该操作,也就是说这些操作是不可分割的,线程不能在这些操作上交替执行。

​ 由 Java 内存模型来直接保证的原子性变量操作包括 read、load、assign、use、store 和 write。大致可以认为基本数据类型的操作是原子性的。同时 lock 和 unlock 可以保证更大范围操作的原子性。而 synchronize 同步块操作的原子性是用更高层次的字节码指令 monitorenter 和 monitorexit 来隐式操作的。

9.2 Java中的原子操作有哪些
  1. 除了long、double的赋值
  2. 引用的赋值
  3. 原子包下的赋值都是原子操作
9.3 long和double的原子性

​ Java 要求对于主内存和工作内存之间的八个操作都是原子性的,但是对于 64 位的数据类型,有一条宽松的规定:允许虚拟机将没有被 volatile 修饰的 64 位数据的读写操作划分为两次 32 位的操作来进行,即允许虚拟机实现选择可以不保证 64 位数据类型的 load、store、read 和 write 这 4 个操作的原子性。这就是 long 和 double 的非原子性协定。

我们来说说8.5.6中第6点说的关于long和double的原子性

​ **问题描述:官方文档对于64位值的写入,分为两个32位的操作进行写入、读取,错误使用volatile解决

结论:在32位的JVM上,long和double的操作不是原子的,但是在64位的JVM上是原子的

​ **实际开发中:**商用Java虚拟机不会出现。官方文档提出的问题是Java的规范(商用虚拟机有考虑这点)中并没有对此有规定,所以实现的时候可能会出错。

9.4 原子操作+原子操作 !=原子性
  • 简单地把原子操作组合到一起,并不能保证整体依然具有原子性
  • 比如我去ATM机两次取钱时两次独立的原子操作,但是期间有可能银行卡被借给朋友,也就是被其他线程打断并修改。
  • 全同步的HashMap也不完全安全,就比如说,【判断key为“2”的value是不是“5”,是“5”就+1】,这些操作单个都没什么问题,但是组合到一起,就很可能出现线程安全问题。
十、应用
1. JMM应用实例:单例模式8种写法、单例和并发的关系(真实面试超高频考点)
  • 单例模式的作用:为什么需要单例:节省内存和计算、保证结果正确、方便管理

  • 单例模式的适用场景:

    • 无状态的工具类:比如日志工具类,不管是在哪里使用,我们需要的只是它帮我们记录信息,除此之外,并不需要在他的实例对象上存储任何状态,这时候我们就只需要一个实例对象即可。
    • 全局信息类:比如我们在一个类上记录网站的访问次数,我们不希望有的访问被记录在对象A上,有的却记录在对象B上,这时候我们就让这个类成为单例。
  • 单例模式的8种写法

    1. 饿汉式(静态常量)- 可用

    2. 饿汉式(静态代码块)- 可用

    3. 懒汉式(线程不安全)- 不可用

    4. 懒汉式(线程安全,同步方法)- 不推荐用

    5. 懒汉式(线程不安全,同步代码块)- 不可用

    6. 双检锁(双重检查)- 推荐用

      • 什么要用double-check:1.线程安全

      • 单check行不行? 方法4行不行 : 可以的,是线程安全的,但当多个线程访问时可能不能及时的响应

      • 为什么要用volatile

    7. 静态内部类 - 推荐用

    8. 枚举 - 推荐用

  • 单例模式不同写法对比

    • 饿汉:简单,但是没有lazy loading
    • 懒汉:有线程安全问题
    • 静态内部类:可用
    • 双重检查:面试用
    • 枚举:最好
  • 用哪种单例实现最好?

    • Joshua Bloch大神在《Effective Java》中明确表达过观点:“使用枚举实现单例的方法虽然没被广泛采用,但是单元素的枚举类型已经成为实现Singleton的最佳方法”
      • 写法简单
      • 线程安全有保障
      • 避免反序列化破环单例
  • 懒加载虽好,但会增加变成复杂性

  • 单例模式面试常见问题:

    • 【面试问题】饿汉式的缺点?
      • 上来就加载资源,有时候我们不需要实例,它也会加载就会造成资源的浪费。
    • 【面试问题】懒汉式的缺点?
      • 虽然解决了加载浪费的问题写法比较复杂,不注意就会写成线程不安全的情况
  1. 讲一讲什么是Java内存模型【提示:对照目录】
    • 首先,早期C/C++。。。需要规范
    • 三兄弟
    • JMM是规范,规范了我们JVM、CPU和Java代码之间一系列的转换关系来帮助我们开发
    • JMM最重要的应该是重排序、可见性,原子性,
      • 然后重排序例子好处,
      • 可见性 -多级缓存引起的可见性问题- JMM抽象主内存和本地内存-
      • 可见性问题还在 - happens-before有单线程规则、锁操作(sy和lock)、线程开始和线程加入、volatile
      • volatile
  2. volatil和synchronied的异同

​ volatile在这方面可以看作是轻量版的synchronied:如果一个共享变量自始自终都只是被各个线程赋值,而没有其他操作,那么就可以用volatile来代替synchronied或者代替原子变量,因为赋值自身是有原子性的,而volatile又保证了可见性,所以就足以保证线程安全

  1. 什么是原子操作?Java中有哪些原子操作?生成对象的过程是不是原子操作

    ​ 原子操作就是一组不可分割的操作,要么全完成,要么全不完成;Java中有3种原子操作:9.2 Java中的原子操作有哪些;不是原子操作,看

  2. 什么是内存可见性

    为什么会有可见性问题

  3. 64位的double和long写入的时候是原子性吗
    1. 首先Java没有规定他们是原子的,理论上是有错位的可能,但是实际上JVM已经考虑到了,我们就不用去考虑加volatile了
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值