你写的单例模式,真正安全吗?

Posted 申码er

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了你写的单例模式,真正安全吗?相关的知识,希望对你有一定的参考价值。

作者:Sicimike
链接:blog.csdn.net/Baisitao_/article/details/104452776
说起单例模式,相信大家都不会陌生。因为相比其他设计模式,实现一个单例模式是比较简单的。单例模式的意思就是一个类只有一个实例。

获取类的实例,我们往往采用new关键字,但是要保证一个类只能有一个实例,所以不能让使用这个类的开发人员利用new关键字来创建实例。也就是不能让外部调用类的构造方法,所以很容易想到类的构造方法私有,这样开发人员就不能在类之外通过new的方法创建该类的对象了。

由于外部不能通过new关键字来创建单例类的对象了,所以单例类本身必须提供一个静态方法,使得外部可以通过类名 + 方法名的方法获取单例类的对象。

这就是单例模式的两个特点:
  • 构造方法私有
  • 提供一个静态方法,使得外部通过该方法获取单例类的实例
几乎所有的单例模式实现,都围绕这两点展开

单例模式,也叫单子模式,是一种常用的软件设计模式,属于创建型模式的一种。在应用这个模式时,单例对象的类必须保证只有一个实例存在。
单例模式根据实例创建的时机,大致可以分为两种:饿汉式单例和懒汉式单例。
  • 饿汉式单例是指在单例类加载的时候就初始化一个对象,不管之后的程序会不会用到。因为显得迫不及待,感觉很饿,所以叫饿汉式单例。


  • 懒汉式单例类似懒加载,只有程序第一次用到的时候,才开始实例化,所以叫懒汉式单例。


饿汉式单例

由于饿汉式单例比较简单,所以直接给出源代码
  
    
    
  
/**
 * 恶汉式单例,线程安全
 * @author sicimike
 * @create 2020-02-23 20:15
 */
public class Singleton1 {
    private static final Singleton1 INSTANCE = new Singleton1();
    private Singleton1() {}
    public static Singleton1 getInstance() {
        return INSTANCE;
    }
}
除此之外,饿汉式单例还有一种变种写法,就是把实例化的过程放在静态代码块中
/**
 * 饿汉式单例,线程安全
 * @author sicimike
 * @create 2020-02-23 20:19
 */
public class Singleton2 {
    private static Singleton2 INSTANCE = null;
    static {
        INSTANCE = new Singleton2();
    }    private Singleton2() {}
    public static Singleton2 getInstance() {
        return INSTANCE;
    }
}
  
    
    
  
总的来说,都是在类加载的时候就被实例化,所以这两种写法基本上没什么区别,都是饿汉式单例。

懒汉式单例

懒汉式单例相比饿汉式单例就复杂得多。先看一种比较简单的写法
/**
 * 懒汉式单例,线程不安全
 * @author sicimike
 * @create 2020-02-23 20:21
 */
public class Singleton3 {
    private static Singleton3 INSTANCE = null;
    private Singleton3() {}
    public static Singleton3 getInstance() {
        if (INSTANCE == null) {
            INSTANCE = new Singleton3();
        }        
        return INSTANCE;
    }
}
  
    
    
  
当程序第一次调用getInstance()方法时,才开创建实例。以上代码在单线程环境没有什么问题,但是在多线程环境下,是线程不安全的。原因也很简单,假设线程A执行到了第14行(尚未执行)INSTANCE = new Singleton3(),此时INSTANCE依然等于null,线程B也也会进入if判断,如果两个线程继续执行,那就产生了两个不同的实例,所以线程不安全。要想实现线程安全也很简单,因为我们知道有个关键字叫synchronized,稍微改造下代码
  
    
    
  
/**
 * 懒汉式单例,线程安全
 * synchronized关键字实现
 * @author sicimike
 * @create 2020-02-23 20:26
 */
public class Singleton4 {
    private static Singleton4 INSTANCE = null;
    private Singleton4() {}
    public static synchronized Singleton4 getInstance() {
        if (INSTANCE == null) {
            INSTANCE = new Singleton4();
        }        
        return INSTANCE;
    }
}
毫无疑问,我们实现了线程安全的懒汉式单例。

但是由于锁住了整个getInstance(),所以这种单例模式的效率是非常低下的。之前的博客也写过,尽量缩小锁的范围。所以我们不锁整个方法,而是锁住方法中的部分代码,再次改造代码
  
    
    
  
/**
 * 懒汉式单例,线程不安全
 * @author sicimike
 * @create 2020-02-23 20:29
 */
public class Singleton5 {
    private static Singleton5 INSTANCE = null;
    private Singleton5() {}
    public static Singleton5 getInstance() {
        if (INSTANCE == null) {
            synchronized (Singleton5.class) {
                if (INSTANCE == null) {
                    INSTANCE = new Singleton5();
                }            
            }        
        }        
        return INSTANCE;
    }
}
相比于前一种,这种实现明显效率更高,因为synchronized关键字外部有一层判断,只要实例被创建了,就不会再进入同步代码块,而前一种是每次调用方法都会进入同步代码。

也许有的同学会在这里有疑问,为什么synchronized外部加了一次判断,内部还要加一次判断?

这其实是并发编程中常见的一种手段,叫double-check,也就是常说的双重校验(博主的并发编程分类,写过一些JDK并发容器、线程池的源码,很多地方用到了双重校验,有兴趣的同学可以去翻一翻)。假设线程A执行到了第15行(尚未执行)if (INSTANCE == null),此时INSTANCE依然等于null,而线程B可能已经进入了外层判断,而被阻塞在synchronized这里。线程A继续执行完成对象的创建后释放锁,线程B获取锁进入同步代码块,如果没有第二次判断,线程B会直接创建对象。所以synchronized内也必须加一次判断。

这种实现方式看起来似乎已经天衣无缝了,但是它依然是线程不安全的。线程不安全的根本原因就是INSTANCE = new Singleton5()不是原子操作。而是分为三步完成
1、分配内存给这个对象
2、初始化这个对象
3、把INSTANCE变量指向初始化的对象
正常情况下按照1 -> 2 -> 3的顺序执行,但是2和3可能会发生重排序,执行顺序变成1 -> 3 -> 2。如果是1 -> 3 -> 2的顺序执行。线程A执行完3,此时对象尚未初始化,但是INSTANCE变量已经不为null,线程B执行到synchronized关键字外部的if判断时,就直接返回了。此时线程B拿到的是一个尚未初始化完成的对象,可能会造成安全隐患。所以这种实现方式是线程不安全的。要向解决这个问题,也就是解决重排序的问题,聪明的你应该想到了另一个关键字volatile,再次改造下代码
  
    
    
  
/**
 * 懒汉式单例,线程安全
 * 双重校验锁
 * @author sicimike
 * @create 2020-02-23 20:34
 */
public class Singleton6 {
    private static volatile Singleton6 INSTANCE = null;
    private Singleton6() {}
    public static Singleton6 getInstance() {
        if (INSTANCE == null) {
            synchronized (Singleton6.class) {
                if (INSTANCE == null) {
                    INSTANCE = new Singleton6();
                }            
            }        
        }        
        return INSTANCE;
    }
}
给INSTANCE加上volatile关键字就解决了这个问题,这种实现方式就是双重校验锁的方式。volatile关键字的在这里的作用有两个:
  • 解决了重排序的问题
  • 保证了INSTANCE的修改,能够及时的被其他线程所知
双重校验锁的实现方式涉及到的知识较多,所以相对来说,还有更加简便的方式,那就是利用静态内部类
  
    
    
  
/**
 * 懒汉式单例,线程安全
 * 静态内部类
 * @author sicimike
 * @create 2020-02-23 20:36
 */
public class Singleton7 {
    private Singleton7() {}
    public static Singleton7 getInstance() {
        return InnerClass.INSTANCE;
    }    
    private static class InnerClass {
        private static Singleton7 INSTANCE = new Singleton7();
    }
}
这种实现方式既满足懒加载,又满足线程安全,代码量还少,相对来说是一种比较优雅的实现方式。
至此已经给出了单例模式的7种写法,线程安全的有5种。虽然有点类似“茴香豆的茴字有几种写法”,但是仔细了解各种写法之间的区别,以及线程安全的问题,收获肯定不小。

单例与序列化

序列化(serialization)在计算机科学的数据处理中,是指将数据结构或对象状态转换成可取用格式(例如存成文件,存于缓冲,或经由网络中发送),以留待后续在相同或另一台计算机环境中,能恢复原先状态的过程。依照序列化格式重新获取字节的结果时,可以利用它来产生与原始对象相同语义的副本。
简单来说,将数据结构或者对象的状态转化成可存储或可传输的过程称为序列化,相反的过程称为反序列化。咋一看,序列化和单例似乎没有什么关系。所以先看一个例子,首先利用第一种饿汉单例的方式写一个单例,实现Serializable接口
  
    
    
  
/**
 * 单例模式与序列化
 * @author sicimike
 * @create 2020-02-23 22:26
 */
public class SingletonWithSerialize implements Serializable {
    private static final long serialVersionUID = 6133201454552796162L;
    
    private static final SingletonWithSerialize INSTANCE = new SingletonWithSerialize();
    
    private SingletonWithSerialize() {}
    public static SingletonWithSerialize getInstance() {
        return INSTANCE;
    }
}

接下来对这个对象进行序列化和反序列化
/**
 * @author sicimike
 * @create 2020-02-23 20:48
 */
public class SingletonDemo {
    private static final String FILE_PATH = "singleton.data";
    public static void main(String[] args) throws IOException, ClassNotFoundException {
        SingletonWithSerialize instance = SingletonWithSerialize.getInstance();
        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(new File(FILE_PATH)));
        oos.writeObject(instance);                
        ObjectInputStream ois = new ObjectInputStream(new FileInputStream(new File(FILE_PATH)));
        SingletonWithSerialize readInstance = (SingletonWithSerialize) ois.readObject();                
        System.out.println(instance);        
        System.out.println(readInstance);    // 关闭IO流
    }
}
执行结果
com.sicimike.creation.singleton.SingletonWithSerialize@7f31245a
com.sicimike.creation.singleton.SingletonWithSerialize@7cca494b
可以看到,序列化和反序列化破坏了单例模式。
对于这个问题,JDK早已提供了解决方案,那就是在单例类中提供一个readResolve方法
The readResolve method is called when ObjectInputStream has read an object from the stream and is preparing to return it to the caller. ObjectInputStream checks whether the class of the object defines the readResolve method. If the method is defined, the readResolve method is called to allow the object in the stream to designate the object to be returned.
——https://docs.oracle.com/javase/8/docs/platform/serialization/spec/input.html#a5903
当ObjectInputStream从流中读取一个对象并准备将其返回给调用方时,将调用readResolve方法。ObjectInputStream检查对象的类是否定义了readResolve方法。如果定义了该方法,则将调用readResolve方法,以允许流中的对象指定要返回的对象。

也就是说反序列化的时候,JDK提供了一个钩子函数来让开发者指定要返回的对象,用法如下
  
    
    
  
/**
 * 单例模式与序列化
 * @author sicimike
 * @create 2020-02-23 22:26
 */
public class SingletonWithSerialize implements Serializable {
    private static final long serialVersionUID = 6133201454552796162L;
    private static final SingletonWithSerialize INSTANCE = new SingletonWithSerialize();
    private SingletonWithSerialize() {}
    public static SingletonWithSerialize getInstance() {
        return INSTANCE;
    } // 解决序列化与反序列化破坏单例模式的问题
    private Object readResolve() {
        return this.INSTANCE;
    }
}
执行结果
  
    
    
  
com.sicimike.creation.singleton.SingletonWithSerialize@6d6f6e28
com.sicimike.creation.singleton.SingletonWithSerialize@6d6f6e28
可以看到添加readResolve方法后,再一次完成了单例模式

单例与反射

乍一看单例模式和反射也没有什么关系。但是仔细一想,前文所述创建对象的方式,都是通过new关键字来实现的。其实还可以通过反射的方式来创建对象。以第一种饿汉式单例为例
  
    
    
  
/**
 * @author sicimike
 * @create 2020-02-23 20:48
 */
public class SingletonDemo {
    public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
        Class<Singleton1> classObject = Singleton1.class;        
        Constructor<Singleton1> constructor = classObject.getDeclaredConstructor();
        constructor.setAccessible(true);
        // 单例模式获取
        Singleton1 instance = Singleton1.getInstance();
        // 反射获取
        Singleton1 reflectInstance = constructor.newInstance();
        System.out.println(instance);
        System.out.println(reflectInstance);
    }
}
执行结果
  
    
    
  
com.sicimike.creation.singleton.Singleton1@4554617c
com.sicimike.creation.singleton.Singleton1@74a14482
可以看到通过单例类创建的对象,和通过反射创建的对象不是同一个,并且反射可以随意创建实例,这样就破坏了单例模式。

第八种单例

前文提到的7种单例都会被序列化/反序列化、反射不同程度的破坏。解决序列化/反序列化靠JDK提供的钩子函数readResolve,想要解决反射也有一些办法,那就是在私有构造方法里加一下判断,如果INSTANCE不为null时,就抛出异常…
想要更优雅的解决序列化/反序列化、反射的问题,还有一种更优雅的写法,那就是利用枚举
  
    
    
  
/**
 * 枚举实现单例,线程安全
 * @author sicimike
 * @create 2020-02-23 20:46
 */
public enum Singleton8 {
    INSTANCE;    
    public static Singleton8 getInstance() {
        return INSTANCE;
    }
}
再来测试下反射
  
    
    
  
/**
 * @author sicimike
 * @create 2020-02-23 20:48
 */
public class SingletonDemo {
    private static final String FILE_PATH = "singleton.data";
    public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
        Class<Singleton8> classObject = Singleton8.class;        
        Constructor<Singleton8> constructor = classObject.getDeclaredConstructor();
        constructor.setAccessible(true);
        // 单例模式获取
        Singleton8 instance = Singleton8.getInstance();
        // 反射获取
        Singleton8 reflectInstance = constructor.newInstance();
        System.out.println(instance);
        System.out.println(reflectInstance);
    }
}
执行会得到如下结果
Exception in thread "main" java.lang.NoSuchMethodException: com.sicimike.creation.singleton.Singleton8.<init>()
 at java.lang.Class.getConstructor0(Class.java:3082)
 at java.lang.Class.getDeclaredConstructor(Class.java:2178)
 at com.sicimike.creation.singleton.SingletonDemo.main(SingletonDemo.java:31)
在解释这个结果之前,先来看下Singleton8的反编译之后的代码(编译工具:jad下载页面)
  
    
    
  
// Decompiled by Jad v1.5.8g. Copyright 2001 Pavel Kouznetsov.
// Jad home page: http://www.kpdus.com/jad.html
// Decompiler options: packimports(3)
// Source File Name:   Singleton8.java
package com.sicimike.creation.singleton;
public final class Singleton8 extends Enum
{
    public static Singleton8[] values()
    {
        return (Singleton8[])$VALUES.clone();
    }
    public static Singleton8 valueOf(String name)
    {
        return (Singleton8)Enum.valueOf(com/sicimike/creation/singleton/Singleton8, name);
    }
    private Singleton8(String s, int i)
    {
        super(s, i);
    }
    public static Singleton8 getInstance()
    {
        return INSTANCE;
    }
    public static final Singleton8 INSTANCE;
    private static final Singleton8 $VALUES[];
    static
    {
        INSTANCE = new Singleton8("INSTANCE", 0);
        $VALUES = (new Singleton8[] {
            INSTANCE
        });
    }
}
对于反编译之后的代码,只需要关注3点:
  • 默认继承了java.lang.Enum类
  • 生成了一个私有的构造方法Singleton8(String s, int i),并不是无参的
  • INSTANCE实例在静态代码块中被创建
现在也就知道为什么上面的实例会报NoSuchMethodException异常了,既然没有无参构造方法,而是有2个参数的构造方法。那我们就再次修改代码,调用有2个参数的构造方法
  
    
    
  
/**
 * @author sicimike
 * @create 2020-02-23 20:48
 */
public class SingletonDemo {
    private static final String FILE_PATH = "singleton.data";
    public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
        Class<Singleton8> classObject = Singleton8.class;        
        Constructor<Singleton8> constructor = classObject.getDeclaredConstructor(String.class, int.class);
        constructor.setAccessible(true);
        // 单例模式获取
        Singleton8 instance = Singleton8.getInstance();
        // 反射获取
        Singleton8 reflectInstance = constructor.newInstance("Sicimike", 18);
        System.out.println(instance);
        System.out.println(reflectInstance);
    }
}
执行结果如下
  
    
    
  
Exception in thread "main" java.lang.IllegalArgumentException: Cannot reflectively create enum objects
 at java.lang.reflect.Constructor.newInstance(Constructor.java:417)
 at com.sicimike.creation.singleton.SingletonDemo.main(SingletonDemo.java:59)
也就是说枚举类型根本就不支持通过反射创建实例。
至此,通过枚举类型实现的单例模式完美的解决了序列化/反序列化和反射的问题。这种方式也是Joshua Bloch推荐的方式。
但是通过反编译的结果我们也可以看出,枚举类创建对象也是在静态代码块中完成的,也就是类加载阶段。所以说枚举类型实现的单例模式应该属于饿汉式单例。

总结
本篇中提到了前七种方式,有部分可以通过在构造方法中添加判断逻辑,来避免被反射破坏。有兴趣的同学可以尝试一下。
单例模式在面试中很常见,因为手写一个单例模式比较快,几分钟就能搞定。单例模式最简单的一种设计模式,同时也是最复杂的一种设计模式,涉及到的知识点比较多。我感觉面试官要你手写单例模式时,最希望看到的应该是双重校验锁的那种,也就是Singleton6。因为这里涉及到的知识点最多:JVM、多线程、锁、volatile、序列化、反射等等。

以上是关于你写的单例模式,真正安全吗?的主要内容,如果未能解决你的问题,请参考以下文章

你的单例模式真的安全吗?双重检索模式和指令重排序助你高垒单例安全防御

Android你以为的单例模式和真正的单例模式是一样的吗?

怎么实现一个线程安全的单例模式

线程安全的单例模式是否真的安全

C# 两行代码实现 延迟加载的单例模式(线程安全)

如何写一个简单的单例模式?