设计模式之单例模式(Singleton)

共 15772字,需浏览 32分钟

 ·

2021-03-31 20:45

定义

一个类只有一个实例,它自己负责创建自己的对象,这个类提供了一种访问其唯一对象的方式,可以直接访问,不需要实例化该类的对象

单例模式有以下三个特点:

1、单例类只能有一个实例
2、单例类必须自己创建自己的唯一实例
3、单例类必须给所有其他对象提供这一实例

如果要实现这三点,需要满足如下三个要求:

1、单例类中含有一个该类的私有的静态实例
2、单例类只提供私有的构造函数
3、单例类提供一个公有的静态的函数用于获取它本身的私有静态实例

单例模式UML图
二、单例模式示例
1、单例模式分类

单例模式根据实例的创建时机来划分可分为:饿汉式和懒汉式。

饿汉式:应用刚启动的时候,不管外部有没有调用该类的实例方法,该类的实例就已经创建好了。

懒汉式:应用刚启动的时候,并不创建实例,等到第一次被使用时,才创建该类的实例。

2、举例说明

(1)、饿汉式(线程安全,可用)

1 public class Singleton {
2    // 私有的静态实例
3    private final static Singleton instance = new Singleton();
4
5    // 私有的构造函数,防止在该类外部通过new创建实例
6    private Singleton(){
7        System.out.println("创建Singleton实例!");
8    }
9
10    // 公有的静态的函数,用于获取实例
11    public static Singleton getInstance(){
12        return instance;
13    }
14}
15
16public class Test {
17    public static void main(String[] args) {
18        Singleton instance1 = Singleton.getInstance();
19        System.out.println(instance1);
20
21        Singleton instance2 = Singleton.getInstance();
22        System.out.println(instance2);
23
24        Singleton instance3 = Singleton.getInstance();
25        System.out.println(instance3);
26    }
27}

程序运行结果:

创建Singleton实例!
com.zxj.test.Singleton@154617c
com.zxj.test.Singleton@154617c
com.zxj.test.Singleton@154617c

观察程序运行结果:只创建了一个实例,三次调用获取到的实例都是同一个。

饿汉式优点:
在类加载的时候就完成了实例化,避免了多线程的同步问题。

饿汉式缺点:
在外部没有使用到该类的时候,该类的实例就创建了,若该类的实例的创建比较消耗系统资源,并且外部一直没有调用该实例,那么这部分的系统资源的消耗是没有意义的。

(2)、懒汉式(线程不安全,不可用)
(a).单线程环境下,是没有问题的:

1public class Singleton {
2    private static Singleton instance = null;
3
4    private Singleton(){
5        System.out.println("创建Singleton实例!");
6    }
7
8    public static Singleton getInstance(){
9        if(instance == null){
10            instance = new Singleton();
11        }
12        return instance;
13    }
14}
15
16public class Test {
17    public static void main(String[] args) {
18        Singleton instance1 = Singleton.getInstance();
19        System.out.println(instance1);
20
21        Singleton instance2 = Singleton.getInstance();
22        System.out.println(instance2);
23
24        Singleton instance3 = Singleton.getInstance();
25        System.out.println(instance3);
26    }
27}

程序运行结果:

创建Singleton实例!
com.zxj.test2.Singleton@154617c
com.zxj.test2.Singleton@154617c
com.zxj.test2.Singleton@154617c

(b).多线程环境下,单例模式失效,程序中有多个实例:

1public class Test {
2    public static void main(String[] args) {
3        for(int i = 0; i < 10; i++){
4            new Thread(){
5                @Override
6                public void run(){
7                    Singleton instance = Singleton.getInstance();
8                    System.out.println(instance);
9                }
10            }.start();
11        }
12    }
13}

程序运行结果:

创建Singleton实例!
创建Singleton实例!
创建Singleton实例!
创建Singleton实例!
创建Singleton实例!
创建Singleton实例!
创建Singleton实例!
创建Singleton实例!
创建Singleton实例!
创建Singleton实例!
com.zxj.test2.Singleton@1329a4
com.zxj.test2.Singleton@10ada9c
com.zxj.test2.Singleton@16268e5
com.zxj.test2.Singleton@95e4d4
com.zxj.test2.Singleton@2191b2
com.zxj.test2.Singleton@173a9e8
com.zxj.test2.Singleton@1a1b9f8
com.zxj.test2.Singleton@1122c7b
com.zxj.test2.Singleton@16ab049
com.zxj.test2.Singleton@15cc318

我们来分析一下,为什么在多线程环境下,懒汉式单例模式会失效?
假设有两个线程,线程A和线程B,线程A执行到第10行,但是并没有执行这一行,这个时候,线程B执行到第9行,线程B判断结果是true,线程B执行第10行,new了一个对象,此时,线程A,这就造成执行个对象被new了两次。

假设有两个线程,线程A和线程B,线程A先得到CPU的执行权,线程A执行到第9行 ,由于instance之前并没有实例化,所以线程A判断结果为true,线程A还没有来得及执行第10行,CPU执行权就被线程B抢去了,线程B执行到第9行,由于instance之前并没有实例化,所以线程B判断结果为true,此时线程B new了一个实例。之后CPU执行权分给线程A,线程A接着执行第10行实例的创建。由此看到线程A和线程B分别创建了一个实例(存在2个实例了),这就导致了单例的失效。

那么如何保证懒汉式在多线程环境仍然保证只有一个单例呢?当然是在创建实例时进行同步控制。

(3)、懒汉式(线程安全,可用)

1public class Singleton {
2    private static Singleton instance = null;
3
4    private Singleton(){
5        System.out.println("创建Singleton实例!");
6    }
7
8    // 对整个获取实例的方法进行同步
9    public static synchronized Singleton getInstance(){
10        if(instance == null){
11            instance = new Singleton();
12        }
13        return instance;
14    }
15}

这种写法,对获取实例的整个方法用synchronized关键字进行方法同步,保证了同一时刻只能有一个线程能够访问并获得实例。

但是缺点也很明显,这里锁住的是整个方法,锁的粒度太大,造成效率低下,那应该怎么办呢?减小锁的粒度,只把创建实例这块代码上锁,见下面的双重校验锁方式。

(4)、双重校验锁(DCL,double-checked locking,线程安全,可用)

双重校验锁,双重校验说的是两个空值判断,锁说的是synchronized。

(a).双重校验锁代码

1public class Singleton {
2    private static Singleton instance = null;
3
4    private Singleton(){
5        System.out.println("创建Singleton实例!");
6    }
7    // 对必要的代码块(创建实例的代码块)进行同步
8    public static Singleton getInstance(){
9        if (instance == null) {
10            synchronized (Singleton.class{
11                if (instance == null) {
12                    instance = new Singleton();
13                }
14            }
15        }
16        return instance;
17    }
18}
1public class Test {
2    public static void main(String[] args) {
3        for(int i = 0; i < 10; i++){
4            new Thread(){
5                @Override
6                public void run(){
7                    Singleton instance = Singleton.getInstance();
8                    System.out.println(instance);
9                }
10            }.start();
11        }
12    }
13}

程序运行结果:

创建Singleton实例!
com.zxj.test2.Singleton@1a1b9f8
com.zxj.test2.Singleton@1a1b9f8
com.zxj.test2.Singleton@1a1b9f8
com.zxj.test2.Singleton@1a1b9f8
com.zxj.test2.Singleton@1a1b9f8
com.zxj.test2.Singleton@1a1b9f8
com.zxj.test2.Singleton@1a1b9f8
com.zxj.test2.Singleton@1a1b9f8
com.zxj.test2.Singleton@1a1b9f8
com.zxj.test2.Singleton@1a1b9f8

(b).关于双重校验锁的两个疑问

从程序运行结果可以看到,程序中只有一个实例。关于双重校验锁,小伙伴们一定会有一些疑问,下面我们就来共同一一解答。

问题1:既然有了一次空值判断,为什么还要再加一层空值判断呢?

答案:这主要涉及线程安全的问题

这个问题的意思,就是在第9行进行实例非空判断之后,进入synchronized代码块之后就不必再进行一次非空判断了,我们来看一下,如果这样做的话,会产生什么问题?

假设有两个线程,线程A和线程B,线程A先得到CPU的执行权,在执行到第9行时,由于之前没有实例化,所以线程A判断结果为true,然后线程A获得锁进入synchronized代码块里面。

此时线程B争抢到CPU的执行权,并执行到第9行,此时线程A还没有执行实例化动作,所以此时线程B判断为true,线程B想进入同步代码块,但是发现锁还在线程A手里,所以B只能在同步代码块外面等待。

此时线程A得回CPU执行权,执行实例化动作并返回该实例,然后释放锁。

线程A释放了锁,线程B就获得了锁,若此时不进行第二次非空判断,会导致线程B也实例化创建一个实例,然后返回自己创建的实例,这就导致了2个线程访问创建了2个实例,导致单例失效。

若进行第二次非空判断,线程B发现线程A已经创建了实例,则直接返回线程A创建的实例,这样就避免了单例的失效。

问题2:即便将第9行的空值判断去掉,在多线程环境下单例模式仍然有效,那为什么多次一举加上第9行代码呢?

答案:这主要涉及多线程下的效率问题

使用synchronized关键字进行同步,意味着同一时刻只能有一个线程执行同步块里面的代码,还要涉及到锁的争夺、释放等问题,是很消耗资源的。如果我们不加第9行,即不在进入同步块之前进行非空判断,如果之前已经有线程创建了该类的实例了,那每次访问获取实例的方法都会进入同步块,这会非常的耗费性能如果在进入同步块之前加上非空判断,如果之前已经有线程创建了该类的实例了,那就不必进入同步块了,直接返回之前创建的实例即可,减少synchronized操作次数,从而提高 程序性能。

(c).DCL失效问题,以及解决方案

在第12行,instance = new Singleton();,其实这行代码在JVM里面的执行分三步:
1.在堆内存中分配内存空间给这个对象。
2.初始化这个对象。
3.设置instance指向刚分配的内存空间。

由于jvm的指令重排序功能,可能在2还没执行时就先执行了3,此时第12行就变成:
1.在堆内存中分配内存空间给这个对象。
2.设置instance指向刚分配的内存空间。
3.初始化这个对象。

假设有两个线程,线程A和线程B,并且第12行代码发生指令重排序。

线程A先获取到CPU执行权,并执行到第9行。

此时线程B争抢到CPU执行权,并执行完第12行的第2个步骤。

此时线程A夺回CPU执行权,由于线程B执行第12行的第2个步骤,instance已经非空了,它会被线程A直接拿来用,这样的话,就会出现异常,因为instance只是一个引用,它指向的对象还没有实例化呢,自然会出现问题了。这个就是著名的DCL失效问题

解决DCL失效问题,有两种方案,一是使用volatile关键字禁止指令重排序,二是使用静态内部类实现单例模式。

使用volatile关键字禁止指令重排序:

1public class Singleton {
2    private volatile static Singleton instance = null;
3
4    private Singleton(){
5        System.out.println("创建Singleton实例!");
6    }
7    // 对必要的代码块(创建实例的代码块)进行同步
8    public static Singleton getInstance(){
9        if (instance == null) {
10            synchronized (Singleton.class{
11                if (instance == null) {
12                    instance = new Singleton();
13                }
14            }
15        }
16        return instance;
17    }
18}

(5)、静态内部类 (可用,推荐)

1public class Singleton {
2    private Singleton() {}
3
4    private static class SingletonInstance {
5        private static final Singleton instance = new Singleton();
6    }
7    // 静态内部类
8    public static Singleton getInstance() {
9        return SingletonInstance.instance;
10    }
11
12}

使用静态内部类实现单例模式,是在实际开发中最推荐的写法。

问题1:静态内部类,如何保证延迟加载?
当Singleton类第一次被加载时并不会立即实例化,而是当getInstance()方法第一次被调用时,才会去加载SingletonInstance类,从而完成Singleton的实例化。

问题2:静态内部类,如何保证程序中只有一个实例?
因为类的静态属性只会在第一次加载类的时候初始化,也就保证了SingletonInstance中的对象只会被实例化一次。

问题3:如何保证静态内部类实现的单例模式在多线程环境是生效的?
有两点原因,一是虚拟机规范规定,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的()方法,其他线程都需要阻塞等待,直到活动线程执行()方法完毕,二是类的静态属性只会在第一次加载类的时候初始化。

综合这两点以及第5行代码,在多线程环境,只会有一个线程创建一个实例,其他线程用的都是这个线程创建的这个实例。

(6)、枚举 (可用、推荐)

public enum Singleton {
    INSTANCE;
}

这种方式是《Effective JAVA》作者 Josh Bloch 提倡的方式,它不仅能避免多线程同步问题,而且还自动支持序列化机制,防止反序列化重新创建新的对象,绝对防止多次实例化。

单元素的枚举类型已经成为实现Singleton的最佳方法
                      -- 出自 《effective java》

(7)、在实际开发中,选择哪种单例模式的实现方式?

一般情况下,不建议使用第2种和第3种懒汉方式,建议使用第1种饿汉方式。只有在要明确实现 lazy loading 效果时,才会使用第5种静态内部类方式。如果涉及到反序列化创建对象时,可以尝试使用第6种枚举方式。如果有其他特殊的需求,可以考虑使用第4种双重校验锁方式。

三、单例模式优点

(1)在内存里只有一个实例,减少了内存开销,特别是一个对象需要频繁创建和销毁,而且创建和销毁时的性能又无法优化时,这个时候,把它设置成单例可以提高系统性能。
(2)可以避免对资源的多重占用,比如对一个文件进行写操作,由于只有一个连接实例存在内存中,可以避免对一个资源文件同时进行写操作。
(3)设置全局访问点,严格控制访问。例如Web应用的页面计数器就可以用单例模式来实现,从而保证计数的准确性。

四、单例模式缺点

(1)不适用于变化频繁的对象,如果同一类型的对象总是要在不同的用例场景发生变化,单例模式就会引起数据的错误,不能保存彼此的状态。
(2)由于单例模式中没有抽象层,因此单例类的扩展有很大的困难。
(3)单例类的职责过重,在一定程度上违背了“单一职责原则”。
(3)如果实例化的对象长时间不被利用,系统会认为该对象是垃圾而被回收,可能会导致对象状态的丢失。

五、单例模式的使用场景

(1)要求一个类在程序的生命周期当中只有一个实例。比如在Spring框架项目中,若某个类只需要有一个实例,那么只要把Spring配置文件该类的的scope属性配置成singleton即可。
(2)需要频繁实例化然后销毁的对象,也就是频繁的 new 对象,可以考虑用单例模式替换。
(3)创建对象时耗时过多或者耗资源过多,但又经常用到的对象,比如数据库连接。

下面我们举个例子,讲解单例模式的使用:网站在线人数统计

其实就是一个全局计数器,也就是说所有用户在相同的时刻获取到的在线人数数量都是一致的。要实现这个需求,计数器就要全局唯一,也就正好可以用单例模式来实现(利用单例模式的第三个优点:全局访问)。

public class Counter {
    // 使用静态类实现单例模式
    private static class CounterHolder{
        private static final Counter counter = new Counter();
    }
    // 私有构造器
    private Counter(){
        System.out.println("init...");
    }
    // 获取实例的方法
    public static final Counter getInstance(){
        return CounterHolder.counter;
    }
    // 使用AtomicLong类型存储计数
    private AtomicLong online = new AtomicLong();
    // 获取计数
    public long getOnline(){
        return online.get();
    }
    // 计数加1
    public long add(){
        return online.incrementAndGet();
    }
}  


浏览 16
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报