Garbage collector no .NET: meça antes de ajustar

O garbage collector no .NET libera a memória de objetos que o seu código não alcança mais, mas isso não significa que a aplicação vai usar pouca memória. Coletar um objeto, devolver memória ao sistema operacional e fechar um arquivo são operações diferentes, e cada uma tem uma solução própria.
Este artigo segue uma ordem prática de trabalho. Primeiro você mede e reduz alocações, depois controla o tempo de vida dos objetos e, só então, avalia configurações como server GC, DATAS, compactação do LOH e regiões sem coleta.
Como o garbage collector no .NET decide o que coletar
O garbage collector no .NET é o recurso do runtime que gerencia a memória dos objetos criados pelo seu código C#. Ele parte das raízes, como variáveis locais e campos estáticos, e segue as referências a partir delas.
Tudo o que pode ser alcançado a partir dessas raízes continua vivo. Por outro lado, um objeto sem nenhum caminho até uma raiz vira lixo, e a memória dele pode ser reaproveitada.
Esse detalhe muda a forma de investigar problemas de memória. De fato, o garbage collector no .NET não remove objetos que o código ainda alcança, mesmo que eles nunca mais sejam usados.
Além disso, reaproveitar memória não é o mesmo que devolvê-la ao Windows ou ao Linux. O runtime pode manter essa memória reservada para alocações futuras, por isso o processo continua parecendo grande.
Por que medir antes de ajustar o garbage collector no .NET
Muitos objetos temporários fazem a aplicação trabalhar mais na limpeza. Por exemplo, cada alteração em uma string do C# cria uma nova string, então montar um texto longo em partes gera várias cópias intermediárias.
Ao mesmo tempo, objetos grandes ficam em uma área especial chamada large object heap, ou LOH. Objetos que permanecem alcançáveis por muito tempo também continuam na memória, enquanto arquivos e conexões dependem do seu código para serem fechados.
Todos esses cenários parecem "uso alto de memória". No entanto, cada um pede uma correção diferente, e por isso o diagnóstico vem antes de qualquer ajuste no garbage collector no .NET.
Chamar GC.Collect() não explica o que causou o consumo. Pelo contrário, a chamada pode pausar a aplicação e não remove nada que o código ainda alcança.
Pausas curtas que se acumulam
As coletas de geração 0 e geração 1 sempre bloqueiam as threads da aplicação, mas costumam ser rápidas. Já o background GC vale apenas para coletas de geração 2, que fazem boa parte do trabalho em uma thread dedicada.
Uma pausa curta raramente causa problema. Porém, quando um caminho muito acessado aloca rápido demais, as coletas pequenas se repetem e o tempo somado chega a ser percebido pelo usuário.
Por isso, reduzir a taxa de alocação costuma ajudar mais do que trocar uma configuração. Afinal, a contagem de coletas sozinha não diz quanto tempo o usuário esperou.
Medindo alocações no código
O primeiro passo é medir quanta memória o código cria, em vez de forçar uma coleta. Para isso, o método GC.GetAllocatedBytesForCurrentThread() resolve comparações simples entre duas abordagens.
Comparando duas estratégias de buffer
O exemplo a seguir compara criar um buffer de 1 KB a cada iteração com criar um único buffer e reutilizá-lo. Além disso, cada método roda uma vez antes da medição, para que a primeira execução não distorça o resultado.
using System;
var allocationBytes = Measure(AllocateEachTime);
var reuseBytes = Measure(ReuseBuffer);
Console.WriteLine($"New buffer per item {allocationBytes,15:N0}");
Console.WriteLine($"Reuse one buffer {reuseBytes,15:N0}");
long Measure(Func<int> operation)
{
_ = operation(); // aquecimento
var before = GC.GetAllocatedBytesForCurrentThread();
_ = operation();
return GC.GetAllocatedBytesForCurrentThread() - before;
}
int AllocateEachTime()
{
var total = 0;
for (var i = 0; i < 500; i++)
{
var buffer = new byte[1024]; // novo array a cada volta
buffer[0] = (byte)i;
total += buffer[0];
}
return total;
}
int ReuseBuffer()
{
var buffer = new byte[1024]; // um único array
var total = 0;
for (var i = 0; i < 500; i++)
{
buffer[0] = (byte)i;
total += buffer[0];
}
return total;
}
Em um runtime de 64 bits, a primeira estratégia aloca 524.000 bytes e a segunda aloca apenas 1.048 bytes. Isso acontece porque cada byte[1024] ocupa 1.048 bytes no heap, somando os 24 bytes de cabeçalho, ponteiro de tipo e tamanho do array.
Os números podem variar um pouco entre versões do runtime. Mesmo assim, a diferença importa, porque são 499 arrays a menos para o garbage collector no .NET limpar.
Vale lembrar que esse método conta apenas o que a thread atual alocou. Ele não mostra a memória ainda em uso nem o consumo do processo inteiro, então comparações sérias pedem uma ferramenta como o BenchmarkDotNet.
Observando o garbage collector no .NET em produção
Com a aplicação rodando, os contadores do runtime mostram o comportamento real. Entre os valores úteis estão a taxa de alocação, o tamanho do heap gerenciado, a frequência de coletas de geração 2 e o tempo gasto em GC.
# instala a ferramenta, se necessário
dotnet tool install --global dotnet-counters
# troque 1234 pelo id do processo
dotnet-counters monitor --process-id 1234 System.Runtime
Esses contadores mostram a atividade geral do garbage collector no .NET, mas não a duração de cada pausa. Para isso, você coleta um trace enquanto a aplicação processa uma carga representativa.
dotnet tool install --global dotnet-trace
dotnet-trace collect --process-id 1234 --profile gc-collect
O perfil gc-collect registra apenas eventos de coleta, então o overhead é muito baixo. Em seguida, você abre o trace no PerfView e inspeciona os eventos de pausa.
Quando precisar também de eventos de alocação amostrados, o perfil gc-verbose atende, com um custo maior. Já o impacto real na latência das requisições aparece em testes de carga ou na telemetria de produção.
Reduzindo alocações para aliviar o garbage collector no .NET
Menos objetos desnecessários significam menos trabalho para o garbage collector no .NET. Contudo, "zero alocações" nem sempre é a meta certa, e reutilizar um buffer só faz sentido quando isso é seguro para o código.
Span, stackalloc e ArrayPool
Quando a medição aponta um trecho crítico, stackalloc com Span<T> funciona bem para buffers pequenos e de vida curta. O método abaixo monta um código de pedido na pilha e aloca somente a string final.
static string FormatOrderCode(int id)
{
Span<char> chars = stackalloc char[16]; // buffer na pilha
"ORD-".AsSpan().CopyTo(chars);
id.TryFormat(chars[4..], out var written);
return new string(chars[..(4 + written)]); // única alocação no heap
}
Para arrays maiores, o ArrayPool<T> empresta um array de um pool compartilhado. Assim, a próxima chamada reaproveita o mesmo array, e o garbage collector no .NET não limpa um array novo a cada uso.
using System.Buffers;
static int ChecksumOfLength(int length)
{
var buffer = ArrayPool<byte>.Shared.Rent(length);
try
{
var data = buffer.AsSpan(0, length); // Rent pode devolver um array maior
for (var i = 0; i < data.Length; i++)
{
data[i] = (byte)i;
}
var sum = 0;
foreach (var value in data)
{
sum += value;
}
return sum;
}
finally
{
ArrayPool<byte>.Shared.Return(buffer); // devolve mesmo em caso de erro
}
}
O método Rent pode devolver um array maior que o pedido, por isso o código fatia o trecho necessário. Além disso, o array não deve ser usado depois de devolvido, e buffers com stackalloc precisam ser pequenos, porque o espaço na pilha é limitado.
StringBuilder para concatenações repetidas
Strings no C# não mudam depois de criadas. Logo, concatenar com + dentro de um laço pode gerar uma string intermediária a cada volta, e todas viram trabalho para o garbage collector no .NET.
using System.Text;
var parts = new[] { "GC", " ", "measures", " ", "memory" };
var builder = new StringBuilder();
foreach (var part in parts)
{
builder.Append(part); // reaproveita o buffer interno
}
var message = builder.ToString();
O StringBuilder também aloca o próprio objeto e a string final. Portanto, ele compensa em anexos repetidos, enquanto poucas partes fixas ficam mais claras com a concatenação comum.
Escape analysis no .NET 10 e o garbage collector
A escape analysis é a verificação que o JIT faz para saber se um objeto sai do método. Um objeto escapa quando o método o retorna, o guarda em um campo ou o passa para um código que o JIT não enxerga por completo.
Quando o JIT prova que o objeto não escapa, ele pode colocá-lo na pilha. Nesse caso, o garbage collector no .NET nem chega a rastrear esse objeto.
static int Largest(int a, int b, int c)
{
int[] candidates = { a, b, c }; // nunca sai do método
var largest = candidates[0];
foreach (var value in candidates)
{
largest = Math.Max(largest, value);
}
return largest;
}
static int[] KeepForLater(int a, int b, int c) => new[] { a, b, c }; // escapa para quem chamou
A partir do .NET 10, o JIT consegue alocar na pilha arrays pequenos e de tamanho fixo, como candidates. Por outro lado, o array de KeepForLater precisa ficar no heap, porque quem chamou continua usando o resultado. As notas do runtime do .NET 10 detalham essas melhorias de stack allocation.
Trate esse ganho como bônus, não como ferramenta de design. Afinal, ele vale apenas para código otimizado, então as primeiras execuções e os builds de Debug continuam alocando no heap.
Lambdas que capturam variáveis externas seguem a mesma lógica. O compilador cria um objeto auxiliar, a closure, para guardar a variável capturada, e essa criação é uma alocação.
int offset = 10;
Func<int, int> addOffset = value => value + offset; // captura offset
Func<int, int> doubleValue = static value => value * 2; // não captura nada
Uma lambda sem captura dispensa a closure, e o compilador pode reaproveitar o delegate. Já a palavra static não cria esse cache, ela apenas faz o compilador rejeitar uma captura acidental.
No .NET 10, o JIT às vezes aloca na pilha um delegate que não escapa. Ainda assim, a closure continua no heap, então vale mover o trabalho para fora do laço quando o design permitir.
Controlando o tempo de vida dos objetos
Um FileStream é um objeto gerenciado, mas o handle de arquivo que ele usa pertence ao sistema operacional. O garbage collector no .NET recupera a memória do stream, porém o handle só seria liberado mais tarde, pela finalização, em um momento imprevisível.
Dispose e o padrão de descarte
O IDisposable expõe um método Dispose para liberar recursos em um momento previsível. Chamar Dispose não manda o garbage collector no .NET coletar o objeto, apenas libera o que ele possui.
using System.IO;
using (var stream = File.OpenRead("report.json"))
{
// leia o arquivo aqui
}
// o Dispose já rodou, então o handle pode ser liberado
O compilador transforma o bloco using em um try/finally que chama Dispose. Dessa forma, o recurso é liberado mesmo quando ocorre uma exceção.
Uma classe que só possui outros objetos descartáveis não precisa de finalizador. O finalizador só entra em cena quando a classe guarda diretamente um recurso não gerenciado.
using System;
using System.Runtime.InteropServices;
public class NativeScratchBuffer : IDisposable
{
private IntPtr _memory;
private bool _disposed;
public NativeScratchBuffer(int sizeInBytes) =>
_memory = Marshal.AllocHGlobal(sizeInBytes);
~NativeScratchBuffer() => Dispose(disposing: false);
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this); // evita manter o objeto vivo para a finalização
}
protected virtual void Dispose(bool disposing)
{
if (_disposed)
return;
if (disposing)
{
// descarte aqui os objetos gerenciados que a classe possui
}
if (_memory != IntPtr.Zero)
{
Marshal.FreeHGlobal(_memory);
_memory = IntPtr.Zero;
}
_disposed = true;
}
}
O GC.SuppressFinalize também tem efeito na performance. Isso porque um objeto que ainda precisa de finalização vai para a fila do finalizador e continua vivo, com tudo o que referencia, até uma coleta posterior.
Em código real, prefira SafeHandle para envolver handles do sistema operacional. Ele já oferece uma limpeza confiável quando o Dispose é esquecido.
Eventos, timers e referências fracas
Eventos também mantêm objetos vivos. Quando um objeto assina o evento de outro, o publicador guarda uma referência ao assinante até que ele cancele a assinatura.
public sealed class PriceBadge : IDisposable
{
private readonly StockTicker _ticker;
public PriceBadge(StockTicker ticker)
{
_ticker = ticker;
_ticker.PriceChanged += OnPriceChanged;
}
// cancela a assinatura para o badge poder ser coletado
public void Dispose() => _ticker.PriceChanged -= OnPriceChanged;
private void OnPriceChanged(object? sender, decimal price) =>
Console.WriteLine($"Badge shows {price}");
}
Se um StockTicker de vida longa receber milhares de assinaturas sem Dispose, cada badge continua na lista de handlers. Ou seja, há um vazamento de memória, mesmo que o código não use mais esses objetos.
Timers prendem objetos de modo parecido, fora do alcance do garbage collector no .NET. Um System.Timers.Timer iniciado é mantido vivo pela infraestrutura de timers do runtime, por isso você deve pará-lo, remover o handler de Elapsed e descartá-lo.
Já o WeakReference<T> aponta para um objeto sem mantê-lo vivo. Assim, ele serve para um cache opcional, cujo conteúdo pode ser recriado se o GC já tiver coletado o alvo.
No entanto, referência fraca não substitui o Dispose, a parada de um timer ou o cancelamento de um evento. Essas tarefas pedem posse explícita e limpeza explícita.
Configurações do garbage collector no .NET
Só depois de medir e corrigir alocações e tempo de vida faz sentido mexer nas configurações do garbage collector no .NET. Nenhum ajuste desta seção corrige uma referência esquecida ou um laço que aloca demais.
Modos do garbage collector no .NET
O workstation GC foi pensado para aplicações que dividem a máquina com outros programas. Por isso, ele usa orçamentos de alocação menores, coleta com mais frequência e mantém a memória mais baixa.
O server GC, por sua vez, trata a aplicação como a carga principal da máquina. Por padrão, ele cria um heap e uma thread de GC por CPU lógica, então costuma processar mais trabalho e usar mais memória.
<PropertyGroup>
<!-- liga o server GC no arquivo de projeto -->
<ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>
Aplicações ASP.NET Core usam server GC por padrão, enquanto apps de console, desktop e workers usam workstation GC. Mesmo assim, nenhum dos modos é sempre mais rápido, então teste os dois com a carga real.
O DATAS, sigla de Dynamic Adaptation to Application Sizes, tenta manter o heap proporcional aos dados de vida longa da aplicação. Ele começa com um único heap e adiciona ou remove heaps conforme a carga muda.
O recurso surgiu como opcional no .NET 8 e passou a ser padrão no server GC a partir do .NET 9. A documentação oficial do DATAS explica que ele troca um pouco de throughput por um working set bem menor.
<PropertyGroup>
<ServerGarbageCollection>true</ServerGarbageCollection>
<!-- desliga o DATAS apenas se a medição mostrar perda de throughput -->
<GarbageCollectionAdaptationMode>0</GarbageCollectionAdaptationMode>
</PropertyGroup>
Mesmo assim, o DATAS não é um teto rígido de memória. Em um container com limite, o limite padrão do heap do GC é 75% desse valor, mas ele não cobre a memória nativa do processo.
Compactação do LOH e regiões sem GC
Objetos com 85.000 bytes ou mais vão para o LOH e são coletados junto com a geração 2. Por padrão, o garbage collector no .NET deixa lacunas onde ficavam objetos grandes mortos, em vez de copiar blocos enormes de memória.
Se o profiling mostrar que essas lacunas causam problema, você pode pedir uma compactação única. O pedido vale para a próxima coleta completa e bloqueante, e depois a configuração volta para Default.
using System.Runtime;
GCSettings.LargeObjectHeapCompactionMode =
GCLargeObjectHeapCompactionMode.CompactOnce;
// opcional: compacta agora, em um momento controlado
GC.Collect();
Force a coleta apenas em momentos controlados, como o fim de um job em lote. Além disso, confirme antes que a fragmentação do LOH é realmente o problema.
Já uma região sem GC é um período curto em que o runtime tenta não iniciar coletas. O método GC.TryStartNoGCRegion reserva um orçamento de memória e pode falhar se não conseguir reservá-lo.
using System.Runtime;
const long budgetBytes = 32 * 1024 * 1024;
if (GC.TryStartNoGCRegion(budgetBytes))
{
try
{
ProcessAudioBlock(); // trabalho curto e limitado
}
finally
{
// a região pode ter terminado antes se o orçamento estourou
if (GCSettings.LatencyMode == GCLatencyMode.NoGCRegion)
GC.EndNoGCRegion();
}
}
else
{
ProcessAudioBlock();
}
Se a aplicação passar do orçamento, o runtime encerra a região antes. Nesse caso, chamar GC.EndNoGCRegion() lança InvalidOperationException, por isso o código verifica o modo de latência primeiro.
Use esse recurso apenas para trabalhos curtos e medidos, como um bloco de áudio em tempo real. Na maioria das aplicações, reduzir alocações é uma escolha mais simples e mais segura.
Conclusão
Ajustar o garbage collector no .NET começa por medir, não por trocar configurações. Primeiro você mede alocações e pausas, depois corrige alocações evitáveis e o tempo de vida dos objetos, e só então avalia os modos do GC.
Na prática, reutilizar buffers, usar Span<T>, stackalloc, ArrayPool<T> e StringBuilder resolve boa parte dos casos. Em seguida, Dispose, o cancelamento de eventos e a parada de timers evitam que objetos fiquem vivos sem necessidade.
Por fim, server GC, DATAS, compactação do LOH e regiões sem GC são ferramentas para cenários comprovados por medição. Se você está migrando para a versão mais recente, o artigo sobre package pruning no .NET 10 mostra outra melhoria que chega sem custo de código.
FAQ
Chamar GC.Collect resolve problemas de memória?
Não. A chamada pode pausar a aplicação e não remove objetos que o código ainda alcança, então ela não explica nem corrige a causa do consumo.
Por que a aplicação continua grande depois de uma coleta?
O runtime reaproveita a memória liberada, mas não promete devolvê-la ao sistema operacional imediatamente. Por isso o processo pode continuar parecendo grande.
Devo desligar o DATAS no server GC?
Só quando a medição mostrar perda de throughput relevante. Na maioria dos casos, o DATAS reduz bastante o working set com um custo pequeno.
Quando preciso implementar um finalizador?
Apenas quando a classe possui diretamente um recurso não gerenciado e não pode usar SafeHandle. Classes que só possuem outros objetos descartáveis dispensam finalizador.
A stack allocation do .NET 10 substitui o stackalloc?
Não. Ela vale apenas para código otimizado e depende da análise do JIT, então use stackalloc quando um buffer pequeno precisar ficar fora do heap.