CategoryResourceRepost/极客时间专栏/geek/设计模式之美/设计模式与范式:创建型/41 | 单例模式(上):为什么说支持懒加载的双重检测不比饿汉式更优?.md
louzefeng bf99793fd0 del
2024-07-09 18:38:56 +00:00

18 KiB
Raw Blame History

从今天开始我们正式进入到设计模式的学习。我们知道经典的设计模式有23种。其中常用的并不是很多。据我的工作经验来看常用的可能都不到一半。如果随便抓一个程序员让他说一说最熟悉的3种设计模式那其中肯定会包含今天要讲的单例模式。

网上有很多讲解单例模式的文章,但大部分都侧重讲解,如何来实现一个线程安全的单例。我今天也会讲到各种单例的实现方法,但是,这并不是我们专栏学习的重点,我重点还是希望带你搞清楚下面这样几个问题(第一个问题会在今天讲解,后面三个问题放到下一节课中讲解)。

  • 为什么要使用单例?
  • 单例存在哪些问题?
  • 单例与静态类的区别?
  • 有何替代的解决方案?

话不多说,让我们带着这些问题,正式开始今天的学习吧!

为什么要使用单例?

单例设计模式Singleton Design Pattern理解起来非常简单。一个类只允许创建一个对象或者实例那这个类就是一个单例类这种设计模式就叫作单例设计模式简称单例模式。

对于单例的概念,我觉得没必要解释太多,你一看就能明白。我们重点看一下,为什么我们需要单例这种设计模式?它能解决哪些问题?接下来我通过两个实战案例来讲解。

实战案例一:处理资源访问冲突

我们先来看第一个例子。在这个例子中我们自定义实现了一个往文件中打印日志的Logger类。具体的代码实现如下所示

public class Logger {
  private FileWriter writer;
  
  public Logger() {
    File file = new File("/Users/wangzheng/log.txt");
    writer = new FileWriter(file, true); //true表示追加写入
  }
  
  public void log(String message) {
    writer.write(mesasge);
  }
}

// Logger类的应用示例
public class UserController {
  private Logger logger = new Logger();
  
  public void login(String username, String password) {
    // ...省略业务逻辑代码...
    logger.log(username + " logined!");
  }
}

public class OrderController {
  private Logger logger = new Logger();
  
  public void create(OrderVo order) {
    // ...省略业务逻辑代码...
    logger.log("Created an order: " + order.toString());
  }
}

看完代码之后,先别着急看我下面的讲解,你可以先思考一下,这段代码存在什么问题。

在上面的代码中,我们注意到,所有的日志都写入到同一个文件/Users/wangzheng/log.txt中。在UserController和OrderController中我们分别创建两个Logger对象。在Web容器的Servlet多线程环境下如果两个Servlet线程同时分别执行login()和create()两个函数并且同时写日志到log.txt文件中那就有可能存在日志信息互相覆盖的情况。

为什么会出现互相覆盖呢我们可以这么类比着理解。在多线程环境下如果两个线程同时给同一个共享变量加1因为共享变量是竞争资源所以共享变量最后的结果有可能并不是加了2而是只加了1。同理这里的log.txt文件也是竞争资源两个线程同时往里面写数据就有可能存在互相覆盖的情况。

那如何来解决这个问题呢我们最先想到的就是通过加锁的方式给log()函数加互斥锁Java中可以通过synchronized的关键字同一时刻只允许一个线程调用执行log()函数。具体的代码实现如下所示:

public class Logger {
  private FileWriter writer;

  public Logger() {
    File file = new File("/Users/wangzheng/log.txt");
    writer = new FileWriter(file, true); //true表示追加写入
  }
  
  public void log(String message) {
    synchronized(this) {
      writer.write(mesasge);
    }
  }
}

不过你仔细想想这真的能解决多线程写入日志时互相覆盖的问题吗答案是否定的。这是因为这种锁是一个对象级别的锁一个对象在不同的线程下同时调用log()函数会被强制要求顺序执行。但是不同的对象之间并不共享同一把锁。在不同的线程下通过不同的对象调用执行log()函数,锁并不会起作用,仍然有可能存在写入日志互相覆盖的问题。

我这里稍微补充一下在刚刚的讲解和给出的代码中我故意“隐瞒”了一个事实我们给log()函数加不加对象级别的锁其实都没有关系。因为FileWriter本身就是线程安全的它的内部实现中本身就加了对象级别的锁因此在外层调用write()函数的时候再加对象级别的锁实际上是多此一举。因为不同的Logger对象不共享FileWriter对象所以FileWriter对象级别的锁也解决不了数据写入互相覆盖的问题。

那我们该怎么解决这个问题呢实际上要想解决这个问题也不难我们只需要把对象级别的锁换成类级别的锁就可以了。让所有的对象都共享同一把锁。这样就避免了不同对象之间同时调用log()函数,而导致的日志覆盖问题。具体的代码实现如下所示:

public class Logger {
  private FileWriter writer;

  public Logger() {
    File file = new File("/Users/wangzheng/log.txt");
    writer = new FileWriter(file, true); //true表示追加写入
  }
  
  public void log(String message) {
    synchronized(Logger.class) { // 类级别的锁
      writer.write(mesasge);
    }
  }
}

除了使用类级别锁之外实际上解决资源竞争问题的办法还有很多分布式锁是最常听到的一种解决方案。不过实现一个安全可靠、无bug、高性能的分布式锁并不是件容易的事情。除此之外并发队列比如Java中的BlockingQueue也可以解决这个问题多个线程同时往并发队列里写日志一个单独的线程负责将并发队列中的数据写入到日志文件。这种方式实现起来也稍微有点复杂。

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

我们将Logger设计成一个单例类程序中只允许创建一个Logger对象所有的线程共享使用的这一个Logger对象共享一个FileWriter对象而FileWriter本身是对象级别线程安全的也就避免了多线程情况下写日志会互相覆盖的问题。

按照这个设计思路我们实现了Logger单例类。具体代码如下所示

public class Logger {
  private FileWriter writer;
  private static final Logger instance = new Logger();

  private Logger() {
    File file = new File("/Users/wangzheng/log.txt");
    writer = new FileWriter(file, true); //true表示追加写入
  }
  
  public static Logger getInstance() {
    return instance;
  }
  
  public void log(String message) {
    writer.write(mesasge);
  }
}

// Logger类的应用示例
public class UserController {
  public void login(String username, String password) {
    // ...省略业务逻辑代码...
    Logger.getInstance().log(username + " logined!");
  }
}

public class OrderController {  
  public void create(OrderVo order) {
    // ...省略业务逻辑代码...
    Logger.getInstance().log("Created a order: " + order.toString());
  }
}

实战案例二:表示全局唯一类

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

比如,配置信息类。在系统中,我们只有一个配置文件,当配置文件被加载到内存之后,以对象的形式存在,也理所应当只有一份。

再比如唯一递增ID号码生成器第34讲中我们讲的是唯一ID生成器这里讲的是唯一递增ID生成器如果程序中有两个对象那就会存在生成重复ID的情况所以我们应该将ID生成器类设计为单例。

import java.util.concurrent.atomic.AtomicLong;
public class IdGenerator {
  // AtomicLong是一个Java并发库中提供的一个原子变量类型,
  // 它将一些线程不安全需要加锁的复合操作封装为了线程安全的原子操作,
  // 比如下面会用到的incrementAndGet().
  private AtomicLong id = new AtomicLong(0);
  private static final IdGenerator instance = new IdGenerator();
  private IdGenerator() {}
  public static IdGenerator getInstance() {
    return instance;
  }
  public long getId() { 
    return id.incrementAndGet();
  }
}

// IdGenerator使用举例
long id = IdGenerator.getInstance().getId();

实际上今天讲到的两个代码实例Logger、IdGenerator设计的都并不优雅还存在一些问题。至于有什么问题以及如何改造今天我暂时卖个关子下一节课我会详细讲解。

如何实现一个单例?

尽管介绍如何实现一个单例模式的文章已经有很多了,但为了保证内容的完整性,我这里还是简单介绍一下几种经典实现方式。概括起来,要实现一个单例,我们需要关注的点无外乎下面几个:

  • 构造函数需要是private访问权限的这样才能避免外部通过new创建实例
  • 考虑对象创建时的线程安全问题;
  • 考虑是否支持延迟加载;
  • 考虑getInstance()性能是否高(是否加锁)。

如果你对这块已经很熟悉了你可以当作复习。注意下面的几种单例实现方式是针对Java语言语法的如果你熟悉的是其他语言不妨对比Java的这几种实现方式自己试着总结一下利用你熟悉的语言该如何实现。

1.饿汉式

饿汉式的实现方式比较简单。在类加载的时候instance静态实例就已经创建并初始化好了所以instance实例的创建过程是线程安全的。不过这样的实现方式不支持延迟加载在真正用到IdGenerator的时候再创建实例从名字中我们也可以看出这一点。具体的代码实现如下所示

public class IdGenerator { 
  private AtomicLong id = new AtomicLong(0);
  private static final IdGenerator instance = new IdGenerator();
  private IdGenerator() {}
  public static IdGenerator getInstance() {
    return instance;
  }
  public long getId() { 
    return id.incrementAndGet();
  }
}

有人觉得这种实现方式不好,因为不支持延迟加载,如果实例占用资源多(比如占用内存多)或初始化耗时长(比如需要加载各种配置文件),提前初始化实例是一种浪费资源的行为。最好的方法应该在用到的时候再去初始化。不过,我个人并不认同这样的观点。

如果初始化耗时长,那我们最好不要等到真正要用它的时候,才去执行这个耗时长的初始化过程,这会影响到系统的性能(比如,在响应客户端接口请求的时候,做这个初始化操作,会导致此请求的响应时间变长,甚至超时)。采用饿汉式实现方式,将耗时的初始化操作,提前到程序启动的时候完成,这样就能避免在程序运行的时候,再去初始化导致的性能问题。

如果实例占用资源多按照fail-fast的设计原则有问题及早暴露那我们也希望在程序启动时就将这个实例初始化好。如果资源不够就会在程序启动的时候触发报错比如Java中的 PermGen Space OOM我们可以立即去修复。这样也能避免在程序运行一段时间后突然因为初始化这个实例占用资源过多导致系统崩溃影响系统的可用性。

2.懒汉式

有饿汉式,对应的,就有懒汉式。懒汉式相对于饿汉式的优势是支持延迟加载。具体的代码实现如下所示:

public class IdGenerator { 
  private AtomicLong id = new AtomicLong(0);
  private static IdGenerator instance;
  private IdGenerator() {}
  public static synchronized IdGenerator getInstance() {
    if (instance == null) {
      instance = new IdGenerator();
    }
    return instance;
  }
  public long getId() { 
    return id.incrementAndGet();
  }
}

不过懒汉式的缺点也很明显我们给getInstance()这个方法加了一把大锁synchronzed导致这个函数的并发度很低。量化一下的话并发度是1也就相当于串行操作了。而这个函数是在单例使用期间一直会被调用。如果这个单例类偶尔会被用到那这种实现方式还可以接受。但是如果频繁地用到那频繁加锁、释放锁及并发度低等问题会导致性能瓶颈这种实现方式就不可取了。

3.双重检测

饿汉式不支持延迟加载,懒汉式有性能问题,不支持高并发。那我们再来看一种既支持延迟加载、又支持高并发的单例实现方式,也就是双重检测实现方式。

在这种实现方式中只要instance被创建之后即便再调用getInstance()函数也不会再进入到加锁逻辑中了。所以,这种实现方式解决了懒汉式并发度低的问题。具体的代码实现如下所示:

public class IdGenerator { 
  private AtomicLong id = new AtomicLong(0);
  private static IdGenerator instance;
  private IdGenerator() {}
  public static IdGenerator getInstance() {
    if (instance == null) {
      synchronized(IdGenerator.class) { // 此处为类级别的锁
        if (instance == null) {
          instance = new IdGenerator();
        }
      }
    }
    return instance;
  }
  public long getId() { 
    return id.incrementAndGet();
  }
}

网上有人说这种实现方式有些问题。因为指令重排序可能会导致IdGenerator对象被new出来并且赋值给instance之后还没来得及初始化执行构造函数中的代码逻辑就被另一个线程使用了。

要解决这个问题我们需要给instance成员变量加上volatile关键字禁止指令重排序才行。实际上只有很低版本的Java才会有这个问题。我们现在用的高版本的Java已经在JDK内部实现中解决了这个问题解决的方法很简单只要把对象new操作和初始化操作设计为原子操作就自然能禁止重排序。关于这点的详细解释跟特定语言有关我就不展开讲了感兴趣的同学可以自行研究一下。

4.静态内部类

我们再来看一种比双重检测更加简单的实现方法那就是利用Java的静态内部类。它有点类似饿汉式但又能做到了延迟加载。具体是怎么做到的呢我们先来看它的代码实现。

public class IdGenerator { 
  private AtomicLong id = new AtomicLong(0);
  private IdGenerator() {}

  private static class SingletonHolder{
    private static final IdGenerator instance = new IdGenerator();
  }
  
  public static IdGenerator getInstance() {
    return SingletonHolder.instance;
  }
 
  public long getId() { 
    return id.incrementAndGet();
  }
}

SingletonHolder 是一个静态内部类当外部类IdGenerator被加载的时候并不会创建SingletonHolder实例对象。只有当调用getInstance()方法时SingletonHolder才会被加载这个时候才会创建instance。instance的唯一性、创建过程的线程安全性都由JVM来保证。所以这种实现方法既保证了线程安全又能做到延迟加载。

5.枚举

最后我们介绍一种最简单的实现方式基于枚举类型的单例实现。这种实现方式通过Java枚举类型本身的特性保证了实例创建的线程安全性和实例的唯一性。具体的代码如下所示

public enum IdGenerator {
  INSTANCE;
  private AtomicLong id = new AtomicLong(0);
 
  public long getId() { 
    return id.incrementAndGet();
  }
}

重点回顾

好了,今天的内容到此就讲完了。我们来总结回顾一下,你需要掌握的重点内容。

1.单例的定义

单例设计模式Singleton Design Pattern理解起来非常简单。一个类只允许创建一个对象或者叫实例那这个类就是一个单例类这种设计模式就叫作单例设计模式简称单例模式。

2.单例的用处

从业务概念上,有些数据在系统中只应该保存一份,就比较适合设计为单例类。比如,系统的配置信息类。除此之外,我们还可以使用单例解决资源访问冲突的问题。

3.单例的实现

单例有下面几种经典的实现方式。

  • 饿汉式

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

  • 懒汉式

懒汉式相对于饿汉式的优势是支持延迟加载。这种实现方式会导致频繁加锁、释放锁,以及并发度低等问题,频繁的调用会产生性能瓶颈。

  • 双重检测

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

  • 静态内部类

利用Java的静态内部类来实现单例。这种实现方式既支持延迟加载也支持高并发实现起来也比双重检测简单。

  • 枚举

最简单的实现方式基于枚举类型的单例实现。这种实现方式通过Java枚举类型本身的特性保证了实例创建的线程安全性和实例的唯一性。

课堂讨论

  1. 在你所熟悉的编程语言的类库中,有哪些类是单例类?又为什么要设计成单例类呢?
  2. 在第一个实战案例中,除了我们讲到的类级别锁、分布式锁、并发队列、单例模式等解决方案之外,实际上还有一种非常简单的解决日志互相覆盖问题的方法,你想到了吗?

可以在留言区说一说,和同学一起交流和分享。如果有收获,也欢迎你把这篇文章分享给你的朋友。