单例模式

前言:可先了解《单例模式的几种创建方式》

一、什么是单例模式

单例模式是一种常用的设计模式,其定义是单例对象的类只能允许一个实例存在,并且提供一个全局访问点。

单例模式具备典型的3个特点:1、只有一个实例。 2、自我实例化。 3、提供全局访问点。

因此当系统中只需要一个实例对象或者系统中只允许一个公共访问点,除了这个公共访问点外,不能通过其他访问点访问该实例时,可以使用单例模式。单例模式的主要优点就是节约系统资源、提高了系统效率,同时也能够严格控制客户对它的访问。也许就是因为系统中只有一个实例,这样就导致了单例类的职责过重,违背了“单一职责原则”,同时也没有抽象类,所以扩展起来有一定的困难。

 二、单例模式的创建方式(推荐用)

懒汉式:

package com.sinosoft.gzgdjcmcs.design;

public class Singleton {

    private Singleton() {} //私有构造函数、避免类被实例化

    private static Singleton instance = null; //单例对象

    public Singleton getInstance() {
        if (instance == null){
            instance = new Singleton();
        }
        return instance;
    }

}

为什么这样写呢?我们来解释几个关键点:

1.要想让一个类只能构建一个对象,自然不能让它随便去做new操作,因此Signleton的构造方法是私有的。

2.instance是Singleton类的静态成员,也是我们的单例对象。它的初始值可以写成null,也可以写成new Singleton()。如果单例初始值是null,还未构建,则构建单例对象并返回。这个写法属于单例模式当中的懒汉模式。如果单例对象一开始就被new Singleton()主动构建,则不再需要判空操作,这种写法属于饿汉模式

3.getInstance是获取单例对象的方法。

从上面的代码显然可以看出,这样的创建方式存在线程安全问题,假设Singleton类刚刚被初始化,instance对象还是空,这时候两个线程同时访问getInstance方法,因为Instance是空,所以两个线程同时通过了条件判断,开始执行new操作,这样一来,显然instance被构建了两次,测试代码如下:

public class TestThread extends Thread{

    @Override
    public void run() {
        System.out.println(Singleton.getInstance().hashCode());
    }
}
public class TestMain {

    public static void main(String[] args) {
        TestThread thread1 = new TestThread();
        TestThread thread2 = new TestThread();
        TestThread thread3 = new TestThread();
        TestThread thread4 = new TestThread();
        thread1.start();
        thread2.start();
        thread3.start();
        thread4.start();
    }
}

测试结果:

112430522
1981657541
1981657541
1981657541

我们看到对象返回的哈希值不一致了,这就说明他们已经不是同一个对象实例了,也就是说这种写法是线程不安全的,那么我们对它进行如下的改造:

public class Singleton {

    private static Singleton instance; //单例对象

    private Singleton(){} //私有构造函数、避免类被实例化

    //整个方法加同步,效率低
    synchronized public static Singleton getInstance() {
        if (instance == null){
            instance = new Singleton();
        }
        return instance;
    }

}

我们给整个getInstance方法添加了个synchronized关键字,才保证多线程的情况下也能保持单例,我们再运行一下测试类:

1689237072
1689237072
1689237072
1689237072

这时我们看见已经没有问题了,就是说写法是正确的,但是这种写法导致所以并发的线程获取实例时都是排队进行的,那么就会导致性能低下,接下来我们尝试另外一种写法:

双重校验机制:

public class Singleton {

    private static Singleton instance; //单例对象

    private Singleton(){} //私有构造函数、避免类被实例化

    public static Singleton getInstance() {
        if (instance == null){ //双重检测
        	synchronized (Singleton.class) { //同步锁
				if (instance == null) { //双重检测
		            instance = new Singleton();
				}
			}
        }
        return instance;
    }

}

为什么这样写呢?我们来解释几个关键点:

1.为了防止new Singleton被执行多次,因此在new操作之前加上synchronized 同步锁,锁住整个类(注意,这里不能使用对象锁)。

2.进入synchronized 临界区以后,还要再做一次判空。因为当两个线程同时访问的时候,线程A构建完对象,线程B也已经通过了最初的判空验证,不做第二次判空的话,线程B还是会再次构建instance对象。

针对以上代码设计后,总体还是可以的,但还不是绝对的线程安全。

假设这样的场景,当两个线程一先一后访问getInstance方法的时候,当A线程正在构建对象,B线程刚刚进入方法:

 

这种情况表面看似没什么问题,要么instance 还没被线程A构建,线程B执行 if(instance == null)的时候得到true;要么Instance已经被线程A构建完成,线程B执行 if(instance == null)的时候得到false。

真的如此吗?答案是否定的。这里涉及到了JVM编译器的指令重排

指令重排是什么意思呢?比如java中简单的一句 instance = new Singleton,会被编译器编译成如下JVM指令:

memory =allocate();    //1:分配对象的内存空间 
ctorInstance(memory);  //2:初始化对象 
instance =memory;     //3:设置instance指向刚分配的内存地址 

 但是这些指令顺序并非一成不变,有可能会经过JVM和CPU的优化,指令重排成下面的顺序:

memory =allocate();    //1:分配对象的内存空间 
instance =memory;     //3:设置instance指向刚分配的内存地址 
ctorInstance(memory);  //2:初始化对象 

当线程A执行完1、3,步骤时,instance对象还未完成初始化,但已经不再指向null。此时如果线程B抢占到CPU资源,执行  if(instance == null)的结果会是false,从而返回一个没有初始化完成的instance对象

如何避免这一情况呢?我们需要在instance对象前面增加一个修饰符volatile。

public class Singleton {

    private volatile static Singleton instance; //单例对象

    private Singleton(){} //私有构造函数、避免类被实例化

    public static Singleton getInstance() {
        if (instance == null){ //双重检测
        	synchronized (Singleton.class) { //同步锁
				if (instance == null) { //双重检测
		            instance = new Singleton();
				}
			}
        }
        return instance;
    }

}

那么这个volatile又是什么呢?我们可以看一下维基百科上的描述:

The volatile keyword indicates that a value may change between different accesses, it prevents an optimizing compiler from optimizing away subsequent reads or writes and thus incorrectly reusing a stale value or omitting writes.

用最简单的方式理解就是,volatile修饰符阻止了变量访问前后的指令重排,保证指令执行顺序。

经过volatile的修饰,当线程A执行instance = new Singleton();的时候,JVM执行顺序是什么样?始终保证是下面的顺序:

memory =allocate();    //1:分配对象的内存空间 
ctorInstance(memory);  //2:初始化对象 
instance =memory;     //3:设置instance指向刚分配的内存地址 

 

如此在线程B看来,instance对象的引用要么指向null,要么指向一个初始化完毕的instance,而不会出现某个中间态,保证了安全。

静态内部类:

public class Singleton implements Serializable {

    private static final long serialVersionUID = 1L;

	private static class LazyHolder {
		private static final Singleton instance = new Singleton();
    }

    private Singleton(){} //私有构造函数、避免类被实例化

    public static Singleton getInstance() {
        return LazyHolder.instance;
    }

}

这里有几个需要注意的点:

1.从外部无法访问静态内部类LazyHolder,只有当调用Singleton.getInstance方法的时候,才能得到单例对象instance。

2.instance对象初始化的时机并不是在单例类Singleton被加载的时候,而是在调用getInstance方法,使得静态内部类LazyHolder被加载的时候。因此这种实现方式是利用classloader的加载机制来实现懒加载,并保证构建单例的线程安全。

静态内部类的创建方法虽好,但是也存在着单例模式共同的问题,无法防止利用反射来重复构建对象。

利用反射打破单例:

// 获得单例类构造器
Constructor con = Singleton.class.getDeclaredConstructor();
// 设置为可访问
con.setAccessible(true);
// 构造两个不同的对象
Singleton singleton1 = (Singleton) con.newInstance();
Singleton singleton2 = (Singleton) con.newInstance();
// 获取两个对象哈希值
System.out.println(singleton1.hashCode());
System.out.println(singleton2.hashCode());

代码可以简单归纳为三个步骤:

第一步,获得单例类的构造器。

第二步,把构造器设置为可访问。

第三步,使用newInstance方法构造对象。

最后为了确认这两个对象是否真的是不同的对象,我们可以运行查看它们的哈希值是否一致。毫无疑问,运行结果是不一致的。

运行结果:

1154147768
1689237072

 反序列化:

Singleton instance = Singleton.getInstance();
System.out.println(instance);
byte[] serialize = SerializationUtils.serialize(instance);
Singleton singleTon = (Singleton) SerializationUtils.deserialize(serialize);
System.out.println(singleTon);
System.out.println(instance.equals(singleTon));

 运行结果:

com.sinosoft.gzgdjcmcs.controller.Singleton@5faecf45
com.sinosoft.gzgdjcmcs.controller.Singleton@470d1f30
false

 由此可以看出,对象被反序列化的时候,返回的对象不是同一对象。

 那我们可以怎么阻止反射构建、反序列化呢?其实我们可以使用枚举来实现单例,有了enum语法糖,JVM会阻止反射获取枚举类的私有构造方法,这是一种优雅而又简洁的创建方式。

枚举类:

public enum SingletonEnum {
	
	//枚举元素本身就是单例
	INSTANCE;

}

让我们来做一个实验,仍然执行刚才的反射代码:

//获得构造器
Constructor con = SingletonEnum.class.getDeclaredConstructor();
//设置为可访问
con.setAccessible(true);
//构造两个不同的对象
SingletonEnum singleton1 = (SingletonEnum)con.newInstance();
SingletonEnum singleton2 = (SingletonEnum)con.newInstance();
//验证是否是不同对象
System.out.println(singleton1.equals(singleton2));

执行获得构造器这一步的时候,抛出了如下异常:

Exception in thread "main" java.lang.NoSuchMethodException: com.sinosoft.gzgdjcmcs.controller.SingletonEnum.<init>()
	at java.lang.Class.getConstructor0(Class.java:2721)
	at java.lang.Class.getDeclaredConstructor(Class.java:2002)
	at com.sinosoft.gzgdjcmcs.controller.TestMain.main(TestMain.java:30)

使用枚举实现的单例模式不仅可以防止反射构建对象,而且可以保证线程安全。不过这种方式也有唯一的缺点,就是它并非使用懒加载,其单例对象是在枚举类被加载的时候进行初始化的。

最后针对单例模式做下简单的总结:

几点补充:

1. volatile关键字不但可以防止指令重排,也可以保证线程访问的变量值是主内存中的最新值

2.使用枚举实现的单例模式,不但可以防止利用反射强行构建单例对象,而且可以在枚举类对象被反序列化的时候,保证反序列的返回结果是同一对象。

对于其他方式实现的单例模式,如果既想要做到可序列化,又想要反序列化为同一对象,则必须实现readResolve方法。

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值