DEV Community

mkuts12
mkuts12

Posted on

Memory Management in JavaScript

Note: This is the same post I've posted in Medium while I was working for Walkme.

Creating and modifying objects is something very trivial in JS, and it is continuously done in the runtime of a program. So how do the popular JS engines do it efficiently, and how does it impact your beautiful websites?

For more enjoyment from this article, you should know that the JS engine is the thing that takes JS code as its input and executes it. Chrome’s engine is V8, Firefox’s is SpiderMonkey and Edge’s was Chakra.
Objects in JS are dictionary type data structure (string to data mapping), and they are dynamic, i.e., their structure can be changed on the fly, adding new keys in the dictionary similarly to Hashmaps.

Object Creation

To kickstart the discussion, let’s look at a way to create an object.

function createPoint1(is3d) {
    var point = {};
    if(is3d) {
        point.z = 0;
    } else {
        point.z = undefined;
    }
    point.x = 0;
    point.y = 0; 
    return point;
}
Enter fullscreen mode Exit fullscreen mode

Odd else, right? Not setting a value in JS is logically the same as setting it to undefined.

So let’s omit that else

function createPoint2(is3d) {
    var point = {};
    if(is3d) {
        point.z = 0;
    }
    point.x = 0;
    point.y = 0; 
    return point;
}
Enter fullscreen mode Exit fullscreen mode

Better, right? And you might think that both should be the same,
performance-wise, or even that the second is better since it’s shorter, but the JS engine teams work hard to make it so one of them is better than the other. To understand why, we need to look at how objects are mapped to memory.

Memory?

Computer memory is like a huge array. Every time you create a new something (array, object, function) anywhere in your code, the engine asks the operating system (OS) for a slice of memory to store your creation in.

When you create an array with 3 slots for 3 integer numbers, the OS allocates you the exact space you need, but if you want to add a fourth number to that array, the OS cannot just expand the already given memory, so the engine has to ask for new space for an array with 4 slots and move the data there. If you do this over and over again it takes many priceless CPU cycles.

This creates overhead from 3 main causes:

  • It takes a while for the OS to find you a big enough chunk (if it can’t, it needs to defrag the memory, that is to reshuffle the allocated blocks to make up some sequential space),
  • It takes time to copy,
  • It takes time to clean the old slice the engine does not need anymore (garbage collection).

These are the limitations imposed by handling dynamic data in software. So the engine does its best not do this much, here’s how.

It’s an Array! No! It’s a string! No! It’s an OBJECT!

When creating an object, it still has to be represented in memory as an array of values. There is no other way to represent in memory, since memory is an array-like structure.

So, how does the engine know how to direct a property to its value?

Let’s assume for a moment that we are in a different world, a statically typed (there are types, and they don’t ever change) and compiled world, where we can write something like this:

type Point {
 int x,
 int y,
 int z
};

const p1 = new Point();
p1.x = 12;
p1.z = 1;
console.log(p1.x);
Enter fullscreen mode Exit fullscreen mode

A Point has 3 parameters, ‘x’, ‘y’, and ‘z’. You can create a point and edit the value of its parameters.

With the additional information about the type and the assumption that objects of type Point never change their structure, a compiler can look at this code and change it to this:

const p1 = new Array(3);
p1[0] = 12;
p1[2] = 1;
console.log(p1[0]);
Enter fullscreen mode Exit fullscreen mode

The compiled code changes every creation of a Point to the creation of a new array with 3 slots, and remembers that the first slot is for ‘x,’ the second is for ‘y’ and the third is for ‘z.’

Logically, the two code snippets are the same, but the compiled code allows the engine to map array to memory-array and never have to worry about changes to the structure, etc.

Note: this is similar to what Bucklescript does for ReasonML.

Back to JS Objects

Since in JS we can add properties dynamically, the JS engine tries to “remember” the mapping of the property names to their place in the object array in memory.

So, how does this magic work?

Each object is stored with a mapping, which is a special structure. The first time an object is created, the engine asks for a big slice of memory and saves it with an empty mapping.

p = {}
Enter fullscreen mode Exit fullscreen mode

Note: it asks for a big slice so it can store many properties before expanding. The slice is later shrunk to the right amount.

When you add a property, let’s name it x,

p.x = 1
Enter fullscreen mode Exit fullscreen mode

it saves the value in the first place in the array and changes the mapping of the object properties to a mapping that says:

x -> first
Enter fullscreen mode Exit fullscreen mode

In the memory it might look something like this:
memory is an array

The object’s data array and the mapping array might be stored in different places in the memory, as illustrated in the image. The left array is the mapping, and the first place holds the string “x” in it, indicating that the property with this name is stored in the first place.

So when the engine goes to see what is in the object’s property with the name “x”, it will fetch the mapping, see that “x” is in the first place, then fetch the data array and return the first value.

If you add a property, p.y = 14, it saves the value in the second place and changes the mapping to this:

x -> first
y -> second
Enter fullscreen mode Exit fullscreen mode

mapping

And when you ask for “y” in an object, the engine looks at this mapping and returns you the second value in the array.

Since the data and mapping are separate entities, the engine can reuse the mapping for similar objects, if it wants, and the engine sure does want it.

Mapping Reuse

Creating a new mapping is costly, probably just as creating objects. The mapping is also stored somewhere in memory, and each time we add a property, the engine has to create a map object, move the contents of the old mapping to the new and bigger one and then add the new property.
But that’s exactly what we tried to avoid by using mappings!

So engines do 2 things to avert that:

  • They reuse the old mappings.
  • They don’t store the whole mapping but only incremental changes with a link to the previous mapping, similar to a linked-list or a tree with 2-way links.

Let’s look at an example,

1: var point = {};
2: point.x = 1;
3: point.y = 14;
4: var vector = {};
5: vector.x = 1;
6: vector.y = 20;
Enter fullscreen mode Exit fullscreen mode

In the first line, the engine creates an empty mapping, M0, just as before. In the second line, it creates a new mapping, M1, that holds the information that x is in the first place in the data array, and changes M0 by adding a special line in the mapping:

M0:
x -> transition to M1M1:
x -> first
Enter fullscreen mode Exit fullscreen mode

Now our object point has the mapping M1 linked to it. So, if you want to read x the engine will go to M1 see that x is in the first place, go to the data array and return the first value. Just like before.

In the third line, it creates a new mapping, M2, and changes M1 like this:

M0:
x -> transition to M1M1:
x -> first
y -> transition to M2M2:
y -> second
Enter fullscreen mode Exit fullscreen mode

So now when reading y on point it knows immediately that it’s in the second place, but when reading x it will see that it’s not in M2 and go upwards to M1 , see that x exists here and return the first place in the value array.

Here’s the example again,

1: var point = {};
2: point.x = 1;
3: point.y = 14;
4: var vector = {};
5: vector.x = 1;
6: vector.y = 20;
Enter fullscreen mode Exit fullscreen mode

When creating vector in line 4, it uses M0 as the initial mapping for vector. On line 5 it checks M0 whether somebody already added x to M0. Since M0 has a line indicating the transition to M1 when adding x, it saves the object with M1 as the mapping. A similar thing happens when adding y to M1. The engine reuses M2 since it's referenced by M1 when adding y.

So now both point and vector have the same mapping. The engine reused both the memory space and saved the time needed for creating these mappings :)
This way the mappings are as small as possible, and we can create the least amount of mappings.

Here’s an illustration of the memory, if we had a small memory of 20 slots.

illustration

To simplify graphs in the future, I will show the mappings as a tree:

M0 -> M1 -> M2
Enter fullscreen mode Exit fullscreen mode

And I will use this illustration to show the relation transitions between mappings and describe each mapping separately.

Fool me once, shame on you!

How does it all connect to the example from the beginning?

Let’s look at the mappings tree of the first example when calling it twice.

function createPoint1(is3d) {
    var point = {};
    if(is3d) {
        point.z = 0;
    } else {
        point.z = undefined;
    }
    point.x = 0;
    point.y = 0; 
    return point;
}
var p1 = createPoint1(true);
var p2 = createPoint1(false);
Enter fullscreen mode Exit fullscreen mode

The mapping for both p1 and p2 is the same m3 :

M1:             M2:             M3:
z -> first      x -> second     y -> third

M0 -> M1 -> M2 -> M3
Enter fullscreen mode Exit fullscreen mode

The initialization process is the same no matter how we call it. z is always initialized first to something, then x and then y.

In comparison, let’s look at the mapping of the second example when calling it twice,

function createPoint2(is3d) {
    var point = {};
    if(is3d) {
        point.z = 0;
    }
    point.x = 0;
    point.y = 0; 
    return point;
}
var p1 = createPoint2(true);
var p2 = createPoint2(false);
Enter fullscreen mode Exit fullscreen mode

The mapping for p1 is just as before,

M1:             M2:             M3:
z -> first      x -> second     y -> third

M0 -> M1 -> M2 -> M3
Enter fullscreen mode Exit fullscreen mode

But something different happens after creating p2.

M1:             M2:             M3:
z -> first      x -> second     y -> third

M4:             M5:
x -> first      y -> second

M0 -> M1 -> M2 -> M3
  `-> M4 -> M5
Enter fullscreen mode Exit fullscreen mode

When the function skips the initialization of z on the new object, it goes
from empty to x. Since such a transition does not exist on M0, a new mapping is created and M0 gets a new transition to the mapping.

We almost doubled the mappings because the object created by createPoint2(false) was of a different structure. M1 describes a similar object to M4, both say that x is in the first place in the data array, so we wasted memory real-estate.

Real life implications

To illustrate how important it is to be aware of this, look at these two snippets:

function createSlowly(a,b,c) {
    var vec = {}
    if(a) {
        vec.a = Math.random();
    }
    if(b) {
        vec.b = Math.random();
    }
    if(c) {
        vec.c = Math.random();
    }
    vec.d = Math.random();
    vec.e = Math.random();
}
function createQuickly(a,b,c) {
    var vec = {}
    vec.a = a ? Math.random() : undefined;
    vec.b = b ? Math.random() : undefined;
    vec.c = c ? Math.random() : undefined;
    vec.d = Math.random();
    vec.e = Math.random();
}
for( var i = 0; i < 5000; i ++ )
    createSlowly(i % 3, i % 4, i % 5);
for( var i = 0; i < 5000; i ++ )
    createQuickly(i % 3, i % 4, i % 5);
Enter fullscreen mode Exit fullscreen mode

The function createQuickly runs 50% faster than createSlowly.

The core reason is what we touched here. The engine has different mappings for these objects and different parts of the engine that rely on similar objects to have the same mapping fail to predict the code well and are de-optimized.

Summary

In this article, we saw that we should mind the way we create objects in JS. That JS engines use mappings to deal with the dynamic nature of objects and one optimization that is done to make sure we don’t flood our memory. Lastly, we saw a relatable object initialization method that impacts the performance of our glorious sites.

I hope you understand the reason mappings exist and how they work in state of the art JS engines. Keep in mind that even the smallest things, like removing an “else” clause, might affect your performance.

:)

Thanks to Omer Tellem, Yonatan Behor,Yonatan Kra and Udi Doron for their great help :)

Top comments (0)