转载

Effective Java学习笔记(三)单例模式

本文对应原书条目3,原书仅仅提到了如何实现单例模式,本文想在此基础上做一定的拓展,力求较为全面地介绍单例模式,探讨单例模式的应用场景、优缺点及多种实现方式,以及如何防范序列化和反射导致的安全性问题。如有问题或建议,欢迎指教,谢谢~

什么是单例模式

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

单例模式的适用场景

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

单例模式的实现方式

有三种实现单例的方式,公共静态不可变成员、静态工厂方法和枚举。前两种比较类似,都是通过私有构造方法+公共静态成员的方式提供单例。而第三种枚举的方式是在Java1.5以后引入的,事实上我们在后面会看到这是Java语言实现单例的最佳实践。

公共静态不可变成员

这种方式的具体实现如下:

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

    private Singleton() {}
}

这种方式实现起来比较简单,而且可以清楚地标明这是个单例类,但是缺点正如第一篇学习笔记中提到的,对它的访问不如静态工厂方法来得清晰,所以就有了下面使用静态工厂方法实现单例的方式。

静态工厂方法

我们先来看看最典型的静态工厂方法实现的单例。

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

    private Singleton() {}

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

静态工厂方法实现单例有几个好处 [1] 。首先,它具备 灵活性 ,在不改变对外发布的API的前提下,我们可以改变它内部的实现,比如从单例变成非单例,或是每个线程一个单例。其次,它的 可扩展性 强,可以使用泛型单例工厂的方式提供单例的访问(这个我们以后再讨论)。最后是它的 便利性 ,可以支持方法引用,像 Singleton::instance 这样。

使用静态工厂方法实现单例有很多种玩法 [2] ,上面那种被称为 饿汉式 ,它的 优点 是线程安全、便于使用; 缺点 是应用初始化时较慢,如果这个单例对象一直没有使用,会浪费内存空间。

下面是它的一个变种:

public class Singleton {
    private static final Singleton INSTANCE;

    static {
        // 一些前置操作
        INSTANCE = new Singleton();
    }

    private Singleton() {}

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

这种变种其实就是把单例对象的初始化过程放到了静态代码块中,优缺点同上。我理解主要适用于实例化单例类需要一些前置操作的情况。

除此之外,还有其他的写法——

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,这样一些共享数据就丢失了

小小的感慨:虽然Effective Java上面这个条目的内容非常少,但是自己去深挖以后发现居然有这么多值得研究的东西。个人感觉书上讲得还是太简单,好多地方都没有讲透,也没有相应的例子。还是得靠自己多搜索资料,多思考,才能吃透一块知识。

声明

本文仅用于学习交流,请勿用于商业用途。转载请注明出处,谢谢。

参考资料

  1. 《Effective Java(第3版)》
  2. 设计模式 | 单例模式及典型应用 https://www.jianshu.com/p/8f6...
  3. 单例模式 https://www.runoob.com/design...
原文  https://segmentfault.com/a/1190000022342212
正文到此结束
Loading...