JVM类加载机制详解——类加载过程

代码编译的结果从本地机器码转变为字节码, 是存储格式发展的一小步, 却是编程语言发展的一大步
----《深入理解Java虚拟机:JVM高级特性与最佳实践》

一、知识铺垫

        Java虚拟机把描述类的数据从Class文件加载到内存, 并对数据进行校验、 转换解析和初始化, 最终形成可以被虚拟机直接使用的Java类型, 这个过程被称作虚拟机的类加载机制。
Class文件被加载到内存中是如何存储的?就要涉及到JVM的Klass模型了,Klass类是C++中的一个类,包含元数据和方法信息,用来描述Java类,像常量池、字段、属性、方法、父类、类的访问权限等等,这些都是Java类的元信息。所以想要搞清楚Java类在JVM中如何存储,那么就有必要了解一下Klass模型。

klass模型

继承结构
在这里插入图片描述
      Metadata表示元空间的内存结构,由此可见类的元信息是存储在原空间的
Java类在JVM中分成两种:非数组类和数组类,非数组类在JVM中对应的是InstanceKlass类的实例,InstanceKlass用来存储Java类的元信息,存储在方法区,它有三个子类:

  • InstanceMirrorKlass(镜像类):用于描述java.lang.Class的实例,Java中的Class对象就是它的实例,存储在堆区;
  • InstanceRefKlass:用于表示java/lang/ref/Reference类的子类
  • InstanceClassLoaderKlass:用于遍历某个加载器加载的类

Java数组的元信息用ArrayKlass的子类来表示:

  • TypeArrayKlass:用于表示基本类型的数组
  • ObjArrayKlass:用于表示引用类型的数组

二、类加载机制

类的生命周期

        一个类型从被加载到虚拟机内存中开始, 到卸载出内存为止, 它的整个生命周期将会经历加载(Loading) 、 验证(Verification) 、 准备(Preparation) 、 解析(Resolution) 、 初始化(Initialization) 、 使用(Using) 和卸载(Unloading) 七个阶段, 其中验证、 准备、 解析三个部分统称为连接(Linking)
在这里插入图片描述

何时被初始化

1、遇到new、 getstatic、 putstatic或invokestatic这四条字节码指令时, 如果类型没有进行过初始化,则需要先触发其初始化阶段。 能够生成这四条指令的典型Java代码场景有:

  • 使用new关键字实例化对象的时候。
  • 读取或设置一个类型的静态字段(被final修饰、 已在编译期把结果放入常量池的静态字段除外)的时候。
  • 调用一个类型的静态方法的时候。

2、使用反射的时候,如果类没有进行过初始化,则需要先进行初始化。

3、初始化类的时候,如果其父类还没有进行过初始化,则需要先初始化其父类,但是一个接口在初始化 时, 并不要求其父接口全部都完成了初始化, 只有在真正使用到父接口的时候(如引用接口中定义的常量) 才会初始化。

4、虚拟机启动时,会先初始化主类(main()所在类)。

5、当使用jdk1.7动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getstatic,REF_putstatic,REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行初始化,则需要先出触发其初始化。

6、在jdk1.8中,如果一个接口中定义了默认方法(default修饰的方法),如果有这个接口的实现类进行了初始化,则需要在该实现类初始化之前先初始化该接口。

        在《Java虚拟机规范》一书中,将上述6种场景称为对类的主动使用,除了这6种场景之外的使用方法称为被动引用,并且所有被动引用都不会初始化,即有且仅有上述6中方式中的任意一种场景时,才会进行类的初始化。

关于被动引用,通过几个代码示例来说明,加深一下理解。

代码演示1:

public class Test {
    public static void main(String[] args) {
        System.out.println(SubClass.value);
    }
}

class SuperClass {
    public static int value = 123;
    static {
        System.out.println("SuperClass init!");
    }
}
class SubClass extends SuperClass {
    static {
        System.out.println("SubClass init!");
    }
}

上述代码运行指挥,输出结果如下:

SuperClass init!
123

        没有输出“SubClass init!”,对于静态字段,只有直接定义这个字段的类才会被初始化, 因此通过其子类来引用父类中定义的静态字段, 只会触发父类的初始化而不会触发子类的初始化,由此可见,JVM加载类是懒加载模式,只有在使用的时候,才会去初始化类。

        以上代码只是说明了SubClass类没有初始化,那么类加载在类的生命周期的第一阶段加载(Loading)有没有触发呢?其实可以通过JVM参数来观察:-XX:+TraceClassLoading,如下图所示,可以看到,其实虚拟机已经加载(Loading)了子类,其实在《Java虚拟机规范》中并没有明确规定这种场景下子类要不要加载,可能不同的虚拟机实现会有不同的结果,我所运行的代码使用的都是HotSpot虚拟机,HotSpot虚拟机在此种情况下会去加载子类。
在这里插入图片描述

代码演示2:

public class Test {
    public static void main(String[] args) {
        SuperClass[] superClasss = new SuperClass[1];
    }
}

class SuperClass {
    public static int value = 123;
    static {
        System.out.println("SuperClass init!");
    }
}

        运行上述代码,发现没有任何输出!说明并没有初始化SuperClass类,其实这里只是定义了一个SuperClass类型的数组,并没有使用SuperClass类;从另一个角度看,这种情况并不在类的初始化的6个场景中,因此并不会初始化。

代码演示3:

public class Test {
    public static void main(String[] args) {
        System.out.println(SuperClass.value);
    }
}

class SuperClass {
    public static final  int value = 123;
    static {
        System.out.println("SuperClass init!");
    }
}

        运行上述代码,只是输出了123,"SuperClass init!"还是没有输出!这又是为什么呢?与代码演示2不同的是这里的value是个final修饰的的常量,在JVM中,常量在编译阶段就已经经过常量传播优化,将此常量值123放入了Test类的常量池中,以后对SuperClass.value的使用,都是直接从Test类的常量池中获取了,也就是说Test类中并不存在对SuperClass类的引用,在编译阶段它俩就没有任何联系了。

        可以通过查看Test类的字节码来证实这一说法(使用IDEA的Jclasslib插件),如下图,bipush 123字节码指令的意思就是将常量123压入操作数栈的栈顶。
在这里插入图片描述
为了增加说服力,再来看一下SuperClass类的字节码,如下图,看到没有,字节码指令直接就是获取静态变量(getstatic),并没有关于value字段赋值的字节码指令(getstatic),这是因为编译的时候已经把它放进Test类的常量池中了,SuperClass类就不再有对value常量的引用了。
在这里插入图片描述
我把代码改一下,来进行更加直观的比较:

public class Test {
    public static void main(String[] args) {
        System.out.println(SuperClass.value);
    }
}

class SuperClass {
    public static int value = 123;
    static {
        System.out.println("SuperClass init!");
    }
}

        上述代码中,我把value字段的final关键字去掉,变成了一个普通的静态字段,再来分别看一下Test类和SuperClass类的字节码,看看有什么变化:

Test类字节码:
在这里插入图片描述

SuperClass类字节码:
在这里插入图片描述
        可以看到,跟加了final关键字的代码的字节码完全相反了,此时SuperClass类便拥有对value变量的引用,并且存在于自己的常量池中,这样Test.value就要从SuperClass类的常量池中获取。

类加载的过程

        接下来我们会详细了解Java虚拟机中类加载的全过程, 即加载、 验证、 准备、 解析和初始化这五个阶段所执行的具体动作。1

加载

      “加载”(Loading)阶段主要干了以下几件事:

  1. 通过一个类的全限定名获取类编译后的class文件,准确地说是获取编译后该类的二进制字节码流,获取途径 包括但不限于磁盘文件(包括.class文件、zip包、jar包、war包等等)、网络(Web Applet)、JSP、 数据库、 内存或者动态产生(比如动态代理)等等;
  2. 将这个字节流所代表的的静态数据结构解析成运行时数据,即instanceKlass实例,存放在方法区;
  3. 在堆区生成该类的Class对象,即instanceMirrorKlass实例。

验证

       验证阶段以及之后的准备、解析阶段都属于连接阶段,“加载”(Loading)阶段与连接阶段的部分动作( 如一部分字节码文件格式验证动作) 是交叉进行的, 加载阶段尚未完成, 连接阶段可能已经开始, 但这些夹在加载阶段之中进行的动作, 仍然属于连接阶段的一部分, 这两个阶段的开始时间仍然保持着固定的先后顺序。比如“加载”(Loading)阶段中包括了字节码解析操作,而字节码解析的时候需要验证字节码格式,比如通过验证字节码中首个4字节数据是否是“0xcafebabe”来验证是否是一个合法的java字节码文件,而这里的验证正是连接阶段里的验证。
验证阶段是非常重要的,它是Java虚拟机保护自身安全的一项必要措施,验证阶段主要验证如下4部分内容(按顺序进行验证):

1、 文件格式验证

       这个阶段主要验证了class文件格式是否合法,比如魔数(“0xcafebabe”)验证、主、次版本号是否在当前Java虚拟机接收的范围之内、常量池中是否有非法的常量类型(tag)等等,想要详细了解的,可以参考周志明的《深入理解Java虚拟机:JVM高级特性与最佳实战》

2、元数据验证

       这个阶段是对类的元数据信息进行语义校验, 以保证其描述的信息符合《Java虚拟机规范》 的要求,这个阶段可能包括的验证点如下:

  • 这个类是否有父类(除了java.lang.Object之外, 所有的类都应当有父类) 。
  • 这个类的父类是否继承了不允许被继承的类(被final修饰的类) 。
  • 如果这个类不是抽象类, 是否实现了其父类或接口之中要求实现的所有方法。
  • 类中的字段、 方法是否与父类产生矛盾(例如覆盖了父类的final字段, 或者出现不符合规则的方法重载, 例如方法参数都一致, 但返回值类型却不同等)等等。
  • ……
3、字节码验证

       这个阶段主要是保证程序语义是合法的、符合逻辑的,主要对方法体(Class文件中Method_info中的Code属性)进行校验分析,例如:

  • 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作, 例如不会出现类似于“在操作栈放置了一个int类型的数据,
    使用时却按long类型来加载入本地变量表中”这样的情况。
  • 保证任何跳转指令都不会跳转到方法体以外的字节码指令上。
  • 保证方法体中的类型转换总是有效的, 例如可以把一个子类对象赋值给父类数据类型, 这是安全的, 但是把父类对象赋值给子类数据类型,
    甚至把对象赋值给与它毫无继承关系、 完全不相干的一个数据类型, 则是危险和不合法的
  • ……
4、符号引用验证

       这个阶段的校验行为发生在虚拟机将符号引用转化为直接引用的时候, 这个转化动作将在连接的第三阶段——解析阶段中发生,主要目的是为了保证解析行为能正常执行,主要校验一下内容:

  • 符号引用中通过字符串描述的全限定名是否能找到对应的类。
  • 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段。
  • 符号引用中的类、 字段、 方法的可访问性(private、 protected、 public、 )
    是否可被当前类访问。
  • ……

       如果无法通过符号引用的验证,JVM将会抛出如java.lang.IllegalAccessError、 java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等异常。

总结:

       验证阶段对于虚拟机的类加载机制来说, 是一个非常重要的、 但却不是必须要执行的阶段, 因为验证阶段只有通过或者不通过的差别(比如,Java用户自己手写了一个JVM,他完全可以忽略验证部分,只要他能保证运行在他缩写的JVM上的Java程序完全符合Java虚拟机规范,那就没问题,而实际上正是因为无法保证这一点,所以各种商用的JVM才会有验证这一阶段), 只要通过了验证, 其后就对程序运行期没有任何影响了。 如果程序运行的全部代码(包括自己编写的、 第三方包中的、 从外部加载的、 动态生成的等所有代码) 都已经被反复使用和验证过, 在生产环境的施阶段就可以考虑使用-Xverify: none参数来关闭大部分的类验证措施, 以缩短虚拟机类加载的时间。

准备

       准备阶段就是为类变量(静态变量,被static关键字修饰)分配内存并赋初值。这种变量存在于方法区,在JDK1.8之前的版本中,HotSpot虚拟机中的方法区由永久代来实现,而永久代存在于堆中,因此类的静态变量存在于堆区;在JDK1.8以及之后的版本,类的静态变量存放在InstanceMirrorKlass中(即Class对象),也是存放在堆区,所以我们常说的“类静态变量在方法区”是一种逻辑上的概念,而静态变量真正在JVM中是存放在Java堆这一物理内存中。
       对于在JDK1.8以及之后的版本,类的静态变量存放在InstanceMirrorKlass中这一说法,我们可以使用HSDB工具来进行验证,例如下面的代码:

public class Test {
    public static String str = "test";

    public static void main(String[] args) {
        while (true);
    }
}

       运行上述代码,通过jps命令查看Test的进程id,然后通过HSDB(关于HSDB的使用,这里不详细介绍,需要了解的自行在网上查资料)查看该类的内存结构如下图,可以看到str变量正是存在于Class对象中,而Class对象在JVM中使用InstanceMirrorKlass来描述的。
在这里插入图片描述
       关于准备阶段,需要注意的是,只有类变量才会在准备阶段分配内存和赋初值,而实例变量将会在对象实例化时随着对象一起分配在Java堆中,所以没有赋初值一说。这里的赋初值其实就是各种数据类型的零值,下表列出了所有类型的数据的零值。
在这里插入图片描述
       有一个例外,就是类变量如果被final修饰,在编译的时候会给类字段的字段属性表中添加ConstantValue属性,准备阶段直接完成赋值,即没有赋初值这一步。

       其实前面的代码演示中已经证实了这一点,这里为了更加直观的说明这个问题,我再通过查看字节码的方法来验证一下吧,对于下面的代码段:

public class Test {
    public static final String str = "test";
}

       查看Test的字节码如下图,可以看到ConstantValue属性的值就是“test”,这里是一个符号引用cp_info #7,指向了常量池中CONSTANT_Utf8_info类型的数据结构,它的值正是“test”。
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述
注意:
这里所说的赋初值是指类变量,即静态变量,也就是说类变量有一个默认的初始值,即便程序员不手动赋值,也不影响程序运行,但是如果是方法内的局部变量,则不会赋初值(即没有默认初始值),必须由程序员手动初始化。如下面的代码是无法运行的,在字节码的校验阶段就无法通过:

public static void main(String[] args) {
    int a;
    System.out.println(a); //提示:Variable 'a' might not have been initialized
}

解析

       在前面验证阶段的符号引用验证的介绍中,已经说明解析阶段的目的:将常量池中的符号引用转为直接引用。

1)概念解释
  • 直接引用:就是实际目标的内存地址。
  • 符号引用:指向class文件常量池中各种数据结构的一组符号,比如上面例子中的cp_info #7、cp_info #20就是符号引用。

       首先需要明白一个问题:为什么会有符号引用?

Java代码在进行Javac编译的时候, 并不像C和C++那样有“连接”这一步骤, 而是在虚拟机加载Class文件的时候进行动态连接(具体见第7章) 。 也就是说, 在Class文件中不会保存各个方法、 字段最终在内存中的布局信息, 这些字段、 方法的符号引用不经过虚拟机在运行期转换的话是无法得到真正的内存入口地址, 也就无法直接被虚拟机使用的。 当虚拟机做类加载时, 将会从常量池获得对应的符号引用, 再在类创建时或运行时解析、 翻译到具体的内存地址之中。

       这段解释是摘抄自《深入理解Java虚拟机:JVM高级特性与最佳实践》一书中,我个人的理解其实就是JVM在编译的时候,并不知道类中所引用的引用类型到底是什么,也不知道这个字段的访问权限是什么,比如下面的代码,编译时JVM不知道superClass的全限定名是什么,因为这时候SuperClass可能还没有被加载到虚拟机内存中,必须在运行时才会知道,因此需要用指向常量池的一个符号引用来代替,等到运行时,才会解析成SuperClass类的真正的内存地址。

public class Test {
    private SuperClass superClass;
}
2)ConstantPoolCache

       在解析时,经常会对同一个符号引用进行多次解析,因此JVM会将解析后的信息存储在ConstantPoolCache类实例中,下次再遇到对这个符号引用进行解析情况时,就不需要再次解析了,直接从ConstantPoolCache取出解析结果即可。

3)何时解析

       关于何时解析,《Java虚拟机规范》中并没有明确的规定,规范中只是强制规定了在ane-warray、checkcast、 getfield、 getstatic、 instanceof、 invokedynamic、 invokeinterface、 invoke-special、invokestatic、 invokevirtual、 ldc、 ldc_w、 ldc2_w、 multianewarray、 new、 putfield和putstatic这17个用于操作符号引用的字节码指令之前,先对它们所使用的的符号引用进行解析即可。这就给了Java开发者发挥的空间,我们先不管HotSpot虚拟机是如何实现的,先以我们自己的思路去想一下,解析其实就是符号引用->直接引用,那么我是不是有两种思路:

  • 在类加载器的“加载”(Loading)阶段,就将所有符号引用解析完成(这里的所有符号引用指的不仅仅是当前类加载器要加载的类中的符号引用,还有该类中过程中由于验证阶段的需要,再去加载其它类,比如加载父类、比如方法中定义一个引用类型等等,那么这些其它类中所有的符号引用也一并解析)
  • 啥时候使用这个符号引用,啥时候再解析;

       HotSpot虚拟机采用的是第二种思路,即在符号引用被使用的时候才取解析。

初始化

       在初始化阶段,Java虚拟机才真正开始执行Java程序代码,在准备阶段,会对类变量(静态变量)赋过初值(零值)了,那么在初始化阶段,就要根据Java程序对静态变量进行赋值,这时候的静态变量才是程序员想要看到的实际的值。
       从字节码层面,初始化阶段就是执行类构造器()方法,它是如何产生的?()由Javac编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块) 中的语句合并产生的, 编译器收集的顺序是由代码在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量, 定义在它之后的变量, 在前面的静态语句块可以赋值, 但是不能访问。例如如下代码,在编译时就无法编译通过,但是可以赋值。
在这里插入图片描述

1)类的初始化

       类的()方法在执行之前,会先执行该类的父类的()方法,因此,如果一个类有父类,则会根据继承链逐级向上找到最顶层的父类(java.lang.Object)的()方法,然后由java.lang.Object类沿着这个继承链向下开始逐级执行()方法,在Java虚拟机中第一个执行的()方法的类型用于是java.lang.Object。
       为了更形象的表达这个意思,用一张图片来表示:
在这里插入图片描述

2)接口的初始化

       接口中虽然不能有static{}语句块,但是可以有静态变量的赋值操作,但是与类不同,接口中的()方法执行的时候,不一定非要先执行其父接口的()方法,例外情况就是:只有当父接口中的静态变量被使用的时候才会先初始化父接口。此外,接口的实现类在初始化时也不会执行接口的()方法,但是也有例外情况:在jdk1.8中,如果一个接口中定义了默认方法(default修饰的方法),如果有这个接口的实现类进行了初始化,则需要在该实现类初始化之前先初始化该接口(执行接口的()方法)。这两个例外情况其实在本文开始时,类初始化时机的6个场景中的3和6中已经有介绍了。

3)多线程执行()方法

       如果多线程同时初始化一个类,即有多个线程同时想去执行该类的()方法,这就相当于多线程同时对静态变量进行赋值操作,这时候该怎么办呢?很显然,需要有同步控制手段,Java虚拟机这时候会对()方法加锁,以保证同一时刻只有一个线程执行()方法,其它线程阻塞等待。那么,你肯定会以为当前线程执行完()方法的时候,其它阻塞等待的线程被唤醒后,会继续执行()方法,其实不然,这是因为在同一个类加载器下,同一个类只会被加载一次。
       这个场景中有几个重点需要特别说明一下,我分别用代码来演示一下吧。

  • 重点1:多线程下java虚拟机会对()方法加锁,只有一个线程会执行,其它线程阻塞等待
    代码解释:使用while(true)模拟线程长时间执行
public class Test {
    public static void main(String[] args) {
        Runnable script = new Runnable() {
            public void run() {
                System.out.println(Thread.currentThread() + "start");
                DeadLoopClass dlc = new DeadLoopClass();
                System.out.println(Thread.currentThread() + " run over");
            }
        };
        Thread thread1 = new Thread(script, "thread1");
        Thread thread2 = new Thread(script, "thread2");
        thread1.start();
        thread2.start();
    }
}

class DeadLoopClass {
    static {
        //如果不加上这个if语句, 编译器将提示“Initializer does not complete normally”,并拒绝编译
        if (true) {
            System.out.println(Thread.currentThread() + "init DeadLoopClass");
            while (true) {
            }
        }
    }
}

       运行上述代码,结果如下图,通过该运行结果可以看到,只有thread2初始化了DeadLoopClass类,即只有thread2执行了()方法,thread1一直在阻塞。这其中会隐藏一个实际生产中的问题:如果一个类的()方法(对于程序员来说,基本上就是static{}代码块,静态变量赋值操作很快,不会耗时很久)中包含有耗时很长的操作,那么会造成多个线程长时间阻塞,从而导致系统性能变慢。

在这里插入图片描述
 

  • 重点2:当前线程执行完()方法的时候,其它阻塞等待的线程被唤醒后,不会继续执行()方法
    代码解释:使用Thread.sleep(5000);模拟当前线程执行一段时间后退出()方法,其它线程被唤醒
public class Test {
    public static void main(String[] args) {
        Runnable script = new Runnable() {
            public void run() {
                System.out.println(Thread.currentThread() + "start");
                DeadLoopClass dlc = new DeadLoopClass();
                System.out.println(Thread.currentThread() + " run over");
            }
        };
        Thread thread1 = new Thread(script, "thread1");
        Thread thread2 = new Thread(script, "thread2");
        thread1.start();
        thread2.start();
    }
}

class DeadLoopClass {
    static {
        //如果不加上这个if语句, 编译器将提示“Initializer does not complete normally”,并拒绝编译
        if (true) {
            System.out.println(Thread.currentThread() + "init DeadLoopClass");
            try {
                Thread.sleep(5000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

       运行上述代码,结果如下,发现当thread2执行()方法的时候,thread1线程阻塞,当5s之后,thread2线程执行完()方法,thread1会被唤醒,但是并没有接着执行()方法,而是直接跳过了()方法往下执行了,有没有感觉到很无法理解?但是如果了解JVM类加载器的话,可以想明白:同一个类加载器下,同一个类只会被加载一次。
在这里插入图片描述
 

  • 重点3:上述代码中,如果不加if判断,编译报错,这是为啥?
    解释:
           这涉及到Java虚拟机对static代码块中代码的检测机制,简单说,就是static中不能直接使用while循环和抛异常,这会导致类初始化失败,那加if为什么就可以了?很简单,java只检测最外面那层代码,就是说if里面的while不检测了。

解决办法以及原因:

  • 最容易想到的办法就是去掉while(true)代码;
  • 改成如下形式,这是因为虽然flag被赋值了true,但是编译器在编译static代码块的时候,并不知道flag的直接引用,而是把flag编译成了符号引用,所以是可以编译通过的。同样的,如果把flag定义成了常量,即前面加一个final关键字,这时候又编译失败了,这是因为常量flag的值true在编译的时候直接被放入了DeadLoopClass类的常量池中,所以static{}代码块中使用的flag直接被编译成了true,这时候while(flag)等价于while(true)。
class DeadLoopClass {
    static boolean flag = true;
    static {
        System.out.println(Thread.currentThread() + "init DeadLoopClass");
        while (flag) {
        }
    }
}

  1. 概念说明
    先说一下容易混淆的两个概念:类的加载和类加载过程中的“加载”阶段,以上7个阶段中的前5个阶段统称为类的加载过程,我们常说的类加载指得就是这个;而这5个阶段的第一步就是“加载”(Loading)阶段,所以本文在此之后所说的类加载指的是加载、 验证、 准备、 解析和初始化这五个阶段的统称,凡是需要表达类加载过程中的“加载”阶段,我都会用“加载”(Loading)表示。 ↩︎

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值