Linq SelectMany遍历HashSet列表时每个内部集合产生内存分配的原因解析及优化方案
首先要肯定你的结论:你的分析完全准确——内存分配确实来自结构体转接口时的装箱操作,接下来我们详细拆解原因,并给出几种无分配的优化方案:
问题根源:值类型枚举器的装箱
HashSetEnumerator是一个值类型(结构体),而当你把HashSetIEnumerable<T>接口来处理时(不管是显式转换为IEnumerable<IEnumerable<TestObj>>,还是调用SelectMany时的隐式转换),遍历过程中会发生以下操作:
- 调用
IEnumerable<T>.GetEnumerator(),需要返回一个IEnumerator<T>接口实例; - 由于HashSet的Enumerator是值类型,将它赋值给接口变量时,CLR会自动把这个结构体装箱到堆内存,这就产生了额外的内存分配。
对比两种遍历场景:
- 直接遍历
List<HashSet<TestObj>>:foreach会直接使用HashSet的结构体Enumerator,不需要转换为接口,因此无装箱、无额外分配; - 转换为
IEnumerable<IEnumerable<TestObj>>或使用SelectMany:遍历每个子集合时,都需要将值类型Enumerator装箱为IEnumerator<T>接口,每个子集合对应一次装箱分配。
无分配的优化方案
方案1:自定义针对List<HashSet>的扩展方法(最推荐)
我们可以写一个专门的扩展方法,利用C#的yield语法生成无装箱的枚举器——编译器会自动为yield方法生成结构体类型的枚举器,避免接口转换带来的装箱:
public static class HashSetListExtensions { public static IEnumerable<T> EnumerateAll<T>(this List<HashSet<T>> listOfSets) { foreach (var set in listOfSets) { foreach (var item in set) { yield return item; } } } }
使用方式非常简单,和SelectMany完全一致,但不会产生任何内存分配:
foreach (TestObj neighbor in listOfSets.EnumerateAll()) { // 对neighbor执行操作 }
如果你追求极致性能,也可以手动实现结构体枚举器(yield的底层其实就是这个逻辑),不过yield版本已经足够简洁高效:
public struct HashSetListEnumerator<T> : IEnumerator<T> { private readonly List<HashSet<T>> _source; private int _setIndex; private HashSet<T>.Enumerator _currentSetEnumerator; public HashSetListEnumerator(List<HashSet<T>> source) { _source = source; _setIndex = -1; _currentSetEnumerator = default; Current = default!; } public bool MoveNext() { while (true) { if (_setIndex == -1) { if (_source.Count == 0) return false; _setIndex = 0; _currentSetEnumerator = _source[0].GetEnumerator(); } if (_currentSetEnumerator.MoveNext()) { Current = _currentSetEnumerator.Current; return true; } _currentSetEnumerator.Dispose(); _setIndex++; if (_setIndex >= _source.Count) return false; _currentSetEnumerator = _source[_setIndex].GetEnumerator(); } } public T Current { get; private set; } object IEnumerator.Current => Current; public void Reset() => throw new NotSupportedException(); public void Dispose() => _currentSetEnumerator.Dispose(); } // 对应扩展方法 public static IEnumerable<T> EnumerateAll<T>(this List<HashSet<T>> listOfSets) { return new EnumerableImpl<T>(listOfSets); } private class EnumerableImpl<T> : IEnumerable<T> { private readonly List<HashSet<T>> _source; public EnumerableImpl(List<HashSet<T>> source) => _source = source; public IEnumerator<T> GetEnumerator() => new HashSetListEnumerator<T>(_source); IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); }
方案2:避免接口类型转换
尽量保持变量的强类型,不要将List<HashSet<TestObj>>转换为IEnumerable<IEnumerable<TestObj>>。只要在强类型下操作,无论是手动嵌套遍历还是使用上面的自定义扩展方法,都不会触发装箱分配。
方案3:使用Span(局限性较大)
如果你的场景允许,可以考虑将HashSet的元素复制到数组中,然后通过Span
验证建议
你可以用BenchmarkDotNet来对比不同方案的性能和分配情况,比如测试以下几种场景:
- 原始手动嵌套遍历(基准)
- SelectMany遍历
- 转换为接口后的嵌套遍历
- 自定义扩展方法遍历
测试结果会清晰显示,自定义方案的内存分配为0,性能和原始手动遍历一致。
内容的提问来源于stack exchange,提问作者Tomer B

