.NET 内存性能实战:Span<T>、ArrayPool、GC 与 LOH 控制

很多服务在压测里吞吐不错,一上生产就抖。根因常常不是业务逻辑,而是内存分配模式不健康。

典型表现:

  • GC 频率偏高
  • P99 周期性尖峰
  • 单机吞吐不稳定

这篇文章重点讲"如何减少无意义分配",并把优化控制在团队可维护范围内。

问题背景

真实现场:一次常规版本上线后,接口平均耗时变化不大,但 P99 从 120ms 拉到 380ms,最后定位到的是字符串拼接和临时数组在高峰时段触发了更频繁的 GC。

一个日志清洗或协议解析接口,经常会写出这样的代码:

csharp 复制代码
var parts = line.Split('|');
var user = parts[0].Trim();
var action = parts[1].Trim();
var ts = DateTime.Parse(parts[2]);

逻辑简单,但每次请求都在制造短命对象。高并发下,这些小分配会形成大压力。

原理解析

代际 GC

短命对象主要在 Gen0/Gen1 回收。分配速率过高时,GC 会频繁触发,吞吐被打断。

LOH(大对象堆)

大于约 85KB 的对象进入 LOH。大对象频繁创建和释放,会导致更重的回收成本和内存碎片问题。

Span<T>ArrayPool<T>

  • Span<T> 在不额外分配的前提下切片处理内存
  • ArrayPool<T> 复用数组,避免重复 new 大缓冲区

示例代码

先定义记录:

csharp 复制代码
public sealed record AuditLog(string User, string Action, DateTime Timestamp);

使用 ReadOnlySpan<char> 解析:

csharp 复制代码
public static bool TryParseAuditLog(string line, out AuditLog? log)
{
    log = null;
    ReadOnlySpan<char> span = line.AsSpan();

    var p1 = span.IndexOf('|');
    if (p1 < 0) return false;

    var p2 = span[(p1 + 1)..].IndexOf('|');
    if (p2 < 0) return false;
    p2 += p1 + 1;

    var user = span[..p1].Trim();
    var action = span[(p1 + 1)..p2].Trim();
    var tsText = span[(p2 + 1)..].Trim();

    if (!DateTime.TryParse(tsText, out var ts)) return false;

    log = new AuditLog(user.ToString(), action.ToString(), ts);
    return true;
}

对于大文本拼接,使用池化数组:

csharp 复制代码
using System.Buffers;
using System.Text;

public static string BuildCsvLine(IReadOnlyList<string> columns)
{
    var pool = ArrayPool<char>.Shared;
    var buffer = pool.Rent(4096);

    try
    {
        var pos = 0;
        for (var i = 0; i < columns.Count; i++)
        {
            var col = columns[i].AsSpan();
            col.CopyTo(buffer.AsSpan(pos));
            pos += col.Length;

            if (i < columns.Count - 1)
            {
                buffer[pos++] = ',';
            }
        }

        return new string(buffer, 0, pos);
    }
    finally
    {
        pool.Return(buffer, clearArray: true);
    }
}

工程实践建议

先定位热点,再做低层优化

优先用以下工具定位:

  • dotnet-counters
  • dotnet-trace
  • PerfViewSpeedScope

不要把 Span<T> 当"默认写法"。

池化有收益,也有代价

ArrayPool 会提升复杂度。只有在高频路径或大数组场景才值得引入。

限制 LOH 对象生成

  • 避免一次性拼接超大字符串
  • 大缓冲优先池化
  • 流式处理替代整块加载

团队约束建议

建立两层规范:

  • 普通代码优先可读性
  • 热路径允许低层优化,但必须配压测数据

评论区讨论

  • 你们线上更常见的是 CPU 打满,还是 GC 抖动导致的延迟尖峰?
  • 在你的团队里,Span<T> 属于默认工具,还是只在热点路径使用?
  • 如果一个优化能省 8% 分配但明显降低可读性,你会怎么取舍?

总结

内存优化不是炫技,目标是把分配曲线压平,让系统在高峰期也能稳定响应。

Span<T>ArrayPool<T> 非常有价值,但前提是你清楚热点在哪、收益有多大、复杂度是否可接受。工程上,量化收益永远比"看起来高级"更重要。

相关推荐
SunnyDays101117 分钟前
如何使用 C# 自动调整 Excel 行高和列宽
开发语言·c#·excel
itgather1 小时前
OfficeExcel — Word / Excel DLL 验证台功能介绍
c#·word·excel
云中小生1 小时前
Scrutor:.NET 依赖注入自动化的优雅实现
c#·.net
郝亚军1 小时前
Visual Studio 2022项目中的.sln是什么?
c++·c#·visual studio
jghhh011 小时前
C# 图片水印工具(支持9个位置)
数据库·microsoft·c#
咸鱼翻身小阿橙2 小时前
C# WinForms 控件学习项目
开发语言·学习·c#
JaydenAI2 小时前
[MAF预定义Agent中间件-03]FunctionInvocationDelegatingAgent:将AOP引入函数调用
ai·c#·agent·aop·maf
.NET修仙日记2 小时前
.NET 领域驱动设计:用户角色更新如何从应用服务落地到领域实体(代码拆解)
c#·.net·领域驱动设计·微软技术·角色设计
.NET修仙日记2 小时前
Scrutor:.NET 依赖注入自动化的优雅实现
c#·.net·.net core·微软技术·依赖注入·scrutor
xiaoshuaishuai82 小时前
C# Avalonia 依赖属性与WPF的区别
开发语言·c#·wpf