34

单例模式的几种实现And反射对其的破坏

 3 years ago
source link: http://www.cnblogs.com/ideal-20/p/13912766.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

zA7rEru.png!mobile

一 单例模式概述

(一) 什么是单例模式

单例模式属于 创建型模式 之一,它提供了一种创建对象的最佳方式

在软件工程中,创建型模式是处理对象创建的设计模式,试图根据实际情况使用合适的方式创建对象。基本的对象创建方式可能会导致设计上的问题,或增加设计的复杂度。创建型模式通过以某种方式控制对象的创建来解决问题。

因为我们平时虽然可以定义一个全局变量使一个对象被访问,但是它并不能保证你多次实例化对象,最直观的, 多次创建对象的代价就是消耗性能 ,导致效率会低一些。单例模式就是用来解决这些问题

顺便提一个很常见的例子:例如在 Win 系的电脑下我们永远只能打开一个任务管理器,这样可以避免出现一些资源浪费,以及多窗口显示数据不一致的问题

定义:单例模式,保证一个类仅有一个实例,并且提供一个访问它的全局访问点

(二) 特点

  • ① 单例类只能有一个实例对象

  • ② 单例类必须自己创建自己的唯一实例

  • ③ 单例类必须对外提供一个访问该实例的方法

(三) 优缺点以及使用场景

(1) 优点

  • 提供了对唯一实例的受控访问

  • 保证了内存中只有唯一实例,减少了内存的开销

    • 尤其表现在一些需要多次创建销毁实例的情况下
  • 避免对资源的多重占用

    • 比如对文件的写操作

(2) 缺点

  • 单例模式中没有抽象层,没有接口,不能继承,扩展困难,扩展需要修改原来的代码,违背了 “开闭原则”
  • 单例类的代码一般写在同一个类中,一定程度上职责过重,违背了 “单一职责原则”

(3) 应用场景

先说几个大家常见单例的例子:

  • Windows 下的任务管理器和回收站,都是典型的单例模式,你可以试一下,没法同时打开两个的哈

  • 数据库连接池的设计一般也是单例模式,因为频繁的打开关闭与数据库的连接,会有不小的效率损耗

    • 但是滥用单例也可能带来一些问题,例如导致共享连接池对象的程序过多而出现连接池溢出
  • 网站计数器,通过单例解决同步问题

  • 操作系统的文件系统

  • Web 应用的配置对象读取,因为配置文件属于共享的资源

  • 程序的日志应用,一般也是单例,否则追加内容时,容易出问题

所以,根据一些常见的例子,简单总结一下,什么时候用单例模式呢?

  • ① 需要频繁创建销毁实例的
  • ② 实例创建时,消耗资源过多,或者耗时较多的,例如数据连接或者IO
  • ③ 某个类只要求生成一个类的情况,例如生成唯一序列号,或者人的身份证
  • ④ 对象需要共享的情况,如 Web 中配置对象

二 实现单例模式

根据单例模式的定义和特点,我们可以分为三步来实现最基本的单例模式

  • ① 构造函数私有化
  • ② 在类的内部创建实例
  • ③ 提供本类实例的唯一全局访问点,即提供获取唯一实例的方法

(一) 饿汉式

我们就按照最基本的这三点来写

public class Hungry {
    // 构造器私有,静止外部new
    private Hungry(){}

    // 在类的内部创建自己的实例
    private static Hungry hungry = new Hungry();

    // 获取本类实例的唯一全局访问点
    public static Hungry getHungry(){
        return hungry;
    }
}

这种做法一开始就直接创建这个实例,我们也称为饿汉式单例,但是如果 这个实例一直没有被调用,会造成内存的浪费 ,显然这样做是不合适的

(二) 懒汉式

饿汉式的主要问题在于,一开始就创建实例导致的内存浪费问题,那么我们将创建对象的步骤,挪到具体使用的时候

public class Lazy1 {
    // 构造器私有,静止外部new
    private Lazy1(){
        System.out.println(Thread.currentThread().getName() + " 访问到了");
    }
    
    // 定义即可,不真正创建
    private static Lazy1 lazy1 = null;

    // 获取本类实例的唯一全局访问点
    public static Lazy1 getLazy1(){
        // 如果实例不存在则new一个新的实例,否则返回现有的实例
        if (lazy1 == null) {
            lazy1 = new Lazy1();
        }
        return lazy1;
    }

    public static void main(String[] args) {
        // 多线程访问,看看会有什么问题
        for (int i = 0; i < 10; i++) {
            new Thread(()->{
                Lazy1.getLazy1();
            }).start();
        }
    }
}

例如上述代码,我们只在刚开始做了一个定义,真正的实例化是在调用 getLazy1() 时被执行

单线程环境下是没有问题的,但是多线程的情况下就会出现问题,例如下面是我运行结果中的一次:

Thread-0 访问到了
Thread-4 访问到了
Thread-1 访问到了
Thread-3 访问到了
Thread-2 访问到了

(三) DCL 懒汉式

(1) 方法上直接加锁

很显然,多线程下的普通懒汉式出现了问题,这个时候,我们只需要加一层锁就可以解决

简单的做法就是在方法前加上 synchronized 关键字

public static synchronized Lazy1 getLazy1(){
    if (lazy1 == null) {
        lazy1 = new Lazy1();
    }
    return lazy1;
}

(2) 缩小锁的范围

但是我们又想缩小锁的范围,毕竟方法上加锁,多线程中效率会低一些,所以只把锁加到需要的代码上

我们直观的可能会这样写

public static Lazy1 getLazy1(){
    if (lazy1 == null) {
    	synchronized(Lazy1.class){
    		lazy1 = new Lazy1();
    	}
   	}
    return lazy1;
}

但是这样还是有问题的

(3) 双重锁定

当线程 A 和 B 同时访问getLazy1(),执行到到 if (lazy1 == null) 这句的时候,同时判断出 lazy1 == null,也就同时进入了 if 代码块中,后面因为加了锁,只有一个能先执行实例化的操作,例如 A 先进入,但是 后面的 B 进入后同样也可以创建新的实例,就达不到单例的目的了,不信可以自己试一下

解决的方式就是再进行第二次的判断

// 获取本类实例的唯一全局访问点
public static Lazy1 getLazy1(){
    // 如果实例不存在则new一个新的实例,否则返回现有的实例
    if (lazy1 == null) {
        // 加锁
        synchronized(Lazy1.class){
            // 第二次判断是否为null
            if (lazy1 == null){
                lazy1 = new Lazy1();
            }
        }
    }
    return lazy1;
}

(4) 指令重排问题

这种在适当位置加锁的方式,尽可能的降低了加锁对于性能的影响,也能达到预期效果

但是这段代码,在一定条件下还是会有问题,那就是指令重排问题

指令重排序是JVM为了优化指令,提高程序运行效率,在不影响单线程程序执行结果的前提下,尽可能地提高并行度。

什么意思呢?

首先要知道 lazy1 = new Lazy1(); 这一步并不是一个原子性操作,也就是说这个操作会分成很多步

  • ① 分配对象的内存空间
  • ② 执行构造函数,初始化对象
  • ③ 指向对象到刚分配的内存空间

但是 JVM 为了效率对这个步骤进行了重排序,例如这样:

  • ① 分配对象的内存空间
  • ③ 指向对象到刚分配的内存空间,对象还没被初始化
  • ② 执行构造函数,初始化对象

按照 ① ③ ② 的顺序,当 A 线程执行到 ② 后,B线程判断 lazy1 != null ,但是此时的 lazy1 还没有被初始化,所以会出问题,并且这个过程中 B 根本执行到锁那里,配个表格说明一下:

Time ThreadA ThreadB t1 A:① 分配对象的内存空间 t2 A:③ 指向对象到刚分配的内存空间,对象还没被初始化 t3 B:判断 lazy1 是否为 null t4 B:判断到 lazy1 != null,返回了一个没被初始化的对象 t5 A:② 初始化对象

解决的方法很简单——在定义时增加 volatile 关键字,避免指令重排

(5) 最终代码

最终代码如下:

public class Lazy1 {
    // 构造器私有,静止外部new
    private Lazy1(){
        System.out.println(Thread.currentThread().getName() + " 访问到了");
    }

    // 定义即可,不真正创建
    private static volatile Lazy1 lazy1 = null;

    // 获取本类实例的唯一全局访问点
    public static Lazy1 getLazy1(){
        // 如果实例不存在则new一个新的实例,否则返回现有的实例
        if (lazy1 == null) {
            // 加锁
            synchronized(Lazy1.class){
                // 第二次判断是否为null
                if (lazy1 == null){
                    lazy1 = new Lazy1();
                }
            }
        }
        return lazy1;
    }

    public static void main(String[] args) {
        // 多线程访问,看看会有什么问题
        for (int i = 0; i < 10; i++) {
            new Thread(()->{
                Lazy1.getLazy1();
            }).start();
        }
    }
}

(四) 静态内部类懒汉式

双重锁定算是一种可行不错的方式,而静态内部类就是一种更加好的方法,不仅速度较快,还保证了线程安全,先看代码

public class Lazy2 {
    // 构造器私有,静止外部new
    private Lazy2(){
        System.out.println(Thread.currentThread().getName() + " 访问到了");
    }

    // 用来获取对象
    public static Lazy2 getLazy2(){
        return InnerClass.lazy2;
    }

    // 创建内部类
    public static class InnerClass {
        // 创建单例对象
        private static Lazy2 lazy2 = new Lazy2();
    }

    public static void main(String[] args) {
        // 多线程访问,看看会有什么问题
        for (int i = 0; i < 10; i++) {
            new Thread(()->{
                Lazy2.getLazy2();
            }).start();
        }
    }
}

上面的代码,首先 InnerClass 是一个内部类,其在初始化时是不会被加载的,当用户执行了 getLazy2() 方法才会加载,同时创建单例对象,所以他也是懒汉式的方法,因为 InnerClass 是一个静态内部类,所以只会被实例化一次,从而达到线程安全,因为并没有加锁,所以性能上也会很快,所以一般是推荐的

(五) 枚举方式

最后推荐一个非常好的方式,那就是枚举单例方式,其不仅简单,且保证了安全,先看一下 《Effective Java》中作者的说明:

这种方法在功能上与公有域方法相似,但更加简洁无偿地提供了序列化机制,绝对防止多次实例化。即使是在面对复杂的序列化或者反射攻击的时候。虽然这种方法还没有广泛采用, 但是单元素的枚举类型经常成为实现Singleton 的最佳方法 ,注意,如果 Singleton 必须扩展一个超类,而不是扩展 enum 时则不宜使用这个方法,(虽然可以声明枚举去实现接口)。

节选自 《Effective Java》第3条:用私有构造器或者枚举类型强化 Singleton 属性

原著:Item3: Enforce the singleton property with a private constructor or an enum

代码就这样,简直不要太简单,访问通过 EnumSingle.IDEAL 就可以访问了

public enum EnumSingle {
    IDEAL;
}

我们接下来就要给大家演示为什么枚举是一种比较安全的方式

三 反射破坏单例模式

(一) 单例是如何被破坏的

下面用双重锁定的懒汉式单例演示一下,这是我们原来的写法,new 两个实例出来,输出一下

public class Lazy1 {
    // 构造器私有,静止外部new
    private Lazy1(){
        System.out.println(Thread.currentThread().getName() + " 访问到了");
    }

    // 定义即可,不真正创建
    private static volatile Lazy1 lazy1 = null;

    // 获取本类实例的唯一全局访问点
    public static Lazy1 getLazy1(){
        // 如果实例不存在则new一个新的实例,否则返回现有的实例
        if (lazy1 == null) {
            // 加锁
            synchronized(Lazy1.class){
                // 第二次判断是否为null
                if (lazy1 == null){
                    lazy1 = new Lazy1();
                }
            }
        }
        return lazy1;
    }

    public static void main(String[] args) {

        Lazy1 lazy1 = getLazy1();
        Lazy1 lazy2 = getLazy1();
        System.out.println(lazy1);
        System.out.println(lazy2);

    }
}

运行结果:

main 访问到了

cn.ideal.single.Lazy1@1b6d3586

cn.ideal.single.Lazy1@1b6d3586

可以看到,结果是单例没有问题

(1) 一个普通实例化,一个反射实例化

但是我们如果通过反射的方式进行实例化类,会有什么问题呢?

public static void main(String[] args) throws Exception {
    Lazy1 lazy1 = getLazy1();
    // 获得其空参构造器
    Constructor<Lazy1>  declaredConstructor = Lazy1.class.getDeclaredConstructor(null);
    // 使得可操作性该 declaredConstructor 对象
    declaredConstructor.setAccessible(true);
    // 反射实例化
    Lazy1 lazy2 = declaredConstructor.newInstance();
    System.out.println(lazy1);
    System.out.println(lazy2);
}

getDeclaredConstructor() 方法说明

方法返回一个Constructor对象,它反映此Class对象所表示的类或接口指定的构造函数。parameterTypesparameter是确定构造函数的形参类型,在Class对象声明顺序的数组。

public Constructor getDeclaredConstructor(Class<?>... parameterTypes) throws NoSuchMethodException, SecurityException

运行结果:

main 访问到了

main 访问到了

cn.ideal.single.Lazy1@1b6d3586

cn.ideal.single.Lazy1@4554617c

可以看到,单例被破坏了

解决办法:因为我们反射走的其无参构造,所以在无参构造中再次进行非null判断,加上原来的双重锁定,现在也就有三次判断了

// 构造器私有,静止外部new
private Lazy1(){
    synchronized (Lazy1.class){
        if(lazy1 != null) {
            throw new RuntimeException("反射破坏单例异常");
        } 
    }
}

不过结果也没让人失望,这种测试下,第二次实例化会直接报异常

RVJ7Zja.png!mobile

(2) 两个都是反射实例化

如果两个都是反射实例化出来的,也就是说,根本就不去调用 getLazy1() 方法,那可怎么办?

如下:

public static void main(String[] args) throws Exception {

    // 获得其空参构造器
    Constructor<Lazy1> declaredConstructor = Lazy1.class.getDeclaredConstructor(null);
    // 使得可操作性该 declaredConstructor 对象
    declaredConstructor.setAccessible(true);
     // 反射实例化
    Lazy1 lazy1 = declaredConstructor.newInstance();
    Lazy1 lazy2 = declaredConstructor.newInstance();

    System.out.println(lazy1);
    System.out.println(lazy2);
}

运行结果:

main 访问到了

main 访问到了

cn.ideal.single.Lazy1@1b6d3586

cn.ideal.single.Lazy1@4554617c

单例又被破坏了

解决方案:增加一个标识位,例如下文通过增加一个布尔类型的 ideal 标识,保证只会执行一次,更安全的做法,可以进行加密处理,保证其安全性

// 构造器私有,静止外部new
private Lazy1(){
    synchronized (Lazy1.class){
        if (ideal == false){
            ideal = true;
        } else {
            throw new RuntimeException("反射破坏单例异常");
        }
    }
	System.out.println(Thread.currentThread().getName() + " 访问到了");
}

mamiAbr.png!mobile

这样就没问题了吗,并不是,一旦别人通过一些手段得到了这个标识内容,那么他就可以通过修改这个标识继续破坏单例,代码如下(这个把代码贴全一点,前面都是节选关键的,都可以参考这个)

public class Lazy1 {

    private static boolean ideal = false;

    // 构造器私有,静止外部new
    private Lazy1(){
        synchronized (Lazy1.class){
            if (ideal == false){
                ideal = true;
            } else {
                throw new RuntimeException("反射破坏单例异常");
            }
        }
        System.out.println(Thread.currentThread().getName() + " 访问到了");
    }

    // 定义即可,不真正创建
    private static volatile Lazy1 lazy1 = null;

    // 获取本类实例的唯一全局访问点
    public static Lazy1 getLazy1(){
        // 如果实例不存在则new一个新的实例,否则返回现有的实例
        if (lazy1 == null) {
            // 加锁
            synchronized(Lazy1.class){
                // 第二次判断是否为null
                if (lazy1 == null){
                    lazy1 = new Lazy1();
                }
            }
        }
        return lazy1;
    }

    public static void main(String[] args) throws Exception {

        Field ideal = Lazy1.class.getDeclaredField("ideal");
        ideal.setAccessible(true);

        // 获得其空参构造器
        Constructor<Lazy1> declaredConstructor = Lazy1.class.getDeclaredConstructor(null);
        // 使得可操作性该 declaredConstructor 对象
        declaredConstructor.setAccessible(true);
        // 反射实例化
        Lazy1 lazy1 = declaredConstructor.newInstance();
        ideal.set(lazy1,false);
        Lazy1 lazy2 = declaredConstructor.newInstance();

        System.out.println(lazy1);
        System.out.println(lazy2);

    }
}

运行结果:

main 访问到了

main 访问到了

cn.ideal.single.Lazy1@4554617c

cn.ideal.single.Lazy1@74a14482

实例化 lazy1 后,其执行了修改 ideal 这个布尔值为 false,从而绕过了判断,再次破坏了单例

所以,可以得出,这几种方式都是不安全的,都有着被反射破坏的风险

(二) 枚举类不会被破坏

上面在讲解枚举单例方式的时候就提过《Effective Java》中提到, 即使是在面对复杂的序列化或者反射攻击的时候,(枚举单例方式)绝对防止多次实例化 ,下面来看一下是不是这样:

首先说一个前提条件:这是 Constructor 下的 newInstance 方法节选,也就是说遇到枚举时,会报异常,也就是不允许通过反射创建枚举

if ((clazz.getModifiers() & Modifier.ENUM) != 0)
    throw new IllegalArgumentException("Cannot reflectively create enum objects");

看一下我们枚举单例类 EnumSingle 生成的字节码文件,可以看到其中有一个无参构造,也就是说,我们还是只需要拿到 getDeclaredConstructor(null) 就行了

bqAbAnN.png!mobile

代码如下:

public enum EnumSingle {
    IDEAL;

    public static void main(String[] args) throws Exception {
        EnumSingle ideal1 = EnumSingle.IDEAL;
        // 获得其空参构造器
        Constructor<EnumSingle> declaredConstructor = EnumSingle.class.getDeclaredConstructor(null);
        // 使得可操作性该 declaredConstructor 对象
        declaredConstructor.setAccessible(true);
        // 反射实例化
        EnumSingle ideal2 = declaredConstructor.newInstance();
        System.out.println(ideal1);
        System.out.println(ideal2);
    }
}

运行结果却是出人意料:

eYj6fer.png!mobile

提示竟然是找不到这个空参???字节码中可是却是存在的啊

Exception in thread "main" java.lang.NoSuchMethodException: cn.ideal.single.EnumSingle.<init>()

自己 javap 反编译一下,可以看到还是有这个空参

UZni6b.png!mobile

换成 jad 再看看(将 jad.exe 放在字节码文件同目录下)

  • 执行: jad -sjava EnumSingle.class

提示已经反编译结束:Parsing EnumSingle.class... Generating EnumSingle.java

打开生成的 java 文件,终于发现,原来它是一个带参构造,同时有两个参数,String 和 int

meqQbyI.png!mobile

所以下面,我们只需要修改原来的无参为有参即可:

public enum EnumSingle {
    IDEAL;

    public static void main(String[] args) throws Exception {
        EnumSingle ideal1 = EnumSingle.IDEAL;
        Constructor<EnumSingle> declaredConstructor = EnumSingle.class.getDeclaredConstructor(String.class, int.class);
        // 使得可操作性该 declaredConstructor 对象
        declaredConstructor.setAccessible(true);
        // 反射实例化
        EnumSingle ideal2 = declaredConstructor.newInstance();
        System.out.println(ideal1);
        System.out.println(ideal2);
    }
}

这样就没问题了,提示了我们想要的错误:Cannot reflectively create enum objects

3Mf6ZrQ.png!mobile

这也说明,枚举类的单例模式写法确实不会被反射破坏!

四 结尾

如果文章中有什么不足,欢迎大家留言交流,感谢朋友们的支持!

如果能帮到你的话,那就来关注我吧!如果您更喜欢微信文章的阅读方式,可以关注我的公众号

在这里的我们素不相识,却都在为了自己的梦而努力 ❤

一个坚持推送原创开发技术文章的公众号:理想二旬不止

UbeyEn.png!mobile


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK