內(nèi)存
- 平時(shí)我們?cè)陔娔X上聽歌疲恢,聊天,或者啟動(dòng)某個(gè)程序瓷胧,那么這個(gè)啟動(dòng)過程显拳,其實(shí)就是把程序從硬盤讀入到內(nèi)存中去。就像安卓手機(jī)搓萧,內(nèi)存不夠了很卡杂数,殺掉幾個(gè)軟件,內(nèi)存就升上來了瘸洛。但也不是所有的程序都會(huì)一次性的讀入內(nèi)存揍移,為了節(jié)省內(nèi)存空間和提高效率,程序是可用分段或者分頁的加載反肋,比如一個(gè)2k內(nèi)存的機(jī)器讀一個(gè)2m的文件羊精。
什么是內(nèi)存呢
我們知道,CPU計(jì)算很快囚玫,但是磁盤的IO實(shí)在是太慢了喧锦。解決CPU和磁盤之間速度的鴻溝,我們引入了內(nèi)存抓督。其實(shí)在CPU內(nèi)部還有一部分緩存燃少。我們先來看一下計(jì)算機(jī)的存儲(chǔ)設(shè)備有哪些。
[圖片上傳失敗...(image-154fa6-1683545907588)]
我們?cè)倭炕幌逻@些存儲(chǔ)設(shè)備的速度铃在,大概是這樣
- CPU : 每個(gè)指令大概需要 0.38ns阵具,以此作為對(duì)比的基本單位 1s
- 一級(jí)緩存:讀取時(shí)間大約為 0.5ns,對(duì)比 CPU 的時(shí)間大約是 1.3s
- CPU 分支預(yù)測錯(cuò)誤: 耗時(shí)為 5ns定铜,對(duì)比 CPU 的時(shí)間大約是 13s
- 二級(jí)緩存:讀取時(shí)間大約為 7ns阳液,對(duì)比 CPU 的時(shí)間大約是 18.2s(與一級(jí)緩存相差了一個(gè)數(shù)量級(jí))
- 鎖:互斥鎖的加鎖和解鎖大約需要 25ns,對(duì)比 CPU 的時(shí)間大約是 65s(一分鐘)揣炕。所以說帘皿,在并發(fā)編程中,鎖是一個(gè)很耗時(shí)的操作
- 內(nèi)存:每次內(nèi)存尋址需要 100ns畸陡,對(duì)比 CPU 的時(shí)間大約是 260s(四分鐘鹰溜,又提升了一個(gè)數(shù)量級(jí))。CPU 和內(nèi)存之間的瓶頸被稱為馮諾依曼瓶頸
- 一次 CPU 上下文切換:大約耗時(shí)為 1500ns丁恭,對(duì)比 CPU 的時(shí)間大約是 65 分鐘(一個(gè)小時(shí))曹动。在上下文切換的時(shí)間內(nèi)萄金,CPU 沒有做任何有用的計(jì)算书在,只是切換了兩個(gè)不同進(jìn)程的寄存器和內(nèi)存狀態(tài)。
- 在 1Gbps 的網(wǎng)絡(luò)上傳輸 2k 的數(shù)據(jù)需要 20us埋嵌,對(duì)比 CPU 的時(shí)間大約是 14.4 個(gè)小時(shí)(理論值,實(shí)際中可能更久)贡必,可以看到網(wǎng)絡(luò)上非常少的數(shù)據(jù)傳輸對(duì)于 CPU 來說已經(jīng)很漫長了
- SSD 隨機(jī)讀取耗時(shí)為 150us熬的,對(duì)比 CPU 的時(shí)間為 4.5 天。SSD 的速度已經(jīng)比機(jī)械硬盤快很多了赊级,但對(duì)于 CPU 來說速度就想烏龜一樣押框。所以應(yīng)該少寫 I/O 設(shè)備讀取的代碼,把常用的數(shù)據(jù)放到內(nèi)存中作為緩存理逊。
- 從內(nèi)存中讀取1MB 的連續(xù)數(shù)據(jù)橡伞,耗時(shí)大約是 250us,對(duì)比 CPU 的時(shí)間是 7.5 天
- 同一個(gè)數(shù)據(jù)中心網(wǎng)絡(luò)上跑一個(gè)來回需要 0.5ms晋被,對(duì)比 CPU 的時(shí)間大約是 15 天(半個(gè)月)兑徘。
- 從 SSD 讀取 1MB 的順序數(shù)據(jù),大約學(xué)院 1ms羡洛,對(duì)比 CPU 的時(shí)間大約是一個(gè)月
- 磁盤尋址時(shí)間是 10ms挂脑,對(duì)比 CPU 的時(shí)間是 10 個(gè)月
- 從磁盤讀取 1MB 的連續(xù)數(shù)據(jù)需要 20ms,對(duì)比 CPU 的時(shí)間是 20 個(gè)月欲侮。所以說IO 設(shè)備是計(jì)算機(jī)系統(tǒng)的瓶頸
- 從世界上不同城市的網(wǎng)絡(luò)上走一個(gè)來回崭闲,平均需要 150ms,對(duì)比 CPU 的時(shí)間是 12.5 年威蕉。所以程序和架構(gòu)都會(huì)盡量避免不同城市或者是跨國家的網(wǎng)絡(luò)訪問
- 虛擬機(jī)重啟一次需要 4s 的時(shí)間刁俭,對(duì)比 CPU 的時(shí)間是三百多年,
- 物理服務(wù)器重啟一次的時(shí)間是5min韧涨,對(duì)比 CPU 的時(shí)間是2萬5千年
那么為什么我們不能全部用最高速的存儲(chǔ)設(shè)備呢牍戚?因?yàn)樵娇拷?CPU 速度越快,容量越小虑粥,價(jià)格越貴如孝。哈哈。
另外每一種存儲(chǔ)器設(shè)備只和它相鄰的存儲(chǔ)設(shè)備打交道娩贷。比如第晰,CPU Cache 是從內(nèi)存里加載而來的,或者需要寫回內(nèi)存育勺,并不會(huì)直接寫回?cái)?shù)據(jù)到硬盤但荤,也不會(huì)直接從硬盤加載數(shù)據(jù)到 CPU Cache 中,而是先加載到內(nèi)存涧至,再從內(nèi)存加載到 Cache 中。
- 在linux下桑包,我們可以通過以下命令查看高速緩存的大小
# cat /sys/devices/system/cpu/cpu0/cache/index0/size
32K
# cat /sys/devices/system/cpu/cpu0/cache/index1/size
32K
# cat /sys/devices/system/cpu/cpu0/cache/index2/size
4096K
內(nèi)存逃逸
- go程序中的數(shù)據(jù)和變量都會(huì)被分配到程序所在擁有的內(nèi)存中南蓬。而內(nèi)存中有兩個(gè)重要的區(qū)域,就是棧區(qū)(Stack)和堆區(qū)(Heap)。
棧
棧區(qū)的內(nèi)存一般由編譯器自動(dòng)進(jìn)行分配和釋放赘方,其中存儲(chǔ)著函數(shù)的入?yún)⒁约熬植孔兞可沼保@些參數(shù)會(huì)隨著函數(shù)的創(chuàng)建而 創(chuàng)建,函數(shù)的返回而消亡窄陡,一般不會(huì)在程序中長期存在炕淮,這種線性的內(nèi)存分配策略有著極高地效率,但是工程師也往 往不能控制棧內(nèi)存的分配跳夭,這部分工作基本都是由編譯器自動(dòng)完成的涂圆。
堆
一般來講堆是人為手動(dòng)進(jìn)行管理,手動(dòng)申請(qǐng)币叹、分配润歉、釋放。一般硬件內(nèi)存有多大堆內(nèi)存就有多大颈抚。適合不可預(yù)知大小的內(nèi)存分配踩衩,分配速度較慢,而且會(huì)形成內(nèi)存碎片贩汉。C++ 等編程語言會(huì)由工程師主動(dòng)申請(qǐng)和釋放內(nèi)存驱富,Go 以及 Java 等編程語言 會(huì)由工程師和編譯器共同管理,堆中的對(duì)象由內(nèi)存分配器分配并由垃圾收集器回收匹舞。
什么是內(nèi)存逃逸
當(dāng)編譯器無法保證一個(gè)變量的生命周期只在函數(shù)內(nèi)部時(shí)萌朱,它就會(huì)認(rèn)為這個(gè)變量逃逸了,需要在堆上分配內(nèi)存策菜。這樣可以保證變量在函數(shù)返回后仍然有效晶疼,不會(huì)被棧回收又憨。簡單來說翠霍,局部變量通過堆分配和回收,就叫內(nèi)存逃逸蠢莺。在程序中寒匙,每個(gè)函數(shù)塊都會(huì)有自己的內(nèi)存區(qū)域用來存自己的局部變量(內(nèi)存占用少)、返回地址躏将、返回值之類的數(shù)據(jù)锄弱,這一塊內(nèi)存區(qū)域有特定的結(jié)構(gòu)和尋址方式,尋址起來十分迅速祸憋,開銷很少会宪。這一塊內(nèi)存地址稱為棧。棧是線程級(jí)別的蚯窥,大小在創(chuàng)建的時(shí)候已經(jīng)確定掸鹅,當(dāng)變量太大的時(shí)候塞帐,會(huì)"逃逸"到堆上,這種現(xiàn)象稱為內(nèi)存逃逸巍沙。
內(nèi)存逃逸的影響
通過前面講解的堆棧葵姥,我們知道堆分配昂貴,棧分配廉價(jià)句携,在go中所有內(nèi)存優(yōu)先棧分配榔幸。而堆是一塊沒有特定結(jié)構(gòu),也沒有固定大小的內(nèi)存區(qū)域矮嫉,可以根據(jù)需要進(jìn)行調(diào)整削咆。全局變量,內(nèi)存占用較大的局部變量敞临,函數(shù)調(diào)用結(jié)束后不能立刻回收的局部變量都會(huì)存在堆里面态辛。變量在堆上的分配和回收都比在棧上開銷大的多。對(duì)于 go 這種帶 GC 的語言來說挺尿,會(huì)增加 gc 壓力奏黑,同時(shí)也容易造成內(nèi)存碎片。
go中內(nèi)存逃逸的現(xiàn)象舉例
- 局部變量x在函數(shù)結(jié)束后還被其他地方調(diào)用
func Foo()func(){
x:=5
return func(){
x+=1
}
}
func main(){
foo:=Foo()
foo()
}
我們使用go build -gcflags '-m -l' main.go
來查看內(nèi)存逃逸的情況编矾,
- -m 會(huì)打印出逃逸分析的優(yōu)化策略熟史,實(shí)際上最多總共可以用 4 個(gè) -m,但是信息量較大窄俏,一般用 1 個(gè)就可以了
- -l 會(huì)禁用函數(shù)內(nèi)聯(lián)蹂匹,在這里禁用掉 inline 能更好的觀察逃逸情況,減少干擾凹蜈。
或者通過反編譯命令go tool compile -S main.go
更底層限寞,更硬核,更準(zhǔn)確的方式來判斷一個(gè)對(duì)象是否逃逸
[圖片上傳失敗...(image-629c4-1683545907588)]
其中move to heap 是在代碼生成階段發(fā)生的仰坦,它是編譯器根據(jù)逃逸分析的結(jié)果履植,為變量生成在堆上分配內(nèi)存的代碼。escapes to heap 是在逃逸分析階段發(fā)生的悄晃,它是編譯器判斷一個(gè)變量是否需要在堆上分配內(nèi)存的過程玫霎。
- 像下面這種指針類型的值,都會(huì)被存儲(chǔ)到堆上面妈橄,因?yàn)槭侵羔橆愋褪幾g器不知道在函數(shù)運(yùn)行結(jié)束后,外部還是否會(huì)用到它眷蚓,所以不能對(duì)它進(jìn)行回收鼻种,它就會(huì)認(rèn)為這個(gè)變量逃逸了,就會(huì)在堆上分配內(nèi)存溪椎。這樣可以保證變量在函數(shù)返回后仍然有效普舆,不會(huì)被椞窨冢回收校读。
[圖片上傳失敗...(image-9c27df-1683545907588)]
同理沼侣,下面這種也是內(nèi)存逃逸,因?yàn)榍衅瑂1指向的是底層數(shù)組歉秫,沒有發(fā)生逃逸蛾洛,切片里面元素x發(fā)生了逃逸
[圖片上傳失敗...(image-86c4be-1683545907588)] - 還有一種情況,就是數(shù)據(jù)量太大雁芙,棧放不下了轧膘。也會(huì)發(fā)生逃逸,比如下面這個(gè)切片兔甘,大小是10000 * 8 = 80000字節(jié) = 80KB谎碍,而切片的預(yù)估容量是64k,所以發(fā)生內(nèi)存逃逸洞焙。當(dāng)然蟆淀,在32位操作系統(tǒng)中,超過32k就會(huì)發(fā)生內(nèi)存逃逸澡匪。
[圖片上傳失敗...(image-3b5e7a-1683545907588)] - 還有像 interface 類型上調(diào)用方法熔任,接口在編譯的時(shí)候不知道foofunc怎么實(shí)現(xiàn)的。只有運(yùn)行的時(shí)候才知道唁情,所以interface變量使用堆分配疑苔。
[圖片上傳失敗...(image-5f33c5-1683545907588)]
如何避免內(nèi)存逃逸
- 盡量減少外部指針引用,必要的時(shí)候可以使用值傳遞甸鸟;
- 對(duì)于自己定義的數(shù)據(jù)大小惦费,有一個(gè)基本的預(yù)判,盡量不要出現(xiàn)椙谰拢空間溢出的情況薪贫;
- Golang中的接口類型的方法調(diào)用是動(dòng)態(tài)調(diào)度,如果對(duì)于性能要求比較高且訪問頻次比較高的函數(shù)調(diào)用篮绰,應(yīng)該盡量避免使用接口類型后雷;
- 盡量不要寫閉包函數(shù),可讀性差且發(fā)生逃逸吠各。
總結(jié)
- 逃逸分析在編譯階段確定哪些變量可以分配在棧中臀突,哪些變量分配在堆上
- 逃逸分析減輕了GC壓力,提高程序的運(yùn)行速度
- 棧上內(nèi)存使用完畢不需要GC處理贾漏,堆上內(nèi)存使用完畢會(huì)交給GC處理
- 函數(shù)傳參時(shí)對(duì)于需要修改原對(duì)象值候学,或占用內(nèi)存比較大的結(jié)構(gòu)體,選擇傳指針纵散。對(duì)于只讀的占用內(nèi)存較小的結(jié)構(gòu)體梳码,直接傳值能夠獲得更好的性能
- 根據(jù)代碼具體分析隐圾,盡量減少逃逸代碼,減輕GC壓力掰茶,提高性能