python3的配置⽂件类单例实现_设计模式——单例模式(上)
我们知道,经典的设计模式有 23 种。其中,常⽤的并不是很多。据我的⼯作经验来看,常⽤的可能都不到⼀半。如果随便抓⼀个程序员,
让他说⼀说最熟悉的 3 种设计模式,那其中肯定会包含今天要讲的单例模式。
⽹上有很多讲解单例模式的⽂章,但⼤部分都侧重讲解,如何来实现⼀个线程安全的单例。我今天也会讲到各种单例的实现⽅法,但是,这
并不是我们专栏学习的重点,我重点还是希望带你搞清楚下⾯这样⼏个问题(第⼀个问题会在今天讲解,后⾯三个问题放到下⼀节课中讲
解)。
为什么要使⽤单例?
单例存在哪些问题?
单例与静态类的区别?
有何替代的解决⽅案?
话不多说,让我们带着这些问题,正式开始今天的学习吧!
为什么要使⽤单例?
单例设计模式(Singleton Design Pattern)理解起来⾮常简单。⼀个类只允许创建⼀个对象(或者实例),那这个类就是⼀个单例类,这种设
计模式就叫作单例设计模式,简称单例模式。
对于单例的概念,我觉得没必要解释太多,你⼀看就能明⽩。我们重点看⼀下,为什么我们需要单例这种设计模式?它能解决哪些问题?接
下来我通过两个实战案例来讲解。
实战案例⼀:处理资源访问冲突
我们先来看第⼀个例⼦。在这个例⼦中,我们⾃定义实现了⼀个往⽂件中打印⽇志的 Logger 类。具体的代码实现如下所⽰:
public class Logger { private FileWriter writer; public Logger() { File file = new File("/Users/"); writer = new FileWriter(file, true); //tru表⽰追
看完代码之后,先别着急看我下⾯的讲解,你可以先思考⼀下,这段代码存在什么问题。
在上⾯的代码中,我们注意到,所有的⽇志都写⼊到同⼀个⽂件 /Users/ 中。在 UserController 和
OrderController 中,我们分别创建两个 Logger 对象。在 Web 容器的 Servlet 多线程环境下,如果两个 Servlet 线程同时分别执⾏
login() 和 create() 两个函数,并且同时写⽇志到 ⽂件中,那就有可能存在⽇志信息互相覆盖的情况。
为什么会出现互相覆盖呢?我们可以这么类⽐着理解。在多线程环境下,如果两个线程同时给同⼀个共享变量加 1,因为共享变量是竞争资
单例模式的几种实现方式源,所以,共享变量最后的结果有可能并不是加了 2,⽽是只加了 1。同理,这⾥的 ⽂件也是竞争资源,两个线程同时往⾥⾯写数
据,就有可能存在互相覆盖的情况。
那如何来解决这个问题呢?我们最先想到的就是通过加锁的⽅式:给 log() 函数加互斥锁(Java 中可以通过 synchronized 的关键字),同
⼀时刻只允许⼀个线程调⽤执⾏ log() 函数。具体的代码实现如下所⽰:
public class Logger { private FileWriter writer; public Logger() { File file = new File("/Users/"); writer = new FileWriter(file, true); //true表⽰追
不过,你仔细想想,这真的能解决多线程写⼊⽇志时互相覆盖的问题吗?答案是否定的。这是因为,这种锁是⼀个对象级别的锁,⼀个对象
在不同的线程下同时调⽤ log() 函数,会被强制要求顺序执⾏。但是,不同的对象之间并不共享同⼀把锁。在不同的线程下,通过不同的对
象调⽤执⾏ log() 函数,锁并不会起作⽤,仍然有可能存在写⼊⽇志互相覆盖的问题。
我这⾥稍微补充⼀下,在刚刚的讲解和给出的代码中,我故意“隐瞒”了⼀个事实:我们给 log() 函数加不加对象级别的锁,其实都没有关
系。因为 FileWriter 本⾝就是线程安全的,它的内部实现中本⾝就加了对象级别的锁,因此,在外层调⽤ write() 函数的时候,再加对象级
别的锁实际上是多此⼀举。因为不同的 Logger 对象不共享 FileWriter 对象,所以,FileWriter 对象级别的锁也解决不了数据写⼊互相覆
盖的问题。
那我们该怎么解决这个问题呢?实际上,要想解决这个问题也不难,我们只需要把对象级别的锁,换成类级别的锁就可以了。让所有的对象
都共享同⼀把锁。这样就避免了不同对象之间同时调⽤ log() 函数,⽽导致的⽇志覆盖问题。具体的代码实现如下所⽰:
public class Logger { private FileWriter writer; public Logger() { File file = new File("/Users/"); writer = new FileWriter(file, true); //true表⽰追
除了使⽤类级别锁之外,实际上,解决资源竞争问题的办法还有很多,分布式锁是最常听到的⼀种解决⽅案。不过,实现⼀个安全可靠、⽆
bug、⾼性能的分布式锁,并不是件容易的事情。除此之外,并发队列(⽐如 Java 中的 BlockingQueue)也可以解决这个问题:多个线程同
时往并发队列⾥写⽇志,⼀个单独的线程负责将并发队列中的数据,写⼊到⽇志⽂件。这种⽅式实现起来也稍微有点复杂。
相对于这两种解决⽅案,单例模式的解决思路就简单⼀些了。单例模式相对于之前类级别锁的好处是,不⽤创建那么多 Logger 对象,⼀⽅
⾯节省内存空间,另⼀⽅⾯节省系统⽂件句柄(对于操作系统来说,⽂件句柄也是⼀种资源,不能随便浪费)。
我们将 Logger 设计成⼀个单例类,程序中只允许创建⼀个 Logger 对象,所有的线程共享使⽤的这⼀个 Logger 对象,共享⼀个
FileWriter 对象,⽽ FileWriter 本⾝是对象级别线程安全的,也就避免了多线程情况下写⽇志会互相覆盖的问题。
按照这个设计思路,我们实现了 Logger 单例类。具体代码如下所⽰
public class Logger { private FileWriter writer; private static final Logger instance = new Logger(); private Logger() { File file = new File("/Users/wangzh
实战案例⼆:表⽰全局唯⼀类
从业务概念上,如果有些数据在系统中只应保存⼀份,那就⽐较适合设计为单例类。
⽐如,配置信息类。在系统中,我们只有⼀个配置⽂件,当配置⽂件被加载到内存之后,以对象的形式存在,也理所应当只有⼀份。
再⽐如,唯⼀递增 ID 号码⽣成器,如果程序中有两个对象,那就会存在⽣成重复 ID 的情况,所以,我们应该将 ID ⽣成器类设计为单例。
import urrent.atomic.AtomicLong;public class IdGenerator { // AtomicLong是⼀个Java并发库中提供的⼀个原⼦变量类型, // 它将⼀些线程不安全需要加如何实现⼀个单例?
尽管介绍如何实现⼀个单例模式的⽂章已经有很多了,但为了保证内容的完整性,我这⾥还是简单介绍⼀下⼏种经典实现⽅式。概括起来,
要实现⼀个单例,我们需要关注的点⽆外乎下⾯⼏个:
构造函数需要是 private 访问权限的,这样才能避免外部通过 new 创建实例;
考虑对象创建时的线程安全问题;
考虑是否⽀持延迟加载;
考虑 getInstance() 性能是否⾼(是否加锁)。
如果你对这块已经很熟悉了,你可以当作复习。注意,下⾯的⼏种单例实现⽅式是针对 Java 语⾔语法的,如果你熟悉的是其他语⾔,不妨
对⽐ Java 的这⼏种实现⽅式,⾃⼰试着总结⼀下,利⽤你熟悉的语⾔,该如何实现。
1. 饿汉式
饿汉式的实现⽅式⽐较简单。在类加载的时候,instance 静态实例就已经创建并初始化好了,所以,instance 实例的创建过程是线程安全的。不过,这样的实现⽅式不⽀持延迟加载(在真正⽤到 IdGenerator 的时候,再创建实例),从名字中我们也可以看出这⼀点。具体的代码实现如下所⽰:
public class IdGenerator { private AtomicLong id = new AtomicLong(0); private static final IdGenerator instance = new IdGenerator(); private IdGenerato
有⼈觉得这种实现⽅式不好,因为不⽀持延迟加载,如果实例占⽤资源多(⽐如占⽤内存多)或初始化耗时长(⽐如需要加载各种配置⽂件),
提前初始化实例是⼀种浪费资源的⾏为。最好的⽅法应该在⽤到的时候再去初始化。不过,我个⼈并不认同这样的观点。
如果初始化耗时长,那我们最好不要等到真正要⽤它的时候,才去执⾏这个耗时长的初始化过程,这会影响到系统的性能(⽐如,在响应客
户端接⼝请求的时候,做这个初始化操作,会导致此请求的响应时间变长,甚⾄超时)。采⽤饿汉式实现⽅式,将耗时的初始化操作,提前
到程序启动的时候完成,这样就能避免在程序运⾏的时候,再去初始化导致的性能问题。
如果实例占⽤资源多,按照 fail-fast 的设计原则(有问题及早暴露),那我们也希望在程序启动时就将这个实例初始化好。如果资源不够,就会在程序启动的时候触发报错(⽐如 Java 中的 PermGen Space OOM),我们可以⽴即去修复。这样也能避免在程序运⾏⼀段时间后,突
然因为初始化这个实例占⽤资源过多,导致系统崩溃,影响系统的可⽤性。
2. 懒汉式
有饿汉式,对应的,就有懒汉式。懒汉式相对于饿汉式的优势是⽀持延迟加载。具体的代码实现如下所⽰:
public class IdGenerator { private AtomicLong id = new AtomicLong(0); private static IdGenerator instance; private IdGenerator() {} public static synchro
不过懒汉式的缺点也很明显,我们给 getInstance() 这个⽅法加了⼀把⼤锁(synchronzed),导致这个函数的并发度很低。量化⼀下的话,并发度是 1,也就相当于串⾏操作了。⽽这个函数是在单例使⽤期间,⼀直会被调⽤。如果这个单例类偶尔会被⽤到,那这种实现⽅式还可以接受。但是,如果频繁地⽤到,那频繁加锁、释放锁及并发度低等问题,会导致性能瓶颈,这种实现⽅式就不可取了。
3. 双重检测
饿汉式不⽀持延迟加载,懒汉式有性能问题,不⽀持⾼并发。那我们再来看⼀种既⽀持延迟加载、⼜⽀持⾼并发的单例实现⽅式,也就是双重检测实现⽅式。
在这种实现⽅式中,只要 instance 被创建之后,即便再调⽤ getInstance() 函数也不会再进⼊到加锁逻辑中了。所以,这种实现⽅式解决
了懒汉式并发度低的问题。具体的代码实现如下所⽰:
public class IdGenerator { private AtomicLong id = new AtomicLong(0); private static IdGenerator instance; private IdGenerator() {} public static IdGene
⽹上有⼈说,这种实现⽅式有些问题。因为指令重排序,可能会导致 IdGenerator 对象被 new 出来,并且赋值给 instance 之后,还没来
得及初始化(执⾏构造函数中的代码逻辑),就被另⼀个线程使⽤了。
要解决这个问题,我们需要给 instance 成员变量加上 volatile 关键字,禁⽌指令重排序才⾏。实际上,只有很低版本的 Java 才会有这个
问题。我们现在⽤的⾼版本的 Java 已经在 JDK 内部实现中解决了这个问题(解决的⽅法很简单,只要把对象 new 操作和初始化操作设计
为原⼦操作,就⾃然能禁⽌重排序)。关于这点的详细解释,跟特定语⾔有关,我就不展开讲了,感兴趣的同学可以⾃⾏研究⼀下。
4. 静态内部类
我们再来看⼀种⽐双重检测更加简单的实现⽅法,那就是利⽤ Java 的静态内部类。它有点类似饿汉式,但⼜能做到了延迟加载。具体是怎么做到的呢?我们先来看它的代码实现。
public class IdGenerator { private AtomicLong id = new AtomicLong(0); private IdGenerator() {} private static class SingletonHolder{ private static final
SingletonHolder 是⼀个静态内部类,当外部类 IdGenerator 被加载的时候,并不会创建 SingletonHolder 实例对象。只有当调⽤getInstance() ⽅法时,SingletonHolder 才会被加载,这个时候才会创建 instance。instance 的唯⼀性、创建过程的线程安全性,都由JVM 来保证。所以,这种实现⽅法既保证了线程安全,⼜能做到延迟加载。
5. 枚举
最后,我们介绍⼀种最简单的实现⽅式,基于枚举类型的单例实现。这种实现⽅式通过 Java 枚举类型本⾝的特性,保证了实例创建的线程安全性和实例的唯⼀性。具体的代码如下所⽰:
public enum IdGenerator { INSTANCE; private AtomicLong id = new AtomicLong(0); public long getId() { return id.incrementAndGet(); }}
重点回顾
好了,今天的内容到此就讲完了。我们来总结回顾⼀下,你需要掌握的重点内容。
1. 单例的定义
单例设计模式(Singleton Design Pattern)理解起来⾮常简单。⼀个类只允许创建⼀个对象(或者叫实例),那这个类就是⼀个单例类,这种设计模式就叫作单例设计模式,简称单例模式。
2. 单例的⽤处
从业务概念上,有些数据在系统中只应该保存⼀份,就⽐较适合设计为单例类。⽐如,系统的配置信息类。除此之外,我们还可以使⽤单例解决资源访问冲突的问题。
3. 单例的实现
单例有下⾯⼏种经典的实现⽅式。
饿汉式
饿汉式的实现⽅式,在类加载的期间,就已经将 instance 静态实例初始化好了,所以,instance 实例的创建是线程安全的。不过,这样的实现⽅式不⽀持延迟加载实例。
懒汉式
懒汉式相对于饿汉式的优势是⽀持延迟加载。这种实现⽅式会导致频繁加锁、释放锁,以及并发度低等问题,频繁的调⽤会产⽣性能瓶颈。双重检测
双重检测实现⽅式既⽀持延迟加载、⼜⽀持⾼并发的单例实现⽅式。只要 instance 被创建之后,再调⽤ getInstance() 函数都不会进⼊到加锁逻辑中。所以,这种实现⽅式解决了懒汉式并发度低的问题。
静态内部类
利⽤ Java 的静态内部类来实现单例。这种实现⽅式,既⽀持延迟加载,也⽀持⾼并发,实现起来也⽐双重检测简单。
枚举
最简单的实现⽅式,基于枚举类型的单例实现。这种实现⽅式通过 Java 枚举类型本⾝的特性,保证了实例创建的线程安全性和实例的唯⼀性。
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。
发表评论