No artigo sobre Arrays, um array foi descrito como um bloco de slots do mesmo tamanho, lado a lado na memória. Se você escreve Go, essa descrição bate com o que você digita todo dia. Se você escreve JavaScript, provavelmente não: você nunca diz a um array em JavaScript qual o tamanho dele, o que ele guarda ou quando ele deve crescer.
Aqui está a mesma lista de quatro números nas duas linguagens:
As duas linguagens rodam no mesmo hardware, e o hardware só sabe fazer um tipo de array. Este artigo mostra o que cada linguagem faz com isso, usando o Uint32Array para medir quanto um array em JavaScript custa de verdade em memória.
Por que o Go pede o tamanho
No Go, o tamanho do array faz parte do tipo. [4]uint32 e [5]uint32 são tipos diferentes, tão diferentes quanto int e string:
Como o tipo carrega o tamanho, o compilador sabe exatamente quantos bytes cada array ocupa antes do programa rodar. Um [4]uint32 tem 4 elementos × 4 bytes = 16 bytes, nem um a mais:
Não existe header, campo de capacity nem tag de tipo guardados junto com os dados. O acesso por índice é o cálculo início + índice × 4 do artigo anterior, e o compilador pode colocar o array inteiro na stack ou direto dentro de uma struct, porque sabe quantos bytes reservar.
O preço é a rigidez que apareceu no snippet em Go: o tamanho não muda e precisa ser conhecido quando você escreve o código. Para o resto, o Go tem os slices, um header pequeno (ponteiro, length e capacity: 24 bytes em 64 bits) que aponta para um array e troca ele por um maior quando enche. O artigo de Arrays e slices explica como eles crescem. O que importa aqui é que o Go deixa a escolha explícita: você escolhe entre um array fixo e um slice que cresce, e o tipo dos elementos é sempre fixo.
O que um Array em JavaScript realmente é
A especificação do ECMAScript não define Array como um bloco de memória. Ela define como um exotic object: um objeto comum, com chaves do tipo string, que trata de forma especial as chaves que parecem índices (inteiros de 0 a 2³² − 2) e mantém uma propriedade length sempre uma unidade acima do maior índice.
Essa definição explica tudo que o JavaScript deixa você fazer com um array:
Nada disso se encaixa na definição do artigo anterior. Não tem tamanho fixo por elemento, não tem garantia de contiguidade e não tem tamanho fixo. Por isso alguns livros e cursos nem chamam o Array do JavaScript de array: tratam como um objeto parecido com lista, ou um hash map com chaves inteiras. Do ponto de vista da especificação, eles estão certos.
Como o V8 deixa isso rápido mesmo assim
Se as engines de JavaScript implementassem essa especificação ao pé da letra, todo arr[i] seria uma busca numa hash table. Elas não fazem isso. O V8 (a engine por trás do Chrome e do Node.js) acompanha o que cada array contém e, sempre que dá, guarda os elementos num array contíguo de verdade por baixo dos panos.
Ele faz isso atribuindo a cada array um elements kind. Os três principais são:
| Elements kind | Guarda | Armazenado como |
|---|---|---|
PACKED_SMI_ELEMENTS | Só inteiros pequenos (SMIs) | Inteiros com tag, um por slot |
PACKED_DOUBLE_ELEMENTS | Números, incluindo frações e inteiros altos | Floats de 64 bits crus, um por slot |
PACKED_ELEMENTS | Qualquer coisa (strings, objetos, misturas) | Um ponteiro por slot |
Cada um tem também uma variante HOLEY_ para arrays com buracos, e um array esparso demais cai para DICTIONARY_ELEMENTS, que é uma hash table de fato. Dá para ver as transições no Node com a flag --allow-natives-syntax:
As transições só andam num sentido. Depois que a recebe uma string, remover a string não faz ele voltar para PACKED_DOUBLE_ELEMENTS, e um array que ganhou um buraco continua HOLEY_ mesmo depois que você preenche o buraco.
O crescimento funciona como nos arrays dinâmicos do artigo anterior, com outro fator. Quando o array fica sem espaço, o V8 aloca new_length + new_length / 2 + 16 slots e copia os elementos. Fazer push de um milhão de inteiros, um por vez, causa 26 realocações e deixa o array com espaço para 1.304.209 elementos.
Se quiser ir mais fundo, o post Elements kinds in V8 (abre em nova janela), do próprio time do V8, cobre todos os kinds e transições. Os kinds estão declarados em src/objects/elements-kind.h (abre em nova janela), e os métodos de Array ficam em src/builtins (abre em nova janela).
Uint32Array: um array de verdade em JavaScript
O JavaScript tem, sim, arrays no sentido do artigo anterior: os typed arrays. Um Uint32Array é uma view sobre um ArrayBuffer, um bloco cru de bytes, e se comporta muito mais como o [N]uint32 do Go do que como o Array:
O tamanho é fixo, todo elemento é um inteiro de 32 bits sem sinal, e não tem push, buracos nem tipos misturados. Em troca, o layout na memória é exatamente o do artigo anterior:
4 bytes por elemento, 16 bytes no total
Compare com um Array que passou para PACKED_ELEMENTS. Os slots continuam contíguos, mas cada um tem 8 bytes, e os valores que não são SMIs moram em outro lugar do heap:
Slots de 8 bytes; os índices 1 e 2 apontam para objetos separados no heap
Um buffer, várias views
Um Uint32Array é um jeito de ler um ArrayBuffer, não o buffer em si. O buffer é só um monte de bytes, sem tipo de elemento, e dá para colocar várias views sobre os mesmos bytes ao mesmo tempo. Oito bytes podem ser lidos como oito uint8, quatro uint16, dois uint32 ou um único float64:
Cada view aplica a fórmula início + índice × tamanho com o seu próprio tamanho de elemento, então o mesmo índice cai em bytes diferentes:
| View | Tamanho do elemento | Elementos | Índice i cobre os bytes |
|---|---|---|---|
Uint8Array | 1 byte | 8 | i |
Uint16Array | 2 bytes | 4 | 2i a 2i + 1 |
Uint32Array | 4 bytes | 2 | 4i a 4i + 3 |
Float64Array | 8 bytes | 1 | 0 a 7 |
Como elas dividem a mesma memória, escrever por uma view muda o que as outras leem:
shorts[0] vale 772 porque lê os bytes 0 e 1 (0x0304), e shorts[1] vale 258 (0x0102). Depois de doubles[0] = 1, os mesmos oito bytes guardam a codificação IEEE 754 de 1.0, que o Uint32Array lê como 0 e 1.072.693.248. Nada nos bytes diz qual leitura é a certa: quem decide é a view. É o mesmo motivo pelo qual C e Go pedem o tipo do elemento, visto pelo outro lado. A memória não sabe o que guarda, e é o tipo que diz ao programa quantos bytes pular por índice e como interpretá-los.
As views também precisam estar alinhadas com o tamanho do elemento. new Uint32Array(buffer, 2) lança um RangeError, porque um elemento de 4 bytes não pode começar no byte 2, e um Uint32Array também não consegue cobrir um buffer de 6 bytes.
Medindo o consumo de memória
Para colocar números nisso, aloquei um milhão de elementos em cada formato e medi o heap (e, no caso do typed array, a memória de ArrayBuffer) antes e depois, forçando o garbage collector dos dois lados:
No Node.js 24 (V8 13.6, Linux 64 bits), com o equivalente em Go via unsafe.Sizeof como referência:
| Container | Elements kind | Bytes por elemento |
|---|---|---|
Go [1_000_000]uint32 | n/a | 4,00 |
Uint32Array | n/a | 4,00 |
Array.from com inteiros pequenos | PACKED_SMI_ELEMENTS | 8,00 |
push com inteiros pequenos | PACKED_SMI_ELEMENTS | 10,44 |
push com números fracionários | PACKED_DOUBLE_ELEMENTS | 10,44 |
push com valores misturados | PACKED_ELEMENTS | 18,44 |
Os números batem com os layouts acima:
- 4 bytes são os dados e mais nada, tanto no Go quanto no typed array.
- 8 bytes é um slot com tag por SMI, ou um double cru por número. Sem pointer compression, cada slot tem 8 bytes, então inteiros pequenos que caberiam em 4 bytes ocupam 8.
- 10,44 bytes é o mesmo slot de 8 bytes somado à capacity sobrando que a fórmula de crescimento deixa: 1.304.209 slots × 8 bytes divididos por um milhão de elementos.
- 18,44 bytes inclui os números em caixa. Metade dos valores não cabe num SMI, e cada um deles vira um objeto separado de 16 bytes no heap, atrás de um ponteiro.
O array misturado usa 4,6 vezes a memória do Uint32Array para o mesmo um milhão de números, e todos eles cabem em 32 bits. No Chrome, a pointer compression corta o slot pela metade, então a diferença é menor, mas o formato do resultado é o mesmo.
O que a gente perde em troca de developer experience
O Array do JavaScript foi pensado para você nunca precisar pensar em memória. Você não escolhe tamanho, não escolhe tipo, e o array nunca fica "cheio". A engine faz isso funcionar adivinhando, acompanhando e convertendo por baixo dos panos, e você paga em alguns lugares:
- Memória: no mínimo 2× os bytes de um typed array para inteiros no Node.js, mais a sobra do crescimento, mais um objeto no heap para cada número que não é SMI.
- Previsibilidade: se
arr[i]é um cálculo direto de offset, um ponteiro a seguir ou uma busca num dicionário depende do histórico do array, não da declaração dele. Um únicoarr.push('')ouarr[1e6] = 1muda a representação para sempre. - Localidade: em
PACKED_ELEMENTS, os slots são contíguos mas os valores não. Percorrer o array significa seguir ponteiros para objetos espalhados, o que perde a vantagem de cache descrita no artigo anterior. - Garantia de tipo: nada impede uma string de parar num array de preços.
O que você ganha em troca é real: código mais curto, nenhum erro de índice fora do limite em tempo de compilação para brigar, e um array que se adapta ao que o programa fizer com ele. Para a maior parte do código de aplicação (uma lista de usuários, os itens de um carrinho) a diferença de bytes não importa, e o Array é a escolha certa.
Os typed arrays compensam a rigidez quando os dados são grandes, numéricos e uniformes:
- Dados binários: arquivos, pacotes de rede, pixels de imagem (o
ImageDatado Canvas usa umUint8ClampedArray). - Amostras de áudio e vertex buffers de WebGL, onde as APIs esperam typed arrays diretamente.
- Trabalho numérico em milhões de valores, onde 4 ou 8 bytes previsíveis por elemento ganham de 10 a 18.
- Compartilhar memória com WebAssembly, cuja memória linear é um
ArrayBuffer.
Resumo
| Container | Tamanho definido | Tipo dos elementos | Cresce | Bytes por valor do tamanho de um uint32 (Node.js) |
|---|---|---|---|---|
Go [N]uint32 | Em tempo de compilação | Fixo | Não | 4 |
Go []uint32 | Em tempo de execução | Fixo | Sim (append) | 4 + capacity sobrando |
JS Uint32Array | Na criação | Fixo (uint32) | Não | 4 |
JS Array | Nunca, cresce sob demanda | Qualquer um | Sim | De 8 a 18+, conforme o elements kind |
- No Go, o tamanho do array faz parte do tipo, então o compilador sabe exatamente quantos bytes ele ocupa e o layout é o do artigo anterior: os dados e mais nada.
- O
Arraydo JavaScript é especificado como um objeto com chaves string e umlengthespecial. Por isso alguns autores nem consideram ele um array. - O V8 esconde isso acompanhando elements kinds e guardando os elementos de forma contígua quando dá, mas a representação depende do que o array já guardou e só fica mais genérica.
- O
Uint32Arrayé o array de verdade do JavaScript: tamanho fixo, tipo fixo, exatamente 4 bytes por elemento sobre umArrayBuffer. - Um
ArrayBuffernão tem tipo de elemento. Cada view de typed array traz o seu próprio tamanho de elemento, então os mesmos 8 bytes podem ser 8, 4, 2 ou 1 elemento, e escrever por uma view muda o que as outras leem. - No Node.js 24, o mesmo um milhão de inteiros ocupou 4 bytes cada num
Uint32Arraye entre 8 e 18,44 bytes cada numArray, dependendo de como ele foi construído e do que mais ele guardava.