- 设计模式解析一 工厂模式的不同
- 设计模式解析二 结构模式三剑客
- 设计模式解析三 行为模式三剑客
- 设计模式解析四 模板方法模式和外观模式
- 设计模式解析五 观察者模式和桥接模式
- 设计模式解析六 单例模式
一. 前言
几天晚上的奋战,终于写到第六篇了,这也是最后详细写的设计模式了,单例模式介绍完后,剩下的几个模式就是几个不常用的设计模式了,届时便不会这么详细介绍了。
二. 单例模式
单例模式是可以说是一个最简单,但也最难的设计模式,简单是因为很好理解,难是因为要真正写出一个完美的单例模式也需要很多专业知识,写出单例的方式也非常多。
日常编程中我们也经常用到单例模式,比如数据库连接池、缓存对象等,这种对象如果出现两个是会对程序有极大的伤害的,而由于我们程序的多线程特性,要在多线程运行环境中得到加载到一个高性能又安全的单例并不容易。
接下里就按步骤一一来实现单例模式,首先按照普通的思维逻辑,我们在一个地方存储该单例的实例化对象,并在获取前进行一次判断,如果为空就初始化,如果不为空就返回该对象,这样来看看呢:
public final class DataSourcePool {
private static DataSourcePool pool;
private DataSourcePool() {}
public static DataSourcePool getPool() {
if (Objects.isNull(pool)) {
pool = new DataSourcePool();
}
return pool;
}
}
嗯,看起来还不错,不过如果程序是在多线程环境运行的话就有问题了,并发的时候可能大家在判断的时候都是为空,然后都去进行实例化,这样大家拿到的可能就不是同一个对象了,那么加上同步锁如何呢:
public final class DataSourcePool {
private static DataSourcePool pool;
private DataSourcePool() {}
public synchronized static DataSourcePool getPool() {
if (Objects.isNull(pool)) {
pool = new DataSourcePool();
}
return pool;
}
}
同步关键字加在方法上呢,嗯这的确能保证是一个单例,毕竟调用这个方法进行了同步机制,同时只会有一个线程进入这个方法内,但是这样这个方法就会成为我们程序的瓶颈了,这可不行,那么在方法内部加呢:
public final class DataSourcePool {
private static DataSourcePool pool;
private DataSourcePool() {}
public static DataSourcePool getPool() {
if (Objects.isNull(pool)) {
synchronized (DataSourcePool.class) {
if (Objects.isNull(pool)) {
pool = new DataSourcePool();
}
}
}
return pool;
}
}
这样看起来比较不错了,只有在第一次初始化前大家并发访问的时候才会出现触发锁关键字部分的代码,初始化后大家都会直接拿到单例对象,但是这个在多线程环境下就一定安全吗?
其实也不够安全,如果了解多线程的指令重排序原理的话就会理解这段代码实际上也不够安全,cpu在执行我们的指令时会在不影响最终结果的前提下进行一些优化和指令重排序。
我们的每行代码都会被编译成一条条原子指令,而原子指令才是cpu执行指令的最小单位,只有原子指令才是不可分割的,举个例子
int a ;
a = 1;
这两条指令就是两条原子指令,第一条指令是为变量a
分配一块内存空间,第二条指令是为a
赋值。
而接下来这条指令就不是原子指令:
int a = 2 ;
这条指令就不算是一个原子操作了,因为指令会被拆分为先定义a变量,再赋值给a,这一点我们可以通过编译查看java字节码来证明:
public class Test {
public static void main(String[] args) {
int a ;
a = 1 ;
int b = 3 ;
}
}
javap -verbose Test.class
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=3, args_size=1
0: iconst_1
1: istore_1
2: iconst_3
3: istore_2
4: return
LineNumberTable:
line 13: 0
line 14: 2
line 15: 4
上面的0,1,2,3就是我们的指令,仔细看这四条指令,iconst_3
代表int型常量值3进栈,而istore_2将栈顶int型数值存入第3个局部变量,也就是说这个步骤分为了两条指令。
再回到上面的代码pool = new DataSourcePool();
我们也就能看到这里其实可以分为两部分,一部分是实例化DataSourcePool
,然后再将该对象的地址赋值给pool
。但系统如果进行指令重排序其实是有可能先将对象的地址赋值给pool
还没来得及初始化,然后出现线程切换,其他线程来拿到了pool
,因为已经赋值了地址,这时候pool
就不为空了,而这个线程拿到后进行使用就是会报错的,因为对象还没初始化完成,当然了,这种事非常极限的情况,但也不能保证一定不会报错,所以为了避免这种情况,我们可以为变量增加volatile
关键字:
public final class DataSourcePool {
private volatile static DataSourcePool pool;
private DataSourcePool() {}
public static DataSourcePool getPool() {
if (Objects.isNull(pool)) {
synchronized (DataSourcePool.class) {
if (Objects.isNull(pool)) {
pool = new DataSourcePool();
}
}
}
return pool;
}
}
增加关键字后,就可以禁止对该对象的指令重排序。而volatile
关键字实际上是利用内存屏障的方式来实现的,即为pool
变量增加内存屏障,在写指令执行完成前,读指令不能执行,从而确保其他线程拿到的pool
对象不是一个中间状态。具体的volatile
和happen-before原则会在以后的文章里描述。
如上就是一个完美的懒汉模式的单例了。
实现单例模式其实还有其他方式,比如直接在静态变量上实例化,让jvm加载器来为我们保证对象的单例,因为jvm会保证类的加载只有一次:
public final class DataSourcePool {
public final static DataSourcePool pool = new DataSourcePool();
}
而增加了final关键字也可以确保该对象值不会被改变。
网上也有看到说通过枚举来实现单例,该方式也被称为最安全的方式,因为JVM会保证enum不能被反射并且构造器方法只执行一次,普通的单例如果进行反序列化就是一个新的对象了,而枚举因为序列化的特殊处理(在序列化的时候Java仅仅是将枚举对象的name
属性输出到结果中,反序列化的时候则是通过java.lang.Enum
的valueOf
方法来根据名字查找枚举对象)也能保证即使进行反序列化,也还是单例对象:
public class DataSourcePool {
private DataSourcePool() {}
public static DataSourcePool getPool() {
return Singleton.INSTANCE.getInstance();
}
private enum Singleton {
INSTANCE;
private DataSourcePool singleton;
//JVM会保证此方法绝对只调用一次
Singleton() {
singleton = new DataSourcePool();
}
public DataSourcePool getInstance() {
return singleton;
}
}
}
public static void main(String[] args) {
DataSourcePool pool = DataSourcePool.getPool();
DataSourcePool pool2 = DataSourcePool.getPool();
System.out.println(pool == pool2);
}
运行结果:
true
另外,在我们的spring中的一些bean注解也都是默认单例的,@Component
@Service
等,spring会为我们初始化bean并存放于容器中。