Memory Allocation Techniques in Modern C++

C++ has always been regarded as a high-performance programming language, in no small part because its memory handling capabilities are close to the machine hardware. Modern C++ continues this tradition with additional abstractions for memory allocation and access, allowing the Standard Library to provide advanced memory management facilities which are easy to use while still being powerful and performant. This article aims to provide an overview of the ways in which available memory can be allocated and used in Modern C++.

Automatic Variables

Variables allocated a runtime-provided address in the (function call) stack frame are sometimes known as “automatic” variables, as memory allocation and deallocation for them is automatic with growth and shrinkage of the stack. (This is the origin of the auto keyword as once available in the C language, and reimagined as we know it for Modern C++.)

The reason for mentioning them in an article about memory management techniques is to give the advice: Always use automatic variables unless you have a compelling reason or requirement not to. Again, this advice may sound obvious, but to those with coding experience in a language like Java with automated garbage collection when using new, it may be necessary advice.

While the memory allocation for automatic variables is associated with function call and return, their lifetime and visibility is connected to the scope in which they are defined. This inescapable fact is the reason RAII (Resource Acquisition Is Initialization) classes can be created, such as the Standard Library’s file handling classes:

The main motivation driving RAII classes is that early function return and/or an exception being thrown can cause early exit from a (sub-)scope, which can often be difficult to detect or handle. RAII classes typically “acquire” (take ownership of) resource(s) in their constructor(s), and “release” (destroy) them in their destructor. This idiom ensures that uninitialized or expired objects cannot be in existence, and also that resource leaks (orphaned resources) do not occur. Being averse to copying for this reason, they are typically “move-only” classes.

When allocating Standard Containers on the stack, you should be aware when they are likely to need to allocate heap memory too:

void g() {
    std::vector<int> vi(10); // allocate 10 * sizeof(int) bytes on heap, plus sizeof(std::vector<int>) bytes on stack
    std::string s1{ "Great Expectations, a Novel by Charles Dickens." }; // heap allocation for contents, plus sizeof(std::string) on stack
    std::array<double,4> ad{ 4, 3, 2 ,1 }; // use stack memory exclusively, no heap allocation
    std::string s2{ "Magwitch" }; // assume SSO (Small String Optimization), no heap allocation
}

Finally, there is the non-Standard void *alloca(size_t n) function of C-origin, which grabs n bytes from the stack frame and returns a pointer to this memory. It can be assumed that this memory is reclaimed when the function exits, so no pointers to any location within this block can be returned from the function. The obtained memory could be used for a C++ object by using “placement new”, with the caveat that a corresponding “placement delete” should also be employed.

Raw Heap Allocation

Single objects and arrays of objects/built-in types can be allocated on the heap by the new operator, and released with the delete operator:

struct MyType { /* ... */ };

void f() {
    auto ptr1 = new MyType{};    // default-constructed object on heap
    auto ptr2 = new int[ 10 ];   // array of 10 ints on heap
    auto ptr3 = new MyType[ 5 ]; // array of 5 default-constructed objects on heap
    // ...
    delete[] ptr3; // always use the correct form (with or without [])
    delete[] ptr2; // in order to prevent memory leaks
    delete ptr1;   // assume destructor ~MyType() is always called
}

There is nothing to prevent programmers from using malloc() and free() from the C Standard Library, however memory acquired by new can be safely passed to C API functions expecting a pointer to a block of raw memory, so this is unlikely to be necessary. (It would be up to the caller function to deallocate this memory with either delete or delete[]).

Smart Pointers

So-called “naked new and delete” are less common in Modern C++ due to the availability of “smart pointers” in the Standard Library, which are essentially RAII classes for heap memory. In Modern C++ there are three pointer types, each of which can point to a single object, or to an array of objects. Operator new is required to claim the heap memory (and initialize the object, in the case of single object), unless the make_ function template is employed:

struct MyType {
    MyType(int i); // Constructor accepting an "int" parameter
    // ...
};

void g() {
    auto ptr1 = std::unique_ptr{ new MyType(42) }; // allocate non-default-initialized scoped object on heap
    auto ptr2 = std::make_unique<MyType>(42);      // identical effect to above
    auto ptr3 = std::move(ptr1);                   // ptr3 points to a valid scoped object, ptr1 is nullptr
    auto ptr4 = std::shared_ptr{ new MyType(42) }; // allocate non-default-initialized shared object on heap
    auto ptr5 = std::make_shared<MyType>(42);      // identical effect to above
    auto ptr6 = ptr4;                              // ptr6 and ptr4 point to the same heap object
    auto ptr7 = std::unique_ptr<MyType[]>{ new MyType[ 10 ] }; // scoped array of default-initialized objects
    auto ptr8 = std::shared_ptr<MyType[]>{ new MyType[ 10 ] }; // shared array of default-initialized objects
    // note: there is also std::weak_ptr<> which breaks std::shared_ptr cycles
} // all ptrs have destructors automatically called here

Smart pointers have a number of useful member functions defined, such as operator[] for the array specializations, operator* and operator-> for single object pointers, and reset() to change ownership of the resource, or deallocate it early with ptr.reset(nullptr).

Exclusive resource ownership is provided by class std::unique_ptr and this is a move-only class. Shared (reference-counted) resource ownership is provided by class std::shared_ptr and instances can be copied as well as moved. Non-owning pointers can be created as std::weak_ptr instances; these can be checked for expiry and, if still valid, turned into a std::shared_ptr for access.

Custom Allocators

A little known fact about the Standard Containers is that they all have a second template parameter, which is by default std::allocator<T>; thus std::vector<int> is in fact std::vector<int,std::allocator<int>>. Possibly even less-widely known is that this stems from the requirement of 16-bit DOS Standard Library implementations to allow for NEAR, FAR and HUGE pointers.

Allocators cannot have state, which would appear to lessen their usefullness and flexibility somewhat, however this is necessary to ensure that all types using std::allocator<T> can be considered equivalent. Template parameters, in particular non-type template parameters, could be employed with static members to get around this restriction:

template<int ID,typename T,std::size_t N>
class MyAllocator {
    static void* pool{ nullptr };
    static std::bitset<N> freeSpaceMap;
    static std::size_t referenceCount{ 0 };
public:
    typedef T value_type;
    MyAllocator() { if (!referenceCount++) { pool = ::operator new(N * sizeof(T)); } }
    template<int ID1,typename T1,std::size_t N1>
    MyAllocator(const MyAllocator<ID1,T1,N>& U) : MyAllocator() {}
    ~MyAllocator() { if (!--referenceCount) { ::operator delete(pool); pool = nullptr; } }
    T* allocate(std::size_t n) {
        // find n consecutive 0s in freeSpaceMap and set them to 1
        // return offset within pool
    }
    void deallocate(T *p, size_t n) {
        // call destructor on p
        // set n consecutive 1s at correct offset to 0
    }
};

template<int ID1,typename T1,std::size_t N1,int ID2,typename T2,std::size_t N2>
bool operator==(const MyAllocator<ID1,T1,N1>&, const MyAllocator<ID2,T2,N2>&) noexcept {
    return std::is_same_v<T1,T2> && (N1 == N2) && (ID1 == ID2);
}

template<typename T>
using CustomAlloc0_16 = MyAllocator<0,T,16>;

template<typename T>
using CustomAlloc1_16 = MyAllocator<1,T,16>;

void f() {
    std::vector<int,CustomAlloc0_16<int>> vi1{ 5, 4, 3 ,2 }; // vi1 and vi2 use fixed-size memory allocation
    std::vector<int,CustomAlloc1_16<int>> vi2{ 2, 3, 4 ,5 }; // from different regions with maximum size 16
    // note: vi1 and vi2 cannot be reassigned to each other as they have different allocator types
}

An extension available to allocator classes which is available in C++17 onwards is to use “polymorphic memory allocators” as defined in header <memory_resource>, which provide a polymorphic interface for memory management. This allows custom memory allocation strategies with STL containers using minimal boilerplate; allocator classes provided in the Standard are synchronized_pool_resource, unsynchronized_pool_resource and monotonic_buffer_resource:

#include <memory_resource>

std::pmr::monotonic_buffer_resource pool1;
std::pmr::monotonic_buffer_resource pool2;

void g() {
    std::pmr::vector<int> vec1{ &pool1 };
    vec1.assign({ 0, -1, -2, -3});
    std::pmr::vector<int> vec2{ &pool2 };
    vec2 = vec1; // assign from region within different memory area
}

Customizing new and delete

It is possible to provide your own global new and delete operators, and also to specialize them on a per-class basis. Be aware that delete should be noexcept, and that new should have throwing and std::nothrow variants in each case. Per-class implementations can also have their own placement new and placement delete.

Calls to new (of any form) should call a “new handler” in case of failure due to memory exhuastion. By default this throws an exception of type std::bad_alloc, but a Standard Library function set_new_handler() exists to allow for the possibility of further allocation of memory (without modifying the global operators). The “new handler” is called repeatedly until it has allocated sufficient memory or throws an exception (which must derive from std::bad_alloc).

Pool and Arena Allocators

Pool allocators typically allocate same-sized objects from a fixed-size buffer; these restrictions allow allocations to be made as fast as possible. The interface can be user-defined as it is unlikely that Standard Container classes will be used with it.

Arena allocators typically allocate from a fixed-size buffer which can be destroyed in one operation, thus implicitly destroying all of the objects contained within it. Arena allocators can allocate from a “special” region of memory, which may be connected to the expected lifetime of the objects allocated within it.

Other Allocators

Garbage collection is prevalent on other platforms, and is available for C++ too. If profiling your code reveals too much time is being spent in new/delete, then you could consider a drop-in GC.

Sliding heap allocators are a way to prevent heap fragmentation, as the assigned objects are coalesced from time-to-time. This may be time-consuming however, and the new locations must be correctly written into all references to the relocated objects.

Conclusion

That wraps things up for this article on memory allocation in C++. Maybe you have gained some ideas about how to perform safe memory-managment in Modern C++, or learned about some new terms and methodologies. Regular readers may have noticed a bit of a gap since the last article; the current intention is to continue to make regular blog posts. If you have any ideas for new articles please contact me.

Leave a comment