How std::make_shared Allocates Memory in C++

This article explores the internal mechanics of std::make_shared in the C++ Standard Library, focusing on how it consolidates heap allocations. While creating a std::shared_ptr directly from a raw pointer requires two distinct heap allocations, std::make_shared optimizes this process by allocating the managed object and its associated control block inside a single contiguous memory segment. The following sections detail this memory layout, the construction mechanism, and the resulting performance implications.

The Standard Allocation Problem

When you initialize a std::shared_ptr using a raw pointer, such as:

std::shared_ptr<Widget> ptr(new Widget());

Two separate dynamic memory allocations take place:

  1. The new Widget() expression invokes operator new to allocate memory specifically for the Widget instance.
  2. The std::shared_ptr constructor invokes operator new a second time to allocate an internal control block. This control block maintains the strong reference count, the weak reference count, and auxiliary data such as custom deleters or allocators.

This dual-allocation pattern introduces heap fragmentation, doubles allocator overhead, and harms cache locality because the control block and the target object reside in unrelated memory addresses.

The std::make_shared Contiguous Architecture

std::make_shared resolves the dual-allocation issue by combining the control block and the managed object into a unified data structure.

Internally, standard library implementations define a combined internal structure conceptually structured as follows:

template <typename T>
struct SpControlBlockWithStorage {
    // Standard control block members
    std::atomic<long> strong_ref_count;
    std::atomic<long> weak_ref_count;
    // ... other control metadata ...

    // Properly aligned storage for the managed object
    alignas(T) unsigned char storage[sizeof(T)];
};

Instead of performing two separate allocations, std::make_shared calculates the total size needed for both the control metadata and the object type T, accounting for proper memory alignment. It then issues a single call to the allocator:

void* memory = ::operator new(sizeof(SpControlBlockWithStorage<T>));

In-Place Construction via Placement New

Once the single contiguous block of raw memory is obtained, std::make_shared initializes the components sequentially:

  1. Control Block Initialization: The control block members (such as strong_ref_count initialized to 1 and weak_ref_count initialized to 1) are set up at the beginning of the memory block.
  2. Object Construction: The user-provided arguments are forwarded to the constructor of T using perfect forwarding (std::forward<Args>(args)...). The object is constructed directly inside the designated offset of the contiguous memory block via placement new:
::new (static_cast<void*>(storage_address)) T(std::forward<Args>(args)...);

Finally, the std::shared_ptr instance is constructed by setting its internal pointer to point directly to the newly constructed T and its internal control block pointer to the base of the allocated segment.

Advantages of Contiguous Allocation

  • Halved Allocation Overhead: Performing one heap allocation instead of two significantly reduces dynamic memory management latency and reduces heap bookkeeping overhead.
  • Improved Cache Locality: Because the reference counts and the object data reside adjacent to one another in memory, accessing the object immediately after manipulating reference counts is more likely to result in a CPU cache hit.
  • Strict Exception Safety: Constructing the object and smart pointer in one unified function prevents memory leaks that could occur if an exception was thrown between raw memory allocation and smart pointer construction.

The Weak Pointer Trade-Off

While contiguous allocation offers clear performance benefits, it introduces a trade-off regarding memory deallocation when std::weak_ptr is used.

In a traditional two-allocation setup, the object's memory is freed as soon as the strong reference count drops to zero, even if weak pointers still point to the control block. With std::make_shared, because the object and control block occupy the same contiguous block of memory, the underlying memory cannot be released back to the heap until both the strong reference count and the weak reference count reach zero. The destructor of T runs immediately when the strong count hits zero, but the memory footprint of sizeof(T) remains allocated until all associated std::weak_ptr instances are destroyed.