欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

让设计模式飞一会儿|②单例模式

程序员文章站 2022-04-01 15:40:59
...

今天要和大家分享的是GOF23模式中第一个模式——单例模式。这个模式号称是GOF23所有设计模式中最简单的一个设计模式。不过,等你看完这篇文章后会发现,这句话听听就好,别当真。????单例模式简单吗?这是不存在的,要想吃透里面的细节并不容易,尤其是初学者。但是单例模式在实际生活和开发中,却是大量的被使用到,因此,这个模式我们是需要深入学习掌握的。下面不废话直入主题。

为什么要使用单例模式

单例模式属于上篇说过的设计模式三大分类中的第一类——创建型模式,自然是跟对象的创建相关,而且听这名字——单例,也就是说,这个模式在创建对象的同时,还致力于控制创建对象的数量,是的,只能创建一个实例,多的不要。或者从这一方面讲,它确实是最简单的模式。每个Java程序员都知道,Java中的对象都是使用new关键字来加载类并在堆内存中开辟空间创建对象,这是平时用到最多创建对象的方式。也知道每次new都会产生一个全新的对象。一直这样用着,好像从来没觉得有啥不好,更没有怎么思考过,这玩意居然还要去控制它的数量。

????那么问题来了,到底我们为什么要控制对象创建的个数?直接new一下多省事啊❓

既然这个模式存在并且大量使用,说明有些场景下,没它还真不行。那么什么场景下会没它不行呢?我举个栗子????,比如我们平时使用的Windows上的回收站,是不是只有一个?要是有多个,会发生什么?我刚把回收站清空了,换到另一个回收站看垃圾还在,那这垃圾到底是在,还是不在?是不是很诡异了?另外比如博客上会有一个博客访问人数统计,这个东西要是不是单例的会有啥问题?今天统计了流量有100个,第二天用了一个新的计数器,又回到0了重新开始统计,那这个统计还有意义吗?

也就是说,有些场景下,不使用单例模式,会导致系统同一时刻出现多个状态缺乏同步,用户自然无法判断当前处于什么状态

在技术领域,单例模式的场景更是不计其数。
比如XXX池的基本都是单例,为什么呢?对象的创建是一个比较耗时耗费资源的过程,尤其是像线程、数据库连接等,都属于使用非常频繁,但是创建销毁又非常耗时的资源,如果不使用池来控制其数量和创建,会对性能造成极大的影响。另外,像线程池中的线程,可能会需要相互通信,如果不是在同一个池中,对通信也会有影响。

通过控制创建对象的数量,可以节约系统资源开销

另外像应用中的日志系统,一般也会采用单例模式。这样所有的日志都会统一追加,方便后续管理。
读取配置的类一般会使用一个单例去统一加载读取。因为一般配置只会在应用启动时加载一次,而且会需要给整个应用所有对象共享。

全局数据共享

还有在各大主流开源框架以及JDK源码当中,也是大量使用到这种模式。后续我也会抛砖引玉给大家举两个例子。
正是存在上这些痛点,使得有时候我们创建对象还真不能再简单任性直接new一下,需要对其做一些精细控制。那怎么才能控制只创建一个对象呢?
经过无数前人总结,一般有以下这些经典的解决方案,

分类

饿汉式

啥叫饿汉式?饿了就立刻想到吃,类比到创建对象也是如此,当类一初始化,该类的对象就立刻会被实例化。
????怎么实现?
代码如下:

public class HungrySingleton {
  private HungrySingleton() {}private static HungrySingleton instance = new HungrySingleton();public static HungrySingleton getInstance() {
    return instance;
  }
}

当外部调用HungrySingleton.getInstance()时,就会返回唯一的instance实例。为什么是唯一的?
这代码中有几个要点
标注❶处该类的构造器用private修饰,防止外部手动通过new创建。后面的例子都需要这样,后面就不解释了。
标注❷处是核心,instance使用static修饰,然后调用new创建对象,我们知道static修饰的东西都属于类,而且在类加载阶段就已经被加载,并且只能被加载一次。就是类加载这种特性很好的保证了单例的特性,也天然防止了并发的问题。
卧槽,单例模式居然如此简单,这么轻松就完成一个,这就算完事了?呵呵…
这个代码确实实现了单例的效果,只要调用HungrySingleton.getInstance(),你就算是神仙也造不出第二个对象…(其实后面会知道,还是有办法的)

????但是想想,这个方法有啥问题没?
没错,一旦类初始化时就会创建这个对象,有人说,创建就创建呗,这有啥大不了的?大部分情况下确实是没啥问题,但是如果创建这个对象极其耗费时间和资源呢?这样必然会造成巨大的性能损耗。
另外还有一种情况,有的时候我只是想单纯的加载一下类,但并不想去用该对象,那这个时候这种模式就属于浪费内存了。什么意思?我举个栗子????,如下代码,其他代码和上面一样,就是加了❶行,然后我现在外部调用HungrySingleton.flag,会发生什么?

public class HungrySingleton {
  private HungrySingleton() {}
	
  private static int flag = 1;private static HungrySingleton instance = new HungrySingleton();

  public static HungrySingleton getInstance() {
    return instance;
  }
}

学过Java类加载机制的都知道,当去访问一个类的静态属性的时候会触发该类初始化,这就导致,我明明只是想使用一下flag属性,并不想用HungrySingleton对象,但由于你访问了flag导致HungrySingleton的初始化,从而导致instance被实例化,造成内存泄露。
看来这种方案可行,但不是完美的,那有啥更好方案,既能保证只创建单个对象,又可以做到真正需要使用该对象时再创建它(延迟加载),从来达到节约系统资源目的?答案当然是有的!

懒汉式

和饿汉相反,懒汉自然是很懒,能不吃就不吃饭,等到实在饿得不行了(需要用该对象了)才去吃饭(创建对象)。
????怎么实现?
代码如下:

public class LazySingleton {
	private static LazySingleton instance = null;private LazySingleton() {	
	
	}	
	public static LazySingleton getInstance() {if(instance == null){ ❸
	      instance = new LazySingleton();
	    }
    	return instance;
  }
}

关注❶处,类加载时不会立刻创建对象了,然后当LazySingleton.getInstance()调用❷处方法时,通过判断instance == null,如果有了就不创建了,没有就才会创建。
哈哈,既实现了延迟加载,节约资源,又保证了单例,貌似没毛病。飘了~????
没错,在单线程下面确实如此,可惜忽略了多线程场景。为什么在多线程下会有问题?分析一下,现在有两个线程A和B,同时到达❸处,自然此时instance = new LazySingleton()这一行没被调用过,自然❸处条件成立,然后A和B同时进入了if{}代码块,后面的事情就知道了,A和B线程都会调用instance = new LazySingleton(),从而创建多个对象,破坏了单例。
那怎么办?有并发问题?那就加锁同步呗…

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

这代码中有几个要点

  • 标注❶处其它和上面一样,多了一个volatile修饰,这主要是为了保证多线程下内存可见性。因为高速缓存关系,一个线程的修改并不一定要实时同步到另一线程,volatile可以用来解决这个问题。
  • 标注❷处加synchronized同步锁,可以保证同一时刻只会有一个线程进入getInstance()方法,自然只会有一个线程调用instance = new LazySingleton(),单例自然就保证了。但同时这个带来了一个新问题,因为每个线程不管instance有没有被创建过,都会去调用getInstance(),因为if(instance == null)是需要进入方法后才判断的,然而getInstance()又是一个同步的,同一时刻只会有一个线程进入,其余线程都必须等待,这就会导致线程阻塞,导致性能下降。
    上述方法确实实现了延迟创建对象,但是性能低下的问题如何解决?聪明的攻城狮们又想到了新的方案…

双重检测锁

????怎么实现?
代码如下:

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

这个代码看上去会比较复杂,讲几个关注点:

  • 标注❶处方法内部和上面例子代码最大的区别在于,有❸❹两处if判断。为什么要两次判断?

    • **第一次if判断是为了提高效率。**怎么理解?回顾懒汉式方案代码,synchronized将整个方法加同步锁,也就是说,不管外部(线程)在调用getInstance()方法这一刻该对象是否已经被创建好,都需要阻塞等待。而❸处的if判断就使得,只有此时真的还没有创建出对象才会进入synchronized代码块,如果已经创建了就直接return了,所以显然提高性能了。
    • **那么❹处的第二次if判断又是为什么?**这才是用来保证多线程安全的。又怎么理解?设想下面这种场景。A和B两个线程同时来到❺处,此时因为synchronized缘故,只能有一个线程进入,假设A拿到了这把锁,进入synchronized代码块,然后通过❻创建出了一个LazySingleton实例,然后离开synchronized代码块,然后把锁释放了,但是还没等到它return的时候,B线程拿到了这把锁,进入synchronized代码块,此时要是没有❹处if判断,B线程照样可以来到❻处,以迅雷不及掩耳之势噼里啪啦一顿操作,又创建出一个LazySingleton实例。显然此时,单例模式已经被破坏了。所以❹处的判断也不可省略。
  • 标注❷处看上去和懒汉式的代码没区别,但是这边volatile语义已经发生改变,已经不单纯是为了内存可见的问题了,还涉及到指令重排序的问题。怎么理解?一切问题出在❻处。震惊!❻处看似平常的一行代码居然会有问题。是的,下面我来详解。❻处会创建一个LazySingleton实例,并且赋值给instance变量,很遗憾,这一个动作在指令层面并非原子操作。这个动作可以分为4步,

      1.申请内存空间
      2.初始化默认值
      3.执行构造器初始化
      4.将instance指向创建的对象
    

    而有些编译器会对代码做指令重排序,因为3和4本身相互并不存在依赖,指令重排序的存在可能会导致3和4顺序发生颠倒。这会有什么问题?首先在单线程下并不会有什么问题,为什么?因为指令重排序的前提就是不改变在单线程下的结果,无论先执行3还是4,最后返回的对象都是初始化好后的。但是在多线程下呢?设想一种极端场景,现在假设A线程拿到锁进入到❻处,然后它完成了上面4步的1和2,因为现在指令重排序了,下面A线程会将instance指向创建的对象,也就是说,此时instance != null了!然后正当A要去执行构造器初始化对象时,巧得很,这时候B线程来到❸处,判断instance == null不成立了,直接返回,独留A线程在原地骂娘“尼玛,我™还没初始化对象呢…”,因为返回了一个没有经过初始化的对象,后续操作自然会有问题。正是因为这个原因,所以❷处volatile不可省略,主要原因就在于防止指令重排序,避免上述问题。

那是不是这样就万无一失了呢?很遗憾,上述如此严密的控制,还是不能完全保证出问题。What?那就是上述的做法有个前提,JDK必须是JDK5或更高版本,因为从JDK5才开始使用新的JSR-133内存模型规范,而在这个规范中才增强了volatile这个语义…
卧槽,原来搞了大半天还是有问题啊…心好累,而且说实话,就算不考虑JDK版本这个问题,这种方案的实现代码太过丑陋,本身看着就不是很爽,而且考虑的东西太多,稍有闪失就GG了。所以,这种方案虽然分析了这么多,但是其实没有实际意义,实际工作中强烈不建议使用。那还有没有好的方案啊?当然有啊!

静态内部类实现

根据类加载机制,外部类的初始化并不会导致静态内部类的初始化。
????怎么验证?
如下代码:

public class Demo {
    private static int a = 1;
    private static class Inner{
        static {
            System.out.println("Inner loading ...");
        }
    }
    public static void main(String[] args) {
        System.out.println(Demo.a);
    }
}

通过Demo.a引用外部类Demo的静态变量a,会导致外部类的初始化,如果Inner被初始化了,必然会执行static块,从而打印"Inner loading ...",然而很遗憾,这个代码执行结果只会打印出“1”。这也印证了开始的结论。有了这个结论,我们就可以利用它实现优雅的单例了。哈哈~????
????怎么实现?
代码如下:

public class StaticInnerSingleton {
    private StaticInnerSingleton() {
    }
    private static class StaticInnerSingletonInstance {private static final StaticInnerSingleton instance = new StaticInnerSingleton();
    }
    public static StaticInnerSingleton getInstance() {return StaticInnerSingletonInstance.instance;
    }
}

讲几个关注点:

  • ❶处StaticInnerSingletonInstance是一个静态内部类,内部静态字段instance负责创建对象。因为上面的结论,所以当然外部类StaticInnerSingleton初始化时,并不会导致StaticInnerSingletonInstance初始化,进而导致instance的初始化。所以实现了延迟加载。
  • 当外部调用❷处getInstance()时,通过StaticInnerSingletonInstance.instanceinstance引用才会导致对象的创建。由于static的属性只会跟随类加载初始化一次,天然保证了线程安全问题。
    这个方案算是完美解决了上述所有方案的问题,且保留了所有的优点。算是一个完美方案。
    还有没有其它方案?必须的!

枚举实现

用枚举实现单例是最简单的了,因为,Java中的枚举类型本身就天然单例的,
????怎么实现?
代码如下:

enum EnumSingletonInstance{
   INSTANCE;
   public static EnumSingletonInstance getInstance(){
   		return INSTANCE;
   }
}

唯一遗憾的是,这个方案和饿汉式一样,没法延迟加载。枚举类加载自然就会初始化INSTANCE
常用的单例模式的方案基本就是这些。那这样是不是就真的万无一失了呢?很遗憾的告诉大家,上述这些方法还不是绝对能保证只创建一个对象。mmp…我擦,我就想玩个单例咋这么累呢?心塞…????是的,上面的方案除了枚举方案,其余方案都可以被**。下面我们来了解一下。

**单例

**单例有两种方法,反射或者反序列化。下面我用代码做简单演示。以饿汉式为例,其余模式同理,大家可以自行测试。

反射

????怎么**?
代码如下:

//饿汉式的代码省略,参考前面饿汉式章节
public static void main(String[] args) throws Exception {
        System.out.println(HungrySingleton.getInstance());
        System.out.println(HungrySingleton.getInstance());
        System.out.println("反射**单例...");
        HungrySingleton instance1 = HungrySingleton.class.newInstance();
        HungrySingleton instance2 = HungrySingleton.class.newInstance();
        System.out.println(instance1);
        System.out.println(instance2);
}

输出结果如图,很清楚的看到单例被**了。
让设计模式飞一会儿|②单例模式
????如何防止?
很简单,因为Class.newInstance()是通过调用HungrySingleton无参构造器创建对象的,只要在构造器中加入有以下逻辑即可。这样,当类初始化时,第一次正常创建出实例并赋值给instance。当再想通过反射想要**单例时,自然会抛出异常阻止继续实例化。

//饿汉式的其它代码,参考前面饿汉式章节
private HungrySingleton() {
    if (instance != null) {
      try {
        throw new Exception("只能创建一个对象!");
      } catch (Exception e) {
        e.printStackTrace();
      }
    }
}

反序列化

????怎么**?
另外,通过序列化和反序列化也可以**单例。(前提是单例类实现了Serializable接口)
代码如下:

public static void main(String[] args) throws Exception {
        System.out.println(HungrySingleton.getInstance());
        System.out.println(HungrySingleton.getInstance());
        System.out.println("反序列化**单例...");
        HungrySingleton instance1 = HungrySingleton.getInstance();
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        ObjectOutputStream out = new ObjectOutputStream(baos);
        out.writeObject(instance1);	//序列化
        ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(baos.toByteArray()));
        HungrySingleton instance2 = (HungrySingleton) ois.readObject();	//反序列化
        System.out.println(instance1);
        System.out.println(instance2);
}

输出结果如图,很清楚的看到单例也被**了。
让设计模式飞一会儿|②单例模式
????如何防止?
也非常简单,只需要在单例类中添加如下readResolve()方法,然后在方法体中返回我们的单例实例即可。为什么?因为readResolve()方法是在readObject()方法之后才被调用,因而它每次都会用我们自己生成的单实例替换从流中读取的对象。这样自然就保证了单例。

private Object readResolve() throws ObjectStreamException{
  return instance;
}

如何选择

关于单例模式的全部内容就是这些,最后来做个总结,那么这么多的单例模式实现方案我们到底需要选择哪个呢?技术选型从来不是非黑即白的问题,而是需要根据你的实际应用场景决定的。不过从上述各种单例模式的特点,我们可以得出以下结论:

  • 从安全性角度考虑,枚举显然是最安全的,保证绝对的单例,因为可以天然防止反射和反序列化的**手段。而其它方案一定场合下全部可以被**。

  • 从延迟加载考虑,懒汉式、双重检测锁、静态内部类方案都可以实现,然而双重检测锁方案代码实现复杂,而且还有对JDK版本的要求,首先排除。懒汉式加锁性能较差,而静态内部类实现方法既能够延迟加载节约资源,另外也不需要加锁,性能较好,所以这方面考虑静态内部类方案最佳。
    ????一般选用原则

     单例对象占用资源少,不需要延时加载:枚举式好于饿汉式。
     单例对象占用资源大,需要延时加载:静态内部类式好于懒汉式。
    

抛砖引玉

前面也提到,单例模式在开源框架中被使用的非常之多,下面我就抛砖引玉挑选几处给大家讲解一下,
下面这个代码截取自Mybatis中,这是一个典型的使用静态内部类方式实现单例。

public abstract class VFS {
  	... //省略大量无关代码
    private static class VFSHolder {
        static final VFS INSTANCE = createVFS();

        static VFS createVFS() {
            ... //省略创建过程
        }
    }
		... //省略大量无关代码
    public static VFS getInstance() {
        return VFSHolder.INSTANCE;
    }
  	... //省略大量无关代码
}

在JDK底层也是大量使用了单例模式,比如,Runtime类是JDK中表示Java运行时的环境的一个类,其内部实现也是采用单例模式,因为一个应用程序只需要一个运行时的环境即可,并且是采用饿汉式方式实现。

public class Runtime {
    private static Runtime currentRuntime = new Runtime();

    public static Runtime getRuntime() {
        return currentRuntime;
    }

    private Runtime() {
    }
}

最后啰嗦一句

在结束之前,我再多说一句,网上有些文章经常会用Spring的单例bean和Mybatis中的ErrorContext类作为单例模式的例子,其实这是有问题的

  • 首先,Spring中的单例bean跟本文讲的单例模式并没有关系,不是一回事,可能也就名字比较像,这也是容易混淆的地方,实现方式当然也就天差地别了。Spring的单例bean是通过将指定类首次创建的bean进行缓存,后续去获取的时候,如果设置为singleton,就直接会从缓存中返回之前缓存的对象,而不会创建新对象。但是这个是有前提的,那就是在同一个容器中。如果在你的JVM中存在多个Spring容器,该类也就会创建多个实例了。所以这是不能算是真正的单例模式。本文上述描述的单例模式是指JVM进程级别的,也就是说,只要是在同一个JVM中,单例类只会存在一个对象。
  • Mybatis中的ErrorContext类中采用的是ThreadLocal机制保证同一个线程跟唯一一个ErrorContext实例绑定,但是这个也是有前提的,那就是在线程范围内的,在每一个线程内部,确实做到了只创建一个实例,但是从应用级别或者JVM级别依然不是单例,所以不能将其称之为单例模式。

一言以蔽之,真正的单例模式,是指JVM进程级别的!