java设计模式-单例模式

设计模式 -- 单例模式

前言:

在面试的时候面试官会怎么在单例模式中提问呢?我们又该如何回答呢?可能在面试的时候会碰到这些问题:

  • 为什么说饿汉式单例天生就是线程安全的呢?
  • 传统的懒汉式单例为什么是非线程安全的?
  • 怎么修改传统的懒汉式单例,使其线程变得安全?
  • 双重检查模式、volatile关键字在单例模式中的应用
  • ThreadLocal在单例模式中的应用
  • 枚举式单例

那我们该怎么回答呢?那么答案来了,看完接下来的内容就可以跟面试官唠唠单例模式了

单例模式简介

单例模式是一种常用的软件设计模式,其属于创建型模式,其含义即是一个类只有一个实例,并为整个系统提供一个实例,(向整个系统提供这个实例)。

结构:

单例模式的三要素:

  • 私有的构造方法
  • 私有的静态实例引用
  • 返回静态实例的静态公有方法

单例模式的优点:

  • 在内存中只有一个对象,节省内存空间
  • 避免频繁的创建销毁对象,可以提高性能
  • 避免对共享资源的多重占用,简化访问
  • 为整个系统提供一个全局访问点

单例模式的注意事项

在使用单例模式的时候,我们必须使用单例类提供的公有工厂方法得到单例对象,而不应该使用反射来创建,使用反射将会破坏单例模式,将会实例化一个新的对象。

单线程的实现方式

  • 饿汉式单例(立即加载):饿汉式单例在单例类被加载的时候,就实例化一个对象并将引用所指向这个实例。
  • 懒汉式单例(延迟加载):只有在需要使用的时候才会实例化一个对象将引用所指向的这个实例。

从速度和反应事件角度来讲,饿汉式(又称立即加载)要好一些;从资源利用效率上说,懒汉式(又称延迟加载)要好一些。

饿汉式单例

public class HungryMan {

    //类一加载就会被创建 可能会浪费空间
    private byte[] data1=new byte[1024*1024];
    private byte[] data2=new byte[1024*1024];
    private byte[] data3=new byte[1024*1024];
    private byte[] data4=new byte[1024*1024];

    //单例模式最重要的 构造器私有
    private HungryMan(){

    }
	//私有静态实例引用 创建私有静态实例,并将引用所指向的实例
    private final static HungryMan HUNGRY_MAN =new HungryMan();

    //返回静态实例的静态公有方法,静态工厂方法
    public static HungryMan getInstance(){
        return HUNGRY_MAN;
    }
}

饿汉式单例,在类被加载的时候,就会实例化一个对象并将引用所指向这个实例,更重要的是,由于这个类在整个生命周期只会被加载一次,只会被创建一次,因此饿汉式单例是线程安全的

那饿汉式单例为什么天生就说线程安全的呢?

因为类加载的方式是按需加载,且只加载一次。由于一个类在整个生命周期中只会被加载一次,在线程访问单例对象之前就已经创建好了,且仅此一个实例。即线程每次都只能也必定只可以拿到这个唯一的对象。

传统懒汉式单例实现

public class LazyMan {

    //私有的构造方法
    private LazyMan(){
        System.out.println(Thread.currentThread().getName()+"ok");
    }

    //私有的静态实例引用
    private static LazyMan lazyMan;
	
    //返回静态实例的静态公有方法,静态工厂方法
    public static LazyMan getInstance(){
        //当需要创建类对象的时候创建单例类对象,并将引用所指向的实例 
        //这里为什么要判断 因为懒汉式是延迟加载,不像是饿汉式是立即加载,在线程访问对象之前
        //就已经创建好了 这里访问对象的时候可能还没有被创建 所以要进行判断非空
        if(lazyMan==null){
            lazyMan=new LazyMan();
        }
        return lazyMan;
    }
}

懒汉式单例是延迟加载,只有在需要的时候才会实例化一个对象,并将引用所指向的这个对象。

由于是需要时创建,在多线程环境下是不安全的,可能会出现并发创建实例,出现多实例的情况,单例模式的初衷是相背离的。那我们需要怎么避免呢?可以看接下来的多线程环境中单例模式的实现形式。

那为什么传统的懒汉式单是非线程安全的呢?

非线程安全的主要原因是,会有多个线程同时进入创建实例if(lazyMan==null){代码块}的情况发生。当这种情形发生后,该单例类就会创建出多个实例,违背单例模式的初衷。因此,传统的懒汉式单例是非线程安全的。

多线程实现方式

在单线程环境下,无论是饿汉式单例还是懒汉式单例,它们都能够正常工作。但是,在多线程环境下就有可能发生变异:

  • 饿汉式单例天生就是线程安全的,可以直接用于多线程而不会出现问题
  • 懒汉式单例本身就说非线程安全的,因此就会出现多个实例的情况,与单例模式的初衷是相背离的。

那我们应该怎么在懒汉的基础上改造呢?

  • synchronized方法
  • synchronized块
  • 使用内部类实现延迟加载

synchronized同步静态方法

public class LazyMan {

    //私有的构造方法
    private LazyMan(){
        System.out.println(Thread.currentThread().getName()+"ok");
    }

    private static LazyMan lazyMan;

    //使用synchronized关键字修饰 临界资源的同步互斥访问
    public synchronized static LazyMan getInstance(){
        if(lazyMan==null){
            lazyMan=new LazyMan();
        }
        return lazyMan;
    }
}

使用synchronized修饰getInstance()方法,将getInstance()方法进行加锁,实现对临界资源的同步互斥访问,以此来保证单例。

虽然可以实现线程安全,但由于同步作用域偏大、锁得粒度有点粗,会导致运行效率很低。

synchronized同步代码块

public class BlockSingleton {

    private static BlockSingleton singleton;

    private BlockSingleton(){

    }

    public static BlockSingleton getInstance(){
        //使用synchronized同步代码块,锁得对象是BlockSingleton类,又称为类锁
        synchronized (BlockSingleton.class){
            if(singleton==null){
                singleton=new BlockSingleton();
            }
        }
        return singleton;
    }
}

其实synchronized同步代码块和sychronized同步静态方法类似,效率都偏低。

使用内部类实现延迟加载

public class InsideSingleton {

    //私有内部类 按需加载,用时加载,也就是延迟加载
    private static class Holder {
        private static InsideSingleton insideSingleton=new InsideSingleton();
    }

    private InsideSingleton(){

    }

    public static InsideSingleton getInstance(){
        return Holder.insideSingleton;
    }
}

如上述代码所示,我们可以使用内部类实现线程安全的懒汉式单例,这种方式也是一种效率比较高的做法,其跟饿汉式单例原理是相同的,但可能还存在反射攻击或者反序列化攻击。

双重检查(Double-Check Idiom)实现

双重检查(Double-Check Idiom)-volatile

使用双重检测同步延迟加载去创建单例,不但保证保证了单例,而且提高了运行效率。

//线程安全的懒汉式单例
public class DoubleCheckSingleton {

    private static boolean qinjiang = false;

    private static volatile DoubleCheckSingleton singleton;

    private DoubleCheckSingleton() {
        synchronized (DoubleCheckSingleton.class) {
            if(qinjiang==false){
                qinjiang=true;
            }else{
                throw new RuntimeException("不要试图使用反射破坏单例模式");
            }
            /*if (singleton != null) {
                throw new RuntimeException("不要试图使用反射破坏单例模式");
            }*/
        }
    }

    public static DoubleCheckSingleton getInstance() {
        //Double-Check Idiom 双重检查机制
        if (singleton == null) {
            synchronized (DoubleCheckSingleton.class) {
                if (singleton == null) {
                    singleton = new DoubleCheckSingleton();
                }
            }
        }
        return singleton;
    }

    //反射会破坏单例模式
    public static void main(String[] args) throws Exception {
        //DoubleCheckSingleton singleton = DoubleCheckSingleton.getInstance();

        Field qinjiang = DoubleCheckSingleton.class.getDeclaredField("qinjiang");
        qinjiang.setAccessible(true);
        Constructor<DoubleCheckSingleton> declaredConstructor = DoubleCheckSingleton.class.getDeclaredConstructor(null);
        declaredConstructor.setAccessible(true); //无视私有构造器
        DoubleCheckSingleton singleton1 = declaredConstructor.newInstance();
        qinjiang.setBoolean(singleton1,false);
        DoubleCheckSingleton singleton2 = declaredConstructor.newInstance();

        System.out.println(singleton2 == singleton1); //false
    }

}

为了保证在单例的情况下提高运行效率,我们需要对singleton实例进行第二次检查,为的是避开过多的同步(因为同步只需在第一次创建实例的时候才同步,一旦创建成功,以后获取实例的时候就不需要同步获取锁了)。

但需要注意的必须使用volatile关键字修饰单例引用,为什么呢?

如果没有使用volatile关键字是可能会导致指令重排序的情况出现,在singleton构造函数体执行之前,变量singleton可能提前成为非null的,即赋值语句在对象实例化之前调用,此时别的线程将得到的是一个不完整(未初始化)的对象,会导致系统崩溃。

此可能为程序执行步骤:

  1. 线程1进入getInstance()方法,由于singleton为null,线程1进入synchronized同步代码块。
  2. 同样由于singleton为null,线程1直接进入到singleton=new DoubleCHeckSingleton()处,在new对象的时候出现重排序,导致赋值在构造函数之前执行,使得实例成为非null,并且该实例并未初始化的原因(原因在NOTE)
  3. 此时,线程2检查实例是否为null,由于实例不为null,线程2得到一个不完整(未初始化化完成)的singleton对象
  4. 线程1通过运行singleton对象的构造函数来完成对该对象的初始化。

这种安全隐患正是由于指令重排序的问题所导致的,而volatile关键字正好可以完美解决这个问题,使用volatile关键字修饰单例引用就可以避免上述灾难。

NOTE

new操作会进行三步走,预想的执行步骤:

memory = allocate();//1.分配对象的内存空间

ctorlnstance(memory); //2.初始化对象

singleton = memeory; //3.使singleton指向指向刚刚分配的内存地址

实际上,这个可能发生无序写入(指令重排序),可能会导致所下执行步骤:

memory = allocate(); //1.分配对象的内存空间

singleton = memeory; //2.使singleton指向指向刚刚分配的内存地址

ctorlnstance(memory); //3.初始化对象

双重检查(Double-Check idiom)-ThreadLocal

借助于ThreadLocal,我们可以实现双重检查模式的变体。我们将临界资源线程局部化,具体到本例就说将双重检测的第一层检测条件if(singleton==null)转换为线程局部范围内的操作。

public class ThreadLocalSingleton {

    //ThreadLocal 线程局部变量
    private static ThreadLocal<ThreadLocalSingleton> threadLocal=new ThreadLocal<>();
    private static ThreadLocalSingleton singleton;

    private ThreadLocalSingleton(){

    }

    public static ThreadLocalSingleton getInstance(){
        if(threadLocal.get()==null){ //第一次检查 先检查当前线程局部变量中是否存在该单例对象
            createSingleton();
        }
        return singleton;
    }

    public static void createSingleton() {
        synchronized (ThreadLocalSingleton.class) {
            if (singleton == null) { //第二次检查 经过第一次检查当前线程不存在该单例对象 判断是否为空
                singleton = new ThreadLocalSingleton();
            }
        }
        threadLocal.set(singleton); //将单例对象放入当前线程的局部变量中
    }

    public static void main(String[] args) {
        ThreadLocalSingleton instance = ThreadLocalSingleton.getInstance();
        ThreadLocalSingleton instance1 = ThreadLocalSingleton.getInstance();

        System.out.println(instance==instance1); //true 说明是单例
    }
}

借助于ThreadLocal,我们也可以实现线程安全的懒汉式单例,但与直接双重检查模式使用,使用ThreadLocal的实现在效率上还不如双重杰斯按查锁定。

枚举实现方式

它不仅能避免多线程同步问题,而且还能防止烦序列化重新创建新的对象。

直接通过Singleton.INSTANCE.whateverMETHOD()的方式调用即可。方便、简洁又安全。

public enum EnumSingleton {

    //枚举本身是一个java类 只不过是继承了java.lang.Enum类
    INSTANCE;
    public EnumSingleton getInstance(){
        return INSTANCE;
    }

    /**
     * 枚举默认是单例模式的 反射不能破坏枚举的单例 注意
     */

    public static void main(String[] args) throws Exception {
        EnumSingleton instance1 = EnumSingleton.INSTANCE;
        EnumSingleton instance2 = EnumSingleton.INSTANCE;

        Constructor<EnumSingleton> declaredConstructor = EnumSingleton.class.getDeclaredConstructor(String.class,int.class);
        declaredConstructor.setAccessible(true);
        EnumSingleton enumSingleton = declaredConstructor.newInstance();

        System.out.println(instance1==instance2);
        System.out.println(instance1==enumSingleton);

    }
}

小结:

单例模是java中最简单,也是最基础,最常用的设计模式之一,在运行期间,保证某个类只创建一个实例,保证一个类仅有一个实例,并提供一个访问它的全局访问点,介绍单例模式的各种写法:

  • 饿汉式单例(线程安全)
  • 懒汉式单例
    • 传统懒汉式单例(线程不安全)
    • 使用synchronized同步实例方法实现懒汉式单例(线程安全)
    • 使用synchronized同步代码块实现懒汉式单例(线程安全)
    • 使用静态内部类实现懒汉式单例(线程安全)
  • 使用双重检查锁
    • 使用volatile关键字(线程安全)
    • 使用ThreadLocal实现懒汉式单例(线程安全)
  • 枚举式单例(线程安全)
posted @ 2021-08-18 17:38  轻风格走一走  阅读(59)  评论(0编辑  收藏  举报