「设计模式」单例模式

143 阅读6分钟

一、概述

单例模式属于一种创建型设计模式。单例模式的定义就是某一个类在系统中只需要有一个实例对象,而且对象是由这个类自行实例化并提供给系统其它地方使用,这个类称为单例类。单例模式是GOF 23种设计模式中最简单的一种,但同时也是在项目中接触最多的一种。

二、优缺点

优点:由于单例模式只生成了一个实例,避免了到处new对象,所以能够节约系统资源,减少性能开销,提高系统效率,同时也能够严格控制客户对它的访问。

缺点:也正是因为系统中只有一个实例,这样就导致了单例类的职责过重,违背了“单一职责原则”,同时也没有抽象类,这样扩展起来有一定的困难。

三、实现方式

单例模式在多线程环境下,肯定要考虑线程安全问题。下面围绕「线程安全」,下面一一列举单例模式的五种实现方式:「饿汉式」、「懒汉式」、「双重检测锁式」、「静态内部类式」和「枚举单例」。

1. 饿汉式

线程安全,这种写法是最常用,简单实用,唯一的缺点就是当类加载的时候会生成一个对象,但是这种写法是线程安全的。类加载到内存后,会实例化一个单例,保证线程安全。

public class SingleTon1 {
    private static final SingleTon1 instance = new SingleTon1();

    private SingleTon1(){

    }
    public static SingleTon1 getInstance(){
        return instance;
    }

}

测试

public void test1(){
        SingleTon1 instance1 = SingleTon1.getInstance();
        SingleTon1 instance2 = SingleTon1.getInstance();
        System.out.println(instance1.hashCode() == instance2.hashCode());
        //true
    }

2. 懒汉式

线程不安全,该实现方式在运行时加载对象,这样带来了线程不安全问题。

public class SingleTon2 {

    private static SingleTon2 instance;

    private SingleTon2(){
    }

    public static SingleTon2 getInstance(){
        if(null == instance){
         //休眠一毫秒来模拟线程挂起
         try{
                Thread.sleep(1);
            }catch (Exception e){
                e.printStackTrace();
            }
            instance = new SingleTon2();
        }
        return instance;
    }
}

为了更直观观察开启100个线程,测试线程安全问题,通过休眠一毫秒来模拟线程挂起,为初始化完instance。

public void test2(){
        for(int i=0;i<100;i++){
            new Thread(()->
                    System.out.println(SingleTon2.getInstance().hashCode())
            ).start();
        }
    }

日志输出>>

1060934507
992953204
711038951
1943663771
992953204
40773580
1075978764
1199869364
1942038659
992953204
1775468882
681753486
.......

造成线程不安全的原因

当并发访问的时候,第一个调用getInstance方法的线程t1,在判断完instance是null的时候,线程A就进入了if块准备创造实例,但是同时另外一个线程B在线程A还未创造出实例之前,就又进行了instance是否为null的判断,这时instance依然为null,所以线程B也会进入if块去创造实例,这时问题就出来了,有两个线程都进入了if块去创造实例,结果就造成单例模式并非单例。

3. 懒汉式(synchronized修饰方法)

线程安全,锁的粒度比较大,对getInstance()方法加了 synchronized 来保证多线程下的线程安全,但是锁住了整个方法,导致其它线程堵塞等待,效率比较低。

public class SingleTon3 {

    private static SingleTon3 instance;

    private SingleTon3(){
    }

    public static synchronized SingleTon3 getInstance(){
        if(null == instance){
            instance = new SingleTon3();
        }
        return instance;
    }
}

4. 懒汉式(synchronized修饰代码块)

线程不安全,锁的粒度虽然小,对代码块加了 synchronized 来保证多线程下的线程安全,但是依旧还是会出现和第2种未加锁的问题一样,线程不安全。

public class SingleTon4 {

    private static SingleTon4 instance;

    private SingleTon4(){

    }

    public static SingleTon4 getInstance(){
        if(null == instance){
            synchronized(SingleTon4.class){
                instance = new SingleTon4();
            }
        }
        return instance;
    }
}

5. 懒汉式(双重检测锁式)

线程安全,所谓“双重检测锁式”机制,指的是:并不是每次进入getInstance方法都需要同步,而是先不同步,进入方法后,先检查实例是否存在,如果不存在才进行下面的同步块,这是第一重检查,进入同步块过后,再次检查实例是否存在,如果不存在,就在同步的情况下创建一个实例,这是第二重检查。这样一来,就只需要同步一次了,从而减少了多次在同步情况下进行判断所浪费的时间。

public class SingleTon5 {

    private static volatile SingleTon5 instance;

    private SingleTon5(){

    }

    public static SingleTon5 getInstance(){
        if(null == instance){
            synchronized(SingleTon5.class){
                if(null == instance){
                    instance = new SingleTon5();
                }
            }
        }
        return instance;
    }
}

(1)为么两层判空?

第一层:为了减少锁了粒度,因为只有在instance == null的时候才需要上锁,其他情况可以直接返回,这样就节省了很多无谓的线程等待时间。

第二层:获取锁后还需要判断 instance == null是因为instance可能已经被改变,所以要再次判断。

例如:两个线程都在等待获取锁,线程A获取到后实例化了instance后释放了锁,instance现在不是null;之后线程B获取到锁,如果不判断instance == null的话便会又重新创建一个instance。

(2)为么使用volatile修饰?

因为虚拟机在执行创建实例的这一步操作的时候,其实是分了好几步去进行的,也就是说创建一个新的对象并非是原子性操作。

创建一个新的对象,需要3个步骤:

  1. 给 instance 分配内存
  2. 调用 Singleton 的构造函数来初始化成员变量
  3. 将instance对象指向分配的内存空间

JVM存在指令重排序优化,有可能上述步骤变为1-3-2。假设现在的执行顺序是1-3-2,现在有两个线程A和B。A获取锁后,执行new对象,执行步骤是1-3,还未执行2。需要注意的是,执行完3后jinstance就未非null了,而第2步还没有执行,对象不是完整的对象,此时如果判断instance == null 将返回false。此时线程B执行判断同步代码块外的 instance == null 判断,得到结果false,直接返回了这个不完整的对象。因此这里volatile是为了避免指令重排序,而不是可见性。

6. 静态内部类式

线程安全,类加载的机制来保证初始化实例时只有一个线程,类的静态属性只会在第一次加载类的时候初始化,所以在这里jvm帮助我们保证了线程的安全性,在类进行初始化时,别的线程是无法进入的。

public class SingleTon6 {

    private static class StaticSingleTon{
        private static final SingleTon6 instance = new SingleTon6();
    }

    public static SingleTon6 getInstance(){
        return StaticSingleTon.instance;
    }
}

7. 枚举单例

线程安全,使用枚举的方法来实现单例时很简单的一种方法,同时也可以防止反序列化攻击。枚举类型以及其定义的枚举变量在JVM中都是唯一的,外部无法通过构造器创建枚举类的实例,因此反序列化后的实例也会和之前被序列化的对象实例相同,所以枚举本身就是单例的。

public enum SingleTon7 {
    INSTANCE;
    public void doSomething() {
        System.out.println("doSomething");
    }
}

四、常见应用场景

  • 网站计数器
  • 项目中用于读取配置文件的类
  • 数据库连接池
  • Spring中,每个Bean默认都是单例的,这样便于Spring容器进行管理。
  • Windows中任务管理器,回收站。