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

相关推荐
飞跃未来2 小时前
泛型与集合
c#
你的不安4 小时前
C#中 管理NuGet程序包
开发语言·c#·wpf
我是唐青枫4 小时前
C#.NET SignalR 深入解析:实时通信、Hub 与连接管理实战
开发语言·c#·.net
WarPigs5 小时前
基于泛型+反射的Excel万能导表工具
unity·c#·excel·反射
小曹要微笑6 小时前
C#什么是方法
c#·c#方法·方法是什么·什么是方法
阿蒙Amon6 小时前
C#常用类库-详解CsvHelper
开发语言·数据库·c#
军训猫猫头6 小时前
5.正弦波生成器:支持连续相位与可控重置 C# + WPF 完整示例
c#·.net·wpf
心前阳光7 小时前
Mirror网络库插件使用4
java·linux·网络·unity·c#·游戏引擎