别再只会 new List() 了!C#15 的 with(capacity:) 到底香在哪?

最近小编研究了C# 15的一批新语法,翻到集合表达式时,突然被 with(capacity:) 这个不起眼的修饰符勾住了。说起来有点不好意思,写 .NET 这么多年,我一直是"能用就行"派:List<int> items = new();,然后闭眼往循环里 Add。直到上周做数据处理,往 List 里塞了几百万条记录,肉眼可见地卡顿,我才认真把"预分配"这三个字掰开揉碎研究了一遍。

先别急着写代码:List的"成长烦恼"你了解吗?

List<T> 的本质是一个会"自动长大"的数组。它内部维护一块连续内存(_items 数组),不够用时就扩容。扩容策略简单粗暴:容量翻倍------0 → 4 → 8 → 16 → 32 → ...... 一直翻到装得下为止。

每次扩容要干两件事:

  1. 重新申请一块更大的内存。

  2. 把旧数组的所有元素逐个拷贝过去。

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% 以上。后来一查才发现,问题全出在测量方式上:

  1. 毫秒精度太粗ElapsedMilliseconds 是整数毫秒,10 万元素这种量级本来就几毫秒,误差直接把真实差距淹没了。

  2. 没有预热:第一段代码吃满 JIT 编译和启动开销,谁先跑谁吃亏。

  3. 固定执行顺序:执行顺序本身就是个隐藏变量。

这也是很多性能帖子的通病:结论不是代码跑出来的,是测量方式"跑"出来的。我后来改成"预热 + 乱序 + 多轮取中位数",这才看到真实差距------预分配确实赢,但赢在"少扩容",而不是语法本身。

with(capacity:) 到底香在哪?我的答案是"可读性 + 上限保护"

先说性能:new(Size)[with(capacity: Size), ..] 本质都在做同一件事------提前确定底层数组大小,省掉扩容。二者是同一个量级,谁也别想碾压谁。

那新语法价值在哪?我认为有三点:

  1. 声明式表达:集合表达式把"建集合 + 定容量 + 填充"压成一行,语义自文档化,读代码的人一眼就知道你要干什么。

  2. 容量上限保护with(capacity:) 让你在填充前就把"规模预期"写死,从写法上杜绝"忘了预分配"。

  3. 生态统一 :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的这个新语法有什么看法,欢迎留言讨论。

参考:learn.microsoft.com/zh-cn/dotne...

相关推荐
思考着亮21 分钟前
1.路由与请求参数校验
后端
半个落月23 分钟前
NestJS 入门实战:从工厂模式到 Todo CRUD,讲透模块化、依赖注入与测试
后端·nestjs
思考着亮23 分钟前
11.MVCC、行锁与事务隔离级别
后端
一开24 分钟前
一个自己开发的 Agent Harness-持久化与恢复篇
后端
一开25 分钟前
一个自己开发的 Agent Harness-模型降级篇
后端
Geek漫游指南30 分钟前
AI 会回答还不够:ProofOps 业务研判平台落地实战
后端
SimonKing44 分钟前
白嫖国产多模态大模型:商汤 SenseNova 接入指南
java·后端·程序员
小江的记录本44 分钟前
【ORM框架】MyBatis核心原理、ORM思想、MyBatis vs JPA
java·数据库·后端·spring·spring cloud·oracle·mybatis
卷无止境1 小时前
FastAPI生产环境密钥管理全解析,从一个.env文件说起
后端·python·fastapi