拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

揭秘.NET大对象堆LOH:大数组大字符串为何拖垮性能

揭秘.NET大对象堆LOH:大数组大字符串为何拖垮性能

一台服务器上跑了个定时任务,每天凌晨把三十万行业务数据导出成CSV。上线两周一切正常,第三周开始每天凌晨CPU直接飙到100%,接口超时,日志里全是GC暂停记录。排查到半夜,最后定位到的元凶就是标题里这俩字:LOH。

那之后我面试.NET岗位,几乎每次都会问一句“什么是LOH,大数组大字符串为什么让性能变差”,十个候选人里能讲完整的不到三个。这篇文章就把这道题彻底拆开:LOH是什么,它凭什么影响性能,遇到问题怎么优化,以及最后怎么用工具确认优化到底有没有效果。不管是准备面试,还是在WPF、WinForms、ASP.NET Core里做性能调优,这套东西都通用。

1. LOH是什么:先搞懂托管堆里的“另一个世界”

1.1 阈值与判定:为什么是85000字节

.NET的托管堆不是一整块铁板。GC在内部把堆分成了两个区域:小对象堆(Small Object Heap,SOH)和大对象堆(Large Object Heap,LOH)。判断一个对象进哪个堆,标准只有一个:对象分配的总大小是否超过85000字节。

注意这里的细节:85000字节换算过来大约是83KB,不是85KB。很多人张口就说85KB,面试官要是追问一下立刻就露馅了,这两个数差着2KB,而且后面调阈值的时候你会用到真正的字节数。

我用一句代码就能验证这个判定逻辑:

using System; var small = new byte[84999]; // 总大小小于85000字节 var large = new byte[85001]; // 总大小大于85000字节 Console.WriteLine($"small gen = {GC.GetGeneration(small)}"); Console.WriteLine($"large gen = {GC.GetGeneration(large)}");

运行结果你会看到:small返回0或1,而large返回2。因为LOH里的对象在GC眼里直接就是Gen 2级别的对象,它不会经历“从Gen 0晋升到Gen 1再到Gen 2”的成长过程,生下来就是“老头子”。

这就引出一个特别重要的推论:判断依据是对象总大小,不是你写的类型。一个byte[]、char[]、int[]、string,只要分配总量超过85000字节,统统进LOH。反过来,如果你的class里有个大数组字段,这个数组本身进LOH,而class实例本身可能还在SOH。

1.2 LOH里的对象长什么样:数组、字符串、还有谁

实际生产环境里,LOH里最常见的就是三类东西:大数组、大字符串、以及内部持有大缓冲的流对象。我整理了个对照表,方便你快速判断哪些代码会产生LOH分配:

类型示例是否进LOH
byte[]new byte[1024 * 1024],1MB缓冲是
char[]new char[45000],约90KB是
string长文本内容,UTF-16下约43000个字符是
int[]new int[22000],约88KB是
List<byte>内部数组扩容到超过85000字节是(内部数组进)
两个小对象每个50KB,引用关系再复杂否,每个对象单独判断
大对象集合一万个10字节的小对象否,每个节点都在SOH

有个地方特别坑:string是UTF-16编码,每个字符占2字节。所以一个字符串只要大约43000个字符,就已经突破85000字节的阈值了。很多人写日志、拼HTML、拼SQL,随随便便一个几千字的模板,看起来不大,但如果运行时反复产生这种字符串,LOH就会被反复冲击。

还有一处容易被忽略:MemoryStream内部用byte[]做缓冲,当你往里面写大块数据时,它扩出来的内部数组会进LOH。File.ReadAllBytes读一个大文件,本质上是分配一个大byte[],也进LOH。这些都属于“大数组”的变体。

2. 为什么LOH会成为性能杀手

2.1 分配路径不一样:没有bump pointer,只能大海捞针

SOH的分配机制特别简单:GC维护一个“下一位分配位置”的指针,申请内存时直接往后推,这个过程叫bump pointer分配,几乎是常数级开销。

LOH就不一样了。因为大对象生命周期长、总被释放,GC没法用连续推进的方式来管理空闲空间,而是维护一张空闲块列表。每次分配大对象时,GC要在这张表里找一个足够大的连续空闲块。这就像你去停车场,小轿车随便找个缝就能停,而大巴车必须找一段足够长的空位,找不到就得等别人挪车。

这个查找过程本身就比bump pointer贵,而且分配完还要做内存清零。大对象动辄几百KB甚至几MB,清零成本也跟着变大。所以单看“分配”这个动作,LOH对象就比同等字节数的SOH对象更贵。

2.2 回收成本高:LOH收集经常等于一次Full GC

小对象是分代回收的:Gen 0满了只收Gen 0,Gen 1满了收Gen 0+Gen 1,大部分临时对象根本活不到Gen 2就能被清掉。因为回收范围小、存活对象少,STW(Stop The World,暂停全部托管线程)时间就很短。

LOH没有这个待遇。LOH里的对象在GC概念里属于Gen 2,它是跟随完整回收一起被清理的。也就是说,哪怕你只是分配了一个8MB的临时数组,用完之后丢在那里,这个数组不会在“轻量级”的Gen 0回收中消失,它必须等一次完整的GC(包含Gen 2和LOH)才能被清走。

完整GC是什么概念?它要跟踪所有代里的存活对象、压缩SOH、处理终结器队列、更新各种引用,这是所有GC类型里最贵的一次。在Server GC下还要协调多个GC线程,暂停时间很容易飙升到几十甚至几百毫秒。所以业务代码里“高频分配大对象再丢弃”的模式,本质上就是在不断制造完整GC的诱因。

2.3 不压缩的代价:碎片化怎么吃掉你的可用内存

这是LOH最阴险的地方。SOH在GC后通常会做压缩,把存活对象挪到一起,空出来的空间是连续的。LOH默认不压缩,因为搬动大对象的开销太大(要复制大量字节,还要更新所有指向这些对象的引用)。

不压缩的直接后果是内存碎片化。想象你往一块地里种树,每隔一段种一棵,树之间的空隙因为大小不合适一直没法用。等你想种一棵大树时,地总剩余空间够,但没有一块连续的空位能放下它。

实际表现就是:内存占用看着并不高,但new一个大数组时直接抛OutOfMemoryException。更难受的是,碎片化还影响内存归还操作系统。LOH的段(segment)只有整段清空时才会释放给OS,只要段内还有存活对象,那整段内存就一直被进程占着。所以你会在任务管理器里看到进程私有内存居高不下,但GC堆实际使用的内存远没那么高。

2.4 一次真实的OOM现场复盘

我处理过一个典型事故。某个报表导出接口,每次都把10万条记录拼成一个巨大的JSON字符串——长度大约18MB,然后再压缩成byte[]返回给前端。平时流量不大没事,某天大促并发一上来,服务直接OOM。

现场排查分三步:

  1. dotnet-counters先看趋势:LOH Size在几分钟内从100MB涨到近1GB,Allocated Bytes/sec高得离谱。
  2. dotnet-dump抓堆:dumpheap -stat里排在前面的是System.String和System.Byte[],而且出现了一堆几十MB级别的实例。
  3. 查引用链:发现这些字符串和数组全是从一个ExportAsync方法里出来的,生命周期极短,纯粹是“用完就扔”。

这事的根源就是标题里说的“频繁使用大数组或大字符串”。每个请求都临时创建巨大的JSON字符串和压缩缓冲,每个对象都进LOH,然后一起堆到完整GC才被清理。并发一高,GC线程疲于奔命,STW越来越长,最终内存碎片化到无法分配连续空间,进程直接崩。

修复方案后面讲,但核心思路就一句话:别让这种大对象高频出现。

3. 优化手段:从代码层到运行时配置

3.1 数组复用:ArrayPool的正确打开方式

对付高频大数组,最直接的办法就是复用,而不是频繁new。ArrayPool<T>是.NET内置的数组池,它维护了一批可复用的数组,借出来用完再还回去,避免反复走LOH分配。

using System.Buffers; var buffer = ArrayPool<byte>.Shared.Rent(200_000); try { // 用buffer做加解密、序列化、网络收发等操作 // 注意:Rent返回的数组长度可能大于200000 // 实际操作时用buffer.AsSpan(0, actualLength),别直接取buffer.Length } finally { ArrayPool<byte>.Shared.Return(buffer, clearArray: true); }

几个临界点,踩过坑的人都知道:

  • Rent返回的数组长度不保证等于请求长度。请求200000,可能给你一个262144长度的数组,你得记录“实际用了多少”,别盲目用buffer.Length。
  • 还回去之前要考虑要不要清数据。clearArray: true会在归还时把数组清零,涉及敏感数据必须开,否则下一个人从池里拿到的数组可能残留上一个使用方的数据。
  • 借出来的数组不能长期持有。池的设计初衷是“短期借用”,你把数组塞进缓存对象里一直不放回去,池就废了,内存反而更高。
  • 大对象池的默认最大长度有限制,如果你需要超大数组,建议自己基于ArrayPool<T>.Create定制,设置合适的maxArrayLength和maxArraysPerBucket。

ArrayPool适合网络收发包、加解密、JSON序列化、临时计算缓冲这些短生命周期场景。我之前在WPF里做批量图片缩略图,每张图解码到byte[]都用池化,内存峰值直接降了六成。

3.2 字符串和序列化:别让大字符串反复出现

大字符串的问题比大数组更隐蔽,因为字符串不可变,+=一下就是新对象。处理长文本有几个原则:

原则一:用StringBuilder并预分配容量。如果要拼一个可能超过几十KB的文本,别用string +=。StringBuilder内部用char[]做缓冲,预分配合理的容量能减少扩容次数,避免内部数组反复变大进LOH:

var sb = new StringBuilder(capacity: 100_000); // 多次Append,内部char[]不会频繁扩容

原则二:大文本不要整体ToString。假设你拼了一个100KB的日志,sb.ToString()会再复制出一个100KB的字符串,这个字符串又进LOH。如果这个字符串只是被写入文件或网络,直接用StringBuilder往StreamWriter里写,省掉这次复制:

using var writer = new StreamWriter(stream); foreach (var line in lines) { writer.WriteLine(line); } // 别去构造一个超大字符串再写

原则三:JSON/XML序列化用流式API。.NET Core 3.0以后系统内置了System.Text.Json,它提供了Utf8JsonWriter专门做流式写入,不用先构造一个大字符串:

using var stream = response.Body; using var writer = new Utf8JsonWriter(stream); writer.WriteStartArray(); foreach (var item in items) { writer.WriteStartObject(); writer.WriteString("name", item.Name); writer.WriteNumber("id", item.Id); writer.WriteEndObject(); } writer.WriteEndArray();

这段代码无论数据量多大,都不会在托管堆里构造出一个完整的大JSON字符串,内存占用是平稳的。很多导出类接口,改成这种写法之后,我从18MB的字符串直接降到每批只有几KB的缓冲。

3.3 流式处理与分块:从根上消灭大对象

优化更高一级的思路是:让大对象根本不出现。

读取大文件时,File.ReadAllLines会把所有行一次性装进内存,每行一个字符串,总量轻松超过85KB。改用File.ReadLines,它是延迟加载的,读一行处理一行:

foreach (var line in File.ReadLines("huge.csv")) { ProcessLine(line); }

处理大网络包时,考虑用ReadOnlySequence或者System.IO.Pipelines做管道式处理。思想一样:边读边处理,而不是等整个消息都到达后再一次性组装成一个大byte[]。

对超大数组做计算(比如两个10MB数组做合并、Copy、Concat)之前,先问自己一句:这个操作会产生新的大数组吗?Array.Copy到新数组、Concat、Select(...).ToArray()都会产生新的大数组。如果能分块处理,就分块:

// 假设要对source做变换,分批写入目标 var temp = ArrayPool<byte>.Shared.Rent(8192); try { for (int offset = 0; offset < source.Length; offset += temp.Length) { int count = Math.Min(temp.Length, source.Length - offset); // 处理这一块 } } finally { ArrayPool<byte>.Shared.Return(temp); }

这种“分块+复用”的组合能同时解决分配和碎片两个问题。

3.4 运行时配置:调高LOH阈值与“一次性压缩”

代码层优化不是全部,运行时也给了两个后门。

第一个是把LargeObjectHeapCompactionMode设为CompactOnce,让GC在下一次阻塞式完整GC时压缩LOH。这对已经碎片化但还想救一把的场景很有效:

GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;

注意这个设置只对下一次“阻塞式完整GC”生效。如果你希望每次完整GC都压缩,需要在每次GC前重新设置,或者考虑用下面的环境变量方案。压缩LOH本身有代价,一次可能增加几十到几百毫秒的STW,所以只能在业务低峰期做。

第二个是调高LOH的阈值本身。.NET Core 3.0开始支持通过环境变量DOTNET_GCLOHThreshold覆盖默认的85000字节:

export DOTNET_GCLOHThreshold=200000

这个配置的意思是:小于等于200000字节的对象都留在SOH,只有超过它的才进LOH。好处是让一些“比85KB大一点”的数组享受SOH的bump pointer分配和压缩能力;坏处是SOH压力变大,Gen 0和Gen 1变得更大,年轻代GC频次会升高。所以这个参数不是调得越高越好,必须用真实负载压测。我一般在遇到“大量120KB到200KB之间的临时数组”这种场景时,才考虑动它。

还有一个容易混淆的点:Server GC和Workstation GC。托管应用默认Workstation GC,它对延迟更友好,但GC线程只有一条;Server GC在服务器上每个核心都有一组GC线程,收集大堆时通常更快,但STW时需要同步所有线程。如果你的应用是后台服务,在项目文件里加上这个配置通常比默认效果更好:

<PropertyGroup> <ServerGarbageCollection>true</ServerGarbageCollection> </PropertyGroup>

3.5 结构与生命周期设计:避开隐形大对象

最后这条靠设计习惯约束。核心原则:大对象的生命周期要么极短,要么极长,怕的是“短命但高频”。

  • 极短:用完立刻释放引用,配合池化复用,让内存可被快速回收。
  • 极长:比如启动时加载一次字典缓存,它长期活着,只要不频繁创建,对GC压力反而不大。
  • 短命但高频:每个请求都创建一个10MB数组用完就丢,这是最坏的情况。

具体操作上有几个可落地的建议:

  1. 不要在大对象里频繁替换内部缓冲。比如一个长生命周期的对象持有一个byte[],每次写入都扩容出新数组,那旧数组就变垃圾,反复造成LOH分配。
  2. 大文件导入导出,分批提交。一次性把整个文件解析到内存听着简单,实际上是把风险压给GC。分页读取、分批写库,内存曲线会平滑很多。
  3. 选择容器时考虑元素分布。一个List塞十万个小数对象,对象本身都在SOH,问题不大;但如果用List<byte[]>且每个byte[]都超过85KB,就是另一种故事了。提前估算总量,再做设计决策。

4. 诊断方法:怎么确认问题真的在LOH

4.1 dotnet-counters:几秒钟定位大对象堆

不看数据就优化纯属猜。最重要的是先确认“是不是LOH在作怪”。dotnet-counters是.NET官方诊断工具,直接看运行时计数器:

dotnet-counters monitor --process-id 12345 --counters System.Runtime

输出里重点看这几个字段(不同版本字段名略有差异,但思路一致):

  • Allocated Bytes/sec:每秒分配字节数。如果这个数字异常高,说明你的代码在疯狂分配内存。
  • LOH Size (MB):大对象堆当前大小。如果它持续增长且居高不下,说明有大对象在堆积。
  • Gen 2 Size (MB):Gen 2大小。LOH对象会算进Gen 2,这个数据也很关键。
  • GC Heap Size (MB):总托管堆大小。

我一般会同时观察“Allocated高但LOH Size也高”的组合:前者说明有垃圾产生,后者说明垃圾中有相当一部分是LOH大对象。

4.2 dotnet-dump:堆里都有哪些大家伙

如果确认LOH有问题,下一步要回答“是什么在占用”。用dotnet-dump抓取进程转储并分析:

dotnet-dump collect --process-id 12345 dotnet-dump analyze dump.dmp

在分析会话里执行SOS命令:

> dumpheap -stat Statistics: MT Count TotalSize Class Name 00007ffb2c34b7e8 12345 987654321 System.Byte[]

看TotalSize从大到小排序,就能立刻知道什么类型在堆里占据大头。如果System.Byte[]和System.String霸榜,再配合dumpheap -type System.Byte[]列出具体实例,分析引用链,基本能定位到是哪个方法分配出来的。

4.3 开发环境里的快照对比

开发环境复现问题时,用Visual Studio的调试器内存诊断更直观。做法是:在可疑操作前后各拍一次内存快照,然后切换到大对象堆视图,看两次快照之间新增了哪些大对象。

这个方式的优势是能直接看到分配点调用栈,定位到具体代码行。缺点是只能在开发环境做,线上问题还是要靠dotnet-dump。

排查LOH问题还有个经验:先看趋势,再看对象,最后看引用链。趋势用计数器,对象用dump,引用链用SOS的gcroot命令。三步走下来,基本不会漏。

5. 面试怎么答:LOH问题的“标准答案”与加分项

5.1 一分钟速答模板

如果面试被问到“LOH是什么,为什么影响性能,怎么优化”,我建议按“定义—机制—方案—验证”四段来答,时间控制在一分钟到一分半:

LOH是.NET的大对象堆,存放总大小超过85000字节的对象,比如大byte数组、大字符串。性能差的原因有三层:第一,LOH分配要遍历空闲列表找连续空间,比SOH的bump pointer分配贵;第二,LOH对象在GC里属于Gen 2,只有完整GC才会清理,频繁分配大对象会导致频繁Full GC,STW时间变长;第三,LOH默认不压缩,造成内存碎片化,可能出现内存充足却分配不了大对象的OOM。优化方向主要是:用ArrayPool复用大数组,用流式API避免一次性构造大JSON或大字符串,大文件边读边处理而不是一次性载入,必要时调整GCLOHThreshold或开启CompactOnce。最后要用dotnet-counters和dotnet-dump验证效果,不能靠猜。

这套回答把“是什么、为什么、怎么办、怎么验证”全串起来了,面试官想追问都找得到切入点。

5.2 进阶追问怎么接住

面试官如果继续深挖,常见就这几个方向:

追问1:ArrayPool的坑是什么?答:Rent返回的数组可能比请求长,必须记录实际使用长度;用完必须Return,长期持有会破坏池的意义;敏感数据要clearArray;池有最大数组长度限制。

追问2:为什么LOH不压缩?答:压缩要移动大对象并更新所有引用,大对象移动成本高。GC选择了吞吐优先,把压缩成本省掉,代价是碎片化。

追问3:阈值能不能改?答:能。DOTNET_GCLOHThreshold环境变量可以覆盖85000字节默认值(.NET Core 3.0+)。调高让更多对象留在SOH,享受压缩和快速分配,但会增加年轻代压力,需要压测。

追问4:怎么确认LOH有问题?答:dotnet-counters看LOH Size和Allocated Bytes/sec趋势,dotnet-dump看对象分布,VS内存诊断看快照差异和调用栈。

追问5:CLR为什么要区分大小对象堆?答:因为大对象会让SOH的压缩变得很贵,而且大对象通常生命周期长,把混合回收机制套在它身上收益低。单独划出来可以给小对象GC减负,代价是要单独管理碎片化。

这套问答练熟了,不止面试能过,遇到真实性能问题的时候你心里也会有底。

结尾

回到文章开头那个导出任务。我当时的修复方案其实不复杂:File.ReadLines边读边处理,CSV写入用流式,解析出来的记录分批提交数据库,不再把整个数据集一次性怼进内存。改完以后,凌晨的CPU曲线从持续100%降到平稳的10%上下,内存峰值从300MB降到40MB左右,GC暂停从日志里彻底消失了。

现在我写代码,尤其是写那些处理文件、批量数据、JSON的地方,脑子里会多蹦出一句话:这个东西真的需要一次全部出现在内存里吗?不需要,那就分块、流式、池化。LOH本身不是洪水猛兽,真正可怕的是对它的运作机制一无所知,然后在高频路径上反复制造大对象。把分配路径、回收策略、碎片化这几条逻辑想清楚,性能问题解决起来会快得多,面试聊天的时候也能让人刮目相看。

返回列表