Before writing this article, I have never asked myself this question in my work. If I write code, the compiler will edit the code into 01 code that the computer can recognize. What is there to know?
In fact, the compiler in the JS code into executable code, do a lot of complicated work, only a thorough understanding of the underlying principle of compilation, we can write better code, understand the nature of various front-end frameworks behind.
In order to write this article, xiaobian is also apprehensive, read the relevant information, is also a process of learning to understand, there are inevitably some problems, welcome you to correct, improve together.
Digression — a return to childhood curiosity
The pace and pressure of life today may be overwhelming. We spend our days writing code, learning various front-end frameworks, learning faster than we can update, and constantly looking for the best way to solve problems or fix bugs. We rarely have time to really settle down and study our most basic tool, the JavaScript language.
I do not know whether you still remember their childhood, see a new thing or toy, whether there is a strong curiosity, must break the casserole to ask you in the end. But in our work, the various code problems encountered, do you have a strong curiosity, to explore, or add these problems to the “blacklist”, not next time, I don’t know why.
In fact, we should go back to our childhood and not settle for knowing how to use it and just letting the code work, we should figure out the “why” so that you can embrace JavaScript as a whole. With this knowledge, you can easily understand any technology or framework, which is why front-end developers keep updating javaScript basics.
Don’t confuse javascript with a browser
Language and environment are two different concepts. When it comes to JavaScript, most people probably think of a browser, without which JavaScript is impossible to run, which is quite different from other system-level languages. For example, C can develop systems and manufacture environments, while JavaScript can only work in a specific environment.
A JavaScipt runtime environment generally has a host environment and an execution environment. As shown below:
The host environment is generated by the shell, such as the browser (but browsers are not the only one; many servers and desktop applications can and can provide an environment for JavaScript engines to run). An execution-time environment is generated by a JavaScript engine (such as V8, more on that later) embedded in the shell. In this execution-time environment, you first need to create an initial environment for code parsing.
- A set of rules associated with the host environment
- JavaScript engine kernel (basic syntax rules, logic, commands, and algorithms)
- A set of built-in objects and apis
- Other conventions
Although, different JavaScript engines define different initialization environments, which creates so-called browser compatibility issues because different browsers use different JavaScipt engines.
But you probably know the latest news: In the browser market, Microsoft abandoned its Own EDGE, the successor to Internet Explorer, Switch to the Chromium core dominated by rival Google (domestic browsers Baidu, Sogou, Tencent, Cheetah, UC, Marao, 360 all use Chromium (Chromium is the famous V8 engine, I think we all know it), It’s all Chromium), it’s really gratifying that we’re finally writing code happily in the same environment.
Review compilation principles
When it comes to JavaScript, most people categorize it as a “dynamic” or “interpreted execution” language, but it’s actually a “compiled” language. Unlike traditional compiled languages, it is not compiled ahead of time, and the compiled results cannot be migrated to distributed systems. Before introducing the principle of JavaScript compiler, let’s review the basic principle of JavaScript compiler, because this is the most basic, we can understand JavaScript compiler.
The general steps of compiler are divided into: lexical analysis, syntax analysis, semantic checking, code optimization and bytecode generation. The specific compilation process is as follows:
Word Segmentation/Lexical Analysis (Tokenizing/Lexing)
The so-called word segmentation is like dividing a sentence according to the smallest units of words. Computers also break down strings of code into meaningful chunks, called lexical units (tokens), before compiling a piece of code.
For example, consider the program var a=2. The program is usually broken down into the following lexical units: var, a, =, 2; Whether a space is used as a lexical unit depends on whether the space has meaning in the language.
Parsing/Parsing
The process is to change the lexical unit flow into a hierarchical nested tree of elements that represents the syntactic structure of the program. This Tree is called the Abstract Syntax Tree (AST).
Lexical analysis and parsing are not completely independent, but interleaved, meaning that the lexical analyzer does not read all lexical tokens before using the parser to process them. In general, every lexical token obtained is fed into the parser for analysis.
The process of parsing is to generate a syntax tree from the notations generated by lexical analysis, and in layman’s terms, to store the information gathered from the program into a data structure. Note that there are two types of data structures used in compilation: symbol tables and syntax trees.
Symbol table: A table used in a program to store all symbols, including all string variables, immediate quantity strings, and functions and classes.
Syntax tree: A tree representation of the program structure used to generate intermediate code. Here is a simple conditional structure and output snippet that the parser converts into a syntax tree, as in:
if (typeof a == "undefined") {
a = 0;
} else {
a = a;
}
alert(a);
Copy the code
If the JavaScript interpreter constructs the syntax tree and finds that it cannot be constructed, it reports a syntax error and ends parsing the entire code block. For traditional strongly typed languages, after the syntax tree is constructed through grammar analysis, the translated sentences may still have some ambiguity, which requires further semantic checking.
** The main part of semantic checking is type checking. ** For example, whether the argument and parameter types of a function match. However, for weakly typed languages, there is no such step.
In preparation for the compile phase, JavaScript code is built in memory as a syntax tree from which the JavaScript engine interprets and executes.
Code generation
The process of converting an AST into executable code is called code generation. This process is language dependent and target platform dependent.
JavaScript engines are a lot more complicated once you know how to compile, because most of the time, JavaScript compilation doesn’t happen before build, but a few subtle moments before code execution, or even less. JavaScipt uses a variety of methods to ensure optimal performance, as detailed in this article later.
Mysterious JavaScipt compiler – V8 engine
Since most JavaScipt runs on browsers, different browsers use different engines. The following are the mainstream browser engines:
Thanks to Google’s V8 compiler, which has attracted a lot of attention because of its performance, our current front end has blossomed because of V8, which is written in C++, as a JavaScript engine, It originally ran on Google Chrome. It was released with the first release of Chrome as well as open source. It is now used by many other browsers besides Chrome. NodeJS, MongoDB, CouchDB, etc.
One of the most exciting front-end news of late is that Microsoft has abandoned its own EDGE, the successor to Internet Explorer, Switch to the Chromium core dominated by rival Google (domestic browsers Baidu, Sogou, Tencent, Cheetah, UC, Marao, 360 all use Chromium (Chromium is the famous V8 engine, I think we all know it), It seems that the V8 engine will be dominant in the near future, and the following series will focus on V8 engine.
When V8 compiles JavaScript code, the Parser generates an abstract syntax tree (described in the previous section). Syntax trees are tree representations of the syntactic structure of JavaScript code. The interpreter Ignition generates bytecode from the syntax tree. TurboFan is an optimized compiler for V8. TurboFan generates optimized Machine Code from bytecodes.
V8 used to have two compilers
Prior to version 5.9, the engine used two compilers:
Full-codegen – a simple and fast compiler that generates simple and relatively slow machine code.
Crankshaft – a more sophisticated (just-in-time) optimization compiler that generates highly optimized code.
The V8 engine also uses multiple threads internally:
- Main thread: Gets the code, compiles the code, and executes it
- Optimization thread: In parallel with the main thread, used to optimize code generation
- Profiler thread: This will tell the runtime how we’re spending a lot of time so that the Crankshaft can optimize them
- Other threads handle garbage collector scans
The bytecode
Bytecode is an abstraction of machine code. It is easier to compile bytecode into machine code if the bytecode is designed using the same computing model as the physical CPU. This is why an interpreter is often a register or stack. Ignition is a register with an accumulator.
You can think of V8’s bytecodes as small building blocks, which together make up any JavaScript functionality. V8 has hundreds of bytecodes. Operators like Add or TypeOf, or property loaders like LdaNamedProperty, and many similar bytecodes. V8 also has some very special bytecodes, such as CreateObjectLiteral or SuspendGenerator. Header file bytecodes.h (github.com/v8/v8/blob/… Defines a complete list of V8 bytecodes.
In the early V8 engines, where most browsers were bytecode based, the V8 engine skipped this step and compiled jS directly to machine code, saving time and efficiency, but later finding it too memory intensive. So what’s the motivation for going back to bytecode?
- Reducing the amount of memory taken up by machine code, i.e. sacrificing time for space. (Main motivation)
- Refactoring v8 code to improve startup speed.
- Reduce v8 code complexity.
Each bytecode specifies its input and output as register operands. Ignition using registers R0, R1, R2… And the Accumulator register. Almost all bytecodes use accumulator registers. It is like a regular register, except that the bytecode is not specified. For example, Add R1 adds the value in register R1 to the value in the accumulator. This makes bytecode shorter and saves memory.
Many bytecodes begin with Lda or Sta. A in Lda and Stastands is an accumulator. For example, LdaSmi [42] loads the small integer (Smi) 42 into the accumulator register. Star r0 stores the value currently in the accumulator in register R0.
With the basics now in hand, take a moment to look at a working bytecode.
function incrementX(obj) {
return 1 + obj.x;
}
incrementX({ x: 42 }); // The V8 compiler is lazy, and V8 will not interpret a function if it is not run
Copy the code
If you want to see V8 JavaScript bytecode, you can print it by adding –print-bytecode to the command line argument and running D8 or Node.js (8.3 or later). For Chrome, start Chrome from the command line with –js-flags=”–print-bytecode”, see Run Chromium with Flags.
$ node --print-bytecode incrementX.js
...
[generating bytecode for function: incrementX]
Parameter count 2
Frame size 8
12 E> 0x2ddf8802cf6e @ StackCheck
19 S> 0x2ddf8802cf6f @ LdaSmi [1]
0x2ddf8802cf71 @ Star r0
34 E> 0x2ddf8802cf73 @ LdaNamedProperty a0, [0], [4]
28 E> 0x2ddf8802cf77 @ Add r0, [6]
36 S> 0x2ddf8802cf7a @ Return
Constant pool (size = 1)
0x2ddf8802cf21: [FixedArray] in OldSpace
- map = 0x2ddfb2d02309 <Map(HOLEY_ELEMENTS)>
- length: 1 0: 0x2ddf8db91611 <String[1]: x>
Handler Table (size = 16)
Copy the code
We ignore most of the output and focus on the actual bytecode.
This is what each bytecode means, each line:
LdaSmi [1]
Star r0
Next, Star R0 stores the value 1 currently in the accumulator in register R0.
LdaNamedProperty a0, [0], [4]
LdaNamedProperty loads the named property of A0 into the accumulator. The AI points to the i-th parameter of incrementX(). In this example, we look for a named attribute on A0, which is the first argument to incrementX(). The attribute name is determined by the constant 0. LdaNamedProperty uses 0 to look up the name in a separate table:
- length: 1
0: 0x2ddf8db91611 <String[1]: x>
Copy the code
And you can see that 0 maps to x. So this line of bytecode means load obj.x.
So what does an operand of 4 do? It is the index of the feedback vector of the function incrementX(). The feedback vector contains runtime information for performance optimization.
The register now looks like this:
Add r0, [6]
The last instruction adds R0 to the accumulator, resulting in 43. 6 is another index of the feedback vector.
Return Returns the value in the accumulator. The return statement is the end of the function incrementX(). At this point the caller to incrementX() can get a value of 43 in the accumulator and can further process this value.
Why is the V8 so fast?
Due to the nature of the JavaScript weak language (a variable can be assigned to different data types) and its flexibility, allowing us to add or remove properties and methods on objects at any time, the JavaScript language is very dynamic, and we can imagine that this makes compiling engines much more difficult, however difficult, But not the V8, which uses a number of technologies to speed things up:
Inlining:
The inlining feature is fundamental to any optimization and is critical to good performance. Inlining means that if a function calls another function internally, the compiler replaces the function method with the execution content of the function. As shown below:
How do you understand that? Look at the following code
function add(a, b) {
return a + b;
}
function calculateTwoPlusFive() {
var sum;
for (var i = 0; i <= 1000000000; i++) {
sum = add(2 + 5); }}var start = new Date(a); calculateTwoPlusFive();var end = new Date(a);var timeTaken = end.valueOf() - start.valueOf();
console.log("Took " + timeTaken + "ms");
Copy the code
Due to the inline attribute feature, the code is optimized to
function add(a, b) {
return a + b;
}
function calculateTwoPlusFive() {
var sum;
for (var i = 0; i <= 1000000000; i++) {
sum = 2 + 5; }}var start = new Date(a); calculateTwoPlusFive();var end = new Date(a);var timeTaken = end.valueOf() - start.valueOf();
console.log("Took " + timeTaken + "ms");
Copy the code
Without inline attributes, can you imagine how slow things would be? To embed the first section of JS code into the HTML file, we opened it in different browsers (hardware environment: I7, 16GB ram, MAC system), and opened it in Safari, as shown in the picture below, for 17 seconds:
If you open it in Chrome, it’s less than 1 second, 16 seconds faster!
Hidden Class:
In statically typed languages such as C++/Java, each variable has a uniquely determined type. Because of the type information, which members an object contains and the offsets of those members within the object can be determined at compile time. At execution time, the CPU only needs to access the internal members with the object’s header address — in C++ this pointer — plus the member’s internal offset. These access instructions are generated at compile time.
But in a dynamic language like JavaScript, variables can be assigned at run time by different types of objects, and objects themselves can be added and removed at any time. The information needed to access object properties is entirely up to the runtime. To enable indexed access to members, V8 “secretly” classes running objects, generating a kind of internal V8 data structure called hidden classes in the process. The hidden class itself is an object.
Consider the following code:
function Point(x, y) {
this.x = x;
this.y = y;
}
var p1 = new Point(1.2);
Copy the code
If new Point(1, 2) is called, the V8 engine creates a hidden class C0, as shown below:
Since Point is not specified on any property, C0 is null
Once this.x = x is executed, the V8 engine creates a second hidden class named “C1”. Based on “c0”, “c1” describes the location in memory (equivalent to a pointer) where attribute X can be found. In this case, the hidden class switches from C0 to C1, as shown below:
Each time a new property is added to an object, the old hidden class is switched to the new hidden class by path transformation. Because of the importance of transformation, because the engine allows objects to be created in the same way to share hidden classes. If two objects share a hidden class, and you add the same attributes to both objects, the transformation ensures that the two objects use the same hidden class with all the code optimizations.
When this.y = y, a hidden class of C2 is created, and the hidden class is changed to C2.
The performance of the hidden class transformation depends on the order in which the attributes are added, as shown in the following code:
function Point(x, y) {
this.x = x;
this.y = y;
}
var p1 = new Point(1.2);
p1.a = 5;
p1.b = 6;
var p2 = new Point(3.4);
p2.b = 7;
p2.a = 8;
Copy the code
You might think P1 and P2 use the same hidden classes and transformations, but they do not. For P1 objects, the hidden class is a and then B, and for P2 objects, the hidden class is B and then A, resulting in different hidden classes, increasing the computational overhead of compilation. In this case, the object properties should be dynamically modified in the same order so that the hidden classes can be reused.
Inline caching
- The normal process of accessing an object property is to first get the address of the hidden class, then look up the offset value based on the property name, and then compute the address of the property. It’s a lot less work than it used to be to find the entire execution environment, but it’s still time consuming. Can you cache the results of previous queries for re-access? Of course it does, it’s called inline caching.
- The idea behind inline caching is to save the first lookup of the hidden class and its offset value. The next lookup compares whether the current object is the previous hidden class, and if so, directly uses the previous cache result to reduce the time to look up the table again. Of course, if an object has more than one attribute, then the probability of cache errors increases, because when the type of an attribute changes, the hidden class of the object also changes, which is inconsistent with the previous cache, and the hash table needs to be found again in the previous way.
Memory management
The memory management group consists of two parts: allocation and reclamation. V8 memory is divided as follows:
- Zone: manages small memory blocks. The Zone allocates a small memory and manages and allocates some small memory. After a small memory is allocated, it cannot be reclaimed by the Zone. Only the small memory allocated by the Zone can be reclaimed at a time. If a process requires a lot of memory, the Zone needs to allocate a large amount of memory, which cannot be reclaimed in a timely manner, resulting in insufficient memory.
- Heap: Manages data used by JavaScript, generated code, hash tables, and so on. To facilitate garbage collection, the heap is divided into three parts: 1. Young generation: To allocate memory space for newly created objects, garbage collection is often required. To facilitate the collection of content in the young generation, you can divide the young generation into two halves, with one half for allocation and the other half for copying over objects that need to be retained before collection. 2. Old generation: Old objects, Pointers, codes and other data are saved according to needs and garbage collection is less. 3. Large objects: Allocate memory for objects that need to use a lot of memory. Of course, it may also contain memory allocated for data and code, etc. Only one object is allocated per page.
The garbage collection
V8 uses generational and big data memory allocation, using a compact collation algorithm to mark unreferenced objects when reclaiming memory, then eliminate unmarked objects, and finally collate and compress objects that have not yet been saved to complete garbage collection.
To control GC costs and make execution more stable, V8 uses incremental markers. Instead of traversing the entire heap, it tries to mark every possible object, traverses only a portion of the heap, and then resumes normal code execution. The next GC will pick up where the previous iteration stopped. This allows for very short pauses during normal execution. As mentioned earlier, the scanning phase is handled by a separate thread.
To optimize the fallback
V8 to further improve the efficiency of JavaScript code execution, the compiler directly generates more efficient machine code. V8 collects JavaScript code execution data while the program is running. When V8 detects that a function is being executed frequently (the inline function mechanism), it marks it as a hot function. V8 has a more optimistic approach to hot-spot functions, preferring to assume that the function is stable and its type has been determined, so that the compiler generates more efficient machine code. Later in the run, in case of a type change, V8 reverts JavaScript functions to their pre-optimized compilation to the machine bytecode. Such as the following code:
function add(a, b) {
return a + b;
}
for (var i = 0; i < 10000; ++i) {
add(i, i);
}
add("a"."b"); // Don't do that!
Copy the code
Here’s another example:
/ / section 1
var person = {
add: function(a, b) {
returna + b; }}; obj.name ="li";
/ / section 2
var person = {
add: function(a, b) {
return a + b;
},
name: "li"
};
Copy the code
The above code does the same thing: it defines an object with a property name and a method add(). But using fragment 2 is more efficient. Section 1 adds an attribute name to the object obj, which results in the derivation of the hidden class. ** Adding and removing properties to objects dynamically generates new hidden classes. ** If the object’s add function has been optimized to produce more efficient code, the changed object cannot use the optimized code because of the addition or removal of attributes.
We can see from the example
The more certain the argument types are inside the function, the better V8 can generate optimized code.
conclusion
Well, that’s the end of this article. Now that you’ve said that, do you really understand how we can write more optimized code for the compiler’s taste?
Order of object properties: Object properties are always instantiated in the same order so that hidden classes and subsequently optimized code can be shared.
Dynamic properties: Adding properties to an object after instantiation forces class changes to be hidden and slows down any methods optimized for previously hidden classes. Instead, assign all the attributes of the object in the constructor.
Method: Code that executes the same method repeatedly will run faster than code that executes only once (due to inline caching).
Arrays: Avoid sparse arrays whose keys are not incremental numbers. A sparse array is a hash table. Element access costs in this array are high. In addition, try to avoid pre-allocating large arrays. It is better to allocate them on demand and add them automatically. Finally, do not delete elements in the array, it makes the keys sparse.