MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Go内存分配器的工作原理

2023-04-277.9k 阅读

Go内存分配器概述

Go语言的内存分配器是其运行时系统的重要组成部分,负责高效地分配和管理程序运行过程中所需的内存。Go内存分配器的设计目标是在多线程环境下提供高效、低延迟的内存分配与回收,同时尽量减少内存碎片的产生。

在底层,Go内存分配器基于tcmalloc(thread - caching malloc)算法思想进行了优化和改进。tcmalloc是Google开发的一个高效的内存分配器,特别适用于多线程应用程序。Go内存分配器在此基础上,结合Go语言自身的特性,如垃圾回收机制等,构建了一套独特的内存管理体系。

内存分配器的结构组成

  1. 堆(Heap)
    • Go程序运行时的大部分内存都分配在堆上。堆是一个由Go运行时管理的大内存区域,用于存储程序运行过程中动态分配的对象。
    • 堆内存的管理是Go内存分配器的核心任务之一。堆内存需要在不同的线程和对象之间高效地分配和回收,以满足程序的动态内存需求。
  2. 线程缓存(Thread Cache)
    • 每个Go运行时的线程都有自己的线程缓存。线程缓存是一个小型的内存缓存,用于快速分配和回收一些小对象。
    • 线程缓存的存在大大减少了线程在分配小对象时对堆的竞争。当一个线程需要分配小对象时,首先会尝试从线程缓存中获取内存。如果线程缓存中没有足够的空间,则会从堆中获取一批内存块填充到线程缓存中。
  3. 中心缓存(Central Cache)
    • 中心缓存是多个线程共享的缓存。它的作用是协调不同线程缓存与堆之间的内存分配和回收。
    • 当某个线程缓存中的内存耗尽时,它会从中心缓存中获取一批内存块。而当线程缓存中有多余的内存块时,会将其归还到中心缓存。中心缓存的存在减少了线程对堆的直接访问频率,提高了内存分配的效率。
  4. 页堆(Page Heap)
    • 页堆是堆内存的管理单元。它将堆内存划分为不同大小的页(Page),每个页有固定的大小(如8KB)。
    • 页堆负责管理这些页的分配和回收。当需要分配大对象或者线程缓存、中心缓存需要从堆中获取内存时,页堆会根据请求的大小分配相应的页。同时,当有页不再被使用时,页堆会将其回收,以便重新分配。

内存分配的粒度

  1. 小对象分配
    • 在Go中,通常将小于32KB的对象视为小对象。小对象的分配主要通过线程缓存和中心缓存来完成。
    • 例如,假设有如下Go代码:
package main

import "fmt"

func main() {
    var smallObj struct {
        a int32
        b int32
    }
    smallObj.a = 10
    smallObj.b = 20
    fmt.Printf("Small object: a = %d, b = %d\n", smallObj.a, smallObj.b)
}
  • 在这段代码中,smallObj结构体大小相对较小,会被视为小对象。在分配smallObj时,Go内存分配器首先会在线程缓存中查找是否有合适的空闲内存块。如果线程缓存中有足够大小的空闲块,则直接将其分配给smallObj。如果线程缓存中没有合适的块,线程会从中心缓存获取一批内存块填充到线程缓存,然后再从线程缓存中分配给smallObj
  1. 大对象分配
    • 大于等于32KB的对象被视为大对象。大对象的分配直接从页堆中进行。
    • 以下面的代码为例:
package main

import "fmt"

func main() {
    bigObj := make([]byte, 32768)
    for i := range bigObj {
        bigObj[i] = byte(i % 256)
    }
    fmt.Printf("Big object length: %d\n", len(bigObj))
}
  • 这里通过make([]byte, 32768)创建了一个大小为32KB的字节切片,属于大对象。内存分配器会直接从页堆中分配一个或多个合适大小的页给这个大对象。由于大对象直接从页堆分配,不会经过线程缓存和中心缓存,所以在多线程环境下,大对象的分配可能会导致更多的竞争。

线程缓存(Thread Cache)的工作原理

  1. 缓存结构
    • 线程缓存由多个不同大小的链表组成,每个链表用于存储特定大小范围的空闲内存块。
    • 这些链表按照内存块的大小进行排序,较小的内存块在前面的链表,较大的在后面。这样的设计使得线程在分配对象时能够快速找到合适大小的内存块。
  2. 分配过程
    • 当线程需要分配一个小对象时,它会根据对象的大小计算出应该从哪个链表获取内存块。
    • 例如,假设一个小对象大小为16字节,线程会找到存储16字节大小内存块的链表。如果该链表不为空,则直接从链表头部取出一个内存块分配给对象。如果链表为空,线程会从中心缓存获取一批相同大小的内存块填充到该链表,然后再进行分配。
  3. 回收过程
    • 当一个小对象被释放时,线程会将其对应的内存块放回线程缓存中相应大小的链表。
    • 例如,一个16字节大小的对象被释放,其内存块会被放回存储16字节大小内存块的链表尾部。如果链表中的内存块数量达到一定阈值,线程会将部分内存块归还到中心缓存,以避免线程缓存占用过多内存。

中心缓存(Central Cache)的工作原理

  1. 缓存结构
    • 中心缓存同样由多个链表组成,每个链表对应一种特定大小的内存块,与线程缓存的链表结构类似。
    • 中心缓存的链表用于存储从页堆获取的空闲内存块,供线程缓存获取。
  2. 分配过程
    • 当某个线程缓存需要获取一批内存块时,它会向中心缓存请求。中心缓存根据请求的内存块大小,从对应的链表中取出一批内存块给线程缓存。
    • 如果中心缓存对应链表中的内存块数量不足,中心缓存会从页堆获取一批新的内存块填充到链表,然后再满足线程缓存的请求。
  3. 回收过程
    • 当线程缓存中有多余的内存块需要归还时,这些内存块会被放入中心缓存相应大小的链表。
    • 中心缓存会定期将链表中的内存块合并和归还到页堆。例如,如果某个链表中的内存块数量达到一定程度,中心缓存会将这些内存块合并成更大的块,然后归还到页堆,以便页堆重新分配。

页堆(Page Heap)的工作原理

  1. 页的管理
    • 页堆将堆内存划分为不同大小的页,常见的页大小为8KB。页堆使用一种数据结构(如二叉树)来管理这些页的分配和回收。
    • 每个页都有一个状态标识,如“已分配”、“空闲”等。页堆通过这个状态标识来跟踪页的使用情况。
  2. 分配过程
    • 当需要分配大对象或者中心缓存需要从堆中获取内存时,页堆会根据请求的大小查找合适的空闲页。
    • 如果请求的大小小于一个页的大小,页堆会将一个页划分为多个小块进行分配。例如,如果请求大小为4KB,而页大小为8KB,页堆会将一个8KB的页划分为两个4KB的块,将其中一个分配出去,另一个保留在页堆中。如果请求大小大于等于一个页的大小,则直接分配一个或多个完整的页。
  3. 回收过程
    • 当有页不再被使用时,页堆会将其标记为空闲,并将其重新纳入管理。
    • 如果相邻的页都变为空闲,页堆会将它们合并成一个更大的页。例如,两个相邻的8KB空闲页会被合并成一个16KB的页,这样可以减少内存碎片,提高内存利用率。

与垃圾回收(GC)的协同工作

  1. 标记阶段
    • 在垃圾回收的标记阶段,Go垃圾回收器会遍历程序中的所有对象,标记出所有可达对象。
    • 内存分配器在这个过程中会暂停新的内存分配操作,以确保标记过程的准确性。同时,内存分配器会协助垃圾回收器记录对象之间的引用关系,以便垃圾回收器能够准确地标记出可达对象。
  2. 清除阶段
    • 在垃圾回收的清除阶段,垃圾回收器会回收所有未被标记的对象所占用的内存。
    • 内存分配器会与垃圾回收器协作,将这些回收的内存重新纳入管理。对于小对象,回收的内存会被归还到线程缓存或中心缓存。对于大对象,回收的内存会被归还到页堆,作为空闲页供后续分配使用。

内存分配器的优化与调优

  1. 优化策略
    • 减少内存碎片:通过合理的内存块大小划分和合并策略,如在页堆中合并相邻的空闲页,以及在中心缓存中合并相同大小的内存块,减少内存碎片的产生。
    • 提高缓存命中率:优化线程缓存和中心缓存的结构和管理策略,使得对象分配时能够尽可能地从缓存中获取内存,提高缓存命中率,减少对堆的直接访问。
  2. 调优参数
    • Go运行时提供了一些环境变量用于调优内存分配器,如GODEBUG。通过设置GODEBUG的相关参数,可以调整垃圾回收的频率、内存分配的策略等。例如,设置GODEBUG=gctrace=1可以在每次垃圾回收时打印详细的垃圾回收信息,帮助开发者分析和优化内存使用情况。

示例代码分析

  1. 小对象分配示例
package main

import (
    "fmt"
    "runtime"
)

func main() {
    var smallObj struct {
        a int16
        b int16
    }
    smallObj.a = 10
    smallObj.b = 20
    fmt.Printf("Small object: a = %d, b = %d\n", smallObj.a, smallObj.b)

    // 获取内存统计信息
    var m runtime.MemStats
    runtime.ReadMemStats(&m)
    fmt.Printf("Alloc = %v KiB\n", m.Alloc/1024)
}
  • 在这段代码中,首先定义了一个小对象smallObj。当程序运行到分配smallObj时,内存分配器会优先在线程缓存中查找合适的内存块。通过runtime.ReadMemStats函数可以获取当前程序的内存统计信息,m.Alloc表示当前堆上已分配的字节数。在分配smallObj后,可以观察到Alloc的变化,从而了解小对象分配对内存使用的影响。
  1. 大对象分配示例
package main

import (
    "fmt"
    "runtime"
)

func main() {
    bigObj := make([]byte, 32768)
    for i := range bigObj {
        bigObj[i] = byte(i % 256)
    }
    fmt.Printf("Big object length: %d\n", len(bigObj))

    // 获取内存统计信息
    var m runtime.MemStats
    runtime.ReadMemStats(&m)
    fmt.Printf("Alloc = %v KiB\n", m.Alloc/1024)
}
  • 此代码创建了一个大小为32KB的大对象bigObj。由于是大对象,内存分配器会直接从页堆分配内存。同样通过runtime.ReadMemStats获取内存统计信息,可以看到在分配大对象后Alloc的显著增加,直观地展示大对象分配对内存的占用情况。

总结内存分配器的性能影响因素

  1. 对象大小分布:如果程序中大量分配小对象,线程缓存和中心缓存的性能对整体内存分配性能影响较大。而如果程序中频繁分配大对象,则页堆的分配和回收效率成为关键。
  2. 多线程并发程度:在多线程环境下,线程对内存分配资源的竞争会影响性能。合理的线程缓存设计和中心缓存协调机制可以减少竞争,提高并发性能。
  3. 垃圾回收频率:垃圾回收与内存分配器紧密协作,垃圾回收的频率过高或过低都会影响内存分配的性能。适当调整垃圾回收的参数,使其与内存分配的节奏相匹配,能够提高整体性能。

通过深入理解Go内存分配器的工作原理、结构组成以及与垃圾回收的协同工作机制,开发者可以更好地优化Go程序的内存使用,提高程序的性能和稳定性。在实际开发中,根据程序的特点和需求,合理调整内存分配器的相关参数和优化策略,能够有效地提升程序的运行效率。