C ++中的内存泄漏“未定义行为”类问题吗?

Posted

技术标签:

【中文标题】C ++中的内存泄漏“未定义行为”类问题吗?【英文标题】:Are memory leaks "undefined behavior" class problem in C++? 【发布时间】:2010-12-31 00:37:04 【问题描述】:

事实证明,许多看起来很天真的东西在 C++ 中是未定义的行为。例如,一旦一个非空指针被delete'd even printing out that pointer value is undefined behavior。

现在内存泄漏肯定很糟糕。但是它们是什么类别的情况 - 已定义、未定义或其他什么类别的行为?

【问题讨论】:

另见***.com/questions/9971559/… 【参考方案1】:

(在“提示:此答案已从 Does a memory leak cause undefined behaviour? 移至此处”下方评论 - 您可能必须阅读该问题才能获得此答案的正确背景 O_o)。

在我看来,这部分标准明确允许:

拥有一个自定义内存池,您可以将-new 对象放入其中,然后释放/重用整个内容,而无需花时间调用它们的析构函数,只要您不依赖对象析构函数的副作用

分配一点内存但从不释放它的库,可能是因为它们的函数/对象可以被静态对象的析构函数和注册的退出处理程序使用,而且不值得购买整个编排顺序每次这些访问发生时,都会发生毁灭性的或短暂的“凤凰”般的重生。

我无法理解为什么当存在对副作用的依赖时,标准选择不定义行为 - 而不是简单地说这些副作用不会有发生了,让程序定义未定义的行为,就像你通常期望的那样。

我们可以仍然考虑什么标准所说的未定义行为。关键部分是:

“取决于析构函数产生的副作用有未定义的行为。”

标准 §1.9/12 明确定义副作用如下(以下斜体为标准,表示引入了正式定义):

访问由volatile glvalue (3.10) 指定的对象、修改对象、调用库 I/O 函数或调用执行任何这些操作的函数都是副作用 ,即执行环境状态的变化。

在您的程序中,没有依赖关系,因此没有未定义的行为。

一个可以说与§3.8 p4 中的场景相匹配的依赖示例是:

struct X

    ~X()  std::cout << "bye!\n"; 
;

int main()

     new X();

人们正在争论的一个问题是上面的 X 对象是否会被视为 3.8 p4 的目的 released,因为它可能只发布到操作系统。程序终止后 - 从阅读标准中不清楚流程“生命周期”的那个阶段是否在标准的行为要求范围内(我对标准的快速搜索没有澄清这一点)。我个人认为 3.8p4 在这里适用,部分原因是只要它足够模棱两可,编译器编写者可能会觉得有权在这种情况下允许未定义的行为,但即使上面的代码不构成释放场景的容易修改啦...

int main()

     X* p = new X();
     *(char*)p = 'x';   // token memory reuse...

无论如何,尽管 main 实现了上面的析构函数有一个副作用 - 每个“调用库 I/O 函数”;此外,程序的可观察行为可以说是“依赖于”它,因为如果它已经运行,会受到析构函数影响的缓冲区在终止期间被刷新。但是“取决于副作用”是否意味着暗示如果析构函数没有运行,程序显然会有未定义的行为的情况?我会在前者方面犯错,特别是因为后一种情况不需要标准中的专用段落来记录行为未定义。这是一个明显未定义行为的示例:

int* p_;

struct X

    ~X()  if (b_) p_ = 0; else delete p_; 
    bool b_;
;

X xtrue;

int main()

     p_ = new int();
     delete p_; // p_ now holds freed pointer
     new (&x)false;  // reuse x without calling destructor

在终止期间调用x 的析构函数时,b_ 将是false,因此~X()delete p_ 用于已释放的指针,从而创建未定义的行为。如果在重用之前调用了x.~X();,则p_ 将被设置为0,并且删除将是安全的。从这个意义上说,程序的正确行为可以说取决于析构函数,并且行为显然是未定义的,但是我们是否只是制作了一个与 3.8p4 描述的行为本身匹配的程序,而不是让行为成为结果3.8p4...?

有问题的更复杂的场景 - 太长而无法提供代码 - 可能包括例如一个奇怪的 C++ 库,在文件流对象中具有引用计数器,必须达到 0 才能触发某些处理,例如刷新 I/O 或加入后台线程等 - 不做这些事情的风险不仅在于无法执行明确请求的输出析构函数,但也无法从流中输出其他缓冲输出,或者在某些具有事务文件系统的操作系统上可能会导致早期 I/O 的回滚 - 此类问题可能会改变可观察到的程序行为甚至使程序挂起。

注意:没有必要证明任何实际代码在任何现有编译器/系统上行为异常;标准清楚地保留了编译器具有未定义行为的权利......这就是最重要的。这不是您可以推理并选择忽略标准的事情 - 可能是 C++14 或其他一些修订版更改了此规定,但只要它存在,那么即使可以说对 有一些“依赖”副作用那么就有可能出现未定义的行为(当然,它本身允许由特定的编译器/实现来定义,所以这并不意味着每个编译器都必须做一些奇怪的事情)。

【讨论】:

我认为您通常是在正确的轨道上,但是给定的示例程序不依赖于析构函数。 @Cheersandhth.-尽管我已经解释了与缓冲的共享交互,但您断言没有任何解释-那么您希望我对您的反对有何看法?请注意,程序输出显然是程序的有形行为,会受到析构函数的副作用的影响,因此我认为标准授予编译器在这种情况下具有未定义行为的权利。 "析构函数不应被隐式调用" 注意,如果有 UB,这样的保证将是没有意义的。一般而言,该标准不涉及无意义的保证。 @TonyD 说实话,我发现很难找到行为未定义的示例。标准中的这个特殊声明似乎毫无意义,因为程序不可能依赖保证不会发生的行为。 注意:这个答案已从***.com/questions/24137006/…移到这里【参考方案2】:

这显然不能是未定义的行为。仅仅因为UB必须在某个时间点发生,并且忘记释放内存或调用析构函数不会在任何时间点发生。所发生的只是程序在没有释放内存或调用析构函数的情况下终止;这不会以任何方式使程序或其终止的行为未定义。

话虽如此,在我看来,这个标准在这段话中是自相矛盾的。一方面它确保在这种情况下不会调用析构函数,另一方面它表示如果程序依赖于析构函数产生的副作用,那么它具有未定义的行为。假设析构函数调用exit,那么任何程序都不能假装独立于它,因为调用析构函数的副作用会阻止它做它本来应该做的事情;但文本也保证析构函数不会被调用,这样程序就可以继续做它的事情不受干扰。我认为阅读本文结尾的唯一合理方法是,如果程序的正确行为要求调用析构函数,那么实际上没有定义行为;鉴于刚刚规定不会调用析构函数,因此这是多余的评论。

【讨论】:

还有未定义的行为在编译时“发生”,这并不是真正令人信服的论点。 @PlasmaHH:我不相信。你真的是说可能有一些程序只是编译(或尝试)而不尝试运行它会产生未定义的行为吗?据我了解,该标准试图描述执行格式良好的程序应具有的行为,包括在哪些情况下未定义此类行为,但如果未尝试执行,则标准对行为没有任何规定(尽管它可能规定应在编译时发出某些错误信号)。 以翻译阶段 2(预处理器中的续行)为例,其中包含“如果因此产生了与通用字符名称的语法匹配的字符序列,则行为是未定义” @PlasmaHH 所以你赢了。但这让我觉得很奇怪,因为在处理预处理时,甚至还没有一个程序可以谈论其行为。也许他们只是说预处理器的行为是未定义的(因此它可能会在不发出错误信号的情况下决定吐出一个“Hello world”程序进行编译,而不是编写任何内容),但我不明白为什么他们不只是说程序格式错误。 @LightnessRacesinOrbit:虽然我(现在)承认有一些例外,但大多数未定义的行为都出现在标准描述代码执行的地方;是否发生 UB 不需要通过纯粹的程序检查来确定。例如 n=n++; 类型 UB 仅在该语句实际执行时才如此;将它埋在一个从未调用过的函数中不会导致 UB。在手头的情况下,似乎在引用的文本中指出了一个时刻:“对象占用的存储空间被重用或释放”。如果这从未发生过,就像给定的例子一样,那么肯定没有 UB。【参考方案3】:

除了所有其他答案之外,还有一些完全不同的方法。查看第 5.3.4-18 节中的内存分配,我们可以看到:

如果上述对象初始化的任何部分76终止 通过抛出异常和合适的释放函数可以 找到后,调用释放函数来释放其中的内存 正在构造对象,之后异常继续 在新表达式的上下文中传播。如果没有明确 可以找到匹配的释放函数,传播异常 不会导致对象的内存被释放。 [注:这是 当被调用的分配函数不分配时适用 记忆;否则,很可能导致内存泄漏。 ——尾注 ]

这里会不会引起UB,会提到,所以“只是内存泄漏”。

在 §20.6.4-10 等地方,提到了可能的垃圾收集器和泄漏检测器。已经对安全派生指针等概念进行了很多思考。能够将 C++ 与垃圾收集器一起使用(C.2.10“对垃圾收集区域的最小支持”)。

因此,如果 UB 只是丢失了指向某个对象的最后一个指针,那么所有的努力都将毫无意义。

关于“当析构函数有副作用时,它永远不会运行 UB”,我会说这是错误的,否则 std::quick_exit() 等设施本质上也是 UB。

【讨论】:

注意:这个答案已从***.com/questions/24137006/…移到这里【参考方案4】:

举证责任在于那些认为内存泄漏可能是 C++ UB 的人。

自然没有证据。

简而言之,对于任何怀有任何怀疑的人来说,这个问题永远无法得到明确的解决,除非非常可信地威胁委员会,例如大声的贾斯汀比伯音乐,所以他们添加了一个 C++14 声明,说明它不是 UB。


有争议的是 C++11 §3.8/4:

对于具有非平凡析构函数的类类型的对象,在对象占用的存储被重用或释放之前,程序不需要显式调用析构函数;但是,如果没有显式调用析构函数,或者没有使用 delete-expression (5.3.5) 来释放存储,则不应隐式调用析构函数,并且任何依赖于析构函数产生的副作用具有未定义的行为。

这段话在 C++98 和 C++03 中的措辞完全相同。什么意思?

在重用或释放对象占用的存储空间之前,程序不需要显式调用析构函数 – 意味着可以获取变量的内存并重用该内存,而无需首先破坏现有对象。

如果没有显式调用析构函数,或者如果没有使用删除表达式 (5.3.5) 来释放存储,则不应隐式调用析构函数 – 意味着如果在内存重用之前没有销毁现有对象,那么如果该对象自动调用其析构函数(例如,局部自动变量),则程序具有未定义行为,因为该析构函数将在否更长的现有对象。

任何依赖于析构函数产生的副作用的程序都有未定义的行为 – 不能按字面意思表示,因为程序总是依赖于任何副作用,根据副作用的定义。或者换句话说,程序没有办法不依赖副作用,因为那样它们就不会是副作用。

很可能预期的结果并不是最终进入 C++98 的内容,因此我们手头有一个缺陷

从上下文可以猜测,如果程序依赖于自动销毁静态已知类型T 的对象,其中内存已被重用于创建一个或多个不是T 对象的对象,那么这就是未定义的行为。


看过评论的人可能会注意到,上面对“shall”一词的解释并不是我之前假设的意思。正如我现在所看到的,“shall”不是实现的要求,它被允许做什么。这是对程序的要求,允许代码做什么。

因此,这是正式的 UB:

auto main() -> int

    string s( 666, '#' );
    new( &s ) string( 42, '-' );    //  <- Storage reuse.
    cout << s << endl;
    //  <- Formal UB, because original destructor implicitly invoked.

但这可以用字面解释:

auto main() -> int

    string s( 666, '#' );
    s.~string();
    new( &s ) string( 42, '-' );    //  <- Storage reuse.
    cout << s << endl;
    //  OK, because of the explicit destruction of the original object.

一个主要问题是,如果按照上面标准段落的字面解释,如果放置 new 在那里创建了一个不同类型的对象,只是因为显式破坏了原始对象,它仍然是正式的。但在这种情况下,这不是很实用。也许这被标准中的其他段落所涵盖,因此它也是正式的 UB。

这也可以,使用来自&lt;new&gt;的展示位置new

auto main() -> int

    char* storage   = new char[sizeof( string )];
    new( storage ) string( 666, '#' );
    string const& s = *(
        new( storage ) string( 42, '-' )    //  <- Storage reuse.
        );
    cout << s << endl;
    //  OK, because no implicit call of original object's destructor.

正如我所见——现在。

【讨论】:

我不确定你的举证责任。至少 C++03 非常清楚,未定义的行为是默认行为;任何时候标准没有明确指定行为时,都可能会出现这种情况。 (当然,在这种情况下,行为是明确规定的,没有未定义的行为。但那是因为那些说没有未定义行为的人已经承担了举证责任:标准明确规定了在这种情况下会发生什么.) 提供的证据是引用,其中确实提到了未定义的行为。但是,由于程序在终止后无法执行任何操作,并且这些生命周期隐式结束,因此它也不可能依赖任何东西。所以解释是错误的。 您通过描述一个场景(自动变量)来解释“不应隐式调用析构函数”,其中“然后程序具有未定义的行为,因为该析构函数将运行......” - 不能和解了。关于“程序总是依赖于任何副作用” - 不,程序依赖于可观察到的副作用......副作用有一个明确的定义,包括例如对象状态修改,即使该对象以后不使用(这会导致回到 as-if 规则,我想说这不适用,因为行为要求未定义,因此无法推理)。 您的示例在标准中明确涵盖了几段后:“如果程序以静态(3.7.1)结束T类型对象的生命周期,线程(3.7.2 ) 或自动 (3.7.3) 存储持续时间,并且如果 T 具有非平凡的析构函数,则程序必须确保在发生隐式析构函数调用时原始类型的对象占据相同的存储位置;否则行为程序未定义。” @Alf:我认为这也使上面的示例定义明确,如果泄漏的话。【参考方案5】:

我对这句话的解读:

对于具有非平凡析构函数的类类型的对象, 程序不需要在调用之前显式调用析构函数 对象占用的存储空间被重用或释放;然而,如果 没有显式调用析构函数或删除表达式 (5.3.5) 不用于释放存储,析构函数不得 被隐式调用以及依赖于副作用的任何程序 析构函数产生的行为未定义。

如下:

如果你设法释放了对象占用的存储空间 没有在占用内存的对象上调用析构函数,如果析构函数是重要且有副作用。

如果newmalloc 分配,原始存储可以用free() 释放,析构函数不会运行,会导致UB。或者如果一个指针被强制转换为不相关的类型并被删除,内存被释放,但错误的析构函数运行,UB。

这与省略的delete 不同,后者不会释放底层内存。省略 delete 不是 UB。

【讨论】:

关键短语是“任何依赖于析构函数产生的副作用的程序”。大多数程序都没有,std::string 的析构函数没有可观察到的副作用,因此示例中不可能出现未定义的行为。 (释放内存不是可观察到的副作用。) "如果你设法释放对象占用的存储没有在占用内存的对象上调用析构函数,结果是UB”-这如何与“不需要 显式调用析构函数对象占用的存储空间被重用或发布”。恕我直言,标准的这一部分旨在允许在不调用单个对象析构函数的情况下回收自定义内存池 - 您所说的确切内容是 UB。 我认为这意味着可以在不调用析构函数的情况下释放存储空间仅当析构函数微不足道或没有副作用时才可以。我将其添加到答案中。 我发现措辞和意图非常不清楚,所以你有你的看法是公平的,但不管它的价值,总结让我:“如果!之前不需要显式破坏释放”,即析构函数不是必需的,即使不是平凡的,也没有关于平凡析构函数的情况;整个段落似乎只针对具有非平凡析构函数的对象。我无法与你的解释相协调。 C++程序所代表的抽象机在程序结束时不复存在;因此,动态分配的内存在那个时候绝对释放回宇宙(在我们的实现中,我们的操作系统),即使只是隐式的。 3.8/4 相关的。【参考方案6】:

如果航天飞机必须在两分钟内起飞,而我可以在使用泄漏内存的代码和具有未定义行为的代码之间进行选择,我将使用泄漏内存的代码。

但我们中的大多数人通常不会处于这种情况,如果是这样,那可能是由于更进一步的失败。也许我错了,但我将这个问题理解为“哪种罪会让我更快地进入地狱?”

可能是未定义的行为,但实际上两者兼而有之。

【讨论】:

【参考方案7】:

内存泄漏。

没有未定义的行为。泄漏内存是完全合法的。

未定义的行为:是标准明确不想定义并留给实现的操作,以便在不违反标准的情况下灵活地执行某些类型的优化。

内存管理定义明确。 如果您动态分配内存并且不释放它。然后,内存仍然是应用程序的属性,可以按照它认为合适的方式进行管理。您丢失了对该部分内存的所有引用这一事实既不存在也不存在。

当然,如果您继续泄漏,那么您最终将耗尽可用内存并且应用程序将开始抛出 bad_alloc 异常。但这是另一个问题。

【讨论】:

2003 年标准 3.7.3.2/4 说“使用无效指针值(包括将其传递给释放函数)的效果是未定义的。33)”取决于您对“使用”的定义(这可能不仅仅意味着取消引用),那么仅检查无效指针的值可能被认为是无效的。 @Evan:同意,这就是标准所说的。但是(我读它的方式)在这种情况下使用意味着使用指针指向的对象。这并不意味着使用指针“值”是未定义的行为。如果你把你的论点得出一个自然的结论并将其应用于 NULL 指针! NULL 指针在技术上是一个无效的指针,但只要你不使用它的“值”取消引用指针是很好的定义。 我同意你所说的(让我们面对现实,我从未见过打印无效指针的机器会产生任何负面影响)......但是,该标准也有很多规则说明如何比较和转换 NULL 指针。我认为这只是一个他们本可以更具体的领域(特别是因为在“你甚至不能便携式地查看无效指针”阵营中有很多人。哦,好吧。 @Evan:撤回我的对象​​。在阅读了问题中提到的另一个问题的公认答案后。我现在看到它如何成为未定义的行为,并且正在删除我的答案的那部分(完全尴尬)。我要离开 cmets,因为它们为其他人提供有用的信息。 “当然,如果你继续泄漏,那么你最终会耗尽可用内存,应用程序将开始抛出 bad_alloc 异常。” 以我的经验,真正发生了什么是这个过程变得越来越大,系统慢慢地停止了。但是,是的。【参考方案8】:

如果您泄漏内存,则执行将继续进行,就好像什么都没发生一样。这是定义的行为。

接下来,您可能会发现调用malloc 失败是因为没有足够的可用内存。但这是malloc的定义行为,后果也很明确:malloc调用返回NULL

现在这可能会导致不检查malloc 结果的程序因分段违规而失败。但是这种未定义的行为(来自语言规范的 POV)是由于程序取消引用无效指针,而不是早期的内存泄漏或失败的 malloc 调用。

【讨论】:

【参考方案9】:

语言规范没有提到“内存泄漏”。从语言的角度来看,当您在动态内存中创建对象时,您正在这样做:您正在创建一个具有无限生命周期/存储持续时间的匿名对象。在这种情况下,“无限”意味着该对象只能在您显式解除分配时结束其生命周期/存储持续时间,否则它会继续永远存在(只要程序运行)。

现在,我们通常认为动态分配的对象在程序执行时成为“内存泄漏”,此时对该对象的所有引用(通用“引用”,如指针)都丢失到无法恢复的地步。请注意,即使对人类来说,“所有引用都丢失”的概念也没有得到非常精确的定义。如果我们对对象的某些部分有引用,理论上可以“重新计算”为对整个对象的引用怎么办?是不是内存泄漏?如果我们没有对该对象的任何引用,但我们可以使用程序可用的一些其他信息(如精确的分配顺序)以某种方式计算这样的引用怎么办?

语言规范本身并不关心此类问题。无论您认为程序中出现什么“内存泄漏”,从语言的角度来看,这根本不是事件。从语言的角度来看,一个“泄露的”动态分配的对象只会继续快乐地生活,直到程序结束。这是唯一剩下的问题:当程序结束并且仍然分配一些动态内存时会发生什么?

如果我没记错的话,该语言没有指定动态内存在程序终止时仍然分配的情况。不会尝试自动销毁/释放您在动态内存中创建的对象。但在这种情况下,没有正式的未定义行为

【讨论】:

【参考方案10】:

直截了当的回答:标准没有定义泄漏内存时会发生什么,因此它是“未定义的”。虽然它是隐式未定义的,但它不如标准中显式未定义的东西有趣。

【讨论】:

【参考方案11】:

它明确定义行为。

假设服务器正在运行并继续分配堆内存并且即使没有使用它也没有释放内存。 因此最终结果将是最终服务器将耗尽内存并且肯定会发生崩溃。

【讨论】:

但在此之前,一个写得不好的驱动程序可能会认为它分配的内存是可用的,当分配失败时,并继续前进导致蓝屏死机。同时,Microsoft 会打印出一条有用的错误消息,提示您更换驱动程序,但没有任何内存泄漏迹象。 顺便说一句 - 没有新的驱动程序可用!【参考方案12】:

未定义的行为意味着,将发生的事情尚未定义或未知。内存泄漏的行为在 C/C++ 中肯定会吞噬可用内存。然而,由此产生的问题并不总是如游戏结束所描述的那样被定义和变化。

【讨论】:

这不是“未定义行为”的意思。请阅读该标准的副本。 您是否更确切地说“未定义的行为”意味着标准未指定出现的确切行为?【参考方案13】:

内存泄漏肯定是在 C/C++ 中定义的。

如果我这样做:

int *a = new int[10];

紧随其后

a = new int[10]; 

我肯定会泄漏内存,因为无法访问第一个分配的数组,并且由于不支持 GC,因此不会自动释放该内存。

但是这种泄漏的后果是不可预测的,并且会因应用程序而异,对于相同的给定应用程序也会因机器而异。假设由于在一台机器上泄漏而崩溃的应用程序可能在另一台具有更多 RAM 的机器上运行良好。此外,对于给定机器上的给定应用程序,由于泄漏导致的崩溃可能会在运行期间的不同时间出现。

【讨论】:

应用程序不会因您上面给出的代码而崩溃。不过,它最终会耗尽内存。 未知和任意后果听起来就像是未定义行为的定义。 @Joeshperry:未定义的行为:是标准中定义的一个非常具体的术语。这意味着该标准没有具体说明将要发生的事情的含义,以便实现具有足够的灵活性来执行优化并生成适当的最佳代码。因此,任意后果与未定义的行为(由标准定义)无关。 @avakar:不过,游戏结束有点意思。内存泄漏不是未定义的行为,内存不足也不是未定义的行为。但是在实践中操作系统如果内存不足,经常会导致 C++ 实现违反标准。例如,它们可能会过度使用内存,或者由于应用程序级内存使用,操作系统可能会莫名其妙地停止或内部失败。不过,这与泄漏无关,只是使用了所有内存。 Tarydon:在极端情况下,Linux 内核可以杀死一个使用过多内存而没有做任何其他错误的进程。 linux-mm.org/OOM_Killer 它并没有完全崩溃;系统将其击落。【参考方案14】:

定义,因为内存泄漏是你忘记自己清理。

当然,内存泄漏可能会导致以后出现未定义的行为。

【讨论】:

为什么内存泄漏会导致未定义的行为! 除了最终耗尽内存之外,内存泄漏会导致哪些未定义的行为。取消引用已经释放的指针会导致未定义的行为(如段错误),但内存泄漏不会立即造成危险。 内存泄漏不能导致未定义的行为。例如,在 C 中,过多的内存泄漏最终可能导致 malloc 调用返回 NULL。但这是malloc 的定义行为。 这就是我的意思。内存泄漏的行为是绝对定义的。例如,内存不足不是。

以上是关于C ++中的内存泄漏“未定义行为”类问题吗?的主要内容,如果未能解决你的问题,请参考以下文章

它会在快速的类方法中导致内存泄漏吗

它是C中的内存泄漏吗?

C ++中地牢爬行者的多类级别构造函数中的Valgrind内存泄漏

matlab和C中的内存管理器问题[重复]

事件侦听器中的内存泄漏

左移是 Rust 中的负值未定义行为吗?