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, os desenvolvedores que trabalham com código gerenciado não precisam 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 para um objeto que já foi 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, certificando-se de que um objeto não pode usar para si mesmo a memória alocada para outro objeto.

Conceitos básicos de memória

A lista a seguir resume os conceitos importantes de memória 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 página, se houver um.

  • 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 qualquer outra solicitação de alocação. No entanto, você não pode armazenar dados nesse bloco de memória até que eles sejam confirmados.
    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 buracos no espaço de endereço. Quando uma alocação de memória virtual é solicitada, o gerenciador de memória virtual precisa encontrar um único bloco livre que seja grande o suficiente 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 página é usado mesmo que a pressão de memória física (demanda por memória física) seja baixa. Na primeira vez que a pressão de memória física é alta, o sistema operacional deve abrir espaço na memória física para armazenar dados e faz backup de alguns dos dados que estão na memória física para o arquivo de página. Os dados não são paged até que sejam necessários, portanto, é possível encontrar paginação em situações em que 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 coletor de lixo aloca memória para ele no espaço de endereço logo depois do primeiro objeto. Desde que exista espaço de endereço disponível, o coletor de lixo continua alocando 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 grafo que contém todos os objetos que podem ser acessados a partir das raízes.

Objetos que não estão no grafo são inacessíveis nas 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 sobreviverem a uma coleção, não haverá 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. O tamanho da memória é detectado pela notificação de memória baixa do sistema operacional ou memória baixa, conforme 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 é executado continuamente. Esse método é usado principalmente para situações exclusivas e testes.

O heap gerenciado

Depois que o CLR inicializa o coletor de lixo, ele aloca um segmento de 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 necessário e libera segmentos de volta para o sistema operacional (depois de desmarcá-los de 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 dinâmicos para que eles sejam movidos juntos, e o espaço morto seja removido, tornando assim o heap menor. Esse processo garante que os objetos 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 um objeto de instância ser extra 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.
  • Objetos mais recentes têm tempo de vida mais curto e objetos mais antigos têm tempo de vida mais longo.
  • 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 curta duração. 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 coleção para liberar espaço de endereço para o 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 curta duração e serve como um buffer entre objetos de curta duração e objetos de longa duração.

    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 coleção de geração 0 não recuperar memória suficiente para o aplicativo criar um novo objeto, o coletor de lixo poderá executar uma coleção de geração 1 e, em seguida, a 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 longa duração. 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.

    Objetos na geração 2 que sobrevivem a uma coleção permanecem na geração 2 até serem determinados como inacessíveis em uma coleção futura.

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

As coletas de lixo ocorrem em gerações específicas como garantia de condições. Coletar uma geração significa coletar objetos nessa geração e todas as suas gerações mais jovens. Uma coleta de lixo de geração 2 também é conhecida como uma coleta de lixo completa porque 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 conhecidos como sobreviventes e são promovidos para a 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 dependendo se um sistema tem 32 bits ou 64 bits e no 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 do segmento efêmero:

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 de geração 2 podem usar vários segmentos, tanto quanto o processo requer e a memória permite.

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 ocupado pelos objetos mortos.

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 para o final mais antigo 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. Os sobreviventes da geração 1 e 2 podem ser movidos para um segmento diferente porque são promovidos à geração 2.

    Normalmente, o LOH (heap de objeto grande) não é compactado porque copiar objetos grandes impõe uma penalidade de 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 pelo stack walker. 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: manipula esse ponto 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 estar fazendo 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 ilustração a seguir mostra um thread que dispara uma coleta de lixo e faz com que os outros threads sejam suspensos:

Captura de tela de como um thread dispara uma Coleta de Lixo.

Recursos não gerenciados

Para a maioria dos objetos que seu aplicativo cria, você pode contar com a 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 o coletor de lixo possa acompanhar o tempo de vida de um objeto gerenciado que encapsula um recurso não gerenciado, ele 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