顾名思义,饿汉模式就是加载类的时候直接new一个对象,后面直接用即可。
饿汉模式指在类中直接定义全局的静态对象的实例并初始化,然后提供一个方法获取该实例对象。
代码如下:
public class Singleton {
// 使用static修饰,类加载的时候new一个对象
private static Singleton INSTANCE = new Singleton();
// 构造器私有化
private Singleton() {}
public static Singleton getInstance() {
return INSTANCE;
}
}
顾名思义,懒汉模式就是加载类的时候只声明变量,不new对象,后面用到的时候再new对象,然后把对象赋给该变量。
定义一个私有的静态对象INSTANCE,之所以定义INSTANCE为静态,是因为静态属性或方法是属于类的,能够很好地保障单例对象的唯一性;
然后定义一个静态方法获取该对象,如果对象为null,则 new 一个对象并将其赋值给INSTANCE。
代码如下:
public class Singleton {
private static Singleton INSTANCE;
// 构造器私有化
private Singleton() {}
public static Singleton getInstance() {
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
return INSTANCE;
}
}
静态内部类通过在类中定义一个静态内部类,将对象实例的定义和初始化放在内部类中完成,我们在获取对象时要通过静态内部类调用其单例对象。
之所以这样设计,是因为类的静态内部类在JVM中是唯一的,这很好地保障了单例对象的唯一性。
静态内部类的单例实现方式同样是线程安全的。
代码如下:
public class Singleton {
private static class SingletonHolder {
private static final Singleton INSTANCE = new Singleton();
}
private Singleton(){}
public static final Singleton getInstance(){
return SingletonHolder.INSTANCE;
}
}
饿汉模式和静态内部类实现单例模式的优点是写法简单,缺点是不适合复杂对象的创建。对于涉及复杂对象创建的单例模式,比较优雅的实现方式是懒汉模式,但是懒汉模式是非线程安全的,下面就讲一下懒汉模式的升级版——双重构校验锁模式(双重构校验锁是线程安全的)。
普通的懒汉模式在单线程场景下是线程安全的,但在多线程场景下是非线程安全的。
先来看看普通的懒汉模式实现:
public class Singleton {
private static Singleton INSTANCE;
private Singleton() {}
public static Singleton getInstance() {
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
return INSTANCE;
}
}
上面这段代码在单线程环境下没有问题,但是在多线程的情况下会产生线程安全问题。
在多个线程同时调用getInstance方法时,由于方法没有加锁,可能会出现以下情况
① 这些线程可能会创建多个对象
② 某个线程可能会得到一个未完全初始化的对象
为什么会出现以上问题?
对于 ① 的情况解释如下:
public static Singleton getInstance() {
if (INSTANCE == null) {
/**
* 由于没有加锁,当线程A刚执行完if判断INSTANCE为null后还没来得及执行INSTANCE = new Singleton()
* 此时线程B进来,if判断后INSTANCE为null,且执行完INSTANCE = new Singleton()
* 然后,线程A接着执行,由于之前if判断INSTANCE为null,于是执行INSTANCE = new Singleton()重复创建了对象
*/
INSTANCE = new Singleton();
}
return INSTANCE;
}
对于 ② 的情况解释如下:
public static Singleton getInstance() {
if (INSTANCE == null) {
/**
* 由于没有加锁,当线程A刚执行完if判断INSTANCE为null后开始执行 INSTANCE = new Singleton()
* 但是注意,new Singleton()这个操作在JVM层面不是一个原子操作
*
*(具体由三步组成:1.为INSTANCE分配内存空间;2.初始化INSTANCE;3.将INSTANCE指向分配的内存空间,
* 且这三步在JVM层面有可能发生指令重排,导致实际执行顺序可能为1-3-2)
*
* 因为new操作不是原子化操作,因此,可能会出现线程A执行new Singleton()时发生指令重排的情况,
* 导致实际执行顺序变为1-3-2,当执行完1-3还没来及执行2时(虽然还没执行2,但是对象的引用已经有了,
* 只不过引用的是一个还没初始化的对象),此时线程B进来进行if判断后INSTANCE不为null,
* 然后直接把线程A new到一半的对象返回了
*/
INSTANCE = new Singleton();
}
return INSTANCE;
}
为了解决问题 ①,我们可以对 getInstance() 这个方法加锁。
public class Singleton {
private static Singleton INSTANCE;
private Singleton() {}
public static synchronized Singleton getInstance() { // 加锁
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
return INSTANCE;
}
}
仔细看,这里是粗暴地对整个 getInstance() 方法加锁,这样做代价很大,因为,只有当第一次调用 getInstance() 时才需要同步创建对象,创建之后再次调用 getInstance() 时就只是简单的返回成员变量,而这里是无需同步的,所以没必要对整个方法加锁。
由于同步一个方法会降低上百倍甚至更高的性能, 每次调用获取和释放锁的开销似乎是可以避免的:一旦初始化完成,获取和释放锁就显得很不必要。
所以可以只对方法的部分代码加锁!
public class Lock2Singleton {
private static Lock2Singleton INSTANCE;
private Lock2Singleton() {}
public static Lock2Singleton getSingleton() {
// 因为INSTANCE是静态变量,所以给Lock2Singleton的Claa对象上锁
synchronized(Lock2Singleton.class) { // 加 synchronized
if (INSTANCE == null) {
INSTANCE = new Lock2Singleton();
}
}
return INSTANCE;
}
}
优化后的代码选择了对 if (INSTANCE == null) 和 INSTANCE = new Lock2Singleton()加锁
这样,每个线程进到这个方法中之后先加锁,这样就保证了 if (INSTANCE == null) 和 INSTANCE = new Lock2Singleton() 这两行代码被同一个线程执行时不会有另外一个线程进来,由此保证了创建的对象是唯一的。
对象的唯一性保证了,也就是解决了问题①,但是如何解决问题②呢?虽然加了 synchronized,但是 synchronized 是不能禁止指令重排的,也就是说,INSTANCE = new Lock2Singleton(); 这行代码在 JVM 层面还是有可能发生 1-3-2 的现象,那要怎么保证绝对的1-2-3顺序呢,也就是禁止指令重排序,答案是加 volatile 关键字。
public class Lock2Singleton {
private volatile static Lock2Singleton INSTANCE; // 加 volatile
private Lock2Singleton() {}
public static Lock2Singleton getSingleton() {
synchronized(Lock2Singleton.class) { // 加 synchronized
if (INSTANCE == null) {
INSTANCE = new Lock2Singleton();
}
}
return INSTANCE;
}
}
这样总可以解决问题 ① 和 ② 了吧,然而你以为这就结束了吗?NO!这段代码从功能层面来讲确实是已经结束了,但是性能方面呢?是不是还有可以优化的地方?
答案是:有!!
值得优化的地方就在于 synchronized 代码块这里。每个线程进来,不管三七二十一,都要先进入同步代码块再说,如果说现在 INSTANCE 已经不为null了,那么,此时当一个线程进来,先获得锁,然后才会执行 if 判断。我们知道加锁是非常影响效率的,所以,如果 INSTANCE 已经不为null,是不是就可以先判断,再进入 synchronized 代码块。如下
public class Lock2Singleton {
private volatile static Lock2Singleton INSTANCE; // 加 volatile
private Lock2Singleton() {}
public static Lock2Singleton getSingleton() {
if (INSTANCE == null) { // 双重校验:第一次校验
synchronized(Lock2Singleton.class) { // 加 synchronized
if (INSTANCE == null) { // 双重校验:第二次校验
INSTANCE = new Lock2Singleton();
}
}
}
return INSTANCE;
}
}
在 synchronized 代码块之外再加一个 if 判断,这样,当 INSTANCE 已经存在时,线程先判断不为null,然后直接返回,避免了进入 synchronized 同步代码块。
那么可能又有人问,好了,我明白了在 synchronized 代码块外加一个 if 判断,是不是就意味着里面的那个 if 判断可以去掉?
当然不可以!!
如果把里面的 if 判断去掉,就相当于只对 INSTANCE = new Lock2Singleton() 这一行代码加了个锁,只对一行代码加锁,那你岂不是加了个寂寞(加锁的目的就是防止在第二个if判断和new操作之间有别的线程进来!!),结果还是会引起问题①。
所以,两次校验,一次都不能少!!
最终,单例模式双重校验锁模式的完整代码实现如下:
public class Lock2Singleton {
private volatile static Lock2Singleton INSTANCE; // 加 volatile
private Lock2Singleton() {}
public static Lock2Singleton getSingleton() {
if (INSTANCE == null) { // 双重校验:第一次校验
synchronized(Lock2Singleton.class) { // 加 synchronized
if (INSTANCE == null) { // 双重校验:第二次校验
INSTANCE = new Lock2Singleton();
}
}
}
return INSTANCE;
}
}
过程如下:
判断 INSTANCE 是否为null,检查变量是否被初始化(不去获得锁),如果已被初始化立即返回这个变量;
不为null,直接返回,不用去竞争锁
为null,获取锁,然后再次判断(虽然已经判断过,但是在第一个if和synchronized之间仍有可能被另外线程插入导致第一个if判断为null时,当进入同步代码块之后再次判断时已经不为null了,所以需要再次判断)
是否为null
为null,创建并返回
不为null,直接直接返回
为什么是双重校验 ?
第二次校验是为了解决问题①,即避免多个线程重复创建对象。
第一次校验是为了提高效率,避免 INSTANCE 不为null时仍然去竞争锁。
为什么加 volatile ?
加 volatile 是为了禁止指令重排序,也就是为了解决问题②,即避免某个线程获取到其他线程没有初始化完全的对象。
1:针对不知道怎么面试,面试没有信心的小伙伴,我们会给你一个offer保障。
2:我们会监督你15-20天内把面试体系技术点掌握至少7成,这样足够你去找到满意的工作了。
3:我们是面向面试学习指导,不会带你们去写代码,会把项目真实开发的迭代过程和技术细节如何实现业务功能都详细教清楚,你能在面试中流畅表达清楚就行了,项目经验你不用担心(技术老师提供的真实项目经验肯定拿的出手),自己学和别人带着系统学,效率完全不一样。