Golang 语言的内存管理

转载:Golang 语言的内存管理

内存分布

什么是虚拟内存?

计算机系统内存管理的一种技术。
每个进程都拥有独立的、连续的、统一的的虚拟地址空间。
通过 MMU 和物理内存映射,高效使用物理内存。
64 位 linux 进程内存分布情况

理论上有 16E 的寻址空间,目前没有操作系统会用到这么大的空间
目前用了 48 位的寻址空间,总的虚拟地址空间为 256TB
用户空间为 128T
用户空间布局和 linux 布局一样
堆和栈:

空间大小:每个进程拥有的栈的大小要远远小于堆的大小。理论上,程序员可申请的堆大小为虚拟内存的大小,进程栈的大小 64bits 的 Windows 默认 1MB,64bits 的 Linux 默认 10MB;
分配效率:栈由操作系统自动分配,会在硬件层级对栈提供支持:分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率比较高。堆则是由库函数或运算符来完成申请与管理,实现机制较为复杂,频繁的内存申请容易产生内存碎片。
分配方式:栈由操作系统自动分配释放 ,用于存放函数的参数值、局部变量等,其操作方式类似于数据结构中的栈;堆由开发人员分配和释放, 若开发人员不释放,程序结束时由 OS 回收,分配方式类似于链表。

数据类型的内存结构

基础类型

类型 长度 默认值 说明
bool 1 false
byte 1 0 uint8
int,uint 4,8 0 默认整数类型,依据目标平台,32 或 64 位
int8,uint8 1 0 -128~127,0~255
int16,uint16 2 0 -32,768~32,767,0~65,535
int32,uint32 4 0 -21亿~21 亿,0~42 亿
int64,uint64 8 0
float32 4 0.0
float64 8 0.0 默认浮点数类型
complex64 8
complex128 16
rune 4 0 Unicode Code Point,int32
uintptr 4,8 0 足以存储指针的uint
string "" 字符串,默认值为空字符串,而非 NULL
array 数组
struct 结构体
function nil 函数
interface nil 接口
map nil 字典,引用类型
slice nil 切片,引用类型
channel nil 通道,引用类型

字符串

type stringStruct struct {
 str unsafe.Pointer
 len int
}

// Variant with *byte pointer type for DWARF debugging.
type stringStructDWARF struct {
 str *byte
 len int
}

类型为 byte 的只读切片([]byte,长度为 len)
结构体大小:16 字节
str 指针指向字符串首字节,以字节为单位存储
字符串长度

通过实例代码,查看字符串长度:

str := "hello北京"
fmt.Println(len(str)) //11
fmt.Println(utf8.RuneCountInString(str)) // 7

for index, runeValue := range str {
  fmt.Printf("%d: %c\n", index, runeValue)
}

// 0:h
// 1:e
// 2:l
// 3:l
// 4:o
// 5:北
// 8:京

使用 len() 获取字符串长度,返回的是字节长度,如果想要获取 unicode 长度,需要使用 utf8 包的方法。

需要注意的是,在使用 for range 遍历字符串时,index 是按照字节顺序产生的,value 是以 unicode 顺序产生的。

字符串连接

由于字符串是只读的,所以字符串连接操作必然会涉及到内存拷贝。

方式 用法 特点 适用场景
+操作符 s += "hello" + "北京" + "2021" 每次拼接,都申请新的内存块,只能是字符串类型使用,可读性强,性能一般 少量字符串拼接时
fmt.Sprint s = fmt.Sprint("hello","北京",2021) 内部使用[]byte实现,涉及到类型转换,可以拼接其他类型,性能一般 少量非字符串类型拼接时
strings.Join s = strings.Join([]string{"hello","北京","2021"},"") 只能拼接字符串数组,不灵活 已存在字符串数组时
bytes.Buffer var b bytes.Buffer
b.WriteString("hello")
b.WriteString("北京")
b.WriteString("2021")
s = b.String()
拼接字符串、字符和 unicode,底层使用[]byte,设计到 string 和 []byte 之间转换 少量字符串拼接时
strings.Builder var b strings.Builder
b.WriteString("hello")
b.WriteString("北京")
b.WriteString("2021")
s = b.String()
拼接字符串、字符和 unicode,使用unsafe.Pointer 优化了 string 和 []byte 之间的转换 大量字符串拼接

少量字符串拼接时,推荐使用+操作符,可读性强;如果性能要求高时,推荐使用 string.Builder。

切片 - slice

type slice struct {
 array unsafe.Pointer
 len   int
 cap   int
}

切片为:数组的引用
结构体程度:8+8+8=24 字节
增加切片元素:append 方法 cap 不够时,cap < 1024,cap 容量成倍增加;cap >= 1024 时,按照 1.25 倍扩容。

a := []int{1}
fmt.Printf("len: %d cap: %d data: %+v\n", len(a), cap(a), a)
a = append(a, 2)
fmt.Printf("len: %d cap: %d data: %+v\n", len(a), cap(a), a)
a = append(a, 3)
fmt.Printf("len: %d cap: %d data: %+v\n", len(a), cap(a), a)
a = append(a, 4, 5)
fmt.Printf("len: %d cap: %d data: %+v\n", len(a), cap(a), a)
// 输出结果:
// len: 1 cap: 1 data: [1]
// len: 2 cap: 2 data: [1 2]
// len: 3 cap: 4 data: [1 2 3]
// len: 5 cap: 8 data: [1 2 3 4 5]

底层数组扩容时,运行时会新生成一块扩容后大小的内存,然后把数据拷贝过去,这里涉及到一定的内存拷贝开销,建议尽量计算好需要使用的容量,避免自动扩容。

切片作为函数参数传递

func main() {
  a := []int{1,2,3}
  fmt.Printf("len: %d cap: %d data: %+v\n", len(a), cap(a), a)
  appendSlice(a)
  fmt.Printf("len: %d cap: %d data: %+v\n", len(a), cap(a), a)
}

func appendSlice(a []int) {
  a = append(a, 4)
}
// 输出结果:
// len: 3 cap: 3 data: [1 2 3]
// len: 3 cap: 3 data: [1 2 3]

通过代码输出结果可以看到,切片作为函数参数传递,没有追加成功。

a 传入 appendSlice 后,属于值传递,新生成一个和 a 一样的切片结构体 a1,指向同样的底层数组。

a = append(a, 4) 实际上是操作 a1,a 的 len、cap 未变,所以两次打印的数据一样。

注意:切片是结构体,传入函数会新生成结构体实参,如果需要在函数内部改变切片值,需要显示返回:

func appendSlice(a []int) []int {
  a = append(a, 4)
  return a
}

另外,还可以使用指针传递。

指针传参

func main() {
  a := []int{1,2,3}
  fmt.Printf("len: %d cap: %d data: %+v\n", len(a), cap(a), a)
  appendSlice(a)
  fmt.Printf("len: %d cap: %d data: %+v\n", len(a), cap(a), a)
}

func appendSlice(a *[]int) {
  *a = append(*a, 4)
}
// 输出结果:
// len: 3 cap: 3 data: [1 2 3]
// len: 4 cap: 6 data: [1 2 3 4]

Golang 语言中的指针 简约版 C 语言指针

  • 类型安全:不支持指针运算
  • 灵活性
  • 参数传递
  • 固定大小:8 字节

Map

  • 结构体
  • 底层使用桶来存储散列值
  • 使用 hash 算法选择具体的桶
  • 2 倍速度扩容
// A header for a Go map.
type hmap struct {
 // Note: the format of the hmap is also encoded in cmd/compile/internal/gc/reflect.go.
 // Make sure this stays in sync with the compiler's definition.
 count     int // # live cells == size of map.  Must be first (used by len() builtin)
 flags     uint8
 B         uint8  // log_2 of # of buckets (can hold up to loadFactor * 2^B items)
 noverflow uint16 // approximate number of overflow buckets; see incrnoverflow for details
 hash0     uint32 // hash seed

 buckets    unsafe.Pointer // array of 2^B Buckets. may be nil if count==0.
 oldbuckets unsafe.Pointer // previous bucket array of half the size, non-nil only when growing
 nevacuate  uintptr        // progress counter for evacuation (buckets less than this have been evacuated)

 extra *mapextra // optional fields
}

map 作为函数的参数传递时,建议使用指针的方式传递。

interface

  • 结构体
  • 方法集合,duck-type
  • 隐藏实现
type Stringer interface {
  String() string
}

type Binary uint64

func (i Binary) String() string {
  return strconv.FormatUint(i.Get(), 2)
}

func (i Binary) Get() uint64 {
  return uint64(i)
}

func main() {
  var b Binary = 200
  s := Stringer(b)
  fmt.Print(s.String())
}

chan

  • 结构体
  • 存储 goroutine 之间发送的消息和状态
type hchan struct {
 qcount   uint           // total data in the queue
 dataqsiz uint           // size of the circular queue
 buf      unsafe.Pointer // points to an array of dataqsiz elements
 elemsize uint16
 closed   uint32
 elemtype *_type // element type
 sendx    uint   // send index
 recvx    uint   // receive index
 recvq    waitq  // list of recv waiters
 sendq    waitq  // list of send waiters

 // lock protects all fields in hchan, as well as several
 // fields in sudogs blocked on this channel.
 //
 // Do not change another G's status while holding this lock
 // (in particular, do not ready a G), as this can deadlock
 // with stack shrinking.
 lock mutex
}

chan 作为函数参数传递时,建议使用指针的方式传递。

编译器处理

Go 语言编译器与内存 和 C 语言编译器一样,Go 语言编译器也将 Go 代码转换为符合 Linux 进程内存规范的二进制代码:

  • 执行代码加载到 Text 段
  • 全局变量加载到 Data 段
  • 临时变量和函数执行都会通过栈的 Push 和 Pop 来执行
  • 堆上分配程序运行时申请的内存

逃逸分析

  • 编译期间确定一个对象放栈上还是放堆上
  • 编译器如果能在编译期间确定变量的生命周期,就会在栈上分配,否则就是逃逸行为,需要在堆上分配内存。分配效率,栈大于堆,空间大小,堆大于栈。
  • 需要尽量避免逃逸行为

逃逸的几种典型情况

  • 函数返回内部变量的指针
  • 发送指针或带有指针的值到 channel 中
  • 在一个切片上存储指针或带指针的值
  • slice 的背后数组被重新分配了,因为 append 时可能会超出其容量(cap)
  • 在 interface 类型上调用方法

检查是否逃逸:go build -gcflags "-m"

type S struct {}

func main() {
  var x S
  _ = *ref(x)
}

func ref(z S) *S {
  return &z
}

内存分配

内存分配算法的基本策略:

  1. 每次从操作系统申请一大块内存(比如 1MB),以减少系统调用。
  2. 将申请到的大块内存按照特定大小预先切分成小块,构成链表。
  3. 为对象分配内存时,只需从大小合适的链表提取一个小块即可。
  4. 回收对象内存时,将该小块内存重新归还到原链表,以便复用。
  5. 如闲置内存过多,则尝试归还部分内存给操作系统,降低整体开销。

优秀的内存分配器必须要在性能和内存利用率之间做到平衡,Golang 语言的内存分配器使用的内存分配算法是 tcmalloc。

在 tcmalloc 内存管理内部又分为两部分:线程内存(thread memory)和页堆(page heap)。

  1. 每一个线程都可以获得一个用于无锁分配小对象的缓存,这样可以让并行程序分配小对象(小于等于 32kb)非常高效。
  2. tcmalloc 管理的堆由一组页组成,一组连续的页被表示为 span。当分配的对象大于 32kb,将使用页堆(page heap)进行内存分配。

Golang 语言的内存分配器由三种组件组成,

  • cache:每个运行期工作线程都会绑定一个 cache,用于无锁 object 分配。
  • central:为所有 cache 提供切分好的后备 span 资源。
  • heap:管理限制 span,需要时向操作系统申请新内存。

Golang 语言的内存分配器分配流程:

  1. 计算待分配对象对应的规格(size class)。
  2. 从 cache.alloc 数组找到规格相同的 span。
  3. 从 span.freelist 链表提取可用 object。
  4. 如 span.freelist 为空,从central 获取新 span。
  5. 如 central.nonempty 为空,从 heap.free/freelarge 获取,并切分成 object 链表。
  6. 如 heap 没有大小合适的闲置 span,向操作系统申请新内存块。

总结

本文开篇简要介绍了内存分布的相关知识,接着主要是介绍 Golang 语言数据类型的内存结构,最后介绍 Golang 语言的编译器和内存分配的知识。限于篇幅,本文未介绍垃圾回收(GC)相关的知识,我准备单开一篇文章来介绍。

本文重点是希望可以帮助读者了解 Golang 语言数据类型的内存结构,有助于在开发时避开一些隐藏的「坑」。

关于 Golang 编译器和内存分配的知识,有很多文章或书籍做了深入讲解,文末参考资料也列出一些,读者可以自行查找相关资料或查看 runtime 源码。推荐阅读「深入理解计算机系统」,可以帮助您更好地去理解Golang语言的内存分配。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,657评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,662评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,143评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,732评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,837评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,036评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,126评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,868评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,315评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,641评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,773评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,470评论 4 333
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,126评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,859评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,095评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,584评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,676评论 2 351

推荐阅读更多精彩内容