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

相关推荐
123的故事11 小时前
工具分享(2)-NSmartProxy内网穿透工具。
c#·.net·nsmartproxy
SunnyDays101113 小时前
使用 C# 添加、修改和删除 Excel VBA 宏 (无需 Microsoft Office Interop)
c#·excel··vba
影寂ldy13 小时前
C# 多接口、同名冲突、显式实现、接口继承 完整笔记
java·笔记·c#
诸葛大钢铁13 小时前
如何降低Word文件的体积?压缩Word文件的三种方法
开发语言·c#
专注VB编程开发20年13 小时前
阿里通义灵码插件安装失败
开发语言·ide·c#·visual studio
影寂ldy14 小时前
C# 泛型方法
java·前端·c#
caimouse14 小时前
Godot 4.7 内嵌 C# 模块切换到 .NET 9.0 编译指南
c#·.net·godot
z落落1 天前
C# 泛型方法(原理、类型推断、多泛型参数)+泛型效率(普通类型 VS Object装箱 VS 泛型)
开发语言·c#
rockey6271 天前
基于AScript的SQL脚本语言发布啦!
sql·c#·.net·script·expression·动态脚本
z落落1 天前
C# 四种特殊类:抽象类、密封类、静态类、部分类
开发语言·c#