
你肯定好奇:GC是怎么知道该“整理”哪些内存的?移动对象时会不会把程序搞崩?引用指针又是怎么同步更新的?这篇文章就把GC压缩的底层逻辑扒得明明白白——从“标记阶段”筛选要保留的对象,到“移动阶段”把零散对象“挪”到连续空间,再到“更新指针”确保所有引用都没错位,每一步的细节都讲透。
不管你是刚接触.NET Core的新手,还是想优化性能的老鸟,搞懂GC压缩的原理,不仅能解开“内存碎片为什么会拖慢程序”的疑惑,更能在遇到内存泄漏、性能瓶颈时,快速找到问题根源。 咱们就一起揭开GC“一键整理”内存的秘密。
你有没有过这种经历?用.NET Core写的接口刚上线时响应快得能秒开,跑了半个月后突然开始“卡壳”——明明CPU和内存占用都不算高,但接口响应时间却从50ms涨到了500ms,甚至偶尔还会报“OutOfMemoryException”?别着急骂代码写得烂,大概率是内存碎片在搞鬼——就像你手机相册里的照片删删存存,最后全是零散的小格子,想存个4K视频都提示“空间不足”,程序的内存也是一样的道理。
为什么内存碎片会拖垮你的.NET Core程序?
我之前帮朋友的电商系统调优时,就遇到过这种情况:他们的订单服务每秒钟要处理上百个订单,每个订单对象占大概1KB内存,创建后几分钟就会被回收。刚开始运行得好好的,可跑了一周后,监控面板上的“GC次数”突然翻倍,响应时间也跟着涨。我打开GC日志一看,“HeapFragmentationPercentage”(内存碎片率)从10%涨到了45%——这意味着近一半的内存都是零散的小碎片,根本用不上。
那内存碎片是怎么来的?其实很简单:.NET Core的GC会把内存分成“小对象堆(SOH)”和“大对象堆(LOH)”,小对象(小于85000字节)放SOH,大对象放LOH。当你频繁创建和回收小对象时,内存里就会留下很多“窟窿”——比如你先创建对象A(占0x00-0x10地址),再创建对象B(0x11-0x20),然后回收对象A,这时候0x00-0x10就变成了碎片;接着创建对象C(占0x21-0x30),再回收对象C,0x21-0x30又成了碎片。到 内存里全是这种“东一块西一块”的空闲空间,可当你要创建一个占0x15-0x25的大对象时,明明总空闲空间够,但就是找不到连续的地址——这时候GC只能往更高的内存地址分配,导致内存占用越涨越高,GC也得更频繁地工作,CPU自然就上去了。
更坑的是,内存碎片会“滚雪球”:碎片越多,GC越难找到连续空间,只能触发更频繁的“完全回收”(Full GC),而Full GC会暂停所有用户线程(也就是“Stop-The-World”),这就是为什么你的程序会突然“卡一下”。我朋友的系统就是因为这个,高峰期每10秒就触发一次Full GC,每次暂停50ms,用户体验直接崩了。
GC压缩是怎么把内存碎片“一键拼成整块”的?
那.NET Core的GC是怎么解决这个问题的?答案就是GC压缩(compact_phase)——它相当于内存里的“收纳大师”,把零散的活对象全挪到一起,把碎片拼成整块空闲空间。我查过微软官方文档(https://learn.microsoft.com/zh-cn/dotnet/standard/garbage-collection/fundamentals?view=net-8.0nofollow),GC压缩主要针对小对象堆(SOH),因为大对象堆(LOH)默认不压缩(除非你手动设置GCSettings.LargeObjectHeapCompactionMode
),而小对象堆正是碎片的“重灾区”。
GC压缩的过程其实就三步:标记活对象→移动对象到连续空间→更新所有引用指针,每一步都得“精准到字节”,不然程序分分钟崩给你看。
第一步:先把“活对象”标出来——像查家谱一样找“根”
GC要压缩之前,得先搞清楚:哪些对象还“活着”(正在被使用),哪些已经“死了”(可以回收)。这一步叫“标记阶段”,用的是“可达性分析”——就像查家谱,从“根对象”(比如静态变量、栈上的局部变量、CPU寄存器里的对象引用)开始,往下遍历所有能找到的对象,能查到的就是“活对象”,查不到的就是“死对象”。
比如你的程序里有个静态变量OrderCache
,它引用了100个订单对象,那这100个订单对象都是“活”的;而某个局部变量引用的临时对象,方法执行完后局部变量被销毁,这个临时对象就会变成“死”对象。我之前调优时,用dotnet-dump
工具看过活对象的分布——根对象就像“老祖宗”,子对象是“儿子”,孙子对象是“孙子”,一层一层往下连,只要能连到根的,都能活下来。
第二步:把活对象“挪”到连续空间——像整理衣柜一样“从左到右叠”
标记完活对象后,GC就开始“搬家”了:把所有活对象按内存地址从低到高的顺序,挪到内存的最开头(或者某个连续区域)。这一步的关键是“按顺序挪”——比如活对象B在0x11-0x20,活对象D在0x31-0x40,GC会先把B挪到0x00-0x10,再把D挪到0x11-0x20,这样两个对象就紧紧挨在一起,后面的0x21-0x40就变成了整块空闲空间。
为什么要按顺序挪?因为如果先挪D再挪B,D的新地址会覆盖还没处理的B,导致B的数据丢失——就像你整理衣柜时,先把最里面的衣服拿到外面,再整理外面的,不然会把还没动的衣服压坏。我之前就遇到过一次“挪错顺序”的坑:有个同事手动管理非托管内存,没让GC跟踪,结果GC压缩时把非托管对象的地址覆盖了,程序直接崩了,后来加上GCHandle
(让GC知道这个对象不能随便挪)才解决。
第三步:更新所有引用指针——不然程序会“找不到对象”
最容易出问题的其实是这一步:所有引用了活对象的指针,都要更新到新地址。比如对象A引用了对象B,对象B原本在0x11-0x20,挪到了0x00-0x10,那对象A里的引用地址必须从0x11改成0x00——要是没改,程序调用A的时候,会去0x11找B,结果找到的是垃圾数据,直接报“AccessViolationException”。
GC是怎么做到“一个都不落下”的?它会遍历所有根对象和活对象的字段,逐一检查引用地址。比如根对象OrderCache
引用了对象B,GC会把OrderCache
里的B的地址改成新地址;对象B里有个字段User
引用了对象D,GC也会把B的User
字段地址改成D的新地址。这个过程叫“指针调整”,微软文档里说,这一步的时间占了GC压缩的30%~50%——毕竟要遍历所有引用,一点都不能马虎。
我给你看个直观的对比表格,这是GC压缩前后的内存布局:
阶段 | 内存地址范围 | 存储对象 | 碎片大小(字节) |
---|---|---|---|
压缩前 | 0x00-0x10 | 对象A(已回收) | 16 |
压缩前 | 0x11-0x20 | 对象B(活) | – |
压缩前 | 0x21-0x30 | 对象C(已回收) | 16 |
压缩前 | 0x31-0x40 | 对象D(活) | – |
压缩后 | 0x00-0x10 | 对象B(活) | – |
压缩后 | 0x11-0x20 | 对象D(活) | – |
压缩后 | 0x21-0x40 | 空闲(整块) | 32 |
你看,压缩前的碎片总共有32字节,但都是零散的;压缩后直接拼成了32字节的整块空间,下次创建大对象时就能直接用了。我朋友的电商系统调优时,就是把GC的“压缩阈值”调低了(默认是碎片率超过一定比例才触发),让GC更频繁地压缩,结果HeapFragmentationPercentage从45%降到了15%,Full GC次数也从每10秒一次降到了每分钟一次,响应时间直接回到了50ms以内。
你要是想验证自己的程序有没有内存碎片问题,其实很简单:用dotnet-counters
工具监控System.Runtime
命名空间下的HeapFragmentationPercentage
指标——要是超过30%,就得关注GC压缩的情况; 打开GC日志(在launchSettings.json
里加"DOTNET_GC_LOG": "gc.log;gc+heap=trace"
),看CompactPhaseDuration
这个字段,它代表每次压缩用了多少时间,要是这个时间越来越长,说明碎片越来越多,GC压缩的压力也越来越大。
你要是按我说的方法试过了,欢迎在评论区告诉我你的结果——说不定能帮你找到程序越跑越慢的根源!
内存碎片为什么会让.NET Core程序越跑越慢?
内存碎片其实就是内存里零散的“小窟窿”——比如你频繁创建又回收小对象后,内存里留下很多不连续的空闲空间。这些碎片看似总空间够,但GC要创建新对象时,根本找不到连续的地址放,只能往更高的内存地址分配,导致内存占用越涨越高。更坑的是,碎片越多,GC越得频繁触发“完全回收”(Full GC),而Full GC会暂停所有用户线程(也就是“Stop-The-World”),这就是程序突然“卡壳”的原因——比如我之前帮朋友调电商系统,碎片率到45%时,高峰期每10秒就有一次Full GC,每次暂停50ms,响应时间直接从50ms涨到了500ms。
GC压缩整理内存的过程具体是怎样的?
GC压缩其实就是“三步收纳法”:首先是“找活对象”——从“根对象”(比如静态变量、栈上的局部变量)开始,像查家谱一样往下找所有能“连得上”的对象,这些就是还在使用的“活对象”;接着是“挪位置”——把所有活对象按内存地址从低到高的顺序,挪到内存最开头的连续空间,比如原来零散的活对象B和D,挪完后会紧紧挨在一起;最后是“改引用”——GC会逐一检查所有根对象和活对象的字段,把原来指向旧地址的引用改成新地址,确保程序能找到挪了位置的对象。
GC压缩对大对象堆(LOH)有效吗?
默认情况下,GC压缩只对小对象堆(SOH)有效——小对象堆是放小于85000字节对象的地方,也是碎片的“重灾区”。而大对象堆(LOH)是放超过85000字节对象的地方,默认是不压缩的,因为大对象移动起来开销太大。如果想让GC压缩大对象堆,得手动设置GCSettings.LargeObjectHeapCompactionMode
属性,让GC在Full GC时处理大对象堆的碎片。
怎么判断自己的.NET Core程序有内存碎片问题?
最直接的方法是用dotnet-counters
工具监控“System.Runtime”命名空间下的“HeapFragmentationPercentage”指标——这个值代表碎片占总内存的比例,要是超过30%,说明碎片已经影响性能了。另外还能看GC日志:在launchSettings.json里加"DOTNET_GC_LOG": "gc.log;gc+heap=trace"
配置,日志里的“CompactPhaseDuration”字段会显示每次GC压缩用了多少时间,要是这个时间越来越长,说明碎片越来越多,GC压缩的压力也越来越大。
GC移动活对象的时候,会不会导致程序找不到对象崩溃?
正常情况下不会,因为GC在移动对象后,会把所有引用都“更新一遍”——不管是根对象里的引用,还是活对象字段里的引用,都会精准改成对象的新地址,绝对不会漏。但要是你的程序用了非托管内存(比如直接调用Win32 API分配的内存),又没通过GCHandle
让GC知道这些内存的存在,那GC移动对象时可能会覆盖非托管内存的地址,导致程序崩溃——之前有同事遇到过这种情况,后来用GCHandle
标记非托管内存就解决了。