为什么这个程序会出现内存泄漏,如何在使用malloc和free来处理包含std :: string对象的情况下解决它?

58

这是一个最小化的工作示例,用于展示我在实际代码中遇到的问题。

#include <iostream>

namespace Test1 {
    static const std::string MSG1="Something really big message";
}

struct Person{
    std::string name;
};

int main() {
    auto p = (Person*)malloc(sizeof(Person));
    p = new(p)Person();
    p->name=Test1::MSG1;

    std::cout << "name: "<< p->name << std::endl;

    free(p);

    std::cout << "done" << std::endl;

    return 0;
}

当我通过Valgrind编译并运行它时,它会给我这个错误:

明确丢失:1个块中的31字节


限制条件

  1. 在上面的示例中,我必须使用malloc,因为在我的C++项目中我使用了一个C库,该库在内部使用了这个malloc。所以我不能避免使用malloc,因为我在代码中没有明确地执行它。
  2. 我需要在我的代码中一遍又一遍地重新分配 Personstd::string name

23
free之前,您必须调用析构函数。 - HolyBlackCat
18
当你使用placement-new时,必须显式调用对象析构函数。就像malloc不会构造对象一样,free也不会析构对象。 - Some programmer dude
7
这是最小可行示例--你忘了#include <string>#include <cstdlib> - PaulMcKenzie
6
@PaulMcKenzie 这是可以理解的疏忽 - 一些(虽然不是全部)现实世界的编译器/库将 <string><cstdlib> 包含在 <iostream> 中(标准既不要求也不阻止这样做)。 - Peter
3
我认为不需要使用alignas,@PaulSanders。因为std::malloc()返回的内存已经适当地对齐,可以分配给任何具有基本对齐要求的对象指针(或空指针)。 - Toby Speight
显示剩余9条评论
5个回答

55

你的代码每一行都很重要...

为一个Person对象分配内存:

auto p = (Person*)malloc(sizeof(Person));

通过调用构造函数,在已分配的内存中构造一个人(Person)对象:

p = new(p)Person();

释放通过malloc分配的内存:
free(p);

通过使用放置new调用构造函数会创建一个std::string。该字符串将在析构函数中被销毁,但是析构函数从未被调用。free不会调用析构函数(就像malloc不会调用构造函数一样)。

malloc只分配内存。放置new仅在已分配的内存中构造对象。因此,在调用free之前需要调用析构函数。这是我所知道的唯一需要显式调用析构函数的情况:

auto p = (Person*)malloc(sizeof(Person));
p = new(p)Person();
p->~Person();
free(p);

2
关于代码中的注释的讨论总是有些棘手,但对我来说,我期望的代码中唯一的注释是使用placement-new和malloc(分配器、练习等)的原因。 - stefaanv
22
为了使泄漏问题变得非常清晰:由于未调用“Person”析构函数,“Person::name”未被销毁,因此由“std::string”分配的内存未被释放。也就是说,“Person”已被释放,但其指向的任何内存都没有被释放。 - Roger Lipscombe
2
@AnsonSavage 如果没有任何限制,他们可能不会为基本上已经管理动态分配内存的std::string使用动态分配。 - 463035818_is_not_a_number
1
@AnsonSavage 如果您尝试这样做,无论是valgrind还是地址sanitizer都会报告mallocoperator delete之间不匹配的错误。 - Daniel Schepler
1
@AnsonSavage:new 可以在分配的项目之前添加特定的元数据,不同于 malloc 常常是扩展或替换的元数据。delete 依赖于该元数据来正确清理。如果 newmalloc确切的 元数据结构上存在分歧,则 deletefree 也会存在分歧,当你混合使用它们时,将会发生可怕的事情。 - ShadowRanger
显示剩余3条评论

35

在使用free(p);之前,您必须手动调用析构函数:

p->~Person();

或者std::destroy_at(p),这意味着相同的事情。


31

确定问题所在

首先,我们要通过说明每个语句执行后内存的状态来清楚地了解问题所在。

int main() {
    auto p = (Person*)malloc(sizeof(Person));

    //  +---+    +-------+
    //  | p | -> | ~~~~~ |
    //  +---+    +-------+

    p = new(p)Person();

    //  +---+    +-------+
    //  | p | -> | name  |
    //  +---+    +-------+

    p->name=Test1::MSG1;

    //  +---+    +-------+    +---...
    //  | p | -> | name  | -> |Something...
    //  +---+    +-------+    +---...

    free(p);

    //  +---+                 +---...
    //  | p |                 |Something...
    //  +---+                 +---...

    return 0;
}

正如您所看到的,调用free(p)可以释放最初由malloc分配的内存,但它不会释放在分配给p->name时分配的内存。

这就是内存泄漏。

解决问题

Person对象放在堆上有两个方面:

  • 内存分配——在这里由malloc/free处理。
  • 初始化终止该内存——通过对构造函数和析构函数的调用来处理。

由于缺少对析构函数的调用,因此持有Person的资源会被泄漏。这里是内存,但如果Person持有锁,则可能出现永久锁定的互斥体等情况。因此,执行析构函数是必要的。

c风格的方法是自己调用析构函数:

int main() {
    auto p = (Person*)malloc(sizeof(Person));
    p = new(p) Person();
    p->name = Test1::MSG1;

    std::cout << "name: "<< p->name << "\n";

    //  Problem "fixed".
    p->~Person();

    free(p);

    std::cout << "done" << "\n";

    return 0;
}

然而,这不是符合C++惯用法的:容易出错等等...

C++的方法是使用RAII来确保当p超出作用域时,所有资源都被正确处理:执行Person的析构函数并且释放为Person分配的内存。

首先,我们将创建一些辅助程序。我使用了c命名空间,因为我不知道您使用的C库的名称,但我建议您更具体地说明:

namespace c {
struct Disposer<T> {
    void operator()(T* p) {
        p->~T();
        free(p);
    }
};

template <typename T>
using UniquePointer<T> = std::unique_ptr<T, Disposer<T>>;

template <typename T, typename... Args>
UniquePointer<T> make_unique(T* t, Args&&... args) {
    try {
        new (t) T(std::forward<Args>(args)...);
    } catch(...) {
        free(t);
        throw;
    }

    return UniquePointer{t};
}
} // namespace c

有了这个,我们可以改进原始的例子:

int main() {
    auto raw = (Person*) malloc(sizeof(Person));

    auto p = c::make_unique(raw);

    p->name = Test1::MSG1;

    std::cout << "name: "<< p->name << "\n";

    //  No need to call the destructor or free ourselves, welcome to RAII.

    std::cout << "done" << "\n";

    return 0;
}

注意:不要使用std::endl,而应该使用'\n'"\n"std::endl除了加入换行符外,还调用.flush(),这很少是你想要的——它会减慢程序运行速度。

11

如其他答案所述,泄漏的源头在于Personname成员的析构函数没有被调用。通常情况下,在调用Person的析构函数时,它会隐式地被调用。然而,Person从未被销毁。Person实例的内存只是通过free释放。

因此,就像您必须在malloc之后使用放置new显式调用构造函数一样,您还需要在free之前显式调用析构函数。

您还可以考虑重载newdelete运算符。

struct Person {
    std::string name;
    void * operator new (std::size_t sz) { return std::malloc(sz); }
    void operator delete (void *p) { std::free(p); }
};

这样,您可以正常使用newdelete,在底层它们将使用mallocfree

int main (void) {
    auto p = new Person;
    //... 
    delete p;
}

这样,你就可以更自然地使用智能指针了。

int main (void) {
    auto p = std:make_unique<Person>();
    //... unique pointer will delete automatically
}

当然,你可以使用带有自定义删除器的unique_ptr来进行显式调用mallocfree,但这将会更加繁琐,并且你的删除器仍需要知道显式调用析构函数。


2
虽然我可能错了,但我从问题中理解到OP已经从C API中获得了malloc分配的存储空间,因此必须使用放置new来在其中构造对象,也就是说,OP不负责分配。如果我没错的话,我认为重载operator new/delete在他的应用程序中没有意义。 - Erel
3
请注意,operator new 是以一个大小作为输入参数的 - 因此,您通常应该使用它而不是计算 sizeof(Person); 它例如被任何派生类使用(我不会期望有任何例外)。 - Hans Olsson
1
@Erel 这个简化的例子并没有展示精确的使用情境,但是根据这个例子我们可以推测 OP 正在调用一个 API 来获取对象内存,然后释放这个对象。这些活动恰恰是 overloaded newdelete 可以为您完成的。 - jxh
@HansOlsson 已修复。当重载newdelete时,我通常假设编写代码的人会对每个对象进行重载,包括派生对象。我的代码通常会使用assert来检查提供的大小是否与正在分配内存的class的大小相匹配。 - jxh

6
正如其他人提到的,由 Person 成员动态分配的内存只有在析构函数 ~Person 中释放,而 free() 不会调用该函数。
如果您必须使用此函数与需要除默认之外的一些初始化和清除的库一起使用的情况(例如此处),则一种方法是定义一个新的删除器,供标准库智能指针使用:即使是您未分配的内存块也可以这样使用。
#include <memory>
#include <new> // std::bad_alloc
#include <stdlib.h>
#include <string>

struct Person{
    std::string name;
};

struct PersonDeleterForSomeLib {
  constexpr void operator()(Person* ptr) const noexcept {
    ptr->~Person();
    free(ptr);
  }
};


Person* Person_factory() // Dummy for the foreign code.
{
  Person* const p = static_cast<Person*>(malloc(sizeof(Person)));
  if (!p) {
    throw std::bad_alloc();
  }
  new(p) Person();
  return p;
}

这让你可以安全地使用:

const auto p =
  std::unique_ptr<Person, PersonDeleterForSomeLib>(Person_factory());

具有自动内存管理功能。您可以从函数返回智能指针,并且在其生命周期结束时将调用析构函数和free()。您也可以通过这种方式创建std::shared_ptr。如果由于某种原因需要在智能指针仍然存在的情况下销毁对象,则可以使用resetrelease


“STL” 是某种同义词吗?而不是字面上的 STL 吗? - Peter Mortensen
@PeterMortensen 我稍微修改了措辞(以及哪个内存正在泄漏)。 - Davislor

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接