作者: Sébastien Ros和Rick Anderson
内存管理是很复杂的,即使在 .NET 等托管框架中也是如此。 分析和了解内存问题可能非常困难。 本文:
GC 分配堆段,其中每个段都是一系列连续的内存。 位于堆中的对象归类为三个代之一:0、1或2。 该代确定 GC 尝试释放应用程序不再引用的托管对象上内存的频率。 较低编号的生成更为频繁。
根据对象的生存期,将对象从一代移到另一代。 随着对象的运行时间较长,它们会移到较高的代中。 如前所述,较高的版本是不太常见的垃圾回收。 短期生存期的对象始终保留在第0代中。 例如,在 web 请求过程中引用的对象的生存期很短。 应用程序级别单一实例通常迁移到第2代。
当 ASP.NET Core 应用启动时,GC:
出于性能方面的原因,上述内存分配已完成。 性能优势来自连续内存中的堆段。
显式调用GC.Collect
专用工具可帮助分析内存使用量:
使用以下工具分析内存使用量:
任务管理器可用于了解 ASP.NET 应用正在使用的内存量。 任务管理器内存值:
如果任务管理器内存值无限增加且从未平展,则应用程序的内存泄漏。 以下部分演示并解释了几种内存使用模式。
GitHub 上提供了MemoryLeak 示例应用。 MemoryLeak 应用:
运行 MemoryLeak。 分配的内存缓慢增加,直到 GC 发生。 内存增加是因为该工具分配自定义对象来捕获数据。 下图显示了 Gen 0 GC 发生时的 MemoryLeak 索引页。 此图表显示 0 RPS (每秒请求数),因为未调用 API 控制器中的任何 API 终结点。
此图表显示内存使用量的两个值:
以下 API 创建一个 10 KB 的字符串实例,并将其返回给客户端。 对于每个请求,将在内存中分配一个新的对象,并将其写入响应中。 字符串作为 UTF-16 字符存储在 .NET 中,因此每个字符需要2个字节的内存。
[HttpGet("bigstring")] public ActionResult<string> GetBigString() { return new String('x', 10 * 1024); }
下面的关系图是使用相对较小的负载生成的,用于显示 GC 如何影响内存分配。
上面的图表显示:
以下图表采用可由计算机处理的最大吞吐量。
上面的图表显示:
.NET 垃圾回收器具有两种不同的模式:
GC 模式可以在项目文件中或在已发布应用的runtimeconfig.template.json文件中显式设置。 以下标记显示了在项目文件中设置 ServerGarbageCollection
:
<PropertyGroup> <ServerGarbageCollection>true</ServerGarbageCollection> </PropertyGroup>
更改项目文件中的 ServerGarbageCollection
需要重新生成应用。
注意: 服务器垃圾回收在具有单个核心的计算机上不可用。 有关更多信息,请参见IsServerGC。
下图显示了使用工作站 GC 的占用大量 RPS 的内存配置文件。
此图表与服务器版本之间的区别非常重要:
在典型的 web 服务器环境中,CPU 使用率比内存更重要,因此服务器 GC 更好。 如果内存使用率很高且 CPU 使用率相对较低,则工作站 GC 可能会更高的性能。 例如,在内存不足的情况下承载几个 web 应用的高密度。
GC 无法释放所引用的对象。 引用但不再需要的对象将导致内存泄露。 如果应用经常分配对象,但在不再需要对象之后无法释放它们,则内存使用量将随着时间的推移而增加。
以下 API 创建一个 10 KB 的字符串实例,并将其返回给客户端。 与上一示例的不同之处在于,此实例由静态成员引用,这意味着它不能用于收集。
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
终结点会导致内存线性增加。某些方案(如缓存)需要保留对象引用,直到内存压力强制释放它们。 WeakReference 类可用于这种类型的缓存代码。 内存压力下将收集一个 WeakReference
对象。 IMemoryCache 的默认实现使用 WeakReference
。
某些 .NET Core 对象依赖本机内存。 GC无法收集本机内存。 使用本机内存的 .NET 对象必须使用本机代码释放它。
.NET 提供了 IDisposable 界面,使开发人员能够释放本机内存。 即使未调用 Dispose,正确实现的类也会在终结器运行时调用 Dispose
。
考虑下列代码:
[HttpGet("fileprovider")] public void GetFileProvider() { var fp = new PhysicalFileProvider(TempPath); fp.Watch("*.*"); }
PhysicalFileProvider是托管类,因此将在请求结束时收集任何实例。
下图显示了连续调用 fileprovider
API 时的内存配置文件。
上面的图表显示了此类的实现的一个明显问题,因为它会不断增加内存使用量。 这是此问题中正在跟踪的已知问题。
可以通过以下方式之一在用户代码中发生相同的泄漏:
Dispose
方法。频繁的内存分配/空闲周期可以分段内存,尤其是在分配大块内存时。 对象在连续内存块中分配。 为了缓解碎片,当 GC 释放内存时,它会 trys 对内存进行碎片整理。 此过程称为压缩。 压缩涉及移动对象。 移动大型对象会对性能产生负面影响。 出于此原因,GC 将为_大型_对象(称为大型对象堆(LOH))创建特殊的内存区域。 大于85000字节(大约 83 KB)的对象为:
当 LOH 已满时,GC 将触发第2代回收。 第2代回收:
以下代码会立即压缩 LOH:
GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce; GC.Collect();
有关压缩 LOH 的信息,请参阅 LargeObjectHeapCompactionMode。
在使用 .NET Core 3.0 和更高版本的容器中,LOH 将自动压缩。
以下 API 演示了此行为:
[HttpGet("loh/{size=85000}")] public int GetLOH1(int size) { return new byte[size].Length; }
下图显示了在最大负载下调用 /api/loh/84975
终结点的内存配置文件:
下图显示了调用 /api/loh/84976
终结点的内存配置文件,只分配一个字节:
注意: byte[]
结构具有开销字节。 这就是84976字节触发85000限制的原因。
比较上述两个图表:
临时大型对象尤其有问题,因为它们会导致 gen2 Gc。
为了获得最佳性能,应最大程度地减少使用的大型对象。 如果可能,请拆分大型对象。 例如,ASP.NET Core 中的响应缓存中间件会将缓存项拆分为小于85000个字节的块。
以下链接显示了在 LOH 限制下保留对象的 ASP.NET Core 方法:
有关详细信息,请参阅:
使用 HttpClient 错误可能会导致资源泄漏。 系统资源,如数据库连接、套接字、文件句柄等:
有经验的 .NET 开发人员知道在实现 IDisposable的对象上调用 Dispose。 不释放实现 IDisposable
的对象通常会导致内存泄漏或泄漏系统资源。
HttpClient
实现 IDisposable
,但不应在每次调用时都将其释放。 相反,应重用 HttpClient
。
以下终结点针对每个请求创建并释放新的 HttpClient
实例:
[HttpGet("httpclient1")] public async Task<int> GetHttpClient1(string url) { using (var httpClient = new HttpClient()) { var result = await httpClient.GetAsync(url); return (int)result.StatusCode; } }
在 "负载" 下,将记录以下错误消息:
fail: Microsoft.AspNetCore.Server.Kestrel[13] Connection id "0HLG70PBE1CR1", Request id "0HLG70PBE1CR1:00000031": An unhandled exception was thrown by the application. System.Net.Http.HttpRequestException: Only one usage of each socket address (protocol/network address/port) is normally permitted ---> System.Net.Sockets.SocketException: Only one usage of each socket address (protocol/network address/port) is normally permitted at System.Net.Http.ConnectHelper.ConnectAsync(String host, Int32 port, CancellationToken cancellationToken)
即使 HttpClient
实例被释放,操作系统也需要一些时间来释放实际网络连接。 通过持续创建新的连接,会发生_端口耗尽_。 每个客户端连接都需要自己的客户端端口。
防止端口耗尽的一种方法是重复使用同一个 HttpClient
实例:
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
实例的生存期:
前面的示例演示了如何将 HttpClient
实例设为静态的,并由所有请求重复使用。 重复使用会阻止资源耗尽。
对象池:
池是预初始化对象的集合,这些对象可以在线程之间保留和释放。 池可以定义分配规则,例如限制、预定义大小或增长速率。
NuGet 包ObjectPool包含有助于管理此类池的类。
以下 API 终结点将实例化一个 byte
缓冲区,该缓冲区填充了每个请求的随机数字:
[HttpGet("array/{size}")] public byte[] GetArray(int size) { var random = new Random(); var array = new byte[size]; random.NextBytes(array); return array; }
以下图表显示了如何通过中等负载调用前面的 API:
在上图中,第0代回收大约每秒发生一次。
可以通过使用ArrayPool<t >,将 byte
缓冲区进行合并,从而优化前面的代码。 静态实例可跨请求重复使用。
此方法的不同之处在于,将从 API 返回一个共用对象。 这意味着:
设置对象的释放:
RegisterForDispose
将负责调用目标对象 Dispose
,以便仅当 HTTP 请求完成时才会释放该对象。
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代回收量更少。