1. 什么是单例模式?
我认为单例模式的核心就是:确保一个类只有一个实例被创建,在其他地方都可以访问到这个实例。
2. 什么场景用单例模式?
在实习公司现在的项目中,感觉Config、Logger、DB的Conn 其实这种都可以使用单例模式。
3. 懒汉模式 vs 饿汉模式
- 饿汉模式:饥饿,所以没等程序运行就要创建单例。
- 懒汉模式:懒蛋,所以只有当单例第一次被使用的时候,才会被创建。
各自优缺点:
- 懒汉模式相比于饿汉模式更节省内存,如果单例十分大,无论是否被使用都创建,对内存是个浪费。
- 饿汉模式相对来说实现简单,没有并发问题。
3.1 饿汉模式
饿汉模式我先是看了 刘丹冰老师 的代码示例,实现很简单,也讲清楚了哪些地方需要导出,哪些地方需要保持私有。
3.1.1 饿汉v1
golang
代码解读
复制代码
// 1. 为什么这里singleton不能导出? // 答:防止外部通过类创建实例。 type singleton struct {} // 2. 为什么这里s不能导出? // 答:防止外部更改指针。比如s=nil。 var s *singleton = new(singleton) // 3. 为什么这里GetInstance是导出的? // 答:需要让外部使用。 // 4. 这能不能写成 singleton 类的结构体方法? // 答:不能。因为,结构体方法需要实例才能使用,这个就是创建实例的方法。 func GetInstance() *singleton { return s }
3.1.1.1 存在问题
但是这个程序有个啥问题呢?就是 「导出的方法返回了私有的变量」,我感觉这样写是bug。因为恶意代码仍然可以修改指针,不符合单例。该代码的问题如下:
golang
代码解读
复制代码
package main import "fmt" type singleton struct { name string } var s *singleton = &singleton{ name: "singleton", } func GetInstance() *singleton { return s } func main() { instance := GetInstance() fmt.Printf("instance: %v\n", instance) // 输出:instance: &{singleton} // 恶意代码仍然可以修改指针 *instance = singleton{ name: "new singleton", } a := GetInstance() fmt.Printf("a: %v\n", a) // 输出:a: &{new singleton} }
3.1.2 饿汉v2
然后参考了 小徐先生的编程世界 的公众号,他的介绍单例模式的文章里,提到了这个问题(但是没有写我的例子),他认为这只是一种代码规范问题,并给出了正确的写法:定义一个接口,获取实例的方法返回的是抽象的接口而不是指向实例的指针。
golang
代码解读
复制代码
type Instance interface { Work() } type singleton struct{} // singleton类实现Instance接口 func (s *singleton) Work() { fmt.Println("working...") } var s *singleton = new(singleton) func GetInstance() Instance { return s }
3.2 懒汉模式
3.2.1 懒汉模式v1
这个版本就是错的,存在并发问题。
为啥有并发问题?
设想单例还没有被创建,A,B两个协程同时尝试创建单例,都发现s == nil,于是都创建了单例。单例 却被 重复创建,很显然是不行的。会导致内存泄露。
golang
代码解读
复制代码
type Instance interface { Work() } type singleton struct{} // singleton类实现Instance接口 func (s *singleton) Work() { fmt.Println("working...") } var s *singleton func GetInstance() Instance { if s == nil { s = new(singleton) } return s }
3.2.2 懒汉模式v2
那简单啊,加锁呗。但是你要知道,单例虽然只被创建一次,但是会被获取很多次,每次获取都要有锁竞争,性能又出问题。
golang
代码解读
复制代码
type Instance interface { Work() } type singleton struct{} // singleton类实现Instance接口 func (s *singleton) Work() { fmt.Println("working...") } var s *singleton var lock sync.Mutex func GetInstance() Instance { lock.Lock() defer lock.Unlock() if s == nil { s = new(singleton) } return s }
3.2.3 懒汉模式v3
你说,那么既然大多数情况下实例已经被创建了,那我们就先判断呗,如果实例已经存在,就不加锁,直接返回。这样多数情况下不会加锁,岂不美哉?
但现实并非如此,这样仍然可能有并发问题:
- 时刻1: A 和 B两个 Goroutine 同时尝试获取实例, 都发现 s==nil,于是继续执行。
- 时刻2: A率先获取到锁,正常创建实例。B 没获取到锁,等待锁。
- 时刻3: A释放锁。
- 时刻4: B获取到锁,开始创建实例。(啊哦~,实例还是被创建了2次捏)
- 时刻5: B释放锁。
golang
代码解读
复制代码
type Instance interface { Work() } type singleton struct{} // singleton类实现Instance接口 func (s *singleton) Work() { fmt.Println("working...") } var s *singleton var lock sync.Mutex func GetInstance() Instance { if s != nil { return s } lock.Lock() defer lock.Unlock() s = new(singleton) return s }
3.2.4 懒汉模式v4
正确的终于来了,双重检查锁!
基于v3的并发问题的场景,在v4就不会存在。
- 时刻1: A 和 B两个 Goroutine 同时尝试获取实例, 都发现 s==nil,于是继续执行。
- 时刻2: A率先获取到锁,正常创建实例。B 没获取到锁,等待锁。
- 时刻3: A释放锁。
- 时刻4: B获取到锁,第二次检查,发现实例已经存在,不会创建实例。(That‘s the difference!)
- 时刻5: B释放锁。
golang
代码解读
复制代码
type Instance interface { Work() } type singleton struct{} // singleton类实现Instance接口 func (s *singleton) Work() { fmt.Println("working...") } var s *singleton var lock sync.Mutex func GetInstance() Instance { // 第一次检查 if s != nil { return s } lock.Lock() defer lock.Unlock() // 第二次检查 if s == nil { s = new(singleton) } return s }
4. Go语言内置sync.Once()单例模式实现
golang
代码解读
复制代码
type Once struct { done atomic.Uint32 m Mutex } func (o *Once) Do(f func()) { if o.done.Load() == 0 { // first check o.doSlow(f) } } func (o *Once) doSlow(f func()) { o.m.Lock() defer o.m.Unlock() if o.done.Load() == 0 { // double check defer o.done.Store(1) f() } }
也是一种「双重检查锁」的实现方式。先在外部检查一次,如果32位无符号整数done为1,则说明函数f已经被执行。如果32位无符号整数done为0, 则说明函数f还没有被执行,则加锁执行函数,并更新done的值为1.
4.1 为啥要用atomic.Uint32?
Go标准库的思想还是「双重检查锁」的思想,但是,为啥要用atomic.Uint32而不是uint32,你想过没?
我还真想过。没人给我解答,这就是一个人学习的迷茫。
uint32 代码如下:
golang
代码解读
复制代码
type Once struct { done uint32 m Mutex } func (o *Once) Do(f func()) { if done == 0 { o.doSlow(f) } } func (o *Once) doSlow(f func()) { o.m.Lock() defer o.m.Unlock() if done == 0 { done = 1 f() } }
DeepSeek 给了我一个解释:
在极端情况下,如果没有原子操作,编译器和CPU可能对指令重排。例如:
- 在
doSlow
中,done = 1
的写入被重排到解锁(o.m.Unlock()
)之后。 - 另一个goroutine可能在
done = 1
生效前获取锁,并再次执行f()
。
虽然Go的Mutex
本身会插入内存屏障,但用户代码中done
的读写未受保护,仍存在理论上的风险。