一文搞懂设计模式之单例模式

大家好,我是晴天,本周我们一起来学习单例模式。本文将介绍单例模式的基本属性,两种构造单例的方法(饿汉模式和懒汉模式)以及golang自带的sync.Once()方法。

什么是单例模式

GoF对单例模式的定义是:保证一个类、只有一个实例存在,同时提供能对该实例加以访问的全局访问方法。

单例模式属于创建型设计模式,单例模式能够保证一个类全局只有唯一一个实例对象。

为什么需要单例模式

在以下几种场景下,建议使用单例模式:

  1. 某些全局资源进行共享时,需要使用唯一的对象进行访问
  2. 某些实例化很费时的操作,只进行一次实例化
  3. 某些入参特别复杂的模块或者函数,只用一个实例化对象操作

单例模式的分类

  • 饿汉模式:特点是在类加载的时候就创建实例,而不是在实际使用时再进行实例化
  • 懒汉模式:特点是在实际使用的时候才进行实例化,创建实例

饿汉模式

饿汉模式,顾名思义,就是无论是否需要这个单例对象,都在程序运行时,创建这个对象,"饥饿疗法"。我们来看一下常规的一个饿汉模式的写法。

go 复制代码
package main

import "fmt"

// 单例模式要点:
/*
    1.某个类只能有一个实例
    2.该类必须自己创建这个实例
    3.该类必须给所有其他对象提供这个实例

    综述:保证一个类全局只能有一个实例对象,并提供一个全局访问点
*/

// 1.单例类需要是包内私有的,不能被外界访问到,否则就能实例化多个对象
type singletonCar struct {
    name string
}

// 2.访问单例对象的指针必须是私有指针,不能被外界访问到,否则外界就能修改这个指针的指向,导致单例对象丢失
var sc *singletonCar

// 饿汉模式
// 系统启动时就创建单例对象,无论后续是否需要
func init() {
    sc = newSingletonCar()
}

func newSingletonCar() *singletonCar {
    return &singletonCar{"BMW"}
}

// 3.对外提供的全局访问点,包外能够获得这个单例对象,只提供读权限,不提供写权限
func GetSingleCar() *singletonCar {
    return sc
}

// GetSingleCar这个方法只能是普通全局函数,不能是单例类的成员函数
// 以下注释写法是错误的,因为无法获取到单例对象,也就无法调用获取单例对象的函数
//
//  func (sc *singletonCar) GetSingleton() *singletonCar {
//     return sc
//  }

func (sc *singletonCar) PrintCarName() {
    fmt.Println(sc.name)
}

func main() {
    singleCar := GetSingleCar()
    singleCar.PrintCarName() // BMW
    singleCar2 := GetSingleCar()
    singleCar2.PrintCarName() // BMW
    fmt.Println(singleCar == singleCar2) //true
}
代码解析:
  1. 第一步声明一个单例类singletonCar
  2. 第二步声明一个指向单例对象的指针,并初始化单例对象
  3. 第三步对外提供一个全局访问函数GetSingleton来获取这个单例对象
问题讨论:

上述代码逻辑上看起来没什么问题,能正常运行。但是在实践过程中,发现了一个问题,获取到的这个单例对象,是没有办法作为其他函数的入参或者出参的,因为包外无法拿到这个单例对象的类型。

改进:

为了解决上述问题,可以给单例类封装一个接口,让包外以接口的形式访问这个单例。只需要对代码稍作调整即可。

go 复制代码
type SingletonCarInterface interface {
    PrintCarName()
}

// 3.对外提供的全局访问点,包外能够获得这个单例对象,只提供读权限,不提供写权限
func GetSingleCar() SingletonCarInterface {
    return sc
}

懒汉模式

懒汉模式,顾名思义就是在第一次获取单例对象的时候,才进行实例化。我们来看一下懒汉模式第一个版本的代码。

go 复制代码
package main

import "fmt"

// 1.单例类需要是包内私有的,不能被外界访问到,否则就能实例化多个对象
type singletonCar struct {
    name string
}

type SingletonCarInterface interface {
    PrintName()
}

// 2.访问单例对象的指针必须是私有指针,不能被外界访问到,否则外界就能修改这个指针的指向,导致单例对象丢失
var s *singletonCar

func newSingletonCar() *singletonCar {
    return &singletonCar{
       name: "BMW",
    }
}

func (sc *singletonCar) PrintName() {
    fmt.Println(sc.name)
}

// 懒汉模式在获取对象的时候才会实例化对象
func GetSingleton() SingletonCarInterface {
    // 是第一次获取对象
    if s == nil {
       s = newSingletonCar()
    }
    return s
}

func main() {
    sc := GetSingleton()
    sc.PrintName()
}
代码解析:
  1. 第一步声明一个单例类singletonCar
  2. 第二步声明一个指向单例对象的指针,但是不进行实例化
  3. 第三步对外提供一个全局访问函数GetSingleton来获取这个单例对象
  4. 第四步判断这个单例是否已经实例化,未实例化则进行实例化操作
代码问题:

上述懒汉模式代码如果是在并发场景下的话,就会存在问题,可能会有多个goroutine在同一时刻调用GetSingleton()方法获取单例对象。那么就会创建两个单例对象,其中一个单例对象会被浪费,成为内存垃圾。这就是懒汉模式所存在的并发安全问题

改进一:

那么既然存在并发安全问题,我们最先想到的解决方法就是加锁,所以就有了第二个版本的懒汉模式的代码(只体现改动部分)

csharp 复制代码
// 新增锁
var lock sync.Mutex

// 懒汉模式在获取对象的时候才会实例化对象
func GetSingleton() SingletonCarInterface {
    // 获取对象前,先加锁
    lock.Lock()
    defer lock.Unlock()
    // 不存在对象,则实例化对象
    if s == nil {
       s = newSingletonCar()
    }
    return s
}
代码解释:

获取单例对象进行加锁操作,可以保证同一时刻只有一个goroutine获取到互斥锁,从而保证只有第一个进入的goroutine能够创建这个唯一的单例对象,后面的goroutine可以获取到这个唯一的单例对象。

代码问题:

这样写虽然可以解决并发安全的问题,但是由于加锁操作,对性能影响是比较大的,所以这不是一个高效的写法。

改进二:

针对于加锁性能低下的问题,我们可以使用原子读操作来解决问题,即并不让每一个goroutine都对GetSingleton()方法获取锁,而是首先进行一个原子读操作,只有这个原子值不条件,才允许这个goroutine获取锁。这样可以大大提升性能,我们来看一下代码:

csharp 复制代码
// 新增锁
var lock sync.Mutex

// 原子读操作标记位
var syncNum uint32

// 懒汉模式在获取对象的时候才会实例化对象
func GetSingleton() SingletonCarInterface {
    if atomic.LoadUint32(&syncNum) == 1 {
       return s
    }
    // 获取对象前,先加锁
    lock.Lock()
    defer lock.Unlock()
    // 不存在对象,则实例化对象
    if s == nil {
       s = newSingletonCar()
       // 对syncNum这个标记位进行复制操作
       atomic.StoreUint32(&syncNum, 1)
    }
    return s
}
代码解释:

首先进行原子读操作,当标记位是0时,说明没有实例化过这个对象,然后进行加锁操作,实例化单例对象。

tips:atomic.LoadUint32 是 Go 语言中 sync/atomic 包提供的一个函数,用于原子性地加载一个 uint32 类型的值。这个函数的目的是在多线程或并发的情况下,确保对该变量的读取操作是原子的,不会被中断或被其他线程的写操作影响,避免竞态条件和数据竞争的问题。

饿汉模式和懒汉模式对比:

  • 饿汉模式:程序运行时,即刻创建,无论之后是否被用到,也无论性能损耗如何,说起来不够智能
  • 懒汉模式:虽然看起来比较智能,但是如果初始化方法有问题,可能会出现安全隐患

golang内置方法

golang自带sync.Once()方法,该方法能够保证内部的函数只执行一次。我们可以使用该方法来创建一个单例对象,代码如下:

go 复制代码
package main

import (
    "fmt"
    "sync"
)

// 1.单例类需要是包内私有的,不能被外界访问到,否则就能实例化多个对象
type singletonCar struct {
    name string
}

type SingletonCarInterface interface {
    PrintName()
}

// 2.访问单例对象的指针必须是私有指针,不能被外界访问到,否则外界就能修改这个指针的指向,导致单例对象丢失
var s *singletonCar

func newSingletonCar() *singletonCar {
    return &singletonCar{
       name: "BMW",
    }
}

func (sc *singletonCar) PrintName() {
    fmt.Println(sc.name)
}

var once sync.Once

// 3.使用sync.Once来保证只实例化一次
func GetSingleton() SingletonCarInterface {
    once.Do(func() {
       s = newSingletonCar()
    })
    return s
}

func main() {
    sc := GetSingleton()
    sc.PrintName()
}

可以看到,once.Do的源码内部也是使用了原子读操作来创建的单例

go 复制代码
func (o *Once) Do(f func()) {
    // Note: Here is an incorrect implementation of Do:
    //
    // if atomic.CompareAndSwapUint32(&o.done, 0, 1) {
    //    f()
    // }
    //
    // Do guarantees that when it returns, f has finished.
    // This implementation would not implement that guarantee:
    // given two simultaneous calls, the winner of the cas would
    // call f, and the second would return immediately, without
    // waiting for the first's call to f to complete.
    // This is why the slow path falls back to a mutex, and why
    // the atomic.StoreUint32 must be delayed until after f returns.

    if atomic.LoadUint32(&o.done) == 0 {
       // Outlined slow-path to allow inlining of the fast-path.
       o.doSlow(f)
    }
}

总结:

本文介绍了什么是单例模式(一个类全局只能存在一个实例对象,并且对外只提供一个访问点);用途有哪些场景(访问全局资源;初始化操作很耗时;作为模块或者函数入参非常复杂时);按照对象创建时机不同,分为饿汉模式和懒汉模式两种(饿汉模式:程序启动时创建,懒汉模式:需要用到时创建)以及饿汉模式和懒汉模式的各种使用情况以及有哪些问题。

写在最后:

感谢大家的阅读,晴天将继续努力,分享更多有趣且实用的主题,如有错误和纰漏,欢迎给予指正。 更多文章敬请关注作者个人公众号 晴天码字。 我们下期不见不散,to be continued...

本文由mdnice多平台发布

相关推荐
沈韶珺1 小时前
Visual Basic语言的云计算
开发语言·后端·golang
沈韶珺1 小时前
Perl语言的函数实现
开发语言·后端·golang
美味小鱼2 小时前
Rust 所有权特性详解
开发语言·后端·rust
我的K84092 小时前
Spring Boot基本项目结构
java·spring boot·后端
慕璃嫣3 小时前
Haskell语言的多线程编程
开发语言·后端·golang
晴空๓3 小时前
Spring Boot项目如何使用MyBatis实现分页查询
spring boot·后端·mybatis
Hello.Reader7 小时前
深入浅出 Rust 的强大 match 表达式
开发语言·后端·rust
customer0810 小时前
【开源免费】基于SpringBoot+Vue.JS体育馆管理系统(JAVA毕业设计)
java·vue.js·spring boot·后端·开源
计算机-秋大田13 小时前
基于微信小程序的电子竞技信息交流平台设计与实现(LW+源码+讲解)
spring boot·后端·微信小程序·小程序·课程设计