int age = 25;
int *value = malloc(sizeof(int));
*value = 100;
O que muda entre essas duas alocações?
Se você já programou em C, provavelmente lembrou de stack, heap, ponteiros e da necessidade de chamar free. Se trabalha principalmente com JavaScript, talvez tenha pensado: “ainda bem que não preciso cuidar disso”.
O JavaScript realmente abstrai boa parte desse trabalho. Mas entender memória continua ajudando a explicar por que dois objetos mudam juntos, por que uma cópia não ficou independente ou por que uma aplicação mantém dados que já deveriam ter sido descartados.
Este é o terceiro artigo da série “Coisas que usamos todos os dias, mas raramente paramos para entender.” Depois de explorar o DOM e o Event Loop, vamos olhar para o que sustenta os dados usados pelo nosso código: a memória.
A ideia é começar em C, onde algumas responsabilidades ficam explícitas, e conectar esses conceitos ao JavaScript com exemplos para reproduzir.
1. Antes de tudo: o que significa alocar memória?
Durante a execução de um programa, seus dados precisam ser representados em algum lugar. Alocar memória significa disponibilizar espaço para armazená-los; usar essa memória envolve ler e escrever valores; desalocar significa tornar o espaço disponível para reutilização.
Isso não significa necessariamente apagar os bytes imediatamente ou devolver memória ao sistema operacional naquele instante. São coisas diferentes: um alocador pode manter regiões disponíveis para atender a futuras solicitações do próprio processo.
Também precisamos separar dois conceitos que aparecem juntos com frequência:
- Escopo: em que parte do código um nome pode ser acessado.
- Lifetime, ou tempo de vida: durante qual período um objeto existe.
Um nome deixar de estar acessível em determinado trecho não significa, por si só, que todos os dados associados a ele deixaram de existir. Essa distinção será importante quando chegarmos aos objetos e às closures em JavaScript.
2. Voltando ao exemplo em C
Vamos colocar o código dentro de uma função e tratar uma condição importante: malloc pode falhar.
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int age = 25;
int *value = malloc(sizeof *value);
if (value == NULL) {
return EXIT_FAILURE;
}
*value = 100;
printf("age: %d\n", age);
printf("*value: %d\n", *value);
free(value);
value = NULL;
return EXIT_SUCCESS;
}
Aqui temos três coisas que precisam ser distinguidas:
| Elemento | O que representa | Tempo de vida neste exemplo |
|---|---|---|
age |
Um objeto do tipo int
|
Duração automática, associada à execução do bloco |
value |
Uma variável do tipo ponteiro para int
|
Também tem duração automática |
Espaço obtido por malloc
|
Armazenamento para o inteiro acessado por *value
|
Permanece alocado até free
|
Portanto, não temos simplesmente “uma variável na stack e outra no heap”. Temos uma variável inteira, uma variável ponteiro e uma alocação dinâmica acessada por esse ponteiro.
Em implementações comuns, variáveis locais como essas podem ficar na stack, enquanto malloc fornece memória de uma região chamada heap. Mas a linguagem C descreve essas diferenças por meio de duração de armazenamento e regras de uso, sem exigir esse posicionamento físico específico. O compilador também pode manter valores em registradores ou eliminar armazenamento por otimização.
Para entender o exemplo, a diferença mais importante é quem controla o tempo de vida do armazenamento.
3. Stack: memória ligada às chamadas de função
No artigo sobre Event Loop, usamos a call stack para acompanhar chamadas de função. Em implementações comuns, cada chamada pode ter uma estrutura chamada stack frame, que mantém informações necessárias para executar a função e retornar à anterior.
Dependendo da implementação e das otimizações, esse frame pode conter parâmetros, variáveis locais e valores temporários.
Considere:
void mostrarIdade(void) {
int age = 25;
printf("%d\n", age);
}
O objeto local age tem duração automática. Não precisamos chamar free para ele: seu tempo de vida termina quando saímos do bloco correspondente.
Esse modelo combina com a organização de uma pilha: chamadas entram e, ao retornar, deixam de precisar de suas informações de execução.
Mas isso também traz limites. Se uma sequência de chamadas cresce demais, como numa recursão sem condição de parada, podemos esgotar o espaço disponível para a pilha.
Stack e heap não são duas categorias de tipos de dados. São conceitos relacionados à organização da memória e da execução.
4. Heap: armazenamento com tempo de vida independente da chamada
Agora imagine que precisamos criar um valor em uma função e utilizá-lo depois que ela retornar:
int *criarValor(void) {
int *value = malloc(sizeof *value);
if (value != NULL) {
*value = 100;
}
return value;
}
A variável local value deixa de existir ao terminar a função. Mas o armazenamento obtido por malloc continua alocado, e a função retorna o ponteiro necessário para acessá-lo.
Quem recebe esse ponteiro precisa assumir a responsabilidade pela liberação:
int *result = criarValor();
if (result != NULL) {
printf("%d\n", *result);
free(result);
result = NULL;
}
Esse é um dos motivos para usar alocação dinâmica: o armazenamento pode continuar existindo além da chamada que o solicitou.
Se perdemos todos os ponteiros necessários para liberar uma alocação, ela pode continuar ocupando memória sem que o programa consiga recuperá-la pelo caminho esperado. Esse é um exemplo de vazamento de memória.
Também existe o problema contrário: acessar o armazenamento depois de liberá-lo. Em C, isso resulta em comportamento indefinido.
5. Endereço, ponteiro e valor são coisas diferentes
Podemos pensar em um endereço como uma forma de identificar uma localização no espaço de memória do processo. Um ponteiro permite referenciar um objeto por meio dessa localização.
No exemplo:
int age = 25;
int *agePointer = &age;
printf("%d\n", *agePointer); // 25
Os símbolos têm papéis diferentes:
-
&ageobtém o endereço deage. -
agePointerguarda um ponteiro para esse objeto. -
*agePointeracessa o inteiro apontado.
Assim, podemos modificar o mesmo objeto por outro caminho:
*agePointer = 30;
printf("%d\n", age); // 30
Não criamos uma segunda idade independente. Criamos outra forma de acessar a original.
Também vale reparar que sizeof *value, usado anteriormente, consulta o tamanho do tipo apontado. Nesse caso, ele não precisa ler o conteúdo da memória apontada para descobrir esse tamanho.
6. E quando escrevemos um objeto em JavaScript?
Em JavaScript, fazemos isto:
const user = {
name: "Lucas"
};
Não solicitamos um número de bytes nem recebemos um ponteiro manipulável como em C. O motor cuida da representação e da alocação.
Isso costuma ser explicado com a frase “primitivos ficam na stack e objetos ficam no heap”. Ela pode servir como um desenho inicial, mas não deve ser tratada como uma regra da linguagem.
O JavaScript não determina que todo número precise estar na stack ou que toda variável local tenha uma posição fixa ali. Motores como o V8 usam diferentes representações e otimizações.
Para quem está escrevendo a aplicação, o comportamento observável é mais útil: o objeto tem identidade e pode ser compartilhado por diferentes variáveis.
const user = {
name: "Lucas"
};
const anotherUser = user;
anotherUser.name = "Ana";
console.log(user.name); // "Ana"
console.log(user === anotherUser); // true
A atribuição não criou um segundo objeto. As duas variáveis permitem acessar o mesmo objeto.
Aqui usamos “referência” para explicar esse acesso compartilhado. Não significa que o JavaScript nos entregue um endereço de memória que podemos somar, imprimir ou liberar manualmente.
7. const não torna o objeto imutável
No exemplo anterior, declaramos user com const, mas conseguimos modificar sua propriedade. Isso acontece porque const impede a reatribuição da variável, não alterações no conteúdo do objeto.
const user = {
name: "Lucas"
};
user.name = "Ana"; // Permitido.
// user = { name: "Mariana" };
// TypeError: não podemos reatribuir essa variável.
Essa diferença importa ao compartilhar objetos entre funções, componentes e serviços. Uma variável constante não garante que o objeto acessado por ela permanecerá igual.
8. Passar um objeto para uma função não cria uma cópia
Considere:
function atualizarNome(person) {
person.name = "Ana";
}
const user = {
name: "Lucas"
};
atualizarNome(user);
console.log(user.name); // "Ana"
A função acessa o mesmo objeto e altera uma propriedade dele.
Mas observe o que acontece se reatribuirmos o parâmetro:
function substituirUsuario(person) {
person = {
name: "Mariana"
};
}
const user = {
name: "Lucas"
};
substituirUsuario(user);
console.log(user.name); // "Lucas"
JavaScript passa argumentos por valor. No caso de um objeto, o valor passado permite acessar o mesmo objeto; não é uma cópia independente de seu conteúdo. Reatribuir o parâmetro muda apenas a variável local da função.
Essa distinção explica por que alterar uma propriedade pode afetar o chamador, enquanto substituir o parâmetro não substitui sua variável original.
9. Spread faz uma cópia, mas ela é superficial
Uma tentativa comum de criar um objeto independente é usar spread:
const user = {
name: "Lucas",
address: {
city: "Brasília"
}
};
const copy = { ...user };
copy.name = "Ana";
copy.address.city = "Goiânia";
console.log(user.name); // "Lucas"
console.log(user.address.city); // "Goiânia"
O objeto externo foi copiado, mas o objeto address continua compartilhado.
console.log(user === copy); // false
console.log(user.address === copy.address); // true
Essa é uma cópia superficial: os valores das propriedades são copiados, sem duplicar recursivamente os objetos aninhados.
Para alterar a cidade preservando o objeto original, podemos copiar também o nível que será modificado:
const updatedUser = {
...user,
address: {
...user.address,
city: "São Paulo"
}
};
Isso aparece diretamente em atualizações de estado no React. Entender identidade e compartilhamento ajuda a evitar mutações acidentais, sem precisar decorar que “sempre tem que espalhar tudo”.
10. Garbage Collector: quem libera a memória em JavaScript?
JavaScript usa gerenciamento automático de memória. O Garbage Collector, ou GC, identifica armazenamento que pode ser recuperado.
Uma ideia central é a alcançabilidade: se um objeto ainda pode ser acessado a partir das raízes consideradas pelo motor, diretamente ou por uma cadeia de referências, ele permanece alcançável.
let user = {
name: "Lucas"
};
let selectedUser = user;
user = null;
O objeto continua acessível por selectedUser. Atribuir null a user não o libera.
selectedUser = null;
Se não houver outro caminho que mantenha esse objeto alcançável, ele poderá ser coletado. Isso não determina o instante da coleta.
Coletores modernos também conseguem lidar com ciclos de referências. Dois objetos apontarem um para o outro não impede, por si só, a coleta se o conjunto se tornar inalcançável.
O V8 combina técnicas de coleta, incluindo processamento geracional e trabalho incremental, paralelo ou concorrente. Isso reduz determinados custos, mas não torna a administração da memória gratuita.
11. Uma função terminar não significa que seus dados desapareceram
Closures deixam essa diferença bastante visível:
function criarContador() {
let total = 0;
return function incrementar() {
total += 1;
return total;
};
}
const contador = criarContador();
console.log(contador()); // 1
console.log(contador()); // 2
A chamada de criarContador terminou, mas a função retornada continua acessando total.
Uma closure mantém acesso ao ambiente léxico relevante para sua execução. Por isso, dados necessários à função podem continuar disponíveis depois que a chamada externa retorna.
Isso não significa que o stack frame inteiro precise ficar preservado ou que todas as variáveis daquela função sejam necessariamente mantidas. A implementação pode otimizar essa representação.
Closures são um recurso importante da linguagem. O cuidado é perceber quando uma função que permanece acessível também mantém dados de que já não precisamos.
12. Um exemplo prático: um cache que nunca esquece
Imagine um serviço Node.js que mantém relatórios em memória:
const reportsCache = new Map();
function cacheReport(id, report) {
reportsCache.set(id, report);
}
function getReport(id) {
return reportsCache.get(id);
}
Se esse Map continuar acessível e receber novos IDs indefinidamente, os relatórios armazenados continuam alcançáveis. O GC não sabe que um relatório antigo deixou de ter utilidade para o negócio.
Podemos explicitar sua remoção:
function removeReport(id) {
reportsCache.delete(id);
}
Isso remove o caminho de acesso pelo cache. Se outro trecho ainda mantiver o relatório, ele continuará acessível por lá.
Uma política real pode usar limite de entradas, expiração com remoção efetiva ou descarte dos itens menos utilizados. Apenas registrar uma data de expiração não libera nada se as entradas continuarem armazenadas.
O GC cuida da recuperação automática; a aplicação continua responsável por decidir o que precisa permanecer acessível.
13. Como investigar isso na prática?
Para investigar uma suspeita de retenção no navegador, podemos reproduzir repetidamente uma ação, abrir e fechar uma tela, por exemplo, e comparar heap snapshots no DevTools.
O objetivo é observar quais objetos continuam retidos e quais caminhos os mantêm acessíveis. O tamanho de um objeto isolado pode ser pequeno, mas suas referências podem manter uma estrutura muito maior.
Também precisamos interpretar o comportamento com cuidado. A memória não precisa cair imediatamente depois de uma ação, e subir temporariamente não prova um vazamento. O motor pode adiar a coleta e reutilizar espaço já reservado.
A pergunta útil é: depois de repetir o fluxo, estamos mantendo cada vez mais dados que já deveriam ter sido descartados?
Conclusão
O exemplo em C mostra responsabilidades que ficam menos visíveis em JavaScript: obter armazenamento, acessar dados e controlar seu tempo de vida. Stack, heap, ponteiros e referências ajudam a construir esse entendimento, desde que não confundamos um modelo didático com as garantias de cada linguagem.
No JavaScript, esses fundamentos aparecem quando duas variáveis compartilham um objeto, uma cópia superficial mantém dados aninhados em comum, uma closure preserva acesso a um valor ou um cache retém informações indefinidamente.
Não precisamos administrar cada byte para tomar decisões melhores sobre memória. Precisamos entender o que estamos compartilhando, por quanto tempo os dados continuam acessíveis e quais referências deixaram de ser necessárias.
Linguagens modernas escondem a administração da memória; elas não fazem a memória desaparecer.
Você já investigou um problema que parecia ser de performance, mas estava relacionado a dados mantidos em memória por mais tempo do que deveriam?
Top comments (0)