竹笋

首页 » 问答 » 环境 » 发布60分钟霸榜Github的阿里单例
TUhjnbcbe - 2023/4/26 19:57:00

本文简单介绍了单例设计模式的几种实现方式,除了枚举单例,其他的所有实现都可以通过反射破坏单例模式,在《effectivejava》中推荐枚举实现单例模式,在实际场景中使用哪一种单例实现,需要根据自己的情况选择,适合当前场景的才是比较好的方式。

一、概述

单例模式是面试中经常会被问到的一个问题,网上有大量的文章介绍单例模式的实现,本文也是参考那些优秀的文章来做一个总结,通过自己在学习过程中的理解进行记录,并补充完善一些内容,一方面巩固自己所学的内容,另一方面希望能对其他同学提供一些帮助。

本文主要从以下几个方面介绍单例模式:

单例模式是什么单例模式的使用场景单例模式的优缺点单例模式的实现(重点)总结二、单例模式是什么

23种设计模式可以分为三大类:创建型模式、行为型模式、结构型模式。单例模式属于创建型模式的一种,单例模式是最简单的设计模式之一:单例模式只涉及一个类,确保在系统中一个类只有一个实例,并提供一个全局访问入口。许多时候整个系统只需要拥有一个全局对象,这样有利于我们协调系统整体的行为。

三、单例模式的使用场景

1、日志类

日志类通常作为单例实现,并在所有应用程序组件中提供全局日志访问点,而无需在每次执行日志操作时创建对象。

2、配置类

将配置类设计为单例实现,比如在某个服务器程序中,该服务器的配置信息存放在一个文件中,这些配置数据由一个单例对象统一读取,然后服务进程中的其他对象再通过这个单例对象获取这些配置信息,这种方式简化了在复杂环境下的配置管理。

3、工厂类

假设我们设计了一个带有工厂的应用程序,以在多线程环境中生成带有ID的新对象(Acount、Customer、Site、Address对象)。如果工厂在2个不同的线程中被实例化两次,那么2个不同的对象可能有2个重叠的id。如果我们将工厂实现为单例,我们就可以避免这个问题,结合抽象工厂或工厂方法和单例设计模式是一种常见的做法。

4、以共享模式访问资源的类

比如网站的计数器,一般也是采用单例模式实现,如果你存在多个计数器,每一个用户的访问都刷新计数器的值,这样的话你的实计数的值是难以同步的。但是如果采用单例模式实现就不会存在这样的问题,而且还可以避免线程安全问题。

5、在Spring中创建的Bean实例默认都是单例模式存在的。

适用场景:

需要生成唯一序列的环境需要频繁实例化然后销毁的对象。创建对象时耗时过多或者耗资源过多,但又经常用到的对象。方便资源相互通信的环境四、单例模式的优缺点

优点:

在内存中只有一个对象,节省内存空间;避免频繁的创建销毁对象,减轻GC工作,同时可以提高性能;避免对共享资源的多重占用,简化访问;为整个系统提供一个全局访问点。缺点:

不适用于变化频繁的对象;滥用单例将带来一些负面问题,如为了节省资源将数据库连接池对象设计为的单例类,可能会导致共享连接池对象的程序过多而出现连接池溢出;如果实例化的对象长时间不被利用,系统会认为该对象是垃圾而被回收,这可能会导致对象状态的丢失;五、单例模式的实现(重点)

实现单例模式的步骤如下:

私有化构造方法,避免外部类通过new创建对象定义一个私有的静态变量持有自己的类型对外提供一个静态的公共方法来获取实例如果实现了序列化接口需要保证反序列化不会重新创建对象1、饿汉式,线程安全

饿汉式单例模式,顾名思义,类一加载就创建对象,这种方式比较常用,但容易产生垃圾对象,浪费内存空间。

优点:线程安全,没有加锁,执行效率较高缺点:不是懒加载,类加载时就初始化,浪费内存空间

懒加载(lazyloading):使用的时候再创建对象

饿汉式单例是如何保证线程安全的呢?它是基于类加载机制避免了多线程的同步问题,但是如果类被不同的类加载器加载就会创建不同的实例。

代码实现,以及使用反射破坏单例:

使用反射破坏单例,代码如下:

输出结果如下:

2、懒汉式,线程不安全

这种方式在单线程下使用没有问题,对于多线程是无法保证单例的,这里列出来是为了和后面使用锁保证线程安全的单例做对比。

优点:懒加载缺点:线程不安全

代码实现如下:

使用多线程破坏单例,测试代码如下:

3、懒汉式,线程安全

懒汉式单例如何保证线程安全呢?通过synchronized关键字加锁保证线程安全,synchronized可以添加在方法上面,也可以添加在代码块上面,这里演示添加在方法上面,存在的问题是每一次调用getInstance获取实例时都需要加锁和释放锁,这样是非常影响性能的。

优点:懒加载,线程安全缺点:效率较低

代码实现

如下:

4、双重检查锁(DCL,即double-checkedlocking)

实现代码如下:

优点:懒加载,线程安全,效率较高缺点:实现较复杂

这里的双重检查是指两次非空判断,锁指的是synchronized加锁,为什么要进行双重判断,其实很简单,第一重判断,如果实例已经存在,那么就不再需要进行同步操作,而是直接返回这个实例,如果没有创建,才会进入同步块,同步块的目的与之前相同,目的是为了防止有多个线程同时调用时,导致生成多个实例,有了同步块,每次只能有一个线程调用访问同步块内容,当第一个抢到锁的调用获取了实例之后,这个实例就会被创建,之后的所有调用都不会进入同步块,直接在第一重判断就返回了单例。

关于内部的第二重空判断的作用,当多个线程一起到达锁位置时,进行锁竞争,其中一个线程获取锁,如果是第一次进入则为null,会进行单例对象的创建,完成后释放锁,其他线程获取锁后就会被空判断拦截,直接返回已创建的单例对象。

其中最关键的一个点就是volatile关键字的使用,关于volatile的详细介绍可以直接搜索volatile关键字即可,有很多写的非常好的文章,这里不做详细介绍,简单说明一下,双重检查锁中使用volatile的两个重要特性:可见性、禁止指令重排序

这里为什么要使用volatile?

这是因为new关键字创建对象不是原子操作,创建一个对象会经历下面的步骤:

在堆内存开辟内存空间调用构造方法,初始化对象引用变量指向堆内存空间对应字节码指令如下:

为了提高性能,编译器和处理器常常会对既定的代码执行顺序进行指令重排序,从源码到最终执行指令会经历如下流程:

所以经过指令重排序之后,创建对象的执行顺序可能为或者,因此当某个线程在乱序运行指令的时候,引用变量指向堆内存空间,这个对象不为null,但是没有初始化,其他线程有可能这个时候进入了getInstance的第一个if(instance==null)判断不为nulll,导致错误使用了没有初始化的非null实例,这样的话就会出现异常,这个就是著名的DCL失效问题。

当我们在引用变量上面添加volatile关键字以后,会通过在创建对象指令的前后添加内存屏障来禁止指令重排序,就可以避免这个问题,而且对volatile修饰的变量的修改对其他任何线程都是可见的。

5、静态内部类

代码实现

如下示例:

优点:懒加载,线程安全,效率较高,实现简单

静态内部类单例是如何实现懒加载的呢?首先,我们先了解下类的加载时机。

虚拟机规范要求有且只有5种情况必须立即对类进行初始化(加载、验证、准备需要在此之前开始):

遇到new、getstatic、putstatic、invokestatic这4条字节码指令时。生成这4条指令最常见的Java代码场景是:使用new关键字实例化对象的时候、读取或设置一个类的静态字段(final修饰除外,被final修饰的静态字段是常量,已在编译期把结果放入常量池)的时候,以及调用一个类的静态方法的时候。使用java.lang.reflect包方法对类进行反射调用的时候。当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。当虚拟机启动时,用户需要指定一个要执行的主类(包含main()的那个类),虚拟机会先初始化这个主类。当使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果是REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,则需要先触发这个方法句柄所对应的类的初始化。这5种情况被称为是类的主动引用,注意,这里《虚拟机规范》中使用的限定词是有且仅有,那么,除此之外的所有引用类都不会对类进行初始化,称为被动引用。静态内部类就属于被动引用的情况。

当getInstance()方法被调用时,SingleTonHoler才在SingleTon的运行时常量池里,把符号引用替换为直接引用,这时静态对象INSTANCE也真正被创建,然后再被getInstance()方法返回出去,这点同饿汉模式。

那么INSTANCE在创建过程中又是如何保证线程安全的呢?在《深入理解JAVA虚拟机》中,有这么一句话:

虚拟机会保证一个类的clinit()方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的clinit()方法,其他线程都需要阻塞等待,直到活动线程执行clinit()方法完毕。如果在一个类的clinit()方法中有耗时很长的操作,就可能造成多个进程阻塞(需要注意的是,其他线程虽然会被阻塞,但如果执行clinit()方法后,其他线程唤醒之后不会再次进入clinit()方法。同一个加载器下,一个类型只会初始化一次。),在实际应用中,这种阻塞往往是很隐蔽的。

从上面的分析可以看出INSTANCE在创建过程中是线程安全的,所以说静态内部类形式的单例可保证线程安全,也能保证单例的唯一性,同时也延迟了单例的实例化。

6、枚举单例

代码实现

如下:

优点:简单,高效,线程安全,可以避免通过反射破坏枚举单例

枚举在java中与普通类一样,都能拥有字段与方法,而且枚举实例创建是线程安全的,在任何情况下,它都是一个单例,可以直接通过如下方式调用获取实例:

使用下面的命令反编译枚举类

得到如下内容

从枚举的反编译结果可以看到,INSTANCE被staticfinal修饰,所以可以通过类名直接调用,并且创建对象的实例是在静态代码块中创建的,因为static类型的属性会在类被加载之后被初始化,当一个Java类第一次被真正使用到的时候静态资源被初始化、Java类的加载和初始化过程都是线程安全的,所以创建一个enum类型是线程安全的。

通过反射破坏枚举,实现代码如下:

运行结果报如下错误:

查看反射创建实例的newInstance()方法,有如下判断:

所以无法通过反射创建枚举的实例。

六、总结

在java中,如果一个Singleton类实现了java.io.Serializable接口,当这个singleton被多次序列化然后反序列化时,就会创建多个Singleton类的实例。为了避免这种情况,应该实现readResolve方法。请参阅javadocs中的Serializable()和readResolveMethod()。

使用单例设计模式需要注意的点:

多线程-在多线程应用程序中必须使用单例时,应特别小心。序列化-当单例实现Serializable接口时,他们必须实现readResolve方法以避免有2个不同的对象。类加载器-如果Singleton类由2个不同的类加载器加载,我们将有2个不同的类,每个类加载一个。由类名表示的全局访问点-使用类名获取单例实例。这是一种访问它的简单方法,但它不是很灵活。如果我们需要替换Sigleton类,代码中的所有引用都应该相应地改变。

1
查看完整版本: 发布60分钟霸榜Github的阿里单例