问题体现
偶发性的出现内存泄漏的问题,并不会在任何电脑上都出现,有时出现有时又不出现,并且虽然有内存泄漏问题,但是软件内存达到一定程度后又会自然下降,并且再次重新增加内存。在内存增加的过程中会造成由于内存占用过大的原因,但是软件整体运行效率越来越低。
问题分析
由于是偶发性出现,并且不是每台电脑有问题。这个现象导致我一开始认为是电脑配置的问题。所以一开始我申请3台电脑并且都重装电脑环境来测试。但是我发现有1台电脑没有问题,2台电脑是有问题的。所以可以排除是电脑的问题。
重新回到代码上的问题。但是这个现象非常的奇怪,我重新再用软件跑测试,然后再跑测试前和内存比较多时候拍了2个内存快照。一开始我看到内存快照的堆栈的内存占用量很少。我跑完测试软件内存占用量再25G的样子,但是内存快照只占用了不到3G的内存。
正常一个C#软件在运行时,系统会帮我开辟2倍以上的内存作为软件的实际使用和缓存。同时我创建了一份软件的Dump文件(windows内存转储文件),用windbg分析到软件的实际占用量在12G,缓存内存13G的样子。那么内存快照的3G与Dump文件的12G占用明显有很大的异常。这个时候我就想起来,C#软件在内存上除了存在引用堆栈以外,还有非引用堆栈,就是我们常说的死对象。
死对象:已经不再被任何活动引用所指向的对象。这些对象无法再被程序访问,处于等待垃圾回收(Garbage Collection, GC)的状态。死对象的产生原因:1.引用失效:当对象的所有引用变量都超出作用域、被赋值为 null,或指向其他对象时,原对象就会成为死对象。2.集合元素移除:从集合(如 List、Dictionary)中移除对象后,如果没有其他引用指向该对象,它会成为死对象。3.静态引用释放。
问题内容
所以我重点把问题放在内存快照的死对象上面。只需要在内存快照中勾选显示死对象即可

我详细的查找的有异常的死对象。发现在死对象中有数万个Byte数组的存在合计有8G的内存占用,并且每个数组的大小长度不一样,并且都存储在大型对象堆中。我想我已经找到问题点了。
由于软件是视觉检测软件,Byte数组必然来自于不同的图片数据,而且软件使用OpenCV作为图像处理库,OpenCV的Mat数据的源数据就是Byte数组。那么肯定是哪里的图像数据没有被GC导致的。
经过一系列的寻找,我找到了在软件检测结束后,会对软件缓存的图像数据进行清除,但是清除的方式是有异常的。先看一下异常的代码:
cs
public Dictionary<string, Dictionary<string, byte[]>> TestResultsTemplateImageMap { get; set; } = new Dictionary<string, Dictionary<string, byte[]>>();
public Dictionary<string, byte[]> GoldenImageThumbnails { get; set; } = new Dictionary<string, byte[]>();
public Dictionary<string, byte[]> TestImageThumbnails { get; set; } = new Dictionary<string, byte[]>();
public void Dispose(bool disposeTestResultSt, bool disposeVerifiedResults = true)
{
GoldenImageThumbnails?.Clear();
GoldenImageThumbnails = null;
TestImageThumbnails?.Clear();
TestImageThumbnails = null;
TestResultsImageMap?.Clear();
TestResultsImageMap = null;
TestResultsGoldenImageMap?.Clear();
TestResultsGoldenImageMap = null;
TestResultsTemplateImageMap?.Clear();
TestResultsTemplateImageMap = null;
if (disposeTestResultSt)
{
foreach (var testResult in TestResults)
{
testResult.Dispose();
}
foreach (var componentResult in ComponentResults)
{
componentResult.Dispose();
}
}
if (disposeVerifiedResults)
{
VerifiedResults?.Clear();
VerifiedResults = null;
}
}
我发现这个函数对Dictionary的处理会将Dictionary的内部的数据引用取消,又因为调用这个函数时,整个软件运行流程已经结束了,那么这些数据都变成了空引用,GC自动标记为死对象。
后面我修改修复了这个函数的问题,修复后代码
cs
public void Dispose(bool disposeTestResultSt, bool disposeVerifiedResults = true)
{
if (GoldenImageThumbnails!=null)
{
foreach (var Key in GoldenImageThumbnails.Keys.ToList())
{
GoldenImageThumbnails[Key] =null;
}
}
GoldenImageThumbnails?.Clear();
GoldenImageThumbnails = null;
if (TestImageThumbnails!=null)
{
foreach (var Key in TestImageThumbnails.Keys.ToList())
{
TestImageThumbnails[Key] = null;
}
}
TestImageThumbnails?.Clear();
TestImageThumbnails = null;
if (TestResultsImageMap!=null)
{
foreach (var item in TestResultsImageMap)
{
foreach (var Key in item.Value.Keys.ToList())
{
item.Value[Key] = null;
}
}
}
TestResultsImageMap?.Clear();
TestResultsImageMap = null;
if (TestResultsGoldenImageMap!=null)
{
foreach (var item in TestResultsGoldenImageMap)
{
foreach (var Key in item.Value.Keys.ToList())
{
item.Value[Key] = null;
}
}
}
TestResultsGoldenImageMap?.Clear();
TestResultsGoldenImageMap = null;
if (TestResultsTemplateImageMap!=null)
{
foreach (var item in TestResultsTemplateImageMap)
{
foreach (var Key in item.Value.Keys.ToList())
{
item.Value[Key] = null;
}
}
}
TestResultsTemplateImageMap?.Clear();
TestResultsTemplateImageMap = null;
if (disposeTestResultSt)
{
foreach (var testResult in TestResults)
{
testResult.Dispose();
}
foreach (var componentResult in ComponentResults)
{
componentResult.Dispose();
}
}
if (disposeVerifiedResults)
{
VerifiedResults?.Clear();
VerifiedResults = null;
}
}
我修改后,再重新再不同的设备上测试,都没有问题了。
问题分析
回到问题的表象,为什么会出现内存泄漏,并且时高时低的情况,而且还出现在部分电脑有部分电脑又没有的问题。
我们重新分析一下GC的原理。
GC是.NET运行时(CLR)自动管理内存的核心机制,其设计目标是高效回收不再使用的对象内存。代系(Generations)和大型对象堆(Large Object Heap,LOH)是GC实现高效内存管理的两个关键概念
一、GC代系(Generations)
GC代系的设计基于**弱代假设(Weak Generational Hypothesis): 新创建的对象生命周期通常较短(大部分对象会很快变得不可达)。存活时间长的对象,往往会继续存活更长时间。
基于这一假设,GC将对象分为3个代系(0、1、2),通过优先回收"年轻代"对象来提高效率。
| 0代 | 最"年轻"的代系 | 最高(最频繁) | 新创建的对象(未经历过GC回收) |
| 1代 | 过渡代系 | 中等 | 经历过1次0代回收后存活的对象 |
| 2代 | 最"老"的代系 | 最低(最不频繁) | 经历过1代回收后存活的对象 |
二. 代系的工作原理**
对象创建:新对象默认分配在0代(大型对象除外)。
回收触发:当0代内存达到阈值(由CLR动态调整),触发0代回收。回收时,GC会标记0代中仍被引用的"存活对象",并将其"晋升"到1代;未被引用的"死亡对象"则被回收。
晋升机制:1代回收时,存活对象会晋升到2代;2代回收时,存活对象仍留在2代(因为2代是最终代系)。
回收范围:某代回收时,会同时回收所有"更年轻"的代系。例如:1代回收会同时处理0代和1代;2代回收(也叫"全回收")会处理所有代系。
三. 什么是"大型对象"?**
在.NET中,通常将**大小超过85000字节**的对象视为大型对象,例如:
长度超过1000的`int[]`数组(每个int占4字节,1000×4=4000字节,远小于85000,所以需要更大的数组)。大字符串(`string`本质是字符数组,超过一定长度会被视为大对象)。其他大型结构体数组或自定义大对象。
四. LOH的特点
独立于代系堆:普通对象(小型)分配在"小对象堆"(SOH),按0/1/2代管理;大型对象直接分配在LOH。
属于2代:LOH上的对象被视为2代对象,仅在2代回收时才会被处理(回收频率低)。
不压缩:小对象堆在回收后会进行"压缩"(将存活对象紧凑排列,减少内存碎片),但LOH不会压缩------因为移动大对象(如100MB的数组)会消耗大量CPU资源,反而降低性能。
内存碎片风险:由于不压缩,频繁创建和回收大型对象可能导致LOH产生碎片(空闲内存被分割成多个不连续的小块,无法容纳新的大对象)。
所以从GC的原理出发,可以发现问题代码中的Byte数组基本存在与大型对象堆中,属于第2代系。
五:那么第2代系在什么情况下不会被触发
在 C# 的垃圾回收(GC)机制中,第 2 代(Generation 2)是对象存活最久的代系(0 代是新对象,1 代是存活过一次回收的对象,2 代是存活过多次回收的长期对象)。第 2 代回收(通常称为 "全量回收" 或 Full GC)成本最高(涉及对象最多、耗时最长),因此其触发逻辑比 0 代、1 代更严格。
第 2 代回收的触发需要满足较严格的条件,以下场景下通常不会触发:
- 内存压力较小时
当程序内存占用未达到系统或 GC 的阈值(如小对象堆 SOH、大对象堆 LOH 的内存上限),仅发生小规模内存分配时,GC 只会频繁触发 0 代回收(甚至 1 代回收),不会触及 2 代。
例如:短期创建并释放大量临时对象(如方法内的局部变量),仅会触发 0 代回收,2 代对象(如长期存在的单例、静态对象)不会被回收。 - 仅触发低代回收时
GC 的 "代系升级" 机制是:0 代回收后存活的对象进入 1 代,1 代回收后存活的对象进入 2 代。触发 0 代或 1 代回收时,不会扫描或回收 2 代对象。
例如:0 代对象占满预设内存(默认约 16MB)时,仅触发 0 代回收,2 代不受影响。 - 未显式指定回收 2 代时
若手动调用
GC.Collect()未指定代系(默认回收 0 代),或指定
GC.Collect(1)(仅回收 0、1 代),则不会触发 2 代回收。 - 系统资源充足时
当操作系统内存充足、CPU 负载低时,GC 会优先保留 2 代对象(避免高成本回收影响性能),仅在必要时才触发。
六:第 2 代 GC 一定会被触发的情况
以下场景下,第 2 代回收必然会被触发:
- 显式调用GC.Collect(2) 时
手动指定回收代系为 2(GC.Collect(2))会强制触发全量回收,扫描并回收 0、1、2 代中所有不可达对象,这是最直接的 "一定会触发" 的场景。 - 内存严重不足时
当程序内存占用达到系统或 GC 的临界阈值(如小对象堆 / 大对象堆耗尽预设内存),且低代回收(0、1 代)无法释放足够内存时,GC 会触发全量回收(包括 2 代)。
例如:大对象堆(LOH,存放 > 85000 字节的对象,属于 2 代)内存占满时,会触发 2 代回收。 - 应用程序域卸载或进程退出时
当AppDomain被卸载(如插件式架构中移除插件)或进程终止时,CLR 会触发全量回收,释放该域内所有对象(包括 2 代)。 - 某些特殊系统事件时
如操作系统发送低内存通知(Low Memory Notification),或 CLR 检测到内存碎片严重影响分配效率时,会触发全量回收(包含 2 代)。
GC测试
cs
using OpenCvSharp;
using System;
using System.Collections.Generic;
using System.Threading;
public class Program
{
public static void Main()
{
Test();
GC.Collect();
Thread.Sleep(1000);
Console.ReadKey();
}
public static void Test()
{
string Str1 = "1111";
string Str2 = "222";
for (int i = 0; i < 5000; i++)
{
Str1 += "11111";
Str2 += "22222";
}
byte[] ints1 = new byte[10000000];
byte[] ints2 = new byte[10000000];
for (int i = 0; i < 10000000; i++)
{
ints1[i] = 0x01;
ints2[i] = 0x02;
}
Dictionary<string, string> pairs = new Dictionary<string, string>();
pairs.Add("1", Str1);
Dictionary<string, byte[]> Bytes = new Dictionary<string, byte[]>();
Bytes.Add("2", ints1);
//string NewStr=TestStr;
pairs.Clear();
Bytes.Clear();
Str2 = null;
Array.Clear(ints2, 0, ints2.Length);
ints2 = null;
//强制GC第一次
GC.Collect();
//GC结束,ints1为GC代系大型对象堆(2代系)
//GC结束,Str1为GC代系1代系
Thread.Sleep(1000);
}
}
当我执行完函数后,手动强制GC一次


此时我们可以发现在大型对象堆(第2代系)中有我们没有释放完的Byte数组。第1代系中有我们没有释放完的字符串。并且现在我们可以发现,死对象在内存快照中并不会占用引用堆栈,而是放在非引用堆栈,这个就是为什么我一开在拍内存快照时候发现内存快照的内存与实际占用内存差异非常大的原因。
我们进行第二次强制GC
当我们在强制执行第二次GC时,我们会发现第1代系和第2代系的死对象都被清除了。
细节问答
现象1:为什么出现有些电脑正常有些电脑出现内存泄漏情况
答:由于GC是又CLR全自动控制的,CLR在每一台电脑上可能会有策略上的差异,但是具体差异是什么如何造成的并没有找到合理的结论
现象2:为什么内存出现泄漏但电脑内存没有爆
答:由于源代码中的死对象存储在大型对象堆中,隶属于第二代系。当程序内存占用达到系统或 GC 的临界阈值(如小对象堆 / 大对象堆耗尽预设内存),且低代回收(0、1 代)无法释放足够内存时,GC 会触发全量回收(包括 2 代)。
现象3:为什么string与Byte数组值为Null时,可以被正常GC
答:并不是将引用置为Null就会被GC。以例子作为参考:
cs
byte[] ints1 = new byte[10000000];//对象A
byte[] ints2 = new byte[10000000];//对象B
//为对象A对象B进行赋值
for (int i = 0; i < 10000000; i++)
{
ints1[i] = 0x01;
ints2[i] = 0x02;
}
Dictionary<string, byte[]> Bytes = new Dictionary<string, byte[]>();
Bytes.Add("2", ints1);
//此时对象A有2个引用。为Bytes和ints1
//此时对象B有1个引用。为ints2
Bytes.Clear();
ints2 = null;
//此时对象A仍然有一个引用。ints1
//此时对象B没有引用了
所以上述解释可以看到,ints1与ints2本质上是数组对象的地址引用,我们在函数结束前,对象A还有引用所以对象A没有被GC,对象B没有了引用,所以对象B被GC了。
问题4:为什么例子中ints1的所属对象在离开函数后会被标记为死对象
答:ints1是局部变量,且所在的方法已执行完毕(超出作用域)。此时:方法栈帧被销毁,ints1变量不再存在,对象A失去了最后一个引用(字典已清除,变量已消失),GC 判定对象A不可达,标记为死对象(等待回收)
总结:
GC的核心在于是否可达,是否存在引用。要查找一个对象是否可达是一个非常困难的事情。我现在维护的软件项目,外挂组件和代码历经近10余人之手,几十万行的代码量,软件内部异常的复杂,当我在查找次对象是否可达时难度非常的大。所以我们在使用完引用对象后尤其需要注意将引用对象置为Null,可以有效的防止同样的内存泄漏问题。