单例模式
一、什么是单例模式
单例模式是一种常用的软件设计模式,其定义是单例对象的类只能允许一个实例存在。
许多时候整个系统只需要拥有一个的全局对象,这样有利于我们协调系统整体的行为。比如在某个服务器程序中,该服务器的配置信息存放在一个文件中,这些配置数据由一个单例对象统一读取,然后服务进程中的其他对象再通过这个单例对象获取这些配置信息。这种方式简化了在复杂环境下的配置管理。
单例的实现主要是通过以下两个步骤:
- 将该类的构造方法定义为私有方法,这样其他处的代码就无法通过调用该类的构造方法来实例化该类的对象,只有通过该类提供的静态方法来得到该类的唯一实例;
- 在该类内提供一个静态方法,当我们调用这个方法时,如果类持有的引用不为空就返回这个引用,如果类保持的引用为空就创建该类的实例并将实例的引用赋予该类保持的引用。
二、单例模式的应用场景
1. 处理资源访问冲突
比如在做打印日志操作的时候,有一个Logger类,类中有个FileWriter将流写到磁盘上
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());
}
}
上面的代码将所有的日志都写到同一个文件中,如果没有使用单例模式,那么在两个Controller中,就分别创建了两个Logger对象,在web容器中的多线程环境中,两个对象对于磁盘的同一个文件进行写操作,就有可能出现日志信息覆盖的情况。
解决的方法有很多
-
给log函数加锁,同一时刻就只有一个线程调用执行log函数
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(this) { writer.write(mesasge); } } }
synchronized(this)
这种对象级别的锁,锁不住,因为不同的对象之间并不共享同一把锁。所以我们换成类级别的锁。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); } } }
-
分布式锁
-
使用java中的并发队列,多个线程向并发队列中写日志,单独的线程负责将并发数据中的数据写到日志文件
-
使用单例模式。单例模式相对于之前类级别锁的好处是,不用创建那么多 Logger 对象,一方面节省内存空间,另一方面节省系统文件句柄(对于操作系统来说,文件句柄也是一种资源,不能随便浪费)因为如果有多个对象对于文件进行读写的话,那么每次都要生成一个句柄。
在文件I/O中,要从一个文件读取数据,应用程序首先要调用操作系统函数并传送文件名,并选一个到该文件的路径来打开文件。该函数取回一个顺序号,即文件句柄(file handle),该文件句柄对于打开的文件是唯一的识别依据。要从文件中读取一块数据,应用程序需要调用函数ReadFile,并将文件句柄在内存中的地址和要拷贝的字节数传送给操作系统。当完成任务后,再通过调用系统函数来关闭该文件。
2. 表示全局唯一类
从业务概念上,如果有些数据在系统中只应保存一份,那就比较适合设计为单例类。比如,配置信息类。在系统中,我们只有一个配置文件,当配置文件被加载到内存之后,以对象的形式存在,也理所应当只有一份。
再比如,唯一递增 ID 号码生成器。如果程序中有两个对象,那就会存在生成重复 ID 的情况,所以,我们应该将 ID 生成器类设计为单例。
二、单例模式的实现
1.饿汉式
// 饿汉式单例
public class Singleton1 {
// 指向自己实例的私有静态引用,主动创建
//在类加载阶段就已经加载完毕了(加载 链接 初始化的初始化阶段是Cinit)
private static Singleton1 singleton1 = new Singleton1();
// 私有的构造方法
private Singleton1(){}
// 以自己实例为返回值的静态的公有方法,静态工厂方法
public static Singleton1 getSingleton1(){
return singleton1;
}
}
我们知道,类加载的方式是按需加载,且加载一次(在类加载的时候对象就生成了,加载链接初始化)因此,在上述单例类被加载时,就会实例化一个对象并交给自己的引用,供系统使用;而且,由于这个类在整个生命周期中只会被加载一次,因此只会创建一个实例,即能够充分保证单例。
优点:这种写法比较简单,就是在类装载的时候就完成实例化。避免了线程同步问题。
缺点:在类装载的时候就完成实例化,没有达到Lazy Loading的效果。如果从始至终从未使用过这个实例,则会造成内存的浪费。
2.进阶版饿汉式单例
// 饿汉式单例
//为什么加final : 防止该类被继承后,某些子类覆盖了一些方法破坏单例规则
public final class Singleton1 implements Serializable {
//为什么要继承serializable接口
//如果实现了序列化接口,做什么来防止反序列化破坏单例
添加一个固定的方法://当反序列话过程中,发现有这个方法的话就会直接返回该对象而不是字节码生成的对象
public Object readResolve(){
return singleton1;
}
// 指向自己实例的私有静态引用,主动创 建
private static Singleton1 singleton1 = new Singleton1();
//这样初始化由类加载阶段去生成该单例对象。此时线程安全由JVM来保障
// 私有的构造方法
private Singleton1(){}
//这里设置为私有也不能防止反射来创建实例,因为存在着暴力反射
// 以自己实例为返回值的静态的公有方法,静态工厂方法
public static final Singleton1 getSingleton1(){
return singleton1;
}
//为什么这里要这样返回而不是直接将singleton的权限改为public 这样就可以直接返回了
//1.这样实现可以提供了一个公共接口,实现更好的封装。 2.为了可以去支持泛型
}
3.懒汉式
// 懒汉式单例
public class Singleton2 {
// 指向自己实例的私有静态引用
private static Singleton2 singleton2 = null ;
// 私有的构造方法
private Singleton2(){}
// 以自己实例为返回值的静态的公有方法,静态工厂方法
public static Singleton2 getSingleton2(){
// 被动创建,在真正需要使用时才去创建
if (singleton2 == null) {
singleton2 = new Singleton2();
}
return singleton2;
}
}
我们从懒汉式单例可以看到,单例实例被延迟加载,即只有在真正使用的时候才会实例化一个对象并交给自己的引用。
这种写法起到了Lazy Loading的效果,但是只能在单线程下使用。如果在多线程下,一个线程进入了if (singleton == null)判断语句块,还未来得及往下执行,另一个线程也通过了这个判断语句,这时便会产生多个实例。所以在多线程环境下不可使用这种方式。
其实这里也可以修改一下让懒汉式单例变得线程安全
只需要在getSingleton2()方法上加个synchronized关键字,这样的话,因为是static修饰的,相当于锁住该类的类对象。但是效率太低:比如当单例创建好之后,此时不会存在任何线程安全问题,但是每次访问仍然要加锁。导致效率低下。
4.枚举实现
// 问题1:枚举单例是如何限制实例个数的
通过分析字节码可以看到该枚举类就是的Instace就是一个内部静态成员变量
public final static enum 类名 对象名
// 问题2:枚举单例在创建时是否有并发问题
也没有,它也是静态成员变量,在类加载的时候就初始化完毕了。
// 问题3:枚举单例能否被反射破坏单例
不能。反射在通过Newinstance创建对象会检查该类是否是枚举类型,是的话就反射失败
// 问题4:枚举单例能否被反序列化破坏单例
//枚举类默认实现序列化接口,因为枚举类继承于Enum类,而Enum类实现了该接口
因为Enum类中反序列化是通过valueOf()实现的,不是通过反射,反序列化后得到还是该单例
// 问题5:枚举单例属于懒汉式还是饿汉式
//是饿汉式的
// 问题6:枚举单例如果希望加入一些单例创建时的初始化逻辑该如何做
//通过构造方法,枚举类也有构造方法的
enum Singleton {
INSTANCE; //枚举相当于创建了一个对象放进去
}
-
借助了JDK1.5提供的枚举来实现单例模式,不仅避免了线程同步问题,而且还能反序列化重新创建新的对象
-
每一个枚举类的insance就是一个内部静态变量
-
因为是静态变量所以在类加载就初始化完毕了。是饿汉式的
-
不会被反射破坏单例,因为反射在class.forName(类全限定名).newinstance()的过程中会检查类型是否是枚举,如果是的话就会反射失败
-
枚举默认实现序列化接口,但是反序列化的过程不是通过反射实现的,而是通过ValueOf实现的
-
可以通过构造方法加入一些单例创建时的初始化逻辑
5.双重加锁机制
public class Singleton
{
private static Singleton instance;
//程序运行时创建一个静态只读的进程辅助对象
private static readonly object syncRoot = new object();
private Singleton() { }
public static Singleton GetInstance()
{
//先判断是否存在,不存在再加锁处理
if (instance == null)
{
//在同一个时刻加了锁的那部分程序只有一个线程可以进入
lock (syncRoot)
{
if (instance == null)
{
instance = new Singleton();
}
}
}
return instance;
}
}
Double-Check概念对于多线程开发者来说不会陌生,如代码中所示,我们进行了两次if (singleton == null)检查,这样就可以保证线程安全了。这样,实例化代码只用执行一次,后面再次访问时,判断if (singleton == null),直接return实例化对象。
使用双重检测同步延迟加载去创建单例的做法是一个非常优秀的做法,其不但保证了单例,而且切实提高了程序运行效率
优点:线程安全;延迟加载;效率较高。
但是实际上这里还有一点问题。因为在多线程的条件下,加锁的区间
lock (syncRoot)
{
if (instance == null)
{
instance = new Singleton();
}
}
可能会发生指令重排。
实际上Instace= new Singleton()由三步操作,第一步复制一个实例的引用,第二步通过该引用调用该构造方法对类进行初始化(这里分为两步,开辟空间,再去填充该空间)
第三步 将该引用赋值给instance
如果发生指令重排,将第二部排到了第一步也就是顺序变成了 1 3 2
那么此时在执行完该顺序下的第三步后,就会导致instance指向了一个没有初始化完全的实例
而此时另外一个线程进来进行判空,非空返回该单例。会有一定的错误。
如何解决?
使用volatile修饰Instance
6.静态内部类实现
public final class Singleton {
private Singleton() { }
// 问题1:属于懒汉式还是饿汉式
//懒汉式
//静态内部类在使用时完成类加载,不会因为外部类加载而加载
private static class LazyHolder {
static final Singleton INSTANCE = new Singleton();
}
// 问题2:在创建时是否有并发问题
public static Singleton getInstance() {
return LazyHolder.INSTANCE;
}
}
-
这种方式采用类加载的机制保证初始化实例时只有一个单例
-
静态内部类在使用时完成加载,从而完成Singleton的实例化
-
为什么单例模式下静态内部类线程安全
虚拟机会保证一个类的类构造器()在多线程环境中被正确的加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的类构造器(),其他线程都需要阻塞等待,直到活动线程执行()方法完毕。
特别需要注意的是,在这种情形下,其他线程虽然会被阻塞,但如果执行()方法的那条线程退出后,其他线程在唤醒之后不会再次进入/执行()方法,因为 在同一个类加载器下,一个类型只会被初始化一次。如果在一个类的()方法中有耗时很长的操作,就可能造成多个线程阻塞,在实际应用中这种阻塞往往是隐藏的
public final class Singleton{
private Singleton(){}
private static class LazyHolder{
static final Singleton Instance = new Singleton();
}
public static Singleton getInstance(){
return LazyHolder.Instance;
}
}
三、单例模式在JDK中的体现
Java.lang.Runtime就是经典的单例模式(饿汉式)
四、单例模式存在哪些问题?
1. 单例对 OOP 特性的支持不友好
OOP 的四大特性是封装、抽象、继承、多态。
2. 单例会隐藏类之间的依赖关系
由于单例类不需要创建,只要调用函数就能产生,所以如果代码特别复杂,那么调用关系就会比较隐蔽,在阅读代码时,就需要仔细查看每个函数的代码实现,才能知道这个类到底依赖了哪些单例类
3. 单例对代码的扩展性不友好
比如一开始对于数据库连接池只需要一个。
而后期某些sql查询比较慢,执行的时候长期占用数据库连接池的资源,导致其他查询无法响应。
为了解决这个问题,就希望将慢sql和其他sql隔离开执行,这样就需要要创建两个数据库连接池,慢sql独享一个连接池。
但是已经将数据库连接设置成单例了。显然无法应对这样的需求变更
所以,数据库连接池、线程池这类的资源池,最好还是不要设计成单例类。实际上,一些开源的数据库连接池、线程池也确实没有设计成单例类。
4. 单例不支持有参数的构造函数
比如我们创建一个连接池的单例对象,我们没法通过参数来指定连接池的大小。
解决思路是:将参数放到另外一个全局变量中。具体的代码实现如下。Config 是一个存储了 paramA 和 paramB 值的全局变量。里面的值既可以像下面的代码那样通过静态常量来定义,也可以从配置文件中加载得到。实际上,这种方式是最值得推荐的
public class Config {
public static final int PARAM_A = 123;
public static final int PARAM_B = 245;
}
public class Singleton {
private static Singleton instance = null;
private final int paramA;
private final int paramB;
private Singleton() {
this.paramA = Config.PARAM_A;
this.paramB = Config.PARAM_B;
}
public synchronized static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
如何理解单例模式中的唯一性?
- 一个类中只允许创建唯一一个对象,那这个类就是一个单例类
- “一个类只允许创建唯一一个对象”。那对象的唯一性的作用范围是什么呢?是指线程内只允许创建一个对象,还是指进程内只允许创建一个对象?答案是后者,也就是说,单例模式创建的对象是进程唯一的。这里有点不好理解,我来详细地解释一下。
- 我们编写的代码,通过编译、链接,组织在一起,就构成了一个操作系统可以执行的文件,也就是我们平时所说的“可执行文件”(比如 Windows 下的 exe 文件)。可执行文件实际上就是代码被翻译成操作系统可理解的一组指令,你完全可以简单地理解为就是代码本身。
- 当我们使用命令行或者双击运行这个可执行文件的时候,操作系统会启动一个进程,将这个执行文件从磁盘加载到自己的进程地址空间(可以理解操作系统为进程分配的内存存储区,用来存储代码和数据)。接着,进程就一条一条地执行可执行文件中包含的代码。比如,当进程读到代码中的 User user = new User(); 这条语句的时候,它就在自己的地址空间中创建一个 user 临时变量和一个 User 对象。进程之间是不共享地址空间的,如果我们在一个进程中创建另外一个进程(比如,代码中有一个 fork() 语句,进程执行到这条语句的时候会创建一个新的进程),操作系统会给新进程分配新的地址空间,并且将老进程地址空间的所有内容,重新拷贝一份到新进程的地址空间中,这些内容包括代码、数据(比如 user 临时变量、User 对象)。
- 所以,单例类在老进程中存在且只能存在一个对象,在新进程中也会存在且只能存在一个对象。而且,这两个对象并不是同一个对象,这也就说,单例类中对象的唯一性的作用范围是进程内的,在进程间是不唯一的。
如何实现线程唯一的单例?
-
刚刚我们讲了单例类对象是进程唯一的,一个进程只能有一个单例对象。那如何实现一个线程唯一的单例呢?
-
我们先来看一下,什么是线程唯一的单例,以及“线程唯一”和“进程唯一”的区别。
-
“进程唯一”指的是进程内唯一,进程间不唯一。类比一下,“线程唯一”指的是线程内唯一,线程间可以不唯一。实际上,“进程唯一”还代表了线程内、线程间都唯一,这也是“进程唯一”和“线程唯一”的区别之处。这段话听起来有点像绕口令
-
假设 IdGenerator 是一个线程唯一的单例类。在线程 A 内,我们可以创建一个单例对象 a。因为线程内唯一,在线程 A 内就不能再创建新的 IdGenerator 对象了,而线程间可以不唯一,所以,在另外一个线程 B 内,我们还可以重新创建一个新的单例对象 b。
-
实际上就是使用一个哈希map来对线程号和单例进行映射,这样就做到了一个线程只对应一个实例。Java 语言本身提供了 ThreadLocal 工具类,可以更加轻松地实现线程唯一单例。不过,ThreadLocal 底层实现原理也是基于下面代码中所示的 HashMap。
public class IdGenerator { private AtomicLong id = new AtomicLong(0); private static final ConcurrentHashMap<Long, IdGenerator> instances = new ConcurrentHashMap<>(); private IdGenerator() {} public static IdGenerator getInstance() { Long currentThreadId = Thread.currentThread().getId(); instances.putIfAbsent(currentThreadId, new IdGenerator()); return instances.get(currentThreadId); } public long getId() { return id.incrementAndGet(); } }
如何实现集群环境下的单例?
“集群唯一”就相当于是进程内唯一、进程间也唯一。也就是说,不同的进程间共享同一个对象,不能创建同一个类的多个对象。
-
具体来说,我们需要把这个单例对象序列化并存储到外部共享存储区(比如文件)。进程在使用这个单例对象的时候,需要先从外部共享存储区中将它读取到内存,并反序列化成对象,然后再使用,使用完成之后还需要再存储回外部共享存储区。
-
为了保证任何时刻,在进程间都只有一份对象存在,一个进程在获取到对象之后,需要对对象加锁,避免其他进程再将其获取。在进程使用完这个对象之后,还需要显式地将对象从内存中删除,并且释放对对象的加锁。按照这个思路,
如何实现一个多例模式?
跟单例模式概念相对应的还有一个多例模式。那如何实现一个多例模式呢?“单例”指的是,一个类只能创建一个对象。对应地,“多例”指的就是,一个类可以创建多个对象,但是个数是有限制的,比如只能创建 3 个对象。如果用代码来简单示例一下的话,就是下面这个样子:
public class BackendServer {
private long serverNo;
private String serverAddress;
private static final int SERVER_COUNT = 3;
private static final Map<Long, BackendServer> serverInstances = new HashMap<>();
static {
serverInstances.put(1L, new BackendServer(1L, "192.134.22.138:8080"));
serverInstances.put(2L, new BackendServer(2L, "192.134.22.139:8080"));
serverInstances.put(3L, new BackendServer(3L, "192.134.22.140:8080"));
}
private BackendServer(long serverNo, String serverAddress) {
this.serverNo = serverNo;
this.serverAddress = serverAddress;
}
public BackendServer getInstance(long serverNo) {
return serverInstances.get(serverNo);
}
public BackendServer getRandomInstance() {
Random r = new Random();
int no = r.nextInt(SERVER_COUNT)+1;
return serverInstances.get(no);
}
}