Single Pattern
单例模式简介
即单实例,只实例出来一个对象。
一般在创建一些管理器类
、工具类
的时候,需要用到单例模式,比如之前的 JDBCUtil 类,我们只需要一个实例即可(多个实例也可以实现功能,但是增加了代码量且降低了性能)。
如何实现单例:
- 将构造方法私有化
- 提供一个全局唯一获取该类实例的方法帮助用户获取类的实例
应用场景:
主要被用于一个全局类的对象在多个地方被使用并且对象的状态是全局变化的场景下。
-
应用程序的日志应用,一般都用单例模式实现,这一般是由于共享的日志文件一直处于打开状态,因为只能有一个实例去操作,否则内容不好追加。
-
数据库连接池的设计一般也是采用单例模式,因为数据库连接是一种数据库资源。数据库软件系统中使用数据库连接池,主要是节省打开或者关闭数据库连接所引起的效率损耗,这种效率上的损耗还是非常昂贵的,因此用单例模式来维护,就可以大大降低这种损耗。
-
多线程的线程池的设计一般也是采用单例模式,这是由于线程池要方便对池中的线程进行控制。
单例模式的优点:
- 单例模式为系统资源的优化提供了很好的思路,频繁创建和销毁对象都会增加系统的资源消耗,而单例模式保障了整个系统只有一个对象能被使用,很好地节约了资源。
单例模式的写法:
- 懒汉模式(线程安全)
- 饿汉模式
- 静态内部类
- 双重校验锁
懒汉模式
懒汉模式很简单:
定义一个私有的静态对象instance
,之所以定义instance
为静态,是因为静态属性或方法是属于类的,能够很好地保障单例对象的唯一性;
然后定义一个加锁的静态方法获取该对象,如果该对象为null
,则定义一个对象实例并将其赋值给instance
,这样下次再获取该对象时便能够直接获取了。
懒汉模式在获取对象实例时做了加锁操作
,因此是线程安全的。
代码如下:
1public class LazySingleton {
2
3 private static LazySingleton instance;
4
5 private LazySingleton(){}
6
7 public static synchronized LazySingleton getInstance(){
8 if(instance == null){
9 instance = new LazySingleton();
10 }
11 return instance;
12 }
13}
饿汉模式
饿汉模式指在类中直接定义全局的静态对象的实例并初始化,然后提供一个方法获取该实例对象。
懒汉模式和饿汉模式的最大不同在于:
懒汉模式在类中定义了单例但是并未实例化,实例化的过程是在获取单例对象的方法中实现的,也就是说,在第一次调用懒汉模式时,该对象一定为空,然后去实例化对象并赋值,这样下次就能直接获取对象了;
而饿汉模式是在定义单例对象的同时将其实例化的,直接使用便可。也就是说,在饿汉模式下,在Class Loader
完成后该类的实例便已经存在于JVM中了。
代码如下:
1public class HungrySingleton {
2
3 private static HungrySingleton instance = new HungrySingleton();
4
5 private static HungrySingleton getInstannce(){
6 return instance;
7 }
8}
静态内部类
静态内部类通过在类中定义一个静态内部类,将对象实例的定义和初始化放在内部类中完成,我们在获取对象时要通过静态内部类调用其单例对象。
之所以这样设计,是因为类的静态内部类在JVM中是唯一的
,这很好地保障了单例对象的唯一性。
代码如下:
1public class Singleton {
2
3 private static class SingletonHolder {
4 private static final Singleton INSTANCE = new Singleton();
5 }
6
7 private Singleton(){}
8
9 public static final Singleton getInstance(){
10 return SingletonHolder.INSTANCE;
11 }
12}
双重构校验锁
在说双重构校验锁之前我们先来看看普通的实现方式:
1public class Singleton {
2
3 private static Singleton instance;
4
5 private Singleton(){}
6
7 public static Singleton getInstance(){ // 这里没有加锁
8 if(instance == null){
9 instance = new Singleton();
10 }
11 return instance;
12 }
13}
这段代码在多线程的情况下无法正常工作,在多个线程同时调用getInstance()
时,由于没有锁,这些线程可能同时去创建对象,或者某个线程会得到一个未完全初始化的对象。
所以要对getInstance()
加锁:
1public class Singleton {
2
3 private static Singleton instance;
4
5 private Singleton(){}
6
7 public static synchronized Singleton getInstance(){ // 加锁
8 if(instance == null){
9 instance = new Singleton();
10 }
11 return instance;
12 }
13}
加锁之后发现,这不就是懒汉模式的写法吗!但是这里有一个问题,懒汉模式虽然能够实现多线程下的单例,但是粗暴地将getInstance()
锁住了,这样代价很大,为什么呢?
因为,只有当第一次调用getInstance()
时才需要同步创建对象,创建之后再次调用getInstance()
时就只是简单的返回成员变量,而这里是无需同步的,所以没必要对整个方法加锁。
由于同步一个方法会降低100倍或更高的性能, 每次调用获取和释放锁的开销似乎是可以避免的:一旦初始化完成,获取和释放锁就显得很不必要。
所以就有了双重校验锁的方式:
- 双锁模式指在懒汉模式的基础上做进一步优化,给静态对象的定义加上
volatile锁
来保障初始化时对象的唯一性,在获取对象时通过synchronized (Singleton.class)
给单例类加锁来保障操作的唯一性。
代码如下:
1public class Lock2Singleton {
2
3 private volatile static Lock2Singleton singleton; // 1. 对象锁
4
5 private Lock2Singleton(){}
6
7 public static Lock2Singleton getSingleton(){
8 if(singleton == null){
9 synchronized(Lock2Singleton.class){ // 2. synchronized 方法锁
10 if(singleton == null){
11 singleton = new Lock2Singleton();
12 }
13 }
14 }
15 return singleton;
16 }
17}
这个过程就是:
- 检查变量是否被初始化(不去获得锁),如果已被初始化立即返回这个变量;
- 获取锁;
- 第二次检查变量是否已经被初始化:如果其他线程曾获取过锁,那么变量已被初始化,返回初始化的变量;
- 否则,初始化并返回变量。
为什么是双重校验?
这里发现有两个if
判断,即双重校验
。
第一次校验:也就是第一个if(singleton==null)
,前面说过,这个是为了代码提高代码执行效率,由于单例模式只需要创建一次实例即可,所以当创建了一个实例之后,再次调用getSingleton()
就不必要进入同步代码块,不用竞争锁,直接返回前面创建的实例即可。
第二次校验:也就是第二个if(singleton==null)
,这个校验是防止再次创建实例,假如有一种情况,当singleton
还未被创建时,线程T1调用getSingleton()
,由于第一次判断singleton==null
,此时线程T1准备继续执行,但是由于资源被线程T2抢占了,此时T2调用getSingleton()
,同样地,由于singleton
并没有实例化,T2同样可以通过第一个if
,然后继续往下执行,同步代码块,第二个if
也通过,然后线程T2创建了一个实例singleton
。此时线程T2完成任务,资源又回到线程T1,T1此时也进入同步代码块,如果没有这个第二个if
,那么,T1也会创建一个singleton
实例,那么,就会出现创建多个实例的情况,但是加上第二个if
,就可以完全避免这个多线程导致多次创建实例的问题。
所以说:两次校验都必不可少!
为什么给静态对象的定义加上
volatile锁
?
可以先了解一下关于Java的volatile关键字
这里的volatile
也是必不可少的,它有两个作用。
第一个作用:防止JVM指令重排,从而保证在多线程下也能正常执行
singleton = new Lock2Singleton()
这句话可以分为三步:
- 为
singleton
分配内存空间; - 初始化
singleton
; - 将
singleton
指向分配的内存空间。
但是由于JVM具有指令重排的特性,执行顺序有可能变成 1→3→2。 指令重排在单线程下不会出现问题,但是在多线程下会导致一个线程获得一个未初始化的实例。
例如:线程T1执行了1和3,此时T2调用getSingleton()
后发现singleton
不为空,因此返回singleton
, 但是此时的singleton
还没有被初始化。
第二个作用:保证变量在多线程运行时的可见性
在 JDK1.2 之前,Java的内存模型实现总是从主存(即共享内存)读取变量,是不需要进行特别的注意的。而在当前 的 Java 内存模型下,线程可以把变量保存到本地内存(比如机器的寄存器)中,而不是直接在主存中进行读写。
这就可能造成,一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致。 要解决这个问题,就需要把变量声明为volatile
,这就指示 JVM,这个变量是不稳定的,每次使用它都到主存中进行读取。