从零开始学设计模式(二):单例模式

135 阅读12分钟

小知识,大挑战!本文正在参与“   程序员必备小知识   ”创作活动

作者的其他平台:

| CSDN:blog.csdn.net/qq_4115394…

| 掘金:juejin.cn/user/651387…

| 知乎:www.zhihu.com/people/1024…

| GitHub:github.com/JiangXia-10…

| 公众号:1024笔记

本文大概6503字,读完共需12分钟

1 前言

前面的一篇文章从零开始学设计模式(一):什么是设计模式 介绍了什么是设计模式,常见的设计模式有哪些以及设计模式的基本原则。今天这篇文章开始就对前面提到的23种设计模式进行单独介绍,一起从零开始学习设计模式。

从零开始学习的第一个设计模式就是单单例模式!

2 单例模式

1、什么是单例模式

单例模式(Singleton Pattern)是 Java 中最简单的设计模式之一。它的核心作用就是保证整个系统中一个类只有一个实例,并且提供一个访问该实例的全局访问点,实现这种功能的方式就叫单例模式。比如在一家人想要看电视,这时候家里只需要买一台电视机就可以了,没有必要每个人都买一台电视。如果一家看成一个系统,那么一台电视就是一个实例。这时候看电视可以有两种情况:一种是想看的时候才开,这就是懒汉式;一种是我一直开着想看的时候就看,这种就是饿汉式。

2、单例模式的优点

a、通过前面对于单例模式的介绍,我们知道单例模式只生成一个实例,当一个对象的产生需要比较多的资源时,比如读取配置、产生其他依赖对象时,就可以通过在应用启动时直接产生一个单例对象,然后永久驻留内存的方式来解决,所以它减少了系统性能开销;

b、单例模式可以在系统设置全局的访问点,优化环共享资源访问,例如可以设计一个单例类,负责所有数据表的映射处理

3 五种常见的单例模式实现方式

单例模式有五种常见的实现方式:

比较"有名"的两种实现方式是:

1、饿汉式

顾名思义就跟饿汉一样,饿汉就怕饿,所以它一直煮着饭,饿了的时候就直接吃,同理这样的单例实现模式就是一直创建着实例,需要用的时候直接使用。可见这种实现方式具有线程安全,调用效率高的特点。 但是,它不能延时加载,而且容易造成系统资源浪费。

/**
 * 饿汉式单例模式
 * author:jiangxia
 * date:2021-10-09
 */
public class SingletonDemo1 {
    //类初始化时立即加载这个对象!体现饿的特点没有延时加载的优势
    //由于加载类时是线程安全的
    private static SingletonDemo1 instance = new SingletonDemo1();


    private SingletonDemo1(){

    }

    //方法没有同步,所以调用效率高
    public static SingletonDemo1 getInstance(){
       return instance;
    }
}

上述代码中,static变量会在类装载时初始化,此时也不会涉及多个线程对象访问该对象的问题。虚拟机保证只会装载一次该类,所以肯定不会发生并发问题。因此,可以省略synchronized关键字。

但是如果只是加载该类,而不需要调用getInstance(),甚至永远不会调用,那就会造成资源浪费!

2、懒汉式

懒汉式具有懒的特点,就是懒得创建的,只有当需要的时候才创建。这样的实现方式具有线程安全,调用效率不高的特点,并且可以延时加载。

/**
 * 懒汉式单例模式
 * author:jiangxia
 * date:2021-10-09
 */
public class SingletonDemo2 {
    //类初始化时不初始化这个对象,延时加载,只有真正使用的时候再创建
    private static SingletonDemo2 instance;

    //私有化构造器
    private SingletonDemo2(){

    }

    //方法同步,但是调用效率低
    public static synchronized SingletonDemo2 getInstance(){
        if(instance==null){
            instance = new SingletonDemo2();
        }
        return instance;
    }
}

上述代码可以发现懒汉式和饿汉式最大的区别就是懒汉式能够延迟加载,只有在真正用的时候才加载,而饿汉式则是立即加载,所以懒汉式的资源利用率高了。但是,每次调用getInstance()方法都要同步,并发效率较低,所以需要加上synchronized关键字。

单例模式除了以上两种实现方式之外还有其他的三种实现方式,它们可以认为结合了懒汉式和饿汉式的优点,并且避免了其缺点:

3、双重检测锁式

它将同步内容下方到if内部,提高了执行的效率不必每次获取对象时都进行同步,只有第一次才同步创建了以后就没必要了。但是由于JVM底层内部模型原因,偶尔会出问题。所以不建议使用!

/**
 * 双重检测锁实现单例模式
 * author:jiangxia
 * date:2021-10-09
 */
public class SingletonDemo3 {

    private static SingletonDemo3 instance = null;

    public static SingletonDemo3 getInstance() {
        //  一重检测锁:判断是否为null 
        if (instance == null) {
            SingletonDemo3 sc;
            synchronized (SingletonDemo3.class) {
                sc = instance;
                //  二重检测锁:判断是否为null 
                if(sc==null){
                    sc = new SingletonDemo3();
                 }
                  instance = sc;
                }
            }
        }
        return instance;
    }
    private  SingletonDemo3(){

    }
}

上述代码存在一个问题,那就是对于instance = new Singleton()这句代码其实并非一个原子性操作,关于什么是原子操作可以参考:原子操作知多少?这句代码其实在JVM里大概做了三个操作:

1、给instance分配内存;

2、调用Singleton构造完成初始化;

3、使instance对象的引用指向分配的内存空间,在完成这一步之后instance就不是null了;

原子操作知多少?这篇文章中提到过,在JVM的即时编译器中存在指令重排序的优化,所以上面的第二步和第三步的顺序是不能保证的,存在第三步在第二步之前执行的情况,如果这种情况发生了,那么在第三步执行完毕,但是第二步还没有执行之前,被线程二抢占了,那么这时候instance就已经是非null 了,虽然还没有初始化,所以线程二会直接返回 instance,然后使用就报错。所以这种实现方式一般不建议使用,如果非要使用,可以将instance变量用volatile关键字进行修饰, 关于volatile关键字可以参考之前的一篇文章:Java并发编程之Volatile关键字解析

//声明成 volatile
private volatile static SingletonDemo3 instance = null

4、静态内部类式

在Java语言中允许在一个类中再定义类,这种在其它类内部定义的类就叫内部类。而有static关键字修饰的内部类就是静态内部类了。比如:

public class Outer {
    private static String s1 = "s1 in Outer";
    private static String s2 = "s2 in Outer";
 
    public void method1() {
        // 外部类可通过内部类的对象调用内部类的私有成员变量或方法
        System.out.println(new Inner().s1);
        System.out.println(new Inner().method2());
    }
 
    private static String method2() {
        return "method2 in Outer";
    }  
    // 内部静态类
    public static class Inner {
        private String s1 = "s1 in Inner";
        private static String s3 = "s3 in Inner";
 
        public void method1() {
            // 内部类可直接访问外部类的私有静态成员变量或方法
            System.out.println(s2);
            // 内部类和外部类有同名变量和方法时
            System.out.println(s1);
            System.out.println(Outer.s1);
            System.out.println(method2());
            System.out.println(Outer.method2());
        }
 
        private String method2() {
            return "method2 in Inner";
        }
    }
}

要想理解静态内部类式的单例模式,除了要知道静态内部类之外,还要清楚jvm的类的加载机制。jvm把Class文件加载到内存,然后进行校验,准备,解析和初始化,最终形成java类型,这就是虚拟机的类加载机制。这些阶段通常是相互交叉和混合进行的。比如解析阶段在某些情况下,可以在初始化阶段之后再开始,这是为了支持java语言的运行时绑定,这也就是动态绑定和多态的原理。

JVM规范中并没有强制约束什么时候要开始加载,但是,却规定了下面的几种情况必须要进行初始化:

1、遇到 new、getstatic、putstatic、或者invokestatic 这4条字节码指令,如果没有类没有进行过初始化,则触发初始化

2、使用java.lang.reflect包的方法,进行反射调用的时候,如果没有初始化,则先触发初始化

3、初始化一个类时候,如果发现父类没有初始化,则先触发父类的初始化

我们知道类初始化阶段是类加载过程的最后阶段。在这个阶段,java虚拟机才真正开始执行类定义中的java程序代码(这就好比一个人或者一个动物等,它们有精子、卵子阶段、然后受精卵阶段、胚胎阶段、出生之后的婴儿阶段,这才算是一个真正的人,才能开始它的生活(真正运行),纯属比喻!)。java虚拟机会保证一个类的static{} 方法在多线程或者单线程中正确的执行,并且只执行一次。在执行的过程中,便完成了类变量(静态变量)的初始化。如果我们的java类中,没有显式声明static{}块,如果类中有静态变量,编译器会默认给我们生成一个static{}方法。

对于静态变量来说,虚拟机会保证在子类的static{}方法执行之前,父类的static{}方法已经执行完毕(我记得这也是一个常见的面试题)。由于父类的static{}方法先执行,也就意味着父类的静态变量要优先于子类的静态变量赋值操作。

在类初始化阶段,JVM保证同一个类的static{}方法只被执行一次,这是静态内部类单例模式的核心。JVM靠类的全限定类名以及加载它的类加载器来唯一确定一个类(这个也是一个常见的面试题。我记得常见的一个题目就是在反序列化时,被序列化的对象使用java默认的类加载器加载,而使用了反序列化的则使用的框架自己的类加载器并且强制使用自己的类加载器去加载这个类,那么就会因为被JVM判定不是一个类而报ClassNotFoundException异常!)

所以静态内部类的单例模式的原理可以理解为对于一个类,JVM在仅用一个类加载器加载它时,静态变量的赋值在全局只会执行一次!

静态内部类式的单例模式代码如下:

/**
 * 静态内部类实现单例模式方式
 * 这种方式线程安全,调用效率高,并且实现了延时加载
 */
public class SingletonDemo4 {

    private static class SingletonClassInstance{
        private static final SingletonDemo4 instance = new SingletonDemo4();
    }

    private SingletonDemo4(){

    }

    public static SingletonDemo4 getInstance(){
        return SingletonClassInstance.instance;
    }
}

外部类没有static属性,则不会像饿汉式那样立即加载对象。只有真正调用getInstance(),才会加载静态内部类,实现了延时加载。同时因为JVM会保证一个类的初始化方法执行时的线程安全, instance是static final类型,保证了内存中只有这样一个实例存在,而且只能被赋值一次,从而保证了线程安全性。所以这种单例模式的特点可以总结为:线程安全,调用效率高,可以延时加载。

5、枚举式

枚举是jvm底层的,是天然的单例!枚举是不能被破解的,也是最安全的。

先来看两段代码:

enum Type{
    A,B,C,D;
}
class Type extends Enum{
    public static final Type A;
    public static final Type B;
    public static final Type C;
    public static final Type D;
}

对于上面的例子,可以把Type看作一个类,而把A,B,C,D看作类的Type的实例。

枚举的单例模式代码:

/**
 * @Author: 江夏
 * @Date: 2021/10/10/20:12
 * @Description: 枚举单例模式
 */

public class  SingletonDemo05{
    public static void main(String[] args) {
        EnumSingletonDemo5 sd = EnumSingletonDemo5.INSTANCE;
        EnumSingletonDemo5 sd2 = EnumSingletonDemo5.INSTANCE;
        System.out.println(sd==sd2);
        //直接通过EnumSingletonDemo5.INSTANCE.doSomething()的方式调用即可。方便、简洁又安全。
        EnumSingletonDemo5.INSTANCE.singletonOperation();
        }
}

enum EnumSingletonDemo5 {
    /**
     *  定义一个枚举的元素,它就代表了Singleton 的一个实例。
     */
    INSTANCE;
    /**
     *  单例可以有自己的操作
     */
    public void singletonOperation(){
        // TODO
    }
}

Joshua Bloch在effective java这本书中说道,最佳的单例实现模式就是枚举模式。利用枚举的特性,让JVM来帮我们保证线程安全和单一实例的问题。除此之外,写法还特别简单。枚举单例模式具有线程安全,调用效率高,但是不能延时加载的特点!

对于以上几种单例模式各有各的优缺点,一般:

单例对象占用资源少,不需要延时加载时:枚举式优于饿汉式;

单例对象占用资源大,需要延时加载时:静态内部类式好于懒汉式;

4 常见应用场景

单例模式常见的应用场景有很多,比如:

1、Windows系统的任务管理器、回收站等都是单例应用;

2、操作系统的文件系统,也是单例模式,因为一个操作系统只能有一个文件系统。

3、在Spring中,每个Bean默认就是单例的,这样做的优点是Spring容器可以管理

4、在servlet编程中,每个Servlet也是单例

5、在spring MVC框架/struts1框架中,控制器对象也是单例

5 总结

以上就是我对于单例模式的一些简单的理解。

如果你觉得本文不错,就点赞分享给更多的人吧!

如果你觉得文章有不足之处,或者更多的想法和理解,欢迎指出讨论!

相关推荐: