系列导读

本文基于64位平台、1Page=8KB、Go1.6

本文为《Go语言轻松进阶》系列第二章「内存与垃圾回收」的第三小节。第二章目录:

  • 知识预备

  • Go内存设计与实现

  • Go的垃圾回收原理(未开始)

查看本系列完整内容请访问

http://tigerb.cn/go/#/kernal/

本文导读

本文将从6个方向层层递进,帮助大家彻底理解Go语言的栈内存和堆内存:

  • 计算机为什么需要内存?

  • 为什么需要栈内存?

  • 为什么需要堆内存?

  • Go语言分配的是虚拟内存

  • Go语言栈内存的分配

    • 分配时机

    • 分配过程

  • Go语言堆内存的分配

    • 分配时机

    • 分配过程

计算机为什么需要内存?

计算机是运行自动化程序的载体,程序(或称之为进程)由可执行代码被执行后产生。那么计算机在运行程序的过程中为什么需要「内存」呢?为了轻松理解这个问题,我们先来简单看看:

  • 代码的本质

  • 可执行代码被执行后,程序的运行过程

代码的本质

简单来看代码主要包含两部分:

  • 指令部分:中央处理器CPU可执行的指令

  • 数据部分:常量等

代码包含了指令,代码被转化为可执行二进制文件,被执行后加载到内存中,中央处理器CPU通过内存获取指令,图示如下:

程序的运行过程

可执行代码文件被执行之后,代码中的待执行指令被加载到了内存当中。这时CPU就可以从内存中获取指令、并执行指令。

CPU执行指令简易过程分为三步:

  • 取指:CPU控制单元从内存中获取指令

  • 译指:CPU控制单元解析从内存中获取指令

  • 执行:CPU运算单元负责执行具体的指令操作

我们通过一个简易的时序图来看看CPU获取并执行指令的过程:

内存的作用

通过以上我们可以基本看出「内存」在计算机中扮演的角色:

  • 暂存二进制可执行代码文件中的指令、预置数据(常量)等

  • 暂存指令执行过程中的中间数据

  • 等等

至此我们基本明白了内存存在的意义。但是呢,我们又经常会听到关于「栈内存」、「堆内存」的概念,那「栈内存」和「堆内存」到底是什么呢?接下来我们继续来看看这个问题。

为什么需要栈内存?

程序在使用内存的过程中,不仅仅只需要关注内存的分配问题,还需要关注到内存使用完毕的回收问题,这就是内存管理中面临的最大两个问题:

  • 内存的分配

  • 内存的回收

有没有简单、高效、且通用的办法统一解决这个内存分配问题呢?

答:最简单、高效的分配和回收方式就是对一段连续内存的「线性分配」,「栈内存」的分配就采用了这种方式。

「栈内存」的简易管理过程:

1. 栈内存分配逻辑:current - alloc

2. 栈内存释放逻辑:current + alloc

通过利用「栈内存」,CPU在执行指令过程中可以高效地存储临时变量。其次:

  • 栈内存的分配过程:看起来像不像数据结构「栈」的入栈过程。

  • 栈内存的释放过程:看起来像不像数据结构「栈」的出栈过程。

所以同时你应该也理解了「为什么称之为栈内存?」。「栈内存」是计算机对连续内存的采取的「线性分配」管理方式,便于高效存储指令运行过程中的临时变量。

为什么需要堆内存?

假如函数A内变量是个指针且被函数B外的代码依赖,如果对应变量内存被回收,这个指针就成了野指针不安全。怎么解决这个问题呢?

答:这就是「堆内存」存在的意义,Go语言会在代码编译期间通过「逃逸分析」把分配在「栈」上的变量分配到「堆」上去。

「堆内存」如何回收呢?

答:堆内存通过「垃圾回收器」回收,关于「垃圾回收器」后续我们详解。

Go语言分配的是虚拟内存

通过以上我们了解了「内存」、「栈内存」、「堆内存」存在的意义。除此之外,还有一个重要的知识点:程序和操作系统实际操作的都是虚拟内存,最终由CPU通过内存管理单元MMU(Memory Manage Unit)把虚拟内存的地址转化为实际的物理内存地址。图示如下:

使用虚拟内存的原因:

  • 对于我们的进程而言,可使用的内存是连续的

  • 安全,防止了进程直接对物理内存的操作(如果进程可以直接操作物理内存,那么存在某个进程篡改其他进程数据的可能)

  • 提升物理内存的利用率,当进程真正要使用物理内存时再分配

  • 虚拟内存和物理内存是通过MMU(管理单元内存Memory Management Unit)映射的

所以,一个很重要的知识点:

结论:Go语言源代码对「栈内存」和「堆内存」的分配、释放等操作,都是对虚拟内存的操作,最终中央处理器CPU会统一通过MMU(管理单元内存Memory Management Unit)转化为实际的物理内存。

也就是说Go语言源代码中:

  • 「栈内存」的分配或释放都是对虚拟内存的操作

  • 「堆内存」的分配或释放都是对虚拟内存的操作

接着我们分别通过分配时机分配过程两部分,来看看Go语言栈内存和堆内存的分配。

Go语言栈内存的分配

Go语言栈内存分配的时机

Goroutinueg0g
Goroutinue
g0
// src/runtime/proc.go::1720
// 创建 m
func allocm(_p_ *p, fn func(), id int64) *m {
    // ...略
    if iscgo || mStackIsSystemAllocated() {
        mp.g0 = malg(-1)
 } else {
        // 创建g0 并申请8KB栈内存
        // 依赖的malg函数
        mp.g0 = malg(8192 * sys.StackGuardMultiplier)
 }
    // ...略
}
g

 

// src/runtime/proc.go::3999
// 创建一个带有任务fn的goroutine
func newproc1(fn *funcval, argp unsafe.Pointer, narg int32, callergp *g, callerpc uintptr) *g {
    // ...略
    newg := gfget(_p_)
    if newg == nil {
      // 全局队列、本地队列找不到g 则 创建一个全新的goroutine
      // _StackMin = 2048
      // 申请2KB栈内存
      // 依赖的malg函数
      newg = malg(_StackMin)
      casgstatus(newg, _Gidle, _Gdead)
      allgadd(newg)
    }
    // ...略
}
malgg
// src/runtime/proc.go::3943
// 创建一个指定栈内存的g
func malg(stacksize int32) *g {
 newg := new(g)
 if stacksize >= 0 {
     // ...略
     systemstack(func() {
     // 分配栈内存
     newg.stack = stackalloc(uint32(stacksize))
  })
     // ...略
 }
 return newg
}

 栈内存分配时机-栈扩容

// src/runtime/stack.go::838
func copystack(gp *g, newsize uintptr) {
 // ...略

 // 分配新的栈空间
 new := stackalloc(uint32(newsize))

    // ...略
}

 

stackalloc

stackalloc

栈内存分配过程

Go语言栈内存的分配按待分配的栈大小分为两大类:

  • 小于32KB的栈内存

  • 大于等于32KB的栈内存

小于32KB栈分配过程

Mmcachestackcache

stackcachestackpool

stackpoolpp.pagecache

p.pagecachemheap

大于等于32KB栈分配过程

stackLarge

stackLargepp.pagecachep.pagecachemheap

Go语言堆内存的分配
mspan

Go语言堆内存分配时机

判断一个变量是否应该分配到「堆内存」的关键点就是:代码编译阶段,编译器会通过逃逸分析判断并标记上该变量是否需要分配到堆上。

通常我们在创建如下变量时,变量都有可能被分配到堆上:

SliceChannelMapMap

涉及相关数据类型的写操作函数整理如下:

表格可左右滑动查看

makeslice(et *_type, len, cap int) unsafe.Pointergrowslice(et *_type, old slice, cap int) slicemakeslicecopy(et *_type, tolen int, fromlen int, from unsafe.Pointer) unsafe.Pointergobytes(p *byte, n int) (b []byte)string[]byteslicebytetostring(buf *tmpBuf, ptr *byte, n int) (str string)[]bytestringrawstring(size int) (s string, b []byte)stringrawbyteslice(size int) (b []byte)[]byterawruneslice(size int) (b []rune)[]runemakechan(t *chantype, size int) *hchanchanfunc newarray(typ *_type, n int) unsafe.Pointermapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointerfunc (h *hmap) newoverflow(t *maptype, b *bmap) *bmap

这里我们以初始化切片的源代码为例来看看切片何时被分配到堆上的逻辑判断:

src/runtime/slice.go::makeslice()

切片分配过程源代码如下:

// 代码位置:src/cmd/compile/internal/gc/walk.go::1316
// 初始化切片
case OMAKESLICE:
    // ...略...
    // 逃逸标识,是否需要逃逸到堆上
    if n.Esc == EscNone {
        // ...略...

        // 不需要逃逸
        // 直接栈上分配内存
        t = types.NewArray(t.Elem(), i) // [r]T
        
        // ...略...
    } else {
        // 需要内存逃逸到堆上
        
        // ...略...

        // 默认使用makeslice64函数从堆上分配内存
        fnname := "makeslice64"
        argtype := types.Types[TINT64]

        // ...略...

        if (len.Type.IsKind(TIDEAL) || maxintval[len.Type.Etype].Cmp(maxintval[TUINT]) <= 0) &&
            (cap.Type.IsKind(TIDEAL) || maxintval[cap.Type.Etype].Cmp(maxintval[TUINT]) <= 0) {
            // 校验通过,则
            // 使用makeslice函数从堆上分配内存
            fnname = "makeslice"
            argtype = types.Types[TINT]
        }

        // ...略...

        // 调用上面指定的runtime函数
        m.Left = mkcall1(fn, types.Types[TUNSAFEPTR], init, typename(t.Elem()), conv(len, argtype), conv(cap, argtype))

        // ...略...
    }

 

mallocgcmallocgc

Go语言堆内存分配过程

堆内存的分配按对象的大小分,主要分为三大类:

  • 微对象 0 < Micro Object < 16B

  • 小对象 16B =< Small Object <= 32KB

  • 大对象 32KB < Large Object

Pmcachemheap

mcachetinymcachealloc

微对象的分配过程

微对象 0 < Micro Object < 16B

mcachetiny

mcachetinymcachealloctiny

申请16B详细过程图示如下:

小对象的分配过程

小对象 16B =< Small Object <= 32KB

mcachealloc

详细分配过程图示如下:

mcacheallocmcentralmspan

mcacheallocmcentralpagecachepagecachemheapmspan

大对象的分配过程

大对象 32KB < Large Object

pagecache

pagecachemheap

总结
MapSlicemcachestackpoolp.pagecachemheapstackLargep.pagecachemheapmcache.tinymcache.allocmcache.allocmcentralp.pagecachemheapp.pagecachemheapmheap