单例设计模式

发布于 2022年 01月 12日 08:48

一、概述

1、什么是单例设计模式?

在某些特殊场合中,一个类只能够产生一个实例对象,并且这个实例对象要可以对外提供访问。这样的类叫做单例类, 而设计单例的流程和思想叫做单例设计模式

单例模式属于设计模式三大类中的创建型模式

2、单例设计模式的特点

单例模式具有典型的三个特点:

  • 只有一个实例。
  • 自我实例化。
  • 提供全局访问点。

注意:

注:注意单例模式所属类的构造方法是私有的,所以单例类是不能被继承的。 (这句话表述的有点问题,单例类一般情况只想内部保留一个实例对象,所以会选择将构造函数声明为私有的,这才使得单例类无法被继承。单例类与继承没有强关联关系。)

3、单例设计模式的UML类图

单例模式的UML结构图非常简单,就只有一个类,如下图:

Singleton类,定义一个静态方法,getInstance(),可以通过类名来调用,主要负责替代构造方法,创建Singleton类唯一的实例对象。

这个类可以对外提供访问,允许用户通过getInstance()方法访问它唯一的实例。

4、单例设计模式的优缺点:

优点:

1)、由于单例模式只生成了一个实例,所以能够节约系统资源,减少性能开销,提高系统效率,

2)、避免频繁的创建销毁对象,可以提高性能;

3)、避免对共享资源的多重占用,简化访问;

4)、为整个系统提供唯一一个全局访问点,能够严格控制客户对它的访问。

缺点:

1)、不适用于变化频繁的对象;

2)、也正是因为系统中只有一个实例,这样就导致了单例类的职责过重,违背了“单一职责原则”,

滥用单例将带来一些负面问题,如为了节省资源将数据库连接池对象设计为的单例类,可能会导致共享连接池对象的程序过多而出现连接池溢出;

3)、同时也没有抽象类,这样扩展起来有一定的困难。

4)、如果实例化的对象长时间不被利用,系统会认为该对象是垃圾而被回收,这可能会导致对象状态的丢失;(这个所有的对象都会,跟单例无关。)

5、单例模式的应用场景:

场景一:

windows的任务管理器,无论你点击多少次,始终都只有一个管理器窗口存在,系统并不会为你创建新的窗口,也就是说,整个系统运行的过程中,系统只维护了一个进程管理器的实例。这就是一个典型的单例模式运用。

场景二:

线程池、数据库连接池的设计一般也是采用单例模式,因为数据库连接是一种数据库资源。数据库软件系统中使用数据库连接池,主要是节省打开或者关闭数据库连接所引起的效率损耗,这种效率上的损耗还是非常昂贵的,用单例模式来维护,就可以大大降低这种损耗。

场景三:

程序的日志模块。一般也是采用单例模式实现。由于共享的日志文件一直处于打开状态,只能有一个实例去操作,否则内容不好追加。 采用单例模式就可以。

场景四:

Web应用的配置对象的读取,一般也应用单例模式,这个是由于配置文件是共享的资源。

这些配置信息存放在一个文件中,由一个单例对象统一读取,然后服务进程中的其他对象再通过这个单例对象获取这些配置信息。这种方式简化了在复杂环境下的配置管理。

场景五:

在我们的实际项目开发中,可以使用单例模式来封装一些常用的工具类,保证整个应用常用的数据统一。或者保存一些共享数据在内存中,其他类随时可以读取。

二、单例模式的实现步骤

可以使用如下的步骤实现一个单例类:

单例设计模式的实现流程

1、将构造方法私有化,使用private关键字修饰。使其不能在类的外部通过new关键字实例化该类对象。

2、在该类内部产生一个唯一的实例化对象,并且将其封装为private static类型。

3、对外提供一个静态方法getInstance()负责将对象返回出去,使用public static修饰

三、单例模式的实现方式 (推荐枚举类方式)

1、饿汉式——立即加载

线程安全,调用效率高。但是不能延时加载。

立即加载就是加载类的时候就已经将对象创建完毕(不管以后会不会使用到该实例化对象,先创建了再说。很着急的样子,故又被称为“饿汉模式”),常见的实现办法就是直接new实例化。

所以加载类的速度比较慢,但是获取对象的速度比较快,且是线程安全的。

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
/**
 * 饿汉式
 */
public class Singleton {
 
    // 创建全局唯一的实例化对象,在类初始化时,就会立即加载这个对象
    private static Singleton instance = new Singleton();
 
    // 私有化构造方法
    private Singleton() {}
 
    // 提供公有静态方法返回对象
    public static Singleton getInstance() {
        return instance;
    }
}

我们知道,类加载的方式是按需加载,且加载一次。因此,在上述单例类被加载时,就会实例化一个对象并交给自己的引用,供系统使用;而且,由于这个类在整个生命周期中只会被加载一次,因此只会创建一个实例,即能够充分保证单例。

优缺点:

优点:这种写法比较简单,就是在类装载的时候就完成实例化。避免了线程同步问题。

缺点:在类装载的时候就完成实例化,没有达到Lazy Loading的效果。如果从始至终从未使用过这个实例,则会造成内存的浪费。(因为这个static的instance对象会一直占着这段内存,直到卸载类(即便你还没有用到这个实例))

2、懒汉式——延迟加载

延迟加载就是调用get()方法时实例才被创建(先不急着实例化出对象,等要用的时候才给你创建出来。不着急,故又称为“懒汉模式”),常见的实现方法就是在get方法中进行new实例化。

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
/**
 * 懒汉式
  */
public class Singleton {
     
    // 声明一个自身实例对象的引用
    private static Singleton instance;
     
    // 私有化构造方法
    private Singleton(){}
     
    // 提供公有静态方法返回对象
    public static Singleton getInstance() {
        // 判断如果为空,就创建,如果已经有了,就直接返回该实例,避免重复创建,保证全局唯一
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

由于该模式是在运行时加载对象的,所以加载类比较快,但是对象的获取速度相对较慢,且线程不安全。如果想要线程安全的话可以加上synchronized关键字,但是这样会付出惨重的效率代价。

我们从懒汉式单例可以看到,单例实例被延迟加载,即只有在真正使用的时候才会实例化一个对象并交给自己的引用。

这种写法起到了Lazy Loading的效果,但是只能在单线程下使用。如果在多线程下,一个线程进入了if (singleton == null)判断语句块,还未来得及往下执行,另一个线程也通过了这个判断语句,这时便会产生多个实例。所以在多线程环境下不可使用这种方式。

“懒汉模式”的优缺点:

优点:实现起来比较简单,当类SingletonTest被加载的时候,静态变量static的instance未被创建,只是声明了一个引用,并未分配内存空间。要当getInstance方法第一次被调用时,初始化instance变量,才会真正创建对象,开始分配内存,因此在某些特定条件下会节约了内存。(需要时才创建)

缺点:在多线程环境中,这种实现方法是完全错误的,根本不能保证单例的状态。

3、线程安全的“懒汉模式”——  synchronized

在懒汉模式的基础上,增加了synchronized锁同步机制,保证全局唯一。

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
/**
 * 3、线程安全的懒汉式 —— synchronized
 */
public class Singleton {
 
    // 声明一个自身实例对象的引用
    private static Singleton instance;
 
    // 私有化构造方法
    private Singleton(){}
 
    // 提供公有静态方法返回对象,加上synchronized关键字实现同步
    public static synchronized Singleton getInstance() {
        // 判断如果为空,就创建,如果已经有了,就直接返回该实例,避免重复创建,保证全局唯一
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

优点:在多线程情形下,保证了“懒汉模式”的线程安全。

缺点:众所周知在多线程情形下,synchronized方法通常效率低,显然这不是最佳的实现方案。

4、懒汉式(DCL双重检测锁)

DCL双检查锁机制(DCL:double checked locking)

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/**
 * 4、懒汉式 —— DCL双重检查锁机制(类锁)
 * 再一次缩小了锁的范围,提供了性能
 */
public class Singleton {
 
    // 声明一个自身实例对象的引用,使用volatile保证多线程下引用的一致性
    private static volatile Singleton instance;
 
    // 私有化构造方法
    private Singleton(){}
 
    // 提供公有静态方法返回对象
    public static Singleton getInstance() {
        // 第一次检查instance是否被实例化出来,如果没有,再加锁处理
        if (instance == null) {
            synchronized (Singleton.class) {
                // 某个线程取得了类锁,实例化对象前第二次检查instance是否已经被实例化出来,如果没有,才最终实例出对象
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

Double-Check概念对于多线程开发者来说不会陌生。

我们这里相比3直接对静态方法getInstance加上synchronized锁的方式,缩小了锁的范围。

将第一个if判断块释放出来了,如果实例存在,则根本不会锁住,大大加快了返回实例的效率。

只有当第一次if检查后,确定实例是真的不存在,需要创建时,此时才会开始加锁,注意此时加的是类锁,不是对象锁。不过这里是static静态方法,对象也是静态的,所以实际上它们效果是一样的。


为什么在锁里面还要再次判定是否为空呢?

因为高并发,后面的线程在第一次判定实例时也为空,也可以获得锁,只是要排队,只是在等待前面的线程释放锁。所以,当轮到它拿到锁之后,可能前面的线程已经创建了实例,所以要再次判定是否为空。这样才能保证实例唯一。

(重点在于,多个线程可以同时通过第一个if,然后都可以按顺序执行锁里的代码。)


Java指令重排的问题

注意:单纯使用上面这种方式,仍然是线程不安全的。

因为存在java指令重排的问题。

在java创建对象的时候,cpu按照以下三个步骤来执行:

1、memory = allocate() 在堆内存中开辟对象的内存空间,并指定地址

2、根据类加载的顺序,初始化对象。

3、instance = memory 设置instance指向刚分配的内存地址。instance是变量,存在栈中。


单纯执行以上三步没啥问题,但是在多线程情况下,可能会发生指令重排序。

指令重排序对单线程没有影响,单线程下CPU可以按照顺序执行以上三个步骤,但是在多线程下,如果发生了指令重排序,则会打乱上面的三个步骤。

如果发生了JVM和CPU优化,发生重排序时,可能会按照下面的顺序执行:

1、memory = allocate() 在堆内存中开辟对象的内存空间,并指定地址

3、instance = memory 设置instance指向刚分配的内存地址。instance是变量,存在栈中。

2、根据类加载的顺序,初始化对象。


假设目前有两个线程A和B同时执行getInstance()方法,

  • A线程执行到instance = new Singleton(); B线程刚执行到第一个 if (instance == null) 处,
  • 如果按照1.3.2的顺序,假设线程A执行到第三步3.instance = memory 设置instance指向刚分配的内存,此时,线程B判断instance已经有值,就会直接return instance;
  • 而实际上,线程A还未执行第二步 初始化对象,也就是说线程B拿到的instance对象还未进行初始化,这个未初始化的instance对象一旦被线程B使用,就会出现问题。

5、懒汉式(DCL双重检测锁机制+volatile禁止指令重排)—— 推荐

相比4,这里对引用加入了volatile机制,禁止java的指令重排

懒汉式的单例模式的最佳实现方式。内存消耗少,效率高,线程安全,多线程操作原子性。

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/**
 * 5、懒汉式 —— DCL双重检查锁机制(类锁) + volatile禁止指令重排
 * 再一次缩小了锁的范围,提供了性能。(推荐)
 */
public class Singleton {
 
    // 声明一个自身实例对象的引用,使用volatile禁止指令重排,保证多线程下引用的一致性
    private static volatile Singleton instance;
 
    // 私有化构造方法
    private Singleton(){}
 
    // 提供公有静态方法返回对象
    public static Singleton getInstance() {
        // 第一次检查instance是否被实例化出来,如果没有,再加锁处理
        if (instance == null) {
            synchronized (Singleton.class) {
                // 某个线程取得了类锁,实例化对象前第二次检查instance是否已经被实例化出来,如果没有,才最终实例出对象
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

线程安全;延迟加载;效率较高。

6、静态代码块——立即加载

静态代码块方式跟饿汉式的方式几乎是一样的,只是把初始化代码放到了static块中了。

因为我们知道,类加载的时候,这些属性和静态代码块都是会跟随类一起加载的,所以它的实现方式和饿汉式一样。也是线程安全的。

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
 * 6、静态代码块方式
 * 方式类似饿汉式,也是立即加载,是线程安全的
  */
public class Singleton {
 
    // 在外部声明一个对象的引用,注意不能放到静态代码块中
    private static Singleton instance;
 
    // 静态代码块中,创建唯一实例对象,赋值给引用。
    static {
        instance = new Singleton();
    }
 
    // 私有化构造方法
    private Singleton() {}
 
    // 提供公有静态方法返回实例对象
    public static Singleton getInstance() {
        return instance;
    }
}

优缺点:

优缺点都同饿汉式一样,也是立即加载,线程安全的。

这里定义静态变量时要注意:

静态变量只能定义在类的内部,不可以定义在静态块或方法中。可以在类内部定义静态变量,在静态块中进行初始化操作,因为类的内部是不允许有操作语句存在的,比如JDBC操作,所以可以在静态块static{} 中进行初始化操作,如:JDBC 定义静态变量主要是为了供外部访问,定义在一个局部中外部没有权限访问,为什么要定义呢,而且不能定义。

7、静态内部类

懒汉模式需要考虑线程安全,所以我们多写了好多的代码,饿汉模式利用了类加载的特性为我们省去了线程安全的考虑,那么,既能享受类加载确保线程安全带来的便利,又能延迟加载的方式,就是静态内部类。Java静态内部类的特性是,加载的时候不会加载内部静态类,使用的时候才会进行加载。而使用到的时候类加载又是线程安全的,这就完美的达到了我们的预期效果~

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
/**
 * 7、静态内部类
 * 融合饿汉式和懒汉式的优点,推荐
  */
public class Singleton {
 
    // 私有静态内部类中创建并初始化实例对象,注意要private私有化,不能被外部调用了
    private static class SingletonInner{
        private static Singleton instance = new Singleton();
    }
 
    // 私有化构造方法
    private Singleton() {}
 
    // 提供公有静态方法,返回实例对象
    public static Singleton getInstance() {
        return SingletonInner.instance;
    }
}

似乎静态内部类看起来已经是最完美的方法了,其实不是,可能还存在反射攻击或者反序列化攻击。


8、枚举类 —— 线程最安全(最佳方式)

单元素的枚举类型已经成为实现Singleton的最佳方法

                      -- 出自 《effective java》

在effective java(这本书真的很棒)中说道,最佳的单例实现模式就是枚举模式。利用枚举的特性,让JVM来帮我们保证线程安全和单一实例的问题。除此之外,写法还特别简单。

01
02
03
04
05
06
07
/**
 * 8、枚举类
 * 最佳实现方式
  */
public enum Singleton {
    INSTANCE;
}
注意:

因为INSTANCE实例是public公有的,可以直接通过类名的方式调用,即Singleton.INSTANCE,

就不再需要提供公有静态方法getInstance()来返回对象了。


这是最简洁、最安全的方式,不过它不能实现lazy loading延迟加载。


其实枚举类它本身就具备单例的特性:

比如:都会私有化构造方法。枚举类会对属性值加上public static final的属性,保障这个属性值都是全局唯一的。这些操作都和单例很像

所以把这个属性变成对象,它就是一个单例类。

类似于这种内部类的形式:

01
02
03
public class Singleton {
    public static final Singleton INSTANCE = new Singleton();
}

枚举类继承自ENUM,内部实现了Serializable接口,所以不用考虑序列化的问题(其实序列化反序列化也能导致单例失败的,但是我们这里不过多研究)。

对于线程安全,同样的,加载的时候JVM能确保只加载一个实例。避免暴力反射创建多个实例,绝对防止多次实例化。


枚举类最佳实践:

参考:https://www.jianshu.com/p/d35f244f3770

枚举单例示例:

01
02
03
04
05
06
public enum  EnumSingleton {
    INSTANCE;
    public EnumSingleton getInstance(){
        return INSTANCE;
    }
}

实际应用场景中,很多人会这么使用枚举单例:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class User {
    //私有化构造函数
    private User(){ }
  
    //定义一个静态枚举类
    static enum SingletonEnum{
        //创建一个枚举对象,该对象天生为单例
        INSTANCE;
        private User user;
        //私有化枚举的构造函数
        private SingletonEnum(){
            user=new User();
        }
        public User getInstnce(){
            return user;
        }
    }
  
    //对外暴露一个获取User对象的静态方法
    public static User getInstance(){
        return SingletonEnum.INSTANCE.getInstnce();
    }
}
 
public class Test {
    public static void main(String [] args){
        System.out.println(User.getInstance());
        System.out.println(User.getInstance());
        System.out.println(User.getInstance()==User.getInstance());
    }
}
结果为true

以上代码看起来已经是ok了,其实不是,可能还存在反射攻击或者反序列化攻击

最终版

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
public enum Singleton {
 
    INSTANCE;
 
    public void doSomething() {
        System.out.println("doSomething");
    }
 
}
 
// 调用方法:
 
public class Main {
 
    public static void main(String[] args) {
        Singleton.INSTANCE.doSomething();
    }
 
}
 
// 直接通过Singleton.INSTANCE.doSomething()的方式调用即可。方便、简洁又安全。

推荐大家使用枚举类实现单例模式。

四、各种实现方式的选择

一般情况下,懒汉式(包含线程安全和线程不安全两种方式)都比较少用;

饿汉式和DCL双重检测锁都可以使用,可根据具体情况自主选择;

在要明确实现 lazy loading 效果时,可以考虑静态内部类的实现方式;

若涉及到反序列化创建对象时,大家也可以尝试使用枚举方式。

在选择时,请参考下面这张图:

图片来源:https://www.cnblogs.com/rainbowbridge/p/12902359.html

五、破坏单例模式的方法及解决办法

参考:https://blog.csdn.net/b_just/article/details/104061314

1、除枚举方式外, 其他方法都会通过反射的方式破坏单例,反射是通过调用构造方法生成新的对象,所以如果我们想要阻止单例破坏,可以在构造方法中进行判断,若已有实例, 则阻止生成新的实例,解决办法如下:

01
02
03
04
05
private SingletonObject1(){
    if (instance !=null){
        throw new RuntimeException("实例已经存在,请通过 getInstance()方法获取");
    }
}

2、如果单例类实现了序列化接口Serializable, 就可以通过反序列化破坏单例,所以我们可以不实现序列化接口,如果非得实现序列化接口,可以重写反序列化方法readResolve(), 反序列化时直接返回相关单例对象。

01
02
03
public Object readResolve() throws ObjectStreamException {
    return instance;
}


引用转载:

https://www.jianshu.com/p/3f5eb3e0b050 (爆赞)

https://www.cnblogs.com/xuwendong/p/9633985.html (爆赞)

https://segmentfault.com/a/1190000010755849 (赞)

https://www.cnblogs.com/binaway/p/8889184.html

https://www.jianshu.com/p/d35f244f3770 (赞)

https://blog.csdn.net/b_just/article/details/104061314

推荐文章