赞
踩
今天开始正式学习设计模式。经典的设计模式有 23 种。其中,常用的并不是很多,可能一半都不到。作为程序员,最熟悉的设计模式,肯定包含单例模式。
本次单例模式的讲解,希望你搞清楚下面这样几个问题。(第一个问题在本章讲解,后面三个问题放到下一章节)。
单例设计模式(Singleton Design Pattern)定义非常简单。一个类只被允许创建一个对象,那这个类就是单例类,这种设计模式简称单例模式。
下面,我们看下为什么需要单例这种设计模式?它能解决哪些问题?我们通过两个案例来讲解。
先看第一个例子,我们自定义实现了一个往文件中打印日志的 Logger
类。具体的代码如下所示:
public class Logger {
private FileWriter writer;
public Logger() {
File file = new File("/test/log.txt");
writer = new FileWriter(file, true);
}
public void log(String message) {
writer.write(message);
}
}
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());
}
}
看完代码后,我们先思考下,这段代码存在什么问题?
我们注意到,所有的日志都写入到同一个文件 “/test/log.txt” 中。在 UserController
和 OrderController
中,分别创建了两个 Logger
对象。在 Web 容器的 Servlet 多线程环境下,如果两个 Servlet 线程同时分别执行 login()
和 create()
两个函数,并且同时写到日志 “log.txt” 中,那就有可能存在日志信息互相覆盖的情况。
我们可以类比着理解。在多线程环境下,如果两个线程同时给同一个共享变量加 1,因为共享变量是竞争资源,所以,共享变量最后的结果有可能并不是加了 2,而是只加了 1。同理,这里的 “log.txt” 也是竞争资源,两个线程同时往里面写数据,就可能存在互相覆盖的情况。
那如何来解决这个问题呢?
我们最先想到的是通过加锁的方式:给 log()
函数加锁(Java 中通过 synchronized 关键字),同时只允许一个线程执行 log()
函数。具体的代码实现如下所示:
public class Logger {
private FileWriter writer;
public Logger() {
File file = new File("/test/log.txt");
writer = new FileWriter(file, true);
}
public void log(String message) {
synchronized (this) {
writer.write(message);
}
}
}
不过,这真的能解决多线程写入日志时的问题吗?答案是否定的。这是因为,这种所是一个对象级别的所,一个对象在不同的线程下同时调用 log()
函数,会被强制要求顺序执行。但是,不同的对象之间并不共享同一把锁。在不同的线程下,通过不同的对象执行 log()
函数,所并不会起作用,仍然有可能存在写入日志相互覆盖的问题。
刚刚讲解的代码中,故意 “隐藏” 了一个事实:我们给
log()
函数加不加对象的锁,其实都没有关系。因为FileWriter
本身是线程安全的,它的内部实现本身就加了对象级别的锁,因此,在外层调用write()
时,再加对象锁实际上是多此一举。因为不同的Logger
不共享FileWriter
对象,所以,FileWriter
对象级别的锁也解决不了数据写入相互覆盖的问题。
那该如何解决这个问题呢?
我们只要把对象级别的锁,换成类级别的锁就可以了。让所有对象都共同使用同一把锁。
public class Logger {
private FileWriter writer;
public Logger() {
File file = new File("/test/log.txt");
writer = new FileWriter(file, true);
}
public void log(String message) {
synchronized (Logger.class) {
writer.write(message);
}
}
}
除了使用类级别的锁外,实际上,解决资源竞争问题的办法还有很多,分布式锁是最常听到的一种解决方案。不过,实现一个安全可靠、无 bug、高性能的分布式锁,并不容易。此外,并发队列(如 Java 中的 BlockingQueue
)也可以解决这个问题:多个线程同时往并发队列里写日志,一个单独的线程负责将并发队列中的数据,写入到日志文件。这种方式实现起来也稍微有点复杂。
相对于这两种方案,单例模式的解决思路就简单一些了。单例模式相对于之前类级别的好处是,不用创建那么多的 Logger
对象,一方面节省内存空间,另一方面节省系统文件句柄(对操作系统来说,文件句柄也是一种资源,不能随便浪费)。
我们将 Logger
设计为单例类,程序只允许创建一个 Logger
对象,所有的线程共享使用这个 Logger
对象,共享一个 FileWriter
对象,而 FileWriter
本身是对象级别线程安全的,也就避免了多线程情况下写日志会相互覆盖的问题。
按照这个设计思路,我们实现了 Logger
类。
public class Logger {
private FileWriter writer;
private static Logger instance = new Logger();
private Logger() {
File file = new File("/test/log.txt");
writer = new FileWriter(file, true);
}
public static Logger getInstance() {
return instance;
}
public void log(String message) {
writer.write(message);
}
}
// 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 号码生成器,如果程序中有两个对象,那就回存在生成重复 ID 的情况,所以,我们应该将 ID 生成器类设计为单例。
public class IdGenerator {
/**
* AtomicLong是Java并发库中的一个原子类型变量类型,它将一些线程不安全需要
* 加锁的符合操作封装为了线程安全的原子操作,比如下面用到的 incrementAndGet()
*/
private AtomicLong id = new AtomicLong(0);
private static IdGenerator instance = new IdGenerator();
private IdGenerator() {}
public static IdGenerator getInstance() {
return instance;
}
public long getId() {
return id.incrementAndGet();
}
}
// IdGenerator使用举例
long id = IdGenerator.getInstance().getId();
实际上,今天将的两个代码实例(Logger
、IdGenerator
),设计的都并不优雅,还存在一些问题。至于有什么问题及如何改造,下一节会详细讲解。
要实现一个单例,需要关注以下几个方面:
getInstance()
性能是否高(是否加锁)。饿汉式的实现方式比较简单。在类加载的时候,instance 静态实例已经创建并初始化好了,所以, instance 实例的创建过程是线程安全的。不过,这样的实现方式不支持延迟加载(在真正用到 IdGenerator
时,再创建实例)。具体的实现代码如下所示:
public class IdGenerator {
private AtomicLong id = new AtomicLong(0);
private static IdGenerator instance = new IdGenerator();
private IdGenerator() {}
public static IdGenerator getInstance() {
return instance;
}
public long getId() {
return id.incrementAndGet();
}
}
有的人觉得这种实现方式不好,因为不支持延迟加载,如果实例占用资源多(比如占用内存多)或初始化耗时长(比如需要加载各种配置文件),提前实例化实例是一种浪费行为。最后应该在用到的时候再去初始化。不过呢,我个人不认同这样的观点。
如果初始耗时长,那最好不要等到真正用到它的时候,才去执行这个耗时长的初始化过程,这会影响到系统的性能。
比如,在响应客户端接口请求时,做这个初始化操作,会导致此请求的响应时间变长,甚至超时。而采用饿汉式实现方式,将耗时的初始化操作,提前到程序启动的时候完成,这样就能避免在程序运行的时候,再去初始化导致性能问题。
如果实例占用资源多,按照 fail-fast 的设计原则(有问题及早暴露),那也是希望在程序启动时将这个实例初始化好。如果资源不够,就会在程序启动时触发报错(比如 Java 中的 OOM),我们可以立即去修复。这样也能避免在程序运行一段时间后,突然因为初始化这个实例占用资源过多,导致系统崩溃,影响系统的可用性。
懒汉式相对于饿汉式的优势是支持延迟加载。具体的实现如下所示:
public class IdGenerator {
private AtomicLong id = new AtomicLong(0);
private static IdGenerator instance;
private IdGenerator() {}
public static synchronized IdGenerator getInstance() {
if (instance == null) {
instance = new IdGenerator();
}
return instance;
}
public long getId() {
return id.incrementAndGet();
}
}
懒汉式的缺点也很明显,给 getInstance()
这个方法加了一把大锁(synchronized
),导致这个函数的并发度很低。量化一下的话,并发度是 1,也就相当于串行操作了。而这个函数是在单例使用期间,一直会被调用。如果这个单例偶尔被用到,这种实现方式还能接受。但是,如果频繁地被用到,那频繁地加锁、释放锁及并发度低等问题,会导致性能瓶颈,这种实现方式就不可取了。
双重校验方式,是既支持延迟加载、又支持高并发的单实例实现方式。
在这种实现方式中,只要 instance 被创建后,即便在调用 getInstance()
方法也不会再进入到加锁逻辑中了。所以,这种实现方式解决了懒汉式并发度低的问题。
public class IdGenerator {
private AtomicLong id = new AtomicLong(0);
private static IdGenerator instance; // 可以加volatile关键字,禁止指令重排。
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();
}
}
实际上,上述的实现方式存在问题:CPU 指令重排序可能导致在 IdGenerator
类的对象被关键字 new 创建并赋值给 instance 之后,还没有来得及初始化,就被另一个线程使用了。这样,另一个线程就使用了没有完整初始化的 IdGenerator
类对象。要解决这个问题,只需要给 instance 变量家 volatile
关键字来禁止指令重排序即可。
再来看一种比双重检测更加简单的实现方法,利用 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();
}
}
1.单例的定义
单例设计模式理解起来非常简单。一个类只允许创建一个对象,那这个类就是单例类,这种设计模式就叫做单例设计模式,简称单例模式。
2.单例的用处
从业务概念上,有些数据在系统中只应该保存一份,就比较适合设计为单例类。比如,系统的配置信息类。
此外,我们还可以使用单例解决资源访问冲突的问题。
3.单例的实现
getInstance()
函数并不会进入到加锁逻辑中。所以,这种实现方式解决了懒汉式并发度低的问题。Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。