Intrusive vs Non-Intrusive Reference Counting in C++
This article examines the core differences between intrusive and
non-intrusive reference counting in C++. It breaks down how both memory
management strategies function under the hood, compares their memory
overhead and performance characteristics, and highlights the trade-offs
in flexibility, safety, and API design when choosing between standard
solutions like std::shared_ptr and intrusive alternatives
like boost::intrusive_ptr.
Fundamentals of Reference Counting
Reference counting is a memory management technique where an object tracks the number of references pointing to it, automatically deallocating its resources when the count drops to zero. In C++, reference counting is implemented primarily through two paradigms: intrusive and non-intrusive.
Non-Intrusive Reference Counting
In non-intrusive reference counting, the reference counter is stored
externally to the managed object. The managed class itself has no
awareness of how its lifetime is tracked. The standard C++
implementation of this pattern is std::shared_ptr.
- Control Block Allocation: When creating a
std::shared_ptr, an auxiliary structure called a control block is created. This block contains the strong reference counter, the weak reference counter, allocators, and custom deleters. - Pointer Size: A
std::shared_ptris typically two pointers in size (16 bytes on a 64-bit architecture): one pointer points to the managed object, and the other points to the control block. - Third-Party Compatibility: Because the counter resides externally, non-intrusive smart pointers can wrap any type, including standard library types, primitive types, and legacy third-party library classes, without requiring source code modifications.
Intrusive Reference Counting
In intrusive reference counting, the reference counter is embedded
directly inside the managed object. The object itself provides functions
to increment and decrement this internal count. A prominent
implementation is boost::intrusive_ptr.
- Memory Layout: The object and the counter share the same memory allocation. No external control block is required.
- Pointer Size: An intrusive smart pointer holds only a single raw pointer to the object (8 bytes on a 64-bit architecture), making it identical in size to a native C++ raw pointer.
- Class Requirements: The managed class must be
modified to include the counter and the necessary management logic
(typically
intrusive_ptr_add_refandintrusive_ptr_releasefunctions), often accomplished via inheritance from a base class.
Key Architectural Differences
1. Memory Overhead and Cache Locality
- Non-Intrusive: If not using
std::make_shared, wrapping a raw pointer in astd::shared_ptrrequires two separate dynamic allocations: one for the object and one for the control block. Even withstd::make_shared(which allocates the object and control block in one contiguous block), accessing the control block requires traversing an extra pointer indirection if the object pointer is manipulated separately. Furthermore, withstd::make_shared, the memory for the object cannot be deallocated until allstd::weak_ptrinstances are destroyed, because the weak count resides in the same memory block. - Intrusive: The object and its counter are always co-located in the same cache line or memory block. Memory deallocation happens immediately when the reference count reaches zero, regardless of any other systems tracking the instance.
2. Reconstructing Smart Pointers from Raw Pointers
- Non-Intrusive: Creating a
std::shared_ptrfrom a raw pointer creates a new control block. If anotherstd::shared_ptralready owns that object, two separate control blocks will independently manage the same memory, inevitably causing a double-free crash. To safely obtain astd::shared_ptrfrom inside a member function, the class must inherit fromstd::enable_shared_from_this. - Intrusive: Constructing an intrusive smart pointer
from a raw pointer or from
thisis completely safe at any time. The constructor simply increments the internal counter embedded within the target object, eliminating double-free risks stemming from raw pointer conversions.
3. Interoperability with C APIs
- Non-Intrusive: Passing objects managed by
std::shared_ptrto C-style APIs that require a rawvoid*context pointer requires storing or leaking thestd::shared_ptrelsewhere to keep it alive. Reconstructing the smart pointer inside a C callback is error-prone. - Intrusive: Intrusive objects can be cast directly to raw pointers and passed to C callbacks. Inside the callback, the raw pointer can be safely wrapped back into an intrusive smart pointer, or the ref-count functions can be invoked manually from C-compatible wrappers.
Summary Comparison
| Feature | Non-Intrusive
(std::shared_ptr) |
Intrusive
(boost::intrusive_ptr) |
|---|---|---|
| Counter Location | External control block | Inside the object |
| Smart Pointer Size | 2 words (typically 16 bytes) | 1 word (typically 8 bytes) |
| Type Requirements | None (works with any type) | Must define internal counter logic |
Conversion from this
/ Raw |
Requires
enable_shared_from_this |
Inherently safe and direct |
| Weak Pointer Support | Built-in via
std::weak_ptr |
Requires custom implementation |
| Standardization | C++ Standard Library
(<memory>) |
External (e.g., Boost) or custom |