【Go】Golang内存管理篇 ⑤

文章目录

Golang 内存模型与分配机制

Golang 内存模型

Golang 的内存模型设计的几个核心要点:

  • 以空间换时间,一次缓存,多次复用
  • 由于每次向操作系统申请内存的操作很重,那么不妨一次多申请一些,以备后用.

Golang 中的堆 mheap 正是基于该思想,产生的数据结构. 我们可以从两个视角来解决 Golang 运行时的堆:

  • 对操作系统而言,这是用户进程中缓存的内存

  • 对于 Go 进程内部,堆是所有对象的内存起源

堆是 Go 运行时中最大的临界共享资源,这意味着每次存取都要加锁,在性能层面是一件很可怕的事情.

mcache、mcentral、mcache模型

在解决这个问题,Golang 在堆 mheap 之上,依次细化粒度,建立了 mcentral、mcache 的模型:

  • mheap:全局的内存起源,访问要加全局锁
  • mcentral:每种对象大小规格(全局共划分为 68 种)对应的缓存,锁的粒度也仅限于同一种规格以内,管理全局线程的 mspan;
  • mcache:每个 P(正是 GMP 中的 P)持有一份的内存缓存,访问时无锁,(本地缓存) 管理当前 P 的 mspan;

mspan:最小的管理、分配单元

  • page:最小的存储单元.
  • page: 最小的存储单元, 内存页,一块 8K 大小的内存空间。Go 与操作系统之间的内存申请和释放,都是以 page 为单位的。

mspan 大小为 page 的整数倍,且从 8B 到 80 KB 被划分为 67 种不同的规格,分配对象时,会根据大小映射到不同规格的 mspan,从中获取空间.(go 会根据当前所需对象大小来分配;)

多规格 mspan 下产生的特点:

  • 根据规格大小,产生了等级的制度
  • 消除了外部碎片,但不可避免会有内部碎片
  • 宏观上能提高整体空间利用率
  • 正是因为有了规格等级的概念,才支持 mcentral 实现细锁化
    在这里插入图片描述

内存布局

在这里插入图片描述

  • 多级规格,提高利用率
    • mspan
    • mspanClass(规格)

在这里插入图片描述

我们需要先知道几个重要的概念:

  • span: 内存块,一个或多个连续的 page 组成一个 span。
  • sizeclass: 空间规格,每个 span 都带有一个 sizeclass,标记着该 span 中的 page 应该如何使用。
  • object: 对象,用来存储一个变量数据内存空间,一个 span 在初始化时,会被切割成一堆等大的 object。假设 object 的大小是 16B,span 大小是 8K,那么就会把 span 中的 page 就会被初始化 8K / 16B = 512 个 object。

在这里插入图片描述

内存分级分配

在这里插入图片描述

  • 如果 mcache 内存不够,则通过 mcentral 申请 mspan;
  • 如果 mcentral 内存不够,则向 mheap 申请
  • 如果 mheap 也不够就向系统内存申请;

不同类型的对象,会有着不同的分配策略,这些内容在 mallocgc 方法中都有体现.

核心流程类似于读多级缓存的过程,由上而下,每一步只要成功则直接返回. 若失败,则由下层方法兜底.

对于微对象的分配流程:

  • (1)从 P 专属 mcache 的 tiny 分配器取内存(无锁)

  • (2)根据所属的 spanClass,从 P 专属 mcache 缓存的 mspan 中取内存(无锁)

  • (3)根据所属的 spanClass 从对应的 mcentral 中取 mspan 填充到 mcache,然后从 mspan 中取内存(spanClass 粒度锁)

  • (4)根据所属的 spanClass,从 mheap 的页分配器 pageAlloc 取得足够数量空闲页组装成 mspan 填充到 mcache,然后从 mspan 中取内存(全局锁)

  • (5)mheap 向操作系统申请内存,更新页分配器的索引信息,然后重复(4).

对于小对象的分配流程是跳过(1)步,执行上述流程的(2)-(5)步;

对于大对象的分配流程是跳过(1)-(3)步,执行上述流程的(4)-(5)步.

内存分配总结

一般小对象通过 mcache 分配内存;大对象则直接由 mheap 分配内存。

  • Go 在程序启动时,会向操作系统申请一大块内存,由 mheap 结构全局管理(现在 Go 版本不需要连续地址了,所以不会申请一大堆地址)

  • Go 内存管理的基本单元是 mspan,每种 mspan 可以分配特定大小的 object

  • mcache, mcentral, mheap 是 Go 内存管理的三大组件,

    • mcache 管理线程在本地缓存的 mspan;
    • mcentral 管理全局的 mspan 供所有线程

在这里插入图片描述

堆栈&&逃逸分析

堆和栈区别

1.空间分配区别:

  • 栈:由操作系统(编译器)自动分配释放,存放函数的参数值,局部变量的值等,其操作方式类似于数据结构中的栈,这些参数会随着函数的创建而创建,函数的返回而销毁。

  • 堆:一般由代码分配释放,若代码没有显式释放,程序结束时可能由OS回收,分配方式类似链表。

2.缓存方式区别:

  • 栈:使用的是一级缓存,通常都是被调用时处于存储空间中,调用完毕立即释放。
  • 堆:存放在二级缓存中,生命周期由垃圾回收算法来决定。

3.数据结构区别:

  • 栈:先进后出的线性结构。
  • 堆:类似于一颗树。

Go 有两个地方可以分配内存:一个全局堆空间用来动态分配内存,另一个是每个 goroutine 都有的自身栈空间。

堆区的内存一般由编译器和工程师自己共同进行管理分配,交给 Runtime GC 来释放。堆上分配必须找到一块足够大的内存来存放新的变量数据。后续释放时,垃圾回收器扫描堆空间寻找不再被使用的对象。

逃逸分析

逃逸分析是一种确定指针动态范围的方法,简单来说就是分析在程序的哪些地方可以访问到该指针。通俗的讲,逃逸分析就是确定一个变量要放堆上还是栈上。

常见的逃逸场景

1.申请过大的空间:make的大小设定的很大。 引用指针变量一定逃逸(返回值、参数,直接修改指针变量),一定会逃逸。

2.Slice、Map、Channel里面有指针,一定会逃逸。

先来说一下通过go编译器查看内存逃逸方式go build -gcflags=-m xxx.go

  • 在方法内把局部变量指针返回并被引用。 局部变量原本应该在栈中分配,在栈中回收。但是由于返回时被外部引用,因此其生命周期大于栈,则溢出,因此要分配到堆上。

  • Slice、Map、Channel里面有指针,一定会逃逸

    • channel: 在编译时,是没有办法知道哪个 goroutine 会在 channel 上接收数据的。所以编译器没法知道变量什么时候才会被释放,一般都会逃逸到堆上分配。
    • slice: 在一个切片上存储指针或带指针的值。** 一个典型的例子就是 []*string 。这会导致切片的内容逃逸。尽管其后面的数组可能是在栈上分配的,但其引用的值一定是在堆上。
  • slice 的背后数组被重新分配了,因为 append 时可能会超出其容量( cap )。 slice 初始化的地方在编译时是可以知道的,它最开始会在栈上分配。如果切片背后的存储要基于运行时的数据进行扩充,就会在堆上分配。

  • 在 interface 类型上调用方法。 在 interface 类型上调用方法都是动态调度的 —— 方法的真正实现只能在运行时知道。想像一个 io.Reader 类型的变量 r , 调用 r.Read(b) 会使得 r 的值和切片b 的背后存储都逃逸掉,所以会在堆上分配。

  • 闭包引用对象逃逸,也会发生逃逸
    尽管能够符合分配到栈的场景,但是其大小不能够在在编译时候确定的情况,也会分配到堆上

  • slice 的背后数组被重新分配了,因为 append 时可能会超出其容量( cap )

https://blog.csdn.net/qq_35703848/article/details/103862437

为什么需要逃逸分析

逃逸分析的优势

  • 通过逃逸分析,可以尽量把那些不需要分配到堆上的变量直接分配到栈上,堆上的变量少了,会减轻分配堆内存的开销,同时也会减少gc的压力,提高程序的运行速度。
  • 减少内存碎片的产生(动态分配产生一定量碎片问题)
  • 减轻分配堆内存的开销,提高程序的运行速度(申请、分配、回收内存的系统开销增大。)

https://www.cnblogs.com/iqxqzx/p/14032884.html

“通过检查变量的作用域是否超出了它所在的栈来决定是否将它分配在堆上”的技术,其中“变量的作用域超出了它所在的栈”这种行为即被称为逃逸。逃逸分析在大多数语言里属于静态分析:在编译期由静态代码分析来决定一个值是否能被分配在栈帧上,还是需要“逃逸”到堆上。

内存逃逸

为什么要尽量避免内存逃逸?
因为如果变量的内存发生逃逸,它的生命周期就是不可知的,其会被分配到堆上,而堆上分配内存不能像栈一样会自动释放,为了解放程序员双手,专注于业务的实现,go实现了gc垃圾回收机制,但gc会影响程序运行性能,所以要尽量减少程序的gc操作。

常见内存逃逸情况
1、在方法内把局部变量指针返回,被外部引用,其生命周期大于栈,则溢出。
2、发送指针或带有指针的值到channel,因为编译时候无法知道那个goroutine会在channel接受数据,编译器无法知道什么时候释放。
3、在一个切片上存储指针或带指针的值。比如[]*string,导致切片内容逃逸,其引用值一直在堆上。
4、因为切片的append导致超出容量,切片重新分配地址,切片背后的存储基于运行时的数据进行扩充,就会在堆上分配。
5、在interface类型上调用方法,在Interface调用方法是动态调度的,只有在运行时才知道。

如何避免

1、go语言的接口类型方法调用是动态,因此不能在编译阶段确定,所有类型结构转换成接口的过程会涉及到内存逃逸发生,在频次访问较高的函数尽量调用接口。
2、不要盲目使用变量指针作为参数,虽然减少了复制,但变量逃逸的开销更大。
3、预先设定好slice长度,避免频繁超出容量,重新分配。

内存结构

内存管理

TCMalloc 是 Thread Cache Malloc 的简称,是Go 内存管理的起源,Go的内存管理是借鉴了TCMalloc:

Tcmalloc(Thread Caching Malloc) 是 google 为 c 语言开发的运行时内存分配算法. 其核心思想是多级管理,从而降低锁的粒度. Go runtime 的内存分配就采用了 Tcmalloc 算法.

内存碎片
随着内存不断的申请和释放,内存上会存在大量的碎片,降低内存的使用率。为了解决内存碎片,可以将2个连续的未使用的内存块合并,减少碎片。

大锁
同一进程下的所有线程共享相同的内存空间,它们申请内存时需要加锁,如果不加锁就存在同一块内存被2个线程同时访问的问题。

1.1 go runtime 初始化内存分配、回收(Tcmalloc)

内置函数newobject会通过调用另外一个内置函数mallocgc在堆上分配新内存。

在Go里面有两种内存分配策略,

  • 一种适用于程序里小内存块的申请,
  • 另一种适用于大内存块的申请,大内存块指的是大于32KB。

分配、回收

1.预先从操作系统申请一大块内存。(当空间不足时,向系统申请一块较大的内存,如100KB或者1MB申请到的内存块按特定的size)

2.内存分配算法采用Google的 TCMalloc算法,预先将申请的内存分成不同大小的内存集合(并用链表管理),给不同场景的内存使用。

3.

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值