深入浅出学设计模式(五)之单例模式

今天,我们一起来学习学习单例模式。单例模式初看很简单,细看有点不简单。今天,我们就从各个角度来讨论一下单例模式。
单例模式在我们平常开发中也经常用到,比如:被设计成重量级的对象,一般将其设计成单例,Hibernate 的 SessionFactory 、spring 的 BeanFactory 或者是数据库连接池等。

1 单例模式定义

定义:一个类有且仅有一个实例,并且自行实例化向整个系统提供。

该定义不难理解,在一个系统中,只允许 一 个类有一个实例,并且这个实例要自己创建并要给其他类提供可以访问这个实例的方法。

所以,在实现单例模式时,我们一般会分如下三个步骤完成:

  1. 将构造器私有化
  2. 定义指向该单例实例的一个静态私有的实例引用
  3. 提供一个静态方法,返回该静态实例

2 饿汉式

饿汉式采用的是立即加载机制在类加载的时候就创建了实例。 打个比方,一个和尚要去化缘,当他来到一家饭店时,老板一看见他就拿出了饭菜给他(这个饭菜是老板提前就准备好的),这种方式就是饿汉式。调用者想要使用该单例对象时,直接就能获取到,不需要再去等待创建。

代码示例:

//饿汉式
public class Singleton {

    //构造器私有化,其他的内不能通过 new Singleton() 来创建对象
    private Singleton(){

    }

    //指向单实例的私有静态变量
    private static Singleton instance = new Singleton();

    //其他类获取该单实例的方法
    public static Singleton getInstance(){
        return instance;
    }
    
}

这样几句简单的代码就是实现了 饿汉式 的单例模式,并且该方式还是线程安全的,因为实例是在类加载的时候就创建的,因为这个类在整个生命周期中只会被加载一次,因此只会创建一个实例。 但是该方式有一个问题就是如果这个实例从始至终都没有使用过的话,就算是浪费了。

3 懒汉式

懒汉式采用的是懒加载方式即只有在获取该单例实例的时候才创建实例,否则就不创建。

3.1 简单的懒汉式

代码示例:

//懒汉式
public class Singleton {

    //私有化构造方法
    private Singleton(){

    }

    //静态私有实例引用
    private static Singleton instance;

    //返回单例实例
    public static Singleton getInstance(){
        if(instance == null){//如果未创建,则创建一个
            instance = new Singleton();
        }
        //已创建过,就直接返回
        return instance;
    }
}
public class Test {
    public static void main(String[] args) {
        Singleton singleton = Singleton.getInstance();
        Singleton singleton1 = Singleton.getInstance();

        System.out.println(singleton == singleton1);
    }
}

结果截图:
在这里插入图片描述
从结果我们可以看出,两次获取的对象是同一个实例,从而证明上述代码达到了单例的效果。但是这种方式只能在单线程下才能达到单例的效果,如果是在多线程的情况下,就不一定了。

3.2 验证简单懒汉式线程不安全

接下来,我们验证一下在多线程情况下不能达到单例的问题。

我们创建几个线程,这些线程都来调用 Singleton.getInstance() 方法获取 Singleton 的实例,如果所有线程无论何时获取的实例的 hashCode 值都相同,则证明我们前面写的简单懒汉式的代码在多线程的情况下也能达到单例的效果,否则就不是单例的。

TestThread.java:定义一个线程类,获取一个 Singleton 实例,并打印该实例的 hashCode 值。

public class TestThread extends Thread {
    private String name;
    public TestThread(String name){
        this.name = name;
    }
    @Override
    public void run() {
        Singleton singleton = Singleton.getInstance();
        System.out.println("线程 "+name+" 获取到的实例:"+singleton.hashCode());
    }
}

Test.java:测试类

public class Test {
    public static void main(String[] args) {
        //验证多线程下懒汉式是否能达到单例的效果
        TestThread thread1 = new TestThread("thread1");
        TestThread thread2 = new TestThread("thread2");
        TestThread thread3 = new TestThread("thread3");
        TestThread thread4 = new TestThread("thread4");
        TestThread thread5 = new TestThread("thread5");
        TestThread thread6 = new TestThread("thread6");

        thread1.start();
        thread2.start();
        thread3.start();
        thread4.start();
        thread5.start();
        thread6.start();
    }
}

在 Test 测试类中,我们创建了 6 个 TestThread 的实例,并启动这些线程。
运行截图:
在这里插入图片描述
从上图中,我们可以看出,这 6 个线程一共出现了两个 Singleton 的实例,所以我们写的简单懒汉式单例模式的代码在多线程的情况下是不能达到单例的效果的。

出现这种情况的原因:就是存在多个线程同时执行到了 if( instance == null )的可能,一个线程 A 执行到该判断语句时,判断该 instance 为 null ,但还未来得及执行后面的创建实例的代码时,突然又有一个线程 B 执行到了该 if 语句 ,由于线程 A 此时还没有创建对象,所以线程 B 也认为 instance 为 null ,它也将创建实例,所以最后线程 A 和 B 都创建了一个 Singleton 的实例。所以在系统中就有多个 Singleton 的实例了。

如果你在执行上面的测试类时,没有出现这种情况,可以多执行几次。

3.3 使用同步方法来实现懒汉式

经过前面的分析,我们得出 简单的懒汉式在多线程下不能达到单例的效果 ,那么接下来我们就使用 synchronized 关键字来同步 getInstance() 方法实现单例模式。
原理:经过 synchronized 修饰的方法,相当于给该方法添加了一个锁,那么其他线程在调用该方法时,就需要排队等待,一个一个的执行 getInstance() 方法,所以就不会出现前面的多个线程同时进入 if( instance == null ) 代码块。

代码示例:

// synchronized 同步方法 懒汉式
public class Singleton {

    //私有化构造方法
    private Singleton(){

    }

    //静态私有实例引用
    private static Singleton instance;

    //返回单例实例
    public static synchronized Singleton getInstance(){
        if(instance == null){//如果未创建,则创建一个
            instance = new Singleton();
        }
        //已创建过,就直接返回
        return instance;
    }
}

只是在简单懒汉式的代码基础上多加了一个 synchronized 就完成了在多线程情况下实现单例的效果。
可以使用前面的 TestThread 类来验证,无论开启多少个线程,执行多少次都只能够获取到一个 Singtelon 的实例。

这种方式存在的问题就是:同步锁的粒度太粗,直接对整个方法加锁,所有线程都要被阻塞在这里,等待当前正在执行 getInstance() 方法的线程执行完、释放所之后才能执行,并且假如在 getInstance() 方法中还有其他很费事的操作,就会导致很多线程堵塞在这里,导致系统效率降低。

3.3 双重检查来实现懒汉式

代码示例:

//懒汉式(双重检查机制)
public class Singleton {
    private Singleton(){
    }
    private static volatile Singleton instance;//1
    public static Singleton getInstance(){//2
        if(instance == null){//3
            synchronized (Singleton.class) {//4
                if(instance == null) {//5
                    instance = new Singleton();//6
                }
            }
        }
        return instance;
    }
}

在上述代码中,我们采用双重检查和同步机制来保证实例的唯一性和系统的性能。假如现在有线程 A、B 都同时执行到了第 3 行代码处( //3 处 ),此时都判断 istance 为 null ,A B 都会去执行第 4 行代码,但是这里是一个同步块代码,所以 A B 只能有一个可以获得锁并执行同步块中的代码,假如 A 先抢到锁,则 B 需要等 A 执行完成才能执行该同步块中的代码。当 A 执行第 5 行的时候,由于它是第一个进来的,所以 instance 肯定为 null,所以继续执行第 6 行代码,创建一个 Singleton 实例,然后释放锁。接着 B 执行第 5 行代码,此时 instance 由于已经被 A 创建了,所以 instance 不为空,也就不会执行第 6 行代码了。假如此时又有线程 C 来调用 getInstance() 方法,它在执行第 3 行的代码时就已经知道 instance 不为空了,也就不会去抢锁执行同步块代码。所以,双重检查机制既保证了在多线程中实例的唯一性,也提升了性能。

双重检查机制实现单例模式时,我们还需要注意一个很重要的点,就是需要在 Singleton 类定义私有静态变量 instance 时加上 volatile 关键字来避免指令重排序带来的问题。

在前面的示例代码中,需要禁止指令重排序的为 instance = new Singleton()。 如果我们学习过 java 虚拟机的知识的话,我们知道 JVM 在执行 new Singleton() 时,并不是一步完成的,它大致包含了如下三个操作:

  1. 从堆中为对象分配相应的内存空间
  2. 初始化对象,根据构造函数的参数给对象的属性赋值。
  3. 在线程栈中创建建对象引用,并指向堆中刚刚新建的对象实例。

JVM 在执行这三个步骤时,进行指令重排序来提升效率。因为这三个过程中,过程 2 是依赖于过程 1 的,即必须要给新对象分配一个内存,才能给对象的属性进行赋值。但是过程 3 可以不需要等到 过程 2 结束再执行,因为过程 3 不关心怎么给属性赋的值,也不会用到新对象的属性,它只是在当前的线程栈中创建一个引用,并将该引用指向堆中的实例,所以它依赖于过程 1 ,只要知道该对象已分配的内存地址即可。

所以,在 new Singleton() 的过程中,由于指令重排序,可能执行的顺序就是过程 1 、过程 3 和 过程 2 ,当然过程 3 和 过程 2 是可以异步进行的。

前面的双重检查的示例代码,方便查看:

    private static volatile Singleton instance;//1
    public static Singleton getInstance(){//2
        if(instance == null){//3
            synchronized (Singleton.class) {//4
                if(instance == null) {//5
                    instance = new Singleton();//6
                }
            }
        }
        return instance;
    }

此时就出现了我们需要注意的问题:假设现在有一个线程 A,是最开始调用 getInstance() 方法的,所以它会经过两次检查,并 new Singleton() ,由于指令重排序,执行顺序为过程 1 、过程 3 、和过程 2 。当执行完过程 3 时,引用变量 instance 不为 null ,假如此时又有一个线程 B ,执行到第 3 行代码时,发现 instance 不为 null,所以就直接 return 了,(假设此时线程 A 中的过程 2 仍然没有完成)线程 B 获取到 该实例后,立即进行一些操作,由于获取到的对象是未完全初始化完成的,所以 线程 B 的后续操作可能就会出现问题,导致系统崩溃。

举个生活中的例子来体会一下:假如我们现在有一个生产可乐的工厂。生产一瓶可乐一共分三个部分:

  1. 生产一个可乐瓶
  2. 生产出可乐,并装入过程 1 生产的可乐瓶中。
  3. 给可乐瓶贴上公司的 logo

假如现在过程 1 已完成,过程 2 和过程 3 同时由两条生产线异步进行,过程 3 很快就能完成,过程 2 需要发酵、调位等诸多步骤,需要挺长时间才能完成,假如此时突然冲进来一个十分饥渴的人,非常想喝一瓶可乐,当他看到刚完成过程 3 的一瓶可乐(其实还没有装上可乐,只是一个贴上标签的空瓶子因为过程 2 还未执行完成),实在忍不住了,拿起来就喝,结果一滴可乐也没有,然后由于太渴,这人直接渴死了。之所以会出现这样的悲剧,就是因为他心里知道,只要是贴上了 可口可乐 logo 的可乐瓶中就一定有可乐,所以看见这样的瓶子他就直接开始喝。所以在双重检查机制中,当一个线程执行到第 3 行代码时,它心里知道,只要这个对象不是 null 就行,它却不知道这个对象到底有没有完成初始化,所以在后续操作中,就会出现错误。

那么解决指令重排序也很简单,我们只需要在前面示例代码中的第 2 行使用 volatile 来修饰 instance:

 private static volatile Singleton instance;

就好比在可乐工厂,我们为了防止前面的悲剧反生,我们在过程 3 的生产线旁边加上一个十分醒目提示牌:该可乐瓶还没有可乐,请勿打开,否则后果自负。用来提醒哪些慌不看瓶的人这是个空瓶子,不要去拿。该提示牌的功能就体现了 volatile 会在内存读写操作时添加 内存屏障 一样。

3.4 静态内部类的形式实现懒汉式

我们先看一下代码示例:

public class Singleton {

    private Singleton(){

    }
    //静态内部类
    private static class InnerSingleton{
        private static Singleton INSTANCE  = new Singleton();
    }
    public static Singleton getInstance(){
        return InnerSingleton.INSTANCE;
    }
}

由于 InnerSingleton 是一个内部类,所以在 Singleton 类装载的时候,不会装载类 InnerSingleton,而是在调用 getInstance() 方法时,才会被装载,达到了懒加载的效果。并且类的装载过程是线程安全的,INSTANCE 也是静态的,所以装载 InnerSingleton 时,只会创建一个 Singleton 的实例。

4 防止恶意破坏单例模式

经过前面的学习,我们已经大致介绍了 5 种实现单例模式的方法,其中我们推荐使用 饿汉式、双重检查机制、静态内部类的方式来实现单例模式但是这几种方法就真的能保证单例模式一定是单例吗,当然不是。

接下来我们将学习三种破坏单例的方式:反射、克隆 和 序列化,并了解如何防止。

4.1 反射

我们知道,通过反射,我们可以获取到一个类的字节码信息,即可以获取到该类的全部信息,实现的接口、有什么属性、有什么方法、有几个构造函数全都能获取。并且还可以通过构造函数创建新的对象等等,即使构造函数是私有的。所以就可以通过反射机制来恶意破坏我们的单例。

我们通过如下代码来验证一下:

public class ReflectTest {

    public static void main(String[] args) {

        //通过 getInstance() 方法获取一个实例
        Singleton singleton = Singleton.getInstance();

        //获取 Singleton 类的字节码
        Class clazz = singleton.getClass();
        try {
            //获取 Singleton 类的第一个默认构造函数,即我们定义的 private Singleton(){}
            Constructor<Singleton> constructor = clazz.getDeclaredConstructors()[0];

            //设置允许访问私有的构造器,设置后,就可以调用私有的构造方法
            constructor.setAccessible(true);
            //使用构造方法创建实例
            Singleton singleton1 = constructor.newInstance();

            System.out.println("getInstance() 获取的实例:"+singleton.hashCode());
            System.out.println("通过反射创建的实例 :"+singleton1.hashCode());

        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

结果截图:
在这里插入图片描述
从结果可以看出,通过 getInstance() 方法获取的对象与通过反射创建的对象不是同一个。那么我们来分析一下问题到底出在哪呢?其实很简单,无论是 getInstance() 方法还是反射,到最终都是调用了 Singleton 的私有构造函数 private Singleton(){} 来创建的实例,所以我们只要保证该构造函数只能执行一次即可。

我们可以在该私有构造函数中判断 instance 是否为空,如果不为空就抛出异常,即实例已经被创建了,不能再调用构造函数来创建实例了。

以双重检查机制的代码为例,其他几种方式都一样:

将构造函数改为如下:

private Singleton(){
        if (instance != null) {
            throw new RuntimeException("对象已存在不可重复创建");
        }
    }

具体的测试代码见示例代码的:com.llk.singleton.reflecttest 包。这里就不一 一列出了。

这样就可以防止调用 getInstance() 方法后,再通过反射创建新的对象来破坏单例机制,但是不能防止多线程下通过反射创建新对象。其他博文说:可以在 Singleton 中添加一个私有静态变量来判断是否创建过对象,如果已创建就抛出异常,该方法能够防止多线程下通过反射创建新对象。但是有一个问题就是:该变量也可以通过反射进行修改的,所以就达不到控制的效果了。至于如何防止在多线程下使用反射破坏单例的方法我目前还没有找到,希望大家留言一起讨论。

在日常开发中,我们写单例类时,也不用太在意通过反射来破坏单例,一般不会有人恶意这么做,就相当于搬起石头砸自己的脚。

4.2 克隆

通过 clone() 方法我们也可以创建对象,克隆不会调用构造函数创建新对象,而是直接从内存中复制一个已存在的对象。

我们已双重检查机制为例来测试克隆破坏单例:

需要实现 Cloneable 接口,并重写 clone() 方法。

public class Singleton implements Cloneable {
    private static volatile Singleton instance;//1
    public static Singleton getInstance(){//2
        if(instance == null){//3
            synchronized (Singleton.class) {//4
                if(instance == null) {//5
                    instance = new Singleton();//6
                }
            }
        }
        return instance;
    }

    private Singleton(){
        if (instance != null) {
            throw new RuntimeException("对象已存在不可重复创建");
        }
    }


    @Override
    protected Object clone() throws CloneNotSupportedException {
        return super.clone();
    }
}
//通过克隆 破坏单例
public class Test {

    public static void main(String[] args) throws CloneNotSupportedException {
        Singleton singleton = Singleton.getInstance();
        System.out.println("getInstance() 获取的实例:"+singleton.hashCode());

        Singleton clone = (Singleton) singleton.clone();
        System.out.println("通过 clone() 获取的实例:"+clone.hashCode());

    }
}

运行结果:
在这里插入图片描述
我们可以看到两种方式创建的实例不是同一个,所以克隆能够破坏单例。

解决办法:我们可以让 clone() 方法返回 getInstance() 方法的结果:

@Override
    protected Object clone() throws CloneNotSupportedException {
        return getInstance();
    }

这样再通过 clone() 方法获取就不会复制对象了。

其实,针对克隆破坏单例的问题,一般情况下我们可以不用理睬,因为要实现克隆就必须在 Singleton 类上实现 Cloneable 接口,并重写 Object 类的 clone() 方法才可以。那为了防止克隆破坏,我们不让 Singleton 实现 Cloneable 接口就可以了。

4.3 反序列化

示例代码如下:

Singleton 必须要实现 Serializable 接口,才可以进行序列化和反序列化。

//通过反序列化破坏单例
public class Test {
    public static void main(String[] args) throws Exception {
        Singleton singleton = Singleton.getInstance();
        System.out.println("getInstance() 获取的实例:"+singleton.hashCode());
        //序列化对象到文件中
        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("E:/singleton.txt"));
        oos.writeObject(singleton);
        if(oos!=null){
            oos.close();
        }
        //反序列化
        File file = new File("E:/singleton.txt");
        ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file));
        Singleton singleton2 = (Singleton) ois.readObject();

        System.out.println("反序列化获取的实例:"+singleton2.hashCode());

        if(ois!=null){
            ois.close();
        }

    }
}

运行结果:
在这里插入图片描述
从结果可以看出两种方式创建了两个不同的对象,所以反序列化能够破坏单例。

解决办法:当一个类进行反序列化时,如果该类中有 readResolve() 方法,就会执行该方法,并会把反序列化的结果丢掉,将 readResolve() 方法的返回值作为反序列化的结果。所以我们在 Singleton 类中添加 readResolve() 方法即可。

    //返回 instance
    private Object readResolve() {
        return getInstance();
    }

其实和克隆一样,如果这个 Singleton 不需要进行序列化和反序列化的话,我们也不需要考虑反序列化来破坏单例。

一个完整的双重检查机制的单例类如下:

public class Singleton implements Serializable,Cloneable {
    private static volatile  Singleton instance;//1
    public static Singleton getInstance(){//2
        if(instance == null){//3
            synchronized (Singleton.class) {//4
                if(instance == null) {//5
                    instance = new Singleton();//6
                }
            }
        }
        return instance;
    }

    private Singleton(){
        if (instance != null) {
            throw new RuntimeException("对象已存在不可重复创建");
        }
    }

    @Override
    protected Object clone() throws CloneNotSupportedException {
        return getInstance();
    }

    //返回 instance
    private Object readResolve() {
        return getInstance();
    }
}

5 枚举实现单例

经过前面的学习,我们学习了几种生成单例对象的方式,这些方式存在被恶意破坏的可能,那么我们现在就介绍一种不能够被破坏,且大家一致认为值得推荐使用的方式:枚举实现单例。关于 java 中枚举如何使用,这里就不做过多的赘述了。

示例代码:

//枚举类实现单例
public enum  Singleton {
    INSTANCE;//可以把它看成是 Singleton 的一个实例

    //定义一些方法
    public void doAction(){
        System.out.println("doAction()");
    }
}
public class Test {
    public static void main(String[] args) {
        Singleton instance = Singleton.INSTANCE;
        Singleton instance1 = Singleton.INSTANCE;
        instance.doAction();
        System.out.println(instance == instance1);
    }
}

结果截图:
在这里插入图片描述
使用枚举来实现单例,是从 JVM 的层面保证了实例的线程安全以及不被前面所讲的 3 种方式破坏。

最后,在日常开发中,前面讲到的饿汉式、双重检查机制和静态内部类的方式都是我们推荐使用的方式,也不需要太去较真被恶意破坏。不过现在大家使用枚举的方式越来越多。

单例模式本身不是很难,但是它可以牵扯出很多的东西,多线程、同步、反射等等。

6 示例代码地址

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值