Go 和 Java 對比學(xué)習(xí):單例模式
Java 是較典型的面向?qū)ο笳Z言。如果說 C++ 是設(shè)計模式的發(fā)源地(GoF 的書使用 C++ 描述的),那么 Java 將設(shè)計模式發(fā)揚光大。設(shè)計模式,很多人可能工作中沒有用到,因為大部分人停留在寫面條式的業(yè)務(wù)代碼,從頭擼到尾,沒有設(shè)計可言。但實際上,只要你用心思考,這樣的場景下也是很有可能用上設(shè)計模式的。特別是,當(dāng)系統(tǒng)復(fù)雜時,設(shè)計模式的作用會很明顯。
雖然 Go 語言并非完全的面向?qū)ο笳Z言,只提供了部分面向?qū)ο蟮奶匦裕恍┰O(shè)計模式還是可以使用的。這個系列嘗試講解在 Go 中使用設(shè)計模式,同時給出 Java 對應(yīng)的版本,進行對比學(xué)習(xí)。另外,我們的設(shè)計模式不會局限在 GoF 的 23 中設(shè)計模式之中。
在開始設(shè)計模式之前,有必要提一下面向?qū)ο蟮?SOLID 5 大設(shè)計原則:
名稱縮寫含義The Single Responsibility Principle(單一職責(zé))S對象應(yīng)該具有單一的職責(zé)。這也是 Unix 的設(shè)計哲學(xué)The Open/Closed Principle(開/閉原則)O對擴展開發(fā),對修改關(guān)閉The Liskov Substitution Principle(里氏替換)L對象應(yīng)該可以在不破壞系統(tǒng)的情況下被子對象替換The Interface Segregation Principle(接口隔離)I不應(yīng)強迫任何客戶端依賴其不使用的方法The Dependency Inversion Principle(依賴倒轉(zhuǎn))D高級模塊不應(yīng)依賴于低級實現(xiàn)
遵循這樣的設(shè)計原則,你的系統(tǒng)會更好維護。
除了 SOLID 5 大設(shè)計原則,一些書上可能還會提到下面的設(shè)計原則:
- 合成/聚合復(fù)用原則(Composite/Aggregate Reuse Principle):盡量使用合成/聚合,而不要使用繼承。這也是 Go 語言設(shè)計遵循的,基于此,Go 中沒有繼承。
- 迪米特法則(LoD),又叫 最少知識原則:一個對象應(yīng)當(dāng)對其他對象有盡可能少的了解;一個軟件實體應(yīng)當(dāng)與盡可能少的其他實體發(fā)生相互作用。
在你日常的工作中,可以運用以上原則審視你的設(shè)計,改進你的設(shè)計。
今天先看第一個設(shè)計模式。
1、單例模式簡介
面向?qū)ο笾械膯卫J绞且粋€常見、簡單的模式。
英文名稱:Singleton Pattern,該模式規(guī)定一個類只允許有一個實例,而且自行實例化并向整個系統(tǒng)提供這個實例。因此單例模式的要點有:1)只有一個實例;2)必須自行創(chuàng)建;3)必須自行向整個系統(tǒng)提供這個實例。
單例模式主要避免一個全局使用的類頻繁地創(chuàng)建與銷毀。當(dāng)你想控制實例的數(shù)量,或有時候不允許存在多實例時,單例模式就派上用場了。
先看 Java 中的單例模式。
通過該類圖我們可以看出,實現(xiàn)一個單例模式有如下要求:
- 私有、靜態(tài)的類實例變量;
- 構(gòu)造函數(shù)私有化;
- 靜態(tài)工廠方法,返回此類的唯一實例;
根據(jù)實例化的時機,單例模式一般分成餓漢式和懶漢式。
- 餓漢式:在定義 instance 時直接實例化,private static Singleton instance = new Singleton();
- 懶漢式:在 getInstance 方法中進行實例化;
那兩者有什么區(qū)別或優(yōu)缺點?餓漢式單例類在自己被加載時就將自己實例化。即便加載器是靜態(tài)的,餓漢式單例類被加載時仍會將自己實例化。單從資源利用率角度講,這個比懶漢式單例類稍差些。從速度和反應(yīng)時間角度講,則比懶漢式單例類稍好些。然而,懶漢式單例類在實例化時,必須處理好在多個線程同時首次引用此類時的訪問限制問題,特別是當(dāng)單例類作為資源控制器在實例化時必須涉及資源初始化,而資源初始化很有可能耗費時間。這意味著出現(xiàn)多線程同時首次引用此類的幾率變得較大。
2、單例模式的 Java 實現(xiàn)
結(jié)合上面的講解,以一個計數(shù)器為例,我們看看 Java 中餓漢式的實現(xiàn):
- public class Singleton {
- private static final Singleton instance = new Singleton();
- private int count = 0;
- private Singleton() {}
- public static Singleton getInstance() {
- return instance;
- } public int Add() int {
- this.count++;
- return this.count;
- }}
代碼很簡單,不過多解釋。直接看懶漢式的實現(xiàn):
- public class Singleton {
- private static Singleton instance = null;
- private int count = 0;
- private Singleton() {}
- public static synchronized Singleton getInstance() {
- if (instance == null) {
- instance = new Singleton();
- } return instance;
- } public int Add() int {
- this.count++;
- return this.count;
- }}
主要區(qū)別在于 getInstance 的實現(xiàn),要注意 synchronized ,避免多線程時出現(xiàn)問題。
3、單例模式的 Go 實現(xiàn)
在 Go 語言中如何實現(xiàn)單例模式,類比 Java 代碼實現(xiàn)。
- // 餓漢式單例模式
- package singleton
- type singleton struct { count int
- }var Instance = new(singleton)func (s *singleton) Add() int {
- s.count++ return s.count
- }
前面說了,Go 只支持部分面向?qū)ο蟮奶匦裕虼丝雌饋碛悬c不太一樣:
- 類(結(jié)構(gòu)體 singleton)本身非公開(小寫字母開頭,非導(dǎo)出);
- 沒有提供導(dǎo)出的 GetInstance 工廠方法(Go 沒有靜態(tài)方法),而是直接提供包級導(dǎo)出變量 Instance;
這樣使用:
- c := singleton.Instance.Add()
看看懶漢式單例模式在 Go 中如何實現(xiàn):
- // 懶漢式單例模式
- package singleton
- import ( "sync"
- )type singleton struct {
- count int}var ( instance *singleton mutex sync.Mutex)func New() *singleton { mutex.Lock() if instance == nil {
- instance = new(singleton) } mutex.Unlock() return instance
- }func (s *singleton) Add() int { s.count++ return s.count
- }
代碼多了不少:
- 包級變量變成非導(dǎo)出(instance),注意這里類型應(yīng)該用指針,因為結(jié)構(gòu)體的默認值不是 nil;
- 提供了工廠方法,按照 Go 的慣例,我們命名為 New();
- 多 goroutine 保護,對應(yīng) Java 的 synchronized,Go 使用 sync.Mutex;
關(guān)于懶漢式有一個“雙重檢查”,這是 C 語言的一種代碼模式。
在上面 New() 函數(shù)中,同步化(鎖保護)實際上只在 instance 變量第一次被賦值之前才有用。在 instance 變量有了值之后,同步化實際上變成了一個不必要的瓶頸。如果能夠有一個方法去掉這個小小的額外開銷,不是更加完美嗎?因此出現(xiàn)了“雙重檢查”??纯?Go 如何實現(xiàn)“雙重檢查”,只看 New() 代碼:
- func New() *singleton {
- if instance == nil { // 第一次檢查(①)
- // 這里可能有多于一個 goroutine 同時達到(②)
- mutex.Lock()
- // 這里每個時刻只會有一個 goroutine(③)
- if instance == nil { // 第二次檢查(④)
- instance = new(singleton)
- }
- mutex.Unlock()
- }
- return instance
- }
有讀者可能看不懂上面代碼的意思,這里詳細解釋下。假設(shè) goroutine X 和 Y 作為第一批調(diào)用者同時或幾乎同時調(diào)用 New 函數(shù)。
- 因為 goroutine X 和 Y 是第一批調(diào)用者,因此,當(dāng)它們進入此函數(shù)時,instance 變量是 nil。因此 goroutine X 和 Y 會同時或幾乎同時到達位置 ①;
- 假設(shè) goroutine X 會先達到位置 ②,并進入 mutex.Lock() 達到位置 ③。這時,由于 mutex.Lock 的同步限制,goroutine Y 無法到達位置 ③,而只能在位置 ② 等候;
- goroutine X 執(zhí)行 instance = new(singleton) 語句,使得 instance 變量得到一個值,即對 singleton 實例的引用。此時,goroutine Y 只能繼續(xù)在位置 ② 等候;
- goroutine X 釋放鎖,返回 instance,退出 New 函數(shù);
- goroutine Y 進入 mutex.Lock(),到達位置 ③,進而到達位置 ④。由于 instance 變量已經(jīng)不是 nil,因此 goroutine Y 釋放鎖,返回 instance 所引用的 singleton 實例(也就是 goroutine X 鎖創(chuàng)建的 singleton 實例),退出 New 函數(shù);
到這里,goroutine X 和 Y 得到了同一個 singleton 實例。可見上面的 New 函數(shù)中,鎖僅用來避免多個 goroutine 同時實例化 singleton。
相比前面的版本,雙重檢查版本,只要 instance 實例化后,鎖永遠不會執(zhí)行了,而前面版本每次調(diào)用 New 獲取實例都需要執(zhí)行鎖。性能很顯然,我們可以基準(zhǔn)測試來驗證:(雙重檢查版本 New 重命名為 New2)
- package singleton_test
- import ( "testing"
- "github.com/polaris1119/go-demo/singleton"
- )func BenchmarkNew(b *testing.B) {
- for i := 0; i < b.N; i++ {
- singleton.New() }}func BenchmarkNew2(b *testing.B) {
- for i := 0; i < b.N; i++ {
- singleton.New2() }}
因為是單例,所以兩個基準(zhǔn)測試需要分別執(zhí)行。
New1 的結(jié)果:
- $ go test -benchmem -bench ^BenchmarkNew$ github.com/polaris1119/go-demo/singleton
- goos: darwin
- goarch: amd64
- pkg: github.com/polaris1119/go-demo/singleton
- BenchmarkNew-8 80470467 14.0 ns/op 0 B/op 0 allocs/op
- PASS
- ok github.com/polaris1119/go-demo/singleton 1.151s
New2 的結(jié)果:
- $ go test -benchmem -bench ^BenchmarkNew2$ github.com/polaris1119/go-demo/singleton
- goos: darwin
- goarch: amd64
- pkg: github.com/polaris1119/go-demo/singleton
- BenchmarkNew2-8 658810392 1.80 ns/op 0 B/op 0 allocs/op
- PASS
- ok github.com/polaris1119/go-demo/singleton 1.380s
New2 快十幾倍。
細心得讀者會發(fā)現(xiàn),在 Go 中,餓漢式還有一種更好的實現(xiàn)方式,那就是使用 sync.Once,這是 Go 實現(xiàn)懶漢式更標(biāo)準(zhǔn)的做法。核心代碼如下(New3):
- var once sync.Once
- func New3() *singleton { once.Do(func() {
- instance = new(singleton)
- }) return instance
- }
通過基準(zhǔn)測試,它的性能和 New2 差不多。
此外,無論是 Java 還是 Go,都有一些其他“黑魔法”,比如 Go 語言中,利用 init 函數(shù)來初始化唯一的單例。不過一般都不太建議,還是常規(guī)方式來。
Go 語言單例模式,一般推薦優(yōu)先考慮使用餓漢式。但如果初始化比較耗時,懶漢式延遲初始化是更好的選擇。
4、使用場景
在 Go 語言中,如下兩個場景比較適合使用單例模式:
- 數(shù)據(jù)庫實例。只想創(chuàng)建一個 DB 對象實例,該實例在整個應(yīng)用程序中使用。
- 日志實例。同樣,只創(chuàng)建一個 Logger 的實例,并且在整個應(yīng)用程序中使用它。