如果你在.NET领域沉浸已久,便会发现运行时环境中隐藏着许多宝藏。有些工具精致友好,旨在让初学者安心使用;而另一些则是原始而锋利的利器,它们虽不提供“辅助轮”,但若你懂得何时以及如何运用,便能获得惊人的威力。
今天,我们将探讨五个较少为人知的API,它们能帮助你在内存密集型代码中榨取更多性能。
我们讨论的并非在玩具基准测试中节省毫秒级时间。这些特性能够真正影响高吞吐量服务、低延迟管道以及任何频繁分配内存的应用程序。
让我们深入挖掘。
1. 使用CollectionsMarshal.AsSpan()直接访问List内部存储
通常,当你使用List<T>时,会将其视为对数组的一种友好且安全的抽象。你可以添加元素、遍历列表,一切都很美好。但若你需要直接访问底层缓冲区而避免复制开销呢?这时CollectionsMarshal.AsSpan()便派上用场。
传统方式:痛苦的复制
var numbers = new List<int> { 1, 2, 3, 4, 5 };
// 需要Span?抱歉,你得先复制。
Span<int> span = numbers.ToArray().AsSpan();
foreach (var n in span)
Console.WriteLine(n);
这里的ToArray()会创建列表内容的完整副本。若你的列表有10万个元素,你便毫无理由地使内存占用翻倍。
更优方式
using System.Runtime.InteropServices;
var numbers = new List<int> { 1, 2, 3, 4, 5 };
// 直接基于内部数组创建Span
Span<int> span = CollectionsMarshal.AsSpan(numbers);
foreach (var n in span)
Console.WriteLine(n);
此时,你直接操作列表的内部存储。无需复制,没有多余的内存分配。当然,这有一个注意事项:若列表调整大小(例如添加新元素),你的Span将失效。这正是此API位于System.Runtime.InteropServices命名空间的原因——它强大但锋利。
在处理大型缓冲区或执行紧凑循环的实际代码中,这一点至关重要。它能避免不必要的复制,从而减轻GC压力并提升性能流畅度。
2. 优先使用CollectionsMarshal.GetValueRefOrNullRef进行字典访问
字典在.NET应用中无处不在。大多数时候,我们这样操作:
if (dict.TryGetValue("foo", out var value))
{
value++;
dict["foo"] = value;
}
看起来无害,对吧?但请注意:你通过TryGetValue查找一次键,然后通过索引器写入新值时又查找了一次。这相当于两次哈希查找。
更优方式
using System.Runtime.InteropServices;
Dictionary<string, int> dict = new()
{
["foo"] = 42
};
ref int valueRef = ref CollectionsMarshal.GetValueRefOrNullRef(dict, "foo");
if (!Unsafe.IsNullRef(ref valueRef))
{
valueRef++; // 直接更新字典条目
}
通过GetValueRefOrNullRef,你可以直接获取字典存储的引用。无需第二次查找,没有冗余的哈希计算,且避免了额外的写入操作。
若你处理大型字典或性能关键的循环(如编译器符号表或缓存),这些多余的查找会累积成可观的开销。此方法更简洁且更快速,但同样地,你是在操作引用,务必小心处理。
3. 使用GC.AllocateUninitializedArray
在.NET中创建新数组时,运行时会将其内容清零。这通常是好事,保证了可预测的行为且无垃圾数据。但若你实际上不关心初始内容(因为你会覆盖每个元素)呢?那清零步骤就是浪费时间。
传统方式
int[] arr = new int[1000]; // 自动清零
for (int i = 0; i < arr.Length; i++)
arr[i] = i; // 反正会覆盖所有内容
更优方式
int[] arr = GC.AllocateUninitializedArray<int>(1000);
for (int i = 0; i < arr.Length; i++)
arr[i] = i;
此处,运行时跳过了内存清零步骤。这使得分配更快,尤其对于大数组。
当然,这是需要严格自律的API之一。若你在写入前读取数组,将得到垃圾值。但在序列化、缓冲区重用或紧凑数学循环等场景中,这可以节省显著时间。
4. 优先使用结合IMemoryOwner的ArrayPool
若你在循环中重复分配大数组,垃圾回收器(GC)会不堪重负。这时ArrayPool<T>便登场了——它是一个可租用和归还的共享数组池。
传统方式
for (int i = 0; i < 1000; i++)
{
byte[] buffer = new byte[1024]; // 每次循环都分配
DoSomething(buffer);
}
这将产生成千上万个在堆上累积的分配。
更优方式
using System.Buffers;
for (int i = 0; i < 1000; i++)
{
using IMemoryOwner<byte> owner = MemoryPool<byte>.Shared.Rent(1024);
var memory = owner.Memory;
DoSomething(memory.Span);
}
这里我们从池中租用缓冲区,使用完毕后在处置IMemoryOwner<T>时将其归还。不会产生大量分配淹没GC。这在网络服务器、管道或视频处理等高吞吐量场景中表现卓越。
且相比直接租用和归还原始数组更安全,因为IMemoryOwner<T>保证了正确的清理。
5. 使用ObjectPool
数组是一回事,但创建成本高昂的对象呢?它们内部可能持有大缓冲区,且可能需要繁重的初始化。若你不断创建和销毁它们,不仅浪费内存,还浪费CPU周期。
这时ObjectPool<T>就派上用场了。
无池化
for (int i = 0; i < 100; i++)
{
var sb = new StringBuilder(1024);
sb.Append("Hello ").Append(i);
Console.WriteLine(sb.ToString());
}
这会创建100个不同的StringBuilder实例。它们最终会被回收,但何必一开始就创建这么多呢?
使用池化
using Microsoft.Extensions.ObjectPool;
var provider = new DefaultObjectPoolProvider();
var pool = provider.CreateStringBuilderPool();
for (int i = 0; i < 100; i++)
{
var sb = pool.Get();
sb.Clear();
sb.Append("Hello ").Append(i);
Console.WriteLine(sb.ToString());
pool.Return(sb);
}
现在你重用的是一个小型StringBuilder对象池。分配大幅减少,且避免了频繁触发GC的动荡。
此技术对于日志框架、序列化库或任何需要创建大量临时对象的工作负载来说是瑰宝。
这五个API有一个共同点:它们跳过了日常.NET编码中舒适、安全的外壳,让你更接近底层。这使得它们极其强大,但若你不注意权衡,也会有些危险。
CollectionsMarshal.AsSpan和GetValueRefOrNullRef。GC.AllocateUninitializedArray。ArrayPool<T>和ObjectPool<T>来减少分配波动。若你曾查看性能分析器,疑惑为何如此多时间花在GC或内存复制上,这些技巧可以帮你解决。它们并非适用于所有情况,但在追求真实性能时,这些工具能将“足够好”的应用与真正卓越的应用区分开来。