什么是內(nèi)存?什么是內(nèi)存逃逸?怎么做內(nèi)存逃逸分析

內(nèi)存

  • 平時我們在電腦上聽歌,聊天,或者啟動某個程序,那么這個啟動過程,其實就是把程序從硬盤讀入到內(nèi)存中去。就像安卓手機,內(nèi)存不夠了很卡,殺掉幾個軟件,內(nèi)存就升上來了。但也不是所有的程序都會一次性的讀入內(nèi)存,為了節(jié)省內(nèi)存空間和提高效率,程序是可用分段或者分頁的加載,比如一個2k內(nèi)存的機器讀一個2m的文件。

什么是內(nèi)存呢

我們知道,CPU計算很快,但是磁盤的IO實在是太慢了。解決CPU和磁盤之間速度的鴻溝,我們引入了內(nèi)存。其實在CPU內(nèi)部還有一部分緩存。我們先來看一下計算機的存儲設(shè)備有哪些。
[圖片上傳失敗...(image-154fa6-1683545907588)]
我們再量化一下這些存儲設(shè)備的速度,大概是這樣

  • CPU : 每個指令大概需要 0.38ns,以此作為對比的基本單位 1s
  • 一級緩存:讀取時間大約為 0.5ns,對比 CPU 的時間大約是 1.3s
  • CPU 分支預(yù)測錯誤: 耗時為 5ns,對比 CPU 的時間大約是 13s
  • 二級緩存:讀取時間大約為 7ns,對比 CPU 的時間大約是 18.2s(與一級緩存相差了一個數(shù)量級)
  • 鎖:互斥鎖的加鎖和解鎖大約需要 25ns,對比 CPU 的時間大約是 65s(一分鐘)。所以說,在并發(fā)編程中,鎖是一個很耗時的操作
  • 內(nèi)存:每次內(nèi)存尋址需要 100ns,對比 CPU 的時間大約是 260s(四分鐘,又提升了一個數(shù)量級)。CPU 和內(nèi)存之間的瓶頸被稱為馮諾依曼瓶頸
  • 一次 CPU 上下文切換:大約耗時為 1500ns,對比 CPU 的時間大約是 65 分鐘(一個小時)。在上下文切換的時間內(nèi),CPU 沒有做任何有用的計算,只是切換了兩個不同進程的寄存器和內(nèi)存狀態(tài)。
  • 在 1Gbps 的網(wǎng)絡(luò)上傳輸 2k 的數(shù)據(jù)需要 20us,對比 CPU 的時間大約是 14.4 個小時(理論值,實際中可能更久),可以看到網(wǎng)絡(luò)上非常少的數(shù)據(jù)傳輸對于 CPU 來說已經(jīng)很漫長了
  • SSD 隨機讀取耗時為 150us,對比 CPU 的時間為 4.5 天。SSD 的速度已經(jīng)比機械硬盤快很多了,但對于 CPU 來說速度就想烏龜一樣。所以應(yīng)該少寫 I/O 設(shè)備讀取的代碼,把常用的數(shù)據(jù)放到內(nèi)存中作為緩存。
  • 從內(nèi)存中讀取1MB 的連續(xù)數(shù)據(jù),耗時大約是 250us,對比 CPU 的時間是 7.5 天
  • 同一個數(shù)據(jù)中心網(wǎng)絡(luò)上跑一個來回需要 0.5ms,對比 CPU 的時間大約是 15 天(半個月)。
  • 從 SSD 讀取 1MB 的順序數(shù)據(jù),大約學(xué)院 1ms,對比 CPU 的時間大約是一個月
  • 磁盤尋址時間是 10ms,對比 CPU 的時間是 10 個月
  • 從磁盤讀取 1MB 的連續(xù)數(shù)據(jù)需要 20ms,對比 CPU 的時間是 20 個月。所以說IO 設(shè)備是計算機系統(tǒng)的瓶頸
  • 從世界上不同城市的網(wǎng)絡(luò)上走一個來回,平均需要 150ms,對比 CPU 的時間是 12.5 年。所以程序和架構(gòu)都會盡量避免不同城市或者是跨國家的網(wǎng)絡(luò)訪問
  • 虛擬機重啟一次需要 4s 的時間,對比 CPU 的時間是三百多年,
  • 物理服務(wù)器重啟一次的時間是5min,對比 CPU 的時間是2萬5千年

那么為什么我們不能全部用最高速的存儲設(shè)備呢?因為越靠近 CPU 速度越快,容量越小,價格越貴。哈哈。
另外每一種存儲器設(shè)備只和它相鄰的存儲設(shè)備打交道。比如,CPU Cache 是從內(nèi)存里加載而來的,或者需要寫回內(nèi)存,并不會直接寫回數(shù)據(jù)到硬盤,也不會直接從硬盤加載數(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ù)和變量都會被分配到程序所在擁有的內(nèi)存中。而內(nèi)存中有兩個重要的區(qū)域,就是棧區(qū)(Stack)和堆區(qū)(Heap)。

棧區(qū)的內(nèi)存一般由編譯器自動進行分配和釋放,其中存儲著函數(shù)的入?yún)⒁约熬植孔兞?,這些參數(shù)會隨著函數(shù)的創(chuàng)建而 創(chuàng)建,函數(shù)的返回而消亡,一般不會在程序中長期存在,這種線性的內(nèi)存分配策略有著極高地效率,但是工程師也往 往不能控制棧內(nèi)存的分配,這部分工作基本都是由編譯器自動完成的。

一般來講堆是人為手動進行管理,手動申請、分配、釋放。一般硬件內(nèi)存有多大堆內(nèi)存就有多大。適合不可預(yù)知大小的內(nèi)存分配,分配速度較慢,而且會形成內(nèi)存碎片。C++ 等編程語言會由工程師主動申請和釋放內(nèi)存,Go 以及 Java 等編程語言 會由工程師和編譯器共同管理,堆中的對象由內(nèi)存分配器分配并由垃圾收集器回收。

什么是內(nèi)存逃逸

當(dāng)編譯器無法保證一個變量的生命周期只在函數(shù)內(nèi)部時,它就會認為這個變量逃逸了,需要在堆上分配內(nèi)存。這樣可以保證變量在函數(shù)返回后仍然有效,不會被?;厥?。簡單來說,局部變量通過堆分配和回收,就叫內(nèi)存逃逸。在程序中,每個函數(shù)塊都會有自己的內(nèi)存區(qū)域用來存自己的局部變量(內(nèi)存占用少)、返回地址、返回值之類的數(shù)據(jù),這一塊內(nèi)存區(qū)域有特定的結(jié)構(gòu)和尋址方式,尋址起來十分迅速,開銷很少。這一塊內(nèi)存地址稱為棧。棧是線程級別的,大小在創(chuàng)建的時候已經(jīng)確定,當(dāng)變量太大的時候,會"逃逸"到堆上,這種現(xiàn)象稱為內(nèi)存逃逸。

內(nèi)存逃逸的影響

通過前面講解的堆棧,我們知道堆分配昂貴,棧分配廉價,在go中所有內(nèi)存優(yōu)先棧分配。而堆是一塊沒有特定結(jié)構(gòu),也沒有固定大小的內(nèi)存區(qū)域,可以根據(jù)需要進行調(diào)整。全局變量,內(nèi)存占用較大的局部變量,函數(shù)調(diào)用結(jié)束后不能立刻回收的局部變量都會存在堆里面。變量在堆上的分配和回收都比在棧上開銷大的多。對于 go 這種帶 GC 的語言來說,會增加 gc 壓力,同時也容易造成內(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 會打印出逃逸分析的優(yōu)化策略,實際上最多總共可以用 4 個 -m,但是信息量較大,一般用 1 個就可以了
  • -l 會禁用函數(shù)內(nèi)聯(lián),在這里禁用掉 inline 能更好的觀察逃逸情況,減少干擾。

或者通過反編譯命令go tool compile -S main.go 更底層,更硬核,更準確的方式來判斷一個對象是否逃逸

[圖片上傳失敗...(image-629c4-1683545907588)]
其中move to heap 是在代碼生成階段發(fā)生的,它是編譯器根據(jù)逃逸分析的結(jié)果,為變量生成在堆上分配內(nèi)存的代碼。escapes to heap 是在逃逸分析階段發(fā)生的,它是編譯器判斷一個變量是否需要在堆上分配內(nèi)存的過程。

  • 像下面這種指針類型的值,都會被存儲到堆上面,因為是指針類型,編譯器不知道在函數(shù)運行結(jié)束后,外部還是否會用到它,所以不能對它進行回收,它就會認為這個變量逃逸了,就會在堆上分配內(nèi)存。這樣可以保證變量在函數(shù)返回后仍然有效,不會被?;厥?。
    [圖片上傳失敗...(image-9c27df-1683545907588)]
    同理,下面這種也是內(nèi)存逃逸,因為切片s1指向的是底層數(shù)組,沒有發(fā)生逃逸,切片里面元素x發(fā)生了逃逸
    [圖片上傳失敗...(image-86c4be-1683545907588)]
  • 還有一種情況,就是數(shù)據(jù)量太大,棧放不下了。也會發(fā)生逃逸,比如下面這個切片,大小是10000 * 8 = 80000字節(jié) = 80KB,而切片的預(yù)估容量是64k,所以發(fā)生內(nèi)存逃逸。當(dāng)然,在32位操作系統(tǒng)中,超過32k就會發(fā)生內(nèi)存逃逸。
    [圖片上傳失敗...(image-3b5e7a-1683545907588)]
  • 還有像 interface 類型上調(diào)用方法,接口在編譯的時候不知道foofunc怎么實現(xiàn)的。只有運行的時候才知道,所以interface變量使用堆分配。
    [圖片上傳失敗...(image-5f33c5-1683545907588)]

如何避免內(nèi)存逃逸

  • 盡量減少外部指針引用,必要的時候可以使用值傳遞;
  • 對于自己定義的數(shù)據(jù)大小,有一個基本的預(yù)判,盡量不要出現(xiàn)??臻g溢出的情況;
  • Golang中的接口類型的方法調(diào)用是動態(tài)調(diào)度,如果對于性能要求比較高且訪問頻次比較高的函數(shù)調(diào)用,應(yīng)該盡量避免使用接口類型;
  • 盡量不要寫閉包函數(shù),可讀性差且發(fā)生逃逸。

總結(jié)

  • 逃逸分析在編譯階段確定哪些變量可以分配在棧中,哪些變量分配在堆上
  • 逃逸分析減輕了GC壓力,提高程序的運行速度
  • 棧上內(nèi)存使用完畢不需要GC處理,堆上內(nèi)存使用完畢會交給GC處理
  • 函數(shù)傳參時對于需要修改原對象值,或占用內(nèi)存比較大的結(jié)構(gòu)體,選擇傳指針。對于只讀的占用內(nèi)存較小的結(jié)構(gòu)體,直接傳值能夠獲得更好的性能
  • 根據(jù)代碼具體分析,盡量減少逃逸代碼,減輕GC壓力,提高性能
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容