vlambda博客
学习文章列表

设计模式-深入单例模式精髓-剖析单例模式适用场景以及多线程问题

设计模式最常见的模式之一单例模式,废话不多说,前面的文章已经有对设计模式的7大原则有过介绍,从本文开始对每一种设计模式以及设计模式所适用的场景做全面的剖析。


本文是针对常见的设计模式之一单例模式做一个分析,单例模式有懒汉模式、饿汉模式、双重锁模式、静态内部类模式,接下来一一呈现并对单例模式下多线程需要注意的地方做出分析和解决。


概念

单例模式(Singleton Pattern)是 Java 中最简单的设计模式之一。这种类型的设计模式属于创建型模式,它提供了一种创建对象的最佳方式。


定义:Ensure a class has only one instance,and provide a global point of access to it.(确保某一个类只有一个实例,而且自行实例化并向整个系统提供这个实例。)


这种模式涉及到一个单一的类,该类负责创建自己的对象,同时确保只有单个对象被创建。这个类提供了一种访问其唯一的对象的方式,可以直接访问,不需要实例化该类的对象。


实现单例模式的思路是:一个类能返回对象一个引用(永远是同一个)和一个获得该实例的方法(必须是静态方法,通常使用getInstance这个名称);当我们调用这个方法时,如果类持有的引用不为空就返回这个引用,如果类保持的引用为空就创建该类的实例并将实例的引用赋予该类保持的引用;同时我们还将该类的构造函数定义为私有方法,这样其他处的代码就无法通过调用该类的构造函数来实例化该类的对象,只有通过该类提供的静态方法来得到该类的唯一实例。基本实现方式如下:

public class Singleton { private static Singleton singleton;  //限制产生多个对象 private Singleton(){ } //通过该方法获得实例对象 public static Singleton getSingleton(){ if(singleton==null)singleton = new Singleton(); return singleton; }  //类中其他方法,尽量是static public static void doSomething(){ }}


懒汉模式

我们可以看到上文中的代码实现,在获取实例的时候去实例化一个对象,这种实现方式就叫做懒汉模式。


优点:只有当第一次使用的时候才会创建,有利于减少系统启动时间;


缺点:在第一次使用的时候需要占用创建时间;


接下来我们思考一下,如果有两个线程同时调用上面getSingleton()方法,可以能会出现什么问题?


有可能第一个线程获取的对象实例和第二个线程获取的实例不一致,我们可以将代码修改成如下:


public class Singleton { private static Singleton singleton;  //限制产生多个对象 private Singleton(){ } //通过该方法获得实例对象 public static synchronized Singleton getSingleton(){ if(singleton==null)singleton = new Singleton(); return singleton; }  //类中其他方法,尽量是static public static void doSomething(){ }}

在getSingleton()方法前增进同步标识synchronized,synchronized增加的是一个互斥锁虽然可以保障实例的唯一性,但是也降低了多线程下的执行效率。怎么解决互斥锁导致的性能问题?


双重锁懒汉模式(Double Check Lock)

我们先看下面代码的实现方式:


public class Singleton { private volatile static Singleton singleton;  //限制产生多个对象 private Singleton(){ } //通过该方法获得实例对象 public static Singleton getSingleton(){ if(singleton==null){ synchronized(Singleton.class){ if(singleton == null)singleton = new Singleton(); } } return singleton; }  //类中其他方法,尽量是static public static void doSomething(){ }}

 这种方式优化了普通懒汉模式的性能,接下来我们分析一下双重锁(DCL)怎么带来的性能提升以及为什么要有多重判断。


  • 第一次判断singleton是否为null

  第一次判断是在Synchronized同步代码块外进行判断,由于单例模式只会创建一个实例,并通过getInstance方法返回singleton对象,所以,第一次判断,是为了在singleton对象已经创建的情况下,避免进入同步代码块,提升效率。


  • 第二次判断singleton是否为null

  第二次判断是为了避免以下情况的发生。

  (1)假设:线程A已经经过第一次判断,判断singleton=null,准备进入同步代码块.

  (2)此时线程B获得时间片,由于线程A并没有创建实例,所以,判断singleton仍然=null,所以线程B创建了实例singleton。

  (3)此时,线程A再次获得时间片,由于刚刚经过第一次判断singleton=null(不会重复判断),进入同步代码块,这个时候,我们如果不加入第二次判断的话,那么线程A又会创造一个实例singleton,就不满足我们的单例模式的要求,所以第二次判断是很有必要的。


  • 为什么要加Volatile关键字

  其实,上面两点比较好理解,第三点,既然有了Synchronized作为限制,为什么还要加入Volatile呢?


  首先,我们需要知道Volatile可以保证可见性和原子性,同时保证JVM对指令不会进行重排序。

  其次,这点也很关键,对象的创建不是一步完成的,是一个符合操作,需要3个指令。

  我们结合这一句代码来解释:

singleton = new Singleton(); 
  • 指令2:初始化singleton对象

  那么,这样我们就比较好理解,为什么要加入Volatile变量了。由于Volatile禁止JVM对指令进行重排序。所以创建对象的过程仍然会按照指令1-2-3的有序执行。

  反之,如果没有Volatile关键字,假设线程A正常创建一个实例,那么指定执行的顺序可能2-1-3,当执行到指令1的时候,线程B执行getInstance方法,获取到的,可能是对象的一部分,或者是不正确的对象,程序可能就会报异常信息。


      然而这种方式并未完全解决,锁带来的性能问题,因此饿汉模式出现了。


饿汉模式

饿汉模式指在类中直接定义全局的静态对象的实例并初始化,然后提供一个方法获取该实例对象。懒汉模式和饿汉模式的最大不同在于,懒汉模式在类中定义了单例但是并未实例化,实例化的过程是在获取单例对象的方法中实现的,也就是说,在第一次调用懒汉模式时,该对象一定为空,然后去实例化对象并赋值,这样下次就能直接获取对象了;而饿汉模式是在定义单例对象的同时将其实例化的,直接使用便可。也就是说,在饿汉模式下,在Class Loader完成后该类的实例便已经存在于JVM中了,代码如下:


public class Singleton { private static Singleton singleton = new Singleton();  //限制产生多个对象 private Singleton(){ } //通过该方法获得实例对象 public static Singleton getSingleton(){ return singleton; }  //类中其他方法,尽量是static public static void doSomething(){ }}

了解完饿汉模式的实现方式后,互斥锁不存在了,性能问题也就得到了解决。其主要问题在于增加了启动所需要的时间和内存。


饿汉模式:以空间换时间,懒汉模式:以时间换空间,是否有种方式可以解决所有问题呢?


静态内部类模式

静态内部类实现代码如下:


public class SingleTon{ private SingleTon(){}  private static class SingleTonHoler{ private static SingleTon INSTANCE = new SingleTon(); }  public static SingleTon getInstance(){ return SingleTonHoler.INSTANCE; }}

静态内部类的优点是:外部类加载时并不需要立即加载内部类,内部类不被加载则不去初始化INSTANCE,故而不占内存。即当SingleTon第一次被加载时,并不需要去加载SingleTonHoler,只有当getInstance()方法第一次被调用时,才会去初始化INSTANCE,第一次调用getInstance()方法会导致虚拟机加载SingleTonHoler类,这种方法不仅能确保线程安全,也能保证单例的唯一性,同时也延迟了单例的实例化。


场景与实践

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

使用单例模式需要注意的一点就是JVM的垃圾回收机制,如果我们的一个单例对象在内存中长久不使用,JVM就认为这个对象是一个垃圾,在CPU资源空闲的情况下该对象会被清理掉,下次再调用时就需要重新产生一个对象。如果我们在应用中使用单例类作为有状态值(如计数器)的管理,则会出现恢复原状的情况,应用就会出现故障。如果确实需要采用单例模式来记录有状态的值,有两种办法可以解决该问题:


  • 由容器管理单例的生命周期

Java EE容器或者框架级容器(如Spring)可以让对象长久驻留内存。当然,自行通过管理对象的生命期也是一个可行的办法,既然有那么多的工具提供给我们,为什么不用呢?


  • 状态随时记录

可以使用异步记录的方式,或者使用观察者模式,记录状态的变化,写入文件或写入数据库中,确保即使单例对象重新初始化也可以从资源环境获得销毁前的数据,避免应用数据丢失。


欢迎各位程序猿朋友一起探讨,大家也可分享这篇文章给其他朋友一起学习。