目录
定义
一个类在整个系统只能有一个实例,这样做的好处是防止创建多个重复对象消耗内存,而且操作 IO 和数据库等都是比较耗资源的,单例可以减少性能的开销
实现单例模式有多种方式,具体如下:
1、饿汉式(线程安全)
public class Singleton {
private static Singleton instance = new Singleton();
private Singleton() {
}
public static Singleton getInstance() {
return instance;
}
}
//1、私有静态化对象
//2、无参构造器
//3、公共静态化获取对象方法
上面就是一个单例模式的简单写法,可以看到在类加载的时候就初始化实例,并且使用的是私有构造函数,目的是为了其他地方不能通过构造函数来创建该类的实例,只能通过公共的 getInstance() 方法获取该类的实例,虽然避免了多线程并发操作导致的线程安全问题,但是会造成内存的浪费,因为还没有使用这个对象就把它加载到内存中了
2、懒汉式(线程不安全)
public class Singleton {
private static Singleton instance;
private Singleton() {
}
public static Singleton getInstance() {
if (Objects.isNull(instance)) {
instance = new Singleton();
}
return instance;
}
}
小结:只有第一次用到的时候才初始化实例,解决了饿汉式造成内存浪费的问题,但是多线程的时候是不安全的。例如有线程 A 与线程 B 同时执行,这时候就有可能 2 个线程都同时执行到 if (instance == null),这样就创建了 2 个实例了。所以这种方式只适用于单线程
例如:
public class Singleton {
private static Singleton instance;
private Singleton() {
System.out.println(Thread.currentThread().getName() + "执行OK");
}
public static Singleton getInstance() {
if (Objects.isNull(instance)) {
instance = new Singleton();
}
return instance;
}
//多线程并发
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
new Thread(() -> Singleton.getInstance()).start();
}
}
}
输出结果:
Thread-0执行OK
Thread-5执行OK
Thread-4执行OK
Thread-3执行OK
Thread-1执行OK
Thread-2执行OK
3、懒汉式(线程安全)
//双重检测锁模式的懒汉式单例 DCL懒汉式
public static Singleton getInstance() {
if (Objects.isNull(instance)) {
synchronized (Singleton.class) {
if (Objects.isNull(instance)) {
instance = new Singleton();
}
}
}
return instance;
}
小结:在 getInstance 方法上加了同步锁,这样有多个线程的时候会等前面的线程执行完了再执行当前线程,可以解决第二种懒汉式的线程安全问题
4、双重校验锁(线程安全)
public class Singleton {
private static volatile Singleton instance;
private Singleton() {
}
//双重检测锁模式的懒汉式单例 DCL懒汉式
public static Singleton getInstance() {
if (Objects.isNull(instance)) {
synchronized (Singleton.class) {
if (Objects.isNull(instance)) {
instance = new Singleton();
}
}
}
return instance;
}
}
小结:可以看到加了双重 if (instance == null) 的判断,并且同步锁放到了方法内部,新增的第一个判空可以避免每个线程过来都去执行同步锁,可以解决同步锁造成的效率低的问题,但是instance = new Singleton(); 并不是一个原子操作,这一行代码可以分成 3 个步骤:
- 1、申请内存空间
- 2、初始化对象
- 3、返回内存空间地址
编译有可能优化为:
- 1、申请内存空间
- 2、返回内存空间地址
- 3、初始化对象
所以代码中需要使用 volatile 关键字,因为它可以解决指令重排的问题,但是只能在 JDK 1.5 之后生效。而且使用 volatile 关键字也会影响一些性能问题
什么是原子操作?
简单来说,原子操作(atomic)就是不可分割的操作,在计算机中,就是指不会因为线程调度被打断的操作
比如简单的赋值是一个原子操作:m = 6; // 这是个原子操作
假如m原先的值为0,那么对于这个操作,要么执行成功m变成了6,要么是没执行m还是0,而不会出现诸如m=3这种中间态——即使是在并发的线程中。声明并赋值就不是一个原子操作:int n = 6; // 这不是一个原子操作对于这个语句,至少有两个操作:①声明一个变量n②给n赋值为6——这样就会有一个中间状态:变量n已经被声明了但是还没有被赋值的状态。——这样,在多线程中,由于线程执行顺序的不确定性,如果两个线程都使用n,就可能会导致不稳定的结果出现
什么是指令重排?
简单来说,就是计算机为了提高执行效率,会做的一些优化,在不影响最终结果的情况下,可能会对一些语句的执行顺序进行调整
比如这一段代码:
int a ; // 语句1
a = 8 ; // 语句2
int b = 9 ; // 语句3
int c = a + b ; // 语句4
正常来说,对于顺序结构执行的顺序是自上到下,也即1234。但是,由于指令重排的原因,因为不影响最终的结果,所以,实际执行的顺序可能会变成3124或者1324
5、静态内部类(线程安全)
public class Singleton {
private static class SingletonHolder {
private static final Singleton INSTANCE = new Singleton();
}
private Singleton() {
}
public static Singleton getInstance() {
return SingletonHolder.INSTANCE;
}
}
小结:这种方式在 Singleton 类加载的时候并没有初始化实例,而是第一次调用 getInstance() 的时候才进行初始化,可以达到延迟加载对象的作用。并且 JVM 能确保 INSTANCE 只被实例化一次。也就是这种方式可以实现双重校验锁一样的效果,而且解决了使用 volatile 关键字只能在 JDK 1.5 之后生效和影响性能的问题。但是这种方式用的人还是比较少的
但以上方式还会存在一个问题,可通过反射破坏单例
//反射破坏单例
public static void main(String[] args) throws Exception {
Singleton instance1 = Singleton.getInstance();
Constructor<Singleton> constructor = Singleton.class.getDeclaredConstructor(null);
constructor.setAccessible(true);
Singleton instance2 = constructor.newInstance();
System.out.println(instance1);
System.out.println(instance2);
}
输出结果:
com.xhy.demo01.gof.Singleton@30f39991
com.xhy.demo01.gof.Singleton@452b3a41
6、枚举(线程安全)
public enum Singleton {
INSTANCE;
public Singleton getInstance() {
return INSTANCE;
}
}
小结:可以说使用枚举的方式实现单例是目前最完美的方式,这种方式可以防止序列化与反射造成的创建多个实例的问题,而前面的 5 种方式默认情况是无法解决这个问题的。可能使用枚举的唯一缺点是可读性不高
优点
- 由于单例模式在内存中只有一个实例,对于需要频繁实例化然后销毁的对象可以减少内存开销
- 由于单例只有一个实例,对于创建比较耗时或者耗资源的对象可以减少系统的性能开销。 可以避免对资源的多重占用
- 可以在系统设置全局的访问点,优化和共享资源访问
缺点
- 单例模式一般没有接口,所以扩展困难,需要扩展只能修改原有的代码。
- 单例模式一般职责过多,所以与单一职责原则是有冲突的
使用场景
- 只需要一个实例对象
- 需要频繁实例化然后销毁的对象
- 创建比较耗时的对象,例如访问IO和数据库等资源
选用哪种方式实现单例模式?
以上 6 种方式各有利弊(线程安全问题、性能问题、代码复杂度与可读性问题),所以需要根据自己的项目选择合适的方式。一般建议使用第三种线程安全的懒汉式(例如系统源码 LocalBroadcastManager 就是使用该种方式),如果涉及到序列化与反射则可以使用第六种枚举的方式
什么情况下使用?
有些对象只有一个,比如配置文件,工具类,线程池,缓存,日志对象等
懒汉式:指全局的实例在第一次使用时构建
饿汉式:指全局的实例在类装载时构建
注:日常我们使用较多的应该是懒汉式的单例,毕竟按需加载才能做到资源的最大化利用
例如单例线程池:
public enum AssetClearEnum {
INSTANCE;
private final ExecutorService threadPool;
AssetClearEnum() {
threadPool = Executors.newCachedThreadPool();
}
public ExecutorService getThreadPool() {
return threadPool;
}
}