前言
经常在一些面试题中遇到这样的面试官:请你手写下单例模式。面试者如果没有准备或者提前储备不足的话,就会陷入一种尴尬的境地,单例模式?记得好像有懒汉式和饿汉式,手写?糟糕...记得不太清了。
一、什么是单例模式?
在Java中,单例模式是一种设计模式,用于确保一个类只有一个实例,并提供全局访问点以访问该实例。它通常用于需要共享资源的情况,例如数据库连接池、线程池以及常见的配置信息等。在实际应用中,我们常遇到的单例模式,有“懒汉式”,“饿汉式”,“枚举类”等
二、单例模式能解决什么问题?
1. 限制一个类只能有一个实例:通过单例模式,可以确保一个类只有一个实例对象被创建,并且提供一个全局访问点以供其他类使用。
2. 提供对实例的全局访问:单例模式可以使实例对象在程序的任何地方都可以被访问,方便了对象之间的通信和数据共享。
3. 控制资源的共享和访问:通过单例模式,可以对某些共享资源进行控制,避免资源的重复创建和浪费。
4. 避免全局变量的使用:通过单例模式,可以避免将变量定义为全局变量,减少了对全局变量的依赖和使用带来的问题。
5. 实现延迟加载:通过单例模式,可以在需要时才创建实例对象,避免了一开始就进行实例化的开销。
6. 简化代码:通过单例模式,可以将一些需要共享的方法和数据封装在单个类中,简化了代码的逻辑和结构。
三、常见的单例模式有哪几种?
懒汉式
饿汉式
枚举类
四、先上菜:解锁最完美的单例模式
/**
* 饿汉式
*/
class Demo {
/**
* 私有化构造器
*/
private Demo() {}
/**
* 创建对象
*/
private static class DemoIn{
private static Demo INSTANCE = new Demo();
}
/**
* 获取实例的方法
*/
public static Demo getInstance() {
return DemoIn.INSTANCE;
}
}
这段Java代码定义了一个`Demo`类来实现单例模式。它通过将构造器私有化,阻止外部类直接实例化该类。然后,内部定义了一个静态内部类`DemoIn`,在这个静态内部类中创建了`Demo`类的唯一实例`INSTANCE`,最后通过`getInstance`方法对外提供获取这个唯一实例的途径。
1.类加载阶段的初始化保障:
在Java中,类的加载过程是由类加载器来完成的,并且类加载器会保证一个类在整个Java虚拟机中只会被加载一次。对于静态成员变量(如这里的`DemoIn`类中的`INSTANCE`)的初始化,是在类加载阶段完成的,并且Java的类加载机制会确保类的静态初始化在多线程环境下是线程安全的。
当一个类第一次被主动使用(比如通过`getInstance`方法第一次调用去访问`DemoIn.INSTANCE`时触发`DemoIn`类的加载和`INSTANCE`的初始化),JVM会保证这个过程的原子性,多个线程同时尝试访问这个单例实例时,只有一个线程会实际触发类的初始化过程(也就是创建`INSTANCE`这个实例),其他线程会阻塞等待这个初始化完成,然后获取到同一个已经初始化好的实例。
2.获取实例方法的线程安全性:
`getInstance`方法只是简单地返回已经初始化好的`DemoIn.INSTANCE`,不存在对共享变量的修改等并发操作,不存在多个线程同时修改导致数据不一致等问题。只要类的初始化过程(也就是实例的创建过程)是线程安全的,那么通过这个方法获取实例在多线程环境下就是安全的。
五、集中常见的单例写法
1、“饿汉式”
所谓饿汉式,即,上来就创建对象,像饿了好几天一样。
public class SingletonHungry {
/**
* 单例比较关键的一句就是:先私有化构造器.这样外部就不可能通过new一个对象,来构造对象.
*/
private SingletonHungry(){};
private final static SingletonHungry INSTANCE = new SingletonHungry();
public static SingletonHungry getInstance(){
return INSTANCE;
}
/**
* 线程运行100次,每次取到的值,都是一样的.
*/
public static void main(String[] args) {
for (int i = 0; i < 100; i++) {
new Thread(()->{
SingletonHungry instance = SingletonHungry.getInstance();
System.out.println(instance.hashCode());
}).start();
}
}
}
1176009558
1176009558
1176009558
这种单例模式,看起来的确没有我们常常担心的线程安全问题。只不过对于有些强迫症的coder而言,他们会认为,这段代码,先创建实例对象,假如我不用到呢,假如实例要占据的内存比较大,这样是否有浪费空间的嫌疑?额...你说的都对
那我们先不创建,好叭
2、“懒汉式”
public class SingletonLazy {
// 1-私有化构造器
private SingletonLazy() {}
// 2-先声明一个实例对象
private volatile static SingletonLazy INSTANCE = null;
// 3-提供获取实例的方法
public static SingletonLazy getInstance() {
if (INSTANCE == null) {
INSTANCE = new SingletonLazy();
}
return INSTANCE;
}
public static void main(String[] args) {
// 多线程,看看是否都是同一个对象
for (int i = 0; i < 100; i++) {
new Thread(() -> {
SingletonLazy instance = SingletonLazy.getInstance();
System.out.println(instance.hashCode());
}).start();
}
}
}
这种写法,先声明了一个实例对象,但是并没有给它分配内存空间,很好的避免了“饿汉式”所突出的内存浪费问题。
不过,多线程环境下,这么写会有问题:
1145699847
1120021779
186050359
186050359
186050359
我们分析下:
假如有2个线程A,B,当A执行到:instance == null 的时候,刚要往下走(说明它已经判定 instance == null,为true),B线程拿到了CPU的执行权,也到了这里(instance == null ),此时,instance没有实例过,程序往下走,会创建对象。A线程恰好这时也拿到了,继续往下走,也创建了一个对象。这下,就有了两个实例对象。
常规思路,考虑给实例化的方法,加个锁:
public class SingletonLazy {
// 1-私有化构造器
private SingletonLazy() {}
// 2-先声明一个实例对象
private volatile static SingletonLazy INSTANCE = null;
// 3-提供获取实例的方法
public static synchronized SingletonLazy getInstance() {
if (INSTANCE == null) {
INSTANCE = new SingletonLazy();
}
return INSTANCE;
}
public static void main(String[] args) {
// 多线程,看看是否都是同一个对象
for (int i = 0; i < 100; i++) {
new Thread(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
SingletonLazy instance = SingletonLazy.getInstance();
System.out.println(instance.hashCode());
}).start();
}
}
}
这样写,是不是就没有问题呢?
我想,大家应该知道,同步锁会增加锁竞争,带来系统性能开销,从而导致系统性能下降。
再仔细看,没有加“synchronized”时,由于多线程判断 instance == null 时,造成了创建多个实例的情况,看前面的多线程结果,只有前面的两个hashCode是不同的,后面拿到的都是同一个实例对象,基于这种情形,是不是可以考虑,将同步锁挪个位置,减少同步锁资源竞争呢?
看代码:
public static SingletonLazy getInstance() {
if (INSTANCE == null) {
synchronized (SingletonLazy.class) {
INSTANCE = new SingletonLazy();
}
}
return INSTANCE;
}
这样写,是不是就完美了呢?答案是否定的。这是因为,当多个线程进入到if条件判断里,虽然有同步锁,但是,线程会依次拿到锁资源,创建对象,再释放锁对象。这么说的话,我们考虑再同步锁里面,再加一层判断:
public static SingletonLazy getInstance() {
if (INSTANCE == null) {
synchronized (SingletonLazy.class) {
if(INSTANCE == null) {
INSTANCE = new SingletonLazy();
}
}
}
return INSTANCE;
}
如上,就是典型的单例模式中常被提到的“双重校验锁”写法。这种写法,多数时候,程序不会进入到同步锁,从而减少竞争,提高效率,又不浪费空间,几乎完美。总结好处就是:
- 实现了延迟加载
- 解决了线程并发
- 提高了执行效率
不过这种写法,已然存在潜在的风险。
这里就不得不提到Java中的指令重排优化和 Happens-Before 规则。所谓指令重排优化是指在不改变原语义的情况下,通过调整指令的执行顺序让程序运行的更快。JVM中并没有规定编译器优化相关的内容,也就是说JVM可以自由的进行指令重排序的优化。
这个问题的关键就在于由于指令重排优化的存在,导致初始化Singleton和将对象地址赋给instance字段的顺序是不确定的。在某个线程创建单例对象时,在构造方法被调用之前,就为该对象分配了内存空间并将对象的字段设置为默认值。此时就可以将分配的内存地址赋值给instance字段了,然而该对象可能还没有初始化。若紧接着另外一个线程来调用getInstance,取到的就是状态不正确的对象,程序就会出错。
这个问题,在JDK1.5解决了,官方引入了关键字:volatile
// 懒汉模式 + synchronized 同步锁 + double-check
public final class SingletonLazy {
private volatile static SingletonLazy instance= null;// 不实例化
public List<String> list = null;//list 属性
private SingletonLazy(){
list = new ArrayList<String>();
}// 构造函数
public static SingletonLazy getInstance(){// 加同步锁,通过该函数向整个系统提供实例
if(null == instance){// 第一次判断,当 instance 为 null 时,则实例化对象,否则直接返回对象
synchronized (SingletonLazy.class){// 同步锁
if(null == instance){// 第二次判断
instance = new SingletonLazy();// 实例化对象
}
}
}
return instance;// 返回已存在的对象
}
}
问题:有没有比较简单的写法呢?
答案:有,请看第四部分。
总结:
- 饿汉式:线程安全
- 懒汉式:非线程安全
- 双检锁:线程安全
- 静态内部类:线程安全
- 枚举:线程安全