Partilhar via


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 um pedido de alocação de objetos é para 85.000 ou mais bytes, o tempo de execução aloca-o no heap de objetos grandes.

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 serão finalizados lá. Essencialmente, gen1 atua como um buffer 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, todo o heap é recolhido. Por esta razão, um GC de geração 2 também é chamado de GC completo. Este artigo refere-se ao GC de geração 2 em vez do 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 tiver menos de 85.000 bytes, será colocado no segmento para o SOH; caso contrário, será colocado num segmento LOH. Os segmentos são atribuídos (em pequenos blocos) à 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 cria uma lista livre 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.

Figura 1: Um GC gen 0 e um GC gen 1
Figura 1: Uma GC de geração 0 e uma GC de geração 1.

A Figura 2 mostra que após um GC de geração 2 identificar que Obj1 e Obj2 estão mortos, o GC forma espaço livre contíguo na memória que era ocupado por Obj1 e Obj2, e este espaço foi então utilizado para atender a 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.

Figura 2: Após um GC gen 2
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 liberta um segmento (segmento 2) de volta ao sistema operacional e liberta 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 do VirtualAlloc.)

Figura 3: LOH após um GC gen 2
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 da geração 0 ou o limite de objeto grande.

    O limiar é uma característica 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 é alocado nas gerações 1 e 2, ele consome os seus limites de utilização. 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 método GC.Collect é invocado.

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

  • 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 objetos grandes afetam o desempenho de várias formas.

  • Custo da 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ências, eles incorrerão em um custo que não estará presente se os elementos não forem ricos em referências. 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 sistema de recolha de lixo não precisa verificar nenhuma referência nos nós 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 deverias considerar esta área.
  2. Esgotou outras áreas que conhece sem encontrar nada que pudesse explicar o problema de desempenho que 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 do CLR do .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 objetos grandes

    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.

Captura de ecrã que mostra a adição de contadores no Monitor de Desempenho.

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.

Observação

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 está acontecendo na pilha e o motivo. As seguintes postagens de blog mostram como coletar e entender eventos de GC com o ETW:

Para identificar GCs de geração 2 em excesso causadas por alocações temporárias de LOH, consulte a coluna Motivo do Acionamento 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:

Captura de tela que mostra eventos ETW no PerfView.

Pode-se ver que todos os GCs são de geração 2, e todos eles são ativados por AllocLarge, o que significa que a alocação de um objeto grande ativou este GC. Sabemos que essas alocações são temporárias porque a coluna LOH Survival Rate % 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

recolhe um evento AllocationTick, que é acionado aproximadamente a cada 100 mil alocações. Em outras palavras, um evento é acionado sempre 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:

Captura de ecrã que mostra uma vista de heap do coletor de lixo.

Como pode-se ver, este é um teste muito simples que apenas aloca grandes objetos do seu método Main.

Um depurador

Se tudo o que tens é um despejo de memória e precisas de observar quais são os objetos realmente no LOH, podes usar a extensão do depurador SoS fornecida pelo .NET.

Observação

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 controlado, indicada pela quantidade de espaço livre entre objetos controlados. 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 entra no depurador e mostra a pilha de chamadas somente se VirtualAlloc for chamado com um tamanho de alocação superior a 8 MB (0x800000).

O CLR 2.0 incluiu um recurso denominado VM Hoarding, que pode ser valioso em cenários onde segmentos (incluindo nas pilhas de objetos grandes e pequenos) são frequentemente adquiridos e libertados. Para especificar o Açambarcamento de VMs, especifique um sinalizador de inicialização chamado STARTUP_HOARD_GC_VM através 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.

A retenção de VM também é útil para aplicações que desejam manter os segmentos que já adquiriram, como algumas aplicações de servidor que são predominantes no sistema, para evitar excepçõ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.