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语言的序列化主要依靠 ObjectOutputStream
ObjectInputStream
这两个类。前者负责将对象序列化为二进制数组,而后者负责反序列化。通过 ObjectOutputStream
writeObject()
方法将单例对象写入外部文件,再通过 ObjectInputStream
readObject()
方法从外部读取一个二进制数组进来写入单例中,这个时候单例就成了另外一个对象了。如下面的代码所示 [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

本站部分文章源于互联网,本着传播知识、有益学习和研究的目的进行的转载,为网友免费提供。如有著作权人或出版方提出异议,本站将立即删除。如果您对文章转载有任何疑问请告之我们,以便我们及时纠正。

PS:推荐一个微信公众号: askHarries 或者qq群:474807195,里面会分享一些资深架构师录制的视频录像:有Spring,MyBatis,Netty源码分析,高并发、高性能、分布式、微服务架构的原理,JVM性能优化这些成为架构师必备的知识体系。还能领取免费的学习资源,目前受益良多

转载请注明原文出处:Harries Blog™ » Effective Java学习笔记(三)单例模式

赞 (0)
分享到:更多 ()

评论 0

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址