前言 在.NET 开发中,垃圾回收(GC)是 CLR 提供的核心能力,它自动管理内存分配与回收,大幅降低了内存泄漏的风险。但GC 并非 “无成本”—— 回收过程中会产生 “Stop-The-World”(STW)暂停,频繁的 GC 操作会直接导致程序卡顿、CPU 占用飙升,尤其在大数据处理、高并发接口等场景下,GC 压力可能成为性能瓶颈。简单GC介绍
常见的 GC 不友好代码 List 扩容带来的 GC 压力 不友好代码
1 2 3 4 5 6 7 8 9 static void Main () { var ls = new List<int >(); for (var i = 0 ; i < 1000000 ; i++) { ls.Add(i); } }
List有三个关键成员:
私有数组_items:真正存储元素的容器。
Count属性:当前实际存储的元素数量。
Capacity属性:当前_items数组的容量。
当Count等于Capacity时,再添加元素就会触发扩容。
在频繁扩容或处理大量元素时会引入一些性能和内存管理方面的压力:
GC 压力,每次扩容时,旧数组会被标记为 “不再引用”,当旧数组大小超过 85000 字节(大对象阈值)时,会被分配到大对象堆(LOH),而 LOH 的回收成本远高于小对象堆(SOH)。频繁扩容产生的旧数组(尤其是 LOH 上的大对象)被回收后,可能在内存中留下不连续的空闲块,导致内存碎片。这会使后续大对象分配时,即使总空闲内存足够,也可能因找不到连续空间而触发 Full GC,从而导致内存占用居高不下。
扩容时会出现新旧数组共存的短暂状态,当前数组容量为 2000,扩容后新数组容量为 4000,复制期间内存中同时存在两个数组(共占用 4000 个元素的内存)。在内存紧张的场景下可能导致内存峰值升高,触发 OOM。
扩容的另一个核心操作是Array.Copy(将旧数组元素复制到新数组),这是一个O (n) 复杂度的 CPU 密集型操作。元素数量越多,复制耗时越长,频繁扩容会累积 CPU 开销效率远低于预设初始容量的情况。
点击查看扩容关键源码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 public void Add (T item ) { ++this ._version; T[] items = this ._items; int size = this ._size; if ((uint )size < (uint )items.Length) { this ._size = size + 1 ; items[size] = item; } else { this .AddWithResize(item); } }private void AddWithResize (T item ) { int size = this ._size; this .Grow(size + 1 ); this ._size = size + 1 ; this ._items[size] = item; }internal void Grow (int capacity ) { int newCapacity = this ._items.Length == 0 ? 4 : 2 * this ._items.Length; if ((uint )newCapacity > 2147483591U ) newCapacity = 2147483591 ; if (newCapacity < capacity) newCapacity = capacity; this .Capacity = newCapacity; }public int Capacity { get => this ._items.Length; set { if (value < this ._size) ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.value , ExceptionResource.ArgumentOutOfRange_SmallCapacity); if (value == this ._items.Length) return ; if (value > 0 ) { T[] destinationArray = new T[value ]; if (this ._size > 0 ) Array.Copy((Array)this ._items, (Array)destinationArray, this ._size); this ._items = destinationArray; } else this ._items = List<T>.s_emptyArray; } }
优化后代码:
1 2 3 4 5 6 7 8 9 static void Main () { var ls = new List<int >(1000000 ); for (var i = 0 ; i < 1000000 ; i++) { ls.Add(i); } }
循环中频繁创建字符串 不友好代码
1 2 3 4 5 6 7 8 9 var dataList = Enumerable.Range(0 , 1 _000_000).ToList();string result = "" ;foreach (var item in dataList) { result += $"Item: {item} , " ; }
字符串是不可变类型,result += … 会创建新的字符串对象,丢弃旧对象。100 万次循环会产生 100 万个垃圾字符串,且这些对象会快速晋升到 1 代、2 代,触发多次 GC。
友好的代码
1 2 3 4 5 6 7 8 9 10 var dataList = Enumerable.Range(0 , 1 _000_000).ToList();var stringBuilder = new StringBuilder(dataList.Count * 10 ); foreach (var item in dataList) { stringBuilder.Append($"Item: {item} , " ); }string result = stringBuilder.ToString();
StringBuilder 内部维护一个可扩容的字符数组, Append 操作不会每次创建新对象,仅在缓冲区不足时扩容(且扩容策略更高效)。最终仅产生 1 个结果字符串。
大对象的频繁创建与销毁 我们模拟了一个大对象,并在循环中创建,其次还使用了对象池ObjectPool对比使用对象池前后的性能差异,主要体现在GC压力和执行时间上。
ObjectPool 是 .NET 提供的一种对象复用机制,核心思想是:创建一批对象放在「池」中,需要时从池里取,用完后放回池里,而非直接销毁。 ObjectPool 使用中有一些注释事项:
maximumRetained 不宜过大(避免内存占用过高),也不宜过小(频繁创建新对象),需根据并发量调整。
Return 方法中必须清空对象状态(如 sb.Clear()、dto.Name = null),否则会导致数据污染。
默认实现不是线程安全的,多线程场景下需使用 ObjectPoolProvider.CreatePool 或自行加锁。
不要为所有对象都创建池,仅针对「创建成本高 + 使用频繁」的对象。
不使用 ObjectPool 和使用 ObjectPool 对比
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 class Program { static void Main (string [] args ) { Console.WriteLine("=== ObjectPool优化示例 ===\n" ); const int iterations = 50000 ; Console.WriteLine("方式1:不使用对象池 (每次new新对象)" ); TestWithoutPool(iterations); GC.Collect(); GC.WaitForPendingFinalizers(); Console.WriteLine("\n方式2:使用对象池 (复用对象实例)" ); TestWithPool(iterations); Console.ReadKey(); } static void TestWithoutPool (int iterations ) { var sw = Stopwatch.StartNew(); var beforeGC = GC.CollectionCount(0 ); for (var i = 0 ; i < iterations; i++) { var obj = new LargeObject(); obj.DoWork($"Task_{i} " ); } sw.Stop(); var gcCount = GC.CollectionCount(0 ) - beforeGC; Console.WriteLine($" 执行时间: {sw.ElapsedMilliseconds} ms" ); Console.WriteLine($" 触发GC次数: {gcCount} " ); Console.WriteLine($" 创建了{iterations} 个对象,占用约{iterations * 100 } KB内存" ); } static void TestWithPool (int iterations ) { var pool = new DefaultObjectPool<LargeObject>(new SimplePolicy(), maximumRetained: 100 ); var sw = Stopwatch.StartNew(); var beforeGC = GC.CollectionCount(0 ); for (var i = 0 ; i < iterations; i++) { var obj = pool.Get(); obj.DoWork($"Task_{i} " ); pool.Return(obj); } sw.Stop(); var gcCount = GC.CollectionCount(0 ) - beforeGC; Console.WriteLine($" 执行时间: {sw.ElapsedMilliseconds} ms" ); Console.WriteLine($" 触发GC次数: {gcCount} " ); } }public class LargeObject { private byte [] _data = new byte [100 _000]; private string _taskName; public void DoWork (string taskName ) { _taskName = taskName; for (int i = 0 ; i < 1000 ; i++) { _data[i] = (byte )(i % 256 ); } } public void ResetWithDataClearing () { _taskName = null ; Array.Clear(_data, 0 , _data.Length); } }public class SimplePolicy : PooledObjectPolicy <LargeObject > { public override LargeObject Create () => new LargeObject(); public override bool Return (LargeObject obj ) { obj.ResetWithDataClearing(); return true ; } }
最终结果输出
1 2 3 4 5 6 7 8 方式1:不使用对象池 (每次new新对象) 执行时间: 619 ms 触发GC次数: 1562 创建了50000个对象,占用约5000000KB内存 方式2:使用对象池 (复用对象实例) 执行时间: 130 ms 触发GC次数: 0
可以看到对于 GC 的压力的影响是还非常大的。
未及时释放非托管资源 FileStream、StreamReader等实现了IDisposable接口的类,内部持有非托管资源(如文件句柄、网络连接)。如果不主动调用Dispose(),非托管资源需等待 GC Finalize 时才会释放。如果不即使手动释放,会导致非托管资源长期占用,内存无法释放和带来GC 回收压力。
不友好代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 static void TestWithoutDispose () { for (var i = 0 ; i < 10000 ; i++) { var fs = new FileStream(Path.GetTempFileName(), FileMode.Create); fs.WriteByte((byte )(i % 256 )); fs.Flush(); } }
友好代码
1 2 3 4 5 6 7 8 9 10 11 12 static void TestWithDispose () { for (var i = 0 ; i < 10000 ; i++) { using var fs = new FileStream(Path.GetTempFileName(), FileMode.Create); fs.WriteByte((byte )(i % 256 )); fs.Flush(); } }
值类型装箱(隐式类型转换导致额外分配) 将值类型(如 int)转换为引用类型(如 object)时,CLR 会在堆上分配一个 “装箱对象”,存储值类型的数据,这会产生额外的内存分配。
不友好代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 static void TestWithBoxing (int iterations ) { var sw = Stopwatch.StartNew(); var beforeMemory = GC.GetTotalMemory(false ); var beforeGC = GC.CollectionCount(0 ); var list = new ArrayList(); for (int i = 0 ; i < iterations; i++) { list.Add(i); } int sum = 0 ; for (int i = 0 ; i < list.Count; i++) { sum += (int )list[i]; } sw.Stop(); var afterMemory = GC.GetTotalMemory(false ); var gcCount = GC.CollectionCount(0 ) - beforeGC; Console.WriteLine($" 执行时间: {sw.ElapsedMilliseconds} ms" ); Console.WriteLine($" 内存增长: {(afterMemory - beforeMemory) / 1024 :F1} KB" ); Console.WriteLine($" GC次数: {gcCount} " ); }
友好代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 static void TestWithoutBoxing (int iterations ) { var sw = Stopwatch.StartNew(); var beforeMemory = GC.GetTotalMemory(false ); var beforeGC = GC.CollectionCount(0 ); var list = new List<int >(iterations); for (int i = 0 ; i < iterations; i++) { list.Add(i); } int sum = 0 ; for (int i = 0 ; i < list.Count; i++) { sum += list[i]; } sw.Stop(); var afterMemory = GC.GetTotalMemory(false ); var gcCount = GC.CollectionCount(0 ) - beforeGC; Console.WriteLine($" 执行时间: {sw.ElapsedMilliseconds} ms" ); Console.WriteLine($" 内存增长: {(afterMemory - beforeMemory) / 1024 :F1} KB" ); Console.WriteLine($" GC次数: {gcCount} " ); }
在循环 1000000 下输出,在使用泛型后 GC 回收次数减少,遍历效率也会提升。
1 2 3 4 5 6 7 8 9 10 测试1: 使用非泛型集合 (会装箱) 执行时间: 52 ms 内存增长: 35222.0 KB GC次数: 3 测试2: 使用泛型集合 (避免装箱) 执行时间: 5 ms 内存增长: 3906.0 KB GC次数: 0
滥用 LINQ 导致额外对象分配 LINQ 延迟执行,使用Where和Select返回的IEnumerable是 “延迟迭代器”,调用ToList()时才会执行,过程中会创建多个迭代器对象。Select中创建的匿名类型会产生大量短生命周期对象,全部进入 0 代,触发 GC 回收。
不友好代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 static void TestLinqAbuse (List<Customer> data ) { var sw = Stopwatch.StartNew(); var beforeGC = GC.CollectionCount(0 ); for (int i = 0 ; i < 50 ; i++) { var result = data .Where(c => c.Age > 25 ) .Select(c => new { c.Id, c.Balance }) .Where(c => c.Balance > 1000 ) .Take(5 ) .ToList(); } }
用普通循环替代LINQ,大数据场景更优
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 static void TestOptimizedCode (List<Customer> data ) { for (int i = 0 ; i < 50 ; i++) { var result = new List<CustomerInfo>(5 ); var count = 0 ; foreach (var customer in data) { if (customer.Age > 25 && customer.Balance > 1000 ) { result.Add(new CustomerInfo { Id = customer.Id, Balance = customer.Balance }); if (++count >= 5 ) break ; } } } }
如果仍想使用 LINQ,可直接返回具体 DTO,减少匿名类型分配:
1 2 3 4 5 6 7 var result = data .Where(c => c.Age > 25 ) .Select(c => new CustomerInfo { Id = c.Id, Balance = c.Balance }) .Where(c => c.Balance > 1000 ) .Take(5 ) .ToList();
总结 GC 友好的代码本质是 “减少不必要的内存分配,降低 GC 回收压力”。在日常开发中,很多 GC 问题并非源于复杂的架构设计,而是来自 “未指定 List 初始容量”“循环中字符串拼接” 等细节。 尤其是在大数据、高并发场景下,这些细节的优化会产生 “量变到质变” 的效果 —— 不仅能减少 GC 暂停时间,还能提升程序的稳定性和吞吐量。