点评
细微的对象切割
极致的多级缓存
精准的位图管理
这是Go内存分配
各位看官,你希望从这篇文章中得到什么?
阅读的前置知识
什么是内存、虚拟内存、常见的分配算法、内存分配的目标、TCmalloc
Go语言将内存分成了大大小小67个级别的span,其中0级代表特殊的大对象,其大小是不固定的。当具体的对象需要分配内存时,并不是直接分配span,而是分配不同级别的span中的元素。因此span的级别也不是以每个span大小为依据,而是以span中元素的大小为依据。
span等级 元素大小 span大小 对象个数
1 8 8192 1024
2 16 8192 512
3 32 8192 256
4 48 8192 170
5 64 8192 128
...
65 28672 57344 2
66 32768 32768 1
如上所示,第一级元素大小为8字节,span的大小为8192字节,因此第一级中每一个span拥有8192/8 = 1024个。每一个span的大小和span中元素的个数都不是固定的,例如span 65 级别的span大小为57344字节,每一个对象为28672字节,元素个数为2个。Span大小虽然不固定,但其是8K或更大的连续内存区域。
运行时,每一个具体的对象在分配的时候,都需要对齐到指定的大小,例如分配17字节的对象,最终会对应到分配比17字节大并最接近它的元素级别,即等级为3的级别32字节。因此,这种分配方式不可避免的会带来内存的浪费。
为了能够方便的对span进行管理、加速span对象的访问和分配。Go语言采取了三级管理结构,分别为:mcache、mcentral、mheap。
Go 像 TCMalloc 一样为每一个逻辑处理器P 提供一个本地span缓存称作 mcache。如果 协程 需要内存可以直接从 mcache 中获取,由于在同一时间只有一个 协程运行在 逻辑处理器P上,所以中间不需要任何锁的参与。mcache 包含所有大小规格的 mspan 作为缓存,但是每种规格大小只包含一个。除class0外,mcache的span都来自于mcentral中。
mcentral是被所有逻辑处理器P共享的
mcentral 对象收集所有给定规格大小的 span。每一个 mcentral 都包含两个 mspan 的链表:
做这种区主要是为了更快的分配span到mcache中。
除了级别0,每一个级别都会有一个mcentral,管理span列表。
nonempty mspanList -- 有空闲对象的 span链表
empty mspanList -- 没有空闲对象或 span 已经被 mcache 缓存的 span 链表
而所有级别的这些mcentral,其实是一个数组,由 mheap进行管理。
central [numSpanClasses]struct {
mcentral mcentral
pad [cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize]byte
}
mheap的作用不只是管理central,另外大对象也会直接通过mheap进行分配。mheap实现了对于虚拟内存线性地址空间的精准管理,建立了span与具体线性地址空间的联系,保存了分配的位图信息,是管理内存的最核心单元。后面我们还会看到,堆区的内存还被分成了HeapArea大小进行管理。对heap进行的操作必须得全局加锁,而不管是mcache、mcentral都只能看做是某种形式的缓存。
根据对象的大小,Go语言将堆内存中的块分成了HeapArea、chunk、span与page 4种进行管理。其中HeapArea最大,是平台相关的,在uinx 64位系统中占据64M,chunk占据了512KB,span根据级别大小的不同而不同,但是是page的倍数。而一个page占据了8KB。不同的内存块主要用于不同的场景当中,用于高效地对内存进行管理,后面还会详细介绍。
正如前面介绍的,根据对象的不同大小会分配到不同的span中去,在这一小节中,将具体看一看其分配细节。在运行时分配对象的逻辑主要位于mallocgc函数中,这个名字很有意思,malloc代表了分配,gc代表了垃圾回收。意味着此函数还会为了gc做一些位图标记工作。在本小节中,主要关注于内存的分配。
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
// 判断是否小对象, maxSmallSize当前的值是32K
if size <= maxSmallSize {
if noscan && size < maxTinySize {
// 微对象分配
} else {
// 小对象分配
}else{
// 大对象分配
}}
按照对象的大小划分,分为了tiny微小对象、小对象、以及大对象。tiny微小对象是可能的分配流程最长,逻辑链路最复杂的对象分配。并且在介绍tiny微小对象的分配时,其实就已经将小对象、大对象的分配流程介绍了,因此,下一小节,将重点介绍tiny微小对象,并对比介绍几种不同大小对象在分配上的不同。
对于小于16字节的对象,Go语言将其划分为了tiny对象。划分tiny对象的主要目的是为了处理极小的字符串和独立的转义变量。对json的基准测试表明,使用tiny对象减少了12%的分配次数和20%的堆大小[1]
tiny对象会被放入class为2的span中,由上例中知道,class为2的span元素大小为16字节。首先对tiny对象按照2、4、8进行字节对齐。例如字节为1的元素会分配2个字节,字节为7的元素会分配8个字节。
if size&7 == 0 {
off = alignUp(off, 8)
} else if size&3 == 0 {
off = alignUp(off, 4)
} else if size&1 == 0 {
off = alignUp(off, 2)
}
首先查看之前分配的元素中是否有空余的空间。如下所示,如果当前对象要分配8个字节,并且当正在分配的元素可以容纳大小为8,则返回tiny+ooffset的地址,意味着当前的地址往后8个字节都是可以被分配的。
并且现在offset的位置也进行了偏移,为了下一次分配做准备。
如果当前要分配的大小不够,例如要分配16字节的大小,这时就需要找到下一个空闲的元素。因此,tiny分配的第一步是尝试利用分配过的前一个元素的空间,达到节约内存的目的。
off := c.tinyoffset
if off+size <= maxTinySize && c.tiny != 0 {
x = unsafe.Pointer(c.tiny + off)
c.tinyoffset = off + size
return x
}
当前一个元素中没有空间时,第二步将尝试从mcache中查找元素。
首先需要从mcache 中找到对应级别的mspan,mspan中拥有allocCache字段,其作为一个位图,用于标记span元素中的大小。由于allocCache元素为uint64大小,因此其最多一次缓存64位的大小。
func nextFreeFast(s *mspan) gclinkptr {
theBit := sys.Ctz64(s.allocCache)
if theBit < 64 {
result := s.freeindex + uintptr(theBit)
if result < s.nelems {
freeidx := result + 1
if freeidx%64 == 0 && freeidx != s.nelems {
return 0
}
s.allocCache >>= uint(theBit + 1)
s.freeindex = freeidx
s.allocCount++
return gclinkptr(result*s.elemsize + s.base())
}
}
return 0
}
allocCache使用从后往前的方式与span中元素对应起来。例如allocCache中最后一个bit位对应的是span元素中最前的一个元素。
有时候,span中元素的个数大于64个,因此需要专门有一个字段freeindex来标识当前span中元素分配到了哪里。在span中小于freeindex序号的元素意味着都已经分配了,将从freeindex开始继续分配。
因此,只要从acclocCache开始找到哪一位为0即可。假如找到了X位为0,那么X + freeindex 为当前span中可用的元素序号。当allocCache中全部都已经标记为1后,就需要移动freeindex ,并更新allocCache。一直到达span元素末尾为止。
如果当前的span中并没有可以使用的元素,这时就需要从mcentral中加锁查找。
之前介绍过,在mcentral中有两种类型的span链表,分别是有空闲元素的nonempty,以及没有空闲元素的empty链表。会分别遍历这两个列表,查找是否有可用的span。有些读者可能会有疑问,既然是没有空闲元素的empty列表,怎么还需要去遍历呢?这是由于有些span可能已经被垃圾回收器标记为空闲了,只是还没有来得及清理。这些Span在清扫后仍然是可以使用的,因此需要遍历。
func (c *mcentral) cacheSpan() *mspan {
var s *mspan
for s = c.nonempty.first; s != nil; s = s.next {
c.nonempty.remove(s)
c.empty.insertBack(s)
unlock(&c.lock)
goto havespan
}
//
for s = c.empty.first; s != nil; s = s.next {
...
}
如果在mcentral元素中查找到有空闲元素的span,则将其赋值到mcache中,并更新allocCache,同时还需要将span添加到mcentral的empty链表中去。
当在mcentral中找不到可以使用的span后,就需要从mheap中查找。在Go1.12的时候,Go语言采用了Treap 进行内存的管理,Treap 是一种引入了随机数的二叉树搜索树,其实现简单,并且引入的随机数以及必要时的旋转保证了比较好的平衡特性。Michael Knyszek 提出[2]这种方式具有扩展性的问题,由于这棵树是mheap管理,当操作此二叉树的时候都需要维持一个lock。这在密集的对象分配以及逻辑处理器P过多的时候,会导致更长的等待时间。Michael Knyszek 提出用bitmap来管理内存页,并在每个P中维护一份page cache。这就是现在Go语言实现的方式。因此在go1.14之后,我们会看到在每个逻辑处理器P内部都有一个cache。
type pageCache struct {
base uintptr
cache uint64
scav uint64
}
mheap会首先查找在每个逻辑处理器P中pageCache字段的cache。cache也是一个位图,其每一位都代表了一个page(8 KB) 因此,由于cache为uint64类型,其一共可以存储64*8=512KB的缓存。这512KB是连续的虚拟内存。在cache中,1代表未分配的内存,而0代表已分配的内存。base代表该虚拟内存的基地址。当需要分配的页数小于 512/4=128KB时,需要首先从cache中分配。
例如,假如要分配n pages,就需要查找cache中是否有连续n个1位。如果存在,则说明在缓存中查找到了合适的内存,用于初始化span。
当要分配的page过大或者在逻辑处理器P的cache中没有找到可用的页数时,就需要对mheap加锁,并在整个mheap管理的虚拟地址空间的位图中查找是否有可用的pages。而且其在本质上涉及到Go语言是如何对线性的地址空间进行位图管理的。
管理线性的地址空间的位图结构叫做基数树(radix tree), 他和一般的基数树结构有点不太一样,这个名字很大一部分是由于父节点包含了子节点的若干信息。
在该树中的每一个节点对应一个pallocSum结构。其中最底层的叶子节点对应的一个pallocSum结构包含了一个chunk的信息(512 * 8 KB),而除了叶子节点外的节点都包含了连续8个子节点的内存信息。例如,倒数第二层的节点包含了8个叶子节点(即8*chunk)的连续内存信息。因此,越上层的节点,其对应的内存就越多。
type pallocSum uint64
func (p pallocSum) start() uint {
return uint(uint64(p) & (maxPackedValue - 1))
}
// max extracts the max value from a packed sum.
func (p pallocSum) max() uint {
return uint((uint64(p) >> logMaxPackedValue) & (maxPackedValue - 1))
}
// end extracts the end value from a packed sum.
func (p pallocSum) end() uint {
return uint((uint64(p) >> (2 * logMaxPackedValue)) & (maxPackedValue - 1))
}
pallocSum虽然是一个简单的uint64类型,但是分成了开头、中间、末尾3个部分,开头语末尾部分占据了21bit,中间部分占据了22bit。它们分别包含了在这个区域中连续空闲内存页的信息。包括了在开头有多少连续内存页,最大有多少连续内存页,在末尾有多少连续内存页。对于最顶层的节点,由于其中间的max位为22bit,因此一颗完整的基数树最多代表2^21 pages=16G内存。
Go语言并不是一开始就直接从根节点往下查找的,而是首先做了一定的优化,类似于又一级别的缓存。在Go语言中,存储了一个特别的字段searchAddr,看名字就可以猜想到是用于搜索可用内存时使用的。searchAddr有一个重要的设定是在searchAddr地址之前一定是已经分配过的。因此在查找时,只需要往searchAddr地址的后方查找即可跳过查找的节点,减少查找的时间。
在第一次查找时,会首先从当前searchAddr的chunk块中查找是否有对应大小的连续空间。这种优化主要是针对比较小的内存分配(至少小于512KB)时使用的。Go语言对于内存有非常精细化的管理,chunk块的每一个page(8 KB)都有位图表明其是否已经被分配。
每一个chunk都有一个pallocData结构,其中pallocBits管理其分配的位图。pallocBits是一个uint64的大小为8的数组。由于每一位对应着一个page,因此pallocBits总共对应着64*8=512KB,恰好是一个chunk块的大小。位图的对应方式和之前是一样的。
type pallocData struct {
pallocBits
scavenged pageBits
}
type pallocBits [8]uint64
而所有的chunk pallocData都在pageAlloc结构中进行管理。
type pageAlloc struct {
chunks [1 << pallocChunksL1Bits]*[1 << pallocChunksL2Bits]pallocData
}
当所有的内存分配过大或者当前chunk块没有连续的npages空间时,就需要到基数树中从上到下进行查找。基数树有一个特性,即当要分配的内存越大时,它能够越快的查找到当前的基数树中是否有连续的空间能够满足。
在查找基数树的过程中,从上到下,从左到右的查找每一个节点是否符合要求。首先计算
pallocSum字段的开头start有多少连续的内存空间。如果start大于npages,说明我们已经查找到了可用的空间和地址。没有找到时,会计算pallocSum字段的max,即中间有多少连续的内存空间。如果max大于npages,我们需要继续往基数树当前节点对应的下一级继续查找。原因在于,max大于npages,表明当前一定有连续的空间满足npages,但是我们并不知道具体在哪一个位置,必须要继续往下一级查找时才能找到具体可用的地址。如果max也不满足,是不是就不满足了呢?不一定,因为有可能两个节点可以合并起来组成一个更大的连续空间。因此还需要将当前pallocSum计算的end与后一个节点的start加起来查看是否能够组合成大于npages的连续空间。
每一次从基数树中查找到内存,或者事后从操作系统分配内存的时候,都需要更新基数树中每一个节点的pallocSum。
当在基数树都查找不到可用的连续内存时,就需要从操作系统中索取内存。
从操作系统获取内存的代码是平台独立的,例如在unix系统中,最终使用了mmap系统调用向操作系统申请内存。
func sysReserve(v unsafe.Pointer, n uintptr) unsafe.Pointer {
p, err := mmap(v, n, _PROT_NONE, _MAP_ANON|_MAP_PRIVATE, -1, 0)
if err != 0 {
return nil
}
return p
}
每一次向操作系统申请内存时,Go语言规定必须为heapArena 大小的倍数。heapArena是和平台有关的内存大小,在unix 64位系统中,其大小为64M。这意味着即便需要的内存大小很少,最终也至少向操作系统申请64M。多申请的内存可以用于下次分配使用。
Go语言中对于heapArena有精准的管理,精准到每一个指针大小的内存信息,每一个page对应的mspan信息都有记录。
type heapArena struct {
// heapArena中bitmap用每2个bit记录一个指针大小(8byte)的内存信息,主要用于gc
bitmap [heapArenaBitmapBytes]byte
// spans 將 page ID 對應到 arena 裡的 mspan
spans [pagesPerArena]*mspan
...
}
当对象不属于tiny对象时,会判断对象是否为小对象。小对象是小于32KB的对象。Go语言会计算小对象对应于哪一个等级的span,并在指定等级的span的进行查找。
此后和tiny微小对象的分配一样,经历 mcache → mcentral → mheap位图查找 → mheap基数树查找→操作系统分配 的过程。(具体过程见上)
大对象是大小大于32KB的内存,不与 mcache 和 mcentral 沟通,并直接通过 mheap 进行分配。经历mheap基数树查找→操作系统分配 的过程。每一个大对象是一个特殊的span, 它的class是0。(具体过程见上)
各位看官,下一篇文章再见
see you~
本文由哈喽比特于2年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/oZEhCQW0KYWRSlm6ERxhjA
京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。
日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。
据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。
今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。
日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。
近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。
据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。
9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...
9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。
据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。
特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。
据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。
近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。
据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。
9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。
《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。
近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。
社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”
2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。
罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。