Java设计模式:单例模式(饿汉式、懒汉式、枚举实现类)

❤ 作者主页:欢迎来到我的技术博客😎
❀ 个人介绍:大家好,本人热衷于Java后端开发,欢迎来交流学习哦!( ̄▽ ̄)~*
🍊 如果文章对您有帮助,记得关注点赞收藏评论⭐️⭐️⭐️
📣 您的支持将是我创作的动力,让我们一起加油进步吧!!!🎉🎉

一、什么是单例模式

单例模式 指在内存中只会创建且仅创建一次对象的设计模式。

在程序中 多次使用同一对象且作用相同 时,为了防止频繁地创建对象使得内存飙升,单例模式可以让程序仅在内存中 创建一个对象,让所有需要调用的地方都 共享 这一单例对象。

未使用单例模式:
在这里插入图片描述
 

使用单例模式:
在这里插入图片描述
 


二、单例模式的结构

单例模式通常由以下几个组成部分构成:

  1. 单例类(Singleton): 单例类是单例模式的 核心,它包含一个私有的构造方法、一个私有的静态实例变量和一个公共的静态访问方法。单例类负责创建并提供访问单例实例的唯一途径。

  2. 静态实例变量(Instance): 单例类中通常包含一个私有的静态实例变量,用于保存单例实例。该变量被声明为私有的,以保证只有单例类内部可以访问和修改它。

  3. 公共的静态访问方法(getInstance): 单例类通过公共的静态访问方法提供对单例实例的全局访问。该方法通常被命名为 getInstance(),在方法内部实现懒加载或饿加载实例的创建,并返回单例实例。

单例模式的结构示意图如下:
在这里插入图片描述
 

在这个结构中,单例模式的核心是单例类,它负责创建并提供访问单例实例的方法。通过静态实例变量,单例类确保只有一个实例存在,并通过静态访问方法使得外部代码可以获取该实例。


三、单例模式的类型

单例模式主要有以下两种类型:

  • 懒汉式:真正需要使用 对象时才会去创建该单例类对象。
  • 饿汉式:类加载 时已经创建好该单例对象,等待被程序调用。

四、单例模式的实现

1. 饿汉式

在饿汉式的单例模式中,实例在类加载时就被创建,因此不存在多线程安全问题。以下是两种常见的饿汉式单例模式的实现方式:

1.1 静态变量实现

核心方法:

public class Singleton {
    // 在类加载时就创建实例
    private static final Singleton instance = new Singleton();

    // 私有构造函数,防止外部实例化
    private Singleton() {
    }

    // 静态方法返回单例实例
    public static Singleton getInstance() {
      return instance;
    }
}

测试类:

public class Singleton {
    // 在类加载时就创建实例
    private static final Singleton instance = new Singleton();

    // 私有构造函数,防止外部实例化
    private Singleton() {
    }

    // 静态方法返回单例实例
    public static Singleton getInstance() {
      return instance;
    }
}

class SingletonTest {

  public static void main(String[] args) {

      //获取单例类的对象,因为对象私有,只能通过方法去获取
      Singleton instance1 = Singleton.getInstance();
      Singleton instance2 = Singleton.getInstance();

      //判断是否为同一个对象
      System.out.println("instance1和instance2的地址是否相同:" + instance1.equals(instance2));
   }

}

测试结果:
在这里插入图片描述


1.2 静态代码块实现

核心方法:

public class Singleton {
  
    // 声明实例变量
    private static final Singleton instance;

    // 使用静态代码块在类加载时初始化实例
    static {
      instance = new Singleton();
    }

    // 私有构造函数,防止外部实例化
    private Singleton() {
    }

    // 静态方法返回单例实例
    public static Singleton getInstance() {
      return instance;
    }
}

测试类:

public class Singleton {

    // 声明实例变量
    private static final Singleton instance;

    // 使用静态代码块在类加载时初始化实例
    static {
      instance = new Singleton();
    }

    // 私有构造函数,防止外部实例化
    private Singleton() {
    }

    // 静态方法返回单例实例
    public static Singleton getInstance() {
      return instance;
    }
}


class SingletonTest {

  public static void main(String[] args) {

      //获取单例类的对象,因为对象私有,只能通过方法去获取
      Singleton instance1 = Singleton.getInstance();
      Singleton instance2 = Singleton.getInstance();

      //判断是否为同一个对象
      System.out.println("instance1和instance2的地址是否相同:" + instance1.equals(instance2));
   }

}

测试结果:
在这里插入图片描述


2. 懒汉式

2.1 基本懒汉式(线程不安全)

这种实现方式最简单,就是在程序使用对象前,先判断该对象是否已经实例化(判空),若已实例化直接返回该类对象,否则先执行实例化操作。

核心代码:

public class Singleton {

	//私有静态变量实例,用于保存单例实例
    private static Singleton instance;

	//私有构造函数,确保只有该类内部可以实例化对象,阻止外部通过构造函数创建新的实例
    private Singleton() {
    }

	//静态方法返回单例实例
    public static Singleton getInstance() {
      //如果实例为null,则创建一个新的实例
      if (instance == null) {
        instance = new Singleton();
      }
      //返回单例实例
      return instance;
    }
}

2.2 加锁的懒汉式(线程安全)

首先,我们先看一下基本懒汉式中的核心方法:

public static Singleton getInstance() {
    if (singleton == null) {
        singleton = new Singleton();
    }
    return singleton;
}

这种实现方式的 getInstance() 方法并没有考虑多线程情况,在单线程的情况下是线程安全的。但是在多线程下,如果这时候有两个线程同时调用方法并判断 singleton 为空时,那么它们就都会去实例化一个 Singleton 对象,这样就可能导致创建多个实例。

因此,为了解决多线程带来的并发问题,最直接的方法就是加锁,这里可以在方法上加锁,也可以对类对象进行加锁,加锁后的核心代码如下:

//方式一:在方法上加锁
public static synchronized Singleton getInstance() {
    if (singleton == null) {
        singleton = new Singleton();
    }
    return singleton;
}

//方式二:对类对象加锁
public static Singleton getInstance() {
    synchronized(Singleton.class) {   
        if (singleton == null) {
            singleton = new Singleton();
        }
    }
    return singleton;
}

2.3 双重检查锁(优化)

首先,我们先来讨论一下在加锁的懒汉式模式中,该模式下我们解决并发问题使用的是对方法或者类对象进行加锁操作,但是这种方式也存在着问题,虽然加锁能解决线程安全问题,但是造成性能下降。每次调用 getInstance() 方法都需要进行同步,即使实例已经创建,仍然需要获取锁,这就大大影响了并发性能。

因此,我们进行优化的地方就是:如果没有实例化对象则加锁创建,如果已经实例化了,则不需要加锁,直接获取实例对象即可

最终,产生了一种新的实现模式:双重检查加锁

核心代码如下:

public static Singleton getInstance() {

    if (singleton == null) {  //第一次判断,如果singleton不为null,则不进入抢锁阶段,直接返回实例
        synchronized(Singleton.class) { //如果singleton为null,则进行抢锁操作
            if (singleton == null) { //第二次判断,抢到锁之后再次判断是否为null
                singleton = new Singleton();
            }
        }
    }
    return singleton;
}

代码详细解析:

  • 第3行代码,如果 singleton 不为空,则直接返回对象,不需要获取锁;而如果多个线程发现 singleton 为空,则进入下一环节;
  • 第4行代码,多个线程抢占同一把锁,只有一个线程会获取锁成功,第一个获取到锁的线程会再次判断 singleton 是否为空,因为 singleton 有可能已经被之前的线程实例化了;
  • 接下来,其他线程抢占到上一个线程释放的锁后,会去执行第4行代码进行判断,发现 singleton 已经不为空了,则不需要再去 new 一个对象,直接返回对象即可;
  • 之后所有进入该方法的线程都不会去获取锁了,因为在第一次判断的时候 singleton 已经不为空了。

双重检查锁模式下的完整代码如下:

public class Singleton {

    private static Singleton singleton;

    private Singleton() {
    }

    public static Singleton getInstance() {

        if (singleton == null) {
            synchronized(Singleton.class) {
                if (singleton == null) {
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}

到这里就基本理解了双重检查加锁,但大家可能有这个疑问:就是在双重检查锁的模式下,为什么需要进行两次的判断呢?

现在我们假设有两个线程A、B,他们同时都去请求我们单例模式下类的实例,当第一次判断的时候,此时 singleton 为空,所以两个线程都进入到对锁的抢占,假设线程A先抢占到了锁,那么线程B只能在加锁的代码外部进行等待,这个时候线程A创建了对象的实例,完成功能后释放了锁,这个时候线程B就抢占到了锁,进入代码内部。假设此时没有第二次判断的话,那么线程B也会去再次创建一个对象,这样就会出现多个对象了,因为线程A已经实例化对象,所以等到线程B的时候 singleton 已经是不为空了(线程A创建的),因此线程B只需直接返回即可。


双重检查锁模式是一种非常好的单例实现模式,解决了单例、性能、线程安全问题,但是还存在这最后一个问题:指令重排

JVM在实例化对象的时候会进行优化和指令重排操作,在 多线程 的环境下,就会出现 空指针 问题。

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

以下将对在多线程环境中,因为JVM的指令重排操作导致出现空指针的问题进行讲解:
JVM创建上述对象的过程主要分为三步:

  1. singleton 分配内存空间;
  2. 初始化 singleton 对象;
  3. singleton 指向分配好的内存空间

在这三步中,第2、3步有可能发生发生指令重排现象,即创建对象的顺序由原本的 1-2-3 变为 1-3-2,这样对创建对象并没有什么影响,但是在多线程环境下,假设线程A、线程B都去获取对象,有可能线程A在创建对象的过程中先执行了 1-3 操作,然后在准备去执行 2 操作的时候,这时候线程B提前进来了,此时线程B判断 singleton 已经不为空了,但是线程A还没有对 singleton 进行初始化,因此线程B获取到的是线程A未初始化好的 singleton 对象,最终就会报 空指针异常

具体的示意图如下:
在这里插入图片描述
 

在Java中,可以使用 volatile 关键字来防止JVM对指令进行重排。当一个字段被声明为 volatile 时,编译器和处理器会注意到这个字段可能被其他线程同时访问,因此不会对其进行优化重排。

具体来说,volatile 关键字提供了两个主要的功能:

  1. 禁止指令重排: 当一个字段被声明为 volatile 时,编译器和处理器会确保在读取、写入这个字段的操作前后不会进行重排,保证操作的有序性。
  2. 可见性: volatile 关键字还具有可见性的特性,即当一个线程修改了被volatile修饰的变量的值,其他线程会立即看到这个变化。这确保了多线程环境下的正确性。

最终实现的完整代码如下:

public class Singleton {

    private static volatile  Singleton singleton;

    private Singleton() {
    }

    public static Singleton getInstance() {

        if (singleton == null) {
            synchronized(Singleton.class) {
                if (singleton == null) {
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}

五、单例模式存在的问题

1. 单例模式存在什么问题

在上面我们定义的单例类(Singleton)中正常情况下只可以同时只有一个对象存在,但是存在着一些操作可以破坏这种现象,即可以使得上述单例模式中可以创建多个对象。

破坏这种现象主要有以下两种方式:

  • 序列化
  • 反射

2. 通过序列化与反序列化破坏单例模式

我们通过以下测试类来检验序列化与反序列化是否会创建不同的对象:

    public static void main(String[] args) {
    
        // 创建输出流
        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("Singleton.file"));
        
        // 将单例对象写到文件中
        oos.writeObject(Singleton.getInstance());
        
        // 从文件中读取单例对象
        File file = new File("Singleton.file");
        ObjectInputStream ois =  new ObjectInputStream(new FileInputStream(file));
        Singleton newInstance = (Singleton) ois.readObject();
        
        // 判断是否是同一个对象
        System.out.println(newInstance == Singleton.getInstance()); // false
    }

很显然,通过序列化和反序列化的方式,最终创建了两个不同的对象。

原因分析:
出现这种情况的原因是:readObject() 方法读入对象时,它必定会返回一个新的对象实例,因此必然会只想新的内存地址。

解决方案:
解决序列化和反序列化破坏单例模式的问题通常需要在单例类中添加一个特殊的方法,即 readResolve() 方法。这个方法在序列化时被调用,它允许开发者指定在反序列化后返回哪个实例,从而防止生成新的实例破坏单例模式。

以下展示了如何使用 readResolve() 方法来解决序列化与反序列化破坏单例模式的问题:

import java.io.Serializable;

public class Singleton implements Serializable {
  // 私有静态实例
  private static final Singleton INSTANCE = new Singleton();

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

  // 公共静态方法获取实例
  public static Singleton getInstance() {
    return INSTANCE;
  }

  // readResolve方法,确保在反序列化时返回同一实例
  private Object readResolve() {
    return INSTANCE;
  }
}

3. 通过反射破坏单例模式

同样,我们通过以下测试类来检验反射是否会创建不同的对象:

    public static void main(String[] args) {
    
        // 获取类的显式构造器
        Constructor<Singleton> construct = Singleton.class.getDeclaredConstructor();
        
        // 可访问私有构造器
        construct.setAccessible(true);
        
        // 利用反射构造新对象
        Singleton obj1 = construct.newInstance();
        
        // 通过正常方式获取单例对象
        Singleton obj2 = Singleton.getInstance();
        System.out.println(obj1 == obj2); // false
    }

很显然,通过反射的方式,最终也是创建了两个对象。

原因分析:
出现这种情况的原因是:通过反射,可以绕过单例类的私有构造方法的限制,直接调用构造方法创建新的实例,从而破坏了单例模式的约束。

解决方案:
要解决反射破坏单例模式的问题,可以在单例类的私有构造方法中进行特殊处理,以防止多次实例化。一种常见的方法是在构造方法中检查是否已存在实例,如果存在则抛出异常,防止通过反射再次创建实例。

public class Singleton {
  // 私有静态实例
  private static final Singleton INSTANCE = new Singleton();

  // 私有构造方法
  private Singleton() {
    // 在构造方法中检查实例是否已存在,如果存在则抛出异常
    if (INSTANCE != null) {
      throw new IllegalStateException("Singleton instance already created");
    }
  }

  // 公共静态方法获取实例
  public static Singleton getInstance() {
    return INSTANCE;
  }
}

六、利用枚举类实现单例模式(极力推荐)

1. 枚举实现单例模式

枚举实现方式属于饿汉式方式。

枚举类实现单例模式是极力推荐的单例实现模式,因为在Java中,枚举类型是线程安全且只会被实例化一次的,因此,当使用枚举方式实现单例模式时,无需担心多线程并发访问的问题。枚举类的实例在类加载时被创建,并且无法通过反射或序列化等方式再次实例化,是 唯一不会被破坏 的单例模式。

枚举实现的单例模式中,代码写法非常简单,具体如下:

public enum Singleton {
    INSTANCE;
}

我们可以这样来简单理解枚举实现单例的过程: 在程序启动时,会调用 Singleton 的空参构造器,实例化一个 Singleton 对象赋给 INSTANCE,之后再也不会去实例化。

接下来,我们使用一个测试类来证明在程序启动时仅会创建一个 Singleton 对象,并且是线程安全的。

public enum Singleton {
    
    INSTANCE;
    
    Singleton() { 
        System.out.println("枚举创建对象了"); 
    }
        
    public static void main(String[] args) {
        
        Singleton t1 = Singleton.INSTANCE;
        Singleton t2 = Singleton.INSTANCE;
        
        System.out.print("t1和t2的地址是否相同:" + t1 == t2);
    }
}

测试结果如下:

枚举创建对象了
t1和t2的地址是否相同:true

2. 枚举类单例模式防止序列化破坏

在读入 Singleton 对象时,每个枚举类型和枚举名字都是唯一的,所以在序列化时,仅仅只是对枚举的 类型和变量名 输出到文件中,在读入文件反序列化成对象时,利用 Enum 类的 valueOf(String name) 方法根据变量的名字查找对应的枚举对象。

所以,在序列化与反序列化的过程中,只是写出和读入了 枚举类型和名字,没有任何关于对象的操作。


3. 枚举类单例模式防止反射破坏

枚举类默认继承了 Enum 类,在利用反射调用 newInstance() 方法时,会判断该类是否是一个枚举类,如果是的话,则直接抛出异常。


七、单例模式的使用场景

单例模式适用于以下场景:

  1. 资源共享的情况: 当多个模块需要共享某个资源(例如数据库连接池或配置信息对象)时,使用单例模式可以确保只有一个实例存在,避免资源的重复创建和浪费。
  2. 控制资源的访问: 单例模式可以用于控制对资源的访问,例如线程池、线程管理、日志管理等,确保在系统中只有一个实例来管理这些资源,避免竞态条件和冲突。
  3. 配置管理: 当一个系统需要有一个全局的配置管理对象,负责读取配置文件,并提供配置信息给其他模块使用时,单例模式可以确保配置信息的一致性和唯一性。
  4. 日志记录: 在多线程环境下,使用单例模式可以确保日志记录的一致性,避免多个日志实例同时写入导致混乱。
  5. 线程池: 在需要维护线程池的应用中,单例模式可以用于确保只有一个线程池实例,以便更好地控制线程的创建和管理。
  6. 数据库连接池: 单例模式可以用于管理数据库连接池,确保系统中只有一个连接池实例,避免过多的数据库连接占用资源。
  7. 缓存管理: 当需要管理全局的缓存对象时,单例模式可以确保只有一个缓存管理实例,便于统一管理和更新缓存。

八、单例模式总结

  1. 单例模式常见的两种类型: 懒汉式、饿汉式
  2. 懒汉式: 在需要用到对象时才实例化对象,最佳的实现方式是 双重检查加锁 ,解决了并发安全和性能低下问题。
  3. 饿汉式: 在类加载时就创建好了单例对象,在获取单例对象时直接返回对象即可,不会存在并发安全和性能问题。
  4. 如果对内存要求非常高,则使用懒汉式写法,可以在特定的时候才创建该对象。
  5. 如果对内存要求不高,则使用饿汉式写法,因为实现简单,并且没有任何并发安全和性能安全。
  6. 为了防止在多线程环境下,因为指令重排导致出现空指针问题,可以在单例对象上添加 volatile 关键字来防止指令重排。
  7. 最好的实现方式是通过 枚举 来实现单例模式,实现方式简单,没有任何线程安全问题,且 Enum 类内部可以防止反射和序列化来破坏单例模式。

 
非常感谢您阅读到这里,如果这篇文章对您有帮助,希望能留下您的点赞👍 关注💖 分享👥 留言💬thanks!!!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Java技术一点通

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值