文章目录
一、单例模式是什么?
单例模式,也叫单子模式,是一种常用的软件设计模式,属于创建型模式的一种。
在java当中,创建一个类的对象,为了确保这个对象是唯一的,以符合某些特定的场景,就需要使用到单例模式,全局唯一
二、单例的类型
1.饿汉式
饿汉式,顾名思义,就很饿,上来就想要new 对象。
这里说下实现单例模式的固定的思路:
- 类构造函数私有化
- 静态该类对象属性(私有化)
- 静态方法
下面用代码举例说明:
饿汉式单例模式
package com.lhh.signalpattern;
public class Hungry {
private Hungry() {
}
private static final Hungry hungry = new Hungry();
public static Hungry getInstance() {
return hungry;
}
}
优点:比较方便简单,直接调用即可。
缺点:因为一开始就new对象,容易造成内存空间的浪费,占据系统资源,特别是假如我现在在该类里面再额外创建一个很大的空间,比如像这样:
private byte[] data1 = new byte[1024 * 1024];
private byte[] data2 = new byte[1024 * 1024];
private byte[] data3 = new byte[1024 * 1024];
private byte[] data4 = new byte[1024 * 1024];
懒汉式单例模式
废话不多说,懒汉式就是不直接new 对象,而是有一个判断的过程,只有当需要的时候,我才创建。
package com.lhh.signalpattern;
public class Lazy {
private Lazy() {
}
private static Lazy lazy = null;
public static Lazy getInstance() {
if (lazy == null) {
lazy = new Lazy();
}
return lazy;
}
}
优点:只有等到真正要用的时候,也就是调用getInstance方法的时候,再创建,否则不创建,节省了一定的内存空间。
缺点:在单线程下是没问题的,但是在多线程的环境下,会出现异常,也就是拿到多个不同的对象,违背了单例模式的原则。
懒汉式单例模式为什么会出现多个对象呢?
本质上还是因为在方法中的代码不是原子性的,也就是存在多行代码,那么就有可能出现这样一种情况:
假设现在有两个线程,a,b,首先我们让a线程进入了getInstance方法,进入了if判断条件,进入之后,还没等执行创建对象lazy = new Lazy()这行代码,该线程的cpu执行权被b线程拿去了,拿去了之后,另外一个线程也进入了if判断,并且成功创建了对象,而此时,不巧的的是,a线程刚好又得到了执行权,因为也创建了一个对象,那么这过程当中,就相当于创建了两个不同的对象,违背的单例模式的原则,因而说在多线程环境下,懒汉式单例模式是不安全的,需要进行某种加锁的策略。
升级版懒汉式单例模式(DCL懒汉式)
那有人可能就要说了,加一个锁可以解决这个问题,确实,是可以解决多线程环境下引发的问题,且看代码。
package com.lhh.signalpattern;
public class Lazy {
private Lazy() {
}
private static Lazy lazy = null;
public static Lazy getInstance() {
if (lazy == null) {
synchronized (Lazy.class) {
if (lazy == null) {
lazy = new Lazy();
}
}
}
return lazy;
}
}
我们使用多线程来测试一下:
class Demo {
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
new Thread(() -> {
System.out.println(Lazy.getInstance());
}).start();
}
}
}
执行结果如下:
但是仅仅是这样,还是会存在一个问题,也就是关乎原子性的问题,上面的代码当中,lazy=new Lazy()并不是一个原子性操作,真正的执行过程是这样子的:
据于此,就存在线程并不能保证new对象这个操作按照顺序的执行,可能会因为按照非常规顺序执行,引发问题,比如我现在有两个线程,第一个线程是按照132执行的,那么可能存在当第一个线程执行到13的时候,轮到了第二个线程执行,由于第一个线程已经指向了一个没有经过对象初始化的空间,那么第二个线程可能得到的就是一个没有构造的空间,就产生问题了。所以为了避免这种问题的发生,就应该需要使用到java当中的关键字volatile关键字,确保不发生指令重排,避免问题的发生。
///标准的双重检测锁模式,也称为DCL懒汉式。
public class Lazy {
private Lazy() {
}
//加上volitile关键字
private volatile static Lazy lazy = null;
public static Lazy getInstance() {
if (lazy == null) {
synchronized (Lazy.class) {
if (lazy == null) {
lazy = new Lazy();
}
}
}
return lazy;
}
}
但是尽管看起来dcl单例模式已经如此完善了,它还是有一个缺点,就是不能对付java中的反射技术,在文章的第三部分存在的问题当中我们再来讨论。
静态内部类单例模式
package com.lhh.signalpattern;
public class OuterClass {
private OuterClass(){
}
public static OuterClass getInstance(){
return InnerClass.outer_class;
}
public static class InnerClass{
private static final OuterClass outer_class = new OuterClass();
}
}
优点:外部类加载时并不需要立即加载内部类,内部类不被加载则不去初始化outer_class实例,故而不占内存.
枚举实现单例
public enum EnumSingle {
INSTANCE;
public EnumSingle getInstance(){
return INSTANCE;
}
}
//测试运行,使用反射拿到对象
class Demo2{
public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
EnumSingle instance = EnumSingle.INSTANCE;
Constructor<EnumSingle> declaredConstructor = EnumSingle.class.getDeclaredConstructor();
declaredConstructor.setAccessible(true);
EnumSingle enumSingle = declaredConstructor.newInstance();
System.out.println(instance);
System.out.println(enumSingle);
}
}
运行发现错误,错误如下:
但是按照某某说的,计算机程序的执行结果是不可能骗我们的,那么很有可能terminal终端反编译结果也有问题,那么我们借助专业的反编译的工具再来查看,可以发现结果如下:
至此,问题就解决了,在测试程序里面需要加上两个对应类型的class类,代码如下:
class Demo2{
public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
EnumSingle instance = EnumSingle.INSTANCE;
Constructor<EnumSingle> declaredConstructor = EnumSingle.class.getDeclaredConstructor(String.class,int.class);//特别要注意这里,加上String.class与int.class,不然报错。
declaredConstructor.setAccessible(true);
EnumSingle enumSingle = declaredConstructor.newInstance();
System.out.println(instance);
System.out.println(enumSingle);
}
}
再次测试运行,发现确实不能使用反射创建对象,报错,符合预期。
最后说下枚举类型的单例优点:枚举实例创建是线程安全的,在任何情况下,它都是一个单例。
三、存在的问题
接下来,我们来深度剖析一下,dcl双重检测锁单例模式的一些问题。
因为我们可以使用反射破解类,所以有必要针对一下该问题,进行一些探讨,以及一些解决方案:
//DCL双重检测锁单例实现代码
public class Lazy {
private Lazy() {
}
private volatile static Lazy lazy = null;
public static Lazy getInstance() {
if (lazy == null) {
synchronized (Lazy.class) {
if (lazy == null) {
lazy = new Lazy();
}
}
}
return lazy;
}
}
接下来我们使用一个测试,利用反射技术破坏单例模式:
private static void test1() throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
Lazy lazy = Lazy.getInstance();
Constructor<Lazy> declaredConstructor = Lazy.class.getDeclaredConstructor(null);
//暴力破解,具备访问私有权限的能力
declaredConstructor.setAccessible(true);
Lazy lazy1 = declaredConstructor.newInstance();
System.out.println(lazy);
System.out.println(lazy1);
}
执行的结果不同,破坏成功.

那么针对上述问题,我们提供了反破解思路,因为反射拿到的是构造函数,通过构造函数来创建对象,那么我们就可以在构造函数里面加一些操作,比如锁判断,加入一个标志位。
public class Lazy {
private static boolean flag = false;
private Lazy() {
synchronized(Lazy.class){
if(flag==false){
flag=true;
}else{
throw new RuntimeException("不要企图通过反射创建对象");
}
}
}
private volatile static Lazy lazy = null;
public static Lazy getInstance() {
if (lazy == null) {
synchronized (Lazy.class) {
if (lazy == null) {
lazy = new Lazy();
}
}
}
return lazy;
}
}
像这样的话,就可以防止反射创建对象。
可以尽管如此,还是可以做到再次破坏,如何实现呢?假如你现在知道了有一个标志位在起作用,那么我可以先拿到你这个标志位,使用了反射创建了一次对象之后,我就把你这个值重新改过来,那么我就可以再次用反射来创建一个新的对象了,具体操作过程如下代码所示:
private static void test1() throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException, NoSuchFieldException {
//获取字段,以便后续手动修改
Field flag = Lazy.class.getDeclaredField("flag");
flag.setAccessible(true);
Constructor<Lazy> declaredConstructor = Lazy.class.getDeclaredConstructor(null);
//暴力破解,具备访问私有权限的能力
declaredConstructor.setAccessible(true);
Lazy lazy1 = declaredConstructor.newInstance();
System.out.println(flag.getName());
//再次使用反射创建对象
flag.set(lazy1,false);
Lazy lazy2 =declaredConstructor.newInstance();
System.out.println(lazy2);
System.out.println(lazy1);
}
总结
所以说,单例模式里面的学问也是挺深的了。
要我推荐使用的话,一般情况下,不建议使用懒汉方式,建议使用饿汉方式。只有在要明确实现 lazy loading 效果时,才会使用静态内部类方式。如果涉及到反序列化创建对象时,可以尝试使用枚举方式。如果有其他特殊的需求,可以考虑使用第 3 种双检锁方式。