每日一个设计模式之【单例模式】
☁️前言🎉🎉🎉
大家好✋,我是知识汲取者😄,今天给大家带来一篇有关单例模式的学习笔记。众所周知能够熟练使用设计模式是一个优秀程序猿的必备技能,当我们在项目中选择一个或多个合适的设计模式,不仅能大大提高项目的稳健性、可移植性、可维护性,同时还能让你的代码更加精炼,具备艺术美感。
单例模式是 Java 设计模式中最简单的一种📗,但是你可不能小看单例模式,虽然从设计上来说它比较简单,但是在实现当中你可能还是会遇到很多的坑🙊,同时需要注意的细节也是有很多地。我们不仅要知其所以(如何写),更要知其所以然(为什么怎这么写),所以系好安全带🛃,老司机带你们上车🚗。
推荐阅读:
🌻单例模式概述
-
什么是单例模式?
单例模式(Singleton Pattern)属于创建型模式,是Java中最简单的设计模式之一,单例模式需要保证系统中,应用该模式的这个类永远只有一个实例,即:一个类永远只能创建一个对象。
-
单例模式的作用
- 保障对象的唯一性,不让系统发生混乱,比如笔记本的任务管理器就是使用了单例模式,否则多个资源管理器同时操作,会让系统混乱,甚至造成系统崩溃
- 避免资源浪费,无论实例化多少次对象,只加载到内存中一次
- 提高系统性能,当使用单例模式创建的对象,再次进行访问时,能够直接使用无需再次创建
……
主要解决一个全局使用的类频繁地创建与销毁
-
单例模式的优缺点
-
优点:
- 确保对象的唯一性
- 避免资源资源
- 提高系统性能
- 具有较高的可伸缩性。类自己来控制实例化进程,类就在改变实例化进程上有相应的伸缩性
-
缺点:
- 使用范围狭窄。只适合用于不变的对象,不适合用于变化的对象,单例对象一旦被创建,它的数据就不会发生改变了
- 难于扩展。由于单例模式中没有抽象层,无法使用继承,因此单例类的扩展有很大的困难
- 违背面对对象的基本设计原则。单例模式职责过重,不符合单例职责原则的设计要求
- 滥用单例将带来一些负面问题。如为了节省资源将数据库连接池对象设计为的单例类,可能会导致共享连接池对象的程序过多而出现连接池溢出;如果实例化的对象长时间不被利用,系统会认为是垃圾而被回收,这将导致对象状态的丢失
……
-
-
单例模式的应用场景
-
对于需要保障系统中只能存在一个对象的情况,需要使用单例模式,比如电脑上的回收站、资源管理器、Web引用中的计数器
-
有频繁实例化然后销毁的情况,也就是频繁的new对象,可以考虑单例模式
-
创建对象时耗时过多或者耗资源过多,但又经常用到的对象,可以考虑单例模式
-
频繁访问IO资源的对象,例如数据库连接池或访问本地文件
-
对于配置文件的读取,我们一般可以使用创建一个工具类,使用单例模式来创建对象
……
Spring中的应用:Bean的默认作用域就是单例的
scope="singleton"
-
-
单例模式的分类
在我看来根据单例对象的创建时机可以将单例模式分为两大类:
- 懒汉式:单例对象在被使用时才被创建(延迟加载)
- 饿汉式:单例模式在单例类被加载时就被创建
-
懒汉式和饿汉式比较:
两者并没有谁优谁劣,根据具体的使用场景来选择,我们要做到的是掌握他们各自的特点
🌱单例模式的实现
🍀懒汉式
🐳原始懒汉式
原始懒汉式的核心实现需要依靠
static
和private
这两个关键字。备注:原始懒汉式一般都是直接称作懒汉式,这是我对他的命名,主要用来区分其他的懒汉式单例模式。
-
原始懒汉式的缺点:由于没有加锁,所以存在严重的线程安全问题😨,在多线程下无法正常使用,严格意义上讲这都不算是一种单例模式
-
原始懒汉式的使用场景:在工作中禁止使用(使用就等着挨骂吧🤣),由于这种方式实现最简单,可以在你自己的一些小项目中使用
-
原始懒汉式的实现步骤:
-
Step1:创建一个类,使用将构造器私有化
目的:让外部不能通过构造器获取该类的实例化对象,从而保障实例化对象的唯一性
-
Step2:创建一个静态成员变量
目的:用于存储创建的单例对象
-
Step3:提供一个公开的使用
static
修饰的get方法目的:用于外部获取获取单例对象,get方法需要使用
static
修饰是因为单例类已经将构造器私有化了,外部无法通过实例化对象来调用get方法
具体实现见如下代码:
package com.hhxy.singleton; /** * 原始懒汉式 * @author ghp * @date 2022/9/17 */ public class Singleton1 { //Step2: 创建一个静态成员变量 private static Singleton1 instance = null; //Step1: 私有化构造器 private Singleton1(){} //Step3: 提供一个get方法 public static Singleton1 getInstance() { if(instance == null){ instance = new Singleton1(); } return instance; } }
-
这种方式是最简但的实现方式,但是存在严重的线程安全问题,主要体现在get方法上。当我们在多线程的场景下使用这种方式,由于线程之间存在一定的时间差,大概率会出现这种情况:当Thread1进入if后,还没有执行
instance = new Singleton1();
语句,Thread2也跟着进入了if语句中来了,这就导致单例模式的失败!显然在今这种高并发时代,这种场景是十分常见的!口说无凭,上代码:
测试代码:
/**
* 用于创建线程的内部类
*/
class MyThreadInner{
/**
* 不断调用单例类的get方法,获取单例对象
*/
public void createInstance(){
new Thread(){
@Override
public void run() {
System.out.println(Singleton1.getInstance().hashCode());
}
}.start();
}
}
/**
* 测试对象;原始懒汉单例模式
* 测试目标:原始懒汉单例模式是否存在线程安全
* 测试结果:存在线程安全
*/
@Test
public void Singleton1Test() {
SingleTest.MyThreadInner myThreadInner = new SingleTest().new MyThreadInner();
myThreadInner.createInstance();
myThreadInner.createInstance();
myThreadInner.createInstance();
}
测试结果如下:
在使用单元测试时遇到一个小bug,具体请参考:使用单元测试测试多线程时无输出问题的解决方案(强烈推荐阅读)
🐳使用线程锁
由于原始的懒汉式存在严重的线程安全问题,所以我们对其进行改进:通过给get方法添加一个
synchronized
修饰,让其变成一个同步方法,这样就能很好地避免线程安全问题了😃
-
使用线程锁解决的问题:线程安全问题
-
使用线程锁的缺点:性能较低,因为每次调用get方法,都要上锁、解锁很大程度上消耗了时间😔
-
使用线程锁的懒汉式的使用场景:适用于对效率要求不高,但极为看重线程安全的系统\软件,推荐使用
-
使用线程锁的懒汉式的实现步骤:
-
Step1:创建一个类,使用将构造器私有化
目的:让外部不能通过构造器获取该类的实例化对象,从而保障实例化对象的唯一性
-
Step2:创建一个静态成员变量
目的:用于存储创建的单例对象
-
Step3:提供一个公开的使用
static
和synchronized
修饰的get方法目的:用于外部获取获取单例对象,get方法需要使用
static
修饰是因为单例类已经将构造器私有化了,外部无法通过实例化对象来调用get方法,使用synchronized
修饰是为了保障线程安全
具体实现见如下代码:
package com.hhxy.singleton; /** * * @author ghp * @date 2022/9/17 */ public class Singleton2 { //Step2: 创建一个静态成员变量 private static Singleton2 instance = null; //Step1: 私有化构造器 private Singleton2(){} //Step3: 提供一个get方法 public static synchronized Singleton2 getInstance() { if(instance == null){ instance = new Singleton2(); } return instance; } }
-
关于线程安全的测试:
/**
* 用于创建线程的内部类
*/
class MyThreadInner{
/**
* 不断调用单例类的get方法,获取单例对象
*/
public void createInstance(){
new Thread(){
@Override
public void run() {
System.out.println(Singleton2.getInstance().hashCode());
}
}.start();
}
}
/**
* 测试对象:使用线程锁的原始懒汉式
* 测试目标:测试使用线程锁的原始懒汉式是否存在线程安全
* 测试结果:线程安全
*/
@Test
public void Singleton2Test() {
SingleTest.MyThreadInner myThreadInner = new SingleTest().new MyThreadInner();
myThreadInner.createInstance();
myThreadInner.createInstance();
myThreadInner.createInstance();
myThreadInner.createInstance();
myThreadInner.createInstance();
myThreadInner.createInstance();
}
测试结果:
🐳登记式/静态内部类
前面我们使用线程锁虽然解决了线程安全问题,但是却让程序的性能较低,为了解决这一问题,我们使用静态内部类方式对其改进。
主要实现方式:静态内部类
-
登记式/静态内部类解决的问题:性能较低的问题
-
登记式/静态内部类的缺点:无法解决传参问题,无法防止序列化攻击,由于静态内部类的形式创建单例,故而无法传递参数进去,例如Contxt这种参数
-
登记式/静态内部类的使用场景:不需要考虑传参,但是需要考虑线程安全、性能
-
登记式/静态内部类的实现步骤:
-
Step1:创建一个类,将构造器私有化
目的:让外部不能通过构造器获取该类的实例化对象,从而保障实例化对象的唯一性
-
Step2:创建一个静态内部类,在静态内部类中获取单例对象
目的:使用static修饰内部类,是为了不使用静态内部类对象获取单例对象,避免创建对象导致资源浪费;至于为什么在静态内部类中获取单例对象,这是为了保障懒加载
-
Step3:提供一个公开的使用
static
和final
修饰的get方法目的:使用
final
修饰只是为了让方法不被重写(其实这里也可以不使用final修饰,也是有效果的,使用final修饰,会显得你更加专业),使用static
修饰,构造器已经私有化了,外部无法通过对象访问get方法
具体实现代码如下所示:
package com.hhxy.singleton; /** * 登记式/静态内部类 * @author ghp * @date 2022/9/17 */ public class Singleton4 { //Step2: 创建一个静态内部类,在静态内部类中获取单例对象 private static class Singleton4Holder{ private static final Singleton4 INSTANCE = new Singleton4(); } //Step1: 私有化构造器 private Singleton4(){} //Step3: 提供一个get方法 public static final Singleton4 getInstance(){ return Singleton4Holder.INSTANCE; } }
测试代码:
/** * 测试序列化、反序列化攻击 */ public class Test{ public static void main(String[] args) throws Exception{ System.out.println(Singleton4.getInstance().hashCode()); Singleton4 singleton4 = Singleton4.getInstance(); System.out.println(singleton4.hashCode()); //通过writeObject将对象进行序列化,然后存储到文件中 ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("tempFile.txt")); oos.writeObject(singleton4); //读取文件中的数据,然后进行反序列化,将数据存储到对象中 ObjectInputStream ois = new ObjectInputStream(new FileInputStream("tempFile.txt")); Singleton4 singleton41 = (Singleton4) ois.readObject(); System.out.println(singleton41.hashCode()); } }
注意事项:使用序列化和反序列化的前提,是必须让Singelton4实现序列化接口,否则会抛异常
知识拓展:
可以在Singleton4中添加一个
readResolve
方法,从而防止序列化反序列化攻击备注:这种方式适用于所有的无法防止序列化反序列化攻击的单例模式,前提是必须让单例类实现序列化接口
//实现readResolve方法可以解决反序列化攻击,反序列化时会检查有没有这个方法,如果有可以调用这个方法返回对象 private Object readResolve() { return Singleton4Holder.INSTANCE; }
-
🐳双检锁/双重校验锁
双检锁/双重校验锁(DCL,double checked locking)不仅效率高,而且线程安全,同时还能够解决传参问题,可以说是懒汉式中最完美的一种方案了,推荐使用😃
双检验所核心实现:
volatile
和synchronized
两个关键字的使用。
-
双检锁/双重校验锁解决的问题:无法给单例对象进行传参、序列化攻击
-
双检锁/双重校验锁的缺点:无法防止反射攻击
-
双检锁/双重校验锁解决的使用场景:JDK版本大于1.5(因为volatile)
-
双检锁/双重校验锁解决的实现步骤:
-
Step1:创建一个类,将构造器私有化
目的:让外部不能通过构造器获取该类的实例化对象,从而保障实例化对象的唯一性
-
Step2:创建一个使用
volatile
和static
修饰的成员变量目的:存储创建的单例对象,使用
volatile
修饰的原因后续详细解说,至于使用static
修饰,是因为我们的单例对象是在静态方法中创建的,静态方法中的变量都是静态变量,必须要使用静态变量来存储静态变量 -
Step3:提供一个公开的、使用static修饰的get方法
目的:为了让外部能够获取单例对象,至于使用static修饰,已经说过很多遍了,在此不在赘述
具体实现代码如下所示:
package com.hhxy.singleton; /** * 双重锁/双重校验锁 * @author ghp * @date 2022/9/17 */ public class Singleton5 { //Step2: 创建一个使用volatile和static修饰的成员变量 private volatile static Singleton5 instance; //Step1: 将构造器私有化 private Singleton5(){} //Step3: 提供一个get方法 public static Singleton5 getInstance() { //第一层锁,提高效率 if(instance == null){ //第二层锁,提高线程安全 synchronized (Singleton5.class){ instance = new Singleton5(); } } return instance; } }
测试代码:
/** * 使用反射攻击双重校验锁的单例对象 */ public class Test { public static void main(String[] args) { System.out.println(Singleton5.getInstance().hashCode()); //1、获取类对象 Class cls = Singleton5.class; Constructor constructor = null; try { //2、获取构造器对象 constructor = cls.getDeclaredConstructor(); //3、暴力反射,无视private修饰 constructor.setAccessible(true); //4、使用构造器对象重新获取一个单例对象 Singleton5 singleton5 = (Singleton5) constructor.newInstance(); System.out.println(singleton5.hashCode()); } catch (Exception e) { throw new RuntimeException(e); } } }
测试结果:
-
-
为什么需要使用
volatile
修饰成员变量?使用new创建对象,一般在JVM中可以分成3步执行:
- 在堆中开辟对象所需空间,分配地址
- 根据类加载的初始化顺序进行初始化
- 将堆中的内存地址返回给栈中的引用变量
由于 Java 内存模型允许“无序写入”,JVM为了提高性能,可能会把上述步骤中的 2 和 3 进行重排序。如果不使用
volatile
修饰,可能出现2,3重排,在多线程下就会产生多个不同的实例
🍀饿汉式
🐳原始饿汉式
原始饿汉式的核心实现是利用
static
关键字的特性。备注:一般而言,原始饿汉模式我们都是直接称作饿汉模式,这里是博主为了区分他和其他饿汉式的区别,就将它命名为原始饿汉式
主要有两种写法:
方式一:在成员变量初始化时进行创建对象。这种方式利用了类加载机制,当类被加载时,类的成员变量会被初始化到内存中,
后续除非调用set方法,否则成员变量的值是不会发生改变的
方式二:在静态代码块中创建对象。这种方式利用了静态代码块的特点,静态代码块在类被加载时,会被执行且只会被执行一次,然后将静态代码块中的数据加载到内存中,后续访问会直接访问前面加载到内存的数据
-
原始饿汉式的缺点:无法防止序列化攻击、反射攻击
-
原始饿汉式的使用场景:需要线程安全和高效率,内存充足,推荐使用
-
原始饿汉式的实现步骤:
-
方式一:
-
Step1:创建一个类,将构造器私有化
-
Step2:使用一个static成员变量,用于获取单例对象
目的:使用static修饰,是为了能够让静态方法访问
-
Step3:提供一个公共的,使用static修饰的get方法
目的:使用静态方法,是因为我们将类的构造器私有化了,外部无法通过
对象.方法名
调用get方法,而静态方法可以使用类名.方法名
调用get方法
public class Singleton3 { //Step2: 使用静态成员变量获取单例对象(也可以将赋值操作放在构造器中) private static Singleton3 instance = new Singleton3(); //Step1: 将构造器私有化 private Singleton3(){} //Step: 提供一个get方法 public static Singleton3 getInstance() { return instance; } }
-
-
方式二:(一般常用这种方式读取配置文件)
- Step1:创建一个类,将构造器私有化
- Step2:创建一个静态成员变量,用来存储静态代码块创建的单例对象
- Step3:在静态代码块中创建单例对象
- Step4:提供一个
static
修饰的get方法
public class Singleton3{ //Step2: 创建一个静态成员变量 private static Singleton3 instance = null; //Step1: 将构造器私有化 private Singleton3(){} //Step3: 在静态代码块中创建单例对象 static { instance = new Singleton3(); } //Step4: 提供一个get方法 public static Singleton3 getInstance() { return instance; } }
-
知识拓展:类的加载时机
- 创建类的实例对象时,类会被加载
- 创建子类实例对象时,父类会被加载
- 执行类中的main方法时,类会被加载
- 调用类的静态方法或静态成员变量时,类会被加载
🐳使用枚举类
这种方式是Java之母 Josh Bloch 提倡的方式,它不仅能避免多线程同步问题,而且还自动支持序列化机制,防止反序列化重新创建新的对象,同时还能防止反射攻击,堪称是最为安全的单例模式,唯一的瑕疵就是会浪费内存。
-
使用枚举类解决的问题:防止序列化攻击、反射攻击
-
使用场景:对于安全要求很严格的系统,同时JDK要求是1.5以上的版本
-
实现步骤:
-
创建自己的单例对象:
public enum Singleton6 { //Step1: 创建一个Singleton6对象 INSTANCE; //通过 类名.INSTANCE获取Singleton6对象,然后就能调用该方法了 public void say(){ System.out.println("我是枚举类创建的对象"); } }
-
创建其他类的单例对象(一般都是这么使用的):
public enum SingletonEnum { //其他类通过 SingletonEnum.INSTANCE.getObj()获取单例对象 INSTANCE; private Object obj = null; private SingeltonEnum(){ obj = new Object(); } public Object getObj(){ return obj; } }
-
🌲总结
总的来讲,用的最多的还是【登记式】和【原始饿汉式】,最推荐使用的的是【双重检验锁】和 【使用枚举类实现的饿汉式】,至于这6种单例模式的具体实用场景,读者可以依据它们各自的特点进行选择(●ˇ∀ˇ●)
此致,文章就结束了,如果觉得本文对你有一丢丢帮助的话😄,欢迎点赞👍+评论✍,您的支持将是我写出更加优秀文章的动力O(∩_∩)O
上一篇:设计模式导学
下一篇:每日一个设计模式之【原型模式】
参考文章:
- 【源码分析设计模式 1】JDK中的单例模式
- 双检锁/双重校验锁(DCL,即 double-checked locking)详细解析
- Java单例7种测试实践
- Volatile关键字的作用
- 单例模式的攻击之序列化与反序列化
- 单例模式中的反射攻击和反序列化问题
在此致谢