设计模式之单例模式(懒汉, 饿汉)

Posted 愿荣

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了设计模式之单例模式(懒汉, 饿汉)相关的知识,希望对你有一定的参考价值。

文章目录

一. 单例模式概述

单例模式是一种常用的软件设计模式, 该模式的主要目的是确保某一个类在内存中只能有一个实例对象, 通过单例模式的方法创建的类在当前进程中只有一个实例对象.

常见的单例模式有两种:

  1. 饿汉式, 这里的 “饿” 意义表述不够清晰, 用 “急” 来表述意义更加容易联想一些, 饿汉式就是类一加载就创建对象, 给人一种 “特别急切” 的感觉.
  2. 懒汉式, 在使用时才去创建对象.

单例模式有 3 个特点:

  1. 单例类只有一个实例对象.
  2. 该单例对象必须由单例类自行创建.
  3. 单例类对外提供一个访问该单例的全局访问点.

我们知道static修饰的成员是属于类的, 也就是只有一份, 所以我们可以使用static修饰的成员变量保存实例对象的引用.

二. 单例模式的实现

1. 饿汉模式

单例模式中, 一个类只能有一个实例对象, 我们可以使用static成员变量来保存这个唯一实例对象的引用, 同时, 为了防止类被创建多个实例对象, 需要将类构造方法封装为private修饰的私有方法, 在使用该类时必须要得到该类的这个实例对象, 所以我们还需要创建一个对外公开的获取该唯一实例的方法getInstance.

class Singleton 
    //使用static变量存储实例对象,类加载时创建对象.
    private static Singleton intance = new Singleton();
    //对外提供公开的方法获取该类的唯一实例.
    public static Singleton getInstance() 
        return intance;
    
    //封装构造方法,防止该类被实例出新的对象
    private Singleton() 

单线程情况下不存在线程安全的问题, 我们需要考虑多线程情况下上面的代码是否存在线程安全问题, 重点要看读写情况下的线程安全问题, 上面的代码只有getInstance涉及到读操作, 所以上面的代码是线程安全的.

2. 懒汉模式

懒汉式与饿汉明显的区别就是对象创建的时机不同, 懒汉式要等到第一次使用实例对象的时候才会创建对象, 所以我们将上面的代码稍做修改写出下面的代码.

class SingletonLazy 
    //在使用的时候创建对象,这里不初始化实例对象
    private volatile static SingletonLazy instance = null;
    //对外提供公开的方法获取该类的唯一实例,如果没有就创建
    public static SingletonLazy getInstance() 
        if (instance == null) 
            instance = new SingletonLazy();
        
        return instance;
    
	//封装构造方法,防止该类被实例出新的对象
    private SingletonLazy() 

那么再来考虑上面的代码在多线程情况下是否有线程安全问题存在, 代码中的getInstance方法中存在两次读操作(一次比较一次返回)和一次写操作(修改intsance变量的值),第一次创建对象时(即instance=null)可能会存在多个线程进入判断语句, 此时就可能会创建出多个实例对象, 看下图理解:

所以上面写出来的代码可能会触发多次new操作, 显然就不是单例了, 也就是说上面写出来的懒汉式的代码是线程不安全的.

导致上面的线程安全问题的主要原因是读, 比较和写这三个操作不是原子的, 这就导致了t2读到的值可能是t1还没来得及写的(脏读), 所以我们只需要将代码中的读写操作加锁即可这里的问题, 得到下面的代码:

class SingletonLazy 
    private volatile static SingletonLazy instance = null;

    public static SingletonLazy getInstance() 
        synchronized (SingletonLazy.class) 
            if(instance == null) 
                instance = new SingletonLazy();
            
        
        return instance;
    

    private SingletonLazy() 

但代码写到这里还是有问题的, 上述的线程安全问题只出现在instance没有初始化的时候, 如果instance已经有了实例对象, 后续调用getlnstanceinstance的值一定是非空的, 因此就会直接触发return, 相当于一个是比较操作, 一个是返回操作, 这就都是读操作了, 此时就不涉及线程安全问题了.

而上面的代码, 不论instance是否已经初始化, 每次调用getlnstance, 都需要进行加锁操作, 要知道加锁操作是有开销的, 在instance已经有了实例对象以后就完全没有必要去加锁了, 所以代码还需要修改, 在加锁前需要先判断一下instance是否已经初始化, 如果还没初始化就进行加锁, 如果实例对象创建了, 就不加锁了.

调整后的获取实例对象的代码如下:

public static SingletonLazy getInstance() 
    if (instance == null) 
        synchronized (SingletonLazy.class) 
            if(instance == null) 
                instance = new SingletonLazy();
            
        
    
    return instance;

到这里其实还没完, 还要考虑编译器优化造成的内存可见性和指令重排序的问题, 上面的代码中是有判断操作的(instance==null), 假设有很多线程, 都去进行getlnstance, 就会进行频繁的判断, 这时候就会有被优化的风险, 可能只有第一次读才是真正读了内存, 后续都是读寄存器/cache中的值, 这时候instance值变化时, 线程就感知不到了, 所以这里的内存可见性问题需要解决.

还有要注意这里的instance = new Singleton();操作, 拆分成三个步骤就是:

  1. 申请内存空间.
  2. 调用构造方法, 把这个内存空间初始化成一个合理的对象.
  3. 把内存空间的地址赋值给instance引用.

正常情况下,是按照123这个顺序来执行的, 但你防不住编译器的优化操作, 也就是指令重排序问题, 编译器为了提高程序效率, 可能就调整了代码执行顺序, 123这个顺序就可能变成132, 如果是单线程,123和132没有本质区别, 但是多线程环境下, 就会有问题了;

假设t1是按照132的步骤执行的, t1执行到13之后, 执行2之前,被切出cpu了, 然后t2来执行;

t1执行完3之后, 再从t2角度来看, 此处的引用就非空了, 此时此刻, t2就相当于直接返回了instance引用并且可能会尝试使用引用中的属性, 但是由于t1中的2操作还没执行完呢, t2拿到的是非法的对象(还没构造完成的不完整的对象), 这就可能会造成一些问题.

为了解决上述问题需要使用关键字volatile修饰instance变量来防止编译器优化, 从而保证内存可见性, 禁止指令重排序, 完整代码如下:

class SingletonLazy 
    private volatile static SingletonLazy instance = null;

    public static SingletonLazy getInstance() 
        if (instance == null) 
            synchronized (SingletonLazy.class) 
                if(instance == null) 
                    instance = new SingletonLazy();
                
            
        
        return instance;
    

    private SingletonLazy() 

设计模式之单例模式--懒汉模式与饿汉模式

上一章讲了单例模式的基本实现方式,但是在多线程环境下,这种方式是存在严重的问题的。所以本章我们就来解决多线程环境的问题。

懒汉模式

利用方法锁实现

一种方式是在获取实例的方法上加锁

// 代码清单1

public class LazyInstantiationConfigUtils 

    private static Logger logger = LoggerFactory.getLogger(SimpleConfigUtils.class);
    private static LazyInstantiationConfigUtils instance = null;

    private Map<String, String> props = new HashMap<>();

    /**
     * 单例的目的就是为了控制实例数量,所以构造函数一定要私有化,这样才能被其他使用者不能够随便实例化对象。
     */
    private LazyInstantiationConfigUtils() 
        this.readFromConfigFile();
        logger.info("对象实例化完成。");
    

    /**
     * 获取配置实例,方法上加锁。当多个线程同时调用时只有一个线程可以访问方法,其他线程将被阻塞。
     * @return 配置实例
     */
    public static synchronized LazyInstantiationConfigUtils getInstance() 
        if (instance == null) 
            instance = new LazyInstantiationConfigUtils();
        
        return instance;
    

    /**
     * 根据配置项的名称获取配置项的值
     * @param propName 配置项的名称
     * @return 配置项的值
     */
    public String getPropertyValue(String propName) 
        return this.props.get(propName);
    

    private void readFromConfigFile() 
        logger.info("假装从配置文件中读取了配置");
        props.put("application.name", "DesignPattern");
        props.put("application.type", "SpringBoot");
    

这种方式简单,但是性能差。每次调用都会判断是否有锁,在同一时刻只能有一个线程调用。

双重检查锁定(Double-Check Locking)

另外一种方式是,使用双重检查锁定(Double-Check Locking)或者叫双重锁定机制(double locking mechanism)。

// 代码清单2

/**
 * 获取配置实例
 * @return 配置实例
 */
public static LazyInstantiationConfigUtils getInstance() 
    if (instance == null) 
        synchronized (LazyInstantiationConfigUtils.class)  // (1)
            if (instance == null)  // (2)
                instance = new LazyInstantiationConfigUtils(); // (3)
            
            
        
    
    return instance;

指令重排问题(Out-of-Order)

问题就在于(3),在构造函数执行之前,变量instance变为了non-null
想象有两个线程,执行顺序如下

  1. Thread 1 进入getInstance()方法
  2. Thread 1 进入同步块,也就是(1),因为这是instancenull
  3. Thread 1 执行了代码(3),但是构造函数还没有执行。这时instance变为了non-null
  4. Thread 2 抢占了 Thread 1的资源,开始执行
  5. Thread 2 进入getInstance()方法
  6. Thread 2 判断instance不为null,并把为初始化完成的instance返回

问题已经出现了, Thread 2获取到了一个未初始化的对象。那么在使用的时候肯定也是有问题的。如何解决这个问题呢?

第一种方式是,使用两段双重锁定机制,如下。

/**
 * 获取配置实例
 * @return 配置实例
 */
public static LazyInstantiationConfigUtils getInstance() 
    if (instance == null) 
        synchronized (LazyInstantiationConfigUtils.class) 
            LazyInstantiationConfigUtils inst = instance;
            if (inst == null) 
                synchronized (LazyInstantiationConfigUtils.class) 
                    inst = new LazyInstantiationConfigUtils();
                
                instance = inst;
            
        
    
    return instance;

这种方式可以保证在构造函数未执行完之前,instance一直是null

另一种方法是给instance变量增加volatile修饰符。使用volatile可以保证执行顺序的正确性,保证变量的读在写之后。但是也会带来新的问题。

  • volatile会阻止指令重拍,这样就会屏蔽掉JVM所做的优化,降低了程序的执行效率。
  • 许多虚拟机并没有实现volatile的顺序一致性(Sequential Consistency)。比如在1.5以下的版本中。

饿汉模式

饿汉模式就是在定义静态变量时就实例化对象,因此在类加载的时候就完成了对象的实例化,而并不是在类第一次使用时。

public class EarlyInstantiationConfigUtils 

    private static EarlyInstantiationConfigUtils instance = new EarlyInstantiationConfigUtils();

    private EarlyInstantiationConfigUtils() 
        // 初始化过程
    

    /**
     * 获取配置实例
     * @return 配置实例
     */
    public static EarlyInstantiationConfigUtils getInstance() 
        return instance;
    

这种方式是最简单的方式,但是会造成一定的资源浪费。因为无论你使用与否,JVM都会在类加载的时候完成类的实例化。

Initialization on Demand Holder (IoDH)

这种方式可以实现实例的懒加载。

public class EarlyInstantiationConfigUtils 

    private EarlyInstantiationConfigUtils() 
        // 初始化过程
    
    
    private static class DemandHolder 
        private static EarlyInstantiationConfigUtils instance = new EarlyInstantiationConfigUtils();
    

    /**
     * 获取配置实例
     * @return 配置实例
     */
    public static EarlyInstantiationConfigUtils getInstance() 
        return DemandHolder.instance;
    

在类加载时,由于instance不是类的静态成员变量,所以不会初始化。在调用getInstance()方法时,加载DemandHolder类,这时instance作为静态成员变量,开始初始化。这是由Java虚拟机来保证线程安全的,并确保只有一个实例被创建。

通过使用IoDH,我们既可以实现延迟加载,又可以保证线程安全,不影响系统性能。

参考资料

JSR-133 Java Memory Model and Thread Specification 1.0 Proposed Final Draft
Double-checked locking and the Singleton pattern
Singleton Pattern
Singleton and its variance (Contextual and Conditional) and scope in WAS 7

以上是关于设计模式之单例模式(懒汉, 饿汉)的主要内容,如果未能解决你的问题,请参考以下文章

设计模式创建型模式之单例设计模式

23种设计模式之单例模式

每天一个设计模式之单例模式

设计模式之单例模式以及简单代码实现

#yyds干货盘点#设计模式之单例模式

设计模式之单例模式