Why std::make_shared Delays Memory Deallocation
Using std::make_shared in C++ combines the allocation of
a managed object and its reference control block into a single
contiguous chunk of heap memory to boost performance and reduce
allocation overhead. However, this optimization couples the lifetime of
the underlying memory to both strong and weak reference counts. While an
object’s destructor executes as soon as its strong reference count drops
to zero, the actual memory block cannot be released back to the
allocator until every associated std::weak_ptr is also
destroyed, which can lead to significant memory retention issues when
long-lived weak pointers persist.
The Anatomy of Shared Pointers
To understand this behavior, it is necessary to examine how
std::shared_ptr manages resources. A shared pointer setup
relies on two key components: the managed object itself and a control
block. The control block stores essential metadata, including:
- The strong reference count (the number of active
std::shared_ptrinstances owning the object). - The weak reference count (the number of active
std::weak_ptrinstances observing the object, plus one while the strong count is greater than zero). - Custom deleters and allocators, if provided.
Separate Allocation vs.
std::make_shared
When creating a shared pointer using direct initialization, such as
std::shared_ptr<T>(new T()), two distinct dynamic
allocations occur. The new T() expression allocates the
object on the heap, and the std::shared_ptr constructor
allocates a separate control block.
When the strong count reaches zero:
- The object's destructor
~T()is invoked. - The memory allocated for
Tis immediately deallocated viaoperator delete. - The control block remains in memory until the weak reference count also reaches zero, at which point the control block's memory is separately deallocated.
In contrast, std::make_shared<T>() performs a
single dynamic allocation that encompasses space for both the control
block and the T instance side by side.
Why Memory Deallocation Is Delayed
Because std::make_shared allocates the object and the
control block in one contiguous buffer, the C++ runtime can only execute
a single deallocation call for the entire chunk.
When the last std::shared_ptr pointing to an object
created with std::make_shared is destroyed:
- The strong count drops to zero.
- The runtime explicitly calls the destructor
~T()on the object. - Any resources held by
Tinternally (such asstd::vectorbuffers, file handles, or nested pointers) are released. - However, the raw memory footprint occupied by
sizeof(T)cannot be returned to the heap because doing so would also invalidate the adjacent control block.
std::weak_ptr relies on the control block remaining
valid so it can inspect the strong reference count and safely determine
whether the object has expired via weak_ptr::expired() or
weak_ptr::lock(). Consequently, the entire combined memory
block must remain allocated until the last std::weak_ptr
goes out of scope and the weak count reaches zero.
Practical Implications and Solutions
This delayed deallocation becomes problematic when the managed type
T has a large memory footprint directly embedded within its
layout (for example, a large static array member like
struct HugeData { char buffer[10 * 1024 * 1024]; };) and
weak pointers are retained over long periods, such as in caching layers,
observer registries, or cyclic-reference breakers. Even though
HugeData is destroyed, its 10-megabyte footprint lingers in
RAM alongside the control block.
When handling large objects observed by long-lived weak pointers, you
should bypass std::make_shared and use explicit separate
allocations:
// Allows immediate memory deallocation of HugeData when strong count reaches zero:
std::shared_ptr<HugeData> ptr(new HugeData());This trade-off introduces a second heap allocation during creation but guarantees that the object’s memory footprint is reclaimed immediately when its lifetime ends, regardless of persisting weak pointers.