深入理解设计模式(2) — 创建型模式之单例模式

今天,我们来说说创建型模式。

学习之前,我们先来考虑一下:为什么我们要使用创建型模式?

创建型模式优点

首先,这种设计模式在创建对象的过程中有很多优点:
比如说能将对象的创建和使用分离

其实就是能让:

①使用者无需关注对象的创建细节

很多对象创建起来非常复杂,尤其是框架内的一些对象,如果每次创建对象都要手动调用一堆方法,这些方法甚至很可能并不是你写的,这岂不是很困难?所以,我们可以通过设计模式来屏蔽他的创建细节。比如,我们可以通过工厂设计模式,这样对象的创建就完全交由工厂来完成….之类的,这样就使得使用者不用在关心对象如何创建的,只要专心使用就好了。

②降低系统的耦合度
这个没什么好说的,毕竟基本上所有的设计模式都是为了这个嘛。

接下来,让我们在例子中讲解创建型模式的优点。

我们首先来讲最容易被面试官考到的模式,单例模式:

单例模式

一个单一的类,负责创建自己的对象,同时确保系统中只有单个对象被创建。

特点

①某个类只能有一个实例(构造器私有)

②它必须自行创建这个实例;(自己编写对象实力化的逻辑)

③它必须自行向整个系统提供这个实例(比如说对外提供一个getInstance()方法,通过这个方法可以返回唯一的那个实例。)))

实例

一般来说,单例模式有两种情况:懒汉式和饿汉式。

区别在于:懒汉式的创建就是等第一个人调用这个唯一实例的时候再初始化这个唯一实例。而饿汉式的就是早早初始化好这个实例,每次调用的时候把这个实例给他就好了。

我们首先来看饿汉式的。这种比较简单

饿汉式

public class Singleton {

    //为了他能让静态方法访问到,所以应该是静态的属性
    //他一经初始化后不应该再有任何修改,所以设置为final
    private final static Singleton singleton = new Singleton();

    //首先让构造器私有,这样外部实例化对象的时候就会报错
    private Singleton(){

    }

    /**
     * 返回唯一实例的方法,要做到每次调用方法返回的都是同一个对象。
     * @return 单例
     */
    public static Singleton getInstance(){
        return singleton;
    }

}
复制代码

然后我们编写一个类测试一下

public class Test {
    public static void main(String[] args) {
        Singleton instance1 = Singleton.getInstance();
        Singleton instance2 = Singleton.getInstance();
        System.out.println("instance1 = " + instance1);
        System.out.println("instance2 = " + instance2);
        System.out.println("两个实例相等吗 : "+ (instance1 == instance2));
    }
}

instance1 = com.yswdqz.io.netty.websocket.Singleton@3f99bd52
instance2 = com.yswdqz.io.netty.websocket.Singleton@3f99bd52
两个实例相等吗 : true

复制代码

没什么问题,这样我们就简单的实现了饿汉式的单例模式。

懒汉模式

懒汉模式要稍微复杂一点

我们首先肯定能轻松想到这种写法。

public class Singleton {

    //为了他能让静态方法访问到,所以应该是静态的属性
    //由于要再后来设置该属性的值,所以设为非final
    private static Singleton singleton;

    //首先让构造器私有,这样外部实例化对象的时候就会报错
    private Singleton(){

    }

    /**
     * 返回唯一实例的方法,要做到每次调用方法返回的都是同一个对象。
     * @return 单例
     */
    public static Singleton getInstance(){
        if(singleton == null){
            singleton = new Singleton();
        }
        return singleton;
    }

}
复制代码

但是,我们设想一下,这种方式真的安全吗?

我们来设想一个场景,假如有两个用户(就是两个线程),同时调用getInstance,那么岂不是会创建出两个对象?这样就不符合我们单例模式的要求了。所以我们要加以改进。比较好想当的改进就是加把锁。比如说用synchronized。

让我们回顾一下synchronized 的知识,呗synchronized锁住的代码块同一时间内只能有一个线程在里面。这样就保证了线程安全,不会出现两个线程同时访问的情况了。

让我们修改代码。

public class Singleton {

    //为了他能让静态方法访问到,所以应该是静态的属性
    //由于要再后来设置该属性的值,所以设为非final
    private static Singleton singleton;

    //首先让构造器私有,这样外部实例化对象的时候就会报错
    private Singleton(){

    }

    /**
     * 返回唯一实例的方法,要做到每次调用方法返回的都是同一个对象。
     * @return 单例
     */
    public synchronized static Singleton getInstance(){
        if(singleton == null){
            singleton = new Singleton();
        }
        return singleton;
    }

}
复制代码

但是这个锁太大了,会影响效率,一般来说锁应该锁的越少越好。所以我们再次改进。

public class Singleton {

    //为了他能让静态方法访问到,所以应该是静态的属性
    //由于要再后来设置该属性的值,所以设为非final
    private static Singleton singleton;

    //首先让构造器私有,这样外部实例化对象的时候就会报错
    private Singleton(){

    }

    /**
     * 返回唯一实例的方法,要做到每次调用方法返回的都是同一个对象。
     * @return 单例
     */
    public static Singleton getInstance(){
        if(singleton == null){
            synchronized (Singleton.class){
                singleton = new Singleton();
            }
        }
        return singleton;
    }

}
复制代码

这样一来,确实效率会提升一些,但是这样真的安全吗?其实并不

因为这个锁不跟没加一样嘛,第一个线程在占有锁的时候,很可能其他线程已经突破了isnull的判断,来到了这里,这样的话就又得创建对象了。

所以要再做修改:

public class Singleton {

    //为了他能让静态方法访问到,所以应该是静态的属性
    //由于要再后来设置该属性的值,所以设为非final
    private static Singleton singleton;

    //首先让构造器私有,这样外部实例化对象的时候就会报错
    private Singleton(){

    }

    /**
     * 返回唯一实例的方法,要做到每次调用方法返回的都是同一个对象。
     * @return 单例
     */
    public static Singleton getInstance(){
        if(singleton == null){
            synchronized (Singleton.class){
                if(singleton == null){
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }

}
复制代码

那么,这回总算安全了吧?很可惜还差一点

我们这里先要回顾一下另一个知识,关于对象的创建一般分三步走。

1.分配对象内存空间

2.初始化对象

3.设置对象实例指向刚分配的内存地址 此时对象实例 !=null

其中第2,3步可能会被调换 导致先执行3但是2还没做完。

让我们再设想一个场景,线程a获得了锁,进去执行,这时候cpu指令重排序了,先执行的第三步还没执行第二步,虽然这个对象没有创建完呢,但是已经设置为非null了,其他的线程此时要是正好获得锁,进来判断,确实非空了,就带着实例走了,这样就会让线程获得一个没初始化好的对象。

那么要怎么改进呢? 很简单,给单例的属性加一个volatile修饰符,这个修饰符能确保指令按三步走,不调换顺序。

public class Singleton {

    //为了他能让静态方法访问到,所以应该是静态的属性
    //由于要再后来设置该属性的值,所以设为非final
    private volatile static Singleton singleton;

    //首先让构造器私有,这样外部实例化对象的时候就会报错
    private Singleton(){

    }

    /**
     * 返回唯一实例的方法,要做到每次调用方法返回的都是同一个对象。
     * @return 单例
     */
    public static Singleton getInstance(){
        if(singleton == null){
            synchronized (Singleton.class){
                if(singleton == null){
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }

}
复制代码

那么这个就是最终的实现了,想必如果之前没听说过这些的已经有点晕了….

但是没关系,我们费这么半天劲写出来的懒汉式,其实在大部分情况,还不如简简单单的饿汉式呢。因为在大多数情况下,很多资源都得在服务器创建的时候就初始化好,要不然用户第一次调用的时候岂不是会很卡?

只有在一些对资源特别在意的时候,而且这个类很可能一次也不会被调用的时候,懒汉比饿汉要好。

那么,上述的代码真的安全吗?

(又来了)

java中还有一个技术叫反射,在里面有很多方法,比如说可以通过类本身来获取构造器,进而构造出对象,这个方法甚至能获取到私有构造器。那么我们之前写过的类能抵挡这种程度的攻击吗?

public static void main(String[] args) throws ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {

    //获取无参构造器
    Constructor<Singleton> constructor = Singleton.class.getDeclaredConstructor();
    //设置为可以获取到私有构造器
    constructor.setAccessible(true);
    //通过构造器获取实例
    Singleton singleton = constructor.newInstance();

    Singleton instance = Singleton.getInstance();
    System.out.println("singleton = " + singleton);
    System.out.println("instance = " + instance);
    System.out.println("两个对象是否相等:"+(instance == singleton));
}

singleton = com.yswdqz.io.netty.websocket.Singleton@3f99bd52
instance = com.yswdqz.io.netty.websocket.Singleton@4f023edb
两个对象是否相等:false
复制代码

很遗憾,我们没能成功,通过反射我们还是获得了新的实例。

那么,这该怎么解决呢?

这要用到枚举。

public enum Singleton {
    //不用getInstace 直接调用即可
    INSTANCE;
    //假如有一些其他方法就这么写就好
    public void other(){
        
    }

}
复制代码

这段代码看上去好简单,而且肉眼可见的能明白为啥是单例。但是他真的有那么安全吗?

首先,这个方法也能实现线程安全,这是因为枚如果反编译举,我们就能发现,其实枚举在经过javac的编译之后,会被转换成形如public final class T extends Enum的定义。

而且,枚举中的各个枚举项同事通过static来定义的。了解JVM的类加载机制的朋同学应该对这部分比较清楚。static类型的属性会在类被加载之后被初始化,当一个Java类第一次被真正使用到的时候静态资源被初始化、Java类的加载和初始化过程都是线程安全的(因为虚拟机在加载枚举的类的时候,会使用ClassLoader的loadClass方法,而这个方法使用同步代码块保证了线程安全)。所以,创建一个enum类型是线程安全的。

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

最后,对于反射的攻击呢,让我们来测试一下

public static void main(String[] args) throws ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {

    //获取无参构造器
    Constructor<Singleton> constructor = Singleton.class.getDeclaredConstructor();
    //设置为可以获取到私有构造器
    constructor.setAccessible(true);
    //通过构造器获取实例
    Singleton singleton = constructor.newInstance();

    Singleton instance = Singleton.INSTANCE;
    System.out.println("singleton = " + singleton);
    System.out.println("instance = " + instance);
    System.out.println("两个对象是否相等:"+(instance == singleton));
}

Exception in thread "main" java.lang.NoSuchMethodException: com.yswdqz.io.netty.websocket.Singleton.<init>()
	at java.lang.Class.getConstructor0(Class.java:3110)
	at java.lang.Class.getDeclaredConstructor(Class.java:2206)
	at com.yswdqz.io.netty.websocket.Test.main(Test.java:10)
复制代码

结果报错了。这也很好理解,毕竟枚举根本没构造器。但是不甘心的我们去看了看枚举的源码

public abstract class Enum<E extends Enum<E>>
            implements Comparable<E>, Serializable {
        private final String name;
        public final String name() {
            return name;
        }
        private final int ordinal;
        public final int ordinal() {
            return ordinal;
        }
        protected Enum(String name, int ordinal) {
            this.name = name;
            this.ordinal = ordinal;
        }
        //余下省略
复制代码

啊哈,这不还是有个构造函数。我们试着通过他来攻破枚举的防线。

public static void main(String[] args) throws ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {

   //获取无参构造器
   Constructor<Singleton> constructor = Singleton.class.getDeclaredConstructor(String.class,int.class);
   //设置为可以获取到私有构造器
   constructor.setAccessible(true);
   //通过构造器获取实例
   Singleton singleton = constructor.newInstance("test",111);

   Singleton instance = Singleton.INSTANCE;
   System.out.println("singleton = " + singleton);
   System.out.println("instance = " + instance);
   System.out.println("两个对象是否相等:"+(instance == singleton));
}

Exception in thread "main" java.lang.IllegalArgumentException: Cannot reflectively create enum objects
   at java.lang.reflect.Constructor.newInstance(Constructor.java:417)
   at com.yswdqz.io.netty.websocket.Test.main(Test.java:14)
复制代码

结果更直接….直接告诉你不能反射创建枚举、

读源码会发现

 }
         if ((clazz.getModifiers() & Modifier.ENUM) != 0)
             throw new IllegalArgumentException("Cannot reflectively create enum objects");
         ConstructorAccessor ca = constructorAccessor;  
复制代码

如果类没被ENUM修饰,那么直接就会报错,这下没办法了。

所以,大家也就能理解实现单例模式的安全性了吧。

讲完了单例的使用方法,接下来我们来看看使用场景

单例的应用场景

单例的使用场景其实特别多:比如说多线程的线程池啊,数据库的连接池啊之类的。

没什么好说的,一个东西是否应该为单例应该很好判断。同时我也希望大家学会了这些之后能有选择的在自己的代码中使用这种设计模式。

总结

谢谢大家能看到这里。

今天很详细的讲解了单例模式。无论是在面试还是项目中,这种思想都相当重要。希望大家能有所收获吧。

下一节是创建型模式的原型模式。敬请期待!

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享