O coletor de lixo não torna a memória gratuita: ele torna a liberação automática, e entender quando e como ele trabalha é o que separa código rápido de código que engasga.
A Fase 3 terminou com uma pergunta que atravessou todas as suas aulas: o boxing dos inteiros guardados como object, as closures que alocam objetos, o StringBuilder preferido à concatenação e a reflexão evitada em laços têm um custo, e esse custo tem a ver com memória. Hoje abrimos a Fase 4 enfrentando a pergunta de frente: onde os dados moram, como o .NET os coloca lá e como os retira. Se você vem do C, onde cada malloc exige o seu free, vai reconhecer os problemas e descobrir como o .NET os resolve. Se não vem, vai entender pela primeira vez o que acontece por baixo de cada new.
Pilha e heap, revisitados
Desde "Cada Valor Tem um Tipo" usamos a distinção entre tipos de valor e de referência. Agora podemos ser precisos sobre as duas regiões de memória envolvidas.
A pilha (stack) guarda os dados de cada chamada de método em andamento: parâmetros, variáveis locais e o endereço de retorno. Quando um método é chamado, um bloco é empilhado. Quando retorna, o bloco é descartado de uma vez. É uma memória rapidíssima e automática, e ninguém precisa liberá-la. Mas é pequena (1 MB por thread no Windows; no Linux, o .NET segue o limite do sistema, em geral 8 MB), e seus dados só vivem enquanto o método executa. É por isso que uma recursão sem fim termina em StackOverflowException, como vimos em "Métodos".
O heap guarda os objetos: tudo o que é criado com new a partir de uma classe, além de arrays e strings. Ele é grande e flexível, e seus objetos vivem enquanto alguém os referenciar, independentemente de qual método os criou.
Onde cada coisa fica, com os tipos que já conhecemos:
void Exemplo()
{
int quantidade = 3; // pilha: variável local de tipo de valor
var livro = new LivroImpresso(...); // a VARIÁVEL fica na pilha; o OBJETO, no heap
Coordenada c = new(-25.4, -49.3); // pilha: struct local, inteira no lugar
Coordenada[] pontos = new Coordenada[100]; // o array no heap, com as 100 structs DENTRO dele
}
O campo Estoque do livro, que é um int, fica dentro do objeto, no heap. A struct Coordenada num campo de classe também fica dentro do objeto. É a regra de "Classe, Struct ou Record": um tipo de valor mora onde foi declarado. Campos static vivem em áreas do heap associadas ao tipo e existem enquanto o programa rodar.
Alocar é barato; coletar é o que custa
Uma surpresa para quem vem do C: no .NET, criar um objeto é muito rápido. O malloc precisa procurar um bloco livre do tamanho certo em listas de espaços vagos. O .NET mantém um ponteiro para o fim da área usada e, para alocar, simplesmente o avança e zera os bytes. É praticamente o custo de somar um número.
O custo real está na outra ponta: recolher os objetos que ninguém mais usa. Esse é o trabalho do coletor de lixo (garbage collector, ou GC).
Como o coletor de lixo decide o que é lixo
O GC do .NET não conta referências. Ele parte das raízes, que são as variáveis locais de todos os métodos em andamento, os campos estáticos e alguns registros internos do runtime, e percorre tudo o que é alcançável a partir delas, seguindo cada referência. O que for alcançável está vivo. O que não for alcançável é lixo, não importa se outros objetos mortos ainda apontam para ele. Por isso ciclos de objetos que se referenciam mutuamente, mas que ninguém de fora alcança, são recolhidos sem problema.
Depois de identificar os vivos, o GC compacta o heap: move os objetos sobreviventes para ficarem lado a lado, elimina os buracos e atualiza todas as referências para os novos endereços. É isso que mantém a alocação por simples avanço de ponteiro possível, e é um dos motivos pelos quais o C# comum não usa endereços de memória fixos, como os ponteiros do C.
Gerações: por que o GC é rápido na prática
Percorrer o heap inteiro a cada coleta seria caro. O .NET se apoia numa observação empírica, a hipótese geracional: a maioria dos objetos morre jovem. Uma string temporária, um iterador de LINQ, um objeto intermediário de um cálculo: quase tudo vive por microssegundos. Por isso o heap é dividido em gerações:
- Geração 0: onde todo objeto nasce. É pequena e coletada com frequência, e a coleta é rápida, porque quase tudo ali já está morto.
- Geração 1: os sobreviventes de uma coleta da geração 0. Funciona como um amortecedor.
- Geração 2: os sobreviventes de longa duração, como o catálogo carregado na inicialização ou caches. É coletada raramente, e essa coleta é a mais cara.
Existe ainda o heap de objetos grandes (Large Object Heap), para objetos a partir de 85.000 bytes, como arrays grandes. Eles não são movidos pela compactação, porque copiá-los sairia caro, e são coletados junto com a geração 2.
A consequência prática: objetos de vida curta são baratos, e objetos de vida média são os mais caros, porque sobrevivem o bastante para serem promovidos e depois morrem numa geração cara de coletar. E, durante parte de cada coleta, o programa precisa ser pausado. O .NET faz boa parte do trabalho da geração 2 em segundo plano, mas pausas existem. Num servidor, elas aparecem como aumento de latência. Num jogo, como um quadro que engasga. É por isso que a fase de jogos vai insistir em não alocar dentro do laço principal.
Medindo alocações
Não é preciso adivinhar: o .NET informa quanto cada thread alocou. Vamos medir a diferença entre concatenar strings e usar StringBuilder, prometida em "Reflexão e Atributos":
using System.Text;
long antes = GC.GetAllocatedBytesForCurrentThread();
string texto = "";
for (int i = 0; i < 10_000; i++)
texto += "x";
long concatenacao = GC.GetAllocatedBytesForCurrentThread() - antes;
antes = GC.GetAllocatedBytesForCurrentThread();
var sb = new StringBuilder();
for (int i = 0; i < 10_000; i++)
sb.Append('x');
string resultado = sb.ToString();
long builder = GC.GetAllocatedBytesForCurrentThread() - antes;
Console.WriteLine($"Concatenação: {concatenacao / 1_000_000.0:F1} MB");
Console.WriteLine($"StringBuilder: {builder / 1_000.0:F1} KB");
Console.WriteLine($"Coletas da geração 0 até aqui: {GC.CollectionCount(0)}");
A concatenação aloca na casa dos 100 MB para produzir uma string de 10 mil caracteres. Como strings são imutáveis, cada += cria uma string nova com um caractere a mais, copiando tudo o que já existia: 1 + 2 + 3 + ... + 10.000 caracteres, a 2 bytes cada. O StringBuilder aloca algumas dezenas de KB, porque mantém um buffer que cresce por blocos. É o mesmo mecanismo de crescimento da List<T> de "Guardando Muitos Valores". Os números exatos variam com a versão do .NET, mas a diferença de milhares de vezes, não. E o contador de coletas mostra o GC trabalhando para recolher as 10 mil strings descartadas.
Uma tentação comum depois de ver números assim é chamar GC.Collect() para "limpar a memória". Resista a ela. O GC decide quando coletar com base em estatísticas que você não tem. Forçar uma coleta interrompe o programa, e, pior, promove os objetos que estão vivos naquele momento para gerações mais velhas, onde serão mais caros de recolher depois. Há raras exceções legítimas, como medições controladas, mas em código de aplicação a resposta certa é alocar menos, e não coletar à força.
Vazamentos de memória existem no .NET
O GC recolhe o que é inalcançável. Um objeto que continua alcançável nunca é recolhido, mesmo que o programa nunca mais vá usá-lo. Esse é o vazamento de memória do mundo gerenciado, e ele tem causas típicas:
- Coleções estáticas que só crescem. Um
static List<Pedido> Historicoao qual se acrescenta todo pedido processado. A lista é uma raiz, e tudo nela vive para sempre. - Eventos não desinscritos. Como vimos em "Delegates, Lambdas e Eventos", o objeto que publica um evento guarda referências para os inscritos. Uma tela que se inscreve no evento de um livro do catálogo e nunca se desinscreve fica presa na memória enquanto o livro existir.
- Caches sem limite. Um dicionário usado como cache, que guarda tudo o que já foi consultado e nunca remove nada.
Em todos os casos, o GC está fazendo exatamente o que deve: não recolher o que ainda é alcançável. A correção é sempre a mesma: cortar a referência quando o objeto deixa de ser necessário.
Recursos que o GC não conhece: IDisposable
Há uma categoria de recursos que o coletor de lixo não gerencia bem: os que não são memória do .NET. Um arquivo aberto é um identificador do sistema operacional. Uma conexão de banco de dados ocupa uma vaga num servidor. Um soquete de rede segura uma porta. Esses recursos são escassos, e o GC não sabe que eles existem. Ele só vê o pequeno objeto .NET que os representa e pode demorar minutos para recolhê-lo, enquanto o arquivo continua travado ou o banco continua recusando conexões novas.
Para isso existe a interface IDisposable, com um único método, Dispose(), que libera o recurso agora, de forma determinística. Todo tipo que segura um recurso externo a implementa, e quem o usa tem a obrigação de chamar Dispose quando terminar. A instrução using, prometida desde "Quando Algo Dá Errado", garante que isso aconteça mesmo se uma exceção interromper o caminho:
using (var escritor = new StreamWriter("vendas.log", append: true))
{
escritor.WriteLine("Venda registrada");
} // Dispose() é chamado aqui, com ou sem exceção
O compilador traduz o using para um try/finally com a chamada a Dispose() no finally. É exatamente o padrão do fclose garantido que discutimos para o finally, agora sem precisar escrevê-lo. A forma moderna, a declaração using (C# 8), dispensa as chaves: o objeto é descartado ao final do bloco em que foi declarado.
void RegistrarVenda(string mensagem)
{
using var escritor = new StreamWriter("vendas.log", append: true);
escritor.WriteLine(mensagem);
} // descartado aqui, ao sair do método
Implementar IDisposable numa classe sua é necessário quando ela segura um recurso descartável, como um campo StreamWriter. A forma recomendada para classes seladas, sem recursos nativos diretos, é simples:
Disposelibera os recursos que a classe possui;- chamá-lo duas vezes não causa problema (é idempotente);
- usar o objeto depois de descartado lança
ObjectDisposedException.
Você também vai encontrar, em código mais antigo, o "padrão Dispose completo", com um método Dispose(bool) e um finalizador (~Classe()), o método que o GC chama antes de recolher um objeto. Finalizadores só são necessários quando a classe segura diretamente um recurso nativo, como um ponteiro obtido do sistema operacional, e mesmo nesse caso o .NET moderno recomenda embrulhar o recurso num SafeHandle, que já cuida disso. Na aula de interop com C veremos esse caso. Para todo o resto, a forma simples basta.
(Existe ainda a versão assíncrona, IAsyncDisposable, usada com await using, para recursos cuja liberação envolve esperar, como enviar os últimos dados de uma conexão. Ela aparece junto com o async, daqui a três aulas.)
Juntando tudo: um registro de vendas e um monitor que não vaza
Dois objetos da Livraria precisam liberar algo ao terminar. O registro de vendas segura um arquivo aberto. O monitor de estoque segura inscrições em eventos:
public sealed class RegistroDeVendas : IDisposable
{
private readonly StreamWriter _escritor;
private bool _descartado;
public RegistroDeVendas(string caminho)
{
_escritor = new StreamWriter(caminho, append: true);
}
public void Registrar(Produto produto, int quantidade)
{
ObjectDisposedException.ThrowIf(_descartado, this);
_escritor.WriteLine($"{DateTime.Now:yyyy-MM-dd HH:mm:ss};{produto.Id};{produto.Titulo};{quantidade}");
}
public void Dispose()
{
if (_descartado) return;
_escritor.Dispose(); // grava o que estiver pendente e fecha o arquivo
_descartado = true;
}
}
public sealed class MonitorDeEstoque : IDisposable
{
private readonly List<LivroImpresso> _monitorados = [];
public void Acompanhar(LivroImpresso livro)
{
livro.EstoqueBaixo += AoEstoqueBaixo;
_monitorados.Add(livro);
}
private void AoEstoqueBaixo(object? remetente, EstoqueBaixoEventArgs e) =>
Console.WriteLine($"[monitor] \"{e.Livro.Titulo}\": restam {e.Restante}.");
public void Dispose()
{
foreach (var livro in _monitorados)
livro.EstoqueBaixo -= AoEstoqueBaixo; // corta as referências dos livros para o monitor
_monitorados.Clear();
}
}
O uso, com declarações using:
var dom = new LivroImpresso("Dom Casmurro", "Machado de Assis", 59.90m, pesoGramas: 350, estoque: 4);
using var registro = new RegistroDeVendas("vendas.log");
using var monitor = new MonitorDeEstoque();
monitor.Acompanhar(dom);
dom.Vender(2);
registro.Registrar(dom, 2); // restam 2: o monitor avisa
// ao sair do escopo: monitor.Dispose() e depois registro.Dispose(), na ordem inversa da declaração
Os objetos declarados com using são descartados na ordem inversa da criação, como numa pilha. Quem foi criado por último, e pode depender dos anteriores, é descartado primeiro. O MonitorDeEstoque resolve o vazamento por eventos descrito na seção anterior: sem o -= no Dispose, cada livro guardaria para sempre uma referência ao monitor, e o monitor nunca seria recolhido. Repare que ele implementa IDisposable sem segurar nenhum recurso do sistema operacional. Dispose serve para qualquer coisa que precise ser desfeita de forma determinística, e não só para arquivos.
O registro de vendas grava cada linha montando uma string nova com interpolação, e o exportador de CSV da aula de reflexão cortava e juntava texto a cada campo. Cada corte, com Substring, Split ou um intervalo como Valor[..5], cria uma string nova no heap, que o GC depois recolhe. Para processar um arquivo de um milhão de linhas, isso significa milhões de pequenos objetos de vida curtíssima. Existe uma forma de olhar para um pedaço de um array ou de uma string sem copiá-lo, sem alocar nada no heap e sem abrir mão da verificação de limites. É uma das ferramentas que mais mudaram o desempenho do .NET na última década.
Fontes e leituras recomendadas
- Fundamentos da coleta de lixo (Microsoft Learn): https://learn.microsoft.com/dotnet/standard/garbage-collection/fundamentals. Explica gerações, raízes, compactação, o heap de objetos grandes e os modos de coleta.
- Implementar um método Dispose: https://learn.microsoft.com/dotnet/standard/garbage-collection/implementing-dispose. Mostra a forma simples e o padrão completo, e quando cada um é necessário.
- Instrução
using: https://learn.microsoft.com/dotnet/csharp/language-reference/statements/using. Cobre a forma com bloco, a declaraçãousinge a ordem de descarte. - The Book of the Runtime, capítulo sobre o GC: https://github.com/dotnet/runtime/blob/main/docs/design/coreclr/botr/garbage-collection.md. É a documentação interna do coletor do .NET, escrita pela equipe que o mantém. Densa e definitiva.
- Konrad Kokosa, Pro .NET Memory Management (Apress, 2018): o livro de referência sobre memória no .NET, da teoria ao diagnóstico de problemas reais em produção.
Exercícios
Exercício 1
Para cada item abaixo, diga se ele fica na pilha ou no heap: (a) uma variável local int total; (b) o objeto criado por new Pedido() numa variável local; (c) a própria variável local que guarda esse pedido; (d) o campo int Numero dentro do pedido; (e) uma struct Cep guardada como campo de uma classe Cliente; (f) os elementos de um int[].
Ver resposta
✓ Resposta: (a) Pilha: variável local de tipo de valor. (b) Heap: todo objeto de classe criado com new vive no heap. (c) Pilha: a variável local é uma referência, um endereço, e ela própria fica no bloco do método, apontando para o objeto no heap. (d) Heap: o campo faz parte do objeto Pedido e fica dentro dele. Tipos de valor moram onde foram declarados. (e) Heap, dentro do objeto Cliente: a struct não tem vida própria separada, os seus bytes fazem parte do cliente. (f) Heap: o array é um objeto, e os inteiros ficam lado a lado dentro dele, sem boxing, como vimos em "Genéricos". A regra geral: tipos de valor vivem onde foram declarados, e tipos de referência sempre têm o objeto no heap, com apenas a referência vivendo onde a variável foi declarada.
Exercício 2
Rode o experimento de concatenação da aula com 20.000 iterações em vez de 10.000. A alocação dobra? Explique o resultado.
Ver resposta
✓ Resposta: Não dobra: ela fica cerca de quatro vezes maior. Cada += cria uma string com o comprimento atual, então o total alocado é proporcional a 1 + 2 + ... + n, que vale n(n+1)/2. Com n = 10.000, a soma é de cerca de 50 milhões de caracteres, uns 100 MB a 2 bytes cada. Com n = 20.000, cerca de 200 milhões de caracteres, uns 400 MB. Dobrar a entrada quadruplica o custo: é um algoritmo quadrático. Com o StringBuilder, a alocação cresce aproximadamente na mesma proporção da entrada (dobra quando a entrada dobra), porque o buffer cresce por blocos e cada caractere é copiado poucas vezes. Essa é a verdadeira razão para preferir StringBuilder em laços: não um ganho constante, mas uma curva de crescimento completamente diferente, que num processamento de arquivos grandes separa segundos de horas.
Exercício 3
Um serviço guarda cada pedido processado num static readonly List<Pedido> _processados para "consultar depois", e a memória do servidor cresce sem parar ao longo dos dias. Explique por que o GC não resolve e proponha duas correções.
Ver resposta
✓ Resposta: Um campo estático é uma raiz: ele vive enquanto o programa rodar, e tudo o que ele alcança está vivo. Cada pedido acrescentado à lista, com seus itens, produtos e clientes, permanece alcançável para sempre, e o GC, corretamente, nunca os recolhe. É um vazamento lógico: a memória não "escapou", ela está sendo retida por uma referência que o programa esqueceu que existia. Primeira correção: limitar a coleção, guardando só os N pedidos mais recentes (removendo o mais antigo a cada inclusão) ou os das últimas 24 horas, com uma limpeza periódica. Segunda correção, geralmente a certa: não guardar em memória o que é histórico. Pedidos processados pertencem a um banco de dados, consultado quando necessário. É o que faremos na Fase 5. Caches em memória são legítimos, mas sempre com limite de tamanho ou de tempo, e o .NET oferece o MemoryCache para isso, que veremos na fase de API.
Exercício 4
Implemente a classe ExportadorDeRelatorio : IDisposable, que abre um StreamWriter no construtor e oferece EscreverLinha(string). Garanta que Dispose possa ser chamado duas vezes e que EscreverLinha falhe claramente depois do descarte. Depois mostre o que acontece com o arquivo se uma exceção ocorrer no meio da escrita, com e sem using.
Ver resposta
✓ Resposta:
public sealed class ExportadorDeRelatorio : IDisposable
{
private readonly StreamWriter _escritor;
private bool _descartado;
public ExportadorDeRelatorio(string caminho) => _escritor = new StreamWriter(caminho);
public void EscreverLinha(string linha)
{
ObjectDisposedException.ThrowIf(_descartado, this);
_escritor.WriteLine(linha);
}
public void Dispose()
{
if (_descartado) return;
_escritor.Dispose();
_descartado = true;
}
}
Com using var exportador = new ExportadorDeRelatorio("relatorio.csv");, se uma exceção interromper a escrita na terceira linha, o finally gerado pelo using chama Dispose mesmo assim. As linhas já escritas são gravadas no disco, e o arquivo é fechado, liberado para outros programas. Sem using, o Dispose nunca é chamado no caminho da exceção. O StreamWriter guarda os dados num buffer em memória antes de gravá-los, então as linhas escritas podem não chegar ao disco, e o arquivo continua aberto, possivelmente travado para outros processos, até que o GC recolha o objeto, o que pode demorar indefinidamente. O _descartado torna o segundo Dispose inofensivo, e ObjectDisposedException.ThrowIf transforma o uso indevido depois do descarte num erro claro, em vez de uma exceção obscura vinda de dentro do StreamWriter.
Exercício 5
Um colega colocou GC.Collect() ao final de cada requisição de uma API "para manter a memória baixa". Explique por que isso tende a piorar o desempenho, mencionando as gerações.
Ver resposta
✓ Resposta: Primeiro, uma coleta completa pausa o programa (ao menos em parte do trabalho) e percorre todas as gerações, inclusive a 2, que é a mais cara. Fazer isso a cada requisição adiciona latência a todas elas, justamente o que o GC, ao escolher sozinho o momento, tenta evitar. Segundo, e mais sutil, uma coleta promove os sobreviventes: objetos que estavam vivos naquele instante por pura coincidência (os dados de outra requisição em andamento, por exemplo) sobem da geração 0 para a 1, e da 1 para a 2. Quando morrerem logo depois, como faria a maioria, estarão numa geração que só é coletada raramente e a custo alto, e a memória média pode até aumentar. Terceiro, o GC ajusta seus limites com base no comportamento observado do programa, e coletas forçadas embaralham essa adaptação. A memória "baixa" logo após o Collect é uma ilusão de medição. O caminho certo é medir o que está sendo alocado e alocar menos, e, se houver retenção indevida, encontrar a referência que prende os objetos, como no exercício 3.