全方位分析单例模式,看这篇文章就够了

带着问题去学习

首先,我们带着几个问题,来学习下单例模式
1.如何实现一个线程安全的单例模式?
2.那是如何做到线程安全的呢?
3.你知道如何破坏单例吗?
接下来我们就跟着这几个问题来探索下单例模式

啥是单例模式

单例模式是指,确保一个类在任何情况下都有一个实例,并且提供一个全局访问点。

隐藏其所有的构造方法,属于创建型模式

1. 饿汉式单例

在单例类首次加载的时候就创建实例

缺点:

  1. 不管用不用,首次加载的时候就创建了实例,浪费内存空间。
public class HungrySingleton {
    private static final HungrySingleton HUNGRY_SINGLETON = new HungrySingleton();
    private HungrySingleton(){};
    public static HungrySingleton getInstance(){
        return HUNGRY_SINGLETON;
    }
    public void call(){
        System.out.println("hello");
    }
}

public class HungrySingletionTest {
    public static void main(String[] args) {
        HungrySingleton hungrySingleton = HungrySingleton.getInstance();
        hungrySingleton.call();
    }
}


2. 懒汉式单例模式

被外部调用的时候才创建实例

3. 非线程安全的实现单例方式

//这种方案是线程不安全的
public class LazySimpleSingleton {
    private LazySimpleSingleton(){};
    private static LazySimpleSingleton lazySimpleSingleton = null;
    public static LazySimpleSingleton getInstance(){
        if (lazySimpleSingleton == null){
            lazySimpleSingleton = new LazySimpleSingleton();
        }
        return lazySimpleSingleton;
    }
}

//写两个线程测试一下
public class ExectorThread implements Runnable{
    public void run() {
        LazySimpleSingleton lazy = LazySimpleSingleton.getInstance();
        System.out.println(Thread.currentThread().getName()+":"+lazy);
    }
}

//测试方法
public class LazySimpleSingletonTest {
    public static void main(String[] args) {
        Thread t1 = new Thread(new ExectorThread());
        Thread t2 = new Thread(new ExectorThread());
        t1.start();
        t2.start();
    }
}

执行测试方法,多执行几次,我们会发现,有一定几率 LazySimpleSingleton 被创建了两次,这也就是意味着上面的单例存在安全隐患

打印结果:

Connected to the target VM, address: '127.0.0.1:50008', transport: 'socket'
Thread-0:singleton.LazySimpleSingleton@26e865da
Thread-1:singleton.LazySimpleSingleton@69ab3312
Disconnected from the target VM, address: '127.0.0.1:50008', transport: 'socket'

4. 加锁实现线程安全的实现单例方式

那么如何优化代码,使懒汉模式的单例在线程环境下安全呢? 给 getInstance() 方法加上 synchronize 关键字

//加锁的单例模式
public class LazySimpleSingletonSyn {
    private LazySimpleSingletonSyn(){};
    private static LazySimpleSingletonSyn lazySimpleSingleton = null;
    public synchronized static LazySimpleSingletonSyn getInstance(){
        if (lazySimpleSingleton == null){
            lazySimpleSingleton = new LazySimpleSingletonSyn();
        }
        return lazySimpleSingleton;
    }
}
//进行debug时,我们会发现,当其中一个线程执行并且调用 getInstance() 方法时,另一个线程再调用getInstance() 方法,线程会由 RUNNING 变成了 MONITOR 出现了阻塞;直到第一个线程执行完,第二个线程才恢复 RUNNING 

虽然使用 Synchronize 解决了线程安全问题,但是用 Synchronize 加锁,在线程数量较多的情况下,如果CPU分配压力上升,会导致大量线程出现阻塞,从而导致程序运行性能下降;

5. 双重检查线程安全的实现单例方式

如何既兼顾线程安全又能提升程序能能?我们在看代码

//双重检查锁的单例方式
public class LazySimpleSingletonDouble {
    private LazySimpleSingletonDouble(){};
    private static LazySimpleSingletonDouble lazySimpleSingleton = null;
    public static LazySimpleSingletonDouble getInstance(){
        if (lazySimpleSingleton == null){
            synchronized(LazySimpleSingletonDouble.class){
                if (lazySimpleSingleton == null){
                    lazySimpleSingleton = new LazySimpleSingletonDouble();
                }
            }
        }
        return lazySimpleSingleton;
    }
}
//当第一个线程调用 getInstance() 方法时,第二个线程也调用 getInstance() ;当第一个线程执行到 Synchronize 时,第二个线程会变成  MONITOR 状态,此时的阻塞不是基于整个 LazySimpleSingleton 类的阻塞,而是在 getInstance() 方法内部阻塞。只要逻辑不复杂,对于调用者而言感知不到

用了双重检查锁的方式虽然对性能做了优化;但是 synchronized 关键字,总归是加锁,对程序的性能还是存在一定影响的;

6. 内部类的实现单例方式

下面我们在看一种使用内部类实现的方式,利用类的加载顺序,内部类是延时加载的,也就是说只会在第一次使用时加载,不使用就不加载。

public class LazySimpleSingletonInner {
    private LazySimpleSingletonInner(){};

    //final 保证这个方法不被重写/重载
    public static final LazySimpleSingletonInner getInstance(){
        return LazyHolder.lazy;
    }

    //不使用不加载
    private static class LazyHolder{
        private static final LazySimpleSingletonInner lazy = new LazySimpleSingletonInner();
    }
}

这种实现方式即兼顾饿汉的内存浪费,也兼顾了 Synchronize 性能问题;内部类一定是要在方法调用之前初始化的,巧妙的避开了线程安全问题。

7. 反射破坏单例

上面介绍的单例模式,构造方法都加上了 private 修饰,如果我们用反射来调用构造反方,然后在调用 getInstance() 方法;就会生成两种不同的实例,下面看代码。

//用反射创建 
public void test5(){
        Class<LazySimpleSingleton> clazz = LazySimpleSingleton.class;
        try {
            Constructor<LazySimpleSingleton> c = clazz.getDeclaredConstructor(null);
            c.setAccessible(true);
            LazySimpleSingleton lazySimpleSingleton = c.newInstance();
            LazySimpleSingleton lazySimpleSingleton2 = LazySimpleSingleton.getInstance();
            System.out.println(lazySimpleSingleton);
            System.out.println(lazySimpleSingleton2);
            System.out.println(lazySimpleSingleton == lazySimpleSingleton2);
        } catch (Exception e) {
            e.printStackTrace();
        }
	}

输出结果,显然创建了两种不同的是实例

singleton.LazySimpleSingletonInner@6aa8ceb6
singleton.LazySimpleSingletonInner@2530c12
false

现在我们对其做一些限制,一旦多次重复创建,直接抛出异常,这样通过反射来创建对象时会抛出异常,来看代码

public class LazySimpleSingletonInner {
    private LazySimpleSingletonInner(){};

    //final 保证这个方法不被重写/重载
    public static final LazySimpleSingletonInner getInstance(){
        if (LazyHolder.lazy != null){
            throw new RuntimeException("不可以重复创建多个实例");
        }
        return LazyHolder.lazy;
    }

    //不使用不加载
    private static class LazyHolder{
        private static final LazySimpleSingletonInner lazy = new LazySimpleSingletonInner();
    }
}

输出结果

java.lang.RuntimeException: 不可以重复创建多个实例
	at singleton.LazySimpleSingletonInner.getInstance(LazySimpleSingletonInner.java:9)
	at singleton.LazySimpleSingletonTest.test5(LazySimpleSingletonTest.java:43)
	at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
	at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.lang.reflect.Method.invoke(Method.java:498)
	at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:50)
	at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
	at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:47)
	at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
	at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:325)
	at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:78)
	at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:57)
	at org.junit.runners.ParentRunner$3.run(ParentRunner.java:290)
	at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:71)
	at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288)
	at org.junit.runners.ParentRunner.access$000(ParentRunner.java:58)
	at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:268)
	at org.junit.runners.ParentRunner.run(ParentRunner.java:363)
	at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
	at com.intellij.junit4.JUnit4IdeaTestRunner.startRunnerWithArgs(JUnit4IdeaTestRunner.java:68)
	at com.intellij.rt.junit.IdeaTestRunner$Repeater.startRunnerWithArgs(IdeaTestRunner.java:33)
	at com.intellij.rt.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:230)
	at com.intellij.rt.junit.JUnitStarter.main(JUnitStarter.java:58)

8. 序列化破坏单例

当我们一个单例对象创建好,有时候需要将对象序列化写入磁盘,下次使用的时候再读区对象,反序列化为内存对象。反序列化后的对象会重新分配内存,即重新创建,那么如果序列化的目标对象是单例对象,就违背了单例模式的初衷,相当于破坏了单例,下面看代码和输出结果

public class SeriableSingeton implements Serializable {
    //序列化就是说把内存中的状态通过转换成字节码的形式
    //从而转换成一个IO流,写入到其他地方,网络、磁盘、IO
    //内存中的状态被永久保存下来

    //反序列化,将已经持久化的自己码内容,转换成IO流
    //通过IO流读取,进而将读取的内容转换成Java对象
    //在转换过程中会重新创建对象 new
    public final static SeriableSingeton INSTANCE = new SeriableSingeton();
    private SeriableSingeton(){};
    public static final SeriableSingeton getInstance(){
        return INSTANCE;
    }
}

public class SeriableSingetonTest {
    public static void main(String[] args) {
        SeriableSingeton s1 = null;
        SeriableSingeton s2 = SeriableSingeton.getInstance();
        FileOutputStream outputStream = null;
        try {
            outputStream = new FileOutputStream("SeriableSingeton.obj");
            ObjectOutputStream oos = new ObjectOutputStream(outputStream);
            oos.writeObject(s2);
            oos.flush();
            oos.close();
            FileInputStream fis = new FileInputStream("SeriableSingeton.obj");
            ObjectInputStream ois = new ObjectInputStream(fis);
            s1 = (SeriableSingeton) ois.readObject();
            System.out.println(s1);
            System.out.println(s2);
            System.out.println(s1 == s2);
        }catch (Exception e){
            e.printStackTrace();
        }
    }
}

输出结果

singleton.SeriableSingeton@4783da3f
singleton.SeriableSingeton@279f2327
false

从运行结果看出,反序列化的对象和手动创建的对象是不一致的,实例化了两次,违背了单例设计初衷。那么如果保证序列化的情况下也能够实现单例,只需要增加 readResolve() 方法即可,下面看代码。

// 增加了 readResolve() 方法
public class SeriableSingeton implements Serializable {
    //序列化就是说把内存中的状态通过转换成字节码的形式
    //从而转换成一个IO流,写入到其他地方,网络、磁盘、IO
    //内存中的状态被永久保存下来

    //反序列化,将已经持久化的自己码内容,转换成IO流
    //通过IO流读取,进而将读取的内容转换成Java对象
    //在转换过程中会重新创建对象 new
    public final static SeriableSingeton INSTANCE = new SeriableSingeton();
    private SeriableSingeton(){};
    public static final SeriableSingeton getInstance(){
        return INSTANCE;
    }
    //重写readResolve方法,只不过是覆盖了反序列化出来的对象
    //还是创建了两次,发生在JVM层面,相对比较安全
    //之前反序列化出来的对象会被GC回收
    public Object readResolve(){
        return INSTANCE;
    }
}

//测试类
public static void main(String[] args) {
    SeriableSingeton s1 = null;
    SeriableSingeton s2 = SeriableSingeton.getInstance();
    FileOutputStream outputStream = null;
    try {
      outputStream = new FileOutputStream("SeriableSingeton.obj");
      ObjectOutputStream oos = new ObjectOutputStream(outputStream);
      oos.writeObject(s2);
      oos.flush();
      oos.close();
      FileInputStream fis = new FileInputStream("SeriableSingeton.obj");
      ObjectInputStream ois = new ObjectInputStream(fis);
      s1 = (SeriableSingeton) ois.readObject();
      System.out.println(s1);
      System.out.println(s2);
      System.out.println(s1 == s2);
    }catch (Exception e){
      e.printStackTrace();
    }
}

输出结果

singleton.SeriableSingeton@279f2327
singleton.SeriableSingeton@279f2327
true

为什么呢?

在JDK的源码中,进入 ObjectInputStream 类的 readObject() 方法

private final Object readObject(Class<?> type)
        throws IOException, ClassNotFoundException
    {
        if (enableOverride) {
            return readObjectOverride();
        }

        if (! (type == Object.class || type == String.class))
            throw new AssertionError("internal error");

        // if nested read, passHandle contains handle of enclosing object
        int outerHandle = passHandle;
        try {
            Object obj = readObject0(type, false);
            handles.markDependency(outerHandle, passHandle);
            ClassNotFoundException ex = handles.lookupException(passHandle);
            if (ex != null) {
                throw ex;
            }
            if (depth == 0) {
                vlist.doCallbacks();
            }
            return obj;
        } finally {
            passHandle = outerHandle;
            if (closed && depth == 0) {
                clear();
            }
        }
    }

在进入 readObjec0() 方法

    private Object readObject0(Class<?> type, boolean unshared) throws IOException {
        boolean oldMode = bin.getBlockDataMode();
        if (oldMode) {
            int remain = bin.currentBlockRemaining();
            if (remain > 0) {
                throw new OptionalDataException(remain);
            } else if (defaultDataEnd) {
                /*
                 * Fix for 4360508: stream is currently at the end of a field
                 * value block written via default serialization; since there
                 * is no terminating TC_ENDBLOCKDATA tag, simulate
                 * end-of-custom-data behavior explicitly.
                 */
                throw new OptionalDataException(true);
            }
            bin.setBlockDataMode(false);
        }

        byte tc;
        while ((tc = bin.peekByte()) == TC_RESET) {
            bin.readByte();
            handleReset();
        }

        depth++;
        totalObjectRefs++;
        try {
            switch (tc) {
                case TC_NULL:
                    return readNull();

                case TC_REFERENCE:
                    // check the type of the existing object
                    return type.cast(readHandle(unshared));

                case TC_CLASS:
                    if (type == String.class) {
                        throw new ClassCastException("Cannot cast a class to java.lang.String");
                    }
                    return readClass(unshared);

                case TC_CLASSDESC:
                case TC_PROXYCLASSDESC:
                    if (type == String.class) {
                        throw new ClassCastException("Cannot cast a class to java.lang.String");
                    }
                    return readClassDesc(unshared);

                case TC_STRING:
                case TC_LONGSTRING:
                    return checkResolve(readString(unshared));

                case TC_ARRAY:
                    if (type == String.class) {
                        throw new ClassCastException("Cannot cast an array to java.lang.String");
                    }
                    return checkResolve(readArray(unshared));

                case TC_ENUM:
                    if (type == String.class) {
                        throw new ClassCastException("Cannot cast an enum to java.lang.String");
                    }
                    return checkResolve(readEnum(unshared));

                case TC_OBJECT:
                    if (type == String.class) {
                        throw new ClassCastException("Cannot cast an object to java.lang.String");
                    }
                    return checkResolve(readOrdinaryObject(unshared));

                case TC_EXCEPTION:
                    if (type == String.class) {
                        throw new ClassCastException("Cannot cast an exception to java.lang.String");
                    }
                    IOException ex = readFatalException();
                    throw new WriteAbortedException("writing aborted", ex);

                case TC_BLOCKDATA:
                case TC_BLOCKDATALONG:
                    if (oldMode) {
                        bin.setBlockDataMode(true);
                        bin.peek();             // force header read
                        throw new OptionalDataException(
                            bin.currentBlockRemaining());
                    } else {
                        throw new StreamCorruptedException(
                            "unexpected block data");
                    }

                case TC_ENDBLOCKDATA:
                    if (oldMode) {
                        throw new OptionalDataException(true);
                    } else {
                        throw new StreamCorruptedException(
                            "unexpected end of block data");
                    }

                default:
                    throw new StreamCorruptedException(
                        String.format("invalid type code: %02X", tc));
            }
        } finally {
            depth--;
            bin.setBlockDataMode(oldMode);
        }
    }

看到TC_OBJECT中,调用了 readOrdinaryObject() 方法

   private Object readOrdinaryObject(boolean unshared)
        throws IOException
    {
        if (bin.readByte() != TC_OBJECT) {
            throw new InternalError();
        }

        ObjectStreamClass desc = readClassDesc(false);
        desc.checkDeserialize();

        Class<?> cl = desc.forClass();
        if (cl == String.class || cl == Class.class
                || cl == ObjectStreamClass.class) {
            throw new InvalidClassException("invalid class descriptor");
        }

        Object obj;
        try {
            obj = desc.isInstantiable() ? desc.newInstance() : null;
        } catch (Exception ex) {
            throw (IOException) new InvalidClassException(
                desc.forClass().getName(),
                "unable to create instance").initCause(ex);
        }

        passHandle = handles.assign(unshared ? unsharedMarker : obj);
        ClassNotFoundException resolveEx = desc.getResolveException();
        if (resolveEx != null) {
            handles.markException(passHandle, resolveEx);
        }

        if (desc.isExternalizable()) {
            readExternalData((Externalizable) obj, desc);
        } else {
            readSerialData(obj, desc);
        }

        handles.finish(passHandle);

        if (obj != null &&
            handles.lookupException(passHandle) == null &&
            desc.hasReadResolveMethod())
        {
            Object rep = desc.invokeReadResolve(obj);
            if (unshared && rep.getClass().isArray()) {
                rep = cloneArray(rep);
            }
            if (rep != obj) {
                // Filter the replacement object
                if (rep != null) {
                    if (rep.getClass().isArray()) {
                        filterCheck(rep.getClass(), Array.getLength(rep));
                    } else {
                        filterCheck(rep.getClass(), -1);
                    }
                }
                handles.setObject(passHandle, obj = rep);
            }
        }

        return obj;
    }

发现 obj = desc.isInstantiable() ? desc.newInstance() : null; 这个方法

 boolean isInstantiable() {
   	requireInitialized();
   	return (cons != null);
}

代码看出,判断cons是否为空,cons 是什么呢

 /** serialization-appropriate constructor, or null if none */
    private Constructor<?> cons;

cons 是构造方法,也就是判断一下构造方法是不为空,意味着只要有无参构造方法就会实例化

然后我们在回到 readOrdinaryObject() 方法,往下看

判断无参构造方法是否存在后,又调用了 desc.hasReadResolveMethod() 方法

boolean hasReadResolveMethod() {
  	requireInitialized();
  	return (readResolveMethod != null);
}

判断 readResolveMethod 是否为空,不为空返回true,然后我们在看下 readResolveMethod 复制,通过全局查找如下

readResolveMethod = getInheritableMethod(
                        cl, "readResolve", null, Object.class);

实际上就是通过反射,找到一个无参数的 readResolve 方法,然后在回到 readOrdinaryObject() 方法,找到这行 Object rep = desc.invokeReadResolve(obj);

Object invokeReadResolve(Object obj)
        throws IOException, UnsupportedOperationException
    {
        requireInitialized();
        if (readResolveMethod != null) {
            try {
                return readResolveMethod.invoke(obj, (Object[]) null);
            } catch (InvocationTargetException ex) {
                Throwable th = ex.getTargetException();
                if (th instanceof ObjectStreamException) {
                    throw (ObjectStreamException) th;
                } else {
                    throwMiscException(th);
                    throw new InternalError(th);  // never reached
                }
            } catch (IllegalAccessException ex) {
                // should not occur, as access checks have been suppressed
                throw new InternalError(ex);
            }
        } else {
            throw new UnsupportedOperationException();
        }
    }

实际上是执行了下 readResolveMethod 方法,并返回值

所有,增加了readResolve() 方法返回实例,解决了破坏单例的问题,实际上是实例化了两次,只不过新创建的对象没有被返回而已;那么如果创建对象的动作反升频率增大,意味着内存开销也增大,如何解决呢,下面来看注册式单例

9. 注册式单例

注册式单例就是将每一个实例都登记到某一个地方,使用唯一的标识获取实例。注册式单例模式有两种:一种是枚举式单例模式,另一种是容器式单例模式。

枚举实现的方式

public enum EnumSingleton {
    INSTANCE;
    private Object data;
    public Object getData() {
        return data;
    }
    public void setData(Object data) {
        this.data = data;
    }
    public static EnumSingleton getInstance(){
        return INSTANCE;
    }
}

public class User {
    private String id;

    public String getId() {
        return id;
    }
}

//测试方法
public class EnumSingletonTest {
    public static void main(String[] args) {
        EnumSingleton e1 = null;
        EnumSingleton e2 = EnumSingleton.getInstance();
        e2.setData(new User());
        FileOutputStream fos = null;
        try {
            fos = new FileOutputStream("EnumSingleton.obj");
            ObjectOutputStream oos = new ObjectOutputStream(fos);
            oos.writeObject(e2);
            oos.flush();
            oos.close();

            FileInputStream fis = new FileInputStream("EnumSingleton.obj");
            ObjectInputStream ois = new ObjectInputStream(fis);
            e1 = (EnumSingleton)ois.readObject();
            ois.close();
            System.out.println(e1.getData());
            System.out.println(e2.getData());
            System.out.println(e1.getData() == e2.getData());
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

输出结果

singleton.User@4fca772d
singleton.User@4fca772d
true

容器式单例实现的方式

比如IOC容器,IOC容器就属于容器式单例

我们在看文章开始时提出的几个问题

1.如何实现一个线程安全的单例模式?

​ 可以通过恶汉模式,静态内部类

​ 也可提通过懒汉模式,在对象实例化时候用 synchronize 关键字双重检查

​ 再有就是通过枚举实现

2.那是如何做到线程安全的呢?

借助 synchronize 关键字实现线程安全我们就不说了,我们先看下恶汉模式是如何做到线程安全的

恶汉模式,静态内部,这两种方式都是通过定义静态变量,以保证单例对象在类初始化时候被实例化。这其实是利用了ClassLoader的线程安全加载机制。ClassLoader的loadClass方法在加载类的时候,实际上使用了 synchronized 关键字;所以除非被重写,这个方法在默认在线程的整个装载中都是线程安全的,所以使用类加载中创建的对象是线程安全的。loadClass 方法参考如下:

    protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // First, check if the class has already been loaded
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }

                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    long t1 = System.nanoTime();
                    c = findClass(name);

                    // this is the defining class loader; record the stats
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

我们在来看下枚举实现的单例 ,枚举底层依赖Enum类实现的,它的所有成员变量都是静态的,并且在静态代码块中实例化的,所以枚举是天然的线程安全的

3.你知道如何破坏单例吗?

可以通过反射破坏单例

序列化和反序列化破坏单例

为啥呢,文章上面已经说明了

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值