【设计模式与范式:创建型】41 | 单例模式(上):为什么说支持懒加载的双重检测不比饿汉式更优?

news2024/10/5 16:20:12

从今天开始,我们正式进入到设计模式的学习。我们知道,经典的设计模式有 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 {
  private Logger logger = new Logger();
  
  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。insance 的唯一性、创建过程的线程安全性,都由 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 枚举类型本身的特性,保证了实例创建的线程安全性和实例的唯一性。

课堂讨论

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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/548820.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Kelvin和Rossby波 Part-2(浅水方程)

在前面博主有篇关于Kelvin和Rossby波的简要介绍,见下: Kelvin和Rossby波 Part-1(简要介绍) 开尔文波(Kelvin Wave)是发生在大气或海洋中的,迎向地形边界(例如海岸线)平衡…

回顾2023年计算机专业学生卑微求职 / 申博血泪史

0.前言 2023年是悲惨的一年,各个行业都在卷中卷。同时对于个人来说,2023年也是最忙碌的一年,不仅面临毕业,而且面临工作/读博,随便挑出一件,都是一件耗时耗力的事。相信大家也从网络上看到了很多的帖子或者…

文心一言 VS 讯飞星火 VS chatgpt (19)-- go语言的slice和rust语言的Vec的扩容流程是什么?

go语言的slice和rust语言的Vec的扩容流程是什么? 文心一言: 在Go语言中,slice(切片)和Rust语言中的Vec都是用于存储一组固定长度的元素的数据结构。它们的扩容流程略有不同,下面是它们的基本概述&#xf…

HNU数据结构与算法分析-作业3-树结构

1. (简答题) 5.1 定义满二叉树的每一个结点或者是一个分支结点,并恰好有两个非空子结点;或者是叶结点。 证明在有n个分支结点的所有二叉树中,满二叉树的叶结点的数目(或者叶结点与全部结点数的比例)是最高的。 5.6 …

Linux下安装配置maven

1.安装以及配置maven 1.1.下载maven安装包 首先需要切换到自己需要安装的目录 把配置都放到了:/root路径下 1.2.解压下载好的maven包 tar -zxvf apache-maven-3.6.0-bin.tar.gzcp -r apache-maven-3.6.0 /usr/local/1.3.配置maven环境变量 1.3.1.在环境变量中…

微信小程序nodejs+vue校园二手商城交易(积分兑换)38gw6

随着我国经济迅速发展,人们对手机的需求越来越大,各种手机软件也都在被广泛应用,但是对于手机进行数据信息管理,对于手机的各种软件也是备受用户的喜爱,校园二手交易被用户普遍使用,为方便用户能够可以随时…

chatgpt赋能Python-python3_8怎么设置字体大小

Python3.8如何设置文本字体大小 Python是一种高级编程语言,它在全球开发者中间得到了广泛的应用。随着Python的不断发展,Python 3.8版本也应运而生。在这个新版本中,有许多新的功能,其中一个是设置文本字体大小。本文将展示如何在…

redis高级篇三(分片集群)

一)进行测试Sentinel池: 集群的定义:所谓的集群,就是通过增加服务器的数量,提供相同的服务,从而让服务器达到一个稳定、高效的状态 之前的哨兵模式是存在着一些问题的,因为如果主节点挂了,那么sentinel集群会选举新的s…

一些题目__

好耶,第一次div2做出来3道题,虽然中间看了个题解,但是思路差不多,被复杂度困住了,nnd 首先是第一个题,emm 第一题 那么这个题的要求是,构造一个数组,满足这些条件: 注意…

Java学习路线(6)——方法

概念: 方法是一种语法结构,可以将一段代码封装成一个功能,方便复用。 特点: 提高代码复用性提高逻辑清晰性 一、基本方法定义和调用 1、有反有参方法 修饰符 返回类型 方法名( 形参列表 ){ 方法体代码; return 返回值; } public…

printf串口重定向标准方法

一,简介 在程序调试的过程中,需要用到串口打印信息来判断单片机程序运行是否正确。需要使用串口对printf进行重定向,本文就介绍一下ARM官方推荐的一种重定向的方法,供参考使用。 二,具体步骤 主要分为两步&#xff…

leetcode 138.复制带随机指针的链表

题目链接:leetcode 138 1.题目 给你一个长度为 n 的链表,每个节点包含一个额外增加的随机指针 random ,该指针可以指向链表中的任何节点或空节点。 构造这个链表的 深拷贝。 深拷贝应该正好由 n 个 全新 节点组成,其中每个新节…

如何用Nginx实现对城市以及指定IP的访问限制?

1.前言 在【如何用Nginx代理MySQL连接,并限制可访问IP】一文中,我们实现了通过Nginx代理MySQL连接,并限制了指定IP才能通过Nginx进行连接,以提高数据安全性。 该场景适用于根据具体的IP地址来进行访问限制,假如我们要…

C++控制台打飞机小游戏

我终于决定还是把这个放出来。 视频在这:https://v.youku.com/v_show/id_XNDQxMTQwNDA3Mg.html 具体信息主界面上都有写。 按空格暂停,建议暂停后再升级属性。 记录最高分的文件进行了加密。 有boss(上面视频2分47秒)。 挺好…

LeetCode 不同路径1\2

不同路径1和2 题目在上面 这两个题目都是简单的动态规划问题 对不同路径最初始的问题举个例子 因为我们的机器人只能向右或者向下走一步 因此这个矩形的第一行和第一列都可以初始化为1 然后我们就可以得到动态规划的方程 f i , j f i − 1 , j f i , j − 1 f_{i,j} f_{i…

【C++模板】

目录 一、什么是泛型编程二、函数模板2.1函数模板概念2.2函数模板格式2.3函数模板的原理2.4函数模板的实例化 三、类模板3.1类模板的定义格式3.2类模板的成员函数的声明与定义分开的写法 一、什么是泛型编程 问题:如何实现一个加法函数呢?假设加法函数的…

LeetCode94. 二叉树的中序遍历(递归与非递归)

写在前面: 题目链接:添加链接描述 编程语言:c 题目难度:简单 一、题目描述 给定一个二叉树的根节点 root ,返回 它的 中序 遍历 。 输入:root [1,null,2,3] 输出:[1,3,2] 示例 2:…

chatgpt赋能Python-python3_6怎么保存

Python3.6的保存方式简介 Python3.6是一种高级编程语言,由于其易读性和清晰性,成为了广泛使用的编程语言之一。Python3.6提供了丰富的特性和功能,使其成为了开发各种网站和Web应用程序的完美选择。在这篇文章中,我们将介绍Python…

8.2 综合案例2.0-远程遥控智能锁

综合案例2.0-远程遥控智能锁 案例说明1.硬件2.连线图3.dvr8833电机驱动使用说明 搭建云平台环境1.添加设备2.创建设备类型3.功能定义(创建物模型)4.ThingsX App 配置5.生成用户应用 App 代码1.更改MQTT信息2.测试 案例说明 生活中很多场景需要用到锁&am…