Compartilhar via


Heap de objeto grande 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, sua compactação, ou seja, copiá-lo na memória em outro lugar do heap, pode ser cara. Por isso, o coletor de lixo do .NET coloca objetos grandes no LOH (heap de objeto grande). 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 aborda o heap de objeto grande no .NET Framework e no .NET Core em execução somente em sistemas Windows. Ele não abrange o LOH em execução em implementações do .NET em outras plataformas.

Como um objeto acaba no LOH

Se um objeto for maior ou igual a 85.000 bytes de tamanho, ele será considerado um objeto grande. Esse número foi determinado pelo ajuste de desempenho. Quando uma requisição de alocação de objeto for de 85.000 bytes ou mais, o runtime a aloca no heap de objetos grandes.

Para entender o que isso significa, é útil examinar alguns conceitos básicos sobre o coletor de lixo.

O coletor de lixo é um coletor geracional. Ele 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 na geração0. Por exemplo, em um aplicativo de servidor, as alocações associadas a cada solicitação devem morrer após a conclusão da solicitação. As solicitações de alocação em andamento chegarão à gen1 e morrerão lá. Essencialmente, gen1 atua como um buffer entre áreas de objetos jovens e áreas de objetos de longa vida.

Objetos recém-alocados formam uma nova geração de objetos e são coleções de geração 0 implicitamente. 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 é agrupada logicamente dentro da geração 2.

Objetos grandes pertencem à geração 2 porque são coletados somente durante uma coleção de geração 2. Quando uma geração é coletada, todas as suas gerações mais jovens também são coletadas. Por exemplo, quando um GC de geração 1 acontece, as duas gerações 1 e 0 são coletadas. E quando ocorre uma GC de geração 2, todo o heap é coletado. Por esse motivo, um GC de geração 2 também é chamado de GC completo. Este artigo refere-se ao GC de geração 2 em vez de GC completo, mas os termos são intercambiáveis.

Gerações fornecem uma exibição lógica do heap de GC. Fisicamente, os objetos residem em segmentos de heaps gerenciados. Um segmento de heap gerenciado é um bloco 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 os objetos gerenciados em um desses segmentos de heap gerenciado. Se o objeto for menor que 85.000 bytes, ele será colocado no segmento para o SOH; caso contrário, ele será colocado em um segmento LOH. Os segmentos são confirmados (em blocos menores) à medida que mais objetos são alocados a eles. Para o SOH, os objetos que sobrevivem a um GC são promovidos para a próxima geração. Objetos que sobrevivem a uma coleção de geração 0 agora são considerados objetos de geração 1 e assim por diante. No entanto, os objetos que sobrevivem à geração mais antiga ainda são considerados na geração mais antiga. Em outras palavras, os sobreviventes da geração 2 são objetos de geração 2; e os sobreviventes de LOH são objetos LOH (que são coletados com a gen2).

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

Quando uma coleta de lixo é disparada, o GC rastreia pelos objetos vivos e os compacta. Mas como a compactação é cara, o GC varre o LOH; ele cria uma lista livre com objetos mortos que poderão ser reutilizados posteriormente para atender a solicitações de alocação de objetos grandes. Objetos mortos adjacentes são transformados em um objeto livre.

O .NET Core e o .NET Framework (do .NET Framework 4.5.1 em diante) incluem a propriedade GCSettings.LargeObjectHeapCompactionMode, 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ê aloca objetos grandes e deseja ter certeza de que eles não se moverão, 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 coleta de geração 0, onde Obj1 e Obj3 estão mortos, e forma a geração 2 após a primeira coleta de geração 1, onde Obj2 e Obj5 estão mortos. Observe que isso e as figuras a seguir são apenas para fins de ilustração; elas contêm muito poucos objetos para mostrar melhor o que acontece no heap. Na realidade, muitos outros objetos normalmente estão envolvidos em um GC.

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

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

Figura 2: após uma GC de geração 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, ele disparará um GC de geração 2 na esperança de liberar algum espaço.

Durante um GC de geração 1 ou 2, o coletor de lixo libera segmentos que não contêm objetos vivos novamente para o sistema operacional chamando a função VirtualFree. O espaço após o último objeto vivo até o final do segmento tem a confirmação anulada (exceto no segmento efêmero em que gen0/gen1 vivem, em que o coletor de lixo mantém alguns confirmados, porque o aplicativo alocará nele imediatamente). E os espaços livres permanecem confirmados, embora sejam redefinidos, o que significa que o sistema operacional não precisa gravar dados neles novamente em disco.

Como o LOH só é coletado durante os GCs de geração 2, o segmento LOH só pode ser liberado durante esse GC. A Figura 3 ilustra um cenário em que o coletor de lixo libera um segmento (segmento 2) de volta ao sistema operacional e libera mais espaço nos segmentos restantes. Se ele precisar usar o espaço com anulação de confirmação no final do segmento para atender a solicitações de alocação de objeto grande, ele confirmará a memória novamente. (Para obter uma explicação do commit/decommit, confira a documentação para VirtualAlloc.)

Figura 3: LOH após uma GC de geração 2
Figura 3: O LOH após um GC de geração 2

Quando um objeto grande é coletado?

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

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

    O limite é 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 é disparado nessa geração. Quando você aloca objetos pequenos ou grandes, consome os limites da geração 0 e do LOH, respectivamente. Quando o coletor de lixo aloca na geração 1 e 2, ele consome seus limites. Esses limites são ajustados dinamicamente conforme o programa é executado.

    Esse é o caso típico; a maioria dos GCs ocorre por causa de alocações no heap gerenciado.

  • O método GC.Collect é chamado.

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

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

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

Implicações de desempenho de LOH

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

  • Custo de alocação.

    O CLR garante que a memória de cada novo objeto fornecido é apagada. Isso significa que o custo de alocação de um objeto grande é dominado pela limpeza da memória (a menos que ela dispare um GC). Se forem necessários dois ciclos para limpar um byte, são necessários 170.000 ciclos para limpar o menor objeto grande. Limpar a memória de um objeto de 16 MB em um computador de 2 GHz leva aproximadamente 16 ms. É um custo muito grande.

  • Custo de coleta.

    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 será iniciada. Se uma coleta de geração 2 for disparada devido ao LOH, a geração 2 não necessariamente será muito menor após o GC. Se não houver muitos dados sobre a geração 2, isso terá impacto mínimo. Mas se a geração 2 for grande, poderá causar problemas de desempenho se muitos GCs de geração 2 forem disparados. Se vários objetos grandes forem alocados de forma temporária e você tiver um SOH grande, você poderá gastar muito tempo realizando GCs. Além disso, o custo de alocação poderá realmente aumentar se você continuar alocando e liberando objetos muito grandes.

  • Elementos de matriz com tipos de referência.

    Objetos muito grandes no LOH geralmente são matrizes (é muito raro ter um objeto de instância realmente grande). Se os elementos de uma matriz forem ricos em referência, ela resultará em um custo que não está presente se os elementos não forem ricos em referência. Se o elemento não contiver nenhuma referência, o coletor de lixo não precisará percorrer a matriz. Por exemplo, se você usa uma matriz para armazenar nós em uma árvore binária, uma maneira de implementar é se referir ao nó 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 precisará passar por pelo menos duas referências por elemento. Uma abordagem alternativa é armazenar o índice dos nós à direita e à esquerda:

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

    Em vez de referenciar os dados do nó esquerdo como left.d, você se refere a ele como binary_tr[left_index].d. Além disso, o coletor de lixo não precisa examinar nenhuma referência dos nós esquerdo e direito.

Dos três fatores, os dois primeiros geralmente são mais significativos do que o terceiro. Por isso, recomendamos que você aloque um pool de objetos grandes que reutilize 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. Encontrar evidência de que você deve observar essa área.
  2. Ter esgotado outras áreas conhecidas sem encontrar algo que poderia explicar o problema de desempenho observado.

Para obter mais informações sobre os conceitos básicos 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 da 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 seja recomendável usar eventos ETW). Uma maneira comum de examinar contadores de desempenho é com o Monitor de Desempenho (perfmon.exe). Selecione Adicionar (Ctrl + A) para adicionar os contadores interessantes para processos com os quais você se importa. Você pode salvar os dados do contador de desempenho em um arquivo de log.

Os seguintes dois contadores na categoria .NET CLR Memory são relevantes para o LOH:

  • Nº de Coleções Geração 2.

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

  • Tamanho de Heap 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.

Captura de tela 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 "Memória do CLR" do .NET como o CategoryName e "Tamanho do heap de objeto grande" 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ê detectar 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, pois o ETW fornece informações muito mais avançadas.

Eventos ETW

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

Para identificar GCs excessivos de geração 2 causados por alocações de LOH temporárias, veja a coluna Motivo de Gatilho dos 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.

Como você pode ver, todos os GCs são GCs de geração 2 e são disparados por AllocLarge, o que significa que a alocação de um objeto grande disparou esse GC. Sabemos que essas alocações são temporárias porque a coluna LOH Survival Rate % indica 1%.

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

perfview /GCOnly /AcceptEULA /nogui collect

coleta um evento AllocationTick disparado aproximadamente a cada 100 mil alocações. Em outras palavras, um evento é disparado sempre que um objeto grande é alocado. Em seguida, você pode observar uma das exibições de Alocação de Heap de GC, que mostra as pilhas de chamadas que alocaram objetos grandes:

Captura de tela que mostra a exibição de heap de um coletor de lixo.

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

Um depurador

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

Observação

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

O seguinte exemplo mostra uma saída de exemplo da análise de 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 do heap 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 runtime em si usa o LOH para alocar alguns objetos menores que um objeto grande.

Como o LOH não é compactado, às vezes o LOH é considerado a fonte da fragmentação. Fragmentação significa:

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

  • Fragmentação do espaço de endereço da VM (memória virtual), que é a memória marcada como MEM_FREE. Você pode obtê-lo usando vários comandos de 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 gerenciado do sistema operacional e libere os segmentos vazios novamente para o sistema operacional.

Para verificar se o LOH está causando a fragmentação da 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 partes 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'"

Esse comando invade o depurador e mostra a pilha de chamadas somente se o 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 os segmentos (incluindo heaps de objeto grande e pequeno) são frequentemente adquiridos e liberados. Para especificar o VM Hoarding, você especifica um sinalizador de inicialização chamado STARTUP_HOARD_GC_VM pela API de hospedagem. Em vez de liberar segmentos vazios de volta para o sistema operacional, o CLR descompromissa a memória nesses segmentos e os coloca em uma lista em espera. (Observe que o CLR não faz isso para segmentos muito grandes.) O CLR posteriormente usa esses segmentos para atender a novas solicitações de segmento. Na próxima vez que seu aplicativo precisar de um novo segmento, o CLR usará um desta lista em espera se encontrar um que seja grande o suficiente.

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

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