设计模式 - 单例模式(详解)看看和你理解的是否一样?
一、概述
单例模式是设计模式中相对简单且非常常见的一种设计模式,但是同时也是非常经典的高频面试题,相信还是有很多人在面试时会挂在这里。本篇文章主要针对单例模式做一个回顾,记录单例模式的应用场景、常见写法、针对线程安全进行调试(看得见的线程)以及总结。相信大家看完这篇文章之后,对单例模式有一个非常深刻的认识。
文章中按照常见的单例模式的写法,由浅入深进行讲解记录;以及指出该写法的不足,从而进行演进改造。
秉承废话少说的原则,我们下面快速开始
二、定义
单例模式(singleton pattern)是指确保一个类在任何情况下都绝对只有一个实例,并提供一个全局访问点。
单例模式是创建型模式。
三、应用场景
- 生活中的单例:例如,国家主席、公司 ceo、部门经理等。
- 在
java
世界中:servletcontext
、servletcontextconfig
等; - 在
spring
框架应用中:applicationcontext
、数据库的连接池也都是单例形式。
四、常见的单例模式写法
单例模式主要有:饿汉式单例、懒汉式单例(线程不安全型、线程安全型、双重检查锁类型、静态内部类类型)、注册式(登记式)单例(枚举式单例、容器式单例)、
threadlocal
线程单例
下面我们来看看各种模式的写法。
1、饿汉式单例
饿汉式单例是在类加载的时候就立即初始化,并且创建单例对象。绝对线程安全,在线程还没出现以前就是实例化了,不可能存在访问安全问题。
spring 中 ioc 容器 applicationcontext 就是典型的饿汉式单例
优缺点
优点:没有加任何的锁、执行效率比较高,在用户体验上来说,比懒汉式更好。
缺点:类加载的时候就初始化,不管用与不用都占着空间,浪费了内存,有可能占着茅坑不拉屎。
写法
/** * @author eamon.zhang * @date 2019-09-30 上午9:26 */ public class hungrysingleton { // 1.私有化构造器 private hungrysingleton (){} // 2.在类的内部创建自行实例 private static final hungrysingleton instance = new hungrysingleton(); // 3.提供获取唯一实例的方法(全局访问点) public static hungrysingleton getinstance(){ return instance; } }
还有另外一种写法,利用静态代码块的机制:
/** * @author eamon.zhang * @date 2019-09-30 上午10:46 */ public class hungrystaticsingleton { // 1. 私有化构造器 private hungrystaticsingleton(){} // 2. 实例变量 private static final hungrystaticsingleton instance; // 3. 在静态代码块中实例化 static { instance = new hungrystaticsingleton(); } // 4. 提供获取实例方法 public static hungrystaticsingleton getinstance(){ return instance; } }
测试代码,我们创建 10 个线程(具体线程发令枪 concurrentexecutor 在文末源码中获取):
/** * @author eamon.zhang * @date 2019-09-30 上午11:17 */ public class hungrysingletontest { @test public void test() { try { concurrentexecutor.execute(() -> { hungrysingleton instance = hungrysingleton.getinstance(); system.out.println(thread.currentthread().getname() + " : " + instance); }, 10, 10); } catch (exception e) { e.printstacktrace(); } } }
测试结果:
pool-1-thread-6 : com.eamon.javadesignpatterns.singleton.hungry.hungrysingleton@5e37cce6 pool-1-thread-1 : com.eamon.javadesignpatterns.singleton.hungry.hungrysingleton@5e37cce6 pool-1-thread-9 : com.eamon.javadesignpatterns.singleton.hungry.hungrysingleton@5e37cce6 pool-1-thread-10 : com.eamon.javadesignpatterns.singleton.hungry.hungrysingleton@5e37cce6 pool-1-thread-2 : com.eamon.javadesignpatterns.singleton.hungry.hungrysingleton@5e37cce6 pool-1-thread-7 : com.eamon.javadesignpatterns.singleton.hungry.hungrysingleton@5e37cce6 pool-1-thread-5 : com.eamon.javadesignpatterns.singleton.hungry.hungrysingleton@5e37cce6 pool-1-thread-3 : com.eamon.javadesignpatterns.singleton.hungry.hungrysingleton@5e37cce6 pool-1-thread-4 : com.eamon.javadesignpatterns.singleton.hungry.hungrysingleton@5e37cce6 pool-1-thread-8 : com.eamon.javadesignpatterns.singleton.hungry.hungrysingleton@5e37cce6 ...
可以看到,饿汉式每次获取实例都是同一个。
使用场景
这两种写法都非常的简单,也非常好理解,饿汉式适用在单例对象较少的情况。
下面我们来看性能更优的写法——懒汉式单例。
2、懒汉式单例
懒汉式单例的特点是:被外部类调用的时候内部类才会加载。
懒汉式单例可以分为下面这几种写法来。
简单懒汉式(线程不安全)
这是懒汉式单例的简单写法
/** * @author eamon.zhang * @date 2019-09-30 上午10:55 */ public class lazysimplesingleton { private lazysimplesingleton(){} private static lazysimplesingleton instance = null; public static lazysimplesingleton getinstance(){ if (instance == null) { instance = new lazysimplesingleton(); } return instance; } }
我们创建一个多线程来测试一下,是否线程安全:
/** * @author eamon.zhang * @date 2019-09-30 上午11:12 */ public class lazysimplesingletontest { @test public void test() { try { concurrentexecutor.execute(() -> { lazysimplesingleton instance = lazysimplesingleton.getinstance(); system.out.println(thread.currentthread().getname() + " : " + instance); }, 5, 5); } catch (exception e) { e.printstacktrace(); } } }
运行结果:
pool-1-thread-3 : com.eamon.javadesignpatterns.singleton.lazy.lazysimplesingleton@abe194f pool-1-thread-5 : com.eamon.javadesignpatterns.singleton.lazy.lazysimplesingleton@abe194f pool-1-thread-1 : com.eamon.javadesignpatterns.singleton.lazy.lazysimplesingleton@748e48d0 pool-1-thread-2 : com.eamon.javadesignpatterns.singleton.lazy.lazysimplesingleton@abe194f pool-1-thread-4 : com.eamon.javadesignpatterns.singleton.lazy.lazysimplesingleton@abe194f
从测试结果来看,一定几率出现创建两个不同结果的情况,意味着上面的单例存在线程安全隐患。
至于为什么?由于篇幅问题,我们在后面一篇文章中利用测试工具进行详细的分析(这可能也是面试中面试官会问到的问题)。大家现在只需要知道简单的懒汉式会存在这么一个问题就行了。
简单懒汉式(线程安全)
通过对上面简单懒汉式单例的测试,我们知道存在线程安全隐患,那么,如何来避免或者解决呢?
我们都知道 java 中有一个synchronized
可以来对共享资源进行加锁,保证在同一时刻只能有一个线程拿到该资源,其他线程只能等待。所以,我们对上面的简单懒汉式进行改造,给getinstance()
方法加上synchronized
:
/** * @author eamon.zhang * @date 2019-09-30 上午10:55 */ public class lazysimplesyncsingleton { private lazysimplesyncsingleton() { } private static lazysimplesyncsingleton instance = null; public synchronized static lazysimplesyncsingleton getinstance() { if (instance == null) { instance = new lazysimplesyncsingleton(); } return instance; } }
然后使用发令枪进行测试:
@test public void testsync(){ try { concurrentexecutor.execute(() -> { lazysimplesyncsingleton instance = lazysimplesyncsingleton.getinstance(); system.out.println(thread.currentthread().getname() + " : " + instance); }, 5, 5); } catch (exception e) { e.printstacktrace(); } }
进行多轮测试,并观察结果,发现能够获取同一个示例:
pool-1-thread-3 : com.eamon.javadesignpatterns.singleton.lazy.simple.lazysimplesyncsingleton@1a7e99de pool-1-thread-2 : com.eamon.javadesignpatterns.singleton.lazy.simple.lazysimplesyncsingleton@1a7e99de pool-1-thread-5 : com.eamon.javadesignpatterns.singleton.lazy.simple.lazysimplesyncsingleton@1a7e99de pool-1-thread-1 : com.eamon.javadesignpatterns.singleton.lazy.simple.lazysimplesyncsingleton@1a7e99de pool-1-thread-4 : com.eamon.javadesignpatterns.singleton.lazy.simple.lazysimplesyncsingleton@1a7e99de
线程安全问题是解决了,但是,用synchronized
加锁,在线程数量比较多情况下,如果cpu
分配压力上升,会导致大批量线程出现阻塞,从而导致程序运行性能大幅下降。
那么,有没有一种更好的方式,既兼顾线程安全又提升程序性能呢?答案是肯定的。
我们来看双重检查锁的单例模式。
双重检查锁懒汉式
上面的线程安全方式的写法,synchronized
锁是锁在 getinstance()
方法上,当多个线程过来拿资源的时候,其实需要拿的不是getinstance()
这个方法,而是getinstance()
方法里面的instance
实例对象,而如果这个实例对象一旦被初始化之后,多个线程到达时,就可以利用方法中的 if (instance == null)
去判断是否实例化,如果已经实例化了就直接返回,就没有必要再进行实例化一遍。所以对上面的代码进行改造:
第一次改造:
/** * @author eamon.zhang * @date 2019-09-30 下午2:03 */ public class lazydoublechecksingleton { private lazydoublechecksingleton() { } private static lazydoublechecksingleton instance = null; public static lazydoublechecksingleton getinstance() { // 这里判断是为了过滤不必要的同步加锁,因为如果已经实例化了,就可以直接返回了 if (instance == null) { // 如果未初始化,则对资源进行上锁保护,待实例化完成之后进行释放 synchronized (lazydoublechecksingleton.class) { instance = new lazydoublechecksingleton(); } } return instance; } }
这种方法行不行?答案肯定是不行,代码中虽然是将同步锁添加到了实例化操作中,解决了每个线程由于同步锁的原因引起的阻塞,提高了性能;但是,这里会存在一个问题:
-
线程x
和线程y
同时调用getinstance()
方法,他们同时判断instance == null
,得出的结果都是为null
,所以进入了if
代码块了 - 此时
线程x
得到cpu
的控制权 -> 进入同步代码块 -> 创建对象 -> 返回对象 -
线程x
执行完成了以后,释放了锁,然后线程y
得到了cpu
的控制权。同样是 -> 进入同步代码块 -> 创建对象 -> 返回对象
所以我们明显可以分析出来:lazydoublechecksingleton
类返回了不止一个实例!所以上面的代码是不行的!大家可以自行测试,我这里就不进行测试了!
我们再进行改造,经过分析,由于线程x
已经实例化了对象,在线程y
再次进入的时候,我们再加一层判断不就可以解决 “这个” 问题吗?确实如此,来看代码:
/** * @author eamon.zhang * @date 2019-09-30 下午2:03 */ public class lazydoublechecksingleton { private lazydoublechecksingleton() { } private static lazydoublechecksingleton instance = null; public static lazydoublechecksingleton getinstance() { // 这里判断是为了过滤不必要的同步加锁,因为如果已经实例化了,就可以直接返回了 if (instance == null) { // 如果未初始化,则对资源进行上锁保护,待实例化完成之后进行释放(注意,可能多个线程会同时进入) synchronized (lazydoublechecksingleton.class) { // 这里的if作用是:如果后面的进程在前面一个线程实例化完成之后拿到锁,进入这个代码块, // 显然,资源已经被实例化过了,所以需要进行判断过滤 if (instance == null) { instance = new lazydoublechecksingleton(); } } } return instance; } }
大家觉得经过这样改造是不是就完美了呢?在我们习惯性的“讲道理”的思维模式看来,好像确实没什么问题,但是,程序是计算机在执行;什么意思呢?
在 instance = new lazydoublechecksingleton();
这段代码执行的时候,计算机内部并非简单的一步操作,也就是非原子操作,在jvm
中,这一行代码大概做了这么几件事情:
- 给
instance
分配内存 - 调用
lazydoublechecksingleton
的构造函数来初始化成员变量 - 将
instance
对象指向分配的内存空间(执行完这步instance
就为非 null 了)
但是在 jvm
中的即时编译器中存在指令重排序的优化;通俗的来说就是,上面的第二步和第三步的顺序是不能保证的,如果执行顺序是 1 -> 3 -> 2
那么在 3 执行完毕、2 未执行之前,被另外一个线程 a 抢占了,这时 instance
已经是非 null 了(但却没有初始化),所以线程 a 会直接返回 instance
,然后被程序调用,就会报错。
当然,这种情况是很难测试出来的,但是确实会存在这么一个问题,所以我们必须解决它,解决方式也很简单,就是 j 将
instance
加上volatile
关键字。
所以相对较完美的实现方式是:
/** * @author eamon.zhang * @date 2019-09-30 下午2:03 */ public class lazydoublechecksingleton { private lazydoublechecksingleton() { } private static volatile lazydoublechecksingleton instance = null; public static lazydoublechecksingleton getinstance() { // 这里判断是为了过滤不必要的同步加锁,因为如果已经实例化了,就可以直接返回了 if (instance == null) { // 如果未初始化,则对资源进行上锁保护,待实例化完成之后进行释放(注意,可能多个线程会同时进入) synchronized (lazydoublechecksingleton.class) { // 这里的if作用是:如果后面的进程在前面一个线程实例化完成之后拿到锁,进入这个代码块, // 显然,资源已经被实例化过了,所以需要进行判断过滤 if (instance == null) { instance = new lazydoublechecksingleton(); } } } return instance; } }
测试代码见文末说明
静态内部类懒汉式
上面的双重锁检查形式的单例,对于日常开发来说,确实够用了,但是在代码中使用synchronized
关键字 ,总归是要上锁,上锁就会存在一个性能问题。难道就没有更好的方案吗?别说,还真有,我们从类初始化的角度来考虑,这就是这里所要说到的静态内部类的方式。
废话不多说,直接看代码:
/** * * @author eamon.zhang * @date 2019-09-30 下午2:55 */ public class lazyinnerclasssingleton { private lazyinnerclasssingleton() { } // 注意关键字final,保证方法不被重写和重载 public static final lazyinnerclasssingleton getinstance() { return lazyholder.instance; } private static class lazyholder { // 注意 final 关键字(保证不被修改) private static final lazyinnerclasssingleton instance = new lazyinnerclasssingleton(); } }
进行多线程测试:
pool-1-thread-9 : com.eamon.javadesignpatterns.singleton.lazy.inner.lazyinnerclasssingleton@88b7fa2 pool-1-thread-1 : com.eamon.javadesignpatterns.singleton.lazy.inner.lazyinnerclasssingleton@88b7fa2 pool-1-thread-6 : com.eamon.javadesignpatterns.singleton.lazy.inner.lazyinnerclasssingleton@88b7fa2 ...
结果都是同一个对象实例。
结论
这种方式即解决了饿汉式的内存浪费问题,也解决了synchronized
所带来的性能问题
原理
利用的原理就是类的加载初始化顺序:
- 当类不被调用的时候,类的静态内部类是不会进行初始化的,这就避免了内存浪费问题;
- 当有方法调用
getinstance()
方法时,会先初始化静态内部类,而静态内部类中的成员变量是final
的,所以即便是多线程,其成员变量是不会被修改的,所以就解决了添加synchronized
所带来的性能问题
首先感谢也恭喜大家能够看到这里,因为我想告诉你,上面所有的单例模式似乎还存在一点小问题 —— 暴力破坏。解决这一问题的方式就是下面提到的枚举类型单例。
至于缘由和为何枚举能够解决这个问题,同样,篇幅原因,我将在后面单独开一篇文章来说明。
下面我们先来讲讲注册式单例。
3、注册式(登记式)单例
注册式单例又称为登记式单例,就是将每一个实例都登记到某一个地方,使用唯一的标识获取实例。
注册式单例有两种写法:一种为容器缓存,一种为枚举登记。
先来看枚举式单例的写法。
枚举单例
废话少说,直接看代码,我们先创建enumsingleton
类:
/** * @author eamon.zhang * @date 2019-09-30 下午3:42 */ public enum enumsingleton { instance; private object instance; enumsingleton() { instance = new enumresource(); } public object getinstance() { return instance; } }
来看测试代码:
/** * @author eamon.zhang * @date 2019-09-30 下午3:47 */ public class enumsingletontest { @test public void test() { try { concurrentexecutor.execute(() -> { enumsingleton instance = enumsingleton.instance; system.out.println(instance.getinstance()); }, 10, 10); } catch (exception e) { e.printstacktrace(); } } }
测试结果:
com.eamon.javadesignpatterns.singleton.enums.enumresource@3eadb1e7 com.eamon.javadesignpatterns.singleton.enums.enumresource@3eadb1e7 com.eamon.javadesignpatterns.singleton.enums.enumresource@3eadb1e7 com.eamon.javadesignpatterns.singleton.enums.enumresource@3eadb1e7 com.eamon.javadesignpatterns.singleton.enums.enumresource@3eadb1e7 com.eamon.javadesignpatterns.singleton.enums.enumresource@3eadb1e7 com.eamon.javadesignpatterns.singleton.enums.enumresource@3eadb1e7 com.eamon.javadesignpatterns.singleton.enums.enumresource@3eadb1e7 com.eamon.javadesignpatterns.singleton.enums.enumresource@3eadb1e7 com.eamon.javadesignpatterns.singleton.enums.enumresource@3eadb1e7
结果都一样,说明枚举类单例是线程安全的,且是不可破坏的;在 jdk 枚举的语法特殊性,以及反射也为枚举保驾护航,让枚举式单例成为一种比较优雅的实现。
枚举类单例也是《effective java》中所建议使用的。
容器式单例
注册式单例还有另外一种写法,利用容器缓存,直接来看代码:
创建containersingleton
类:
/** * @author eamonzzz * @date 2019-10-06 18:28 */ public class containersingleton { private containersingleton() { } private static map<string, object> ioc = new concurrenthashmap<string, object>(); public static object getbean(string classname) { synchronized (ioc) { if (!ioc.containskey(classname)) { object object = null; try { object = class.forname(classname).newinstance(); ioc.put(classname, object); } catch (exception e) { e.printstacktrace(); } return object; } else { return ioc.get(classname); } } } }
测试代码:
@test public void test() { try { concurrentexecutor.execute(() -> { object bean = containersingleton .getbean("com.eamon.javadesignpatterns.singleton.container.resource"); system.out.println(bean); }, 5, 5); } catch (exception e) { e.printstacktrace(); } }
测试结果:
com.eamon.javadesignpatterns.singleton.container.resource@42e7420f com.eamon.javadesignpatterns.singleton.container.resource@42e7420f com.eamon.javadesignpatterns.singleton.container.resource@42e7420f com.eamon.javadesignpatterns.singleton.container.resource@42e7420f com.eamon.javadesignpatterns.singleton.container.resource@42e7420f
容器式写法适用于创建实例非常多的情况,便于管理。但是,是非线程安全的。
其实 spring 中也有相关容器史丹利的实现代码,比如 abstractautowirecapablebeanfactory
接口
至此,注册式单例介绍完毕。
五、拓展
threadlocal 线程单例
threadlocal 不能保证其创建的对象是唯一的,但是能保证在单个线程中是唯一的,并且在单个线程中是天生的线程安全。
看代码:
/** * @author eamonzzz * @date 2019-10-06 21:40 */ public class threadlocalsingleton { private threadlocalsingleton() { } private static final threadlocal<threadlocalsingleton> instance = threadlocal.withinitial(threadlocalsingleton::new); public static threadlocalsingleton getinstance() { return instance.get(); } }
测试程序:
@test public void test() { system.out.println("-------------- 单线程 start ---------"); system.out.println(threadlocalsingleton.getinstance()); system.out.println(threadlocalsingleton.getinstance()); system.out.println(threadlocalsingleton.getinstance()); system.out.println(threadlocalsingleton.getinstance()); system.out.println(threadlocalsingleton.getinstance()); system.out.println("-------------- 单线程 end ---------"); system.out.println("-------------- 多线程 start ---------"); try { concurrentexecutor.execute(() -> { threadlocalsingleton singleton = threadlocalsingleton.getinstance(); system.out.println(thread.currentthread().getname() + " : " + singleton); }, 5, 5); } catch (exception e) { e.printstacktrace(); } system.out.println("-------------- 多线程 end ---------"); }
测试结果:
-------------- 单线程 start --------- com.eamon.javadesignpatterns.singleton.threadlocal.threadlocalsingleton@1374fbda com.eamon.javadesignpatterns.singleton.threadlocal.threadlocalsingleton@1374fbda com.eamon.javadesignpatterns.singleton.threadlocal.threadlocalsingleton@1374fbda com.eamon.javadesignpatterns.singleton.threadlocal.threadlocalsingleton@1374fbda com.eamon.javadesignpatterns.singleton.threadlocal.threadlocalsingleton@1374fbda -------------- 单线程 end --------- -------------- 多线程 start --------- pool-1-thread-5 : com.eamon.javadesignpatterns.singleton.threadlocal.threadlocalsingleton@2f540d92 pool-1-thread-1 : com.eamon.javadesignpatterns.singleton.threadlocal.threadlocalsingleton@3ef7ab4e pool-1-thread-2 : com.eamon.javadesignpatterns.singleton.threadlocal.threadlocalsingleton@604ffe2a pool-1-thread-3 : com.eamon.javadesignpatterns.singleton.threadlocal.threadlocalsingleton@50f41c9f pool-1-thread-4 : com.eamon.javadesignpatterns.singleton.threadlocal.threadlocalsingleton@40821a7a -------------- 多线程 end ---------
从测试结果来看,我们不难发现,在主线程中无论调用多少次,获得到的实例都是同一个;在多线程环境下,每个线程获取到了不同的实例。
所以,在单线程环境中,threadlocal 可以达到单例的目的。这实际上是以空间换时间来实现线程间隔离的。
六、总结
单例模式可以保证内存里只有一个实例,减少了内存的开销;可避免对资源的浪费。
单例模式看起来非常简单,实现起来也不难,但是在面试中却是一个高频的面试题。希望大家能够彻底理解。
本篇文章所涉及的源代码:
上一篇: 关于thymeleaf中th:if的使用
下一篇: vue组件中的data与methods