'Go內存分配那些事,看這一篇就夠,深度好文'

"
Go語言中文網,致力於每日分享編碼、開源等知識,歡迎關注我,會有意想不到的收穫!

這篇文章主要介紹Go內存分配和Go內存管理,會輕微涉及內存申請和釋放,以及Go垃圾回收。

從非常宏觀的角度看,Go的內存管理就是下圖這個樣子,我們今天主要關注其中標紅的部分。

"
Go語言中文網,致力於每日分享編碼、開源等知識,歡迎關注我,會有意想不到的收穫!

這篇文章主要介紹Go內存分配和Go內存管理,會輕微涉及內存申請和釋放,以及Go垃圾回收。

從非常宏觀的角度看,Go的內存管理就是下圖這個樣子,我們今天主要關注其中標紅的部分。

Go內存分配那些事,看這一篇就夠,深度好文


友情提醒:文章有點長,建議先收藏,後閱讀,絕對是學習內存管理的好資料。

本文基於go1.11.2,不同版本Go的內存管理可能存在差別,比如1.9與1.11的mheap定義就是差別比較大的,後續看源碼的時候,請注意你的go版本,但無論你用哪個go版本,這都是一個優秀的資料,因為內存管理的思想和框架始終未變。

Go這門語言拋棄了C/C++中的開發者管理內存的方式:主動申請與主動釋放,增加了逃逸分析和GC,將開發者從內存管理中釋放出來,讓開發者有更多的精力去關注軟件設計,而不是底層的內存問題。這是Go語言成為高生產力語言的原因之一。

我們不需要精通內存的管理,因為它確實很複雜,但掌握內存的管理,可以讓你寫出更高質量的代碼,另外,還能助你定位Bug。

這篇文章採用層層遞進的方式,依次會介紹關於存儲的基本知識,Go內存管理的“前輩”TCMalloc,然後是Go的內存管理和分配,最後是總結。這麼做的目的是,希望各位能通過全局的認識和思考,擁有更好的編碼思維和架構思維。

最後,這不是一篇源碼分析文章,因為Go源碼分析的文章已經有很多了,這些源碼文章能夠幫助你去學習具體的工程實踐和奇淫巧計了,文章的末尾會推薦一些優秀文章,如果你對內存感興趣,建議每一篇都去看一下,挑出自己喜歡的,多花時間研究下。

1. 存儲基礎知識回顧

這部分我們簡單回顧一下計算機存儲體系、虛擬內存、棧和堆,以及堆內存的管理,這部分內容對理解和掌握Go內存管理比較重要,建議忘記或不熟悉的朋友不要跳過。

存儲金字塔

"
Go語言中文網,致力於每日分享編碼、開源等知識,歡迎關注我,會有意想不到的收穫!

這篇文章主要介紹Go內存分配和Go內存管理,會輕微涉及內存申請和釋放,以及Go垃圾回收。

從非常宏觀的角度看,Go的內存管理就是下圖這個樣子,我們今天主要關注其中標紅的部分。

Go內存分配那些事,看這一篇就夠,深度好文


友情提醒:文章有點長,建議先收藏,後閱讀,絕對是學習內存管理的好資料。

本文基於go1.11.2,不同版本Go的內存管理可能存在差別,比如1.9與1.11的mheap定義就是差別比較大的,後續看源碼的時候,請注意你的go版本,但無論你用哪個go版本,這都是一個優秀的資料,因為內存管理的思想和框架始終未變。

Go這門語言拋棄了C/C++中的開發者管理內存的方式:主動申請與主動釋放,增加了逃逸分析和GC,將開發者從內存管理中釋放出來,讓開發者有更多的精力去關注軟件設計,而不是底層的內存問題。這是Go語言成為高生產力語言的原因之一。

我們不需要精通內存的管理,因為它確實很複雜,但掌握內存的管理,可以讓你寫出更高質量的代碼,另外,還能助你定位Bug。

這篇文章採用層層遞進的方式,依次會介紹關於存儲的基本知識,Go內存管理的“前輩”TCMalloc,然後是Go的內存管理和分配,最後是總結。這麼做的目的是,希望各位能通過全局的認識和思考,擁有更好的編碼思維和架構思維。

最後,這不是一篇源碼分析文章,因為Go源碼分析的文章已經有很多了,這些源碼文章能夠幫助你去學習具體的工程實踐和奇淫巧計了,文章的末尾會推薦一些優秀文章,如果你對內存感興趣,建議每一篇都去看一下,挑出自己喜歡的,多花時間研究下。

1. 存儲基礎知識回顧

這部分我們簡單回顧一下計算機存儲體系、虛擬內存、棧和堆,以及堆內存的管理,這部分內容對理解和掌握Go內存管理比較重要,建議忘記或不熟悉的朋友不要跳過。

存儲金字塔

Go內存分配那些事,看這一篇就夠,深度好文


這幅圖表達了計算機的存儲體系,從上至下依次是:

  • CPU寄存器
  • Cache
  • 內存
  • 硬盤等輔助存儲設備
  • 鼠標等外接設備

從上至下,訪問速度越來越慢,訪問時間越來越長。

你有沒有思考過下面2個簡單的問題,如果沒有不妨想想:

  1. 如果CPU直接訪問硬盤,CPU能充分利用嗎?
  2. 如果CPU直接訪問內存,CPU能充分利用嗎?

CPU速度很快,但硬盤等持久存儲很慢,如果CPU直接訪問磁盤,磁盤可以拉低CPU的速度,機器整體性能就會低下,為了彌補這2個硬件之間的速率差異,所以在CPU和磁盤之間增加了比磁盤快很多的內存。

"
Go語言中文網,致力於每日分享編碼、開源等知識,歡迎關注我,會有意想不到的收穫!

這篇文章主要介紹Go內存分配和Go內存管理,會輕微涉及內存申請和釋放,以及Go垃圾回收。

從非常宏觀的角度看,Go的內存管理就是下圖這個樣子,我們今天主要關注其中標紅的部分。

Go內存分配那些事,看這一篇就夠,深度好文


友情提醒:文章有點長,建議先收藏,後閱讀,絕對是學習內存管理的好資料。

本文基於go1.11.2,不同版本Go的內存管理可能存在差別,比如1.9與1.11的mheap定義就是差別比較大的,後續看源碼的時候,請注意你的go版本,但無論你用哪個go版本,這都是一個優秀的資料,因為內存管理的思想和框架始終未變。

Go這門語言拋棄了C/C++中的開發者管理內存的方式:主動申請與主動釋放,增加了逃逸分析和GC,將開發者從內存管理中釋放出來,讓開發者有更多的精力去關注軟件設計,而不是底層的內存問題。這是Go語言成為高生產力語言的原因之一。

我們不需要精通內存的管理,因為它確實很複雜,但掌握內存的管理,可以讓你寫出更高質量的代碼,另外,還能助你定位Bug。

這篇文章採用層層遞進的方式,依次會介紹關於存儲的基本知識,Go內存管理的“前輩”TCMalloc,然後是Go的內存管理和分配,最後是總結。這麼做的目的是,希望各位能通過全局的認識和思考,擁有更好的編碼思維和架構思維。

最後,這不是一篇源碼分析文章,因為Go源碼分析的文章已經有很多了,這些源碼文章能夠幫助你去學習具體的工程實踐和奇淫巧計了,文章的末尾會推薦一些優秀文章,如果你對內存感興趣,建議每一篇都去看一下,挑出自己喜歡的,多花時間研究下。

1. 存儲基礎知識回顧

這部分我們簡單回顧一下計算機存儲體系、虛擬內存、棧和堆,以及堆內存的管理,這部分內容對理解和掌握Go內存管理比較重要,建議忘記或不熟悉的朋友不要跳過。

存儲金字塔

Go內存分配那些事,看這一篇就夠,深度好文


這幅圖表達了計算機的存儲體系,從上至下依次是:

  • CPU寄存器
  • Cache
  • 內存
  • 硬盤等輔助存儲設備
  • 鼠標等外接設備

從上至下,訪問速度越來越慢,訪問時間越來越長。

你有沒有思考過下面2個簡單的問題,如果沒有不妨想想:

  1. 如果CPU直接訪問硬盤,CPU能充分利用嗎?
  2. 如果CPU直接訪問內存,CPU能充分利用嗎?

CPU速度很快,但硬盤等持久存儲很慢,如果CPU直接訪問磁盤,磁盤可以拉低CPU的速度,機器整體性能就會低下,為了彌補這2個硬件之間的速率差異,所以在CPU和磁盤之間增加了比磁盤快很多的內存。

Go內存分配那些事,看這一篇就夠,深度好文


然而,CPU跟內存的速率也不是相同的,從上圖可以看到,CPU的速率提高的很快(摩爾定律),然而內存速率增長的很慢,雖然CPU的速率現在增加的很慢了,但是內存的速率也沒增加多少,速率差距很大,從1980年開始CPU和內存速率差距在不斷拉大,為了彌補這2個硬件之間的速率差異,所以在CPU跟內存之間增加了比內存更快的Cache,Cache是內存數據的緩存,可以降低CPU訪問內存的時間。

不要以為有了Cache就萬事大吉了,CPU的速率還在不斷增大,Cache也在不斷改變,從最初的1級,到後來的2級,到當代的3級Cache,(有興趣看cache歷史)

"
Go語言中文網,致力於每日分享編碼、開源等知識,歡迎關注我,會有意想不到的收穫!

這篇文章主要介紹Go內存分配和Go內存管理,會輕微涉及內存申請和釋放,以及Go垃圾回收。

從非常宏觀的角度看,Go的內存管理就是下圖這個樣子,我們今天主要關注其中標紅的部分。

Go內存分配那些事,看這一篇就夠,深度好文


友情提醒:文章有點長,建議先收藏,後閱讀,絕對是學習內存管理的好資料。

本文基於go1.11.2,不同版本Go的內存管理可能存在差別,比如1.9與1.11的mheap定義就是差別比較大的,後續看源碼的時候,請注意你的go版本,但無論你用哪個go版本,這都是一個優秀的資料,因為內存管理的思想和框架始終未變。

Go這門語言拋棄了C/C++中的開發者管理內存的方式:主動申請與主動釋放,增加了逃逸分析和GC,將開發者從內存管理中釋放出來,讓開發者有更多的精力去關注軟件設計,而不是底層的內存問題。這是Go語言成為高生產力語言的原因之一。

我們不需要精通內存的管理,因為它確實很複雜,但掌握內存的管理,可以讓你寫出更高質量的代碼,另外,還能助你定位Bug。

這篇文章採用層層遞進的方式,依次會介紹關於存儲的基本知識,Go內存管理的“前輩”TCMalloc,然後是Go的內存管理和分配,最後是總結。這麼做的目的是,希望各位能通過全局的認識和思考,擁有更好的編碼思維和架構思維。

最後,這不是一篇源碼分析文章,因為Go源碼分析的文章已經有很多了,這些源碼文章能夠幫助你去學習具體的工程實踐和奇淫巧計了,文章的末尾會推薦一些優秀文章,如果你對內存感興趣,建議每一篇都去看一下,挑出自己喜歡的,多花時間研究下。

1. 存儲基礎知識回顧

這部分我們簡單回顧一下計算機存儲體系、虛擬內存、棧和堆,以及堆內存的管理,這部分內容對理解和掌握Go內存管理比較重要,建議忘記或不熟悉的朋友不要跳過。

存儲金字塔

Go內存分配那些事,看這一篇就夠,深度好文


這幅圖表達了計算機的存儲體系,從上至下依次是:

  • CPU寄存器
  • Cache
  • 內存
  • 硬盤等輔助存儲設備
  • 鼠標等外接設備

從上至下,訪問速度越來越慢,訪問時間越來越長。

你有沒有思考過下面2個簡單的問題,如果沒有不妨想想:

  1. 如果CPU直接訪問硬盤,CPU能充分利用嗎?
  2. 如果CPU直接訪問內存,CPU能充分利用嗎?

CPU速度很快,但硬盤等持久存儲很慢,如果CPU直接訪問磁盤,磁盤可以拉低CPU的速度,機器整體性能就會低下,為了彌補這2個硬件之間的速率差異,所以在CPU和磁盤之間增加了比磁盤快很多的內存。

Go內存分配那些事,看這一篇就夠,深度好文


然而,CPU跟內存的速率也不是相同的,從上圖可以看到,CPU的速率提高的很快(摩爾定律),然而內存速率增長的很慢,雖然CPU的速率現在增加的很慢了,但是內存的速率也沒增加多少,速率差距很大,從1980年開始CPU和內存速率差距在不斷拉大,為了彌補這2個硬件之間的速率差異,所以在CPU跟內存之間增加了比內存更快的Cache,Cache是內存數據的緩存,可以降低CPU訪問內存的時間。

不要以為有了Cache就萬事大吉了,CPU的速率還在不斷增大,Cache也在不斷改變,從最初的1級,到後來的2級,到當代的3級Cache,(有興趣看cache歷史)

Go內存分配那些事,看這一篇就夠,深度好文


三級Cache分別是L1、L2、L3,它們的速率是三個不同的層級,L1速率最快,與CPU速率最接近,是RAM速率的100倍,L2速率就降到了RAM的25倍,L3的速率更靠近RAM的速率。

看到這了,你有沒有Get到整個存儲體系的分層設計自頂向下,速率越來越低,訪問時間越來越長,從磁盤到CPU寄存器,上一層都可以看做是下一層的緩存。

看了分層設計,我們看一下內存,畢竟我們是介紹內存管理的文章。

虛擬內存

虛擬內存是當代操作系統必備的一項重要功能了,它向進程屏蔽了底層了RAM和磁盤,並向進程提供了遠超物理內存大小的內存空間。我們看一下虛擬內存的分層設計

"
Go語言中文網,致力於每日分享編碼、開源等知識,歡迎關注我,會有意想不到的收穫!

這篇文章主要介紹Go內存分配和Go內存管理,會輕微涉及內存申請和釋放,以及Go垃圾回收。

從非常宏觀的角度看,Go的內存管理就是下圖這個樣子,我們今天主要關注其中標紅的部分。

Go內存分配那些事,看這一篇就夠,深度好文


友情提醒:文章有點長,建議先收藏,後閱讀,絕對是學習內存管理的好資料。

本文基於go1.11.2,不同版本Go的內存管理可能存在差別,比如1.9與1.11的mheap定義就是差別比較大的,後續看源碼的時候,請注意你的go版本,但無論你用哪個go版本,這都是一個優秀的資料,因為內存管理的思想和框架始終未變。

Go這門語言拋棄了C/C++中的開發者管理內存的方式:主動申請與主動釋放,增加了逃逸分析和GC,將開發者從內存管理中釋放出來,讓開發者有更多的精力去關注軟件設計,而不是底層的內存問題。這是Go語言成為高生產力語言的原因之一。

我們不需要精通內存的管理,因為它確實很複雜,但掌握內存的管理,可以讓你寫出更高質量的代碼,另外,還能助你定位Bug。

這篇文章採用層層遞進的方式,依次會介紹關於存儲的基本知識,Go內存管理的“前輩”TCMalloc,然後是Go的內存管理和分配,最後是總結。這麼做的目的是,希望各位能通過全局的認識和思考,擁有更好的編碼思維和架構思維。

最後,這不是一篇源碼分析文章,因為Go源碼分析的文章已經有很多了,這些源碼文章能夠幫助你去學習具體的工程實踐和奇淫巧計了,文章的末尾會推薦一些優秀文章,如果你對內存感興趣,建議每一篇都去看一下,挑出自己喜歡的,多花時間研究下。

1. 存儲基礎知識回顧

這部分我們簡單回顧一下計算機存儲體系、虛擬內存、棧和堆,以及堆內存的管理,這部分內容對理解和掌握Go內存管理比較重要,建議忘記或不熟悉的朋友不要跳過。

存儲金字塔

Go內存分配那些事,看這一篇就夠,深度好文


這幅圖表達了計算機的存儲體系,從上至下依次是:

  • CPU寄存器
  • Cache
  • 內存
  • 硬盤等輔助存儲設備
  • 鼠標等外接設備

從上至下,訪問速度越來越慢,訪問時間越來越長。

你有沒有思考過下面2個簡單的問題,如果沒有不妨想想:

  1. 如果CPU直接訪問硬盤,CPU能充分利用嗎?
  2. 如果CPU直接訪問內存,CPU能充分利用嗎?

CPU速度很快,但硬盤等持久存儲很慢,如果CPU直接訪問磁盤,磁盤可以拉低CPU的速度,機器整體性能就會低下,為了彌補這2個硬件之間的速率差異,所以在CPU和磁盤之間增加了比磁盤快很多的內存。

Go內存分配那些事,看這一篇就夠,深度好文


然而,CPU跟內存的速率也不是相同的,從上圖可以看到,CPU的速率提高的很快(摩爾定律),然而內存速率增長的很慢,雖然CPU的速率現在增加的很慢了,但是內存的速率也沒增加多少,速率差距很大,從1980年開始CPU和內存速率差距在不斷拉大,為了彌補這2個硬件之間的速率差異,所以在CPU跟內存之間增加了比內存更快的Cache,Cache是內存數據的緩存,可以降低CPU訪問內存的時間。

不要以為有了Cache就萬事大吉了,CPU的速率還在不斷增大,Cache也在不斷改變,從最初的1級,到後來的2級,到當代的3級Cache,(有興趣看cache歷史)

Go內存分配那些事,看這一篇就夠,深度好文


三級Cache分別是L1、L2、L3,它們的速率是三個不同的層級,L1速率最快,與CPU速率最接近,是RAM速率的100倍,L2速率就降到了RAM的25倍,L3的速率更靠近RAM的速率。

看到這了,你有沒有Get到整個存儲體系的分層設計自頂向下,速率越來越低,訪問時間越來越長,從磁盤到CPU寄存器,上一層都可以看做是下一層的緩存。

看了分層設計,我們看一下內存,畢竟我們是介紹內存管理的文章。

虛擬內存

虛擬內存是當代操作系統必備的一項重要功能了,它向進程屏蔽了底層了RAM和磁盤,並向進程提供了遠超物理內存大小的內存空間。我們看一下虛擬內存的分層設計

Go內存分配那些事,看這一篇就夠,深度好文


上圖展示了某進程訪問數據,當Cache沒有命中的時候,訪問虛擬內存獲取數據的過程。

訪問內存,實際訪問的是虛擬內存,虛擬內存通過頁表查看,當前要訪問的虛擬內存地址,是否已經加載到了物理內存,如果已經在物理內存,則取物理內存數據,如果沒有對應的物理內存,則從磁盤加載數據到物理內存,並把物理內存地址和虛擬內存地址更新到頁表。

有沒有Get到:物理內存就是磁盤存儲緩存層

另外,在沒有虛擬內存的時代,物理內存對所有進程是共享的,多進程同時訪問同一個物理內存存在併發訪問問題。引入虛擬內存後,每個進程都要各自的虛擬內存,內存的併發訪問問題的粒度從多進程級別,可以降低到多線程級別

棧和堆

我們現在從虛擬內存,再進一層,看虛擬內存中的棧和堆,也就是進程對內存的管理。

"
Go語言中文網,致力於每日分享編碼、開源等知識,歡迎關注我,會有意想不到的收穫!

這篇文章主要介紹Go內存分配和Go內存管理,會輕微涉及內存申請和釋放,以及Go垃圾回收。

從非常宏觀的角度看,Go的內存管理就是下圖這個樣子,我們今天主要關注其中標紅的部分。

Go內存分配那些事,看這一篇就夠,深度好文


友情提醒:文章有點長,建議先收藏,後閱讀,絕對是學習內存管理的好資料。

本文基於go1.11.2,不同版本Go的內存管理可能存在差別,比如1.9與1.11的mheap定義就是差別比較大的,後續看源碼的時候,請注意你的go版本,但無論你用哪個go版本,這都是一個優秀的資料,因為內存管理的思想和框架始終未變。

Go這門語言拋棄了C/C++中的開發者管理內存的方式:主動申請與主動釋放,增加了逃逸分析和GC,將開發者從內存管理中釋放出來,讓開發者有更多的精力去關注軟件設計,而不是底層的內存問題。這是Go語言成為高生產力語言的原因之一。

我們不需要精通內存的管理,因為它確實很複雜,但掌握內存的管理,可以讓你寫出更高質量的代碼,另外,還能助你定位Bug。

這篇文章採用層層遞進的方式,依次會介紹關於存儲的基本知識,Go內存管理的“前輩”TCMalloc,然後是Go的內存管理和分配,最後是總結。這麼做的目的是,希望各位能通過全局的認識和思考,擁有更好的編碼思維和架構思維。

最後,這不是一篇源碼分析文章,因為Go源碼分析的文章已經有很多了,這些源碼文章能夠幫助你去學習具體的工程實踐和奇淫巧計了,文章的末尾會推薦一些優秀文章,如果你對內存感興趣,建議每一篇都去看一下,挑出自己喜歡的,多花時間研究下。

1. 存儲基礎知識回顧

這部分我們簡單回顧一下計算機存儲體系、虛擬內存、棧和堆,以及堆內存的管理,這部分內容對理解和掌握Go內存管理比較重要,建議忘記或不熟悉的朋友不要跳過。

存儲金字塔

Go內存分配那些事,看這一篇就夠,深度好文


這幅圖表達了計算機的存儲體系,從上至下依次是:

  • CPU寄存器
  • Cache
  • 內存
  • 硬盤等輔助存儲設備
  • 鼠標等外接設備

從上至下,訪問速度越來越慢,訪問時間越來越長。

你有沒有思考過下面2個簡單的問題,如果沒有不妨想想:

  1. 如果CPU直接訪問硬盤,CPU能充分利用嗎?
  2. 如果CPU直接訪問內存,CPU能充分利用嗎?

CPU速度很快,但硬盤等持久存儲很慢,如果CPU直接訪問磁盤,磁盤可以拉低CPU的速度,機器整體性能就會低下,為了彌補這2個硬件之間的速率差異,所以在CPU和磁盤之間增加了比磁盤快很多的內存。

Go內存分配那些事,看這一篇就夠,深度好文


然而,CPU跟內存的速率也不是相同的,從上圖可以看到,CPU的速率提高的很快(摩爾定律),然而內存速率增長的很慢,雖然CPU的速率現在增加的很慢了,但是內存的速率也沒增加多少,速率差距很大,從1980年開始CPU和內存速率差距在不斷拉大,為了彌補這2個硬件之間的速率差異,所以在CPU跟內存之間增加了比內存更快的Cache,Cache是內存數據的緩存,可以降低CPU訪問內存的時間。

不要以為有了Cache就萬事大吉了,CPU的速率還在不斷增大,Cache也在不斷改變,從最初的1級,到後來的2級,到當代的3級Cache,(有興趣看cache歷史)

Go內存分配那些事,看這一篇就夠,深度好文


三級Cache分別是L1、L2、L3,它們的速率是三個不同的層級,L1速率最快,與CPU速率最接近,是RAM速率的100倍,L2速率就降到了RAM的25倍,L3的速率更靠近RAM的速率。

看到這了,你有沒有Get到整個存儲體系的分層設計自頂向下,速率越來越低,訪問時間越來越長,從磁盤到CPU寄存器,上一層都可以看做是下一層的緩存。

看了分層設計,我們看一下內存,畢竟我們是介紹內存管理的文章。

虛擬內存

虛擬內存是當代操作系統必備的一項重要功能了,它向進程屏蔽了底層了RAM和磁盤,並向進程提供了遠超物理內存大小的內存空間。我們看一下虛擬內存的分層設計

Go內存分配那些事,看這一篇就夠,深度好文


上圖展示了某進程訪問數據,當Cache沒有命中的時候,訪問虛擬內存獲取數據的過程。

訪問內存,實際訪問的是虛擬內存,虛擬內存通過頁表查看,當前要訪問的虛擬內存地址,是否已經加載到了物理內存,如果已經在物理內存,則取物理內存數據,如果沒有對應的物理內存,則從磁盤加載數據到物理內存,並把物理內存地址和虛擬內存地址更新到頁表。

有沒有Get到:物理內存就是磁盤存儲緩存層

另外,在沒有虛擬內存的時代,物理內存對所有進程是共享的,多進程同時訪問同一個物理內存存在併發訪問問題。引入虛擬內存後,每個進程都要各自的虛擬內存,內存的併發訪問問題的粒度從多進程級別,可以降低到多線程級別

棧和堆

我們現在從虛擬內存,再進一層,看虛擬內存中的棧和堆,也就是進程對內存的管理。

Go內存分配那些事,看這一篇就夠,深度好文


上圖展示了一個進程的虛擬內存劃分,代碼中使用的內存地址都是虛擬內存地址,而不是實際的物理內存地址。棧和堆只是虛擬內存上2塊不同功能的內存區域:

  • 棧在高地址,從高地址向低地址增長。
  • 堆在低地址,從低地址向高地址增長。

棧和堆相比有這麼幾個好處

  1. 棧的內存管理簡單,分配比堆上快。
  2. 棧的內存不需要回收,而堆需要,無論是主動free,還是被動的垃圾回收,這都需要花費額外的CPU。
  3. 棧上的內存有更好的局部性,堆上內存訪問就不那麼友好了,CPU訪問的2塊數據可能在不同的頁上,CPU訪問數據的時間可能就上去了。

堆內存管理

"
Go語言中文網,致力於每日分享編碼、開源等知識,歡迎關注我,會有意想不到的收穫!

這篇文章主要介紹Go內存分配和Go內存管理,會輕微涉及內存申請和釋放,以及Go垃圾回收。

從非常宏觀的角度看,Go的內存管理就是下圖這個樣子,我們今天主要關注其中標紅的部分。

Go內存分配那些事,看這一篇就夠,深度好文


友情提醒:文章有點長,建議先收藏,後閱讀,絕對是學習內存管理的好資料。

本文基於go1.11.2,不同版本Go的內存管理可能存在差別,比如1.9與1.11的mheap定義就是差別比較大的,後續看源碼的時候,請注意你的go版本,但無論你用哪個go版本,這都是一個優秀的資料,因為內存管理的思想和框架始終未變。

Go這門語言拋棄了C/C++中的開發者管理內存的方式:主動申請與主動釋放,增加了逃逸分析和GC,將開發者從內存管理中釋放出來,讓開發者有更多的精力去關注軟件設計,而不是底層的內存問題。這是Go語言成為高生產力語言的原因之一。

我們不需要精通內存的管理,因為它確實很複雜,但掌握內存的管理,可以讓你寫出更高質量的代碼,另外,還能助你定位Bug。

這篇文章採用層層遞進的方式,依次會介紹關於存儲的基本知識,Go內存管理的“前輩”TCMalloc,然後是Go的內存管理和分配,最後是總結。這麼做的目的是,希望各位能通過全局的認識和思考,擁有更好的編碼思維和架構思維。

最後,這不是一篇源碼分析文章,因為Go源碼分析的文章已經有很多了,這些源碼文章能夠幫助你去學習具體的工程實踐和奇淫巧計了,文章的末尾會推薦一些優秀文章,如果你對內存感興趣,建議每一篇都去看一下,挑出自己喜歡的,多花時間研究下。

1. 存儲基礎知識回顧

這部分我們簡單回顧一下計算機存儲體系、虛擬內存、棧和堆,以及堆內存的管理,這部分內容對理解和掌握Go內存管理比較重要,建議忘記或不熟悉的朋友不要跳過。

存儲金字塔

Go內存分配那些事,看這一篇就夠,深度好文


這幅圖表達了計算機的存儲體系,從上至下依次是:

  • CPU寄存器
  • Cache
  • 內存
  • 硬盤等輔助存儲設備
  • 鼠標等外接設備

從上至下,訪問速度越來越慢,訪問時間越來越長。

你有沒有思考過下面2個簡單的問題,如果沒有不妨想想:

  1. 如果CPU直接訪問硬盤,CPU能充分利用嗎?
  2. 如果CPU直接訪問內存,CPU能充分利用嗎?

CPU速度很快,但硬盤等持久存儲很慢,如果CPU直接訪問磁盤,磁盤可以拉低CPU的速度,機器整體性能就會低下,為了彌補這2個硬件之間的速率差異,所以在CPU和磁盤之間增加了比磁盤快很多的內存。

Go內存分配那些事,看這一篇就夠,深度好文


然而,CPU跟內存的速率也不是相同的,從上圖可以看到,CPU的速率提高的很快(摩爾定律),然而內存速率增長的很慢,雖然CPU的速率現在增加的很慢了,但是內存的速率也沒增加多少,速率差距很大,從1980年開始CPU和內存速率差距在不斷拉大,為了彌補這2個硬件之間的速率差異,所以在CPU跟內存之間增加了比內存更快的Cache,Cache是內存數據的緩存,可以降低CPU訪問內存的時間。

不要以為有了Cache就萬事大吉了,CPU的速率還在不斷增大,Cache也在不斷改變,從最初的1級,到後來的2級,到當代的3級Cache,(有興趣看cache歷史)

Go內存分配那些事,看這一篇就夠,深度好文


三級Cache分別是L1、L2、L3,它們的速率是三個不同的層級,L1速率最快,與CPU速率最接近,是RAM速率的100倍,L2速率就降到了RAM的25倍,L3的速率更靠近RAM的速率。

看到這了,你有沒有Get到整個存儲體系的分層設計自頂向下,速率越來越低,訪問時間越來越長,從磁盤到CPU寄存器,上一層都可以看做是下一層的緩存。

看了分層設計,我們看一下內存,畢竟我們是介紹內存管理的文章。

虛擬內存

虛擬內存是當代操作系統必備的一項重要功能了,它向進程屏蔽了底層了RAM和磁盤,並向進程提供了遠超物理內存大小的內存空間。我們看一下虛擬內存的分層設計

Go內存分配那些事,看這一篇就夠,深度好文


上圖展示了某進程訪問數據,當Cache沒有命中的時候,訪問虛擬內存獲取數據的過程。

訪問內存,實際訪問的是虛擬內存,虛擬內存通過頁表查看,當前要訪問的虛擬內存地址,是否已經加載到了物理內存,如果已經在物理內存,則取物理內存數據,如果沒有對應的物理內存,則從磁盤加載數據到物理內存,並把物理內存地址和虛擬內存地址更新到頁表。

有沒有Get到:物理內存就是磁盤存儲緩存層

另外,在沒有虛擬內存的時代,物理內存對所有進程是共享的,多進程同時訪問同一個物理內存存在併發訪問問題。引入虛擬內存後,每個進程都要各自的虛擬內存,內存的併發訪問問題的粒度從多進程級別,可以降低到多線程級別

棧和堆

我們現在從虛擬內存,再進一層,看虛擬內存中的棧和堆,也就是進程對內存的管理。

Go內存分配那些事,看這一篇就夠,深度好文


上圖展示了一個進程的虛擬內存劃分,代碼中使用的內存地址都是虛擬內存地址,而不是實際的物理內存地址。棧和堆只是虛擬內存上2塊不同功能的內存區域:

  • 棧在高地址,從高地址向低地址增長。
  • 堆在低地址,從低地址向高地址增長。

棧和堆相比有這麼幾個好處

  1. 棧的內存管理簡單,分配比堆上快。
  2. 棧的內存不需要回收,而堆需要,無論是主動free,還是被動的垃圾回收,這都需要花費額外的CPU。
  3. 棧上的內存有更好的局部性,堆上內存訪問就不那麼友好了,CPU訪問的2塊數據可能在不同的頁上,CPU訪問數據的時間可能就上去了。

堆內存管理

Go內存分配那些事,看這一篇就夠,深度好文


我們再進一層,當我們說內存管理的時候,主要是指堆內存的管理,因為棧的內存管理不需要程序去操心。這小節看下堆內存管理乾的是啥,如上圖所示主要是3部分:分配內存塊,回收內存塊和組織內存塊

在一個最簡單的內存管理中,堆內存最初會是一個完整的大塊,即未分配內存,當來申請的時候,就會從未分配內存,分割出一個小內存塊(block),然後用鏈表把所有內存塊連接起來。需要一些信息描述每個內存塊的基本信息,比如大小(size)、是否使用中(used)和下一個內存塊的地址(next),內存塊實際數據存儲在data中。

"
Go語言中文網,致力於每日分享編碼、開源等知識,歡迎關注我,會有意想不到的收穫!

這篇文章主要介紹Go內存分配和Go內存管理,會輕微涉及內存申請和釋放,以及Go垃圾回收。

從非常宏觀的角度看,Go的內存管理就是下圖這個樣子,我們今天主要關注其中標紅的部分。

Go內存分配那些事,看這一篇就夠,深度好文


友情提醒:文章有點長,建議先收藏,後閱讀,絕對是學習內存管理的好資料。

本文基於go1.11.2,不同版本Go的內存管理可能存在差別,比如1.9與1.11的mheap定義就是差別比較大的,後續看源碼的時候,請注意你的go版本,但無論你用哪個go版本,這都是一個優秀的資料,因為內存管理的思想和框架始終未變。

Go這門語言拋棄了C/C++中的開發者管理內存的方式:主動申請與主動釋放,增加了逃逸分析和GC,將開發者從內存管理中釋放出來,讓開發者有更多的精力去關注軟件設計,而不是底層的內存問題。這是Go語言成為高生產力語言的原因之一。

我們不需要精通內存的管理,因為它確實很複雜,但掌握內存的管理,可以讓你寫出更高質量的代碼,另外,還能助你定位Bug。

這篇文章採用層層遞進的方式,依次會介紹關於存儲的基本知識,Go內存管理的“前輩”TCMalloc,然後是Go的內存管理和分配,最後是總結。這麼做的目的是,希望各位能通過全局的認識和思考,擁有更好的編碼思維和架構思維。

最後,這不是一篇源碼分析文章,因為Go源碼分析的文章已經有很多了,這些源碼文章能夠幫助你去學習具體的工程實踐和奇淫巧計了,文章的末尾會推薦一些優秀文章,如果你對內存感興趣,建議每一篇都去看一下,挑出自己喜歡的,多花時間研究下。

1. 存儲基礎知識回顧

這部分我們簡單回顧一下計算機存儲體系、虛擬內存、棧和堆,以及堆內存的管理,這部分內容對理解和掌握Go內存管理比較重要,建議忘記或不熟悉的朋友不要跳過。

存儲金字塔

Go內存分配那些事,看這一篇就夠,深度好文


這幅圖表達了計算機的存儲體系,從上至下依次是:

  • CPU寄存器
  • Cache
  • 內存
  • 硬盤等輔助存儲設備
  • 鼠標等外接設備

從上至下,訪問速度越來越慢,訪問時間越來越長。

你有沒有思考過下面2個簡單的問題,如果沒有不妨想想:

  1. 如果CPU直接訪問硬盤,CPU能充分利用嗎?
  2. 如果CPU直接訪問內存,CPU能充分利用嗎?

CPU速度很快,但硬盤等持久存儲很慢,如果CPU直接訪問磁盤,磁盤可以拉低CPU的速度,機器整體性能就會低下,為了彌補這2個硬件之間的速率差異,所以在CPU和磁盤之間增加了比磁盤快很多的內存。

Go內存分配那些事,看這一篇就夠,深度好文


然而,CPU跟內存的速率也不是相同的,從上圖可以看到,CPU的速率提高的很快(摩爾定律),然而內存速率增長的很慢,雖然CPU的速率現在增加的很慢了,但是內存的速率也沒增加多少,速率差距很大,從1980年開始CPU和內存速率差距在不斷拉大,為了彌補這2個硬件之間的速率差異,所以在CPU跟內存之間增加了比內存更快的Cache,Cache是內存數據的緩存,可以降低CPU訪問內存的時間。

不要以為有了Cache就萬事大吉了,CPU的速率還在不斷增大,Cache也在不斷改變,從最初的1級,到後來的2級,到當代的3級Cache,(有興趣看cache歷史)

Go內存分配那些事,看這一篇就夠,深度好文


三級Cache分別是L1、L2、L3,它們的速率是三個不同的層級,L1速率最快,與CPU速率最接近,是RAM速率的100倍,L2速率就降到了RAM的25倍,L3的速率更靠近RAM的速率。

看到這了,你有沒有Get到整個存儲體系的分層設計自頂向下,速率越來越低,訪問時間越來越長,從磁盤到CPU寄存器,上一層都可以看做是下一層的緩存。

看了分層設計,我們看一下內存,畢竟我們是介紹內存管理的文章。

虛擬內存

虛擬內存是當代操作系統必備的一項重要功能了,它向進程屏蔽了底層了RAM和磁盤,並向進程提供了遠超物理內存大小的內存空間。我們看一下虛擬內存的分層設計

Go內存分配那些事,看這一篇就夠,深度好文


上圖展示了某進程訪問數據,當Cache沒有命中的時候,訪問虛擬內存獲取數據的過程。

訪問內存,實際訪問的是虛擬內存,虛擬內存通過頁表查看,當前要訪問的虛擬內存地址,是否已經加載到了物理內存,如果已經在物理內存,則取物理內存數據,如果沒有對應的物理內存,則從磁盤加載數據到物理內存,並把物理內存地址和虛擬內存地址更新到頁表。

有沒有Get到:物理內存就是磁盤存儲緩存層

另外,在沒有虛擬內存的時代,物理內存對所有進程是共享的,多進程同時訪問同一個物理內存存在併發訪問問題。引入虛擬內存後,每個進程都要各自的虛擬內存,內存的併發訪問問題的粒度從多進程級別,可以降低到多線程級別

棧和堆

我們現在從虛擬內存,再進一層,看虛擬內存中的棧和堆,也就是進程對內存的管理。

Go內存分配那些事,看這一篇就夠,深度好文


上圖展示了一個進程的虛擬內存劃分,代碼中使用的內存地址都是虛擬內存地址,而不是實際的物理內存地址。棧和堆只是虛擬內存上2塊不同功能的內存區域:

  • 棧在高地址,從高地址向低地址增長。
  • 堆在低地址,從低地址向高地址增長。

棧和堆相比有這麼幾個好處

  1. 棧的內存管理簡單,分配比堆上快。
  2. 棧的內存不需要回收,而堆需要,無論是主動free,還是被動的垃圾回收,這都需要花費額外的CPU。
  3. 棧上的內存有更好的局部性,堆上內存訪問就不那麼友好了,CPU訪問的2塊數據可能在不同的頁上,CPU訪問數據的時間可能就上去了。

堆內存管理

Go內存分配那些事,看這一篇就夠,深度好文


我們再進一層,當我們說內存管理的時候,主要是指堆內存的管理,因為棧的內存管理不需要程序去操心。這小節看下堆內存管理乾的是啥,如上圖所示主要是3部分:分配內存塊,回收內存塊和組織內存塊

在一個最簡單的內存管理中,堆內存最初會是一個完整的大塊,即未分配內存,當來申請的時候,就會從未分配內存,分割出一個小內存塊(block),然後用鏈表把所有內存塊連接起來。需要一些信息描述每個內存塊的基本信息,比如大小(size)、是否使用中(used)和下一個內存塊的地址(next),內存塊實際數據存儲在data中。

Go內存分配那些事,看這一篇就夠,深度好文


一個內存塊包含了3類信息,如下圖所示,元數據、用戶數據和對齊字段,內存對齊是為了提高訪問效率。下圖申請5Byte內存的時候,就需要進行內存對齊。

"
Go語言中文網,致力於每日分享編碼、開源等知識,歡迎關注我,會有意想不到的收穫!

這篇文章主要介紹Go內存分配和Go內存管理,會輕微涉及內存申請和釋放,以及Go垃圾回收。

從非常宏觀的角度看,Go的內存管理就是下圖這個樣子,我們今天主要關注其中標紅的部分。

Go內存分配那些事,看這一篇就夠,深度好文


友情提醒:文章有點長,建議先收藏,後閱讀,絕對是學習內存管理的好資料。

本文基於go1.11.2,不同版本Go的內存管理可能存在差別,比如1.9與1.11的mheap定義就是差別比較大的,後續看源碼的時候,請注意你的go版本,但無論你用哪個go版本,這都是一個優秀的資料,因為內存管理的思想和框架始終未變。

Go這門語言拋棄了C/C++中的開發者管理內存的方式:主動申請與主動釋放,增加了逃逸分析和GC,將開發者從內存管理中釋放出來,讓開發者有更多的精力去關注軟件設計,而不是底層的內存問題。這是Go語言成為高生產力語言的原因之一。

我們不需要精通內存的管理,因為它確實很複雜,但掌握內存的管理,可以讓你寫出更高質量的代碼,另外,還能助你定位Bug。

這篇文章採用層層遞進的方式,依次會介紹關於存儲的基本知識,Go內存管理的“前輩”TCMalloc,然後是Go的內存管理和分配,最後是總結。這麼做的目的是,希望各位能通過全局的認識和思考,擁有更好的編碼思維和架構思維。

最後,這不是一篇源碼分析文章,因為Go源碼分析的文章已經有很多了,這些源碼文章能夠幫助你去學習具體的工程實踐和奇淫巧計了,文章的末尾會推薦一些優秀文章,如果你對內存感興趣,建議每一篇都去看一下,挑出自己喜歡的,多花時間研究下。

1. 存儲基礎知識回顧

這部分我們簡單回顧一下計算機存儲體系、虛擬內存、棧和堆,以及堆內存的管理,這部分內容對理解和掌握Go內存管理比較重要,建議忘記或不熟悉的朋友不要跳過。

存儲金字塔

Go內存分配那些事,看這一篇就夠,深度好文


這幅圖表達了計算機的存儲體系,從上至下依次是:

  • CPU寄存器
  • Cache
  • 內存
  • 硬盤等輔助存儲設備
  • 鼠標等外接設備

從上至下,訪問速度越來越慢,訪問時間越來越長。

你有沒有思考過下面2個簡單的問題,如果沒有不妨想想:

  1. 如果CPU直接訪問硬盤,CPU能充分利用嗎?
  2. 如果CPU直接訪問內存,CPU能充分利用嗎?

CPU速度很快,但硬盤等持久存儲很慢,如果CPU直接訪問磁盤,磁盤可以拉低CPU的速度,機器整體性能就會低下,為了彌補這2個硬件之間的速率差異,所以在CPU和磁盤之間增加了比磁盤快很多的內存。

Go內存分配那些事,看這一篇就夠,深度好文


然而,CPU跟內存的速率也不是相同的,從上圖可以看到,CPU的速率提高的很快(摩爾定律),然而內存速率增長的很慢,雖然CPU的速率現在增加的很慢了,但是內存的速率也沒增加多少,速率差距很大,從1980年開始CPU和內存速率差距在不斷拉大,為了彌補這2個硬件之間的速率差異,所以在CPU跟內存之間增加了比內存更快的Cache,Cache是內存數據的緩存,可以降低CPU訪問內存的時間。

不要以為有了Cache就萬事大吉了,CPU的速率還在不斷增大,Cache也在不斷改變,從最初的1級,到後來的2級,到當代的3級Cache,(有興趣看cache歷史)

Go內存分配那些事,看這一篇就夠,深度好文


三級Cache分別是L1、L2、L3,它們的速率是三個不同的層級,L1速率最快,與CPU速率最接近,是RAM速率的100倍,L2速率就降到了RAM的25倍,L3的速率更靠近RAM的速率。

看到這了,你有沒有Get到整個存儲體系的分層設計自頂向下,速率越來越低,訪問時間越來越長,從磁盤到CPU寄存器,上一層都可以看做是下一層的緩存。

看了分層設計,我們看一下內存,畢竟我們是介紹內存管理的文章。

虛擬內存

虛擬內存是當代操作系統必備的一項重要功能了,它向進程屏蔽了底層了RAM和磁盤,並向進程提供了遠超物理內存大小的內存空間。我們看一下虛擬內存的分層設計

Go內存分配那些事,看這一篇就夠,深度好文


上圖展示了某進程訪問數據,當Cache沒有命中的時候,訪問虛擬內存獲取數據的過程。

訪問內存,實際訪問的是虛擬內存,虛擬內存通過頁表查看,當前要訪問的虛擬內存地址,是否已經加載到了物理內存,如果已經在物理內存,則取物理內存數據,如果沒有對應的物理內存,則從磁盤加載數據到物理內存,並把物理內存地址和虛擬內存地址更新到頁表。

有沒有Get到:物理內存就是磁盤存儲緩存層

另外,在沒有虛擬內存的時代,物理內存對所有進程是共享的,多進程同時訪問同一個物理內存存在併發訪問問題。引入虛擬內存後,每個進程都要各自的虛擬內存,內存的併發訪問問題的粒度從多進程級別,可以降低到多線程級別

棧和堆

我們現在從虛擬內存,再進一層,看虛擬內存中的棧和堆,也就是進程對內存的管理。

Go內存分配那些事,看這一篇就夠,深度好文


上圖展示了一個進程的虛擬內存劃分,代碼中使用的內存地址都是虛擬內存地址,而不是實際的物理內存地址。棧和堆只是虛擬內存上2塊不同功能的內存區域:

  • 棧在高地址,從高地址向低地址增長。
  • 堆在低地址,從低地址向高地址增長。

棧和堆相比有這麼幾個好處

  1. 棧的內存管理簡單,分配比堆上快。
  2. 棧的內存不需要回收,而堆需要,無論是主動free,還是被動的垃圾回收,這都需要花費額外的CPU。
  3. 棧上的內存有更好的局部性,堆上內存訪問就不那麼友好了,CPU訪問的2塊數據可能在不同的頁上,CPU訪問數據的時間可能就上去了。

堆內存管理

Go內存分配那些事,看這一篇就夠,深度好文


我們再進一層,當我們說內存管理的時候,主要是指堆內存的管理,因為棧的內存管理不需要程序去操心。這小節看下堆內存管理乾的是啥,如上圖所示主要是3部分:分配內存塊,回收內存塊和組織內存塊

在一個最簡單的內存管理中,堆內存最初會是一個完整的大塊,即未分配內存,當來申請的時候,就會從未分配內存,分割出一個小內存塊(block),然後用鏈表把所有內存塊連接起來。需要一些信息描述每個內存塊的基本信息,比如大小(size)、是否使用中(used)和下一個內存塊的地址(next),內存塊實際數據存儲在data中。

Go內存分配那些事,看這一篇就夠,深度好文


一個內存塊包含了3類信息,如下圖所示,元數據、用戶數據和對齊字段,內存對齊是為了提高訪問效率。下圖申請5Byte內存的時候,就需要進行內存對齊。

Go內存分配那些事,看這一篇就夠,深度好文


釋放內存實質是把使用的內存塊從鏈表中取出來,然後標記為未使用,當分配內存塊的時候,可以從未使用內存塊中有先查找大小相近的內存塊,如果找不到,再從未分配的內存中分配內存。

上面這個簡單的設計中還沒考慮內存碎片的問題,因為隨著內存不斷的申請和釋放,內存上會存在大量的碎片,降低內存的使用率。為了解決內存碎片,可以將2個連續的未使用的內存塊合併,減少碎片。

以上就是內存管理的基本思路,關於基本的內存管理,想了解更多,可以閱讀這篇文章《Writing a Memory Allocator》,本節的3張圖片也是來自這片文章。

2. TCMalloc

TCMalloc是Thread Cache Malloc的簡稱,是Go內存管理的起源,Go的內存管理是借鑑了TCMalloc,隨著Go的迭代,Go的內存管理與TCMalloc不一致地方在不斷擴大,但其主要思想、原理和概念都是和TCMalloc一致的,如果跳過TCMalloc直接去看Go的內存管理,也許你會似懂非懂。

掌握TCMalloc的理念,無需去關注過多的源碼細節,就可以為掌握Go的內存管理打好基礎,基礎打好了,後面知識才紮實。

在Linux裡,其實有不少的內存管理庫,比如glibc的ptmalloc,FreeBSD的jemalloc,Google的tcmalloc等等,為何會出現這麼多的內存管理庫?本質都是在多線程編程下,追求更高內存管理效率:更快的分配是主要目的。

那如何更快的分配內存?

我們前面提到:

引入虛擬內存後,讓內存的併發訪問問題的粒度從多進程級別,降低到多線程級別。


這是更快分配內存的第一個層次

同一進程的所有線程共享相同的內存空間,他們申請內存時需要加鎖,如果不加鎖就存在同一塊內存被2個線程同時訪問的問題。

TCMalloc的做法是什麼呢?為每個線程預分配一塊緩存,線程申請小內存時,可以從緩存分配內存,這樣有2個好處:

  1. 為線程預分配緩存需要進行1次系統調用,後續線程申請小內存時,從緩存分配,都是在用戶態執行,沒有系統調用,縮短了內存總體的分配和釋放時間,這是快速分配內存的第二個層次
  2. 多個線程同時申請小內存時,從各自的緩存分配,訪問的是不同的地址空間,無需加鎖,把內存併發訪問的粒度進一步降低了,這是快速分配內存的第三個層次

基本原理

下面就簡單介紹下TCMalloc,細緻程度夠我們理解Go的內存管理即可。

聲明:我沒有研究過TCMalloc,以下介紹根據TCMalloc官方資料和其他博主資料總結而來,錯誤之處請朋友告知我。
"
Go語言中文網,致力於每日分享編碼、開源等知識,歡迎關注我,會有意想不到的收穫!

這篇文章主要介紹Go內存分配和Go內存管理,會輕微涉及內存申請和釋放,以及Go垃圾回收。

從非常宏觀的角度看,Go的內存管理就是下圖這個樣子,我們今天主要關注其中標紅的部分。

Go內存分配那些事,看這一篇就夠,深度好文


友情提醒:文章有點長,建議先收藏,後閱讀,絕對是學習內存管理的好資料。

本文基於go1.11.2,不同版本Go的內存管理可能存在差別,比如1.9與1.11的mheap定義就是差別比較大的,後續看源碼的時候,請注意你的go版本,但無論你用哪個go版本,這都是一個優秀的資料,因為內存管理的思想和框架始終未變。

Go這門語言拋棄了C/C++中的開發者管理內存的方式:主動申請與主動釋放,增加了逃逸分析和GC,將開發者從內存管理中釋放出來,讓開發者有更多的精力去關注軟件設計,而不是底層的內存問題。這是Go語言成為高生產力語言的原因之一。

我們不需要精通內存的管理,因為它確實很複雜,但掌握內存的管理,可以讓你寫出更高質量的代碼,另外,還能助你定位Bug。

這篇文章採用層層遞進的方式,依次會介紹關於存儲的基本知識,Go內存管理的“前輩”TCMalloc,然後是Go的內存管理和分配,最後是總結。這麼做的目的是,希望各位能通過全局的認識和思考,擁有更好的編碼思維和架構思維。

最後,這不是一篇源碼分析文章,因為Go源碼分析的文章已經有很多了,這些源碼文章能夠幫助你去學習具體的工程實踐和奇淫巧計了,文章的末尾會推薦一些優秀文章,如果你對內存感興趣,建議每一篇都去看一下,挑出自己喜歡的,多花時間研究下。

1. 存儲基礎知識回顧

這部分我們簡單回顧一下計算機存儲體系、虛擬內存、棧和堆,以及堆內存的管理,這部分內容對理解和掌握Go內存管理比較重要,建議忘記或不熟悉的朋友不要跳過。

存儲金字塔

Go內存分配那些事,看這一篇就夠,深度好文


這幅圖表達了計算機的存儲體系,從上至下依次是:

  • CPU寄存器
  • Cache
  • 內存
  • 硬盤等輔助存儲設備
  • 鼠標等外接設備

從上至下,訪問速度越來越慢,訪問時間越來越長。

你有沒有思考過下面2個簡單的問題,如果沒有不妨想想:

  1. 如果CPU直接訪問硬盤,CPU能充分利用嗎?
  2. 如果CPU直接訪問內存,CPU能充分利用嗎?

CPU速度很快,但硬盤等持久存儲很慢,如果CPU直接訪問磁盤,磁盤可以拉低CPU的速度,機器整體性能就會低下,為了彌補這2個硬件之間的速率差異,所以在CPU和磁盤之間增加了比磁盤快很多的內存。

Go內存分配那些事,看這一篇就夠,深度好文


然而,CPU跟內存的速率也不是相同的,從上圖可以看到,CPU的速率提高的很快(摩爾定律),然而內存速率增長的很慢,雖然CPU的速率現在增加的很慢了,但是內存的速率也沒增加多少,速率差距很大,從1980年開始CPU和內存速率差距在不斷拉大,為了彌補這2個硬件之間的速率差異,所以在CPU跟內存之間增加了比內存更快的Cache,Cache是內存數據的緩存,可以降低CPU訪問內存的時間。

不要以為有了Cache就萬事大吉了,CPU的速率還在不斷增大,Cache也在不斷改變,從最初的1級,到後來的2級,到當代的3級Cache,(有興趣看cache歷史)

Go內存分配那些事,看這一篇就夠,深度好文


三級Cache分別是L1、L2、L3,它們的速率是三個不同的層級,L1速率最快,與CPU速率最接近,是RAM速率的100倍,L2速率就降到了RAM的25倍,L3的速率更靠近RAM的速率。

看到這了,你有沒有Get到整個存儲體系的分層設計自頂向下,速率越來越低,訪問時間越來越長,從磁盤到CPU寄存器,上一層都可以看做是下一層的緩存。

看了分層設計,我們看一下內存,畢竟我們是介紹內存管理的文章。

虛擬內存

虛擬內存是當代操作系統必備的一項重要功能了,它向進程屏蔽了底層了RAM和磁盤,並向進程提供了遠超物理內存大小的內存空間。我們看一下虛擬內存的分層設計

Go內存分配那些事,看這一篇就夠,深度好文


上圖展示了某進程訪問數據,當Cache沒有命中的時候,訪問虛擬內存獲取數據的過程。

訪問內存,實際訪問的是虛擬內存,虛擬內存通過頁表查看,當前要訪問的虛擬內存地址,是否已經加載到了物理內存,如果已經在物理內存,則取物理內存數據,如果沒有對應的物理內存,則從磁盤加載數據到物理內存,並把物理內存地址和虛擬內存地址更新到頁表。

有沒有Get到:物理內存就是磁盤存儲緩存層

另外,在沒有虛擬內存的時代,物理內存對所有進程是共享的,多進程同時訪問同一個物理內存存在併發訪問問題。引入虛擬內存後,每個進程都要各自的虛擬內存,內存的併發訪問問題的粒度從多進程級別,可以降低到多線程級別

棧和堆

我們現在從虛擬內存,再進一層,看虛擬內存中的棧和堆,也就是進程對內存的管理。

Go內存分配那些事,看這一篇就夠,深度好文


上圖展示了一個進程的虛擬內存劃分,代碼中使用的內存地址都是虛擬內存地址,而不是實際的物理內存地址。棧和堆只是虛擬內存上2塊不同功能的內存區域:

  • 棧在高地址,從高地址向低地址增長。
  • 堆在低地址,從低地址向高地址增長。

棧和堆相比有這麼幾個好處

  1. 棧的內存管理簡單,分配比堆上快。
  2. 棧的內存不需要回收,而堆需要,無論是主動free,還是被動的垃圾回收,這都需要花費額外的CPU。
  3. 棧上的內存有更好的局部性,堆上內存訪問就不那麼友好了,CPU訪問的2塊數據可能在不同的頁上,CPU訪問數據的時間可能就上去了。

堆內存管理

Go內存分配那些事,看這一篇就夠,深度好文


我們再進一層,當我們說內存管理的時候,主要是指堆內存的管理,因為棧的內存管理不需要程序去操心。這小節看下堆內存管理乾的是啥,如上圖所示主要是3部分:分配內存塊,回收內存塊和組織內存塊

在一個最簡單的內存管理中,堆內存最初會是一個完整的大塊,即未分配內存,當來申請的時候,就會從未分配內存,分割出一個小內存塊(block),然後用鏈表把所有內存塊連接起來。需要一些信息描述每個內存塊的基本信息,比如大小(size)、是否使用中(used)和下一個內存塊的地址(next),內存塊實際數據存儲在data中。

Go內存分配那些事,看這一篇就夠,深度好文


一個內存塊包含了3類信息,如下圖所示,元數據、用戶數據和對齊字段,內存對齊是為了提高訪問效率。下圖申請5Byte內存的時候,就需要進行內存對齊。

Go內存分配那些事,看這一篇就夠,深度好文


釋放內存實質是把使用的內存塊從鏈表中取出來,然後標記為未使用,當分配內存塊的時候,可以從未使用內存塊中有先查找大小相近的內存塊,如果找不到,再從未分配的內存中分配內存。

上面這個簡單的設計中還沒考慮內存碎片的問題,因為隨著內存不斷的申請和釋放,內存上會存在大量的碎片,降低內存的使用率。為了解決內存碎片,可以將2個連續的未使用的內存塊合併,減少碎片。

以上就是內存管理的基本思路,關於基本的內存管理,想了解更多,可以閱讀這篇文章《Writing a Memory Allocator》,本節的3張圖片也是來自這片文章。

2. TCMalloc

TCMalloc是Thread Cache Malloc的簡稱,是Go內存管理的起源,Go的內存管理是借鑑了TCMalloc,隨著Go的迭代,Go的內存管理與TCMalloc不一致地方在不斷擴大,但其主要思想、原理和概念都是和TCMalloc一致的,如果跳過TCMalloc直接去看Go的內存管理,也許你會似懂非懂。

掌握TCMalloc的理念,無需去關注過多的源碼細節,就可以為掌握Go的內存管理打好基礎,基礎打好了,後面知識才紮實。

在Linux裡,其實有不少的內存管理庫,比如glibc的ptmalloc,FreeBSD的jemalloc,Google的tcmalloc等等,為何會出現這麼多的內存管理庫?本質都是在多線程編程下,追求更高內存管理效率:更快的分配是主要目的。

那如何更快的分配內存?

我們前面提到:

引入虛擬內存後,讓內存的併發訪問問題的粒度從多進程級別,降低到多線程級別。


這是更快分配內存的第一個層次

同一進程的所有線程共享相同的內存空間,他們申請內存時需要加鎖,如果不加鎖就存在同一塊內存被2個線程同時訪問的問題。

TCMalloc的做法是什麼呢?為每個線程預分配一塊緩存,線程申請小內存時,可以從緩存分配內存,這樣有2個好處:

  1. 為線程預分配緩存需要進行1次系統調用,後續線程申請小內存時,從緩存分配,都是在用戶態執行,沒有系統調用,縮短了內存總體的分配和釋放時間,這是快速分配內存的第二個層次
  2. 多個線程同時申請小內存時,從各自的緩存分配,訪問的是不同的地址空間,無需加鎖,把內存併發訪問的粒度進一步降低了,這是快速分配內存的第三個層次

基本原理

下面就簡單介紹下TCMalloc,細緻程度夠我們理解Go的內存管理即可。

聲明:我沒有研究過TCMalloc,以下介紹根據TCMalloc官方資料和其他博主資料總結而來,錯誤之處請朋友告知我。
Go內存分配那些事,看這一篇就夠,深度好文

結合上圖,介紹TCMalloc的幾個重要概念:

  1. Page:操作系統對內存管理以頁為單位,TCMalloc也是這樣,只不過TCMalloc裡的Page大小與操作系統裡的大小並不一定相等,而是倍數關係。《TCMalloc解密》裡稱x64下Page大小是8KB。
  2. Span:一組連續的Page被稱為Span,比如可以有2個頁大小的Span,也可以有16頁大小的Span,Span比Page高一個層級,是為了方便管理一定大小的內存區域,Span是TCMalloc中內存管理的基本單位。
  3. ThreadCache:每個線程各自的Cache,一個Cache包含多個空閒內存塊鏈表,每個鏈表連接的都是內存塊,同一個鏈表上內存塊的大小是相同的,也可以說按內存塊大小,給內存塊分了個類,這樣可以根據申請的內存大小,快速從合適的鏈表選擇空閒內存塊。由於每個線程有自己的ThreadCache,所以ThreadCache訪問是無鎖的。
  4. CentralCache:是所有線程共享的緩存,也是保存的空閒內存塊鏈表,鏈表的數量與ThreadCache中鏈表數量相同,當ThreadCache內存塊不足時,可以從CentralCache取,當ThreadCache內存塊多時,可以放回CentralCache。由於CentralCache是共享的,所以它的訪問是要加鎖的。
  5. PageHeap:PageHeap是堆內存的抽象,PageHeap存的也是若干鏈表,鏈表保存的是Span,當CentralCache沒有內存的時,會從PageHeap取,把1個Span拆成若干內存塊,添加到對應大小的鏈表中,當CentralCache內存多的時候,會放回PageHeap。如下圖,分別是1頁Page的Span鏈表,2頁Page的Span鏈表等,最後是large span set,這個是用來保存中大對象的。毫無疑問,PageHeap也是要加鎖的。


"
Go語言中文網,致力於每日分享編碼、開源等知識,歡迎關注我,會有意想不到的收穫!

這篇文章主要介紹Go內存分配和Go內存管理,會輕微涉及內存申請和釋放,以及Go垃圾回收。

從非常宏觀的角度看,Go的內存管理就是下圖這個樣子,我們今天主要關注其中標紅的部分。

Go內存分配那些事,看這一篇就夠,深度好文


友情提醒:文章有點長,建議先收藏,後閱讀,絕對是學習內存管理的好資料。

本文基於go1.11.2,不同版本Go的內存管理可能存在差別,比如1.9與1.11的mheap定義就是差別比較大的,後續看源碼的時候,請注意你的go版本,但無論你用哪個go版本,這都是一個優秀的資料,因為內存管理的思想和框架始終未變。

Go這門語言拋棄了C/C++中的開發者管理內存的方式:主動申請與主動釋放,增加了逃逸分析和GC,將開發者從內存管理中釋放出來,讓開發者有更多的精力去關注軟件設計,而不是底層的內存問題。這是Go語言成為高生產力語言的原因之一。

我們不需要精通內存的管理,因為它確實很複雜,但掌握內存的管理,可以讓你寫出更高質量的代碼,另外,還能助你定位Bug。

這篇文章採用層層遞進的方式,依次會介紹關於存儲的基本知識,Go內存管理的“前輩”TCMalloc,然後是Go的內存管理和分配,最後是總結。這麼做的目的是,希望各位能通過全局的認識和思考,擁有更好的編碼思維和架構思維。

最後,這不是一篇源碼分析文章,因為Go源碼分析的文章已經有很多了,這些源碼文章能夠幫助你去學習具體的工程實踐和奇淫巧計了,文章的末尾會推薦一些優秀文章,如果你對內存感興趣,建議每一篇都去看一下,挑出自己喜歡的,多花時間研究下。

1. 存儲基礎知識回顧

這部分我們簡單回顧一下計算機存儲體系、虛擬內存、棧和堆,以及堆內存的管理,這部分內容對理解和掌握Go內存管理比較重要,建議忘記或不熟悉的朋友不要跳過。

存儲金字塔

Go內存分配那些事,看這一篇就夠,深度好文


這幅圖表達了計算機的存儲體系,從上至下依次是:

  • CPU寄存器
  • Cache
  • 內存
  • 硬盤等輔助存儲設備
  • 鼠標等外接設備

從上至下,訪問速度越來越慢,訪問時間越來越長。

你有沒有思考過下面2個簡單的問題,如果沒有不妨想想:

  1. 如果CPU直接訪問硬盤,CPU能充分利用嗎?
  2. 如果CPU直接訪問內存,CPU能充分利用嗎?

CPU速度很快,但硬盤等持久存儲很慢,如果CPU直接訪問磁盤,磁盤可以拉低CPU的速度,機器整體性能就會低下,為了彌補這2個硬件之間的速率差異,所以在CPU和磁盤之間增加了比磁盤快很多的內存。

Go內存分配那些事,看這一篇就夠,深度好文


然而,CPU跟內存的速率也不是相同的,從上圖可以看到,CPU的速率提高的很快(摩爾定律),然而內存速率增長的很慢,雖然CPU的速率現在增加的很慢了,但是內存的速率也沒增加多少,速率差距很大,從1980年開始CPU和內存速率差距在不斷拉大,為了彌補這2個硬件之間的速率差異,所以在CPU跟內存之間增加了比內存更快的Cache,Cache是內存數據的緩存,可以降低CPU訪問內存的時間。

不要以為有了Cache就萬事大吉了,CPU的速率還在不斷增大,Cache也在不斷改變,從最初的1級,到後來的2級,到當代的3級Cache,(有興趣看cache歷史)

Go內存分配那些事,看這一篇就夠,深度好文


三級Cache分別是L1、L2、L3,它們的速率是三個不同的層級,L1速率最快,與CPU速率最接近,是RAM速率的100倍,L2速率就降到了RAM的25倍,L3的速率更靠近RAM的速率。

看到這了,你有沒有Get到整個存儲體系的分層設計自頂向下,速率越來越低,訪問時間越來越長,從磁盤到CPU寄存器,上一層都可以看做是下一層的緩存。

看了分層設計,我們看一下內存,畢竟我們是介紹內存管理的文章。

虛擬內存

虛擬內存是當代操作系統必備的一項重要功能了,它向進程屏蔽了底層了RAM和磁盤,並向進程提供了遠超物理內存大小的內存空間。我們看一下虛擬內存的分層設計

Go內存分配那些事,看這一篇就夠,深度好文


上圖展示了某進程訪問數據,當Cache沒有命中的時候,訪問虛擬內存獲取數據的過程。

訪問內存,實際訪問的是虛擬內存,虛擬內存通過頁表查看,當前要訪問的虛擬內存地址,是否已經加載到了物理內存,如果已經在物理內存,則取物理內存數據,如果沒有對應的物理內存,則從磁盤加載數據到物理內存,並把物理內存地址和虛擬內存地址更新到頁表。

有沒有Get到:物理內存就是磁盤存儲緩存層

另外,在沒有虛擬內存的時代,物理內存對所有進程是共享的,多進程同時訪問同一個物理內存存在併發訪問問題。引入虛擬內存後,每個進程都要各自的虛擬內存,內存的併發訪問問題的粒度從多進程級別,可以降低到多線程級別

棧和堆

我們現在從虛擬內存,再進一層,看虛擬內存中的棧和堆,也就是進程對內存的管理。

Go內存分配那些事,看這一篇就夠,深度好文


上圖展示了一個進程的虛擬內存劃分,代碼中使用的內存地址都是虛擬內存地址,而不是實際的物理內存地址。棧和堆只是虛擬內存上2塊不同功能的內存區域:

  • 棧在高地址,從高地址向低地址增長。
  • 堆在低地址,從低地址向高地址增長。

棧和堆相比有這麼幾個好處

  1. 棧的內存管理簡單,分配比堆上快。
  2. 棧的內存不需要回收,而堆需要,無論是主動free,還是被動的垃圾回收,這都需要花費額外的CPU。
  3. 棧上的內存有更好的局部性,堆上內存訪問就不那麼友好了,CPU訪問的2塊數據可能在不同的頁上,CPU訪問數據的時間可能就上去了。

堆內存管理

Go內存分配那些事,看這一篇就夠,深度好文


我們再進一層,當我們說內存管理的時候,主要是指堆內存的管理,因為棧的內存管理不需要程序去操心。這小節看下堆內存管理乾的是啥,如上圖所示主要是3部分:分配內存塊,回收內存塊和組織內存塊

在一個最簡單的內存管理中,堆內存最初會是一個完整的大塊,即未分配內存,當來申請的時候,就會從未分配內存,分割出一個小內存塊(block),然後用鏈表把所有內存塊連接起來。需要一些信息描述每個內存塊的基本信息,比如大小(size)、是否使用中(used)和下一個內存塊的地址(next),內存塊實際數據存儲在data中。

Go內存分配那些事,看這一篇就夠,深度好文


一個內存塊包含了3類信息,如下圖所示,元數據、用戶數據和對齊字段,內存對齊是為了提高訪問效率。下圖申請5Byte內存的時候,就需要進行內存對齊。

Go內存分配那些事,看這一篇就夠,深度好文


釋放內存實質是把使用的內存塊從鏈表中取出來,然後標記為未使用,當分配內存塊的時候,可以從未使用內存塊中有先查找大小相近的內存塊,如果找不到,再從未分配的內存中分配內存。

上面這個簡單的設計中還沒考慮內存碎片的問題,因為隨著內存不斷的申請和釋放,內存上會存在大量的碎片,降低內存的使用率。為了解決內存碎片,可以將2個連續的未使用的內存塊合併,減少碎片。

以上就是內存管理的基本思路,關於基本的內存管理,想了解更多,可以閱讀這篇文章《Writing a Memory Allocator》,本節的3張圖片也是來自這片文章。

2. TCMalloc

TCMalloc是Thread Cache Malloc的簡稱,是Go內存管理的起源,Go的內存管理是借鑑了TCMalloc,隨著Go的迭代,Go的內存管理與TCMalloc不一致地方在不斷擴大,但其主要思想、原理和概念都是和TCMalloc一致的,如果跳過TCMalloc直接去看Go的內存管理,也許你會似懂非懂。

掌握TCMalloc的理念,無需去關注過多的源碼細節,就可以為掌握Go的內存管理打好基礎,基礎打好了,後面知識才紮實。

在Linux裡,其實有不少的內存管理庫,比如glibc的ptmalloc,FreeBSD的jemalloc,Google的tcmalloc等等,為何會出現這麼多的內存管理庫?本質都是在多線程編程下,追求更高內存管理效率:更快的分配是主要目的。

那如何更快的分配內存?

我們前面提到:

引入虛擬內存後,讓內存的併發訪問問題的粒度從多進程級別,降低到多線程級別。


這是更快分配內存的第一個層次

同一進程的所有線程共享相同的內存空間,他們申請內存時需要加鎖,如果不加鎖就存在同一塊內存被2個線程同時訪問的問題。

TCMalloc的做法是什麼呢?為每個線程預分配一塊緩存,線程申請小內存時,可以從緩存分配內存,這樣有2個好處:

  1. 為線程預分配緩存需要進行1次系統調用,後續線程申請小內存時,從緩存分配,都是在用戶態執行,沒有系統調用,縮短了內存總體的分配和釋放時間,這是快速分配內存的第二個層次
  2. 多個線程同時申請小內存時,從各自的緩存分配,訪問的是不同的地址空間,無需加鎖,把內存併發訪問的粒度進一步降低了,這是快速分配內存的第三個層次

基本原理

下面就簡單介紹下TCMalloc,細緻程度夠我們理解Go的內存管理即可。

聲明:我沒有研究過TCMalloc,以下介紹根據TCMalloc官方資料和其他博主資料總結而來,錯誤之處請朋友告知我。
Go內存分配那些事,看這一篇就夠,深度好文

結合上圖,介紹TCMalloc的幾個重要概念:

  1. Page:操作系統對內存管理以頁為單位,TCMalloc也是這樣,只不過TCMalloc裡的Page大小與操作系統裡的大小並不一定相等,而是倍數關係。《TCMalloc解密》裡稱x64下Page大小是8KB。
  2. Span:一組連續的Page被稱為Span,比如可以有2個頁大小的Span,也可以有16頁大小的Span,Span比Page高一個層級,是為了方便管理一定大小的內存區域,Span是TCMalloc中內存管理的基本單位。
  3. ThreadCache:每個線程各自的Cache,一個Cache包含多個空閒內存塊鏈表,每個鏈表連接的都是內存塊,同一個鏈表上內存塊的大小是相同的,也可以說按內存塊大小,給內存塊分了個類,這樣可以根據申請的內存大小,快速從合適的鏈表選擇空閒內存塊。由於每個線程有自己的ThreadCache,所以ThreadCache訪問是無鎖的。
  4. CentralCache:是所有線程共享的緩存,也是保存的空閒內存塊鏈表,鏈表的數量與ThreadCache中鏈表數量相同,當ThreadCache內存塊不足時,可以從CentralCache取,當ThreadCache內存塊多時,可以放回CentralCache。由於CentralCache是共享的,所以它的訪問是要加鎖的。
  5. PageHeap:PageHeap是堆內存的抽象,PageHeap存的也是若干鏈表,鏈表保存的是Span,當CentralCache沒有內存的時,會從PageHeap取,把1個Span拆成若干內存塊,添加到對應大小的鏈表中,當CentralCache內存多的時候,會放回PageHeap。如下圖,分別是1頁Page的Span鏈表,2頁Page的Span鏈表等,最後是large span set,這個是用來保存中大對象的。毫無疑問,PageHeap也是要加鎖的。


Go內存分配那些事,看這一篇就夠,深度好文

上文提到了小、中、大對象,Go內存管理中也有類似的概念,我們瞄一眼TCMalloc的定義:

  1. 小對象大小:0~256KB
  2. 中對象大小:257~1MB
  3. 大對象大小:>1MB

小對象的分配流程:ThreadCache -> CentralCache -> HeapPage,大部分時候,ThreadCache緩存都是足夠的,不需要去訪問CentralCache和HeapPage,無鎖分配加無系統調用,分配效率是非常高的。

中對象分配流程:直接在PageHeap中選擇適當的大小即可,128 Page的Span所保存的最大內存就是1MB。

大對象分配流程:從large span set選擇合適數量的頁面組成span,用來存儲數據。

通過本節的介紹,你應當對TCMalloc主要思想有一定了解了,我建議再回顧一下上面的內容。

本節圖片皆來自《TCMalloc解密》,圖片版權歸原作者所有。

精彩文章推薦

本文對於TCMalloc的介紹並不多,重要的是3個快速分配內存的層次,如果想了解更多,可閱讀下面文章。

  1. TCMalloc
  2. 必讀,通過這篇你能掌握TCMalloc的原理和性能,對掌握Go的內存管理有非常大的幫助,雖然如今Go的內存管理與TCMalloc已經相差很大,但是,這是Go內存管理的起源和“大道”,這篇文章頂看十幾篇Go內存管理的文章。
  3. TCMalloc解密
  4. 可選異常詳細,包含大量精美圖片,看完得花小時級別,理解就需要更多時間了,看完這篇不需要看其他TCMalloc的文章了。
  5. TCMalloc介紹
  6. 可選,算是TCMalloc的文檔的中文版,多數是從英文版翻譯過來的,如果你英文不好,看看。

3. Go內存管理

前面鋪墊了那麼多,終於到了本文核心的地方。前面的鋪墊不是不重要,相反它們很重要,Go語言內存管理源自前面的基礎知識和內存管理思維,如果你跳過了前面的內容,建議你回頭看一看,它可以幫助你更好的掌握Go內存管理。

前文提到Go內存管理源自TCMalloc,但它比TCMalloc還多了2件東西:逃逸分析和垃圾回收,這是2項提高生產力的絕佳武器。

這一大章節,我們先介紹Go內存管理和Go內存分配,最後涉及一點垃圾回收和內存釋放。

Go內存管理的基本概念

前面計算機基礎知識回顧,是一種自上而下,從宏觀到微觀的介紹方式,把目光引入到今天的主題。

Go內存管理的許多概念在TCMalloc中已經有了,含義是相同的,只是名字有一些變化。先給大家上一幅宏觀的圖,藉助圖一起來介紹。

"
Go語言中文網,致力於每日分享編碼、開源等知識,歡迎關注我,會有意想不到的收穫!

這篇文章主要介紹Go內存分配和Go內存管理,會輕微涉及內存申請和釋放,以及Go垃圾回收。

從非常宏觀的角度看,Go的內存管理就是下圖這個樣子,我們今天主要關注其中標紅的部分。

Go內存分配那些事,看這一篇就夠,深度好文


友情提醒:文章有點長,建議先收藏,後閱讀,絕對是學習內存管理的好資料。

本文基於go1.11.2,不同版本Go的內存管理可能存在差別,比如1.9與1.11的mheap定義就是差別比較大的,後續看源碼的時候,請注意你的go版本,但無論你用哪個go版本,這都是一個優秀的資料,因為內存管理的思想和框架始終未變。

Go這門語言拋棄了C/C++中的開發者管理內存的方式:主動申請與主動釋放,增加了逃逸分析和GC,將開發者從內存管理中釋放出來,讓開發者有更多的精力去關注軟件設計,而不是底層的內存問題。這是Go語言成為高生產力語言的原因之一。

我們不需要精通內存的管理,因為它確實很複雜,但掌握內存的管理,可以讓你寫出更高質量的代碼,另外,還能助你定位Bug。

這篇文章採用層層遞進的方式,依次會介紹關於存儲的基本知識,Go內存管理的“前輩”TCMalloc,然後是Go的內存管理和分配,最後是總結。這麼做的目的是,希望各位能通過全局的認識和思考,擁有更好的編碼思維和架構思維。

最後,這不是一篇源碼分析文章,因為Go源碼分析的文章已經有很多了,這些源碼文章能夠幫助你去學習具體的工程實踐和奇淫巧計了,文章的末尾會推薦一些優秀文章,如果你對內存感興趣,建議每一篇都去看一下,挑出自己喜歡的,多花時間研究下。

1. 存儲基礎知識回顧

這部分我們簡單回顧一下計算機存儲體系、虛擬內存、棧和堆,以及堆內存的管理,這部分內容對理解和掌握Go內存管理比較重要,建議忘記或不熟悉的朋友不要跳過。

存儲金字塔

Go內存分配那些事,看這一篇就夠,深度好文


這幅圖表達了計算機的存儲體系,從上至下依次是:

  • CPU寄存器
  • Cache
  • 內存
  • 硬盤等輔助存儲設備
  • 鼠標等外接設備

從上至下,訪問速度越來越慢,訪問時間越來越長。

你有沒有思考過下面2個簡單的問題,如果沒有不妨想想:

  1. 如果CPU直接訪問硬盤,CPU能充分利用嗎?
  2. 如果CPU直接訪問內存,CPU能充分利用嗎?

CPU速度很快,但硬盤等持久存儲很慢,如果CPU直接訪問磁盤,磁盤可以拉低CPU的速度,機器整體性能就會低下,為了彌補這2個硬件之間的速率差異,所以在CPU和磁盤之間增加了比磁盤快很多的內存。

Go內存分配那些事,看這一篇就夠,深度好文


然而,CPU跟內存的速率也不是相同的,從上圖可以看到,CPU的速率提高的很快(摩爾定律),然而內存速率增長的很慢,雖然CPU的速率現在增加的很慢了,但是內存的速率也沒增加多少,速率差距很大,從1980年開始CPU和內存速率差距在不斷拉大,為了彌補這2個硬件之間的速率差異,所以在CPU跟內存之間增加了比內存更快的Cache,Cache是內存數據的緩存,可以降低CPU訪問內存的時間。

不要以為有了Cache就萬事大吉了,CPU的速率還在不斷增大,Cache也在不斷改變,從最初的1級,到後來的2級,到當代的3級Cache,(有興趣看cache歷史)

Go內存分配那些事,看這一篇就夠,深度好文


三級Cache分別是L1、L2、L3,它們的速率是三個不同的層級,L1速率最快,與CPU速率最接近,是RAM速率的100倍,L2速率就降到了RAM的25倍,L3的速率更靠近RAM的速率。

看到這了,你有沒有Get到整個存儲體系的分層設計自頂向下,速率越來越低,訪問時間越來越長,從磁盤到CPU寄存器,上一層都可以看做是下一層的緩存。

看了分層設計,我們看一下內存,畢竟我們是介紹內存管理的文章。

虛擬內存

虛擬內存是當代操作系統必備的一項重要功能了,它向進程屏蔽了底層了RAM和磁盤,並向進程提供了遠超物理內存大小的內存空間。我們看一下虛擬內存的分層設計

Go內存分配那些事,看這一篇就夠,深度好文


上圖展示了某進程訪問數據,當Cache沒有命中的時候,訪問虛擬內存獲取數據的過程。

訪問內存,實際訪問的是虛擬內存,虛擬內存通過頁表查看,當前要訪問的虛擬內存地址,是否已經加載到了物理內存,如果已經在物理內存,則取物理內存數據,如果沒有對應的物理內存,則從磁盤加載數據到物理內存,並把物理內存地址和虛擬內存地址更新到頁表。

有沒有Get到:物理內存就是磁盤存儲緩存層

另外,在沒有虛擬內存的時代,物理內存對所有進程是共享的,多進程同時訪問同一個物理內存存在併發訪問問題。引入虛擬內存後,每個進程都要各自的虛擬內存,內存的併發訪問問題的粒度從多進程級別,可以降低到多線程級別

棧和堆

我們現在從虛擬內存,再進一層,看虛擬內存中的棧和堆,也就是進程對內存的管理。

Go內存分配那些事,看這一篇就夠,深度好文


上圖展示了一個進程的虛擬內存劃分,代碼中使用的內存地址都是虛擬內存地址,而不是實際的物理內存地址。棧和堆只是虛擬內存上2塊不同功能的內存區域:

  • 棧在高地址,從高地址向低地址增長。
  • 堆在低地址,從低地址向高地址增長。

棧和堆相比有這麼幾個好處

  1. 棧的內存管理簡單,分配比堆上快。
  2. 棧的內存不需要回收,而堆需要,無論是主動free,還是被動的垃圾回收,這都需要花費額外的CPU。
  3. 棧上的內存有更好的局部性,堆上內存訪問就不那麼友好了,CPU訪問的2塊數據可能在不同的頁上,CPU訪問數據的時間可能就上去了。

堆內存管理

Go內存分配那些事,看這一篇就夠,深度好文


我們再進一層,當我們說內存管理的時候,主要是指堆內存的管理,因為棧的內存管理不需要程序去操心。這小節看下堆內存管理乾的是啥,如上圖所示主要是3部分:分配內存塊,回收內存塊和組織內存塊

在一個最簡單的內存管理中,堆內存最初會是一個完整的大塊,即未分配內存,當來申請的時候,就會從未分配內存,分割出一個小內存塊(block),然後用鏈表把所有內存塊連接起來。需要一些信息描述每個內存塊的基本信息,比如大小(size)、是否使用中(used)和下一個內存塊的地址(next),內存塊實際數據存儲在data中。

Go內存分配那些事,看這一篇就夠,深度好文


一個內存塊包含了3類信息,如下圖所示,元數據、用戶數據和對齊字段,內存對齊是為了提高訪問效率。下圖申請5Byte內存的時候,就需要進行內存對齊。

Go內存分配那些事,看這一篇就夠,深度好文


釋放內存實質是把使用的內存塊從鏈表中取出來,然後標記為未使用,當分配內存塊的時候,可以從未使用內存塊中有先查找大小相近的內存塊,如果找不到,再從未分配的內存中分配內存。

上面這個簡單的設計中還沒考慮內存碎片的問題,因為隨著內存不斷的申請和釋放,內存上會存在大量的碎片,降低內存的使用率。為了解決內存碎片,可以將2個連續的未使用的內存塊合併,減少碎片。

以上就是內存管理的基本思路,關於基本的內存管理,想了解更多,可以閱讀這篇文章《Writing a Memory Allocator》,本節的3張圖片也是來自這片文章。

2. TCMalloc

TCMalloc是Thread Cache Malloc的簡稱,是Go內存管理的起源,Go的內存管理是借鑑了TCMalloc,隨著Go的迭代,Go的內存管理與TCMalloc不一致地方在不斷擴大,但其主要思想、原理和概念都是和TCMalloc一致的,如果跳過TCMalloc直接去看Go的內存管理,也許你會似懂非懂。

掌握TCMalloc的理念,無需去關注過多的源碼細節,就可以為掌握Go的內存管理打好基礎,基礎打好了,後面知識才紮實。

在Linux裡,其實有不少的內存管理庫,比如glibc的ptmalloc,FreeBSD的jemalloc,Google的tcmalloc等等,為何會出現這麼多的內存管理庫?本質都是在多線程編程下,追求更高內存管理效率:更快的分配是主要目的。

那如何更快的分配內存?

我們前面提到:

引入虛擬內存後,讓內存的併發訪問問題的粒度從多進程級別,降低到多線程級別。


這是更快分配內存的第一個層次

同一進程的所有線程共享相同的內存空間,他們申請內存時需要加鎖,如果不加鎖就存在同一塊內存被2個線程同時訪問的問題。

TCMalloc的做法是什麼呢?為每個線程預分配一塊緩存,線程申請小內存時,可以從緩存分配內存,這樣有2個好處:

  1. 為線程預分配緩存需要進行1次系統調用,後續線程申請小內存時,從緩存分配,都是在用戶態執行,沒有系統調用,縮短了內存總體的分配和釋放時間,這是快速分配內存的第二個層次
  2. 多個線程同時申請小內存時,從各自的緩存分配,訪問的是不同的地址空間,無需加鎖,把內存併發訪問的粒度進一步降低了,這是快速分配內存的第三個層次

基本原理

下面就簡單介紹下TCMalloc,細緻程度夠我們理解Go的內存管理即可。

聲明:我沒有研究過TCMalloc,以下介紹根據TCMalloc官方資料和其他博主資料總結而來,錯誤之處請朋友告知我。
Go內存分配那些事,看這一篇就夠,深度好文

結合上圖,介紹TCMalloc的幾個重要概念:

  1. Page:操作系統對內存管理以頁為單位,TCMalloc也是這樣,只不過TCMalloc裡的Page大小與操作系統裡的大小並不一定相等,而是倍數關係。《TCMalloc解密》裡稱x64下Page大小是8KB。
  2. Span:一組連續的Page被稱為Span,比如可以有2個頁大小的Span,也可以有16頁大小的Span,Span比Page高一個層級,是為了方便管理一定大小的內存區域,Span是TCMalloc中內存管理的基本單位。
  3. ThreadCache:每個線程各自的Cache,一個Cache包含多個空閒內存塊鏈表,每個鏈表連接的都是內存塊,同一個鏈表上內存塊的大小是相同的,也可以說按內存塊大小,給內存塊分了個類,這樣可以根據申請的內存大小,快速從合適的鏈表選擇空閒內存塊。由於每個線程有自己的ThreadCache,所以ThreadCache訪問是無鎖的。
  4. CentralCache:是所有線程共享的緩存,也是保存的空閒內存塊鏈表,鏈表的數量與ThreadCache中鏈表數量相同,當ThreadCache內存塊不足時,可以從CentralCache取,當ThreadCache內存塊多時,可以放回CentralCache。由於CentralCache是共享的,所以它的訪問是要加鎖的。
  5. PageHeap:PageHeap是堆內存的抽象,PageHeap存的也是若干鏈表,鏈表保存的是Span,當CentralCache沒有內存的時,會從PageHeap取,把1個Span拆成若干內存塊,添加到對應大小的鏈表中,當CentralCache內存多的時候,會放回PageHeap。如下圖,分別是1頁Page的Span鏈表,2頁Page的Span鏈表等,最後是large span set,這個是用來保存中大對象的。毫無疑問,PageHeap也是要加鎖的。


Go內存分配那些事,看這一篇就夠,深度好文

上文提到了小、中、大對象,Go內存管理中也有類似的概念,我們瞄一眼TCMalloc的定義:

  1. 小對象大小:0~256KB
  2. 中對象大小:257~1MB
  3. 大對象大小:>1MB

小對象的分配流程:ThreadCache -> CentralCache -> HeapPage,大部分時候,ThreadCache緩存都是足夠的,不需要去訪問CentralCache和HeapPage,無鎖分配加無系統調用,分配效率是非常高的。

中對象分配流程:直接在PageHeap中選擇適當的大小即可,128 Page的Span所保存的最大內存就是1MB。

大對象分配流程:從large span set選擇合適數量的頁面組成span,用來存儲數據。

通過本節的介紹,你應當對TCMalloc主要思想有一定了解了,我建議再回顧一下上面的內容。

本節圖片皆來自《TCMalloc解密》,圖片版權歸原作者所有。

精彩文章推薦

本文對於TCMalloc的介紹並不多,重要的是3個快速分配內存的層次,如果想了解更多,可閱讀下面文章。

  1. TCMalloc
  2. 必讀,通過這篇你能掌握TCMalloc的原理和性能,對掌握Go的內存管理有非常大的幫助,雖然如今Go的內存管理與TCMalloc已經相差很大,但是,這是Go內存管理的起源和“大道”,這篇文章頂看十幾篇Go內存管理的文章。
  3. TCMalloc解密
  4. 可選異常詳細,包含大量精美圖片,看完得花小時級別,理解就需要更多時間了,看完這篇不需要看其他TCMalloc的文章了。
  5. TCMalloc介紹
  6. 可選,算是TCMalloc的文檔的中文版,多數是從英文版翻譯過來的,如果你英文不好,看看。

3. Go內存管理

前面鋪墊了那麼多,終於到了本文核心的地方。前面的鋪墊不是不重要,相反它們很重要,Go語言內存管理源自前面的基礎知識和內存管理思維,如果你跳過了前面的內容,建議你回頭看一看,它可以幫助你更好的掌握Go內存管理。

前文提到Go內存管理源自TCMalloc,但它比TCMalloc還多了2件東西:逃逸分析和垃圾回收,這是2項提高生產力的絕佳武器。

這一大章節,我們先介紹Go內存管理和Go內存分配,最後涉及一點垃圾回收和內存釋放。

Go內存管理的基本概念

前面計算機基礎知識回顧,是一種自上而下,從宏觀到微觀的介紹方式,把目光引入到今天的主題。

Go內存管理的許多概念在TCMalloc中已經有了,含義是相同的,只是名字有一些變化。先給大家上一幅宏觀的圖,藉助圖一起來介紹。

Go內存分配那些事,看這一篇就夠,深度好文


Page

與TCMalloc中的Page相同,x64下1個Page的大小是8KB。上圖的最下方,1個淺藍色的長方形代表1個Page。

Span

與TCMalloc中的Span相同,Span是內存管理的基本單位,代碼中為mspan,一組連續的Page組成1個Span,所以上圖一組連續的淺藍色長方形代表的是一組Page組成的1個Span,另外,1個淡紫色長方形為1個Span。

mcache

mcache與TCMalloc中的ThreadCache類似,mcache保存的是各種大小的Span,並按Span class分類,小對象直接從mcache分配內存,它起到了緩存的作用,並且可以無鎖訪問

但mcache與ThreadCache也有不同點,TCMalloc中是每個線程1個ThreadCache,Go中是每個P擁有1個mcache,因為在Go程序中,當前最多有GOMAXPROCS個線程在用戶態運行,所以最多需要GOMAXPROCS個mcache就可以保證各線程對mcache的無鎖訪問,線程的運行又是與P綁定的,把mcache交給P剛剛好。

mcentral

mcentral與TCMalloc中的CentralCache類似,是所有線程共享的緩存,需要加鎖訪問,它按Span class對Span分類,串聯成鏈表,當mcache的某個級別Span的內存被分配光時,它會向mcentral申請1個當前級別的Span。

但mcentral與CentralCache也有不同點,CentralCache是每個級別的Span有1個鏈表,mcache是每個級別的Span有2個鏈表,這和mcache申請內存有關,稍後我們再解釋。

mheap

mheap與TCMalloc中的PageHeap類似,它是堆內存的抽象,把從OS申請出的內存頁組織成Span,並保存起來。當mcentral的Span不夠用時會向mheap申請,mheap的Span不夠用時會向OS申請,向OS的內存申請是按頁來的,然後把申請來的內存頁生成Span組織起來,同樣也是需要加鎖訪問的。

但mheap與PageHeap也有不同點:mheap把Span組織成了樹結構,而不是鏈表,並且還是2棵樹,然後把Span分配到heapArena進行管理,它包含地址映射和span是否包含指針等位圖,這樣做的主要原因是為了更高效的利用內存:分配、回收和再利用。

大小轉換

除了以上內存塊組織概念,還有幾個重要的大小概念,一定要拿出來講一下,不要忽視他們的重要性,他們是內存分配、組織和地址轉換的基礎。

"
Go語言中文網,致力於每日分享編碼、開源等知識,歡迎關注我,會有意想不到的收穫!

這篇文章主要介紹Go內存分配和Go內存管理,會輕微涉及內存申請和釋放,以及Go垃圾回收。

從非常宏觀的角度看,Go的內存管理就是下圖這個樣子,我們今天主要關注其中標紅的部分。

Go內存分配那些事,看這一篇就夠,深度好文


友情提醒:文章有點長,建議先收藏,後閱讀,絕對是學習內存管理的好資料。

本文基於go1.11.2,不同版本Go的內存管理可能存在差別,比如1.9與1.11的mheap定義就是差別比較大的,後續看源碼的時候,請注意你的go版本,但無論你用哪個go版本,這都是一個優秀的資料,因為內存管理的思想和框架始終未變。

Go這門語言拋棄了C/C++中的開發者管理內存的方式:主動申請與主動釋放,增加了逃逸分析和GC,將開發者從內存管理中釋放出來,讓開發者有更多的精力去關注軟件設計,而不是底層的內存問題。這是Go語言成為高生產力語言的原因之一。

我們不需要精通內存的管理,因為它確實很複雜,但掌握內存的管理,可以讓你寫出更高質量的代碼,另外,還能助你定位Bug。

這篇文章採用層層遞進的方式,依次會介紹關於存儲的基本知識,Go內存管理的“前輩”TCMalloc,然後是Go的內存管理和分配,最後是總結。這麼做的目的是,希望各位能通過全局的認識和思考,擁有更好的編碼思維和架構思維。

最後,這不是一篇源碼分析文章,因為Go源碼分析的文章已經有很多了,這些源碼文章能夠幫助你去學習具體的工程實踐和奇淫巧計了,文章的末尾會推薦一些優秀文章,如果你對內存感興趣,建議每一篇都去看一下,挑出自己喜歡的,多花時間研究下。

1. 存儲基礎知識回顧

這部分我們簡單回顧一下計算機存儲體系、虛擬內存、棧和堆,以及堆內存的管理,這部分內容對理解和掌握Go內存管理比較重要,建議忘記或不熟悉的朋友不要跳過。

存儲金字塔

Go內存分配那些事,看這一篇就夠,深度好文


這幅圖表達了計算機的存儲體系,從上至下依次是:

  • CPU寄存器
  • Cache
  • 內存
  • 硬盤等輔助存儲設備
  • 鼠標等外接設備

從上至下,訪問速度越來越慢,訪問時間越來越長。

你有沒有思考過下面2個簡單的問題,如果沒有不妨想想:

  1. 如果CPU直接訪問硬盤,CPU能充分利用嗎?
  2. 如果CPU直接訪問內存,CPU能充分利用嗎?

CPU速度很快,但硬盤等持久存儲很慢,如果CPU直接訪問磁盤,磁盤可以拉低CPU的速度,機器整體性能就會低下,為了彌補這2個硬件之間的速率差異,所以在CPU和磁盤之間增加了比磁盤快很多的內存。

Go內存分配那些事,看這一篇就夠,深度好文


然而,CPU跟內存的速率也不是相同的,從上圖可以看到,CPU的速率提高的很快(摩爾定律),然而內存速率增長的很慢,雖然CPU的速率現在增加的很慢了,但是內存的速率也沒增加多少,速率差距很大,從1980年開始CPU和內存速率差距在不斷拉大,為了彌補這2個硬件之間的速率差異,所以在CPU跟內存之間增加了比內存更快的Cache,Cache是內存數據的緩存,可以降低CPU訪問內存的時間。

不要以為有了Cache就萬事大吉了,CPU的速率還在不斷增大,Cache也在不斷改變,從最初的1級,到後來的2級,到當代的3級Cache,(有興趣看cache歷史)

Go內存分配那些事,看這一篇就夠,深度好文


三級Cache分別是L1、L2、L3,它們的速率是三個不同的層級,L1速率最快,與CPU速率最接近,是RAM速率的100倍,L2速率就降到了RAM的25倍,L3的速率更靠近RAM的速率。

看到這了,你有沒有Get到整個存儲體系的分層設計自頂向下,速率越來越低,訪問時間越來越長,從磁盤到CPU寄存器,上一層都可以看做是下一層的緩存。

看了分層設計,我們看一下內存,畢竟我們是介紹內存管理的文章。

虛擬內存

虛擬內存是當代操作系統必備的一項重要功能了,它向進程屏蔽了底層了RAM和磁盤,並向進程提供了遠超物理內存大小的內存空間。我們看一下虛擬內存的分層設計

Go內存分配那些事,看這一篇就夠,深度好文


上圖展示了某進程訪問數據,當Cache沒有命中的時候,訪問虛擬內存獲取數據的過程。

訪問內存,實際訪問的是虛擬內存,虛擬內存通過頁表查看,當前要訪問的虛擬內存地址,是否已經加載到了物理內存,如果已經在物理內存,則取物理內存數據,如果沒有對應的物理內存,則從磁盤加載數據到物理內存,並把物理內存地址和虛擬內存地址更新到頁表。

有沒有Get到:物理內存就是磁盤存儲緩存層

另外,在沒有虛擬內存的時代,物理內存對所有進程是共享的,多進程同時訪問同一個物理內存存在併發訪問問題。引入虛擬內存後,每個進程都要各自的虛擬內存,內存的併發訪問問題的粒度從多進程級別,可以降低到多線程級別

棧和堆

我們現在從虛擬內存,再進一層,看虛擬內存中的棧和堆,也就是進程對內存的管理。

Go內存分配那些事,看這一篇就夠,深度好文


上圖展示了一個進程的虛擬內存劃分,代碼中使用的內存地址都是虛擬內存地址,而不是實際的物理內存地址。棧和堆只是虛擬內存上2塊不同功能的內存區域:

  • 棧在高地址,從高地址向低地址增長。
  • 堆在低地址,從低地址向高地址增長。

棧和堆相比有這麼幾個好處

  1. 棧的內存管理簡單,分配比堆上快。
  2. 棧的內存不需要回收,而堆需要,無論是主動free,還是被動的垃圾回收,這都需要花費額外的CPU。
  3. 棧上的內存有更好的局部性,堆上內存訪問就不那麼友好了,CPU訪問的2塊數據可能在不同的頁上,CPU訪問數據的時間可能就上去了。

堆內存管理

Go內存分配那些事,看這一篇就夠,深度好文


我們再進一層,當我們說內存管理的時候,主要是指堆內存的管理,因為棧的內存管理不需要程序去操心。這小節看下堆內存管理乾的是啥,如上圖所示主要是3部分:分配內存塊,回收內存塊和組織內存塊

在一個最簡單的內存管理中,堆內存最初會是一個完整的大塊,即未分配內存,當來申請的時候,就會從未分配內存,分割出一個小內存塊(block),然後用鏈表把所有內存塊連接起來。需要一些信息描述每個內存塊的基本信息,比如大小(size)、是否使用中(used)和下一個內存塊的地址(next),內存塊實際數據存儲在data中。

Go內存分配那些事,看這一篇就夠,深度好文


一個內存塊包含了3類信息,如下圖所示,元數據、用戶數據和對齊字段,內存對齊是為了提高訪問效率。下圖申請5Byte內存的時候,就需要進行內存對齊。

Go內存分配那些事,看這一篇就夠,深度好文


釋放內存實質是把使用的內存塊從鏈表中取出來,然後標記為未使用,當分配內存塊的時候,可以從未使用內存塊中有先查找大小相近的內存塊,如果找不到,再從未分配的內存中分配內存。

上面這個簡單的設計中還沒考慮內存碎片的問題,因為隨著內存不斷的申請和釋放,內存上會存在大量的碎片,降低內存的使用率。為了解決內存碎片,可以將2個連續的未使用的內存塊合併,減少碎片。

以上就是內存管理的基本思路,關於基本的內存管理,想了解更多,可以閱讀這篇文章《Writing a Memory Allocator》,本節的3張圖片也是來自這片文章。

2. TCMalloc

TCMalloc是Thread Cache Malloc的簡稱,是Go內存管理的起源,Go的內存管理是借鑑了TCMalloc,隨著Go的迭代,Go的內存管理與TCMalloc不一致地方在不斷擴大,但其主要思想、原理和概念都是和TCMalloc一致的,如果跳過TCMalloc直接去看Go的內存管理,也許你會似懂非懂。

掌握TCMalloc的理念,無需去關注過多的源碼細節,就可以為掌握Go的內存管理打好基礎,基礎打好了,後面知識才紮實。

在Linux裡,其實有不少的內存管理庫,比如glibc的ptmalloc,FreeBSD的jemalloc,Google的tcmalloc等等,為何會出現這麼多的內存管理庫?本質都是在多線程編程下,追求更高內存管理效率:更快的分配是主要目的。

那如何更快的分配內存?

我們前面提到:

引入虛擬內存後,讓內存的併發訪問問題的粒度從多進程級別,降低到多線程級別。


這是更快分配內存的第一個層次

同一進程的所有線程共享相同的內存空間,他們申請內存時需要加鎖,如果不加鎖就存在同一塊內存被2個線程同時訪問的問題。

TCMalloc的做法是什麼呢?為每個線程預分配一塊緩存,線程申請小內存時,可以從緩存分配內存,這樣有2個好處:

  1. 為線程預分配緩存需要進行1次系統調用,後續線程申請小內存時,從緩存分配,都是在用戶態執行,沒有系統調用,縮短了內存總體的分配和釋放時間,這是快速分配內存的第二個層次
  2. 多個線程同時申請小內存時,從各自的緩存分配,訪問的是不同的地址空間,無需加鎖,把內存併發訪問的粒度進一步降低了,這是快速分配內存的第三個層次

基本原理

下面就簡單介紹下TCMalloc,細緻程度夠我們理解Go的內存管理即可。

聲明:我沒有研究過TCMalloc,以下介紹根據TCMalloc官方資料和其他博主資料總結而來,錯誤之處請朋友告知我。
Go內存分配那些事,看這一篇就夠,深度好文

結合上圖,介紹TCMalloc的幾個重要概念:

  1. Page:操作系統對內存管理以頁為單位,TCMalloc也是這樣,只不過TCMalloc裡的Page大小與操作系統裡的大小並不一定相等,而是倍數關係。《TCMalloc解密》裡稱x64下Page大小是8KB。
  2. Span:一組連續的Page被稱為Span,比如可以有2個頁大小的Span,也可以有16頁大小的Span,Span比Page高一個層級,是為了方便管理一定大小的內存區域,Span是TCMalloc中內存管理的基本單位。
  3. ThreadCache:每個線程各自的Cache,一個Cache包含多個空閒內存塊鏈表,每個鏈表連接的都是內存塊,同一個鏈表上內存塊的大小是相同的,也可以說按內存塊大小,給內存塊分了個類,這樣可以根據申請的內存大小,快速從合適的鏈表選擇空閒內存塊。由於每個線程有自己的ThreadCache,所以ThreadCache訪問是無鎖的。
  4. CentralCache:是所有線程共享的緩存,也是保存的空閒內存塊鏈表,鏈表的數量與ThreadCache中鏈表數量相同,當ThreadCache內存塊不足時,可以從CentralCache取,當ThreadCache內存塊多時,可以放回CentralCache。由於CentralCache是共享的,所以它的訪問是要加鎖的。
  5. PageHeap:PageHeap是堆內存的抽象,PageHeap存的也是若干鏈表,鏈表保存的是Span,當CentralCache沒有內存的時,會從PageHeap取,把1個Span拆成若干內存塊,添加到對應大小的鏈表中,當CentralCache內存多的時候,會放回PageHeap。如下圖,分別是1頁Page的Span鏈表,2頁Page的Span鏈表等,最後是large span set,這個是用來保存中大對象的。毫無疑問,PageHeap也是要加鎖的。


Go內存分配那些事,看這一篇就夠,深度好文

上文提到了小、中、大對象,Go內存管理中也有類似的概念,我們瞄一眼TCMalloc的定義:

  1. 小對象大小:0~256KB
  2. 中對象大小:257~1MB
  3. 大對象大小:>1MB

小對象的分配流程:ThreadCache -> CentralCache -> HeapPage,大部分時候,ThreadCache緩存都是足夠的,不需要去訪問CentralCache和HeapPage,無鎖分配加無系統調用,分配效率是非常高的。

中對象分配流程:直接在PageHeap中選擇適當的大小即可,128 Page的Span所保存的最大內存就是1MB。

大對象分配流程:從large span set選擇合適數量的頁面組成span,用來存儲數據。

通過本節的介紹,你應當對TCMalloc主要思想有一定了解了,我建議再回顧一下上面的內容。

本節圖片皆來自《TCMalloc解密》,圖片版權歸原作者所有。

精彩文章推薦

本文對於TCMalloc的介紹並不多,重要的是3個快速分配內存的層次,如果想了解更多,可閱讀下面文章。

  1. TCMalloc
  2. 必讀,通過這篇你能掌握TCMalloc的原理和性能,對掌握Go的內存管理有非常大的幫助,雖然如今Go的內存管理與TCMalloc已經相差很大,但是,這是Go內存管理的起源和“大道”,這篇文章頂看十幾篇Go內存管理的文章。
  3. TCMalloc解密
  4. 可選異常詳細,包含大量精美圖片,看完得花小時級別,理解就需要更多時間了,看完這篇不需要看其他TCMalloc的文章了。
  5. TCMalloc介紹
  6. 可選,算是TCMalloc的文檔的中文版,多數是從英文版翻譯過來的,如果你英文不好,看看。

3. Go內存管理

前面鋪墊了那麼多,終於到了本文核心的地方。前面的鋪墊不是不重要,相反它們很重要,Go語言內存管理源自前面的基礎知識和內存管理思維,如果你跳過了前面的內容,建議你回頭看一看,它可以幫助你更好的掌握Go內存管理。

前文提到Go內存管理源自TCMalloc,但它比TCMalloc還多了2件東西:逃逸分析和垃圾回收,這是2項提高生產力的絕佳武器。

這一大章節,我們先介紹Go內存管理和Go內存分配,最後涉及一點垃圾回收和內存釋放。

Go內存管理的基本概念

前面計算機基礎知識回顧,是一種自上而下,從宏觀到微觀的介紹方式,把目光引入到今天的主題。

Go內存管理的許多概念在TCMalloc中已經有了,含義是相同的,只是名字有一些變化。先給大家上一幅宏觀的圖,藉助圖一起來介紹。

Go內存分配那些事,看這一篇就夠,深度好文


Page

與TCMalloc中的Page相同,x64下1個Page的大小是8KB。上圖的最下方,1個淺藍色的長方形代表1個Page。

Span

與TCMalloc中的Span相同,Span是內存管理的基本單位,代碼中為mspan,一組連續的Page組成1個Span,所以上圖一組連續的淺藍色長方形代表的是一組Page組成的1個Span,另外,1個淡紫色長方形為1個Span。

mcache

mcache與TCMalloc中的ThreadCache類似,mcache保存的是各種大小的Span,並按Span class分類,小對象直接從mcache分配內存,它起到了緩存的作用,並且可以無鎖訪問

但mcache與ThreadCache也有不同點,TCMalloc中是每個線程1個ThreadCache,Go中是每個P擁有1個mcache,因為在Go程序中,當前最多有GOMAXPROCS個線程在用戶態運行,所以最多需要GOMAXPROCS個mcache就可以保證各線程對mcache的無鎖訪問,線程的運行又是與P綁定的,把mcache交給P剛剛好。

mcentral

mcentral與TCMalloc中的CentralCache類似,是所有線程共享的緩存,需要加鎖訪問,它按Span class對Span分類,串聯成鏈表,當mcache的某個級別Span的內存被分配光時,它會向mcentral申請1個當前級別的Span。

但mcentral與CentralCache也有不同點,CentralCache是每個級別的Span有1個鏈表,mcache是每個級別的Span有2個鏈表,這和mcache申請內存有關,稍後我們再解釋。

mheap

mheap與TCMalloc中的PageHeap類似,它是堆內存的抽象,把從OS申請出的內存頁組織成Span,並保存起來。當mcentral的Span不夠用時會向mheap申請,mheap的Span不夠用時會向OS申請,向OS的內存申請是按頁來的,然後把申請來的內存頁生成Span組織起來,同樣也是需要加鎖訪問的。

但mheap與PageHeap也有不同點:mheap把Span組織成了樹結構,而不是鏈表,並且還是2棵樹,然後把Span分配到heapArena進行管理,它包含地址映射和span是否包含指針等位圖,這樣做的主要原因是為了更高效的利用內存:分配、回收和再利用。

大小轉換

除了以上內存塊組織概念,還有幾個重要的大小概念,一定要拿出來講一下,不要忽視他們的重要性,他們是內存分配、組織和地址轉換的基礎。

Go內存分配那些事,看這一篇就夠,深度好文


  1. object size:代碼裡簡稱size,指申請內存的對象大小。
  2. size class:代碼裡簡稱class,它是size的級別,相當於把size歸類到一定大小的區間段,比如size[1,8]屬於size class 1,size(8,16]屬於size class 2。
  3. span class:指span的級別,但span class的大小與span的大小並沒有正比關係。span class主要用來和size class做對應,1個size class對應2個span class,2個span class的span大小相同,只是功能不同,1個用來存放包含指針的對象,一個用來存放不包含指針的對象,不包含指針對象的Span就無需GC掃描了。
  4. num of page:代碼裡簡稱npage,代表Page的數量,其實就是Span包含的頁數,用來分配內存。

在介紹這幾個大小之間的換算前,我們得先看下圖這個表,這個表決定了映射關係。

最上面2行是我手動加的,前3列分別是size class,object size和span size,根據這3列做size、size class和num of page之間的轉換。

仔細看一遍這個表,再向下看轉換是如何實現的。

"
Go語言中文網,致力於每日分享編碼、開源等知識,歡迎關注我,會有意想不到的收穫!

這篇文章主要介紹Go內存分配和Go內存管理,會輕微涉及內存申請和釋放,以及Go垃圾回收。

從非常宏觀的角度看,Go的內存管理就是下圖這個樣子,我們今天主要關注其中標紅的部分。

Go內存分配那些事,看這一篇就夠,深度好文


友情提醒:文章有點長,建議先收藏,後閱讀,絕對是學習內存管理的好資料。

本文基於go1.11.2,不同版本Go的內存管理可能存在差別,比如1.9與1.11的mheap定義就是差別比較大的,後續看源碼的時候,請注意你的go版本,但無論你用哪個go版本,這都是一個優秀的資料,因為內存管理的思想和框架始終未變。

Go這門語言拋棄了C/C++中的開發者管理內存的方式:主動申請與主動釋放,增加了逃逸分析和GC,將開發者從內存管理中釋放出來,讓開發者有更多的精力去關注軟件設計,而不是底層的內存問題。這是Go語言成為高生產力語言的原因之一。

我們不需要精通內存的管理,因為它確實很複雜,但掌握內存的管理,可以讓你寫出更高質量的代碼,另外,還能助你定位Bug。

這篇文章採用層層遞進的方式,依次會介紹關於存儲的基本知識,Go內存管理的“前輩”TCMalloc,然後是Go的內存管理和分配,最後是總結。這麼做的目的是,希望各位能通過全局的認識和思考,擁有更好的編碼思維和架構思維。

最後,這不是一篇源碼分析文章,因為Go源碼分析的文章已經有很多了,這些源碼文章能夠幫助你去學習具體的工程實踐和奇淫巧計了,文章的末尾會推薦一些優秀文章,如果你對內存感興趣,建議每一篇都去看一下,挑出自己喜歡的,多花時間研究下。

1. 存儲基礎知識回顧

這部分我們簡單回顧一下計算機存儲體系、虛擬內存、棧和堆,以及堆內存的管理,這部分內容對理解和掌握Go內存管理比較重要,建議忘記或不熟悉的朋友不要跳過。

存儲金字塔

Go內存分配那些事,看這一篇就夠,深度好文


這幅圖表達了計算機的存儲體系,從上至下依次是:

  • CPU寄存器
  • Cache
  • 內存
  • 硬盤等輔助存儲設備
  • 鼠標等外接設備

從上至下,訪問速度越來越慢,訪問時間越來越長。

你有沒有思考過下面2個簡單的問題,如果沒有不妨想想:

  1. 如果CPU直接訪問硬盤,CPU能充分利用嗎?
  2. 如果CPU直接訪問內存,CPU能充分利用嗎?

CPU速度很快,但硬盤等持久存儲很慢,如果CPU直接訪問磁盤,磁盤可以拉低CPU的速度,機器整體性能就會低下,為了彌補這2個硬件之間的速率差異,所以在CPU和磁盤之間增加了比磁盤快很多的內存。

Go內存分配那些事,看這一篇就夠,深度好文


然而,CPU跟內存的速率也不是相同的,從上圖可以看到,CPU的速率提高的很快(摩爾定律),然而內存速率增長的很慢,雖然CPU的速率現在增加的很慢了,但是內存的速率也沒增加多少,速率差距很大,從1980年開始CPU和內存速率差距在不斷拉大,為了彌補這2個硬件之間的速率差異,所以在CPU跟內存之間增加了比內存更快的Cache,Cache是內存數據的緩存,可以降低CPU訪問內存的時間。

不要以為有了Cache就萬事大吉了,CPU的速率還在不斷增大,Cache也在不斷改變,從最初的1級,到後來的2級,到當代的3級Cache,(有興趣看cache歷史)

Go內存分配那些事,看這一篇就夠,深度好文


三級Cache分別是L1、L2、L3,它們的速率是三個不同的層級,L1速率最快,與CPU速率最接近,是RAM速率的100倍,L2速率就降到了RAM的25倍,L3的速率更靠近RAM的速率。

看到這了,你有沒有Get到整個存儲體系的分層設計自頂向下,速率越來越低,訪問時間越來越長,從磁盤到CPU寄存器,上一層都可以看做是下一層的緩存。

看了分層設計,我們看一下內存,畢竟我們是介紹內存管理的文章。

虛擬內存

虛擬內存是當代操作系統必備的一項重要功能了,它向進程屏蔽了底層了RAM和磁盤,並向進程提供了遠超物理內存大小的內存空間。我們看一下虛擬內存的分層設計

Go內存分配那些事,看這一篇就夠,深度好文


上圖展示了某進程訪問數據,當Cache沒有命中的時候,訪問虛擬內存獲取數據的過程。

訪問內存,實際訪問的是虛擬內存,虛擬內存通過頁表查看,當前要訪問的虛擬內存地址,是否已經加載到了物理內存,如果已經在物理內存,則取物理內存數據,如果沒有對應的物理內存,則從磁盤加載數據到物理內存,並把物理內存地址和虛擬內存地址更新到頁表。

有沒有Get到:物理內存就是磁盤存儲緩存層

另外,在沒有虛擬內存的時代,物理內存對所有進程是共享的,多進程同時訪問同一個物理內存存在併發訪問問題。引入虛擬內存後,每個進程都要各自的虛擬內存,內存的併發訪問問題的粒度從多進程級別,可以降低到多線程級別

棧和堆

我們現在從虛擬內存,再進一層,看虛擬內存中的棧和堆,也就是進程對內存的管理。

Go內存分配那些事,看這一篇就夠,深度好文


上圖展示了一個進程的虛擬內存劃分,代碼中使用的內存地址都是虛擬內存地址,而不是實際的物理內存地址。棧和堆只是虛擬內存上2塊不同功能的內存區域:

  • 棧在高地址,從高地址向低地址增長。
  • 堆在低地址,從低地址向高地址增長。

棧和堆相比有這麼幾個好處

  1. 棧的內存管理簡單,分配比堆上快。
  2. 棧的內存不需要回收,而堆需要,無論是主動free,還是被動的垃圾回收,這都需要花費額外的CPU。
  3. 棧上的內存有更好的局部性,堆上內存訪問就不那麼友好了,CPU訪問的2塊數據可能在不同的頁上,CPU訪問數據的時間可能就上去了。

堆內存管理

Go內存分配那些事,看這一篇就夠,深度好文


我們再進一層,當我們說內存管理的時候,主要是指堆內存的管理,因為棧的內存管理不需要程序去操心。這小節看下堆內存管理乾的是啥,如上圖所示主要是3部分:分配內存塊,回收內存塊和組織內存塊

在一個最簡單的內存管理中,堆內存最初會是一個完整的大塊,即未分配內存,當來申請的時候,就會從未分配內存,分割出一個小內存塊(block),然後用鏈表把所有內存塊連接起來。需要一些信息描述每個內存塊的基本信息,比如大小(size)、是否使用中(used)和下一個內存塊的地址(next),內存塊實際數據存儲在data中。

Go內存分配那些事,看這一篇就夠,深度好文


一個內存塊包含了3類信息,如下圖所示,元數據、用戶數據和對齊字段,內存對齊是為了提高訪問效率。下圖申請5Byte內存的時候,就需要進行內存對齊。

Go內存分配那些事,看這一篇就夠,深度好文


釋放內存實質是把使用的內存塊從鏈表中取出來,然後標記為未使用,當分配內存塊的時候,可以從未使用內存塊中有先查找大小相近的內存塊,如果找不到,再從未分配的內存中分配內存。

上面這個簡單的設計中還沒考慮內存碎片的問題,因為隨著內存不斷的申請和釋放,內存上會存在大量的碎片,降低內存的使用率。為了解決內存碎片,可以將2個連續的未使用的內存塊合併,減少碎片。

以上就是內存管理的基本思路,關於基本的內存管理,想了解更多,可以閱讀這篇文章《Writing a Memory Allocator》,本節的3張圖片也是來自這片文章。

2. TCMalloc

TCMalloc是Thread Cache Malloc的簡稱,是Go內存管理的起源,Go的內存管理是借鑑了TCMalloc,隨著Go的迭代,Go的內存管理與TCMalloc不一致地方在不斷擴大,但其主要思想、原理和概念都是和TCMalloc一致的,如果跳過TCMalloc直接去看Go的內存管理,也許你會似懂非懂。

掌握TCMalloc的理念,無需去關注過多的源碼細節,就可以為掌握Go的內存管理打好基礎,基礎打好了,後面知識才紮實。

在Linux裡,其實有不少的內存管理庫,比如glibc的ptmalloc,FreeBSD的jemalloc,Google的tcmalloc等等,為何會出現這麼多的內存管理庫?本質都是在多線程編程下,追求更高內存管理效率:更快的分配是主要目的。

那如何更快的分配內存?

我們前面提到:

引入虛擬內存後,讓內存的併發訪問問題的粒度從多進程級別,降低到多線程級別。


這是更快分配內存的第一個層次

同一進程的所有線程共享相同的內存空間,他們申請內存時需要加鎖,如果不加鎖就存在同一塊內存被2個線程同時訪問的問題。

TCMalloc的做法是什麼呢?為每個線程預分配一塊緩存,線程申請小內存時,可以從緩存分配內存,這樣有2個好處:

  1. 為線程預分配緩存需要進行1次系統調用,後續線程申請小內存時,從緩存分配,都是在用戶態執行,沒有系統調用,縮短了內存總體的分配和釋放時間,這是快速分配內存的第二個層次
  2. 多個線程同時申請小內存時,從各自的緩存分配,訪問的是不同的地址空間,無需加鎖,把內存併發訪問的粒度進一步降低了,這是快速分配內存的第三個層次

基本原理

下面就簡單介紹下TCMalloc,細緻程度夠我們理解Go的內存管理即可。

聲明:我沒有研究過TCMalloc,以下介紹根據TCMalloc官方資料和其他博主資料總結而來,錯誤之處請朋友告知我。
Go內存分配那些事,看這一篇就夠,深度好文

結合上圖,介紹TCMalloc的幾個重要概念:

  1. Page:操作系統對內存管理以頁為單位,TCMalloc也是這樣,只不過TCMalloc裡的Page大小與操作系統裡的大小並不一定相等,而是倍數關係。《TCMalloc解密》裡稱x64下Page大小是8KB。
  2. Span:一組連續的Page被稱為Span,比如可以有2個頁大小的Span,也可以有16頁大小的Span,Span比Page高一個層級,是為了方便管理一定大小的內存區域,Span是TCMalloc中內存管理的基本單位。
  3. ThreadCache:每個線程各自的Cache,一個Cache包含多個空閒內存塊鏈表,每個鏈表連接的都是內存塊,同一個鏈表上內存塊的大小是相同的,也可以說按內存塊大小,給內存塊分了個類,這樣可以根據申請的內存大小,快速從合適的鏈表選擇空閒內存塊。由於每個線程有自己的ThreadCache,所以ThreadCache訪問是無鎖的。
  4. CentralCache:是所有線程共享的緩存,也是保存的空閒內存塊鏈表,鏈表的數量與ThreadCache中鏈表數量相同,當ThreadCache內存塊不足時,可以從CentralCache取,當ThreadCache內存塊多時,可以放回CentralCache。由於CentralCache是共享的,所以它的訪問是要加鎖的。
  5. PageHeap:PageHeap是堆內存的抽象,PageHeap存的也是若干鏈表,鏈表保存的是Span,當CentralCache沒有內存的時,會從PageHeap取,把1個Span拆成若干內存塊,添加到對應大小的鏈表中,當CentralCache內存多的時候,會放回PageHeap。如下圖,分別是1頁Page的Span鏈表,2頁Page的Span鏈表等,最後是large span set,這個是用來保存中大對象的。毫無疑問,PageHeap也是要加鎖的。


Go內存分配那些事,看這一篇就夠,深度好文

上文提到了小、中、大對象,Go內存管理中也有類似的概念,我們瞄一眼TCMalloc的定義:

  1. 小對象大小:0~256KB
  2. 中對象大小:257~1MB
  3. 大對象大小:>1MB

小對象的分配流程:ThreadCache -> CentralCache -> HeapPage,大部分時候,ThreadCache緩存都是足夠的,不需要去訪問CentralCache和HeapPage,無鎖分配加無系統調用,分配效率是非常高的。

中對象分配流程:直接在PageHeap中選擇適當的大小即可,128 Page的Span所保存的最大內存就是1MB。

大對象分配流程:從large span set選擇合適數量的頁面組成span,用來存儲數據。

通過本節的介紹,你應當對TCMalloc主要思想有一定了解了,我建議再回顧一下上面的內容。

本節圖片皆來自《TCMalloc解密》,圖片版權歸原作者所有。

精彩文章推薦

本文對於TCMalloc的介紹並不多,重要的是3個快速分配內存的層次,如果想了解更多,可閱讀下面文章。

  1. TCMalloc
  2. 必讀,通過這篇你能掌握TCMalloc的原理和性能,對掌握Go的內存管理有非常大的幫助,雖然如今Go的內存管理與TCMalloc已經相差很大,但是,這是Go內存管理的起源和“大道”,這篇文章頂看十幾篇Go內存管理的文章。
  3. TCMalloc解密
  4. 可選異常詳細,包含大量精美圖片,看完得花小時級別,理解就需要更多時間了,看完這篇不需要看其他TCMalloc的文章了。
  5. TCMalloc介紹
  6. 可選,算是TCMalloc的文檔的中文版,多數是從英文版翻譯過來的,如果你英文不好,看看。

3. Go內存管理

前面鋪墊了那麼多,終於到了本文核心的地方。前面的鋪墊不是不重要,相反它們很重要,Go語言內存管理源自前面的基礎知識和內存管理思維,如果你跳過了前面的內容,建議你回頭看一看,它可以幫助你更好的掌握Go內存管理。

前文提到Go內存管理源自TCMalloc,但它比TCMalloc還多了2件東西:逃逸分析和垃圾回收,這是2項提高生產力的絕佳武器。

這一大章節,我們先介紹Go內存管理和Go內存分配,最後涉及一點垃圾回收和內存釋放。

Go內存管理的基本概念

前面計算機基礎知識回顧,是一種自上而下,從宏觀到微觀的介紹方式,把目光引入到今天的主題。

Go內存管理的許多概念在TCMalloc中已經有了,含義是相同的,只是名字有一些變化。先給大家上一幅宏觀的圖,藉助圖一起來介紹。

Go內存分配那些事,看這一篇就夠,深度好文


Page

與TCMalloc中的Page相同,x64下1個Page的大小是8KB。上圖的最下方,1個淺藍色的長方形代表1個Page。

Span

與TCMalloc中的Span相同,Span是內存管理的基本單位,代碼中為mspan,一組連續的Page組成1個Span,所以上圖一組連續的淺藍色長方形代表的是一組Page組成的1個Span,另外,1個淡紫色長方形為1個Span。

mcache

mcache與TCMalloc中的ThreadCache類似,mcache保存的是各種大小的Span,並按Span class分類,小對象直接從mcache分配內存,它起到了緩存的作用,並且可以無鎖訪問

但mcache與ThreadCache也有不同點,TCMalloc中是每個線程1個ThreadCache,Go中是每個P擁有1個mcache,因為在Go程序中,當前最多有GOMAXPROCS個線程在用戶態運行,所以最多需要GOMAXPROCS個mcache就可以保證各線程對mcache的無鎖訪問,線程的運行又是與P綁定的,把mcache交給P剛剛好。

mcentral

mcentral與TCMalloc中的CentralCache類似,是所有線程共享的緩存,需要加鎖訪問,它按Span class對Span分類,串聯成鏈表,當mcache的某個級別Span的內存被分配光時,它會向mcentral申請1個當前級別的Span。

但mcentral與CentralCache也有不同點,CentralCache是每個級別的Span有1個鏈表,mcache是每個級別的Span有2個鏈表,這和mcache申請內存有關,稍後我們再解釋。

mheap

mheap與TCMalloc中的PageHeap類似,它是堆內存的抽象,把從OS申請出的內存頁組織成Span,並保存起來。當mcentral的Span不夠用時會向mheap申請,mheap的Span不夠用時會向OS申請,向OS的內存申請是按頁來的,然後把申請來的內存頁生成Span組織起來,同樣也是需要加鎖訪問的。

但mheap與PageHeap也有不同點:mheap把Span組織成了樹結構,而不是鏈表,並且還是2棵樹,然後把Span分配到heapArena進行管理,它包含地址映射和span是否包含指針等位圖,這樣做的主要原因是為了更高效的利用內存:分配、回收和再利用。

大小轉換

除了以上內存塊組織概念,還有幾個重要的大小概念,一定要拿出來講一下,不要忽視他們的重要性,他們是內存分配、組織和地址轉換的基礎。

Go內存分配那些事,看這一篇就夠,深度好文


  1. object size:代碼裡簡稱size,指申請內存的對象大小。
  2. size class:代碼裡簡稱class,它是size的級別,相當於把size歸類到一定大小的區間段,比如size[1,8]屬於size class 1,size(8,16]屬於size class 2。
  3. span class:指span的級別,但span class的大小與span的大小並沒有正比關係。span class主要用來和size class做對應,1個size class對應2個span class,2個span class的span大小相同,只是功能不同,1個用來存放包含指針的對象,一個用來存放不包含指針的對象,不包含指針對象的Span就無需GC掃描了。
  4. num of page:代碼裡簡稱npage,代表Page的數量,其實就是Span包含的頁數,用來分配內存。

在介紹這幾個大小之間的換算前,我們得先看下圖這個表,這個表決定了映射關係。

最上面2行是我手動加的,前3列分別是size class,object size和span size,根據這3列做size、size class和num of page之間的轉換。

仔細看一遍這個表,再向下看轉換是如何實現的。

Go內存分配那些事,看這一篇就夠,深度好文


在Go內存大小轉換那幅圖中已經標記各大小之間的轉換,分別是數組:class_to_size,size_to_class*和class_to_allocnpages,這3個數組內容,就是跟上表的映射關係匹配的。比如class_to_size,從上表看class 1對應的保存對象大小為8,所以class_to_size[1]=8,span大小為8192Byte,即8KB,為1頁,所以class_to_allocnpages[1]=1。

"
Go語言中文網,致力於每日分享編碼、開源等知識,歡迎關注我,會有意想不到的收穫!

這篇文章主要介紹Go內存分配和Go內存管理,會輕微涉及內存申請和釋放,以及Go垃圾回收。

從非常宏觀的角度看,Go的內存管理就是下圖這個樣子,我們今天主要關注其中標紅的部分。

Go內存分配那些事,看這一篇就夠,深度好文


友情提醒:文章有點長,建議先收藏,後閱讀,絕對是學習內存管理的好資料。

本文基於go1.11.2,不同版本Go的內存管理可能存在差別,比如1.9與1.11的mheap定義就是差別比較大的,後續看源碼的時候,請注意你的go版本,但無論你用哪個go版本,這都是一個優秀的資料,因為內存管理的思想和框架始終未變。

Go這門語言拋棄了C/C++中的開發者管理內存的方式:主動申請與主動釋放,增加了逃逸分析和GC,將開發者從內存管理中釋放出來,讓開發者有更多的精力去關注軟件設計,而不是底層的內存問題。這是Go語言成為高生產力語言的原因之一。

我們不需要精通內存的管理,因為它確實很複雜,但掌握內存的管理,可以讓你寫出更高質量的代碼,另外,還能助你定位Bug。

這篇文章採用層層遞進的方式,依次會介紹關於存儲的基本知識,Go內存管理的“前輩”TCMalloc,然後是Go的內存管理和分配,最後是總結。這麼做的目的是,希望各位能通過全局的認識和思考,擁有更好的編碼思維和架構思維。

最後,這不是一篇源碼分析文章,因為Go源碼分析的文章已經有很多了,這些源碼文章能夠幫助你去學習具體的工程實踐和奇淫巧計了,文章的末尾會推薦一些優秀文章,如果你對內存感興趣,建議每一篇都去看一下,挑出自己喜歡的,多花時間研究下。

1. 存儲基礎知識回顧

這部分我們簡單回顧一下計算機存儲體系、虛擬內存、棧和堆,以及堆內存的管理,這部分內容對理解和掌握Go內存管理比較重要,建議忘記或不熟悉的朋友不要跳過。

存儲金字塔

Go內存分配那些事,看這一篇就夠,深度好文


這幅圖表達了計算機的存儲體系,從上至下依次是:

  • CPU寄存器
  • Cache
  • 內存
  • 硬盤等輔助存儲設備
  • 鼠標等外接設備

從上至下,訪問速度越來越慢,訪問時間越來越長。

你有沒有思考過下面2個簡單的問題,如果沒有不妨想想:

  1. 如果CPU直接訪問硬盤,CPU能充分利用嗎?
  2. 如果CPU直接訪問內存,CPU能充分利用嗎?

CPU速度很快,但硬盤等持久存儲很慢,如果CPU直接訪問磁盤,磁盤可以拉低CPU的速度,機器整體性能就會低下,為了彌補這2個硬件之間的速率差異,所以在CPU和磁盤之間增加了比磁盤快很多的內存。

Go內存分配那些事,看這一篇就夠,深度好文


然而,CPU跟內存的速率也不是相同的,從上圖可以看到,CPU的速率提高的很快(摩爾定律),然而內存速率增長的很慢,雖然CPU的速率現在增加的很慢了,但是內存的速率也沒增加多少,速率差距很大,從1980年開始CPU和內存速率差距在不斷拉大,為了彌補這2個硬件之間的速率差異,所以在CPU跟內存之間增加了比內存更快的Cache,Cache是內存數據的緩存,可以降低CPU訪問內存的時間。

不要以為有了Cache就萬事大吉了,CPU的速率還在不斷增大,Cache也在不斷改變,從最初的1級,到後來的2級,到當代的3級Cache,(有興趣看cache歷史)

Go內存分配那些事,看這一篇就夠,深度好文


三級Cache分別是L1、L2、L3,它們的速率是三個不同的層級,L1速率最快,與CPU速率最接近,是RAM速率的100倍,L2速率就降到了RAM的25倍,L3的速率更靠近RAM的速率。

看到這了,你有沒有Get到整個存儲體系的分層設計自頂向下,速率越來越低,訪問時間越來越長,從磁盤到CPU寄存器,上一層都可以看做是下一層的緩存。

看了分層設計,我們看一下內存,畢竟我們是介紹內存管理的文章。

虛擬內存

虛擬內存是當代操作系統必備的一項重要功能了,它向進程屏蔽了底層了RAM和磁盤,並向進程提供了遠超物理內存大小的內存空間。我們看一下虛擬內存的分層設計

Go內存分配那些事,看這一篇就夠,深度好文


上圖展示了某進程訪問數據,當Cache沒有命中的時候,訪問虛擬內存獲取數據的過程。

訪問內存,實際訪問的是虛擬內存,虛擬內存通過頁表查看,當前要訪問的虛擬內存地址,是否已經加載到了物理內存,如果已經在物理內存,則取物理內存數據,如果沒有對應的物理內存,則從磁盤加載數據到物理內存,並把物理內存地址和虛擬內存地址更新到頁表。

有沒有Get到:物理內存就是磁盤存儲緩存層

另外,在沒有虛擬內存的時代,物理內存對所有進程是共享的,多進程同時訪問同一個物理內存存在併發訪問問題。引入虛擬內存後,每個進程都要各自的虛擬內存,內存的併發訪問問題的粒度從多進程級別,可以降低到多線程級別

棧和堆

我們現在從虛擬內存,再進一層,看虛擬內存中的棧和堆,也就是進程對內存的管理。

Go內存分配那些事,看這一篇就夠,深度好文


上圖展示了一個進程的虛擬內存劃分,代碼中使用的內存地址都是虛擬內存地址,而不是實際的物理內存地址。棧和堆只是虛擬內存上2塊不同功能的內存區域:

  • 棧在高地址,從高地址向低地址增長。
  • 堆在低地址,從低地址向高地址增長。

棧和堆相比有這麼幾個好處

  1. 棧的內存管理簡單,分配比堆上快。
  2. 棧的內存不需要回收,而堆需要,無論是主動free,還是被動的垃圾回收,這都需要花費額外的CPU。
  3. 棧上的內存有更好的局部性,堆上內存訪問就不那麼友好了,CPU訪問的2塊數據可能在不同的頁上,CPU訪問數據的時間可能就上去了。

堆內存管理

Go內存分配那些事,看這一篇就夠,深度好文


我們再進一層,當我們說內存管理的時候,主要是指堆內存的管理,因為棧的內存管理不需要程序去操心。這小節看下堆內存管理乾的是啥,如上圖所示主要是3部分:分配內存塊,回收內存塊和組織內存塊

在一個最簡單的內存管理中,堆內存最初會是一個完整的大塊,即未分配內存,當來申請的時候,就會從未分配內存,分割出一個小內存塊(block),然後用鏈表把所有內存塊連接起來。需要一些信息描述每個內存塊的基本信息,比如大小(size)、是否使用中(used)和下一個內存塊的地址(next),內存塊實際數據存儲在data中。

Go內存分配那些事,看這一篇就夠,深度好文


一個內存塊包含了3類信息,如下圖所示,元數據、用戶數據和對齊字段,內存對齊是為了提高訪問效率。下圖申請5Byte內存的時候,就需要進行內存對齊。

Go內存分配那些事,看這一篇就夠,深度好文


釋放內存實質是把使用的內存塊從鏈表中取出來,然後標記為未使用,當分配內存塊的時候,可以從未使用內存塊中有先查找大小相近的內存塊,如果找不到,再從未分配的內存中分配內存。

上面這個簡單的設計中還沒考慮內存碎片的問題,因為隨著內存不斷的申請和釋放,內存上會存在大量的碎片,降低內存的使用率。為了解決內存碎片,可以將2個連續的未使用的內存塊合併,減少碎片。

以上就是內存管理的基本思路,關於基本的內存管理,想了解更多,可以閱讀這篇文章《Writing a Memory Allocator》,本節的3張圖片也是來自這片文章。

2. TCMalloc

TCMalloc是Thread Cache Malloc的簡稱,是Go內存管理的起源,Go的內存管理是借鑑了TCMalloc,隨著Go的迭代,Go的內存管理與TCMalloc不一致地方在不斷擴大,但其主要思想、原理和概念都是和TCMalloc一致的,如果跳過TCMalloc直接去看Go的內存管理,也許你會似懂非懂。

掌握TCMalloc的理念,無需去關注過多的源碼細節,就可以為掌握Go的內存管理打好基礎,基礎打好了,後面知識才紮實。

在Linux裡,其實有不少的內存管理庫,比如glibc的ptmalloc,FreeBSD的jemalloc,Google的tcmalloc等等,為何會出現這麼多的內存管理庫?本質都是在多線程編程下,追求更高內存管理效率:更快的分配是主要目的。

那如何更快的分配內存?

我們前面提到:

引入虛擬內存後,讓內存的併發訪問問題的粒度從多進程級別,降低到多線程級別。


這是更快分配內存的第一個層次

同一進程的所有線程共享相同的內存空間,他們申請內存時需要加鎖,如果不加鎖就存在同一塊內存被2個線程同時訪問的問題。

TCMalloc的做法是什麼呢?為每個線程預分配一塊緩存,線程申請小內存時,可以從緩存分配內存,這樣有2個好處:

  1. 為線程預分配緩存需要進行1次系統調用,後續線程申請小內存時,從緩存分配,都是在用戶態執行,沒有系統調用,縮短了內存總體的分配和釋放時間,這是快速分配內存的第二個層次
  2. 多個線程同時申請小內存時,從各自的緩存分配,訪問的是不同的地址空間,無需加鎖,把內存併發訪問的粒度進一步降低了,這是快速分配內存的第三個層次

基本原理

下面就簡單介紹下TCMalloc,細緻程度夠我們理解Go的內存管理即可。

聲明:我沒有研究過TCMalloc,以下介紹根據TCMalloc官方資料和其他博主資料總結而來,錯誤之處請朋友告知我。
Go內存分配那些事,看這一篇就夠,深度好文

結合上圖,介紹TCMalloc的幾個重要概念:

  1. Page:操作系統對內存管理以頁為單位,TCMalloc也是這樣,只不過TCMalloc裡的Page大小與操作系統裡的大小並不一定相等,而是倍數關係。《TCMalloc解密》裡稱x64下Page大小是8KB。
  2. Span:一組連續的Page被稱為Span,比如可以有2個頁大小的Span,也可以有16頁大小的Span,Span比Page高一個層級,是為了方便管理一定大小的內存區域,Span是TCMalloc中內存管理的基本單位。
  3. ThreadCache:每個線程各自的Cache,一個Cache包含多個空閒內存塊鏈表,每個鏈表連接的都是內存塊,同一個鏈表上內存塊的大小是相同的,也可以說按內存塊大小,給內存塊分了個類,這樣可以根據申請的內存大小,快速從合適的鏈表選擇空閒內存塊。由於每個線程有自己的ThreadCache,所以ThreadCache訪問是無鎖的。
  4. CentralCache:是所有線程共享的緩存,也是保存的空閒內存塊鏈表,鏈表的數量與ThreadCache中鏈表數量相同,當ThreadCache內存塊不足時,可以從CentralCache取,當ThreadCache內存塊多時,可以放回CentralCache。由於CentralCache是共享的,所以它的訪問是要加鎖的。
  5. PageHeap:PageHeap是堆內存的抽象,PageHeap存的也是若干鏈表,鏈表保存的是Span,當CentralCache沒有內存的時,會從PageHeap取,把1個Span拆成若干內存塊,添加到對應大小的鏈表中,當CentralCache內存多的時候,會放回PageHeap。如下圖,分別是1頁Page的Span鏈表,2頁Page的Span鏈表等,最後是large span set,這個是用來保存中大對象的。毫無疑問,PageHeap也是要加鎖的。


Go內存分配那些事,看這一篇就夠,深度好文

上文提到了小、中、大對象,Go內存管理中也有類似的概念,我們瞄一眼TCMalloc的定義:

  1. 小對象大小:0~256KB
  2. 中對象大小:257~1MB
  3. 大對象大小:>1MB

小對象的分配流程:ThreadCache -> CentralCache -> HeapPage,大部分時候,ThreadCache緩存都是足夠的,不需要去訪問CentralCache和HeapPage,無鎖分配加無系統調用,分配效率是非常高的。

中對象分配流程:直接在PageHeap中選擇適當的大小即可,128 Page的Span所保存的最大內存就是1MB。

大對象分配流程:從large span set選擇合適數量的頁面組成span,用來存儲數據。

通過本節的介紹,你應當對TCMalloc主要思想有一定了解了,我建議再回顧一下上面的內容。

本節圖片皆來自《TCMalloc解密》,圖片版權歸原作者所有。

精彩文章推薦

本文對於TCMalloc的介紹並不多,重要的是3個快速分配內存的層次,如果想了解更多,可閱讀下面文章。

  1. TCMalloc
  2. 必讀,通過這篇你能掌握TCMalloc的原理和性能,對掌握Go的內存管理有非常大的幫助,雖然如今Go的內存管理與TCMalloc已經相差很大,但是,這是Go內存管理的起源和“大道”,這篇文章頂看十幾篇Go內存管理的文章。
  3. TCMalloc解密
  4. 可選異常詳細,包含大量精美圖片,看完得花小時級別,理解就需要更多時間了,看完這篇不需要看其他TCMalloc的文章了。
  5. TCMalloc介紹
  6. 可選,算是TCMalloc的文檔的中文版,多數是從英文版翻譯過來的,如果你英文不好,看看。

3. Go內存管理

前面鋪墊了那麼多,終於到了本文核心的地方。前面的鋪墊不是不重要,相反它們很重要,Go語言內存管理源自前面的基礎知識和內存管理思維,如果你跳過了前面的內容,建議你回頭看一看,它可以幫助你更好的掌握Go內存管理。

前文提到Go內存管理源自TCMalloc,但它比TCMalloc還多了2件東西:逃逸分析和垃圾回收,這是2項提高生產力的絕佳武器。

這一大章節,我們先介紹Go內存管理和Go內存分配,最後涉及一點垃圾回收和內存釋放。

Go內存管理的基本概念

前面計算機基礎知識回顧,是一種自上而下,從宏觀到微觀的介紹方式,把目光引入到今天的主題。

Go內存管理的許多概念在TCMalloc中已經有了,含義是相同的,只是名字有一些變化。先給大家上一幅宏觀的圖,藉助圖一起來介紹。

Go內存分配那些事,看這一篇就夠,深度好文


Page

與TCMalloc中的Page相同,x64下1個Page的大小是8KB。上圖的最下方,1個淺藍色的長方形代表1個Page。

Span

與TCMalloc中的Span相同,Span是內存管理的基本單位,代碼中為mspan,一組連續的Page組成1個Span,所以上圖一組連續的淺藍色長方形代表的是一組Page組成的1個Span,另外,1個淡紫色長方形為1個Span。

mcache

mcache與TCMalloc中的ThreadCache類似,mcache保存的是各種大小的Span,並按Span class分類,小對象直接從mcache分配內存,它起到了緩存的作用,並且可以無鎖訪問

但mcache與ThreadCache也有不同點,TCMalloc中是每個線程1個ThreadCache,Go中是每個P擁有1個mcache,因為在Go程序中,當前最多有GOMAXPROCS個線程在用戶態運行,所以最多需要GOMAXPROCS個mcache就可以保證各線程對mcache的無鎖訪問,線程的運行又是與P綁定的,把mcache交給P剛剛好。

mcentral

mcentral與TCMalloc中的CentralCache類似,是所有線程共享的緩存,需要加鎖訪問,它按Span class對Span分類,串聯成鏈表,當mcache的某個級別Span的內存被分配光時,它會向mcentral申請1個當前級別的Span。

但mcentral與CentralCache也有不同點,CentralCache是每個級別的Span有1個鏈表,mcache是每個級別的Span有2個鏈表,這和mcache申請內存有關,稍後我們再解釋。

mheap

mheap與TCMalloc中的PageHeap類似,它是堆內存的抽象,把從OS申請出的內存頁組織成Span,並保存起來。當mcentral的Span不夠用時會向mheap申請,mheap的Span不夠用時會向OS申請,向OS的內存申請是按頁來的,然後把申請來的內存頁生成Span組織起來,同樣也是需要加鎖訪問的。

但mheap與PageHeap也有不同點:mheap把Span組織成了樹結構,而不是鏈表,並且還是2棵樹,然後把Span分配到heapArena進行管理,它包含地址映射和span是否包含指針等位圖,這樣做的主要原因是為了更高效的利用內存:分配、回收和再利用。

大小轉換

除了以上內存塊組織概念,還有幾個重要的大小概念,一定要拿出來講一下,不要忽視他們的重要性,他們是內存分配、組織和地址轉換的基礎。

Go內存分配那些事,看這一篇就夠,深度好文


  1. object size:代碼裡簡稱size,指申請內存的對象大小。
  2. size class:代碼裡簡稱class,它是size的級別,相當於把size歸類到一定大小的區間段,比如size[1,8]屬於size class 1,size(8,16]屬於size class 2。
  3. span class:指span的級別,但span class的大小與span的大小並沒有正比關係。span class主要用來和size class做對應,1個size class對應2個span class,2個span class的span大小相同,只是功能不同,1個用來存放包含指針的對象,一個用來存放不包含指針的對象,不包含指針對象的Span就無需GC掃描了。
  4. num of page:代碼裡簡稱npage,代表Page的數量,其實就是Span包含的頁數,用來分配內存。

在介紹這幾個大小之間的換算前,我們得先看下圖這個表,這個表決定了映射關係。

最上面2行是我手動加的,前3列分別是size class,object size和span size,根據這3列做size、size class和num of page之間的轉換。

仔細看一遍這個表,再向下看轉換是如何實現的。

Go內存分配那些事,看這一篇就夠,深度好文


在Go內存大小轉換那幅圖中已經標記各大小之間的轉換,分別是數組:class_to_size,size_to_class*和class_to_allocnpages,這3個數組內容,就是跟上表的映射關係匹配的。比如class_to_size,從上表看class 1對應的保存對象大小為8,所以class_to_size[1]=8,span大小為8192Byte,即8KB,為1頁,所以class_to_allocnpages[1]=1。

Go內存分配那些事,看這一篇就夠,深度好文


為何不使用函數計算各種轉換,而是寫成數組?

有1個很重要的原因:空間換時間。你如果仔細觀察了,上表中的轉換,並不能通過簡單的公式進行轉換,比如size和size class的關係,並不是正比的。這些數據是使用較複雜的公式計算出來的,公式在makesizeclass.go中,這其中存在指數運算與for循環,造成每次大小轉換的時間複雜度為O(N*2^N)。另外,對一個程序而言,內存的申請和管理操作是很多的,如果不能快速完成,就是非常的低效。把以上大小轉換寫死到數組裡,做到了把大小轉換的時間複雜度直接降到O(1)。

其他轉換表字段

第4列num of objects代表是當前size class級別的Span可以保存多少對象數量,第5列tail waste是span%obj計算的結果,因為span的大小並不一定是對象大小的整數倍。

最後一列max waste代表最大浪費的內存百分比,計算方法在printComment函數中:

func printComment(w io.Writer, classes []class) {
\tfmt.Fprintf(w, "// %-5s %-9s %-10s %-7s %-10s %-9s\\n", "class", "bytes/obj", "bytes/span", "objects", "tail waste", "max waste")
\tprevSize := 0
\tfor i, c := range classes {
\t\tif i == 0 {
\t\t\tcontinue
\t\t}
\t\tspanSize := c.npages * pageSize
\t\tobjects := spanSize / c.size
\t\ttailWaste := spanSize - c.size*(spanSize/c.size)
\t\tmaxWaste := float64((c.size-prevSize-1)*objects+tailWaste) / float64(spanSize)
\t\tprevSize = c.size
\t\tfmt.Fprintf(w, "// %5d %9d %10d %7d %10d %8.2f%%\\n", i, c.size, spanSize, objects, tailWaste, 100*maxWaste)
\t}
\tfmt.Fprintf(w, "\\n")
}

Span最浪費內存的場景是:Span內的每一個對象空間保存的對象,實際佔用內存是前一個class中對象的大小加1,這樣無法佔用低一級的Span。一個對象空間未被佔用的內存就被浪費了,所以一個Span內對象空間所浪費的內存為:所有對象空間浪費的內存之和+tail waste。

((c.size - (preSize+1)) * objects + tailWaste) / spanSize


"
Go語言中文網,致力於每日分享編碼、開源等知識,歡迎關注我,會有意想不到的收穫!

這篇文章主要介紹Go內存分配和Go內存管理,會輕微涉及內存申請和釋放,以及Go垃圾回收。

從非常宏觀的角度看,Go的內存管理就是下圖這個樣子,我們今天主要關注其中標紅的部分。

Go內存分配那些事,看這一篇就夠,深度好文


友情提醒:文章有點長,建議先收藏,後閱讀,絕對是學習內存管理的好資料。

本文基於go1.11.2,不同版本Go的內存管理可能存在差別,比如1.9與1.11的mheap定義就是差別比較大的,後續看源碼的時候,請注意你的go版本,但無論你用哪個go版本,這都是一個優秀的資料,因為內存管理的思想和框架始終未變。

Go這門語言拋棄了C/C++中的開發者管理內存的方式:主動申請與主動釋放,增加了逃逸分析和GC,將開發者從內存管理中釋放出來,讓開發者有更多的精力去關注軟件設計,而不是底層的內存問題。這是Go語言成為高生產力語言的原因之一。

我們不需要精通內存的管理,因為它確實很複雜,但掌握內存的管理,可以讓你寫出更高質量的代碼,另外,還能助你定位Bug。

這篇文章採用層層遞進的方式,依次會介紹關於存儲的基本知識,Go內存管理的“前輩”TCMalloc,然後是Go的內存管理和分配,最後是總結。這麼做的目的是,希望各位能通過全局的認識和思考,擁有更好的編碼思維和架構思維。

最後,這不是一篇源碼分析文章,因為Go源碼分析的文章已經有很多了,這些源碼文章能夠幫助你去學習具體的工程實踐和奇淫巧計了,文章的末尾會推薦一些優秀文章,如果你對內存感興趣,建議每一篇都去看一下,挑出自己喜歡的,多花時間研究下。

1. 存儲基礎知識回顧

這部分我們簡單回顧一下計算機存儲體系、虛擬內存、棧和堆,以及堆內存的管理,這部分內容對理解和掌握Go內存管理比較重要,建議忘記或不熟悉的朋友不要跳過。

存儲金字塔

Go內存分配那些事,看這一篇就夠,深度好文


這幅圖表達了計算機的存儲體系,從上至下依次是:

  • CPU寄存器
  • Cache
  • 內存
  • 硬盤等輔助存儲設備
  • 鼠標等外接設備

從上至下,訪問速度越來越慢,訪問時間越來越長。

你有沒有思考過下面2個簡單的問題,如果沒有不妨想想:

  1. 如果CPU直接訪問硬盤,CPU能充分利用嗎?
  2. 如果CPU直接訪問內存,CPU能充分利用嗎?

CPU速度很快,但硬盤等持久存儲很慢,如果CPU直接訪問磁盤,磁盤可以拉低CPU的速度,機器整體性能就會低下,為了彌補這2個硬件之間的速率差異,所以在CPU和磁盤之間增加了比磁盤快很多的內存。

Go內存分配那些事,看這一篇就夠,深度好文


然而,CPU跟內存的速率也不是相同的,從上圖可以看到,CPU的速率提高的很快(摩爾定律),然而內存速率增長的很慢,雖然CPU的速率現在增加的很慢了,但是內存的速率也沒增加多少,速率差距很大,從1980年開始CPU和內存速率差距在不斷拉大,為了彌補這2個硬件之間的速率差異,所以在CPU跟內存之間增加了比內存更快的Cache,Cache是內存數據的緩存,可以降低CPU訪問內存的時間。

不要以為有了Cache就萬事大吉了,CPU的速率還在不斷增大,Cache也在不斷改變,從最初的1級,到後來的2級,到當代的3級Cache,(有興趣看cache歷史)

Go內存分配那些事,看這一篇就夠,深度好文


三級Cache分別是L1、L2、L3,它們的速率是三個不同的層級,L1速率最快,與CPU速率最接近,是RAM速率的100倍,L2速率就降到了RAM的25倍,L3的速率更靠近RAM的速率。

看到這了,你有沒有Get到整個存儲體系的分層設計自頂向下,速率越來越低,訪問時間越來越長,從磁盤到CPU寄存器,上一層都可以看做是下一層的緩存。

看了分層設計,我們看一下內存,畢竟我們是介紹內存管理的文章。

虛擬內存

虛擬內存是當代操作系統必備的一項重要功能了,它向進程屏蔽了底層了RAM和磁盤,並向進程提供了遠超物理內存大小的內存空間。我們看一下虛擬內存的分層設計

Go內存分配那些事,看這一篇就夠,深度好文


上圖展示了某進程訪問數據,當Cache沒有命中的時候,訪問虛擬內存獲取數據的過程。

訪問內存,實際訪問的是虛擬內存,虛擬內存通過頁表查看,當前要訪問的虛擬內存地址,是否已經加載到了物理內存,如果已經在物理內存,則取物理內存數據,如果沒有對應的物理內存,則從磁盤加載數據到物理內存,並把物理內存地址和虛擬內存地址更新到頁表。

有沒有Get到:物理內存就是磁盤存儲緩存層

另外,在沒有虛擬內存的時代,物理內存對所有進程是共享的,多進程同時訪問同一個物理內存存在併發訪問問題。引入虛擬內存後,每個進程都要各自的虛擬內存,內存的併發訪問問題的粒度從多進程級別,可以降低到多線程級別

棧和堆

我們現在從虛擬內存,再進一層,看虛擬內存中的棧和堆,也就是進程對內存的管理。

Go內存分配那些事,看這一篇就夠,深度好文


上圖展示了一個進程的虛擬內存劃分,代碼中使用的內存地址都是虛擬內存地址,而不是實際的物理內存地址。棧和堆只是虛擬內存上2塊不同功能的內存區域:

  • 棧在高地址,從高地址向低地址增長。
  • 堆在低地址,從低地址向高地址增長。

棧和堆相比有這麼幾個好處

  1. 棧的內存管理簡單,分配比堆上快。
  2. 棧的內存不需要回收,而堆需要,無論是主動free,還是被動的垃圾回收,這都需要花費額外的CPU。
  3. 棧上的內存有更好的局部性,堆上內存訪問就不那麼友好了,CPU訪問的2塊數據可能在不同的頁上,CPU訪問數據的時間可能就上去了。

堆內存管理

Go內存分配那些事,看這一篇就夠,深度好文


我們再進一層,當我們說內存管理的時候,主要是指堆內存的管理,因為棧的內存管理不需要程序去操心。這小節看下堆內存管理乾的是啥,如上圖所示主要是3部分:分配內存塊,回收內存塊和組織內存塊

在一個最簡單的內存管理中,堆內存最初會是一個完整的大塊,即未分配內存,當來申請的時候,就會從未分配內存,分割出一個小內存塊(block),然後用鏈表把所有內存塊連接起來。需要一些信息描述每個內存塊的基本信息,比如大小(size)、是否使用中(used)和下一個內存塊的地址(next),內存塊實際數據存儲在data中。

Go內存分配那些事,看這一篇就夠,深度好文


一個內存塊包含了3類信息,如下圖所示,元數據、用戶數據和對齊字段,內存對齊是為了提高訪問效率。下圖申請5Byte內存的時候,就需要進行內存對齊。

Go內存分配那些事,看這一篇就夠,深度好文


釋放內存實質是把使用的內存塊從鏈表中取出來,然後標記為未使用,當分配內存塊的時候,可以從未使用內存塊中有先查找大小相近的內存塊,如果找不到,再從未分配的內存中分配內存。

上面這個簡單的設計中還沒考慮內存碎片的問題,因為隨著內存不斷的申請和釋放,內存上會存在大量的碎片,降低內存的使用率。為了解決內存碎片,可以將2個連續的未使用的內存塊合併,減少碎片。

以上就是內存管理的基本思路,關於基本的內存管理,想了解更多,可以閱讀這篇文章《Writing a Memory Allocator》,本節的3張圖片也是來自這片文章。

2. TCMalloc

TCMalloc是Thread Cache Malloc的簡稱,是Go內存管理的起源,Go的內存管理是借鑑了TCMalloc,隨著Go的迭代,Go的內存管理與TCMalloc不一致地方在不斷擴大,但其主要思想、原理和概念都是和TCMalloc一致的,如果跳過TCMalloc直接去看Go的內存管理,也許你會似懂非懂。

掌握TCMalloc的理念,無需去關注過多的源碼細節,就可以為掌握Go的內存管理打好基礎,基礎打好了,後面知識才紮實。

在Linux裡,其實有不少的內存管理庫,比如glibc的ptmalloc,FreeBSD的jemalloc,Google的tcmalloc等等,為何會出現這麼多的內存管理庫?本質都是在多線程編程下,追求更高內存管理效率:更快的分配是主要目的。

那如何更快的分配內存?

我們前面提到:

引入虛擬內存後,讓內存的併發訪問問題的粒度從多進程級別,降低到多線程級別。


這是更快分配內存的第一個層次

同一進程的所有線程共享相同的內存空間,他們申請內存時需要加鎖,如果不加鎖就存在同一塊內存被2個線程同時訪問的問題。

TCMalloc的做法是什麼呢?為每個線程預分配一塊緩存,線程申請小內存時,可以從緩存分配內存,這樣有2個好處:

  1. 為線程預分配緩存需要進行1次系統調用,後續線程申請小內存時,從緩存分配,都是在用戶態執行,沒有系統調用,縮短了內存總體的分配和釋放時間,這是快速分配內存的第二個層次
  2. 多個線程同時申請小內存時,從各自的緩存分配,訪問的是不同的地址空間,無需加鎖,把內存併發訪問的粒度進一步降低了,這是快速分配內存的第三個層次

基本原理

下面就簡單介紹下TCMalloc,細緻程度夠我們理解Go的內存管理即可。

聲明:我沒有研究過TCMalloc,以下介紹根據TCMalloc官方資料和其他博主資料總結而來,錯誤之處請朋友告知我。
Go內存分配那些事,看這一篇就夠,深度好文

結合上圖,介紹TCMalloc的幾個重要概念:

  1. Page:操作系統對內存管理以頁為單位,TCMalloc也是這樣,只不過TCMalloc裡的Page大小與操作系統裡的大小並不一定相等,而是倍數關係。《TCMalloc解密》裡稱x64下Page大小是8KB。
  2. Span:一組連續的Page被稱為Span,比如可以有2個頁大小的Span,也可以有16頁大小的Span,Span比Page高一個層級,是為了方便管理一定大小的內存區域,Span是TCMalloc中內存管理的基本單位。
  3. ThreadCache:每個線程各自的Cache,一個Cache包含多個空閒內存塊鏈表,每個鏈表連接的都是內存塊,同一個鏈表上內存塊的大小是相同的,也可以說按內存塊大小,給內存塊分了個類,這樣可以根據申請的內存大小,快速從合適的鏈表選擇空閒內存塊。由於每個線程有自己的ThreadCache,所以ThreadCache訪問是無鎖的。
  4. CentralCache:是所有線程共享的緩存,也是保存的空閒內存塊鏈表,鏈表的數量與ThreadCache中鏈表數量相同,當ThreadCache內存塊不足時,可以從CentralCache取,當ThreadCache內存塊多時,可以放回CentralCache。由於CentralCache是共享的,所以它的訪問是要加鎖的。
  5. PageHeap:PageHeap是堆內存的抽象,PageHeap存的也是若干鏈表,鏈表保存的是Span,當CentralCache沒有內存的時,會從PageHeap取,把1個Span拆成若干內存塊,添加到對應大小的鏈表中,當CentralCache內存多的時候,會放回PageHeap。如下圖,分別是1頁Page的Span鏈表,2頁Page的Span鏈表等,最後是large span set,這個是用來保存中大對象的。毫無疑問,PageHeap也是要加鎖的。


Go內存分配那些事,看這一篇就夠,深度好文

上文提到了小、中、大對象,Go內存管理中也有類似的概念,我們瞄一眼TCMalloc的定義:

  1. 小對象大小:0~256KB
  2. 中對象大小:257~1MB
  3. 大對象大小:>1MB

小對象的分配流程:ThreadCache -> CentralCache -> HeapPage,大部分時候,ThreadCache緩存都是足夠的,不需要去訪問CentralCache和HeapPage,無鎖分配加無系統調用,分配效率是非常高的。

中對象分配流程:直接在PageHeap中選擇適當的大小即可,128 Page的Span所保存的最大內存就是1MB。

大對象分配流程:從large span set選擇合適數量的頁面組成span,用來存儲數據。

通過本節的介紹,你應當對TCMalloc主要思想有一定了解了,我建議再回顧一下上面的內容。

本節圖片皆來自《TCMalloc解密》,圖片版權歸原作者所有。

精彩文章推薦

本文對於TCMalloc的介紹並不多,重要的是3個快速分配內存的層次,如果想了解更多,可閱讀下面文章。

  1. TCMalloc
  2. 必讀,通過這篇你能掌握TCMalloc的原理和性能,對掌握Go的內存管理有非常大的幫助,雖然如今Go的內存管理與TCMalloc已經相差很大,但是,這是Go內存管理的起源和“大道”,這篇文章頂看十幾篇Go內存管理的文章。
  3. TCMalloc解密
  4. 可選異常詳細,包含大量精美圖片,看完得花小時級別,理解就需要更多時間了,看完這篇不需要看其他TCMalloc的文章了。
  5. TCMalloc介紹
  6. 可選,算是TCMalloc的文檔的中文版,多數是從英文版翻譯過來的,如果你英文不好,看看。

3. Go內存管理

前面鋪墊了那麼多,終於到了本文核心的地方。前面的鋪墊不是不重要,相反它們很重要,Go語言內存管理源自前面的基礎知識和內存管理思維,如果你跳過了前面的內容,建議你回頭看一看,它可以幫助你更好的掌握Go內存管理。

前文提到Go內存管理源自TCMalloc,但它比TCMalloc還多了2件東西:逃逸分析和垃圾回收,這是2項提高生產力的絕佳武器。

這一大章節,我們先介紹Go內存管理和Go內存分配,最後涉及一點垃圾回收和內存釋放。

Go內存管理的基本概念

前面計算機基礎知識回顧,是一種自上而下,從宏觀到微觀的介紹方式,把目光引入到今天的主題。

Go內存管理的許多概念在TCMalloc中已經有了,含義是相同的,只是名字有一些變化。先給大家上一幅宏觀的圖,藉助圖一起來介紹。

Go內存分配那些事,看這一篇就夠,深度好文


Page

與TCMalloc中的Page相同,x64下1個Page的大小是8KB。上圖的最下方,1個淺藍色的長方形代表1個Page。

Span

與TCMalloc中的Span相同,Span是內存管理的基本單位,代碼中為mspan,一組連續的Page組成1個Span,所以上圖一組連續的淺藍色長方形代表的是一組Page組成的1個Span,另外,1個淡紫色長方形為1個Span。

mcache

mcache與TCMalloc中的ThreadCache類似,mcache保存的是各種大小的Span,並按Span class分類,小對象直接從mcache分配內存,它起到了緩存的作用,並且可以無鎖訪問

但mcache與ThreadCache也有不同點,TCMalloc中是每個線程1個ThreadCache,Go中是每個P擁有1個mcache,因為在Go程序中,當前最多有GOMAXPROCS個線程在用戶態運行,所以最多需要GOMAXPROCS個mcache就可以保證各線程對mcache的無鎖訪問,線程的運行又是與P綁定的,把mcache交給P剛剛好。

mcentral

mcentral與TCMalloc中的CentralCache類似,是所有線程共享的緩存,需要加鎖訪問,它按Span class對Span分類,串聯成鏈表,當mcache的某個級別Span的內存被分配光時,它會向mcentral申請1個當前級別的Span。

但mcentral與CentralCache也有不同點,CentralCache是每個級別的Span有1個鏈表,mcache是每個級別的Span有2個鏈表,這和mcache申請內存有關,稍後我們再解釋。

mheap

mheap與TCMalloc中的PageHeap類似,它是堆內存的抽象,把從OS申請出的內存頁組織成Span,並保存起來。當mcentral的Span不夠用時會向mheap申請,mheap的Span不夠用時會向OS申請,向OS的內存申請是按頁來的,然後把申請來的內存頁生成Span組織起來,同樣也是需要加鎖訪問的。

但mheap與PageHeap也有不同點:mheap把Span組織成了樹結構,而不是鏈表,並且還是2棵樹,然後把Span分配到heapArena進行管理,它包含地址映射和span是否包含指針等位圖,這樣做的主要原因是為了更高效的利用內存:分配、回收和再利用。

大小轉換

除了以上內存塊組織概念,還有幾個重要的大小概念,一定要拿出來講一下,不要忽視他們的重要性,他們是內存分配、組織和地址轉換的基礎。

Go內存分配那些事,看這一篇就夠,深度好文


  1. object size:代碼裡簡稱size,指申請內存的對象大小。
  2. size class:代碼裡簡稱class,它是size的級別,相當於把size歸類到一定大小的區間段,比如size[1,8]屬於size class 1,size(8,16]屬於size class 2。
  3. span class:指span的級別,但span class的大小與span的大小並沒有正比關係。span class主要用來和size class做對應,1個size class對應2個span class,2個span class的span大小相同,只是功能不同,1個用來存放包含指針的對象,一個用來存放不包含指針的對象,不包含指針對象的Span就無需GC掃描了。
  4. num of page:代碼裡簡稱npage,代表Page的數量,其實就是Span包含的頁數,用來分配內存。

在介紹這幾個大小之間的換算前,我們得先看下圖這個表,這個表決定了映射關係。

最上面2行是我手動加的,前3列分別是size class,object size和span size,根據這3列做size、size class和num of page之間的轉換。

仔細看一遍這個表,再向下看轉換是如何實現的。

Go內存分配那些事,看這一篇就夠,深度好文


在Go內存大小轉換那幅圖中已經標記各大小之間的轉換,分別是數組:class_to_size,size_to_class*和class_to_allocnpages,這3個數組內容,就是跟上表的映射關係匹配的。比如class_to_size,從上表看class 1對應的保存對象大小為8,所以class_to_size[1]=8,span大小為8192Byte,即8KB,為1頁,所以class_to_allocnpages[1]=1。

Go內存分配那些事,看這一篇就夠,深度好文


為何不使用函數計算各種轉換,而是寫成數組?

有1個很重要的原因:空間換時間。你如果仔細觀察了,上表中的轉換,並不能通過簡單的公式進行轉換,比如size和size class的關係,並不是正比的。這些數據是使用較複雜的公式計算出來的,公式在makesizeclass.go中,這其中存在指數運算與for循環,造成每次大小轉換的時間複雜度為O(N*2^N)。另外,對一個程序而言,內存的申請和管理操作是很多的,如果不能快速完成,就是非常的低效。把以上大小轉換寫死到數組裡,做到了把大小轉換的時間複雜度直接降到O(1)。

其他轉換表字段

第4列num of objects代表是當前size class級別的Span可以保存多少對象數量,第5列tail waste是span%obj計算的結果,因為span的大小並不一定是對象大小的整數倍。

最後一列max waste代表最大浪費的內存百分比,計算方法在printComment函數中:

func printComment(w io.Writer, classes []class) {
\tfmt.Fprintf(w, "// %-5s %-9s %-10s %-7s %-10s %-9s\\n", "class", "bytes/obj", "bytes/span", "objects", "tail waste", "max waste")
\tprevSize := 0
\tfor i, c := range classes {
\t\tif i == 0 {
\t\t\tcontinue
\t\t}
\t\tspanSize := c.npages * pageSize
\t\tobjects := spanSize / c.size
\t\ttailWaste := spanSize - c.size*(spanSize/c.size)
\t\tmaxWaste := float64((c.size-prevSize-1)*objects+tailWaste) / float64(spanSize)
\t\tprevSize = c.size
\t\tfmt.Fprintf(w, "// %5d %9d %10d %7d %10d %8.2f%%\\n", i, c.size, spanSize, objects, tailWaste, 100*maxWaste)
\t}
\tfmt.Fprintf(w, "\\n")
}

Span最浪費內存的場景是:Span內的每一個對象空間保存的對象,實際佔用內存是前一個class中對象的大小加1,這樣無法佔用低一級的Span。一個對象空間未被佔用的內存就被浪費了,所以一個Span內對象空間所浪費的內存為:所有對象空間浪費的內存之和+tail waste。

((c.size - (preSize+1)) * objects + tailWaste) / spanSize


Go內存分配那些事,看這一篇就夠,深度好文


感謝foobar的提醒max waste的計算。


Go內存分配

涉及的概念已經講完了,我們看下Go內存分配原理。

Go中的內存分類並不像TCMalloc那樣分成小、中、大對象,但是它的小對象裡又細分了一個Tiny對象,Tiny對象指大小在1Byte到16Byte之間並且不包含指針的對象。小對象和大對象只用大小劃定,無其他區分。

"
Go語言中文網,致力於每日分享編碼、開源等知識,歡迎關注我,會有意想不到的收穫!

這篇文章主要介紹Go內存分配和Go內存管理,會輕微涉及內存申請和釋放,以及Go垃圾回收。

從非常宏觀的角度看,Go的內存管理就是下圖這個樣子,我們今天主要關注其中標紅的部分。

Go內存分配那些事,看這一篇就夠,深度好文


友情提醒:文章有點長,建議先收藏,後閱讀,絕對是學習內存管理的好資料。

本文基於go1.11.2,不同版本Go的內存管理可能存在差別,比如1.9與1.11的mheap定義就是差別比較大的,後續看源碼的時候,請注意你的go版本,但無論你用哪個go版本,這都是一個優秀的資料,因為內存管理的思想和框架始終未變。

Go這門語言拋棄了C/C++中的開發者管理內存的方式:主動申請與主動釋放,增加了逃逸分析和GC,將開發者從內存管理中釋放出來,讓開發者有更多的精力去關注軟件設計,而不是底層的內存問題。這是Go語言成為高生產力語言的原因之一。

我們不需要精通內存的管理,因為它確實很複雜,但掌握內存的管理,可以讓你寫出更高質量的代碼,另外,還能助你定位Bug。

這篇文章採用層層遞進的方式,依次會介紹關於存儲的基本知識,Go內存管理的“前輩”TCMalloc,然後是Go的內存管理和分配,最後是總結。這麼做的目的是,希望各位能通過全局的認識和思考,擁有更好的編碼思維和架構思維。

最後,這不是一篇源碼分析文章,因為Go源碼分析的文章已經有很多了,這些源碼文章能夠幫助你去學習具體的工程實踐和奇淫巧計了,文章的末尾會推薦一些優秀文章,如果你對內存感興趣,建議每一篇都去看一下,挑出自己喜歡的,多花時間研究下。

1. 存儲基礎知識回顧

這部分我們簡單回顧一下計算機存儲體系、虛擬內存、棧和堆,以及堆內存的管理,這部分內容對理解和掌握Go內存管理比較重要,建議忘記或不熟悉的朋友不要跳過。

存儲金字塔

Go內存分配那些事,看這一篇就夠,深度好文


這幅圖表達了計算機的存儲體系,從上至下依次是:

  • CPU寄存器
  • Cache
  • 內存
  • 硬盤等輔助存儲設備
  • 鼠標等外接設備

從上至下,訪問速度越來越慢,訪問時間越來越長。

你有沒有思考過下面2個簡單的問題,如果沒有不妨想想:

  1. 如果CPU直接訪問硬盤,CPU能充分利用嗎?
  2. 如果CPU直接訪問內存,CPU能充分利用嗎?

CPU速度很快,但硬盤等持久存儲很慢,如果CPU直接訪問磁盤,磁盤可以拉低CPU的速度,機器整體性能就會低下,為了彌補這2個硬件之間的速率差異,所以在CPU和磁盤之間增加了比磁盤快很多的內存。

Go內存分配那些事,看這一篇就夠,深度好文


然而,CPU跟內存的速率也不是相同的,從上圖可以看到,CPU的速率提高的很快(摩爾定律),然而內存速率增長的很慢,雖然CPU的速率現在增加的很慢了,但是內存的速率也沒增加多少,速率差距很大,從1980年開始CPU和內存速率差距在不斷拉大,為了彌補這2個硬件之間的速率差異,所以在CPU跟內存之間增加了比內存更快的Cache,Cache是內存數據的緩存,可以降低CPU訪問內存的時間。

不要以為有了Cache就萬事大吉了,CPU的速率還在不斷增大,Cache也在不斷改變,從最初的1級,到後來的2級,到當代的3級Cache,(有興趣看cache歷史)

Go內存分配那些事,看這一篇就夠,深度好文


三級Cache分別是L1、L2、L3,它們的速率是三個不同的層級,L1速率最快,與CPU速率最接近,是RAM速率的100倍,L2速率就降到了RAM的25倍,L3的速率更靠近RAM的速率。

看到這了,你有沒有Get到整個存儲體系的分層設計自頂向下,速率越來越低,訪問時間越來越長,從磁盤到CPU寄存器,上一層都可以看做是下一層的緩存。

看了分層設計,我們看一下內存,畢竟我們是介紹內存管理的文章。

虛擬內存

虛擬內存是當代操作系統必備的一項重要功能了,它向進程屏蔽了底層了RAM和磁盤,並向進程提供了遠超物理內存大小的內存空間。我們看一下虛擬內存的分層設計

Go內存分配那些事,看這一篇就夠,深度好文


上圖展示了某進程訪問數據,當Cache沒有命中的時候,訪問虛擬內存獲取數據的過程。

訪問內存,實際訪問的是虛擬內存,虛擬內存通過頁表查看,當前要訪問的虛擬內存地址,是否已經加載到了物理內存,如果已經在物理內存,則取物理內存數據,如果沒有對應的物理內存,則從磁盤加載數據到物理內存,並把物理內存地址和虛擬內存地址更新到頁表。

有沒有Get到:物理內存就是磁盤存儲緩存層

另外,在沒有虛擬內存的時代,物理內存對所有進程是共享的,多進程同時訪問同一個物理內存存在併發訪問問題。引入虛擬內存後,每個進程都要各自的虛擬內存,內存的併發訪問問題的粒度從多進程級別,可以降低到多線程級別

棧和堆

我們現在從虛擬內存,再進一層,看虛擬內存中的棧和堆,也就是進程對內存的管理。

Go內存分配那些事,看這一篇就夠,深度好文


上圖展示了一個進程的虛擬內存劃分,代碼中使用的內存地址都是虛擬內存地址,而不是實際的物理內存地址。棧和堆只是虛擬內存上2塊不同功能的內存區域:

  • 棧在高地址,從高地址向低地址增長。
  • 堆在低地址,從低地址向高地址增長。

棧和堆相比有這麼幾個好處

  1. 棧的內存管理簡單,分配比堆上快。
  2. 棧的內存不需要回收,而堆需要,無論是主動free,還是被動的垃圾回收,這都需要花費額外的CPU。
  3. 棧上的內存有更好的局部性,堆上內存訪問就不那麼友好了,CPU訪問的2塊數據可能在不同的頁上,CPU訪問數據的時間可能就上去了。

堆內存管理

Go內存分配那些事,看這一篇就夠,深度好文


我們再進一層,當我們說內存管理的時候,主要是指堆內存的管理,因為棧的內存管理不需要程序去操心。這小節看下堆內存管理乾的是啥,如上圖所示主要是3部分:分配內存塊,回收內存塊和組織內存塊

在一個最簡單的內存管理中,堆內存最初會是一個完整的大塊,即未分配內存,當來申請的時候,就會從未分配內存,分割出一個小內存塊(block),然後用鏈表把所有內存塊連接起來。需要一些信息描述每個內存塊的基本信息,比如大小(size)、是否使用中(used)和下一個內存塊的地址(next),內存塊實際數據存儲在data中。

Go內存分配那些事,看這一篇就夠,深度好文


一個內存塊包含了3類信息,如下圖所示,元數據、用戶數據和對齊字段,內存對齊是為了提高訪問效率。下圖申請5Byte內存的時候,就需要進行內存對齊。

Go內存分配那些事,看這一篇就夠,深度好文


釋放內存實質是把使用的內存塊從鏈表中取出來,然後標記為未使用,當分配內存塊的時候,可以從未使用內存塊中有先查找大小相近的內存塊,如果找不到,再從未分配的內存中分配內存。

上面這個簡單的設計中還沒考慮內存碎片的問題,因為隨著內存不斷的申請和釋放,內存上會存在大量的碎片,降低內存的使用率。為了解決內存碎片,可以將2個連續的未使用的內存塊合併,減少碎片。

以上就是內存管理的基本思路,關於基本的內存管理,想了解更多,可以閱讀這篇文章《Writing a Memory Allocator》,本節的3張圖片也是來自這片文章。

2. TCMalloc

TCMalloc是Thread Cache Malloc的簡稱,是Go內存管理的起源,Go的內存管理是借鑑了TCMalloc,隨著Go的迭代,Go的內存管理與TCMalloc不一致地方在不斷擴大,但其主要思想、原理和概念都是和TCMalloc一致的,如果跳過TCMalloc直接去看Go的內存管理,也許你會似懂非懂。

掌握TCMalloc的理念,無需去關注過多的源碼細節,就可以為掌握Go的內存管理打好基礎,基礎打好了,後面知識才紮實。

在Linux裡,其實有不少的內存管理庫,比如glibc的ptmalloc,FreeBSD的jemalloc,Google的tcmalloc等等,為何會出現這麼多的內存管理庫?本質都是在多線程編程下,追求更高內存管理效率:更快的分配是主要目的。

那如何更快的分配內存?

我們前面提到:

引入虛擬內存後,讓內存的併發訪問問題的粒度從多進程級別,降低到多線程級別。


這是更快分配內存的第一個層次

同一進程的所有線程共享相同的內存空間,他們申請內存時需要加鎖,如果不加鎖就存在同一塊內存被2個線程同時訪問的問題。

TCMalloc的做法是什麼呢?為每個線程預分配一塊緩存,線程申請小內存時,可以從緩存分配內存,這樣有2個好處:

  1. 為線程預分配緩存需要進行1次系統調用,後續線程申請小內存時,從緩存分配,都是在用戶態執行,沒有系統調用,縮短了內存總體的分配和釋放時間,這是快速分配內存的第二個層次
  2. 多個線程同時申請小內存時,從各自的緩存分配,訪問的是不同的地址空間,無需加鎖,把內存併發訪問的粒度進一步降低了,這是快速分配內存的第三個層次

基本原理

下面就簡單介紹下TCMalloc,細緻程度夠我們理解Go的內存管理即可。

聲明:我沒有研究過TCMalloc,以下介紹根據TCMalloc官方資料和其他博主資料總結而來,錯誤之處請朋友告知我。
Go內存分配那些事,看這一篇就夠,深度好文

結合上圖,介紹TCMalloc的幾個重要概念:

  1. Page:操作系統對內存管理以頁為單位,TCMalloc也是這樣,只不過TCMalloc裡的Page大小與操作系統裡的大小並不一定相等,而是倍數關係。《TCMalloc解密》裡稱x64下Page大小是8KB。
  2. Span:一組連續的Page被稱為Span,比如可以有2個頁大小的Span,也可以有16頁大小的Span,Span比Page高一個層級,是為了方便管理一定大小的內存區域,Span是TCMalloc中內存管理的基本單位。
  3. ThreadCache:每個線程各自的Cache,一個Cache包含多個空閒內存塊鏈表,每個鏈表連接的都是內存塊,同一個鏈表上內存塊的大小是相同的,也可以說按內存塊大小,給內存塊分了個類,這樣可以根據申請的內存大小,快速從合適的鏈表選擇空閒內存塊。由於每個線程有自己的ThreadCache,所以ThreadCache訪問是無鎖的。
  4. CentralCache:是所有線程共享的緩存,也是保存的空閒內存塊鏈表,鏈表的數量與ThreadCache中鏈表數量相同,當ThreadCache內存塊不足時,可以從CentralCache取,當ThreadCache內存塊多時,可以放回CentralCache。由於CentralCache是共享的,所以它的訪問是要加鎖的。
  5. PageHeap:PageHeap是堆內存的抽象,PageHeap存的也是若干鏈表,鏈表保存的是Span,當CentralCache沒有內存的時,會從PageHeap取,把1個Span拆成若干內存塊,添加到對應大小的鏈表中,當CentralCache內存多的時候,會放回PageHeap。如下圖,分別是1頁Page的Span鏈表,2頁Page的Span鏈表等,最後是large span set,這個是用來保存中大對象的。毫無疑問,PageHeap也是要加鎖的。


Go內存分配那些事,看這一篇就夠,深度好文

上文提到了小、中、大對象,Go內存管理中也有類似的概念,我們瞄一眼TCMalloc的定義:

  1. 小對象大小:0~256KB
  2. 中對象大小:257~1MB
  3. 大對象大小:>1MB

小對象的分配流程:ThreadCache -> CentralCache -> HeapPage,大部分時候,ThreadCache緩存都是足夠的,不需要去訪問CentralCache和HeapPage,無鎖分配加無系統調用,分配效率是非常高的。

中對象分配流程:直接在PageHeap中選擇適當的大小即可,128 Page的Span所保存的最大內存就是1MB。

大對象分配流程:從large span set選擇合適數量的頁面組成span,用來存儲數據。

通過本節的介紹,你應當對TCMalloc主要思想有一定了解了,我建議再回顧一下上面的內容。

本節圖片皆來自《TCMalloc解密》,圖片版權歸原作者所有。

精彩文章推薦

本文對於TCMalloc的介紹並不多,重要的是3個快速分配內存的層次,如果想了解更多,可閱讀下面文章。

  1. TCMalloc
  2. 必讀,通過這篇你能掌握TCMalloc的原理和性能,對掌握Go的內存管理有非常大的幫助,雖然如今Go的內存管理與TCMalloc已經相差很大,但是,這是Go內存管理的起源和“大道”,這篇文章頂看十幾篇Go內存管理的文章。
  3. TCMalloc解密
  4. 可選異常詳細,包含大量精美圖片,看完得花小時級別,理解就需要更多時間了,看完這篇不需要看其他TCMalloc的文章了。
  5. TCMalloc介紹
  6. 可選,算是TCMalloc的文檔的中文版,多數是從英文版翻譯過來的,如果你英文不好,看看。

3. Go內存管理

前面鋪墊了那麼多,終於到了本文核心的地方。前面的鋪墊不是不重要,相反它們很重要,Go語言內存管理源自前面的基礎知識和內存管理思維,如果你跳過了前面的內容,建議你回頭看一看,它可以幫助你更好的掌握Go內存管理。

前文提到Go內存管理源自TCMalloc,但它比TCMalloc還多了2件東西:逃逸分析和垃圾回收,這是2項提高生產力的絕佳武器。

這一大章節,我們先介紹Go內存管理和Go內存分配,最後涉及一點垃圾回收和內存釋放。

Go內存管理的基本概念

前面計算機基礎知識回顧,是一種自上而下,從宏觀到微觀的介紹方式,把目光引入到今天的主題。

Go內存管理的許多概念在TCMalloc中已經有了,含義是相同的,只是名字有一些變化。先給大家上一幅宏觀的圖,藉助圖一起來介紹。

Go內存分配那些事,看這一篇就夠,深度好文


Page

與TCMalloc中的Page相同,x64下1個Page的大小是8KB。上圖的最下方,1個淺藍色的長方形代表1個Page。

Span

與TCMalloc中的Span相同,Span是內存管理的基本單位,代碼中為mspan,一組連續的Page組成1個Span,所以上圖一組連續的淺藍色長方形代表的是一組Page組成的1個Span,另外,1個淡紫色長方形為1個Span。

mcache

mcache與TCMalloc中的ThreadCache類似,mcache保存的是各種大小的Span,並按Span class分類,小對象直接從mcache分配內存,它起到了緩存的作用,並且可以無鎖訪問

但mcache與ThreadCache也有不同點,TCMalloc中是每個線程1個ThreadCache,Go中是每個P擁有1個mcache,因為在Go程序中,當前最多有GOMAXPROCS個線程在用戶態運行,所以最多需要GOMAXPROCS個mcache就可以保證各線程對mcache的無鎖訪問,線程的運行又是與P綁定的,把mcache交給P剛剛好。

mcentral

mcentral與TCMalloc中的CentralCache類似,是所有線程共享的緩存,需要加鎖訪問,它按Span class對Span分類,串聯成鏈表,當mcache的某個級別Span的內存被分配光時,它會向mcentral申請1個當前級別的Span。

但mcentral與CentralCache也有不同點,CentralCache是每個級別的Span有1個鏈表,mcache是每個級別的Span有2個鏈表,這和mcache申請內存有關,稍後我們再解釋。

mheap

mheap與TCMalloc中的PageHeap類似,它是堆內存的抽象,把從OS申請出的內存頁組織成Span,並保存起來。當mcentral的Span不夠用時會向mheap申請,mheap的Span不夠用時會向OS申請,向OS的內存申請是按頁來的,然後把申請來的內存頁生成Span組織起來,同樣也是需要加鎖訪問的。

但mheap與PageHeap也有不同點:mheap把Span組織成了樹結構,而不是鏈表,並且還是2棵樹,然後把Span分配到heapArena進行管理,它包含地址映射和span是否包含指針等位圖,這樣做的主要原因是為了更高效的利用內存:分配、回收和再利用。

大小轉換

除了以上內存塊組織概念,還有幾個重要的大小概念,一定要拿出來講一下,不要忽視他們的重要性,他們是內存分配、組織和地址轉換的基礎。

Go內存分配那些事,看這一篇就夠,深度好文


  1. object size:代碼裡簡稱size,指申請內存的對象大小。
  2. size class:代碼裡簡稱class,它是size的級別,相當於把size歸類到一定大小的區間段,比如size[1,8]屬於size class 1,size(8,16]屬於size class 2。
  3. span class:指span的級別,但span class的大小與span的大小並沒有正比關係。span class主要用來和size class做對應,1個size class對應2個span class,2個span class的span大小相同,只是功能不同,1個用來存放包含指針的對象,一個用來存放不包含指針的對象,不包含指針對象的Span就無需GC掃描了。
  4. num of page:代碼裡簡稱npage,代表Page的數量,其實就是Span包含的頁數,用來分配內存。

在介紹這幾個大小之間的換算前,我們得先看下圖這個表,這個表決定了映射關係。

最上面2行是我手動加的,前3列分別是size class,object size和span size,根據這3列做size、size class和num of page之間的轉換。

仔細看一遍這個表,再向下看轉換是如何實現的。

Go內存分配那些事,看這一篇就夠,深度好文


在Go內存大小轉換那幅圖中已經標記各大小之間的轉換,分別是數組:class_to_size,size_to_class*和class_to_allocnpages,這3個數組內容,就是跟上表的映射關係匹配的。比如class_to_size,從上表看class 1對應的保存對象大小為8,所以class_to_size[1]=8,span大小為8192Byte,即8KB,為1頁,所以class_to_allocnpages[1]=1。

Go內存分配那些事,看這一篇就夠,深度好文


為何不使用函數計算各種轉換,而是寫成數組?

有1個很重要的原因:空間換時間。你如果仔細觀察了,上表中的轉換,並不能通過簡單的公式進行轉換,比如size和size class的關係,並不是正比的。這些數據是使用較複雜的公式計算出來的,公式在makesizeclass.go中,這其中存在指數運算與for循環,造成每次大小轉換的時間複雜度為O(N*2^N)。另外,對一個程序而言,內存的申請和管理操作是很多的,如果不能快速完成,就是非常的低效。把以上大小轉換寫死到數組裡,做到了把大小轉換的時間複雜度直接降到O(1)。

其他轉換表字段

第4列num of objects代表是當前size class級別的Span可以保存多少對象數量,第5列tail waste是span%obj計算的結果,因為span的大小並不一定是對象大小的整數倍。

最後一列max waste代表最大浪費的內存百分比,計算方法在printComment函數中:

func printComment(w io.Writer, classes []class) {
\tfmt.Fprintf(w, "// %-5s %-9s %-10s %-7s %-10s %-9s\\n", "class", "bytes/obj", "bytes/span", "objects", "tail waste", "max waste")
\tprevSize := 0
\tfor i, c := range classes {
\t\tif i == 0 {
\t\t\tcontinue
\t\t}
\t\tspanSize := c.npages * pageSize
\t\tobjects := spanSize / c.size
\t\ttailWaste := spanSize - c.size*(spanSize/c.size)
\t\tmaxWaste := float64((c.size-prevSize-1)*objects+tailWaste) / float64(spanSize)
\t\tprevSize = c.size
\t\tfmt.Fprintf(w, "// %5d %9d %10d %7d %10d %8.2f%%\\n", i, c.size, spanSize, objects, tailWaste, 100*maxWaste)
\t}
\tfmt.Fprintf(w, "\\n")
}

Span最浪費內存的場景是:Span內的每一個對象空間保存的對象,實際佔用內存是前一個class中對象的大小加1,這樣無法佔用低一級的Span。一個對象空間未被佔用的內存就被浪費了,所以一個Span內對象空間所浪費的內存為:所有對象空間浪費的內存之和+tail waste。

((c.size - (preSize+1)) * objects + tailWaste) / spanSize


Go內存分配那些事,看這一篇就夠,深度好文


感謝foobar的提醒max waste的計算。


Go內存分配

涉及的概念已經講完了,我們看下Go內存分配原理。

Go中的內存分類並不像TCMalloc那樣分成小、中、大對象,但是它的小對象裡又細分了一個Tiny對象,Tiny對象指大小在1Byte到16Byte之間並且不包含指針的對象。小對象和大對象只用大小劃定,無其他區分。

Go內存分配那些事,看這一篇就夠,深度好文


小對象是在mcache中分配的,而大對象是直接從mheap分配的,從小對象的內存分配看起。

小對象分配

"
Go語言中文網,致力於每日分享編碼、開源等知識,歡迎關注我,會有意想不到的收穫!

這篇文章主要介紹Go內存分配和Go內存管理,會輕微涉及內存申請和釋放,以及Go垃圾回收。

從非常宏觀的角度看,Go的內存管理就是下圖這個樣子,我們今天主要關注其中標紅的部分。

Go內存分配那些事,看這一篇就夠,深度好文


友情提醒:文章有點長,建議先收藏,後閱讀,絕對是學習內存管理的好資料。

本文基於go1.11.2,不同版本Go的內存管理可能存在差別,比如1.9與1.11的mheap定義就是差別比較大的,後續看源碼的時候,請注意你的go版本,但無論你用哪個go版本,這都是一個優秀的資料,因為內存管理的思想和框架始終未變。

Go這門語言拋棄了C/C++中的開發者管理內存的方式:主動申請與主動釋放,增加了逃逸分析和GC,將開發者從內存管理中釋放出來,讓開發者有更多的精力去關注軟件設計,而不是底層的內存問題。這是Go語言成為高生產力語言的原因之一。

我們不需要精通內存的管理,因為它確實很複雜,但掌握內存的管理,可以讓你寫出更高質量的代碼,另外,還能助你定位Bug。

這篇文章採用層層遞進的方式,依次會介紹關於存儲的基本知識,Go內存管理的“前輩”TCMalloc,然後是Go的內存管理和分配,最後是總結。這麼做的目的是,希望各位能通過全局的認識和思考,擁有更好的編碼思維和架構思維。

最後,這不是一篇源碼分析文章,因為Go源碼分析的文章已經有很多了,這些源碼文章能夠幫助你去學習具體的工程實踐和奇淫巧計了,文章的末尾會推薦一些優秀文章,如果你對內存感興趣,建議每一篇都去看一下,挑出自己喜歡的,多花時間研究下。

1. 存儲基礎知識回顧

這部分我們簡單回顧一下計算機存儲體系、虛擬內存、棧和堆,以及堆內存的管理,這部分內容對理解和掌握Go內存管理比較重要,建議忘記或不熟悉的朋友不要跳過。

存儲金字塔

Go內存分配那些事,看這一篇就夠,深度好文


這幅圖表達了計算機的存儲體系,從上至下依次是:

  • CPU寄存器
  • Cache
  • 內存
  • 硬盤等輔助存儲設備
  • 鼠標等外接設備

從上至下,訪問速度越來越慢,訪問時間越來越長。

你有沒有思考過下面2個簡單的問題,如果沒有不妨想想:

  1. 如果CPU直接訪問硬盤,CPU能充分利用嗎?
  2. 如果CPU直接訪問內存,CPU能充分利用嗎?

CPU速度很快,但硬盤等持久存儲很慢,如果CPU直接訪問磁盤,磁盤可以拉低CPU的速度,機器整體性能就會低下,為了彌補這2個硬件之間的速率差異,所以在CPU和磁盤之間增加了比磁盤快很多的內存。

Go內存分配那些事,看這一篇就夠,深度好文


然而,CPU跟內存的速率也不是相同的,從上圖可以看到,CPU的速率提高的很快(摩爾定律),然而內存速率增長的很慢,雖然CPU的速率現在增加的很慢了,但是內存的速率也沒增加多少,速率差距很大,從1980年開始CPU和內存速率差距在不斷拉大,為了彌補這2個硬件之間的速率差異,所以在CPU跟內存之間增加了比內存更快的Cache,Cache是內存數據的緩存,可以降低CPU訪問內存的時間。

不要以為有了Cache就萬事大吉了,CPU的速率還在不斷增大,Cache也在不斷改變,從最初的1級,到後來的2級,到當代的3級Cache,(有興趣看cache歷史)

Go內存分配那些事,看這一篇就夠,深度好文


三級Cache分別是L1、L2、L3,它們的速率是三個不同的層級,L1速率最快,與CPU速率最接近,是RAM速率的100倍,L2速率就降到了RAM的25倍,L3的速率更靠近RAM的速率。

看到這了,你有沒有Get到整個存儲體系的分層設計自頂向下,速率越來越低,訪問時間越來越長,從磁盤到CPU寄存器,上一層都可以看做是下一層的緩存。

看了分層設計,我們看一下內存,畢竟我們是介紹內存管理的文章。

虛擬內存

虛擬內存是當代操作系統必備的一項重要功能了,它向進程屏蔽了底層了RAM和磁盤,並向進程提供了遠超物理內存大小的內存空間。我們看一下虛擬內存的分層設計

Go內存分配那些事,看這一篇就夠,深度好文


上圖展示了某進程訪問數據,當Cache沒有命中的時候,訪問虛擬內存獲取數據的過程。

訪問內存,實際訪問的是虛擬內存,虛擬內存通過頁表查看,當前要訪問的虛擬內存地址,是否已經加載到了物理內存,如果已經在物理內存,則取物理內存數據,如果沒有對應的物理內存,則從磁盤加載數據到物理內存,並把物理內存地址和虛擬內存地址更新到頁表。

有沒有Get到:物理內存就是磁盤存儲緩存層

另外,在沒有虛擬內存的時代,物理內存對所有進程是共享的,多進程同時訪問同一個物理內存存在併發訪問問題。引入虛擬內存後,每個進程都要各自的虛擬內存,內存的併發訪問問題的粒度從多進程級別,可以降低到多線程級別

棧和堆

我們現在從虛擬內存,再進一層,看虛擬內存中的棧和堆,也就是進程對內存的管理。

Go內存分配那些事,看這一篇就夠,深度好文


上圖展示了一個進程的虛擬內存劃分,代碼中使用的內存地址都是虛擬內存地址,而不是實際的物理內存地址。棧和堆只是虛擬內存上2塊不同功能的內存區域:

  • 棧在高地址,從高地址向低地址增長。
  • 堆在低地址,從低地址向高地址增長。

棧和堆相比有這麼幾個好處

  1. 棧的內存管理簡單,分配比堆上快。
  2. 棧的內存不需要回收,而堆需要,無論是主動free,還是被動的垃圾回收,這都需要花費額外的CPU。
  3. 棧上的內存有更好的局部性,堆上內存訪問就不那麼友好了,CPU訪問的2塊數據可能在不同的頁上,CPU訪問數據的時間可能就上去了。

堆內存管理

Go內存分配那些事,看這一篇就夠,深度好文


我們再進一層,當我們說內存管理的時候,主要是指堆內存的管理,因為棧的內存管理不需要程序去操心。這小節看下堆內存管理乾的是啥,如上圖所示主要是3部分:分配內存塊,回收內存塊和組織內存塊

在一個最簡單的內存管理中,堆內存最初會是一個完整的大塊,即未分配內存,當來申請的時候,就會從未分配內存,分割出一個小內存塊(block),然後用鏈表把所有內存塊連接起來。需要一些信息描述每個內存塊的基本信息,比如大小(size)、是否使用中(used)和下一個內存塊的地址(next),內存塊實際數據存儲在data中。

Go內存分配那些事,看這一篇就夠,深度好文


一個內存塊包含了3類信息,如下圖所示,元數據、用戶數據和對齊字段,內存對齊是為了提高訪問效率。下圖申請5Byte內存的時候,就需要進行內存對齊。

Go內存分配那些事,看這一篇就夠,深度好文


釋放內存實質是把使用的內存塊從鏈表中取出來,然後標記為未使用,當分配內存塊的時候,可以從未使用內存塊中有先查找大小相近的內存塊,如果找不到,再從未分配的內存中分配內存。

上面這個簡單的設計中還沒考慮內存碎片的問題,因為隨著內存不斷的申請和釋放,內存上會存在大量的碎片,降低內存的使用率。為了解決內存碎片,可以將2個連續的未使用的內存塊合併,減少碎片。

以上就是內存管理的基本思路,關於基本的內存管理,想了解更多,可以閱讀這篇文章《Writing a Memory Allocator》,本節的3張圖片也是來自這片文章。

2. TCMalloc

TCMalloc是Thread Cache Malloc的簡稱,是Go內存管理的起源,Go的內存管理是借鑑了TCMalloc,隨著Go的迭代,Go的內存管理與TCMalloc不一致地方在不斷擴大,但其主要思想、原理和概念都是和TCMalloc一致的,如果跳過TCMalloc直接去看Go的內存管理,也許你會似懂非懂。

掌握TCMalloc的理念,無需去關注過多的源碼細節,就可以為掌握Go的內存管理打好基礎,基礎打好了,後面知識才紮實。

在Linux裡,其實有不少的內存管理庫,比如glibc的ptmalloc,FreeBSD的jemalloc,Google的tcmalloc等等,為何會出現這麼多的內存管理庫?本質都是在多線程編程下,追求更高內存管理效率:更快的分配是主要目的。

那如何更快的分配內存?

我們前面提到:

引入虛擬內存後,讓內存的併發訪問問題的粒度從多進程級別,降低到多線程級別。


這是更快分配內存的第一個層次

同一進程的所有線程共享相同的內存空間,他們申請內存時需要加鎖,如果不加鎖就存在同一塊內存被2個線程同時訪問的問題。

TCMalloc的做法是什麼呢?為每個線程預分配一塊緩存,線程申請小內存時,可以從緩存分配內存,這樣有2個好處:

  1. 為線程預分配緩存需要進行1次系統調用,後續線程申請小內存時,從緩存分配,都是在用戶態執行,沒有系統調用,縮短了內存總體的分配和釋放時間,這是快速分配內存的第二個層次
  2. 多個線程同時申請小內存時,從各自的緩存分配,訪問的是不同的地址空間,無需加鎖,把內存併發訪問的粒度進一步降低了,這是快速分配內存的第三個層次

基本原理

下面就簡單介紹下TCMalloc,細緻程度夠我們理解Go的內存管理即可。

聲明:我沒有研究過TCMalloc,以下介紹根據TCMalloc官方資料和其他博主資料總結而來,錯誤之處請朋友告知我。
Go內存分配那些事,看這一篇就夠,深度好文

結合上圖,介紹TCMalloc的幾個重要概念:

  1. Page:操作系統對內存管理以頁為單位,TCMalloc也是這樣,只不過TCMalloc裡的Page大小與操作系統裡的大小並不一定相等,而是倍數關係。《TCMalloc解密》裡稱x64下Page大小是8KB。
  2. Span:一組連續的Page被稱為Span,比如可以有2個頁大小的Span,也可以有16頁大小的Span,Span比Page高一個層級,是為了方便管理一定大小的內存區域,Span是TCMalloc中內存管理的基本單位。
  3. ThreadCache:每個線程各自的Cache,一個Cache包含多個空閒內存塊鏈表,每個鏈表連接的都是內存塊,同一個鏈表上內存塊的大小是相同的,也可以說按內存塊大小,給內存塊分了個類,這樣可以根據申請的內存大小,快速從合適的鏈表選擇空閒內存塊。由於每個線程有自己的ThreadCache,所以ThreadCache訪問是無鎖的。
  4. CentralCache:是所有線程共享的緩存,也是保存的空閒內存塊鏈表,鏈表的數量與ThreadCache中鏈表數量相同,當ThreadCache內存塊不足時,可以從CentralCache取,當ThreadCache內存塊多時,可以放回CentralCache。由於CentralCache是共享的,所以它的訪問是要加鎖的。
  5. PageHeap:PageHeap是堆內存的抽象,PageHeap存的也是若干鏈表,鏈表保存的是Span,當CentralCache沒有內存的時,會從PageHeap取,把1個Span拆成若干內存塊,添加到對應大小的鏈表中,當CentralCache內存多的時候,會放回PageHeap。如下圖,分別是1頁Page的Span鏈表,2頁Page的Span鏈表等,最後是large span set,這個是用來保存中大對象的。毫無疑問,PageHeap也是要加鎖的。


Go內存分配那些事,看這一篇就夠,深度好文

上文提到了小、中、大對象,Go內存管理中也有類似的概念,我們瞄一眼TCMalloc的定義:

  1. 小對象大小:0~256KB
  2. 中對象大小:257~1MB
  3. 大對象大小:>1MB

小對象的分配流程:ThreadCache -> CentralCache -> HeapPage,大部分時候,ThreadCache緩存都是足夠的,不需要去訪問CentralCache和HeapPage,無鎖分配加無系統調用,分配效率是非常高的。

中對象分配流程:直接在PageHeap中選擇適當的大小即可,128 Page的Span所保存的最大內存就是1MB。

大對象分配流程:從large span set選擇合適數量的頁面組成span,用來存儲數據。

通過本節的介紹,你應當對TCMalloc主要思想有一定了解了,我建議再回顧一下上面的內容。

本節圖片皆來自《TCMalloc解密》,圖片版權歸原作者所有。

精彩文章推薦

本文對於TCMalloc的介紹並不多,重要的是3個快速分配內存的層次,如果想了解更多,可閱讀下面文章。

  1. TCMalloc
  2. 必讀,通過這篇你能掌握TCMalloc的原理和性能,對掌握Go的內存管理有非常大的幫助,雖然如今Go的內存管理與TCMalloc已經相差很大,但是,這是Go內存管理的起源和“大道”,這篇文章頂看十幾篇Go內存管理的文章。
  3. TCMalloc解密
  4. 可選異常詳細,包含大量精美圖片,看完得花小時級別,理解就需要更多時間了,看完這篇不需要看其他TCMalloc的文章了。
  5. TCMalloc介紹
  6. 可選,算是TCMalloc的文檔的中文版,多數是從英文版翻譯過來的,如果你英文不好,看看。

3. Go內存管理

前面鋪墊了那麼多,終於到了本文核心的地方。前面的鋪墊不是不重要,相反它們很重要,Go語言內存管理源自前面的基礎知識和內存管理思維,如果你跳過了前面的內容,建議你回頭看一看,它可以幫助你更好的掌握Go內存管理。

前文提到Go內存管理源自TCMalloc,但它比TCMalloc還多了2件東西:逃逸分析和垃圾回收,這是2項提高生產力的絕佳武器。

這一大章節,我們先介紹Go內存管理和Go內存分配,最後涉及一點垃圾回收和內存釋放。

Go內存管理的基本概念

前面計算機基礎知識回顧,是一種自上而下,從宏觀到微觀的介紹方式,把目光引入到今天的主題。

Go內存管理的許多概念在TCMalloc中已經有了,含義是相同的,只是名字有一些變化。先給大家上一幅宏觀的圖,藉助圖一起來介紹。

Go內存分配那些事,看這一篇就夠,深度好文


Page

與TCMalloc中的Page相同,x64下1個Page的大小是8KB。上圖的最下方,1個淺藍色的長方形代表1個Page。

Span

與TCMalloc中的Span相同,Span是內存管理的基本單位,代碼中為mspan,一組連續的Page組成1個Span,所以上圖一組連續的淺藍色長方形代表的是一組Page組成的1個Span,另外,1個淡紫色長方形為1個Span。

mcache

mcache與TCMalloc中的ThreadCache類似,mcache保存的是各種大小的Span,並按Span class分類,小對象直接從mcache分配內存,它起到了緩存的作用,並且可以無鎖訪問

但mcache與ThreadCache也有不同點,TCMalloc中是每個線程1個ThreadCache,Go中是每個P擁有1個mcache,因為在Go程序中,當前最多有GOMAXPROCS個線程在用戶態運行,所以最多需要GOMAXPROCS個mcache就可以保證各線程對mcache的無鎖訪問,線程的運行又是與P綁定的,把mcache交給P剛剛好。

mcentral

mcentral與TCMalloc中的CentralCache類似,是所有線程共享的緩存,需要加鎖訪問,它按Span class對Span分類,串聯成鏈表,當mcache的某個級別Span的內存被分配光時,它會向mcentral申請1個當前級別的Span。

但mcentral與CentralCache也有不同點,CentralCache是每個級別的Span有1個鏈表,mcache是每個級別的Span有2個鏈表,這和mcache申請內存有關,稍後我們再解釋。

mheap

mheap與TCMalloc中的PageHeap類似,它是堆內存的抽象,把從OS申請出的內存頁組織成Span,並保存起來。當mcentral的Span不夠用時會向mheap申請,mheap的Span不夠用時會向OS申請,向OS的內存申請是按頁來的,然後把申請來的內存頁生成Span組織起來,同樣也是需要加鎖訪問的。

但mheap與PageHeap也有不同點:mheap把Span組織成了樹結構,而不是鏈表,並且還是2棵樹,然後把Span分配到heapArena進行管理,它包含地址映射和span是否包含指針等位圖,這樣做的主要原因是為了更高效的利用內存:分配、回收和再利用。

大小轉換

除了以上內存塊組織概念,還有幾個重要的大小概念,一定要拿出來講一下,不要忽視他們的重要性,他們是內存分配、組織和地址轉換的基礎。

Go內存分配那些事,看這一篇就夠,深度好文


  1. object size:代碼裡簡稱size,指申請內存的對象大小。
  2. size class:代碼裡簡稱class,它是size的級別,相當於把size歸類到一定大小的區間段,比如size[1,8]屬於size class 1,size(8,16]屬於size class 2。
  3. span class:指span的級別,但span class的大小與span的大小並沒有正比關係。span class主要用來和size class做對應,1個size class對應2個span class,2個span class的span大小相同,只是功能不同,1個用來存放包含指針的對象,一個用來存放不包含指針的對象,不包含指針對象的Span就無需GC掃描了。
  4. num of page:代碼裡簡稱npage,代表Page的數量,其實就是Span包含的頁數,用來分配內存。

在介紹這幾個大小之間的換算前,我們得先看下圖這個表,這個表決定了映射關係。

最上面2行是我手動加的,前3列分別是size class,object size和span size,根據這3列做size、size class和num of page之間的轉換。

仔細看一遍這個表,再向下看轉換是如何實現的。

Go內存分配那些事,看這一篇就夠,深度好文


在Go內存大小轉換那幅圖中已經標記各大小之間的轉換,分別是數組:class_to_size,size_to_class*和class_to_allocnpages,這3個數組內容,就是跟上表的映射關係匹配的。比如class_to_size,從上表看class 1對應的保存對象大小為8,所以class_to_size[1]=8,span大小為8192Byte,即8KB,為1頁,所以class_to_allocnpages[1]=1。

Go內存分配那些事,看這一篇就夠,深度好文


為何不使用函數計算各種轉換,而是寫成數組?

有1個很重要的原因:空間換時間。你如果仔細觀察了,上表中的轉換,並不能通過簡單的公式進行轉換,比如size和size class的關係,並不是正比的。這些數據是使用較複雜的公式計算出來的,公式在makesizeclass.go中,這其中存在指數運算與for循環,造成每次大小轉換的時間複雜度為O(N*2^N)。另外,對一個程序而言,內存的申請和管理操作是很多的,如果不能快速完成,就是非常的低效。把以上大小轉換寫死到數組裡,做到了把大小轉換的時間複雜度直接降到O(1)。

其他轉換表字段

第4列num of objects代表是當前size class級別的Span可以保存多少對象數量,第5列tail waste是span%obj計算的結果,因為span的大小並不一定是對象大小的整數倍。

最後一列max waste代表最大浪費的內存百分比,計算方法在printComment函數中:

func printComment(w io.Writer, classes []class) {
\tfmt.Fprintf(w, "// %-5s %-9s %-10s %-7s %-10s %-9s\\n", "class", "bytes/obj", "bytes/span", "objects", "tail waste", "max waste")
\tprevSize := 0
\tfor i, c := range classes {
\t\tif i == 0 {
\t\t\tcontinue
\t\t}
\t\tspanSize := c.npages * pageSize
\t\tobjects := spanSize / c.size
\t\ttailWaste := spanSize - c.size*(spanSize/c.size)
\t\tmaxWaste := float64((c.size-prevSize-1)*objects+tailWaste) / float64(spanSize)
\t\tprevSize = c.size
\t\tfmt.Fprintf(w, "// %5d %9d %10d %7d %10d %8.2f%%\\n", i, c.size, spanSize, objects, tailWaste, 100*maxWaste)
\t}
\tfmt.Fprintf(w, "\\n")
}

Span最浪費內存的場景是:Span內的每一個對象空間保存的對象,實際佔用內存是前一個class中對象的大小加1,這樣無法佔用低一級的Span。一個對象空間未被佔用的內存就被浪費了,所以一個Span內對象空間所浪費的內存為:所有對象空間浪費的內存之和+tail waste。

((c.size - (preSize+1)) * objects + tailWaste) / spanSize


Go內存分配那些事,看這一篇就夠,深度好文


感謝foobar的提醒max waste的計算。


Go內存分配

涉及的概念已經講完了,我們看下Go內存分配原理。

Go中的內存分類並不像TCMalloc那樣分成小、中、大對象,但是它的小對象裡又細分了一個Tiny對象,Tiny對象指大小在1Byte到16Byte之間並且不包含指針的對象。小對象和大對象只用大小劃定,無其他區分。

Go內存分配那些事,看這一篇就夠,深度好文


小對象是在mcache中分配的,而大對象是直接從mheap分配的,從小對象的內存分配看起。

小對象分配

Go內存分配那些事,看這一篇就夠,深度好文


大小轉換這一小節,我們介紹了轉換表,size class從1到66共66個,代碼中_NumSizeClasses=67代表了實際使用的size class數量,即67個,從0到67,size class 0實際並未使用到。

上文提到1個size class對應2個span class:

numSpanClasses = _NumSizeClasses * 2

numSpanClasses為span class的數量為134個,所以span class的下標是從0到133,所以上圖中mcache標註了的span class是,span class 0到span class 133。每1個span class都指向1個span,也就是mcache最多有134個span。

為對象尋找span

尋找span的流程如下:

  1. 計算對象所需內存大小size
  2. 根據size到size class映射,計算出所需的size class
  3. 根據size class和對象是否包含指針計算出span class
  4. 獲取該span class指向的span。

以分配一個不包含指針的,大小為24Byte的對象為例。

根據映射表:

// class bytes/obj bytes/span objects tail waste max waste
// 1 8 8192 1024 0 87.50%
// 2 16 8192 512 0 43.75%
// 3 32 8192 256 0 46.88%
// 4 48 8192 170 32 31.52%

size class 3,它的對象大小範圍是(16,32]Byte,24Byte剛好在此區間,所以此對象的size class為3。

Size class到span class的計算如下:

// noscan為true代表對象不包含指針
func makeSpanClass(sizeclass uint8, noscan bool) spanClass {
\treturn spanClass(sizeclass<<1) | spanClass(bool2int(noscan))
}

所以,對應的span class為:

span class = 3 << 1 | 1 = 7

所以該對象需要的是span class 7指向的span。

從span分配對象空間

Span可以按對象大小切成很多份,這些都可以從映射表上計算出來,以size class 3對應的span為例,span大小是8KB,每個對象實際所佔空間為32Byte,這個span就被分成了256塊,可以根據span的起始地址計算出每個對象塊的內存地址。

"
Go語言中文網,致力於每日分享編碼、開源等知識,歡迎關注我,會有意想不到的收穫!

這篇文章主要介紹Go內存分配和Go內存管理,會輕微涉及內存申請和釋放,以及Go垃圾回收。

從非常宏觀的角度看,Go的內存管理就是下圖這個樣子,我們今天主要關注其中標紅的部分。

Go內存分配那些事,看這一篇就夠,深度好文


友情提醒:文章有點長,建議先收藏,後閱讀,絕對是學習內存管理的好資料。

本文基於go1.11.2,不同版本Go的內存管理可能存在差別,比如1.9與1.11的mheap定義就是差別比較大的,後續看源碼的時候,請注意你的go版本,但無論你用哪個go版本,這都是一個優秀的資料,因為內存管理的思想和框架始終未變。

Go這門語言拋棄了C/C++中的開發者管理內存的方式:主動申請與主動釋放,增加了逃逸分析和GC,將開發者從內存管理中釋放出來,讓開發者有更多的精力去關注軟件設計,而不是底層的內存問題。這是Go語言成為高生產力語言的原因之一。

我們不需要精通內存的管理,因為它確實很複雜,但掌握內存的管理,可以讓你寫出更高質量的代碼,另外,還能助你定位Bug。

這篇文章採用層層遞進的方式,依次會介紹關於存儲的基本知識,Go內存管理的“前輩”TCMalloc,然後是Go的內存管理和分配,最後是總結。這麼做的目的是,希望各位能通過全局的認識和思考,擁有更好的編碼思維和架構思維。

最後,這不是一篇源碼分析文章,因為Go源碼分析的文章已經有很多了,這些源碼文章能夠幫助你去學習具體的工程實踐和奇淫巧計了,文章的末尾會推薦一些優秀文章,如果你對內存感興趣,建議每一篇都去看一下,挑出自己喜歡的,多花時間研究下。

1. 存儲基礎知識回顧

這部分我們簡單回顧一下計算機存儲體系、虛擬內存、棧和堆,以及堆內存的管理,這部分內容對理解和掌握Go內存管理比較重要,建議忘記或不熟悉的朋友不要跳過。

存儲金字塔

Go內存分配那些事,看這一篇就夠,深度好文


這幅圖表達了計算機的存儲體系,從上至下依次是:

  • CPU寄存器
  • Cache
  • 內存
  • 硬盤等輔助存儲設備
  • 鼠標等外接設備

從上至下,訪問速度越來越慢,訪問時間越來越長。

你有沒有思考過下面2個簡單的問題,如果沒有不妨想想:

  1. 如果CPU直接訪問硬盤,CPU能充分利用嗎?
  2. 如果CPU直接訪問內存,CPU能充分利用嗎?

CPU速度很快,但硬盤等持久存儲很慢,如果CPU直接訪問磁盤,磁盤可以拉低CPU的速度,機器整體性能就會低下,為了彌補這2個硬件之間的速率差異,所以在CPU和磁盤之間增加了比磁盤快很多的內存。

Go內存分配那些事,看這一篇就夠,深度好文


然而,CPU跟內存的速率也不是相同的,從上圖可以看到,CPU的速率提高的很快(摩爾定律),然而內存速率增長的很慢,雖然CPU的速率現在增加的很慢了,但是內存的速率也沒增加多少,速率差距很大,從1980年開始CPU和內存速率差距在不斷拉大,為了彌補這2個硬件之間的速率差異,所以在CPU跟內存之間增加了比內存更快的Cache,Cache是內存數據的緩存,可以降低CPU訪問內存的時間。

不要以為有了Cache就萬事大吉了,CPU的速率還在不斷增大,Cache也在不斷改變,從最初的1級,到後來的2級,到當代的3級Cache,(有興趣看cache歷史)

Go內存分配那些事,看這一篇就夠,深度好文


三級Cache分別是L1、L2、L3,它們的速率是三個不同的層級,L1速率最快,與CPU速率最接近,是RAM速率的100倍,L2速率就降到了RAM的25倍,L3的速率更靠近RAM的速率。

看到這了,你有沒有Get到整個存儲體系的分層設計自頂向下,速率越來越低,訪問時間越來越長,從磁盤到CPU寄存器,上一層都可以看做是下一層的緩存。

看了分層設計,我們看一下內存,畢竟我們是介紹內存管理的文章。

虛擬內存

虛擬內存是當代操作系統必備的一項重要功能了,它向進程屏蔽了底層了RAM和磁盤,並向進程提供了遠超物理內存大小的內存空間。我們看一下虛擬內存的分層設計

Go內存分配那些事,看這一篇就夠,深度好文


上圖展示了某進程訪問數據,當Cache沒有命中的時候,訪問虛擬內存獲取數據的過程。

訪問內存,實際訪問的是虛擬內存,虛擬內存通過頁表查看,當前要訪問的虛擬內存地址,是否已經加載到了物理內存,如果已經在物理內存,則取物理內存數據,如果沒有對應的物理內存,則從磁盤加載數據到物理內存,並把物理內存地址和虛擬內存地址更新到頁表。

有沒有Get到:物理內存就是磁盤存儲緩存層

另外,在沒有虛擬內存的時代,物理內存對所有進程是共享的,多進程同時訪問同一個物理內存存在併發訪問問題。引入虛擬內存後,每個進程都要各自的虛擬內存,內存的併發訪問問題的粒度從多進程級別,可以降低到多線程級別

棧和堆

我們現在從虛擬內存,再進一層,看虛擬內存中的棧和堆,也就是進程對內存的管理。

Go內存分配那些事,看這一篇就夠,深度好文


上圖展示了一個進程的虛擬內存劃分,代碼中使用的內存地址都是虛擬內存地址,而不是實際的物理內存地址。棧和堆只是虛擬內存上2塊不同功能的內存區域:

  • 棧在高地址,從高地址向低地址增長。
  • 堆在低地址,從低地址向高地址增長。

棧和堆相比有這麼幾個好處

  1. 棧的內存管理簡單,分配比堆上快。
  2. 棧的內存不需要回收,而堆需要,無論是主動free,還是被動的垃圾回收,這都需要花費額外的CPU。
  3. 棧上的內存有更好的局部性,堆上內存訪問就不那麼友好了,CPU訪問的2塊數據可能在不同的頁上,CPU訪問數據的時間可能就上去了。

堆內存管理

Go內存分配那些事,看這一篇就夠,深度好文


我們再進一層,當我們說內存管理的時候,主要是指堆內存的管理,因為棧的內存管理不需要程序去操心。這小節看下堆內存管理乾的是啥,如上圖所示主要是3部分:分配內存塊,回收內存塊和組織內存塊

在一個最簡單的內存管理中,堆內存最初會是一個完整的大塊,即未分配內存,當來申請的時候,就會從未分配內存,分割出一個小內存塊(block),然後用鏈表把所有內存塊連接起來。需要一些信息描述每個內存塊的基本信息,比如大小(size)、是否使用中(used)和下一個內存塊的地址(next),內存塊實際數據存儲在data中。

Go內存分配那些事,看這一篇就夠,深度好文


一個內存塊包含了3類信息,如下圖所示,元數據、用戶數據和對齊字段,內存對齊是為了提高訪問效率。下圖申請5Byte內存的時候,就需要進行內存對齊。

Go內存分配那些事,看這一篇就夠,深度好文


釋放內存實質是把使用的內存塊從鏈表中取出來,然後標記為未使用,當分配內存塊的時候,可以從未使用內存塊中有先查找大小相近的內存塊,如果找不到,再從未分配的內存中分配內存。

上面這個簡單的設計中還沒考慮內存碎片的問題,因為隨著內存不斷的申請和釋放,內存上會存在大量的碎片,降低內存的使用率。為了解決內存碎片,可以將2個連續的未使用的內存塊合併,減少碎片。

以上就是內存管理的基本思路,關於基本的內存管理,想了解更多,可以閱讀這篇文章《Writing a Memory Allocator》,本節的3張圖片也是來自這片文章。

2. TCMalloc

TCMalloc是Thread Cache Malloc的簡稱,是Go內存管理的起源,Go的內存管理是借鑑了TCMalloc,隨著Go的迭代,Go的內存管理與TCMalloc不一致地方在不斷擴大,但其主要思想、原理和概念都是和TCMalloc一致的,如果跳過TCMalloc直接去看Go的內存管理,也許你會似懂非懂。

掌握TCMalloc的理念,無需去關注過多的源碼細節,就可以為掌握Go的內存管理打好基礎,基礎打好了,後面知識才紮實。

在Linux裡,其實有不少的內存管理庫,比如glibc的ptmalloc,FreeBSD的jemalloc,Google的tcmalloc等等,為何會出現這麼多的內存管理庫?本質都是在多線程編程下,追求更高內存管理效率:更快的分配是主要目的。

那如何更快的分配內存?

我們前面提到:

引入虛擬內存後,讓內存的併發訪問問題的粒度從多進程級別,降低到多線程級別。


這是更快分配內存的第一個層次

同一進程的所有線程共享相同的內存空間,他們申請內存時需要加鎖,如果不加鎖就存在同一塊內存被2個線程同時訪問的問題。

TCMalloc的做法是什麼呢?為每個線程預分配一塊緩存,線程申請小內存時,可以從緩存分配內存,這樣有2個好處:

  1. 為線程預分配緩存需要進行1次系統調用,後續線程申請小內存時,從緩存分配,都是在用戶態執行,沒有系統調用,縮短了內存總體的分配和釋放時間,這是快速分配內存的第二個層次
  2. 多個線程同時申請小內存時,從各自的緩存分配,訪問的是不同的地址空間,無需加鎖,把內存併發訪問的粒度進一步降低了,這是快速分配內存的第三個層次

基本原理

下面就簡單介紹下TCMalloc,細緻程度夠我們理解Go的內存管理即可。

聲明:我沒有研究過TCMalloc,以下介紹根據TCMalloc官方資料和其他博主資料總結而來,錯誤之處請朋友告知我。
Go內存分配那些事,看這一篇就夠,深度好文

結合上圖,介紹TCMalloc的幾個重要概念:

  1. Page:操作系統對內存管理以頁為單位,TCMalloc也是這樣,只不過TCMalloc裡的Page大小與操作系統裡的大小並不一定相等,而是倍數關係。《TCMalloc解密》裡稱x64下Page大小是8KB。
  2. Span:一組連續的Page被稱為Span,比如可以有2個頁大小的Span,也可以有16頁大小的Span,Span比Page高一個層級,是為了方便管理一定大小的內存區域,Span是TCMalloc中內存管理的基本單位。
  3. ThreadCache:每個線程各自的Cache,一個Cache包含多個空閒內存塊鏈表,每個鏈表連接的都是內存塊,同一個鏈表上內存塊的大小是相同的,也可以說按內存塊大小,給內存塊分了個類,這樣可以根據申請的內存大小,快速從合適的鏈表選擇空閒內存塊。由於每個線程有自己的ThreadCache,所以ThreadCache訪問是無鎖的。
  4. CentralCache:是所有線程共享的緩存,也是保存的空閒內存塊鏈表,鏈表的數量與ThreadCache中鏈表數量相同,當ThreadCache內存塊不足時,可以從CentralCache取,當ThreadCache內存塊多時,可以放回CentralCache。由於CentralCache是共享的,所以它的訪問是要加鎖的。
  5. PageHeap:PageHeap是堆內存的抽象,PageHeap存的也是若干鏈表,鏈表保存的是Span,當CentralCache沒有內存的時,會從PageHeap取,把1個Span拆成若干內存塊,添加到對應大小的鏈表中,當CentralCache內存多的時候,會放回PageHeap。如下圖,分別是1頁Page的Span鏈表,2頁Page的Span鏈表等,最後是large span set,這個是用來保存中大對象的。毫無疑問,PageHeap也是要加鎖的。


Go內存分配那些事,看這一篇就夠,深度好文

上文提到了小、中、大對象,Go內存管理中也有類似的概念,我們瞄一眼TCMalloc的定義:

  1. 小對象大小:0~256KB
  2. 中對象大小:257~1MB
  3. 大對象大小:>1MB

小對象的分配流程:ThreadCache -> CentralCache -> HeapPage,大部分時候,ThreadCache緩存都是足夠的,不需要去訪問CentralCache和HeapPage,無鎖分配加無系統調用,分配效率是非常高的。

中對象分配流程:直接在PageHeap中選擇適當的大小即可,128 Page的Span所保存的最大內存就是1MB。

大對象分配流程:從large span set選擇合適數量的頁面組成span,用來存儲數據。

通過本節的介紹,你應當對TCMalloc主要思想有一定了解了,我建議再回顧一下上面的內容。

本節圖片皆來自《TCMalloc解密》,圖片版權歸原作者所有。

精彩文章推薦

本文對於TCMalloc的介紹並不多,重要的是3個快速分配內存的層次,如果想了解更多,可閱讀下面文章。

  1. TCMalloc
  2. 必讀,通過這篇你能掌握TCMalloc的原理和性能,對掌握Go的內存管理有非常大的幫助,雖然如今Go的內存管理與TCMalloc已經相差很大,但是,這是Go內存管理的起源和“大道”,這篇文章頂看十幾篇Go內存管理的文章。
  3. TCMalloc解密
  4. 可選異常詳細,包含大量精美圖片,看完得花小時級別,理解就需要更多時間了,看完這篇不需要看其他TCMalloc的文章了。
  5. TCMalloc介紹
  6. 可選,算是TCMalloc的文檔的中文版,多數是從英文版翻譯過來的,如果你英文不好,看看。

3. Go內存管理

前面鋪墊了那麼多,終於到了本文核心的地方。前面的鋪墊不是不重要,相反它們很重要,Go語言內存管理源自前面的基礎知識和內存管理思維,如果你跳過了前面的內容,建議你回頭看一看,它可以幫助你更好的掌握Go內存管理。

前文提到Go內存管理源自TCMalloc,但它比TCMalloc還多了2件東西:逃逸分析和垃圾回收,這是2項提高生產力的絕佳武器。

這一大章節,我們先介紹Go內存管理和Go內存分配,最後涉及一點垃圾回收和內存釋放。

Go內存管理的基本概念

前面計算機基礎知識回顧,是一種自上而下,從宏觀到微觀的介紹方式,把目光引入到今天的主題。

Go內存管理的許多概念在TCMalloc中已經有了,含義是相同的,只是名字有一些變化。先給大家上一幅宏觀的圖,藉助圖一起來介紹。

Go內存分配那些事,看這一篇就夠,深度好文


Page

與TCMalloc中的Page相同,x64下1個Page的大小是8KB。上圖的最下方,1個淺藍色的長方形代表1個Page。

Span

與TCMalloc中的Span相同,Span是內存管理的基本單位,代碼中為mspan,一組連續的Page組成1個Span,所以上圖一組連續的淺藍色長方形代表的是一組Page組成的1個Span,另外,1個淡紫色長方形為1個Span。

mcache

mcache與TCMalloc中的ThreadCache類似,mcache保存的是各種大小的Span,並按Span class分類,小對象直接從mcache分配內存,它起到了緩存的作用,並且可以無鎖訪問

但mcache與ThreadCache也有不同點,TCMalloc中是每個線程1個ThreadCache,Go中是每個P擁有1個mcache,因為在Go程序中,當前最多有GOMAXPROCS個線程在用戶態運行,所以最多需要GOMAXPROCS個mcache就可以保證各線程對mcache的無鎖訪問,線程的運行又是與P綁定的,把mcache交給P剛剛好。

mcentral

mcentral與TCMalloc中的CentralCache類似,是所有線程共享的緩存,需要加鎖訪問,它按Span class對Span分類,串聯成鏈表,當mcache的某個級別Span的內存被分配光時,它會向mcentral申請1個當前級別的Span。

但mcentral與CentralCache也有不同點,CentralCache是每個級別的Span有1個鏈表,mcache是每個級別的Span有2個鏈表,這和mcache申請內存有關,稍後我們再解釋。

mheap

mheap與TCMalloc中的PageHeap類似,它是堆內存的抽象,把從OS申請出的內存頁組織成Span,並保存起來。當mcentral的Span不夠用時會向mheap申請,mheap的Span不夠用時會向OS申請,向OS的內存申請是按頁來的,然後把申請來的內存頁生成Span組織起來,同樣也是需要加鎖訪問的。

但mheap與PageHeap也有不同點:mheap把Span組織成了樹結構,而不是鏈表,並且還是2棵樹,然後把Span分配到heapArena進行管理,它包含地址映射和span是否包含指針等位圖,這樣做的主要原因是為了更高效的利用內存:分配、回收和再利用。

大小轉換

除了以上內存塊組織概念,還有幾個重要的大小概念,一定要拿出來講一下,不要忽視他們的重要性,他們是內存分配、組織和地址轉換的基礎。

Go內存分配那些事,看這一篇就夠,深度好文


  1. object size:代碼裡簡稱size,指申請內存的對象大小。
  2. size class:代碼裡簡稱class,它是size的級別,相當於把size歸類到一定大小的區間段,比如size[1,8]屬於size class 1,size(8,16]屬於size class 2。
  3. span class:指span的級別,但span class的大小與span的大小並沒有正比關係。span class主要用來和size class做對應,1個size class對應2個span class,2個span class的span大小相同,只是功能不同,1個用來存放包含指針的對象,一個用來存放不包含指針的對象,不包含指針對象的Span就無需GC掃描了。
  4. num of page:代碼裡簡稱npage,代表Page的數量,其實就是Span包含的頁數,用來分配內存。

在介紹這幾個大小之間的換算前,我們得先看下圖這個表,這個表決定了映射關係。

最上面2行是我手動加的,前3列分別是size class,object size和span size,根據這3列做size、size class和num of page之間的轉換。

仔細看一遍這個表,再向下看轉換是如何實現的。

Go內存分配那些事,看這一篇就夠,深度好文


在Go內存大小轉換那幅圖中已經標記各大小之間的轉換,分別是數組:class_to_size,size_to_class*和class_to_allocnpages,這3個數組內容,就是跟上表的映射關係匹配的。比如class_to_size,從上表看class 1對應的保存對象大小為8,所以class_to_size[1]=8,span大小為8192Byte,即8KB,為1頁,所以class_to_allocnpages[1]=1。

Go內存分配那些事,看這一篇就夠,深度好文


為何不使用函數計算各種轉換,而是寫成數組?

有1個很重要的原因:空間換時間。你如果仔細觀察了,上表中的轉換,並不能通過簡單的公式進行轉換,比如size和size class的關係,並不是正比的。這些數據是使用較複雜的公式計算出來的,公式在makesizeclass.go中,這其中存在指數運算與for循環,造成每次大小轉換的時間複雜度為O(N*2^N)。另外,對一個程序而言,內存的申請和管理操作是很多的,如果不能快速完成,就是非常的低效。把以上大小轉換寫死到數組裡,做到了把大小轉換的時間複雜度直接降到O(1)。

其他轉換表字段

第4列num of objects代表是當前size class級別的Span可以保存多少對象數量,第5列tail waste是span%obj計算的結果,因為span的大小並不一定是對象大小的整數倍。

最後一列max waste代表最大浪費的內存百分比,計算方法在printComment函數中:

func printComment(w io.Writer, classes []class) {
\tfmt.Fprintf(w, "// %-5s %-9s %-10s %-7s %-10s %-9s\\n", "class", "bytes/obj", "bytes/span", "objects", "tail waste", "max waste")
\tprevSize := 0
\tfor i, c := range classes {
\t\tif i == 0 {
\t\t\tcontinue
\t\t}
\t\tspanSize := c.npages * pageSize
\t\tobjects := spanSize / c.size
\t\ttailWaste := spanSize - c.size*(spanSize/c.size)
\t\tmaxWaste := float64((c.size-prevSize-1)*objects+tailWaste) / float64(spanSize)
\t\tprevSize = c.size
\t\tfmt.Fprintf(w, "// %5d %9d %10d %7d %10d %8.2f%%\\n", i, c.size, spanSize, objects, tailWaste, 100*maxWaste)
\t}
\tfmt.Fprintf(w, "\\n")
}

Span最浪費內存的場景是:Span內的每一個對象空間保存的對象,實際佔用內存是前一個class中對象的大小加1,這樣無法佔用低一級的Span。一個對象空間未被佔用的內存就被浪費了,所以一個Span內對象空間所浪費的內存為:所有對象空間浪費的內存之和+tail waste。

((c.size - (preSize+1)) * objects + tailWaste) / spanSize


Go內存分配那些事,看這一篇就夠,深度好文


感謝foobar的提醒max waste的計算。


Go內存分配

涉及的概念已經講完了,我們看下Go內存分配原理。

Go中的內存分類並不像TCMalloc那樣分成小、中、大對象,但是它的小對象裡又細分了一個Tiny對象,Tiny對象指大小在1Byte到16Byte之間並且不包含指針的對象。小對象和大對象只用大小劃定,無其他區分。

Go內存分配那些事,看這一篇就夠,深度好文


小對象是在mcache中分配的,而大對象是直接從mheap分配的,從小對象的內存分配看起。

小對象分配

Go內存分配那些事,看這一篇就夠,深度好文


大小轉換這一小節,我們介紹了轉換表,size class從1到66共66個,代碼中_NumSizeClasses=67代表了實際使用的size class數量,即67個,從0到67,size class 0實際並未使用到。

上文提到1個size class對應2個span class:

numSpanClasses = _NumSizeClasses * 2

numSpanClasses為span class的數量為134個,所以span class的下標是從0到133,所以上圖中mcache標註了的span class是,span class 0到span class 133。每1個span class都指向1個span,也就是mcache最多有134個span。

為對象尋找span

尋找span的流程如下:

  1. 計算對象所需內存大小size
  2. 根據size到size class映射,計算出所需的size class
  3. 根據size class和對象是否包含指針計算出span class
  4. 獲取該span class指向的span。

以分配一個不包含指針的,大小為24Byte的對象為例。

根據映射表:

// class bytes/obj bytes/span objects tail waste max waste
// 1 8 8192 1024 0 87.50%
// 2 16 8192 512 0 43.75%
// 3 32 8192 256 0 46.88%
// 4 48 8192 170 32 31.52%

size class 3,它的對象大小範圍是(16,32]Byte,24Byte剛好在此區間,所以此對象的size class為3。

Size class到span class的計算如下:

// noscan為true代表對象不包含指針
func makeSpanClass(sizeclass uint8, noscan bool) spanClass {
\treturn spanClass(sizeclass<<1) | spanClass(bool2int(noscan))
}

所以,對應的span class為:

span class = 3 << 1 | 1 = 7

所以該對象需要的是span class 7指向的span。

從span分配對象空間

Span可以按對象大小切成很多份,這些都可以從映射表上計算出來,以size class 3對應的span為例,span大小是8KB,每個對象實際所佔空間為32Byte,這個span就被分成了256塊,可以根據span的起始地址計算出每個對象塊的內存地址。

Go內存分配那些事,看這一篇就夠,深度好文


隨著內存的分配,span中的對象內存塊,有些被佔用,有些未被佔用,比如上圖,整體代表1個span,藍色塊代表已被佔用內存,綠色塊代表未被佔用內存。

當分配內存時,只要快速找到第一個可用的綠色塊,並計算出內存地址即可,如果需要還可以對內存塊數據清零。

span沒有空間怎麼分配對象

span內的所有內存塊都被佔用時,沒有剩餘空間繼續分配對象,mcache會向mcentral申請1個span,mcache拿到span後繼續分配對象。

mcentral向mcache提供span

mcentral和mcache一樣,都是0~133這134個span class級別,但每個級別都保存了2個span list,即2個span鏈表:

  1. nonempty:這個鏈表裡的span,所有span都至少有1個空閒的對象空間。這些span是mcache釋放span時加入到該鏈表的。
  2. empty:這個鏈表裡的span,所有的span都不確定裡面是否有空閒的對象空間。當一個span交給mcache的時候,就會加入到empty鏈表。

這2個東西名稱一直有點繞,建議直接把empty理解為沒有對象空間就好了。

"
Go語言中文網,致力於每日分享編碼、開源等知識,歡迎關注我,會有意想不到的收穫!

這篇文章主要介紹Go內存分配和Go內存管理,會輕微涉及內存申請和釋放,以及Go垃圾回收。

從非常宏觀的角度看,Go的內存管理就是下圖這個樣子,我們今天主要關注其中標紅的部分。

Go內存分配那些事,看這一篇就夠,深度好文


友情提醒:文章有點長,建議先收藏,後閱讀,絕對是學習內存管理的好資料。

本文基於go1.11.2,不同版本Go的內存管理可能存在差別,比如1.9與1.11的mheap定義就是差別比較大的,後續看源碼的時候,請注意你的go版本,但無論你用哪個go版本,這都是一個優秀的資料,因為內存管理的思想和框架始終未變。

Go這門語言拋棄了C/C++中的開發者管理內存的方式:主動申請與主動釋放,增加了逃逸分析和GC,將開發者從內存管理中釋放出來,讓開發者有更多的精力去關注軟件設計,而不是底層的內存問題。這是Go語言成為高生產力語言的原因之一。

我們不需要精通內存的管理,因為它確實很複雜,但掌握內存的管理,可以讓你寫出更高質量的代碼,另外,還能助你定位Bug。

這篇文章採用層層遞進的方式,依次會介紹關於存儲的基本知識,Go內存管理的“前輩”TCMalloc,然後是Go的內存管理和分配,最後是總結。這麼做的目的是,希望各位能通過全局的認識和思考,擁有更好的編碼思維和架構思維。

最後,這不是一篇源碼分析文章,因為Go源碼分析的文章已經有很多了,這些源碼文章能夠幫助你去學習具體的工程實踐和奇淫巧計了,文章的末尾會推薦一些優秀文章,如果你對內存感興趣,建議每一篇都去看一下,挑出自己喜歡的,多花時間研究下。

1. 存儲基礎知識回顧

這部分我們簡單回顧一下計算機存儲體系、虛擬內存、棧和堆,以及堆內存的管理,這部分內容對理解和掌握Go內存管理比較重要,建議忘記或不熟悉的朋友不要跳過。

存儲金字塔

Go內存分配那些事,看這一篇就夠,深度好文


這幅圖表達了計算機的存儲體系,從上至下依次是:

  • CPU寄存器
  • Cache
  • 內存
  • 硬盤等輔助存儲設備
  • 鼠標等外接設備

從上至下,訪問速度越來越慢,訪問時間越來越長。

你有沒有思考過下面2個簡單的問題,如果沒有不妨想想:

  1. 如果CPU直接訪問硬盤,CPU能充分利用嗎?
  2. 如果CPU直接訪問內存,CPU能充分利用嗎?

CPU速度很快,但硬盤等持久存儲很慢,如果CPU直接訪問磁盤,磁盤可以拉低CPU的速度,機器整體性能就會低下,為了彌補這2個硬件之間的速率差異,所以在CPU和磁盤之間增加了比磁盤快很多的內存。

Go內存分配那些事,看這一篇就夠,深度好文


然而,CPU跟內存的速率也不是相同的,從上圖可以看到,CPU的速率提高的很快(摩爾定律),然而內存速率增長的很慢,雖然CPU的速率現在增加的很慢了,但是內存的速率也沒增加多少,速率差距很大,從1980年開始CPU和內存速率差距在不斷拉大,為了彌補這2個硬件之間的速率差異,所以在CPU跟內存之間增加了比內存更快的Cache,Cache是內存數據的緩存,可以降低CPU訪問內存的時間。

不要以為有了Cache就萬事大吉了,CPU的速率還在不斷增大,Cache也在不斷改變,從最初的1級,到後來的2級,到當代的3級Cache,(有興趣看cache歷史)

Go內存分配那些事,看這一篇就夠,深度好文


三級Cache分別是L1、L2、L3,它們的速率是三個不同的層級,L1速率最快,與CPU速率最接近,是RAM速率的100倍,L2速率就降到了RAM的25倍,L3的速率更靠近RAM的速率。

看到這了,你有沒有Get到整個存儲體系的分層設計自頂向下,速率越來越低,訪問時間越來越長,從磁盤到CPU寄存器,上一層都可以看做是下一層的緩存。

看了分層設計,我們看一下內存,畢竟我們是介紹內存管理的文章。

虛擬內存

虛擬內存是當代操作系統必備的一項重要功能了,它向進程屏蔽了底層了RAM和磁盤,並向進程提供了遠超物理內存大小的內存空間。我們看一下虛擬內存的分層設計

Go內存分配那些事,看這一篇就夠,深度好文


上圖展示了某進程訪問數據,當Cache沒有命中的時候,訪問虛擬內存獲取數據的過程。

訪問內存,實際訪問的是虛擬內存,虛擬內存通過頁表查看,當前要訪問的虛擬內存地址,是否已經加載到了物理內存,如果已經在物理內存,則取物理內存數據,如果沒有對應的物理內存,則從磁盤加載數據到物理內存,並把物理內存地址和虛擬內存地址更新到頁表。

有沒有Get到:物理內存就是磁盤存儲緩存層

另外,在沒有虛擬內存的時代,物理內存對所有進程是共享的,多進程同時訪問同一個物理內存存在併發訪問問題。引入虛擬內存後,每個進程都要各自的虛擬內存,內存的併發訪問問題的粒度從多進程級別,可以降低到多線程級別

棧和堆

我們現在從虛擬內存,再進一層,看虛擬內存中的棧和堆,也就是進程對內存的管理。

Go內存分配那些事,看這一篇就夠,深度好文


上圖展示了一個進程的虛擬內存劃分,代碼中使用的內存地址都是虛擬內存地址,而不是實際的物理內存地址。棧和堆只是虛擬內存上2塊不同功能的內存區域:

  • 棧在高地址,從高地址向低地址增長。
  • 堆在低地址,從低地址向高地址增長。

棧和堆相比有這麼幾個好處

  1. 棧的內存管理簡單,分配比堆上快。
  2. 棧的內存不需要回收,而堆需要,無論是主動free,還是被動的垃圾回收,這都需要花費額外的CPU。
  3. 棧上的內存有更好的局部性,堆上內存訪問就不那麼友好了,CPU訪問的2塊數據可能在不同的頁上,CPU訪問數據的時間可能就上去了。

堆內存管理

Go內存分配那些事,看這一篇就夠,深度好文


我們再進一層,當我們說內存管理的時候,主要是指堆內存的管理,因為棧的內存管理不需要程序去操心。這小節看下堆內存管理乾的是啥,如上圖所示主要是3部分:分配內存塊,回收內存塊和組織內存塊

在一個最簡單的內存管理中,堆內存最初會是一個完整的大塊,即未分配內存,當來申請的時候,就會從未分配內存,分割出一個小內存塊(block),然後用鏈表把所有內存塊連接起來。需要一些信息描述每個內存塊的基本信息,比如大小(size)、是否使用中(used)和下一個內存塊的地址(next),內存塊實際數據存儲在data中。

Go內存分配那些事,看這一篇就夠,深度好文


一個內存塊包含了3類信息,如下圖所示,元數據、用戶數據和對齊字段,內存對齊是為了提高訪問效率。下圖申請5Byte內存的時候,就需要進行內存對齊。

Go內存分配那些事,看這一篇就夠,深度好文


釋放內存實質是把使用的內存塊從鏈表中取出來,然後標記為未使用,當分配內存塊的時候,可以從未使用內存塊中有先查找大小相近的內存塊,如果找不到,再從未分配的內存中分配內存。

上面這個簡單的設計中還沒考慮內存碎片的問題,因為隨著內存不斷的申請和釋放,內存上會存在大量的碎片,降低內存的使用率。為了解決內存碎片,可以將2個連續的未使用的內存塊合併,減少碎片。

以上就是內存管理的基本思路,關於基本的內存管理,想了解更多,可以閱讀這篇文章《Writing a Memory Allocator》,本節的3張圖片也是來自這片文章。

2. TCMalloc

TCMalloc是Thread Cache Malloc的簡稱,是Go內存管理的起源,Go的內存管理是借鑑了TCMalloc,隨著Go的迭代,Go的內存管理與TCMalloc不一致地方在不斷擴大,但其主要思想、原理和概念都是和TCMalloc一致的,如果跳過TCMalloc直接去看Go的內存管理,也許你會似懂非懂。

掌握TCMalloc的理念,無需去關注過多的源碼細節,就可以為掌握Go的內存管理打好基礎,基礎打好了,後面知識才紮實。

在Linux裡,其實有不少的內存管理庫,比如glibc的ptmalloc,FreeBSD的jemalloc,Google的tcmalloc等等,為何會出現這麼多的內存管理庫?本質都是在多線程編程下,追求更高內存管理效率:更快的分配是主要目的。

那如何更快的分配內存?

我們前面提到:

引入虛擬內存後,讓內存的併發訪問問題的粒度從多進程級別,降低到多線程級別。


這是更快分配內存的第一個層次

同一進程的所有線程共享相同的內存空間,他們申請內存時需要加鎖,如果不加鎖就存在同一塊內存被2個線程同時訪問的問題。

TCMalloc的做法是什麼呢?為每個線程預分配一塊緩存,線程申請小內存時,可以從緩存分配內存,這樣有2個好處:

  1. 為線程預分配緩存需要進行1次系統調用,後續線程申請小內存時,從緩存分配,都是在用戶態執行,沒有系統調用,縮短了內存總體的分配和釋放時間,這是快速分配內存的第二個層次
  2. 多個線程同時申請小內存時,從各自的緩存分配,訪問的是不同的地址空間,無需加鎖,把內存併發訪問的粒度進一步降低了,這是快速分配內存的第三個層次

基本原理

下面就簡單介紹下TCMalloc,細緻程度夠我們理解Go的內存管理即可。

聲明:我沒有研究過TCMalloc,以下介紹根據TCMalloc官方資料和其他博主資料總結而來,錯誤之處請朋友告知我。
Go內存分配那些事,看這一篇就夠,深度好文

結合上圖,介紹TCMalloc的幾個重要概念:

  1. Page:操作系統對內存管理以頁為單位,TCMalloc也是這樣,只不過TCMalloc裡的Page大小與操作系統裡的大小並不一定相等,而是倍數關係。《TCMalloc解密》裡稱x64下Page大小是8KB。
  2. Span:一組連續的Page被稱為Span,比如可以有2個頁大小的Span,也可以有16頁大小的Span,Span比Page高一個層級,是為了方便管理一定大小的內存區域,Span是TCMalloc中內存管理的基本單位。
  3. ThreadCache:每個線程各自的Cache,一個Cache包含多個空閒內存塊鏈表,每個鏈表連接的都是內存塊,同一個鏈表上內存塊的大小是相同的,也可以說按內存塊大小,給內存塊分了個類,這樣可以根據申請的內存大小,快速從合適的鏈表選擇空閒內存塊。由於每個線程有自己的ThreadCache,所以ThreadCache訪問是無鎖的。
  4. CentralCache:是所有線程共享的緩存,也是保存的空閒內存塊鏈表,鏈表的數量與ThreadCache中鏈表數量相同,當ThreadCache內存塊不足時,可以從CentralCache取,當ThreadCache內存塊多時,可以放回CentralCache。由於CentralCache是共享的,所以它的訪問是要加鎖的。
  5. PageHeap:PageHeap是堆內存的抽象,PageHeap存的也是若干鏈表,鏈表保存的是Span,當CentralCache沒有內存的時,會從PageHeap取,把1個Span拆成若干內存塊,添加到對應大小的鏈表中,當CentralCache內存多的時候,會放回PageHeap。如下圖,分別是1頁Page的Span鏈表,2頁Page的Span鏈表等,最後是large span set,這個是用來保存中大對象的。毫無疑問,PageHeap也是要加鎖的。


Go內存分配那些事,看這一篇就夠,深度好文

上文提到了小、中、大對象,Go內存管理中也有類似的概念,我們瞄一眼TCMalloc的定義:

  1. 小對象大小:0~256KB
  2. 中對象大小:257~1MB
  3. 大對象大小:>1MB

小對象的分配流程:ThreadCache -> CentralCache -> HeapPage,大部分時候,ThreadCache緩存都是足夠的,不需要去訪問CentralCache和HeapPage,無鎖分配加無系統調用,分配效率是非常高的。

中對象分配流程:直接在PageHeap中選擇適當的大小即可,128 Page的Span所保存的最大內存就是1MB。

大對象分配流程:從large span set選擇合適數量的頁面組成span,用來存儲數據。

通過本節的介紹,你應當對TCMalloc主要思想有一定了解了,我建議再回顧一下上面的內容。

本節圖片皆來自《TCMalloc解密》,圖片版權歸原作者所有。

精彩文章推薦

本文對於TCMalloc的介紹並不多,重要的是3個快速分配內存的層次,如果想了解更多,可閱讀下面文章。

  1. TCMalloc
  2. 必讀,通過這篇你能掌握TCMalloc的原理和性能,對掌握Go的內存管理有非常大的幫助,雖然如今Go的內存管理與TCMalloc已經相差很大,但是,這是Go內存管理的起源和“大道”,這篇文章頂看十幾篇Go內存管理的文章。
  3. TCMalloc解密
  4. 可選異常詳細,包含大量精美圖片,看完得花小時級別,理解就需要更多時間了,看完這篇不需要看其他TCMalloc的文章了。
  5. TCMalloc介紹
  6. 可選,算是TCMalloc的文檔的中文版,多數是從英文版翻譯過來的,如果你英文不好,看看。

3. Go內存管理

前面鋪墊了那麼多,終於到了本文核心的地方。前面的鋪墊不是不重要,相反它們很重要,Go語言內存管理源自前面的基礎知識和內存管理思維,如果你跳過了前面的內容,建議你回頭看一看,它可以幫助你更好的掌握Go內存管理。

前文提到Go內存管理源自TCMalloc,但它比TCMalloc還多了2件東西:逃逸分析和垃圾回收,這是2項提高生產力的絕佳武器。

這一大章節,我們先介紹Go內存管理和Go內存分配,最後涉及一點垃圾回收和內存釋放。

Go內存管理的基本概念

前面計算機基礎知識回顧,是一種自上而下,從宏觀到微觀的介紹方式,把目光引入到今天的主題。

Go內存管理的許多概念在TCMalloc中已經有了,含義是相同的,只是名字有一些變化。先給大家上一幅宏觀的圖,藉助圖一起來介紹。

Go內存分配那些事,看這一篇就夠,深度好文


Page

與TCMalloc中的Page相同,x64下1個Page的大小是8KB。上圖的最下方,1個淺藍色的長方形代表1個Page。

Span

與TCMalloc中的Span相同,Span是內存管理的基本單位,代碼中為mspan,一組連續的Page組成1個Span,所以上圖一組連續的淺藍色長方形代表的是一組Page組成的1個Span,另外,1個淡紫色長方形為1個Span。

mcache

mcache與TCMalloc中的ThreadCache類似,mcache保存的是各種大小的Span,並按Span class分類,小對象直接從mcache分配內存,它起到了緩存的作用,並且可以無鎖訪問

但mcache與ThreadCache也有不同點,TCMalloc中是每個線程1個ThreadCache,Go中是每個P擁有1個mcache,因為在Go程序中,當前最多有GOMAXPROCS個線程在用戶態運行,所以最多需要GOMAXPROCS個mcache就可以保證各線程對mcache的無鎖訪問,線程的運行又是與P綁定的,把mcache交給P剛剛好。

mcentral

mcentral與TCMalloc中的CentralCache類似,是所有線程共享的緩存,需要加鎖訪問,它按Span class對Span分類,串聯成鏈表,當mcache的某個級別Span的內存被分配光時,它會向mcentral申請1個當前級別的Span。

但mcentral與CentralCache也有不同點,CentralCache是每個級別的Span有1個鏈表,mcache是每個級別的Span有2個鏈表,這和mcache申請內存有關,稍後我們再解釋。

mheap

mheap與TCMalloc中的PageHeap類似,它是堆內存的抽象,把從OS申請出的內存頁組織成Span,並保存起來。當mcentral的Span不夠用時會向mheap申請,mheap的Span不夠用時會向OS申請,向OS的內存申請是按頁來的,然後把申請來的內存頁生成Span組織起來,同樣也是需要加鎖訪問的。

但mheap與PageHeap也有不同點:mheap把Span組織成了樹結構,而不是鏈表,並且還是2棵樹,然後把Span分配到heapArena進行管理,它包含地址映射和span是否包含指針等位圖,這樣做的主要原因是為了更高效的利用內存:分配、回收和再利用。

大小轉換

除了以上內存塊組織概念,還有幾個重要的大小概念,一定要拿出來講一下,不要忽視他們的重要性,他們是內存分配、組織和地址轉換的基礎。

Go內存分配那些事,看這一篇就夠,深度好文


  1. object size:代碼裡簡稱size,指申請內存的對象大小。
  2. size class:代碼裡簡稱class,它是size的級別,相當於把size歸類到一定大小的區間段,比如size[1,8]屬於size class 1,size(8,16]屬於size class 2。
  3. span class:指span的級別,但span class的大小與span的大小並沒有正比關係。span class主要用來和size class做對應,1個size class對應2個span class,2個span class的span大小相同,只是功能不同,1個用來存放包含指針的對象,一個用來存放不包含指針的對象,不包含指針對象的Span就無需GC掃描了。
  4. num of page:代碼裡簡稱npage,代表Page的數量,其實就是Span包含的頁數,用來分配內存。

在介紹這幾個大小之間的換算前,我們得先看下圖這個表,這個表決定了映射關係。

最上面2行是我手動加的,前3列分別是size class,object size和span size,根據這3列做size、size class和num of page之間的轉換。

仔細看一遍這個表,再向下看轉換是如何實現的。

Go內存分配那些事,看這一篇就夠,深度好文


在Go內存大小轉換那幅圖中已經標記各大小之間的轉換,分別是數組:class_to_size,size_to_class*和class_to_allocnpages,這3個數組內容,就是跟上表的映射關係匹配的。比如class_to_size,從上表看class 1對應的保存對象大小為8,所以class_to_size[1]=8,span大小為8192Byte,即8KB,為1頁,所以class_to_allocnpages[1]=1。

Go內存分配那些事,看這一篇就夠,深度好文


為何不使用函數計算各種轉換,而是寫成數組?

有1個很重要的原因:空間換時間。你如果仔細觀察了,上表中的轉換,並不能通過簡單的公式進行轉換,比如size和size class的關係,並不是正比的。這些數據是使用較複雜的公式計算出來的,公式在makesizeclass.go中,這其中存在指數運算與for循環,造成每次大小轉換的時間複雜度為O(N*2^N)。另外,對一個程序而言,內存的申請和管理操作是很多的,如果不能快速完成,就是非常的低效。把以上大小轉換寫死到數組裡,做到了把大小轉換的時間複雜度直接降到O(1)。

其他轉換表字段

第4列num of objects代表是當前size class級別的Span可以保存多少對象數量,第5列tail waste是span%obj計算的結果,因為span的大小並不一定是對象大小的整數倍。

最後一列max waste代表最大浪費的內存百分比,計算方法在printComment函數中:

func printComment(w io.Writer, classes []class) {
\tfmt.Fprintf(w, "// %-5s %-9s %-10s %-7s %-10s %-9s\\n", "class", "bytes/obj", "bytes/span", "objects", "tail waste", "max waste")
\tprevSize := 0
\tfor i, c := range classes {
\t\tif i == 0 {
\t\t\tcontinue
\t\t}
\t\tspanSize := c.npages * pageSize
\t\tobjects := spanSize / c.size
\t\ttailWaste := spanSize - c.size*(spanSize/c.size)
\t\tmaxWaste := float64((c.size-prevSize-1)*objects+tailWaste) / float64(spanSize)
\t\tprevSize = c.size
\t\tfmt.Fprintf(w, "// %5d %9d %10d %7d %10d %8.2f%%\\n", i, c.size, spanSize, objects, tailWaste, 100*maxWaste)
\t}
\tfmt.Fprintf(w, "\\n")
}

Span最浪費內存的場景是:Span內的每一個對象空間保存的對象,實際佔用內存是前一個class中對象的大小加1,這樣無法佔用低一級的Span。一個對象空間未被佔用的內存就被浪費了,所以一個Span內對象空間所浪費的內存為:所有對象空間浪費的內存之和+tail waste。

((c.size - (preSize+1)) * objects + tailWaste) / spanSize


Go內存分配那些事,看這一篇就夠,深度好文


感謝foobar的提醒max waste的計算。


Go內存分配

涉及的概念已經講完了,我們看下Go內存分配原理。

Go中的內存分類並不像TCMalloc那樣分成小、中、大對象,但是它的小對象裡又細分了一個Tiny對象,Tiny對象指大小在1Byte到16Byte之間並且不包含指針的對象。小對象和大對象只用大小劃定,無其他區分。

Go內存分配那些事,看這一篇就夠,深度好文


小對象是在mcache中分配的,而大對象是直接從mheap分配的,從小對象的內存分配看起。

小對象分配

Go內存分配那些事,看這一篇就夠,深度好文


大小轉換這一小節,我們介紹了轉換表,size class從1到66共66個,代碼中_NumSizeClasses=67代表了實際使用的size class數量,即67個,從0到67,size class 0實際並未使用到。

上文提到1個size class對應2個span class:

numSpanClasses = _NumSizeClasses * 2

numSpanClasses為span class的數量為134個,所以span class的下標是從0到133,所以上圖中mcache標註了的span class是,span class 0到span class 133。每1個span class都指向1個span,也就是mcache最多有134個span。

為對象尋找span

尋找span的流程如下:

  1. 計算對象所需內存大小size
  2. 根據size到size class映射,計算出所需的size class
  3. 根據size class和對象是否包含指針計算出span class
  4. 獲取該span class指向的span。

以分配一個不包含指針的,大小為24Byte的對象為例。

根據映射表:

// class bytes/obj bytes/span objects tail waste max waste
// 1 8 8192 1024 0 87.50%
// 2 16 8192 512 0 43.75%
// 3 32 8192 256 0 46.88%
// 4 48 8192 170 32 31.52%

size class 3,它的對象大小範圍是(16,32]Byte,24Byte剛好在此區間,所以此對象的size class為3。

Size class到span class的計算如下:

// noscan為true代表對象不包含指針
func makeSpanClass(sizeclass uint8, noscan bool) spanClass {
\treturn spanClass(sizeclass<<1) | spanClass(bool2int(noscan))
}

所以,對應的span class為:

span class = 3 << 1 | 1 = 7

所以該對象需要的是span class 7指向的span。

從span分配對象空間

Span可以按對象大小切成很多份,這些都可以從映射表上計算出來,以size class 3對應的span為例,span大小是8KB,每個對象實際所佔空間為32Byte,這個span就被分成了256塊,可以根據span的起始地址計算出每個對象塊的內存地址。

Go內存分配那些事,看這一篇就夠,深度好文


隨著內存的分配,span中的對象內存塊,有些被佔用,有些未被佔用,比如上圖,整體代表1個span,藍色塊代表已被佔用內存,綠色塊代表未被佔用內存。

當分配內存時,只要快速找到第一個可用的綠色塊,並計算出內存地址即可,如果需要還可以對內存塊數據清零。

span沒有空間怎麼分配對象

span內的所有內存塊都被佔用時,沒有剩餘空間繼續分配對象,mcache會向mcentral申請1個span,mcache拿到span後繼續分配對象。

mcentral向mcache提供span

mcentral和mcache一樣,都是0~133這134個span class級別,但每個級別都保存了2個span list,即2個span鏈表:

  1. nonempty:這個鏈表裡的span,所有span都至少有1個空閒的對象空間。這些span是mcache釋放span時加入到該鏈表的。
  2. empty:這個鏈表裡的span,所有的span都不確定裡面是否有空閒的對象空間。當一個span交給mcache的時候,就會加入到empty鏈表。

這2個東西名稱一直有點繞,建議直接把empty理解為沒有對象空間就好了。

Go內存分配那些事,看這一篇就夠,深度好文


實際代碼中每1個span class對應1個mcentral,圖裡把所有mcentral抽象成1個整體了。

mcache向mcentral要span時,mcentral會先從nonempty搜索滿足條件的span,如果每找到再從emtpy搜索滿足條件的span,然後把找到的span交給mcache。

mheap的span管理

mheap裡保存了2棵二叉排序樹,按span的page數量進行排序:

  1. free:free中保存的span是空閒並且非垃圾回收的span。
  2. scav:scav中保存的是空閒並且已經垃圾回收的span。

如果是垃圾回收導致的span釋放,span會被加入到scav,否則加入到free,比如剛從OS申請的的內存也組成的Span。

"
Go語言中文網,致力於每日分享編碼、開源等知識,歡迎關注我,會有意想不到的收穫!

這篇文章主要介紹Go內存分配和Go內存管理,會輕微涉及內存申請和釋放,以及Go垃圾回收。

從非常宏觀的角度看,Go的內存管理就是下圖這個樣子,我們今天主要關注其中標紅的部分。

Go內存分配那些事,看這一篇就夠,深度好文


友情提醒:文章有點長,建議先收藏,後閱讀,絕對是學習內存管理的好資料。

本文基於go1.11.2,不同版本Go的內存管理可能存在差別,比如1.9與1.11的mheap定義就是差別比較大的,後續看源碼的時候,請注意你的go版本,但無論你用哪個go版本,這都是一個優秀的資料,因為內存管理的思想和框架始終未變。

Go這門語言拋棄了C/C++中的開發者管理內存的方式:主動申請與主動釋放,增加了逃逸分析和GC,將開發者從內存管理中釋放出來,讓開發者有更多的精力去關注軟件設計,而不是底層的內存問題。這是Go語言成為高生產力語言的原因之一。

我們不需要精通內存的管理,因為它確實很複雜,但掌握內存的管理,可以讓你寫出更高質量的代碼,另外,還能助你定位Bug。

這篇文章採用層層遞進的方式,依次會介紹關於存儲的基本知識,Go內存管理的“前輩”TCMalloc,然後是Go的內存管理和分配,最後是總結。這麼做的目的是,希望各位能通過全局的認識和思考,擁有更好的編碼思維和架構思維。

最後,這不是一篇源碼分析文章,因為Go源碼分析的文章已經有很多了,這些源碼文章能夠幫助你去學習具體的工程實踐和奇淫巧計了,文章的末尾會推薦一些優秀文章,如果你對內存感興趣,建議每一篇都去看一下,挑出自己喜歡的,多花時間研究下。

1. 存儲基礎知識回顧

這部分我們簡單回顧一下計算機存儲體系、虛擬內存、棧和堆,以及堆內存的管理,這部分內容對理解和掌握Go內存管理比較重要,建議忘記或不熟悉的朋友不要跳過。

存儲金字塔

Go內存分配那些事,看這一篇就夠,深度好文


這幅圖表達了計算機的存儲體系,從上至下依次是:

  • CPU寄存器
  • Cache
  • 內存
  • 硬盤等輔助存儲設備
  • 鼠標等外接設備

從上至下,訪問速度越來越慢,訪問時間越來越長。

你有沒有思考過下面2個簡單的問題,如果沒有不妨想想:

  1. 如果CPU直接訪問硬盤,CPU能充分利用嗎?
  2. 如果CPU直接訪問內存,CPU能充分利用嗎?

CPU速度很快,但硬盤等持久存儲很慢,如果CPU直接訪問磁盤,磁盤可以拉低CPU的速度,機器整體性能就會低下,為了彌補這2個硬件之間的速率差異,所以在CPU和磁盤之間增加了比磁盤快很多的內存。

Go內存分配那些事,看這一篇就夠,深度好文


然而,CPU跟內存的速率也不是相同的,從上圖可以看到,CPU的速率提高的很快(摩爾定律),然而內存速率增長的很慢,雖然CPU的速率現在增加的很慢了,但是內存的速率也沒增加多少,速率差距很大,從1980年開始CPU和內存速率差距在不斷拉大,為了彌補這2個硬件之間的速率差異,所以在CPU跟內存之間增加了比內存更快的Cache,Cache是內存數據的緩存,可以降低CPU訪問內存的時間。

不要以為有了Cache就萬事大吉了,CPU的速率還在不斷增大,Cache也在不斷改變,從最初的1級,到後來的2級,到當代的3級Cache,(有興趣看cache歷史)

Go內存分配那些事,看這一篇就夠,深度好文


三級Cache分別是L1、L2、L3,它們的速率是三個不同的層級,L1速率最快,與CPU速率最接近,是RAM速率的100倍,L2速率就降到了RAM的25倍,L3的速率更靠近RAM的速率。

看到這了,你有沒有Get到整個存儲體系的分層設計自頂向下,速率越來越低,訪問時間越來越長,從磁盤到CPU寄存器,上一層都可以看做是下一層的緩存。

看了分層設計,我們看一下內存,畢竟我們是介紹內存管理的文章。

虛擬內存

虛擬內存是當代操作系統必備的一項重要功能了,它向進程屏蔽了底層了RAM和磁盤,並向進程提供了遠超物理內存大小的內存空間。我們看一下虛擬內存的分層設計

Go內存分配那些事,看這一篇就夠,深度好文


上圖展示了某進程訪問數據,當Cache沒有命中的時候,訪問虛擬內存獲取數據的過程。

訪問內存,實際訪問的是虛擬內存,虛擬內存通過頁表查看,當前要訪問的虛擬內存地址,是否已經加載到了物理內存,如果已經在物理內存,則取物理內存數據,如果沒有對應的物理內存,則從磁盤加載數據到物理內存,並把物理內存地址和虛擬內存地址更新到頁表。

有沒有Get到:物理內存就是磁盤存儲緩存層

另外,在沒有虛擬內存的時代,物理內存對所有進程是共享的,多進程同時訪問同一個物理內存存在併發訪問問題。引入虛擬內存後,每個進程都要各自的虛擬內存,內存的併發訪問問題的粒度從多進程級別,可以降低到多線程級別

棧和堆

我們現在從虛擬內存,再進一層,看虛擬內存中的棧和堆,也就是進程對內存的管理。

Go內存分配那些事,看這一篇就夠,深度好文


上圖展示了一個進程的虛擬內存劃分,代碼中使用的內存地址都是虛擬內存地址,而不是實際的物理內存地址。棧和堆只是虛擬內存上2塊不同功能的內存區域:

  • 棧在高地址,從高地址向低地址增長。
  • 堆在低地址,從低地址向高地址增長。

棧和堆相比有這麼幾個好處

  1. 棧的內存管理簡單,分配比堆上快。
  2. 棧的內存不需要回收,而堆需要,無論是主動free,還是被動的垃圾回收,這都需要花費額外的CPU。
  3. 棧上的內存有更好的局部性,堆上內存訪問就不那麼友好了,CPU訪問的2塊數據可能在不同的頁上,CPU訪問數據的時間可能就上去了。

堆內存管理

Go內存分配那些事,看這一篇就夠,深度好文


我們再進一層,當我們說內存管理的時候,主要是指堆內存的管理,因為棧的內存管理不需要程序去操心。這小節看下堆內存管理乾的是啥,如上圖所示主要是3部分:分配內存塊,回收內存塊和組織內存塊

在一個最簡單的內存管理中,堆內存最初會是一個完整的大塊,即未分配內存,當來申請的時候,就會從未分配內存,分割出一個小內存塊(block),然後用鏈表把所有內存塊連接起來。需要一些信息描述每個內存塊的基本信息,比如大小(size)、是否使用中(used)和下一個內存塊的地址(next),內存塊實際數據存儲在data中。

Go內存分配那些事,看這一篇就夠,深度好文


一個內存塊包含了3類信息,如下圖所示,元數據、用戶數據和對齊字段,內存對齊是為了提高訪問效率。下圖申請5Byte內存的時候,就需要進行內存對齊。

Go內存分配那些事,看這一篇就夠,深度好文


釋放內存實質是把使用的內存塊從鏈表中取出來,然後標記為未使用,當分配內存塊的時候,可以從未使用內存塊中有先查找大小相近的內存塊,如果找不到,再從未分配的內存中分配內存。

上面這個簡單的設計中還沒考慮內存碎片的問題,因為隨著內存不斷的申請和釋放,內存上會存在大量的碎片,降低內存的使用率。為了解決內存碎片,可以將2個連續的未使用的內存塊合併,減少碎片。

以上就是內存管理的基本思路,關於基本的內存管理,想了解更多,可以閱讀這篇文章《Writing a Memory Allocator》,本節的3張圖片也是來自這片文章。

2. TCMalloc

TCMalloc是Thread Cache Malloc的簡稱,是Go內存管理的起源,Go的內存管理是借鑑了TCMalloc,隨著Go的迭代,Go的內存管理與TCMalloc不一致地方在不斷擴大,但其主要思想、原理和概念都是和TCMalloc一致的,如果跳過TCMalloc直接去看Go的內存管理,也許你會似懂非懂。

掌握TCMalloc的理念,無需去關注過多的源碼細節,就可以為掌握Go的內存管理打好基礎,基礎打好了,後面知識才紮實。

在Linux裡,其實有不少的內存管理庫,比如glibc的ptmalloc,FreeBSD的jemalloc,Google的tcmalloc等等,為何會出現這麼多的內存管理庫?本質都是在多線程編程下,追求更高內存管理效率:更快的分配是主要目的。

那如何更快的分配內存?

我們前面提到:

引入虛擬內存後,讓內存的併發訪問問題的粒度從多進程級別,降低到多線程級別。


這是更快分配內存的第一個層次

同一進程的所有線程共享相同的內存空間,他們申請內存時需要加鎖,如果不加鎖就存在同一塊內存被2個線程同時訪問的問題。

TCMalloc的做法是什麼呢?為每個線程預分配一塊緩存,線程申請小內存時,可以從緩存分配內存,這樣有2個好處:

  1. 為線程預分配緩存需要進行1次系統調用,後續線程申請小內存時,從緩存分配,都是在用戶態執行,沒有系統調用,縮短了內存總體的分配和釋放時間,這是快速分配內存的第二個層次
  2. 多個線程同時申請小內存時,從各自的緩存分配,訪問的是不同的地址空間,無需加鎖,把內存併發訪問的粒度進一步降低了,這是快速分配內存的第三個層次

基本原理

下面就簡單介紹下TCMalloc,細緻程度夠我們理解Go的內存管理即可。

聲明:我沒有研究過TCMalloc,以下介紹根據TCMalloc官方資料和其他博主資料總結而來,錯誤之處請朋友告知我。
Go內存分配那些事,看這一篇就夠,深度好文

結合上圖,介紹TCMalloc的幾個重要概念:

  1. Page:操作系統對內存管理以頁為單位,TCMalloc也是這樣,只不過TCMalloc裡的Page大小與操作系統裡的大小並不一定相等,而是倍數關係。《TCMalloc解密》裡稱x64下Page大小是8KB。
  2. Span:一組連續的Page被稱為Span,比如可以有2個頁大小的Span,也可以有16頁大小的Span,Span比Page高一個層級,是為了方便管理一定大小的內存區域,Span是TCMalloc中內存管理的基本單位。
  3. ThreadCache:每個線程各自的Cache,一個Cache包含多個空閒內存塊鏈表,每個鏈表連接的都是內存塊,同一個鏈表上內存塊的大小是相同的,也可以說按內存塊大小,給內存塊分了個類,這樣可以根據申請的內存大小,快速從合適的鏈表選擇空閒內存塊。由於每個線程有自己的ThreadCache,所以ThreadCache訪問是無鎖的。
  4. CentralCache:是所有線程共享的緩存,也是保存的空閒內存塊鏈表,鏈表的數量與ThreadCache中鏈表數量相同,當ThreadCache內存塊不足時,可以從CentralCache取,當ThreadCache內存塊多時,可以放回CentralCache。由於CentralCache是共享的,所以它的訪問是要加鎖的。
  5. PageHeap:PageHeap是堆內存的抽象,PageHeap存的也是若干鏈表,鏈表保存的是Span,當CentralCache沒有內存的時,會從PageHeap取,把1個Span拆成若干內存塊,添加到對應大小的鏈表中,當CentralCache內存多的時候,會放回PageHeap。如下圖,分別是1頁Page的Span鏈表,2頁Page的Span鏈表等,最後是large span set,這個是用來保存中大對象的。毫無疑問,PageHeap也是要加鎖的。


Go內存分配那些事,看這一篇就夠,深度好文

上文提到了小、中、大對象,Go內存管理中也有類似的概念,我們瞄一眼TCMalloc的定義:

  1. 小對象大小:0~256KB
  2. 中對象大小:257~1MB
  3. 大對象大小:>1MB

小對象的分配流程:ThreadCache -> CentralCache -> HeapPage,大部分時候,ThreadCache緩存都是足夠的,不需要去訪問CentralCache和HeapPage,無鎖分配加無系統調用,分配效率是非常高的。

中對象分配流程:直接在PageHeap中選擇適當的大小即可,128 Page的Span所保存的最大內存就是1MB。

大對象分配流程:從large span set選擇合適數量的頁面組成span,用來存儲數據。

通過本節的介紹,你應當對TCMalloc主要思想有一定了解了,我建議再回顧一下上面的內容。

本節圖片皆來自《TCMalloc解密》,圖片版權歸原作者所有。

精彩文章推薦

本文對於TCMalloc的介紹並不多,重要的是3個快速分配內存的層次,如果想了解更多,可閱讀下面文章。

  1. TCMalloc
  2. 必讀,通過這篇你能掌握TCMalloc的原理和性能,對掌握Go的內存管理有非常大的幫助,雖然如今Go的內存管理與TCMalloc已經相差很大,但是,這是Go內存管理的起源和“大道”,這篇文章頂看十幾篇Go內存管理的文章。
  3. TCMalloc解密
  4. 可選異常詳細,包含大量精美圖片,看完得花小時級別,理解就需要更多時間了,看完這篇不需要看其他TCMalloc的文章了。
  5. TCMalloc介紹
  6. 可選,算是TCMalloc的文檔的中文版,多數是從英文版翻譯過來的,如果你英文不好,看看。

3. Go內存管理

前面鋪墊了那麼多,終於到了本文核心的地方。前面的鋪墊不是不重要,相反它們很重要,Go語言內存管理源自前面的基礎知識和內存管理思維,如果你跳過了前面的內容,建議你回頭看一看,它可以幫助你更好的掌握Go內存管理。

前文提到Go內存管理源自TCMalloc,但它比TCMalloc還多了2件東西:逃逸分析和垃圾回收,這是2項提高生產力的絕佳武器。

這一大章節,我們先介紹Go內存管理和Go內存分配,最後涉及一點垃圾回收和內存釋放。

Go內存管理的基本概念

前面計算機基礎知識回顧,是一種自上而下,從宏觀到微觀的介紹方式,把目光引入到今天的主題。

Go內存管理的許多概念在TCMalloc中已經有了,含義是相同的,只是名字有一些變化。先給大家上一幅宏觀的圖,藉助圖一起來介紹。

Go內存分配那些事,看這一篇就夠,深度好文


Page

與TCMalloc中的Page相同,x64下1個Page的大小是8KB。上圖的最下方,1個淺藍色的長方形代表1個Page。

Span

與TCMalloc中的Span相同,Span是內存管理的基本單位,代碼中為mspan,一組連續的Page組成1個Span,所以上圖一組連續的淺藍色長方形代表的是一組Page組成的1個Span,另外,1個淡紫色長方形為1個Span。

mcache

mcache與TCMalloc中的ThreadCache類似,mcache保存的是各種大小的Span,並按Span class分類,小對象直接從mcache分配內存,它起到了緩存的作用,並且可以無鎖訪問

但mcache與ThreadCache也有不同點,TCMalloc中是每個線程1個ThreadCache,Go中是每個P擁有1個mcache,因為在Go程序中,當前最多有GOMAXPROCS個線程在用戶態運行,所以最多需要GOMAXPROCS個mcache就可以保證各線程對mcache的無鎖訪問,線程的運行又是與P綁定的,把mcache交給P剛剛好。

mcentral

mcentral與TCMalloc中的CentralCache類似,是所有線程共享的緩存,需要加鎖訪問,它按Span class對Span分類,串聯成鏈表,當mcache的某個級別Span的內存被分配光時,它會向mcentral申請1個當前級別的Span。

但mcentral與CentralCache也有不同點,CentralCache是每個級別的Span有1個鏈表,mcache是每個級別的Span有2個鏈表,這和mcache申請內存有關,稍後我們再解釋。

mheap

mheap與TCMalloc中的PageHeap類似,它是堆內存的抽象,把從OS申請出的內存頁組織成Span,並保存起來。當mcentral的Span不夠用時會向mheap申請,mheap的Span不夠用時會向OS申請,向OS的內存申請是按頁來的,然後把申請來的內存頁生成Span組織起來,同樣也是需要加鎖訪問的。

但mheap與PageHeap也有不同點:mheap把Span組織成了樹結構,而不是鏈表,並且還是2棵樹,然後把Span分配到heapArena進行管理,它包含地址映射和span是否包含指針等位圖,這樣做的主要原因是為了更高效的利用內存:分配、回收和再利用。

大小轉換

除了以上內存塊組織概念,還有幾個重要的大小概念,一定要拿出來講一下,不要忽視他們的重要性,他們是內存分配、組織和地址轉換的基礎。

Go內存分配那些事,看這一篇就夠,深度好文


  1. object size:代碼裡簡稱size,指申請內存的對象大小。
  2. size class:代碼裡簡稱class,它是size的級別,相當於把size歸類到一定大小的區間段,比如size[1,8]屬於size class 1,size(8,16]屬於size class 2。
  3. span class:指span的級別,但span class的大小與span的大小並沒有正比關係。span class主要用來和size class做對應,1個size class對應2個span class,2個span class的span大小相同,只是功能不同,1個用來存放包含指針的對象,一個用來存放不包含指針的對象,不包含指針對象的Span就無需GC掃描了。
  4. num of page:代碼裡簡稱npage,代表Page的數量,其實就是Span包含的頁數,用來分配內存。

在介紹這幾個大小之間的換算前,我們得先看下圖這個表,這個表決定了映射關係。

最上面2行是我手動加的,前3列分別是size class,object size和span size,根據這3列做size、size class和num of page之間的轉換。

仔細看一遍這個表,再向下看轉換是如何實現的。

Go內存分配那些事,看這一篇就夠,深度好文


在Go內存大小轉換那幅圖中已經標記各大小之間的轉換,分別是數組:class_to_size,size_to_class*和class_to_allocnpages,這3個數組內容,就是跟上表的映射關係匹配的。比如class_to_size,從上表看class 1對應的保存對象大小為8,所以class_to_size[1]=8,span大小為8192Byte,即8KB,為1頁,所以class_to_allocnpages[1]=1。

Go內存分配那些事,看這一篇就夠,深度好文


為何不使用函數計算各種轉換,而是寫成數組?

有1個很重要的原因:空間換時間。你如果仔細觀察了,上表中的轉換,並不能通過簡單的公式進行轉換,比如size和size class的關係,並不是正比的。這些數據是使用較複雜的公式計算出來的,公式在makesizeclass.go中,這其中存在指數運算與for循環,造成每次大小轉換的時間複雜度為O(N*2^N)。另外,對一個程序而言,內存的申請和管理操作是很多的,如果不能快速完成,就是非常的低效。把以上大小轉換寫死到數組裡,做到了把大小轉換的時間複雜度直接降到O(1)。

其他轉換表字段

第4列num of objects代表是當前size class級別的Span可以保存多少對象數量,第5列tail waste是span%obj計算的結果,因為span的大小並不一定是對象大小的整數倍。

最後一列max waste代表最大浪費的內存百分比,計算方法在printComment函數中:

func printComment(w io.Writer, classes []class) {
\tfmt.Fprintf(w, "// %-5s %-9s %-10s %-7s %-10s %-9s\\n", "class", "bytes/obj", "bytes/span", "objects", "tail waste", "max waste")
\tprevSize := 0
\tfor i, c := range classes {
\t\tif i == 0 {
\t\t\tcontinue
\t\t}
\t\tspanSize := c.npages * pageSize
\t\tobjects := spanSize / c.size
\t\ttailWaste := spanSize - c.size*(spanSize/c.size)
\t\tmaxWaste := float64((c.size-prevSize-1)*objects+tailWaste) / float64(spanSize)
\t\tprevSize = c.size
\t\tfmt.Fprintf(w, "// %5d %9d %10d %7d %10d %8.2f%%\\n", i, c.size, spanSize, objects, tailWaste, 100*maxWaste)
\t}
\tfmt.Fprintf(w, "\\n")
}

Span最浪費內存的場景是:Span內的每一個對象空間保存的對象,實際佔用內存是前一個class中對象的大小加1,這樣無法佔用低一級的Span。一個對象空間未被佔用的內存就被浪費了,所以一個Span內對象空間所浪費的內存為:所有對象空間浪費的內存之和+tail waste。

((c.size - (preSize+1)) * objects + tailWaste) / spanSize


Go內存分配那些事,看這一篇就夠,深度好文


感謝foobar的提醒max waste的計算。


Go內存分配

涉及的概念已經講完了,我們看下Go內存分配原理。

Go中的內存分類並不像TCMalloc那樣分成小、中、大對象,但是它的小對象裡又細分了一個Tiny對象,Tiny對象指大小在1Byte到16Byte之間並且不包含指針的對象。小對象和大對象只用大小劃定,無其他區分。

Go內存分配那些事,看這一篇就夠,深度好文


小對象是在mcache中分配的,而大對象是直接從mheap分配的,從小對象的內存分配看起。

小對象分配

Go內存分配那些事,看這一篇就夠,深度好文


大小轉換這一小節,我們介紹了轉換表,size class從1到66共66個,代碼中_NumSizeClasses=67代表了實際使用的size class數量,即67個,從0到67,size class 0實際並未使用到。

上文提到1個size class對應2個span class:

numSpanClasses = _NumSizeClasses * 2

numSpanClasses為span class的數量為134個,所以span class的下標是從0到133,所以上圖中mcache標註了的span class是,span class 0到span class 133。每1個span class都指向1個span,也就是mcache最多有134個span。

為對象尋找span

尋找span的流程如下:

  1. 計算對象所需內存大小size
  2. 根據size到size class映射,計算出所需的size class
  3. 根據size class和對象是否包含指針計算出span class
  4. 獲取該span class指向的span。

以分配一個不包含指針的,大小為24Byte的對象為例。

根據映射表:

// class bytes/obj bytes/span objects tail waste max waste
// 1 8 8192 1024 0 87.50%
// 2 16 8192 512 0 43.75%
// 3 32 8192 256 0 46.88%
// 4 48 8192 170 32 31.52%

size class 3,它的對象大小範圍是(16,32]Byte,24Byte剛好在此區間,所以此對象的size class為3。

Size class到span class的計算如下:

// noscan為true代表對象不包含指針
func makeSpanClass(sizeclass uint8, noscan bool) spanClass {
\treturn spanClass(sizeclass<<1) | spanClass(bool2int(noscan))
}

所以,對應的span class為:

span class = 3 << 1 | 1 = 7

所以該對象需要的是span class 7指向的span。

從span分配對象空間

Span可以按對象大小切成很多份,這些都可以從映射表上計算出來,以size class 3對應的span為例,span大小是8KB,每個對象實際所佔空間為32Byte,這個span就被分成了256塊,可以根據span的起始地址計算出每個對象塊的內存地址。

Go內存分配那些事,看這一篇就夠,深度好文


隨著內存的分配,span中的對象內存塊,有些被佔用,有些未被佔用,比如上圖,整體代表1個span,藍色塊代表已被佔用內存,綠色塊代表未被佔用內存。

當分配內存時,只要快速找到第一個可用的綠色塊,並計算出內存地址即可,如果需要還可以對內存塊數據清零。

span沒有空間怎麼分配對象

span內的所有內存塊都被佔用時,沒有剩餘空間繼續分配對象,mcache會向mcentral申請1個span,mcache拿到span後繼續分配對象。

mcentral向mcache提供span

mcentral和mcache一樣,都是0~133這134個span class級別,但每個級別都保存了2個span list,即2個span鏈表:

  1. nonempty:這個鏈表裡的span,所有span都至少有1個空閒的對象空間。這些span是mcache釋放span時加入到該鏈表的。
  2. empty:這個鏈表裡的span,所有的span都不確定裡面是否有空閒的對象空間。當一個span交給mcache的時候,就會加入到empty鏈表。

這2個東西名稱一直有點繞,建議直接把empty理解為沒有對象空間就好了。

Go內存分配那些事,看這一篇就夠,深度好文


實際代碼中每1個span class對應1個mcentral,圖裡把所有mcentral抽象成1個整體了。

mcache向mcentral要span時,mcentral會先從nonempty搜索滿足條件的span,如果每找到再從emtpy搜索滿足條件的span,然後把找到的span交給mcache。

mheap的span管理

mheap裡保存了2棵二叉排序樹,按span的page數量進行排序:

  1. free:free中保存的span是空閒並且非垃圾回收的span。
  2. scav:scav中保存的是空閒並且已經垃圾回收的span。

如果是垃圾回收導致的span釋放,span會被加入到scav,否則加入到free,比如剛從OS申請的的內存也組成的Span。

Go內存分配那些事,看這一篇就夠,深度好文


mheap中還有arenas,有一組heapArena組成,每一個heapArena都包含了連續的pagesPerArena個span,這個主要是為mheap管理span和垃圾回收服務。

mheap本身是一個全局變量,它其中的數據,也都是從OS直接申請來的內存,並不在mheap所管理的那部分內存內。

mcentral向mheap要span

mcentral向mcache提供span時,如果emtpy裡也沒有符合條件的span,mcentral會向mheap申請span。

mcentral需要向mheap提供需要的內存頁數和span class級別,然後它優先從free中搜索可用的span,如果沒有找到,會從scav中搜索可用的span,如果還沒有找到,它會向OS申請內存,再重新搜索2棵樹,必然能找到span。如果找到的span比需求的span大,則把span進行分割成2個span,其中1個剛好是需求大小,把剩下的span再加入到free中去,然後設置需求span的基本信息,然後交給mcentral。

mheap向OS申請內存

當mheap沒有足夠的內存時,mheap會向OS申請內存,把申請的內存頁保存到span,然後把span插入到free樹 。

在32位系統上,mheap還會預留一部分空間,當mheap沒有空間時,先從預留空間申請,如果預留空間內存也沒有了,才向OS申請。

大對象分配

大對象的分配比小對象省事多了,99%的流程與mcentral向mheap申請內存的相同,所以不重複介紹了,不同的一點在於mheap會記錄一點大對象的統計信息,見mheap.alloc_m()。

Go垃圾回收和內存釋放

如果只申請和分配內存,內存終將枯竭,Go使用垃圾回收收集不再使用的span,調用mspan.scavenge()把span釋放給OS(並非真釋放,只是告訴OS這片內存的信息無用了,如果你需要的話,收回去好了),然後交給mheap,mheap對span進行span的合併,把合併後的span加入scav樹中,等待再分配內存時,由mheap進行內存再分配,Go垃圾回收也是一個很強的主題,計劃後面單獨寫一篇文章介紹。

現在我們關注一下,Go程序是怎麼把內存釋放給操作系統的?

釋放內存的函數是sysUnused,它會被mspan.scavenge()調用:

// MAC下的實現
func sysUnused(v unsafe.Pointer, n uintptr) {
\t// MADV_FREE_REUSABLE is like MADV_FREE except it also propagates
\t// accounting information about the process to task_info.
\tmadvise(v, n, _MADV_FREE_REUSABLE)
}

註釋說_MADV_FREE_REUSABLE與MADV_FREE的功能類似,它的功能是給內核提供一個建議:這個內存地址區間的內存已經不再使用,可以回收。但內核是否回收,以及什麼時候回收,這就是內核的事情了。如果內核真把這片內存回收了,當Go程序再使用這個地址時,內核會重新進行虛擬地址到物理地址的映射。所以在內存充足的情況下,內核也沒有必要立刻回收內存。

4. Go棧內存

最後提一下棧內存。從一個宏觀的角度看,內存管理不應當只有堆,也應當有棧。

每個goroutine都有自己的棧,棧的初始大小是2KB,100萬的goroutine會佔用2G,但goroutine的棧會在2KB不夠用時自動擴容,當擴容為4KB的時候,百萬goroutine會佔用4GB。

關於goroutine棧內存管理,有篇很好的文章,餓了麼框架技術部的專欄文章:《聊一聊goroutine stack》,把裡面的一段內容摘錄下,你感受下:

可以看到在rpc調用(grpc invoke)時,棧會發生擴容(runtime.morestack),也就意味著在讀寫routine內的任何rpc調用都會導致棧擴容, 佔用的內存空間會擴大為原來的兩倍,4kB的棧會變為8kB,100w的連接的內存佔用會從8G擴大為16G(全雙工,不考慮其他開銷),這簡直是噩夢。


另外,再推薦一篇曹大翻譯的一篇彙編入門文章,裡面也介紹了擴棧:第一章: Go 彙編入門 ,順便入門一下彙編。

5. 總結

內存分配原理就不再回顧了,強調2個重要的思想:

  1. 使用緩存提高效率。在存儲的整個體系中到處可見緩存的思想,Go內存分配和管理也使用了緩存,利用緩存一是減少了系統調用的次數,二是降低了鎖的粒度,減少加鎖的次數,從這2點提高了內存管理效率。
  2. 以空間換時間,提高內存管理效率。空間換時間是一種常用的性能優化思想,這種思想其實非常普遍,比如Hash、Map、二叉排序樹等數據結構的本質就是空間換時間,在數據庫中也很常見,比如數據庫索引、索引視圖和數據緩存等,再如Redis等緩存數據庫也是空間換時間的思想。

6. 參考資料

除了文章中已經推薦的文章,再推薦幾篇值得讀的文章:

  1. 全成的內存分配文章,有不少幫助:https://juejin.im/post/5c888a79e51d456ed11955a8#heading-5
  2. 異常詳細的源碼分析文章,看完這篇我就不想寫源碼分析的文章了:https://www.cnblogs.com/zkweb/p/7880099.html
  3. 從硬件講起的一篇文章,也是有點意思:https://www.infoq.cn/article/IEhRLwmmIM7-11RYaLHR
  4. 這篇文章的總流程圖很棒:http://media.newbmiao.com/blog/malloc.png

7. 彩蛋

在查閱資料時,多篇文章都提到了這本書《The Linux Programming Interface》,關於Thread Cache有興趣去讀一下本書第31章。


如果這篇文章對你有幫助,不妨關注下我的Github,有文章會收到通知。本文作者:大彬,原創授權發佈如果喜歡本文,隨意轉載,但請保留此原文鏈接:http://lessisbetter.site/2019/07/06/go-memory-allocation/
"

相關推薦

推薦中...