Fading Coder

One Final Commit for the Last Sprint

Home > Tech > Content

Understanding Garbage Collection

Tech 1

We know that garbage collection is handled by the engine. There are many JavaScript engines (different browsers have different ones), and their garbage collection mechanisms vary in details and optimizations. This article starts with some common collection algorithms, then takes V8 engine as an example to delve into the optimizations it has made over time (why V8? Because it has a large market share 😄). Step by step, we aim to help you understand garbage collection, because only by truly understanding it can you later understand memory leaks and how to manually prevent and optimize them.

JavaScript is a fascinating language. How much do you know about its GC (garbage collection)? Most people probably learn about it from interview questions. So before we start, here are a few small questions. Think about the answers first, and then read the article with these questions in mind. If your answers improve by the end, you've gained something.

  • What is garbage collection?
  • How is garbage generated?
  • Why do we need garbage collection?
  • How does garbage collection work?
  • What optimizations has the V8 engine made for garbage collection?

Ofcourse, we are not just here for interviews; the goal is to thoroughly understand GC! If something is unclear, don't worry. Read the entire article first to get an overview, then come back and read it again carefully — it will be much clearer. This article is full of useful content; please like and follow before reading.

What is GC?

GC stands for Garbage Collection. During program execution, a lot of garbage is generated — these are memory spaces that the program no longer needs or will never use again. GC is responsible for recycling this garbage. Since it works inside the engine, the GC process is relatively transparent to frontend developers, which is why it's often called the garbage collection mechanism.

Not all languages have GC. Generally, high-level languages like Java, Python, and JavaScript come with built-in GC, while languages like C and C++ do not, requiring programmers to manually manage memory, which is more cumbersome.

How Garbage is Generated & Why Recycle?

When writing code, creating primitive types, objects, functions, etc., all occupy memory. We don't usually pay attention to this because the engine allocates memory for us — we don't have to explicitly allocate it.

But have you ever wondered what happens when we no longer need something? How does the JavaScript engine discover and clean it up?

Let's take a simple example:

let test = {
  name: "isboyjc"
};
test = [1, 2, 3, 4, 5];

As shown above, suppose this is a complete program.

We know that reference data types in JavaScript are stored in heap memory, while the stack holds a reference (pointer) to the actual object in the heap. So operations on reference data types are actually operations on the reference, not the actual object. In simple terms, the stack holds an address that is associated with the actual value in the heap.

In the code above, we first declare a variable test that references the object {name: 'isboyjc'}. Then we reassign it to an array object, so the variable now references an array. The previous object reference is gone, as shown in the diagram:

Without a reference, the object becomes useless. If we leave it there, a few might be okay, but many will overwhelm memory. So it needs to be cleaned up (recycled).

Formally speaking, program execution requires memory. Whenever the program requests memory, the operating system or runtime must provide it. For long-running service processes, memory must be released promptly; otherwise, increasing memory usage can degrade system performance or even crash the process.

Garbage Collection Strategies

In JavaScript memory management, there is a concept called reachability. Values that are accessible or usable in some way are guaranteed to stay in memory. Those that are not reachable need to be recycled.

How to recycle? It's essentially about how to discover these unreachable objects (garbage) and clean them up. The principle of JavaScript's garbage collection mechanism is to periodically find unused memory (variables) and free their memory.

You might wonder why it doesn't find and free useless memory in real time. Simply put, real-time overhead is too high.

The key point is: how do we find the so-called garbage?

This process involves several algorithmic strategies. We'll introduce two common ones:

  • Mark-Sweep Algorithm
  • Reference Counting Algorithm

Mark-Sweep Algorithm

Strategy

Mark-Sweep is currently the most commonly used algorithm in JavaScript engines. Most browsers' JavaScript engines adopt this algorithm, although browser vendors have optimized it, and the frequency of garbage collection varies.

As the name suggests, this algorithm has two phases: mark and sweep. In the mark phase, all live objects are marked. In the sweep phase, unmarked (non-live) objects are destroyed.

How are variables marked? There are many ways, e.g., flipping a bit when a variable enters the execution context, or maintaining two lists (entered and exited) to move variables between them. The specific marking method is not important; the strategy is.

When the engine performs GC (using Mark-Sweep), it starts from a set of root objects and traverses all objects to mark them. Root objects in a browser include (but are not limited to) the global Window object, the document DOM tree, etc.

The whole Mark-Sweep process looks like this:

  • The garbage collector marks all variables in memory as garbage (e.g., sets a flag to 0).
  • Starting from root objects, it traverses and marks non-garbage nodes as 1.
  • It cleans up all objects marked as 0, destroying them and reclaiming their memory.
  • Finally, it resets all marks to 0, ready for the next GC cycle.

Advantages

The only advantage is simplicity. Marking is binary (marked or not), requiring just one bit.

Disadvantages

A major drawback is that after sweeping, the memory positions of remaining objects are unchanged, leading to fragmented free memory (as shown below). Since free memory is not contiguous, memory allocation becomes an issue.

Suppose we need to allocate memory of size size for a new object. Due to fragmentation, we must traverse the free list to find a block larger than or equal to size (as shown below).

How to find a suitable block? There are three strategies:

  • First-fit: Return the first block larger than or equal to size.
  • Best-fit: Traverse the entire free list and return the smallest block larger than or equal to size.
  • Worst-fit: Traverse the entire free list, find the largest block, split it into two parts — one of size size and return that part.

Worst-fit seems most space-efficient, but it creates more small fragments, so it's not recommended. First-fit is often a better choice in terms of speed and efficiency.

In summary, Mark-Sweep has two clear disadvantages:

  • Memory fragmentation: Free memory blocks are non-contiguous, leading to many small free blocks and potentially failing to allocate large objects.
  • Slow allocation: Even with First-fit, it's an O(n) operation, and fragmentation makes large object allocation even slower.

PS: Supplement on Mark-Sweep disadvantages

The root cause is that after sweeping, the positions of remaining objects don't change, causing non-contiguous free memory. The Mark-Compact algorithm solves this. Its marking phase is the same as Mark-Sweep, but after marking, it compacts live objects to one end of memory and then cleans up the boundary (as shown below).

Reference Counting Algorithm

Strategy

Reference Counting is an earlier garbage collection algorithm. It simplifies the concept of "no longer needed" to "has no other references." If no reference points to an object (zero references), it is considered garbage and collected. This algorithm is rarely used today due to many problems, but it's worth understanding.

It tracks how many times each value is referenced:

  • When a variable is declared and assigned a reference type, the reference count for that value becomes 1.
  • If the same value is assigned to another variable, the count increases by 1.
  • If the variable's value is overwritten, the count decreases by 1.
  • When the count reaches 0, no variable uses that value, and it becomes inaccessible. The garbage collector cleans up memory occupied by values with count 0.

Example:

let a = new Object();  // count = 1 (a reference)
let b = a;            // count = 2 (a and b reference)
a = null;             // count = 1 (b reference)
b = null;             // count = 0 (no reference)
// GC reclaims this object

Simple, right? However, soon after Reference Counting appeared, a serious problem emerged: circular references. Object A points to object B, and object B points back to object A, as shown below:

function test() {
  let A = new Object();
  let B = new Object();
  A.b = B;
  B.a = A;
}

In this example, objects A and B reference each other through their properties. According to Reference Counting, their reference counts are both 2. After function test finishes, A and B should be cleaned up, but Reference Counting won't free them because their counts never drop to 0. If this function is called repeatedly, it will cause a lot of memory to never be released.

From a Mark-Sweep perspective, after the function ends, both objects are out of scope and would be treated as non-live objects and cleared. Reference Counting fails to release them, causing memory waste. This is one reason Reference Counting was abandoned in favor of Mark-Sweep.

In IE8 and earlier, BOM and DOM objects were not native JavaScript objects; they were COM (Component Object Model) objects implemented in C++. COM objects used Reference Counting for garbage collection. So even though the browser used Mark-Sweep, circular references involving COM objects were not collected (e.g., two DOM objects referencing each other). To break circular references, you had to set the reference to null:

// COM objects
let ele = document.getElementById("xxx");
let obj = new Object();
// circular reference
obj.ele = ele;
ele.obj = obj;
// break reference
obj.ele = null;
ele.obj = null;

In IE9 and later, BOM and DOM objects became native JavaScript objects, avoiding this problem.

Advantages

Compared to Mark-Sweep, Reference Counting can immediately reclaim garbage when its count becomes 0. Mark-Sweep must run periodically and pause the application thread. Additionally, Mark-Sweep requires traversing the heap to mark and sweep, while Reference Counting only needs to count references.

Disadvantages

The disadvantages are: it requires a counter that can take up significant space (unknown upper limit), and it cannot handle circular references, which is the most serious issue.

V8's GC Optimizations

As mentioned, most browsers use Mark-Sweep, and V8 is no exception. However, V8 has applied some optimizations. Next, we'll look at V8's garbage collection optimizations.

Generational Garbage Collection

Imagine the garbage collection algorithm checking all objects in memory every time. It's inefficient for large, old, long-lived objects to be checked with the same frequency as new, small, short-lived objects. The former don't need frequent collection, while the latter do. How to optimize? Generational collection comes in.

Young and Old Generations

V8's garbage collection strategy is based on generational collection. The heap memory is divided into two regions: the young generation and the old generation, each managed by different garbage collectors (different strategies).

The young generation holds short-lived objects (newly created), typically with a capacity of 1-8 MB. The old generation holds long-lived objects or objects that have survived young generation collection; its capacity is usually larger.

The total heap memory size equals the sum of young and old generation memory (as shown below):

V8 uses two garbage collectors: one for the young generation (young generation garbage collector) and one for the old generation (old generation garbage collector).

Young Generation Garbage Collection

The young generation uses the Scavenge algorithm for garbage collection, which implements a copying method called the Cheney algorithm. Let's explain.

The heap memory is divided into two equally sized spaces: one is active (we'll call it from-space) and the other is idle (to-space), as shown below:

New objects are always allocated in from-space. When from-space is almost full, a garbage collection is triggered.

During collection, the young generation garbage collector marks live objects in from-space, copies them to to-space in order, then cleans up the remaining space (non-live objects). Finally, the roles of from-space and to-space are swapped.

After multiple copies, if an object still survives, it is considered long-lived and moved to the old generation for management by the old generation garbage collector.

Also, if copying an object to to-space would cause to-space occupancy to exceed 25%, the object is immediately promoted to the old generation. The 25% threshold ensures sufficient space for future allocations after the role swap.

Old Generation Garbage Collection

Old generation garbage collection is easier to understand. Large, long-lived objects are allocated here. Since objects are typically larger, copying them like in the young generation would be time-consuming and inefficient. So the old generation uses the Mark-Sweep algorithm.

First, the marking phase: starting from a set of roots, recursively traverse and mark reachable objects as live. Unreachable objects are marked as dead.

In the sweep phase, the old generation collector directly removes dead objects.

As mentioned before, Mark-Sweep leads to memory fragmentation. V8 uses the Mark-Compact algorithm (as described earlier) to eliminate fragmentation and optimize space.

Why Generational Collection?

Why generational? What advantages does it offer?

It's not about solving a specific problem but rather an optimization. It treats new, small, short-lived objects as the young generation, using a small memory space with high-frequency fast collection, while large, old, long-lived objects are placed in the old generation, which is collected less frequently. Different collection mechanisms and frequencies greatly improve GC efficiency.

Parallel Collection

Before introducing parallelism, we need to understand Stop-The-World. JavaScript runs on a single thread (the main thread). During garbage collection, JavaScript execution is blocked until GC finishes. This is called Stop-The-World.

For example, if a GC takes 60ms, the application logic must pause for 60ms. If GC time is too long, it may cause page stuttering for users.

Since GC can be time-consuming, why not use multiple helper threads to speed it up? V8 introduced the parallel collection mechanism.

Parallel means the garbage collector uses multiple helper threads simultaneously with the main thread to perform the same GC work.

In simple terms, if the main thread alone took 3 seconds, with two helper threads, the work can be done in 1 second for each thread, but additional synchronization overhead (say 0.5 seconds) means total time becomes 1.5 seconds instead of 3.

Although the time is reduced, the main thread still has to be paused during the 1.5 seconds. Since the memory is static during this period (reference relationships don't change), only coordination between threads is needed, making implementation simpler.

The young generation uses parallel collection. During GC, multiple threads are started to clean up the young generation. They simultaneously copy live objects from from-space to to-space. Since data addresses change, pointers to these objects need to be updated synchronously. This is parallel collection.

Incremental Marking and Lazy Sweeping

The parallel strategy improves efficiency for the young generation, but it's still a stop-the-world approach. For the old generation, which contains large objects, even parallel collection may take significant time.

To reduce stop-the-world time, in 2011, V8 optimized the marking of the old generation by switching from stop-the-world marking to incremental marking.

What is Incremental Marking?

Incremental marking divides a single GC marking process into many small steps. After each small step, the application logic runs for a while, alternating until the entire marking is complete (as shown below).

How do we pause and resume? And if object references change during the pause, what happens?

Incremental marking is more complex than parallel. V8 addresses these issues with the tri-color marking method and write barriers.

Tri-Color Marking (Pause and Resume)

In the old generation's Mark-Sweep, without incremental marking, all data is initially white during marking. The collector starts from roots and marks reachable objects black. After traversal, black objects are live, and white objects are garbage.

If we used only black and white, after an incremental step, when the collector resumes, it wouldn't know where to continue because some objects are black and some are white.

V8 uses tri-color marking. Each object has two mark bits encoding three colors: white, gray, black.

  • White: Not yet marked.
  • Gray: Self marked, but its child references (referenced objects) are not yet marked.
  • Black: Self and all children are marked.

Initially, all objects are white. Starting from roots, mark them gray and push them into a worklist. Then pop an object from the worklist, turn it black, and mark its children gray. Continue until no gray objects remain. All remaining white objects are unreachable and will be collected.

With tri-color marking, when resuming, we can check if any gray objects exist. If none, marking is complete and we can sweep. If gray objects exist, resume from them.

Tri-color marking allows incremental marking without scanning the entire memory each time, reducing stop-the-world time.

Write Barrier (Handling Reference Changes During Incremental Marking)

During incremental marking, after a step, the application may modify object references. For example (as shown in the image):

Suppose objects A, B, C are in a chain. After the first incremental step, all are marked black. Then the application runs and changes object B's reference from C to D. When GC resumes for the next step, object C is still black (live) but no longer referenced; however, it won't be collected this cycle, which is okay because it will be collected in the next cycle. But object D is still white; since no gray objects exist, marking ends and D would be collected as garbage, even though it's now referenced. This is incorrect.

To solve this, V8 uses a write barrier (or store barrier). When a black object references a white object, the write barrier forces the white object to become gray, ensuring it will be properly marked in the next incremental step. This mechanism is called the strong tri-color invariant.

In our example, when B's reference changes from C to D, the white object D is forced to gray.

Lazy Sweeping

Incremental marking only marks live and dead objects. The actual cleanup (sweeping) is done using lazy sweeping.

After incremental marking is complete, lazy sweeping begins. If there is enough available memory to run code quickly, we don't need to clean immediately. We can delay sweeping and let JavaScript execute first. Also, we don't need to sweep all dead objects at once; we can clean incrementally on demand until all dead memory is reclaimed, then proceed to the next incremental marking cycle.

Pros and Cons of Incremental Marking and Lazy Sweeping

Incremental marking and lazy sweeping significantly reduce main thread pause time, making user interaction smoother. However, since JavaScript code runs between incremental steps, object references may change, requiring write barriers to track these changes. The disadvantages include:

  • The total main thread pause time is not reduced; it may even increase slightly.
  • Write barriers add overhead, potentially reducing application throughput.

Concurrent Collection

Parallel collection still blocks the main thread. Incremental marking increases total pause time and reduces throughput. Is there a way to perform GC without blocking the main thread, and more efficiently than incremental marking?

Enter concurrent collection. It allows helper threads to perform GC in the background while the main thread executes JavaScript uninterrupted.

The main thread can run freely while helper threads do GC — that's the advantage, but also the challenge. Since the main thread can change object references at any time, the helper threads must handle these changes, requiring additional read-write locks or other synchronization mechanisms. We won't go into detail here.

Final Words on V8's GC Optimizations

V8's garbage collection strategy is based on generational collection. For the young generation, parallel collection improves efficiency. For the old generation, which strategy does it use? We've discussed parallel, incremental marking, lazy sweeping, and concurrent. Which one? Actually, the old generation combines multiple strategies.

  • Concurrent marking: While the main thread runs JavaScript, helper threads perform marking concurrently (all marking done by helper threads).
  • After marking, parallel sweeping is performed: the main thread and multiple helper threads simultaneously clean up.
  • Additionally, sweeping tasks are executed incrementally between JavaScript tasks.

Conclusion

The above are the main optimizations V8 has made for garbage collection. However, even with engine optimizations, we cannot completely ignore garbage collection in our code. We should still avoid practices that hinder GC, because not all unused memory can be reclaimed. Memory that is no longer used but not reclaimed in time is called a memory leak.

Related Articles

Understanding Strong and Weak References in Java

Strong References Strong reference are the most prevalent type of object referencing in Java. When an object has a strong reference pointing to it, the garbage collector will not reclaim its memory. F...

Comprehensive Guide to SSTI Explained with Payload Bypass Techniques

Introduction Server-Side Template Injection (SSTI) is a vulnerability in web applications where user input is improper handled within the template engine and executed on the server. This exploit can r...

Implement Image Upload Functionality for Django Integrated TinyMCE Editor

Django’s Admin panel is highly user-friendly, and pairing it with TinyMCE, an effective rich text editor, simplifies content management significantly. Combining the two is particular useful for bloggi...

Leave a Comment

Anonymous

â—ŽFeel free to join the discussion and share your thoughts.