设计模式学习笔记 - 设计模式与范式 - 创建型:1.单例模式(上):为什么说支持懒加载的双重校验不必饿汉式更优?

今天开始正式学习设计模式。经典的设计模式有 23 种。其中,常用的并不是很多,可能一半都不到。作为程序员,最熟悉的设计模式,肯定包含单例模式。

本次单例模式的讲解,希望你搞清楚下面这样几个问题。(第一个问题在本章讲解,后面三个问题放到下一章节)。

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

为什么要使用单例?

单例设计模式(Singleton Design Pattern)定义非常简单。一个类只被允许创建一个对象,那这个类就是单例类,这种设计模式简称单例模式。

下面,我们看下为什么需要单例这种设计模式?它能解决哪些问题?我们通过两个案例来讲解。

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

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

public class Logger {
    private FileWriter writer;

    public Logger() {
        File file = new File("/test/log.txt");
        writer = new FileWriter(file, true);
    }

    public void log(String message) {
        writer.write(message);
    }
}

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());
    }
}

看完代码后,我们先思考下,这段代码存在什么问题?

我们注意到,所有的日志都写入到同一个文件 “/test/log.txt” 中。在 UserControllerOrderController 中,分别创建了两个 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("/test/log.txt");
        writer = new FileWriter(file, true);
    }

    public void log(String message) {
        synchronized (this) {
            writer.write(message);
        }
    }
}

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

在这里插入图片描述

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

那该如何解决这个问题呢?

我们只要把对象级别的锁,换成类级别的锁就可以了。让所有对象都共同使用同一把锁。

public class Logger {
    private FileWriter writer;

    public Logger() {
        File file = new File("/test/log.txt");
        writer = new FileWriter(file, true);
    }

    public void log(String message) {
        synchronized (Logger.class) {
            writer.write(message);
        }
    }
}

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

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

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

按照这个设计思路,我们实现了 Logger 类。

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

    private Logger() {
        File file = new File("/test/log.txt");
        writer = new FileWriter(file, true);
    }
    
    public static Logger getInstance() {
        return instance;
    }

    public void log(String message) {
        writer.write(message);
    }
}

// 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 号码生成器,如果程序中有两个对象,那就回存在生成重复 ID 的情况,所以,我们应该将 ID 生成器类设计为单例。

public class IdGenerator {
    /**
     * AtomicLong是Java并发库中的一个原子类型变量类型,它将一些线程不安全需要
     * 加锁的符合操作封装为了线程安全的原子操作,比如下面用到的 incrementAndGet()
     */
    private AtomicLong id = new AtomicLong(0);
    private static IdGenerator instance = new IdGenerator();
    private IdGenerator() {}

    public static IdGenerator getInstance() {
        return instance;
    }

    public long getId() {
        return id.incrementAndGet();
    }
}

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

实际上,今天将的两个代码实例(LoggerIdGenerator),设计的都并不优雅,还存在一些问题。至于有什么问题及如何改造,下一节会详细讲解。

如何实现一个单例

要实现一个单例,需要关注以下几个方面:

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

1.饿汉式

饿汉式的实现方式比较简单。在类加载的时候,instance 静态实例已经创建并初始化好了,所以, instance 实例的创建过程是线程安全的。不过,这样的实现方式不支持延迟加载(在真正用到 IdGenerator 时,再创建实例)。具体的实现代码如下所示:

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

    public static IdGenerator getInstance() {
        return instance;
    }

    public long getId() {
        return id.incrementAndGet();
    }
}

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

如果初始耗时长,那最好不要等到真正用到它的时候,才去执行这个耗时长的初始化过程,这会影响到系统的性能。

比如,在响应客户端接口请求时,做这个初始化操作,会导致此请求的响应时间变长,甚至超时。而采用饿汉式实现方式,将耗时的初始化操作,提前到程序启动的时候完成,这样就能避免在程序运行的时候,再去初始化导致性能问题。

如果实例占用资源多,按照 fail-fast 的设计原则(有问题及早暴露),那也是希望在程序启动时将这个实例初始化好。如果资源不够,就会在程序启动时触发报错(比如 Java 中的 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() 这个方法加了一把大锁(synchronized),导致这个函数的并发度很低。量化一下的话,并发度是 1,也就相当于串行操作了。而这个函数是在单例使用期间,一直会被调用。如果这个单例偶尔被用到,这种实现方式还能接受。但是,如果频繁地被用到,那频繁地加锁、释放锁及并发度低等问题,会导致性能瓶颈,这种实现方式就不可取了。

3.双重检测

双重校验方式,是既支持延迟加载、又支持高并发的单实例实现方式。

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

public class IdGenerator {
    private AtomicLong id = new AtomicLong(0);
    private static IdGenerator instance; // 可以加volatile关键字,禁止指令重排。
    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();
    }
}

实际上,上述的实现方式存在问题:CPU 指令重排序可能导致在 IdGenerator 类的对象被关键字 new 创建并赋值给 instance 之后,还没有来得及初始化,就被另一个线程使用了。这样,另一个线程就使用了没有完整初始化的 IdGenerator 类对象。要解决这个问题,只需要给 instance 变量家 volatile 关键字来禁止指令重排序即可。

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.单例的定义

单例设计模式理解起来非常简单。一个类只允许创建一个对象,那这个类就是单例类,这种设计模式就叫做单例设计模式,简称单例模式。

2.单例的用处

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

此外,我们还可以使用单例解决资源访问冲突的问题。

3.单例的实现

  • 饿汉式:实现方式为,在类加载期间,就已经将 instance 静态实例创建好了,所以,instance 实例的创建时线程安全的。不过,这样的实现方式不支持延迟加载。
  • 懒汉式:相比于饿汉式的优势是支持延迟加载。这种实现方式会导致频繁加锁、释放锁,以及并发度低等问题,频繁的调用会产生性能瓶颈。
  • 双重检测:既支持延迟加载、又支持高并发的单实例实现方式。只要 instance 被创建之后,再调用 getInstance() 函数并不会进入到加锁逻辑中。所以,这种实现方式解决了懒汉式并发度低的问题。
  • 静态内部类:利用 Java 静态内部类来实现单例。这种实现方式,既支持延迟加载,也支持高并发,实现起来也比比双重校验简单。
  • 枚举:最简单的实现方式,基于枚举类型的单例实现。这种实现方式通过 Java 枚举类型本身的特性,保证了实例创建的线程安全性和实例的唯一性。

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

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

Redis一些命令(2)

启动命令: redis-server /myredis/redis.conf(指定配置文件) redis-cli -a 123456 -p 6379(-a 密码 -p 端口号) redis-cli -a 123456 --raw(解决中文乱码) 关闭命令: redis-cli…

万用表革新升级,WT588F02BP-14S语音芯片助力智能测量新体验v

万能表功能: 万能表是一款集多功能于一体的电子测量工具,能够精准测量电压、电流、电阻等参数,广泛应用于电气、电子、通信等领域。其操作简便、测量准确,是工程师们进行电路调试、故障排查的得力助手,为提升工作效率…

Go语言学习11-测试

Go语言学习11-测试 单元测试 // functions.go package testingfunc square(op int) int {return op * op }// functions_test.go package testingimport ("fmt""github.com/stretchr/testify/assert""testing" )func TestSquare(t *testing.T)…

Panasonic松下PLC如何数据采集?如何实现快速接入IIOT云平台?

在工业自动化领域,数据采集与远程控制是提升生产效率、优化资源配置的关键环节。对于使用Panasonic松下PLC的用户来说,如何实现高效、稳定的数据采集,并快速接入IIOT云平台,是摆在他们面前的重要课题。HiWoo Box工业物联网关以其强…

Git小乌龟安装及使用教程

一、Win7安装git 软件下载地址:git for windows 安装过程直接默认下一步,直到安装结束。 安装结束后重启一下。 安装完成后,在文件夹空白处右键出现以下几个标识,说明安装成功。 二、安装tortoise git(乌龟git&…

鸿蒙Harmony应用开发—ArkTS声明式开发(画布组件:ImageBitmap)

ImageBitmap对象可以存储canvas渲染的像素数据。 说明: 从 API Version 8 开始支持。后续版本如有新增内容,则采用上角标单独标记该内容的起始版本。 接口 ImageBitmap(src: string) 从API version 9开始,该接口支持在ArkTS卡片中使用。 参…

游戏反云手机检测方案

游戏风险环境,是指独立于原有设备或破坏设备原有系统的环境。常见的游戏风险环境有:云手机、虚拟机、虚拟框架、iOS越狱、安卓设备root等。 这类风险环境可以为游戏外挂、破解提供所需的高级别设备权限,当游戏处于这些风险环境下&#xff0c…

Python之Web开发中级教程----ubuntu安装MySQL

Python之Web开发中级教程----ubuntu安装MySQL 进入/opt目录 cd /opt 更新软件源 sudo apt-get upgrade sudo apt-get update 3、安装Mysql server sudo apt-get install mysql-server 4、启动Mysql service mysql start 5、确认Mysql的状态 service mysql status 6、安全设…

springboot286入校申报审批系统的设计与实现

入校申报审批系统设计与实现 摘 要 传统办法管理信息首先需要花费的时间比较多,其次数据出错率比较高,而且对错误的数据进行更改也比较困难,最后,检索数据费事费力。因此,在计算机上安装入校申报审批系统软件来发挥其…

课设系统篇

《古代六扇门人员管理系统》 数据库 sixdoor 编码 utf8mb4 视图 查询官员等级 存储过程 CREATE DEFINERrootlocalhost PROCEDURE levelname(IN g_name VARCHAR(20)) BEGINSELECT name,level FROM servingofficials INNER JOIN jobtitle onservingofficials.role jobtitl…

Linux:Gitlab:16.9.2 创建用户及项目仓库基础操作(2)

我在上一章介绍了基本的搭建以及邮箱配置 Linux:Gitlab:16.9.2 (rpm包) 部署及基础操作(1)-CSDN博客https://blog.csdn.net/w14768855/article/details/136821311?spm1001.2014.3001.5501 本章介绍一下用户的创建,组内设置用户&…

工控机丨工业平板电脑丨工业计算机丨零售行业应用

工控机是一种专门用于工业控制、自动化和数据采集的计算机设备,它具有高可靠性、稳定性和耐用性的特点,常常被用于各种工业场景中。然而,随着科技的发展和应用场景的不断拓展,工控机在零售行业中也有着广泛的应用。下面将从以下几…

退出或关闭Android Studio中的Coverage功能

使用原因: 今天在运行代码的时候,不想在idea中再复制一遍了,就想着在Android Studio中运行一下试试。后来发现只能运行Coverage才能在控制台打印结果。那么运行完之后如何取消呢? 我们可以找到app下拉 找到Edit Configuration&am…

ansible Playbook案例 安装nginx

目录 核心元素基本组件举例命令行也 是可以创建文件的编辑nginx.yml 运行前三部曲 核心元素 Playbook的核心元素: Hosts:主机组; Tasks:任务列表; Variables:变量,设置方式有四种;…

IDEA 配置阿里规范检测

IDEA中安装插件 配置代码风格检查规范 使用代码风格检测 在代码类中,右键 然后会给出一些不符合规范的修改建议: 保存代码时自动格式化代码 安装插件: 配置插件:

Wordpress站点通过修改.htaccess 设置重定向实现强制 https 访问

要在WordPress站点上通过修改.htaccess文件实现强制HTTPS访问,您可以按照以下步骤进行操作: 登录到WordPress站点管理后台。 在文件管理器或通过FTP访问网站根目录,找到并打开名为 .htaccess 的文件。 在打开的文件中添加以下代码&#xf…

Covalent Network借助大规模的历史Web3数据集,推动人工智能发展

人工智能在众多领域中增强了区块链的实用性,反之亦然,区块链确保了 AI 模型所使用的数据的来源和质量。人工智能带来的生产力提升,将与区块链系统固有的安全性和透明度融合。 Covalent Network(CQT)正位于这两项互补技…

09-新热文章-实时计算-黑马头条

热点文章-实时计算 1 今日内容 1.1 定时计算与实时计算 1.2 今日内容 kafkaStream 什么是流式计算 kafkaStream概述 kafkaStream入门案例 Springboot集成kafkaStream 实时计算 用户行为发送消息 kafkaStream聚合处理消息 更新文章行为数量 替换热点文章数据 2 实时…

Linux之线程同步

目录 一、问题引入 二、实现线程同步的方案——条件变量 1、常用接口&#xff1a; 2、使用示例 一、问题引入 我们再次看看上次讲到的多线程抢票的代码&#xff1a;这次我们让一个线程抢完票之后不去做任何事。 #include <iostream> #include <unistd.h> #inc…

前端项目,个人笔记(二)【Vue-cli - 引入阿里矢量库图标 + 吸顶交互 + setup语法糖】

目录 1、项目中引入阿里矢量库图标 2、实现吸顶交互 3、语法糖--<script setup> 3.1、无需return 3.2、子组件接收父组件的值-props的使用 3.3、注册组件 1、项目中引入阿里矢量库图标 步骤一&#xff1a;进入阿里矢量库官网中&#xff1a;iconfont-阿里巴巴矢量…