A pilha de objetos grandes em sistemas Windows

O coletor de lixo .NET (GC) divide objetos em objetos pequenos e grandes. Quando um objeto é grande, alguns de seus atributos se tornam mais significativos do que se o objeto for pequeno. Por exemplo, compactá-lo – ou seja, copiá-lo na memória em outro lugar na pilha – pode ser caro. Por causa disso, o coletor de lixo coloca objetos grandes na pilha de objetos grandes (LOH). Este artigo discute o que qualifica um objeto como um objeto grande, como objetos grandes são coletados e que tipo de implicações de desempenho objetos grandes impõem.

Importante

Este artigo descreve a pilha de objeto grande no .NET Framework e .NET Core em execução apenas em sistemas Windows. Ele não cobre o LOH em execução em implementações .NET em outras plataformas.

Como um objeto acaba no LOH

Se um objeto tiver tamanho maior ou igual a 85.000 bytes, ele será considerado um objeto grande. Este número foi determinado pelo ajuste de desempenho. Quando uma solicitação de alocação de objeto é para 85.000 ou mais bytes, o tempo de execução a aloca no heap de objeto grande.

Para entender o que isso significa, é útil examinar alguns fundamentos sobre o coletor de lixo.

O coletor de lixo é um coletor geracional. Tem três gerações: geração 0, geração 1 e geração 2. A razão para ter três gerações é que, em um aplicativo bem ajustado, a maioria dos objetos morre em gen0. Por exemplo, em um aplicativo de servidor, as alocações associadas a cada solicitação devem morrer depois que a solicitação for concluída. Os pedidos de alocação em voo entrarão em gen1 e morrerão lá. Essencialmente, gen1 atua como um tampão entre áreas de objetos jovens e áreas de objetos de longa duraçã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.

Grandes objetos pertencem à geração 2 porque são coletados apenas durante uma coleção de geração 2. Quando uma geração é recolhida, todas as suas gerações mais jovens também são recolhidas. Por exemplo, quando um GC de geração 1 acontece, tanto a geração 1 quanto a 0 são coletadas. E quando um GC de geração 2 acontece, toda a pilha é coletada. Por esta razão, um GC de geração 2 também é chamado de GC completo. Este artigo refere-se à geração 2 GC em vez de GC completo, mas os termos são intercambiáveis.

As gerações fornecem uma visão lógica da pilha GC. Fisicamente, os objetos vivem em segmentos de heap gerenciados. Um segmento de heap gerenciado é um pedaço de memória que o GC reserva do sistema operacional chamando a função VirtualAlloc em nome do código gerenciado. Quando o CLR é carregado, o GC aloca dois segmentos de heap iniciais: um para objetos pequenos (o heap de objeto pequeno ou SOH) e outro para objetos grandes (o heap de objeto grande).

As solicitações de alocação são então atendidas colocando objetos gerenciados nesses segmentos de heap gerenciados. Se o objeto for inferior a 85.000 bytes, ele será colocado no segmento para o SOH; caso contrário, é colocado em um segmento LOH. Os segmentos são comprometidos (em partes menores) à medida que mais e mais objetos são alocados neles. Para o SOH, os objetos que sobrevivem a um GC são promovidos para a próxima geração. Os objetos que sobrevivem a uma coleção de geração 0 são agora considerados objetos de geração 1, e assim por diante. No entanto, os objetos que sobrevivem à geração mais antiga ainda são considerados da geração mais velha. Em outras palavras, os sobreviventes da geração 2 são objetos da geração 2; e sobreviventes do LOH são objetos LOH (que são coletados com gen2).

O código do usuário só pode alocar na geração 0 (objetos pequenos) ou no LOH (objetos grandes). Apenas o GC pode "alocar" objetos na geração 1 (promovendo sobreviventes da geração 0) e na geração 2 (promovendo sobreviventes da geração 1).

Quando uma coleta de lixo é acionada, o GC rastreia os objetos vivos e os compacta. Mas como a compactação é cara, o GC varre o LOH, ele faz uma lista gratuita de objetos mortos que podem ser reutilizados posteriormente para satisfazer grandes solicitações de alocação de objetos. Objetos mortos adjacentes são transformados em um objeto livre.

O .NET Core e o .NET Framework (começando com o .NET Framework 4.5.1) incluem a GCSettings.LargeObjectHeapCompactionMode propriedade que permite aos usuários especificar que o LOH deve ser compactado durante o próximo GC de bloqueio completo. E no futuro, o .NET pode decidir compactar o LOH automaticamente. Isso significa que, se você alocar objetos grandes e quiser ter certeza de que eles não se movem, você ainda deve fixá-los.

A Figura 1 ilustra um cenário em que o GC forma a geração 1 após a primeira geração 0 GC onde Obj1 e Obj3 estão mortos, e forma a geração 2 após a primeira geração 1 GC onde Obj2 e Obj5 estão mortos. Note-se que esta e as figuras seguintes são apenas para fins ilustrativos; eles contêm muito poucos objetos para mostrar melhor o que acontece na pilha. Na realidade, muitos mais objetos estão normalmente envolvidos em um GC.

Figure 1: A gen 0 GC and a gen 1 GC
Figura 1: Uma geração 0 e uma geração 1 GC.

A Figura 2 mostra que após um GC de geração 2 que viu isso Obj1 e Obj2 está morto, o GC forma espaço livre contíguo fora da memória que costumava ser ocupado por Obj1 e Obj2, que então foi usado para satisfazer uma solicitação de alocação para Obj4. O espaço após o último objeto, Obj3, até o final do segmento também pode ser usado para satisfazer solicitações de alocação.

Figure 2: After a gen 2 GC
Figura 2: Após um GC de geração 2

Se não houver espaço livre suficiente para acomodar as solicitações de alocação de objetos grandes, o GC primeiro tentará adquirir mais segmentos do sistema operacional. Se isso falhar, aciona um GC de geração 2 na esperança de liberar algum espaço.

Durante uma GC de geração 1 ou 2, o coletor de lixo libera segmentos que não têm objetos dinâmicos de volta para o sistema operacional chamando a função VirtualFree. O espaço após o último objeto vivo até o final do segmento é descomprometido (exceto no segmento efêmero onde gen0/gen1 vive, onde o coletor de lixo mantém algum comprometido porque seu aplicativo estará alocando nele imediatamente). E os espaços livres permanecem comprometidos embora sejam redefinidos, o que significa que o sistema operacional não precisa gravar dados neles de volta ao disco.

Uma vez que o LOH só é coletado durante a geração 2 GCs, o segmento LOH só pode ser liberado durante tal GC. A Figura 3 ilustra um cenário em que o coletor de lixo libera um segmento (segmento 2) de volta para o sistema operacional e desvincula mais espaço nos segmentos restantes. Se ele precisar usar o espaço desconfirmado no final do segmento para satisfazer solicitações de alocação de objetos grandes, ele confirmará a memória novamente. (Para obter uma explicação sobre commit/decommit, consulte a documentação para VirtualAlloc.)

Figure 3: LOH after a gen 2 GC
Figura 3: O LOH após um GC de geração 2

Quando um objeto grande é coletado?

Em geral, um GC ocorre sob uma das seguintes três condições:

  • A alocação excede o limite de geração 0 ou objeto grande.

    O limiar é uma propriedade de uma geração. Um limite para uma geração é definido quando o coletor de lixo aloca objetos nela. Quando o limite é excedido, um GC é acionado nessa geração. Ao alocar objetos pequenos ou grandes, você consome a geração 0 e os limites do LOH, respectivamente. Quando o coletor de lixo se aloca nas gerações 1 e 2, ele consome seus limites. Esses limites são ajustados dinamicamente à medida que o programa é executado.

    Este é o caso típico; a maioria dos GCs acontece devido a alocações no heap gerenciado.

  • O GC.Collect método é chamado.

    Se o método sem GC.Collect() parâmetros for chamado ou outra sobrecarga for passada GC.MaxGeneration como um argumento, o LOH será coletado junto com o restante do heap gerenciado.

  • O sistema está em situação de pouca memória.

    Isso ocorre quando o coletor de lixo recebe uma notificação de alta memória do sistema operacional. Se o coletor de lixo acha que fazer um GC de geração 2 será produtivo, ele aciona um.

Implicações no desempenho da LOH

As alocações no heap de objeto grande afetam o desempenho das seguintes maneiras.

  • Custo de alocação.

    O CLR garante que a memória para cada novo objeto que ele fornece é limpa. Isso significa que o custo de alocação de um objeto grande é dominado pela limpeza de memória (a menos que acione um GC). Se forem necessários dois ciclos para limpar um byte, serão necessários 170.000 ciclos para limpar o menor objeto grande. Limpar a memória de um objeto de 16 MB em uma máquina de 2 GHz leva aproximadamente 16 ms. Esse é um custo bastante grande.

  • Custo de recolha.

    Como o LOH e a geração 2 são coletados juntos, se o limite de qualquer um for excedido, uma coleta de geração 2 é acionada. Se uma coleção de geração 2 for acionada por causa do LOH, a geração 2 não será necessariamente muito menor após o GC. Se não houver muitos dados sobre a geração 2, isso terá um impacto mínimo. Mas se a geração 2 for grande, pode causar problemas de desempenho se muitos GCs da geração 2 forem acionados. Se muitos objetos grandes são alocados em uma base temporária e você tem um SOH grande, você pode estar gastando muito tempo fazendo GCs. Além disso, o custo de alocação pode realmente aumentar se você continuar alocando e soltando objetos realmente grandes.

  • Elementos de matriz com tipos de referência.

    Objetos muito grandes no LOH geralmente são matrizes (é muito raro ter um objeto de ocorrência que seja realmente grande). Se os elementos de uma matriz forem ricos em referência, ele incorrerá em um custo que não estará presente se os elementos não forem ricos em referência. Se o elemento não contiver referências, o coletor de lixo não precisará passar pela matriz. Por exemplo, se você usar uma matriz para armazenar nós em uma árvore binária, uma maneira de implementá-la é referir-se aos nós direito e esquerdo de um nó pelos nós reais:

    class Node
    {
       Data d;
       Node left;
       Node right;
    };
    
    Node[] binary_tr = new Node [num_nodes];
    

    Se num_nodes for grande, o coletor de lixo precisa passar por pelo menos duas referências por elemento. Uma abordagem alternativa é armazenar o índice dos nós direito e esquerdo:

    class Node
    {
       Data d;
       uint left_index;
       uint right_index;
    } ;
    

    Em vez de referir os dados do nó esquerdo como left.d, refere-se a ele como binary_tr[left_index].d. E o coletor de lixo não precisa olhar para nenhuma referência para o nó esquerdo e direito.

Dos três fatores, os dois primeiros são geralmente mais significativos do que o terceiro. Por isso, recomendamos que você aloque um pool de objetos grandes que você reutiliza em vez de alocar os temporários.

Coletar dados de desempenho para o LOH

Antes de coletar dados de desempenho para uma área específica, você já deve ter feito o seguinte:

  1. Encontrou evidências de que você deveria estar olhando para esta área.
  2. Exauriu outras áreas que você conhece sem encontrar nada que pudesse explicar o problema de desempenho que você viu.

Para obter mais informações sobre os fundamentos da memória e da CPU, consulte o blog Entenda o problema antes de tentar encontrar uma solução.

Você pode usar as seguintes ferramentas para coletar dados sobre o desempenho do LOH:

Contadores de desempenho de memória CLR .NET

Os contadores de desempenho de memória CLR do .NET geralmente são uma boa primeira etapa na investigação de problemas de desempenho (embora recomendemos que você use eventos ETW). Uma maneira comum de examinar os contadores de desempenho é com o Monitor de Desempenho (perfmon.exe). Selecione Adicionar (Ctrl + A) para adicionar os contadores interessantes para os processos que lhe interessam. Você pode salvar os dados do contador de desempenho em um arquivo de log.

Os dois contadores a seguir na categoria Memória CLR do .NET são relevantes para o LOH:

  • # Coleções Gen 2

    Exibe o número de vezes que os GCs de geração 2 ocorreram desde o início do processo. O contador é incrementado no final de uma coleta de geração 2 (também chamada de coleta de lixo completa). Este contador exibe o último valor observado.

  • Tamanho da pilha de objeto grande

    Exibe o tamanho atual, em bytes, incluindo espaço livre, do LOH. Esse contador é atualizado no final de uma coleta de lixo, não em cada alocação.

Screenshot that shows adding counters in Performance Monitor.

Você também pode consultar contadores de desempenho programaticamente usando a PerformanceCounter classe. Para o LOH, especifique ".NET CLR Memory" como o CategoryName e "Large Object Heap size" como o CounterName.

PerformanceCounter performanceCounter = new()
{
    CategoryName = ".NET CLR Memory",
    CounterName = "Large Object Heap size",
    InstanceName = "<instance_name>"
};

Console.WriteLine(performanceCounter.NextValue());

É comum coletar contadores programaticamente como parte de um processo de teste de rotina. Quando você identificar contadores com valores fora do comum, use outros meios para obter dados mais detalhados para ajudar na investigação.

Nota

Recomendamos que você use eventos ETW em vez de contadores de desempenho, porque o ETW fornece informações muito mais ricas.

Eventos ETW

O coletor de lixo fornece um rico conjunto de eventos ETW para ajudá-lo a entender o que a pilha está fazendo e por quê. As seguintes postagens de blog mostram como coletar e entender eventos de GC com o ETW:

Para identificar GCs de geração 2 excessivas causadas por alocações temporárias de LOH, consulte a coluna Razão do gatilho para GCs. Para um teste simples que aloca apenas objetos grandes temporários, você pode coletar informações sobre eventos ETW com o seguinte comando PerfView :

perfview /GCCollectOnly /AcceptEULA /nogui collect

O resultado é algo assim:

Screenshot that shows ETW events in PerfView.

Como você pode ver, todos os GCs são GCs de geração 2, e todos eles são acionados por AllocLarge, o que significa que a alocação de um objeto grande acionou esse GC. Sabemos que estas alocações são temporárias porque a coluna % da Taxa de Sobrevivência LOH diz 1%.

Você pode coletar eventos ETW adicionais que informam quem alocou esses objetos grandes. A seguinte linha de comando:

perfview /GCOnly /AcceptEULA /nogui collect

coleta um evento AllocationTick, que é disparado aproximadamente a cada 100k de alocações. Em outras palavras, um evento é disparado cada vez que um objeto grande é alocado. Em seguida, você pode examinar uma das visualizações GC Heap Alloc, que mostram as pilhas de chamadas que alocaram objetos grandes:

Screenshot that shows a garbage collector heap view.

Como você pode ver, este é um teste muito simples que apenas aloca grandes objetos de seu Main método.

Um depurador

Se tudo o que você tem é um despejo de memória e você precisa olhar para quais objetos estão realmente no LOH, você pode usar a extensão do depurador SoS fornecida pelo .NET.

Nota

Os comandos de depuração mencionados nesta seção são aplicáveis aos depuradores do Windows.

A seguir mostra a saída de exemplo da análise do LOH:

0:003> .loadby sos mscorwks
0:003> !eeheap -gc
Number of GC Heaps: 1
generation 0 starts at 0x013e35ec
sdgeneration 1 starts at 0x013e1b6c
generation 2 starts at 0x013e1000
ephemeral segment allocation context: none
segment   begin allocated     size
0018f2d0 790d5588 790f4b38 0x0001f5b0(128432)
013e0000 013e1000 013e35f8 0x000025f8(9720)
Large object heap starts at 0x023e1000
segment   begin allocated     size
023e0000 023e1000 033db630 0x00ffa630(16754224)
033e0000 033e1000 043cdf98 0x00fecf98(16699288)
043e0000 043e1000 05368b58 0x00f87b58(16284504)
Total Size 0x2f90cc8(49876168)
------------------------------
GC Heap Size 0x2f90cc8(49876168)
0:003> !dumpheap -stat 023e1000 033db630
total 133 objects
Statistics:
MT   Count   TotalSize Class Name
001521d0       66     2081792     Free
7912273c       63     6663696 System.Byte[]
7912254c       4     8008736 System.Object[]
Total 133 objects

O tamanho da pilha LOH é (16.754.224 + 16.699.288 + 16.284.504) = 49.738.016 bytes. Entre os endereços 023e1000 e 033db630, 8.008.736 bytes são ocupados por uma matriz de System.Object objetos, 6.663.696 bytes são ocupados por uma matriz de System.Byte objetos e 2.081.792 bytes são ocupados por espaço livre.

Às vezes, o depurador mostra que o tamanho total do LOH é inferior a 85.000 bytes. Isso acontece porque o próprio tempo de execução usa o LOH para alocar alguns objetos que são menores do que um objeto grande.

Como o LOH não é compactado, às vezes pensa-se que o LOH seja a fonte de fragmentação. Fragmentação significa:

  • Fragmentação do heap gerenciado, que é indicado pela quantidade de espaço livre entre objetos gerenciados. Em SoS, o !dumpheap –type Free comando exibe a quantidade de espaço livre entre objetos gerenciados.

  • Fragmentação do espaço de endereçamento da memória virtual (VM), que é a memória marcada como MEM_FREE. Você pode obtê-lo usando vários comandos do depurador no windbg.

    O exemplo a seguir mostra a fragmentação no espaço da VM:

    0:000> !address
    00000000 : 00000000 - 00010000
    Type     00000000
    Protect 00000001 PAGE_NOACCESS
    State   00010000 MEM_FREE
    Usage   RegionUsageFree
    00010000 : 00010000 - 00002000
    Type     00020000 MEM_PRIVATE
    Protect 00000004 PAGE_READWRITE
    State   00001000 MEM_COMMIT
    Usage   RegionUsageEnvironmentBlock
    00012000 : 00012000 - 0000e000
    Type     00000000
    Protect 00000001 PAGE_NOACCESS
    State   00010000 MEM_FREE
    Usage   RegionUsageFree
    … [omitted]
    -------------------- Usage SUMMARY --------------------------
    TotSize (     KB)   Pct(Tots) Pct(Busy)   Usage
    701000 (   7172) : 00.34%   20.69%   : RegionUsageIsVAD
    7de15000 ( 2062420) : 98.35%   00.00%   : RegionUsageFree
    1452000 (   20808) : 00.99%   60.02%   : RegionUsageImage
    300000 (   3072) : 00.15%   08.86%   : RegionUsageStack
    3000 (     12) : 00.00%   00.03%   : RegionUsageTeb
    381000 (   3588) : 00.17%   10.35%   : RegionUsageHeap
    0 (       0) : 00.00%   00.00%   : RegionUsagePageHeap
    1000 (       4) : 00.00%   00.01%   : RegionUsagePeb
    1000 (       4) : 00.00%   00.01%   : RegionUsageProcessParametrs
    2000 (       8) : 00.00%   00.02%   : RegionUsageEnvironmentBlock
    Tot: 7fff0000 (2097088 KB) Busy: 021db000 (34668 KB)
    
    -------------------- Type SUMMARY --------------------------
    TotSize (     KB)   Pct(Tots) Usage
    7de15000 ( 2062420) : 98.35%   : <free>
    1452000 (   20808) : 00.99%   : MEM_IMAGE
    69f000 (   6780) : 00.32%   : MEM_MAPPED
    6ea000 (   7080) : 00.34%   : MEM_PRIVATE
    
    -------------------- State SUMMARY --------------------------
    TotSize (     KB)   Pct(Tots) Usage
    1a58000 (   26976) : 01.29%   : MEM_COMMIT
    7de15000 ( 2062420) : 98.35%   : MEM_FREE
    783000 (   7692) : 00.37%   : MEM_RESERVE
    
    Largest free region: Base 01432000 - Size 707ee000 (1843128 KB)
    

É mais comum ver a fragmentação da VM causada por objetos grandes temporários que exigem que o coletor de lixo adquira frequentemente novos segmentos de heap gerenciados do sistema operacional e libere os vazios de volta para o sistema operacional.

Para verificar se o LOH está causando fragmentação de VM, você pode definir um ponto de interrupção em VirtualAlloc e VirtualFree para ver quem os chamou. Por exemplo, para ver quem tentou alocar blocos de memória virtual maiores que 8 MB do sistema operacional, você pode definir um ponto de interrupção como este:

bp kernel32!virtualalloc "j (dwo(@esp+8)>800000) 'kb';'g'"

Este comando invade o depurador e mostra a pilha de chamadas somente se VirtualAlloc for chamado com um tamanho de alocação maior que 8 MB (0x800000).

O CLR 2.0 adicionou um recurso chamado VM Hoarding que pode ser útil para cenários em que segmentos (inclusive nas pilhas de objetos grandes e pequenos) são frequentemente adquiridos e liberados. Para especificar o Açambarcamento de VM, especifique um sinalizador de inicialização chamado STARTUP_HOARD_GC_VM por meio da API de hospedagem. Em vez de liberar segmentos vazios de volta para o sistema operacional, o CLR desconfirma a memória nesses segmentos e os coloca em uma lista de espera. (Observe que o CLR não faz isso para segmentos muito grandes.) Mais tarde, o CLR usa esses segmentos para satisfazer novas solicitações de segmento. Na próxima vez que seu aplicativo precisar de um novo segmento, o CLR usará um dessa lista de espera se puder encontrar um que seja grande o suficiente.

O açambarcamento de VM também é útil para aplicativos que desejam manter os segmentos que já adquiriram, como alguns aplicativos de servidor que são os aplicativos dominantes em execução no sistema, para evitar exceções de falta de memória.

É altamente recomendável que você teste cuidadosamente seu aplicativo quando usar esse recurso para garantir que seu aplicativo tenha um uso de memória bastante estável.