最近小编研究了C# 15的一批新语法,翻到集合表达式时,突然被 with(capacity:) 这个不起眼的修饰符勾住了。说起来有点不好意思,写 .NET 这么多年,我一直是"能用就行"派:List<int> items = new();,然后闭眼往循环里 Add。直到上周做数据处理,往 List 里塞了几百万条记录,肉眼可见地卡顿,我才认真把"预分配"这三个字掰开揉碎研究了一遍。
先别急着写代码:List的"成长烦恼"你了解吗?
List<T> 的本质是一个会"自动长大"的数组。它内部维护一块连续内存(_items 数组),不够用时就扩容。扩容策略简单粗暴:容量翻倍------0 → 4 → 8 → 16 → 32 → ...... 一直翻到装得下为止。
每次扩容要干两件事:
-
重新申请一块更大的内存。
-
把旧数组的所有元素逐个拷贝过去。
bash
// List<T> 扩容的真实逻辑(源码简化版)
private void Grow(int capacity)
{
// 新容量 = 旧容量 * 2(空数组时下限是 4)
int newCapacity = _items.Length == 0 ? 4 : 2 * _items.Length;
// 翻倍还不够装?直接用需要的容量
if ((uint)newCapacity < (uint)capacity) newCapacity = capacity;
// 新数组 + 老数据全量拷贝,一步到位
Array.Resize(ref _items, newCapacity);
}
以 10 万个 int 为例,无预分配时容量要经历 4、8、16、......、65536、131072,一共 16 次扩容 ,累计拷贝约 13 万个元素。单看数据量不大,但请注意:131072 × 4 字节 = 512KB,已经超过 85KB 的大对象堆(LOH)阈值。也就是说,扩容到后期申请的全是大数组,频繁分配 + 拷贝会造成 LOH 碎片和额外的 GC 压力,数据量再大几倍,卡顿就来了。
这里有个新手极易踩坑的地方,很多文章只说"翻倍扩容",却没人提 LOH:当集合元素超过约 2 万个 int(或同等字节数)时,底层数组就进了大对象堆,反复扩容的代价比你想的高得多。
三种写法,直接上实测
我写了个小实验:往 List 里塞 10 万个元素,分别用三种方式创建。
写法一:祖传 new() 无脑 Add
bash
// 零预分配,全靠扩容硬扛
List<int> items = new();
for (int i = 0; i < 100_000; i++)
items.Add(i);
写法二:老派的 new(capacity) 预分配
bash
// 提前告诉 List 我要 10 万个,避免中途扩容
List<int> items = new(100_000);
for (int i = 0; i < 100_000; i++)
items.Add(i);
写法三:C# 15 的 with(capacity:) 集合表达式
bash
// 集合表达式 + with(capacity:) 预分配 + 展开运算符填充
List<int> items = [with(capacity: 100_000), ..Enumerable.Range(0, 100_000)];
完整测速代码如下:
bash
using System.Diagnostics;
const int Size = 100_000;
// 教训来自我第一次跑出来的"假数据":
// 毫秒精度 + 无预热 + 固定顺序,结果完全失真
var sw = Stopwatch.StartNew();
// 先热热身,把 JIT 编译和启动开销排除在外
Warmup();
// 写法一:无预分配
sw.Restart();
List<int> noCap = new();
for (int i = 0; i < Size; i++) noCap.Add(i);
Console.WriteLine($"无预分配 : {sw.ElapsedTicks / 10f:F1} us");
// 写法二:传统容量预分配
sw.Restart();
List<int> old = new(Size);
for (int i = 0; i < Size; i++) old.Add(i);
Console.WriteLine($"new(capacity) 预分配 : {sw.ElapsedTicks / 10f:F1} us");
// 写法三:C# 15 with(capacity:)
sw.Restart();
List<int> modern = [with(capacity: Size), ..Enumerable.Range(0, Size)];
Console.WriteLine($"with(capacity:) : {sw.ElapsedTicks / 10f:F1} us");
static void Warmup()
{
var list = new List<int>();
for (int i = 0; i < Size; i++) list.Add(i);
}
结果(我跑了好几轮,取的是中位数):new() 无脑版最慢;new(Size) 和 with(capacity:) 速度接近,都是无预分配版的 2 倍以上;with() 版本在部分轮次略快,但差距基本在误差范围内。

先泼盆冷水:第一次实测我差点被数据骗了
说实话,第一次跑的时候我的结果"完美得可疑"------with() 版本稳压 50% 以上。后来一查才发现,问题全出在测量方式上:
-
毫秒精度太粗 :
ElapsedMilliseconds是整数毫秒,10 万元素这种量级本来就几毫秒,误差直接把真实差距淹没了。 -
没有预热:第一段代码吃满 JIT 编译和启动开销,谁先跑谁吃亏。
-
固定执行顺序:执行顺序本身就是个隐藏变量。
这也是很多性能帖子的通病:结论不是代码跑出来的,是测量方式"跑"出来的。我后来改成"预热 + 乱序 + 多轮取中位数",这才看到真实差距------预分配确实赢,但赢在"少扩容",而不是语法本身。
with(capacity:) 到底香在哪?我的答案是"可读性 + 上限保护"
先说性能:new(Size) 和 [with(capacity: Size), ..] 本质都在做同一件事------提前确定底层数组大小,省掉扩容。二者是同一个量级,谁也别想碾压谁。
那新语法价值在哪?我认为有三点:
-
声明式表达:集合表达式把"建集合 + 定容量 + 填充"压成一行,语义自文档化,读代码的人一眼就知道你要干什么。
-
容量上限保护 :
with(capacity:)让你在填充前就把"规模预期"写死,从写法上杜绝"忘了预分配"。 -
生态统一 :C# 15 之后,集合初始化越来越"值语义",配合 spread 运算符
..,拼装数据流的代码能写得非常顺。
适用场景我的判断:
-
明确知道规模(批量灌数据、从数据库读 N 条)→ 必须预分配;
-
热点路径(高频创建集合)→ 预分配能明显降 GC;
-
不知道规模 → 别硬猜,
new()+EnsureCapacity按需兜底。
避坑清单:预分配不是无脑抄
-
容量别拍脑袋 :
with(capacity: 1_000_000)但只塞 10 条,纯属浪费内存。预分配的是内存,不是"保险"。 -
大集合留意 LOH:超过 85KB 的数组进大对象堆,频繁扩容更容易碎片化。能一次到位就一次到位。
-
测性能先讲方法论 :预热、乱序、多轮、取中位数,别拿
ElapsedMilliseconds唬自己。 -
注意版本要求 :
with(capacity:)是 C# 15 语法,需要 .NET 10 及以上 SDK;生产环境没升级就老老实实用new(capacity)。
总结
List 预分配这事,说到底是"数组扩容成本"和"内存占用"的博弈。new() 不是不能用,而是你要知道它在背后默默扩容了 16 次;new(capacity) 是性价比最高的传统解;C# 15 的 with(capacity:) 则是把这件事做成了声明式语法,代码更干净,心智负担更小。
大家对这个with的这个新语法有什么看法,欢迎留言讨论。