5 『单例模式』 - 图1
image.png

1. 「引例」

image.png

  • 在同一时期只能有一个皇帝

    1. public class Emperor {
    2. private static final Emperor emperor = new Emperor(); // 初始化一个皇帝
    3. private Emperor() {
    4. // private构造方法,不会产生第二个皇帝
    5. }
    6. public static Emperor getInstance() {
    7. return emperor;
    8. }
    9. public static void say() {
    10. System.out.println("我就是皇帝xxx");
    11. }
    12. }
    13. // Test
    14. class Minister {
    15. @Test
    16. public void test() {
    17. // 三天都是同一个皇帝
    18. for (int day = 0; day < 3; day++) {
    19. Emperor emperor = Emperor.getInstance();
    20. emperor.say();
    21. }
    22. }
    23. }

    我就是皇帝xxx 我就是皇帝xxx 我就是皇帝xxx

2. 「定义」

Ensure a class has only one instance, and provide a global point of access to it.

  • 确保某一个类 只有一个实例,而且自行实例化并向整个系统提供这个实例。

通用类图
image.png

  • Singleton类为单例类,通过使用private的构造函数确保了在一个应用中只产生一个实 例,并且是自行实例化的(在Singleton中自己使用new Singleton())

通用代码

  1. public class Singleton {
  2. private static final Singleton singleton = new Singleton();
  3. // 限制产生多个对象
  4. private Singleton() {
  5. }
  6. // 通过该方法获得实例对象
  7. public static Singleton getSingleton() {
  8. return singleton;
  9. }
  10. //类中其他方法,尽量是「static」
  11. public static void doSomething() {
  12. }
  13. }

3. 「应用」

1. 「优点」

  • 减少内存开支,特别是一个对象需要频繁地 创建、销毁时,而且创建或销毁时性能又无法优化,单例模式的优势就非常明显。
  • 减少了系统的性能开销,当一个对象的产生需要 比较多的资源时,如读取配置、产生其他依赖对象时,则可以通过在应用启动时直接产生一个单例对象,然后用永久驻留内存的方式来解决(在Java EE中采用单例模式时需要注意JVM垃圾回收机制)。
  • 避免对资源多重占用,例如一个写文件动作,由于只有一个实例存在 内存中,避免对同一个资源文件的同时写操作。
  • 在系统设置全局的访问点,优化和共享资源访问,例如可以设计一个单 例类,负责所有数据表的映射处理。

2. 「缺点」

  • 单例模式一般没有接口,扩展很困难,若要扩展,除了修改代码基本上没有第二种途 径可以实现。单例模式为什么不能增加接口呢?因为接口对单例模式是没有意义的,它要求「自行实例化」,并且提供单一实例、接口或抽象类是不可能被实例化的。当然,在特殊情况下,单例模式可以实现接口、被继承等,需要在系统开发中根据环境判断。
  • 单例模式对测试是不利的。在并行开发环境中,如果单例模式没有完成,是不能进行 测试的,没有接口也不能使用mock的方式虚拟一个对象。
  • 「单例模式」与「单一职责原则」有冲突。一个类应该只实现一个逻辑,而不关心它是否是单例的,是不是要单例取决于环境,单例模式把“要单例”和业务逻辑融合在一个类中。

3. 「使用场景」

在一个系统中,要求一个类有且仅有一个对象,如果出现多个对象就会出现「不良反 应」

  • 要求生成唯一序列号的环境;
  • 整个项目中需要一个共享访问点共享数据,例如一个Web页面上的计数器,可以 不用把每次刷新都记录到数据库中,使用单例模式保持计数器的值,并确保是线程安全的;
  • 创建一个对象需要消耗的资源过多,如要访问IO和数据库等资源;
  • 需要定义大量的静态常量和静态方法(如工具类)的环境,可以采用单例模式(当 然,也可以直接声明为static的方式)。

例如:

  • 数据库的连接池不不会反复创建
  • spring中一个单例模式bean的生成和使用
  • 在我们平常的代码中需要设置全局的的⼀些属性保存

4. 「注意事项」

📌首先,在高并发情况下,注意单例模式的线程同步问题。单例模式有几种不同的实现 方式,上面的例子不会出现产生多个实例的情况,但是如下所示的单例模式就需要考虑线程同步。

  1. public class Singleton {
  2. private static Singleton singleton = null;
  3. //限制产生多个对象
  4. private Singleton() {
  5. }
  6. // 通过该方法获得实例对象
  7. public static Singleton getSingleton() {
  8. if (singleton == null) {
  9. singleton = new Singleton();
  10. }
  11. return singleton;
  12. }
  13. }
  • 该单例模式在低并发的情况下尚不会出现问题,若系统压力增大,并发量增加时则可能 在内存中出现多个实例,破坏了最初的预期。
  • 为什么会出现这种情况?如一个线程A执行到singleton = new Singleton(),但还没有获得对象(对象初始化是需要时间的),第二个线程B也在执行,执行到(singleton == null)判断,那么线程B获得判断条件也是为真,于是继续运行下去,线程A获得了一个对象,线程B也获得了一个对象,在内存中就出现两个对象!
  • 解决线程不安全的方法很有多,可以在getSingleton方法前加「synchronized」关键字,也可以 在getSingleton方法内增加synchronized来实现,但都不是最优秀的单例模式。
  • 建议读者使用如 代码清单7-3所示的方式(有的书上把上方定义中的「通用代码」单例称为「饿汉式单例」,在上方「注意事项」增加了synchronized的单例称为「懒汉式单例」)。

📌其次,需要考虑对象的复制情况。在Java中,对象默认是不可以被复制的,若实现了 Cloneable接口,并实现了clone方法,则可以直接通过对象复制方式创建一个新对象,对象复制是不用调用类的构造函数「原型模式注意事项1,因此即使是私有的构造函数,对象仍然可以被复制。
在一般情况下,类复制的情况不需要考虑,很少会出现一个单例类会主动要求被复制的情况,解决该问题的最好方法就是单例类不要实现Cloneable接口


4. 「拓展」

  • 如果一个类可以产生多个对象,对象的数量不受限制,则是非常容易实现的,直接使用 new关键字就可以。
  • 如果只需要一个对象,使用单例模式就可以。
  • 但是如果要求一个类只能产生两三个对象呢?

image.png

  1. public class Emperor {
  2. // 定义最多能产生的实例数量
  3. private static final int maxNumOfEmperor = 2;
  4. // 每个皇帝都有名字,使用一个ArrayList来容纳,每个对象的私有属性
  5. private static ArrayList<String> nameList = new ArrayList<String>();
  6. // 定义一个列表,容纳所有的皇帝实例
  7. private static ArrayList<Emperor> emperorList = new ArrayList<Emperor>();
  8. // 当前皇帝序列号
  9. private static int countNumOfEmperor = 0;
  10. // 产生所有的对象
  11. static {
  12. for (int i = 0; i < maxNumOfEmperor; i++) {
  13. emperorList.add(new Emperor("皇" + (i + 1) + "帝"));
  14. }
  15. }
  16. private Emperor() { // 目的就是不产生第二个皇帝
  17. }
  18. // 传入皇帝名称,建立一个皇帝对象
  19. private Emperor(String name) {
  20. nameList.add(name);
  21. }
  22. // 随机获得一个皇帝对象
  23. public static Emperor getInstance() {
  24. Random random = new Random();
  25. // 随机拉出一个皇帝,只要是个精神领袖就成
  26. countNumOfEmperor = random.nextInt(maxNumOfEmperor);
  27. return emperorList.get(countNumOfEmperor);
  28. }
  29. // 皇帝发话
  30. public static void say() {
  31. System.out.println(nameList.get(countNumOfEmperor));
  32. }
  33. }
  34. // Test
  35. class Minister {
  36. @Test
  37. public void test() {
  38. //定义5个大臣
  39. int ministerNum = 5;
  40. for (int i = 0; i < ministerNum; i++) {
  41. Emperor emperor = Emperor.getInstance();
  42. System.out.print("第" + (i + 1) + "个大臣参拜的是:");
  43. emperor.say();
  44. }
  45. }
  46. }

第1个大臣参拜的是:皇1帝 第2个大臣参拜的是:皇1帝 第3个大臣参拜的是:皇2帝 第4个大臣参拜的是:皇2帝 第5个大臣参拜的是:皇2帝

  • 这种需要产生固定数量对象的模式就叫做「有上限的多例模式」,它是单例模式的一种扩 展,采用有上限的多例模式,可以在设计时决定在内存中有多少个实例,方便系统进行扩展,修正单例可能存在的性能问题,提供系统的响应速度。
  • 例如读取文件,在系统启动时完成初始化工作,在内存中启动固定数量的reader实例,然后在需要读取文件时就 可以快速响应。

5. 「七种实现」

  • 单例模式的实现方式比较多,主要在实现上是否支持懒汉模式、是否线程安全中运用各项技巧。当然也有一些场景不需要考虑懒加载也就是懒汉模式的情况,会直接使用static静态类或属性和方法的方式进行处理,供外部调用。

    0. 「静态类使用」

    1. public class Singleton_00 {
    2. public static Map<String, String> cache = new ConcurrentHashMap<String, String>();
    3. }
  • 这种方式在平常的业务开发中非常常见,这样静态类的方式可以在第一次运行的时候直接初始化Map类,同时这里也不需要到延迟加载再使用。

  • 在不需要维持任何状态下,仅仅用于全局访问,这个使用使用静态类的方式更加方便。
  • 但如果需要被继承以及需要维持一些特定状态的情况下,就适合使用单例模式。

1. ❌「懒汉模式(线程不安全)」

  1. public class Singleton_01 {
  2. private static Singleton_01 instance;
  3. private Singleton_01() {
  4. }
  5. public static Singleton_01 getInstance() {
  6. if (null != instance) return instance;
  7. instance = new Singleton_01();
  8. return instance;
  9. }
  10. }
  • 单例模式有一个特点就是不允许外部直接创建,也就是new Singleton_01(),因此这里在默认的构造函数上添加了私有属性private 。
  • 目前此种方式的单例确实满足了懒加载,但是如果有多个访问者同时获取对象实例,就会造成多个同样的实例并存,从而没有达到单例的要求。

2. 「懒汉模式(线程安全)」

  1. public class Singleton_02 {
  2. private static Singleton_02 instance;
  3. private Singleton_02() {
  4. }
  5. public static synchronized Singleton_02 getInstance() {
  6. if (null != instance) return instance;
  7. instance = new Singleton_02();
  8. return instance;
  9. }
  10. }
  • 在获得实例的方法上加锁,synchronized.
  • 此种模式虽然是安全的,但由于把锁加到方法上后,所有的访问都因需要锁占用导致资源的浪费。如果不是特殊情况下,不建议此种方式实现单例模式。

3. 「饿汉模式(线程安全)」

  1. public class Singleton_03 {
  2. private static Singleton_03 instance = new Singleton_03();
  3. private Singleton_03() {
  4. }
  5. public static Singleton_03 getInstance() {
  6. return instance;
  7. }
  8. }
  • 此种方式与开头的第一个实例化Map基本一致,在程序启动的时候直接运行加载,后续有外部需要使用的时候获取即可。
  • 但此种方式并不是懒加载,也就是说无论你程序中是否用到这样的类都会在程序启动之初进行创建。 会占用较多的内存。

4. 👍「使用类的内部类(线程安全)」

  1. public class Singleton_04 {
  2. private static class SingletonHolder {
  3. private static Singleton_04 instance = new Singleton_04();
  4. }
  5. private Singleton_04() {
  6. }
  7. public static Singleton_04 getInstance() {
  8. return SingletonHolder.instance;
  9. }
  10. }
  • 使用类的静态内部类实现的单例模式,既保证了「线程安全」又保证了「懒加载」,同时不会因为加锁的方式耗费性能。
  • 主要是因为JVM虚拟机可以保证多线程并发访问的正确性,也就是「一个类的构造方法在多线程环境下可以被正确地加载」。
  • 此种方式也是非常推荐使用的一种单例模式。

5. 「双重锁校验(线程安全)」

  1. public class Singleton_05 {
  2. private static Singleton_05 instance;
  3. private Singleton_05() {
  4. }
  5. public static Singleton_05 getInstance() {
  6. if (null != instance) return instance;
  7. synchronized (Singleton_05.class) {
  8. if (null == instance) {
  9. instance = new Singleton_05();
  10. }
  11. }
  12. return instance;
  13. }
  14. }
  • 双重锁的方式是方法级锁的优化,减少了部分获取实例的耗时。
  • 同时这种方式也满足了懒加载

    双重锁校验真的线程安全吗?

  • 「问题发现」

乍一看上述代码没啥问题,但是注意到 instance = new Singleton_05(); ,这一操作并不具备 原子性
在JVM中,完成这一个操作需要三个操作,大致描述如下:

//1:分配对象的内存空间 memory = allocate(); //2:初始化对象 ctorInstance(memory); //3:设置instance指向刚分配的内存地址 instance = memory;

  • 先在堆中开辟一块空间
  • 然后调用构造方法初始化对象
  • 再把变量指向这块空间

在多线程环境下会出现「重排列指令」的问题。

假设有A、B两个线程去调用该单例方法,当A线程执行到instance = new Singleton_05();时,如果编译器和处理器对指令重新排序,指令重排后:

//1:分配对象的内存空间 memory = allocate(); //3:设置instance指向刚分配的内存地址 instance = memory; //2:初始化对象 ctorInstance(memory);

2、3位置调换。1开辟完空间后,3直接把空间指向instance,而没把初始化对象给空间,即指向的空间此时不是要的对象,现在线程B执行到第一个条件判空,会执行进去,直接返回instance实例,接下来就会出现各种错误,因为返回的对象不是逻辑产生的对象。

  • 📌「解决」

一句话,用 **volatile** 修饰私有变量。
其特性如下:

  • 可见性
    • 写volatile修饰的变量时,JMM会把本地内存中值刷新到主内存
    • 读volatile修饰的变量时,JMM会设置本地内存无效
  • 有序性
    • 要避免指令重排序,synchronized、lock作用的代码块自然是有序执行的,volatile关键字有效的禁止了指令重排序,实现了程序执行的有序性。

加上这个修饰,就可以避免「指令重排列」的问题。

📌修改:private static volatile Singleton_05 instance;

6. CAS「AtomicReference」(线程安全)

  1. public class Singleton_06 {
  2. private static final AtomicReference<Singleton_06> INSTANCE = new
  3. AtomicReference<Singleton_06>();
  4. private static Singleton_06 instance;
  5. private Singleton_06() {
  6. }
  7. public static final Singleton_06 getInstance() {
  8. for (; ; ) {
  9. Singleton_06 instance = INSTANCE.get();
  10. if (null != instance) return instance;
  11. INSTANCE.compareAndSet(null, new Singleton_06());
  12. return INSTANCE.get();
  13. }
  14. }
  15. public static void main(String[] args) {
  16. System.out.println(Singleton_06.getInstance());
  17. // com.hypocrite30.patterns.Singleton.demo5.Singleton_06@4554617c
  18. System.out.println(Singleton_06.getInstance());
  19. // com.hypocrite30.patterns.Singleton.demo5.Singleton_06@4554617c
  20. }
  21. }
  • java并发库提供了很多原子类来支持并发访问的数据安全 性; AtomicIntegerAtomicBooleanAtomicLongAtomicReference
  • AtomicReference可以封装引用一个V实例,支持并发访问如上的单例方式就是使用了这样的一个特点。
  • 使用CAS的好处就是不需要使用传统的加锁方式保证线程安全,而是依赖于CAS的忙等算法,依赖于底层硬件的实现,来保证线程安全。相对于其他锁的实现没有线程的切换和阻塞也就没有了额外的开销,并且可以支持较大的并发性。
  • 当然CAS也有一个缺点就是忙等,如果一直没有获取到将会处于死循环中。

7. 「Effective Java」作者推荐的枚举单例例(线程安全)

  1. public enum Singleton_07 {
  2. INSTANCE;
  3. public void test() {
  4. System.out.println("hi~");
  5. }
  6. }
  7. // Test
  8. class Singleton_07Test {
  9. @Test
  10. public void test() {
  11. Singleton_07.INSTANCE.test(); // hi~
  12. }
  13. }
  • 「Effective Java」第三条「用私有构造器或者枚举类型强化Singleton属性」说明了单例模式下枚举的应用及原因。
  • 这种⽅方式解决了最主要的:线程安全自由串行化单一实例、防止反序列化和反射的破坏。
  • 这种写法在功能上与共有域方法相近,但是它更简洁,无偿地提供了串行化机制,绝对防止对此实例化,即使是在面对复杂的串行化或者反射攻击的时候。虽然这种方法还没有广泛采用,但是单元素的枚举类型已经成为实现Singleton的最佳方法
  • 但也要知道此种方式在存在继承场景下是不可用的。

6. 「最佳实践」

单例模式是23个模式中比较简单的模式,应用也非常广泛,如在Spring中,每个Bean默 认就是单例的,这样做的优点是Spring容器可以管理这些Bean的生命期,决定什么时候创建出来,什么时候销毁,销毁的时候要如何处理,等等。如果采用非单例模式(Prototype类型),则Bean初始化后的管理交由J2EE容器,Spring容器不再跟踪管理Bean的生命周期。

推荐博客: