首页 > 技术文章 > 一文带你了解常见的单例设计模式写法,学会了能跟面试官扯皮

codeluojay 2020-09-19 17:37 原文

单例设计模式

单例设计模式就是说任何时刻一个Java有且只有一个实例对象,这种设计模式称为单例设计模式

如果你不会基本单例设计模式,那么你面试遇到几乎就凉凉的节奏。我试过求职面试的时候

看见面试官直接鄙视并且把不会单例设计模式的候选人轰出去了,并鄙视说了句:“单例都不会还来面试”

单例设计模式的思想

  1. 构造器私有化
  2. 声明一个类变量用于存储创建的实例
  3. 提供一个静态公共方法来获取的创建实例

掌握好这三个思想,轻松写出简单的单例设计模式

单例设计模式-饿汉式

public class SingletonDemo {
	// 构造器私有化
	private SingletonDemo() {
 
	}
 	// 声明一个类变量用于存储创建的实例
	private static SingletonDemo singleton = new SingletonDemo();
 	提供一个静态公共方法来获取的创建实例
	public static SingletonDemo getSingleton() {
		return singleton;
	}
}

这是一个简单的线程安全单例设计模式,上述例子的单例称为饿汉式单例

为什么是线程安全的?

  1. 因为饿汉式单例中类一加载就创建实例对象,static修饰的变量是类变量,在内存中共享同一内存空间(唯一性)
  2. 由于一个类在整个生命周期中只会被加载一次,因此该单例类只会创建一个实例(唯一性)

总的来说,理解饿汉式单例可以用一句话概括

饿汉式单例的实例对象在线程访问单例对象之前(也就是类加载时候)就已经创建好了,且用static修饰的变量内存唯一性,保证它式天生就是线程安全的

单例设计模式-懒汉式

public class Singleton1 {
	//	类加载时只声明类变量
    private static Singleton1 instance = null;
	//	构造器私有化
    private Singleton1(){

    }
	//	加了同步的修饰方法
    public static synchronized Singleton1 getInstance() {
        if (instance == null) {
            instance =  new Singleton1();
        }
        return instance;
    }
}

懒汉式单例是线程不安全单例设计模式,上述加了synchronized例子的单例又称为线程懒汉式单例

在不加synchronized修饰的时候,它是一个线程不安全的饿汉式单例

为什么不加synchronized是线程不安全的?

  1. 懒汉式线程创建实例的时候分了两步走,第一步是判断实例对象是否为null,第二步才是创建线程
  2. 在多线程下的多步操作中,每一步操作完后线程都有可能进入阻塞,让其他线程获取CPU执行权,优先执行
  3. 假设两个线程,线程A在判断完instance为空后,突然进入阻塞状态,然后线程B同时抢到CPU执行权,
    也进入判断断完instance为空,再接着完成创建对象实例后退出,此时线程A才恢复执行状态,
    并且记得上次判断instance是空的,那么它又创建一个实例,至此内存就有两个对象实例,
    这就违反单例的设计原则,所以是线程不安全

为什么加synchronized是线程安全的?

synchronized是同步的意思,synchronizedstatic修饰的方法,需要获取到类锁,才能执行方法中的代码

那问题来了,什么是类锁?

在Java反射中,每个类可以抽象成一个Class对象,这个Class对象有且只有一个,因为把它拿来作为一个锁对象(简称类锁),
线程每次都要争抢占有这把类锁,才能到方法内部执行代码,直到完全执行完锁中代码块才会释放这个锁,下一个线程才有机会去拿到这个锁

这就避免了某个线程阻塞的时候,其他线程进入同一方法先于该线程执行代码,从而保证线程安全

单例设计模式-双重校验锁

public class SingleTon {
    private static volatile SingleTon instance = null;
    private SingleTon(){

    }
    public static SingleTon getInstance(){
        if(instance == null){		//	第一次校验
            synchronized (SingleTon.class){
                if(instance == null){	//  第二次校验	
                   instance = new SingleTon();
                }
            }
        }
        return instance;
    }
}

这是一个双重校验锁的线程安全单例设计模式,双重校验锁是说在创建实例之前,
进行两次检查后才加上类锁的意思,那么问题来了,为什么要用双重校验才加锁的单例?线程安全的懒汉式单例不香吗?

香是香,但是synchronized修饰的方法,那可是一个大锁呀,同步性能开销大呀,它锁住的是一整个方法的代码呀,
如果方法中还有其他执行流程的代码,比如打印输出这些小功能,那也将会一起锁住。

public static synchronized Singleton1 getInstance() {
       // 1.创建实例
       // 2.打印输出
}

这就好比每个人交了男女朋友后,都会存一点自己的私房钱,要给私房钱加个小金库才能保证不给自己的女朋友乱花,

但是又不能不让女朋友花我的钱,这怎么办呢?给自己的小金库加个小锁,男女朋友之间公共的钱可以随便花,

只有涉及到小金库的钱才需要拿到锁的钥匙才能花

那有没有锁住一小块代码的保证同步执行效率高的方法?

那就是给创建实例代码单独加锁,其他打印输出的部分不加锁,如下代码可以实现

public static SingleTon getInstance(){
        if(instance == null){		//	第一次校验
            synchronized (SingleTon.class){
                if(instance == null){	//  第二次校验	
                   instance = new SingleTon();
                }
            }
        }
        System.out.println("打印输出")
        return instance;
    }
}

面试过程中如果你写这个双重校验锁单例,那么你要接受面试官各种花式追问了,我就列举我遇到过的连环炮

为什么需要双重校验,每一层校验的作用是什么?

第一层校验也就是最外层的if(instance==null)的作用:

为了代码提高代码执行效率由于单例只要一次创建实例即可

所以当创建了一个实例之后,再次调用getInstance方法

就不必要进入同步代码块不用竞争锁。直接返回前面创建的实例即可。

第二层校验也就是第二个if(singleton==null)的作用:这个校验是防止二次创建实例

二次创建实例因为synchronized不是加在方法上,所以可能出现两个线程同时突破第一层校验,

(因为第一层没有加同步修饰,所以完全有这种情况出现),然后创建多个实例,具体过程如下

假设没有第二层if(instance==null)会出现的情况

假如有一种情况,当instance还未被创建时,线程t1调用getInstance方法,

通过第一层判断if(singleton==null),此时线程t1准备继续执行,但是由于资源被线程t2抢占了

此时t2也调用getInstance方法,同样的,由于instance并没有实例化,t2同样可以通过第一层判断if(singleton==null)

然后继续往下执行,同步代码块,第二层判断if(singleton==null)也通过,然后t2线程创建了一个实例instance。至此t2线程完成任务,资源又回到t1线程,

t1此时也进入同步代码块,如果没有这个第二层判断if(singleton==null),那么,t1就也会创建一个instance实例,那么,就会出现创建多个实例的情况

但是加上第二个if(singleton==null),就可以完全避免这个多线程导致多次创建实例的问题。

为什么双重校验锁需要用到volatile,它的作用是什么?

volatile作用之一:首先volatile是Java的关键字,它可以防止jvm指令重排

instance = new Singleton() 

这行代码可以分为三步:
1. 类加载阶段为 instance 计算分配内存空间
2. 类初始化阶段初始化 instance
3. 创建实例后将instance指向分配的内存空间

但是由于JVM具有指令重排的特性,执行顺序有可能变成 1-3-2。 指令重排在单线程下不会出现问题,但是在多线程下会导致一个线程获得一个未初始化的实例。

例如:线程T1执行了1和3,此时T2调用 getInstance() 后发现 instance 不为空,因此返回 instance

但是此时的 instance还没有被初始化。 使用 volatile 会禁止JVM指令重排,从而保证在多线程下也能正常执行

volatile作用之一:首先volatile是Java的关键字,它可以防止jvm指令重排

在 JDK1.2 之前,Java的内存模型实现总是从主存(即共享内存)读取变量,是不需要进行特别的注意的。

而在当前(目前我用的是1.8主流的开发也是用的1.8) 下的 Java 内存模型下,线程可以把变量保存本地内存(比如机器的寄存器)中,而不是直接在主存中进行读写。

这就可能造成线程t1和线程t2先同时将共享内存中变量保存在自己的寄存器中,接着线程t1修改了主存中变量的值,而线程t2还继续使用它在寄存器中的主存的变量值的拷贝,造成数据的不一致。

要解决这个问题,就需要把变量声明为 volatile,这就指示 JVM,这个变量是不稳定的,每次使用它都到主存中进行读取。

单例设计模式三种写法分析总结

  1. 饿汉式

    优点:天生线程安全,典型空间换时间创建方式

    缺点:不能按需加载,不能控制创建实例的时机

    有些时候,优点也会是变成缺点,就好比面试时,问:你最大的优点是什么?答:我最大的优点就是没有缺点

    问:你最大的缺点是什么?答:我最大的缺点就是太优秀,

    回到饿汉式单例上,它是空间换时间创建方式,也就是说,在类加载就创建实例并分配空间,牺牲控制创建的时机的单例模式

  2. 懒汉式

    懒汉式单例实现方式体现了延迟加载的思想

    什么是延迟加载呢?
    通俗点说,就是一开始不要加载资源或者数据,等到马上就要使用这个资源或者数据了,

    躲不过去了才加载,所以也称Lazy Load,这在实际开发中是一种很常见的思想,尽可能的节约资源。

    优点:懒加载,典型的时间换空间(用可控的时间去换分配的内存空间,因为要加载的时候才开辟内存空间)

    缺点:线程不安全,需要同步修饰才能保证线程安全

  3. 双重校验锁式

    优点:既有线程安全,又不会对性能造成太大的影响

    缺点:从面试的角度来讲,你写出来就要准备好一系列关于并发编程的底层知识追问

    具体包含但不限于:volatile的作用》volatile指令重排的原理和步骤》...

    单例设计模式具体应用

    谈谈你在项目哪里用到单例设计模式

    比较常见的是用在Spring框架的中bean,因为它默认的作用域是单例singleton,

    当然也可以通过@Scope注解改变作用域为原型prototype,为什么感觉自己在实际项目中没用到过单例

    因为Spring框架已经帮我们封装好了,我们无感很正常,但要谈及实际应用,肯定要答出在Spring中用过

    顺带来复习以下Spring的作用域:

    singleton单例

    prototype多例

    session会话

    request请求

    global session作用域

    另外还补充一个面试问到过的问题:

    Spring的bean是默认单例还是多例,为什么是单例的,如何保证它线程安全?

    单例,因为Spring声明bean是单例情况下,遇到第一个请求就创建单例对象并放置在缓存中,

    后期处理多个请求也是共用这个单例对象,可以节约创建新实例的时间和性能开销,同时单例有助于减少垃圾回收

    如何保证线程安全?

    1. 单例模式下尽量少定义类变量,以避免的角度去回答
      (即static修饰的变量,它是共享的变量,减少多线程访问操作共享变量)

    2. 如确实需要使用有状态的bean,通过@Scope声明beanprototype,
      同时使用ThreadLocal去解决线程安全的方法,
      因为ThreadLocal为每个线程保存线程私有的数据。这是一种以空间换时间的方式

    3. 当然也可以通过加锁的方法来解决线程安全,这种以时间换空间的场景,
      只不过高并发下,这种模式是不切实际的,高并发本来就是追求时间优先

    这篇文章是在我近期面试过程中频繁遇到单例的题目情况下,自己看了一些视频和博文写出来的个人总结,总的来说三种写法中还是建议写双重校验锁,为什么这样说,饿汉和懒汉是新手都应该会写的

    双重校验则是算是饿汉和懒汉之上的小优化,你要是回答出双重检验锁单例的原理以及接下来一两个面试官的基于这一点拓展的问题,那么将会是一个加分项,回答不出也没什么,面试本来就是会有知识盲区的,过后回去百度查漏补缺

推荐阅读