手撕设计模式——计划生育之单例模式

1.业务需求

​ 大家好,我是菠菜啊。80、90后还记得计划生育 这个国策吗?估计同龄的小伙伴们,小时候常常被"只生一个好""少生、优生"等宣传标语洗脑,如今国家已经放开并鼓励生育了。话说回来,现实生活中有计划生育,你知道设计模式中也有计划生育吗?它是怎么实现的?

2.代码实现

我们只要保证一个类只有一个实例化对象,这样就能达到计划生育的目的。其实这个设计模式大家应该都很熟悉了,叫做单例模式

实现思路

类的实例化交给类本身,对外提供一个访问该单例的全局访问点,重点考虑线程安全、系统资源消耗、反射以及反序列化破坏等因素。

2.1 静态代码块

java 复制代码
public  class StaticBlockSingleton {

    private static StaticBlockSingleton singleton;

    static {
        singleton=new StaticBlockSingleton();
    }

    private StaticBlockSingleton(){

    }

    public static StaticBlockSingleton getInstance(){
        return singleton;
    }
}

2.2 饿汉式

java 复制代码
public  class HungrySingleton  {

    private static HungrySingleton singleton=new HungrySingleton();

    private HungrySingleton(){

    }

    public static HungrySingleton getInstance(){
        return singleton;
    }
}

思考 :静态代码块和饿汉式不管singleton对象有没有被使用,都会在系统初始化的时候初始化对象从而占用系统资源

2.3 懒汉式

java 复制代码
public  class LazySingleton implements Serializable {

    //volatile 防止指令重排
    private static volatile LazySingleton singleton;

    private LazySingleton(){

    }

    public static LazySingleton getInstance(){
        //1.第一层检索 提高执行效率 如果不是null 直接返回
        if(null==singleton){
            //2.多个线程同时进入 获取锁的执行,没有获取锁的等待
            synchronized (LazySingleton.class){
                //3.防止2步骤有等待锁的线程 锁释放后拿到锁后需判断一下对象是否创建 
                if (null==singleton){
                    singleton=new LazySingleton();
                }
            }
        }
        return singleton;
    }
}

思考 :**双重检查锁定(Double-Check Locking)**懒汉式让对象实例化延迟加载,减少了对象未被使用而占用系统资源,但是引入了锁,系统性能有一定影响。volatile关键字会屏蔽Java虚拟机所做的一些代码优化,也可能会导致系统运行效率降低。

拓展:指令重排

问题:

​ 为什么DCL实现单例,还需要用volatile修饰实例呢?

分析:

​ 问题出现在'singleton=new LazySingleton();'这行代码,java创建对象不是一个原子操作,可以被分解为3步:

java 复制代码
//1.分配对象的内存空间
//2.初始化对象
//3.将instance指向刚分配的内存地址

编译器或者处理器在执行代码的时候为了最大地提高性能,可能会将执行执行顺序重排,2和3执行顺序可能是相反的。在单线程情况下,重排序没有什么问题,因为他最终结果都是一致的。但是如果在多线程并发下,就会有可能有问题了。下面模拟俩个线程创建单例的场景:

CPU时间片 线程A 线程B
T1 A-1:分配singleton对象的内存空间
T2 A-3:将instance指向刚分配的内存地址 B-1:第一层判断instance是否为null
T3 B-2:instance不为null,B线程获得instance引用的对象
T4 A-2:初始化对象
T5 A-4:A线程获得instance引用的对象

​ 如果按照上面的顺序,B线程获取到的是一个未初始化的对象,这就有问题了。解决方案就是引入volatile关键字,它有俩个作用:一是保证变量的内存可见性,二是禁止指令重排。

保证变量内存可见性:

如果属性被volatile修饰,相当于会告诉CPU,对当前属性的操作,不允许使用CPU的缓存,必须去和主内存操作。

volatile的内存语义:

  • volatile属性被写:当写一个volatile变量,JMM会将当前线程对应的CPU缓存及时的刷新到主内存中
  • volatile属性被读:当读一个volatile变量,JMM会将对应的CPU缓存中的内存设置为无效,必须去主内存中重新读取共享变量

禁止指令重排:

当我们使用 volatile 关键字来修饰一个变量时,Java 内存模型会插入内存屏障(一个处理器指令,可以对 CPU 或编译器重排序做出约束)来确保以下两点:

  • 写屏障(Write Barrier):当一个 volatile 变量被写入时,写屏障确保在该屏障之前的所有变量的写入操作都提交到主内存。
  • 读屏障(Read Barrier):当读取一个 volatile 变量时,读屏障确保在该屏障之后的所有读操作都从主内存中读取。

2.4 静态内部类

java 复制代码
public class StaticInnerSingleton implements Serializable {

    private static class InnerSingleton{
        private static final StaticInnerSingleton instance = new StaticInnerSingleton();
    }

    private StaticInnerSingleton(){

    }
    public static StaticInnerSingleton getInstance(){
        return InnerSingleton.instance;
    }
    
}

思考 :静态instance不是StaticInnerSingleton类的成员变量,所以在类加载的时候不会实例化instance,当第一次调用getInstance方法时,内部类InnerSingleton类会初始化instance,JVM保证其线程安全性,确保该成员变量只初始化一次。既实现了延迟加载,又没有性能消耗所以静态内部类这种方式比较推荐。但是有反射和反序列化破坏问题

2.5 枚举

java 复制代码
public enum  Singleton implements Serializable {
    INSTANCE;
    
    void doSomething(){
        System.out.println("do something");
    }

}

思考:枚举可以天然的防止反射和反序列化,但是不能延迟加载,这种方式是《Effective Java》作者的Josh Bloch提倡的方式。

拓展:反射和反序列化破坏单例

反射破坏单例

破坏案例:

以DCL为例,反射生成俩个对象。

java 复制代码
public class Client {
    public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
        //获取类的构造器
        Constructor<LazySingleton> constructor = LazySingleton.class.getDeclaredConstructor();
        //设置权限
        constructor.setAccessible(true);
        //使用 constructor 创造对象
        LazySingleton obj1 = constructor.newInstance();
        LazySingleton obj2 = constructor.newInstance();
        System.out.println(obj1);
        System.out.println(obj2);

    }
}

运行结果

打印俩次对象的地址不一样,说明俩个对象不是同一个。

预防措施:

可以在构造方法中抛出异常

java 复制代码
public  class LazySingleton implements Serializable {

    //volatile 防止指令重排
    private static volatile LazySingleton singleton;

    private LazySingleton(){
        if(singleton!=null){
            throw new RuntimeException("不允许重复创建对象!");

        }
    }
 }

枚举预防源码:

newInstance方法单独判断是否是枚举类型,如果是的话抛出异常,防止反射破坏单例模式。


*

反序列化破坏单例

破坏案例:

以DCL为例,反序列化生成俩个对象。

java 复制代码
public class Client2 {
    public static void main(String[] args) throws Exception {
        LazySingleton hungrySingleton = LazySingleton.getInstance();
        System.out.println(hungrySingleton);

        //将得到的实例序列化到磁盘
        FileOutputStream fileOutputStream = new FileOutputStream("D:/LazySingleton.txt");
        ObjectOutputStream objectOutputStream = new ObjectOutputStream(fileOutputStream);
        objectOutputStream.writeObject(hungrySingleton);
        objectOutputStream.flush();
        objectOutputStream.close();

        //从磁盘反序列化得到实例
        FileInputStream fileInputStream = new FileInputStream("D:/LazySingleton.txt");
        ObjectInputStream objectInputStream = new ObjectInputStream(fileInputStream);
        LazySingleton singleton = (LazySingleton) objectInputStream.readObject();
        System.out.println(singleton);

    }
}

运行结果

打印俩次对象的地址不一样,说明俩个对象不是同一个。

原因分析

obj = desc.isInstantiable() ? desc.newInstance() : null; 这行代码的意思是:如果这个类可以序列化,就创建新对象,不行就返回null。

预防措施

添加readResolve(),返回单例对象。当反序列化恢复一个新对象时,系统会自动调用这个readResolve()方法返回指定好的对象。

java 复制代码
public  class LazySingleton implements Serializable {

    //volatile 防止指令重排
    private static volatile LazySingleton singleton;


    private Object readResolve(){
        return singleton;
    }
}

枚举预防源码:

readEnum方法中'Enum<?> en = Enum.valueOf((Class)cl, name);'这行代码,等提供于将instance对象赋值给en,枚举做了特殊处理,防止反序列化破坏单例模式。

3.定义以及实现步骤

单例模式(Singleton)指一个类只有一个实例,且该类能自行创建这个实例的一种模式。

​ 通用实现步骤:

  1. 私有化构造方法
  2. 在单例内部创建一个唯一实例
  3. 提供一个外部获取实例的方法

4.优缺点以及应用场景

优点:

  • 提供了唯一实例的全局访问方法,可以优化共享资源的访问
  • 避免对象的频繁创建和销毁,可以提高性能

缺点:

  • 单例模式的代码基本上在一个类中,违反了单一职责
  • 单例模式不易扩展,扩展需要修改原来的代码,违背开闭原则

适用场景:

  • 创建一个对象资源消耗过高,并且只需一个
  • 只允许使用一个公共访问点

现实应用场景:

  • 数据库连接池
  • 手机app窗口(大多数app)
  • Spring中Bean的默认生命周期
  • Windows任务管理器

你的收藏和点赞就是我最大的创作动力,关注我我会持续输出更新!

友情提示:请尊重作者劳动成果,如需转载本博客文章请注明出处!谢谢合作!

【作者:我爱吃菠菜 】

相关推荐
阿伟*rui1 小时前
配置管理,雪崩问题分析,sentinel的使用
java·spring boot·sentinel
XiaoLeisj3 小时前
【JavaEE初阶 — 多线程】单例模式 & 指令重排序问题
java·开发语言·java-ee
paopaokaka_luck3 小时前
【360】基于springboot的志愿服务管理系统
java·spring boot·后端·spring·毕业设计
dayouziei3 小时前
java的类加载机制的学习
java·学习
Yaml45 小时前
Spring Boot 与 Vue 共筑二手书籍交易卓越平台
java·spring boot·后端·mysql·spring·vue·二手书籍
小小小妮子~5 小时前
Spring Boot详解:从入门到精通
java·spring boot·后端
hong1616885 小时前
Spring Boot中实现多数据源连接和切换的方案
java·spring boot·后端
aloha_7895 小时前
从零记录搭建一个干净的mybatis环境
java·笔记·spring·spring cloud·maven·mybatis·springboot
记录成长java6 小时前
ServletContext,Cookie,HttpSession的使用
java·开发语言·servlet
睡觉谁叫~~~6 小时前
一文解秘Rust如何与Java互操作
java·开发语言·后端·rust