Loading

设计模式之单例模式

设计模式之单例模式

Intro

一个类只允许创建唯一一个对象(或者实例),那这个类就是一个单例类,这种设计模式就叫作单例设计模式,简称单例模式。

单例模式可能是大家听说最多的设计模式了,网上介绍最多的设计模式大概就是单例模式了,我看过的设计模式相关的文章很多都是写一篇介绍单例模式,然后就没有了。

经典的设计模式有 23 种, 如果随便抓一个程序员,让他说一说最熟悉的 3 种设计模式,那其中肯定会包含今天要讲的单例模式,

使用场景

单例模式主要用来确保某个类型的实例只能有一个。比如手机上的蓝牙之类的只能有一个的实例的场景可以考虑用单例模式。

主要作用:

  • 处理资源访问冲突,比如说上面说的系统唯一硬件,系统文件访问冲突等
  • 表示全局唯一类,比如系统中的唯一 id 生成器

单例模式的实现

单例模式的实现,通常需要私有化构造方法,防止外部类直接使用单例类的构造方法创建对象

简单非线程安全的实现

public class Singleton
{
    private static Singleton _instance;

    private Singleton()
    {
    }

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

        return _instance;
    }
}

这种方式比较简单,但是不是线程安全的,多线程高并发情况下可能会导致创建多个实例,但是如果你的业务场景允许创建多个,我觉得问题也不大,如果一定要保证只能创建一个实例,可以参考下面的做法

双检锁(懒汉式)

/// <summary>
/// 双重判空加锁,饱汉模式(懒汉式),用到的时候再去实例化
/// </summary>
public class Singleton
{
    private static Singleton _instance;
    private static readonly object SyncLock = new object();

    private Singleton()
    {
    }

    public static Singleton GetInstance()
    {
        if (_instance == null)
        {
            lock (SyncLock)
            {
                if (_instance == null)
                {
                    _instance = new Singleton();
                }
            }
        }

        return _instance;
    }
}

这种方式的执行过程会先检查是否完成了实例化,如果已经实例化则直接返回实例,如果没有就尝试获取锁,获得锁之后再判断一下是否已经实例化,如果已经实例化则返回实例,如果没有就进行实例化

静态初始化(饿汉式)

/// <summary>
/// 饿汉模式-就是屌丝,担心饿死。类加载就给准备好
/// </summary>
public sealed class Singleton1
{
    /// <summary>
    /// 静态初始化,由 CLR 去创建,无需加锁
    /// </summary>
    private static readonly Singleton1 Instance = new Singleton1();

    private Singleton1()
    {
    }

    public static Singleton1 GetInstance() => Instance;
}

这也是一种常见的实现单例模式的用法,但是这种方式就不支持懒加载了,不像上面那种方式可以做到需要的时候再实例化,适用于这个对象会被频繁使用或者这个类比较小,是否实例化没有什么影响。

并发字典型

这个是之前忘记在哪里看到的微软框架里的一段代码,类似,可能和源码并不完全一样,只是提供一种实现思路

/// <summary>
/// 使用 ConcurrentDictionary 实现的单例方法,用到的时候再去实例化
/// 这种方式类似于第一种方式,只是使用了并发集合代替了双重判断和 lock
/// </summary>
public class Singleton2
{
    private static readonly ConcurrentDictionary<int, Singleton2> Instances = new ConcurrentDictionary<int, Singleton2>();

    private Singleton2()
    {
    }

    public static Singleton2 GetInstance() => Instances.GetOrAdd(1, k => new Singleton2());
}

Lazy

C# 里提供了 Lazy 的方式实现延迟实例化

/// <summary>
/// 使用 Lazy 实现的单例方法,用到的时候再去实例化
/// </summary>
public class Singleton3
{
    private static readonly Lazy<Singleton3>
        LazyInstance = new Lazy<Singleton3>
        (() => new Singleton3());

    private Singleton3()
    {
    }

    public static Singleton3 GetInstance() => LazyInstance.Value;
}

其他

你也可以使用内部类, Interlocked 等实现方式,这里就不介绍了,想了解可以自己网上找一下

验证是否线程安全,验证示例代码:

Console.WriteLine($"Singleton");
Enumerable.Range(1, 10).Select(i => Task.Run(() =>
{
  Console.WriteLine($"{Singleton.GetInstance().GetHashCode()}");
})).WhenAll().Wait();

Console.WriteLine($"Singleton1");
Enumerable.Range(1, 10).Select(i => Task.Run(() =>
{
  Console.WriteLine($"{Singleton1.GetInstance().GetHashCode()}");
})).WhenAll().Wait();

Console.WriteLine($"Singleton2");
Enumerable.Range(1, 10).Select(i => Task.Run(() =>
{
  Console.WriteLine($"{Singleton2.GetInstance().GetHashCode()}");
})).WhenAll().Wait();

Console.WriteLine($"Singleton3");
Enumerable.Range(1, 10).Select(i => Task.Run(() =>
{
  Console.WriteLine($"{Singleton3.GetInstance().GetHashCode()}");
})).WhenAll().Wait();

上面的 WhenAll 是一个扩展方法,就是调用的 Task.WhenAll,输出示例:

单例模式的存在的问题

  • 单例对 OOP 特性的支持不友好,使用单例模式通常也就意味着放弃了 OOP 的继承,多态特性
  • 单例会隐藏类之间的依赖关系,单例模式,不允许显示 new,使得对象的创建过程对外部来说是不可见的,内部有哪些依赖对外也是不可见的,这样在系统重构的时候就会很危险,很容易造成系统出现问题
  • 单例对代码的扩展性不友好,单例类只能有一个对象实例。如果未来某一天,我们需要在代码中创建两个实例或多个实例,那就要对代码有比较大的改动
  • 单例对代码的可测试性不友好,如果单例类依赖比较重的外部资源,比如 DB,我们在写单元测试的时候,希望能通过 mock 的方式将它替换掉。而单例类这种硬编码式的使用方式,导致无法实现 mock 替换
  • 单例不支持有参数的构造函数,单例模式通常使用私有构造方法,而且只会调用一次构造方法,所以通常不支持构造方法参数,如果有参数通常会给调用方造成误解,两次调用传递的参数不一致的时候如何处理是一个问题

More

随着现在依赖注入思想的普及,asp.net core 更是基于依赖框架构建的,使用依赖注入的方式可以较好的解决上面的各种问题

基于依赖注入框架,你可以不必担心对象的创建和销毁,让依赖注入框架管理对象,这样这个要实现单例模式的类型可以和其他普通类型一样,只需要使用依赖注入框架注册服务的时候指定服务生命周期为单例即可,比如使用微软的依赖注入框架的时候可以使用 services.AddSingleton<TSingletonService>(); 来注册单例服务

关于使用双检锁实现单例的时候是否要使用 volatile 的问题,在 C# 如果你使用了 lock 就没有必要再去用 volatile 标记要同步的对象了,

volatile 的主要是用在于解决多个CPU上运行的多个线程可以并且将缓存数据和指令重新排序的问题。

如果它不是 volatile 的,并且CPU A递增了一个值,则CPU B可能直到一段时间后才能真正看到该递增的值,这可能会引起问题。
如果它是 volatile 的,则仅确保两个CPU同时看到相同的数据。 它根本不会阻止他们交错读取和写入操作,而这正是您要避免的问题。

使用 lock 也可以防止上述多CPU重新排序问题,所以使用了 lock 就可以不需要再 volatile

很多 Java 的单例模式实现强调要使用 volatile 关键词来防止指令重新排序的问题,但是实际上可能并不需要,王争在他的设计模式专栏中指出只有很低的 JDK 版本才需要这样做,我们现在用的高版本的 JDK 已经在内部处理了,不需要再加 volatile

Reference

posted @ 2020-07-15 01:02  WeihanLi  阅读(482)  评论(0编辑  收藏