优化内存流量
示例应用程序 | |
在本教程中,我们将了解如何使用 dotMemory 优化您的应用程序的内存使用。
我们所说的“优化内存使用”是什么意思? 与操作系统中的任何进程一样,垃圾回收器 (GC) 会消耗系统资源。 逻辑很简单:GC 需要进行的回收次数越多,CPU 开销就越大,应用程序性能就越差。 通常,这种情况发生在您的应用程序分配了大量仅在有限时间内需要的对象时。
要识别和分析此类问题,您需要检查所谓的 内存流量。 流量信息显示在特定时间间隔内分配和释放了多少对象(和内存)。 让我们看看如何确定应用程序中的过度分配并使用 dotMemory 消除它们。
示例应用程序
按照惯例,我们将在本教程中使用的示例应用程序是康威的生命游戏。 在开始之前,请从 github下载该应用程序。

由于此应用程序处理大量对象(单元格),因此查看这些对象如何分配和回收的动态将很有趣。
步骤 1。 运行 dotMemory
在 Visual Studio 中打开生命游戏解决方案。
通过菜单 运行 dotMemory。

在打开的 分析器配置 窗口中,选择 从启动时收集内存分配和流量数据。 这将告诉 dotMemory 在应用程序启动后立即开始收集分析信息。 指定选项后,窗口应如下所示:

点击 运行 以开始分析会话。 这将启动我们的应用程序并在 dotMemory 中打开主 分析 #1 页面:

切换到 dotMemory 的主窗口以查看时间线。 时间线实时显示应用程序的内存使用情况。 更具体地说,它提供了有关非托管内存、Gen0、Gen1、Gen2 堆和大对象堆当前大小的详细信息。 在生命游戏开始之前,内存消耗保持不变。

步骤 2。 获取快照
在应用程序启动后,我们可以开始获取内存快照。 由于我们想要研究应用程序行为的动态,我们需要至少获取两个快照。 获取快照之间的时间间隔将成为进一步内存流量分析的主题。
当然,两个快照都必须在生命游戏操作中分配最多的部分期间获取。 让我们在生命游戏的第 30 代获取一个快照,在第 100 代获取第二个快照。
使用应用程序中的 启动 按钮启动游戏。
当代数计数器(在我们应用程序的右上角)达到 30 *时,点击 dotMemory 中的 获取快照 按钮。

如果您现在查看时间线,您将看到应用程序实时消耗内存的情况。 当应用程序分配新对象时,内存消耗增加(Gen0 图表增长)。 当垃圾回收发生时,内存消耗减少。 因此,时间线呈现锯齿状模式。
当代数计数器达到 100 时,再次使用 dotMemory 中的 获取快照 按钮获取一个快照。
通过关闭生命游戏应用程序结束分析会话。 主页面现在包含两个快照:

步骤 3。 分析内存流量
现在,我们将查看获取快照之间时间间隔内的内存流量。
确保两个快照都已添加到比较区域(添加到比较 已为它们两者选中):

点击比较区域中的 查看内存流量 ,这将打开 内存流量 视图。 该视图显示了在快照 #1 和快照 #2 之间创建的某种类型的对象数量。

查看列表。 27+ MB,或大约 50% 的总体内存流量,是由于分配了
GameOfLife.Cell*对象。 同时,这些单元格中的大部分(26+ MB)也被回收了。 这非常奇怪,因为单元格应该在生命游戏的整个持续时间内存在。 毫无疑问,这些回收正在损害我们应用程序的性能。 让我们检查这些Cell对象的来源。点击
GameOfLife.Cell类的行。 此屏幕底部的列表显示了创建这些对象的函数(回溯)。 显然,这是CalculateNextGeneration()方法的Grid类。 让我们在代码中找到它。
在 Visual Studio 中打开 GameOfLife 解决方案。
打开包含
Grid类实现的 Grid.cs 文件:
定位
CalculateNextGeneration(int row, int column)方法:public Cell CalculateNextGeneration(int row, int column) { bool alive; int count, age; alive = _cells[row, column].IsAlive; age = _cells[row, column].Age; count = CountNeighbors(row, column); if (alive && count < 2) return new Cell(row, column, 0, false); if (alive && (count == 2 || count == 3)) { _cells[row, column].Age++; return new Cell(row, column, _cells[row, column].Age, true); } if (alive && count > 3) return new Cell(row, column, 0, false); if (!alive && count == 3) return new Cell(row, column, 0, true); return new Cell(row, column, 0, false); }看起来此方法为生命游戏的每一代计算并返回
Cell对象。 但这并不能解释高内存流量。 让我们返回 dotMemory,找出调用CalculateNextGeneration方法的函数。在 dotMemory 中,展开
CalculateNextGeneration方法以查看堆栈中的下一个函数。 这是Update方法的Grid类:
在代码中找到此方法:
public void Update() { for (int i = 0; i < SizeX; i++) { for (int j = 0; j < SizeY; j++) { _nextGenerationCells[i, j] = CalculateNextGeneration(i,j); } } UpdateToNextGeneration(); }这最终揭示了高内存流量的原因。 存在一个
nextGenerationCells数组,其类型为Cell,用于存储生命游戏下一代的单元格。 在每次代更新时,此数组中的单元格会被新单元格替换。 上一代留下的单元格不再需要,并在一段时间后被 GC 回收。 显然,没有必要每次都用新单元格填充_nextGenerationCells数组,因为该数组在应用程序的整个生命周期内都存在。 为了消除高内存流量,我们需要更新现有单元格的属性为新值,而不是创建新单元格。 让我们在代码中实现这一点。实际上,由于我们的应用程序是一个学习示例,它已经包含了
CalculateNextGeneration方法的必要实现。 此方法通过引用更新单元格的IsAlive和Age字段:public void CalculateNextGeneration(int row, int column, ref bool isAlive, ref int age) { ... }为了解决此问题,请取消注释
Update()中使用此方法更新_nextGenerationCells数组的行。 最后,Update()方法应如下所示:public void Update() { bool alive = false; int age = 0; for (int i = 0; i < SizeX; i++) { for (int j = 0; j < SizeY; j++) { CalculateNextGeneration(i, j, ref alive, ref age); _nextGenerationCells[i, j].IsAlive = alive; _nextGenerationCells[i, j].Age = age; } } UpdateToNextGeneration(); }现在,让我们应用这些更改并检查它们对内存流量的影响。
再次构建应用程序。 重复 步骤 1。 运行 dotMemory和 步骤 2。 获取快照以获取两个新快照。
打开 内存流量 视图以查看收集的快照之间的内存流量(如 步骤 3。 分析内存流量中的子步骤 1 和 2 所述):

GameOfLife.Cell类不再出现在列表中! 这使总体流量减少了 40%(降至 33 MB),这是一个非常好的优化。