【并发编程的艺术】详解单例模式的实现方式(Java)
系列文章:
一 单例模式
单例模式是一种常用且常用来考察的设计模式。尤其在 Java 中,包含了对 JMM 理解的考察。事实上,尽管大家对单例模式概念都有了解,也能说出几种实现模式,但不一定能够保证正确,对原理也了解的不够透彻。所以本章将详细阐述。
二 概念
单例模式(Singleton Pattern)属于创建型模式,这种模式涉及到一个单一的类,该类负责创建自己的对象,同时确保只有单个对象被创建。这个类提供了一种访问其唯一的对象的方式,可以直接访问,不需要实例化该类的对象。
注意:
1、单例类只能有一个实例。
2、单例类必须自己创建自己的唯一实例。
3、单例类必须给所有其他对象提供这一实例。
三 几种常见的实现方式
3.1 饿汉模式
优点:饿汉模式天生是线程安全的,使用时没有延迟。
缺点:启动时即创建实例,启动慢,有可能造成资源浪费。
3.2 饱汉模式(懒汉)
优点:懒加载启动快,资源占用小,使用时才实例化,无锁。
缺点:非线程安全。
原因:当 A 线程执行代码 1 的同时,B 线程执行代码 2.此时,线程 A 可能会看到 Instance 引用的对象还没有完成的初始化(原因稍后描述)。
3.3 同步 getInstance 方法
基于 3.2,一种可以容易想到的方法是在方法上加 synchronized,做了同步处理之后,将不再会出现两个线程同时进入方法内的情况:
但显然,整个方法粒度上的锁过重,这会导致不必要的开销。事实上,我们只需要在 instance 被实例化前,以及实例化过程中加锁,在实例化之后就无需加锁了。所以,基于这一点考虑优化。
3.4 双重检查锁定
一种看似聪明的技巧,锁不在方法上,而是在内部通过判断 instance 是否是 null 来进行部分代码块的加锁,以此来降低锁的粒度:
但实际上,这是一个错误的优化!因为在线程执行到 4 时,代码读取到 instance 不等于 null,instance 引用的对象可能还没有完成初始化。
3.5 问题根源
我们在创建实例时,在 java 中只是一行代码: instance = new Singleton(); 但在汇编层面,代码会分为以下三步执行:
而在 2 和 3 之间,可能会出现指令重排序(在一些 JIT 编译器上,这种重排序是真实存在的)。发生重排序后,执行的顺序变为:
根据 Java 语言规范,这里的重排没有影响单线程内的执行结果,所以是被允许的。但在上面 3.4 的单例模式中,可能会导致在多线程并发调用时,使用一个未完成初始化的实例,从而导致错误。
所以,知道问题根源后,可以想到两个解决方法来保证延迟初始化的线程安全:
1、不允许 2,3 重排序
2、允许 2,3 重排序,但不允许其他线程看到这个重排序
3.5.1 volatile
禁止指令重排,可以把 instance 声明为 volatile,代码如下:
在把 instance 声明为 volatile 之后,将禁止 2,3 两步的指令重排序。但需要注意,需要 JDK 版本为 JDK5 以上!!!(开始使用新的 JSR-133 内存模型规范,加强了 volatile 的内存语义)。
3.5.2 基于类初始化
JVM 在类的初始化阶段(Class 加载后,被线程使用之前),JVM 会去获取一个锁,这个锁可以同步多个线程对同一个类的初始化动作,避免一个类被初始化多次。基于这个特性,就有了另一种方法:
当多线程并发执行时,这种方法的实现过程如下图所示:
也就是说,是通过允许初始化对象 和 设置引用指向内存两个步骤重排序,但不允许非构造线程”看到“这个重排序。
四 总结
本文列举了集中 Java 中实现单例模式的方法和代码,通过分析,可以知道各自实现的正确与否,以及错误原因。在有了前面系列文章的基础之后,对理解这个看似简单的单例代码实现会有更深刻的理解。
通过对比 volatile 的双重检查锁定方案,以及基于类初始化的方案,我们可以看到类初始化方案更简洁,但 volatile+双重检查锁定也有除了对静态字段实现延迟初始化外还可以对实例字段实现延迟初始化的优点。字段延迟初始化降低了初始化类或创建实例的开销,但同时也增加了访问被延迟初始化的字段的开销。大多时候,正常的初始化要优于延迟初始化。
所以,当需要对两种方案做一个最优选择时,那么就看如果:
1)确实需要对实例字段使用线程安全的延迟初始化,应该选择基于 volatile 的方案;
2)如果确实需要对静态字段使用线程安全的初始化,可使用基于类初始化的方案。
版权声明: 本文为 InfoQ 作者【程序员架构进阶】的原创文章。
原文链接:【http://xie.infoq.cn/article/54e977c0a2d0b3afc7ef9bb07】。文章转载请联系作者。
评论