如何写出 GC 友好的代码

前言

在.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时,再添加元素就会触发扩容。

在频繁扩容或处理大量元素时会引入一些性能和内存管理方面的压力:

  1. GC 压力,每次扩容时,旧数组会被标记为 “不再引用”,当旧数组大小超过 85000 字节(大对象阈值)时,会被分配到大对象堆(LOH),而 LOH 的回收成本远高于小对象堆(SOH)。频繁扩容产生的旧数组(尤其是 LOH 上的大对象)被回收后,可能在内存中留下不连续的空闲块,导致内存碎片。这会使后续大对象分配时,即使总空闲内存足够,也可能因找不到连续空间而触发 Full GC,从而导致内存占用居高不下。
  2. 扩容时会出现新旧数组共存的短暂状态,当前数组容量为 2000,扩容后新数组容量为 4000,复制期间内存中同时存在两个数组(共占用 4000 个元素的内存)。在内存紧张的场景下可能导致内存峰值升高,触发 OOM。
  3. 扩容的另一个核心操作是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
// 添加元素到List
public void Add(T item)
{
++this._version;
T[] items = this._items;
// 获取当前元素数量
int size = this._size;
// 检查当前是否有剩余容量(uint转换避免负数判断,更高效)
if ((uint)size < (uint)items.Length)
{
// 有容量:直接添加元素,元素数量+1
this._size = size + 1;
items[size] = item;
}
else
{
// 无容量:调用带扩容的添加方法
this.AddWithResize(item);
}
}

private void AddWithResize(T item)
{
// 记录当前元素数量
int size = this._size;
// 触发扩容,确保至少能容纳size+1个元素
this.Grow(size + 1);
this._size = size + 1;
// 将新元素放入扩容后的数组
this._items[size] = item;
}
internal void Grow(int capacity)
{
// 计算基础新容量:
// - 若当前数组为空(初始状态),则设为4
// - 否则设为当前容量的2倍(翻倍策略)
int newCapacity = this._items.Length == 0 ? 4 : 2 * this._items.Length;

// 边界校验:防止新容量超过最大允许值(2147483591,避免溢出)
if ((uint)newCapacity > 2147483591U)
newCapacity = 2147483591;

// 若计算的新容量仍小于所需容量(如一次添加大量元素),则直接使用所需容量
if (newCapacity < capacity)
newCapacity = capacity;

// 设置新容量(触发数组复制的核心入口)
this.Capacity = newCapacity;
}
// Capacity属性的setter
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;

// 新容量大于0(正常扩容/缩容到指定大小)
if (value > 0)
{
// 创建一个新的数组,长度为新容量
T[] destinationArray = new T[value];

// 2若当前有元素(_size > 0),则将旧数组中的元素复制到新数组
// 只复制已有的元素(数量为_size),而非整个旧数组
if (this._size > 0)
Array.Copy((Array)this._items, (Array)destinationArray, this._size);

// 用新数组替换内部数组(完成容量调整)
this._items = destinationArray;
}
else
// 新容量为0清空容量,使用一个静态空数组。
this._items = List<T>.s_emptyArray;
}
}
优化后代码:
1
2
3
4
5
6
7
8
9
static void Main()
{
// 初始化指定容量的List
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 使用中有一些注释事项:

  1. maximumRetained 不宜过大(避免内存占用过高),也不宜过小(频繁创建新对象),需根据并发量调整。
  2. Return 方法中必须清空对象状态(如 sb.Clear()、dto.Name = null),否则会导致数据污染。
  3. 默认实现不是线程安全的,多线程场景下需使用 ObjectPoolProvider.CreatePool 或自行加锁。
  4. 不要为所有对象都创建池,仅针对「创建成本高 + 使用频繁」的对象。

不使用 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; // 测试循环次数,模拟高频场景

// 测试1:传统方式 - 每次都创建新对象
Console.WriteLine("方式1:不使用对象池 (每次new新对象)");
TestWithoutPool(iterations);

// 强制垃圾回收,清理测试1产生的垃圾
GC.Collect();
GC.WaitForPendingFinalizers();

// 测试2:对象池方式 - 复用对象实例
Console.WriteLine("\n方式2:使用对象池 (复用对象实例)");
TestWithPool(iterations);

Console.ReadKey();
}

/// <summary>
/// 传统方式测试:频繁创建新对象
/// 缺点:每次都要分配内存,产生大量垃圾对象,触发频繁GC
/// </summary>
static void TestWithoutPool(int iterations)
{
var sw = Stopwatch.StartNew();
var beforeGC = GC.CollectionCount(0); // 记录GC次数基线

for (var i = 0; i < iterations; i++)
{
// 每次循环都创建新的大对象
var obj = new LargeObject();
obj.DoWork($"Task_{i}");
// 对象使用完毕后离开作用域,等待GC回收
// 这里产生了大量的垃圾对象
}

sw.Stop();
var gcCount = GC.CollectionCount(0) - beforeGC;
Console.WriteLine($" 执行时间: {sw.ElapsedMilliseconds} ms");
Console.WriteLine($" 触发GC次数: {gcCount}");
Console.WriteLine($" 创建了{iterations}个对象,占用约{iterations * 100}KB内存");
}

/// <summary>
/// 对象池方式测试:复用对象实例
/// 优点:预先创建少量对象,循环使用,大幅减内存分配和GC压力
/// </summary>
static void TestWithPool(int iterations)
{
// 创建对象池,内部维护少量LargeObject实例供复用 maximumRetained: 对象池中最多保留100个对象实例
var pool = new DefaultObjectPool<LargeObject>(new SimplePolicy(), maximumRetained: 100);

var sw = Stopwatch.StartNew();
var beforeGC = GC.CollectionCount(0); // 记录GC次数基线

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}");
}
}

/// <summary>
/// 模拟大对象类 - 占用较多内存的对象
/// 在实际应用中可能是:大的数据传输对象、缓冲区、复杂的业务对象等
/// </summary>
public class LargeObject
{
// 大数组模拟占用内存(100KB),代表"昂贵"的资源
private byte[] _data = new byte[100_000];
private string _taskName;

/// <summary>
/// 模拟对象的工作方法
/// </summary>
public void DoWork(string taskName)
{
_taskName = taskName;
// 模拟一些计算工作,修改内部数据
for (int i = 0; i < 1000; i++)
{
_data[i] = (byte)(i % 256);
}
}

/// <summary>
/// 提供一个安全的重置方法,确保数据完全清理
/// 适用于处理敏感数据的场景
/// </summary>
public void ResetWithDataClearing()
{
_taskName = null;
// 完全清空数据数组,确保不泄露之前的数据
Array.Clear(_data, 0, _data.Length);
}
}

/// <summary>
/// 对象池策略类 - 定义如何创建和回收对象
/// 这是对象池模式的核心组件
/// </summary>
public class SimplePolicy : PooledObjectPolicy<LargeObject>
{
/// <summary>
/// 创建新对象的策略 - 只在对象池初始化或扩容时调用
/// </summary>
public override LargeObject Create() => new LargeObject();

/// <summary>
/// 对象归还策略 - 决定对象是否可以重复使用
/// </summary>
/// <param name="obj">要归还的对象</param>
/// <returns>true表示对象可以复用,false表示丢弃该对象</returns>
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++)
{
// 创建FileStream但不手动释放
var fs = new FileStream(Path.GetTempFileName(), FileMode.Create);
fs.WriteByte((byte)(i % 256));
fs.Flush();
// 不调用Dispose,等待终结器清理
}

}

友好代码

1
2
3
4
5
6
7
8
9
10
11
12
static void TestWithDispose()
{
for (var i = 0; i < 10000; i++)
{
// 使用using语句确保FileStream及时释放
using var fs = new FileStream(Path.GetTempFileName(), FileMode.Create);
fs.WriteByte((byte)(i % 256));
fs.Flush();
// using结束时自动调用Dispose,立即释放文件句柄
}
}

值类型装箱(隐式类型转换导致额外分配)

将值类型(如 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);

// 使用ArrayList存储int值 - 会发生装箱
var list = new ArrayList();

for (int i = 0; i < iterations; i++)
{
// 装箱:int值被包装成object存储在堆上
list.Add(i); // 每次Add都会创建一个装箱的int对象
}

// 取值时也会发生拆箱
int sum = 0;
for (int i = 0; i < list.Count; i++)
{
sum += (int)list[i]; // 拆箱:从object转回int
}

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);

// 使用List<int>存储int值 - 不会装箱
var list = new List<int>(iterations);

for (int i = 0; i < iterations; i++)
{
// 无装箱:int值直接存储,无需转换为object
list.Add(i);
}

// 取值时也无需拆箱
int sum = 0;
for (int i = 0; i < list.Count; i++)
{
sum += list[i]; // 直接使用int值,无需类型转换
}

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++)
{
// LINQ链式调用的详细问题分析:
var result = data
.Where(c => c.Age > 25) // 创建WhereListIterator<Customer>对象
.Select(c => new { c.Id, c.Balance }) // 创建SelectListIterator对象
.Where(c => c.Balance > 1000) // 再次创建WhereEnumerableIterator对象
.Take(5) // 问创建TakeIterator对象
.ToList(); // 创建最终的List<匿名类型>对象


// 总计每次循环创建的对象:
// - 4个迭代器对象(Where, Select, Where, Take)
// - N个匿名类型对象(N = 满足条件的Customer数量)
// - 1个最终的List对象
// - List内部的数组存储
//
// 在50次循环中,这些对象会大量累积,导致:
// 1. 频繁的内存分配
// 2. 大量临时对象等待GC回收
// 3. GC压力增大,影响性能
}
}

用普通循环替代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) // 创建WhereListIterator<Customer>对象
.Select(c => new CustomerInfo { Id = c.Id, Balance = c.Balance }) // 使用具体对象而不是匿名对象
.Where(c => c.Balance > 1000) // 再次创建WhereEnumerableIterator对象
.Take(5) // 创建TakeIterator对象
.ToList(); // 创建最终的List<CustomerInfo>对象

总结

GC 友好的代码本质是 “减少不必要的内存分配,降低 GC 回收压力”。在日常开发中,很多 GC 问题并非源于复杂的架构设计,而是来自 “未指定 List 初始容量”“循环中字符串拼接” 等细节。​
尤其是在大数据、高并发场景下,这些细节的优化会产生 “量变到质变” 的效果 —— 不仅能减少 GC 暂停时间,还能提升程序的稳定性和吞吐量。


如何写出 GC 友好的代码
http://example.com/posts/9552.html
作者
她微笑的脸y
发布于
2025年8月27日
许可协议