golang unique包和字符串內(nèi)部化優(yōu)化技巧
最近在做老系統(tǒng)優(yōu)化,正好遇到了需要使用字符串內(nèi)部化的場(chǎng)景,所以今天就來(lái)說(shuō)說(shuō)字符串內(nèi)部化這種優(yōu)化技巧。
什么是字符串內(nèi)部化
熟悉Java或者python的開(kāi)發(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)類(lèi)似的功能,而且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í)候取出來(lái)復(fù)用,取的時(shí)候既可以用變量也可以用常量。不過(guò)這么做需要字符串類(lèi)型本身是不可變的,因?yàn)樗邢嗤瑑?nèi)容的字符串變量共享同一份數(shù)據(jù),如果其中一個(gè)變量意外修改了這份數(shù)據(jù),其他和它共享的字符串變量都將受到“污染”。好在不管Java、python還是Golang,字符串類(lèi)型都是不可變的。
那么字符串內(nèi)部化的好處是什么呢?好處在于可以減少內(nèi)存分配次數(shù)且節(jié)約內(nèi)存用量,我們不必為了內(nèi)容相同的字符串反復(fù)申請(qǐng)內(nèi)存空間。不過(guò)和其他類(lèi)型的緩存一樣,如果命中率不夠高那么這個(gè)緩存不僅不會(huì)帶來(lái)任何提升反而還會(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ù)中包含員工所在部門(mén)和職位等信息。我們知道一個(gè)公司的員工可能有幾千個(gè)幾萬(wàn)個(gè)甚至幾十萬(wàn)個(gè),但公司里的部分?jǐn)?shù)量往往不會(huì)超過(guò)三位數(shù),職位分類(lèi)也是如此,即使數(shù)據(jù)再多它們也只會(huì)有固定數(shù)量的取值不會(huì)增多,反過(guò)來(lái)員工的姓名就很少會(huì)有重復(fù)數(shù)據(jù),你幾乎可以總是預(yù)估姓名的數(shù)量小于等于員工總數(shù)且隨著員工數(shù)量增加而增加。如果沒(méi)有字符串內(nèi)部化,每收到一條數(shù)據(jù),我們就要重復(fù)創(chuàng)建部門(mén)名稱(chēng)和職務(wù)頭銜這些字符串,數(shù)據(jù)量越大浪費(fèi)的內(nèi)存就越多;而如果我們能把這些名稱(chēng)頭銜的字符串全部緩存起來(lái),后續(xù)只要讓新變量共享這些數(shù)據(jù),就能帶來(lái)非常可觀的內(nèi)存利用率提升和性能改善。
另外除了字符串,其他符合“低基數(shù)”(取值重量有限,但整體數(shù)量很大,比如上文的部門(mén)名稱(chēng))特征的數(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)用法上沒(méi)有和Java有多少區(qū)別,代碼也很簡(jiǎn)單,唯一需要解釋的是在字符串存進(jìn)map的時(shí)候我們需要clone一次,這是為了避免參數(shù)str是某個(gè)長(zhǎng)字符串的子串,因?yàn)槲覀兊膍ap需要長(zhǎng)期持有str,如果是上述情況,這個(gè)長(zhǎng)字符串就會(huì)無(wú)法釋放從而造成泄露。
如果是在反序列化場(chǎng)景使用,可能需要調(diào)用unsafe.String(bytes, length)來(lái)獲取字符串避免不必要的內(nèi)存分配。
這個(gè)方案足夠應(yīng)付大多數(shù)場(chǎng)景,但還有一個(gè)比較麻煩的問(wèn)題——我們沒(méi)有實(shí)現(xiàn)淘汰機(jī)制,這會(huì)導(dǎo)致內(nèi)部化池的規(guī)模越來(lái)越大。
想要在用戶代碼層面解決這個(gè)問(wèn)題往往會(huì)變得得不償失——我們需要額外的空間記錄內(nèi)部化的字符是否需要淘汰,并在每次獲取字符串時(shí)處理緩存淘汰或者周期性掃描并釋放“過(guò)期”的字符串,這不僅增加實(shí)現(xiàn)復(fù)雜度還會(huì)降低性能。
但好在go有垃圾回收器,我們可以拜托垃圾回收器幫我們進(jìn)行清理,只要沒(méi)有引用繼續(xù)指向字符串,它就會(huì)被回收。但這要求我們的map不能持有內(nèi)部化的str,想實(shí)現(xiàn)這個(gè)效果需要費(fèi)點(diǎn)功夫并且要用unsafe。
因?yàn)閮?nèi)部化的需求實(shí)在太常見(jiàn),已經(jīng)有人把代碼幫我們寫(xiě)好了:go4.org/intern
這個(gè)包的原理和我們的StringIntern一樣,不過(guò)map的值換成了一種可以讓gc及時(shí)回收的類(lèi)型,這個(gè)包我不過(guò)多解釋?zhuān)驗(yàn)槿绻隳苡胓o1.24或者更新版本,那么unique標(biāo)準(zhǔn)庫(kù)會(huì)提供更快更安全的替代品,那就沒(méi)必要再引入這個(gè)外部依賴(lài)了。
用法上也和我們自己實(shí)現(xiàn)的內(nèi)部化差不多:
import "go4.org/intern" s1 := "hello" s2 := intern.GetByString(s1)
另外go4.org/intern不僅僅可以?xún)?nèi)部化字符串,它還可以?xún)?nèi)部化很多別的類(lè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è)包,確切得說(shuō)是里面的表示IPv6地址的類(lèi)型需要它。
按照規(guī)范,TPv4地址可以以v6的形式進(jìn)行表示,并且IPv6地址都有一個(gè)叫zone的東西來(lái)區(qū)分不同的網(wǎng)絡(luò)。為了符合規(guī)范也方便進(jìn)行操作,netip中的每個(gè)地址類(lèi)型都需要包含isV4 bool和zone string這兩個(gè)字段。
isV4是標(biāo)準(zhǔn)的“低基數(shù)”變量,zone不是。但問(wèn)題出在目前沒(méi)有多少人主動(dòng)使用zone,而且也沒(méi)多少人愛(ài)用IPv6形式表示v4的地址,這就導(dǎo)致了這兩個(gè)變量的基數(shù)都很低,而且它們組合之后的基數(shù)還要更低。結(jié)構(gòu)體里帶上它們就會(huì)浪費(fèi)至少20字節(jié),因此開(kāi)發(fā)者提出了一種存儲(chǔ)開(kāi)銷(xiāo)和一個(gè)指針變量相同且能對(duì)低基數(shù)變量實(shí)現(xiàn)內(nèi)部化的標(biāo)準(zhǔn)庫(kù)功能。
這就是unique包。unique里核心的只有unique.Handle[T]和unique.Make。
unique.Handle[T]是前面說(shuō)的只有一個(gè)指針大小的可以表示被內(nèi)部化數(shù)據(jù)的東西。從類(lèi)型名字上可以看出這是一個(gè)泛型類(lèi)型,它可以表示任何可比較的類(lèi)型的值。它有一個(gè)Value方法,可以返回被表示的類(lèi)型為T的值。
unique.Make則用來(lái)創(chuàng)建unique.Handle[T],它的用法和我們的StringIntern.Intern是一樣的。并且Make也和我們的實(shí)現(xiàn)一樣,會(huì)clone自己的參數(shù),以避免內(nèi)存泄露或者其他的生命周期問(wèn)題。
所以只要把上一節(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,需要通過(guò)Handle進(jìn)行中介,但作為好處,一個(gè)Handle只有8字節(jié),而string有至少16字節(jié),另一個(gè)好處在于通過(guò)比較Handle我們可以快速判斷兩個(gè)被內(nèi)部化的值是否相同——本來(lái)我們很可能需要進(jìn)行長(zhǎng)字符串的比較,但利用Handle,我們只要對(duì)比一下8字節(jié)的數(shù)據(jù)是否相同就行。
同時(shí)unique包還可以利用gc,只要沒(méi)有Handle繼續(xù),這些內(nèi)部化的數(shù)據(jù)就會(huì)在下一次垃圾回收的時(shí)候被釋放,沒(méi)有泄露的風(fēng)險(xiǎn)。和go4.org/intern相比unique在傳參的時(shí)候利用的是泛型而不是interface,還可以避免一次額外的內(nèi)存分配開(kāi)銷(xiāo)。
unique和intern都可以在并發(fā)場(chǎng)景下使用,unique內(nèi)部使用無(wú)鎖的hashtrie實(shí)現(xiàn),而intern使用鎖來(lái)保證map的并發(fā)安全,因此unique又勝一籌。
所以在新版本的go代碼中如果有利用內(nèi)部化的需求,應(yīng)該優(yōu)先考慮unique。
性能對(duì)比
最后做一下go4.org/intern和unique的性能對(duì)比。測(cè)試代碼是我從文章開(kāi)頭說(shuō)的老系統(tǒng)的數(shù)據(jù)解析邏輯里裁剪下來(lái)的,代碼會(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值需要解析,解析出來(lái)的值還需要經(jīng)過(guò)簡(jiǎn)單的校驗(yàn),因此編譯器解析函數(shù)里東西太多沒(méi)法內(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)存分配來(lái)說(shuō)這個(gè)步驟會(huì)慢一些,另外性能測(cè)試運(yùn)行時(shí)間比較短測(cè)試內(nèi)容也比較簡(jiǎn)單,gc壓力體現(xiàn)得沒(méi)有生產(chǎn)環(huán)境上明顯,因此看著有不小的速度差異,但實(shí)際生產(chǎn)環(huán)境上內(nèi)部化因?yàn)閹缀醪活~外分配內(nèi)存,不僅內(nèi)存占用少很多,速度上的差異也幾乎沒(méi)有監(jiān)測(cè)到。
總結(jié)
內(nèi)部化是一種專(zhuān)門(mén)針對(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í)間較短的程序來(lái)說(shuō)這種優(yōu)化也有些舍近求遠(yuǎn)了。
到此這篇關(guān)于golang unique包和字符串內(nèi)部化的文章就介紹到這了,更多相關(guān)golang unique包內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
一文詳解Go語(yǔ)言中的Option設(shè)計(jì)模式
這篇文章主要為大家詳細(xì)介紹了Go語(yǔ)言中Option設(shè)計(jì)模式的相關(guān)知識(shí),文中的示例代碼講解詳細(xì),具有一定的學(xué)習(xí)價(jià)值,感興趣的可以了解一下2023-05-05
go使用snmp庫(kù)查詢(xún)mib數(shù)據(jù)案例代碼
go語(yǔ)言使用snmp庫(kù)中的 k-sone/snmpgo 實(shí)現(xiàn)相關(guān)mib查詢(xún),本文通過(guò)實(shí)例代碼給大家介紹了go使用snmp庫(kù)查詢(xún)mib數(shù)據(jù),感興趣的朋友跟隨小編一起看看吧2023-10-10
Golang中匿名函數(shù)的實(shí)現(xiàn)
本文主要介紹了Golang中匿名函數(shù)的實(shí)現(xiàn),包括直接調(diào)用、賦值給變量及定義全局匿名函數(shù)三種方式,文中通過(guò)示例代碼介紹的非常詳細(xì),需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2025-06-06
GoFrame框架gcache的緩存控制淘汰策略實(shí)踐示例
這篇文章主要為大家介紹了GoFrame框架gcache的緩存控制淘汰策略的實(shí)踐示例,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-06-06

