单例模式
一 概述
单例模式是一个本身是一个非常容易理解的模式,但是由于本身的一些缺陷所以有了许多对单例模式的改进,因此也比较复杂。这篇文章是我对单例模式学习时的一个总结,希望对大家理解单例模式有所帮助
什么是单例模式
- 概念:保证一个类仅有一个实例,并提供一个访问它的全局访问点
- 概念简单,理解起来也不难,实现这样的方式就是将构造函数私有化,将自身的引用声明成一个全局变量,再提供一个初始化变量的方法。但是也有许多问题,下面我将会在实现方式中慢慢讨论。
单例模式的实现种类
单例模式有许多实现方法,来应对各种问题,其中就有:1.懒汉模式,2.饿汉模式,3.静态内部类,4.枚举类型。下面我们会对每种类型详细讨论并且优化。
二 每种实现方式与优化
实现方式
1)懒汉模式
- 懒汉模式,字面意思就能充分体现出这种实现方式的特点:只有在使用的时候才进行初始化,延迟加载。
具体实现:
public class Lazy {
//申明一个对自身的引用
private static Lazy lazy;
//私有的构造函数
private Lazy(){
}
//对自身引用的初始化
public static Lazy getLazy(){
if(lazy==null){
lazy = new Lazy();
}
return lazy;
}
}
这就是一个最简单的懒汉模式。
- 但是,这样真的能保证只创建一个对象吗?
其实在单线程的情况下是只创建一个对象,但是,多线程就不一定了看代码:
1.主函数
public class MainClass {
public static void main(String[] args) {
//线程一
new Thread(new Runnable(){
@Override
public void run() {
Lazy l = Lazy.getLazy();
System.out.println(l);
}}).start();
//线程二
new Thread(new Runnable(){
@Override
public void run() {
Lazy l = Lazy.getLazy();
System.out.println(l);
}}).start();
}
}
public class Lazy {
//申明一个对自身的引用
private static Lazy lazy;
//私有的构造函数
private Lazy(){
}
//对自身引用的初始化
public static Lazy getLazy(){
if(lazy==null){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
lazy = new Lazy();
}
return lazy;
}
}
通过程序我们模拟了多线程的访问,发现出现了两个对象。
这就是懒汉模式的一个问题:线程不安全
所以就有了这样的解决方法
public class Lazy {
//申明一个对自身的引用
private static Lazy lazy;
//私有的构造函数
private Lazy(){
}
//对自身引用的初始化
public static Lazy getLazy(){
if(lazy==null){
//加同步代码块进行控制。
synchronized (Lazy.class) {
if(lazy==null){
lazy = new Lazy();
}
}
}
return lazy;
}
}
结果
我们发现这样的问题解决了。需要注意的是这里synchronized关键没有加在方法上,因为这个方法是静态方法,如果加上synchronized相当于加在类上,会造成不小的开销
- 但是这样真的就没有问题了吗?
其实代码中的lazy = new Lazy();在JVM中会有三步:
1)分配空间,返回一个指向该空间的内存引用,
2)把内存空间进行初始化
3)把内存引用赋值给lazy变量
在多线程的情况下,2与3会出现重排序,即2与3发生调换。这样就有了问题:假设,线程一进入getLazy()方法,执行lazy = new Lazy(),线程二到来卡在静态代码块处等待,此时线程三到来,由于多线程的原因,线程一在执行lazy = new Lazy()时发生重排序,先执行了3)把内存引用赋值给了lazy变量,还没有执行2)初始化,但是对于线程三来说判断条件lazy==null为false,所以他继续向下执行,直接返回一个空的lazy,此时代码就出现了空指针异常。(这个没有想出演示方法)
解决这个问题的方法就是在声明自身引用时加上volatile,这样volatile修饰的lazy指向的内存空间中的指令集就不会发生重排序。 - 这样就有了比较严谨和完整的懒汉模式的代码
public class Lazy {
//申明一个对自身的引用
private volatile static Lazy lazy;
//私有的构造函数
private Lazy(){
}
//对自身引用的初始化
public static Lazy getLazy(){
if(lazy==null){
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//加同步锁进行控制。
synchronized (Lazy.class) {
if(lazy==null){
lazy = new Lazy();
}
}
}
return lazy;
}
}
- 其实在spring框架中就有许多这样结构的代码
2)饿汉模式
饿汉模式:在类加载阶段,完成了实例的初始化,也是很符合名字。
直接看代码:
public class Hungry {
//在类加载的时候就对类进行了初始化
private static Hungry hungry = new Hungry();
private Hungry(){
}
public static Hungry getHungry(){
return hungry;
}
}
这里饿汉模式没有懒汉模式那么多问题,因为它通过类加载机制来保证了线程安全。
3)静态内部类与反射攻击
第三种实现方式是静态内部类,我认为这时饿汉模式与懒汉模式的结合。
看代码
public class HuAndla {
static class HL{
private static HuAndla hl = new HuAndla();
}
private HuAndla(){}
public static HuAndla getHuAndla(){
return HL.hl;
}
}
静态内部类就是在类中的静态内部类中进行类的初始化。按照整体来说他是懒汉模式,按照他的初始化方式来说他又是饿汉模式。
但是这样的方式也有缺点,就是通过反射进行创建对象的时候就会出现问题
看代码
public class MainTest {
public static void main(String[] args) throws NoSuchMethodException, SecurityException, InstantiationException, IllegalAccessException, IllegalArgumentException, InvocationTargetException {
//通过反射获取HuAndla的构造函数
Constructor<HuAndla> c = HuAndla.class.getDeclaredConstructor();
//设置对构造函数的访问权限
c.setAccessible(true);
//对构造函数进行调用,实例化
HuAndla hl1 = c.newInstance();
//通过普通的模式进行对象的获取
HuAndla hl2 = HuAndla.getHuAndla();
System.out.println(hl1==hl2);
}
}
结果
这里我们发现通过反射创建出来的对象对原有的结构进行了破坏,创建了两个不同的对象。其实不只是静态内部类有这样的问题,就连上面的懒汉模式与饿汉模式也有这样的问题
这里,有两种解决方式
- 第一种就是在构造函数中进行异常的抛出。
public class HuAndla {
static class HL{
private static HuAndla hl = new HuAndla();
}
private HuAndla(){
//如果hl已经实例化了就没必要在实例化了,直接抛出异常
if(HL.hl!=null){
throw new RuntimeException("已经实例化过了");
}
}
public static HuAndla getHuAndla(){
return HL.hl;
}
}
这样如果通过反射进行对象的创建就会报异常。
- 第二种解决方法就是用枚举类型来进行单例模式的创建,下面会进行具体说明。
4)枚举类型
直接看代码
public class Enum {
private Enum(){
}
//构建枚举类的内部类
private enum E{
INSTANCE;
private final Enum instance;
E(){
instance = new Enum();
}
private Enum getEnum(){
return instance;
}
}
public static Enum getEnum(){
return E.INSTANCE.getEnum();
}
}
这样就是枚举类型的单例模式,为什么枚举类型的内部类会防止反射进行不同对象的创建呢?
从反射的源码中我们就可以看到原因
从源码中我们可以看到如果是枚举类型就会抛出异常。
枚举类实现单例模式是 effective java 作者极力推荐的单例实现模式,因为枚举类型是线程安全的,并且只会装载一次,不仅能避免多线程同步问题,而且还能防止反序列化重新创建新的对象。但是失去了类的一些特性,没有延迟加载,用的人也很少。
三 模式的优点与适用场景
优点
单例模式中单例类的唯一实例由单例类本省控制,所以可以很好的控制用户何时访问它。
场景
当系统需要某个类只能有一个实例,比如系统中的线程池,数据库连接池等。
这就是我所理解的单例模式,如果有错误或者你有更好的想法请告诉我。