专栏首页喵叔's 专栏Java单例模式一文通

Java单例模式一文通

在程序开发中我们往往会涉及到设计模式,那么什么是设计模式呢?官方正式的定义是一套被反复使用经过分类编目,且多数人知晓的代码设计经验总结。简单的说设计模式是软件开发人员在软件开发过程中面临问题时所做出的解决方案。常用的设计模式有23中,因为篇幅有限在本篇文章中我之讲解23中设计模式中最经典的模式:单例模式。

零、什么是单例模式

单例模式是创建型模式的一种,它主要提供了创建类对象的最优方式。在单例模式下每个类负责创建自己的对象,并且要保证每个类只创建一个对象,也就是说每个类只能提供唯一的访问对象的方式。

单例模式的出现是为了解决全局类被频繁的创建和销毁造成的性能开销,以及避免对资源的多重占用。单例模式虽然解决了这些问题但是它存在几个问题,首先在单例模式下没有接口无法继承,其次它还与单一职责原则相互冲突,并且单例模式下的类只关注内部实现逻辑,不关注外部如何实例化。

Java 中实现单例模式的方式有六种,分别是饿汉模式、懒汉模式、加锁懒汉模式、双重判定加锁懒汉模式、内部静态类实现懒汉模式以及枚举懒汉模式。下面我分别对这六种实现单例模式的方法进行一一讲解。

一、饿汉模式

所谓饿汉模式就是在类被加载时就会实例化该类的一个对象,它就像是一个很饥饿人要迫不及待的吃东西一样。以饿汉模式编写的单例模式需要注意如下两点,首先类的构造函数必须定义为 private,这是为防止该类不会被其他类实例化,其次类必须提供了静态实例,并通过静态方法返回给调用方。下面我们就根据这两点来编写饿汉模式的单例模式。

//饿汉模式
public class HungryMode {
	//1.定义私有构造函数
	private HungryMode() {
	}
	//2.定义静态实例并返回给调用方
	private static HungryMode hungryMode=new HungryMode();
	public static HungryMode getHungryMode() {
		return hungryMode;
	}
	
}

饿汉模式的代码很简单,按照前面所说的两个注意点进行编写代码即可。这种模式可以快速且简单的创建一个线程安全的单例对象,之所以说它是线程安全的,是因为它只在类加载时才会初始化,在类初始化后的生命周期中将不会再次进行创建类的实例。因此这种模式特别适合在多线程情况下使用,因为它不会多个线程中创建多个实例,避免了多线程同步的问题。但是万物不是只有优点没有缺点的,这种模式最大的缺点就是不管你是否用到这个类,这个类都会在初始化的时候被实例化,这样会造成性能的轻微损耗。饿汉模式一般用于占用内存小并且在初始化时就会被用到的时候。

二、懒汉模式

什么是懒汉模式呢?懒汉模式就是只在需要对象时才会生成单例对象,它就像一个很懒的人只有在你叫他的时候他才会动一动。和饿汉模式一样,懒汉模式也有两点需要注意的,首先类的构造函数必须定义为 private,其次类必须提供静态实例对象且不进行初始化,并通过静态方法返回给调用方,在编写返回实例的静态方法时我们需要判断实例对象是否为空,如果为空则进行实例化反之则直接放回实例化对象。下面我们就来看以下代码如何实现懒汉模式。

//懒汉模式
public class LazyMode {
	//1.定义私有构造函数
	private LazyMode() {
	}
	//2.静态实例对象且不进行初始化
	private static LazyMode lazyMode;
	//3.编写静态方法,返回实例对象
	public static LazyMode getLazyMode() {
		if(lazyMode==null)
			lazyMode=new LazyMode();
		return lazyMode;
	}
}

懒汉模式规避了饿汉模式的缺点,只有在我们需要用到类的时候才会去实例化它,并且通过饿汉模式类中的静态方法(本例中的getLazyMode),基本上规避了重复创建类对象的问题。到这里就需要注意了我所说的是基本上规避,而不是完全规避,我为什么这么说呢?这是因为懒汉模式并没有考虑在多线程下当类的实例对象没有被生成的时候很有可能存在多个线程同时进入 getLazyMode 方法,并同时生成实例对象的问题。因此我们说在懒汉模式下实现的单例模式是线程不安全的。那么这个问题怎么解决呢?这时我们就可以使用加锁懒汉模式,我们来看一下代码如何实现。

//加锁懒汉模式
public class LockSluggerMode {
	//1.定义私有构造函数
	private LockSluggerMode() {
	}
	//2.静态实例对象且不进行初始化
	private static LockSluggerMode lockSluggerMode;
	//3.编写静态方法,返回实例对象
	public static LockSluggerMode getLazyMode() {
		synchronized(LockSluggerMode.class) {
			if(lockSluggerMode==null) {
				lockSluggerMode=new LockSluggerMode();
			}
		}
		return lockSluggerMode;
	}
}

在上面的代码中我们增加了同步锁,这样就避免了前面所说的问题。加锁懒汉模式和懒汉模式的相同点都是在第一次需要时,类的实例才会被创建,再次调用将不会重新创建新的实例对象,而是直接返回之前创建的实例对象。这两种模式都适用于单例类的使用次数少,但消耗资源较多的时候。但是加锁懒汉模式因为涉及到了锁,因此与懒汉模式相比多了一些额外的资源消耗。

三、双重判定加锁懒汉模式

双重判定加锁懒汉模式在 Java 面试中会被经常问到,但是很少有人能够正确的写出双重判定加锁懒汉模式的代码,甚至很少有人会说出来这种模式的问题,以及在 JDK1.5版本中是如何修正这个问题的。针对这几个问题我在这一小节中进行一一讲解。

双重判定加锁懒汉模式的实现其实是创建线程安全单例模式的老方法,当单例的实例被创建时它会用单个锁进行性能优化,但是因为这个方法实现起来很复杂,因此在 JDK1.4 中实现总是失败。在 JDK1.5 没有修正这个问题前,为什么还需要这个模式呢?这时因为在加锁懒汉模式中虽然解决了线程并发的问题,又实现了延迟加载,但是它存在性能问题。这是因为使用 synchronized 的同步方法执行速度会比普通方法慢得多,如果多次调用获取实例的方法时积累的性能损耗就会很大,因此就出现了双重判定加锁懒汉模式。我们先来看一下具体的代码实现。

public class DoubleJudgementLockSluggerMode {
	private static DoubleJudgementLockSluggerMode doubleJudgementLockSluggerMode;
	private DoubleJudgementLockSluggerMode() {
	}
	
	public static DoubleJudgementLockSluggerMode getInstance() {
		if(doubleJudgementLockSluggerMode==null) {
			synchronized (DoubleJudgementLockSluggerMode.class) {
				if(doubleJudgementLockSluggerMode==null) {
					doubleJudgementLockSluggerMode=new DoubleJudgementLockSluggerMode();
				}
			}
		}
		return doubleJudgementLockSluggerMode;
	}
}

在上述代码中我们在同步代码块外层多加了一个 doubleJudgementLockSluggerMode 是否为空的判断,因此在大部分情况下调用 getInstance 方法都不会执行同步代码块,而是直接返回已经实例化的对象,进而提高了代码的性能。下面我们考虑一个问题,如果程序中存在线程一和线程二,当线程一执行了外层的判断语句它发现实例对象没有创建,然而这个时候线程二也执行到了外层判断语句,它同样发现实例对象没有创建,然后这两个线程依次执行同步代码块中的内容,分别创建了连个实例对象,对于单例模式来说这种情况我们必须避免,因此我么们在同步代码块中增加了 if(doubleJudgementLockSluggerMode==null) 判断语句来解决这个问题。

到目前为止虽然实现了延迟加载和线程并发问题,同时也解决了执行效率问题但是在 JDK1.5 之前还存一些问题。首先在 Java 中存在指令重排优化,这个功能会在不改变原有语义的情况下调整指令顺序来让程序运行的更快。但是在 JVM 中并没有规定优化哪些内容,所以 JVM 可以随意的进行指令重排优化。这样就引出了一个问题,因为指令重排优化的存在会导致初始化 DoubleJudgementLockSluggerMode 和将对象地址付给 doubleJudgementLockSluggerMode 的顺序发生改变。如果在创建单例对象时,在构造函数被调用之前就已经给当前对象分配了内存并且还将对象的字段赋予了默认值,那么此时如果将分配的内存地址赋予 doubleJudgementLockSluggerMode 字段,并有一个线程来调用 getInstance 方法,由于该对象有可能尚未初始化,因此程序就会报错。但是在 JDK1.5 中修正了这个问题,我们只需要利用 volatile 关键字来禁止指令重排优化来避免上述问题。增加 volatile 关键字后,代码如下:

public class DoubleJudgementLockSluggerMode {
	private static volatile DoubleJudgementLockSluggerMode doubleJudgementLockSluggerMode;
	private DoubleJudgementLockSluggerMode() {
	}
	
	public static DoubleJudgementLockSluggerMode getInstance() {
		if(doubleJudgementLockSluggerMode==null) {
			synchronized (DoubleJudgementLockSluggerMode.class) {
				if(doubleJudgementLockSluggerMode==null) {
					doubleJudgementLockSluggerMode=new DoubleJudgementLockSluggerMode();
				}
			}
		}
		return doubleJudgementLockSluggerMode;
	}
}

四、内部静态类懒汉模式

双重判定加锁懒汉模式实现起来不仅复杂,在 JDK1.4 及其以下版本上还存在指令重排优化的问题,那么有没有既解决了线程安全的问题又可以实现懒加载的单例模式的实现方法呢?答案是有的,我们可以利用静态内部类来实现。我们先来看一下代码如何实现。

// 内部静态类懒汉模式
public class StaticInnerClass {
	private StaticInnerClass() {}
	//定义内部静态来
	private static class StaticInnerClassHolder{
		//在内部静态类中实例化 StaticInnerClass
		public static StaticInnerClass staticInnerClass =new StaticInnerClass();
	}
	public static StaticInnerClass getStaticInnerClass() {
		return StaticInnerClassHolder.staticInnerClass;
	}
}

这种方式和饿汉模式一样都是利用了类加载机制,因此不存在对线程并发的问题,同时只要不适用内部类 JVM 就不会去创建单例对象,进而实现了与懒汉模式一样的延迟加载。但是这种方式会导致最终生成的 class 文件变大,程序体积变大。

五、枚举懒汉模式

枚举懒汉模式在开发中并不常用,一般来说如果你编写的类既要支持序列化和反射,又要支持单例模式的话可以使用枚举懒汉模式,但是因为使用了枚举因此会造成内存占用过大的问题。下面我们来看以下代码,然后根据代码来详细讲解枚举懒汉模式。

class EnumMode {
	//more code
}

public enum ModeEnum{
	INSTAMCE;
	private EnumMode enumMode;
	private ModeEnum() {
		enumMode=new EnumMode();
	}
	public EnumMode getEnumMode() {
		return enumMode;
	}
}

在上述代码中如果要获取 EnumMode 实例对象我们必须这样调用 ModeEnum.INSTAMCE.getEnumMode()。那么枚举懒汉模式实现的原理是什么呢?首先在枚举中明确了构造方法并设置为私有,当我们访问枚举实例的时候会执行构造方法,同时每个枚举实例是 static final 类型,因此只能被实例化一次。只有在构造方法被调用时单例才会被实例化。

六、总结

这篇文章讲解了 Java 中单例模式的实现方式,这些实现方式中常用的是饿汉模式和双重判定加锁懒汉模式这两种,其他方式我们也需要掌握。最后我总结一下实现单例模式各种方式的线程安全问题。

实现方式

是否线程安全

饿汉模式

懒汉模式

双重判定加锁懒汉模式

内部静态类懒汉模式

枚举懒汉模式

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 线程基础必知必会(一)

    从这篇文章开始,我将利用两篇文章讲解线程的基础知识,本篇文章涉及到了 创建线程、线程等待、线程暂停、线程终止 和 线程状态检测 相关的内容。这篇文章及其下一篇文...

    喵叔
  • 线程同步(一)

    当多个线程同时对同一个内存地址进行写入时,由于CPU时间调度上的问题写入数据会被多次的覆盖,所以就要使线程同步。所谓的同步就是协同步调,按预定的先后次序进行运行...

    喵叔
  • 搞懂线程池(一)

    创建线程是一个很代价很高的操作,每个异步操作创建线程都会对 CPU 产生显著的性能影响。为了解决这个问题我们引入了线程池的概念,所谓的线程池就是我们提前分配一定...

    喵叔
  • springcloud(八):配置中心服务化和高可用

    在前两篇的介绍中,客户端都是直接调用配置中心的server端来获取配置文件信息。这样就存在了一个问题,客户端和服务端的耦合性太高,如果server端要做集群,客...

    纯洁的微笑
  • Apache和PHP三种结合方法、三种MPM模式及解析漏洞

    为了减少频繁创建和销毁进程的开销,apache在启动之初,就预先fork一些子进程,然后等待请求进来。每个子进程只有一个线程,在一个时间点内,只能处理一个请求。

    宸寰客
  • 操作系统笔记-进/线程模型

    进程表(process table),也称进程控制块(PCB),是由操作系统维护的,每个进程占用其中一个表项。该表项包含了操作系统对进程进行描述和控制的全部信息...

    Cloud-Cloudys
  • 详细领悟ThreadLocal变量

    关于对ThreadLocal变量的理解,我今天查看一下午的博客,自己也写了demo来测试来看自己的理解到底是不是那么回事。从看到博客引出不解,到仔细查看Thre...

    java思维导图
  • Spring使用ThreadPoolTaskExecutor自定义线程池及实现异步调用

    在项目的 resources 目录下创建 executor.properties 文件,并添加如下配置:

    create17
  • 线程的基本方法

    在前面实例中调用该函数,发现t1线程cpu执行时间片多于t2线程,t1完成了t2还在开头

    晚上没宵夜
  • 细说线程池---高级篇

    上一篇中已经讲了线程池的原理。这一次来说说源码执行过程。建议先看看细说线程池---入门篇 细说线程池---中级篇

    田维常

扫码关注云+社区

领取腾讯云代金券