单例模式学习笔记

本文详细介绍了单例模式的作用,区分了懒汉式和饿汉式的实现方式,并讨论了它们在内存管理、线程安全和代码结构上的特点,以及懒汉式可能带来的性能问题。
摘要由CSDN通过智能技术生成

概述

单例模式是指在内存中只会创建且仅创建一次对象的设计模式。在程序中多次使用同一个对象且作用相同时,为了防止频繁地创建对象使得内存飙升,单例模式可以让程序仅在内存中创建一个对象,让所有需要调用的地方都共享这一单例对象。

在有些情况下,一个类如果有多个实例就不能正常运作。最常见的就是,这个类与一个维持着自身全局状态的外部系统进行交互的情况。比如一个封装了底层文件API的类。因为文件操作需要一定时间去完成,所以类将异步地处理。这意味着许多操作可以同时进行,所以它们必须相互协调。如果我们调用一个方法创建文件,又调用另外一个方法删除这个文件,那么我们的封装类就必须知悉,并确保它们不会相互干扰。

如果每次调用文件操作API时都会创建一个新的文件管理实例的话,那么第一次会创建一个新文件,此时文件管理实例1只记录了创建文件这个操作;第二次删除一个文件,此时新创建的文件管理实例2只记录了删除文件这个操作,此时便出现了问题。因此我们需要确保文件管理只能有一个实例

单例模式的类型

单例模式有两种类型:

  • 懒汉式:在真正需要使用对象时才去创建该单例类对象
  • 饿汉式:在类加载时已经创建好该单例对象,等待被程序使用

懒汉式创建单例对象

懒汉式创建对象的方法是在程序使用对象前,先判断该对象是否已经实例化(判空)若已实例化直接返回该类对象。,否则则先执行实例化操作。

Unity中实现的代码如下:

using UnityEngine;

public class Singleton : MonoBehaviour
{
    private static Singleton instance;

    
    // 全局访问点
    public static Singleton GetInstance()
    {
        // 若此时该对象还没有实例,那么实例化对象;若有实例,则跳过实例化这一步
        if (instance == null)
        {
            instance = new GameObject("BulletPool").AddComponent<Singleton>();
        }
        // 返回该对象的实例
        return instance;
    }
    
}

继承了MonoBehaviour的类无法通过构造函数来实例化,这里我们直接创建一个新的gameobject,然后将该脚本挂载在它的上面。

我们在Player的Update函数中调用该单例对象试试:

结果如下:

只会创建一个实例

饿汉式创建单例对象

饿汉式在类加载时已经创建好该对象,在程序调用时直接返回该单例对象即可,即我们在编码时就已经指明了要马上创建这个对象,不需要等到被调用时再去创建。可以简单认为在程序启动时,这个单例对象就已经创建好了。

Unity中实现的代码如下:

using UnityEngine;

public class Singleton : MonoBehaviour
{
    private static Singleton instance;

    // 全局访问点
    public static Singleton Instance
    { 
        get
        {
            return instance;
        }
    }
    private void Awake()
    {
        // 在程序运行前如果该对象还未被实例化(判空),那么先实例化
        if (instance == null)
        {
            instance = this;
        }
        // 如果此时已经存在了该对象的实例,那么就将已存在的对象实例销毁(必须保证只有一个实例)
        else
        {
            Destroy(gameObject);
        }
    }
}

此时我们创建四个空物体并且都挂载上该脚本来试试效果

结果如下:

最后只剩一个实例。

当前懒汉式存在的问题

回顾一下懒汉式的核心代码(为了代码简洁,之后不再继承MonoBehaviour)

public static Singleton GetInstance() 
{
    if (instance == null) 
    {
        instance = new Singleton();
    }
    return instance;
}

试想一下,如果两个线程同时判断instance为空,那么它们都会去实例化一个instance对象,这就变成"双例"了。因此,我们要解决的是线程安全问题。

private static readonly object lockObj= new object();

public static Singleton getInstance() 
{
    lock(lockObj) 
    {   
        if (instance == null) 
        {
            instance = new Singleton();
        }
    }
    return instance;
}

该代码的执行流程为:

1.判断lockObj是否被lock了,否,则我来lock;是,则一直等待直到lockObj被释放

2.lock之后执行大括号内的代码期间其他现在不能调用大括号内的代码,也不能调用lockObj

3.执行完大括号内的代码后释放lockObj,并且大括号内的代码可以被其他线程访问

因此,执行该代码时首先判断是否被上锁,若上锁了则代表有线程在进行单例模式(懒汉式)的初始化,所以需要等待,直到初始化完成后才能进行访问。此举则避免了多线程时有多个线程同时实例化instance对象。

这样就规避了两个线程同时创建Singleton对象的风险,但是引来另外一个问题:每次去获取对象都需要先获取锁,并发性能非常地差,极端情况下,可能会出现卡顿现象。

接下来要做的就是优化性能,目标是:如果没有实例化对象则加锁创建,如果已经实例化了,则不需要加锁,直接获取实例

所以直接在方法上加锁的方式就被废掉了,因为这种方式无论如何都需要先获取锁。。

private static readonly object lockObj= new object();

public static Singleton getInstance() 
{
    // 若多个线程同时发现instance为空,则进入下一步
    if(instance == null)
    {
        // 多个线程中只有一个线程抢到锁,其他的线程则卡在这里等待
        lock(lockObj) 
        {   
            // 第一个进来的线程发现instance为空,实例化instance后释放锁
            // 同一批且被卡在锁外的线程中,第二个进来的线程发现instance不为空了
            //(被第一个进来的线程实例化了),于是直接释放锁
            if(instance == null)
            {
                instance = new Singleton();
            }       
        }
        // 这一批线程运行完毕后,之后所有的线程都不会来争夺锁了,因为instance已经实例化完毕
    }
    // 返回实例
    return instance;
}

优点(懒汉式):

1.如果我们不使用它,就不会创建实例。节省内存和CPU周期始终是好的。既然单例只在第一次被访问的时候初始化,那么如果我们的游戏始终不使用它,它就不会初始化。

2.它在运行时初始化。包含静态成员的类是单例最常见的替代品。但是静态类有一个局限:自动初始化。编译器早在main()函数调用之前就初始化静态数据了。这意味着它不能利用那些只有游戏运行起来才能知道的信息(比如,从文件中载入的配置)。它还意味着它们之间不能相互依赖——鉴于静态数据之间初始化的关联性,编译器不能保证它们之间的初始化的顺序。延迟初始化解决了以上所有问题。单例会尽可能地将初始化延后,所以到那时它们需要的信息都应该是可以得到的。只要不是循环依赖,一个单例甚至可以在其初始化时引用另一个单例。

静态构造函数用于初始化任何静态数据,或者用于执行仅需执行一次的操作;在创建第一个实例对象或者引用任何静态变量之前,将由程序自动调用静态构造函数,所以一般静态构造函数用来为静态成员初始化,或者作为单件模式中创建对象的唯一入口

3.单例可以继承,可以将它实现为一个抽象接口,所有继承它的子类都会实现单例模式

代码如下:

using UnityEngine;

public abstract class SingletonMonobehaviour<T> : MonoBehaviour where T : MonoBehaviour
{
    private static T instance;

    public static T Instance
    {
        get
        {
            return instance;
        }
    }

    protected virtual void Awake()
    {
        if (instance == null)
        {
            // 因为T是该类的子类,因此可以将this转换为T
            instance = this as T;
        }
        else
        {
            Destroy(gameObject);
        }
    }
}

小总结

(1)单例模式常见的写法有两种:懒汉式、饿汉式

(2)懒汉式:在需要用到对象时才实例化对象,正确的实现方式是:两层判空+ 锁,解决了并发安全和性能低下问题

(3)饿汉式:在类加载时已经创建好该单例对象,在获取单例对象时直接返回对象即可,不会存在并发安全和性能问题。

(4)在开发中如果对内存要求非常高,那么使用懒汉式写法,可以在特定时候才创建该对象;

(5)如果对内存要求不高使用饿汉式写法,因为简单不易出错,且没有任何并发安全和性能问题

单例模式存在的问题

单例模式需要用一个全局变量来访问,而全局变量在使用方便之余也有不少问题

1.它会令代码晦涩难懂。假设我们正在跟踪其他人写的函数中的bug。如果这个函数没有使用全局状态,那么我们只需要将精力集中在理解函数体,和传递给它的参数就可以了。但是,设想这个函数之中有个Sington.Instance.function()这样的调用。我们需要检查整个代码库来看是哪些部分访问了全局状态。

2.它会促进代码耦合。例如,我们实现一个功能“在巨石撞击地面的时候播放声音”,而我们不想让物理引擎代码与所有游戏对象的音频代码耦合起来,但如果此时,AudioPlayer这个类实例是全局可见的,那么直接调用的话就会促进代码耦合,甚至有可能打乱项目的架构

3.它对并发并不友好。当设置全局变量时,我们创建了一段内存,每个线程都能够访问和修改它,因此在多线程的情况下,我们需要小心谨慎的使用它。但仍有可能导致死锁、条件竞争和其他一些难以修复的线程同步的Bug。

懒汉式的问题

实例化一个系统需要花费时间:分配内存、加载资源等。如果实例化音频系统需要花费几百毫秒,那么我们需要控制进行实例化的时机。如果我们让它在第一次播放声音的时候延迟实例化,而游戏可能正步入高潮,那么此时的初始化将导致明显的掉帧和游戏卡顿。

同样地,游戏通常需要仔细地控制内存在堆中的布局来防止碎片化。如果我们的音频系统在初始化时分配了内存,我们需要知道初始化发生的时间,以便让我们控制它在堆中的内存布局。

参考资料:

我给面试官讲解了单例模式后,他对我竖起了大拇指!-CSDN博客

《游戏编程模式》

  • 26
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值