(目录)
单例模式的设计与作⽤
为什么要使⽤单例
单例设计模式(Singleton Design Pattern)
理解起来⾮常简单。
⼀个类只允许创建⼀个对象(或者实例)
,那这个类就是⼀个单例类,这种设计模式就叫作单例设计模式,简称单例模式。
案例⼀:处理资源访问冲突
在这个例⼦中,我们⾃定义实现了⼀个往⽂件中打印⽇志的 Logger 类。具体的代码实现如下所示:
public class Logger {
private FileWriter writer;
public Logger() {
File file = new File("/Users/wangzheng/log.txt");
writer = new FileWriter(file, true); //true表示追加写⼊
}
public void log(String message) {
writer.write(message);
}
}
// Logger类的应⽤示例:
public class UserController {
private Logger logger = new Logger();
public void login(String username, String password) {
// ...省略业务逻辑代码...
logger.log(username + " logined!");
}
}
public class OrderController {
private Logger logger = new Logger();
public void create(OrderVo order) {
// ...省略业务逻辑代码...
logger.log("Created an order: " + order.toString());
}
}
在上⾯的代码中,我们注意到,所有的⽇志都写⼊到同⼀个⽂件 /var/log/app.log 中。在UserController 和 OrderController 中,我们分别创建两个 Logger 对象。在 Web 容器的Servlet 多线程环境下,如果两个 Servlet 线程同时分别执⾏ login() 和 create() 两个函数,并且同时写⽇志到log.txt ⽂件中,那就有可能存在⽇志信息互相覆盖的情况。
在类对象层⾯上锁解决争执问题
public class Logger {
private FileWriter writer;
public Logger() {
File file = new File("/Users/wangzheng/log.txt");
writer = new FileWriter(file, true); //true表示追加写⼊
}
public void log(String message) {
synchronized (Logger.class) { // 类级别的锁
writer.write(mesasge);
}
}
}
除了使⽤类级别锁之外,实际上,解决资源竞争问题的办法还有很多。不过,实现⼀个安全可靠、⽆bug、⾼性能的分布式锁,并不是件容易的事情。除此之外,并发队列(⽐如 Java 中的
BlockingQueue)也可以解决这个问题:多个线程同时往并发队列⾥写⽇志,⼀个单独的线程负责将并发队列中的数据,写⼊到⽇志⽂件。这种⽅式实现起来也稍微有点复杂。
单例模式的解决思路
就简单⼀些了。单例模式相对于之前类级别锁的好处是,不⽤创建那么多 Logger对象,⼀⽅⾯节省内存空间,另⼀⽅⾯节省系统⽂件句柄(对于操作系统来说,⽂件句柄也是⼀种资源,不能随便浪费)。
我们将 Logger 设计成⼀个单例类
,程序中只允许创建⼀个 Logger 对象,所有的线程共享使⽤的这⼀个Logger 对象,共享⼀个FileWriter 对象
,⽽ FileWriter 本身是对象级别线程安全的,也就避免了多线程情况下写⽇志会互相覆盖的问题。
public class Logger {
private FileWriter writer;
private static final Logger instance = new Logger();
private Logger() {
File file = new File("/Users/wangzheng/log.txt");
writer = new FileWriter(file, true); //true表示追加写⼊
}
public static Logger getInstance() {
return instance;
}
public void log(String message) {
writer.write(mesasge);
}
}
// Logger类的应⽤示例:
public class UserController {
public void login(String username, String password) {
// ...省略业务逻辑代码...
Logger.getInstance().log(username + " logined!");
}
}
public class OrderController {
public void create(OrderVo order) {
// ...省略业务逻辑代码...
Logger.getInstance().log("Created a order: " + order.toString());
}
}
案例⼆:表示全局唯⼀类
从业务概念上,如果有些数据在系统中只应保存⼀份,那就⽐较适合设计为单例类。⽐如,配置信息类。在系统中,我们只有⼀个配置⽂件,当配置⽂件被加载到内存之后,以对象的形式存在,也理所应当只有⼀份。
⽐如id⽣成器也很适合
import java.util.concurrent.atomic.AtomicLong;
public class IdGenerator {
private AtomicLong id = new AtomicLong(0);
//在初始化操作提前到类加载时完成
private static final IdGenerator instance = new IdGenerator();
private IdGenerator() {
}
public static IdGenerator getInstance() {
return instance;
}
public long getId() {
return id.incrementAndGet();
}
}
// IdGenerator使⽤举例
long id = IdGenerator.getInstance().getId();
如何实现⼀个单例
概括起来,要实现⼀个单例,需要关注的点有下⾯⼏个:
构造函数需要是 private 访问权限的
,这样才能避免外部通过 new 创建实例;- 考虑
对象创建时的线程安全问题
; - 考虑
是否⽀持延迟加载
; - 考虑 getInstance() 性能是否⾼(
是否加锁
)。
1. 饿汉式 (类加载时直接实例化单例对象)
饿汉式的实现⽅式⽐较简单。
在类加载的时候,instance 静态实例就已经创建并初始化好了
,所以,instance 实例的创建过程是线程安全的。不过,这样的实现⽅式不⽀持延迟加载
(在真正⽤到IdGenerator 的时候,再创建实例),从名字中我们也可以看出这⼀点。具体的代码实现如下所示:
public class IdGenerator {
private AtomicLong id = new AtomicLong(0);
private static final IdGenerator instance = new IdGenerator();
private IdGenerator() {
}
public static IdGenerator getInstance() {
return instance;
}
public long getId() {
return id.incrementAndGet();
}
}
有⼈觉得这种实现⽅式不好,因为不⽀持延迟加载,如果实例占⽤资源多(⽐如占⽤内存多)或初始化耗时⻓(⽐如需要加载各种配置⽂件),提前初始化实例是⼀种浪费资源的⾏为。最好的⽅法应该在⽤的时候再去初始化。
如果初始化耗时⻓,那我们最好不要等到真正要⽤它的时候,才去执⾏这个耗时⻓的初始化过程,这会影响到系统的性能(⽐如,在响应客户端接⼝请求的时候,做这个初始化操作,会导致此请求的响应时间变⻓,甚⾄超时)。采⽤饿汉式实现⽅式,将耗时的初始化操作,提前到程序启动的时候完成,这样就能避免在程序运⾏的时候,再去初始化导致的性能问题
。
如果实例占⽤资源多,按照 fail-fast
的设计原则(有问题及早暴露),那我们也希望通过饿汉式在程序启动时就将这个实例初始化好。如果资源不够,就会在程序启动的时候触发报错(⽐如Java中的PermGen Space OOM),我们可以⽴即去修复。这样也能避免在程序运⾏⼀段时间后,突然因为初始化这个实例占⽤资源过多,导致系统崩溃,影响系统的可⽤性
。
2. 懒汉式 (第⼀次调⽤时实例化对象(延迟加载))
① 双重校验锁
在这种实现⽅式中,只要 instance 被创建之后,即便再调⽤ getInstance()函数也不会再进⼊到加锁逻辑中了。所以,这种实现⽅式解决了懒汉式并发度低的问题。具体的代码实现如下所示:
public class IdGenerator {
private AtomicLong id = new AtomicLong(0);
private static IdGenerator instance;
private IdGenerator() {
}
public static IdGenerator getInstance() {
if (instance == null) {
synchronized (IdGenerator.class) { // 此处为类级别的锁
if (instance == null) {
instance = new IdGenerator();
}
}
}
return instance;
}
public long getId() {
return id.incrementAndGet();
}
}
⽹上有⼈说,这种实现⽅式有些问题。因为指令重排序,可能会导致 IdGenerator 对象被 new 出来,并且赋值给 instance之后,还没来得及初始化(执⾏构造函数中的代码逻辑),就被另⼀个线程使⽤了。
要解决这个问题,我们需要给 instance 成员变量加上 volatile 关键字,禁⽌指令重排序才⾏。实际上,只有很低版本的 Java才会有这个问题。我们现在⽤的⾼版本的 Java 已经在 JDK 内部实现中解决了这个问题(解决的⽅法很简单,只要把对象 new操作和初始化操作设计为原⼦操作,就⾃然能禁⽌重排序)
为什么需要两次判断if(instance==null)?
-
第⼀次校验:由于单例模式只需要创建⼀次实例,如果后⾯再次调⽤getInstance⽅法时,则直接返回之前创建的实例,因此⼤部分时间不需要执⾏同步⽅法⾥⾯的代码,⼤⼤提⾼了性能。如果不加第⼀次校验的话,那跟上⾯的懒汉模式没什么区别,每次都要去竞争锁。
-
第⼆次校验:如果没有第⼆次校验,假设线程t1执⾏了第⼀次校验后,判断为null,这时t2也获取了CPU执⾏权,也执⾏了第⼀次校验,判断也为null。接下来t2获得锁,创建实例。这时t1⼜获得CPU执⾏权,由于之前已经进⾏了第⼀次校验,结果为null(不会再次判断),获得锁后,直接创建实例。结果就会导致创建多个实例。所以需要在同步代码⾥⾯进⾏第⼆次校验,如果实例为空,则进⾏创建。
② 静态内部类
再来看⼀种⽐双重检测更加简单的实现⽅法,那就是利⽤ Java 的静态内部类。它有点类似饿汉式,但⼜能做到了延迟加载。具体是怎么做到的呢?我们先来看它的代码实现。
public class IdGenerator {
private AtomicLong id = new AtomicLong(0);
private IdGenerator() {
}
private static class SingletonHolder {
private static final IdGenerator instance = new IdGenerator();
}
public static IdGenerator getInstance() {
return SingletonHolder.instance;
}
public long getId() {
return id.incrementAndGet();
}
}
SingletonHolder 是⼀个静态内部类,当外部类 IdGenerator 被加载的时候,并不会创建
SingletonHolder 实例对象。只有当调⽤ getInstance() ⽅法时,SingletonHolder才会被加载,这个时候才会创建 instance。instance 的唯⼀性、创建过程的线程安全性,都由 JVM来保证。所以,这种实现⽅法既保证了线程安全,⼜能做到延迟加载。
③ 枚举
枚举对象是单例的,⼀种对象只会在内存中保存⼀份。
基于枚举类型的单例实现。这种实现⽅式通过 Java枚举类型本身的特性,是最简单实现单例的⽅式,保证了实例创建的线程安全性和实例的唯⼀性。具体的代码如下所示:
public enum IdGenerator {
INSTANCE;
private AtomicLong id = new AtomicLong(0);
public long getId() {
return id.incrementAndGet();
}
public static void main(String[] args) {
IdGenerator instance = IdGenerator.INSTANCE;
System.out.println(instance.getId());
}
}
上面的IdGenerator的定义利用的 enum 是一种特殊的class。
代码中的第一行INSTANCE会被编译器编译为IdGenerator本身的一个对象
。当第一次访问IdGenerator.INSTANCE时会创建该对象,并且enum变量的创建是线程安全的
经典实现
如果需要将一个已有的类改造为单例类,也可以使用枚举的方式来实现。
下面我们来看一下对应的实现代码。
public class Singleton {
private Singleton(){
}
public static enum SingletonEnum {
SINGLETON;
private Singleton instance = null;
private SingletonEnum(){
instance = new Singleton();
}
public Singleton getInstance(){
return instance;
}
}
}
在代码中,我们首先将Singleton类的构造函数设置为private私有的,然后在Singleton类中定义一个静态的枚举类型SingletonEnum。
在SingletonEnum中定义了枚举类型的实例对象Singleton,再按照单例模式的要求在其中定义一个Singleton类型的对象instance,其初始值为null;我们需要将SingletonEnum的构造函数改为私有的,在私有构造函数中创建一个Singleton的实例对象;最后在getInstance()方法中返回该对象。
在实现过程中,Java虚拟机会保证枚举类型不能被反射并且构造函数只被执行一次。
正如我们前面所讲的Singleton类本身就是一个普通类,它里面还包含了其他业务方法。在这里我们只需要在其中增加一个内部枚举类型来存储和创建它的唯一实例即可,这和前面的静态内部类的实现有点相似,但是枚举实现可以很好地解决反射和反序列化会破坏单例模式的问题,提供了一种更加安全和可靠的单例模式实现机制。
我们在客户端代码中进行一下测试:
……
public static void main(String args[]) {
Singleton s1 = SingletonEnum.SINGLETON.getInstance();
Singleton s2 = SingletonEnum.SINGLETON.getInstance();
System.out.println(s1==s2);
}
……
大家可以看到,在这里我们通过调用枚举SingletonEnum中定义的枚举实例对象SINGLETON的getInstance()方法获取对象s2和s2,然后比较s1是否等于s2,最后输出true,输出结果说明s1和s2是同一个对象,所得到的对象具有唯一性。