原文地址: Allocation Efficiency in High-Performance Go Services, 沒(méi)有原模原樣的翻譯, 但不影響理解蔬墩。
關(guān)于工具
我們的第一個(gè)建議就是: 不要過(guò)早優(yōu)化。Go 提供了很棒的性能調(diào)優(yōu)工具可以直接指出代碼上哪里消耗了大量?jī)?nèi)存。沒(méi)必要重復(fù)造輪子,建議讀者閱讀下 Go 官方博客上的這篇很贊的文章诗祸;里面會(huì)一步步教你使用 pprof
對(duì) CPU 和內(nèi)存進(jìn)行調(diào)優(yōu)溪厘。在 Segment 我們也是用這些工具去找到項(xiàng)目的性能瓶頸的。
用數(shù)據(jù)來(lái)驅(qū)動(dòng)優(yōu)化忠荞。
逃逸分析
Go 可以自動(dòng)的管理內(nèi)存,這幫我們避免了大量潛在 bug帅掘,但它并沒(méi)有將程序員徹底的從內(nèi)存分配的事情上解脫出來(lái)委煤。因?yàn)?Go 沒(méi)有提供直接操作內(nèi)存的方式,所以開發(fā)者必須要搞懂其內(nèi)部機(jī)制修档,這樣才能將收益最大化碧绞。
如果讀了這篇文章后,你只能記住一點(diǎn)吱窝,那請(qǐng)記住這個(gè):棧分配廉價(jià)讥邻,堆分配昂貴。現(xiàn)在讓我們深入講述下這是什么意思院峡。
Go 有兩個(gè)地方可以分配內(nèi)存:一個(gè)全局堆空間用來(lái)動(dòng)態(tài)分配內(nèi)存兴使,另一個(gè)是每個(gè) goroutine 都有的自身?xiàng)?臻g照激。
Go 更傾向于在椃⑵牵空間上分配內(nèi)存 —— 一個(gè) Go 程序大部分的內(nèi)存分配都是在棧空間上的俩垃。它的代價(jià)很低励幼,因?yàn)橹恍枰獌蓚€(gè) CPU 指令:一個(gè)是把數(shù)據(jù) push 到棧空間上以完成分配口柳,另一個(gè)是從椘凰冢空間上釋放。
不幸的是, 不是所有的內(nèi)存都可以在椩灸郑空間上分配的嵌削。椕茫空間分配要求一個(gè)變量的生命周期和內(nèi)存足跡能在編譯時(shí)確定。
否則就需要在運(yùn)行時(shí)在堆空間上進(jìn)行動(dòng)態(tài)分配掷贾。
malloc
必須找到一塊足夠大的內(nèi)存來(lái)存放新的變量數(shù)據(jù)睛榄。后續(xù)釋放時(shí),垃圾回收器掃描堆空間尋找不再被使用的對(duì)象想帅。
不用多說(shuō)场靴,這明顯要比只需兩個(gè)指令的棧分配更加昂貴。
譯者注: 內(nèi)存足跡, 代表和一個(gè)變量相關(guān)的所有內(nèi)存塊港准。
比如一個(gè) struct 中含有成員*int
, 那么這個(gè)*int
所指向的內(nèi)存塊屬于該 struct 的足跡旨剥。
編譯器使用逃逸分析的技術(shù)來(lái)在這兩者間做選擇∏掣祝基本的思路就是在編譯時(shí)做垃圾回收的工作轨帜。
編譯器會(huì)追蹤變量在代碼塊上的作用域。變量會(huì)攜帶有一組校驗(yàn)數(shù)據(jù)衩椒,用來(lái)證明它的整個(gè)生命周期是否在運(yùn)行時(shí)完全可知蚌父。如果變量通過(guò)了這些校驗(yàn),它就可以在棧上分配毛萌。否則就說(shuō)它 逃逸 了苟弛,必須在堆上分配。
逃逸分析的機(jī)制阁将,并沒(méi)有在 Go 語(yǔ)言官方說(shuō)明上闡述膏秫。對(duì) Go 程序員來(lái)說(shuō),學(xué)習(xí)這些規(guī)則最有效的方式就是憑經(jīng)驗(yàn)做盅。編譯命令 go build -gcflags '-m'
會(huì)讓編譯器在編譯時(shí)輸出逃逸分析的結(jié)果缤削。
讓我們來(lái)看一個(gè)例子:
package main
import "fmt"
func main() {
x := 42
fmt.Println(x)
}
$ go build -gcflags '-m' ./main.go
# command-line-arguments
./main.go:7: x escapes to heap
./main.go:7: main ... argument does not escape
我們看到 x escapes to heap
, 表示它會(huì)在運(yùn)行時(shí)在堆空間上動(dòng)態(tài)分配。
這個(gè)例子讓人有些費(fèi)解吹榴,直覺(jué)上亭敢,很明顯變量 x
并沒(méi)有逃出 main()
函數(shù)之外。
編譯器沒(méi)有說(shuō)明它為什么認(rèn)為這個(gè)變量逃逸了图筹。為得到更詳細(xì)的內(nèi)容帅刀,多傳幾個(gè) -m
參數(shù)給編譯器,會(huì)打印出更詳細(xì)的內(nèi)容婿斥。
$ go build -gcflags '-m -m' ./main.go
# command-line-arguments
./main.go:5: cannot inline main: non-leaf function
./main.go:7: x escapes to heap
./main.go:7: from ... argument (arg to ...) at ./main.go:7
./main.go:7: from *(... argument) (indirection) at ./main.go:7
./main.go:7: from ... argument (passed to call[argument content escapes]) at ./main.go:7
./main.go:7: main ... argument does not escape
是的,上面顯示了哨鸭,變量 x
之所以逃逸了民宿,是因?yàn)樗粋魅肓艘粋€(gè)逃逸的函數(shù)內(nèi)。
這個(gè)機(jī)制乍看上去有些難以捉摸像鸡,但多用幾次這個(gè)工具后活鹰,就能搞明白這其中的規(guī)律了哈恰。長(zhǎng)話短說(shuō),下面是一些我們找到的志群,能引起變量逃逸到堆上的典型情況:
發(fā)送指針或帶有指針的值到 channel 中着绷。在編譯時(shí),是沒(méi)有辦法知道哪個(gè) goroutine 會(huì)在 channel 上接收數(shù)據(jù)锌云。所以編譯器沒(méi)法知道變量什么時(shí)候才會(huì)被釋放荠医。
在一個(gè)切片上存儲(chǔ)指針或帶指針的值。一個(gè)典型的例子就是
[]*string
桑涎。這會(huì)導(dǎo)致切片的內(nèi)容逃逸彬向。盡管其后面的數(shù)組可能是在棧上分配的,但其引用的值一定是在堆上攻冷。slice 的背后數(shù)組被重新分配了娃胆,因?yàn)?
append
時(shí)可能會(huì)超出其容量(cap
)。slice 初始化的地方在編譯時(shí)是可以知道的等曼,它最開始會(huì)在棧上分配里烦。如果切片背后的存儲(chǔ)要基于運(yùn)行時(shí)的數(shù)據(jù)進(jìn)行擴(kuò)充,就會(huì)在堆上分配禁谦。在 interface 類型上調(diào)用方法胁黑。在 interface 類型上調(diào)用方法都是動(dòng)態(tài)調(diào)度的 —— 方法的真正實(shí)現(xiàn)只能在運(yùn)行時(shí)知道。想像一個(gè)
io.Reader
類型的變量r
, 調(diào)用r.Read(b)
會(huì)使得r
的值和切片b
的背后存儲(chǔ)都逃逸掉枷畏,所以會(huì)在堆上分配别厘。
以我們的經(jīng)驗(yàn),這四點(diǎn)是 Go 程序中最常見的導(dǎo)致堆分配的原因拥诡。幸運(yùn)的是触趴,是有解決辦法的!下面我們深入幾個(gè)具體例子說(shuō)明渴肉,如何定位線上系統(tǒng)的內(nèi)存性能問(wèn)題冗懦。
關(guān)于指針
一個(gè)經(jīng)驗(yàn)是:指針指向的數(shù)據(jù)都是在堆上分配的。因此仇祭,在程序中減少指針的運(yùn)用可以減少堆分配披蕉。這不是絕對(duì)的,但是我們發(fā)現(xiàn)這是在實(shí)際問(wèn)題中最常見的問(wèn)題乌奇。
一般情況下我們會(huì)這樣認(rèn)為:“值的拷貝是昂貴的没讲,所以用一個(gè)指針來(lái)代替〗该纾”
但是爬凑,在很多情況下,直接的值拷貝要比使用指針廉價(jià)的多试伙。你可能要問(wèn)為什么嘁信。
編譯器會(huì)在解除指針時(shí)做檢查于样。目的是在指針是
nil
的情況下直接panic()
以避免內(nèi)存泄露。這就必須在運(yùn)行時(shí)執(zhí)行更多的代碼潘靖。如果數(shù)據(jù)是按值傳遞的穿剖,那就不需要做這些了,它不可能是nil
指針通常有糟糕的局部引用卦溢。一個(gè)函數(shù)內(nèi)部的所有值都會(huì)在椇啵空間上分配。局部引用是編寫高效代碼的重要環(huán)節(jié)既绕。它會(huì)使得變量數(shù)據(jù)在 CPU Cache(cpu 的一級(jí)二級(jí)緩存) 中的熱度更高啄刹,進(jìn)而減少指令預(yù)取時(shí) Cache 不命中的的幾率。
在 Cache 層拷貝一堆對(duì)象凄贩,可粗略地認(rèn)為和拷貝一個(gè)指針效率是一樣的誓军。CPU 在各 Cache 層和主內(nèi)存中以固定大小的 cache 進(jìn)行內(nèi)存移動(dòng)。x86 機(jī)器上是 64 字節(jié)疲扎。而且昵时,Go 使用了Duff’s device 技術(shù)來(lái)使得常規(guī)內(nèi)存操作變得更高效。
指針應(yīng)該主要被用來(lái)做映射數(shù)據(jù)的所有權(quán)和可變性的椒丧。實(shí)際項(xiàng)目中壹甥,用指針來(lái)避免拷貝的方式應(yīng)該盡量少用。
不要掉進(jìn)過(guò)早優(yōu)化的陷阱壶熏。養(yǎng)成一個(gè)按值傳遞的習(xí)慣句柠,只在需要的時(shí)候用指針傳遞。另一個(gè)好處就是可以較少 nil
帶來(lái)的安全問(wèn)題棒假。
減少程序中指針的使用的另一個(gè)好處是溯职,如果可以證明它里面沒(méi)有指針,垃圾回收器會(huì)直接越過(guò)這塊內(nèi)存帽哑。例如谜酒,一塊作為 []byte
背后存儲(chǔ)的堆上內(nèi)存,是不需要進(jìn)行掃描的妻枕。對(duì)于那些不包含指針的數(shù)組和 struct
數(shù)據(jù)類型也是一樣的僻族。
譯者注: 垃圾回收器回收一個(gè)變量時(shí),要檢查該類型里是否有指針屡谐。
如果有述么,要檢查指針?biāo)赶虻膬?nèi)存是否可被回收,進(jìn)而才能決定這個(gè)變量能否被回收愕掏。如此遞歸下去度秘。
如果被回收的變量里面沒(méi)有指針, 就不需要進(jìn)去遞歸掃描了,直接回收掉就行亭珍。
減少指針的使用不僅可以降低垃圾回收的工作量敷钾,它會(huì)產(chǎn)生對(duì) cache 更加友好的代碼。讀內(nèi)存是要把數(shù)據(jù)從主內(nèi)存讀到 CPU 的 cache 中肄梨。
Cache 的空間是有限的阻荒,所以其他的數(shù)據(jù)必須被抹掉,好騰出空間众羡。
被抹掉的數(shù)據(jù)很可能程序的另外一部分相關(guān)侨赡。
由此產(chǎn)生的 cache 抖動(dòng)會(huì)引起線上服務(wù)的一些意外的和突然的抖動(dòng)。
還是關(guān)于指針
減少指針的使用就意味著要深入我們自定義的數(shù)據(jù)類型粱侣。我們的一個(gè)服務(wù)羊壹,用帶有一組數(shù)據(jù)結(jié)構(gòu)的循環(huán) buffer 構(gòu)建了一個(gè)失敗操作的隊(duì)列好做重試;它大致是這個(gè)樣子:
type retryQueue struct {
buckets [][]retryItem // each bucket represents a 1 second interval
currentTime time.Time
currentOffset int
}
type retryItem struct {
id ksuid.KSUID // ID of the item to retry
time time.Time // exact time at which the item has to be retried
}
buckets
中外面的數(shù)組大小是固定的, 但是 []retryItem
中 item 的數(shù)量是在運(yùn)行時(shí)變化的齐婴。重試次數(shù)越多, 切片增長(zhǎng)的越大油猫。
挖掘一下 retryItem
的具體實(shí)現(xiàn),我們發(fā)現(xiàn) KSUID
是 [20]byte
的別名, 里面沒(méi)有指針柠偶,所以可以排除情妖。currentOffset
是一個(gè) int
類型, 也是固定長(zhǎng)度的,故也可排除诱担。接下來(lái)毡证,看一下 time.Time
的實(shí)現(xiàn):
type Time struct {
sec int64
nsec int32
loc *Location // pointer to the time zone structure
}
time.Time
的結(jié)構(gòu)體中包含了一個(gè)指針成員 loc
。在 retryItem
中使用它會(huì)導(dǎo)致 GC 每次經(jīng)過(guò)堆上的這塊區(qū)域時(shí)蔫仙。
都要去追蹤到結(jié)構(gòu)體里面的指針。
我們發(fā)現(xiàn),這個(gè)案例很典型峡谊。 在正常運(yùn)行期間失敗情況很少趾撵。 只有少量?jī)?nèi)存用于存儲(chǔ)重試操作。 當(dāng)失敗突然飆升時(shí)涎嚼,重試隊(duì)列中的對(duì)象數(shù)量每秒增長(zhǎng)好幾千阱州,從而對(duì)垃圾回收器增加很多壓力。
在這種情況下法梯,time.Time
中的時(shí)區(qū)信息不是必要的苔货。這些保存在內(nèi)存中的時(shí)間截從來(lái)不會(huì)被序列化。所以可以重寫這個(gè)數(shù)據(jù)結(jié)構(gòu)來(lái)避免這種情況:
type retryItem struct {
id ksuid.KSUID
nsec uint32
sec int64
}
func (item *retryItem) time() time.Time {
return time.Unix(item.sec, int64(item.nsec))
}
func makeRetryItem(id ksuid.KSUID, time time.Time) retryItem {
return retryItem{
id: id,
nsec: uint32(time.Nanosecond()),
sec: time.Unix(),
}
注意現(xiàn)在的 retryItem
不包含任何指針立哑。這大大降低了 gc 壓力夜惭,因?yàn)?retryItem
的整個(gè)足跡都可以在編譯時(shí)知道。
傳遞 Slice
切片是造成低效內(nèi)存分配行為的狂熱區(qū)域铛绰。除非切片的大小在編譯時(shí)就能知道诈茧,否則切片背后的數(shù)組(map也一樣)會(huì)在堆上分配。讓我們來(lái)講幾個(gè)方法捂掰,讓切片在棧上分配而不是在堆上敢会。
一個(gè)重度依賴于 MySQL 的項(xiàng)目曾沈。整個(gè)項(xiàng)目的性能嚴(yán)重依賴 MySQL 客戶端驅(qū)動(dòng)的性能。
使用 pprof
對(duì)內(nèi)存分配進(jìn)行分析后鸥昏,我們發(fā)現(xiàn) MySQL driver 中序列化 time.Time
的那段代碼非常低效塞俱。
性能分析器顯示了堆上分配的內(nèi)存有很大比例都是用來(lái)序列化 time.Time
的,所以才導(dǎo)致了 MySQL driver 低效吏垮。
這段低效的代碼就是調(diào)用了 time.Time
的 Format()
方法, 它返回一個(gè) string
障涯。等等,我們不是在討論切片嘛膳汪?好吧唯蝶,根據(jù) Go 官方的博客,一個(gè) string
實(shí)際就是一個(gè)只讀的 []byte
遗嗽,只是語(yǔ)言上在語(yǔ)法上多了點(diǎn)支持粘我。在內(nèi)存分配上規(guī)則都是一樣的。
分析結(jié)果告訴我們 12.38%
的內(nèi)存分配都是 Format()
引起的痹换,Format()
都做了什么涂滴?
它表示使用標(biāo)準(zhǔn)庫(kù)還有更高效的方式來(lái)達(dá)到通樣的效果。但是 Format()
用起來(lái)很方便晴音,使用 AppendFormat()
在內(nèi)存分配上更友好柔纵。剖析下 time
包的源碼,我們發(fā)現(xiàn)里面都是使用 AppendFormat()
而不是 Format()
锤躁。這更說(shuō)明了 AppendFormat()
可以帶來(lái)更高的性能搁料。
實(shí)際上, Format()
函數(shù)只是對(duì) AppendFormat()
的一層封裝系羞。
func (t Time) Format(layout string) string {
const bufSize = 64
var b []byte
max := len(layout) + 10
if max < bufSize {
var buf [bufSize]byte
b = buf[:0]
} else {
b = make([]byte, 0, max)
}
b = t.AppendFormat(b, layout)
return string(b)
}
更重要的是郭计,AppendFormat()
給程序員留了更多的優(yōu)化空間。它需要傳入一個(gè)切片進(jìn)行存儲(chǔ)椒振,而不是直接返回一個(gè) string
昭伸。使用 AppendFormat()
代替 Format()
可以用固定大小的內(nèi)存空間來(lái)完成同樣的事,而且這些操作是在椗煊空間完成的庐杨。
Interface 類型
眾所周知的,在 Interface 類型上調(diào)用方法要比直接在 Struct 上調(diào)用方法效率低夹供。在 interface 類型上調(diào)用方法是動(dòng)態(tài)調(diào)度的灵份。這就極大的限制了編譯器確定運(yùn)行時(shí)代碼執(zhí)行方式的能力。到目前為止我們已經(jīng)大量的討論了哮洽,調(diào)整代碼好讓編譯器能在編譯時(shí)更好的理解你的代碼行為填渠。但 interface 類型會(huì)讓這一切都白做。
不幸的是,interface 類型還是一個(gè)非常有用的抽象方式 —— 它能讓我們寫出擴(kuò)展性更高的代碼氛什。interface 的一個(gè)普遍應(yīng)用場(chǎng)景是標(biāo)準(zhǔn)庫(kù)里的 hash
包中的哈希函數(shù)莺葫。hash
包定義了通用的接口,然后提供了幾個(gè)具體的實(shí)現(xiàn)枪眉。讓我們看幾個(gè)例子:
package main
import (
"fmt"
"hash/fnv"
)
func hashIt(in string) uint64 {
h := fnv.New64a()
h.Write([]byte(in))
out := h.Sum64()
return out
}
func main() {
s := "hello"
fmt.Printf("The FNV64a hash of '%v' is '%v'\n", s, hashIt(s))
}
編譯上段代碼徙融,加上逃逸分析參數(shù),會(huì)有以下輸出:
./foo1.go:9:17: inlining call to fnv.New64a
./foo1.go:10:16: ([]byte)(in) escapes to heap
./foo1.go:9:17: hash.Hash64(&fnv.s·2) escapes to heap
./foo1.go:9:17: &fnv.s·2 escapes to heap
./foo1.go:9:17: moved to heap: fnv.s·2
./foo1.go:8:24: hashIt in does not escape
./foo1.go:17:13: s escapes to heap
./foo1.go:17:59: hashIt(s) escapes to heap
./foo1.go:17:12: main ... argument does not escape
這說(shuō)明了瑰谜,hash
對(duì)象,輸入字符串树绩,和 []byte
都會(huì)逃逸到堆上萨脑。
人肉眼看上去很明顯這些數(shù)據(jù)根本沒(méi)有逃逸,但是 interface 類型限制了編譯器的功能饺饭。
沒(méi)有辦法不進(jìn)入 hash
的 interface 結(jié)構(gòu)而安全的調(diào)用其具體實(shí)現(xiàn)渤早。
所以碰到這種情況,除非自己手動(dòng)實(shí)現(xiàn)一個(gè)不使用 interface 的庫(kù)瘫俊,沒(méi)什么好辦法鹊杖。
一個(gè)小把戲
最后一點(diǎn)要比實(shí)際情況更搞笑。但是扛芽,它能讓我們對(duì)編譯器的逃逸分析機(jī)制有更深刻的理解骂蓖。當(dāng)通過(guò)閱讀標(biāo)準(zhǔn)庫(kù)源碼來(lái)解決性能問(wèn)題時(shí),我們看到了下面這樣的代碼:
// noescape hides a pointer from escape analysis. noescape is
// the identity function but escape analysis doesn't think the
// output depends on the input. noescape is inlined and currently
// compiles down to zero instructions.
// USE CAREFULLY!
//go:nosplit
func noescape(p unsafe.Pointer) unsafe.Pointer {
x := uintptr(p)
return unsafe.Pointer(x ^ 0)
}
這個(gè)函數(shù)會(huì)把指針參數(shù)從編譯器的逃逸分析中隱藏掉川尖。這意味著什么呢登下?讓我們來(lái)舉個(gè)例子看下。
package main
import (
"unsafe"
)
type Foo struct {
S *string
}
func (f *Foo) String() string {
return *f.S
}
type FooTrick struct {
S unsafe.Pointer
}
func (f *FooTrick) String() string {
return *(*string)(f.S)
}
func NewFoo(s string) Foo {
return Foo{S: &s}
}
func NewFooTrick(s string) FooTrick {
return FooTrick{S: noescape(unsafe.Pointer(&s))}
}
func noescape(p unsafe.Pointer) unsafe.Pointer {
x := uintptr(p)
return unsafe.Pointer(x ^ 0)
}
func main() {
s := "hello"
f1 := NewFoo(s)
f2 := NewFooTrick(s)
s1 := f1.String()
s2 := f2.String()
}
上段代碼對(duì)同樣的功能有兩種實(shí)現(xiàn):他們包含一個(gè) string
叮喳,然后用 String()
函數(shù)返回這個(gè)字符串被芳。
但是編譯器的逃逸分析輸出表名了 FooTrick
版本沒(méi)有逃逸。
./foo3.go:24:16: &s escapes to heap
./foo3.go:23:23: moved to heap: s
./foo3.go:27:28: NewFooTrick s does not escape
./foo3.go:28:45: NewFooTrick &s does not escape
./foo3.go:31:33: noescape p does not escape
./foo3.go:38:14: main &s does not escape
./foo3.go:39:19: main &s does not escape
./foo3.go:40:17: main f1 does not escape
./foo3.go:41:17: main f2 does not escape
關(guān)鍵在這兩行
./foo3.go:24:16: &s escapes to heap
./foo3.go:23:23: moved to heap: s
編譯器識(shí)別出了 NewFoo()
函數(shù)引用了字符串并將其存儲(chǔ)在結(jié)構(gòu)體中馍悟,導(dǎo)致了逃逸畔濒。但是,NewFooTrick()
卻沒(méi)有這樣的輸出锣咒。如果把調(diào)用 noescape()
的代碼刪掉侵状,就會(huì)出現(xiàn)逃逸的情況。
到底發(fā)生了什么毅整?
func noescape(p unsafe.Pointer) unsafe.Pointer {
x := uintptr(p)
return unsafe.Pointer(x ^ 0)
}
noescape()
函數(shù)遮蔽了輸入和輸出的依賴關(guān)系壹将。編譯器不認(rèn)為 p
會(huì)通過(guò) x
逃逸, 因?yàn)?uintptr()
產(chǎn)生的引用是編譯器無(wú)法理解的毛嫉。
內(nèi)置的 uintptr
類型讓人相信這是一個(gè)真正的指針類型诽俯,但是在編譯器層面,它只是一個(gè)足夠存儲(chǔ)一個(gè) point 的 int 類型。代碼的最后一行返回 unsafe.Pointer
也是一個(gè) int暴区。
noescape()
在 runtime
包中使用 unsafe.Pointer
的地方被大量使用闯团。如果作者清楚被 unsafe.Pointer
引用的數(shù)據(jù)肯定不會(huì)被逃逸,但編譯器卻不知道的情況下仙粱,這是很有用的房交。
但是請(qǐng)記住,我們強(qiáng)烈不建議使用這種技術(shù)伐割。這就是為什么包的名字叫做 unsafe
而且源碼中包含了 USE CAREFULLY!
注釋的原因候味。
小貼士
- 不要過(guò)早優(yōu)化,用數(shù)據(jù)來(lái)驅(qū)動(dòng)我們的優(yōu)化工作隔心。
- 棸兹海空間分配是廉價(jià)的,堆空間分配是昂貴的硬霍。
- 了解逃逸機(jī)制可以讓我們寫出更高效的代碼帜慢。
- 指針的使用會(huì)導(dǎo)致棧分配更不可行。
- 找到在低效代碼塊中提供分配控制的 api唯卖。
- 在調(diào)用頻繁的地方慎用 interface粱玲。