Year-End Discount: 10% OFF 1-year and 20% OFF 2-year subscriptions!

Home/Blog/Uncovering the power of memory management in C++

Uncovering the power of memory management in C++

Mar 10, 2022 - 12 min read
Fahim ul Haq
editor-page-cover

Before co-founding Educative, I worked at Facebook and Microsoft. Leveraging the C++ language was the main focus of my time there. I learned C++ early in my programming career. However, it wasn’t until my role at Microsoft that I really understood C++ memory management, and all the power that comes with it.

Memory management in C++ is incredibly powerful for optimizing performance, especially when it comes to large applications. As we move into a future of increasingly distributed systems, skills such as memory management will have a growing demand. Whether you’re an experienced programmer, or you’re learning C++ as your first programming language, knowing how to manage memory in C++ can open the door to several opportunities in your programming career.

Today, we’ll discuss the benefits of memory management in C++, and introduce you to the building blocks of C++ memory management.

We’ll cover:

Get hands-on with C++ today.

Try one of our 300+ courses and learning paths: C++ for Programmers.

A case for learning memory management in C++

While at Microsoft, my work was centered on leveraging C++ memory management to optimize performance for large distributed applications. In my experience, I found that manipulating the buffer in memory helped shave off tens of milliseconds from runtime, especially for distributed memory-intensive applications.

Beginner developers might mistake milliseconds as negligible units of time. However, in many cases, a good user experience requires a highly responsive application. Take the example of gameplay, for instance, where an unresponsive application can break immersion and ruin the gaming experience.

It’s no coincidence that C++ programs are used for various applications and industries wherein high performance is critical:

  • Air and space travel
  • Life-saving medical equipment
  • Game development

Prioritizing performance not only benefits users, but businesses as well. As the performance gain accumulates with each use of an application, businesses can accrue significant savings and reduce the total compute resources needed.

More and more development is moving to cloud-based and distributed systems. Learning to optimize performance with memory management serves to benefit much more than just local applications. In a distributed system, it means you’re also helping optimize the performance of every machine and component in the system. By learning memory management in C++, you can position yourself as next-gen engineer who can contribute to highly performant scalable systems in an increasingly distributed future.

A brief introduction to memory management

I’ll start with a quick overview on memory management for the uninitiated.

Memory management oversees how a program consumes computer memory. During execution, every computer program uses main memory (i.e. RAM) to store temporary variables, data structures, and so on. Managing memory consumption involves both memory allocation and memory deallocation. Memory allocation is when a portion of main memory is allocated by the program’s request. Memory deallocation frees the memory that’s no longer needed by the program.

A programming language may provide one of two approaches to memory management:

  • Automatic memory management (e.g. Java, Python, C#)
  • Dynamic memory management (e.g. C++, C)

C++ supports dynamic memory management, which means you as the programmer are responsible for allocating and deallocating memory. On the other hand, automatic memory management means the programming language automates this process by performing memory allocation and deallocation for you.

Many programmers are comfortable staying in the realm of automatic memory management. It certainly has benefits, such as reducing development time and eliminating the risk of memory-related bugs. However, automatic memory management has higher memory requirements. This is mainly because memory deallocation is done for you by a program called the garbage collector, which consumes both memory and CPU cycles. This is why automatic memory management can negatively impact application performance, especially for large applications with limited resources.

While it results in a longer development time, dynamic memory management empowers you to tailor your application’s memory consumption and build highly performant applications. Dynamic memory management is the only plausible choice when you’re dealing with resource-constrained machines such as embedded devices. It’s also valuable for keeping performance high in a real-time system, which is why C++ is used often in game development. The C++ language then becomes a strong choice for situations where performance and small memory footprints are requirements.

I understand that many people still hesitate to learn dynamic memory management in C++. Other than the learning curve, there’s a real risk that comes with using incorrect techniques, which can result in bugs such as memory leaks (which we’ll discuss shortly). In some cases, the errors could lead to even worse outcomes. However, there’s no need to avoid learning this valuable skill. The C++ language has implemented several guardrails and safety measures to help reduce the risks that can result from manipulating hardware. With enough practice, you can learn to safely leverage memory management to speak directly to computer hardware and build highly performant applications.

Getting started with C++ memory management

Basics of the C++ memory model

Every memory word (or block) is commonly made of two, four, or eight bytes, depending upon your hardware architecture. We can refer to a block in our C++ program using its numerical address. The address of the first block is 0, whereas the address of the last block depends on the size of your computer memory. The figure below depicts a block of memory.

memory block


In C++, we can divide a program’s memory into three parts:

  1. Static region, wherein static variables are stored. Static variables are variables that remain in use throughout the execution of a program. The size of the static region does not change during the C++ program’s execution.

  2. Stack, wherein stack frames are stored. A new stack frame is created for every function call. A stack frame is a frame of data that contains the corresponding function’s local variables and is destroyed (popped) when that function returns.

  3. Heap, wherein dynamically allocated memory is stored. To optimize memory utilization, heap and stack typically grow towards each other, as illustrated in the following figure.

heap and stack


For the remainder of our discussion, we’ll focus on memory allocation and deallocation on the heap.

In C++, a block of memory refers to a contiguous array of bytes, where each byte has a unique address.

We can perform memory management in C++ with the use of two operators:

  • new operator to allocate a block of memory on heap
  • delete operator to deallocate a block of memory from heap

In the following code example, we use our two operators to allocate and deallocate memory:

#include <iostream>
int main() {
// a pointer to integer
int *ptr;
//Allocates memory for an integer
ptr = new int;
//Assigns value to newly allocated int
*ptr = 5;
//Prints the value of int
std::cout << "\n\n\tint value=" << *ptr;
//Prints memory location, where int is stored
std::cout << "\n\n\tint stored at address=" << ptr << "\n\n";
//Deallocate memory reserved for the int (to avoid memory leaks)
delete ptr;
return 0;
}
Memory is allocated and deallocated with new and delete operators

We’ll now examine what’s happening in the previous code:

  1. new operator reserves a memory location that may store a C++ integer (i.e. 4 bytes). Subsequently, it returns the newly allocated memory address.
  1. We create a pointer, ptr, to store the memory address returned by the new operator.

new operator

  1. We save an integer value on the newly allocated memory address using *ptr=5.

  1. We print the memory address where the integer is stored and the integer value stored at that memory location.
  1. Finally, we deallocate the block of memory reserved by new using the delete operator.

pointer delete operator

Safely leveraging memory management in C++

The use of new and delete operators will require some caution. They come with a risk of possible memory bugs. However, we can use smart pointers to help us perform memory management more safely.

Common memory management bugs

There are two common coding bugs that we can encounter with dynamic memory management: Memory leaks and segmentation fault.

Memory leaks occur when memory isn’t deallocated, even after it is no longer required. This could lead to the program running out of the maximum memory available to it.

#include <iostream>
void memLeak() {
// Pointer to integer
int *ptr;
//Allocates memory for an integer
ptr = new int;
//Memory is not deallocated here (as the following line is commented)
//delete ptr;
}
int main() {
memLeak();
//Pointer (ptr) is no longer accessible
//but memory is still allocated for an int.
return 0;
}

In this code example, the Function memLeak() allocates memory, but that memory is not deallocated. Once the function is returned, the allocated memory is still in use, even after it isn’t accessible.

Segmentation fault is another well-known dynamic memory management bug. This bug occurs when a program accesses a memory location that is neither allocated to it nor in the address space of the program. Address space refers to the region of memory where a program is allowed to allocate memory.

The following program generates the segmentation fault as soon as it runs out of address space:

#include <iostream>
void segFault() {
// A pointer to integer
int *ptr;
// Allocating memory for an integer.
ptr = new int;
while(true) {
//The following will throw a segmentation fault
//when we run out of the program's address space.
*(ptr++) = 5;
}
}
int main() {
segFault();
return 0;
}
This code returns segmentation error

Note that ptr++ is incrementing the address stored in the pointer. As the while-loop runs continuously, it will soon point to a location outside of the program’s address space, leading to a segmentation fault. To avoid segmentation faults, we must make sure that a program doesn’t access a memory location that isn’t allocated to it.

Get hands-on with C++ today.

Try one of our 300+ courses and learning paths: C++ for Programmers.

Preventing bugs with smart pointers

C++ provides different kinds of smart pointers. We call these pointers “smart” because they automatically get deallocated without explicit instructions from a programmer or garbage collector. While smart pointers have more performance and memory overhead than classical pointers, they help reduce memory leaks.

We’ll discuss a bit about unique pointers and shared pointers, as well as some limitations of smart pointers.

Unique pointers

Unique pointers, unique_ptr, are scope pointers. As a scope pointer, a unique pointer to a certain object gets automatically deallocated when the pointer goes out of scope.

To provide an example, the following code shows a unique pointer, for an object of MyClass, that is created within an if-block. Thus, the scope of the pointer is the if-block. The pointer is automatically deallocated at the end of the if-block.

#include <iostream>
#include <memory>
class MyClass {
public:
int i;
MyClass() { //Constructor
std::cout<<"\n created\n";
}
~MyClass() { //Destructor
std::cout<<"\n destroyed\n";
}
};
int main() {
if (true) {
//Scope of the following MyClass object is this if-block
std::unique_ptr<MyClass> ptr(new MyClass());
ptr->i = 5;
std::cout<<"\n"<<ptr->i<<"\n";
}
// The pointer gets deallocated automatically at this point.
// Thus, the destructor of MyClass is called here.
return 0;
}

As their name suggests, unique pointers can’t be copied. Copying pointers would create multiple pointers to the same object. When any of those pointers are out of scope, the object would be deleted. The remaining pointers would then no longer point to a valid object (we call these dangling pointers).

In the following figure, std::move switches object ownership from one pointer to another.

std::move ownership pointers

Instead of copying, we can use the std::move function to safely transfer the ownership of the current pointer to another. std::move is depicted in the previous figure. This can be understood from the following code, where we have moved the ownership of a MyClass object from pointer ptr to ptr2. Practice caution: To avoid a segmentation fault, the previous pointer must not be used after the transfer of ownership.

The following code shows how we safely transfer ownership with the std::move function:

#include <iostream>
#include <memory>
class MyClass {
public:
int i;
MyClass() {
std::cout << "\n created\n";
}
~MyClass() {
std::cout << "\n destroyed\n";
}
};
int main() {
if (true) {
//Scope of the following MyClass object is this if-block
std::unique_ptr<MyClass> ptr(new MyClass());
/*Uncommenting the following line will produce an error as
copying of unique pointers is not allowed */
//std::unique_ptr<MyClass> ptr2 = ptr;
//Instead move will safely transfer the object ownership from ptr to ptr2
std::unique_ptr<MyClass> ptr2 = std::move(ptr);
//Must not use the old pointer now.
ptr2->i = 5;
std::cout << "\n" << ptr2->i << "\n";
}
// The pointer gets deallocated automatically at this point.
// Thus, the destructor of MyClass is called here.
return 0;
}
Using the std:move function to transfer ownership

Shared pointers

A shared pointer, std::shared_ptr, uses reference counting for memory deallocation. Unlike unique pointers, a shared pointer allows multiple pointers to point to the same object. A shared pointer keeps a count of each pointer still in scope. Whenever a pointer goes out of scope, the count is decremented. Hence, the object is automatically deleted when the reference count reaches zero.

The following figure depicts a shared pointer with a reference count of two:

shared pointer

The following code shows that shared pointers are similar to unique pointers, except that they allow us to create multiple copies of a pointer and safely delete the object only when all the pointers are out of scope.

#include <iostream>
#include <memory>
class MyClass {
public:
int i;
MyClass() {
std::cout << "\n created\n";
}
~MyClass() {
std::cout << "\n destroyed\n";
}
};
int main() {
if (true) {
//Scope of the MyClass object is this if-block
std::shared_ptr <MyClass> ptr = std::make_shared<MyClass>();
//Copying is allowed with shared pointers. Yes!
std::shared_ptr<MyClass> ptr2 = ptr;
//Can use both pointers without any errors
ptr2->i = 5;
std::cout << "\n" << ptr2->i << ", " << ptr->i << "\n";
}
// The pointer gets deallocated automatically at this point.
// Thus, the destructor of MyClass is called here.
return 0;
}

Limitations of smart pointers

Smart pointers help reduce memory leaks and deallocate memory. However, they don’t completely eliminate the need for us to manually deallocate memory. For instance, in a resource-constrained device, we may need to manually free memory immediately after its last use, even before a pointer is out of scope.

The following code manually deletes a smart pointer:

//Unique pointer is created
std::unique_ptr<MyClass> ptr(new MyClass());
//Some code here to use it.
//. . .
//When we do not need it, we can manually release and delete it.
//After release pointer is not automatically managed
MyClass * raw = ptr.release();
//Manually delete it.
delete raw;

While smart pointers reduce the likelihood of memory leaks, they don’t eliminate them entirely. For instance, if we use cyclic shared pointers, our reference count will never be zero and memory will never be automatically released. In such a situation, we must either avoid smart pointers altogether or resort to manually deallocating the smart pointers.

The following code example demonstrates the use of cyclic pointers, where shared pointers will not be able to deallocate memory automatically:

#include <iostream>
#include <memory>
class Cyclic {
public:
std::shared_ptr<Cyclic> myObj;
int k;
Cyclic(int j) {
k = j;
std::cout << "\n created\n";
}
~Cyclic() {
std::cout << "\n destroyed\n";
}
void setObject(std::shared_ptr<Cyclic> obj) {
myObj = obj;
}
};
int main() {
if (true) {
//First shared pointer
std::shared_ptr <Cyclic> ptr = std::make_shared<Cyclic>(5);
//Second shared pointer
std::shared_ptr <Cyclic> ptr2 = std::make_shared<Cyclic>(6);
//Creating cyclic dependencies.
ptr->setObject(ptr2);
ptr2->setObject(ptr);
}
//Even outside of scope the object continues to exist.
//Thus, destructor is never called and "destroyed" never get printed
return 0;
}

Wrapping up and next steps

Congratulations, you made it this far! I hope that this introduction inspires you to harness the full power of C++ through memory management. While it takes some time to learn, memory management in C++ is an especially valuable skill to know as a programmer, especially as we continue to advance into a future of distributed systems.

To get started with C++ memory management, check out our C++ for Programmers learning path. This path has several tutorials and an inbuilt code editor where you can safely practice writing C++ programs. The learning path consists of six modules, starting with the basics of C++ and advancing into the C++ memory model and memory management techniques. If you’re worried about the risks of making errors, we offer a safe virtual coding environment in which you can practice writing C++ code without the risk of incorrectly manipulating your computer hardware.

Happy learning!

Continue learning about C++


WRITTEN BYFahim ul Haq

Join a community of more than 1.6 million readers. A free, bi-monthly email with a roundup of Educative's top articles and coding tips.