Fundamentos da coleta de lixo

No Common Language Runtime (CLR), o coletor de lixo (GC) serve como um gerenciador de memória automática. O coletor de lixo gerencia a alocação e 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 de coleta de lixo.

Benefícios

O coletor de lixo oferece os seguintes benefícios:

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

  • Aloca objetos no heap gerenciado de forma eficiente.

  • Recupera objetos que não estão mais sendo usados, limpa sua memória e mantém a memória disponível para alocações futuras. Os objetos gerenciados obtêm automaticamente conteúdo limpo para começar, para que seus construtores não precisem inicializar todos os campos 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.

Fundamentos da memória

A lista a seguir resume conceitos importantes de memória CLR:

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

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

  • Como desenvolvedor de aplicativos, você trabalha apenas com 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 código nativo, use 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 pilhas nativas.

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

    Condição Description
    Gratuito 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 ele seja confirmado.
    Empenhado O bloco de memória é atribuído ao 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 tem que encontrar um único bloco livre que é grande o suficiente para satisfazer a solicitação de alocação. Mesmo que você tenha 2 GB de espaço livre, uma alocação que exija 2 GB não será bem-sucedida, a menos que todo esse espaço livre esteja em um único bloco de endereço.

  • Você pode 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 é usado mesmo se a pressão da memória física (demanda por memória física) for baixa. Na primeira vez que a pressão da 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 paginação. Os dados não são paginados até que sejam necessários, por isso é possível encontrar paginação em situações em que a pressão da memória física é baixa.

Alocação de memória

Quando você inicializa um novo processo, o tempo de execução 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 onde o próximo objeto no heap será alocado. Inicialmente, esse ponteiro é definido como o endereço base 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 tempo de execução 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 tempo de execução continuará a alocar espaço para novos objetos dessa maneira.

A alocação de memória do heap gerenciado é mais rápida do que a alocação de memória não gerenciada. Como o tempo de execução aloca memória para um objeto adicionando um valor a um ponteiro, é quase tão rápido quanto alocar memória da pilha. Além disso, como novos objetos alocados consecutivamente são armazenados contíguamente no heap gerenciado, um aplicativo pode acessar os objetos rapidamente.

Liberação de memória

O mecanismo de otimização do coletor de lixo determina o melhor momento para realizar 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 para 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 na pilha de um thread, registradores de CPU, identificadores de GC e a fila de finalização. Cada raiz refere-se a um objeto no heap gerenciado ou é definida como null. O coletor de lixo pode pedir o resto do tempo de execução para essas raízes. O coletor de lixo usa essa lista para criar um gráfico que contém todos os objetos que podem ser acessados a partir das raízes.

Os 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 objetos inacessíveis lixo e libera a memória alocada para eles. Durante uma coleta, o coletor de lixo examina a pilha gerenciada, procurando os blocos de espaço de endereço ocupados por objetos inacessíveis. À medida que descobre cada objeto inacessível, ele usa uma função de cópia de memória para compactar os objetos alcançáveis na memória, liberando os blocos de espaços de endereço alocados para objetos inacessíveis. Uma vez que a memória para os objetos acessíveis tenha sido compactada, o coletor de lixo faz as correções de ponteiro necessárias para 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 alcançável.

A memória é compactada somente se uma coleção descobrir 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 tempo de execução aloca memória para objetos grandes em um heap separado. O coletor de lixo libera automaticamente 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 recolha de lixo

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

  • O sistema tem pouca memória física. O tamanho da memória é detetado pela notificação de pouca memória do sistema operacional ou pouca memória, conforme indicado pelo host.

  • A memória usada pelos objetos alocados no heap gerenciado ultrapassa um limite aceitável. Este limiar é continuamente ajustado à medida que o processo é executado.

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

A pilha gerenciada

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 oposiçã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 Windows VirtualAlloc e reserva um segmento de memória de cada 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 limpá-los de quaisquer objetos) chamando a função Windows VirtualFree .

Importante

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

Quanto menos objetos alocados na pilha, menos trabalho o coletor de lixo tem que fazer. Ao alocar objetos, não use valores arredondados que excedam suas necessidades, como alocar uma matriz de 32 bytes quando precisar de apenas 15 bytes.

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

A intrusividade (frequência e duração) das coletas de lixo é o resultado do volume de alocações e da quantidade de memória sobrevivente na pilha gerenciada.

A pilha pode ser considerada como o acúmulo de duas pilhas: a pilha de objeto grande e a pilha de objeto pequeno. O heap de objeto grande contém objetos com 85.000 bytes ou mais, que geralmente são matrizes. É raro que um objeto de instância seja muito grande.

Gorjeta

Você pode configurar o tamanho do limite para que os objetos fiquem 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 da pilha gerenciada do que para toda a pilha gerenciada.
  • Objetos mais novos têm vida útil mais curta, e objetos mais antigos têm vida útil mais longa.
  • Objetos mais recentes tendem a ser relacionados entre si e acessados pelo aplicativo ao mesmo tempo.

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

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

    Os objetos recém-alocados formam uma nova geração de objetos e são implicitamente coleções de geração 0. No entanto, se forem objetos grandes, eles vão para a pilha de objetos grandes (LOH), que às vezes é referida como geração 3. A geração 3 é uma geração física que é logicamente coletada como parte da geração 2.

    A maioria dos objetos é recuperada para coleta de lixo na geração 0 e não sobrevive para a 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çamento para o objeto. O coletor de lixo começa examinando os objetos na geração 0 em vez de todos os objetos na pilha gerenciada. Uma coleção de geração 0 sozinha geralmente recupera memória suficiente para permitir que o aplicativo continue criando novos objetos.

  • Geração 1: Esta geração contém objetos de vida curta e serve como um tampão entre objetos de vida curta e objetos de vida longa.

    Depois que o coletor de lixo realiza uma coleta de geração 0, ele compacta a memória para os objetos alcançáveis e os promove para a geração 1. Como os objetos que sobrevivem às coleções tendem a ter uma vida útil mais longa, faz sentido promovê-los para uma geração mais alta. O coletor de lixo não precisa reexaminar os objetos nas gerações 1 e 2 cada vez que realiza uma coleta da 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 coleta de geração 1 e, em seguida, geração 2. Os objetos da geração 1 que sobrevivem às coleções são promovidos para a geração 2.

  • Geração 2: Esta geração contém objetos de longa duração. Um exemplo de um objeto de longa duração é um objeto em um aplicativo de servidor que contém dados estáticos que estão ativos durante o processo.

    Os objetos da geração 2 que sobrevivem a uma coleção permanecem na geração 2 até que sejam determinados como inalcançáveis em uma coleção futura.

    Os objetos na pilha de objetos grandes (que às vezes é referida como geração 3) também são coletados na geração 2.

As coletas de lixo ocorrem em gerações específicas, conforme as condições o justifiquem. Colecionar uma geração significa colecionar objetos nessa geração e em todas as suas gerações mais jovens. Uma coleta de lixo de geração 2 também é conhecida como coleta de lixo completa porque recupera objetos em todas as gerações (ou seja, todos os objetos na pilha gerenciada).

Sobrevivência e promoções

Os 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:

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

Quando o coletor de lixo deteta que a taxa de sobrevivência é alta em uma geração, aumenta o limiar de alocações para essa geração. A próxima coleção recebe um tamanho substancial de memória recuperada. O CLR equilibra continuamente duas prioridades: não deixar que o conjunto de trabalho de um aplicativo fique muito grande, atrasando a coleta de lixo e não deixando que a coleta de lixo seja executada com muita frequência.

Gerações e segmentos efémeros

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

As gerações efêmeras são 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 de geração 0. O antigo segmento efêmero passa a ser o segmento da nova geração 2.

O tamanho do segmento efêmero varia dependendo se um sistema é de 32 bits ou 64 bits e do tipo de coletor de lixo que está executando (estação de trabalho ou GC do servidor). A tabela a seguir mostra os tamanhos padrão do segmento efêmero:

GC da estação de trabalho/servidor 32 bits 64 bits
Estação de trabalho GC 16 MB 256MB
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 de geração 2. Os objetos da 2ª geração podem usar vários segmentos quantos o seu processo exigir e a memória permitir.

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 acontece durante uma recolha de lixo

A recolha de lixo tem as seguintes fases:

  • Uma fase de marcação que localiza e cria uma lista de todos os objetos dinâmicos.

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

  • Uma fase de compactação que recupera o espaço ocupado pelos objetos mortos e compacta os objetos sobreviventes. A fase de compactação move objetos que sobreviveram a uma coleta de lixo para a extremidade mais antiga do segmento.

    Como as coleções da geração 2 podem ocupar vários segmentos, os objetos promovidos para a geração 2 podem ser movidos para um segmento mais antigo. Os sobreviventes da geração 1 e da geração 2 podem ser movidos para um segmento diferente porque são promovidos para a geração 2.

    Normalmente, a pilha de objetos grandes (LOH) não é compactada 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 GCSettings.LargeObjectHeapCompactionMode propriedade 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 seguintes informações para determinar se os objetos estão vivos:

  • Stack roots: variáveis de pilha fornecidas pelo compilador just-in-time (JIT) e stack walker. As otimizações JIT podem alongar ou encurtar regiões de código dentro das quais as variáveis de pilha são relatadas ao coletor de lixo.

  • Identificadores de coleta de lixo: identifica que apontam para objetos gerenciados e que podem ser alocados por código de 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 uma coleta de lixo começar, 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:

Screenshot of how a thread triggers a Garbage Collection.

Recursos não geridos

Para a maioria dos objetos criados pelo aplicativo, você pode confiar na coleta de lixo para executar as tarefas de gerenciamento de memória necessárias automaticamente. No entanto, recursos não gerenciados exigem limpeza explícita. O tipo mais comum de recurso não gerenciado é um objeto que encapsula um recurso do sistema operacional, como um identificador de arquivo, identificador de janela ou conexão de rede. Embora o coletor de lixo possa controlar 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.

Ao definir 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 público Dispose . Ao fornecer um Dispose método, você permite que os usuários do seu objeto liberem explicitamente o recurso quando terminarem de usá-lo. Quando você usa um objeto que encapsula um recurso não gerenciado, certifique-se de chamar Dispose conforme necessário.

Você também deve fornecer uma maneira para que seus recursos não gerenciados sejam liberados caso um consumidor do seu tipo se esqueça de ligar Dispose. Você pode usar um identificador seguro para encapsular o recurso não gerenciado ou substituir o Object.Finalize() método.

Para obter mais informações sobre como limpar recursos não gerenciados, consulte Limpar recursos não gerenciados.

Consulte também