Noções básicas da coleta de lixo

No CLR (Common Language Runtime), o GC (coletor de lixo) atua como um gerenciador automático de memória. O coletor de lixo gerencia a alocação e a liberação de memória para um aplicativo. Portanto, desenvolvedores que trabalham com código gerenciado não têm que escrever código para executar tarefas de gerenciamento de memória. O gerenciamento automático de memória pode eliminar problemas comuns, como esquecer de liberar um objeto e causar um vazamento de memória ou tentar acessar a memória liberada de um objeto que já tenha sido liberado.

Este artigo descreve os principais conceitos da coleta de lixo.

Benefícios

O coletor de lixo proporciona os seguintes benefícios:

  • Libera os desenvolvedores de terem que liberar manualmente a memória.

  • Aloca objetos no heap gerenciado com eficiência.

  • Recupera os objetos que não estão sendo usados, limpa a memória e mantém a memória disponível para alocações futuras. Os objetos gerenciados obtêm automaticamente conteúdo limpo ao começar, portanto, seus construtores não precisam inicializar cada campo de dados.

  • Fornece segurança de memória, assegurando que um objeto não consiga usar a memória já alocada para outro objeto.

Conceitos básicos de memória

A seguinte lista resume conceitos importantes de memória do CLR:

  • Cada processo tem seu próprio espaço de endereço virtual separado. Todos os processos no mesmo computador compartilham a mesma memória física e o arquivo de paginação, se houver algum.

  • Por padrão, em computadores de 32 bits, cada processo tem um espaço de endereço virtual no modo de usuário de 2 GB.

  • Como desenvolvedor de aplicativos, você trabalha apenas com o espaço de endereço virtual e nunca manipula a memória física diretamente. O coletor de lixo aloca e libera memória virtual para você no heap gerenciado.

    Se você estiver escrevendo um código nativo, use as funções do Windows para trabalhar com o espaço de endereço virtual. Essas funções alocam e liberam memória virtual para você em heaps nativos.

  • A memória virtual pode estar em três estados:

    Estado Descrição
    Gratuita O bloco de memória não tem referências a ele e está disponível para alocação.
    Reservado O bloco de memória está disponível para seu uso e não pode ser usado para nenhuma outra solicitação de alocação. No entanto, você não pode armazenar dados nesse bloco de memória até que ele esteja comprometido.
    Confirmado O bloco de memória é atribuído para armazenamento físico.
  • O espaço de endereço virtual pode ficar fragmentado, o que significa que há blocos livres conhecidos como espaços no espaço de endereço. Quando uma alocação de memória virtual é solicitada, o gerenciador de memória virtual precisa localizar um único bloco livre suficientemente grande para atender à solicitação de alocação. Mesmo que você tenha 2 GB de espaço livre, uma alocação que exige 2 GB não será bem-sucedida a menos que todo esse espaço livre esteja em um único bloco de endereço.

  • Você poderá ficar sem memória se não houver espaço de endereço virtual suficiente para reservar ou espaço físico para confirmar.

    O arquivo de paginação será usado mesmo se a pressão de memória física (a demanda de memória física) for baixa. Na primeira vez em que a pressão de memória física estiver alta, o sistema operacional deverá liberar espaço na memória física para armazenar dados, além de fazer o backup de alguns dos dados na memória física para o arquivo de paginação. Os dados não serão paginados até que sejam necessários, portanto, é possível encontrar paginação em situações nas quais a pressão de memória física é baixa.

Alocação de memória

Quando você inicializa um novo processo, o runtime reserva uma região contígua de espaço de endereço para o processo. Esse espaço de endereço reservado é chamado de heap gerenciado. O heap gerenciado mantém um ponteiro para o endereço no qual o próximo objeto do heap será alocado. Inicialmente, esse ponteiro é definido como o endereço básico do heap gerenciado. Todos os tipos de referência são alocados no heap gerenciado. Quando um aplicativo cria o primeiro tipo de referência, a memória é alocada para o tipo no endereço base do heap gerenciado. Quando o aplicativo cria o próximo objeto, o runtime aloca memória para ele no espaço de endereço imediatamente após o primeiro objeto. Enquanto o espaço de endereço estiver disponível, o runtime continuará a alocar espaço para novos objetos dessa maneira.

A alocação memória com base no heap gerenciado é mais rápida do que a alocação de memória não gerenciada. Como o runtime aloca memória para um objeto adicionando um valor a um ponteiro, ele é quase tão rápido quanto a alocação de memória com base na pilha. Além disso, como novos objetos alocados consecutivamente são armazenados contiguamente no heap gerenciado, um aplicativo pode acessar rapidamente os objetos.

Versão de memória

O mecanismo de otimização do coletor de lixo determina o melhor momento para executar uma coleta com base nas alocações que estão sendo feitas. Quando o coletor de lixo executa uma coleta, ele libera a memória dos objetos que não estão mais sendo usados pelo aplicativo. Ele determina quais objetos não estão mais sendo usados examinando as raízes do aplicativo. As raízes de um aplicativo incluem campos estáticos, variáveis locais em uma pilha de thread, além de registros de CPU, identificador de coleta de lixo e a fila Finalizar. Cada raiz refere-se a um objeto no heap gerenciado ou é definida como nula. O coletor de lixo pode pedir o restante do runtime para essas raízes. O coletor de lixo usa essa lista para criar um gráfico que contém todos os objetos que possam ser alcançados nas raízes.

Objetos que não estão no gráfico são inacessíveis a partir das raízes do aplicativo. O coletor de lixo considera lixo os objetos inacessíveis e libera a memória alocada para eles. Durante uma coleta, o coletor de lixo examina o heap gerenciado, procurando os blocos de espaço de endereço ocupados por objetos inacessíveis. Na medida em que descobre cada objeto inacessível, ele usa uma função de cópia de memória para compactar os objetos acessíveis na memória, liberando os blocos de espaços de endereço alocados para objetos inacessíveis. Uma vez que a memória dos objetos acessíveis tenha sido compactada, o coletor de lixo faz as correções necessárias no ponteiro de forma que as raízes do aplicativo apontem para os objetos em seus novos locais. Ele também posiciona o ponteiro do heap gerenciado após o último objeto acessível.

A memória é compactada somente se uma coleta descobre um número significativo de objetos inacessíveis. Se todos os objetos no heap gerenciado sobrevivem a uma coleta, não há necessidade de compactação de memória.

Para melhorar o desempenho, o runtime aloca memória para objetos grandes em um heap separado. O coletor de lixo automaticamente libera a memória para objetos grandes. No entanto, para evitar mover objetos grandes na memória, essa memória geralmente não é compactada.

Condições para uma coleta de lixo

A coleta de lixo ocorre quando uma das seguintes condições é verdadeira:

  • O sistema tem pouca memória física. Esse tamanho de memória é detectado pela notificação do sistema operacional sobre a falta de memória ou pela falta de memória, como indicado pelo host.

  • A memória usada por objetos alocados no heap gerenciado ultrapassa o limite aceitável. Esse limite é ajustado continuamente enquanto o processo é executado.

  • O método GC.Collect é chamado. Em quase todos os casos, você não precisa chamar esse método porque o coletor de lixo funciona continuamente. Esse método é usado principalmente para situações exclusivas e testes.

O heap gerenciado

Após o CLR inicializar o coletor de lixo, ele aloca um segmento da memória para armazenar e gerenciar objetos. Essa memória é chamada de heap gerenciado, em contraposição a um heap nativo no sistema operacional.

Há um heap gerenciado para cada processo gerenciado. Todos os threads no processo alocam memória para objetos no mesmo heap.

Para reservar memória, o coletor de lixo chama a função VirtualAlloc do Windows e reserva um segmento de memória por vez para aplicativos gerenciados. O coletor de lixo também reserva segmentos, conforme o necessário, e libera segmentos de volta para o sistema operacional (depois de eliminar qualquer objeto) chamando a função VirtualFree do Windows.

Importante

O tamanho de segmentos alocados pelo coletor de lixo é específico da implementação e está sujeito a alterações a qualquer momento, incluindo em atualizações periódicas. Seu aplicativo nunca deve fazer suposições sobre o tamanho de um segmento em particular nem depender dele, tampouco deve tentar configurar a quantidade de memória disponível para alocações de segmento.

Quanto menos objetos alocados no heap, menos trabalho o coletor de lixo precisa fazer. Ao alocar objetos, não use valores arredondados que excedam suas necessidades, por exemplo, alocando uma matriz de 32 bytes quando são necessários apenas 15 bytes.

Quando uma coleta de lixo é disparada, o coletor de lixo recupera a memória ocupada por objetos inativos. O processo de recuperação compacta objetos vivos para que eles sejam movidos juntos e o espaço inativo é removido, tornando o heap menor. Esse processo garante que os objetos que são alocados juntos permaneçam juntos no heap gerenciado, para preservar sua localidade.

O grau de intrusão (frequência e a duração) de coletas de lixo é o resultado do volume de alocações e da quantidade de memória restante no heap gerenciado.

O heap pode ser considerado como o acúmulo de dois heaps: o heap de objetos grandes e o heap de objetos pequenos. O heap de objetos grandes contém objetos com 85.000 bytes ou mais, que geralmente são matrizes. É raro que um objeto de instância seja muito grande.

Dica

Você pode configurar o tamanho do limite dos objetos a serem usados no heap de objetos grandes.

Gerações

O algoritmo GC baseia-se em várias considerações:

  • É mais rápido compactar a memória para uma parte do heap gerenciado do que para o heap gerenciado inteiro.
  • Os objetos mais recentes terão vidas úteis menores e objetos mais antigos terão vidas úteis maiores.
  • Os objetos mais recentes tendem a se relacionar entre si e a serem acessados pelo aplicativo aproximadamente ao mesmo tempo.

A coleta de lixo ocorre principalmente com a recuperação de objetos de vida útil curta. Para otimizar o desempenho do coletor de lixo, o heap gerenciado é dividido em três gerações: 0, 1 e 2 de modo a conseguir manusear os objetos de vida útil longa e curta de forma separada. O coletor de lixo armazena novos objetos na geração 0. Os objetos criados no início no tempo de vida do aplicativo que sobrevivem a coleções são promovidos e armazenados nas gerações 1 e 2. Como é mais rápido compactar uma parte do heap gerenciado do que o heap inteiro, esse esquema permite que o coletor de lixo libere a memória em uma geração específica em vez liberar a memória para toda a memória gerenciada a cada vez que ele executa uma coleta.

  • Geração 0: essa geração é a mais jovem e contém objetos de vida útil curta. Um exemplo de um objeto de vida útil curta é uma variável temporária. A coleta de lixo ocorre com mais frequência nessa geração.

    Objetos recém alocados formam uma nova geração de objetos e são implicitamente coletas da geração 0. No entanto, se forem objetos grandes, eles vão para o LOH (heap de objeto grande), que às vezes é chamado de geração 3. A geração 3 é uma geração física que é coletada logicamente como parte da geração 2.

    A maioria dos objetos são recuperados para coleta de lixo na geração 0 e não sobrevivem à próxima geração.

    Se um aplicativo tentar criar um novo objeto quando a geração 0 estiver cheia, o coletor de lixo executará uma coleta para liberar espaço de endereço para esse objeto. O coletor de lixo inicia examinando os objetos na geração 0 em vez de todos os objetos no heap gerenciado. Uma única coleta de geração 0 normalmente recupera memória suficiente para permitir que o aplicativo continue a criar novos objetos.

  • Geração 1: essa geração contém objetos de vida útil curta e serve como um buffer entre objetos de vida útil curta e longa.

    Depois que o coletor de lixo executa uma coleta de geração 0, ele compacta a memória para os objetos acessíveis e os promove para a geração 1. Como os objetos que sobrevivem a coleções tendem a ter tempos de vida mais longos, faz sentido promovê-los a uma geração mais alta. O coletor de lixo não tem que reexaminar os objetos das gerações 1 e 2 sempre que executa uma coleta de geração 0.

    Se uma coleta de geração 0 não recupera memória suficiente para que o aplicativo conclua com êxito a sua tentativa de criar um novo objeto, o coletor de lixo pode executar uma coleta de geração 1 e, então, de geração 2. Os objetos na geração 1 que sobrevivem a coleções são promovidos para a geração 2.

  • Geração 2: essa geração contém objetos de vida útil longa. Um exemplo de um objeto de vida útil longa é um objeto em um aplicativo para servidores que contém dados estáticos que estão ativos durante o processo.

    Os objetos na geração 2 que sobrevivem a uma coleta permanecem na geração 2 até serem considerados inacessíveis em uma coleta futura.

    Objetos no heap de objeto grande (que às vezes é chamado de geração 3) também são coletados na geração 2.

Coletas de lixo ocorrem em gerações específicas conforme as condições permitirem. Coletar uma geração significa coletar objetos nessa geração e todas as suas gerações mais jovens. Uma coleta de lixo da geração 2 também é conhecida como uma coleta de lixo completa, pois ela recupera objetos em todas as gerações (ou seja, todos os objetos no heap gerenciado).

Sobrevivência e promoções

Objetos que não são recuperados em uma coleta de lixo são os sobreviventes e são promovidos à próxima geração:

  • Objetos que sobrevivem a uma coleta de lixo de geração 0 são promovidos para a geração 1.
  • Objetos que sobrevivem a uma coleta de lixo de geração 1 são promovidos para a geração 2.
  • Objetos que sobrevivem a uma coleta de lixo de geração 2 permanecem na geração 2.

Quando o coletor de lixo detecta que a taxa de sobrevivência é alta em uma geração ele aumenta o limite de alocações para a geração em questão para aquela geração. A próxima coleta recupera um tamanho substancial de memória. O CLR equilibra continuamente duas prioridades: não permitir que o conjunto de trabalho de um aplicativo fique muito grande devido ao atraso da coleta de lixo e não permitir que a coleta de lixo seja executada com uma frequência muito alta.

Gerações e segmentos efêmeros

Como os objetos das gerações 0 e 1 são de vida útil curta, essas gerações são conhecidas como gerações efêmeras.

As gerações efêmeras devem ser alocadas no segmento de memória conhecido como segmento efêmero. Cada novo segmento adquirido pelo coletor de lixo torna-se o novo segmento efêmero e contém os objetos que sobreviveram a uma coleta de lixo da geração 0. O segmento efêmero antigo torna-se o novo segmento da geração 2.

O tamanho do segmento efêmero varia de acordo com o sistema, se é de 32 ou 64 bits, e com o tipo de coletor de lixo em execução (estação de trabalho ou GC de servidor). A tabela a seguir mostra os tamanhos padrão de segmentos efêmeros:

GC de estação de trabalho/servidor 32 bits 64 bits
GC da estação de trabalho 16 MB 256 MB
GC do servidor 64 MB 4 GB
GC do Servidor com > 4 CPUs lógicas 32 MB 2 GB
GC do Servidor com > 8 CPUs lógicas 16 MB 1 GB

O segmento efêmero pode incluir objetos da geração 2. Objetos da geração 2 podem usar vários segmentos, tantos quanto exigido pelo seu processo e permitido pela memória.

A quantidade de memória liberada de uma coleta de lixo efêmera é limitada ao tamanho do segmento efêmero. A quantidade de memória liberada é proporcional ao espaço que era ocupado pelos objetos inativos.

O que ocorre durante uma coleta de lixo

Uma coleta de lixo tem as seguintes fases:

  • Uma fase de marcação que localiza todos os objetos vivos e cria uma lista desses objetos.

  • Uma fase de relocação que atualiza as referências aos objetos que serão compactados.

  • Uma fase de compactação que recupera o espaço ocupado por objetos inativos e compacta os objetos sobreviventes. A fase de compactação move objetos que sobreviveram a uma coleta de lixo em direção à extremidade mais antiga do segmento.

    Em virtude das coletas da geração 2 poderem ocupar vários segmentos, objetos que são promovidos para a geração 2 podem ser movidos para um segmento mais antigo. Tanto os sobreviventes da geração 1 quanto da geração 2 podem ser movidos para um segmento diferente, porque eles são promovidos para a geração 2.

    Normalmente o LOH (heap de objetos grandes) não é compactado, porque copiar objetos grandes impõe uma penalidade no desempenho. No entanto, no .NET Core e no .NET Framework 4.5.1 e posterior, você pode usar a propriedade GCSettings.LargeObjectHeapCompactionMode para compactar o heap de objeto grande sob demanda. Além disso, o LOH é compactado automaticamente quando um limite rígido é definido especificando:

O coletor de lixo usa as informações a seguir para determinar se os objetos estão vivos:

  • Raízes de pilha: variáveis de pilha fornecidas pelo compilador JIT (just-in-time) e movimentador de pilhas. As otimizações JIT podem aumentar ou diminuir as regiões de código dentro das quais as variáveis de pilha são relatadas para o coletor de lixo.

  • Identificadores de coleta de lixo: identificadores que apontam para objetos gerenciados e que podem ser alocados pelo código do usuário ou pelo Common Language Runtime.

  • Dados estáticos: objetos estáticos em domínios de aplicativo que podem fazer referência a outros objetos. Cada domínio de aplicativo controla seus objetos estáticos.

Antes de iniciar uma coleta de lixo, todos os threads gerenciados são suspensos, exceto o thread que disparou a coleta de lixo.

A seguinte ilustração mostra um thread que dispara uma coleta de lixo e faz com que outros threads sejam suspensos:

Screenshot of how a thread triggers a Garbage Collection.

Recursos não gerenciados

Para a maioria dos objetos que seu aplicativo cria, você pode confiar na coleta de lixo para executar as tarefas de gerenciamento de memória necessárias automaticamente. Entretanto, recursos não gerenciados requerem limpeza explícita. O tipo mais comum de recursos não gerenciados é um objeto que encapsula um recurso do sistema operacional, como um identificador de arquivo, um identificador de janela ou uma conexão de rede. Embora a coleta de lixo possa acompanhar o tempo de vida de um objeto gerenciado que encapsule um recurso não gerenciado, ela não tem conhecimento específico sobre como limpar o recurso.

Quando você define um objeto que encapsula um recurso não gerenciado, é recomendável fornecer o código necessário para limpar o recurso não gerenciado em um método Dispose público. Ao fornecer um método Dispose, você permite que usuários do objeto liberem explicitamente o recurso quando terminarem de usar o objeto. Quando usa um objeto que encapsula um recurso não gerenciado, você precisa chamar Dispose conforme a necessidade.

Você também deve fornecer uma forma de liberar os recursos não gerenciados caso um consumidor desse tipo esqueça de chamar Dispose. Você pode usar um identificador seguro para encapsular o recurso não gerenciado ou substituir o método Object.Finalize().

Confira mais informações sobre como limpar recursos não gerenciados em Limpar recursos não gerenciados.

Confira também