由浅入深知单例

一、何谓单例

“单例”这个词很好理解,就是说只能有一个实例,绝对不能有第二实例,否则就叫“双例”了。

二、走进单例

演进 1

为什么一定要写单例呢,创建一个非单例类的实例,用的时候只用这个一个实例可以吗?

class Dog {
   private String name;
}

public void main() {
    Dog d = new Dog;
}

如果多个类都会需要用这个实例,你需要走哪把这个实例带到哪儿呢,这样也太麻烦了。还有一点就是,你不能防止他人使用这个类的时候不小心多建了一个实例,这样容易破坏其全局唯一性。

演进 2

class Dog {
   private static Dog dog = new Dog();
   public static Dog getInstance() {
       return dog;
   }
}

public void main() {
    Dog d = Dog.getInstance();
}
  • 分析:他人调用时可以直接通过其 getInstance 方法获取实例,简捷方便。
  • 问题:他人还是可以通过 new Dog() 来创建新的实例,依然会破坏其全局唯一性。

演进 3

class Dog {
   private static Dog dog = new Dog();
   
   // 构造方法私有化
   private Dog() {}
   
   public static Dog getInstance() {
       return dog;
   }
}

public void main() {
    Dog d = Dog.getInstance();
}
  • 分析:我们添加了“构造方法私有化”的代码,这样就保证了这个类的全局唯一性。这个就是典型的“饿汉式单例模式”。
  • 问题:如果这个类的使用频率很高,或者说基本上每次打开应用程序都必然会用到,这样的写法没什么问题。如果程序运行后不一定会使用到,而且类很大、很耗费资源,如果一上来就在内存创建实例,这样就比较浪费了。我们是否可以用到的时候再创建其实例呢?

演进 4

class Dog {
   private static Dog dog = null;
   
   private Dog() {}
   
   public static Dog getInstance() {
        if (dog == null) {
            dog = new Dog();
        }
        return dog;
   }
}

public void main() {
    Dog d = Dog.getInstance();
}
  • 分析:这样做到了用时再创建实例,避免了不必要的浪费。
  • 问题:在单线程下这样的实现方法没有任何问题,如果在多线程下就可能出现并发问题,应用程序中可能出现多个实例,还是破坏了其全局唯一性。

演进 5

class Dog {
   private static Dog dog = null;
   
   private Dog() {}
   
   // 添加方法锁
   public static synchronized Dog getInstance() {
        if (dog == null) {
            dog = new Dog();
        }
        return dog;
   }
}

public void main() {
    Dog d = Dog.getInstance();
}
  • 分析:在这儿我们用 synchronized 关键字将方法锁定,同一时间只有一个线程可以进入该方法,这样就成功避免了多线程并发下会创建多个实例的问题。
  • 问题:感觉这样的实现还是太过于暴力,不管三七二十一完全不让别人进入,在实例已然创建的情况下,是不是可以让其进来直接把实例拿走呢。其实说白了,我们只是希望把创建过程保护起来。

演进 6

class Dog {
   private static Dog dog = null;
   
   private Dog() {}
   
   public static Dog getInstance() {
        if (dog == null) {
            synchronized (Dog.class) {
                dog = new Dog();
            }
        }
        return dog;
   }
}

public void main() {
    Dog d = Dog.getInstance();
}
  • 分析:这样的写法有了较好的性能,如果已经创建了实例,就不会再锁定了,直接可以返回对象实例。
  • 问题:如果多个线程进入 if 判断,则还是有可能会创建多个实例。

演进 7

class Dog {
   private static Dog dog = null;
   
   private Dog() {}
   
   public static Dog getInstance() {
        if (dog == null) {
            synchronized (Dog.class) {
                if (dog == null) { 
                    dog = new Dog();
                }
            }
        }
        return dog;
   }
}

public void main() {
    Dog d = Dog.getInstance();
}
  • 分析:即使多个线程都正好进入第一层 if,第二层 if 也能成功避免再次创建新的实例。这个就是典型的“懒汉式单例模式”。
  • 问题:大多数实现单例的时候会用这种典型的双重检查方式,其实这样的实现方式仍然是有问题的,这个需要涉及一个“指令重排”的问题。

演进 8

什么是“指令重排”呢?

在计算机执行指令的顺序在经过程序编译器编译之后形成的指令序列,一般而言,这个指令序列是会输出确定的结果;以确保每一次的执行都有确定的结果。但是,一般情况下,CPU和编译器为了提升程序执行的效率,会按照一定的规则允许进行指令优化。

为什么重排可以提高代码的执行效率?

大多数现代微处理器都会采用将指令乱序执行(out-of-order execution,简称 OoOE 或 OOE)的方法,在条件允许的情况下,直接运行当前有能力立即执行的后续指令,避开获取下一条指令所需数据时造成的等待。通过乱序执行的技术,处理器可以大大提高执行效率。
除了处理器,常见的 Java 运行时环境的 JIT 编译器也会做指令重排序操作,即生成的机器指令与字节码指令顺序不一致。

举个例子:

int a = 1;      // ①
int b = 1;      // ②
int c = a + b;  // ③

我们写代码在脑中构想或者进行断点调试都是这样一个语句一个语句执行的,但是 JVM 在执行的时候却不一定按这个顺序。在这里要说下 as-is-serial 这个东西,as-if-serial 是指在执行结果不会改变的情况下,JVM 为了提高程序的执行效率会对指令进行重排序。就像上面的程序,为了保证程序的正确性,③是一定要在①和②之后执行的,但是①和②则就没有依赖了,①和②谁先执行都不影响结果,所以JVM就可能会对它们进行重排序。as-if-serial 保证了单线程下程序执行结果的正确性。

再看看双重检查的代码:

class Dog {
   private static Dog dog = null;
   
   private Dog() {}
   
   public static Dog getInstance() {
        if (dog == null) {
            synchronized (Dog.class) {
                if (dog == null) { 
                    dog = new Dog();
                }
            }
        }
        return dog;
   }
}

synchronized 块里面的代码我们可以看成是单线程程序,因为同一时刻只有一个线程在执行,里面的语句很简单,就是判断是否为空,如果为空则构建对象,现在需要思考的是构建对象是否可能会被JVM重排序,很遗憾,dog = new Dog();这条语句并没有原子性,就像 i++;一样,它是被分为好几步完成的:

①分配空间给对象
②在空间内创建对象
③连接引用和对象

上面的语句中,②是依赖于①的,所以②在①之后执行,但是③和②不存在依赖性,也就是执行顺序可能是:①->③->②,如果是单线程的程序(真的只有一个线程可以访问到它们),如果后续程序使用到了 dog,JVM 会保证你使用 dog 的时候是初始化完成的,但是现在在 synchronized 块之外有其它线程“虎视眈眈”,获取到锁的线程如果按照①->③->②的顺序执行,那在执行③的时候会 store-write,即将值写回主内存,则其它线程会读到最新的 dog 值,而现在这个 dog 指向的是一个不完全的对象,即不安全对象,也不可用,使用这个对象是有危险的,此时构造对象的线程还没有释放锁,其它线程进行第一次检查的时候,dog == null 的结果是 false,会返回这个对象,造成程序的异常。

解决方案:说到指令重排序,我们很容易想到 volatile 关键字,volatile 禁止指令重排序,所以如果 dog 被 volatile 修饰的话,可以保证这个初始化的有序性。

class Dog {
   // 添加 volatile 关键字,解决指令重排的问题
   private static volatile Dog dog = null;
   
   private Dog() {}
   
   public static Dog getInstance() {
        if (dog == null) {
            synchronized (Dog.class) {
                if (dog == null) { 
                    dog = new Dog();
                }
            }
        }
        return dog;
   }
}

public void main() {
    Dog d = Dog.getInstance();
}

三、单例模式的其它形态

1、饿汉式变种

public class Singleton {

    private static Singleton instance;

    static {
        instance = new Singleton();
    }

    private Singleton() {}

    public static Singleton getInstance() {
        return instance;
    }
}

将类实例化的过程放在了静态代码块中,在类装载的时执行静态代码块中的代码,初始化类的实例。

2、静态内部类

public class Singleton {

    private Singleton() {}

    private static class SingletonInstance {
        private static final Singleton INSTANCE = new Singleton();
    }

    public static Singleton getInstance() {
        return SingletonInstance.INSTANCE;
    }
}

静态内部类的方式利用了类装载机制来保证线程安全,只有在第一次调用 getInstance 方法时,才会装载 SingletonInstance 内部类,完成 Singleton 的实例化,所以也有懒加载的效果。

优点:避免了线程不安全,延迟加载,效率高。

3、枚举单例模式

public enum Singleton {
    INSTANCE;
    public void doSomething() {}
}

main() {
    Singleton.INSTANCE.doSomething():
}

这种写法线程安全且写法简单,而且能防止反序列化重新创建新的对象。

一般推荐使用枚举创建单例。

四、单例模式的安全性

单例模式的目标是,任何时候该类都只有唯一的一个对象。但是上面我们写的大部分单例模式都存在漏洞,被攻击时会产生多个对象,破坏了单例模式。

1、序列化攻击

Java 序列化机制

java.io.ObjectOutputStream 是 Java 实现序列化的关键类,它可以将一个对象转换成二进制流,然后可以通过 ObjectInputStream 将二进制流还原成对象。

序列化攻击的要点:
  • 需要序列化的类必须实现 java.io.Serializable 接口,否则会抛出 NotSerializableException 异常
  • 若没有显示地声明一个 serialVersionUID 变量,Java 序列化机制会根据编译时的class自动生成一个 serialVersionUID 作为序列化版本比较(验证一致性),如果检测到反序列化后的类的 serialVersionUID 和对象二进制流的 serialVersionUID 不同,则会抛出异常
  • Java 的序列化会将一个类包含的引用中所有的成员变量保存下来(深度复制),所以里面的引用类型必须也要实现 java.io.Serializable 接口
  • 当某个字段被声明为 transient 后,默认序列化机制就会忽略该字段,反序列化后自动获得 0 或者 null 值
  • 静态成员不参与序列化
  • 每个类可以实现 readObject、writeObject 方法实现自己的序列化策略,即使是 transient 修饰的成员变量也可以手动调用 ObjectOutputStream 的 writeInt 等方法将这个成员变量序列化。
  • 任何一个 readObject 方法,不管是显式的还是默认的,它都会返回一个新建的实例,这个新建的实例不同于该类初始化时创建的实例
  • 每个类可以实现 private Object readResolve() 方法,在调用 readObject 方法之后,如果存在 readResolve 方法则自动调用该方法,readResolve 将对 readObject 的结果进行处理,而最终 readResolve 的处理结果将作为 readObject 的结果返回。readResolve 的目的是保护性恢复对象,其最重要的应用就是保护性恢复单例、枚举类型的对象
  • Serializable 接口是一个标记接口,可自动实现序列化,而 Externalizable 继承自 Serializable,它强制必须手动实现序列化和反序列化算法,相对来说更加高效
序列化攻击实例
public class HungrySingleton {
    private static final HungrySingleton instance = new HungrySingleton();
    private HungrySingleton() {
    }
    public static HungrySingleton getInstance() {
        return instance;
    }

    public static void main(String[] args) throws IOException, ClassNotFoundException {
        HungrySingleton singleton = HungrySingleton.getInstance();
        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("singleton_file"));
        oos.writeObject(singleton); // 序列化

        ObjectInputStream ois = new ObjectInputStream(new FileInputStream("singleton_file"));
        HungrySingleton newSingleton = (HungrySingleton) ois.readObject(); // 反序列化

        System.out.println(singleton);
        System.out.println(newSingleton);
        System.out.println(singleton == newSingleton);
    }
}

输出:

com.singleton.HungrySingleton@ed17bee
com.singleton.HungrySingleton@46f5f779
false
序列化攻击解决方案

根据上面对 Java 序列化机制的复习,我们可以自定义一个 readResolve,在其中返回类的单例对象,替换掉 readObject 方法反序列化生成的对象,让我们自己写的单例模式实现保护性恢复对象。

public class HungrySingleton implements Serializable {
    private static final HungrySingleton instance = new HungrySingleton();
    private HungrySingleton() {
    }
    public static HungrySingleton getInstance() {
        return instance;
    }

    private Object readResolve() {
        return instance;
    }

    public static void main(String[] args) throws IOException, ClassNotFoundException {
        HungrySingleton singleton = HungrySingleton.getInstance();
        ObjectInputStream ois = new ObjectInputStream(new FileInputStream("singleton_file"));
        HungrySingleton newSingleton = (HungrySingleton) ois.readObject();

        System.out.println(singleton);
        System.out.println(newSingleton);
        System.out.println(singleton == newSingleton);
    }
}

输出:

com.singleton.HungrySingleton@24273305
com.singleton.HungrySingleton@24273305
true

2、反射攻击

在单例模式中,构造器都是私有的,而反射可以通过构造器对象调用 setAccessible(true) 来获得权限,这样就可以创建多个对象,来破坏单例模式了。

public class HungrySingleton {
    private static final HungrySingleton instance = new HungrySingleton();

    private HungrySingleton() {
    }

    public static HungrySingleton getInstance() {
        return instance;
    }

    public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
        HungrySingleton instance = HungrySingleton.getInstance();
        Constructor constructor = HungrySingleton.class.getDeclaredConstructor();
        constructor.setAccessible(true);    // 获得权限
        HungrySingleton newInstance = (HungrySingleton) constructor.newInstance();

        System.out.println(instance);
        System.out.println(newInstance);
        System.out.println(instance == newInstance);
    }
}

输出:

com.singleton.HungrySingleton@3b192d32
com.singleton.HungrySingleton@16f65612
false
反射攻击解决方案

反射是通过它的 Class 对象来调用构造器创建新的对象,我们只需要在构造器中检测并抛出异常就可以达到目的了。

private HungrySingleton() {
    // instance 不为空,说明单例对象已经存在
    if (instance != null) {
        throw new RuntimeException("单例模式禁止反射调用!");
    }
}

运行结果:

Exception in thread "main" java.lang.reflect.InvocationTargetException
    at sun.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)
    at sun.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:62)
    at sun.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45)
    at java.lang.reflect.Constructor.newInstance(Constructor.java:423)
    at com.singleton.HungrySingleton.main(HungrySingleton.java:32)
Caused by: java.lang.RuntimeException: 单例模式禁止反射调用!
    at com.singleton.HungrySingleton.<init>(HungrySingleton.java:20)
    ... 5 more

注意,上述方法针对饿汉式单例模式是有效的,但对懒汉式的单例模式是无效的,懒汉式的单例模式是无法避免反射攻击的!

为什么对饿汉有效,对懒汉无效?
因为饿汉的初始化是在类加载的时候,反射一定是在饿汉初始化之后才能使用;而懒汉是在第一次调用 getInstance() 方法的时候才初始化,我们无法控制反射和懒汉初始化的先后顺序,如果反射在前,不管反射创建了多少对象,instance 都将一直为null,直到调用 getInstance()。

事实上,实现单例模式的推荐方法是使用枚举类。

3、枚举

package com.singleton;

import java.io.*;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;

public enum SerEnumSingleton implements Serializable {
    INSTANCE;   // 单例对象
    private String content;

    public String getContent() {
        return content;
    }

    public void setContent(String content) {
        this.content = content;
    }

    private SerEnumSingleton() {
    }

    public static void main(String[] args) throws IOException, ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
        SerEnumSingleton singleton1 = SerEnumSingleton.INSTANCE;
        singleton1.setContent("枚举单例序列化");
        System.out.println("枚举序列化前读取其中的内容:" + singleton1.getContent());
        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("SerEnumSingleton.obj"));
        oos.writeObject(singleton1);
        oos.flush();
        oos.close();

        FileInputStream fis = new FileInputStream("SerEnumSingleton.obj");
        ObjectInputStream ois = new ObjectInputStream(fis);
        SerEnumSingleton singleton2 = (SerEnumSingleton) ois.readObject();
        ois.close();
        System.out.println(singleton1 + "\n" + singleton2);
        System.out.println("枚举序列化后读取其中的内容:" + singleton2.getContent());
        System.out.println("枚举序列化前后两个是否同一个:" + (singleton1 == singleton2));

        Constructor<SerEnumSingleton> constructor = SerEnumSingleton.class.getDeclaredConstructor();
        constructor.setAccessible(true);
        SerEnumSingleton singleton3 = constructor.newInstance(); // 通过反射创建对象
        System.out.println("反射后读取其中的内容:" + singleton3.getContent());
        System.out.println("反射前后两个是否同一个:" + (singleton1 == singleton3));
    }
}

输出:

枚举序列化前读取其中的内容:枚举单例序列化
INSTANCE
INSTANCE
枚举序列化后读取其中的内容:枚举单例序列化
枚举序列化前后两个是否同一个:true
Exception in thread "main" java.lang.NoSuchMethodException: com.singleton.SerEnumSingleton.<init>()
    at java.lang.Class.getConstructor0(Class.java:3082)
    at java.lang.Class.getDeclaredConstructor(Class.java:2178)
    at com.singleton.SerEnumSingleton.main(SerEnumSingleton.java:39)

序列化前后的对象是同一个对象,而反射的时候抛出了异常。

反编译得到下面的代码:

package com.singleton;

import java.io.*;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;

public final class SerEnumSingleton extends Enum
    implements Serializable
{

    public static SerEnumSingleton[] values()
    {
        return (SerEnumSingleton[])$VALUES.clone();
    }

    public static SerEnumSingleton valueOf(String name)
    {
        return (SerEnumSingleton)Enum.valueOf(com/singleton/SerEnumSingleton, name);
    }

    public String getContent()
    {
        return content;
    }

    public void setContent(String content)
    {
        this.content = content;
    }

    private SerEnumSingleton(String s, int i)
    {
        super(s, i);
    }

    public static void main(String args[])
        throws IOException, ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException
    {
        SerEnumSingleton singleton1 = INSTANCE;
        singleton1.setContent("\u679A\u4E3E\u5355\u4F8B\u5E8F\u5217\u5316");
        System.out.println((new StringBuilder()).append("\u679A\u4E3E\u5E8F\u5217\u5316\u524D\u8BFB\u53D6\u5176\u4E2D\u7684\u5185\u5BB9\uFF1A").append(singleton1.getContent()).toString());
        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("SerEnumSingleton.obj"));
        oos.writeObject(singleton1);
        oos.flush();
        oos.close();
        FileInputStream fis = new FileInputStream("SerEnumSingleton.obj");
        ObjectInputStream ois = new ObjectInputStream(fis);
        SerEnumSingleton singleton2 = (SerEnumSingleton)ois.readObject();
        ois.close();
        System.out.println((new StringBuilder()).append(singleton1).append("\n").append(singleton2).toString());
        System.out.println((new StringBuilder()).append("\u679A\u4E3E\u5E8F\u5217\u5316\u540E\u8BFB\u53D6\u5176\u4E2D\u7684\u5185\u5BB9\uFF1A").append(singleton2.getContent()).toString());
        System.out.println((new StringBuilder()).append("\u679A\u4E3E\u5E8F\u5217\u5316\u524D\u540E\u4E24\u4E2A\u662F\u5426\u540C\u4E00\u4E2A\uFF1A").append(singleton1 == singleton2).toString());
        Constructor constructor = com/singleton/SerEnumSingleton.getDeclaredConstructor(new Class[0]);
        constructor.setAccessible(true);
        SerEnumSingleton singleton3 = (SerEnumSingleton)constructor.newInstance(new Object[0]);
        System.out.println((new StringBuilder()).append("\u53CD\u5C04\u540E\u8BFB\u53D6\u5176\u4E2D\u7684\u5185\u5BB9\uFF1A").append(singleton3.getContent()).toString());
        System.out.println((new StringBuilder()).append("\u53CD\u5C04\u524D\u540E\u4E24\u4E2A\u662F\u5426\u540C\u4E00\u4E2A\uFF1A").append(singleton1 == singleton3).toString());
    }

    public static final SerEnumSingleton INSTANCE;
    private String content;
    private static final SerEnumSingleton $VALUES[];

    static 
    {
        INSTANCE = new SerEnumSingleton("INSTANCE", 0);
        $VALUES = (new SerEnumSingleton[] {
            INSTANCE
        });
    }
}

通过反编译后代码我们可以看到,public final class T extends Enum,说明,当我们使用enmu来定义一个枚举类型的时候,编译器会自动帮我们创建一个final类型的类继承Enum类,所以枚举类型不能被继承。

为什么使用枚举:
1、枚举单例写法简单
2、线程安全&懒加载

代码中 INSTANCE 变量被 public static final 修饰,因为 static 类型的属性是在类加载之后初始化的,JVM 可以保证线程安全;且 Java 类是在引用到的时候才进行类加载,所以枚举单例也有懒加载的效果。

3、枚举自己能避免序列化攻击

为了保证枚举类型像 Java 规范中所说的那样,每一个枚举类型极其定义的枚举变量在JVM中都是唯一的,在枚举类型的序列化和反序列化上,Java 做了特殊的规定。

在序列化的时候 Java 仅仅是将枚举对象的 name 属性输出到结果中,反序列化的时候则是通过 java.lang.Enum的valueOf 方法来根据名字查找枚举对象。同时,编译器是不允许任何对这种序列化机制的定制,因此禁用了writeObject、readObject、readObjectNoData、writeReplace 和 readResolve 等方法。

4、枚举能够避免反射攻击,因为反射不支持创建枚举对象

Constructor 类的 newInstance 方法中会判断是否为 enum,若是会抛出异常:

    @CallerSensitive
    public T newInstance(Object ... initargs) throws InstantiationException, IllegalAccessException, IllegalArgumentException, InvocationTargetException {
        if (!override) {
            if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) {
                Class<?> caller = Reflection.getCallerClass();
                checkAccess(caller, clazz, null, modifiers);
            }
        }
        // 不能为 ENUM,否则抛出异常:不能通过反射创建 enum 对象
        if ((clazz.getModifiers() & Modifier.ENUM) != 0)
            throw new IllegalArgumentException("Cannot reflectively create enum objects");
        ConstructorAccessor ca = constructorAccessor;   // read volatile
        if (ca == null) {
            ca = acquireConstructorAccessor();
        }
        @SuppressWarnings("unchecked")
        T inst = (T) ca.newInstance(initargs);
        return inst;
    }

五、对比单例和静态类的区别

用静态类的方式也可以实现“单个实例”,因为其在整个程序中只有一份,那他们有什么区别呢?我们应该怎样抉择呢?

静态类优点:由于是在编译期绑定的,所以静态类比较快。

静态类缺点:

  1. 静态类不可以延迟加载,它是在 JVM 加载时进行初始化,如果是一个非常重的对象就不太适合了。
  2. 不可以在创建实例时初始化一些东西。
  3. 不能继承普通类和实现接口。
  4. 不能覆写父类方法。
  5. 不支持多态

静态类使用场景:

  • 如果想使用一些工具方法,最好用静态类。由于静态类的绑定是在编译期,所以静态类比单例类更快。而且可以解决在并发的情况下多个线程并行修改时容易导致不容易发现的 race condition。
  • 永恒不变的,比如 Math 类中的方法都是永恒不变的,除非人类的数学成果重新来过。

单例模式使用场景:

  • 如果要维护状态信息时,应该选择单例。
  • 使用面向对象能力时,应该选择单例。
  • 创建实例时初始化一些东西,应该使用单例。

六、总结

介绍了这么多单例的写法演变,但总得来说其主要目的有 2 个:

  1. 维护其实例的“独一无二”性。
  2. 性能提升。

单例模式作为一种目标明确、结构简单、理解容易的设计模式,在软件开发中使用频率相当高,在很多应用软件和框架中都得以广泛应用,还是很有必要去学习掌握的。

参考

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值