单例概述
单例模式(Singleton Pattern)是 Java 中最简单的设计模式之一。它涉及到一个单一的类,该类负责创建自己的对象,同时确保只有单个对象被创建。简单点说,就是一个应用程序中,某个类的实例对象只有一个,你没有办法去 new,因为构造器是被 private 修饰的,一般通过getInstance()的方法来获取它们的实例。
- 主要解决:一个全局使用的类频繁地创建与销毁。
- 何时使用:当想控制实例数目,节省系统资源的时候。
- 如何解决:判断系统是否已经有这个单例,有则返回,没有则创建。
- 关键代码:构造函数是私有的,使用
getInstance()去获取实例getInstance()的返回值是一个对象的引用,并不是一个新的实例,所以不要错误的理解成多个对象。
单例模式需要满足的条件:
- 单例类只能有一个实例;
- 单例类必须自己创建自己的唯一实例;
- 单例类必须给所有其他对象提供这一实例。
单例模式的使用场景:
- 要求生产唯一序列号。
- WEB中的计数器,不用每次刷新都在数据库里加一次,用单例先缓存起来。
- 创建的一个对象需要消耗的资源过多,比如 I/O 与数据库的连接等。
单例模式实现起来也很容易,直接看如下实现代码。
实现方法
1. 懒汉式写法
懒汉式:线程不安全
懒汉式顾名思义,会延迟加载,在初次使用该单例时才会实例化对象出来。首次调用时要做初始化,如果要做的工作比较多,性能会有所延迟,之后就和饿汉式一样了。
public class Singleton {
private static Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
对于以上代码,如果此时有两个线程,线程A执行到某处读取了 instance 为 null,然后 CPU 就被线程 B 抢去了,此时线程 A 还没有对 instance 进行实例化。因此,线程 B 读取 instance 时仍然为 null,于是它对 nstance 进行实例化了。然后,CPU 就被线程 A 抢去了。此时,线程 A 由于已经读取了 instance 的值并且认为它为 null,再次对 instance 进行实例化。所以,线程 A 和线程 B 返回的不是同一个实例。
懒汉式:加锁使线程安全
public class Singleton {
private static Singleton instance;
private Singleton() { }
public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
从 1.0 版本开始,Java 中的每一个对象都有一个内部锁,并且该锁有一个内部条件,由锁来管理那些试图进入synchronized方法的线程,由条件来管理那些调用 wait 的线程。如果一个方法用synchronized关键字声明,那么对象的锁将保护整个方法。也就是说,要调用该方法,线程必须获得内部的对象锁。
这种解决方式有个问题:假如有 100 个线程同时执行,那么每次去执行getInstance()方法时都要先获得锁再去执行方法体。如果没有锁就要等待,耗时长,感觉像是变成了串行处理。
缺点:性能不高,同步范围太大。在实例化instacne后,获取实例仍然是同步的,效率太低,需要缩小同步的范围。所以用如下解决方法:
懒汉式:减小锁的粒度
public class Singleton {
private static Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized(Singleton.class) {
instance = new Singleton();
}
}
return instance;
}
}
这样处理就没有问题了吗?同样的原理,线程 A 读取到 instance 值为 null,此时 CPU 被线程B抢去了,线程 B 判断到 instance 值为 null,于是它开始执行同步代码块中的代码,对 instance 进行实例化。此后,线程 A 重新获得 CPU,由于线程 A 之前已经判断 instance值为 null,于是开始执行它后面的同步代码块代码。它也会去对 instance 进行实例化。这样就导致了还是会创建两个不一样的实例。
缺点:虽然缩小了同步范围来提高性能,但是仍然存在多次执行instance=new Singleton()的可能,由此引出双重校验锁。
懒汉式:双重校验锁
public class Singleton {
private static Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
但是,双重检查加锁并不表示一定没有线程安全问题了。
指令重排序存在的问题
Java内存模型(Java Memory Model)并不限制处理器重排序。即instatnce = new Singleton()并不是原子语句,其实可以分为下面的步骤:
- 申请一块内存空间;
- 在这块空间里实例化对象;
- instance 的引用指向这块空间地址(instance 指向分配的内存空间后就不为 null 了)。
对于以上步骤,指令重排序很有可能不是按上面 1、2、3 步骤依次执行的。比如,先执行 1 申请一块内存空间,然后执行 3 步骤, instance 的引用去指向刚刚申请的内存空间地址。那么,当它再去执行 2 步骤,判断 instance 时,由于 instance 已经指向了某一地址,它就不会再为 null 了,因此,也就不会实例化对象了。这就是所谓的指令重排序安全问题。那么,如何解决这个问题呢?
解决指令重排序
加上 volatile 关键字,因为 volatile 可以禁止指令重排序。volatile 可以保证 1、2、3 的执行顺序,没执行完 1、2 就肯定不会执行 3,也就是没有执行完 1、2,instance 一直为空。这样就可以保证 3 步骤(instance 赋值操作)是最后一步完成,这样就不会出现 instance 在对象没有初始化时就不为 null 的情况了。这样也就实现了正确的单例模式了。具体代码如下:
懒汉式:双重校校验锁终极写法
public class Singleton {
private volatile static Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
lazy loading:“懒加载”也被叫作“延迟加载”,它的核心思想是把对象的实例化延迟到真正调用该对象的时候,这样做的好处是可以减轻大量对象在实例化时对资源的消耗,而不是在程序初始化的时候就预先将对象实例化。另外延迟加载可以将对象的实例化代码从初始化方法中独立出来,从而提高代码的可读性,以便于代码能够更好地组织。
2. 饿汉式写法
饿汉式在类创建的同时就实例化一个静态对象出来,不管之后会不会使用这个单例,都会占据一定的内存,但是相应的,在第一次调用时速度也会更快,因为其资源已经初始化完成。
public class Singleton {
private static Singleton instance = new Singleton();
private Singleton() {}
public static Singleton getInstance() {
return instance;
}
}
这种方式基于classloder机制,类加载时就创建单例,保证了实例的单例。类加载机制保证单例见下文讲解。
3. 静态内部类
public class Singleton {
// 静态内部类
private static class SingletonHolder {
private static final Singleton INSTANCE = new Singleton();
}
private Singleton(){}
public static final Singleton getInstance() {
return SingletonHolder.INSTANCE;
}
}
这种方式同样利用了 classloder的机制来保证初始化 instance 时只有一个线程,它跟饿汉式不同的是:饿汉式只要 Singleton 类被装载了,那么 instance 就会被实例化(没有达到 lazy loading 的效果),而这种方式是 Singleton 类被装载了,instance 不一定被初始化。因为 SingletonHolder 类没有被主动使用,只有显式通过调用 getInstance 方法时,才会显式装载 SingletonHolder 类,从而实例化 instance。想象一下,如果实例化 instance 很消耗资源,我就应该让它延迟加载;另外一方面,我不希望在 Singleton 类加载时就实例化,因为我不能确保 Singleton 类是否还可能在其他的地方被主动使用而被加载,那么这个时候实例化 instance 显然是不合适的。这个时候,使用静态内部类方式比使用饿汉式显得合理。
饿汉式和静态内部类实现的单例模式是线程安全的,因为使用了static,然后在类加载的时候是线程安全的。这里虽然没有直接使用synchronized,但是也是间接用到了。
类加载过程的线程安全性保证
静态内部类、饿汉式实现方式均是通过定义静态的成员变量,以保证单例对象可以在类初始化的过程中被实例化。这其实是利用了ClassLoader 的线程安全机制。ClassLoader的loadClass方法在加载类的时候使用了synchronized关键字。所以, 除非被重写,否则这个方法默认在整个装载过程中都是线程安全的,所以在类加载过程中对象的创建也是线程安全的。
4. 枚举
枚举类实现单例模式相当硬核,因为枚举类型是线程安全的,且只会装载一次。使用枚举类来实现单例模式,是所有的单例实现中唯一一种不会被破坏的单例模式实现。
public enum EnumSingleton {
INSTANCE;
public EnumSingleton getInstance(){
return INSTANCE;
}
}
那么枚举单例是如何保证线程安全的呢?为了了解它的实现原理,来看一下反编译后的代码:
public final class EnumSingleton extends Enum<EnumSingleton> {
public static final EnumSingleton ENUMSINGLETON;
public static EnumSingleton[] values();
public static EnumSingleton valueOf(String s);
static {};
}
枚举底层是依赖Enum类实现的,这个类的成员变量都是 static 类型的,并且在静态代码块中实例化的,和饿汉式有点像, 所以它天然是线程安全的。所以,枚举其实也是借助了 synchronized 的。
从 Enum 的源码中看到,大部分的方法都是 final 修饰的,特别是 clone、readObject、writeObject 这三个方法,保证了枚举类型的不可变性,不能通过克隆、序列化和反序列化复制枚举,这就保证了枚举变量只是一个实例,即是单例的。
这种方式是 Effective Java 作者 Josh Bloch 提倡的方式,它不仅能避免多线程同步问题,是线程安全的,而且还能防止反序列化重新创建新的对象,可谓是很坚强的壁垒。
解决的问题:避免反射攻击和避免序列化问题
写在最后
1. 总结
- 实现单例模式必须要考虑线程的安全,实现安全的方式是加锁。
- 懒汉式直接使用 synchronized 实现;饿汉式、静态内部类、枚举间接使用 synchronized 实现线程安全(static 关键字)
2. 单例模式的优缺点
优点:
- 在内存里只有一个实例,减少了内存的开销,尤其是频繁的创建和销毁实例(比如管理学院首页页面缓存);
- 避免对资源的多重占用(比如写文件操作)。
缺点:
- 没有接口,不能继承
- 与单一职责原则冲突,一个类应该只关心内部逻辑,而不关心外面怎么样来实例化。
3. 破坏单例
如何破坏单例?通过这个问题可以引申到序列化和反射的相关知识。
作者信息
大家好,我是 CoderGeshu,一个热爱生活的程序员,如果这篇文章对您有所帮助,还请大家给点个赞哦 👍👍👍
另外,欢迎大家关注本人同名公众号:CoderGeshu,一个致力于分享编程技术知识的公众号!!
一个人可以走的很快,而一群人可以走的很远……







网友评论