Última atualização:
Logo do motor V8 de execução JavaScript.
Logo do motor V8 de execução JavaScript.

Como funciona o JavaScript: por dentro do motor V8 e 5 dicas de como otimizar seu código

Atenção: este texto foi traduzido, editado e adaptado por @nawarian. Você encontra o texto original em inglês no blog do nosso parceiro SessionStack através deste link.

Há algumas semanas, começamos uma série que se aprofunda em JavaScript e como ele realmente funciona: pensamos que, conhecendo as peças elementares deste quebra-cabeças, você será capaz de escrever melhor seus códigos e aplicativos.

O primeiro post da série deu uma visão geral do motor, do tempo de execução e da pilha (stack). Este segundo post se aprofunda nas partes internas do motor JavaScript V8 do Google. Também trazemos algumas dicas rápidas sobre como escrever JavaScript melhor - as melhores práticas que nossa equipe de desenvolvimento na SessionStack segue ao construir o produto.

Visão Geral

O motor JavaScript é um programa (ou interpretador) que executa código JavaScript. Este motor pode ser implementado como um interpretador padrão ou um compilador Just in Time que transforma JavaScript em um tipo de bytecode.

Aqui vai uma lista de projetos populares que implementam um motor JavaScript:

  • V8 — de código aberto, desenvolvido pelo Google, escrito em C++
  • Rhino — de código aberto, gerenciado pela Mozilla Foundation, escrito em Java
  • SpiderMonkey — foi o primeiro motor JavaScript e rodava no navegador Netscape Navigator, e hoje em dia roda no Firefox
  • JavaScriptCore — de código aberto, desenvolvido pela Apple para o navegador Safari e conhecido como Nitro
  • KJS — motor do KDE desenvolvido por Harri Porten para o navegador Konqueror
  • Chakra (JScript9) — Internet Explorer
  • Chakra (JavaScript) — Microsoft Edge
  • Nashorn — de código aberto e parte do OpenJDK, escrito por Oracle Java Languages and Tool Group
  • JerryScript — motor leve para Internet das Coisas (IoT)

Por que o motor V8 foi criado?

O motor V8, criado pelo Google, é de código aberto, escrito em C++ e é utilizado pelo navegador Google Chrome. Diferente dos outros motores, V8 também é utilizado pelo ambiente Node.JS.

Logo do motor V8
Logo do motor V8

O motor V8 foi desenhado para aumentar a performance da execução de JavaScript em navegadores web. V8 traduz código JavaScript em código de máquina de forma eficiente em vez de utilizar um interpretador.

Ele compila código JavaScript em código de máquina utilizando uma técnica chamada JIT (Just-in-Time), técnica também utilizada por outros motores JavaScript modernos como o SpiderMonkey ou Rhino (Mozilla). A principal diferença é que o motor V8 não gera nenhum bytecode ou código intermediário.

V8 tinha dois compiladores

Antes da versão 5.9 do motor V8 ser lançada (no começo de 2017), o motor utilizava dois compiladores:

  • full-codegen — um compilador simples e rápido que produz código de máquina simples e relativamente lento.
  • Crankshaft — um compilador de otimizações mais complexo (Just-In-Time) que produz código ultra otimizado.

O motor V8 também utiliza diferentes threads internamente:

  • A thread principal (main thread) faz o que você poderia esperar: pega o seu código JavaScript, compila e executa
  • Há também uma thread separada somente para compilação, desta forma a thread principal consegue continuar executando enquanto a anterior otimiza o código
  • Uma thread de análise (profiler) que indica quais métodos são mais utilizados para que Crankshaft possa os otimizar
  • Algumas threads para Garbage Collection

Quando V8 roda um código JavaScript, o full-codegen é utilizado primeiro para transformar código Javascript em código de máquina sem nenhuma transformação. Isto permite que o motor execute código de máquina muito rápido. Note que V8 não utiliza um bytecode ou representação intermediária, portanto um interpretador não é necessário.

Quando o código já tiver rodado por algum tempo, a thread de análise terá coletado informações o suficiente para dizer quais métodos deveriam ser otimizados.

Daí otimizações no motor Crankshaft começam a executar em outra thread. Este motor transforma a Árvore de Sintaxe Abstrata (Abstract Syntax Tree, AST) em uma representação estática de atribuição única (static single-assignment, SSA) de alto nível chamada Hydrogen e tenta otimizar este grafo. A maior parte das otimizações acontecem aqui.

Código embutido

A primeira otimização é embutir o máximo de código possível de forma antecipada.

Veja o snippet abaixo com três funções:

Embutir código é o processo de substituir uma chamada de função pelo corpo daquela função chamada. Este passo permite que as otimizações seguintes tenham melhor efeito. O código acima embutido fica assim:

Classes escondidas

JavaScript é uma linguagem baseada em protótipos: não existem classes e objetos são criados utilizando um processo de clone. JavaScript também é uma linguagem de programação dinâmica e por isso podemos facilmente adicionar ou remover propriedades de um objeto depois de instanciado.

A maioria dos interpretadores JavaScript utilizam estruturas parecidas com dicionários (baseadas em funções hash) para gravar a localização dos valores das propriedades em memória. Esta estrutura faz com que obter o valor de uma propriedade em JavaScript seja mais lento do que em linguagens de programação não dinâmicas como Java ou C#.

Em Java, todas as propriedades de objeto são definidas por um layout fixo antes da compilação e não podem ser adicionadas ou removidas de forma dinâmica em tempo de execução (bom, o C# tem o tipo dynamic, mas esta é outra história).

O resultado é que os valores das propriedades (ou ponteiros para estas propriedades) podem ser armazenados como um buffer contínuo na memória com uma distância fixa entre si. O tamanho desta distância pode ser facilmente determinado pelo tipo da propriedade e isto não é possível com JavaScript já que o tipo de uma propriedade pode mudar em tempo de execução.

Já que utilizar dicionários para encontrar propriedades de um objeto em memória é ineficiente, o motor V8 utiliza um método diferente: classes escondidas. Estas classes escondidas funcionam de forma semelhante a estes objetos de layout fixo (classes) em linguagens como Java, só que criados em tempo de execução. Vamos ver como se parecem essas classes:

Assim que new Point(1, 2) é executado, o motor V8 cria uma classe escondida chamada C0.

Um objeto do tipo Point gera uma classe escondida chamada C0.
Um objeto do tipo Point gera uma classe escondida chamada C0.

Nenhuma propriedade foi definida ainda, então C0 está vazia.

Assim que o primeiro comando this.x = x executar (dentro da função Point), V8 vai criar uma nova classe escondida chamada C1, que é baseada em C0. C1 descreve o local em memória (relativo ao ponteiro do objeto) onde a propriedade x pode ser encontrada.

Neste caso, "x" é encontrada no deslocamento (offset) 0 - portanto quando se observar um objeto na memória como um buffer contínuo, o primeiro deslocamento se refere à propriedade "x".

V8 também atualiza C0 com uma "transição de classe" que diz que se a propriedade "x" for adicionada a um objeto Point, a classe escondida deverá ser trocada de C0 para C1. A classe escondida no objeto Point abaixo agora é C1.

Toda vez que uma nova propriedade for adiciona a um objeto, a antiga classe escondida é atualizada com um caminho de transição para a nova classe escondida. Transições de classes são importante porque elas permitem classes escondidas a serem compartilhadas entre objetos que são criados de forma semelhante. Se dois objetos compartilham a mesma classe escondida e a mesma propriedade é adicionada para os dois objetos, transições vão permitir que os dois objetos recebam a mesma nova classe escondida e todo o código otimizado que ela traz consigo.
Toda vez que uma nova propriedade for adiciona a um objeto, a antiga classe escondida é atualizada com um caminho de transição para a nova classe escondida. Transições de classes são importante porque elas permitem classes escondidas a serem compartilhadas entre objetos que são criados de forma semelhante. Se dois objetos compartilham a mesma classe escondida e a mesma propriedade é adicionada para os dois objetos, transições vão permitir que os dois objetos recebam a mesma nova classe escondida e todo o código otimizado que ela traz consigo.

Este processo se repete quando o comando this.y = y é executado (dentro da função Point, depois que this.x = x executa).

Uma nova classe escondida chamada C2 é criada, uma transição de classe é adicionada à C1 dizendo que se uma propriedade y for adicionada ao objeto Point (que já possui uma propriedade x) então a classe escondida deverá mudar para C2 e a classe escondida do objeto p1 passa a ser C2.

Um objeto do tipo Point que inicialmente possui uma classe escondida C0 e transita para as classes C1 e C2 assim que as propriedades x e y são adicionadas.
Um objeto do tipo Point que inicialmente possui uma classe escondida C0 e transita para as classes C1C2 assim que as propriedades xy são adicionadas.

Transformações de classes escondidas dependem da ordem em que as propriedades são adicionadas a um objeto. Vejamos o código abaixo:

É natural pensar que os objetos p1p2 utilizariam as mesmas classes escondidas e transições, mas não é isso o que acontece. Para p1, a propriedade a é inserida primeiro e depois inserimos a propriedade b. Para o objeto p2, a propriedade b é inserida primeiro e só então a propriedade a é inserida. Desta forma p1p2 geram classes escondidas e transições diferentes, já que suas propriedades foram inseridas em ordem diferente.

Neste caso é muito melhor inicializar propriedades dinâmicas na mesma ordem para que as mesmas classes escondidas sejam reutilizadas.

Inline caching

O motor V8 utiliza ainda uma outra técnica de otimização para linguagens dinamicamente tipadas chamada Inline CachingInline Caching assume que chamadas repetidas ao mesmo objeto costumam ocorrer com o mesmo tipo de objeto. Você pode ler mais sobre isso aqui.

Como isso funciona? O motor V8 mantem um cache dos tipos de objetos passados por parâmetro em chamadas de método recentes e usa esta informação pra assumir qual o tipo do objeto que será passado no futuro. Se o motor V8 for capaz de assumir corretamente o tipo do objeto que será passado, ele pode pular o processo de entender como acessar as propriedades de um objeto e, em vez disso, usar a informação armazenada das classes escondidas dos últimos objetos utilizados como parâmetro.

Como os conceitos de classe escondida e inline caching se relacionam?

Sempre que um método é chamado num objeto específico, o motor V8 precisa identificar a classe escondida para entender como acessar suas propriedades em memória. Depois de duas chamadas ao mesmo método passando a mesma classe escondida, V8 passar a pular o processo de identificar a classe escondida e adiciona um ponteiro para a propriedade do objeto em si. Para todas as chamadas futuras a este método, o motor V8 assume que a classe escondida não mudou, e pula diretamente para o endereço de memória da propriedade específica utilizando deslocamentos (offsets) das últimas chamadas.

Isso aumenta em muito a velocidade de execução.

Inline caching é outra razão pela qual é tão importante que objetos compartilhem as mesmas classes escondidas.

Se você criar dois objetos do mesmo tipo com classes escondidas diferentes (como fizemos anteriormente), o motor V8 não será capaz de realizar o inline caching porque mesmo que os dois objetos sejam do mesmo tipo, as classes escondidas destes objetos possuem deslocamentos (offsets) diferentes para suas propriedades.

Os dois objetos são basicamente os mesmos, mas as propriedades a e b foram criadas em ordem diferentes.
Os dois objetos são basicamente os mesmos, mas as propriedades ab foram criadas em ordem diferentes.

Compilação para código de máquina

Assim que o grafo Hydrogen é otimizado, Crankshaft o traduz para uma representação de baixo nível chamada Lithium. A maior parte da implementação de Lithium é específica para a arquitetura do computador em que roda. A alocação de registradores acontece neste nível.

Por fim, Lithium é compilado para código de máquina. E então um outro processo chamado Substituição na Pilha (on-stack replacement, OSR) acontece.

Antes de começarmos a compilar e otimizar um método lento, nós provavelmente estávamos executando este método. O motor V8 não vai simplesmente esquecer a implementação lenta e começar a utilizar a implementação otimizada. Em vez disso, o motor transforma todo o contexto que temos (pilha, registradores) para que possamos mudar de implementação em tempo de execução.

Esta é uma tarefa complexa, dado que dentre outras otimizações, o motor V8 já embutiu código anteriormente. V8 não é o único motor capaz de utilizar a técnica de OSR.

Há alguns mecanismos de segurança para reverter uma otimizações caso alguma suposição que o motor vez não seja mais verdadeira.

Coleta de lixo

Para a coleta de lixo (garbage collection), o motor V8 utiliza o tradicional método "marque e limpe".

A fase de demarcar bloqueia a execução do JavaScript.

Para controlar os custos da coleta de lixo e tornar a execução mais estável, o motor V8 utiliza marcação incremental: em vez de iterar toda a heap e tentar marcar tudo quanto é objeto, o motor itera apenas uma parte da heap e depois volta a executar o código JavaScript. A próxima coleta de lixo continua de onde a última iteração da heap parou.

Isso permite pequenas pausas durante a execução normal. Como mencionado antes, a faze de limpar o que foi marcado acontece em threads distintas.

Ignition e TurboFan

Com o release da V8 5.9 no começo 2017, uma estratégia de execução foi introduzida ao motor. Esta nova estratégia alcança ainda maiores melhorias em performance e menor utilização de memória em aplicações JavaScript reais.

A nova estratégia de execução é baseada no novo interpretador do motor V8, Ignition, e o novo compilador de otimizações TurboFan.

Você pode ver o post sobre este tópico, escrito pelo time que desenvolve o motor V8 neste link.

Desde a versão 5.9 do motor V8, full-codegenCrankshaft (tecnologias utilizadas no motor V8 desde 2010) não foram mais usadas pelo motor para execução de JavaScript já que o time V8 não consegue facilmente adicionar as novas funcionalidades da linguagem JavaScript e manter as otimizações necessárias para estas funcionalidades.

Isto significa que o motor V8 manterá uma arquitetura muito mais simples e de fácil manutenção daqui pra frente.

Benchmarks executados em navegadores Web e no Node.JS.
Benchmarks executados em navegadores Web e no Node.JS.

Estas melhorias são só o começo. A nova estratégia Ignition TurboFan moldaram o caminho para outras otimizações que vão melhorar ainda mais a performance do JavaScript e diminuir o consumo de recursos do motor V8 no navegador Chrome e no ambiente Node.JS pelos próximos anos.

Por fim, aqui vão algumas dicas e truques sobre como escrever JavaScript otimizado. Você pode derivar estas dicas do texto que leu, mas aqui vai um resumo pra você:

5 Dicas para escrever JavaScript otimizado

  1. Ordem das propriedades dos objetos: sempre instancie as propriedades de seus objetos na mesma ordem para que classes escondidas, e posteriormente código otimizado, possam ser compartilhados.
  2. Propriedades dinâmicas: adicionar propriedades à um objeto após instanciação vai forçar a classe escondida a mudar e diminuir a performance de qualquer método que esteja otimizado para a classe escondida anterior. Em vez disso, adicione todas as propriedades de um objeto em seu construtor.
  3. Métodos: um código que execute o mesmo método várias vezes executa mais rápido do que um código que rode vários métodos diferentes apenas uma vez, por causa do inline caching.
  4. Arrays: evite arrays em que as chaves não sejam números em sequência. Arrays não sequenciais são tratados como uma hash table. Elementos nestes arrays são mais difíceis de acessar. É bom também evitar pré alocar arrays grandes. É melhor aumentar o tamanho do array conforme for necessário. Por fim, não remova elementos em arrays. Remover elementos torna os arrays descontínuos (os transforma em hash tables).
  5. Valores inteiros: o motor V8 representa objetos e números utilizando 32 bits. Um desses bits é utilizado como flag para entender se aquele espaço de memória é um objeto (flag = 1) ou um inteiro (flag = 0). Este inteiro então só pode possuir 31 bits e é chamado de Inteiro Pequeno (Small Integer, SMI). Se um valor numérico for maior do que os 31 bits conseguem representar, o motor V8 vai encapsular este número, transformando-o em um double e criando um novo objeto para alocar o número dentro. Tente utilizar números de 31 bits sempre que possível para evitar estas operações de encapsulamento.

Nós da SessionStack tentamos seguir estas melhores praticas quando escrevemos código JavaScript super otimizado. O motivo pelo qual buscamos estas práticas é que assim que você integrar SessionStack no seu aplicativo em produção, ele grava tudo: mudanças no DOM, interações de usuários, erros de JavaScript, stack traces, chamadas de rede que falharam e mensagens de depuração. Com SessionStack você pode reproduzir problemas da sua aplicação como se fossem vídeos e ver tudo o que aconteceu com seu usuário. E tudo isso precisa acontecer sem causar nenhum impacto na sua aplicação.
Nós temos um plano gratuito que te permite começar de graça.

Ilustração de como o software SessionStack permite reproduzir vídeos da sessão de seus usuários.
Ilustração de como o software SessionStack permite reproduzir vídeos da sessão de seus usuários.

Fontes

Comentários