C# Memória e Coletor de Lixo

Todo new que você escreve pede um pedaço da memória do computador: uma lista de compras, um texto, um objeto Conta. Em linguagens como o C, o programador tem de devolver cada pedaço com free, e esquecer disso é um dos erros mais comuns. Em C# quem devolve é o coletor de lixo (em inglês, garbage collector, ou GC). Nesta aula você vai ver onde as variáveis moram, quando um objeto vira "lixo", como o coletor trabalha e por que, mesmo com ele, um programa C# ainda pode gastar memória demais.

Você vai aprender
  • A diferença entre a pilha (stack) e o heap.
  • Quando um objeto vira lixo: quando nenhuma variável chega mais até ele.
  • Como o coletor trabalha e o que são as gerações 0, 1 e 2.
  • Como medir a memória com GC.GetTotalMemory e GC.GetAllocatedBytesForCurrentThread.
  • Os dois "vazamentos" mais comuns em C#: a lista static que só cresce e o evento sem -=.
  • Por que o coletor não fecha arquivos (e o using fecha).
  • Os erros Stack overflow. e Out of memory., com o conserto.

Os programas desta aula rodaram na máquina da Ana com o .NET 10, cada um num arquivo solto em ~/cs, com dotnet run arquivo.cs (veja Rodar com dotnet run). Para entender a aula, ajuda ter visto Valor × Referência e Classes e Objetos.


A ideia: a mesa, o depósito e o faxineiro

Imagine a lojinha da Ana. Ela trabalha numa mesa pequena, onde ficam só as coisas da tarefa do momento: a calculadora, um bloquinho. Quando a tarefa acaba, a mesa é limpa de uma vez. As caixas grandes ficam num depósito nos fundos, e na mesa ela guarda só um papelzinho com o endereço da caixa ("prateleira 7"). De vez em quando passa um faxineiro pelo depósito: toda caixa cujo endereço não está anotado em nenhum papel é jogada fora, porque ninguém mais consegue achá-la.

Na lojinhaNo programaO que fica lá
A mesaA pilha (stack)As variáveis locais de cada método que está rodando. Quando o método termina, a parte dele some de uma vez.
O depósitoO heap (em inglês, "monte")Os objetos criados com new: listas, arrays, strings, objetos das suas classes.
O papel com o endereçoA referênciaO que uma variável de classe guarda: não o objeto, mas onde ele está.
O faxineiroO coletor de lixo (GC)Passa de vez em quando e libera os objetos que nenhuma referência alcança.

Os tipos de valor (int, double, decimal, bool, structs) guardam o próprio valor na variável. Os tipos de referência (string, arrays, List<T>, as suas classes) guardam um endereço, e o objeto fica no heap.

Detalhe: "o int fica na pilha" é quase sempre verdade

Um int que é variável local fica na pilha. Mas um int que é campo de uma classe fica dentro do objeto, no heap. A regra certa é: o valor fica onde a variável está. Para quem está começando, "valores na mesa, objetos no depósito" basta.


1. Uma cópia × o mesmo objeto

Este programa mostra as duas coisas lado a lado. Copiar um int copia o número. Copiar uma lista copia só o endereço: as duas variáveis passam a apontar para a mesma lista no heap.

int idade = 31;
int copia = idade;
copia = 40;
Console.WriteLine($"idade = {idade}, copia = {copia}");

var compras = new List<string> { "arroz" };
var mesmaLista = compras;
mesmaLista.Add("feijão");
Console.WriteLine($"compras tem {compras.Count} itens: {string.Join(", ", compras)}");
Console.WriteLine(ReferenceEquals(compras, mesmaLista));
LinhaO que acontece na memória
int copia = idade;Na pilha, a variável copia recebe uma cópia do número 31. Mudar copia não mexe em idade.
new List<string> { "arroz" }O new cria a lista no heap. A variável compras, na pilha, guarda o endereço dela.
var mesmaLista = compras;Copia o endereço. Agora há dois papéis com o mesmo endereço, e uma lista só.
mesmaLista.Add("feijão");Mexe na lista do heap. Quem olhar por compras vê a mudança.
ReferenceEquals(compras, mesmaLista)"São o mesmo objeto?" Compara os endereços.
$ dotnet run ref.cs
idade = 31, copia = 40
compras tem 2 itens: arroz, feijão
True

2. Quando um objeto vira lixo

O coletor não olha se o objeto "ainda vai ser usado" (isso ele não tem como saber). Ele olha se o objeto ainda é alcançável: se dá para chegar até ele partindo das variáveis locais dos métodos que estão rodando e dos campos static. Esses pontos de partida se chamam raízes. O que não é alcançável é lixo.

Para ver isso acontecer, usamos uma WeakReference ("referência fraca"): ela observa um objeto sem segurá-lo. Se só ela aponta para o objeto, o coletor pode levá-lo, e a propriedade IsAlive ("está vivo") passa a dar false.

using System.Runtime.CompilerServices;

WeakReference observador = CriarRelatorio();
Console.WriteLine($"Antes da coleta, vivo? {observador.IsAlive}");

GC.Collect();
Console.WriteLine($"Depois da coleta, vivo? {observador.IsAlive}");

[MethodImpl(MethodImplOptions.NoInlining)]
static WeakReference CriarRelatorio()
{
    var relatorio = new byte[1_000_000];
    return new WeakReference(relatorio);
}
static WeakReference CriarRelatorio()
Um método local que cria um array de 1 milhão de bytes (1 MB) e devolve só um observador dele. A variável relatorio é local: quando o método termina, ela some da pilha, e nenhuma referência "forte" ao array sobra.
[MethodImpl(MethodImplOptions.NoInlining)]
Um atributo que pede ao .NET para não "colar" o método dentro de quem o chama (inlining). Assim temos certeza de que relatorio morre quando o método volta. Num programa normal você não precisa disso; aqui é só para o experimento dar sempre o mesmo resultado.
GC.Collect()
Manda o coletor passar agora. Só usamos para aprender: num programa de verdade, deixe o .NET decidir (veja a dica mais abaixo).
observador.IsAlive
true enquanto o objeto existir.
$ dotnet run lixo.cs
Antes da coleta, vivo? True
Depois da coleta, vivo? False

Antes da coleta o array ainda existia (virou lixo, mas o faxineiro não tinha passado). Depois, o coletor viu que ninguém o alcançava e liberou o 1 MB.

"Mas eu coloquei null e ele continua vivo!"

A Ana tentou o mesmo experimento sem o método, direto no programa:

byte[]? relatorio = new byte[1_000_000];
var observador = new WeakReference(relatorio);
relatorio = null;
GC.Collect();
Console.WriteLine($"Ainda vivo? {observador.IsAlive}");
$ dotnet run fraca.cs
Ainda vivo? True

O que aconteceu: o dotnet run compila em modo Debug (para depurar), e nesse modo o .NET pode guardar cópias escondidas das referências na pilha até o fim do método, para o depurador conseguir mostrá-las. O array ainda estava "alcançável" por uma dessas cópias. Não é um erro: o coletor só promete liberar o que não é alcançável em algum momento, nunca num momento exato.

A lição: nunca escreva um programa que dependa de quando o coletor passa. Para o experimento, criar o objeto dentro de um método (como em lixo.cs) resolve.


3. Gerações: o coletor olha primeiro para os novatos

Varrer o depósito inteiro toda vez seria lento. O coletor do .NET aposta numa coisa que quase sempre é verdade: a maioria dos objetos morre jovem (um texto montado para um WriteLine, uma lista temporária). Por isso ele separa o heap em gerações:

GeraçãoQuem fica láQuando é varrida
0Os objetos recém-criados.Muitas vezes: é pequena e a varredura é rápida.
1Os que sobreviveram a uma coleta.Menos vezes. É um "meio-termo".
2Os que sobreviveram a duas ou mais: os objetos de vida longa (configurações, caches). Arrays grandes, de 85 mil bytes ou mais, já nascem num lugar especial ligado à geração 2 (o Large Object Heap).Raramente. É a coleta completa, a mais cara.
var compras = new List<string> { "arroz", "feijão" };
Console.WriteLine($"Recém-criada: geração {GC.GetGeneration(compras)}");

GC.Collect();
Console.WriteLine($"Sobreviveu a 1 coleta: geração {GC.GetGeneration(compras)}");

GC.Collect();
Console.WriteLine($"Sobreviveu a 2 coletas: geração {GC.GetGeneration(compras)}");

Console.WriteLine($"Maior geração: {GC.MaxGeneration}");
Console.WriteLine($"Coletas da geração 0 até agora: {GC.CollectionCount(0)}");
GC.GetGeneration(compras)
Em que geração o objeto está agora.
GC.Collect()
Uma coleta completa. A lista ainda é usada (a variável compras a alcança), então sobrevive e é promovida para a geração seguinte.
GC.MaxGeneration
O número da última geração.
GC.CollectionCount(0)
Quantas vezes a geração 0 foi varrida desde que o programa começou. Toda coleta completa também conta como coleta da geração 0.
$ dotnet run geracoes.cs
Recém-criada: geração 0
Sobreviveu a 1 coleta: geração 1
Sobreviveu a 2 coletas: geração 2
Maior geração: 2
Coletas da geração 0 até agora: 2
O editor do guia tem outro coletor

Os números acima são os do dotnet no Linux. O editor do guia roda o .NET em WebAssembly, dentro do navegador, e o coletor de lá é mais simples: o mesmo programa mostra a lista na geração 0 nas três linhas, Maior geração: 1 e 0 coletas. As ideias da aula (alcançável, lixo, promoção) são as mesmas; os números de gerações e de memória, não. Para ver as gerações de verdade, rode no terminal.


4. Medir a memória

A classe GC tem dois métodos para espiar a memória:

  • GC.GetTotalMemory(true): quantos bytes os objetos vivos ocupam agora no heap. O true pede uma coleta antes de medir, para não contar lixo.
  • GC.GetAllocatedBytesForCurrentThread(): quantos bytes este programa já pediu desde o começo, contando o que já virou lixo. Serve para comparar duas formas de fazer a mesma coisa.

Um exemplo clássico: montar um texto de 1000 letras com += e com StringBuilder.

using System.Text;

long antes = GC.GetAllocatedBytesForCurrentThread();
string texto = "";
for (int i = 0; i < 1000; i++)
{
    texto += "x";
}
long depois = GC.GetAllocatedBytesForCurrentThread();
Console.WriteLine($"Com +=:           {depois - antes:N0} bytes");

antes = GC.GetAllocatedBytesForCurrentThread();
var sb = new StringBuilder();
for (int i = 0; i < 1000; i++)
{
    sb.Append('x');
}
string pronto = sb.ToString();
depois = GC.GetAllocatedBytesForCurrentThread();
Console.WriteLine($"Com StringBuilder: {depois - antes:N0} bytes");
Console.WriteLine(texto == pronto);
$ dotnet run aloc.cs
Com +=:           1.025.976 bytes
Com StringBuilder: 4.576 bytes
True

Rode e compare os dois números. No editor eles saem um pouco diferentes dos do terminal (o .NET do navegador guarda os objetos de outro jeito), mas a diferença entre += e StringBuilder é a mesma. Depois, troque o 1000 dos dois laços por 2000: o += dobra ou quadruplica?

using System.Text;

long antes = GC.GetAllocatedBytesForCurrentThread();
string texto = "";
for (int i = 0; i < 1000; i++)
{
    texto += "x";
}
long depois = GC.GetAllocatedBytesForCurrentThread();
Console.WriteLine($"Com +=:           {depois - antes:N0} bytes");

antes = GC.GetAllocatedBytesForCurrentThread();
var sb = new StringBuilder();
for (int i = 0; i < 1000; i++)
{
    sb.Append('x');
}
string pronto = sb.ToString();
depois = GC.GetAllocatedBytesForCurrentThread();
Console.WriteLine($"Com StringBuilder: {depois - antes:N0} bytes");
Console.WriteLine(texto == pronto);

Os dois textos são iguais (True), mas o += pediu mais de 1 milhão de bytes, e o StringBuilder, menos de 5 mil. Por quê? Uma string nunca muda (ela é imutável). Cada texto += "x" cria uma string nova, uma letra maior, e a anterior vira lixo: 1000 strings de 1, 2, 3… até 1000 letras, cada letra com 2 bytes. O StringBuilder vai enchendo uma área que cresce aos poucos e só cria a string no fim.

Lixo não é vazamento

O megabyte do += não "vazou": o coletor recolhe tudo nas próximas passadas. O problema é o trabalho: muito lixo faz o coletor passar mais vezes, e cada passada custa tempo. Num laço com milhares de voltas, prefira o StringBuilder. Para juntar três ou quatro pedaços, o + e a interpolação são ótimos.


5. "Vazamento" de memória em C#

Com o coletor, você nunca esquece de liberar um objeto que ninguém usa. Mas existe o problema contrário: um objeto que você não usa mais, mas que continua alcançável. O coletor não pode levá-lo. Se isso se repete, a memória só cresce: é o que se chama de vazamento em C#.

Uma lista static que só cresce

Campos static são raízes: vivem enquanto o programa viver. A Ana guardou os relatórios de cada dia num "cache" (uma lista para reaproveitar depois), mas nunca tirou nada de lá:

for (int dia = 1; dia <= 5; dia++)
{
    Relatorios.Gerar(dia);
}
Console.WriteLine($"Relatórios guardados: {Relatorios.Cache.Count}");
Console.WriteLine($"Memória: {GC.GetTotalMemory(true) / 1_000_000} MB");

static class Relatorios
{
    public static List<byte[]> Cache { get; } = new();

    public static void Gerar(int dia)
    {
        byte[] relatorio = new byte[10_000_000]; // 10 MB
        Cache.Add(relatorio);
        Console.WriteLine($"Dia {dia}: relatório gerado");
    }
}
$ dotnet run cache.cs
Dia 1: relatório gerado
Dia 2: relatório gerado
Dia 3: relatório gerado
Dia 4: relatório gerado
Dia 5: relatório gerado
Relatórios guardados: 5
Memória: 50 MB

A variável relatorio morre no fim de cada Gerar, mas a lista Cache continua segurando cada array. Cinco dias, 50 MB. Num programa que fica ligado o mês inteiro, seria 300 MB, depois 1 GB… O conserto é decidir quanto guardar: só os últimos dias, por exemplo.

Missão: o cache da Ana guarda os 5 relatórios. Faça ele guardar só os 3 últimos: em Gerar, depois do Add, se Cache.Count passar de 3, tire o mais antigo com Cache.RemoveAt(0). (Aqui cada relatório tem 1 MB, para não pesar no navegador.)

for (int dia = 1; dia <= 5; dia++)
{
    Relatorios.Gerar(dia);
}
Console.WriteLine($"Relatórios guardados: {Relatorios.Cache.Count}");

static class Relatorios
{
    public static List<byte[]> Cache { get; } = new();

    public static void Gerar(int dia)
    {
        byte[] relatorio = new byte[1_000_000]; // 1 MB
        Cache.Add(relatorio);
        // se o cache passou de 3, tire o mais antigo
        Console.WriteLine($"Dia {dia}: relatório gerado");
    }
}
for (int dia = 1; dia <= 5; dia++)
{
    Relatorios.Gerar(dia);
}
Console.WriteLine($"Relatórios guardados: {Relatorios.Cache.Count}");

static class Relatorios
{
    public static List<byte[]> Cache { get; } = new();

    public static void Gerar(int dia)
    {
        byte[] relatorio = new byte[1_000_000]; // 1 MB
        Cache.Add(relatorio);
        if (Cache.Count > 3)
        {
            Cache.RemoveAt(0);
        }
        Console.WriteLine($"Dia {dia}: relatório gerado");
    }
}

Um evento sem -=

Quando um objeto se inscreve num evento com +=, o dono do evento passa a guardar uma referência a ele. Se ninguém fizer -=, o inscrito fica vivo enquanto o dono do evento viver, mesmo que mais ninguém o use. No exemplo abaixo, cada Tela é usada uma vez só, mas o Relogio continua chamando todas:

var relogio = new Relogio();
for (int i = 1; i <= 3; i++)
{
    var tela = new Tela($"Tela {i}");
    relogio.Tique += tela.Mostrar;
    relogio.Bater();
}
Console.WriteLine($"Inscritos no fim: {relogio.Inscritos}");

class Relogio
{
    public event Action? Tique;
    public int Inscritos => Tique?.GetInvocationList().Length ?? 0;
    public void Bater() => Tique?.Invoke();
}

class Tela(string nome)
{
    public void Mostrar() => Console.WriteLine($"{nome}: tique!");
}
$ dotnet run evento.cs
Tela 1: tique!
Tela 1: tique!
Tela 2: tique!
Tela 1: tique!
Tela 2: tique!
Tela 3: tique!
Inscritos no fim: 3
relogio.Tique += tela.Mostrar;
Inscreve o método Mostrar daquela tela. O evento guarda o método e o objeto tela.
Tique?.GetInvocationList().Length
Quantos métodos estão inscritos no evento.
class Tela(string nome)
Uma classe com construtor primário C# 12: o nome fica disponível dentro dela.

A "Tela 1" foi chamada três vezes, embora o laço já tenha passado dela: o relógio ainda a segura. Num programa com janelas, cada janela fechada continuaria na memória.


6. O coletor cuida da memória, não dos arquivos

Um objeto que abriu um arquivo ou uma conexão segura algo que não é memória: o arquivo fica aberto no sistema. O coletor não sabe disso e só passa quando acha que falta memória, o que pode demorar. Existe um jeito de rodar código quando o coletor leva um objeto, o finalizador (~NomeDaClasse()), mas você não controla quando ele roda:

using System.Runtime.CompilerServices;

UsarArquivo();
Console.WriteLine("Chamando o coletor...");
GC.Collect();
GC.WaitForPendingFinalizers();
Console.WriteLine("Fim do programa");

[MethodImpl(MethodImplOptions.NoInlining)]
static void UsarArquivo()
{
    var r = new Recurso("notas.txt");
    Console.WriteLine($"Usando {r.Nome}");
}

class Recurso
{
    public string Nome { get; }
    public Recurso(string nome) => Nome = nome;
    ~Recurso() => Console.WriteLine($"Finalizador: liberando {Nome}");
}
$ dotnet run final.cs
Usando notas.txt
Chamando o coletor...
Finalizador: liberando notas.txt
Fim do programa

O UsarArquivo terminou bem antes, mas o "arquivo" só foi liberado quando chamamos o coletor à força e esperamos os finalizadores (GC.WaitForPendingFinalizers). Sem essas duas linhas, ele poderia ficar aberto até o fim do programa. No editor do guia é ainda pior: lá o finalizador não chegou a rodar, e a linha Finalizador: … nem aparece. Por isso a regra do C#:

Memória: deixe com o coletor. Arquivos e conexões: using.

Classes que seguram arquivos, conexões ou outros recursos do sistema implementam IDisposable, e você as usa com using: o recurso é liberado na hora em que o bloco termina, sempre. A aula using e IDisposable mostra como. Escrever finalizadores é raro: as classes do .NET que precisam deles já os têm.


Qual usar: quando se preocupar com memória

SituaçãoO que fazer
Quase todo programaNada. Crie os objetos com new à vontade; o coletor é muito bom no que faz.
Montar texto num laço grandeStringBuilder em vez de +=.
Arquivos, conexões, StreamReaderusing (veja using e IDisposable).
Listas e dicionários static, cachesDecida um limite e tire os itens antigos.
Eventos de objetos de vida curtaFaça -= quando o objeto não for mais usado.
Muitos dados pequenos, como pontos de um gráficoUma struct pode evitar milhões de objetos no heap. Veja também Span<T>.
O programa ficou lento ou pesadoMeça antes de mudar (GC.GetAllocatedBytesForCurrentThread, ou ferramentas como o dotnet-counters). Não adivinhe.
Não espalhe GC.Collect() pelo programa

Nesta aula usamos GC.Collect() para ver o coletor trabalhar. Num programa de verdade, ele quase sempre atrapalha: força uma coleta completa (a mais cara) e promove para a geração 2 objetos que iam morrer logo, o que deixa as próximas coletas mais caras. O .NET escolhe a hora certa melhor do que nós.


Quando dá errado

Stack overflow.: a recursão que nunca para

A pilha é pequena (no Linux, 8 MB por padrão). Cada chamada de método ocupa um pedaço dela, e o pedaço só é devolvido quando o método termina. Um método que chama a si mesmo sem parar (recursão sem caso base) enche a pilha:

int Contar(int n)
{
    return Contar(n + 1);
}

Console.WriteLine("Contando...");
Console.WriteLine(Contar(1));
$ dotnet run pilha.cs
Contando...
Stack overflow.
Repeated 261736 times:                    « o número muda um pouco a cada execução
--------------------------------
   at Program.<<Main>$>g__Contar|0_0(Int32)
--------------------------------
   at Program.<Main>$(System.String[])
$ echo $?
134

O que significa: "estouro da pilha". O método Contar (o nome estranho <<Main>$>g__Contar|0_0 é como o compilador batiza uma função local) foi chamado mais de 260 mil vezes, uma dentro da outra, até a pilha acabar. Esse erro não pode ser pego com try/catch: o .NET encerra o programa na hora, com o código 134. No editor do guia, o estouro aparece do mesmo jeito (Stack overflow., o bloco repetido e o código 134), só que com uma pilha menor: Repeated 999 times.

Como consertar: toda recursão precisa de um caso base, uma condição em que o método para de se chamar: if (n >= 10) return n;. Se a recursão é muito funda de propósito (milhões de níveis), troque por um laço while.

Out of memory.: um pedido grande demais

A Ana quis um array com o maior tamanho possível:

Console.WriteLine("Criando um array gigante...");
int[] numeros = new int[int.MaxValue];
Console.WriteLine(numeros.Length);
$ dotnet run oom.cs
Criando um array gigante...
Out of memory.
$ echo $?
134

O que significa: int.MaxValue são 2.147.483.647 posições de 4 bytes: 8 GB num array só, além do tamanho máximo que um array do .NET aceita. O new lançou uma OutOfMemoryException ("sem memória"), ninguém a pegou, e o .NET encerrou o programa com uma mensagem curta e o código 134. Pegando a exceção, dá para ler a mensagem completa:

try
{
    int[] numeros = new int[int.MaxValue];
    Console.WriteLine(numeros.Length);
}
catch (OutOfMemoryException e)
{
    Console.WriteLine(e.Message);
}
$ dotnet run oom2.cs
Array dimensions exceeded supported range.

"As dimensões do array passaram do limite aceito". (No editor do guia, o oom.cs mostra o mesmo Out of memory. e o código 134; a mensagem lida no catch é outra: Out of memory.) Como consertar: peça só o que vai usar. Para muitos dados, use uma List<T> que cresce aos poucos, ou leia um arquivo linha por linha (veja StreamReader) em vez de carregá-lo inteiro. Se a memória cresce sem parar num programa que fica ligado, procure um dos vazamentos da seção 5.


Referência

MembroO que faz
GC.Collect()Força uma coleta completa agora. Para testes e aulas, quase nunca em programas.
GC.WaitForPendingFinalizers()Espera os finalizadores dos objetos coletados terminarem.
GC.GetGeneration(obj)A geração em que o objeto está (0, 1 ou 2).
GC.MaxGenerationO número da última geração.
GC.CollectionCount(n)Quantas coletas da geração n já aconteceram.
GC.GetTotalMemory(true)Bytes ocupados pelos objetos vivos (com true, coleta antes de medir).
GC.GetAllocatedBytesForCurrentThread()Bytes já pedidos por esta thread desde o começo, contando o lixo.
new WeakReference(obj) / .IsAliveObserva um objeto sem impedir que ele seja coletado.
ReferenceEquals(a, b)true se as duas variáveis apontam para o mesmo objeto.
~Classe() { … }Finalizador: roda quando o coletor leva o objeto, sem hora marcada.

Exercícios

1. Quando o coletor de lixo pode liberar um objeto?

2. Onde fica a lista criada por var compras = new List<string>(); dentro de um método?

3. Um objeto acabou de ser criado com new. Em que geração ele está?

4. Um programa que fica ligado o dia inteiro guarda cada pedido numa static List<Pedido> e nunca remove nada. O que acontece?

5. Qual é o jeito certo de garantir que um arquivo seja fechado na hora?

6. Complete o nome do método que força uma coleta agora:

GC.();

7. Complete o operador que desinscreve a tela do evento:

relogio.Tique tela.Mostrar;

Missão final: conserte o vazamento do relógio. Dentro do laço, depois do relogio.Bater();, desinscreva a tela com relogio.Tique -= tela.Mostrar;. Cada tela deve dizer "tique!" uma vez só, e no fim não deve sobrar nenhum inscrito.

var relogio = new Relogio();
for (int i = 1; i <= 3; i++)
{
    var tela = new Tela($"Tela {i}");
    relogio.Tique += tela.Mostrar;
    relogio.Bater();
}
Console.WriteLine($"Inscritos no fim: {relogio.Inscritos}");

class Relogio
{
    public event Action? Tique;
    public int Inscritos => Tique?.GetInvocationList().Length ?? 0;
    public void Bater() => Tique?.Invoke();
}

class Tela(string nome)
{
    public void Mostrar() => Console.WriteLine($"{nome}: tique!");
}
var relogio = new Relogio();
for (int i = 1; i <= 3; i++)
{
    var tela = new Tela($"Tela {i}");
    relogio.Tique += tela.Mostrar;
    relogio.Bater();
    relogio.Tique -= tela.Mostrar;
}
Console.WriteLine($"Inscritos no fim: {relogio.Inscritos}");

class Relogio
{
    public event Action? Tique;
    public int Inscritos => Tique?.GetInvocationList().Length ?? 0;
    public void Bater() => Tique?.Invoke();
}

class Tela(string nome)
{
    public void Mostrar() => Console.WriteLine($"{nome}: tique!");
}
$ dotnet run evento.cs                     « com o -=
Tela 1: tique!
Tela 2: tique!
Tela 3: tique!
Inscritos no fim: 0

Resumo

  • Variáveis locais ficam na pilha e somem quando o método termina; objetos criados com new ficam no heap.
  • Uma variável de tipo de referência guarda o endereço do objeto; copiar a variável copia o endereço.
  • O coletor de lixo libera os objetos que nenhuma raiz alcança, numa hora que ele escolhe.
  • O heap tem gerações: 0 (novos), 1 e 2 (vida longa). A maioria dos objetos morre na geração 0, onde a coleta é barata.
  • += em string num laço gera muito lixo; StringBuilder resolve.
  • Vazamento em C# é objeto inútil mas alcançável: listas static sem limite e eventos sem -=.
  • O coletor cuida só da memória; arquivos e conexões se fecham com using.
  • Stack overflow. é recursão sem fim; Out of memory. é um pedido grande demais. Os dois encerram o programa com o código 134.

Próxima aula: Boas Práticas.