.NET Core GC 的设计
作者: Maoni Stephens ( @maoni0) - 2015
提示: 推荐看 The Garbage Collection Handbook 这本书学习更多关于GC的知识 (在文章底部的链接中)
组件结构
在GC中有两个主要的组件, 一个是分配器(Allocator), 另一个是收集器(Collector)。
分配器负责获取更多的内存并且在适当的时机触发收集器。
收集器负责回收垃圾和不再被程序使用的对象内存。
此外还有一些途径可以触发收集器, 例如手动调用GC.Collect函数或析构线程(Finalizer Thread)收到一个内存不足的异步通知(由收集器发送)。
分配器的设计
分配器由运行引擎(Execution Engine (EE))调用, 调用时会带有以下的信息:
- 需要分配的大小
- 线程专用的分配上下文(Allocation Context)
- 标记, 如对象是否有析构函数
GC不会根据对象类型的不同做出特殊处理, 它会从运行引擎获取对象的大小, 根据对象的大小把对象分为两类:
- 小对象 (小于 85,000字节)
- 大对象 (大于或等于 85,000字节)
原则上小对象和大对象都可以用同样的方式处理, 但因为压缩(Compacting)大对象的代价会比较昂贵所以GC会区分对待。
当GC把一段内存交给分配器时, 它会参照分配上下文(Allocation Context)。
分配上下文的大小取决于分配单位(Allocation Quantum)的定义。
- 分配上下文 是在堆段(Heap Segment)中专门给指定线程使用的小区域, 在单核计算机(指单逻辑核心)中只会使用一个上下文, 即第0世代(Generation 0)分配上下文。
- 分配单位 是分配器每次申请的内存大小, 用于在分配上下文中给对象分配内存. 分配单位通常为8K且受管理对象(Managed Object)的大小通常为32字节, 使得一个分配单位可以用于多个对象的分配 (译者注: 原理和内存池相同)
大对象不会使用分配上下文和分配单位, 因为一个大对象本身可以大于这些小区域。
并且使用这些区域带来的好处(会在下面讨论)仅仅受限于小对象。
大对象会直接在堆段(Heap Segment)中分配。
分配器的设计要求实现:
- 在适当时机触发GC: 如果超过了分批预算(由收集器设置的阈值)或分配器不能在指定的堆段上分配时将会触发GC, 分配预算(Allocation Budget)和受管理的段(Managed Segments)的细节将在下面讨论。
- 保持对象位置: 如果多个对象在同一个堆段中分配, 它们的虚拟内存地址也会邻接。
- 高效的使用缓存: 分配器每次都会按 分配单位 申请内存, 而不是按每个对象的大小. 在申请内存后它会对足够激活cpu缓存的内存大小进行清0, 因为在此之后对象会从这块内存中分配(所以性能会得到提升). 分配单位通常为8K。
- 高效的避免线程锁: 因为分配上下文和线程的绑定, 可以保证每个分配单位的内存只有对应一个线程可以操作. 因此只要当前的分配上下文没有用完, 给对象分配内存时就不需要线程锁。
- 内存完整性: GC总会把新分配的对象的内存清0, 从而防止对象指向随机的内容(未定义的内容)。
- 保证堆可爬取(Crawlable): 分配器会在每个分配单位即将用完之际新建一个自由对象(Free Object)填充, 例如一个分配单位剩余30个字节并且下一个要分配的对象需要40个字节, 则分配器会新建一个30个字节的自由对象填充原来的分配单位并重新获取一个新的分配单位
分配器的API
Object* GCHeap::Alloc(size_t size, DWORD flags);
Object* GCHeap::Alloc(alloc_context* acontext, size_t size, DWORD flags);
以上的函数可以用于分配小对象和大对象。
另外还有一个函数用于强制从大对象的堆(LOH: Large Object Heap)中分配内存:
Object* GCHeap::AllocLHeap(size_t size, DWORD flags);
收集器的设计
GC的目标
GC致力于高效的内存管理, 只要求程序员付出很小的努力
高效指的是:
- GC应该足够频繁的发生, 以避免堆中有大量(根据比例和绝对值)未使用但已分配的对象(垃圾)和多余的内存。
- GC应该尽可能不频繁的发生, 以避免过多的消耗cpu时间, 即便频繁发生可以让程序占用更小的内存。
- GC应该是生产性的, 如果一次GC只回收了少量的内存那么这次GC和它消耗的cpu周期被浪费了。
- 每次GC应该足够快, 许多场景会要求低延迟。
- 程序员们不需要为了优化内存的利用对GC了解太多(取决于他们的工作)。
– GC应该调整自身以适应不同的内存使用模式。
受管理的堆(Managed Heap)的逻辑表现
CLR GC把对象分成了不同的世代, 当第 N 世代的垃圾被回收后, 生存的对象会被标记为第 N+1 世代, 这个过程被称为升级(Promotion)。
还有一些例外的情况当我们决定是否降级或不升级。
对于小对象的堆会分为3个世代: 第0世代(gen0), 第1世代(gen1)和第2世代(gen2)。
对于大对象只有1个世代: 第3世代(gen3)。
第0世代和第1世代被称为短暂(对象的生命周期短)的世代。
对于小对象的堆, 世代中的数字代表了年龄, 第0世代是最年轻的世代。
但不代表第0世代中的对象一定比第1世代和第2世代的对象年轻, 下面将会说明那些例外。
收集一个世代中的垃圾同时也会收集所有比它年轻的世代的垃圾。
原则上大对象可以使用和小对象一样的处理方式, 但是压缩大对象的代价会非常的昂贵, 因此大对象和小对象会受到不同的对待。
因为性能上的原因, 大对象只使用了一个世代(第3世代)并且这个世代会和第2世代一起回收垃圾。
因为第2世代和第3世代可以很大, 需要和短暂的世代(第0世代和第1世代)在开销上划出边界。
为对象分配内存时总会从最年轻的世代分配 - 对于小对象总会从第0世代分配, 对于大对象总会从第3世代分配(因为只有一个世代)。
受管理的堆(Managed Heap)的物理表现
受管理的堆(Managed Heap)是一个包含了受管理的堆段(Managed Heap Segments)的集合。
受管理的堆段是从系统内核申请得到的一块连续的内存空间. (译者注: 即malloc/brk申请得到的空间)
用于区别小对象和大对象, 堆段又分为小对象堆段和大对象堆段。
每个堆中的堆段都是相互链接在一起的, 至少会有1个小对象堆段和1个大对象堆段 - 它们会在加载CLR时预留。
每个小对象的堆中只有一个短暂的堆段(Ephemeral Segment)用于存放短暂世代(第0世代和第1世代)的对象, 但也有可能包含第2世代的对象。
其他额外的堆段(0或1或更多个)中只会存放第2世代的对象。
每个大对象的堆中有一个或更多个堆段。
堆段中的空间会从较低的地址向较高的地址消耗, 即地址更小的对象比地址更大的对象更老. 这里也有一些例外将在下面说明。
堆段会在需要时向系统申请, 并且在不包含任何生存的对象时删除。
但是初始的堆段(加载时预留的)会一直保留。
每个堆中每次只会处理一段(而不是全部), 如在回收小对象和分配大对象时。
这样的设计提供了更好的性能, 因为大对象只会在第2世代回收时一同回收(代价相当昂贵)。
堆段会按它们的申请顺序链接在一起, 链中的最后一个堆段一定是短暂的堆段(Ephemeral Segment)。
回收的堆段(不包含任何生存的对象)不一定会被删除, 也可能被作为一个新的短暂的堆段, 这种机制仅在小对象的堆中实现。
每次分配大对象都会考虑整个大对象的堆, 而小对象仅仅考虑短暂的堆段。
分配预算(Allocation Budget)
分配预算是一个关联于每个世代的逻辑概念, 当世代的大小达到了指定的限制则会触发GC。
每个世代的预算值属性基本取决于该世代的对象的生存率, 如果生存率较高, 那么这个限制会调大使得下次对该世代的GC会得到一个更好的回收率。
判断需要回收哪个世代的垃圾
当GC被触发时, GC首先需要确定回收哪个世代。
除了分配预算外, 还有这些因素需要考虑:
- 世代的碎片化程度 – 如果这个世代的碎片化程度比较高, 则收集这个世代将会得到更好的效果
- 如果系统内存占用比较高, 并且每次可以回收到一定的内存, GC可能会更积极的去回收。
这对于防止系统内存分页(把多出的内存数据转移到硬盘)很重要。 - 如果短暂的堆段的空间已经用完, GC可能会更积极的去回收以防止申请一个新的堆段。
GC的工作流程
标记阶段 (Mark phase)
标记阶段的目标是寻找所有存活的对象。
多世代收集器的好处是每次只需要去看堆中最近的对象, 而不需要去看历史生成的所有对象。
当收集短暂世代(第0世代和第1世代)中的对象时, GC需要寻找这些世代中所有存活的对象,
运行引擎(EE)使用中的对象会标记为存活, 此外被其他对象(更老世代的对象)引用的对象也会标记为存活。
GC在标记更老的世代中的对象时会使用卡片(Cards),
JIT的帮助类会在赋值操作时设置卡片, 如果JIT的帮助类看到一个对象在短暂的范围中它会设置一个包含了源位置的卡片的字节。
在短暂世代的回收中, GC可以只看堆中其余的部分设置的卡片来找到它们对应的对象。
(译者注: 这段如果看不懂可以去看原文, 因为我还看不懂所以这里是直译)
计划阶段 (Plan phase)
计划阶段会做一个比较来决定使用压缩(Compaction)还是清扫(Sweeps)。
如果压缩效果更好则GC会开始实际的压缩, 否则GC会开始清扫。
重分配阶段 (Relocate phase)
如果GC决定要压缩, 那就要移动现有的对象, 指向这些对象的引用都需要被更新。
重分配阶段需要找出指向回收世代中的对象的所有引用。
相反, 标记阶段仅会参考存活的对象因此不需要考虑弱引用(Weak References)。
压缩阶段 (Compact phase)
这个阶段的目标非常直接, 因为计划阶段已经计算了对象应该移动到的新地址, 压缩阶段会复制对象到这些新地址中。
清扫阶段 (Sweep phase)
清扫阶段会寻找夹在存活对象中的空余空间(死对象), 并在这些空间里创建一些自由对象。
相邻的死对象会被合并成一个自由对象。
创建的自由对象会放到 自由对象列表(freelist) 里。
代码流程
缩写: (译者注: 以下不会使用这些缩写)
- WKS GC: 工作站GC。
- SRV GC: 服务器GC
各个模式的举动
工作站GC - 不启用并发式GC
- 用户线程超过了分配预算并触发了GC。
- GC调用SuspendEE函数来停止所有受管理的线程(Managed Threads)。
- GC决定需要回收哪个世代。
- 运行标记阶段。
- 运行计划阶段决定是用压缩还是用清扫。
- 如果决定压缩则运行重分配阶段和压缩阶段, 否则运行清扫阶段。
- GC调用RestartEE函数来恢复受管理的线程。
- 用户线程恢复运行。
工作站GC - 启用并发式GC
这里说明了后台GC如何运作。
- 用户线程超过了分配预算并触发了GC。
- GC调用SuspendEE函数来停止所有受管理的线程(Managed Threads)。
- GC决定是否使用后台GC。
- 如果使用后台GC, 则后台GC线程会被唤醒并进行回收工作. 后台GC线程会调用RestartEE唤醒受管理的线程。
- 受管理的线程继续运行, 同时后台GC线程也继续它的工作。
- 用户线程可能又一次的超过了分配预算并触发了一个短暂的GC(我们称为前台GC), 流程和 "工作站GC - 不启用并发式GC" 一样。
- 后台GC线程再次调用SuspendEE函数, 进行标记阶段(Marking), 然后调用RestartEE函数, 再进行可以并行的清扫阶段(Sweep)。
- 后台GC已完成工作。
服务器GC - 不启用并发式GC
- 用户线程超过了分配预算并触发了GC。
- 服务器GC线程被唤醒, 并调用SuspendEE来停止所有受管理的线程(Managed Threads)。
- 服务器GC进行回收工作(流程和工作站GC - 不启用并发式GC相同)。
- 服务器GC线程调用RestartEE唤醒受管理的线程。
- 用户线程恢复运行。
服务器GC - 启用并发式GC
流程和工作站GC - 启用并发式GC一样, 除了非后台的GC也会在服务器GC线程中完成。
物理架构
这一段旨在帮助你追踪代码流程。
当用户线程用完分配单位(Allocation Quantum), 需要一个新的分配单位时会调用try_allocate_more_space函数。
当try_allocate_more_space函数需要触发GC时会调用GarbageCollectGeneration函数。
在 "工作站GC - 不启用并发式GC" 的模式下, GarbageCollectGeneration会在触发GC的用户线程上完成所有工作, 代码流程是:
GarbageCollectGeneration()
{
SuspendEE();
garbage_collect();
RestartEE();
}
garbage_collect()
{
generation_to_condemn();
gc1();
}
gc1()
{
mark_phase();
plan_phase();
}
plan_phase()
{
// 实际的计划阶段, 判断要用压缩还是清扫
if (compact)
{
relocate_phase();
compact_phase();
}
else
make_free_lists();
}
在"工作站GC - 启用并发式GC"的模式(默认模式)下, 后台GC的代码流程是:
GarbageCollectGeneration()
{
SuspendEE();
garbage_collect();
RestartEE();
}
garbage_collect()
{
generation_to_condemn();
// 判断要用后台GC, 唤醒后台GC
do_background_gc();
}
do_background_gc()
{
init_background_gc();
start_c_gc ();
// 等待后台GC完成工作并重启受管理的线程
wait_to_proceed();
}
bgc_thread_function()
{
while (1)
{
// 等待事件
// 唤醒
gc1();
}
}
gc1()
{
background_mark_phase();
background_sweep();
}
资源链接
- .NET CLR GC Implementation
- The Garbage Collection Handbook: The Art of Automatic Memory Management
- Garbage collection (Wikipedia)
译者后注
这份文档简单的介绍了GC的设计和工作流程, 同时也带来和很多疑问, 例如一个程序有多少个堆和什么是卡片等
我将在CoreCLR源码探索的系列文章中分析CoreCLR的GC源码来解决这些疑问
另外博客园上已经有一位大神对CoreCLR的GC源码进行了部分分析,可以 查看他的博客
.NET Core GC 的设计的更多相关文章
- CoreCLR文档翻译 - GC的设计
此文档来源于CoreCLR的BOTR(The Book of the Runtime), 点击打开原文 一切著作权归微软公司所有 GC的设计 作者: Maoni Stephens (@maoni0) ...
- 讨论.NET Core 配置对GC 工作模式与内存的影响
引出问题: Asp.net core应用在 Kubernetes上内存使用率过高问题分析 https://mp.weixin.qq.com/s/PqhUzvFpzopU7rVRgdy7eg 这篇文章中 ...
- .NET Core 配置GC工作模式与内存的影响
.NET Core 配置GC工作模式与内存的影响 .NET Core GC 原文:https://blog.markvincze.com/troubleshooting-high-memory-usa ...
- Java 8 VM GC Tunning Guide Charter 7-8-b
第七章 并发gc Java 8提供两种并发gc,CMS和G1 Concurrent Mark Sweep (CMS) Collector This collector is for applicati ...
- Golang源码探索(三) GC的实现原理
Golang从1.5开始引入了三色GC, 经过多次改进, 当前的1.9版本的GC停顿时间已经可以做到极短. 停顿时间的减少意味着"最大响应时间"的缩短, 这也让go更适合编写网络服 ...
- Golang源码探索(三) GC的实现原理(转)
Golang从1.5开始引入了三色GC, 经过多次改进, 当前的1.9版本的GC停顿时间已经可以做到极短.停顿时间的减少意味着"最大响应时间"的缩短, 这也让go更适合编写网络服务 ...
- ASP.NET Core 中文文档 第三章 原理(10)依赖注入
原文:Dependency Injection 作者:Steve Smith 翻译:刘浩杨 校对:许登洋(Seay).高嵩 ASP.NET Core 的底层设计支持和使用依赖注入.ASP.NET Co ...
- 读<<领域驱动设计-软件核心复杂性应对之道>>有感
道可道,非常道. 名可名,非常名. 无名天地之始,有名万物之母. ---老子 关于标题 好久没写东西了,动笔的动机是看完了一本书,想写点总结性的东西,一是为了回顾一下梳理知识点,二是为了日后遗忘时能有 ...
- .Net Core 杂记
在学习.net core的路上,遇到很多坑,慢慢了解了.net core设计理念和设计思想(纯属跟人理解). 再此整理了之前写的一些学习笔记,后续也会把新的学习新的加上. 1..net core 跨平 ...
随机推荐
- PHP生成图表pChart
pChart是一个开源的图表生成库,主要涉及3个Class:pChart.class, pData.class, pCache.class,可生成20多种简单或复杂的图表,支持PNG,JPG,GIF通 ...
- docker之使用System.Drawing生成图片缺少Gdiplus.dll错误
1.在docker 上找到并运行需要System.Drawing的镜像 然后退出执行另一语句 docker run -it container01 进入镜像以方便安装gdiplus docker ex ...
- ES6-个人学习大纲
1,let const学习补充 1.1,let的知识点: 01-作用域只限制在当前代码块内,代码块形式如下: { var str = '张三'; console.log(str); let str ...
- 航班座位_hihocoder
题目2 : 航班座位 时间限制:10000ms 单点时限:1000ms 内存限制:256MB 描述 小Hi在给一个家庭旅游团订某次航班的机票.该航班的飞机一共有N排座位,每排座位有10个座位,从左到右 ...
- 反调试——jmp到那个地址
目录 1.前言 2.原理讲解 3.代码实现 前言 这节的反调试是通过构造代码来干扰正常的分析.反调试参考CrypMic勒索病毒 原理讲解 在逆向分析汇编代码时,一般都是通过汇编指令call或jmp跳到 ...
- pickle 模块
序列化和反序列化的定义 序列化:就是把不可传输的对象转换为可存储或可传输的过程 反序列化:就是把在磁盘,等介质中的数据转换为对象 import pickle #dic={'name':'alex',' ...
- java枚举使用 总结
补充几点: 1.枚举对象是可以用 == 比较. 2. TestEnum3反编译结果: F:\tree\Test\src\test>javap TestEnum3* Compiled from & ...
- Web发展史
Web 万维网常称为WWW(World Wide Web)发展至今仅30年,英国计算机科学家,蒂姆 伯纳斯 李爵士 提出了 World Wide Web的设计方案,1990年李爵士完成了Web 所有的 ...
- 对JS闭包和函数作用域的问题的深入讨论,如何理解JS闭包和函数作用域链?
首先先引用<JavaScript权威指南>里面的一句话来开始我的博客:函数的执行依赖于变量作用域,这个作用域是在函数定义时决定的,而不是函数调用时决定的. 因此,就出现了如下的几串代码: ...
- AIO系列文档(2)----TIO使用
AIO系列文档(1)----图解ByteBuffer中介绍了ByteBuffer用法,下面通过介绍t-io介绍如何使用: hello world例子简介 本例子演示的是一个典型的TCP长连接应用,代码 ...