Java笔记··By/蜜汁炒酸奶

漫谈单例模式(上)

1. 什么是

一个类只允许创建一个对象(或者实例),那这个类就是一个单例类,这种设计模式就叫作单例设计模式,简称单例模式(Singleton Design Pattern)。

2. 为何用

2.1 处理资源访问冲突

单例模式相对于之前类级别锁的好处是,不用创建那么多 Logger 对象,一方面节省内存空间,另一方面节省系统文件句柄(对于操作系统来说,文件句柄也是一种资源,不能随便浪费)。

2.2 表示全局唯一类

从业务概念上,如果有些数据在系统中只应保存一份,那就比较适合设计为单例类。

大部分情况下,我们在项目中使用单例,都是用它来表示一些全局唯一类,比如配置信息类、连接池类、ID 生成器类。

单例模式书写简洁、使用方便,在代码中,我们不需要创建对象,直接通过类似 IdGenerator.getInstance().getId() 这样的方法来调用就可以了。

3. 怎样实现

相关文章

Java设计模式学习笔记—单例模式(上)

Java设计模式学习笔记—单例模式(下)

并发学习笔记11-双重检查锁定与延迟初始化

3.1 饿汉模式

  • 饿汉式的实现方式,在类加载的期间,就已经将 instance 静态实例初始化好了,所以,instance 实例的创建是线程安全的。
  • 这样的实现方式不支持延迟加载实例

注:享有特权的客户端可以借助 AccessibleObject.setAccessible()方法,通过反射机制调用私有构造器。若需抵御这种攻击,可以修改构造器,让它在被要求创建第二个实例的时候抛出异常。

public class IdGenerator1 {
    private AtomicLong id = new AtomicLong(0);
    // 公有静态成员是final域(public-field,公有域的方法),此时无需getInstance()方法,直接调用 IdGenerator1.INSTANCE
    // public static final IdGenerator1 INSTANCE = new IdGenerator1();

    //共有静态成员静态工厂方法,需要调用getInstance()方法
    private static final IdGenerator1 instantce = new IdGenerator1();

    private IdGenerator1(){}


    public static IdGenerator1 getInstance() {
        return instantce;
    }

    public  long getId() {
        return id.incrementAndGet();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

3.1.1 公有域方法的优势

  • API清楚的表明这个类是一个Singleton:公有的静态域是final的,所以该域总是包含相同的对象引用。
  • 第二个优势是更简单

3.1.2 静态工厂方法的优势

  • 提供了灵活性:在不改变其API的前提下,可以改变该类是否为Singleton的想法。工厂方法返回该类的唯一实例,但很容易被修改,如改成每一个调用该方法的线程返回一个唯一的实例(可见《漫谈单例模式(下)》中的线程唯一的示例)。
  • 如果应用程序需要,可以编写一个泛型Singleton工厂(generic singleton factory)
  • 可以通过方法引用(method reference)作为提供者,比如 IdGenerator1::instance 就是一个 Supplier<IdGenerator1>

除非满足以上任何一种优势,否则应优先考虑公有域(public-field)的方法。

3.2 懒汉模式

懒汉式相对于饿汉式的优势是支持延迟加载

这种实现方式使用了synchronized 锁,导致函数的并发度很低,即会导致频繁加锁、释放锁,以及并发度低等问题,频繁的调用会产生性能瓶颈,此种情况下该方法便不可取。

public class IdGenerator2 {
    private AtomicLong id = new AtomicLong(0);
    private static IdGenerator2 instance;

    private IdGenerator2(){}


    public static synchronized IdGenerator2 getInstance() {
        if (instance == null) {
            instance = new IdGenerator2();
        }

        return instance;
    }

    public  long getId() {
        return id.incrementAndGet();
    }

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

3.3 双重检测

双重检测实现方式既支持延迟加载、又支持高并发的单例实现方式。只要 instance 被创建之后,再调用 getInstance() 函数都不会进入到加锁逻辑中。所以,这种实现方式解决了懒汉式并发度低的问题。

public class IdGenerator3 {

    private AtomicLong id = new AtomicLong(0);
    // private static  IdGenerator3 instance;
    private static volatile IdGenerator3 instance;

    private  IdGenerator3(){}
    public static IdGenerator3 getInstance(){
        if (null == instance) {
            synchronized (IdGenerator3.class) {
                if (null == instance) {
                    instance = new IdGenerator3();
                }
            }
        }
        return instance;
    }

    public long getId() {
        return id.incrementAndGet();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

3.3.1 volatile关键字的作用

指令重排序,可能会导致 IdGenerator 对象被 new 出来,并且赋值给 instance 之后,还没来得及初始化(执行构造函数中的代码逻辑),就被另一个线程使用了。

故给 instance 成员变量加上 volatile 关键字,禁止指令重排序,从而解决该问题。

然而设计模式之美中有这样一段话:

只有很低版本的 Java 才会有这个问题。我们现在用的高版本的 Java 已经在 JDK 内部实现中解决了这个问题(解决的方法很简单,只要把对象 new 操作和初始化操作设计为原子操作,就自然能禁止重排序)。

关于这段话在网上暂未找到相关依据,《深入理解JVM虚拟机(第三版)》中涉及双重检测的部分也没看到有关于此的相关改动,实力有限,暂且存疑。

3.5 静态内部类

利用 Java 的静态内部类来实现单例。这种实现方式

  • 支持延迟加载
  • 支持高并发
  • 实现起来也比双重检测简单
public class IdGenerator4 {

    private AtomicLong id = new AtomicLong(0);

    private IdGenerator4(){}

    private static class SingletonHolder {
        private static final  IdGenerator4 instance = new IdGenerator4();
    }

    public static IdGenerator4 getInstance(){

        return SingletonHolder.instance;
    }

    public long getId() {
        return id.incrementAndGet();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

3.6 枚举

最简单的实现方式,基于枚举类型的单例实现。

这种实现方式通过 Java 枚举类型本身的特性,保证了实例创建的线程安全性实例的唯一性

public enum IdGenerator5 {
    INSTANCE;
    private AtomicLong id = new AtomicLong(0);
    public long getId() {
        return id.incrementAndGet();
    }
}
1
2
3
4
5
6
7

这种方法在功能上和公有域方法相似,但比公有域方法更简洁

枚举本身是一个继承了java.lang.Enum的不可变类,而Enum本身实现了 Comparable<E>Serializable接口,故无偿提供了序列化机制,即使面对复杂的序列化或反射攻击的时候也能绝对防止多次实例化

单元素的枚举类型经常成为实现Singleton的最佳方法。但若Singleton必须扩展一个超类,而不是扩展Enum时,则不宜使用这种方法(虽然可以声明枚举去实现接口)。

其他方式实现单例序列化时需要同时满足以下两种方式(原因可见《漫谈单例模式(下)》中的集群中的唯一的部分):

  • 实现Serializable接口
  • 提供一个readResolve方法。

4. 单例存在的问题

4.1 单例对 OOP 特性的支持不友好

IdGenerator 的使用方式违背了基于接口而非实现的设计原则,也就违背了广义上理解的 OOP 的抽象特性

当某天需要不同业务采用不同的ID生成算法,为了该需求,需要修改所有用到IdGenerator 类的地方,这样代码的改动就会比较大。

单例对继承、多态特性的支持也不友好。因为从理论上来讲,单例类也可以被继承、也可以实现多态,只是实现起来会非常奇怪,会导致代码的可读性变差。不明白设计意图的人,看到这样的设计,会觉得莫名其妙。

一旦你选择将某个类设计成到单例类,也就意味着放弃了继承和多态这两个强有力的面向对象特性,也就相当于损失了可以应对未来需求变化的扩展性。

4.2 单例会隐藏类之间的依赖关系

通过构造函数、参数传递等方式声明的类之间的依赖关系,通过查看函数的定义,就能很容易识别出来。

单例类不需要显示创建、不需要依赖参数传递,在函数中直接调用就可以了。如果代码比较复杂,这种调用关系就会非常隐蔽。在阅读代码的时候, 需要仔细查看每个函数的代码实现,才能知道这个类到底依赖了哪些单例类。

4.3 单例对代码的扩展性不友好

单例类只能有一个对象实例。如果未来某一天, 需要在代码中创建两个实例或多个实例,那就要对代码有比较大的改动。

4.4 单例对代码的可测试性不友好

单例类这种硬编码式的使用方式,导致无法实现 mock 替换。

如果单例类持有成员变量(比如 IdGenerator 中的 id 成员变量),那它实际上相当于一种全局变量,被所有的代码共享。在编写单元测试的时候,还需要注意不同测试用例之间,修改了单例类中的同一个成员变量的值,从而导致测试结果互相影响的问题。

4.5 单例不支持有参数的构造函数

  • 第一种解决思路是:使用这个单例类的时候,要先调用 init() 函数传递参数,然后才能调用 getInstance() 方法。
  • 第二种解决思路是:将参数放到 getIntance() 方法中。
  • 第三种解决思路是:将参数放到另外一个全局变量中,单例里面的值既可以通过静态常量来定义,也可以从配置文件中加载得到。

参考资料

预览
Loading comments...
0 条评论

暂无数据

example
预览