Sem o garbage collector seu código não seria como ele é hoje. Provavelmente você se preocuparia muito mais com o consumo de recursos e descarte de instâncias de objetos não mais utilizadas. Também é fato que pouco se pensa sobre o funcionamento do garbage collector. Este post visa destacar brevemente o funcionamento do garbage collector.
Toda aplicação .Net contém um conjunto de roots. Cada root corresponde a um local de armazenamento contendo um ponteiro de memória para um objeto de tipo referência. Assim, este ponteiro referência apenas objetos na memória heap. Apenas variáveis de tipos referência são consideradas como roots: uma propriedade estática é considerada um root, parâmetros de métodos são considerados roots e variáveis locais são consideradas como roots. Variáveis de tipo valor nunca são consideradas como roots.
Uma propriedade estática é referenciada no root durante todo o ciclo de vida de uma AppDomain, isto significa que sua referência pode durar para sempre ou até que o AppDomain (no qual a propriedade está carregada) seja fechado. Diante disso, é preciso tomar cuidado com os dados que são armazenados em propriedades estáticas, principalmente com a quantidade de itens que são adicionados em coleções referenciadas por propriedades estáticas, pois cada item adicionado é mantido ativo em memória durante toda a existência do AppDomain.
Ao contrário das propriedades estáticas, as variáveis e propriedades por referência são marcadas como ‘prontas para coleta’ sempre que saem do escopo de execução, isto é, quando tornam-se inacessíveis.
Assim, deve-se ter em mente que: tão logo um objeto se torne inacessível, então mais cedo se tornará candidato a ser coletado.
Quando o garbage collector começa a trabalhar é mantido como premissa que todos os objetos no heap são lixo. Isto é, adota-se que nenhuma variável referencia algum outro objeto no heap, ou assume-se que nenhum registrador aponta para algum objeto no heap, ou que nenhum campo estático referência objetos no heap. Assim, após adotada essa premissa, inicia-se a fase de marcação.
A fase de marcação é responsável por percorrer o thread stack verificando todos os roots. A cada root verifica-se se o root referencia no heap algum objeto não marcado para coleta, se referenciar então sua referência é marcada como ‘ainda ativa’. Ao fim da fase de marcação pode-se ver um conjunto de referências marcadas e desmarcadas.
Após a fase de marcação inicia-se a fase de compactação, na qual o garbage collector compacta o heap removendo referências inativas. Naturalmente, a compactação move objetos em memória e invalida variáveis e registradores que contém ponteiros para objetos. Assim, o garbage collector é obrigado a revisitar todos os roots e modifica-los para que o roots apontem para os endereços corretos.
Perceba que o garbage collector exige pontos consideráveis de performance.
Mas quando o garbage collector detecta que deve começar a coletar lixo?
Quando um processo é iniciado o CLR reserva uma região contínua de memória, essa região é chamada de managed heap. O managed heap contém um ponteiro de memória chamado NextObjPtr que indica onde o próximo objeto a ser alocado deve ser posicionado dentro do managed heap.
Sempre que executamos o operador ‘new’ forçamos o compilador a emitir uma instrução ‘newobj’ em IL (Intermediate Language), que é responsável por:
- Calcular o número de bytes necessários para a criação do novo tipo;
- Verificar se estes bytes estão disponíveis na memória heap;
- Alocar o espaço na memória heap para o objeto;
- Invocar o construtor;
- Retornar o endereço do novo objeto;
- Avançar o ponteiro NextObjPtr para a próxima posição de memória, na qual o próximo objeto será inserido na memória heap.
Quando uma aplicação executa o operador ‘new’ e não existe espaço suficiente para alocação do objeto dentro do managed heap o garbage collector entra em ação e começa a coletar lixo.
Observação: esta é uma versão simplificada da coleta de lixo do garbage collector. Existem outros recursos que podem fazer o garbage collector começar a coletar lixo, como o (próprio) Windows, o CLR, uso de gerações no garbage collector e chamadas diretas ao garbage collector.
Controle de gerações
O uso de gerações é um meio adotado pelo garbage collector para melhorar a performance da coleta de lixo. A ideia central do uso de gerações é associar um valor para cada geração de instâncias de objetos, para assim classificar cada geração como sendo a mais nova (aquela com o número de geração igual a zero) ou a mais velha (aquela com o maior número de geração).
Separar os objetos em gerações permite ao garbage collector coletar gerações em específico ao invés de avaliar todo o managed heap.
Toda instância quando criada inicia na geração 0, e após sobreviver a uma coleta de lixo é promovida para geração 1. Após muitas coletas, o número de objetos na geração 1 é bastante grande e é bastante provável que muitos desses objetos estejam inacessíveis neste momento. Desta forma, é executada uma coleta sobre a geração 1, e os objetos sobreviventes são incluídos em uma nova geração, a geração 2. Não existe uma geração 3 (veja a propriedade estática MaxGeneration da classe System.GC).
Um meio de avaliar a quantidade de coletas
O código abaixo foi extraído do livro "CLR Via C# 3.0" (página 279) e demonstra um meio de apresentar a quantidade de vezes que o garbage collector foi acionado para coleta de lixo. Veja o código abaixo:
internal sealed class OperationTimer : IDisposable { private Int64 m_startTime; private string m_text; private Int32 m_collectionCount; public OperationTimer(string text) { PrepareForOperation(); m_text = text; m_collectionCount = GC.CollectionCount(0); m_startTime = Stopwatch.GetTimestamp(); } public void Dispose() { Console.WriteLine("{0,6:###.00} seconds (GCs={1,3}) {2}", (Stopwatch.GetTimestamp() - m_startTime) / (double)Stopwatch.Frequency, GC.CollectionCount(0) - m_collectionCount, m_text); } private static void PrepareForOperation() { GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); } }
Como essa classe funciona? Logo no construtor a quantidade de vezes que o garbage collector foi executado para a geração 0 (GC.CollectionCount(0)) é armazenada em um atributo local, além do exato momento no qual o construtor é executado (Stopwatch.GetTimestamp()). E no método Dispose (sim, a classe implementa a interface IDisposable!) é calculada a diferença de tempo entre a execução do construtor e a execução do método Dispose (Stopwatch.GetTimestamp() – m_startTime), além de computada a quantidade de coletas de lixo durante a existência da instância de OperationTimer (GC.CollectionCount(0) – m_collectionCount).
E para que um bloco de código seja avaliado, basta incluí-lo dentro de um bloco using com a classe OperationTimer, como demonstrado no exemplo abaixo:
private static void CheckEntityFramework() { using (new OperationTimer("Insert with Entity Framework")) { var newStudent = new Student(); newStudent.BirthDate = new DateTime(1988, 10, 19); newStudent.Graduation = 8; newStudent.SchoolName = "XPTO School"; newStudent.Name = "XPTO XPTO XYZ"; using (var context = new DataContext()) { context.Students.Add(newStudent); context.SaveChanges(); } } }
E por fim se obtém a seguinte saída:
4,79 seconds (GCs= 3) Insert with Entity Framework
É claro que 4,79 segundos com 3 coletas de lixo representa bastante trabalho para um simples código de inclusão de dados no Entity Framework. Mas vale ressaltar que: como esta é a primeira instância do contexto de dados dentro do AppDomain, é preciso fazer a inicialização do contexto, e isso toma algum tempo, além de recursos. Desta forma é preciso executar o método CheckEntityFramework duas vezes para se comparar a quantidade de recursos gastos com a inicialização do contexto e as demais operações. Então, que sejam feitas duas chamadas seguidas ao mesmo método para que a segunda chamada execute sem a inicialização do contexto.
class Program { static void Main(string[] args) { CheckEntityFramework(); CheckEntityFramework(); // Nothing cool to read here...
E assim se obtém como saída um resultado muito bom na segunda execução do método…
4,49 seconds (GCs= 3) Insert with Entity Framework
0,00 seconds (GCs= 0) Insert with Entity Framework
Assim, nota-se que a classe OperationTimer tem como objetivo medir o trabalho do garbage collector durante a execução de um bloco de código específico, além de computar o tempo gasto para sua execução.
Moral da história: o garbage collector é gigantesco. Não pense que esta é uma descrição completa sobre suas funcionalidades. Existem ainda muitos detalhes sobre o seu funcionamento que valem o seu estudo e compreensão.
Por
MSc. Fernando Henrique Inocêncio Borba Ferreira
Microsoft Most Valuable Professional – Visual C#
Referências:
CLR Via C# – 3rd Edition – Jeffrey Richter
http://msdn.microsoft.com/en-us/magazine/cc163392.aspx
http://web.archive.org/web/20090815065617/http://www.codeproject.com/KB/mcpp/garbage_collection.aspx
Fala Fernando, tudo bem?
Em primeiro lugar feliz ano novo!
Parabéns por mais este excelente post. O assunto é fascinante e temos de aplaudir os caras que cnseguiram fazer lho tão bom!
Abraços
Grande, Magoo! Feliz ano novo!
Realmente o GC foi mto bem feito, essa é uma das principais vantagens de managed languages.
[]s!
Cara, parabéns! Muito bacana sua explanação sobre o assunto (que eu particularmente sempre tive curiosidade) e muito clara também deu uma boa ideia de como funciona e gostei muito das dicas.