设计模式之单例模式

什么是单例模式

单例模式是一个只会被实例化一次的类,它会自行实例化,并提供可全局访问的方法。

单例模式的适用场景

  • 一个系统中只需要存在一个的对象,例如文件管理器
  • 需要频繁适用但创建成本太高的对象,如数据库的连接

1. 懒汉式

public class Singleton {
    private static final Singleton INSTANCE;

    private Singleton() {}

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

懒汉式将初始化单例变量的时机放在了第一次调用的时候(懒加载),这样做的优点在于可以加快启动速度,且不会像饿汉式那样造成可能的内存空间浪费,但是缺点在于无法保证线程安全性。

2. 懒汉式变种

public class Singleton {
    private static final Singleton INSTANCE;

    private Singleton() {}

    public static synchronized Singleton getInstance() {
        if (INSTANCE == null) {
            INSTANCE = new Singleton();
        }
        return INSTANCE;
    }
}

这一变种形式的优点同上,并解决了上面的线程不安全问题,但是缺点在于对getInstance()方法进行了同步,并发性能较差。

3. 双重检查锁

public class Singleton {
    // 这里加了volatile关键字修饰
    private static volatile final Singleton INSTANCE;

    private Singleton() {}

    public static Singleton getInstance() {
        // 双重检查
        if (INSTANCE == null) {
            synchronized(Singleton.class) {
                if (INSTANCE == null) {
                    INSTANCE = new Singleton();
                }
            }
        }
        return INSTANCE;
    }
}

这种方式的优点是在保证线程安全的前提下提高了多线程访问的性能。因为采用了volatile关键字+代码块加锁+两次是否null检查,当一个线程初始化了INSTANCE后,其他线程马上可见了。它的缺点是实现起来比较复杂。

4.静态内部类

public class Singleton {

    private Singleton() {}

    private static class SingletonHolder() {
        private static final Singleton INSTANCE = new Singleton();
    }

    public static Singleton getInstance() {
        return SingletonHolder.INSTANCE;
    }
}

这种方式本质上也是懒加载的,拥有懒加载方式的优点。它采用类加载的机制实现懒加载和保证线程安全,只有第一次调用getInstance()方法的时候才会装载内部类SingletonHolder

5.枚举

    public enum Singleton {
        INSTANCE;
        public void yourOwnMethod() {}
    }

你或许会觉得枚举这种方式很奇怪,但是它事实上兼具了上述所有的优点,加载效率高,并发性能好,而且易于编写。并且在后面我们还可以看到,它的安全性也非常高,不需要我们采取额外的防范。

单例模式的安全问题

有一些手段能够破坏类的单例模式,比如通过序列化反射的方式。

序列化破坏单例

Java语言的序列化主要依靠ObjectOutputStreamObjectInputStream这两个类。前者负责将对象序列化为二进制数组,而后者负责反序列化。通过ObjectOutputStreamwriteObject()方法将单例对象写入外部文件,再通过ObjectInputStreamreadObject()方法从外部读取一个二进制数组进来写入单例中,这个时候单例就成了另外一个对象了。如下面的代码所示[2]:

public class Singleton {
    private static final Singleton INSTANCE = new Singleton();

    private Singleton() {}

    public static Singleton getInstance() {
        return INSTANCE;
    }

    public static void main(String[] args) throws IOException, ClassNotFoundException {
        Singleton singleton1 = Singleton.getInstance();
        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("singleton_bin_file"));
        oos.writeObject(singleton1); // 序列化
        ObjectInputStream ois = new ObjectInputStream(new FileInputStream("singleton_bin_file"));
        Singleton singleton2 = (Singleton) ois.readObject(); // 反序列化
        System.out.println(singleton1 == singleton2); // 会返回false
    }
}

为了防止被这种方式攻击,我们可以在单例类中加入readResolve()方法。如下所示:

public class Singleton {
    private static final Singleton INSTANCE = new Singleton();

    private Singleton() {}

    public static Singleton getInstance() {
        return INSTANCE;
    }

    private Object readResolve() {
        return INSTANCE;
    }

}

为什么这样可行呢?因为Java的序列化机制在允许类自己实现一个readResolve()方法,在ObjectInputStream执行了readObject()之后,如果存在readResolve()方法,则会调用,并对readObject()的结果进行处理,之后作为最终的结果返回。像我们上面那样在readResolve()中返回了原本的INSTANCE,这样就能保证不会因readObject()生成新的对象,从而确保了单例机制不被破坏[2]。

另外,如果单例中有成员变量,应当声明为transient类型[1],这样,在序列化的时候会跳过这个字段,而反序列化时会获得一个默认值或者null。我理解这样做的目的是保护单例的成员变量,不让它们泄露出去,也不会被乱赋值。没有值总比被赋了错值要好。

反射破坏单例

反射对单例的破坏主要是通过调用成员变量或者构造方法的setAccessible()方法,来访问原本private的变量或者方法,从而破坏了单例模式。

对反射攻击的防御可以通过在构造方法中增加校验的方式实现,如下所示:

public class Singleton {
    private static final Singleton INSTANCE = new Singleton();

    private Singleton() {
        if (INSTANCE != null) {
            throw new RuntimeException("INSTANCE already exists!");
        }
    }

    public static Singleton getInstance() {
        return INSTANCE;
    }
}

这种方式只对饿汉式单例实现有效,而对懒汉式无效。因为前者的单例在类加载时即被初始化了,类加载的时机一定是在反射前的;而后者则是在getInstance()被调用时才初始化单例,不能保证在反射之前执行。[2]

最好的单例模式实现

不论是通过公共静态不可变成员还是静态工厂方法来实现单例,都有缺陷,需要程序员自己去保证性能和安全。然而,正如前面所看到的,还有一种更好的方式来实现单例,那就是枚举

    public enum Singleton {

        INSTANCE;

        private String yourOwnField;

        public String getYourOwnField() {
            return yourOwnField;
        }

        public void setYourOwnField(String yourOwnField) {
            this.yourOwnField = yourOwnField;
        }

        public void yourOwnMethod() {}
    }

枚举有如下几个优点[2]:

  • 写法简单
  • 线程安全:编译成class文件后的枚举类中,INSTANCE变量会被public static final修饰,而静态变量会在类加载时被初始化,因此JVM会保证其线程安全性。
  • 懒加载:JVM会在类被引用到的时候才去加载它,所以枚举自带懒加载效果
  • 避免序列化攻击:在序列化枚举类型时,Java仅会序列化枚举对象的name,然后在反序列化时根据这个name得到具体的枚举对象,所以是可以天然防御序列化攻击的。
  • 避免反射攻击:反射不允许创建枚举对象

序列化、反射和枚举这几部分参考资料[2]中讲得很透彻,建议大家阅读下~

总结

单例模式提供了对某一对象的受控访问,适用于很多场景。用枚举来实现单例是最好的方式。下面是单例模式的优缺点[2][3]:

优点

  • 节省频繁创建和销毁对象的性能开销
  • 实现对某些临界资源的单一受控访问

缺点

  • 单例机制无法被继承
  • 违背了单一职责原则,单例类既要维护单例逻辑,又要实现其他内部逻辑
  • 当一个单例对象长期未被访问,可能会被GC,这样一些共享数据就丢失了

转载自 https://segmentfault.com/a/1190000022342212

posted @ 2021-12-22 20:27  追梦少年阿飞  阅读(53)  评论(0)    收藏  举报