一、介绍
单例模式,属于创建型设计模式,单指一个,例指实例,就是说在一个程序的运行过程中,应用了单例模式的类始终只有一个实例,且不允许出现多个实例,并在整个程序中提供一个获取该实例的方法。
应用:
- spring中的bean默认就是单例模式,类的实例化由spring完成,我们只需要从框架中获取即可,而不是直接去
new
一个实例。 - web应用中的
Servlet
。 - 我们在应用开发时,线程池也应当设计成单例模式。
**何时使用:**如果一个类的实例在整个生命周期中是无状态的,则可以使用单例模式使整个应用程序中只有一个实例。
**优点:**在内存里只有一个实例,减少了内存的开销,同时避免了频繁的创建实例与销毁实例。
注意:
- 单例类只能有一个实例
- 必须在单例类内部完成该类实例的创建
- 单例类应提供获取该实例的方法
- 不允许通过构造方法(即
new
的方式)来获取实例。
二、实现步骤
实现单例模式的方式有很多,总结起来为三个步骤:
① 将构造方法私有化
② 在类内部定义一个唯一的实例化对象,且使用private statis
修饰。
③ 定义一个静态方法访问该实例对象。
三、饿汉式
饿汉式是最简单的单例模式实现,
基于上面三个步骤,饿汉式的实现如下:
public class Person {
// 在类内部定义一个唯一的实例化对象,且使用private statis修饰
private static Person person = new Person();
/**
* 将构造方法私有化
*/
private Person() {
}
public static Person getInstance() {
return person;
}
}
在程序启动时对该类进行实例化并赋值给person属性,因此又被称为立即加载。由于其构造方法被私有化,调用方只能通过静态方法getInstance()
获取该实例。
优点:
- 实现简单
- 线程安全
缺点:
- 当
Person
类被加载时,静态变量person被初始化并分配其所需的内存空间,直至程序运行结束该内存空间才会被释放,即使我们从始至终都没有使用该实例,所以造成了一定的内存空间的浪费。
四、简单懒汉式
针对饿汉式单例实现的缺点,我们对其进行改造:该类被加载时先不要对其person属性进行初始化,当我们需要使用该实例时,才对person属性进行初始化。就是说把person属性的初始化延迟到调用getInstance()
方法时,因此该实现方式也称为延迟加载。
下面我们通过代码对饿汉式进行改造
public class Person {
private static Person person;
private Person() {
}
public static Person getInstance() {
// 将实例化延迟进行
if (person == null) {
person = new Person();
}
return person;
}
}
优点:
- 实现简单
- 一定程度上节省内存空间。如果在程序运行过程中从不调用
getInstance()
方法获取实例,则永远不会对Person类进行实例化。
缺点:
- 线程不安全。当多个线程同时调用
getInstance()
方法获取实例时,由于此时person==null
成立,那么这些线程则无一例外都会对Person类进行实例化。
1. 线程不安全
下面我们通过在idea上模拟多线程环境,对该实现进行测试。
-
在判断
person==null
时添加多线程模式的断点,如下图所示 -
新建三个线程,在
run()
方法中调用Person.getInstance()
获取实例。public static void main(String[] args) { Thread thread1 = new Thread(() -> { Person person = Person.getInstance(); // 输出该对象的内存地址 System.out.println(System.identityHashCode(person)); }); Thread thread2 = new Thread(() -> { Person person = Person.getInstance(); System.out.println(System.identityHashCode(person)); }); Thread thread3 = new Thread(() -> { Person person = Person.getInstance(); System.out.println(System.identityHashCode(person)); }); thread1.start(); thread2.start(); thread3.start(); }
-
使用
Debug
模式启动程序此时我们在左下角可以看到三个线程都进入断点,并且三个线程中
person==null
均为true
,则意味着三个线程都会进入person = new Person()
这一行代码,那么也就表示三个线程都会对Person
类进行一次实例化并赋值给person属性。我们每个线程都会输出获取的实例对象的内存地址,通过
System.identityHashCode()
方法获取。下图为三个线程获取的实例对象的内存地址。
五、线程安全懒汉式
针对于上面线程不安全的懒汉式实现方案,为了保证线程安全,最简单粗暴的方式就是加锁,例如使用synchronized
关键字。
于是我们对其进行改造,代码如下
public class Person {
private static Person person;
private Person() {
}
// 添加synchronized关键字,保证线程安全
public static synchronized Person getInstance() {
if (person == null) {
person = new Person();
}
return person;
}
}
此时,无论多少个线程同时调用getInstance()
方法,在synchronized
关键字的加持下,只允许有一个拿到锁的线程进入方法内部。当第一个拿到锁的线程对person属性实例化完成后,后续拿到锁的线程都会直接将person返回,而不再对其进行初始化。但是,虽然实现了线程安全,此实现方案仍有缺点,即便person属性已经被第一个线程完成初始化,后续的线程却依然因拿不到锁而漫长地等待,这对性能无疑是一种损耗,典型的时间换空间。
优点:
- 线程安全
缺点:
- 性能大打折扣
六、双重检查锁的线程安全懒汉式
该方案通过双重检测(Double Check) + 锁的机制,既满足了线程安全的需求,同时又能避免性能的损耗。
public class Person {
// volatile关键字使得person对象在多线程环境下彼此可见
private static volatile Person person;
private Person() {
}
public static Person getInstance() {
// 第一次检查person是否被实例化出来,如果没有进入if块
if (person == null) {
synchronized (Person.class) {
// 第一个线程拿到锁,实例化对象前第二次检查person是否已经被实例化出来,如果没有,才最终实例出对象
if (person == null) {
person = new Person();
}
}
}
return person;
}
}
由于java内存模型的设计,在此方案中应对person属性添加volatile
关键字才能实现真正的延迟加载。
优点:
- 延迟加载
- 线程安全
缺点:
- 影响性能。因为该方案的线程安全是通过
synchronized
关键字实现的,该关键字本身对程序就是一种消耗。
七、静态内部类
有没有一种既能满足延迟加载,又能保证线程安全的实现方案呢?答案是肯定的。
我们可以利用java的语法特性来满足要求,我们都知道,一个类的静态内部类是延迟加载的,当我们仅使用外部类的成员变量或成员方法而不涉及其静态内部类时,静态内部类是不会被加载的。
public class Person {
private Person() {
}
public static Person getInstance() {
return LazyPerson.person;
}
private static class LazyPerson {
private static final Person person = new Person();
}
}
这种方式既避免了饿汉式单例的内存浪费问题,又摆脱了synchronized
关键字的性能问题,同时也不存在线程安全问题。
八、总结
- 无论通过哪种方式实现单例,都需要将构造方法私有化,避免外部通过
new
的方式创建多个实例。 - 单例的类内部应定义一个获取单例对象的静态方法
- 单例模式的实现有多种,立即加载或延时加载,线程安全或线程不安全等。
纸上得来终觉浅,绝知此事要躬行。
————————我是万万岁,我们下期再见————————