Noções básicas sobre coleta de lixo no .NET

170

Considere o código abaixo:

public class Class1
{
    public static int c;
    ~Class1()
    {
        c++;
    }
}

public class Class2
{
    public static void Main()
    {
        {
            var c1=new Class1();
            //c1=null; // If this line is not commented out, at the Console.WriteLine call, it prints 1.
        }
        GC.Collect();
        GC.WaitForPendingFinalizers();
        Console.WriteLine(Class1.c); // prints 0
        Console.Read();
    }
}

Agora, mesmo que a variável c1 no método principal esteja fora do escopo e não seja mais referenciada por nenhum outro objeto quando GC.Collect()é chamado, por que ela não está finalizada aí?

Victor Mukherjee
fonte
8
O GC não libera instâncias imediatamente quando estão fora do escopo. Faz isso quando julga necessário. Você pode ler tudo sobre o GC aqui: msdn.microsoft.com/en-US/library/vstudio/0xy59wtx.aspx
user1908061
@ user1908061 (. Pssst sua ligação é interrompida.)
Dragomok

Respostas:

352

Você está viajando até aqui e tirando conclusões muito erradas porque está usando um depurador. Você precisará executar seu código da maneira que ele é executado na máquina do usuário. Alterne para a versão Build primeiro com o gerenciador Build + Configuration, altere a combinação "Active solution configuration" no canto superior esquerdo para "Release". Em seguida, acesse Ferramentas + Opções, Depuração, Geral e desmarque a opção "Suprimir otimização de JIT".

Agora execute seu programa novamente e mexa no código fonte. Observe como as chaves extras não têm efeito. E observe como definir a variável como nulo não faz nenhuma diferença. Ele sempre imprimirá "1". Agora ele funciona da maneira que você espera e espera que funcione.

O que deixa com a tarefa de explicar por que funciona tão diferente quando você executa a compilação Debug. Isso requer explicar como o coletor de lixo descobre variáveis ​​locais e como isso é afetado pela presença de um depurador.

Primeiro, o jitter executa duas tarefas importantes quando compila a IL para um método no código de máquina. O primeiro é muito visível no depurador. Você pode ver o código da máquina na janela Debug + Windows + Disassembly. O segundo dever é, no entanto, completamente invisível. Também gera uma tabela que descreve como as variáveis ​​locais dentro do corpo do método são usadas. Essa tabela possui uma entrada para cada argumento do método e variável local com dois endereços. O endereço em que a variável armazenará primeiro uma referência de objeto. E o endereço da instrução do código da máquina em que essa variável não é mais usada. Também se essa variável está armazenada no quadro da pilha ou em um registro da CPU.

Esta tabela é essencial para o coletor de lixo, ela precisa saber onde procurar referências de objetos ao realizar uma coleta. Muito fácil de fazer quando a referência faz parte de um objeto no heap do GC. Definitivamente, não é fácil fazer isso quando a referência do objeto é armazenada em um registro da CPU. A tabela diz para onde procurar.

O endereço "não mais usado" na tabela é muito importante. Isso torna o coletor de lixo muito eficiente . Ele pode coletar uma referência de objeto, mesmo que seja usado dentro de um método e esse método ainda não tenha terminado de executar. O que é muito comum, seu método Main (), por exemplo, só para de executar um pouco antes de seu programa terminar. Claramente, você não gostaria que nenhuma referência de objeto usada dentro desse método Main () permanecesse durante a duração do programa, o que equivaleria a um vazamento. O jitter pode usar a tabela para descobrir que essa variável local não é mais útil, dependendo de quanto tempo o programa progrediu dentro desse método Main () antes de fazer uma chamada.

Um método quase mágico relacionado a essa tabela é GC.KeepAlive (). É um método muito especial, não gera nenhum código. Seu único dever é modificar essa tabela. ele se estendeo tempo de vida da variável local, impedindo que a referência que ela armazena obtenha o lixo coletado. O único momento em que você precisa usá-lo é impedir que o GC fique com muita vontade de coletar uma referência, o que pode acontecer em cenários de interoperabilidade em que uma referência é passada para código não gerenciado. O coletor de lixo não pode ver essas referências sendo usadas por esse código, pois não foi compilado pelo jitter, portanto, não possui a tabela que diz onde procurar a referência. Passar um objeto delegado para uma função não gerenciada como EnumWindows () é o exemplo padrão de quando você precisa usar GC.KeepAlive ().

Portanto, como você pode ver no seu snippet de amostra após executá-lo no build Release, as variáveis ​​locais podem ser coletadas mais cedo, antes que o método seja executado. Ainda mais poderoso, um objeto pode ser coletado enquanto um de seus métodos é executado se esse método não se referir mais a isso . Há um problema com isso, é muito estranho depurar esse método. Como você pode colocar a variável na janela Watch ou inspecioná-la. E desapareceria enquanto você estiver depurando se ocorrer um GC. Isso seria muito desagradável, então o jitter está ciente de que há um depurador anexado. Em seguida, modificaa tabela e altera o endereço "último usado". E altera do seu valor normal para o endereço da última instrução no método. O que mantém a variável ativa enquanto o método não retornar. O que permite que você continue assistindo até o método retornar.

Isso agora também explica o que você viu anteriormente e por que você fez a pergunta. Ele imprime "0" porque a chamada GC.Collect não pode coletar a referência. A tabela diz que a variável está em uso após a chamada GC.Collect (), até o final do método. Forçado a dizer isso, anexando o depurador e executando a compilação Debug.

Definir a variável como nula tem efeito agora, porque o GC inspecionará a variável e não verá mais uma referência. Mas certifique-se de não cair na armadilha em que muitos programadores de C # caíram, na verdade, escrever esse código era inútil. Não faz nenhuma diferença se essa instrução está presente ou não quando você executa o código na compilação Release. De fato, o otimizador de instabilidade removerá essa declaração, pois não tem efeito algum. Portanto, certifique-se de não escrever um código como esse, mesmo que pareça ter um efeito.


Uma observação final sobre esse tópico, é o que causa problemas aos programadores que escrevem pequenos programas para fazer algo com um aplicativo do Office. O depurador geralmente os coloca no caminho errado, eles querem que o programa do Office saia sob demanda. A maneira apropriada de fazer isso é chamando GC.Collect (). Mas eles descobrirão que isso não funciona quando eles depuram seu aplicativo, levando-os a uma terra nunca-nunca, chamando Marshal.ReleaseComObject (). Gerenciamento manual de memória, ele raramente funciona corretamente porque eles ignoram facilmente uma referência de interface invisível. GC.Collect () realmente funciona, mas não quando você depura o aplicativo.

Hans Passant
fonte
1
Veja também minha pergunta que Hans respondeu muito bem para mim. stackoverflow.com/questions/15561025/…
Dave Nay
1
@HansPassant Acabei de encontrar esta explicação incrível, que também responde parte da minha pergunta aqui: stackoverflow.com/questions/30529379/… sobre GC e sincronização de threads. Uma pergunta que ainda tenho: Gostaria de saber se o GC realmente compacta e atualiza endereços usados ​​em um registro (armazenado na memória enquanto suspenso) ou apenas os ignora? Um processo que é atualizado é registrado após a suspensão do encadeamento (antes do resumo) parece-me um grave encadeamento de segurança bloqueado pelo sistema operacional.
atlaste
Indiretamente, sim. O encadeamento está suspenso, o GC atualiza o armazenamento de backup dos registros da CPU. Depois que o encadeamento retoma a execução, agora usa os valores de registro atualizados.
Hans Passant
1
@HansPassant, eu gostaria que você adicionasse referências para alguns dos detalhes não óbvios do coletor de lixo CLR que você descreveu aqui?
Denfromufa
Parece que, em termos de configuração, um ponto importante é que "Otimizar código" ( <Optimize>true</Optimize>in .csproj) está ativado. Esse é o padrão na configuração "Release". Porém, no caso de se usar configurações personalizadas, é relevante saber que essa configuração é importante.
Zero3 12/11/19
34

[Só queria acrescentar mais sobre o processo de Internals of Finalization]

Portanto, você cria um objeto e, quando o objeto é coletado, o Finalizemétodo do objeto deve ser chamado. Mas há mais na finalização do que essa suposição muito simples.

CONCEITOS CURTOS ::

  1. Objetos que NÃO implementam Finalizemétodos, a memória é recuperada imediatamente, a menos que, é claro, não sejam mais acessíveis pelo
    código do aplicativo

  2. Objetos implementação Finalizemétodo, o conceito / Implementação de Application Roots, Finalization Queue, Freacheable Queuevem antes que eles possam ser recuperados.

  3. Qualquer objeto é considerado lixo se NÃO for alcançável pelo Código do Aplicativo

Suponha: Classes / Objetos A, B, D, G, H NÃO implementam o FinalizeMétodo e C, E, F, I, J implementam o FinalizeMétodo.

Quando um aplicativo cria um novo objeto, o novo operador aloca a memória do heap. Se o tipo do objeto contiver um Finalizemétodo, um ponteiro para o objeto será colocado na fila de finalização .

portanto, ponteiros para os objetos C, E, F, I, J são adicionados à fila de finalização.

A fila de finalização é uma estrutura de dados interna controlada pelo coletor de lixo. Cada entrada na fila aponta para um objeto que deve ter seu Finalizemétodo chamado antes que a memória do objeto possa ser recuperada. A figura abaixo mostra uma pilha contendo vários objetos. Alguns desses objetos são acessíveis a partir das raízes do aplicativo, e alguns não são. Quando os objetos C, E, F, I e J foram criados, a estrutura .Net detecta que esses objetos têm Finalizemétodos e os ponteiros para esses objetos são adicionados à fila de finalização .

insira a descrição da imagem aqui

Quando ocorre um GC (1ª coleção), os objetos B, E, G, H, I e J são determinados como lixo. Como A, C, D, F ainda são acessíveis pelo Código do Aplicativo, representado pelas setas da Caixa amarela acima.

O coletor de lixo varre a fila de finalização procurando por ponteiros para esses objetos. Quando um ponteiro é encontrado, o ponteiro é removido da fila de finalização e anexado à fila acessível ("F alcançável").

A fila acessível é outra estrutura de dados interna controlada pelo coletor de lixo. Cada ponteiro na fila acessível identifica um objeto que está pronto para ter seu Finalizemétodo chamado.

Após a coleção (1ª coleção), o heap gerenciado é semelhante à figura abaixo. Explicação dada abaixo:
1.) A memória ocupada pelos objetos B, G e H foi recuperada imediatamente porque esses objetos não tinham um método finalize que precisava ser chamado .

2.) No entanto, a memória ocupada pelos objetos E, I e J não pôde ser recuperada porque seu Finalizemétodo ainda não foi chamado. A chamada do método Finalize é feita por fila passível de busca.

3.) A, C, D, F ainda são acessíveis pelo Código do Aplicativo, representado pelas setas da caixa amarela acima, portanto, NÃO serão coletados em nenhum caso

insira a descrição da imagem aqui

Há um encadeamento de tempo de execução especial dedicado à chamada dos métodos Finalize. Quando a fila alcançável está vazia (que geralmente é o caso), esse encadeamento dorme. Mas quando as entradas aparecem, esse segmento é ativado, remove cada entrada da fila e chama o método Finalize de cada objeto. O coletor de lixo compacta a memória recuperável e o encadeamento de tempo de execução especial esvazia a fila acessível , executando o Finalizemétodo de cada objeto . Então aqui finalmente é quando o seu método Finalize é executado

Na próxima vez que o coletor de lixo for chamado (2ª coleção), ele verá que os objetos finalizados são realmente lixo, já que as raízes do aplicativo não apontam para ele e a fila inacessível não aponta mais para ele (também é VAZIO). A memória dos objetos (E, I, J) é simplesmente recuperada do Heap. Veja a figura abaixo e compare-a com a figura logo acima

insira a descrição da imagem aqui

O importante a entender aqui é que dois GCs são necessários para recuperar a memória usada por objetos que requerem finalização . Na realidade, são necessárias mais de duas cabines de coleta, pois esses objetos podem ser promovidos para uma geração mais antiga

NOTA: A fila acessível é considerada uma raiz, assim como variáveis ​​globais e estáticas são raízes. Portanto, se um objeto estiver na fila alcançável, o objeto estará acessível e não será lixo.

Como última observação, lembre-se de que o aplicativo de depuração é uma coisa, a Coleta de Lixo é outra e funciona de maneira diferente. Até o momento, você não pode SENTIR a coleta de lixo apenas depurando aplicativos, além disso, se desejar investigar a Memória, comece aqui.

RC
fonte