设计模式——单例模式

单例模式

个人主页

单例模式(Singleton Pattern)是 Java 中最简单的设计模式之一。这种类型的设计模式属于创建型模式,它提供了一种创建对象的最佳方式。

这种模式涉及到一个单一的类,该类负责创建自己的对象,同时确保只有单个对象被创建。这个类提供了一种访问其唯一的对象的方式,可以直接访问,不需要实例化该类的对象。

注意:

  • 1、单例类只能有一个实例。
  • 2、单例类必须自己创建自己的唯一实例。
  • 3、单例类必须给所有其他对象提供这一实例。

饿汉式单例

​ 由于饿汉式单例是在类加载的时候创建的实例,避免了线程安全问题,所以是线程安全的。

​ 但是由于饿汉式是在类加载的时候就初始化,所以浪费内存。

/**
 * Hungry   饿汉式单例
 */
public class Hungry {
	//如果此时加入一个成员,那类加载的时候就初始化,会浪费内存
    private Hungry() {
			/*单例模式构造器都是私有的*/
    }

    private final static Hungry HUNGRY = new Hungry();

    public static Hungry getInstance() {
        return HUNGRY;
    }
}

静态内部类

package Design_Patterns.Single;

//静态内部类实现单例
public class Holder {

    private Holder() {
        //单例模式,都必须构造器私有
    }

    public static Holder getInstance() {
        return InnerClass.HOLDER;
    }
	//一个静态的内部类
    public static class InnerClass {
        private static final Holder HOLDER = new Holder();
    }
}

懒汉式单例

线程不安全

/**
 * 懒汉式单例
 */
public class Lazy {

    private Lazy() {
        System.out.println(Thread.currentThread().getName() + "ok");
    }

    private static Lazy LAZY;

    public static Lazy getInstance() {
        if (LAZY == null) {
            LAZY = new Lazy();  //不是一个原子性操作
        }
        return LAZY;    
    }
}

分析:假如在getInstance()方法中,判断LAZY为null后,CPU切换到另一个线程,再来判断又是null,CPU继续切换回刚开始那个线程,继续执行new对象操作,然后CPU切换回第二个线程,也会顺着继续执行new对象操作,此时的对象就不再是单个的对象,违反了单例模式。

我们对可以做一个测试:通过输出得知调用了四次构造函数,已经破坏了单例模式

public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            new Thread() {
                public void run() {
                    Lazy.getInstance();
                }
            }.start();
        }
    }
/**output
Thread-1ok
Thread-3ok
Thread-0ok
Thread-2ok
*/

线程安全

为了解决线程安全问题,我们采用双重检验锁(DCL,即 double-checked locking)

/**
 * 懒汉式单例
 */
public class Lazy {

    private Lazy() {
        synchronized(Lazy.class) {
            if(LAZY != null) {		//防止反射破坏
                throw new RuntimeException("不要试图使用反射破坏单例");
            } else {
                System.out.println(Thread.currentThread().getName() + "ok");
            }            
        } 
    }

    private volatile static Lazy LAZY;

    //双重检测锁模式的懒汉式单例
    public static Lazy getInstance() {
        if (LAZY == null) {
            synchronized (Lazy.class) {
                if (LAZY == null) {
                    LAZY = new Lazy();  //不是一个原子性操作
                }
            }
        }
        return LAZY;    
    }

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            new Thread() {
                public void run() {
                    Lazy.getInstance();
                }
            }.start();
        }
    }
}

分析:

为什么给LAZY对象加volatile关键字

在Java中new一个对象并非一个原子操作,可分为三步:

  1. 分配内存空间
  2. 执行构造方法,初始化对象
  3. 把这个对象指向空间

由于new对象并不是一个原子操作,所以可能发生指令重排,执行顺序可能是123,也可能是132,假如指令执行顺序变成了132:

  1. 假如A进程刚进来,先分配内存空间,再把对象指向这个空间
  2. 此时进来一个线程B,由于LAZY已经指向了一个空间,它会认为对象不为null,所以会直接返回
  3. 此时LAZY还未完成构造,空间是一片虚无,所以LAZY必须要避免指令重排,加volatile
反射对单例的破坏

Java的反射可以从class中反射出构造函数,从而达到创建对象的目的,也就破坏了单例的“只有一个实例”。

public static void main(String[] args) throws Exception {
        Lazy lazy1 = Lazy.getInstance();
        Constructor<Lazy> declaredConstructor = Lazy.class.getDeclaredConstructor(null);   //构造器是空参
        declaredConstructor.setAccessible(true);
        Lazy lazy2 = declaredConstructor.newInstance();
        System.out.println(lazy1.hashCode() + " ::: " + lazy2.hashCode());
    }
/** output
705927765 ::: 366712642
*/ 

可以看出两个对象并不是同一个对象,而是不同的两个对象,所以单例模式被破坏了。所以在构造函数里我们应该加上对对象的判断,如果LAZY已经不为空,就要抛出异常。

more try

当然除此之外,就算在构造器中加入了判断,也可以利用反射对单例造成破坏。判断是根据类中声明的对象是否为空来作为依据的,如果我们不调用getInstance()方法,而是直接利用反射构造出两个对象,即可避过这种检查,使LAZY一直等于null。

public static void main(String[] args) throws Exception {
        Constructor<Lazy> declaredConstructor = Lazy.class.getDeclaredConstructor(null);   //构造器是空参
        declaredConstructor.setAccessible(true);
        Lazy lazy1 = declaredConstructor.newInstance();
        Lazy lazy2 = declaredConstructor.newInstance();
        System.out.println(lazy1.hashCode() + " ::: " + lazy2.hashCode());
    }
/**output
705927765 ::: 366712642
*/

出现了这种情况,我已经可以解决。加入一个变量,这个变量的名字可以是加密过后的,在构造器中继续加入判断

private static boolean flag = false;   //表示还未调用过构造器new对象

    private Lazy() {
        synchronized(Lazy.class) {
            if(flag == false) { //还未new过对象
                flag = true;
            } else {
                throw new RuntimeException("不要尝试使用反射破坏单例");
            }
        }
    }

当然,这种也不是绝对安全的,如果利用反编译技术,可以得到flag这个变量(虽说已经加过密,但有加密也就有解密),那么flag依旧可以被反射出来,看下面示例:

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

        //对flag变量的反射
        Field flag = Lazy.class.getDeclaredField("flag");
        flag.setAccessible(true);
		
    	//对构造器的反射
        Constructor<Lazy> declaredConstructor = Lazy.class.getDeclaredConstructor(null);   //构造器是空参
        declaredConstructor.setAccessible(true);
        Lazy lazy1 = declaredConstructor.newInstance();
        flag.set(lazy1, false);				//将标志变量又变回false
        Lazy lazy2 = declaredConstructor.newInstance();
        System.out.println(lazy1.hashCode() + " ::: " + lazy2.hashCode());
    }
/** output
366712642 ::: 1829164700
*/

所以反射本就是一个bug,需要见招拆招,而不是一味的墨守成规。

枚举

​ JDK1.5开始引入了枚举类型,它可以防止反射来破坏单例。

​ 这种实现方式还没有被广泛采用,但这是实现单例模式的最佳方法。它更简洁,自动支持序列化机制,绝对防止多次实例化。
​ 这种方式是 Effective Java 作者 Josh Bloch 提倡的方式,它不仅能避免多线程同步问题,而且还自动支持序列化机制,防止反序列化重新创建新的对象,绝对防止多次实例化。不过,由于 JDK1.5 之后才加入 enum 特性,用这种方式写不免让人感觉生疏,在实际工作中,也很少用。
​ 不能通过 reflection attack 来调用私有构造方法。

import java.lang.reflect.Constructor;

public enum EnumSingle {
    INSTANCE;

    public EnumSingle getInstance() {
        return INSTANCE;
    }
}

class Test {
    public static void main(String[] args) throws Exception {
        EnumSingle instance1 = EnumSingle.INSTANCE;
        Constructor<EnumSingle> declaredConstructor = EnumSingle.class.getDeclaredConstructor(null);
        declaredConstructor.setAccessible(true);
        EnumSingle instance2 = declaredConstructor.newInstance();
        System.out.println(instance1.hashCode());
        System.out.println(instance2.hashCode());
    }
}
/** output
Exception in thread "main" java.lang.NoSuchMethodException: EnumSingle.<init>()
	at java.lang.Class.getConstructor0(Class.java:3082)
	at java.lang.Class.getDeclaredConstructor(Class.java:2178)
	at Test.main(EnumSingle.java:14)
	*/

抛出的异常说明enum中根本没有一个空参的构造方法,通过将class反编译为java文件,发现我们的类继承了枚举类,而构造器并非空参构造器,而是有参构造器,一个String和一个int

//更改一下
class Test {
    public static void main(String[] args) throws Exception {
        EnumSingle instance1 = EnumSingle.INSTANCE;
        Constructor<EnumSingle> declaredConstructor = EnumSingle.class.getDeclaredConstructor(String.class,int.class);
        declaredConstructor.setAccessible(true);
        EnumSingle instance2 = declaredConstructor.newInstance();
        System.out.println(instance1.hashCode());
        System.out.println(instance2.hashCode());
    }
}
/** output
Exception in thread "main" java.lang.IllegalArgumentException: Cannot reflectively create enum objects
	at java.lang.reflect.Constructor.newInstance(Constructor.java:417)
	at Test.main(EnumSingle.java:16)
*/

这样说明反射确实无法破解枚举的单例

总结

实现单例模式有四种方式,饿汉式、懒汉式、静态内部类、枚举。

饿汉式:

  1. 线程安全
  2. 由于在类加载的时候初始化,浪费内存

懒汉式:

  1. 要想线程安全得加锁,但加锁就会影响效率,但getInstance方法由于调用机会不多,所以影响不是很大
  2. 第一次调用才初始化,避免内存的浪费。

静态内部类:

  1. 线程安全

枚举:

  1. 线程安全
  2. 绝对防止多次实例化
  3. 自动支持序列化
posted @ 2020-09-15 10:29  头发是我最后的倔强  阅读(452)  评论(0编辑  收藏  举报