单例模式的几种实现方式

目录

一、饿汉式单例

二、懒汉式单例

三、加锁的懒汉式单例

[四、双重校验锁单例模式 DCL](#四、双重校验锁单例模式 DCL)

为什么是要两次判断?

为什么需要加volatile关键字?

五、静态内部类单例模式

六、枚举单例模式

其他

volatile的原理

volatile的扩展问题

如果说volatile不保证有序性,双重校验锁的写法是否有问题?


我们在设计多线程代码的时候,必须在满足线程安全的前提下,尽可能的提高任务执行的效率。使用多线程需要考虑的因素:

  • 提高效率: 使用多线程就是为了充分利用CPU资源,提高任务的效率。
  • 线程安全: 使用多线程最基本的就是保障线程安全问题。

单例模式是一种创建型设计模式,其目的是确保类只有一个实例,并且提供全局访问点以访问该实例。单例模式能保证某个类在程序中只存在唯一一份实例,而不会创建出多个实例。例如:DataSource(数据连接池),一个数据库只需要一个连接池对象。

在 Java 中,实现单例模式有多种方式,下面介绍其中的一些。

一、饿汉式单例

原理:基于类加载机制避免了多线程的同步问题。在饿汉式单例模式中,实例在类加载时就被创建,这种方式是满足线程安全的(JVM内部使用了加锁,即多个线程调用静态方法,只有一个线程竞争到锁并且完成创建,只执行一次),因此可以保证实例的唯一性。

具体来说,请看下面这个代码,

java 复制代码
public class Single {
	private static final Single INSTANCE = new Single();

	private Single() {}
	public static Single getInstance() {return INSTANCE;}

	public static void main(String[] args) {
		Single s1 = new Single().getInstance();
		Single s2 = new Single().getInstance();
		System.out.println(s1 == s2);

		for(int i = 0; i < 100; i++){
			new Thread(() ->     
               System.out.println(Singleton.getInstance().hashCode())
            ).start();
		}
	}
}

在这个例子中, INSTANCE 是一个静态常量,它在类加载时被初始化为 Single 类的实例。getInstance() 方法提供了对该实例的全局访问点。

优点:

  • 写法比较简单,没有线程同步等复杂问题。
  • 线程安全:实例在类装载的时候就完成实例化,避免了线程同步问题。
  • 线程访问单例实例的速度比懒汉式单例模式更快。

缺点:

  • 不是懒加载,如果从始至终从未使用过这个实例,会造成内存浪费。
  • 如果类被不同的类加载器加载就会创建不同的实例。
  • 如果单例类依赖于其他类,这些依赖的类在类加载时也会被创建。

二、懒汉式单例

原理:在懒汉式单例模式中,实例在第一次使用时才被创建。当实例没有被创建的时候,如果有多个线程都调用getInstance方法,就可能创建多个实例,就存在线程安全问题。但是实例一旦创建好,后面线程调用getInstance方法就不会出现线程安全问题。

  • 私有化构造函数 :防止外部通过new关键字直接创建类的实例。
  • 声明私有静态变量:用于存储类的唯一实例。
  • 提供公共静态方法:首先检查实例是否已经创建,如果没有则创建实例并返回;如果已经创建则直接返回。

具体来说,请看下面这个代码,

java 复制代码
public class Single {
	private static Single instance;

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

	public static Single getInstance() {
		if (instance == null) instance = new Single();
		return instance;
	}

	public static void main(String[] args) {
		Single s1 = new Single().getInstance();
		Single s2 = new Single().getInstance();
		System.out.println(s1 == s2);

		for(int i = 0; i < 100; i++){
			new Thread(() ->     
               System.out.println(Singleton.getInstance().hashCode())
            ).start();
		}
	}
}

在上面的实现中,instance 是静态变量,用于存储单例对象。在 getInstance 方法中,如果 instance 为 null,则创建一个新的 Singleton 对象,否则直接返回 instance。由于没有进行同步锁定,所以线程不安全,可能会导致并发创建多个实例的问题。

优点:

  • 简单易懂,易于实现。
  • 延迟加载:单例对象被使用的时候才初始化,避免了内存浪费。

缺点:线程不安全,只能在单线程中使用,当有多个线程同时进入getInstance() 方法中的if判断,若判断为 null 就会创建多个实例对象。

三、加锁的懒汉式单例

原理:我们对 getInstance() 方法使用synchronized关键字修饰,也就是每次调用该方法的时候都会竞争锁,但是创建实例只需要创建一次,即确保每次只有一个线程能够执行该方法。也就是创建实例后,再调用该方法还需要竞争锁释放锁。

具体来说,请看下面这个代码,

java 复制代码
public class Single {
	private static Single instance;

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

	public static synchronized Single getInstance() {
		if (instance == null) instance = new Single();
		return instance;
	}

	public static void main(String[] args) {
		Single s1 = new Single().getInstance();
		Single s2 = new Single().getInstance();
		System.out.println(s1 == s2);

		for(int i = 0; i < 100; i++){
			new Thread(() ->     
               System.out.println(Singleton.getInstance().hashCode())
            ).start();
		}
	}
}

优点:线程安全,只有在使用时才会实例化单例,一定程度上节约了资源。

缺点:效率低下,每次调用 getInstance() 获取实例时都需要加锁和释放锁。

适用:适用于那些实例创建开销较大,且不一定在程序启动时就需要使用的场景。例如,一些重量级的资源管理器、配置管理器等,可以在实际使用时才进行创建和初始化,以减少启动时间和内存占用。

四、双重校验锁单例模式 DCL

在上述代码的基础上进行改动:使用双重if判定来降低竞争锁频率,使用volatile修饰instance。

"双重检查锁"(Double-Checked Locking) 是懒汉式的一种优化,这种实现方式结合了懒汉式和饿汉式的优点,既能够延迟对象的创建时间,又能够保证线程安全。

原理:

  • 利用 volatile 关键字防止指令重排,保证了可见性。保证在多线程环境中,当一个线程读取到 instance 为非 null 时,它确实指向了一个有效的 实例。
  • 利用双重检查机制 减少了同步带来的性能损耗。同步代码块确保了在多线程环境下,只有一个线程能够初始化 instance 变量。

具体来说,请看下面这个代码,

java 复制代码
public class Single {
	private static volatile Single instance;

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

	public static synchronized Single getInstance() {
        //第一次判断,如果instance不为null,不进入抢锁阶段,直接返回实例
		if (instance == null){
			synchronized (Single.class){
                //抢到锁之后再次进行判断是否为null
				if (instance == null) instance = new Single();
			}
		}
		return instance;
	}

	public static void main(String[] args) {
		Single s1 = new Single().getInstance();
		Single s2 = new Single().getInstance();
		System.out.println(s1 == s2);

		for(int i = 0; i < 100; i++){
			new Thread(() ->     
               System.out.println(Singleton.getInstance().hashCode())
            ).start();
		}
	}

在这个例子中, instance 是一个 volatile 的静态变量,它在第一次使用时被创建。getInstance() 方法使用双重检查锁定来确保 INSTANCE 的唯一性。当多个线程同时访问 getInstance() 方法时,只有第一个线程会获得锁定并创建实例。其他线程会等待锁定被释放后再次检查 instance 是否为空,从而避免了多次创建实例的情况。

需要注意的是,使用 volatile 关键字可以确保 instance 变量的可见性和有序性,从而保证多线程环境下的正确性。

优点:

  • 线程安全,适合高并发场景。
  • 延迟加载,只有当需要用到实例时才会创建,节省了系统资源。
  • 效率较高:避免对整个方法被锁,只对需要锁的代码部分加锁。

缺点:

  • 实现复杂,容易出现问题。
  • 由于 Java 内存模型的限制,可能会出现指令重排的问题,需要使用 volatile 关键字来解决。

为什么是要两次判断?

(1)第一次判断singleton是否为null

第一次判断是在Synchronized同步代码块外进行判断,由于单例模式只会创建一个实例,并通过getInstance方法返回singleton对象,所以,第一次判断,是为了在singleton对象已经创建的情况下,避免进入同步代码块,直接return返回,提升效率。

(2)第二次判断singleton是否为null

第二次判断是为了避免以下情况的发生。

假设线程A已经经过第一次判断,判断singleton=null,准备进入同步代码块。此时线程B获得时间片,由于线程A并没有创建实例,所以,判断 singleton仍然=null,所以线程B创建了实例singleton。此时,线程A再次获得时间片,由于刚刚经过第一次判断 singleton=null(不会重复判断),进入同步代码块,这个时候,我们如果不加入第二次判断的话,那么线程A又会创造一个实例singleton,就不满足我 们的单例模式的要求,所以第二次判断是很有必要的。

因此,内层的的if判断是为了在实例未被创建时,多个线程同时竞争锁,只有一个线程竞争成功并创建实例,其他竞争失败的线程就会阻塞等待,当第一线程释放锁后,这些竞争失败的线程就会继续竞争,但是实例已经创建好了,所以需要再次进行if判断。

为什么需要加volatile关键字?

Volatile禁止JVM对指令进行重排序。所以创建对象的过程仍然会按照指令1-2-3 的有序执行。

保证变量的可见性:当一个被volatile关键字修饰的变量被一个线程修改的时候,其他线程可以立刻得到修改之后的结果。

屏蔽指令重排序:指令重排序是编译器和处理器为了高效对程序进行优化的 手段,它只能保证程序执行的结果时正确的,但是无法保证程序的操作顺序与代码顺序一致。这在单线程中不会构成问题,但是在多线程中就会出现问题。

在java内存模型中,volatile 关键字作用可以是保证可见性或者禁止指令重排。

这里是因为 singleton = new Singleton() ,它并非是一个原子操作,事实上,在 JVM 中上述语句至少做了以下这 3 件事:

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

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

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

五、静态内部类单例模式

由于饿汉式单例类不能实现延迟加载,不管将来用不用始终占据内存,懒汉式单例类线程安全控制烦琐,而且性能受影响。

静态内部类实现单例模式就可以克服以上两种单例模式的缺点,是一种优雅而简洁的实现方式。

原理:引入了静态内部类(只有在调用时才会加载),利用了 Java 类加载器机制,保证了实例的延迟初始化和唯一性,并避免了饿汉式单例模式的缺点。

具体来说,请看下面这个代码,

java 复制代码
public class Singleton {
	//私有构造函数,防止外部实例化
	private Singleton() {}

	private static class SingletonHolder{
		private static Singleton INSTANCE = new Singleton();
	}

	public static synchronized Singleton getInstance() {
		return SingletonHolder.INSTANCE;
	}

	public static void main(String[] args) {
		Singleton s1 = new Singleton().getInstance();
		Singleton s2 = new Singleton().getInstance();
		System.out.println(s1 == s2);

		for(int i = 0; i < 100; i++){
			new Thread(() ->     
               System.out.println(Singleton.getInstance().hashCode())
            ).start();
		}
	}
}

在这个例子中, Singleton 类的构造方法是私有的,只能在 Singleton 类的内部进行调用。 SingletonHolder 类是 Singleton 类的一个静态内部类,它在 Singleton 类被加载时并不会立即被加载,而是在第一次调用 Singleton.getInstance() 方法时才会被加载,从而实现了延迟加载。

由于静态内部类 SingletonHolder 只会被加载一次,因此 INSTANCE 实例也只会被创建一次,从而保证了实例的唯一性。总的来说,静态内部类单例模式是一种比较优秀的单例模式实现方式,它兼顾了线程安全、懒加载和高效等特点,是一种值得推荐的单例模式实现方式。

总结来说,由于静态单例对象没有作为Singleton的成员变量直接实例化,因此类加载时不会实例Singleton,第一次调用getInstance()时将加载内部类SingletonHolder,在该内部类中定义了一个static类型的变量instance,此时会首先初始化这个成员变量,由Java虚拟机来保证其线程安全性,确保该成员变量只能初始化一次。由于getInstance()方法没有任何线程锁定,因此其性能不会造成任何影响。

优点:

  • 线程安全: 静态内部类只会被加载一次,因此可以保证单例对象的线程安全性;
  • 延迟加载:第一次加载类时不会初始化实例,只有在第一次调用 getInstance()方法 时,JVM 会加载静态内部类,同时初始化实例。
  • 高效:静态内部类单例模式没有加锁,所以性能比懒汉式和饿汉式都要高;
  • 简单: 静态内部类单例模式的实现比较简单,代码量较少。

缺点:

  • 不易理解: 相比于饿汉式和懒汉式,静态内部类单例模式的实现方式可能不太容易理解;
  • 无法传递参数: 静态内部类单例模式的构造函数是私有的,无法传递参数。如果需要传递参数,需要使用其他方式实现。
  • 虽然保证了单例在多线程并发下的线程安全性,但是在遇到序列化对象时,默认的方式运行得到的结果就是多例的。

六、枚举单例模式

枚举单例模式是一种比较新的单例模式实现方式,它在 Java 5 中引入,是在JDK1.5以及以后版本中增加的一个"语法糖",它主要用于维护一些实例对象固定的类。例如一年有四个季节,就可以将季节定义为一个枚举类型,然后在其中定义春、夏、秋、冬四个季节的枚举类型的实例对象。按照Java语言的命名规范,通常,枚举的实例对象全部采用大写字母定义,这一点与Java里面的常量是相同的。

原理:默认枚举实例的创建是线程安全的,即使反序列化也不会生成新的实例,任何情况下都是一个单例。它利用了 Java 中枚举类型的特性来实现单例模式,枚举类型的每个枚举常量都是单例的。

具体来说,请看下面这个代码,

java 复制代码
public class Singleton {
	//私有构造函数,防止外部实例化
	private Singleton() {}

	private enum EnumSingleton{
		INSTANCE;
		private Singleton instance;
		//枚举类的构造方法在类加载时被实例化
		private EnumSingleton(){instance = new Singleton();}
		public Singleton getInstance(){return instance;}
	}

	public static Singleton getInstance(){
		return EnumSingleton.INSTANCE.getInstance();
	}

	public static void main(String[] args) {
		Singleton s1 = new Singleton().getInstance();
		Singleton s2 = new Singleton().getInstance();
		System.out.println(s1 == s2);
	}
}

在这个例子中,

多线程测试的结果如下,

总之,单例模式是一种非常常用的设计模式,可以确保类只有一个实例,并提供全局访问点以访问该实例。在 Java 中,有多种方式可以实现单例模式,开发者可以根据实际需要选择适合自己的实现方式。

其他

volatile的原理

volatile保证了可见性,有序性,在Java层面看,volatile是无锁操作,多个线程对volatile修饰的变量进行读可以并发并行执行,和无锁执行效率差不多。

volatile修饰的变量中,CPU使用了缓存一致性协议来保证读取的都是最新的主存数据。

缓存一致性:如果有别的线程修改了volatile修饰的变量,就会把CPU缓存中的变量置为无效,要操作这个变量就要从主存中重新读取。

volatile的扩展问题

如果说volatile不保证有序性,双重校验锁的写法是否有问题?

关于new对象按顺序分为3条指令:分配对象的内存空间 、实例化对象、赋值给变量。

正常的执行顺序为(1)(2)(3),JVM可能会优化进行重排序后的顺序为(1)(3)(2)。这个重排序的结果可能导致分配内存空间后,对象还没有实例化完成,就完成了赋值。在这个错误的赋值后,instance==null不成立,线程就会拿着未完成实例化的instance,使用它的属性和方法就会出错。

使用volatile保证有序性后,线程在new对象时不管(1)(2)(3)是什么顺序,后续线程拿到的instance是已经实例化完成的CPU里边,基于volatile变量操作是有CPU级别的加锁机制,它保证(1)(2)(3)全部执行完,写回主存,再执行其他线程对该变量的操作。

相关推荐
运维老司机4 分钟前
Jenkins关闭更新提醒和插件提醒
java·运维·jenkins
lly2024068 分钟前
MongoDB 监控:确保数据库性能和可靠性
开发语言
SlothLu10 分钟前
Debezium-EmbeddedEngine
java·数据库·设计模式·database·多线程·debezium·数据迁移
Hsu_kk15 分钟前
Kafka Offset 自动提交和手动提交 - 漏消费与重复消费
java·kafka
发呆的百香果子18 分钟前
Python接口自动化测试
开发语言·python
卿着飞翔20 分钟前
Java面试题2024-Java基础
java·开发语言·python
好奇的菜鸟26 分钟前
Go语言基本类型转换
开发语言·后端·golang
weisian15128 分钟前
认证鉴权框架SpringSecurity-6--6.x版本升级篇
java·spring boot·安全
Crossoads28 分钟前
【汇编语言】数据处理的两个基本问题(二) —— 解密汇编语言:数据长度与寻址方式的综合应用
android·java·开发语言·javascript·汇编·数据挖掘·c#
躺不平的理查德31 分钟前
数据结构及算法--排序篇
c语言·开发语言·数据结构·算法·排序算法