std::unordered_map不释放内存。

8
我观察到在MSVC14(VS2015)中,std::unordered_map表现怪异。 考虑以下情景,我创建了一个无序映射并用消耗相当大的内存结构填充它,比如说1GB,总共插入了100k个元素。随后你开始从这个映射中删除元素。假设你已经删除了一半的元素,那么你期望释放了一半的内存。对吗?不对!我发现只有在元素数量超过某个阈值时才会释放内存。在我的情况下,这个阈值是1443个元素。
有人可能会说这是malloc优化,使用VirtualAllocExHeapAlloc从操作系统分配大块内存,并且实际上并没有将内存释放回系统,因为优化规定了政策,可能不会调用HeapFree来重用已分配的内存。
为了排除这种情况,我使用了自定义分配器来进行allocate_shared,但它并没有起作用。所以主要问题是为什么会发生这种情况,以及可以采取什么措施“压缩”unordered_map使用的内存?
代码如下:
#include <windows.h>
#include <memory>
#include <vector>
#include <map>
#include <unordered_map>
#include <random>
#include <thread>
#include <iostream>
#include <allocators>

HANDLE heap = HeapCreate(0, 0, 0);
template <class Tp>
struct SimpleAllocator
{
    typedef Tp value_type;
    SimpleAllocator() noexcept
    {}
    template <typename U>
    SimpleAllocator(const SimpleAllocator<U>& other) throw()
    {};
    Tp* allocate(std::size_t n)
    {
        return static_cast<Tp*>(HeapAlloc(heap, 0, n * sizeof(Tp)));
    }
    void deallocate(Tp* p, std::size_t n)
    {
        HeapFree(heap, 0, p);
    }
};
template <class T, class U>
bool operator==(const SimpleAllocator<T>&, const SimpleAllocator<U>&)
{
    return true;
}
template <class T, class U>
bool operator!=(const SimpleAllocator<T>& a, const SimpleAllocator<U>& b)
{
    return !(a == b);
}

struct Entity
{
    Entity()
    {
        _6 = std::string("a", dis(gen));
        _7 = std::string("b", dis(gen));
        for(size_t i = 0; i < dis(gen); ++i)
        {
            _9.emplace(i, std::string("c", dis(gen)));
        }
    }
    int _1 = 1;
    int _2 = 2;
    double _3 = 3;
    double _4 = 5;
    float _5 = 3.14f;
    std::string _6 = "hello world!";
    std::string _7 = "A quick brown fox jumps over the lazy dog.";
    std::vector<unsigned long long> _8 = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0};
    std::map<long long, std::string> _9 = {{0, "a"},{1, "b"},{2, "c"},{3, "d"},{4, "e"},
    {5, "f"},{6, "g"},{7, "h"},{8, "e"},{9, "j"}};
    std::vector<double> _10{1000, 3.14};
    std::random_device rd;
    std::mt19937 gen = std::mt19937(rd());
    std::uniform_int_distribution<size_t> dis = std::uniform_int_distribution<size_t>(16, 256);
};

using Container = std::unordered_map<long long, std::shared_ptr<Entity>>;

void printContainerInfo(std::shared_ptr<Container> container)
{
    std::cout << std::chrono::system_clock::to_time_t(std::chrono::system_clock::now())
        << ", Size: " << container->size() << ", Bucket count: " << container->bucket_count()
        << ", Load factor: " << container->load_factor() << ", Max load factor: " << container->max_load_factor()
        << std::endl;
}

int main()
{
    constexpr size_t maxEntites = 100'000;
    constexpr size_t ps = 10'000;
    stdext::allocators::allocator_chunklist<Entity> _allocator;
    std::shared_ptr<Container> test = std::make_shared<Container>();
    test->reserve(maxEntites);

    for(size_t i = 0; i < maxEntites; ++i)
    {
        test->emplace(i, std::make_shared<Entity>());
    }

    std::random_device rd;
    std::mt19937 gen(rd());
    std::uniform_int_distribution<size_t> dis(0, maxEntites);
    size_t cycles = 0;
    while(test->size() > 0)
    {
        size_t counter = 0;
        std::cout << "Press any key..." << std::endl;
        std::cin.get();
        while(test->size() > 1443)
        {
            test->erase(dis(gen));
        }
        printContainerInfo(test);
        std::cout << "Press any key..." << std::endl;
        std::cin.get();
        std::cout << std::endl;
    }
    return 0;
}

我尝试过以下几种方法: 当负载因子达到某个阈值时,尝试重新哈希/调整大小-在擦除while中添加类似于这样的内容。

if(test->load_factor() < 0.2)
{
    test->max_load_factor(1 / test->load_factor());
    test->rehash(test->size());
    test->reserve(test->size());
    printContainerInfo(test);
    test->max_load_factor(1);
    test->rehash(test->size());
    test->reserve(test->size());
}

如果这些方法都无法解决问题,可以尝试一些“傻瓜式”的方法,例如创建临时容器,复制/移动剩余的条目,清除原始条目,并从临时容器中复制/移回原始条目。类似这样:

if(test->load_factor() < 0.2)
{
    Container tmp;
    std::copy(test->begin(), test->end(), std::inserter(tmp, tmp.begin()));
    test->clear();
    test.reset();
    test = std::make_shared<Container>();
    std::copy(tmp.begin(), tmp.end(), std::inserter(*test, test->begin()));
}

最后,使用allocate_shared替换shared_ptr并将SimpleAllocator实例传递给它。
此外,我在STL代码中做了一些修改,比如在std::unordered_map'svector上调用std::vector::shrink_to_fit(msvc stl实现的unordered_map基于listvector),但有用也不行。

编辑001:对于所有不信者。下面的代码与之前的代码更或多或少相同,但使用std::vector<Entity>代替了unordered_map。内存操作系统回收。
#include <memory>
#include <vector>
#include <map>
#include <random>
#include <thread>
#include <iostream>

struct Entity
{
    Entity()
    {
        _6 = std::string("a", dis(gen));
        _7 = std::string("b", dis(gen));
        for(size_t i = 0; i < dis(gen); ++i)
        {
            _9.emplace(i, std::string("c", dis(gen)));
        }
    }
    int _1 = 1;
    int _2 = 2;
    double _3 = 3;
    double _4 = 5;
    float _5 = 3.14f;
    std::string _6 = "hello world!";
    std::string _7 = "A quick brown fox jumps over the lazy dog.";
    std::vector<unsigned long long> _8 = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0};
    std::map<long long, std::string> _9 = {{0, "a"}, {1, "b"}, {2, "c"}, {3, "d"}, {4, "e"},
                                           {5, "f"}, {6, "g"}, {7, "h"}, {8, "e"}, {9, "j"}};
    std::vector<double> _10{1000, 3.14};
    std::random_device rd;
    std::mt19937 gen = std::mt19937(rd());
    std::uniform_int_distribution<size_t> dis = std::uniform_int_distribution<size_t>(16, 256);
};

using Container = std::vector<std::shared_ptr<Entity>>;

void printContainerInfo(std::shared_ptr<Container> container)
{
    std::cout << std::chrono::system_clock::to_time_t(std::chrono::system_clock::now())
              << ", Size: " << container->size() << ", Capacity: " << container->capacity() << std::endl;
}

int main()
{
    constexpr size_t maxEntites = 100'000;
    constexpr size_t ps = 10'000;
    std::shared_ptr<Container> test = std::make_shared<Container>();
    test->reserve(maxEntites);

    for(size_t i = 0; i < maxEntites; ++i)
    {
        test->emplace_back(std::make_shared<Entity>());
    }

    std::random_device rd;
    std::mt19937 gen(rd());
    size_t cycles = 0;
    while(test->size() > 0)
    {
        std::uniform_int_distribution<size_t> dis(0, test->size());
        size_t counter = 0;
        while(test->size() > 0 && counter < ps)
        {
            test->pop_back();
            ++counter;
        }
        ++cycles;
        if(cycles % 7 == 0)
        {
            std::cout << "Inflating..." << std::endl;
            while(test->size() < maxEntites)
            {
                test->emplace_back(std::make_shared<Entity>());
            }
        }
        std::this_thread::sleep_for(std::chrono::seconds(1));
        printContainerInfo(test);
        std::cout << std::endl;
    }
    return 0;
}

通过任务管理器中的提交大小或Sysinternals的RAMMap中的“总内存”来查看提交大小。 - kreuzerkrieg
1
@kreuzerkrieg 释放的内存实际上并没有从运行进程中返回给操作系统。您无法在任务管理器中看到这一点。使用类似valgrind的工具来检测内存泄漏。 - πάντα ῥεῖ
1
a) 没有内存泄漏。 b)所以,正如您所说,如果我将unordered_map替换为vector<Entity>,我将看到相同的行为? 错误! 内存将立即释放给操作系统。 此外,如果您的说法是正确的,“clean”不会释放内存,但它确实会在unordered_map中的项目数少于〜1400个项目时释放内存。 为什么内存会被操作系统收回? - kreuzerkrieg
@kreuzerkrieg:“在任务管理器中查看提交大小或使用Sysinternals的RAMMap中的“总内存””。 - πάντα ῥεῖ
@n.m. 你期望来自不同操作系统、由不同人实现的 CRT 行为相同吗?虽然我不是 CRT 专家,但我记得 MSVCRT 没有像 glibc 那样实现每个线程的 arena。 - kreuzerkrieg
显示剩余11条评论
3个回答

9

您是正确的,但只是部分正确。

C++中unordered_map的实现方式在VC++中是通过使用内部的std::vector实现的,这是桶列表,以及一个std::list,其中保存了映射的节点

在图表中,它看起来像这样:

buckets : [][][*][][][][][][*][][][][][][*]
               |            |            |
               |            |            | 
             ---             ------      |
             |                    |      |
             V                    V      V
elements: [1,3]->[5,7]->[7,1]->[8,11]->[10,3]->[-1,2]

现在,当您擦除节点时,它们实际上从列表中删除,但是它并没有提及存储桶数组。当达到某个阈值时(要么是每个存储桶具有过多的元素,要么是存储桶数量过多),将重新调整存储桶数组的大小。

为了证明我的观点,这里是一个使用最新VC ++编译的示例:

std::unordered_map<int, std::vector<char>> map;
for (auto i = 0; i < 1000; i++) {
    map.emplace(i, std::vector<char>(10000));
}

for (auto i = 0; i < 900; i++) {
    map.erase(i);
}

在调试器中查看原始视图,我们可以看到:

+       _List   { size=100 }    std::list<std::pair<int const ,std::vector<char,std::allocator<char> > >,std::allocator<std::pair<int const ,std::vector<char,std::allocator<char> > > > >
+       _Vec    { size=2048 }   std::vector<std::_List_unchecked_iterator<std::_List_val<std::_List_simple_types<std::pair<int const ,std::vector<char,std::allocator<char> > > > > >,std::_Wrap_alloc<std::allocator<std::_List_unchecked_iterator<std::_List_val<std::_List_simple_types<std::pair<int const ,std::vector<char,std::allocator<char> > > > > > > > >

这意味着,尽管我们只有100个元素,但该映射保留了2048个桶。

因此,当您删除元素时,并非所有内存都被释放。该映射维护另一个内存部分以记录桶本身,而该内存比元素内存更加顽固。

编辑:
让我们再来更加疯狂一些!

std::unordered_map<int, std::vector<char>> map;
for (auto i = 0; i < 100'000; i++) {
    map.emplace(i, std::vector<char>(10000));
}

for (auto i = 0; i < 90'000; i++) {
    map.erase(i);
}

擦除循环结束时的结果如下:
+       _List   { size=10000 }  std::list<std::pair<int const ,std::vector<char,std::allocator<char> > >,std::allocator<std::pair<int const ,std::vector<char,std::allocator<char> > > > >
+       _Vec    { size=262144 } std::vector<std::_List_unchecked_iterator<std::_List_val<std::_List_simple_types<std::pair<int const ,std::vector<char,std::allocator<char> > > > > >,std::_Wrap_alloc<std::allocator<std::_List_unchecked_iterator<std::_List_val<std::_List_simple_types<std::pair<int const ,std::vector<char,std::allocator<char> > > > > > > > >

现在,在64位操作系统上,std::_List_unchecked_iterator<...> 的大小为8字节。我们有262144个这样的迭代器,所以我们持有262144*8/(1024*1024) = 2MB 几乎没用的数据。 这就是你看到的高内存使用率
在所有多余的节点被删除后调用 map.rehash(1024*10) 似乎有助于减少内存消耗。
+       _List   { size=10000 }  std::list<std::pair<int const ,std::vector<char,std::allocator<char> > >,std::allocator<std::pair<int const ,std::vector<char,std::allocator<char> > > > >
+       _Vec    { size=32768 }  std::vector<std::_List_unchecked_iterator<std::_List_val<std::_List_simple_types<std::pair<int const ,std::vector<char,std::allocator<char> > > > > >,std::_Wrap_alloc<std::allocator<std::_List_unchecked_iterator<std::_List_val<std::_List_simple_types<std::pair<int const ,std::vector<char,std::allocator<char> > > > > > > > >

这就是您寻找的解决方案。

(PS: 最近我一直在做 .NET,但这个问题真正展示了 C++ 的优点:我们可以使用调试器进入标准库代码,确切地看到事情发生的时间和方式,之后可以相应地采取行动。在 .NET 中这样做将是一场噩梦,如果可能的话。)


David,正如我在问题中所述,我已经修改了STL unordered_map实现以缩小桶版本,但它并没有起作用。除此之外,你的帖子在描述unordered_map实现方面是100%正确的。 - kreuzerkrieg
@kreuzerkrieg,“shrink_to_fit”与您的问题无关,因为buckets向量实际上使用了内存。“shrink_to_fit”从向量末尾删除多余的容量。在我们的例子中,如果向量有2048个桶,但只有100个元素,“shrink_to_fit”将无法帮助。 - David Haim
这段代码与 shrink_to_fit 有关,你误以为重新哈希会改变一些东西。它确实会改变大小,但不会改变容量。在 reshash 函数和 _Init(_Newsize); 中设置断点,你会发现它只是调整了向量的大小和保留空间,这两者都不会影响向量的内部容量,除非新的大小大于之前分配的大小。所以实际上你浪费了所有这些指针来填充 capacity - kreuzerkrieg
@kreuzerkrieg 1000010000/(10241024) = 95MB。此外,如果您的元素是活动的,那么您不可能期望它们的内存是不可见的。另外,桶数组每次乘以2,我只能猜测如果您放置大量元素(几百万个),内存消耗将会更加明显。 - David Haim
怎么回事?你放了10万个大小为10k的元素,应该是953Mb。此外,我的元素不是活的,它是shared_ptr,当我将其放置在容器中时,引用计数增加到1,从容器中删除后则减少到零,然后shared_ptr的删除器应该启动。 - kreuzerkrieg
显示剩余2条评论

3
假设你删除了一半的元素,那么你期望释放一半的内存。对吗?
实际上并不是这样。我希望内存分配器是根据我的程序执行效率来编写的。我希望它分配比所需更多的内存,并仅在被命令或确定该内存永远不会再次需要时将该内存释放回操作系统。
我希望尽可能经常地在用户空间重复使用内存块,并且它们是以连续的块分配的。
对于大多数应用程序来说,一个过于严谨的内存分配器,它从操作系统中分配内存并在对象被销毁时立即返回,会导致程序运行缓慢,并且会出现大量磁盘抖动。在实践中,这也意味着在所有流行的操作系统上,即使是最小的40字节字符串也将分配自己的4k页面,因为英特尔芯片组只能以这个大小的页面(或者某些系统上更大?)处理保护内存。

正确,这就是它应该工作的方式,但你仍然期望服务器应用程序在运行24/7的时候不时地释放内存,因为数据工作集没有增长,它不能永远增长。据我所知,在MSVC中,malloc执行分配块和管理内存子块的魔术,这与我使用自定义分配器时观察到的情况相矛盾,后者使用直接的操作系统调用进行分配/释放内存,尽管效率低下,但它被编写来检查是否是malloc的问题。 - kreuzerkrieg
@kreuzerkrieg 在一个内存管理系统中,实际上没有必要将内存释放给操作系统。你所分配的是虚拟页面,当不需要时,它们会被操作系统交换出去。你真正消耗的资源只有磁盘空间。 - Richard Hodges
然后你会遇到页面错误,延迟飙升,这不是服务器应用程序想要的。除了操作系统的问题,为什么如果你使用vector而不是unordered_map并开始弹出元素,内存会被释放回操作系统? - kreuzerkrieg

2
好的,在向微软开通高级支持工单后,我得到了以下回答。大部分内容我们已经知道了,但有一点是我们没有考虑到的。
  1. 在Windows中,内存以页面的形式在堆中分配。
  2. 在STL中,没有任何缓存,我们在调用erase之后直接调用RtlHeapFree。
  3. 你所看到的就是Windows如何管理堆。
  4. 一旦你标记某个东西进行删除,即使没有内存压力,它也可能不会被返回给操作系统,它可能会决定将来重新分配内存的成本比保留在进程中更高。
  5. 这就是任何堆算法的工作方式。
  6. 另一个需要考虑的问题是:如果你要删除的值恰好分布在多个页面上,并且除非页面中的所有值都为空,否则它将驻留在内存中。
  7. 如果你非常关注私有字节的立即减少,你可能需要编写自己的内存管理器,而不是依赖于Windows堆句柄。
重点是我的。我想这个回答解决了问题,或者问题就是“这就是Windows堆管理的工作方式”。无论哪种情况,这个问题没有(简单的)解决方案,也许最好使用像boost::intrusive容器这样的东西,理论上应该提供更好的局部性,这样Windows内存管理器就有更好的机会将内存返回给操作系统。
更新001:Boost intrusive容器也没有解决问题。
struct Entity : public boost::intrusive::unordered_set_base_hook<>
{
    explicit Entity(size_t id)
    {
        first = id;
        _6 = std::string("a", dis(gen));
        _7 = std::string("b", dis(gen));
        for(size_t i = 0; i < dis(gen); ++i)
        {
            _9.emplace(i, std::string("c", dis(gen)));
        }
    }

    size_t first = 1;
    int _1 = 1;
    int _2 = 2;
    float _5 = 3.14f;
    double _3 = 3;
    double _4 = 5;
    std::string _6 = "hello world!";
    std::string _7 = "A quick brown fox jumps over the lazy dog.";
    std::vector<unsigned long long> _8 = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0};
    std::map<long long, std::string> _9 = {{0, "a"}, {1, "b"}, {2, "c"}, {3, "d"}, {4, "e"},
                                           {5, "f"}, {6, "g"}, {7, "h"}, {8, "e"}, {9, "j"}};
    std::vector<double> _10{1000, 3.14};
    std::random_device rd;
    std::mt19937 gen = std::mt19937(rd());
    std::uniform_int_distribution<size_t> dis = std::uniform_int_distribution<size_t>(16, 256);
};

struct first_is_key
{
    typedef size_t type;

    const type& operator()(const Entity& v) const { return v.first; }
};

using Container = boost::intrusive::unordered_set<Entity, boost::intrusive::key_of_value<first_is_key>>;

void printContainerInfo(const Container& container)
{
    std::cout << std::chrono::system_clock::to_time_t(std::chrono::system_clock::now())
              << ", Size: " << container.size() << ", Bucket count: " << container.bucket_count() << std::endl;
}

int main()
{
    constexpr size_t maxEntites = 100'000;
    Container::bucket_type* base_buckets = new Container::bucket_type[maxEntites];
    Container test(Container::bucket_traits(base_buckets, maxEntites));

    std::random_device rd;
    std::mt19937 gen(rd());
    std::uniform_int_distribution<size_t> dis;

    while(test.size() < maxEntites)
    {
        auto data = new Entity(dis(gen));
        auto res = test.insert(*data);
        if(!res.second)
        {
            delete data;
        }
    }

    printContainerInfo(test);
    while(test.size() > 0)
    {
        while(test.size() > maxEntites * 2 / 3)
        {
            test.erase_and_dispose(test.begin(), [](Entity* entity)
                                   {
                                       delete entity;
                                   });
        }

        printContainerInfo(test);
        while(test.size() < maxEntites)
        {
            auto data = new Entity(dis(gen));
            auto res = test.insert(*data);
            if(!res.second)
            {
                delete data;
            }
        }
    }
    return 0;
}

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