从零开始学习Java设计模式 | 创建型模式篇:单例设计模式

Posted 李阿昀

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了从零开始学习Java设计模式 | 创建型模式篇:单例设计模式相关的知识,希望对你有一定的参考价值。

从本讲开始,咱们就要开始正式学习23种设计模式了。当然,我们得按照顺序来学,首先先来学习23种设计模式里面的第一类模式,即创建型模式。

创建型模式的主要关注点是"怎样创建对象?",它的主要特点是"将对象的创建与使用分离"。也就是说,如果我们作为一个使用者的话,那么我们可以直接通过某种方式去获取别人创建好的对象,至于别人是如何创建的,我们并不关注。这样,就可以降低系统的耦合度了,使用者并不需要关注对象的创建细节,尤其是那些创建特别麻烦的对象。于是,创建型模式的好处就这样体现出来了。

此外,我们还应知道,创建型模式分为以下几种:

  • 单例模式
  • 工厂方法模式
  • 抽象工厂模式
  • 原型模式
  • 建造者模式

注意,在本讲中,我们先来学习第一种创建型模式,即单例模式。

概述

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

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

单例设计模式的概念讲完之后,接下来我们再来看一下单例设计模式的结构。

结构

单例设计模式主要有两种角色,它们分别是:

  • 单例类:该类只能创建一个对象
  • 访问类:该类其实就是测试类,即使用单例类

实现

在介绍单例设计模式的实现之前,我们先来看一下单例设计模式的分类,单例设计模式可分为如下两类:

  • 饿汉式:类加载就会导致该单实例对象被创建。也就是说类加载的时候,该类的对象就创建好了
  • 懒汉式:类加载不会导致该单实例对象被创建,而是首次使用该对象时才会创建

所以,我们如何去区分饿汉式和懒汉式呢?只需要去判断该单实例对象是否是在类加载的时候创建即可,若是,则是饿汉式,若不是,则是懒汉式。

饿汉式

在本节,我们先来看一下饿汉式是如何来实现的。大家要注意了,饿汉式又可分为多种实现方式,在这里,我只介绍两种,它们分别是:

  • 静态成员变量方式
  • 静态代码块方式

下面,我们先来看静态成员变量这种实现方式。

实现方式一:静态成员变量方式

打开咱们的maven工程,并在com.meimeixia包下再创建一个子包,即pattern,在该包下存放的都是我们编写的有关设计模式的代码,然后在pattern包下再创建一个子包,即singleton,这表明编写的有关单例设计模式的代码我们都会放在该包下。

由于单例设计模式有多种实现方式,要想讲清楚每一种实现方式,我们还得在com.meimeixia.pattern.singleton包下分别创建多个子包来存放每一种实现方式所对应的代码,例如com.meimeixia.pattern.singleton.demo1包里面存放的就是饿汉式第一种实现方式(即静态成员变量方式)所对应的代码。

以上工作都做好之后,下面咱们就得来正式编写代码实现饿汉式的第一种实现方式(即静态成员变量方式)了。

首先,在com.meimeixia.pattern.singleton.demo1包下创建一个单例类,不妨我们就命名为Singleton。

那如何创建该类呢?只要大家跟着我的思路一步一步来,相信大家肯定能创建出来。

  1. 私有构造方法:为什么要私有构造方法呢?因为私有了构造方法之后,外界就访问不到这个构造方法了,访问不到的话外界就无法去创建对象了
  2. 在本类中创建一个本类对象供外界去使用
  3. 提供一个公共的访问方式,让外界获取该对象

经过上述三步,相信你很快就能创建出Singleton类了。

package com.meimeixia.pattern.singleton.demo1;

/**
 * 饿汉式:静态成员变量
 * @author liayun
 * @create 2021-05-28 18:07
 */
public class Singleton {

    // 1. 私有构造方法
    private Singleton() {}

    // 2. 在本类中创建本类对象
    private static Singleton instance = new Singleton();

    // 3. 提供一个公共的访问方式,让外界获取该对象
    public static Singleton getInstance() {
        return instance;
    }

}

注意,以上该类中对外提供的公共访问方法,除了使用public修饰之外,还使用了static修饰,这是为什么呢?因为外界无法创建Singleton类的对象,既然不能创建对象的话,那么就无法去调用其非静态方法了,所以这里面我们对外要提供的是静态方法。而且,由于静态的不能直接访问非静态的,所以instance成员变量还得使用static来修饰。

然后,创建一个测试类,这里我们不妨就命名为Client。

package com.meimeixia.pattern.singleton.demo1;

/**
 * @author liayun
 * @create 2021-05-28 18:16
 */
public class Client {

    public static void main(String[] args) {
        // 创建Singleton类的对象
        Singleton instance = Singleton.getInstance();

        Singleton instance1 = Singleton.getInstance();

        // 判断获取到的两个是否是同一个对象
        System.out.println(instance == instance1);
    }

}

可以看到,两次获取到Singleton类的对象之后,我们还得再来判断一下获取到的两个对象是不是同一个对象。

此时,我们运行一下以上测试类,如下图所示,可以看到打印结果是true,也就是说两次获取到Singleton类的对象是同一个对象,这样,我们就已经保证Singleton这个类只能创建一个对象了。

在这里插入图片描述

实现方式二:静态代码块方式

饿汉式的第一种实现方式我们已经实现了,接下来,我们再来看一下饿汉式的第二种实现方式,即静态代码块方式。

首先,在com.meimeixia.pattern.singleton包下再创建一个子包,即demo2,这表明编写的饿汉式的第二种实现方式的代码我们都会放在该包下。

然后,在com.meimeixia.pattern.singleton.demo2包下创建一个单例类,该类我们同样命名为Singleton。

同理,只要大家跟着我的思路一步一步来,相信你一定能创建出该类。

  1. 私有构造方法:私有构造方法其实就是为了让外界不能创建该类的对象
  2. 在成员位置声明一个该类的成员变量,不过不要给其赋值
  3. 在静态代码块中进行赋值
  4. 对外提供一个公共的访问方式,让外界获取该对象

经过上述四步,相信你很快就能创建出Singleton类了。

package com.meimeixia.pattern.singleton.demo2;

/**
 * 饿汉式:静态代码块
 * @author liayun
 * @create 2021-05-28 18:29
 */
public class Singleton {

    // 私有构造方法
    private Singleton() {}

    // 声明Singleton类型的变量
    private static Singleton instance;

    // 在静态代码块中进行赋值
    static {
        instance = new Singleton();
    }

    // 对外提供获取该类对象的方法
    public static Singleton getInstance() {
        return instance;
    }

}

你会发现,这和饿汉式的第一种实现方式唯一的区别是:对于饿汉式的第一种实现方式来说,在单例类里面是在成员位置声明了一个该类的成员变量,声明的同时并直接为其进行了赋值;而对于饿汉式的第二种实现方式而言,在单例类里面是先在成员位置声明了一个该类的成员变量,然后再在静态代码块里面对其进行赋值。除此之外,这两种实现方式就差不多了,其他就没有什么区别了。

紧接着,创建一个测试类,同理我们不妨命名为Client。

package com.meimeixia.pattern.singleton.demo2;

/**
 * @author liayun
 * @create 2021-05-28 23:47
 */
public class Client {
    public static void main(String[] args) {
        Singleton instance = Singleton.getInstance();
        Singleton instance1 = Singleton.getInstance();
        // 判断两次获取到的Singleton对象是否是同一个对象
        System.out.println(instance == instance1);
    }
}

最后,我们运行一下以上测试类,若打印结果是true,则说明我们确实已经做到了单例;若打印结果是false,则说明我们写的代码是有问题的。如下图所示,可以看到打印结果是true,也就是说两次获取到Singleton类的对象是同一个对象,当然了,多次获取到的肯定也是同一个对象,这不用说。

在这里插入图片描述

至此,我就将饿汉式的两种实现方式讲解完了。那么我们不妨再来总结一下饿汉式有什么特点?不管是饿汉式的第一种实现方式还是第二种实现方式,它们都属于是在类加载的时候就已经创建了该类的对象。

大家不妨设想一下这种情况,如果我们只是对单例类进行了一个类加载的操作,并没有去获取该类的对象,那么这个对象是不是已经存在于内存中了啊!当然了,前提是使用饿汉式的方式来实现单例。此时,如果我们一直不用它的话,那么你会发现它是一直存在于内存中的,这势必就会造成内存的浪费。也就是说,饿汉式的另外一个特点是会造成内存的浪费。

由于饿汉式会浪费内存,所以我们还得去学习一下懒汉式,下面我就会讲到。

懒汉式

饿汉式的两种实现方式讲完之后,接下来我们来看一下懒汉式。当然,懒汉式它也有多种实现方式,下面我会一一介绍给大家。

接下来,我们先来看懒汉式的第一种实现方式,即线程不安全的方式。

实现方式一:线程不安全

首先,在com.meimeixia.pattern.singleton包下再创建一个子包,即demo3,这表明编写的懒汉式的第一种实现方式的代码我们都会放在该包下。

然后,在com.meimeixia.pattern.singleton.demo3包下创建一个单例类,该类我们同样命名为Singleton。

同理,只要大家跟着我的思路一步一步来,相信你一定能创建出该类。

  1. 私有构造方法
  2. 在成员位置声明一个该类的成员变量,不过不要给其赋值
  3. 对外提供一个公共的访问方式

经过以上三个步骤,可能有些同学写出来的Singleton类是下面这样的。

package com.meimeixia.pattern.singleton.demo3;

/**
 * 懒汉式
 * @author liayun
 * @create 2021-05-29 17:57
 */
public class Singleton {

    // 私有构造方法
    private Singleton() {}

    // 声明Singleton类型的变量instance
    private static Singleton instance; // 只是声明了一个该类型的变量,并没有对其进行赋值

    // 对外提供访问方式
    public static synchronized Singleton getInstance() {
        instance = new Singleton();
        return instance;
    }

}

写出来之后,他还很得意,他说懒汉式的概念不就是在首次使用单实例对象的时候才创建的嘛,首次使用无非就是调用以上getInstance方法呗,所以我在该方法里面给其赋值并返回出去,不是合情合理的吗?是,懒汉式的概念理解得是没错,但他没意识到一个问题,就是外界每调用一次getInstance方法,都会发现又重新new了一个对象,这样,多次获取到的肯定就不是同一个对象了。

你要是不信的话,不妨编写一个测试类来测试一下。

package com.meimeixia.pattern.singleton.demo3;

/**
 * @author liayun
 * @create 2021-05-29 18:03
 */
public class Client {

    public static void main(String[] args) {
        Singleton instance = Singleton.getInstance();
        Singleton instance1 = Singleton.getInstance();

        // 判断两次获取到的Singleton对象是否是同一个对象
        System.out.println(instance == instance1);
    }

}

运行以上测试类之后,如下图所示,你会发现打印结果是false,为什么是false呢?上面我已经分析过了,这里不再赘述。

在这里插入图片描述

而我们现在想要的是以上Singleton类只能创建一个对象,所以我们得在Singleton类的getInstance方法里面加上一个如下if判断。

package com.meimeixia.pattern.singleton.demo3;

/**
 * 懒汉式
 * @author liayun
 * @create 2021-05-29 17:57
 */
public class Singleton {

    // 私有构造方法
    private Singleton() {}

    // 声明Singleton类型的变量instance
    private static Singleton instance; // 只是声明了一个该类型的变量,并没有对其进行赋值

    // 对外提供访问方式
    public static Singleton getInstance() {
        // 判断instance是否为null,如果为null,那么说明还没有创建Singleton类的对象
        // 如果没有创建的话,那么我们就创建一个并返回;如果有创建,那么直接返回即可
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }

}

代码改进之后,不妨再来运行一下测试类,如下图所示,发现打印结果是true,这说明咱们多次获取到的是同一个对象。

在这里插入图片描述

你觉得以上Singleton类写的有没有什么问题啊?肯定是存在问题的,如果是多线程环境下,那么就会出现线程安全问题。

为什么这么说呢?假设现在是多线程环境,两个线程同时调用getInstance方法,线程1拿到cpu的执行权,它在调用getInstance方法时,首先肯定是要做一个判断的,做完判断之后,它会进入到判断里面。此时,如果线程2拿到了cpu的执行权,那么线程1就会处于等待状态,同样,线程2在调用getInstance方法时也是要进行判断的,你觉得线程2还能进入到判断里面吗?

肯定能够啊!因为此时if判断条件是成立的,即instance是等于null的,这一切都是由于线程1还处于等待状态,还未执行下面的代码而导致的。也就是说,只要线程2获取到了cpu的执行权,那么它也会进入到判断里面。这样,以上Singleton类创建的就不是单个对象了,而是多个。

实现方式二:线程安全

既然以上实现方式在多线程环境下会出现线程安全问题,那么我们就得改进代码了,如何进行改进呢?很简单,只须在以上Singleton类的getInstance方法上加上一个同步关键字(即synchronized)即可。

package com.meimeixia.pattern.singleton.demo3;

/**
 * 懒汉式
 * @author liayun
 * @create 2021-05-29 17:57
 */
public class Singleton {

    // 私有构造方法
    private Singleton() {}

    // 声明Singleton类型的变量instance
    private static Singleton instance; // 只是声明了一个该类型的变量,并没有对其进行赋值

    // 对外提供访问方式
    public static synchronized Singleton getInstance() {
        // 判断instance是否为null,如果为null,那么说明还没有创建Singleton类的对象
        // 如果没有创建的话,那么我们就创建一个并返回;如果有创建,那么直接返回即可
        if (instance == null) {
            // 线程1等待,线程2获取到cpu的执行权,也会进入到该判断里面
            instance = new Singleton();
        }
        return instance;
    }

}

由于涉及到多线程,如果我们暂时不创建多线程的话,那么是演示不出来效果的,而且即使创建出来多线程,也不一定能演示出来效果,所以,我们只好做如下分析了。

假设线程1现在拿到了cpu的执行权,它在调用getInstance方法时,首先肯定是要做一个判断的,做完判断之后,它会进入到判断里面。此时,如果线程2拿到了cpu的执行权,那么线程1就会处于等待状态,这样,当线程2调用getInstance方法时,线程2现在还能进入到判断里面吗?很显然,进不来,因为这里面有一个同步锁,而线程1还在判断里面等待,它并没有释放这个同步锁,所以线程2是进不来的,它只能在外面等着。那么这样的话,线程2就只能等线程1执行完,而线程1执行完,就意味着instance变量已经被赋好值了,如此一来,就解决掉了线程不安全的问题了。

因此,以上两种实现方式要进行选择的话,我们肯定首选线程安全的这种实现方式。

实现方式三:双重检查锁

接下来,我们来看一下懒汉式的第三种实现方式,即双重检查锁方式。在讲解双重检查锁方式之前,我们先讨论一下上面懒汉式线程安全的那种实现方式所存在的问题,也即在getInstance方法上加了锁之后所导致的性能问题。

先看下面这句话:

对于getInstance方法来说,绝大部分的操作都是读操作,而读操作本身就是线程安全的,所以我们没必要让每个线程必须持有锁才能调用该方法,故我们需要调整加锁的时机。

看完上面这句话之后,有些同学可能不甚理解什么是读操作。没关系,我为大家解释一下,大家不妨看一下以上懒汉式的第二种实现方式所对应的代码,可以看到,第一次调用getInstance方法的时候,会先判断instance是否为null,此时肯定是为null的,所以就会创建一个Singleton类的对象,并将其赋给instance,这是一个赋值的操作,我们又可以称为写操作。

赋完值之后,接着就要把instance的值进行一个返回了,即将当前类的对象返回。后续每一次调用getInstance方法时,由于if判断语句中的条件不再成立(这是因为instance已经被赋予值了),所以都会直接将instance的值进行返回。于是,后续每一次调用getInstance方法,将instance直接返回的这个操作,我们就可以把它称为读操作了。

明确了读操作之后,我们继续看以上那句话的后半段描述,说我们没必要让每个线程必须持有锁才能调用getInstance方法,为什么呢?因为这会导致程序运行的性能更加低下。所以,我们才要调整加锁的时机,由此也产生了一种新的实现方式——双重检查锁方式。

既然是双重检查,那么肯定是两次判断了。接下来,我们就来看一下具体的代码实现。

首先,在com.meimeixia.pattern.singleton包下再创建一个子包,即demo4,这表明编写的懒汉式的第三种实现方式的代码我们都会放在该包下。

然后,在com.meimeixia.pattern.singleton.demo4包下创建一个单例类,该类我们同样命名为Singleton。

同理,只要大家跟着我的思路一步一步来,相信你一定能创建出该类。

  1. 私有构造方法
  2. 在成员位置声明一个该类的成员变量,不过不要给其赋值
  3. 对外提供一个公共的访问方式,在该方法里面记得要做两次判断

经过以上三个步骤,相信大家能写出来下面这样的Singleton类。

package com.meimeixia.pattern.singleton.demo4;

/**
 * 双重检查锁方式
 * @author liayun
 * @create 2021-05-29 18:48
 */
public class Singleton {
    // 私有构造方法
    private Singleton() {}

    // 声明Singleton类型的变量
    private static /*volatile*/ Singleton instance;

    // 对外提供公共的访问方式
    public static Singleton getInstance() {
        // 第一次判断,如果instance的值不为null,那么就不需要抢占锁了,直接返回对象即可
        if (instance == null) {
            synchronized (Singleton.class) {
                // 第二次判断
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

可以看到,对于getInstance方法来说,我们是做了两次判断的。

  • 第一次判断:判断instance是否等于null,若是,则需抢占锁;若不是,则直接返回instance即可,由于并没有去持有锁,所以效率就可以得到提升了
  • 第二次判断:如果第一次判断时,instance等于null,那么进来判断里面之后我们得先持一把锁,

以上是关于从零开始学习Java设计模式 | 创建型模式篇:单例设计模式的主要内容,如果未能解决你的问题,请参考以下文章

从零开始学习Java设计模式 | 创建型模式篇:原型模式

从零开始学习Java设计模式 | 创建型模式篇:原型模式

从零开始学习Java设计模式 | 创建型模式篇:抽象工厂模式

从零开始学习Java设计模式 | 创建型模式篇:抽象工厂模式

从零开始学习Java设计模式 | 创建型模式篇:工厂方法模式

从零开始学习Java设计模式 | 创建型模式篇:工厂方法模式