美文网首页
设计模式系列——单例模式

设计模式系列——单例模式

作者: CoderGeshu | 来源:发表于2021-02-21 09:10 被阅读0次

单例概述

单例模式(Singleton Pattern)是 Java 中最简单的设计模式之一。它涉及到一个单一的类,该类负责创建自己的对象,同时确保只有单个对象被创建。简单点说,就是一个应用程序中,某个类的实例对象只有一个,你没有办法去 new,因为构造器是被 private 修饰的,一般通过getInstance()的方法来获取它们的实例。

  • 主要解决:一个全局使用的类频繁地创建与销毁。
  • 何时使用:当想控制实例数目,节省系统资源的时候。
  • 如何解决:判断系统是否已经有这个单例,有则返回,没有则创建。
  • 关键代码:构造函数是私有的,使用getInstance()去获取实例getInstance()的返回值是一个对象的引用,并不是一个新的实例,所以不要错误的理解成多个对象。

单例模式需要满足的条件:

  1. 单例类只能有一个实例;
  2. 单例类必须自己创建自己的唯一实例;
  3. 单例类必须给所有其他对象提供这一实例。

单例模式的使用场景:

  1. 要求生产唯一序列号。
  2. WEB中的计数器,不用每次刷新都在数据库里加一次,用单例先缓存起来。
  3. 创建的一个对象需要消耗的资源过多,比如 I/O 与数据库的连接等。

单例模式实现起来也很容易,直接看如下实现代码。

实现方法

1. 懒汉式写法

懒汉式:线程不安全

懒汉式顾名思义,会延迟加载,在初次使用该单例时才会实例化对象出来。首次调用时要做初始化,如果要做的工作比较多,性能会有所延迟,之后就和饿汉式一样了。

public class Singleton {
    private static Singleton instance;
    private Singleton() {}
    
    public static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

对于以上代码,如果此时有两个线程,线程A执行到某处读取了 instance 为 null,然后 CPU 就被线程 B 抢去了,此时线程 A 还没有对 instance 进行实例化。因此,线程 B 读取 instance 时仍然为 null,于是它对 nstance 进行实例化了。然后,CPU 就被线程 A 抢去了。此时,线程 A 由于已经读取了 instance 的值并且认为它为 null,再次对 instance 进行实例化。所以,线程 A 和线程 B 返回的不是同一个实例。

懒汉式:加锁使线程安全

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

从 1.0 版本开始,Java 中的每一个对象都有一个内部锁,并且该锁有一个内部条件,由锁来管理那些试图进入synchronized方法的线程,由条件来管理那些调用 wait 的线程。如果一个方法用synchronized关键字声明,那么对象的锁将保护整个方法。也就是说,要调用该方法,线程必须获得内部的对象锁。

这种解决方式有个问题:假如有 100 个线程同时执行,那么每次去执行getInstance()方法时都要先获得锁再去执行方法体。如果没有锁就要等待,耗时长,感觉像是变成了串行处理。
缺点:性能不高,同步范围太大。在实例化instacne后,获取实例仍然是同步的,效率太低,需要缩小同步的范围。所以用如下解决方法:

懒汉式:减小锁的粒度

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

这样处理就没有问题了吗?同样的原理,线程 A 读取到 instance 值为 null,此时 CPU 被线程B抢去了,线程 B 判断到 instance 值为 null,于是它开始执行同步代码块中的代码,对 instance 进行实例化。此后,线程 A 重新获得 CPU,由于线程 A 之前已经判断 instance值为 null,于是开始执行它后面的同步代码块代码。它也会去对 instance 进行实例化。这样就导致了还是会创建两个不一样的实例。
缺点:虽然缩小了同步范围来提高性能,但是仍然存在多次执行instance=new Singleton()的可能,由此引出双重校验锁。

懒汉式:双重校验锁

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

但是,双重检查加锁并不表示一定没有线程安全问题了。

指令重排序存在的问题

Java内存模型(Java Memory Model)并不限制处理器重排序。即instatnce = new Singleton()并不是原子语句,其实可以分为下面的步骤:

  1. 申请一块内存空间;
  2. 在这块空间里实例化对象;
  3. instance 的引用指向这块空间地址(instance 指向分配的内存空间后就不为 null 了)。

对于以上步骤,指令重排序很有可能不是按上面 1、2、3 步骤依次执行的。比如,先执行 1 申请一块内存空间,然后执行 3 步骤, instance 的引用去指向刚刚申请的内存空间地址。那么,当它再去执行 2 步骤,判断 instance 时,由于 instance 已经指向了某一地址,它就不会再为 null 了,因此,也就不会实例化对象了。这就是所谓的指令重排序安全问题。那么,如何解决这个问题呢?

解决指令重排序

加上 volatile 关键字,因为 volatile 可以禁止指令重排序。volatile 可以保证 1、2、3 的执行顺序,没执行完 1、2 就肯定不会执行 3,也就是没有执行完 1、2,instance 一直为空。这样就可以保证 3 步骤(instance 赋值操作)是最后一步完成,这样就不会出现 instance 在对象没有初始化时就不为 null 的情况了。这样也就实现了正确的单例模式了。具体代码如下:

懒汉式:双重校校验锁终极写法

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

lazy loading:“懒加载”也被叫作“延迟加载”,它的核心思想是把对象的实例化延迟到真正调用该对象的时候,这样做的好处是可以减轻大量对象在实例化时对资源的消耗,而不是在程序初始化的时候就预先将对象实例化。另外延迟加载可以将对象的实例化代码从初始化方法中独立出来,从而提高代码的可读性,以便于代码能够更好地组织。

2. 饿汉式写法

饿汉式在类创建的同时就实例化一个静态对象出来,不管之后会不会使用这个单例,都会占据一定的内存,但是相应的,在第一次调用时速度也会更快,因为其资源已经初始化完成。

public class Singleton { 
    private static Singleton instance = new Singleton();
    private Singleton() {}
    public static Singleton getInstance() {
        return instance;
    }
}

这种方式基于classloder机制,类加载时就创建单例,保证了实例的单例。类加载机制保证单例见下文讲解。

3. 静态内部类

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

这种方式同样利用了 classloder的机制来保证初始化 instance 时只有一个线程,它跟饿汉式不同的是:饿汉式只要 Singleton 类被装载了,那么 instance 就会被实例化(没有达到 lazy loading 的效果),而这种方式是 Singleton 类被装载了,instance 不一定被初始化。因为 SingletonHolder 类没有被主动使用,只有显式通过调用 getInstance 方法时,才会显式装载 SingletonHolder 类,从而实例化 instance。想象一下,如果实例化 instance 很消耗资源,我就应该让它延迟加载;另外一方面,我不希望在 Singleton 类加载时就实例化,因为我不能确保 Singleton 类是否还可能在其他的地方被主动使用而被加载,那么这个时候实例化 instance 显然是不合适的。这个时候,使用静态内部类方式比使用饿汉式显得合理。

饿汉式和静态内部类实现的单例模式是线程安全的,因为使用了static,然后在类加载的时候是线程安全的。这里虽然没有直接使用synchronized,但是也是间接用到了。

类加载过程的线程安全性保证

静态内部类、饿汉式实现方式均是通过定义静态的成员变量,以保证单例对象可以在类初始化的过程中被实例化。这其实是利用了ClassLoader 的线程安全机制。ClassLoader的loadClass方法在加载类的时候使用了synchronized关键字。所以, 除非被重写,否则这个方法默认在整个装载过程中都是线程安全的,所以在类加载过程中对象的创建也是线程安全的。

4. 枚举

枚举类实现单例模式相当硬核,因为枚举类型是线程安全的,且只会装载一次。使用枚举类来实现单例模式,是所有的单例实现中唯一一种不会被破坏的单例模式实现。

public enum EnumSingleton {
    
    INSTANCE;
    
    public EnumSingleton getInstance(){
        return INSTANCE;
    }
}

那么枚举单例是如何保证线程安全的呢?为了了解它的实现原理,来看一下反编译后的代码:

public final class  EnumSingleton extends Enum<EnumSingleton> {
    public static final  EnumSingleton  ENUMSINGLETON;
    public static  EnumSingleton[] values();
    public static  EnumSingleton valueOf(String s);
    static {};
 }

枚举底层是依赖Enum类实现的,这个类的成员变量都是 static 类型的,并且在静态代码块中实例化的,和饿汉式有点像, 所以它天然是线程安全的。所以,枚举其实也是借助了 synchronized 的。

从 Enum 的源码中看到,大部分的方法都是 final 修饰的,特别是 clone、readObject、writeObject 这三个方法,保证了枚举类型的不可变性,不能通过克隆、序列化和反序列化复制枚举,这就保证了枚举变量只是一个实例,即是单例的。

这种方式是 Effective Java 作者 Josh Bloch 提倡的方式,它不仅能避免多线程同步问题,是线程安全的,而且还能防止反序列化重新创建新的对象,可谓是很坚强的壁垒。

解决的问题:避免反射攻击和避免序列化问题

写在最后

1. 总结

  1. 实现单例模式必须要考虑线程的安全,实现安全的方式是加锁。
  2. 懒汉式直接使用 synchronized 实现;饿汉式、静态内部类、枚举间接使用 synchronized 实现线程安全(static 关键字)

2. 单例模式的优缺点

优点

  1. 在内存里只有一个实例,减少了内存的开销,尤其是频繁的创建和销毁实例(比如管理学院首页页面缓存);
  2. 避免对资源的多重占用(比如写文件操作)。

缺点

  1. 没有接口,不能继承
  2. 与单一职责原则冲突,一个类应该只关心内部逻辑,而不关心外面怎么样来实例化。

3. 破坏单例

如何破坏单例?通过这个问题可以引申到序列化和反射的相关知识。

作者信息

大家好,我是 CoderGeshu,一个热爱生活的程序员,如果这篇文章对您有所帮助,还请大家给点个赞哦 👍👍👍

另外,欢迎大家关注本人同名公众号:CoderGeshu,一个致力于分享编程技术知识的公众号!!

一个人可以走的很快,而一群人可以走的很远……

相关文章

  • 设计模式系列教程之单例模式-原理介绍

    设计模式系列教程之单例模式-原理介绍 一:单例模式(Singleton)学习步骤 经典的单例模式原理: 本文出处:...

  • Android 设计模式之简单工厂模式

    设计模式系列文章 Android 设计模式之单例模式 Android 设计模式之Builder模式 Android...

  • 设计模式一、单例模式

    系列传送门设计模式一、单例模式设计模式二、简单工厂模式设计模式三、工厂模式设计模式四、抽象工厂模式 简单单例(推荐...

  • 单例模式Java篇

    单例设计模式- 饿汉式 单例设计模式 - 懒汉式 单例设计模式 - 懒汉式 - 多线程并发 单例设计模式 - 懒汉...

  • JavaScript 设计模式(上)——基础知识

    系列链接 JavaScript 设计模式(上)——基础知识 JavaScript 设计模式(中)——1.单例模式 ...

  • python中OOP的单例

    目录 单例设计模式 __new__ 方法 Python 中的单例 01. 单例设计模式 设计模式设计模式 是 前人...

  • 单例

    目标 单例设计模式 __new__ 方法 Python 中的单例 01. 单例设计模式 设计模式设计模式 是 前人...

  • 设计模式四、抽象工厂模式

    系列传送门设计模式一、单例模式设计模式二、简单工厂模式设计模式三、工厂模式设计模式四、抽象工厂模式 抽象工厂模式 ...

  • 设计模式 - 单例模式

    设计模式 - 单例模式 什么是单例模式 单例模式属于创建型模式,是设计模式中比较简单的模式。在单例模式中,单一的类...

  • 设计模式三、工厂模式

    系列传送门设计模式一、单例模式设计模式二、简单工厂模式设计模式三、工厂模式设计模式四、抽象工厂模式 工厂模式 在一...

网友评论

      本文标题:设计模式系列——单例模式

      本文链接:https://www.haomeiwen.com/subject/ssiffltx.html