近期在工作中为了完成需求开发,同时为了把代码写得稍许优雅一些,应用到了一些设计模式。借此契机就在这记录一下运用的设计模式,以及一些运用时需要注意的地方。Ps:这部分内容大概会以系列形式进行不定期更新,所有内容的设计模式可能包括但不限于GOF的23种设计模式。
单例模式
单例模式是GOF第一种设计模式。在Spring框架中所创建的Bean,默认的生命周期是
singleton,也就是单例,由Spring容器进行统一的生命周期管理,而prototype,则由创建者进行对象生命周期的管理。单例模式虽然是23种设计模式中最简单的设计模式,但在实际应用中还是注意很多细节,不然还是会出一些问题。
饿汉 - 单例模式
饿汉单例模式,通过JVM的类加载机制所实现的单例模式,实现方式很简单,也能保证线程安全,但是由于是主动加载,所以会造成一定的空间资源浪费。
具体实现:
public class SingletonDemo{
private static SingletonDemo instance = new SingletomDemo();
private SingletonDemo(){}
public static getInstance(){
return instance;
}
}
至此,饿汉单例模式的实现就已经完成了,至于为什么饿汉单例模式是线程安全的,需要有一定的JVM-ClassLoader加载方面的知识,这边由于主要是谈论设计模式的系列,所以就稍微简单解释一下,有需求的朋友可以去参考一下《深入理解Java虚拟机》。
JVM中所持有三个类加载器,分别是BootstrapClassLoader、ExtClassLoader、ApplicationClassLoader,当一个类需要被加载的时候,类加载器会去询问父加载器是否加载过,层层递进,若根加载器都未加载过该目标类,则通过本身进行类加载操作,而类加载操作一共有五个阶段,分别是 加载 -> 验证 -> 验证 -> 解析 -> 初始化。而初始化过程,会对类的类的静态变量进行初始化以及对静态方法块进行执行。而类加载过程只会进行一次,类加载完成后类信息会存放在方法区。而饿汉单例模式,借此实现单例,是线程安全的。
懒汉 - 单例模式
懒汉单例模式,即在类加载时不主动去创建单例对象,而是在需要使用时再去创建单例对象,但因此也会存在线程不安全的问题。我们先来看一个单线程应用的简单懒汉单例实现。
public class SingletonDemo{
private static SingletonDemo instance;
private SingletomDemo(){}
public static getInstance(){
if(instace == null){
instance = new SingletonDemo();
}
return instance;
}
}
以上代码在单线程应用下可能不会出问题,但是设想一下,若是多线程环境下,线程A,线程B同时会调用getInstance()方法,线程A判断instance对象为空,进入到if方法块后,还未执行instance赋值语句,此时线程切换,线程B进入获取到的instance对象还未赋值,则进入了if方法块执行赋值操作并完成返回,此时线程切换回到A线程,A线程继续执行赋值操作,并完成返回。则A、B线程获取到了两个不同的对象。为了应对这种问题,则可以使用双重检查锁(Double-checked-locking)。
public class SingletonDemo{
private static volatile SingletonDemo instance;
private SingletonDemo(){}
public static getInstance(){
if(instance == null){
Synchronized (SingletonDemo.class){
if(instance == null){
instance = new SingletonDemo();
}
}
}
return instance;
}
此时,线程A、B同时调用getInstance()方法时,线程A、B都得到判断instance为空进入if块,A线程获得锁,B线程进入等待池,A线程完成instance对象的初始化操作,进行锁的释放,接着B线程获得锁,进行对象判空,此时获取到instance对象已经不为空,则跳出同步块,返回instance对象。这段代码是没有问题的,但可以注意到instance对象多了一个volatile修饰关键字,要是没有这个关键字,会出现怎样的问题呢?
让我们来设想一种比较极端的情况,线程A调用getInstance()方法,判断instance对象为空,进入同步块进行对象初始化操作,而instance对象的初始化操作在JVM内部会被拆分成三步 为对象分配空间 -> 初始化对象 -> 将instance指针指向初始化完成的对象的地址,若要是能按照这样的对象初始化步骤执行也是不会出现问题的,但若经过JVM和CPU的优化指令重排 初始化过程可能会变成 为对象分配空间 -> 将instance指针指向分配的对象的地址 -> 初始化对象,若在执行完第二条指令,第三条指令未执行时,线程B切入,进行对象判空,读取到instance对象已经指向了一块区域而不是null,则直接返回了未被初始化完成的对象,那么线程B根据该对象执行的后置操作,都是有问题的。
关于volatile关键字,字面意思是易变的,我们可以把其理解成一个轻量级的锁。
该关键字主要有两个特性:
- 保证修饰对象的内存可见性
- Java内存模型中,对象都存放在主存中,而线程只操作各自工作内存(高速缓存),所以在操作对象时会将主存的值同步到自己的工作内存中,在操作完成后再将工作内存中的值写回主程中去,而在执行操作的过程中会遇到别的线程执行操作,可能最终的结果就不是我们想要的了。
- 举个简单的例子:
static int i = 0; /* * 线程A、B同时对i进行+1操作 * 线程A加载i=0写入高速缓存 进行+1操作 但未写回主存 * 线程B加载i=0写入告诉缓存 进行+1操作 写入主存 此时i=1 * 线程A将值写回主存 由于线程A写入高速缓存的i值为0 所以写回主存的值也是1 */- 而通过
volatile修饰过的对象会被立即写回主存,当其它线程需要读取该变量时,会去内存中读取新值。普通变量则不能保证这一点。
- 禁止指令重排
- 禁止指令重排就很好理解了,之前我们说过了CPU和JVM会对指令进行优化重排,且优化重排后的指令顺序是不一定的。而
volatile关键字能保证其执行顺序始终是 为对象分配空间 -> 初始化对象 -> 将instance指针指向初始化完成的对象的地址,如此一来,则再不会出现获取中间态的问题。
- 禁止指令重排就很好理解了,之前我们说过了CPU和JVM会对指令进行优化重排,且优化重排后的指令顺序是不一定的。而
枚举 - 单例模式
虽然看似双重检查锁配合volatile关键字确实完美的解决了单例对象的获取问题,但是却依然无法阻止通过反射去创建一个对象实例(获取单例类构造器 -> 设置构造器可访问 -> 构造实例),所以依然还是存在问题的。这时候就可以考虑用一种简洁又优雅的方式去实现一个单例。
public enum SingletonDemo{
INSTANCE;
}
此时若通过反射去创建一个INSTANCE实例时则会收到一个没有相关方法的异常,因为JVM会组织反射去获取枚举类的私有构造方法。但此种实现方式和饿汉单例实现方式也存在同样的问题,单例对象会在类被加载时进行初始化,而不是通过懒加载的方式实现的。
总结
单例模式虽然说是最容易实现的方式,但是其中也有很多细节问题需要多加注意。
| 实现方式 | 线程安全 | 主动初始化 | 预防反射构建 | 预防序列化构建 |
|---|---|---|---|---|
| 饱汉单例 | 安全 | 是 | 否 | 否 |
| 懒汉单例 | 否 | 否 | 否 | 否 |
| 懒汉单例(双重检查锁) | 否 | 否 | 否 | 否 |
| 懒汉单例(双重检查锁 + volatile) | 安全 | 否 | 否 | 否 |
| 枚举单例 | 安全 | 是 | 是 | 是 |
ps:个人博客地址是shawjie.me,不定期会发布一些自己所经历的,所学习的,所了解的,欢迎来坐坐。