Memory Management
Frontend memory management mechanisms and optimizations in V8
Updated
GC (Garbage Collection)
What Is Garbage
JS values are divided into primitive types and object types.
Primitive values are usually stored directly on the stack, but may be promoted to the heap due to scope lifetime (closures) or implicit boxing (for example, 'abc'.toUpperCase() temporarily creates a String object); object values are always stored in the heap, and variables only hold an address pointing to the heap memory.
When a variable leaves its scope, the value or address on the stack is cleared, and the heap memory becomes garbage, which is automatically managed by the garbage collection mechanism that reclaims objects with no references.
Collection Strategies
In JavaScript memory management there is a concept called reachability: values that are accessible or usable in some way are guaranteed to be kept in memory, while inaccessible values need to be reclaimed.
As for how to reclaim, it is really a question of how to discover these unreachable objects (garbage) and clean them up. Put plainly, the principle of JavaScript garbage collection is to periodically find memory (variables) that is no longer used and then release it.
There are two common GC strategies:
- Mark-and-sweep algorithm
- Reference counting algorithm
Mark-and-Sweep Algorithm (Mark-Sweep)
The most common strategy so far; most browser JS engines adopt this algorithm and then optimize it.
Process
The whole algorithm has two parts:
- Mark: add a mark to active objects
- Sweep: destroy inactive objects that have no mark
The overall flow is as follows:
- At runtime, the garbage collector adds a mark to every variable in memory, assuming all objects in memory are garbage — everything is marked
0 - Then it traverses from each root object and changes the nodes that are not garbage to
1 - It cleans up all garbage marked
0, destroying it and reclaiming the memory it occupies - Finally, it resets the mark of every object in memory to
0, waiting for the next round of garbage collection
---
title: Mark-and-Sweep Workflow
---
graph TD
A[Mark all variables as inactive] --> B(Traverse root objects, mark non-garbage as active)
B --> C[Clean up garbage marked inactive and reclaim memory]
C --> D[Reset all marks to inactive, waiting for the next round]
Advantages
- Relatively simple to implement: a mark is either set or not, so a single binary bit (
0and1) is enough to represent it
Disadvantages
- Memory fragmentation: after sweeping, the memory locations of the remaining objects do not change, which also makes the free memory space discontinuous, producing memory fragmentation
- Slow memory allocation: even with the
First-fitstrategy, the operation is stillO(n)— in the worst case it traverses to the end every time; and because of fragmentation, allocating large objects is even slower - Blocking during GC: since JS is single-threaded, the GC process is also a task that occupies the entire thread, causing the current task to be blocked.
Memory Allocation Strategies
First-fit: return the first block found that is greater than or equal tosizeBest-fit: traverse the entire free list and return the smallest block that is greater than or equal tosizeWorst-fit: traverse the entire free list, find the largest block, split it into two parts — one ofsize, which is returned
Optimization
You can use the Mark-Compact algorithm, which compacts memory after GC by moving objects to the same end of memory, leaving a complete block of remaining free memory.
Reference Counting Algorithm
A GC algorithm used in the early days; it decides whether a value is active based on whether it is referenced.
Process
- When a variable is declared and a reference type is assigned to it, the value's reference count becomes 1
- If the same value is assigned to another variable, the reference count increases by 1
- If the variable's value is overwritten by another value, the reference count decreases by 1
- When the value's reference count becomes 0, it means no variable is using it and the value can no longer be accessed, so its space is reclaimed — the garbage collector cleans up the memory occupied by values whose reference count is 0 while it runs
---
title: Reference Counting Algorithm Flow
---
graph TD
Start[Start] --> Declare[Declare a variable and assign a reference type]
Declare --> Count1[Reference count = 1]
Count1 --> AssignNew{Assigned to a new variable?}
AssignNew -- Yes --> AddRef[Reference count + 1]
AddRef --> CountN[Current reference count = N]
AssignNew -- No --> OverCheck{Variable overwritten?}
OverCheck -- Yes --> ReduceRef[Reference count - 1]
ReduceRef --> CheckZero{Reference count = 0?}
CheckZero -- Yes --> GC[Garbage collector reclaims memory]
CheckZero -- No --> CountN
CountN --> AssignNew
Advantages
- Garbage is reclaimed immediately: as soon as the counter is detected to be 0, the value can be reclaimed directly, without waiting for a scheduled GC task.
- Low GC overhead: there is no need to traverse the entire heap; only the object involved in the current operation needs to be handled
Disadvantages
- High memory usage: a huge set of counters is needed to store the reference count of every object. Since the upper bound of the reference count is unknown, corresponding space has to be reserved, which takes up considerable memory.
- Circular reference problem: when two objects reference each other, neither counter is 0, so neither is destroyed, which leads to memory leaks.
V8 GC
V8's GC is also an optimization based on the mark-and-sweep algorithm, called generational GC.
Because the mark-and-sweep algorithm blocks the thread during the GC phase, the parts that change frequently and occupy little space are separated out for high-frequency GC, while the parts that do not change easily get low-frequency GC, which effectively reduces the thread blocking time caused by GC.
V8 divides memory into the young generation and the old generation:
- Young generation: newly created, small, short-lived objects go to the young generation, where GC tasks run relatively frequently.
- Old generation: objects created earlier, occupying more space, and living longer go to the old generation, where the GC cycle is longer, reducing GC task execution time.
The young and old generations do not use the same GC; each is optimized specifically for the characteristics of its memory space.
V8 Memory Structure
| Memory area | Description |
|---|---|
Young generation (new_space) |
Most objects are allocated here at first. This area is relatively small but garbage collection is very frequent. The area is split into two halves: one half is used to allocate memory, and the other half is used to copy over the objects that need to be kept during garbage collection. |
Old generation (old_space) |
Objects in the young generation that survive for a while are moved to the old generation memory area, where garbage collection is less frequent than in the young generation. The old generation is further divided into the old pointer area and the old data area: the former contains most objects that may have pointers to other objects, while the latter only holds raw data objects that have no pointers to other objects. |
Large object space (large_object_space) |
Holds objects whose size exceeds that of the other areas. Each object gets its own memory, and garbage collection never moves objects in the large object space. |
Code space (code_space) |
Code objects are allocated here; it is the only memory area with execution permission. |
Map space (map_space) |
Holds Cells and Maps. Each area stores elements of the same size, with a simple structure. |
Young Generation GC
Young generation objects are garbage collected through an algorithm called Scavenge, whose concrete implementation mainly adopts a copying approach known as the Cheney algorithm.
The Cheney algorithm splits the heap memory in two: one space in active use, which we will call the from-space, and one idle space, which we call the to-space.
Newly added objects are all stored in the from-space. When the from-space is nearly full, a garbage collection operation needs to run.
When garbage collection starts, the young-generation collector marks the live objects in the from-space. After marking, it copies the live objects from the from-space into the to-space in order, and then enters the cleaning phase, which clears the space occupied by inactive objects. Finally, the roles are swapped: the old from-space becomes the to-space, and the old to-space becomes the from-space.
---
title: Young Generation GC Flowchart
---
flowchart TD
A[From-space nearly full] --> B[Trigger garbage collection]
B --> C[Mark live objects in from-space]
C --> D[Copy live objects to to-space in order]
D --> E[Clean up inactive objects in from-space]
E --> F[Swap the roles of from-space and to-space]
F --> A
Promotion from Young Generation to Old Generation
- When an object survives multiple copies, it is considered a long-lived object and is then moved to the old generation.
- If copying an object into the to-space would make the to-space more than 25% full, the object is promoted directly to the old generation space.
The reason for the 25% threshold is that after a
Scavengecollection completes, the to-space flips into the from-space and continues to be used for object memory allocation. If it were too full, subsequent memory allocation would be affected.
Old Generation GC
Objects in the old generation are usually large. If they were partitioned and copied back and forth like the young generation, it would be very time-consuming, making collection inefficient. So the old-generation garbage collector manages its collection with the Mark-Compact algorithm mentioned above.
Parallel Collection
To reduce the impact of GC on the main thread, auxiliary threads are started during GC, which effectively reduces blocking time — this is parallel collection.
However, even with parallel collection, the main thread is still blocked; otherwise the memory could change dynamically during execution, distorting the marks — active objects could be cleared by mistake, or inactive objects could be missed.
Young generation GC mainly uses this collection strategy.
Incremental Marking
Splitting one marking process into multiple subtasks and marking them one by one is incremental marking. Incremental marking mainly handles old-generation GC, avoiding the situation where a single old-generation GC blocks the entire thread for a long time.
Incremental marking consists of three parts: tri-color marking, write barriers, and lazy sweeping.
Tri-color Marking
The original mark-and-sweep algorithm divides memory objects into two parts:
- Marked: represented by black
- Unmarked: represented by white
Tri-color marking adds a gray mark:
- Marked: represented by black
- Itself marked, but the objects it references are not yet marked: represented by gray
- Unmarked: represented by white
The overall flow is as follows:
- Mark all objects white
- Take out an object, mark it gray, and push it onto the marking worklist
- The collector pops referenced objects from the marking worklist, turns them from gray to black, and turns the next referenced objects gray
- When there are no gray objects left, marking is complete
The whole process can be interrupted and split, so it can run incrementally — next time it just continues the task from the marking worklist, reducing blocking time.
---
title: Tri-color Marking Workflow
---
flowchart TD
A[All objects marked white] --> B[Take out root objects, mark them gray<br>and push them onto the marking worklist]
B --> C{Is the marking worklist empty?}
C -- No --> D[Pop gray object X]
D --> E[Mark X black]
E --> F[Traverse all child objects Y referenced by X]
F --> G{Is Y white?}
G -- Yes --> H[Mark Y gray<br>and push it onto the worklist]
G -- No --> C
H --> C
C -- Yes --> I[Marking complete]
Write Barrier
The write barrier mainly handles an abnormal scenario in the incremental tri-color marking process: between one marking and the next GC pass, an already-marked object may modify an object property, distorting the marks.
The write barrier mechanism triggers when an object property is modified. It checks whether the property value is an object; if so, it adds it to a buffer. When the buffer is full or a GC is triggered, the objects in the buffer are re-marked gray and tri-color marking runs again.
// Write barrier pseudocode
function writeBarrier(obj, field, newVal) {
const oldVal = obj[field]
if (isPointer(oldVal)) {
storeBuffer.add(oldVal) // Record the old reference
}
obj[field] = newVal // The actual assignment
}
---
title: Write Barrier Flowchart
---
flowchart TD
A[Object property modified] --> B{Is it a pointer write?}
B -- Yes --> C[Trigger the write barrier mechanism]
C --> D[Record the target object in the<br>StoreBuffer]
D --> E[Continue application execution]
B -- No --> Z[Normal write operation]
subgraph Background processing phase
F{StoreBuffer full<br>or GC needs to advance} -- Yes --> G[Traverse the StoreBuffer]
G --> H[Mark target objects gray]
H --> I[Advance marking work]
I --> J{All marking complete?}
J -- Yes --> K[Finish this GC round]
J -- No --> I
end
E --> F
Z --> F
Lazy Sweeping
When there is enough memory space available, there is no need to free memory immediately, so sweeping can be deferred.
During sweeping, there is no need to clean up all inactive object memory at once; it can be done gradually in separate tasks — a producer-consumer model.
Concurrent Collection
Concurrent collection mainly solves the problem that parallel collection still blocks the main thread; it is designed as a collection algorithm that runs entirely in the background.
However, to avoid the main thread reusing freed objects during collection, concurrent locks need to be added to the read/write process, which introduces considerable complexity.
Memory Leaks
Question 1
Q: Will this code leak memory? If so, where is the leak? How would you fix it?
function demo() {
const bigArrayBuffer = new ArrayBuffer(100_000_000)
const id = setTimeout(() => {
console.log(bigArrayBuffer.byteLength)
}, 1000)
return () => clearTimeout(id)
}
globalThis.cancelDemo = demo()
Question 2
Q: Will this code leak memory? If so, where is the leak? How would you fix it?
function demo() {
const bigArrayBuffer = new ArrayBuffer(100_000_000)
globalThis.innerFunc1 = () => {
console.log(bigArrayBuffer.byteLength)
}
globalThis.innerFunc2 = () => {
console.log('hello')
}
}
demo()
globalThis.innerFunc1 = undefined
Question 3
Q: Will this code leak memory? If so, where is the leak? How would you fix it?
function demo() {
const bigArrayBuffer1 = new ArrayBuffer(100_000_000)
const bigArrayBuffer2 = new ArrayBuffer(100_000_000)
globalThis.innerFunc = () => {
eval('arbitrary code, not important')
}
}
demo()
Question 4
Q: Will this code leak memory? If so, where is the leak? How would you fix it?
const customEval = eval
function demo() {
const bigArrayBuffer1 = new ArrayBuffer(100_000_000)
const bigArrayBuffer2 = new ArrayBuffer(100_000_000)
globalThis.innerFunc = () => {
customEval('arbitrary code, not important')
}
}
demo()