go中单例模式以及使用反射破坏单例的方法

单例模式保证一个类或者结构体在整个应用程序的生命周期中只存在一个实例,并且提供一个全局的访问点来获取这个实例,主要用于管理共享资源和控制全局状态。

例如在管理配置中,程序自动读取config.yaml并且映射到config结构体中,因为配置数据在运行期间是只读且全局共享的,不需要每次使用都重新读取文件,保留一份内存副本即可。还有初始化 sql.DB 或 Redis Client,建立连接是昂贵的操作(TCP 握手等),通常希望在全局复用同一个连接池对象,而不是频繁创建和销毁。

我们都知道单例模式有饿汉和懒汉两种实现方式,下面先介绍饿汉式。

饿汉式在程序启动(包加载)阶段就立刻创建实例,不管后面用不用得到。利用 Go 的包初始化机制(全局变量初始化或 init 函数),这是天然线程安全的。在main函数执行之前,Go 的 Runtime(运行时)接管程序的初始化阶段,在这个阶段,Go Runtime 只启动了一个主 Goroutine来负责所有的初始化工作,既然只有一个Goroutine 在跑,不存在第二个 Goroutine 去争抢资源,自然也就不存在数据竞争,所以它是绝对线程安全的。

下面是示例代码:

go 复制代码
package singleton

type eagerSingleton struct {
    Name string
}

// 在包加载时直接实例化
// 优点:没有线程安全问题,Go 的 runtime 保证全局变量初始化是原子的。
var eagerInstance = &eagerSingleton{Name: "Eager"}

// 提供一个公开访问接口
func GetEagerInstance() *eagerSingleton {
    return eagerInstance
}

优点是实现极其简单,而且不需要考虑线程安全加锁相关,性能好。缺点是如果这个对象初始化非常耗时(比如加载巨大的模型文件)或者占用大量内存,但程序运行很久后才用到(甚至完全没用到),就会造成启动慢和内存浪费。

懒汉式实例在第一次被调用 GetInstance 时才创建。这就避免了饿汉式的内存浪费问题,缺点就是第一次访问时需要同步等待初始化完成,稍微有一点点性能损耗。

go 复制代码
type Singleton struct {
	Data string
}

var (
	instance *Singleton
	mu       sync.Mutex
)

func GetInstance() *Singleton {
	if instance == nil {
		mu.Lock()
		defer mu.Unlock()

		if instance == nil {
			instance = &Singleton{Data: "I am Singleton"}
		}
	}
	return instance
}

GetInstance中,第一次检测是否为nil是为了性能优化,如果实例已经被创建了,就没必要去抢锁了。直接返回实例,避免了锁竞争带来的性能损耗。如果没被创建就加锁,保证同一时刻只有一个协程能进入初始化环节。第二次检查是否为nil是为了防止重复创建,假设协程 A 和 B 同时通过了第一层检查。A 抢到了锁,创建了实例并释放锁。此时 B 拿到锁进来,如果没有第二次检查,B 就会再次创建一个新的实例,覆盖掉 A 创建的,从而破坏了单例的唯一性。

上述逻辑也可以使用sync.Once代替,如下:

go 复制代码
type Singleton struct {
	Data string
}

var (
	instance *Singleton
	once     sync.Once
)

func GetInstance() *Singleton {
	// once.Do 接收一个函数,该函数在整个程序生命周期中只会被执行一次
	once.Do(func() {
		fmt.Println("Initializing Singleton...")
		instance = &Singleton{Data: "I am Singleton"}
	})
	return instance
}

其中once.Do()这个方法可以保证函数只会在程序的生命周期内被调用一次,所以可以确保线程安全,不会重复初始化单例。

有关sync.Once的部分也可以看这篇文章:geektutu.com/post/hpg-sy...

go 复制代码
// 简化版源码分析
package sync

import (
	"sync/atomic"
)

type Once struct {
	done uint32

	m    Mutex
}

func (o *Once) Do(f func()) {
	if atomic.LoadUint32(&o.done) == 0 {
		o.doSlow(f)
	}
}

func (o *Once) doSlow(f func()) {
	o.m.Lock()
	defer o.m.Unlock()
	if o.done == 0 {
		defer atomic.StoreUint32(&o.done, 1)
		f()
	}
}

once.Do()中,第一行代码 atomic.LoadUint32(&o.done)是检测快速路径的原子读取,一旦单例创建完成,后续成千上万次的调用都只会走这一行代码。原子读的性能损耗极低(纳秒级),这比 Mutex.Lock() 快得多。

如果检测到单例没有创建完成,就会走到doSlow()路径,实际上就是我们之前实现的两次检测是否为nil的流程。

接下来我们想想,有没有什么方法可以破坏单例模式呢,比如说创建两个。因为结构体是小写的 type singleton struct,在 main 包里根本无法写出 &singleton.singleton{},编译器会直接报错,而且使用sync.Once的话,核心控制字段 done 是小写的(未导出的)。你不能在外部写 once.done = 0,编译器也会报错。

看起来我们束手无策。Go 语言的编译器像一个尽职的保安,因为它看到了首字母小写的字段,就严格禁止我们在包外进行任何读写操作。按照正常的编程逻辑,只要单例初始化过一次,那个 done 标记就永远是 1,我们永远无法触发第二次初始化。

当程序运行起来后,所有的结构体、所有的私有字段,本质上都只是内存中的一段段字节序列。如果我们能绕过编译器的静态检查,直接找到存储 done 标记的那块内存,并将它强行改写为 0,sync.Once 就会失忆。下次我们再调用 GetInstance() 时,它就会以为自己从没运行过,从而再次执行初始化代码,创建出第二个实例。

这可以使用反射做到,反射允许我们在运行时动态地检查变量的类型和值,甚至窥探那些未导出的私有字段。虽然 Go 的反射库出于安全考虑,默认禁止修改私有字段(会触发 Panic),但配合 unsafe 指针操作,我们就能获得上帝视角,对内存进行修改。

go 复制代码
package main

import (
	"fmt"
	"reflect"
	"sync"
	"sync/atomic"
	"unsafe"
)

type singleton struct {
	data string
}

type SingletonManager struct {
	instance *singleton
	once     sync.Once
}

var Mgr = &SingletonManager{}

func (m *SingletonManager) GetInstance() *singleton {
	m.once.Do(func() {
		fmt.Println(">>> 单例初始化逻辑执行中...")
		m.instance = &singleton{data: "UniqueInstance"}
	})
	return m.instance
}

func main() {
	Mgr.GetInstance()
	fmt.Println("第一次获取完成。\n")

	fmt.Println("正在通过反射重置 sync.Once 状态...")

	val := reflect.ValueOf(Mgr).Elem()
	
	onceField := val.FieldByName("once")
	doneField := onceField.FieldByName("done")

	var finalField reflect.Value

	if doneField.Kind() == reflect.Struct {
		for i := 0; i < doneField.NumField(); i++ {
			if doneField.Field(i).Kind() == reflect.Uint32 {
				finalField = doneField.Field(i)
				break
			}
		}
	} else {
		finalField = doneField
	}

	if !finalField.IsValid() {
		panic("无法定位 done 标记位")
	}

	ptr := (*uint32)(unsafe.Pointer(finalField.UnsafeAddr()))

	atomic.StoreUint32(ptr, 0)
	fmt.Println("重置成功!\n")

	Mgr.GetInstance()
	fmt.Println("第二次获取完成(注意上方是否再次打印了初始化日志)。")
}

执行结果:

erlang 复制代码
>>> 单例初始化逻辑执行中...
第一次获取完成。

正在通过反射重置 sync.Once 状态...
重置成功!

>>> 单例初始化逻辑执行中...
第二次获取完成(注意上方是否再次打印了初始化日志)。

由于 Mgr 是一个指针,我们需要先调用 .Elem() 来"解引用",拿到它指向的实际 SingletonManager 结构体,否则无法访问其内部字段。

只会我们使用了 FieldByName。即使 oncedone 是私有字段(首字母小写),反射依然能通过名称找到它们的元数据。此时我们只能看,不能动,因为如果直接调用 Set() 方法修改它们,Go 会直接 Panic。

Go 1.23 之前,sync.Oncedone 标记只是一个普通的 uint32 整数。但在 Go 1.23 及更高版本中,为了更好的语义,官方将其改为了 atomic.Uint32 结构体。我们的代码会自动检测 done 的类型,如果是结构体,它会遍历内部字段,找到那个真正存储数据的 uint32 字段。

最关键的就是ptr := (*uint32)(unsafe.Pointer(finalField.UnsafeAddr()))这一行,直接返回该字段在内存中的物理地址,将这个地址转换为通用指针,剥离了所有原有的类型保护,告诉编译器,把这块内存当成一个普通的整数指针给我处理。

最后,我们使用原子操作将该内存地址的值强制写回 0。 对于 sync.Once 来说,它判断是否初始化的唯一依据就是这块内存的值。一旦变为 0,它就"失忆"了------下次调用 Do 方法时,它会认为自己从未运行过,从而允许单例被再次创建。

如果不使用sync.Once的懒汉单例模式也可以使用类似方式破坏:

go 复制代码
// 假设目标是 var instance *singleton

// 1. 获取 instance 变量的反射值
val := reflect.ValueOf(Mgr).Elem()
instanceField := val.FieldByName("instance")

// 2. 强行设置为 nil
// 注意:这里需要绕过私有检查,和之前一样使用 unsafe
ptr := (*unsafe.Pointer)(unsafe.Pointer(instanceField.UnsafeAddr()))
*ptr = nil // 归零!

// 3. 再次调用 GetInstance
// 此时 instance == nil 成立,由于是懒汉式,它会重新 new 一个对象

但是对于饿汉单例模式就不一样了,

go 复制代码
func GetInstance() *singleton {
    return instance //没有任何判断逻辑,直接返回变量
}

如果你把 instance 设为 nil, 调用 GetInstance() 就会返回 nil。你成功把单例搞挂了,但你没有让它重新创建一个,程序大概率会 Panic。

如果你想创建另外一个单例,你必须手动通过反射创建一个新的 singleton 对象(利用 reflect.New),然后强行把这个新对象的地址塞给全局变量 instance。

相关推荐
bcbnb1 小时前
iOS 反编译防护工具全景解析 从底层符号到资源层的多维安全体系
后端
Java水解1 小时前
GO语言特性介绍,看这一篇就够了!
后端·go
掘金泥石流2 小时前
分享下我创业烧了 几十万的 AI Coding 经验
前端·javascript·后端
武藤一雄2 小时前
C#:Linq大赏
windows·后端·microsoft·c#·.net·.netcore·linq
Andy工程师2 小时前
Spring Boot 按照以下顺序加载配置(后面的会覆盖前面的):
java·spring boot·后端
繁星蓝雨2 小时前
小试Spring boot项目程序(进行get、post方法、打包运行)——————附带详细代码与示例
java·spring boot·后端
山枕檀痕2 小时前
Spring Boot中LocalDateTime接收“yyyy-MM-dd HH:mm:ss“格式参数的最佳实践
java·spring boot·后端
Java水解2 小时前
【Spring Boot 单元测试教程】从环境搭建到代码验证的完整实践
后端·spring
Lear2 小时前
【JavaSE】动态代理技术详解与案例实战
后端