目录
前言:单例模式是校招中最常见的设计模式之一。下面我们来谈谈其中的两个模式:懒汉,饿汉。
何为设计模式?
设计模式是软件开发人员在软件开发过程中面临的一般问题的解决方案。这些解决方案是众多软件开发人员经过相当长的一段时间的试验和错误总结出来的。
日常开发中,虽然不能百分之白解决问题,但是大大提高了普通开发者的下限。
单例模式的概念
在应用这个模式时,单例对象的类必须保证只有一个实例存在。
为何有单例模式的出现?
许多时候整个系统只需要拥有一个的全局对象,这样有利于我们协调系统整体的行为。
比如在某个服务器程序中,该服务器的配置信息存放在一个文件中,这些配置数据由一个单例对象统一读取,然后服务进程中的其他对象再通过这个单例对象获取这些配置信息。
这种方式简化了在复杂环境下的配置管理。
ps:说到这里我们想到Java中JDBC的DataSoucre只需要一个实例,但是如果我们想创建多个实例语法上是没有问题的(虽然会造成代码的冗余)。
因此单例模式本质上就是:借助编程语言自身的语法特性,强制限制某个类,不能创建多个实例。
一、饿汉模式
话不多说,直接看代码(注意看注解):
class Single{
//用static修饰,这样就可以让instance变为这个类的唯一实例。
private static Single instance = new Single();
//因为要在不创建额外实例的情况下调用这个方法,必须将其设置为static方法
public static Single getSingle() {
return instance;
}
//为了防止Single在类外可以被实例,这边将其的构造方法设计未private。
private Single() {
}
}
public class Demo16 {
public static void main(String[] args) {
Single instance = Single.getSingle();
}
}
如果没有将构造方法设置为private,那么可能会造成还是可能获取到两个实例(而不是单个了):
这是设置了private的构造方法:
需要注意的是:饿汉模式是在类加载阶段创建出实例的。(ps:类加载阶段创建实例相对于普通情况早了许多,这也是为什么叫“饿汉”的原因。一个饿了几天的人,对食物的没有抵抗力的,一下子就开始吃了。)
为何饿汉模式不需要考虑线程安全的问题?
首先,我们需要明白线程安全出现的原因是什么;该模式只涉及到单纯的读取数据,并不涉及修改,因此该模式线程安全。
二、懒汉模式
类加载的同时,不再创建实例,而是等第一次使用的时候再创建。(创建的时机更迟,很好的避开了程序刚启动时候资源紧张的情况,提高了效率。)
懒汉模式——单线程版本
class Singleton{
private static Singleton instance = null;
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
private Singleton() {
}
}
懒汉模式——多线程版本
在多线程的环境下,如果不使用synchronized关键字进行加锁会引发线程安全问题,原因如下:
实现代码:
class Singleton{
private static Singleton instance = null;
public static Singleton getInstance() {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
return instance;
}
private Singleton() {
}
}
仔细思考后,我们会发现其实上面多线程版本的懒汉模式是有一定问题的。
虽然通过加锁的方式解决了线程安全问题,但是在这同时又引入了新的问题,因为这里的线程不安全并不是永久性的,什么意思呢?
当我们代码创建了第一个实例之后,那么其实就不需要再执行if语句里面的内容了(条件判断失败),但是我们加锁了之后,就会导致每次执行这个方法,我们都需要进行加锁,这种无脑的加锁方式,会导致程序运行的开销变大(因为加锁可能设计 用户态->内核态 之间的转换,这样的转换成本很高)。
ps:这也是为什么Vector,StringBuffer ,HashTable等不推荐用的原因,因为它们的内部有许多像这样无脑加锁的代码。
解决方案:给外层的嵌套循环加上if。
实现代码:
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
分析:虽然这里使用了两个if语句,但是它们每个的代表含义是不同的,因为有加锁的存在,两个if执行的时间间隔很有可能是特别长的,因为加锁可能会产生锁竞争,竞争就意味着某些线程就会进入阻塞状态,那么什么时候这个处于阻塞状态的线程被唤醒,就无法确认了。
这也就导致了同一个线程中的两个if语句的结果可能是截然不同的,比如 第一个if成立进入,第二个if不成立。
举个例子:
有三个线程同时调用了getInstance这个方法,通过外层的if语句进入,三个线程发生锁竞争,当其中的某个线程拿到锁之后,另外两个线程进入阻塞等待状态,之后这个拿到锁的释放了锁之后(这时已经实例化一个对象了),另外的两个线程再次发生锁竞争,两个中的某个线程拿到锁之后,进行了内层的if条件判断(刚刚这两个线程都已经判断了外层的if条件),发现instance不为空,于是就不再创建实例了。而在这三个线程之后的线程中,如果有人调用getInstance方法,就会在外层的if语句中发现instance不为空,就不再进入内部了。
这样两个if的做法,就很好的提高了效率。
当然,即使修改到这,仍然是不够的,因为这里还存在指令重排序和内存可见性的问题。
内存可见性:
在多线程的环境下,很可能会出现频繁的判断,这时线程不会读内存中的数据,而是会去读寄存器中的数据,可能instance的值以及发生了改变,但是线程却浑然不知,为了防止这一现象出现,我们使用volatile修饰instance变量,防止这一因为编译器优化而带来的问题。
指令重排序:
其实这段代码正常的执行过程应该如下:
- 申请内存空间。
- 在这个内存上构造对象。
- 把这个内存地址赋值给instance引用。
但是由于指令重排序这种优化手段的存在,可能会触发顺序的调整,典型的就是不按照:
1 -> 2 -> 3 的方式执行,而是 1 -> 3 -> 2 的情况。如果是单线程的话这两个执行顺序其实没什么区别,但是多线程情况下,可能会出现问题:
某个线程A,执行1,3 两步的时候,线程A被调度到干其他事情了,这时候线程B来调用这个getInstance方法,由于线程A已经执行了 3(把这个内存地址赋值给instance引用 ) ,线程B在执行第一个if判断的时候,就会得出instance不为空的情况。于是直接返回instance对象,但是A线程并未执行 2 (在这个内存上构造对象)。这时线程B得到的就是一个 ”不完整“ 的对象。可能会导致程序出现问题。
修改后,最终代码如下:
class Singleton{
private static volatile Singleton instance = null;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
private Singleton() {
}
}