设计模式之单例模式

148 阅读6分钟

在我们日常项目开发过程中,单例模式的运用是必不可少的。

1. 概念介绍

单例模式(Singleton Pattern)是 Java 中最简单的设计模式之一。单例模式的作用是在项目中保证一个类只有一个对象实例,并提供一个全局访问方法(比如:getInstance 方法)。

2. 单例模式的两种类型

懒汉式:在真正需要创建的时候才去创建单例类。

饿汉式:在类加载时已经创建好单例对象。

2.1 懒汉式创建单例对象

懒汉式创建对象的方法是在程序使用对象前,先判断该对象是否已经实例化(判空),若已经实例化则直接返回该类对象。否则需要先执行实例化操作。

代码实现:

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

懒汉式创建单例对象在单线程情况下是完全没有问题的,但是在多线程的情况下就会出现一些问题。

2.2 饿汉式单例模式

饿汉的意思就是先吃为敬,在项目启动时就提前创建好单例对象。小型项目建议直接使用饿汉式,代码简洁不需考虑高并发等问题。

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

饿汉式在类加载时已经创建好该对象,在程序被调用时直接返回该单例对象即可,即我们在编码时就已经指明了要马上创建这个对象,不需要等到被调用时再去创建。

2.3 优化懒汉式创建单例对象

我们再来回顾懒汉式的核心方法

public static Singleton getInstance() { 
        if (singleton == null) {
            singleton = new Singleton(); 
        } 
        return singleton; 
}

懒汉模式解决了饿汉模式可能引起的资源浪费问题,因为这种模式只有在用户要使用的时候才会实例化对象。但是这种模式在并发情况下会创建多个对象的情况。

这个方法其实是存在问题的,试想一下,如果两个线程同时判断singleton为空,那么它们都会去实例化一个Singleton对象,这就变成双例了。所以,我们要解决的是线程安全问题。

最容易想到的解决方法就是在方法上加锁,或者是对类对象加锁,程序就会变成下面这个样子

public static synchronized Singleton getInstance() {
    if (singleton == null) {
        singleton = new Singleton();
    }
    return singleton;
}
// 或者
public static Singleton getInstance() {
    synchronized(Singleton.class) {   
        if (singleton == null) {
            singleton = new Singleton();
        }
    }
    return singleton;
}

这样就规避了两个线程同时创建Singleton对象的风险,但是引来另外一个问题:每次去获取对象都需要先获取锁,并发性能非常地差,极端情况下,可能会出现卡顿现象。

接下来要做的就是优化性能,目标是: 如果没有实例化对象则加锁创建,如果已经实例化了,则不需要加锁,直接获取实例

所以直接在方法上加锁的方式就被废掉了,因为这种方式无论如何都需要先获取锁


public static Singleton getInstance() {
    if (singleton == null) {  // 第一重检查 
        synchronized(Singleton.class) { // 假如 A 和 B 两个线程同时执行到 synchronized(SingletonLazy.class)这一行,则需要第二重检查来防止重复 new 对象
            if (singleton == null) { // 第二重检查
                singleton = new Singleton();
            }
        }
    }
    return singleton;
}

上面的代码已经完美地解决了并发安全+性能低效问题:

因为需要两次判空,且对类对象加锁,该懒汉式写法也被称为:Double Check(双重校验) + Lock(加锁)

但是上面的懒汉式创建单例模式还不是最完美的懒汉式创建单例模式,为什么呢?我们来学习一下。

我们先介绍一下指令重排:

2.4 指令重排

什么是指令重排?

指令重排是指:JVM在保证最终结果正确的情况下,可以不按成程序编码的顺序执行语句,尽可能提高程序的性能

在程序中,如果两个指令之间不存在依赖性,就会发生指令重排。例如:a = 1, y = 2 这两个语句之间不存在依赖,就会发生指令重排;

又如 a = 1, y = a + 1 这两个指令之间就存在依赖,因此就不会发生指令重排

我们来看看下面这行 new 对象做了什么操作

instance = new Singleton();

new Singleton 对象时 JVM 操作步骤如下:

  1. 分配内存空间给 Singleton 对象
  2. 在内存空间中创建 Singleton 对象
  3. 把 Singleton 对象赋值给引用 instance

假如 new 对象这个线程是按照 1->3->2 顺序执行,线程执行完毕后会把值(空对象)写会主内存,其他线程就会读取到 instance 最新的值,但由于没有初始化完该对象,此时返回的对象是有问题的。

首先我们先搞明白顺序为什么会乱,这里涉及 Java 内存模型和指令重排,指令重排序是 JVM 对语句执行的优化,只要语句间没有依赖关系,那么 JVM 就有权对语句进行优化(即执行顺序变化)。因为此行代码不是原子性操作,JVM 可能会对它进行执行重排,就有可能让其他线程读到不完全的对象,那如何解决这种情况?办法是有的,只需加上 volatile 关键字修饰 Singleton 对象即可保证有序性并禁止 CPU 指令重排。

最终的代码如下所示:

public class Singleton {
    
    private static volatile Singleton singleton;
    
    private Singleton(){}
    
    public static Singleton getInstance() {
        if (singleton == null) {  // 线程A和线程B同时看到singleton = null,如果不为null,则直接返回singleton
            synchronized(Singleton.class) { // 线程A或线程B获得该锁进行初始化
                if (singleton == null) { // 其中一个线程进入该分支,另外一个线程则不会进入该分支
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}

2.5 静态内部类

只在获取对象的时候才会加载内部类,类加载是线程安全的。

第一次加载Singleton类时并不会初始化 instance,只有第一次调用 getInstance 方法时虚拟机加载 SingletonHolder 并初始化 instance ,这样不仅能确保线程安全也能保证 Singleton 类的唯一性。

public class Singleton {         //外部类

	private Singleton(){}	

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

外部类 加载时,并不需要立即加载 静态内部类静态内部类 不被加载则不去初始化 instance ,故而不占内存。
即当 Singleton 第一次被加载时,并不需要去加载 SingleTonHolder 。

当 getInstance() 第一次被调用时,才会去初始化 instance,虚拟机加载 SingleTonHolder 类。

这种方法 不仅能确保线程安全,也能保证单例的唯一性,同时也延迟了单例的实例化。

3. 小结

单例模式是开发过程中使用的最多的一种设计模式,它只允许创建一个对象,因此节省内存,加快了对象访问速度,当对象需要被公用的场景就很适合使用单例模式;但是它不适用于变化的对象,如果同一类型的对象总是要在不同的用例场景发生变化,单例就会引起数据的错误,不能保存彼此的状态。

所以我们在开发的过程中要根据不同的应用场景选择更合适的设计模式,这样才能帮助我们更好的解决问题。