YAZONG 我的开源

设计模式(一)创建型模式(对象创建型模式)单例模式-SINGLETON

 
0 评论0 浏览

2、1 单例模式

定义

确保一个类只有一个实例,而且自行实例化并向整个系统提供这个实例。

保证一个类仅有一个实例(对唯一实例的受控访问),并提供一个访问它的全局访问点。

(从理论上讲,任何可以实现一个类只有一个实例的设计模式,都可以称为单例模式。)
(通常我们可以让一个全局变量使得一个对象被访问,但它不能防止你实例化多个对象。一个最好的办法就是,让类自身负责保存它的唯一实例。这个类可以保证没有其他实例可以被创建,并且它可以提供一个访问该实例的方法。换个意思就是说,客户端不再考虑是否需要去实例化的问题,而把责任都给了应该负责的类去处理。)

实现单例模式的思路

一个类能返回对象一个引用(永远是同一个)和一个获得该实例的方法(必须是静态方法,通常使用 getInstance 这个名称);当我们调用(请求)这个方法时,如果类持有的引用不为空就返回这个引用,如果类保持的引用为空就创建该类的实例并将实例的引用赋予该类保持的引用;同时我们还将该类的构造函数定义为私有方法,这样其他处的代码就无法通过调用该类的构造函数来实例化该类的对象,只有通过该类提供的静态方法来得到该类的唯一实例。

案例(依次优化)

image.png

饿汉式

代码

public class Singleton {
	//解决全局访问和实例化控制,公共静态属性为访问实例提供了一个全局访问点。
    //Singleton类被加载时就将自己实例化,提前占用资源。
private static final Singleton SINGLETON = new Singleton();

    private Singleton(){}
	
    public static Singleton getSingleton(){
        return SINGLETON;
    }
    //类中的其他方法,最好是static修饰
    public static void doSomething(){

    }

}

解释

为什么叫饿汉式呢?我们看代码,我们定义了一个静态的 final 的实例,并且直接 new 了一个对象,这样就会导致 Singleton 类在加载字节码到虚拟机的时候就会实例化这个实例,当你调用 getInstance 方法的时候,就会直接返回,不必做任何判断,这样做的好处是代码量明显减少了,坏处是,在你没有使用该单例的时候,该单例却被加载了,如果该单例很大的话,将会浪费很多的内存。

懒汉式

在第一次被引用时,才会将自己实例化。(延迟初始化)

懒汉式 1-1(包含压测)

懒汉式加载:最简单的单例模式:2 步,
1.把自己的构造方法设置为私有的,不让别人访问你的实例,
2.提供一个 static 方法给别人获取你的实例。

代码
public class Singleton {

    private static Singleton singleton;

    private Singleton(){}

    public static Singleton getInstance(){
        if(singleton == null){
//如果不需要这个实例,它就不会实例化。这就是"延迟实例化",这种做法对”资源敏感的对象”特别重要。
            singleton = new Singleton();
        }
        return singleton;
    }

}

//我们可以看到,这是一个简单的获取单例的一个类,首先我们定义一个静态实例 single, 如何将构造方法变成私有的。并且给外界一个静态获取实例的方法。如果对象不是null,就直接返回实例,从而保证实例。也可以保证不浪费内存。这是我们的第一个实现单例模式的例子。很简单。但是有问题,我们后面再讲。

class TestSingleton{

    public static void main(String[] args) throws Exception{

        Set<Singleton> set = new HashSet<>();
        for (int i = 0; i < 1000; i++) {
            new Thread(() -> set.add(Singleton.getInstance())).start();
        }

        Thread.sleep(10000);

        System.out.println("----------------单例模式测试-----------------");
        //循环打印实例,小几率会出现2个实例或多个实例
        for (Singleton singleton : set) {
            System.out.println(singleton);
        }

    }

}
解释

我们分析一下上面的代码,首先,我们验证的是什么呢?我们想验证多线程下获取懒汉式单例会不会出现错误。也就是出现一个以上的单例,我们如何做呢?首先我们定义一个 Set 对实例进行去重,然后创建 1000 个线程(Windows 每个进程最多 1000 个线程,Linux 每个进程最多 2000 个线程),每个线程都去获取实例,并添加到 set 中,实际上,我们应该使用 Collections.synchronizedSet(set)获取一个线程安全的 set,但是,这里为了方便,就直接使用 HashSet 了,然后 main 线程等待 10 秒,让 1000 个线程尽量都执行完毕。最后循环打印 set 的内容。在某些情况下,会出现 2 个实例,注意,是某些情况下,一定要多测试几次

我们通过测试用例发现:高并发情况下,我们的懒加载确实存在 bug。为什么会这样呢?我们假设第一个线程进入 getInstance 方法,判断实例为 null,准备进入 if 块内执行实例化,这时线程突然让出时间片,第二个线程也进入方法,判断实例也为 null,并且进入 if 块执行实例化,第一个线程唤醒也进入 if 块进行实例化。这时就会出现 2 个实例。所以出现了 bug。

懒汉式 1-2(synchronized 同步式)

代码 1
public class Singleton {

    private static Singleton singleton;

    private Singleton(){}
    //使用synchronized会降低性能,只有第一次执行此方法时才会真正需要同步。
    //换句话说,一旦设置好singleton变量,就不再需要同步这个方法了。
    //之后每次调用这个方法,同步都是一种累赘。
    //如果在频繁运行的地方使用,那么就得重新考虑了。
    public static synchronized Singleton getInstance(){
        if(singleton == null){
            singleton = new Singleton();
        }
        return singleton;
    }

    //类中的其他方法,最好是static修饰
    public static void doSomething(){

    }

}
解释 1

我们使用 synchronized 关键字,相当于每个想要进入该方法的获取实例的线程都要阻塞排队,我们仔细思考一下:需要吗?当实例已经初始化之后,我们还需要做同步控制吗?这对性能的影响是巨大的。我们只需要在实例第一次初始化的时候同步就足够了。
如果应用程序可以接受 getInstance()造成的额外负担,那么就直接使用此方式即可,既简单又高效,但是必须知道的是,同步一个方法可能造成程序执行效率下降 100 倍,所以这个不适合使用在频繁运行的地方。

代码 2
public class Singleton2 {

    //在静态初始化器中创建单例。这段代码保证了线程安全。
    //依赖JVM种子加载这个类时马上创建此唯一的实例。
    //JVM保证在任何线程访问singleton静态变量之前,一定先创建此实例。
    private static Singleton2 singleton = new Singleton2();

    private Singleton2(){}

    public static Singleton2 getInstance(){
        return singleton;
    }
}

懒汉式 1-3(双重检验锁)

代码
public class Singleton {

    private volatile static Singleton singleton;

    private Singleton(){}

    //双重检查获取单例实例
    public static Singleton getInstance(){
        //多线程直接访问,不做控制,不影响性能
        if(singleton == null){
            //此时,如果有多个线程进入,则进入同步块,其余线程等待。
//确保当一个线程位于代码的临界区时,另一个线程不进入临界区。
//如果其他线程试图进入锁定的代码,则它将一直等待(即被阻止),直到该对象被释放。
            synchronized (Singleton.class){
                //此时,第一个进入的线程判断为null,但第二个线程进来时已经不是null了。
                if(singleton == null){
                    //第一个线程实例化此对象
                    singleton = new Singleton();
                }
            }
        }
        //如果不为null,不会影响性能,只有第一次才会影响性能
        return singleton;
    }

    //类中的其他方法,最好是static修饰
    public static void doSomething(){

    }
}
解释

我们继续分析一下代码:首先看 getInstance 方法,我们在方法声明上去除了 synchronized 关键字,多线程进入方法内部,判断是否为 null,如果为 null,多个线程同时进入 if 块内,此时,我们是用 Single4 Class 对象同步一段方法。保证只有一个线程进入该方法。并且判断是否为 null,如果为 null,就进行初始化。我们想象一下,如果第一个线程进入进入同步块,发现该实例为 null,于是进入 if 块实例化,第二个线程进入同步内则发现实例已经不是 null,直接就返回 了,从而保证了并发安全。那么这个和“懒汉式 1-2(synchronized 同步式)”方式又有什么区别呢?“懒汉式 1-2(synchronized 同步式)”方式的缺陷是:每个线程每次进入该方法都需要被同步,成本巨大。而“懒汉式 1-3(双重检验锁)”呢?每个线程最多只有在第一次的时候才会进入同步块,也就是说,只要实例被初始化了,那么之后进入该方法的线程就不必进入同步块了。就解决并发下线程安全和性能的平衡。虽然第一次还是会被阻塞。但相比较于“懒汉式 1-2(synchronized 同步式)”,已经好多了。

我们还对一个东西感兴趣,就是修饰变量的 volatile 关键字,为什么要用 volatile 关键字呢?这是个有趣的问题。我们好好分析一下:
首先我们看,Java 虚拟机初始化一个对象都干了些什么?总的来说,3 件事情:
在堆空间分配内存;
执行构造方法进行初始化;
将对象指向内存中分配的内存空间,也就是地址。

但是由于当我们编译的时候,编译器在生成汇编代码的时候会对流程进行优化(这里涉及到 happen-before 原则和 Java 内存模型和 CPU 流水线执行的知识,就不展开讲了),优化的结果式有可能式 123 顺序执行,也有可能式 132 执行,但是,如果是按照 132 的顺序执行,走到第三步(还没到第二步)的时候,这时突然另一个线程来访问,走到 if(singleton == null)块,会发现 singleton 已经不是 null 了,就直接返回了,但是此时对象还没有完成初始化,如果另一个线程对实例的某些需要初始化的参数进行操作,就有可能报错。使用 volatile 关键字,能够告诉编译器不要对代码进行重排序的优化。就不会出现这种问题了。

我们看到,小小的单例模式被我们弄得很复杂。但这就是一个程序员的追求,追求最好的性能,追求最好的代码。

那还有没有别的更好的办法呢?这个代码也太多了,代码可读性也不好。而且线程第一次进入还会阻塞,还能更完美吗?

懒汉式加载 + 线程安全:静态内部类

代码
public class Singleton {
    private static Singleton singleton;

    private Singleton(){}

    public static Singleton getInstance(){
        return InnerClass.singleton;
    }

    /**
     * 使用静态内部类,既能保证懒加载,又能保证线程安全。
     */
    private static class InnerClass{
        private static final Singleton singleton = new Singleton();
    }

}
解释

我们来分析一下代码:相比较饿汉式,我们增加了一个内部类,内部类中有一个外部类的实例,并且已经初始化了。我们回忆一下饿汉式有什么问题,饿汉式的问题是:在你没有使用该单例的时候,该单例却被加载了,如果该单例很大的话,将会浪费很多的内存。但是,我们现在引入了内部类的方式,虚拟机的机制是,如果你没有访问一个类,那么是不会载入该类进入虚拟机的。当我们使用外部类的时候其他属性的时候,是不会浪费内存载入内部类中的单例的。从而也就保证了并发安全和防止内存浪费。
但是,这样就能完美了吗?

懒汉式加载 + 线程安全:静态内部类(反射和反序列化破坏单例)

代码
//在测试这个单例对象中的序列化时应该用其中需要单例的对象来实现Serializable接口
public class Singleton implements Serializable{
    private static Singleton singleton;

    private Singleton(){}

    public static Singleton getInstance(){
        return InnerClass.singleton;
    }

    /**
     * 使用静态内部类,既能保证懒加载,又能保证线程安全。
     */
    private static class InnerClass{
        private static final Singleton singleton = new Singleton();
    }

    //注意这里的输出,现在singleton1、singleton2和singleton3都是输出不同的对象
    public static void main(String[] args) throws IllegalAccessException, InstantiationException, IOException,
            ClassNotFoundException {

        Singleton singleton1 = Singleton.class.newInstance();
        Singleton singleton2 = getInstance();

        System.out.println("singleton1:" + singleton1);
        System.out.println("singleton2:" + singleton2);

        String filePath = "G:\\testSerializable\\test";

        //反序列化获取实例
        ObjectOutputStream outputStream = new ObjectOutputStream(new FileOutputStream(filePath));
        //将实例写入文件中
        outputStream.writeObject(getInstance());
        //从文件中读取出来,成为新的实例
        ObjectInputStream inputStream = new ObjectInputStream(new FileInputStream(filePath));

        Singleton singleton3 = (Singleton) inputStream.readObject();
        //但是这种方式可以通过重写readResolve方法来防止。
        System.out.println("singleton3:" + singleton3);

    }

}

输出:(注意第二个和第三个输出是通过同一个对象处理的)
singleton1:com.mbox.danli.example7.Singleton@6bc168e5
singleton2:com.mbox.danli.example7.Singleton@7b3300e5
singleton3:com.mbox.danli.example7.Singleton@769c9116
解释

我们知道 Java 的反射几乎是什么事情都能做,管你什么私有的公有的。都能破坏。我们是没有还手之力的。精心编写的代码就被破坏了,而反序列化也很厉害,但是稍微还有点办法遏制。什么办法呢?重写 readResolve 方法。

懒汉式加载 + 线程安全:静态内部类(防止反序列化)

重写 readResolve 方法

代码
//在测试这个单例对象中的序列化时应该用其中需要单例的对象来实现Serializable接口
public class Singleton implements Serializable{
    private static Singleton singleton;

    private Singleton(){}

    public static Singleton getInstance(){
        return InnerClass.singleton;
    }

    /**
     * 使用静态内部类,既能保证懒加载,又能保证线程安全。
     */
    private static class InnerClass{
        private static final Singleton singleton = new Singleton();
    }

    /**
     * 重写readResolve() 方法,防止反序列化破坏单例机制,
     * 这是因为:反序列化的机制在反序列化的时候,
     * 会判断如果实现了serializable或者externalizable接口的类中包含readResolve方法的话,
     * 会直接调用readResolve方法来获取实例。
     * @return
     */
    public Object readResolve(){
        return InnerClass.singleton;
    }

    //注意这里的输出,现在singleton1、singleton2和singleton3都是输出不同的对象
    public static void main(String[] args) throws IllegalAccessException, InstantiationException, IOException,
            ClassNotFoundException {

        Singleton singleton1 = Singleton.class.newInstance();
        Singleton singleton2 = getInstance();

        System.out.println("singleton1:" + singleton1);
        System.out.println("singleton2:" + singleton2);

        String filePath = "G:\\testSerializable\\test";

        //反序列化获取实例
        ObjectOutputStream outputStream = new ObjectOutputStream(new FileOutputStream(filePath));
        //将实例写入文件中
        outputStream.writeObject(getInstance());
        //从文件中读取出来,成为新的实例
        ObjectInputStream inputStream = new ObjectInputStream(new FileInputStream(filePath));

        Singleton singleton3 = (Singleton) inputStream.readObject();
        //但是这种方式可以通过重新readResolve方法来防止。
        System.out.println("singleton3:" + singleton3);

    }

}


输出结果:(注意第二个和第三个输出是通过同一个对象处理的且输出对象相同)
singleton1:com.mbox.danli.example8.Singleton@6bc168e5
singleton2:com.mbox.danli.example8.Singleton@7b3300e5
singleton3:com.mbox.danli.example8.Singleton@7b3300e5
解释

我们看到:我们重写了 readResolve 方法,在该方法中直接返回了我们的内部类实例。重写 readResolve() 方法,防止反序列化破坏单例机制,这是因为:反序列化的机制在反序列化的时候,会判断如果实现了 serializable 或者 externalizable 接口的类中包含 readResolve 方法的话,会直接调用 readResolve 方法来获取实例。这样我们就制止了反序列化破坏我们的单例模式。那反射呢?我们有办法吗?

枚举(防止反射)

代码

public enum Singleton {
    SINGLETON;

    public Singleton getInstance(){
        return SINGLETON;
    }
}

解释

为什么使用枚举可以呢?枚举类型反编译之后可以看到实际上是一个继承自 Enum 的类。所以本质还是一个类。 因为枚举的特点,你只会有一个实例。我们看一下反编译的枚举类。

image.png

反编译的 class 字节码
我们看到, Singleton 枚举继承了 java.lang.Enum<> 类。事实上就是一个类,但是我们这样就能防止反射破坏我们辛苦写的单例模式了。因为枚举的特点,而他也能保证单例。堪称完美!!!

单例模式的扩展

上限的多例模式

定义

下述案例这种需要产生固定数量对象的模式就叫做上限的多例模式,它是单例模式的一种扩展,采用有上限的多例模式,我们可以在设计时决定在内存中有多少个实例,方便系统进行扩展,修正单例可能存在的性能问题,提供系统的响应速度。例如读取文件,我们可以在系统启动时完成初始化工作,在内存中启动固定数量的 reader 实例,然后在需要读取文件时就可以快速响应。

案例
public class Test {
    public static void main(String[] args) {
        int ministerNum = 5;
        for (int i = 0; i < ministerNum; i++) {
            Emperor emperor = Emperor.getInstance();
            System.out.println("第" + (i+1) + "个大臣参拜的是:");
            emperor.say();
        }
    }
}
class Emperor{
    private static int maxNumOfEmperor = 2;
    private static ArrayList<String> nameList = new ArrayList<String>();
    private static ArrayList<Emperor> emperorList = new ArrayList<Emperor>();
    private static int countNumOfEmperor = 0;
    static {
        for (int i = 0; i < maxNumOfEmperor; i++) {
            emperorList.add(new Emperor("皇帝" + (i+1)));
        }
    }

    private Emperor(){}

    private Emperor(String name){
        nameList.add(name);
    }

    public static Emperor getInstance(){
        Random random = new Random();
        countNumOfEmperor = random.nextInt(maxNumOfEmperor);
        return emperorList.get(countNumOfEmperor);
    }

    public void say(){
        System.out.println(nameList.get(countNumOfEmperor));
    }
}

维基百科总结

回到开始,我们引用了一些维基百科的话,我们再看看维基百科关于并发是怎么说的:

单例模式在多线程的应用场合下必须小心使用。如果当唯一实例尚未创建时,有两个线程同时调用创建方法,那么它们同时没有检测到唯一实例的存在,从而同时各自创建了一个实例,这样就有两个实例被构造出来,从而违反了单例模式中实例唯一的原则。 解决这个问题的办法是为指示类是否已经实例化的变量提供一个互斥锁(虽然这样会降低效率).
我们看到维基百科还是靠谱的。告诉了我们可以使用互斥锁来防止并发出现的问题。

而单例模式带来了什么好处呢?

对于频繁使用的对象,可以省略创建对象所花费的时间,这对于那些重量级对象而言,是非常可观的一笔系统开销;
由于 new 操作的次数减少,因而对系统内存的使用频率也会降低,这将减轻 GC 压力,缩短 GC 停顿时间。

其他总结

优点

1.由于单例模式在内存中只有一个实例,减少了内存开支,特别是一个对象需要频繁地创建、销毁时,而且创建或销毁时性能又无法优化,单例模式的优势就非常明显。
2.由于单例模式只生成了一个实例,所以减少了系统的性能开销,当一个对象的产生需要比较多的资源时,如读取配置、产生其他依赖对象时,则可以通过在应用启动时直接产生一个单例对象,然后用永久驻留内存的方式来解决(在 Java EE 中采用单例模式时需要注意 JVM 垃圾回收机制。)
3.单例模式可以避免对资源的多重占用,例如一个写文件操作,由于只有一个实例存在内存中,避免对同一个资源文件的同时写操作。
4.单例模式可以在系统设置全局的访问点,优化和共享资源访问,例如可以设计一个单例类,负责所有数据表的映射处理。
5.比如客户端不再考虑是否需要去实例化的问题,而把责任都给了应该负责的类去处理,其实这就是最基本的设计模式:单例模式。

缺点

1.单例模式一般没有接口,扩展很困难,若要扩展,除了修改代码基本上没有第二种途径可以实现。单例模式为什么不能增加接口呢?因为接口对单例模式是没有任何意义的,它要求“自行实例化”,并且提供单一实例、接口或抽象类是不可能被实例化的。当然,在特殊情况下,单例模式可以实现接口、被继承等(比如登记式单例),需要在系统开发中根据环境判断。
2.单例模式对测试是不利的。在并行开发环境中,如果单例模式没有完成,是不能进行测试的,没有接口也不能使用 mock 的方式虚拟一个对象。
3.单例模式与单一职责原则有冲突。一个类应该只实现一个逻辑,而不关心是否是单例的,是不是要单例取决于环境,单例模式把“要单例”和业务逻辑融合在一个类中。

使用场景

1.要求生成唯一序列号的环境;
2.频繁访问数据库或文件的对象,如在整个项目中需要一个共享访问点或共享数据,例如一个 Web 页面上的计数器,可以不用把每次刷新都记录到数据库中,使用单例模式保持计数器的值,并确保是线程安全的;
3.创建一个对象需要消耗的资源过多,如要访问 IO 和数据库等资源;
4.有状态的工具类对象,如需要定义大量的静态常量和静态方法(如工具类)的环境,可以采用单例模式(当然,也可以直接声明为 static 的方式)。
5.我们经常使用的 servlet 就是单例多线程的。使用单例能够节省很多内存。

注意事项

1.只能使用单例类提供的方法得到单例对象,不要使用反射,否则将会实例化一个新对象;
2.不要做断开单例类对象与类中静态引用的危险操作;
3.多线程使用单例使用共享资源时,注意线程安全问题;
4.要考虑对象的复制情况。在 Java 中,对象默认是不可以被复制的,若实现复制是不用调用类的构造函数,因此即使是私有的构造函数,对象仍然可以被复制。在一般情况下,类复制的情况不需要考虑,很少会出现一个单例类会主动要求被复制的情况,解决该问题的最好方法就是单例类不要实现 Cloneable 接口。
5.当需要控制实例个数时,应当使用单例模式。
6.每个类加载器都定义了一个命名空间,如果有两个以上的类加载器,不同的类加载器可能会加载同一个类,从整个程序来看,同一个类会被加载多次。如果程序中有多个类加载器又同时使用了单例模式,那么请自行指定类加载器,并指定同一个类加载器。
7.类如果能做两件事,就会被认为是不好的 OO 设计。单例类不只负责管理自己的实例(并提供全局访问),还在应用程序中担当角色,所以也可以被视为是两个责任。
(OO 原则:封装变化;多用组合,少用继承;针对接口编程,不针对实现编程;为交互对象之间的松耦合设计而努力;类应该对扩展开放,对修改关闭;依赖抽象,不要依赖具体类。)
8.是否可以继承单例类,需要衡量好处和坏处。如果要让子类工作顺利,那么基类必须实现注册表的功能。
9.为何全局变量比单例模式差?在 Java 中,全局变量基本上就是对对象的静态引用。全局变量可以提供全局访问,但是不能确保只有一个实例。全局变量也会变量鼓励开发人员,用许多全局变量指向许多小对象来造成命名空间的污染。单例不鼓励这样的现象,但单例仍然可能被滥用。

最佳实践

例如在 Spring 中,每个 bean 默认就是单例的,这样做的优点是 Spring 容器可以管理这些 bean 的生命期,决定什么时候创建出来,什么时候销毁,销毁的时候要如何处理,等等。如果采用非单例模式(prototype 类型),则 bean 的初始化后的管理交由 J2EE 容器,Spring 容器不再跟踪管理 bean 的生命周期。


标题:设计模式(一)创建型模式(对象创建型模式)单例模式-SINGLETON
作者:yazong
地址:https://blog.llyweb.com/articles/2020/03/25/1585150782841.html