设计模式-单例模式(Singleton)详解

版权声明:本文为博主原创文章,转载请注明出处 https://blog.csdn.net/love905661433/article/details/84351613

概述

  • 定义 : 保证一个类仅有一个实例, 并提供一个全局访问点
  • 又称单件模式
  • 类型 : 创建型

适用场景

  • 想确保任何情况下都绝对只有一个实例

优点

  • 在内存里只有一个实例, 减少了内存开销
  • 可以避免对资源的多重占用
  • 设置了全局访问点, 严格控制访问

缺点

  • 没有接口, 扩展困难

重点

  • 私有构造器
  • 线程安全问题
  • 延迟加载
  • 序列化和反序列化安全问题
  • 反射安全问题

分类

单例模式分为懒汉式和饿汉式, 懒汉式是采用了延迟初始化的方式进行创建对象的, 而饿汉式是在类加载的时候就进行初始化的, 下面分别对两种模式进行一些分析

懒汉式

1. 首先, 来看一个最简单的懒汉式单例代码实现 :

/**
 * 单例模式 : 懒汉模式
 *
 * @author 七夜雪
 * 2018/11/14 17:21
 */
public class SingletonLazyInit {
    private static SingletonLazyInit singleton;
	// 单例模式构造器必须是私有的,防止外部使用new关键字构造新对象
    private SingletonLazyInit() {
    }

    public static SingletonLazyInit getInstance(){
        if (null == singleton){
            singleton = new SingletonLazyInit();
        }
        return singleton;
    }

}

上面的代码很简单, 在单线程情况下也是没有问题的, 但是在多线程的情况下就可能会存在问题, 比如说同时又两个线程t1, t2同时调用SingletonLazyInit的getInstance方法, t1执行到singleton = new SingletonLazyInit();这一句但是还没执行完时, t2判断singleton仍然为空, 扔能进入if代码块中, 这是t1对象创建成功并返回, 然后t2线程再进行对象创建, 这时t1和t2就获取的就不是一个对象了


2. 对于上面这种情况, 最简单的一种解决方案, 就是对getInstance方法进行加锁, 增加Synchronized关键字, 对静态方法进行加锁, 锁定的是当前类的class对象, 加锁之后的代码如下:

/**
 * 单例模式 : 加锁单例
 *
 * @author 七夜雪
 * 2018/11/14 17:21
 */
public class SingletonSynchronized {
    private static SingletonSynchronized singleton;

    private SingletonSynchronized() {
    }

    // 写法一
    public static synchronized SingletonSynchronized getInstance(){
        if (null == singleton){
            singleton = new SingletonSynchronized();
        }
        return singleton;
    }

      // 写法二
//    public static synchronized SingletonSynchronized getInstance(){
//        synchronized (SingletonSynchronized.class){
//            if (null == singleton){
//                singleton = new SingletonSynchronized();
//            }
//        }
//        return singleton;
//    }

}
  • getInstance方法上进行加锁之后, 在多线程的情况下就能保证单例的正确性了, 但是每次调用getInstance方法都有进行获取锁和释放锁的操作, 对性能是由一定影响的
  • 不过synchronized的性能并没有想象中的那么差, 是因为在jdk1.6之后, 对synchronized进行了很多优化, 引入了偏向锁和轻量级锁的概念, 详细情况这里就不多说了, 想了解的可以参考这篇文章:https://blog.csdn.net/love905661433/article/details/82871531
  • 继续说回到设计模式, 对于这种情况, 引入了双重检查的单例模式
    3. 双重检查的单例模式代码如下 :
/**
 * 单例模式 : 双重检查
 * 防止并发情况下问题
 * @author 七夜雪
 * 2018/11/14 17:26
 */
public class SingletonDoubleCheck {
    private static SingletonDoubleCheck singleton;

    private SingletonDoubleCheck() {
    }

    public static SingletonDoubleCheck getInstance(){
        if (null == singleton){
        	// 加锁保证这个代码块只有一个线程能够执行
            synchronized (SingletonDoubleCheck.class){
            	// 避免在获取锁的过程中, 对象被其他线程创建, 所以再进行一次检查
                if (null == singleton) {
                    singleton = new SingletonDoubleCheck();
                }
            }
        }

        return singleton;
    }
}
  • 通过两次null == singleton判断, 既保证了反复加锁的问题, 又保证了多线程情况下单例模式能够正常工作
    看到这里是不是都绝对这个方案已经完美解决了并发安全性以及性能问题呢, 事实上这个代码仍然是由问题的, 在多线程情况下, 仍然存在风险, 为什么会存在风险呢? 这是因为在jvm中, 存在指令重排序现象, 下面我们具体来进行分析一下 :
  • singleton = new SingletonDoubleCheck();首先这行看起来只有一句, 但其实是分成了三条指令进行执行的 :
  1. 分配内存给这个对象
  2. 初始化对象
  3. 设置singleton指向刚刚分配的内存地址
  • 但是对于上面的情况, 2和3可能会被重排序, 执行顺序有可能是1->2->3, 也有可能是1->3->2, 因为jvm规范中只要求单线程情况下这种情况能够获得正确的结果
  • 如果执行顺序是1->3->2的顺序的话, 执行了第三步之后, 这个时候singleton就已经不为null了, 这是如果第二个线程进入的话, 判断发现singleton不为null, 直接返回的话, 就会出现问题, 因为这时singleton对象并没有初始化完成, 具体参见下图
    单线程情况 :
    在这里插入图片描述
    并发情况 :
    在这里插入图片描述
    可以看出在并发情况下, 线程1有可能会被访问到并没有初始化完成的对象

通过上面的分析可以知道, 上面的这种double check的单例模式仍然是存在问题, 那这个问题该如何解决呢?
4. 事实上解决上面的这个问题非常简单, 只需要修改一行代码 :

只需要对这一行声明private static SingletonDoubleCheck singleton;
增加一个volatile关键字即可 : private static volatile SingletonDoubleCheck singleton;
为何增加一个volatile就能解决这个问题呢, 简单来说就是volatile可以禁止2, 3两步进行指令重排序, 具体更多关于volatile的信息可以参考 : https://blog.csdn.net/love905661433/article/details/82833361

除了上面的volatile关键字之外, 还有第二种方式解决指令重排序造成的线程安全问题 :

/**
 * 基于静态内部类的单例模式
 *
 * @author 七夜雪
 * @create 2018-11-22 20:57
 */
public class StaticInnerClassSingleton {
    // 注意私有的构造方法
    private StaticInnerClassSingleton(){

    }

    public StaticInnerClassSingleton getInstance(){
        return InnerClass.instance;
    }

    private static class InnerClass{
        private static StaticInnerClassSingleton instance = new StaticInnerClassSingleton();
    }

}

为何使用这种静态内部类的方式就能解决指令重排序问题呢, 这是因为jvm在进行Class对象初始化的时候, 会增加Class对象的初始化锁, 所以哪个对象能够拿到静态内部类的初始化锁, 哪个对象就能完成对静态内部类的初始化, 所以即使存在指令重排序, 也不影响线程安全性问题, 如下图:

在这里插入图片描述
关于Class类的初始化问题, 这里提一下, 在发生下面几种情况下, 会对Class类进行初始化:

  • 实例化一个类,new一个类的实例对象
  • 访问类的静态变量
  • 调用类的静态方法
  • 通过反射调用类
  • 实例化类的子类
  • 被标位启动类的类

饿汉式

  1. 首先仍然是看下最简单的饿汉式单例模式写法:
/**
 * 单例模式 : 饿汉模式
 * @author 七夜雪
 * 2018/11/14 17:13
 */
public class Singleton {
    private final static Singleton instance = new Singleton();

    public static Singleton getInstance() {
        return instance;
    }

    private Singleton() {
    }
}
  • 上面这种恶汉式的写法很简单, 就是在Class类加载的时候, 完成instance对象的创建, 所以不存在任何线程安全问题
  • 唯一的问题就是, 不管这个单例对象有没有用到, 在类加载完成之后这个对象就已经被创建成功了, 会造成一定的内存浪费, 如果这个对象很小的话, 这种影响并不大, 也可以将初始化放在静态代码块中, 效果是一样的, 代码如下:
/**
 * 单例模式 : 静态代码块饿汉模式
 *
 * @author 七夜雪
 * 2018/11/14 17:13
 */
public class StaticHungrySingleton {
    private static StaticHungrySingleton instance;

    static {
        instance = new StaticHungrySingleton();
    }

    public static StaticHungrySingleton getInstance() {
        return instance;
    }

    private StaticHungrySingleton() {
    }
}

单例模式安全性问题

是不是经过上面一系列优化之后, 觉得单例模式已经不存在安全性问题了, 事实上单例模式仍然可以被破坏, 下面来继续看

序列化破坏单例

以饿汉式单例为例, 单例代码如下:

/**
 * 单例模式 : 饿汉模式
 * @author 七夜雪
 * 2018/11/14 17:13
 */
public class Singleton implements Serializable {
    private static Singleton instance = new Singleton();

    public static Singleton getInstance() {
        return instance;
    }

    private Singleton() {
    }
}

上面的单例的代码实现了一个序列化接口, 避免序列化的时候报错, 下面我们来使用序列化的方式来测试一下序列化是不是能破坏单例, 代码如下:

/**
 * 破坏单例模式的测试
 *
 * @author 七夜雪
 * @create 2018-11-22 21:51
 */
public class DestroySingletonTest {

    /**
     * 使用序列化方式破坏单例
     * @param args
     * @throws Exception
     */
    public static void main(String[] args) throws Exception {
        Singleton instance = Singleton.getInstance();
        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("sinleton.dat"));
        oos.writeObject(instance);
        oos.close();
        File file = new File("sinleton.dat");
        ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file));
        Singleton newInstance = (Singleton) ois.readObject();
        System.out.println(instance);
        System.out.println(newInstance);
        System.out.println(instance == newInstance);
    }

}

输出结果:

pattern.creational.singleton.Singleton@135fbaa4
pattern.creational.singleton.Singleton@568db2f2
false

从输出结果可以看出, 序列化之后再进行反序列化的话, 得到的就不是同一个对象了, 这就破坏了单例模式的单例特性, 那应该如何解决这个问题呢, 解决的方法也很简单, 在Singleton类中加入一个readResolve()方法即可, 增加之后Singleton类代码如下:

import java.io.Serializable;

/**
 * 单例模式 : 饿汉模式
 * @author 七夜雪
 * 2018/11/14 17:13
 */
public class Singleton implements Serializable {
    private static Singleton instance = new Singleton();

    public static Singleton getInstance() {
        return instance;
    }

    private Singleton() {
    }

    public Object readResolve(){
        return instance;
    }
}

然后增加了readResolve方法之后, 再执行一次上面的测试类, 结果如下:

pattern.creational.singleton.Singleton@135fbaa4
pattern.creational.singleton.Singleton@135fbaa4
true

发现反序列化对单例的破坏已经解决了, 为什么加了readResolve方法之后就可以了呢, 我们来看下jdk的源码 :

  1. 首先来看下ObjectInputStream的readObject方法, 代码如下:
public final Object readObject()
        throws IOException, ClassNotFoundException
    {
        if (enableOverride) {
            return readObjectOverride();
        }

        // if nested read, passHandle contains handle of enclosing object
        int outerHandle = passHandle;
        try {
            Object obj = readObject0(false);
//  ......................看到这个readObject0方法即可,下面的部分省略了........................
  1. readObject0方法源码如下:
/**
     * Underlying readObject implementation.
     */
    private Object readObject0(boolean unshared) throws IOException {
    // ..............无关紧要的代码这里省略了.................
        byte tc;
        while ((tc = bin.peekByte()) == TC_RESET) {
            bin.readByte();
            handleReset();
        }

        depth++;
        totalObjectRefs++;
        try {
            switch (tc) {
    // ..............其他case省略, 这里是Object类型.................			
                case TC_OBJECT:
                    // checkResolve方法和readOrdinaryObject就是处理这个问题的
                    return checkResolve(readOrdinaryObject(unshared));
 // ..............已经找到关键代码了, 下面的代码这里就省略了.................                   

  1. 继续看readOrdinaryObject的方法
    private Object readOrdinaryObject(boolean unshared)
        throws IOException
    {
// ....................篇幅有限,这里只保存关键代码.........................
        ObjectStreamClass desc = readClassDesc(false);
        Object obj;
        // hasReadResolveMethod这个就是判断原来的Singleton中是否有readResolve方法的, 这里是根据方法名判断的, 所以Singleton中方法名就只能是readResolve
        //有这么一行代码 readResolveMethod = getInheritableMethod(cl, "readResolve", null, Object.class);
        if (obj != null &&
            handles.lookupException(passHandle) == null &&
            desc.hasReadResolveMethod())
        {
        	// 这里就是重点了, 如果存在readResolve方法, 就通过反射调用readResolve方法返回
            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;
    }
  1. checkResolve方法其实没啥好说的, 就是把readOrdinaryObject方法返回的对象返回出去

通过源码的分析, 可以指定为啥加了readResolve方法就可以解决序列化反序列化的问题了

通过反射破坏单例特性

同样的, 以上面的Singleton代码为例, 来试一下通过反射能不能破坏单例的特性, 反射的测试代码如下:

    /**
     * 使用反射方式破坏单例
     * @param args
     * @throws Exception
     */
    public static void main(String[] args) throws Exception {
        Singleton instance = Singleton.getInstance();
        Class clazz = Singleton.class;
        // 获取构造方法
        Constructor constructor = clazz.getDeclaredConstructor();
        // 由于构造方法是私有的, 所以这里先开放权限
        constructor.setAccessible(true);
        Singleton newInstance = (Singleton) constructor.newInstance();
        System.out.println(instance);
        System.out.println(newInstance);
        System.out.println(instance == newInstance);
    }

输出结果:

pattern.creational.singleton.Singleton@1540e19d
pattern.creational.singleton.Singleton@677327b6
false

从输出结果可以看出, 通过反射,确实破坏了单例的特性, 那么如何解决这个问题呢, 可以在构造器中增加一个判断, 代码如下:

/**
 * 单例模式 : 饿汉模式
 * @author 七夜雪
 * 2018/11/14 17:13
 */
public class Singleton implements Serializable {
    private static Singleton instance = new Singleton();

    public static Singleton getInstance() {
        return instance;
    }

    private Singleton() {
        // 防止反射破坏单例
        if (instance != null) {
            throw new RuntimeException("单例构造器不允许反射调用");
        }
    }

    /**
     * 防止序列化破坏单例
     * @return
     */
    public Object readResolve(){
        return instance;
    }
}

再次执行反射的测试代码, 得到如下结果:

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 pattern.creational.singleton.DestroySingletonTest.main(DestroySingletonTest.java:26)
Caused by: java.lang.RuntimeException: 单例构造器不允许反射调用
at pattern.creational.singleton.Singleton.(Singleton.java:19)
… 5 more

从结果可以看到, 现在就无法通过反射来破坏单例的特性了, 加了上面的判断之后, 保证了构造器只能执行一次, 而饿汉式的单例模式在类加载的时候, 对象就已经创建了, 所以可以通过这个方式来处理反射的问题, 但是如果使用懒汉模式的话, 就无法避免反射调用的问题了, 因为没有办法区分第一次调用构造方法是通过反射调用的, 还是通过getInstance方法触发的, 所以就没办法通过这个方式解决了

枚举Enum实现单例模式

  • 枚举可能是实现单例的最佳实践
  • 使用枚举实现单例的代码如下:
/**
 * @author 七夜雪
 * 使用枚举实现单例
 * @create 2018-11-23 0:11
 */
public enum EnumSingleton {
    INSTANCE;

    private Object data;

    public static EnumSingleton getInstance(){
        return INSTANCE;
    }

    public Object getData() {
        return data;
    }

    public void setData(Object data) {
        this.data = data;
    }
}

测试代码 :

    /**
     * 使用序列化方式破坏单例
     * @param args
     * @throws Exception
     */
    public static void main(String[] args) throws Exception {
        System.out.println("----------测试序列化方法对单例的破坏----------");
        EnumSingleton instance = EnumSingleton.getInstance();
        // 同时设置一个Object对象, 看下反序列化之后有没有被破坏
        instance.setData(new Object());
        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("sinleton.dat"));
        oos.writeObject(instance);
        File file = new File("sinleton.dat");
        ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file));
        EnumSingleton newInstance = (EnumSingleton) ois.readObject();
        System.out.println(instance);
        System.out.println(newInstance);
        System.out.println(instance == newInstance);
        System.out.println(instance.getData() == newInstance.getData());
    }

输出结果:

----------测试序列化方法对单例的破坏----------
INSTANCE
INSTANCE
true
true

下面测试一下使用反射破坏单例模式, 测试代码如下:

    /**
     * 使用序列化方式破坏单例
     * @param args
     * @throws Exception
     */
    public static void main(String[] args) throws Exception {
        System.out.println("----------测试序列化方法对单例的破坏----------");
        EnumSingleton instance = EnumSingleton.getInstance();
        Constructor constructor = EnumSingleton.class.getDeclaredConstructor();
        constructor.setAccessible(true);
        EnumSingleton newInstance = (EnumSingleton) constructor.newInstance();
        System.out.println(instance);
        System.out.println(newInstance);
        System.out.println(instance == newInstance);
    }

输出结果:

Exception in thread “main” java.lang.NoSuchMethodException: pattern.creational.singleton.EnumSingleton.()
----------测试序列化方法对单例的破坏----------
at java.lang.Class.getConstructor0(Class.java:3082)
at java.lang.Class.getDeclaredConstructor(Class.java:2178)
at pattern.creational.singleton.EnumTest.main(EnumTest.java:43)

执行上面的测试程序, 抛了个异常, 是没找到无参的构造方法, 对生成的class进行反编译之后发现, 只有如下构造方法:

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

那下面修改一下测试代码, 再进行一次测试, 修改后测试代码如下:

    /**
     * 使用序列化方式破坏单例
     * @param args
     * @throws Exception
     */
    public static void main(String[] args) throws Exception {
        System.out.println("----------测试序列化方法对单例的破坏----------");
        EnumSingleton instance = EnumSingleton.getInstance();
        Constructor constructor = EnumSingleton.class.getDeclaredConstructor(String.class, int.class);
        constructor.setAccessible(true);
        EnumSingleton newInstance = (EnumSingleton) constructor.newInstance();
        System.out.println(instance);
        System.out.println(newInstance);
        System.out.println(instance == newInstance);
    }

再次执行测试代码, 输出结果如下 :

----------测试序列化方法对单例的破坏----------
Exception in thread "main" java.lang.IllegalArgumentException: Cannot reflectively create enum objects
	at java.lang.reflect.Constructor.newInstance(Constructor.java:417)
	at pattern.creational.singleton.EnumTest.main(EnumTest.java:45)

发现通过反射调用的话, 依然会抛一个异常, 所以使用Enum来实现单例是安全的, 通过序列化和反射都无法破坏其单例特性

我们从JDK源码来看下, 为什么Enum具有这些特征
1. 首先看下为什么反射无法创建枚举类型的类, 看下newInstance源码, 源码中有下面这一句:

        if ((clazz.getModifiers() & Modifier.ENUM) != 0)
            throw new IllegalArgumentException("Cannot reflectively create enum objects");

可以看出, 如果对一个枚举类型使用反射调用newInstance生成对象的话, 就会直接抛一个异常出来, 保证了Enum类型无法通过反射创建对象

2. 然后我们看下为什么无法通过序列化破坏Enum的单例特性, 依然是看ObjectInputStream的readObject方法 :
方法调用顺序readObject->readObject0->readEnum, readEnum方法代码如下

//.....................忽略不重要的代码..........................
        String name = readString(false);
        Enum<?> result = null;
        Class<?> cl = desc.forClass();
        if (cl != null) {
            try {
                @SuppressWarnings("unchecked")
                // 这里根据name来创建的, 而EnumSingleton只有一个INSTANCE
                Enum<?> en = Enum.valueOf((Class)cl, name);
                result = en;
  1. 对EnumSingleton生成的class文件进行一下反编译, 看一下生成的代码:
// 首先类编程了final类
public final class EnumSingleton extends Enum {
	// 构造器是私有构造器
    private EnumSingleton(String s, int i)
    {
        super(s, i);
    }
    // INSTANCE属性是static final修饰的
	public static final EnumSingleton INSTANCE;
	// 使用了静态代码块初始化INSTANCE
    static 
    {
        INSTANCE = new EnumSingleton("INSTANCE", 0);
        $VALUES = (new EnumSingleton[] {
            INSTANCE
        });
    }

}

从反编译的结果来看, Enum很像是饿汉式的单例模式, 所以在Enum类加载的时候, 就已经完成了类的创建了

其他单例模式

容器单例

  • 可以使用一个Map管理多组单例, 代码如下:
/**
 * 容器单例
 *
 * @author 七夜雪
 * @create 2018-11-23 1:44
 */
public class ContainerSingleton {
    private static Map<String, Object> map = new HashMap<>();
	
	private ContainerSingleton() {
    }
    public void putInstance(String key, Object instance) {
        if (key != null && instance != null && !"".equals(key)){
            if (!map.containsKey(key)){
                map.put(key, instance);
            }
        }
    }

    public Object getInstance(String key) {
        return map.get(key);
    }

}
  • 可以用来管理多组单例的情况
  • HashMaps本身是线程不安全的, 所以如果并发put的话, 会存在线程安全问题
  • 如果使用HashTable的话, 就能保证线程安全, 但是HashTable性能较低
  • ConcurrentHashMap采用了分段锁的机制, 性能比HashTable有很大的提高, 但是在这种作为静态属性使用的时候, ConcurrentHashMap并不能完全保证线程安全
  • 所以这种容器单例适合在类加载的时候把所有单例的实例初始化进Map中的场景

线程单例(伪单例)

  • 说是伪单例,是因为这种单例并不能保证全局唯一, 只能保证每个线程唯一
  • 利用了ThreadLocal的特性, 代码如下:
/**
 * 线程单例-伪单例
 *
 * @author 七夜雪
 * @create 2018-11-23 9:42
 */
public class TheadLocalSingleton {
    private final static ThreadLocal<TheadLocalSingleton> threadLocal = new ThreadLocal<TheadLocalSingleton>(){
        @Override
        protected TheadLocalSingleton initialValue() {
            return new TheadLocalSingleton();
        }
    };

    public TheadLocalSingleton() {
    }

    public static TheadLocalSingleton getInstance(){
        return threadLocal.get();
    }

}

下面写个测试代码对线程单例这种情况做一个简单测试:

   public static void main(String[] args) {
        TheadLocalSingleton instance = TheadLocalSingleton.getInstance();
        TheadLocalSingleton newInstance = TheadLocalSingleton.getInstance();
        System.out.println(Thread.currentThread().getName() + "---" + instance);
        System.out.println(Thread.currentThread().getName() + "---" + newInstance);
        new Thread(()->{
         	// jdk8的lambda式, 等价于new Thread(new Runnable(){...});
            System.out.println(Thread.currentThread().getName() + "---" + TheadLocalSingleton.getInstance());
        }).start();
    }

输出结果 :

main—pattern.creational.singleton.TheadLocalSingleton@1540e19d
main—pattern.creational.singleton.TheadLocalSingleton@1540e19d
Thread-0—pattern.creational.singleton.TheadLocalSingleton@2314afc1

从输出结果可以看出, 两个main线程获取到的对象是同一个, 另外一个线程获取的对象是另外一个, 这就说明了这种情况是保证每个线程中对象是单例的

总结

  • 单例模式可以说是设计模式中最简单的设计模式, 同时也是最复杂的设计模式
  • 同样的, 单例模式是面试中问到频率最高的一种模式

本文参考:
慕课网<java设计模式精讲 Debug 方式+内存分析>课程

猜你喜欢

转载自blog.csdn.net/love905661433/article/details/84351613
今日推荐