一 单例模式概述

(一) 什么是单例模式

单例模式属于创建型模式之一,它提供了一种创建对象的最佳方式

在软件工程中,创建型模式是处理对象创建的设计模式,试图根据实际情况使用合适的方式创建对象。基本的对象创建方式可能会导致设计上的问题,或增加设计的复杂度。创建型模式通过以某种方式控制对象的创建来解决问题。

因为我们平时虽然可以定义一个全局变量使一个对象被访问,但是它并不能保证你多次实例化对象,最直观的,多次创建对象的代价就是消耗性能,导致效率会低一些。单例模式就是用来解决这些问题

顺便提一个很常见的例子:例如在 Win 系的电脑下我们永远只能打开一个任务管理器,这样可以避免出现一些资源浪费,以及多窗口显示数据不一致的问题

定义:单例模式,保证一个类仅有一个实例,并且提供一个访问它的全局访问点

(二) 特点

  • ① 单例类只能有一个实例对象

  • ② 单例类必须自己创建自己的唯一实例

  • ③ 单例类必须对外提供一个访问该实例的方法

(三) 优缺点以及使用场景

(1) 优点

  • 提供了对唯一实例的受控访问

  • 保证了内存中只有唯一实例,减少了内存的开销

    • 尤其表现在一些需要多次创建销毁实例的情况下
  • 避免对资源的多重占用

    • 比如对文件的写操作

(2) 缺点

  • 单例模式中没有抽象层,没有接口,不能继承,扩展困难,扩展需要修改原来的代码,违背了 “开闭原则”
  • 单例类的代码一般写在同一个类中,一定程度上职责过重,违背了 “单一职责原则”

(3) 应用场景

先说几个大家常见单例的例子:

  • Windows 下的任务管理器和回收站,都是典型的单例模式,你可以试一下,没法同时打开两个的哈

  • 数据库连接池的设计一般也是单例模式,因为频繁的打开关闭与数据库的连接,会有不小的效率损耗

    • 但是滥用单例也可能带来一些问题,例如导致共享连接池对象的程序过多而出现连接池溢出
  • 网站计数器,通过单例解决同步问题

  • 操作系统的文件系统

  • Web 应用的配置对象读取,因为配置文件属于共享的资源

  • 程序的日志应用,一般也是单例,否则追加内容时,容易出问题

所以,根据一些常见的例子,简单总结一下,什么时候用单例模式呢?

  • ① 需要频繁创建销毁实例的
  • ② 实例创建时,消耗资源过多,或者耗时较多的,例如数据连接或者IO
  • ③ 某个类只要求生成一个类的情况,例如生成唯一序列号,或者人的身份证
  • ④ 对象需要共享的情况,如 Web 中配置对象

二 实现单例模式

根据单例模式的定义和特点,我们可以分为三步来实现最基本的单例模式

  • ① 构造函数私有化
  • ② 在类的内部创建实例
  • ③ 提供本类实例的唯一全局访问点,即提供获取唯一实例的方法

(一) 饿汉式

我们就按照最基本的这三点来写

public class Hungry {
// 构造器私有,静止外部new
private Hungry(){} // 在类的内部创建自己的实例
private static Hungry hungry = new Hungry(); // 获取本类实例的唯一全局访问点
public static Hungry getHungry(){
return hungry;
}
}

这种做法一开始就直接创建这个实例,我们也称为饿汉式单例,但是如果这个实例一直没有被调用,会造成内存的浪费,显然这样做是不合适的

(二) 懒汉式

饿汉式的主要问题在于,一开始就创建实例导致的内存浪费问题,那么我们将创建对象的步骤,挪到具体使用的时候

public class Lazy1 {
// 构造器私有,静止外部new
private Lazy1(){
System.out.println(Thread.currentThread().getName() + " 访问到了");
} // 定义即可,不真正创建
private static Lazy1 lazy1 = null; // 获取本类实例的唯一全局访问点
public static Lazy1 getLazy1(){
// 如果实例不存在则new一个新的实例,否则返回现有的实例
if (lazy1 == null) {
lazy1 = new Lazy1();
}
return lazy1;
} public static void main(String[] args) {
// 多线程访问,看看会有什么问题
for (int i = 0; i < 10; i++) {
new Thread(()->{
Lazy1.getLazy1();
}).start();
}
}
}

例如上述代码,我们只在刚开始做了一个定义,真正的实例化是在调用 getLazy1() 时被执行

单线程环境下是没有问题的,但是多线程的情况下就会出现问题,例如下面是我运行结果中的一次:

Thread-0 访问到了
Thread-4 访问到了
Thread-1 访问到了
Thread-3 访问到了
Thread-2 访问到了

(三) DCL 懒汉式

(1) 方法上直接加锁

很显然,多线程下的普通懒汉式出现了问题,这个时候,我们只需要加一层锁就可以解决

简单的做法就是在方法前加上 synchronized 关键字

public static synchronized Lazy1 getLazy1(){
if (lazy1 == null) {
lazy1 = new Lazy1();
}
return lazy1;
}

(2) 缩小锁的范围

但是我们又想缩小锁的范围,毕竟方法上加锁,多线程中效率会低一些,所以只把锁加到需要的代码上

我们直观的可能会这样写

public static Lazy1 getLazy1(){
if (lazy1 == null) {
synchronized(Lazy1.class){
lazy1 = new Lazy1();
}
}
return lazy1;
}

但是这样还是有问题的

(3) 双重锁定

当线程 A 和 B 同时访问getLazy1(),执行到到 if (lazy1 == null) 这句的时候,同时判断出 lazy1 == null,也就同时进入了 if 代码块中,后面因为加了锁,只有一个能先执行实例化的操作,例如 A 先进入,但是 后面的 B 进入后同样也可以创建新的实例,就达不到单例的目的了,不信可以自己试一下

解决的方式就是再进行第二次的判断

// 获取本类实例的唯一全局访问点
public static Lazy1 getLazy1(){
// 如果实例不存在则new一个新的实例,否则返回现有的实例
if (lazy1 == null) {
// 加锁
synchronized(Lazy1.class){
// 第二次判断是否为null
if (lazy1 == null){
lazy1 = new Lazy1();
}
}
}
return lazy1;
}

(4) 指令重排问题

这种在适当位置加锁的方式,尽可能的降低了加锁对于性能的影响,也能达到预期效果

但是这段代码,在一定条件下还是会有问题,那就是指令重排问题

指令重排序是JVM为了优化指令,提高程序运行效率,在不影响单线程程序执行结果的前提下,尽可能地提高并行度。

什么意思呢?

首先要知道 lazy1 = new Lazy1(); 这一步并不是一个原子性操作,也就是说这个操作会分成很多步

  • ① 分配对象的内存空间
  • ② 执行构造函数,初始化对象
  • ③ 指向对象到刚分配的内存空间

但是 JVM 为了效率对这个步骤进行了重排序,例如这样:

  • ① 分配对象的内存空间
  • ③ 指向对象到刚分配的内存空间,对象还没被初始化
  • ② 执行构造函数,初始化对象

按照 ① ③ ② 的顺序,当 A 线程执行到 ② 后,B线程判断 lazy1 != null ,但是此时的 lazy1 还没有被初始化,所以会出问题,并且这个过程中 B 根本执行到锁那里,配个表格说明一下:

Time ThreadA ThreadB
t1 A:① 分配对象的内存空间
t2 A:③ 指向对象到刚分配的内存空间,对象还没被初始化
t3 B:判断 lazy1 是否为 null
t4 B:判断到 lazy1 != null,返回了一个没被初始化的对象
t5 A:② 初始化对象

解决的方法很简单——在定义时增加 volatile 关键字,避免指令重排

(5) 最终代码

最终代码如下:

public class Lazy1 {
// 构造器私有,静止外部new
private Lazy1(){
System.out.println(Thread.currentThread().getName() + " 访问到了");
} // 定义即可,不真正创建
private static volatile Lazy1 lazy1 = null; // 获取本类实例的唯一全局访问点
public static Lazy1 getLazy1(){
// 如果实例不存在则new一个新的实例,否则返回现有的实例
if (lazy1 == null) {
// 加锁
synchronized(Lazy1.class){
// 第二次判断是否为null
if (lazy1 == null){
lazy1 = new Lazy1();
}
}
}
return lazy1;
} public static void main(String[] args) {
// 多线程访问,看看会有什么问题
for (int i = 0; i < 10; i++) {
new Thread(()->{
Lazy1.getLazy1();
}).start();
}
}
}

(四) 静态内部类懒汉式

双重锁定算是一种可行不错的方式,而静态内部类就是一种更加好的方法,不仅速度较快,还保证了线程安全,先看代码

public class Lazy2 {
// 构造器私有,静止外部new
private Lazy2(){
System.out.println(Thread.currentThread().getName() + " 访问到了");
} // 用来获取对象
public static Lazy2 getLazy2(){
return InnerClass.lazy2;
} // 创建内部类
public static class InnerClass {
// 创建单例对象
private static Lazy2 lazy2 = new Lazy2();
} public static void main(String[] args) {
// 多线程访问,看看会有什么问题
for (int i = 0; i < 10; i++) {
new Thread(()->{
Lazy2.getLazy2();
}).start();
}
}
}

上面的代码,首先 InnerClass 是一个内部类,其在初始化时是不会被加载的,当用户执行了 getLazy2() 方法才会加载,同时创建单例对象,所以他也是懒汉式的方法,因为 InnerClass 是一个静态内部类,所以只会被实例化一次,从而达到线程安全,因为并没有加锁,所以性能上也会很快,所以一般是推荐的

(五) 枚举方式

最后推荐一个非常好的方式,那就是枚举单例方式,其不仅简单,且保证了安全,先看一下 《Effective Java》中作者的说明:

这种方法在功能上与公有域方法相似,但更加简洁无偿地提供了序列化机制,绝对防止多次实例化。即使是在面对复杂的序列化或者反射攻击的时候。虽然这种方法还没有广泛采用,但是单元素的枚举类型经常成为实现Singleton 的最佳方法,注意,如果 Singleton 必须扩展一个超类,而不是扩展 enum 时则不宜使用这个方法,(虽然可以声明枚举去实现接口)。

节选自 《Effective Java》第3条:用私有构造器或者枚举类型强化 Singleton 属性

原著:Item3: Enforce the singleton property with a private constructor or an enum

代码就这样,简直不要太简单,访问通过 EnumSingle.IDEAL 就可以访问了

public enum EnumSingle {
IDEAL;
}

我们接下来就要给大家演示为什么枚举是一种比较安全的方式

三 反射破坏单例模式

(一) 单例是如何被破坏的

下面用双重锁定的懒汉式单例演示一下,这是我们原来的写法,new 两个实例出来,输出一下

public class Lazy1 {
// 构造器私有,静止外部new
private Lazy1(){
System.out.println(Thread.currentThread().getName() + " 访问到了");
} // 定义即可,不真正创建
private static volatile Lazy1 lazy1 = null; // 获取本类实例的唯一全局访问点
public static Lazy1 getLazy1(){
// 如果实例不存在则new一个新的实例,否则返回现有的实例
if (lazy1 == null) {
// 加锁
synchronized(Lazy1.class){
// 第二次判断是否为null
if (lazy1 == null){
lazy1 = new Lazy1();
}
}
}
return lazy1;
} public static void main(String[] args) { Lazy1 lazy1 = getLazy1();
Lazy1 lazy2 = getLazy1();
System.out.println(lazy1);
System.out.println(lazy2); }
}

运行结果:

main 访问到了

cn.ideal.single.Lazy1@1b6d3586

cn.ideal.single.Lazy1@1b6d3586

可以看到,结果是单例没有问题

(1) 一个普通实例化,一个反射实例化

但是我们如果通过反射的方式进行实例化类,会有什么问题呢?

public static void main(String[] args) throws Exception {
Lazy1 lazy1 = getLazy1();
// 获得其空参构造器
Constructor<Lazy1> declaredConstructor = Lazy1.class.getDeclaredConstructor(null);
// 使得可操作性该 declaredConstructor 对象
declaredConstructor.setAccessible(true);
// 反射实例化
Lazy1 lazy2 = declaredConstructor.newInstance();
System.out.println(lazy1);
System.out.println(lazy2);
}

getDeclaredConstructor() 方法说明

方法返回一个Constructor对象,它反映此Class对象所表示的类或接口指定的构造函数。parameterTypesparameter是确定构造函数的形参类型,在Class对象声明顺序的数组。

public Constructor getDeclaredConstructor(Class<?>... parameterTypes) throws NoSuchMethodException, SecurityException

运行结果:

main 访问到了

main 访问到了

cn.ideal.single.Lazy1@1b6d3586

cn.ideal.single.Lazy1@4554617c

可以看到,单例被破坏了

解决办法:因为我们反射走的其无参构造,所以在无参构造中再次进行非null判断,加上原来的双重锁定,现在也就有三次判断了

// 构造器私有,静止外部new
private Lazy1(){
synchronized (Lazy1.class){
if(lazy1 != null) {
throw new RuntimeException("反射破坏单例异常");
}
}
}

不过结果也没让人失望,这种测试下,第二次实例化会直接报异常

(2) 两个都是反射实例化

如果两个都是反射实例化出来的,也就是说,根本就不去调用 getLazy1() 方法,那可怎么办?

如下:

public static void main(String[] args) throws Exception {

    // 获得其空参构造器
Constructor<Lazy1> declaredConstructor = Lazy1.class.getDeclaredConstructor(null);
// 使得可操作性该 declaredConstructor 对象
declaredConstructor.setAccessible(true);
// 反射实例化
Lazy1 lazy1 = declaredConstructor.newInstance();
Lazy1 lazy2 = declaredConstructor.newInstance(); System.out.println(lazy1);
System.out.println(lazy2);
}

运行结果:

main 访问到了

main 访问到了

cn.ideal.single.Lazy1@1b6d3586

cn.ideal.single.Lazy1@4554617c

单例又被破坏了

解决方案:增加一个标识位,例如下文通过增加一个布尔类型的 ideal 标识,保证只会执行一次,更安全的做法,可以进行加密处理,保证其安全性

// 构造器私有,静止外部new
private Lazy1(){
synchronized (Lazy1.class){
if (ideal == false){
ideal = true;
} else {
throw new RuntimeException("反射破坏单例异常");
}
}
System.out.println(Thread.currentThread().getName() + " 访问到了");
}

这样就没问题了吗,并不是,一旦别人通过一些手段得到了这个标识内容,那么他就可以通过修改这个标识继续破坏单例,代码如下(这个把代码贴全一点,前面都是节选关键的,都可以参考这个)

public class Lazy1 {

    private static boolean ideal = false;

    // 构造器私有,静止外部new
private Lazy1(){
synchronized (Lazy1.class){
if (ideal == false){
ideal = true;
} else {
throw new RuntimeException("反射破坏单例异常");
}
}
System.out.println(Thread.currentThread().getName() + " 访问到了");
} // 定义即可,不真正创建
private static volatile Lazy1 lazy1 = null; // 获取本类实例的唯一全局访问点
public static Lazy1 getLazy1(){
// 如果实例不存在则new一个新的实例,否则返回现有的实例
if (lazy1 == null) {
// 加锁
synchronized(Lazy1.class){
// 第二次判断是否为null
if (lazy1 == null){
lazy1 = new Lazy1();
}
}
}
return lazy1;
} public static void main(String[] args) throws Exception { Field ideal = Lazy1.class.getDeclaredField("ideal");
ideal.setAccessible(true); // 获得其空参构造器
Constructor<Lazy1> declaredConstructor = Lazy1.class.getDeclaredConstructor(null);
// 使得可操作性该 declaredConstructor 对象
declaredConstructor.setAccessible(true);
// 反射实例化
Lazy1 lazy1 = declaredConstructor.newInstance();
ideal.set(lazy1,false);
Lazy1 lazy2 = declaredConstructor.newInstance(); System.out.println(lazy1);
System.out.println(lazy2); }
}

运行结果:

main 访问到了

main 访问到了

cn.ideal.single.Lazy1@4554617c

cn.ideal.single.Lazy1@74a14482

实例化 lazy1 后,其执行了修改 ideal 这个布尔值为 false,从而绕过了判断,再次破坏了单例

所以,可以得出,这几种方式都是不安全的,都有着被反射破坏的风险

(二) 枚举类不会被破坏

上面在讲解枚举单例方式的时候就提过《Effective Java》中提到,即使是在面对复杂的序列化或者反射攻击的时候,(枚举单例方式)绝对防止多次实例化,下面来看一下是不是这样:

首先说一个前提条件:这是 Constructor 下的 newInstance 方法节选,也就是说遇到枚举时,会报异常,也就是不允许通过反射创建枚举

if ((clazz.getModifiers() & Modifier.ENUM) != 0)
throw new IllegalArgumentException("Cannot reflectively create enum objects");

看一下我们枚举单例类 EnumSingle 生成的字节码文件,可以看到其中有一个无参构造,也就是说,我们还是只需要拿到 getDeclaredConstructor(null) 就行了

代码如下:

public enum EnumSingle {
IDEAL; public static void main(String[] args) throws Exception {
EnumSingle ideal1 = EnumSingle.IDEAL;
// 获得其空参构造器
Constructor<EnumSingle> declaredConstructor = EnumSingle.class.getDeclaredConstructor(null);
// 使得可操作性该 declaredConstructor 对象
declaredConstructor.setAccessible(true);
// 反射实例化
EnumSingle ideal2 = declaredConstructor.newInstance();
System.out.println(ideal1);
System.out.println(ideal2);
}
}

运行结果却是出人意料:

提示竟然是找不到这个空参???字节码中可是却是存在的啊

Exception in thread "main" java.lang.NoSuchMethodException: cn.ideal.single.EnumSingle.<init>()

自己 javap 反编译一下,可以看到还是有这个空参

换成 jad 再看看(将 jad.exe 放在字节码文件同目录下)

  • 执行:jad -sjava EnumSingle.class

提示已经反编译结束:Parsing EnumSingle.class... Generating EnumSingle.java

打开生成的 java 文件,终于发现,原来它是一个带参构造,同时有两个参数,String 和 int

所以下面,我们只需要修改原来的无参为有参即可:

public enum EnumSingle {
IDEAL; public static void main(String[] args) throws Exception {
EnumSingle ideal1 = EnumSingle.IDEAL;
Constructor<EnumSingle> declaredConstructor = EnumSingle.class.getDeclaredConstructor(String.class, int.class);
// 使得可操作性该 declaredConstructor 对象
declaredConstructor.setAccessible(true);
// 反射实例化
EnumSingle ideal2 = declaredConstructor.newInstance();
System.out.println(ideal1);
System.out.println(ideal2);
}
}

这样就没问题了,提示了我们想要的错误:Cannot reflectively create enum objects

这也说明,枚举类的单例模式写法确实不会被反射破坏!

四 结尾

如果文章中有什么不足,欢迎大家留言交流,感谢朋友们的支持!

如果能帮到你的话,那就来关注我吧!如果您更喜欢微信文章的阅读方式,可以关注我的公众号

在这里的我们素不相识,却都在为了自己的梦而努力

一个坚持推送原创开发技术文章的公众号:理想二旬不止

单例模式的几种实现And反射对其的破坏的更多相关文章

  1. java设计模式之单例模式(几种写法及比较)

    概念: Java中单例模式是一种常见的设计模式,单例模式的写法有好几种,这里主要介绍三种:懒汉式单例.饿汉式单例.登记式单例. 单例模式有以下特点: 1.单例类只能有一个实例. 2.单例类必须自己创建 ...

  2. java单例模式的几种写法比较

    概念: Java中单例模式是一种常见的设计模式,单例模式的写法有好几种,这里主要介绍三种:懒汉式单例.饿汉式单例.登记式单例. 单例模式有以下特点: 1.单例类只能有一个实例. 2.单例类必须自己创建 ...

  3. java 单例模式的几种写法

    一.懒汉式 public class Singleton{ private static Singleton instance = null; private Singleton(){} public ...

  4. JAVA中单例模式的几种实现方式

    1 线程不安全的实现方法 首先介绍java中最基本的单例模式实现方式,我们可以在一些初级的java书中看到.这种实现方法不是线程安全的,所以在项目实践中如果涉及到线程安全就不会使用这种方式.但是如果不 ...

  5. Python中的单例模式的几种实现方式的优缺点及优化

    单例模式 单例模式(Singleton Pattern)是一种常用的软件设计模式,该模式的主要目的是确保某一个类只有一个实例存在.当你希望在整个系统中,某个类只能出现一个实例时,单例对象就能派上用场. ...

  6. python实现单例模式的三种方式及相关知识解释

    python实现单例模式的三种方式及相关知识解释 模块模式 装饰器模式 父类重写new继承 单例模式作为最常用的设计模式,在面试中很可能遇到要求手写.从最近的学习python的经验而言,singlet ...

  7. day29单例模式的4种实现模式

    单例模式的四种实现模式单例模式实现方式一: import settings class MySQL:  __instance=None  def __init__(self, ip, port):   ...

  8. Python中的单例模式的几种实现方式的及优化

    单例模式 单例模式(Singleton Pattern)是一种常用的软件设计模式,该模式的主要目的是确保某一个类只有一个实例存在.当你希望在整个系统中,某个类只能出现一个实例时,单例对象就能派上用场. ...

  9. Java设计模式之单例模式(七种写法)

    Java设计模式之单例模式(七种写法) 第一种,懒汉式,lazy初始化,线程不安全,多线程中无法工作: public class Singleton { private static Singleto ...

随机推荐

  1. Go 安装介绍

    Go介绍 Go语言被誉为21世纪的C语言,由Google公司开发,天生对高并发有着优秀的支持.并且语法极度简洁,关键字仅有25个. 所以使用Go语言时你不用担心自己写的和大神写的有着天差地别,Go语言 ...

  2. CentOS7 【linux系统】配置 JDK 教程

    1. 下载 [linux版本] JDK 1.8 的包. 2. 导入linux系统里面. 如何导入,下载一个winSCP 软件 破解安装,然后再linux 系统里面 查询IP,连接即可. 在linux解 ...

  3. vue安装教程

    Vue.js 安装教程 安装node.js https://nodejs.org/zh-cn/download/ 选择一个适合自己电脑的版本下载 下载成功, 直接安装, 全部点击下一步 然后输入 黑窗 ...

  4. 使用Ajax新闻系统管理需求分析

      新闻系统管理需求分析 1.1项目背景 新闻发布系统(News Release System or Content Management System),是一个基于新闻和内容管理的全站管理系统,本系 ...

  5. Bayer Pattern——RGGB

    原博客地址:https://blog.csdn.net/joe9280/article/details/46952947 参考:https://blog.csdn.net/wgx571859177/a ...

  6. Activity常用方法

    setContentView(r.layout.xxxx);//设置布局文件 getViewById(r.id.xxxx);//获取指定控件 getString(r.string.xxxx);//获取 ...

  7. JVM 内存分配和占用

    我们从一个简单示例来引出JVM的内存模型 简单示例 我从一个简单示例谈起这一块,我在看一篇文章的时候看到这么一个场景并且自己做了尝试,就是分配一个2M的数组,使用Xmx即最大内存为12M的话,会报错J ...

  8. 多测师讲解接口测试_F12中network里headers各项属性的含义——高级讲师肖sir

    General部分: Request URL:资源的请求url # Request Method:HTTP方法  Status Code:响应状态码  200(状态码) OK 301 - 资源(网页等 ...

  9. 自定义chrome新标签页

    [跳转GitHub] chromeNewTab 自定义chrome新标签页.由于不想发布到chrome应用商店,因此搜了一下不用开发者模式就能用的方法. 使用说明 下载chrome的一个[window ...

  10. errno线程安全性

    errno errno用于获取系统最后一次出错的错误代码.在C++中,errno其实是宏: // windows #define errno (*_errno()) // linux #define ...