golang unique包和字符串內(nèi)部化優(yōu)化技巧
最近在做老系統(tǒng)優(yōu)化,正好遇到了需要使用字符串內(nèi)部化的場(chǎng)景,所以今天就來說說字符串內(nèi)部化這種優(yōu)化技巧。
什么是字符串內(nèi)部化
熟悉Java或者python的開發(fā)者應(yīng)該對(duì)“內(nèi)部化”這種技術(shù)不陌生。內(nèi)部化指的是對(duì)于內(nèi)容完全相同的字符串變量,內(nèi)存中只保留一份數(shù)據(jù),所有的變量都引用同一份數(shù)據(jù),從而節(jié)約內(nèi)存。
舉個(gè)Java的例子:
public class StringInternDemo {
public static void main(String[] args) {
String s1 = new String("hello");
String s2 = "hello";
// 使用 intern 方法
String s3 = s1.intern();
System.out.println(s1 == s2); // false,因?yàn)?s1 是堆中新建的對(duì)象
System.out.println(s2 == s3); // true,因?yàn)?s3 指向字符串常量池中的 "hello"
}
}例子中s3和s1是不同的兩個(gè)字符串變量,但它們共享同一份字符串?dāng)?shù)據(jù)。在python中可以用sys.intern(str)實(shí)現(xiàn)類似的功能,而且python更進(jìn)一步——對(duì)于長(zhǎng)度短且不包含特殊字符的字符串默認(rèn)會(huì)自動(dòng)進(jìn)行內(nèi)部化。
可以看到所謂內(nèi)部化,其實(shí)相當(dāng)于創(chuàng)建了一個(gè)“字符串緩存”,我們可以把字符串放進(jìn)緩存里,然后在需要的時(shí)候取出來復(fù)用,取的時(shí)候既可以用變量也可以用常量。不過這么做需要字符串類型本身是不可變的,因?yàn)樗邢嗤瑑?nèi)容的字符串變量共享同一份數(shù)據(jù),如果其中一個(gè)變量意外修改了這份數(shù)據(jù),其他和它共享的字符串變量都將受到“污染”。好在不管Java、python還是Golang,字符串類型都是不可變的。
那么字符串內(nèi)部化的好處是什么呢?好處在于可以減少內(nèi)存分配次數(shù)且節(jié)約內(nèi)存用量,我們不必為了內(nèi)容相同的字符串反復(fù)申請(qǐng)內(nèi)存空間。不過和其他類型的緩存一樣,如果命中率不夠高那么這個(gè)緩存不僅不會(huì)帶來任何提升反而還會(huì)浪費(fèi)大量?jī)?nèi)存并加重gc負(fù)擔(dān)。
字符串內(nèi)部化在處理數(shù)據(jù)的反序列化時(shí)是一種很重要的優(yōu)化手段。
舉個(gè)例子,某個(gè)公司需要匯總處理每個(gè)員工的業(yè)績(jī)數(shù)據(jù),數(shù)據(jù)中包含員工所在部門和職位等信息。我們知道一個(gè)公司的員工可能有幾千個(gè)幾萬個(gè)甚至幾十萬個(gè),但公司里的部分?jǐn)?shù)量往往不會(huì)超過三位數(shù),職位分類也是如此,即使數(shù)據(jù)再多它們也只會(huì)有固定數(shù)量的取值不會(huì)增多,反過來員工的姓名就很少會(huì)有重復(fù)數(shù)據(jù),你幾乎可以總是預(yù)估姓名的數(shù)量小于等于員工總數(shù)且隨著員工數(shù)量增加而增加。如果沒有字符串內(nèi)部化,每收到一條數(shù)據(jù),我們就要重復(fù)創(chuàng)建部門名稱和職務(wù)頭銜這些字符串,數(shù)據(jù)量越大浪費(fèi)的內(nèi)存就越多;而如果我們能把這些名稱頭銜的字符串全部緩存起來,后續(xù)只要讓新變量共享這些數(shù)據(jù),就能帶來非??捎^的內(nèi)存利用率提升和性能改善。
另外除了字符串,其他符合“低基數(shù)”(取值重量有限,但整體數(shù)量很大,比如上文的部門名稱)特征的數(shù)據(jù)都可以利用內(nèi)部化進(jìn)行優(yōu)化。
Golang中手動(dòng)實(shí)現(xiàn)內(nèi)部化
在了解了什么是內(nèi)部化,并且看了Java的例子,現(xiàn)在我們可以講講在Golang里如何實(shí)現(xiàn)這一技術(shù)了。
最原始的實(shí)現(xiàn)是這樣的:
type StringIntern struct {
m map[string]string
}
func (s *StringIntern) Intern(str string) string {
ret, ok := s.m[str]
if !ok {
ret = strings.Clone(str)
s.m[str] = ret
}
return ret
}
var si StringIntern
s1 := "hello"
s2 := si.Intern(s1)用法上沒有和Java有多少區(qū)別,代碼也很簡(jiǎn)單,唯一需要解釋的是在字符串存進(jìn)map的時(shí)候我們需要clone一次,這是為了避免參數(shù)str是某個(gè)長(zhǎng)字符串的子串,因?yàn)槲覀兊膍ap需要長(zhǎng)期持有str,如果是上述情況,這個(gè)長(zhǎng)字符串就會(huì)無法釋放從而造成泄露。
如果是在反序列化場(chǎng)景使用,可能需要調(diào)用unsafe.String(bytes, length)來獲取字符串避免不必要的內(nèi)存分配。
這個(gè)方案足夠應(yīng)付大多數(shù)場(chǎng)景,但還有一個(gè)比較麻煩的問題——我們沒有實(shí)現(xiàn)淘汰機(jī)制,這會(huì)導(dǎo)致內(nèi)部化池的規(guī)模越來越大。
想要在用戶代碼層面解決這個(gè)問題往往會(huì)變得得不償失——我們需要額外的空間記錄內(nèi)部化的字符是否需要淘汰,并在每次獲取字符串時(shí)處理緩存淘汰或者周期性掃描并釋放“過期”的字符串,這不僅增加實(shí)現(xiàn)復(fù)雜度還會(huì)降低性能。
但好在go有垃圾回收器,我們可以拜托垃圾回收器幫我們進(jìn)行清理,只要沒有引用繼續(xù)指向字符串,它就會(huì)被回收。但這要求我們的map不能持有內(nèi)部化的str,想實(shí)現(xiàn)這個(gè)效果需要費(fèi)點(diǎn)功夫并且要用unsafe。
因?yàn)閮?nèi)部化的需求實(shí)在太常見,已經(jīng)有人把代碼幫我們寫好了:go4.org/intern
這個(gè)包的原理和我們的StringIntern一樣,不過map的值換成了一種可以讓gc及時(shí)回收的類型,這個(gè)包我不過多解釋,因?yàn)槿绻隳苡胓o1.24或者更新版本,那么unique標(biāo)準(zhǔn)庫(kù)會(huì)提供更快更安全的替代品,那就沒必要再引入這個(gè)外部依賴了。
用法上也和我們自己實(shí)現(xiàn)的內(nèi)部化差不多:
import "go4.org/intern" s1 := "hello" s2 := intern.GetByString(s1)
另外go4.org/intern不僅僅可以內(nèi)部化字符串,它還可以內(nèi)部化很多別的類型的數(shù)據(jù),然而這一功能也被標(biāo)準(zhǔn)庫(kù)的unique全面替代并超越了。
所以如果你能保證需要內(nèi)部化的字符串?dāng)?shù)量很少并且不會(huì)變動(dòng),那么使用StringIntern足矣,否則就需要使用下面要講解的unique包了。
unique包和內(nèi)部化
unique包并不是為了字符串內(nèi)部化而被引入的。
最早需要unique的地方是net/netip這個(gè)包,確切得說是里面的表示IPv6地址的類型需要它。
按照規(guī)范,TPv4地址可以以v6的形式進(jìn)行表示,并且IPv6地址都有一個(gè)叫zone的東西來區(qū)分不同的網(wǎng)絡(luò)。為了符合規(guī)范也方便進(jìn)行操作,netip中的每個(gè)地址類型都需要包含isV4 bool和zone string這兩個(gè)字段。
isV4是標(biāo)準(zhǔn)的“低基數(shù)”變量,zone不是。但問題出在目前沒有多少人主動(dòng)使用zone,而且也沒多少人愛用IPv6形式表示v4的地址,這就導(dǎo)致了這兩個(gè)變量的基數(shù)都很低,而且它們組合之后的基數(shù)還要更低。結(jié)構(gòu)體里帶上它們就會(huì)浪費(fèi)至少20字節(jié),因此開發(fā)者提出了一種存儲(chǔ)開銷和一個(gè)指針變量相同且能對(duì)低基數(shù)變量實(shí)現(xiàn)內(nèi)部化的標(biāo)準(zhǔn)庫(kù)功能。
這就是unique包。unique里核心的只有unique.Handle[T]和unique.Make。
unique.Handle[T]是前面說的只有一個(gè)指針大小的可以表示被內(nèi)部化數(shù)據(jù)的東西。從類型名字上可以看出這是一個(gè)泛型類型,它可以表示任何可比較的類型的值。它有一個(gè)Value方法,可以返回被表示的類型為T的值。
unique.Make則用來創(chuàng)建unique.Handle[T],它的用法和我們的StringIntern.Intern是一樣的。并且Make也和我們的實(shí)現(xiàn)一樣,會(huì)clone自己的參數(shù),以避免內(nèi)存泄露或者其他的生命周期問題。
所以只要把上一節(jié)我們使用intern包的例子稍微修改就能使用unique了:
import "unique"
s1 := unique.Make("hello")
s2 := unique.Make("hello")
fmt.Println(s1.Value(), s2.Value())
fmt.Println(s1 == s2) // true
s3 := unique.Make("hello1")
fmt.Println(s1 == s3) // false,快速比較api變得有些復(fù)雜我們不能之間獲取value,需要通過Handle進(jìn)行中介,但作為好處,一個(gè)Handle只有8字節(jié),而string有至少16字節(jié),另一個(gè)好處在于通過比較Handle我們可以快速判斷兩個(gè)被內(nèi)部化的值是否相同——本來我們很可能需要進(jìn)行長(zhǎng)字符串的比較,但利用Handle,我們只要對(duì)比一下8字節(jié)的數(shù)據(jù)是否相同就行。
同時(shí)unique包還可以利用gc,只要沒有Handle繼續(xù),這些內(nèi)部化的數(shù)據(jù)就會(huì)在下一次垃圾回收的時(shí)候被釋放,沒有泄露的風(fēng)險(xiǎn)。和go4.org/intern相比unique在傳參的時(shí)候利用的是泛型而不是interface,還可以避免一次額外的內(nèi)存分配開銷。
unique和intern都可以在并發(fā)場(chǎng)景下使用,unique內(nèi)部使用無鎖的hashtrie實(shí)現(xiàn),而intern使用鎖來保證map的并發(fā)安全,因此unique又勝一籌。
所以在新版本的go代碼中如果有利用內(nèi)部化的需求,應(yīng)該優(yōu)先考慮unique。
性能對(duì)比
最后做一下go4.org/intern和unique的性能對(duì)比。測(cè)試代碼是我從文章開頭說的老系統(tǒng)的數(shù)據(jù)解析邏輯里裁剪下來的,代碼會(huì)解析一串二進(jìn)制數(shù)據(jù)并生成對(duì)應(yīng)的結(jié)構(gòu)體。數(shù)據(jù)中很大字符串的內(nèi)容是重復(fù)的,因此非常適合使用內(nèi)部化進(jìn)行優(yōu)化:
type Data struct {
A, B, C, D string
}
type Data2 struct {
A, B, C, D unique.Handle[string]
}
type Data3 struct {
A, B, C, D *intern.Value
}
func main() {
fmt.Println("Data size:", unsafe.Sizeof(Data{})) // 64
fmt.Println("Data2 size:", unsafe.Sizeof(Data2{})) // 32
fmt.Println("Data3 size:", unsafe.Sizeof(Data3{})) // 32
}可以看到光是采用內(nèi)部化之后結(jié)構(gòu)體的存儲(chǔ)成本就已經(jīng)節(jié)約了50%。下面再看看利用內(nèi)部化之后對(duì)解析速度的影響:
var (
pool1 = sync.Pool{
New: func() any {
return &Data{}
},
}
pool2 = sync.Pool{
New: func() any {
return &Data2{}
},
}
pool3 = sync.Pool{
New: func() any {
return &Data3{}
},
}
)
//go:noinline
func ParseString(b []byte) *Data {
d := pool1.Get().(*Data)
d.A = string(b[:16])
d.B = string(b[16:32])
d.C = string(b[32:48])
d.D = string(b[48:64])
return d
}
//go:noinline
func ParseUnique(b []byte) *Data2 {
d := pool2.Get().(*Data2)
s1 := unsafe.String(&b[0], 16)
s2 := unsafe.String(&b[16], 16)
s3 := unsafe.String(&b[32], 16)
s4 := unsafe.String(&b[48], 16)
d.A = unique.Make(s1)
d.B = unique.Make(s2)
d.C = unique.Make(s3)
d.D = unique.Make(s4)
return d
}
//go:noinline
func ParseIntern(b []byte) *Data3 {
d := pool3.Get().(*Data3)
s1 := unsafe.String(&b[0], 16)
s2 := unsafe.String(&b[16], 16)
s3 := unsafe.String(&b[32], 16)
s4 := unsafe.String(&b[48], 16)
d.A = intern.GetByString(s1)
d.B = intern.GetByString(s2)
d.C = intern.GetByString(s3)
d.D = intern.GetByString(s4)
return d
}
func BenchmarkParseString(b *testing.B) {
data := []byte("aaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbccccccccccccccccdddddddddddddddd")
for range b.N {
d := ParseString(data)
pool1.Put(d)
}
}
func BenchmarkParseUnique(b *testing.B) {
data := []byte("aaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbccccccccccccccccdddddddddddddddd")
for range b.N {
d := ParseUnique(data)
pool2.Put(d)
}
}
func BenchmarkParseIntern(b *testing.B) {
data := []byte("aaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbccccccccccccccccdddddddddddddddd")
for range b.N {
d := ParseIntern(data)
pool3.Put(d)
}
}給函數(shù)加上noinline是為了更加貼近項(xiàng)目中的實(shí)際代碼,項(xiàng)目中的代碼除了字符串之外還有很多整數(shù)和bool值需要解析,解析出來的值還需要經(jīng)過簡(jiǎn)單的校驗(yàn),因此編譯器解析函數(shù)里東西太多沒法內(nèi)聯(lián),我們這里簡(jiǎn)化了邏輯只保留了字符串處理,但在內(nèi)聯(lián)上和實(shí)際的代碼保持一致,因?yàn)閮?nèi)聯(lián)或者不內(nèi)聯(lián)會(huì)大幅改變性能測(cè)試結(jié)果。數(shù)據(jù)對(duì)象我們也做了池化盡量減少任何環(huán)節(jié)上不必要的分配。
測(cè)試結(jié)果:

采用內(nèi)部化的函數(shù)完全避免了內(nèi)存分配,unique性能優(yōu)于intern,但速度上比原先的版本慢了25%。這是因?yàn)閡nique需要計(jì)算字符串的hash然后去hashtrie里取數(shù)據(jù),比起小塊內(nèi)存分配來說這個(gè)步驟會(huì)慢一些,另外性能測(cè)試運(yùn)行時(shí)間比較短測(cè)試內(nèi)容也比較簡(jiǎn)單,gc壓力體現(xiàn)得沒有生產(chǎn)環(huán)境上明顯,因此看著有不小的速度差異,但實(shí)際生產(chǎn)環(huán)境上內(nèi)部化因?yàn)閹缀醪活~外分配內(nèi)存,不僅內(nèi)存占用少很多,速度上的差異也幾乎沒有監(jiān)測(cè)到。
總結(jié)
內(nèi)部化是一種專門針對(duì)“低基數(shù)”數(shù)據(jù)進(jìn)行的優(yōu)化方法。對(duì)于基數(shù)不低的數(shù)據(jù)使用則會(huì)收到明顯的反效果。
同時(shí)也要記住,雖然內(nèi)存分配的少了,但查找并返回被內(nèi)部保存的數(shù)據(jù)也是需要額外花費(fèi)時(shí)間的,因此對(duì)于非熱點(diǎn)代碼或者運(yùn)行時(shí)間較短的程序來說這種優(yōu)化也有些舍近求遠(yuǎn)了。
到此這篇關(guān)于golang unique包和字符串內(nèi)部化的文章就介紹到這了,更多相關(guān)golang unique包內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Go語言實(shí)現(xiàn)AzDG可逆加密算法實(shí)例
這篇文章主要介紹了Go語言實(shí)現(xiàn)AzDG可逆加密算法,實(shí)例分析了AzDG可逆加密算法的實(shí)現(xiàn)技巧,具有一定參考借鑒價(jià)值,需要的朋友可以參考下2015-02-02
Golang實(shí)現(xiàn)支付寶沙箱支付的方法步驟
本文主要介紹了Golang實(shí)現(xiàn)支付寶沙箱支付的方法步驟,文中通過示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2023-04-04
Go語言編譯程序從后臺(tái)運(yùn)行,不出現(xiàn)dos窗口的操作
這篇文章主要介紹了Go語言編譯程序從后臺(tái)運(yùn)行,不出現(xiàn)dos窗口的操作,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過來看看吧2021-04-04
Go語言基礎(chǔ)switch條件語句基本用法及示例詳解
這篇文章主要為大家介紹了Go語言基礎(chǔ)switch條件語句基本用法及示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步2021-11-11
使用go實(shí)現(xiàn)簡(jiǎn)易比特幣區(qū)塊鏈公鏈功能
這篇文章主要介紹了使用go實(shí)現(xiàn)簡(jiǎn)易比特幣區(qū)塊鏈公鏈功能,本文給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2021-01-01

