ASP.NET Core 中的内存管理和垃圾回收(GC)
垃圾回收 (GC) 在 .NET Core 中的工作方式
GC 会分配堆段,其中每个段都是一系列连续的内存。 置于堆中的对象归类为 3 个代系之一:0、1 或 2。 代系可确定 GC 尝试在应用不再引用的托管对象上释放内存的频率。 编号较低的代系会更加频繁地进行 GC。 对象会基于其生存期从一个代系移到另一个代系。 随着对象生存期延长,它们会移到较高代系。 如前所述,较高代系进行 GC 的频率较低。 短期生存的对象始终保留在第 0 代中。 例如,在 Web 请求存在期间引用的对象的生存期较短。 应用程序级别单一实例通常会迁移到第 2 代。 当 ASP.NET Core 应用启动时,GC 会:
- 为初始堆段保留一些内存。
- 在运行时加载时提交一小部分内存。
进行以上内存分配是出于性能方面的原因。 性能优势来自连续内存中的堆段。
调用 GC.Collect
显式调用 GC.Collect:
- 不应由生产 ASP.NET Core 应用进行。
- 在调查内存泄漏时非常有用。
- 在进行调查时会验证 GC 是否从内存中删除了所有无关联对象,以便可以度量内存。 实例:
在由 GCCollectionMode 值指定的时间,强制对 0 代到指定代进行垃圾回收,另有数值指定回收是否应该为阻碍性。
public static void Collect (int generation, GCCollectionMode mode, bool blocking);
参数说明:
generation Int32
最后一代进行垃圾回收次数。
mode GCCollectionMode
一个枚举值,指定垃圾回收是强制进行(Default 或 Forced)还是优化 (Optimized)。
blocking Boolean
true 执行阻碍性垃圾回收;false 在可能的情况下执行后台垃圾回收。
强制对所有代进行即时垃圾回收。
代码语言:javascript复制public static void Collect ();
以下示例使用设置强制第 2 代对象的 Optimized 垃圾回收。
代码语言:javascript复制using System;
class Program
{
static void Main(string[] args)
{
GC.Collect(2, GCCollectionMode.Optimized);
}
}
在由 GCCollectionMode 值指定的时间,强制对 0 代到指定代进行垃圾回收,另有数值指定回收应该为阻碍性还是压缩性。
代码语言:javascript复制public static void Collect (int generation, GCCollectionMode mode, bool blocking, bool compacting);
参数
generation
Int32
最后一代进行垃圾回收次数。
mode
GCCollectionMode
一个枚举值,指定垃圾回收是强制进行(Default 或 Forced)还是优化 (Optimized)。
blocking
Boolean
true 执行阻碍性垃圾回收;false 在可能的情况下执行后台垃圾回收。
compacting
Boolean
true 表示压缩小对象堆;false 表示仅进行清理。
调用 GC.Collect注解
代码语言:javascript复制false如果是blocking,GC 决定是执行后台还是阻止垃圾回收。 true如果是compacting,它将执行阻止垃圾回收。
true如果是compacting,运行时会压缩小型对象堆, (SOH) 。 除非属性设置为 GCLargeObjectHeapCompactionMode.CompactOnce (LOH) ,GCSettings.LargeObjectHeapCompactionMode否则不会压缩大型对象堆。 请注意,这包括所有阻止垃圾回收,而不仅仅是完全阻止垃圾回收。
可以调用 Collect(Int32, GCCollectionMode, Boolean, Boolean) 此方法,将托管堆减小为尽可能小的大小,如以下代码片段所示。
GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect(2, GCCollectionMode.Forced, true, true);
true指定compacting参数可确保压缩、完全阻止垃圾回收。 设置 GCSettings.LargeObjectHeapCompactionMode 属性以确保 GCLargeObjectHeapCompactionMode.CompactOnce 将 LOH 和 SOH 压缩。
工作站 GC 与服务器 GC
.NET 垃圾回收器具有两种不同的模式:
- 工作站 GC:针对桌面设备进行了优化。
- 服务器 GC。 ASP.NET Core 应用的默认 GC。 针对服务器进行了优化。
可以在项目文件或已发布应用的文件中runtimeconfig.json显式设置 GC 模式。 下面的标记显示项目文件中的 ServerGarbageCollection设置:
代码语言:javascript复制<PropertyGroup>
<ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>
在项目文件中更改 ServerGarbageCollection 需要重新生成应用。 注意:服务器垃圾回收在具有单个核心的计算机上不可用。 有关详细信息,请参阅 IsServerGC。
在典型 Web 服务器环境中,CPU 使用率比内存更重要,因此服务器 GC 更好。 如果内存 利用率较高而 CPU 使用率相对较低,则工作站 GC 可能性能更高。 例如,在内存短缺的 情况下高密度托管多个 Web 应用。
持久性对象引用
GC 无法释放所引用的对象。 引用但不再需要的对象会导致内存泄露。 如果应用经常分配对象,但在不再需要对象之后未能释放它们,则内存使用量会随着时间推移而增加。 下面的 API 创建一个 10-KB 字符串实例,并将它返回给客户端。 此实例由静态成员引用,这意味着它从不可进行回收。
代码语言:javascript复制private static ConcurrentBag<string> _staticStrings = new
ConcurrentBag<string>();
[HttpGet("staticstring")]
public ActionResult<string> GetStaticString()
{
var bigString = new String('x', 10 * 1024);
_staticStrings.Add(bigString);
return bigString;
}
前面的代码:
- 是典型内存泄漏的示例。
- 频繁调用时,会导致应用内存增加,直到进程崩溃并出现 OutOfMemory 异常。
- 测试 /api/staticstring 终结点的负载会导致内存线性增加。
- GC 会在内存压力增加时,通过调用第 2 代回收来尝试释放内存。
- GC 无法释放泄漏的内存。 已分配内存和工作集会随时间而增加。
某些方案(如缓存)需要保持对象引用,直到内存压力迫使释放它们。 WeakReference类可用于此类型的缓存代码。 WeakReference 对象会在内存压力下进行回收。IMemoryCache 的默认实现使用WeakReference。
WeakReference类 表示弱引用,即在引用对象的同时仍然允许通过垃圾回收来回收该对象。 IMemoryCache 接口 表示未序列化其值的本地内存中缓存。
本机内存
某些 .NET Core 对象依赖于本机内存。 GC 无法回收本机内存。 使用本机内存的 .NET 对象必须使用本机代码进行释放。 .NET 提供了IDisposable 接口,使开发人员能够释放本机内存。 即使未调用 Dispose,正确实现的类也会在终结器运行时调用 Dispose。
IDisposable 接口 提供一种用于释放非托管资源的机制。 public interface IDisposable
Dispose 也就是 IDisposable.Dispose 方法 执行与释放或重置非托管资源关联的应用程序定义的任务。 public void Dispose ();
终结器(以前称为析构器)用于在垃圾回收器收集类实例时执行任何必要的最终清理操作。 在大多数情况下,通过使用 System.Runtime.InteropServices.SafeHandle 或派生类包装任何非托管句柄,可以免去编写终结器的过程。 备注: 无法在结构中定义终结器。 它们仅用于类。 一个类只能有一个终结器。 不能继承或重载终结器。 不能手动调用终结器。 可以自动调用它们。 终结器不使用修饰符或参数。
请考虑以下代码:
代码语言:javascript复制[HttpGet("fileprovider")]
public void GetFileProvider()
{
var fp = new PhysicalFileProvider(TempPath);
fp.Watch("*.*");
}
PhysicalFileProvider 是托管类,因此将在请求结束时收集任何实例。 连续调用 fileprovider API 时它会不断增加内存使用量。 用户代码中可能会发生相同的泄漏,如下所示之一:
- 未正确释放类。
- 忘记调用 Dispose 应释放的依赖对象的方法。
大型对象堆
频繁的内存分配/释放周期可能会导致内存碎片,尤其是在分配大型内存区块时。 对象在连续内存块中进行分配。 为了减少碎片,当 GC 释放内存时,它会尝试对其进行碎片整理。 此过程称为压缩。 压缩涉及移动对象。 移动大型对象会造成性能损失。 因此,GC会为大型对象创建特殊内存区域,称为大型对象堆 (LOH)。 大于 85,000 字节(大约 83KB)的对象:
- 置于 LOH 上。
- 不进行压缩。
- 在第 2 代 GC 期间进行回收。
.NET 垃圾回收器 (GC) 将对象分为小型和大型对象。 如果是大型对象,它的某些特性将比对象较小时显得更为重要。 例如,压缩大型对象(也就是在内存中将其复制到堆上的其他地方)的费用相当高。 因此,垃圾回收器将大型对象放置在大型对象堆 (LOH) 上。
当 LOH 已满时,GC 会触发第 2 代回收。 第 2 代回收:
- 在本质上速度较慢。
- 还会产生对所有其他代系触发回收的成本。 下面的代码会立即压缩 LOH:
GCSettings.LargeObjectHeapCompactionMode =
GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect();
在使用 .NET Core 3.0 及更高版本的容器中,LOH 会自动压缩。
为了获得最佳性能,应最大程度减少大型对象使用。 如果可能,请拆分大型对象。 例如,ASP.NET Core 中的响应缓存中间件会将缓存项拆分为小于 85,000 字节的块。
HttpClient
未正确使用 HttpClient 可能会导致资源泄漏。 系统资源(如数据库连接、套接字、文件句柄等):
- 比内存更短缺。
- 在泄漏时出现的问题比内存更多。
重点是我们知道要对实现 IDisposable 的对象调用 Dispose。 未释放实现IDisposable 的对象通常会导致内存泄漏或系统资源泄漏。 HttpClient IDisposable实现,但不应在每个调用上释放。 而是应重用 HttpClient。 下面的终结点会对每个请求创建并释放新的 HttpClient 实例:
代码语言:javascript复制[HttpGet("httpclient1")]
public async Task<int> GetHttpClient1(string url)
{
using (var httpClient = new HttpClient())
{
var result = await httpClient.GetAsync(url);
return (int)result.StatusCode;
}
}
即使释放了 HttpClient 实例,实际网络连接也需要一些时间才能由操作系统释放。 持续创建新连接时,会发生端口耗尽。 每个客户端连接都需要自己的客户端端口。 防止端口耗尽的一种方法是重用同一个 HttpClient 实例:
代码语言:javascript复制private static readonly HttpClient _httpClient = new HttpClient();
[HttpGet("httpclient2")]
public async Task<int> GetHttpClient2(string url)
{
var result = await _httpClient.GetAsync(url);
return (int)result.StatusCode;
}
HttpClient 实例会在应用停止时释放。 此示例演示并非每个可释放资源都应在每次使用后释放。上面的示例演示了如何将 HttpClient 实例设为静态,并由所有请求重用。 重用可防止资源耗尽。
对象池
对象池:
- 使用重用模式。
- 适用于创建成本高昂的对象。 池是预初始化对象的集合,这些对象可以在线程间保留和释放。 池可以定义分配规则,例如限制、预定义大小或增长速率。
NuGet 包 Microsoft.Extensions.ObjectPool 包含有助于管理此类池的类。 下面的 API 终结点会实例化 byte 缓冲区,该缓冲区对每个请求使用随机数字进行填充:
代码语言:javascript复制[HttpGet("array/{size}")]
public byte[] GetArray(int size)
{
var random = new Random();
var array = new byte[size];
random.NextBytes(array);
return array;
}
在上面的图表中,第 0 代回收大约每秒进行一次。 可以使用 ArrayPool 创建 byte 缓冲区池,从而优化上面的代码。 静态实例可在请求间重用。 此方法的不同之处在于,会从 API 返回共用对象。 也就是说:
- 从方法返回后,对象会立即脱离控制。
- 无法释放对象。
若要设置对象的释放,请执行以下操作:
- 将共用数组封装在可释放对象中。
- 向 HttpContext.Response.RegisterForDispose 注册共用对象。
RegisterForDispose 将负责调用 Dispose 目标对象,以便仅在 HTTP 请求完成时释放它。
代码语言:javascript复制HttpResponse.RegisterForDispose(IDisposable) 方法 注册一个对象,以便在请求完成处理后由主机处置。 public virtual void RegisterForDispose (IDisposable disposable); disposable 要释放的对象
private static ArrayPool<byte> _arrayPool = ArrayPool<byte>.Create();
private class PooledArray : IDisposable
{
public byte[] Array { get; private set; }
public PooledArray(int size)
{
Array = _arrayPool.Rent(size);
}
public void Dispose()
{
_arrayPool.Return(Array);
}
}
[HttpGet("pooledarray/{size}")]
public byte[] GetPooledArray(int size)
{
var pooledArray = new PooledArray(size);
var random = new Random();
random.NextBytes(pooledArray.Array);
HttpContext.Response.RegisterForDispose(pooledArray);
return pooledArray.Array;
}
应用与非共用版本相同的负载会生成以下图表:
主要差异是分配的字节数,因而第 0 代回收数要少得多。