调试应用程序内存中的神秘问题

堆内存是什么?

堆内存是在应用程序内动态分配内存时常用的可用内存池。之所以使用 “堆内存” 这个术语,是因为动态内存分配的大多数实现均使用称为 的一种二进制树型数据结构。动态内存分配是在应用程序运行时显式分配和取消分配内存(或存储)的一种方法。在本文中,堆管理器 这个术语用于描述处理应用程序动态内存分配的软件。

堆管理器执行两种主要操作。第一种操作是分配。这种操作将保留给定大小的一个存储块,并返回所保留存储块的指针。存储块的惟一所有权将赋予应用程序,应用程序可以使用该存储块来实现它所需要的任何目的。第二种操作是取消分配。此操作会将之前已分配好的存储块返回给堆管理器。取消分配一个存储块时,块的所有权也将返还给堆管理器。此后,堆管理器可以使用该存储块继续分配。

堆管理器经常会提供的第三种操作是重新分配。这种操作会重新调整给定存储块的大小,并返回存储块的指针(可能经过更新)。重新分配操作并不属于严格需要的操作(因为可以通过基本的分配和取消分配操作予以实现),但堆管理器通常会提供这种操作。

在 IBM i® 上,共有两种不同类型的堆存储:单层存储和 teraspace 存储。应用程序使用的存储类型由创建程序时指定的程序属性决定。默认情况下使用的是单层存储。如果需要使用 teraspace 存储,必须使用特殊编译选项和程序创建选项。除此之外,也可以使用应用程序编程接口 (API) 在单层存储应用程序内分配和取消分配 teraspace 存储。如需进一步了解这两种类型的存储,请参阅 ILE 概念 手册中的 “Teraspace 和单层存储” 部分。

两种类型的堆存储之间存在某些重大差异。从堆中分配单层存储时,最多只能分配 16 MB 的内存。从堆中分配 teraspace 存储时,可以分配数 TB 大小的存储。必须使用十六字节的指针来寻址单层存储。对于支持八字节指针的语言(例如 C 和 C++),可以使用八字节指针寻址 teraspace 存储。单层存储堆的总大小限制为每作业 4 GB。teraspace 堆不存在这类限制,teraspace 存储堆的总大小仅受限于可用的系统存储数量。

如何分配或取消分配堆存储

尽管每种语言均有不同的分配和取消分配方法,但所有集成语言环境 (ILE) 语言均可使用堆内存。C 语言中使用的是 malloc() 和free() 函数,C++ 中使用的是 new 和 delete 操作符,而 RPG 中使用的是 %ALLOC 内置函数和 DEALLOC 操作。尽管 COBOL 和 CL 没有管理堆内存的内置函数,但 ILE 模型允许从任意 ILE 语言调用函数,因此这些语言可以调用 malloc() 和 free() 函数来管理堆内存。实际上,所有 ILE 语言均可调用 malloc() 和 free() 函数来管理堆内存。下面的几个示例展示了所有这些语言中对堆内存的分配、使用和取消分配。

示例 1 是使用 C 语言编写的。

示例 1 – C 中的堆内存

/* To compile: CRTBNDC PGM(EXAMPLE1) */

示例 2 是使用 C++ 语言编写的。

示例 2 – C++ 中的堆内存

示例 3 是使用 RPG 语言编写的。

示例 3 – RPG 中的堆内存

示例 4 是使用 COBOL 语言编写的。

示例 4 – COBOL 中的堆内存

示例 5 是使用 CL 语言编写的。

示例 5 – CL 中的堆内存

这些示例仅分配了极少的堆存储(16 字节)。通常情况下,所分配的存储块要比这大得多。举例来说,考虑这样一个事实,CL 变量的最大大小为 32767 字节。利用堆存储和 *PTR 变量,可以在 CL 中管理更大的存储块。

堆内存的常见问题

堆分配和取消分配必须由应用程序显式执行,因此可能会出现这些操作使用不当的问题。堆内存使用不当的常见场景包括:写入的数据量超过了所分配的存储量(内存写入越界),读取的数据量超过了已分配内存的大小(内存读取越界),写入存储或读取存储时使用的是此前已经取消分配的存储(重用已取消分配的内存),取消分配存储超过一次(重复取消分配),在内存不再使用时未能取消分配内存(内存泄漏)。

下面的示例程序展示了这些堆内存问题。

前两个场景涉及到了堆分配的大小。分配时将为堆管理器提供所需的存储大小,它将返回足够大的存储块,以满足所请求的大小。如果应用程序未能正确计算堆大小,那么有时会意外地读取或写入不属于当前分配存储的一部分堆存储。这可能会给应用程序带来问题。

示例 6 展示了 C++ 中的内存写入越界问题。堆仅分配了 16 字节的大小,但总计写入了 17 字节的数据。strcpy() 函数不仅会复制字符串的 16 个字节,而且还会追加一个零字节后缀(NULL 字符)。

示例 6 – C++ 中的内存写入越界

示例 7 展示了 C 语言中的内存写入越界。堆仅分配了 13 字节的大小,但总计写入了 14 字节的数据。strcpy() 函数不仅会复制字符串的 13 个字节,而且还会追加一个零字节后缀(NULL 字符)。

示例 7 – C 中的内存写入越界

示例 8 展示了 RPG 中的内存写入越界问题。堆仅分配了 13 字节的大小,但总计写入了 14 字节的数据。基本变量的大小是 14 个字节。

示例 8 – RPG 中的内存写入越界

下一个场景涉及过早地取消分配应用程序仍在使用的堆存储。逻辑错误可能允许应用程序引用已经取消分配并返回给堆管理器进行重用的堆存储。此类分配中引用的数据可能是所需数据,也可能不是所需数据,可能会导致应用程序中出现间歇性错误。

示例 9 是使用 C++ 编写的,展示了写入不再属于已分配存储的堆存储的情况。

示例 9 – 取消分配的内存重用

下一个场景涉及到多次取消分配相同的存储。

示例 10 是使用 C 语言编写的,展示了取消分配内存的重复调用。

示例 10 – 取消分配内存的重复调用

 

最后一个场景是未能对某些已经分配的堆内存执行取消分配。这就叫做内存泄露,因为内存泄漏 到了堆以外的地方,无法再供应用程序使用。尽管内存不再被引用,但堆管理器并不了解该情况,也无法重用内存。内存泄漏会造成严重的问题。大量内存泄漏会导致性能问题,还有可能导致应用程序耗尽内存。在长期运行的应用程序中,这种问题尤为严重。在某些时候,应用程序结束之后(在激活组终止时),操作系统会回收该应用程序的所有堆存储,因此内存泄漏不再成为问题。

示例 11 是使用 RPG 编写的,展示了内存泄漏的情况。尽管应用程序尝试取消分配存储,但传递给取消分配函数的指针值不是分配函数返回的原始指针,因此未能取消对内存的分配。

示例 11 – RPG 中的内存泄漏

不正确的堆使用会导致间歇性的应用程序故障、不当的应用程序行为,甚至损坏的数据。致使堆问题难以调试的一个特征是:堆错误往往不会造成直接后果。举例来说,在缓冲区写入越界的情况下,写入的数据超出了已分配内存缓冲区的结尾处。当引用写入越界、不再包含所需数据的内存时,问题征兆要到很晚的时候才会显现在应用程序中。

IBM i 堆内存管理器

IBM i 6.1 及更新版本中提供了三种堆内存管理器:默认内存管理器、Quick Pool 内存管理器和调试内存管理器。对给定应用程序有效的内存管理器由应用程序运行时的 QIBM_MALLOC_TYPE 环境变量的设置控制。6.1 版本中的 PTF 5761SS1-SI33945 提供了访问其他堆内存管理器的环境变量,IBM i 发布版的后续版本也包含此类环境变量。在添加环境变量访问的同时,还添加了调试内存管理器。在版本 5.4 和 6.1 中,还可以调用 API 进行支持设置,从而使用 Quick Pool 内存管理器。如需查看堆内存管理器支持的完整文档,请参阅 ILE C/C++ 运行时库函数 手册。

默认内存管理器

默认内存管理器是一种通用的内存管理器,它会尝试平衡性能和内存需求。它为绝大多数应用程序提供了充足的性能,同时会尝试最大程度地减少开销所需的额外内存量。默认内存管理器是大多数应用程序的最佳选择,默认情况下,内存管理器是启用的。如果IBM_MALLOC_TYPE 环境变量尚未设置,或者被设置为无法识别的值,则会使用默认的内存管理器。

Quick Pool 内存管理器

Quick Pool 内存管理器会将内存拆分为一系列池,以便提高发出大量较小的分配请求的应用程序的性能。在启用 Quick Pool 内存管理器时,将为处于给定分配大小范围内的分配请求分配池中固定大小的单元。这些请求的处理速度将快于大小超出此范围的请求。超出此范围的分配请求将按照与默认内存管理器相同的方式处理。

默认情况下不会启用 Quick Pool 内存管理器,但可以通过设置以下环境变量来启用它:

也可以在应用程序中使用 API 调用启用 Quick Pool 内存管理器。如需了解有关的更多信息,请参阅 ILE C/C++ 运行时库函数 手册。

调试内存管理器

调试内存管理器主要用于查找应用程序没有正确使用堆的情况。它并未针对性能而优化,可能会对应用程序的性能造成负面影响。然而,它对于确定不当的堆使用情况很有价值。

调试内存管理器检测到的内存问题会导致以下两种行为之一:

  • 如果在发生不当使用之时检测到问题,那么将会生成一条机器检查句柄 (MCH) 异常消息(通常是 MCH0601、MCH3402 或者 MCH6801)。在这种情况下,错误消息通常会停止应用程序。
  • 如果在不当使用已经发生之后才检测到问题,则会生成一条 C2M1212 消息。在这种情况下,消息通常不会停止应用程序。

调试内存管理器会通过两种方式检测内存问题:

  • 首先,使用限制访问内存页面。在每次分配之前和之后使用一个限制访问权限的内存页面。让每个内存块都与 16 字节的边界对齐,并尽可能地将它们放置在页面结尾处。由于仅允许在页面边界处保护内存,所以这样的对齐对于内存写入越界和内存读取越界的检测效果最好。在一个限制访问权限的内存页面中执行任何读取或写入操作时,会立即生成一条 MCH 异常。
  • 第二,它会在每次分配前后使用一个填充字节。在分配时,紧邻每次分配的内存之前的几个字节会初始化为预设的字节模式。在分配之时,如果分配的大小需要限制为 16 字节的倍数,那么紧邻所分配内存之后的填充字节也会初始化为预设的字节模式。在所分配的内存取消分配时,将验证填充字节,确保其仍然包含预期的预设字节模式。如果任何填充字节被修改,那么调试内存管理器会生成一条 C2M1212 消息,原因代码为 X’80000000’。

默认情况下不会启用调试内存管理器,但可以通过设置以下环境变量来启用它:

 

调试堆内存的常见问题

上文列出了使用堆内存时的几种常见问题,还给出了一些示例程序,展示了各种堆问题。调试内存管理器允许检测多种堆内存常见问题,包括:内存写入越界、内存读取越界、重用已取消分配的内存和重复的取消分配。调试内存管理器不会检测内存泄漏问题。ILE 应用程序内的内存泄漏检测将在未来的文章中加以介绍。

在使用调试内存管理器运行程序时,将描述展示堆问题的每个示例程序的行为。

示例 6 展示了一个内存写入越界问题。其中展示了尝试超越大小为 16 字节的倍数的数据项末尾的一项写入操作。将该示例编译为一个单层存储程序,并使用调试内存管理器运行此程序,在发生内存写入越界时,这会生成一条 MCH0601 消息。将该示例编译为一个 teraspace 存储程序,并使用调试内存管理器运行此程序,在发生内存写入越界时,这会生成一条 MCH6801 消息。无论出现哪种情况,错误消息的细节都会指向执行内存写入越界的语句。示例展示了内存写入越界,但内存读取越界也会得到相同的结果。

示例 7 展示了一个内存写入越界问题。其中演示了未超越 16 字节边界的内存写入操作。将该示例被编译为一个单层存储程序或 teraspace 程序,并使用调试内存管理器运行该程序,这会得到一条 C2M1212 消息,原因代码为 X’80000000’。消息的细节将指向调用 free() 的语句。调试内存管理器无法检测到未超越 16 字节边界的内存读取越界。

示例 8 展示了一个内存写入越界问题。由于在默认情况下 RPG 应用程序不会启用调试内存管理器,因此未能检测到内存写入越界。IBM i 7.1 的一项增强为 RPG 添加了 ALLOC(*TERASPACE) 控制规范关键字。该关键字将通知 RPG 运行时为内存分配和取消分配使用 C 运行时 teraspace 堆函数,允许对 RPG 应用程序使用调试内存管理器。完成上述操作之后,该示例将具备与示例 7 相同的行为。如需进一步了解 ALLOC 关键字的其他细节,请参阅 ILE RPG 语言参考 手册。

示例 9 展示了写入不再属于已分配存储的堆存储的情况。将该示例编译为一个单层存储程序,并使用调试内存管理器运行此程序,在发生内存写入越界时,这会生成一条 MCH3402 消息。将该示例编译为一个 teraspace 存储程序,并使用调试内存管理器运行此程序,在发生内存写入时,这会生成一条 MCH6801 消息。无论出现哪种情况,错误消息的细节都会指向执行内存写入的语句。示例展示了内存写入,但内存读取也会得到相同的结果。

示例 10 展示了取消分配内存的重复调用。将该示例编译为一个单层存储程序或 teraspace 程序,并使用调试内存管理器运行该程序,这会得到一条 C2M1212 消息。消息的细节将指向调用 free() 的语句。

示例 11 展示了一个内存泄漏问题。如前文所述,调试内存管理器不会检测内存泄漏问题。

如果未使用调试内存管理器,则无法检测到这些内存问题,同时还有可能导致间歇性的应用程序问题、不当的应用程序行为或损坏的数据。

破解堆的谜题

对于编写和维护 ILE 应用程序而言,了解堆内存是什么以及如何正确使用堆内存的能力极为重要。在明确认识常见堆问题的同时,利用调试内存管理器即可轻松检测到应用程序内的堆问题,迅速解决 IBM i ILE 应用程序内的堆内存问题。

本文内容最初是在 iProDeveloper 2010 年 8 月刊中发布的。

收藏 评论

相关文章

可能感兴趣的话题



直接登录
跳到底部
返回顶部