1. 单例模式是什么?
一种最简单的最常用的设计模式,同时也是面试最常问的一种设计模式
单例模式:采取一定的方法保证在整个系统中,对某个类 只能存在一个对象实例 ,并且该类只提供一个取得其对象实例的静态方法。
单例模式 保证了系统内存中该类只存在一个对象,节省了系统资源,对于一些 需要频繁创建销毁 的对象,使用单例模式可以提高系统性能。
单例模式 主要解决的痛点就是 设计一个 全局使用 的类频繁的去 创建和消费 ,从而提升代码性能,提高系统性能。
单例模式的使用场景 (频繁创建和消费的对象、重量级但常用的对象)
- 数据库连接池
- Spring Ioc中单例模式的Bean的生成和使用
- 业务上需要设置全局的属性保存、需要一个全局的对象实例
- 工具类对象、频繁访问数据库或文件的对象
2. 单例模式的实现方式
单例模式是如何使一个类只存在一个对象的呢?
下面介绍以下六种实现方式
- 饿汉式
- 懒汉式
- 静态内部类
- 双重锁检查
- CAS实现
- 枚举
2.1 饿汉式实现
静态常量
public class Singleton {
// 静态常量
private static final Singleton instance = new Singleton();
// 私有化构造器
private Singleton() {}
// 获取单例的静态方法
public static Singleton getInstance(){
return instance;
}
}
复制代码
静态代码块
public class Singleton {
private static Singleton instance;
// 静态代码块
static {
instance = new SingletonReal02();
}
// 私有化构造器
private Singleton() {}
// 获取单例的静态方法
public static Singleton getInstance(){
return instance;
}
}
复制代码
总结
- 为什么可以实现加载一次?
- 因为 类加载在程序启动时进行加载 ,完成实例化,这样就避免了 线程同步 问题。
- 后续想要拿到该实例使用时,只需要通过静态方法获取即可。
- 缺点
- 显而易见的这种方式并 不是懒加载 (用的时候才实例化),即无论程序中是否用到过都已经加载好了
- 这样是会带来 内存浪费 的
2.2 懒汉式实现
线程不安全
优点:懒加载, 可以在 单线程 下使用
缺点:当有多个线程执行代码时,可能会创建多个实例,不再是单例 (即线程不安全)
public class Singleton {
private static Singleton instance;
// 私有化构造器
private Singleton() {}
// 获取单例的静态方法
public static Singleton getInstance(){
if (instance == null){
instance = new Singleton();
}
return instance;
}
}
复制代码
线程安全 - 同步方法
优点:解决了线程安全问题
缺点:众所周知
synchronized
还是蛮影响效率的,所有线程访问时都因为锁导致资源浪费,不是很划算呢。
public class Singleton {
private static Singleton instance;
// 私有化构造器
private Singleton() {}
// 获取单例的静态方法
public static synchronized Singleton getInstance(){
if (null != instance) return instance;
instance = new Singleton();
return instance;
}
}
复制代码
2.3 静态内部类
先说好处(●'◡'●) - 推荐使用方法之一
优点:避免了线程不安全,利用静态内部类可以实现 延迟加载
为什么能做到这些优点呢?
① 静态内部类并不会因为
Singleton
被装载时实例化,而是在调用getInstance
方法才会使SingletonInstance
装载从而才会完成Singleton
的实例化。(即懒加载)② 类的静态属性只会在第一次加载类时初始化,这归功于JVM虚拟机可以保证多线程并发访问的正确性,也就是一个类的构造方法在多线程环境下可以被正确的加载(类初始化时,其他线程是无法进入的)。(即线程安全)
public class Singleton {
// 私有化构造器
private Singleton {}
// 静态内部类
private static class SingletonInstance {
private static final Singleton INSTANCE = new Singleton();
}
// 获取单例的静态方法
public static Singleton getInstance(){
return SingletonInstance.INSTANCE;
}
}
复制代码
2.4 双重锁检查
先说好处(●'◡'●) - 推荐使用方式之一
优点:线程安全、延迟加载(懒加载)、效率蛮高
Double-Check其实是对方法级锁的优化、减少了部分获取实例的耗时
public class Singleton {
private static volatile Singleton instance;
// 私有化构造器
private Singleton() {}
// 获取单例的静态方法
public static Singleton getInstance() {
if (instance == null){
synchronized (Singleton.class){
if (instance == null){
instance = new Singleton();
}
}
}
return instance;
}
}
复制代码
一个困扰许久的问题 - 为什么使用
volatile
?如果单说双重锁检查的话,确实
getInstance
里的代码足以满足两次检查这个方法首先判断变量是否被初始化,没有被初始化,再去获取锁。获取锁之后,再次判断变量是否被初始化。第二次判断目的在于有可能其他线程获取过锁,已经初始化改变量。第二次检查还未通过,才会真正初始化变量。
看似确实已经很完美了,但其中还是有一些问题被忽略了
这个被忽略的问题就是 你真的了解 new
背后的指令吗?
从字节码可以看到创建一个对象实例,可以分为三步:
- 分配对象内存
- 调用构造器方法,执行初始化
- 将对象引用赋值给变量
没错,new
在字节码层面分成了三步,最重要的是它不是一个原子指令 /(ㄒoㄒ)/
所以就会出现这样的情况:如果线程 1 获取到锁进入创建对象实例,而这个时候发生了指令重排序,完成了第一步的分配内存和第三步的变量赋值,但还没有完成第二步的初始化对象。刚好线程1 停滞了一会,线程 2 刚好进入,由于此时对象已经不为 Null,所以线程 2 可以自由访问该对象。然后该对象还未初始化,所以线程 2 访问时将会发生异常。
知道了原因,那为什么要使用 volatile
呢?
因为 volatile
具有两个特性 -- 可见性、禁止指令重排
正因为 volatile
的禁止指令重排,从而使得其他线程不会访问到一个尚未初始化的对象,从而保证安全性。
总结就是:对象的创建可能发生指令的重排序,而volatile
可以避免,保证多线程环境的安全性。
2.5 CAS实现
public class Singleton {
private static final AtomicReference<Singleton> INSTANCE = new AtomicReference<>();
// 私有化构造器
public Singleton() {}
// 获取单例的静态方法
public static Singleton getInstance() {
for (; ;) {
Singleton instance = INSTANCE.get();
if (instance != null) return instance;
INSTANCE.compareAndSet(null, new Singleton());
return INSTANCE.get();
}
}
}
复制代码
使用
AtomicReference<Singleton>
可以引用Singleton
实例,提供了原子性,保证了并发访问的安全性使用CAS的好处在于不需要使用传统的加锁方式来保证线程安全,依赖于CAS的忙等来保证线程安全,并且去除了加锁实现对线程切换和阻塞带来的额外开销,支持较大的并发
当然CAS也有缺陷:忙等的代价就是如果一直未获取会处于死循环,导致CPU开销过大
2.6 枚举
Effective Java 作者推荐使用枚举的方式 -- 枚举
好处:线程安全、自由串行化(防止反序列造成重新创建对象)
public enum Singleton {
INSTANCE;
}
复制代码
3. 总结
- 看了这么多的单例模式实现方式,应该也注意到代码中注释的内容中一直在强调的
- 第一步:私有化构造器
- 第二步:写一个获取单例的静态方法
- 如果开发中并不要求懒加载,可以使用饿汉式实现单例模式
- 推荐使用:双重锁检查、静态内部类、枚举