单例模式(Java和Kotlin分别实现)
单例模式定义
确保某一个类只有一个实例,而且自行实例化并向整个系统提供这个实例
单例模式使用场景
确保某个类有且只有一个对象的场景,避免产生多个对象消耗过多资源,或者某种类型的对象应该有且只有一个。例如,创建一个对象需要消耗的资源过多,如要访问 IO 和数据库等资源,这时就要考虑使用单例模式。
实现单例模式主要有如下几个关键点:
(1)构造函数不对外开放,一般为 private
(2)通过一个静态方法或者枚举返回单例类对象
(3)确保单例类的对象有且只有一个,尤其是在多线程环境下
(4)确保单例类对象在反序列化时不会重新构建对象
通过将单例类的构造函数私有化,使得客户端代码不能通过 new 的形式手动构造单例类对象。单例类会暴露一个公有静态方法,客户端需要调用这个静态方法获取到单例类的唯一对象,在获取这个单例对象的过程中需要确保线程安全,即在多线程环境下构造单例类的对象也是有且只有一个,这也是实现单例模式实现中比较困难的地方。
单例模式的实现方式
1、饿汉模式
饿汉模式的代码如下:
// Java 实现
public class Singleton {
private static Singleton instance = new Singleton();
/**
* 构造私有函数
*/
private Singleton() {
}
/**
* 公有的静态函数,对外暴露获取单例对象的接口
*/
public static Singleton getInstance() {
return instance;
}
}
//Kotlin实现
object Singleton
Kotlin的对象声明,在Kotlin 中类没有静态方法。如果需要写一个可以无需用一个类的实例来调用,但需要访问类内部的函数(如:工厂方法,单例等),可以把该类声明为一个对象。该对象与其他语言的静态成员是类似的。
对象声明的初始化过程是线程安全的
饿汉模式代码比较简单,对象在类中被定义为 private static,通过 getInstance(),通过 Java 的 ClassLoader 机制保证了单例对象唯一。
instance什么时候被初始化?
Singleton 类被加载的时候就会被初始化,java虚拟机规范虽然没有强制性约束在什么时候开始类加载过程,但是对于类的初始化,虚拟机规范则严格规定了有且只有四种情况必须立即对类进行初始化,遇到new、getStatic、putStatic或invokeStatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。 生成这4条指令最常见的java代码场景是:
1)使用new关键字实例化对象
2)读取一个类的静态字段(被final修饰、已在编译期把结果放在常量池的静态字段除外)
3)设置一个类的静态字段(被final修饰、已在编译期把结果放在常量池的静态字段除外)
4)调用一个类的静态方法
class的生命周期?
class的生命周期一般来说会经历加载、连接、初始化、使用、和卸载五个阶段
2、懒汉模式
懒汉模式是声明一个静态对象,并且在用户第一次调用 getInstance() 时进行初始化,而饿汉模式是在声明静态对象时就已经初始化。
懒汉模式的代码如下:
public class Singleton {
private static Singleton instance;
/**
* 构造私有函数
*/
private Singleton() {
}
/**
* 公有的静态函数,对外暴露获取单例对象的接口
*/
public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
getInstance() 方法中添加了 synchronized 关键字,,也就是 getInstance() 是一个同步方法,这就是在多线程情况下保证单例对象唯一性手段。
问题:即使 instance 已经被初始化(第一次调用时就会被初始化 instance),每次调用 getInstance() 方法都会进行同步,这样会消耗不必要的资源,这也是懒汉单例模式存在的最大问题。
懒汉模式的优点是单例只有在使用时才会被实例化,在一定程度上节约了资源;缺点是第一次加载时需要及时进行实例化,反应稍慢,最大的问题是每次调用 getInstance() 都进行同步,造成不必要的开销。
3、Double Check Lock(DCL)双重检查实现单例
DCL 方式实现单例模式的优点是既能够在需要时才初始化单例,又能够保证线程安全,且单例对象初始化后调用 getInstance 不进行同步锁。代码如下所示:
public class Singleton {
/**
* 构造私有函数
*/
private Singleton() {
}
//第一层锁:保证变量可见性
private volatile static Singleton instance = null;
/**
* 公有的静态函数,对外暴露获取单例对象的接口
*/
public static synchronized Singleton getInstance() {
if (instance == null) {//第一次判空:无需每次都加锁,提高性能
synchronized (Singleton.class){//第二层锁:保证线程同步
if (instance == null) {//第二次判空:避免多线程同时执行getInstance()产生多个single对象
instance = new Singleton();
}
}
}
return instance;
}
}
//kotlin实现
class Singleton private constructor() {
companion object {
val instance: Singleton by lazy { Singleton() }
}
}
- 显示声明构造方法为private
- companion object 用来在 class 内部声明一个对象
- Singleton 的实例 instance 通过 lazy 来实现懒汉式加载
- lazy 默认情况下是线程安全的,这就可以避免多个线程同时访问生成多个实例的问题
可参考 lazy讲解
getInstance 方法中对 instance 进行了两次判空:第一次判断主要是为了避免不必要的同步,第二层是为了在 null 的情况下创建实例。
假设线程 A执行到 instance = new Singleton(); 语句,这里看起来是一句代码,但实际上并不是一个原子操作,这句代码最终被编译成多条汇编指令,它大致做了3件事:
(1)给 Singleton 的实例分配内存
(2)调用 Singleton 的构造函数,初始化成员字段
(3)将 instance 对象指向分配的内存空间(此时 instance 就不是 null 了)
原子操作是指不会被线程调度机制打断的操作
在多进程(线程)访问共享资源时,能够确保所有其他的进程(线程)都不在同一时间内访问相同的资源。原子操作(atomic operation)是不需要synchronized
如果这个操作所处的层(layer)的更高层不能发现其内部实现与结构,那么这个操作是一个原子(atomic)操作。
原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
由于 Java 编译器允许处理器乱序执行,上面的第(2)(3)顺序是无法保证的。也就是说,执行顺序可能是 1-2-3 也可能是 1-3-2。如果是后者,并且在(3)执行完毕、(2)为执行之前,被切换到线程 B 上,这时候 instance 因为已经在线程 A 内执行过了第三点,instance 已经是非空了,所以 B 线程直接取走 instance,在使用时就会出错,这就是 DCL 失效问题。
解决:只需要将 instance 的定义改为 private volatile static Singleton instance = null; 就可以保证 instance 对象每次都是从主内存中读取,就可以使用 DCL 的写法来完成单例模式。
volatile一般用于多线程的可见性,但是这里是用来防止指令重排序的。
为什么需要volatile?volatile有什么用?
- 可见性:可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
- 防止指令重排序:防止 new Singleton 时指令重排序导致其他线程获取到未初始化完的对象。
DCL 的优点:资源利用率高,第一次执行 getInstance() 时单例对象才会被实例化,效率高
缺点:第一次加载时反应稍慢,也由于 Java 内存模型的原因偶尔会失败
4、静态内部类单例
DCL 虽然在一定程度上解决了资源消耗,多余的同步,线程安全等问题,但它还是在某些情况下出现失效的问题。这个问题被称为双重检查锁定(DCL)失效。
建议使用如下代码替代:
public class Singleton {
/**
* 构造私有函数
*/
private Singleton() {
}
/**
* 公有的静态函数,对外暴露获取单例对象的接口
*/
public static Singleton getInstance() {
return Holder.INSTANCE;
}
private static class Holder {
private static final Singleton INSTANCE = new Singleton();
}
}
//kotlin实现
class Singleton private constructor() {
companion object {
val instance = Holder.holder
}
private object Holder {
val holder = Singleton()
}
}
第一次加载 Singleton 类时,并不会初始化 INSTANCE ,只有在第一次调用 Singleton 的 getInstance 方法时才会导致 INSTANCE 被初始化。因此第一次调用 getInstance 方法会导致虚拟机加载 Holder 类,这种方式不仅能够确保线程安全,也能够保证单例对象的唯一性,同时也延迟了单例实例化。
5、枚举单例
枚举单例的代码如下:
public enum Singleton {
INSTANCE;
public void doSomething(){
}
}
枚举在 Java 中与普通的类一样,不仅能够有字段,还能够有自己的方法。默认枚举实例的创建是线程安全的,并且在任何情况下它都是一个单例。
在上述的几种单例模式实现中,在反序列化的情况下它们会出现重新创建对象。
通过序列化可以将一个单例的实例写到磁盘,然后在读回来,从而有效的获取一个实例。即使构造函数是私有的,反序列化时依然可以通过特殊的途径去创建类的一个新的实例,相当于调用该类的构造函数。反序列化操作提供了一个很特别的钩子函数,类中具有一个私有的 readResolve() 函数,这个函数可以让开发人员控制对象的反序列化。例如,上述几个示例中,如果要杜绝单例对象在被反序列化时重新生成对象,那么必须加入 readResolve 函数。
public class Singleton implements Serializable {
private static final long serialVersionUID = 0L;
private static final Singleton INSTANCE = new Singleton();
private Singleton() {
}
public static Singleton getInstance() {
return INSTANCE;
}
private Object readResolve() throws ObjectStreamException {
return INSTANCE;
}
}
也就是在 readResolve 方法中将单例对象返回,而不是重新生成一个新的对象。对于枚举,并不存在这个问题,因为即使反序列化也不会重新生成新的实例。两点需要注意:
(1)可序列化类中的字段类型不是 Java 的内置类型,那么该字段类型也需要实现 Serializable 接口;
(2)如果调整了可序列化类的内部结构,例如新增、去除某个字段,但没有修改 serialVersionUID,那么会引发 java.io.InvalidClassException 异常或者导致某个属性为 0 或者 null。此时最好的方案是直接将 serialVersionUID 设置为 0L,这样即使修改了类的内部结构,我们反序列化不会抛出 java.io.InvalidClassException,只是那些新修改的字段会为 0 或者 null。
5、使用容器实现单例模式
具体代码如下:
public class SingletonManager {
private static Map<String, Object> mObjectMap = new HashMap<>();
private SingletonManager() {
}
public static void registerService(String key, Object instance) {
if (!mObjectMap.containsKey(key)) {
mObjectMap.put(key, instance);
}
}
public static Object getService(String key) {
return mObjectMap.get(key);
}
}
在程序的初始,将多种单例类型注入到一个统一的管理类中,在使用时根据 key 获取对象对应类型的对象。这种方式使得我们可以管理多种类型的单例,并且在使用时通过统一的接口进行获取操作,降低了用户的使用成本,也对用户隐藏了具体实现,降低了耦合度。
优点:
(1)由于单例模式在内存中只有一个实例,减少了内存开支,特别是一个对象需要频繁的创建、销毁时,而且创建或销毁时性能又无法优化,单例模式的优化就非常明显。
(2)由于单例模式只生成一个实例,所以减少了系统的性能开销,当一个对象的产生需要比较多的资源时,如读取配置、产生其他依赖对象时,则可以通过在应用启动时直接产生一个单例对象,然后永久驻留内存的方式来解决。
(3)单例模式可以避免对资源的多重占用,例如一个写文件操作,由于只有一个实例在内存中,避免对同一个资源文件的同时写操作。
(4)单例模式可以在系统设置全局的访问点,优化和共享资源访问,例如,可以设计一个单例类,负责所有数据表的映射处理。
缺点:
(1)单例模式一般没有接口,扩展很困难,若要扩展,除了修改代码基本没有第二种途径可实现。
(2)单例对象如果持有 Context,那么很容易引发内存泄漏,此时需要注意传递给单例对象的 Context 最好是 Application Context。