C#由Dictionary不正确释放造成的内存泄漏问题与GC代系
问题体现
偶发性的出现内存泄漏的问题,并不会在任何电脑上都出现,有时出现有时又不出现,并且虽然有内存泄漏问题,但是软件内存达到一定程度后又会自然下降,并且再次重新增加内存。在内存增加的过程中会造成由于内存占用过大的原因,但是软件整体运行效率越来越低。
问题分析
由于是偶发性出现,并且不是每台电脑有问题。这个现象导致我一开始认为是电脑配置的问题。所以一开始我申请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导致的。
经过一系列的寻找,我找到了在软件检测结束后,会对软件缓存的图像数据进行清除,但是清除的方式是有异常的。先看一下异常的代码:
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自动标记为死对象。
后面我修改修复了这个函数的问题,修复后代码
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代系(Generations)
二. 代系的工作原理**
三. 什么是“大型对象”?**
四. LOH的特点
五:那么第2代系在什么情况下不会被触发
- 内存压力较小时
- 仅触发低代回收时
- 未显式指定回收 2 代时
- 系统资源充足时
六:第 2 代 GC 一定会被触发的情况
- 显式调用GC.Collect(2)时
- 内存严重不足时
- 应用程序域卸载或进程退出时
- 某些特殊系统事件时
GC测试
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。以例子作为参考:
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个引用。为ints2Bytes.Clear();ints2 = null;//此时对象A仍然有一个引用。ints1//此时对象B没有引用了
所以上述解释可以看到,ints1与ints2本质上是数组对象的地址引用,我们在函数结束前,对象A还有引用所以对象A没有被GC,对象B没有了引用,所以对象B被GC了。
问题4:为什么例子中ints1的所属对象在离开函数后会被标记为死对象
答:ints1是局部变量,且所在的方法已执行完毕(超出作用域)。此时:方法栈帧被销毁,ints1变量不再存在,对象A失去了最后一个引用(字典已清除,变量已消失),GC 判定对象A不可达,标记为死对象(等待回收)
总结:
GC的核心在于是否可达,是否存在引用。要查找一个对象是否可达是一个非常困难的事情。我现在维护的软件项目,外挂组件和代码历经近10余人之手,几十万行的代码量,软件内部异常的复杂,当我在查找次对象是否可达时难度非常的大。所以我们在使用完引用对象后尤其需要注意将引用对象置为Null,可以有效的防止同样的内存泄漏问题。