Marcell Ciszek Druzynski
← Back to blog posts

Memory Management in Programming Languages: Understanding Stack vs. Heap

In the world of software development, understanding memory management is crucial for writing efficient, bug-free code. This post explores how different programming languages handle memory allocation and why knowing these mechanisms matters for your day-to-day coding.

Memory Management Approaches: Manual vs. Automatic

Low-Level Languages: Manual Memory Management

In low-level languages such as C or C++, memory management falls squarely on the shoulders of us developers. We are responsible for:

This approach offers exceptional performance and control but comes with significant trade-offs:

High-Level Languages: Automatic Memory Management

Languages like JavaScript, Python, and Java take a different approach:

While this automation reduces the risk of memory-related bugs, it's important to note that memory leaks can still occur, particularly in situations involving event listeners, closures, or circular references.

How JavaScript Manages Memory

To understand memory management in JavaScript, we need to explore how different data types are stored and accessed.

Memory Allocation for Different Data Types

Primitive Types

JavaScript's primitive types include:

For primitive values, JavaScript's memory management is abstracted from the developer. While it's commonly explained that primitives are "stored on the stack," the reality is more nuanced—JavaScript engines use various optimization strategies that may involve both stack and heap allocations depending on context.

What's important to understand is that primitive values are immutable and are passed by value. When you assign a primitive to a new variable or pass it to a function, a copy of the value is created.

Non-Primitive (Reference) Types

Non-primitive types in JavaScript include:

These types are stored in the heap memory and are accessed by reference. They can dynamically grow or shrink, making heap storage necessary. When you work with a reference type, you're working with a reference (or pointer) to the location in memory where the actual data resides.

Why This Distinction Matters

Because different data types are stored and accessed differently, they exhibit distinct behaviors in our programs. Understanding these differences is essential for:

Let's explore these differences through code examples.

Primitive vs. Reference Types in Action

1// Example 1: Working with primitive types
2let dog = "Snickers";
3let newDog = dog; // Creates a copy of the value
4newDog = "Sunny"; // Only changes newDog
5
6console.log(dog); // Output: "Snickers" (unchanged)
7console.log(newDog); // Output: "Sunny"
1// Example 1: Working with primitive types
2let dog = "Snickers";
3let newDog = dog; // Creates a copy of the value
4newDog = "Sunny"; // Only changes newDog
5
6console.log(dog); // Output: "Snickers" (unchanged)
7console.log(newDog); // Output: "Sunny"

In this example:

  1. We create a variable dog with the value "Snickers"
  2. We assign its value to newDog, creating a separate, independent copy
  3. When we change newDog, the original dog variable remains unchanged

Now let's contrast this with reference types:

1// Example 2: Working with reference types
2let dogObj = {
3 name: "Snickers",
4 breed: "King Charles Cavalier",
5};
6
7let newDogObj = dogObj; // Creates a new reference to the same object
8newDogObj.breed = "Golden Retriever"; // Modifies the shared object
9
10console.log(dogObj.breed); // Output: "Golden Retriever" (changed!)
11console.log(newDogObj.breed); // Output: "Golden Retriever"
1// Example 2: Working with reference types
2let dogObj = {
3 name: "Snickers",
4 breed: "King Charles Cavalier",
5};
6
7let newDogObj = dogObj; // Creates a new reference to the same object
8newDogObj.breed = "Golden Retriever"; // Modifies the shared object
9
10console.log(dogObj.breed); // Output: "Golden Retriever" (changed!)
11console.log(newDogObj.breed); // Output: "Golden Retriever"

In this case:

  1. We create an object dogObj that lives in heap memory
  2. When we assign it to newDogObj, we're creating a new reference to the same object
  3. Both variables point to the same memory location
  4. When we modify a property through either reference, both variables reflect the change

This behavior is fundamental to understanding JavaScript and can be the source of many bugs when not properly accounted for.

Creating Independent Copies of Objects

When we want to create an independent copy of an object, we have several options:

Shallow Copying

Shallow copying creates a new object but only copies primitive values. Nested objects are still shared through references.

1let person = {
2 name: "Alex",
3 age: 30,
4 address: {
5 city: "San Francisco",
6 state: "CA",
7 },
8};
9
10// Using spread operator for shallow copy
11let shallowCopy1 = { ...person };
12
13// Using Object.assign() for shallow copy
14let shallowCopy2 = Object.assign({}, person);
15
16// Modifying a top-level property
17shallowCopy1.age = 31;
18console.log(person.age); // Still 30 (unchanged)
19console.log(shallowCopy1.age); // 31 (changed)
20
21// Modifying a nested property
22shallowCopy1.address.city = "Oakland";
23console.log(person.address.city); // "Oakland" (changed!)
24console.log(shallowCopy1.address.city); // "Oakland"
1let person = {
2 name: "Alex",
3 age: 30,
4 address: {
5 city: "San Francisco",
6 state: "CA",
7 },
8};
9
10// Using spread operator for shallow copy
11let shallowCopy1 = { ...person };
12
13// Using Object.assign() for shallow copy
14let shallowCopy2 = Object.assign({}, person);
15
16// Modifying a top-level property
17shallowCopy1.age = 31;
18console.log(person.age); // Still 30 (unchanged)
19console.log(shallowCopy1.age); // 31 (changed)
20
21// Modifying a nested property
22shallowCopy1.address.city = "Oakland";
23console.log(person.address.city); // "Oakland" (changed!)
24console.log(shallowCopy1.address.city); // "Oakland"

As you can see, shallow copying only creates independent copies of the top-level properties. Nested objects are still shared.

Deep Copying

For a true independent copy including nested objects, we need a deep copy:

1// Using JSON (with limitations)
2let deepCopy = JSON.parse(JSON.stringify(person));
3
4// Now modifying nested properties won't affect the original
5deepCopy.address.city = "Los Angeles";
6console.log(person.address.city); // Still "Oakland"
7console.log(deepCopy.address.city); // "Los Angeles"
1// Using JSON (with limitations)
2let deepCopy = JSON.parse(JSON.stringify(person));
3
4// Now modifying nested properties won't affect the original
5deepCopy.address.city = "Los Angeles";
6console.log(person.address.city); // Still "Oakland"
7console.log(deepCopy.address.city); // "Los Angeles"

It's important to note that the JSON method has limitations:

For more complex scenarios, consider libraries like Lodash's cloneDeep() or the structured clone API when available.

Memory Leaks in JavaScript

Despite garbage collection, memory leaks can still occur in JavaScript. Common causes include:

1. Unintended References

1let heavyObject = { data: new Array(10000000).fill("x") };
2let cache = {};
3
4function process(key) {
5 // Store in cache - might grow indefinitely!
6 cache[key] = heavyObject;
7 // Process...
8}
1let heavyObject = { data: new Array(10000000).fill("x") };
2let cache = {};
3
4function process(key) {
5 // Store in cache - might grow indefinitely!
6 cache[key] = heavyObject;
7 // Process...
8}

2. Forgotten Event Listeners

1function setupHandler() {
2 let largeData = new Array(10000000).fill("x");
3
4 document.getElementById("button").addEventListener("click", function () {
5 // This closure maintains a reference to largeData
6 console.log("Data size:", largeData.length);
7 });
8}
1function setupHandler() {
2 let largeData = new Array(10000000).fill("x");
3
4 document.getElementById("button").addEventListener("click", function () {
5 // This closure maintains a reference to largeData
6 console.log("Data size:", largeData.length);
7 });
8}

3. Circular References (less common with modern garbage collectors)

1function createCircularReference() {
2 let object1 = {};
3 let object2 = {};
4
5 object1.reference = object2;
6 object2.reference = object1;
7
8 return function () {
9 // Both objects remain in memory due to the closure
10 console.log(object1, object2);
11 };
12}
13
14let leak = createCircularReference();
1function createCircularReference() {
2 let object1 = {};
3 let object2 = {};
4
5 object1.reference = object2;
6 object2.reference = object1;
7
8 return function () {
9 // Both objects remain in memory due to the closure
10 console.log(object1, object2);
11 };
12}
13
14let leak = createCircularReference();

Performance Considerations

Understanding memory allocation has performance implications:

  1. Creation cost: Object creation and garbage collection consume CPU resources
  2. Memory pressure: Excessive memory usage can trigger more frequent garbage collection pauses
  3. Cache efficiency: Memory layout affects CPU cache utilization

Summary

Memory Management in Programming Languages: A Developer's Guide

Key Takeaways:

  1. Manual vs. Automatic Memory Management: Low-level languages require manual memory management, offering control at the cost of potential errors. High-level languages use automatic garbage collection, trading some performance for safety and convenience.

  2. JavaScript Data Types:

    • Primitive Types: These immutable values include strings, numbers, booleans, etc. They're passed by value and remain independent when assigned to new variables.
    • Reference Types: Objects, arrays, and functions are stored in the heap and accessed by reference. Multiple variables can reference the same underlying data.
  3. Copying Objects: JavaScript offers ways to create copies of objects:

    • Shallow copying (spread operator, Object.assign()) copies only the top level
    • Deep copying (like JSON parse/stringify) creates fully independent copies
  4. Memory Leaks: Even with garbage collection, memory leaks can occur through event listeners, closures, or global references.

  5. Performance Impact: Memory management choices affect application performance through creation costs, garbage collection frequency, and memory layout.

In a Nutshell: Understanding memory management in your programming language helps you write more efficient, predictable, and bug-free code. In JavaScript specifically, knowing the difference between primitive and reference types is essential for avoiding unexpected behavior and managing resource usage effectively.