1 为什么要用单例模式
1.1 什么是单例模式
单例模式就是: 在程序运行期间, 某些类有且最多只有一个实例对象.
我们的应用中可能存在这样的需求: 某些类没有自己的状态, 在程序运行期间它们只需要有一个实例 , 换句话说, 无论为这些类创建多少个实例, 对程序的运行状态、运行结果都不会产生影响.
更重要的一点是: 有些类如果存在两个或者两个以上的实例, 应用程序就会发生某些匪夷所思的错误, 不同于空指针、数组越界、非法参数等错误, 这样的问题一般都很难提前发觉和定位.
这个时候, 我们就应该把这样的类控制为单例结构 —— 确保程序运行期间最多只有一个相对应的实例对象.
关于类的状态的理解:
① 比如有一个 Person 类, 它有成员变量name、age等等, 不同的姓名和年龄就是不同的人, 也就是说这些变量都是不确定的, 这样的类就是有状态的类.
② 而像一些配置类, 比如 RedisProps (Redis的配置信息)类, 它的所有属性和方法都是static的, 没有不确定的属性, 这样的类就可以认为是没有状态的类.
—— 纯属个人看法, 若理解有误, 还请读者朋友们提出, 欢迎批评和交流:grin:
最近整理了一套适合2019年学习的Java\大数据资料,从基础的Java、大数据面向对象到进阶的框架知识都有整理哦,可以来我的主页免费领取哦。
1.2 单例模式的思路和优势
(1) 单例模式的实现思路是:
① 静态化实例对象, 让实例对象与Class对象互相绑定, 通过Class类对象就可以直接访问;
② 私有化构造方法, 禁止通过构造方法创建多个实例 —— 最重要的一步;
③ 提供一个公共的静态方法, 用来返回这个类的唯一实例.
(2) 单例模式的优势:
单例模式的好处是: 尽可能节约内存空间(不用为一个类创建多个实例对象), 减少GC(垃圾回收)的消耗, 并使得程序正常运行.
接下来就详细描述单例模式的6种不同写法.
2 写法① - 饥饿模式
2.1 代码示例
饥饿模式又称为饿汉模式, 指的是JVM在加载类的时候就完成类对象的创建:
/**
* 饥饿模式: 类加载时就初始化
*/finalclassHungrySingleton{/**
* 实例对象
*/privatestaticHungrySingleton instance =newHungrySingleton();/**
* 禁用构造方法
*/privateHungrySingleton() { }/** * 获取单例对象, 直接返回已创建的实例 *@returninstance 本类的实例 */publicstaticHungrySingleton getInstance() {returninstance; }}
2.2 优缺点比较
(1) 优点: JVM层面的线程安全.
JVM在加载这个类的时候就会对它进行初始化, 这里包含对静态变量的初始化;
Java的语义包证了在引用这个字段之前并不会初始化它, 并且访问这个字段的任何线程都将看到初始化这个字段所产生的所有写入操作.
—— 参考自 http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html , 原文如下:
Ifthe singleton you are creatingisstatic(i.e., there will only be oneHelpercreated),asopposedtoapropertyofanotherobject(e.g., there will be oneHelperforeach Fooobject, thereisa simpleandelegant solution.Just define the singletonasastaticfieldina separateclass. The semanticsofJava guarantee that the field willnotbe initializeduntilthe fieldisreferenced,andthat any thread which accesses the field will see allofthe writes resulting from initializing that field.
==> 所以这就在JVM层面包证了线程安全.
(2) 缺点: 造成空间的浪费.
饥饿模式是典型的以空间换时间思想的实现: 不用判断就直接创建, 但创建之后如果不使用这个实例, 就造成了空间的浪费. 虽然只是一个类实例, 但如果是体积比较大的类, 这样的消耗也不容忽视.
—— 不过在有些时候, 直接初始化单例的实例对项目的影响也微乎其微, 比如我们在应用启动时就需要加载的配置文件信息, 就可以采取这种方式去保证单例.
3 写法② - 懒惰模式
3.1 代码示例
懒惰模式又称为懒汉模式, 指的是在真正需要的时候再完成类对象的创建:
/**
* 懒惰模式: 用到时再初始化, 线程不安全, 可以在方法上使用synchronized关键字实现线程安全
*/finalclassLazySingleton{/**
* 实例对象
*/privatestaticLazySingleton instance =null;/**
* 禁用构造方法
*/privateLazySingleton() { }/** * 线程不安全, 可以在方法上使用synchronized关键字实现线程安全 *@returninstance 本类的实例 */publicstaticLazySingleton getInstance() {if(instance ==null) { instance =newLazySingleton(); }returninstance; }}
3.2 优缺点比较
(1) 优点: 节省空间, 用到的时候再创建实例对象.
需要这个实例的时候, 先判断它是否为空, 如果为空, 再创建单例对象.
用到的时候再去创建, 与JVM加载类的思路一致: 都是需要的时候再处理.
(2) 缺点: 线程不安全.
① 在并发获取实例的时候, 线程A调用getInstance(), 在判断 singleton == null 时得到true的结果, 之后进入if语句, 准备创建instance实例;
② 恰好在这个时候, 另一个线程B来了, CPU将执行权切换给了B —— 此时A还没来得及创建出实例, 所以线程B在判断 singleton == null 的时候, 结果还是true, 所以线程B也会进入if语句去创建实例;
③ 问题来了: 两个线程都进入了if语句, 结果就是: 创建了2个实例对象.
3.3 线程是否安全的测试
/**
* 测试懒惰模式的线程安全
*/public staticvoidmain(String[] args) {// 同步的Set, 用来保存创建的实例
Set<String> instanceSet = Collections.synchronizedSet(new HashSet<>());
//创建100个线程, 将每个线程获得的实例添加到Set中for(int i =0; i <100; i++) {newThread(() -> {
instanceSet.add(LazySingleton.getInstance().toString());
}).start(); }for(String instance : instanceSet){System.out.println(instance); }}
(1) 代码说明: 上述循环中的Lambda表达式的作用, 等同于:
newThread(newRunnable() {@Overridepublicvoidrun(){ instanceSet.add(LazySingleton.getInstance().toString()); } }).start();
(2) 输出结果说明: 由于Set集合能够自动去重, 所以如果输出的结果中有2个或2个以上的对象, 就足以说明在并发访问的过程中出现了线程安全问题. 当然如果没有出现的话, 不妨多运行几次, 或者把循环次数调大一点再试试:stuck_out_tongue_winking_eye:
3.4 线程安全的懒惰模式
(1) 通过 synchronized 关键字对获取实例的方法进行同步限制, 实现了线程安全:
/**
* 在获取实例的公共方法上使用synchronized关键字实现线程安全
* @return instance 本类的实例
*/public synchronizedstaticLazySingleton getInstance() {if(instance==null) {instance=newLazySingleton(); }returninstance; }
(2) 优缺点比较:
上面的做法是把整个获取实例的方法同步. 这样一来, 当某个线程访问这个方法时, 其它所有的线程都要处于挂起等待状态.
① 优点: 避免了同步访问创建多个实例的问题;
② 缺点: 很明显, 这样的做法对所有线程的访问都会进行同步操作, 有很严重的性能问题.
4 写法③ - 双重检查锁模式
4.1 代码示例
在上述代码中, 我们不难发现, 其实同步操作只需要发生在实例还未创建的时候, 在实例创建以后, 获取实例的方法就没必要再进行同步控制了.
这个思路就是 双重检查锁(Double Checked Locking, 简称DCL)模式 的实现思路, 是在线程安全的懒惰模式的基础上改进得来的. 下面我们通过代码剖析这种模式:
/**
* 双重检查锁模式: 对线程安全的懒惰模式的改进: 方法上的synchronized在每次调用时都要加锁, 性能太低.
*/finalclassDoubleCheckedLockingSingleton {/**
* 实例对象, 这里还没有添加volatile关键字
*/privatestaticDoubleCheckedLockingSingletoninstance=null;/**
* 禁用构造方法
*/private DoubleCheckedLockingSingleton() { }/**
* 获取对象: 将方法上的synchronized移至内部
* @return instance 本类的实例
*/publicstaticDoubleCheckedLockingSingleton getInstance() {// 先判断实例是否存在if(instance==null) {// 加锁创建实例synchronized (DoubleCheckedLockingSingleton.class) {// 再次判断, 因为可能出现某个线程拿了锁之后, 还没来得及执行初始化就释放了锁,// 而此时其他的线程拿到了锁又执行到此处 ==> 这些线程都会创建一个实例, 从而创建多个实例对象if(instance==null) {instance=newDoubleCheckedLockingSingleton(); } } }returninstance; }}
实现过程中需要注意的事项, 都在注视中作了说明.
4.2 DCL存在的问题
你以为到这里, 单例模式就安全了吗? 不是的!
在多处理器的共享内存、或者编译器的优化下, DCL模式并不一定线程 —— 可能 (注意: 只是可能出现) 会发生指令的重排序, 出现半个对象的问题 .
(1) JVM在创建实例的时候, 是分为如下步骤创建的:
① 在堆内存中, 为新的实例开辟空间;
② 初始化构造器, 对实例中的成员进行初始化;
③ 把这个实例的引用 (也就是这里的instance) 指向①中空间的起始地址.
==> 也就是说, Java中创建一个对象的过程并不是原子性操作 .
(2) 上述过程不是原子性的, 所以就可能出现:
JVM在优化代码的过程中, 可能对①-③这三个过程进行重排序 —— 因为 JVM会对字节码进行优化, 其中就包括了指令的重排序.
如果重排序后变为①③②, 就会出现一些难以捕捉的问题.
(3) 再来说说半个对象:
构造方法中有其他非原子性操作, 创建对象时只是得到了对象的正确引用, 而对象内部的成员变量可能还没有来得及赋值, 这个时候就可能访问到 "不正确(陈旧)" 的成员变量.
对引用类型 (包括对象和数组) 变量的非同步访问, 即使得到该引用的最新值, 也并不能保证能得到其成员变量 (对数组而言就是每个数组中的元素) 的最新值;
4.3 解决方法
在声明对象时通过关键字 volatile , 禁止JVM对这个对象涉及到的代码重排序:
privatestaticvolatileDoubleCheckedLockingSingleton instance =null;
这里我们用 volatile 关键字修饰了 instance 变量, JVM就不会对 instance 的创建过程进行优化, 只要我们访问这个类的任意一个静态域, 就会创建这个类的对象.
关于 volatile 关键字的作用:
volatile 关键字禁止了JVM的指令重排序, 并且保证线程中对这个变量所做的任何写入操作对其他线程都是即时可见的 (也就是保证了内存的可见性).
需要注意的是, 这两个特性是在JDK 5 之后才支持的.
—— 关于类的加载机制、volitale关键字的详细作用, 后续会有播客输出, 读者盆友们可以先去各大博客、论坛搜索研究下, 也可以参考这几篇博客:
The "Double-Checked Locking is Broken" Declaration
5 写法④ - 静态内部类实现单例
5.1 代码示例
静态内部类也称作Singleton Holder, 也就是单持有者模式, 是线程安全的, 也是懒惰模式的变形.
JVM加载类的时候, 有这么几个步骤:
①加载 -> ②验证 -> ③准备 -> ④解析 -> ⑤初始化
需要注意的是: JVM在加载外部类的过程中, 是不会加载静态内部类的, 只有内部类(SingletonHolder)的属性/方法被调用时才会被加载, 并初始化其静态属性(instance) .
/**
* 静态内部类模式, 也称作Singleton Holder(单持有者)模式: 线程安全, 懒惰模式的一种, 用到时再加载
*/finalclassStaticInnerSingleton{/**
* 禁用构造方法
*/privateStaticInnerSingleton() { }/** * 通过静态内部类获取单例对象, 没有加锁, 线程安全, 并发性能高 *@returnSingletonHolder.instance 内部类的实例 */publicstaticStaticInnerSingleton getInstance() {returnSingletonHolder.instance; }/**
* 静态内部类创建单例对象
*/privatestaticclassSingletonHolder{privatestaticStaticInnerSingleton instance =newStaticInnerSingleton(); }}
5.2 静态内部类的优势
比较推荐这种方式, 没有加锁, 线程安全, 用到时再加载, 并发行能高.
6 写法⑤ - 枚举类实现单例
6.1 代码示例
JDK 5开始, 提供了枚举(enum), 其实就是一个语法糖: 我们写很少的代码, JVM在编译的时候帮我们添加很多额外的信息.
通过对枚举类的反编译可以知道: 枚举类也是在JVM层面保证的线程安全 .
/**
* 枚举类单例模式
*/enumEnumSingleton{/**
* 此枚举类的一个实例, 可以直接通过 EnumSingleton.INSTANCE 来使用
*/INSTANCE}
6.2 优缺点比较
(1) 优点: JVM对枚举类的处理就决定了: 枚举类天生是线程安全的.
关于JVM对枚举类的处理, 可以参考这篇文章: Java中枚举类型的使用 - enum .
(2) 缺点: 所有的属性都必须在创建时指定, 也就意味着不能延迟加载
7 写法⑥ - 通过ThreadLocal实现单例
还是在 这篇文章 中, 发现了通过 ThreadLocal 修正DCL问题的思路: 每个线程都持有一个 ThreadLocal 标志, 用来确定该线程是否已完成所需的同步. 具体代码如下:
/**
* 通过ThreadLocal实现单例模式, 性能可能比较低
*/classThreadLocalSingleton{/**
* 如果 perThreadInstance.get() 返回一个非空值, 说明当前线程已经被同步了: 它要看到instance变量的初始化
*/privatestaticThreadLocal perThreadInstance =newThreadLocal();privatestaticThreadLocalSingleton instance =null;publicstaticThreadLocalSingletongetInstance(){if(perThreadInstance.get() ==null) { createInstance(); }returninstance; }privatestaticfinalvoidcreateInstance(){ synchronized (ThreadLocalSingleton.class) {if(instance ==null) { instance =newThreadLocalSingleton(); } }// 任何非空的值都可以作为这里的参数perThreadInstance.set(perThreadInstance); }/**
* 阿里代码规范提示: ThreadLocal变量应该至少调用一次remove()方法, 原因如下:
* 必须回收自定义的ThreadLocal变量, 尤其在线程池场景下, 因为线程经常会被复用,
* 如果不清理自定义的 ThreadLocal变量, 可能会影响后续业务逻辑和造成内存泄露等问题.
* 尽量在代理中使用try-finally块进行回收.
*/publicstaticvoidremove(){ perThreadInstance.remove(); }}
这种技术的性能在很大程度上取决于的JDK的版本. 在Sun JDK 1.2中, ThreadLocal性能非常慢, 而在1.3中性能明显提升了. 具体的性能对比, 参见下一节.
8 扩展: JDK中的单例 以及 如何破坏单例模式
8.1 JDK中常见的单例模式
(1)java.lang.Runtime类中的getRuntime()方法;(2)java.awt.Toolkit类中的getDefaultToolkit()方法;(3)java.awt.Desktop类中的getDesktop()方法;(4) 另外,RuntimeException也是单例的 —— 因为一个Java应用只有一个JavaRuntimeEnvironment.
8.2 破坏单例模式的方法
(1) 除枚举方式外, 其他方法都会通过反射的方式破坏单例, 解决方法:
反射是通过调用构造方法生成新的对象, 可以在构造方法中进行判断 —— 若已有实例, 则阻止生成新的实例, 如:
privateSingleton() throwsException{if(instance !=null) {thrownewException("Singleton already initialized, 此类为单例, 不允许生成新对象, 你可以通过getInstance()获取单例对象"); }}
(2) 如果单例类实现了序列化接口Serializable, 就可以通过反序列化破坏单例, 解决方法:
不实现序列化接口, 或者使用readResolve()方法, 反序列化时直接返回相关单例对象:
publicObject readResolve() {returninstance;}
(3) Object#clone()方法也会破坏单例, 即使你没有实现Cloneable接口 —— 因为clone()方法是Object类中的. 解决方法是:
重写clone()方法, 并在其中抛出异常信息“Can not create clone of Singleton class”
9 扩展 - 性能对比
(1) 测试用的代码:
创建100个线程, 每个线程中循环获取10,000次单例对象, 统计各个类所用的时间.
public staticvoidmain(String[] args) throws InterruptedException {// 创建的线程数
int threadNum = 100;
//循环获取对象的次数 int objectNum =10000; Long beginTime = System.currentTimeMillis();for(int i =0; i < threadNum; i++) {newThread(() -> {for(int j =0; j < objectNum; j++) { Object o = HungrySingleton.getInstance(); } }).start(); }LongendTime=System.currentTimeMillis();System.out.println("HungrySingleton --- "+ (endTime - beginTime) +" ms"); // 省去一大串其他类的测试代码beginTime=System.currentTimeMillis();for(int i =0; i < threadNum; i++){newThread(() -> {for(int j =0; j < objectNum; j++) { Object o = EnumSingleton.INSTANCE; } }).start(); }endTime=System.currentTimeMillis();System.out.println("EnumSingleton --- "+ (endTime - beginTime) +" ms");}
说明:
这个测试代码的重复性太高了, 本来想封装成方法、通过反射进行不同类和方法的调用的, 可考虑到反射的性能损耗, 一时又想不到其他好点的方法, 所以不得已采取了这种. 各位看官请别喷, 有好点的方法可以在留言区交流下:pray:
(2) 测试结果, 单位是毫秒(ms):
不同的模式第一次第二次第三次平均耗时
饥饿模式 (HungrySingleton)59616261
线程安全的懒惰模式 (LazySingleton)27104126
双重检查锁模式 (DoubleCheckedLockingSingleton)12141213
静态内部类模式 (StaticInnerSingleton)12132216
枚举类模式 (EnumSingleton)810109
线程本地变量 (ThreadLocalSingleton)21262424
运行多次, 发现结果不太稳定, 暂时未找到原因, 所以就不总结了, 各位看官权当参考, 还请存疑