Java 设计模式之单例模式

本贴最后更新于 1449 天前,其中的信息可能已经物是人非

单例模式是设计模式中看似较为简单的设计模式之一,很多人看一眼基本都能默写出至少一种单例模式的写法。

也有很多面试官会问单例模式,因为单例模式有很多可以深挖的地方。在深挖细节的过程中面试官可以考察到面试者对并发类加载序列化等重要知识点的掌握程度和水平,因此呢,单例模式是 Java 程序员必须掌握的一个设计模式。

很多同学可能还不了解单例模式,心生疑惑,单例模式很重要,什么是单例模式都没搞清楚怕是学了个寂寞。

什么是单例模式(Singleton)

截图录屏_选择区域_20201203171514

单例模式:保证一个类只有一个实例,并且提供一个全局可以访问的入口。

举个栗子:火影忍者鸣人的千重影分身术, 每个分身都对应的同一个真身。

我们为什么需要单例呢?

使用一个东西的理由可以有很多。

理由一:节省内存节约计算

很多情况下使用单例模式是为了节省内存节约计算

如果我们只需要一个实例,那么为什么要 new 出来很多个实例来浪费内存呢。我吃饭向来都是吃多少拿多少,拿多了吃不完 多浪费呀。

看下面的伪代码

 public class ExpensiveResource{
  public ExpensiveResource(){
  field1 = // 查询数据库
         field2 = // 然后对查到的数据做大量计算
         field3 = // 加密、压缩等耗时操作
  }
 }

假设 数据库中的数据是固定不变的,构造方法中的操作非常耗时,如果你要 new 这样一个实例出来,耗费的时间是极大的,这个时候我们就没有必要在 new 出来更多实例浪费内存资源了。

理由二:保证结果正确性

假设我们一个项目中需要用到一个全局的计数器,用来统计人数。如果此时这个全局的计数器都多个实例,很简单的事情就变得很复杂了。

即使是在生活中,当需要统计数量的时候,当计数的人数超过 1 的时候,计数的结果正确性也不容易得到保障。

理由三:方便管理

项目中一般都有用到工具类,其中很多工具类只需要一个实例就够了,

我们可以通过一个统一的入口,比如通过 getInstance 方法就可以获取到这个单例。

你此时 new 出过多的实例,反而会给自己找不痛快。

单例模式的适用场景

无状态的工具类

  • 日志工具类
    • 无论在何处使用,我们需要的只是让这个日志工具类来帮助我们记录项目的日志信息,根本不需要在这个日志工具类的实例对象上存储任何状态,这个时候我们只需要一个实例对象就可以满足需求。
  • 字符串工具类
  • ...

全局信息类

  • 全局计数器
    • 比如我们需要在一个类上记录网站的访问次数,而且不希望有的访问被记录在对象 A 上,而有的访问却被记录在对象 B 上,这时候我们可以把这个全局计数器类写成单例。在需要计数的时候直接拿出来用就可以,简单省时省力。
  • 环境变量
  • ...

单例模式的常见写法

  • 饿汉式
  • 懒汉式
  • 双重检查式
  • 静态内部类式
  • 枚举式

饿汉式单例

 public class EagerSingleton {
     private static final EagerSingleton INSTANCE = new EagerSingleton();
 
     private EagerSingleton(){};
 
     public static EagerSingleton getINSTANCE() {
         return INSTANCE;
    }
 }

可以看到代码中用 static 修饰实例,并且把构造函数也用 private 修饰

这种饿汉式单例是单例的五种写法中最简单的一种。

饿汉式单例的优点

在类装载的时候就完成了实例化,避免了线程同步的问题

饿汉式单例的缺点

在类装载的时候就完成了实例化,而没有达到懒加载的效果

如果从始至终你都没有使用过这个单例,就会造成内存的浪费。


饿汉式单例的变种--> 静态代码块实现方式

 public class EagerSingleton02 {
     private static final EagerSingleton02 INSTANCE ;
     static {
         INSTANCE = new EagerSingleton02();
    }
 
     private EagerSingleton02(){};
 
     public static EagerSingleton02 getINSTANCE() {
         return INSTANCE;
    }
 }

也是在类装载的时候就完成了实例化,优缺点和上文的相同。即:避免了线程同步问题,但是没有实现懒加载。

懒汉式单例

 public class LazySingleton {
     private static volatile LazySingleton INSTANCE;
     private LazySingleton(){};
 
     public static LazySingleton getInstance(){
         if (INSTANCE == null){
             INSTANCE = new LazySingleton();
        }
         return INSTANCE;
    }
 
 }

这种懒汉式写法在 getInstance()被调用的时候才去实例化对象,起到了懒加载的效果。但是这种写法只适合单线程下使用,如果在多线程下使用,会有造成线程不安全的情况发生。比如:

在多线程下,一个线程 A 进入了 if (INSTANCE == null)判断语句块,还未来得及执行语句块中实例化代码时候,又过来一个 B 也进入了 if (INSTANCE == null),由于线程 A 还未来得及实例化,因此 B 线程此时判定 INSTANCE 还是为 null,于是 A 和 B 线程都进行了 实例化,这个时候其实已经有了两个实例。

因此我们需要注意,在多线程情况下,我们不能使用这种方式,这是一个错误的写法


线程安全的懒汉式写法加锁实现

既然我们既想要懒加载还想要线程安全,该怎么办呢?

请给我来一把锁!

 public class LazySingleton02 {
     private static volatile LazySingleton02 INSTANCE;
     
     private LazySingleton02(){};
     
     public static synchronized LazySingleton02 getInstance(){
         if (INSTANCE == null){
             INSTANCE = new LazySingleton02();
        }
         return INSTANCE;
    }
 }

通过在最开始的懒汉式写法上的 getInstance()方法前加上了一个 synchronized 关键字来实现线程安全。

有得就有失,这下线程安全是有了,但由于加锁的缘故,效率又下降了很多。

每个线程在想获得类的实例的时候,执行 getInstance()方法都要进行同步,多个线程不能同时访问,这在大多数情况下是没有必要的。


这个时候,支持懒汉式的人又不服气了,既然你说我上个加锁实现的懒汉式写法效率太低,那我就把加锁的范围缩小。

 public class LazySingleton03 {
     private static volatile LazySingleton03 INSTANCE;
     private LazySingleton03(){};
 
     public static  LazySingleton03 getInstance(){
         //妄图通过减小同步代码块数量的方式提高效率,然后不可行
         if (INSTANCE == null){
             synchronized (LazySingleton03.class) {
 
                 INSTANCE = new LazySingleton03();
            }
        }
         return INSTANCE;
    }
 
 }

害! 就是把 getInstance()前的 synchronized 移除,然后加到 方法内部 采用代码块的形式来保护线程安全。

naive! 图样图森破!

假如。一个线程 A 进入了第一个 if (INSTANCE == null)判断语句块,还没来得及往下执行,而另一个线程 B 也通过了这个判读语句,此时还是会产生多个实例。

饿汉式单例支持者们妄图通过减小同步代码块数量来提高效率的 方法 失败了!


啥! 还来?

这是船新版本?

那就来看看吧!

饿汉式单例的双重检查模式

 public class LazySingleton04 {
     private static volatile LazySingleton04 INSTANCE;
     private LazySingleton04(){};
 
     public static LazySingleton04 getInstance(){
 
         if (INSTANCE == null){
 //           双重检查
             synchronized (LazySingleton04.class) {
                 if (INSTANCE == null) {
                     INSTANCE = new LazySingleton04();
                }
            }
        }
         return INSTANCE;
    }
 
 }

最直观的可以看到,代码中有两个 if 判断嵌套,也就是双重检查,这样实例化代码只用调用一次就后面再次访问的时候只会判断第一次的 if (INSTANCE == null)就可以了,然后会跳过整个 if 语句块儿,直接 return 实例化对象。

终于实现了线程安全和懒加载,效率更高

为什么要 double-check

这里我们探讨一下为什么要 double-check,去掉第二次的 check 行不行呢?

我们考虑这样一种情况。有两个线程同时调用 getInstance()方法,并且由于 INSTANCE 是 null,所以两个线程都可以通过第一重的 if 判断,然后由于锁的存在,线程 A 和 B 必有一个先进入同步语句并进行第二重的 if 判断,然后进入 synchronized 的保护区进行实例化操作,实例化完成之后就回退出 synchronized 的保护区并释放锁,另外一个线程拿到锁再进行第二重 if 判断发现 INSTANCE 已经不是 null 了,这时候就会直接 return INSTANCE,保证了在多线程情况下的线程安全,只实例化一个对象。

如果去掉第二重判断,将无法保证多线程下只实例化一个对象。

那第一重 check 去掉会如何呢?

如果去掉第一重 check 所有的线程都会串行执行,效率非常低下。

因此两重 check 都要保留,任何一重 check 都不可以去掉。

细心的同学肯定发现了,此种写法相比之前的写法多了一个 volatile 关键字。

为什么要使用 volatile 关键字?

为何要在 INSTANCE 前加了一个 volatile 呢?☆☆☆☆☆

关键在于 INSTANCE = new LazySingleton04(); 这行代码。

因为 JVM 在执行这行代码的时候并非是一个原子操作。

JVM 中这句语句至少做了三件事。

  • 1.给 INSTANCE 分配内存空间
  • 2.调用 LazySingleton04 的构造函数等 来初始化 INSTANCE
  • 3.将 INSTANCE 对象指向分配的内存空间(执行完这一步 INSTANCE 就不是 null 了)

由于 JVM 中会进行指令重排序提高执行效率,因此以上三个执行步骤可能会被**“优化”**成

  • 1.给 INSTANCE 分配内存空间
  • 3.将 INSTANCE 对象指向分配的内存空间(执行完这一步 INSTANCE 就不是 null 了)
  • 2.调用 LazySingleton04 的构造函数等 来初始化 INSTANCE

如果是第一个线程 A 按照 1.3.2 的顺序来执行,3 执行完之后 INSTANCE 就不是 null 了,但此时 2 并没有执行。

假设此时线程 B 进入了 getInstance 方法,由于此时 INSTANCE 已经不是 null 了,所以线程 B 就会通过第一重的减产并且直接返回,其实这个时候 INSTANCE 并没有完全地完成初始化,这个时候你使用这个实例就会报错。

然后线程 A 才姗姗来迟进行 2 操作,可此时已经晚了一步,因为线程 B 已经报错抛异常了。

为什么在饿汉式单例双重检查写法中要使用 volatile

使用 volatile 的意义就在于可以防止由于指令重排序导致此情况的发生,也就避免了有某个线程拿到未经完成初始化的对象。

静态内部类单例

 public class StaticSingleton {
     //静态内部类实现单例
     private StaticSingleton(){}
     
     private static class StaticSingletonHolder{
         private final static StaticSingleton INSTANCE = new StaticSingleton();
    }
     public static StaticSingleton getInstance(){
         return StaticSingletonHolder.INSTANCE;
    }
 }

静态内部类的写法 与 饿汉式单例的写法类似,都采用了类装载的机制来保证我们初始化实例时只有一个线程。

静态内部类的方式是依靠 JVM 来保证了线程的安全性。

饿汉式单例的一个特点是 只要 EagerSingleton 这个类被加载了就会实例化单例对象,

而静态内部类方式在 StaticSingleton 类被装载时并不会立刻实例化,而是在需要实例时,也就是调用 getInstance 方法的时候才回去完成对 StaticSingleton 实例的实例化。

总结:

  • 静态内部类的写法与双重检查模式的优点一样
    • 都避免了线程不安全的问题,并且延迟加载,效率高
  • 共同的缺点
    • 但是都不能防止被反序列化生成多个实例

枚举式单例(最完美的实现方式)

 public enum EnumSingleton {
     INSTANCE;
     public void whateverMethod(){}
 }

通过借助 JDK1.5 中添加的枚举类来实现单例模式,这不仅能避免多线程同步的问题,而且还能防止反序列化和反射创建新的对象来破坏单例的情况的出现。

枚举类可能是最好的实现单例的方式,Joshua Bloch 的作者在《Effective Java》中明确表达过一个观点

使用枚举实现单例的方法,虽然还没有被广泛使用,但是单元素的枚举类型已经成为了实现 Singleton 的最佳方法

----Joshua Bloch

枚举式单例写法的优点

  • 写法简单
    • 不需要我们去考虑懒加载 和线程安全等问题
    • 代码短小精悍,更简洁,更优雅
  • 线程安全有保障
    • 通过反编译枚举类发现枚举中的各个枚举项是通过 static 代码块来定义和初始化的,他们会在类加载时完成初始化,而 java 类的类加载由 JVM 保证线程安全,所以创建一个 Enum 类型的枚举是线程安全的
  • 防止破坏单例
    • Java 专门对枚举的序列化做了规定,在序列化时,仅仅是将枚举对象的 name 属性输出到结果中,在反序列化时,通过 java.lang.Enum 的 value.Of 方法来根据名字查找对象,而不是新建一个新的对象,所以这就防止了反序列化导致的单例破坏问题的出现。
    • 对于通过反射破坏单例来说,枚举类也同样有防御措施。反射在通过 newInstance 创建对象时,会检查这个类是否是枚举,如果是枚举类的话就抛出 illegalAArgumentException("Cannot reflectively create enum object"),反射创建对象失败。

可以看出枚举这种方式能中防止序列化和反射破坏单例,在这一点上与其他的实现方式相比有很大的优势。

安全无小事,一旦生成了多个实例,单例模式将彻底失效。

看完这篇 文章 你心中 最完美的单例写法是什么!!!!

枚举式单例

枚举式单例

枚举式单例

读三遍洗脑。。。。


面试必看,建议收藏!!!

  • 设计模式

    设计模式(Design pattern)代表了最佳的实践,通常被有经验的面向对象的软件开发人员所采用。设计模式是软件开发人员在软件开发过程中面临的一般问题的解决方案。这些解决方案是众多软件开发人员经过相当长的一段时间的试验和错误总结出来的。

    200 引用 • 120 回帖
  • 面试

    面试造航母,上班拧螺丝。多面试,少加班。

    325 引用 • 1395 回帖
  • Java

    Java 是一种可以撰写跨平台应用软件的面向对象的程序设计语言,是由 Sun Microsystems 公司于 1995 年 5 月推出的。Java 技术具有卓越的通用性、高效性、平台移植性和安全性。

    3187 引用 • 8213 回帖
1 操作
shuaibing90 在 2020-12-03 22:08:49 更新了该帖

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...
  • hjljy

    设计模式从单例开始,哈哈