自学内容网 自学内容网

单例设计模式

1. 软件设计模式的概念

软件设计模式(Software Design Pattern),又称设计模式,是一套被反复使用多数人知晓的代码设计经验的总结。它描述了在软件设计过程中的一些不断重复发生的问题,以及该问题的解决方案。也就是说,它是解决特定问题的一系列套路,是前辈们的代码设计经验的总结,具有一定的普遍性,可以反复使用。

2. 设计模式分类

  • 创建型模式(5)

    用于描述“怎样创建对象”,它的主要特点是“将对象的创建与使用分离”。GoF(四人组)书中提供了单例、原型、工厂方法、抽象工厂、建造者等 5 种创建型模式。

  • 结构型模式(7)

    用于描述如何将类或对象按某种布局组成更大的结构,GoF(四人组)书中提供了代理、适配器、桥接、装饰、外观、享元、组合等 7 种结构型模式。

  • 行为型模式(11)

    用于描述类或对象之间怎样相互协作共同完成单个对象无法单独完成的任务,以及怎样分配职责。GoF(四人组)书中提供了模板方法、策略、命令、职责链、状态、观察者、中介者、迭代器、访问者、备忘录、解释器等 11 种行为型模式。

3. 单例设计模式

单例模式(Singleton Pattern)是 Java 中最简单的设计模式之一。这种类型的设计模式属于创建型模式,它提供了一种创建对象的最佳方式

这种模式涉及到一个单一的类,该类负责创建自己的对象,同时确保只有单个对象被创建。这个类提供了一种访问其唯一的对象的方式,可以直接访问,不需要实例化该类的对象。

3.1 单例模式的结构

单例模式的主要有以下角色:

  • 单例类——只能创建一个实例的类.
  • 访问类——使用单例类

3.2 单例模式的实现

单例设计模式分类两种:

​ 饿汉式:类加载就会导致该单实例对象被创建

​ 懒汉式:类加载不会导致该单实例对象被创建,而是首次使用该对象时才会创建

3.2.1 饿汉式

  • 饿汉式-静态变量方式
public class Test1 {
    public static void main(String[] args) {
            Teacher teacher=Teacher.getT();
    }
}

//创建单例类
class Teacher{
    //内存中,如果对象没有使用,会造成内存的浪费
    //构造方法私有化
    private Teacher(){

    };
    //在成员位置创建该类的对象
   private static Teacher t=new Teacher();
    //对外提供静态方法获取类对象
    public static  Teacher getT(){
        return t;
    }
}

注:

该方式在成员位置声明Teacher类型的静态变量,并创建Teacher类的对象tt对象是随着类的加载而创建的。如果该对象足够大的话,而一直没有使用就会造成内存的浪费。

3.2.2 懒汉式——线程不安全

  • 懒汉式-静态变量方式(线程不安全)
public class Test3 {
    public static void main(String[] args) {
        Teacher1 t=Teacher1.getTeacher1();
        System.out.println(t);
    }
}
class Teacher1{
    //构造方法私有化
    private Teacher1(){

    };
    //声明该类的对象
    private static Teacher1 teacher1;
    //提供静态方法-返回上面的实例对象
    public static Teacher1 getTeacher1(){
        //对声明的实例对象进行判断,如果为空,表示实例对象还没有被创建
        if(teacher1==null){
            //创建实例对象,并赋值给声明的类对象
            teacher1=new Teacher1();
        }
        //返回实例对象
        return teacher1;
    }
}

注:

从上面代码我们可以看出该方式在成员位置声明Teacher1类型的静态变量,并没有进行对象的赋值操作,那么什么时候赋值的呢?当调用getTeacher1()方法获取Teacher1类的对象的时候才创建Teacher1类的对象,这样就实现了懒加载的效果。但是,如果是多线程环境,会出现线程安全问题。

如果两个线程同时判断t为空,那么它们都会去实例化一个Teacher1对象,这就变成双例了。所以,我们要解决的是线程安全问题。

  • 演示线程不安全
public class Test3 {
    public static void main(String[] args) {
        //演示线程不安全
        //创建线程池
        ExecutorService executorService = Executors.newFixedThreadPool(5);
        for (int i = 0; i < 15; i++) {
            executorService.execute(()->{
                Teacher1 teacher1 = Teacher1.getTeacher1();
                System.out.println(teacher1);
            });
        }
    }
}
class Teacher1{
    //构造方法私有化
    private Teacher1(){

    };
    //声明该类的对象
    private static Teacher1 teacher1;
    //提供静态方法-返回上面的实例对象
    public static Teacher1 getTeacher1(){
        //对声明的实例对象进行判断,如果为空,表示实例对象还没有被创建
        if(teacher1==null){
            //创建实例对象,并赋值给声明的类对象
            teacher1=new Teacher1();
        }
        //返回实例对象
        return teacher1;
    }
}
  • 运行结果

在这里插入图片描述

3.2.3 懒汉式——线程安全

  • 将锁synchronized加在静态方法上
public class Test3 {
    public static void main(String[] args) {
        //演示线程不安全
        //创建线程池
        ExecutorService executorService = Executors.newFixedThreadPool(5);
        for (int i = 0; i < 15; i++) {
            executorService.execute(()->{
                Teacher1 teacher1 = Teacher1.getTeacher1();
                System.out.println(teacher1);
            });
        }
    }
}
class Teacher1{
    //构造方法私有化
    private Teacher1(){

    };
    //声明该类的对象
    private static Teacher1 teacher1;
    //提供静态方法-返回上面的实例对象
    public static synchronized Teacher1 getTeacher1(){
        //对声明的实例对象进行判断,如果为空,表示实例对象还没有被创建
        if(teacher1==null){
            //创建实例对象,并赋值给声明的类对象
            //此处出现线程安全问题
            teacher1=new Teacher1();
        }
        //返回实例对象
        return teacher1;
    }
}

注:

该方式也实现了懒加载效果,同时又解决了线程安全问题。但是在getTeacher1()方法上添加了synchronized关键字,导致该方法的执行效果特别低。从上面代码我们可以看出,其实就是在初始化teacher1的时候才会出现线程安全问题,一旦初始化完成就不存在了。

  • 双重检查锁-锁synchronized代码块

再来讨论一下懒汉模式中加锁的问题,对于 getTeacher1() 方法来说,绝大部分的操作都是读操作,读操作是线程安全的,所以我们没必让每个线程必须持有锁才能调用该方法,我们需要调整加锁的时机。由此也产生了一种新的实现模式:双重检查锁模式

**目标是:**如果没有实例化对象则加锁创建,如果已经实例化了,则不需要加锁,直接获取实例

public class Test4 {
    public static void main(String[] args) {
        T t = T.getT();
        System.out.println(t);
    }
}
class T{
    //构造方法私有化
    private T(){};
    //声明类对象
    private static T t;
    //提供静态方法-获取类对象
    public static T getT(){
        //第一次判断线程A和线程B同时看到t = null,如果不为null,则直接返回t
        if(t==null){
            //线程A或线程B获得该锁进行初始化
            synchronized (T.class){
                //其中一个线程进入该分支,另外一个线程则不会进入该分支
                if(t==null){
                    t=new T();
                }
            }
        }
        return t;
    }
}

上面的代码已经完美地解决了并发安全+性能低效问题:

第2行代码,如果t不为空,则直接返回对象,不需要获取锁;而如果多个线程发现t为空,则进入分支;
第3行代码,多个线程尝试争抢同一个锁,只有一个线程争抢成功,第一个获取到锁的线程会再次判断t是否为空,因为t有可能已经被之前的线程实例化
其它之后获取到锁的线程在执行到第4行校验代码,发现t已经不为空了,则不会再new一个对象,直接返回对象即可
之后所有进入该方法的线程都不会去获取锁,在第一次判断t对象时已经不为空了

双重检查锁模式是一种非常好的单例实现模式,解决了单例、性能、线程安全问题,上面的双重检测锁模式看上去完美无缺,其实是存在问题,在多线程的情况下,可能会出现空指针问题,出现问题的原因是JVM在实例化对象的时候会进行优化和指令重排序操作。

3.2.4 懒汉式——volatile关键字

要解决双重检查锁模式带来空指针异常的问题,只需要使用 volatile 关键字, volatile 关键字可以保证变量在多线程运行时的可见性和有序性,禁止指令重排。

3.2.4.1 使用volatile防止指令重排

因为 t = new Teacher1() ,它并非是一个原子操作,事实上,在 JVM 中上述语句至少做了以下这 3 件事:,在JVM中会经过三步:

  • 第一步是给 t 分配内存空间;
  • 第二步开始调用 Teacher 的构造函数等,来初始化 t;
  • 第三步,将 t 对象指向分配的内存空间(执行完这步 t 就不是 null 了)。

指令重排JVM在保证最终结果正确的情况下,可以不按照程序编码的顺序执行语句,尽可能提高程序的性能

这里需要留意一下 1-2-3 的顺序,因为存在指令重排序的优化,也就是说第 2 步和第 3 步的顺序是不能保证的,最终的执行顺序,可能是 1-2-3,也有可能是 1-3-2。

​ 如果是 1-3-2,那么在第 3 步执行完以后,singleton 就不是 null 了,可是这时第 2 步并没有执行,singleton 对象未完成初始化,它的属性的值可能不是我们所预期的值。假设此时线程 2 进入 getInstance 方法,由于 singleton 已经不是 null 了,所以会通过第一重检查并直接返回,但其实这时的 singleton 并没有完成初始化,所以使用这个实例的时候会报错,详细流程如下图所示:

在这里插入图片描述

public class Test4 {
    public static void main(String[] args) {
        T t = T.getT();
        System.out.println(t);
    }
}
class T{
    //构造方法私有化
    private T(){};
    //声明类对象
    //使用volatile禁止指令重排
    private volatile static T t;
    //提供静态方法-获取类对象
    public static T getT(){
        //第一次判断线程A和线程B同时看到t = null,如果不为null,则直接返回t
        if(t==null){
            //线程A或线程B获得该锁进行初始化
            synchronized (T.class){
                //其中一个线程进入该分支,另外一个线程则不会进入该分支
                if(t==null){
                    t=new T();
                }
            }
        }
        return t;
    }
}
3.2.4.2 保证变量在多线程运行时的可见性
  • 演示变量的不可见
public class Test5 {
    public static void main(String[] args) throws InterruptedException {
       S a=new S();
       a.start();
       Thread.sleep(3000);
        System.out.println("主线程设置a线程的参数来止损失");
        a.setFlag(false);
    }
}
class S extends Thread{
    private boolean flag=true;
    public void setFlag(boolean flag){
        this.flag=flag;
    }
    @Override
    public void run() {
        System.out.println("进入run方法");
        while (flag){
        }
    }
}
  • 运行结果

在这里插入图片描述

主线程中改变线程的变量flag为false,理论上线程while循环停止,线程应该结束。
因为在主线程中改变线程变量,仅在本地内存中做了改变,并没有被读取到线程的本地内存中。导致线程的本地内存flag值仍为true,while循环不关闭。线程不结束

  • 演示变量可见性
public class Test5 {
    public static void main(String[] args) throws InterruptedException {
       S a=new S();
       a.start();
       Thread.sleep(3000);
        System.out.println("主线程设置a线程的参数来止损失");
        a.setFlag(false);
    }
}
class S extends Thread{
    private volatile boolean flag=true;
    public void setFlag(boolean flag){
        this.flag=flag;
    }
    @Override
    public void run() {
        System.out.println("进入run方法");
        while (flag){
        }
    }
}
  • 运行结果

在这里插入图片描述

在变量flag上面添加volatile修饰,声明为不稳定的变量,指示JVM改变了不稳定,每次使用它都到主存中进行读取,从而保证了变量的可见性,线程结束

在 JDK1.2 之前,Java的内存模型实现总是从主存(即共享内存)读取变量,是不需要进行特别的注意的。而在当前 的 Java 内存模型下,线程可以把变量保存本地内存(比如机器的寄存器)中,而不是直接在主存中进行读写。这就 可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致。 要解决这个问题,就需要把变量声明为 volatile,这就指示 JVM,这个变量是不稳定的,每次使用它都到主存中进行 读取。

在这里插入图片描述

3.3 单例模式的优缺点

3.3.1 优点
  • 单例类只有一个实例,节省了内存资源
  • 对于一些需要频繁创建销毁的对象,使用单例模式可以提高系统性能
3.3.2 缺点

单例模式一般没有接口,扩展的话除了修改代码,基本没有其他途径

3.4 面试题—懒汉式和饿汉式的区别

懒汉式:首次使用对象时创建,不会造成资源浪费。缺点就是加锁同步带来的运行效率的损失

饿汉式:在类加载完成时就已经完成对象的创建,如果该对象不被使用,会造成资源浪费。但运行效率高


原文地址:https://blog.csdn.net/qq_59099003/article/details/140305649

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!