Rastreamento de memória nativa na JVM

Rastreamento de memória nativa na JVM

1. Visão geral

Você já se perguntou por que os aplicativos Java consomem muito mais memória do que a quantidade especificada por meio dos conhecidos sinalizadores de ajuste-Xmse-Xmx? Por vários motivos e possíveis otimizações, a JVM pode alocar memória nativa extra. Essas alocações extras podem, eventualmente, aumentar a memória consumida além da limitação de-Xmx.

Neste tutorial, vamos enumerar algumas fontes comuns de alocações de memória nativa na JVM, junto com seus sinalizadores de ajuste de dimensionamento e, em seguida, aprender como usarNative Memory Tracking para monitorá-los.

2. Alocações nativas

O heap geralmente é o maior consumidor de memória em aplicativos Java, mas existem outros. Besides the heap, the JVM allocates a fairly large chunk from the native memory to maintain its class metadata, application code, the code generated by JIT, internal data structures, etc. Nas seções a seguir, exploraremos algumas dessas alocações.

2.1. Metaspace

In order to maintain some metadata about the loaded classes, The JVM uses a dedicated non-heap area called Metaspace. Antes do Java 8, o equivalente era chamado dePermGen ouPermanent Generation. Metaspace ou PermGen contém os metadados sobre as classes carregadas, e não as instâncias delas, que são mantidas dentro da pilha.

O importante aqui é quethe heap sizing configurations won’t affect the Metaspace size, uma vez que o Metaspace é uma área de dados fora do heap. Para limitar o tamanho do Metaspace, usamos outros sinalizadores de ajuste:

  •  -XX:MetaspaceSize e-XX:MaxMetaspaceSize para definir o tamanho mínimo e máximo do Metaspace

  • Antes do Java 8,-XX:PermSize e-XX:MaxPermSize para definir o tamanho mínimo e máximo do PermGen

2.2. Tópicos

Uma das áreas de dados que mais consomem memória na JVM é a pilha, criada ao mesmo tempo que cada encadeamento. A pilha armazena variáveis ​​locais e resultados parciais, desempenhando um papel importante nas invocações de métodos.

O tamanho da pilha de threads padrão depende da plataforma, mas na maioria dos sistemas operacionais modernos de 64 bits, é de cerca de 1 MB. Este tamanho é configurável por meio do sinalizador de atordoamento-Xss .

Em contraste com outras áreas de dados,the total memory allocated to stacks is practically unbounded when there is no limitation on the number of threads. Também vale a pena mencionar que a própria JVM precisa de alguns threads para executar suas operações internas como GC ou compilações just-in-time.

2.3. Cache de código

Para executar o bytecode da JVM em plataformas diferentes, ele precisa ser convertido em instruções da máquina. O compilador JIT é responsável por essa compilação à medida que o programa é executado.

When the JVM compiles bytecode to assembly instructions, it stores those instructions in a special non-heap data area called Code Cache. O cache de código pode ser gerenciado como outras áreas de dados na JVM. Os sinalizadores de atordoamento-XX:InitialCodeCacheSize and-XX:ReservedCodeCacheSize determinam o tamanho inicial e máximo possível para o cache de código.

2.4. Coleta de lixo

A JVM é enviada com um punhado de algoritmos de GC, cada um adequado para diferentes casos de uso. Todos esses algoritmos de GC compartilham uma característica comum: eles precisam usar algumas estruturas de dados fora da pilha para executar suas tarefas. Essas estruturas de dados internas consomem mais memória nativa.

2.5. Símbolos

Vamos começar comStrings,  um dos tipos de dados mais comumente usados ​​em aplicativos e código de biblioteca. Por causa de sua onipresença, eles geralmente ocupam uma grande parte do monte. Se um grande número dessas cadeias contiver o mesmo conteúdo, uma parte significativa do heap será desperdiçada.

Para economizar espaço na pilha, podemos armazenar uma versão de cadaString e fazer com que os outros se refiram à versão armazenada. This process is called String Interning. Como a JVM só pode internarCompile Time String Constants, we pode chamar manualmente o métodointern() nas strings que pretendemos internar.

JVM stores interned strings in a special native fixed-sized hashtable called theString Table,also known as the String Pool. Podemos configurar o tamanho da tabela (ou seja, o número de baldes) por meio do sinalizador de atordoamento-XX:StringTableSize .

Além da tabela de string, há outra área de dados nativos chamadaRuntime Constant Pool. JVM que usa esse pool para armazenar constantes como literais numéricos em tempo de compilação ou referências de método e campo que devem ser resolvidas em tempo de execução.

2.6. Buffers de bytes nativos

A JVM é o suspeito comum de um número significativo de alocações nativas, mas às vezes os desenvolvedores também podem alocar diretamente a memória nativa. As abordagens mais comuns sãomalloc call por JNI e NIO'sByteBuffers. direto

2.7. Sinalizadores de ajuste adicionais

Nesta seção, usamos um punhado de sinalizadores de ajuste da JVM para diferentes cenários de otimização. Usando a dica a seguir, podemos encontrar quase todos os sinalizadores de ajuste relacionados a um conceito específico:

$ java -XX:+PrintFlagsFinal -version | grep 

OPrintFlagsFinal imprime todas as funções -XX na JVM. Por exemplo, para encontrar todos os sinalizadores relacionados ao Metaspace:

$ java -XX:+PrintFlagsFinal -version | grep Metaspace
      // truncated
      uintx MaxMetaspaceSize                          = 18446744073709547520                    {product}
      uintx MetaspaceSize                             = 21807104                                {pd product}
      // truncated

3. Rastreamento de memória nativa (NMT)

Agora que conhecemos as fontes comuns de alocações de memória nativa na JVM, é hora de descobrir como monitorá-las. First, we should enable the native memory tracking using yet another JVM tuning flag: -XX:NativeMemoryTracking=off|sumary|detail. Por padrão, o NMT está desligado, mas podemos habilitá-lo para ver um resumo ou uma visão detalhada de suas observações.

Suponhamos que queremos rastrear as alocações nativas para um aplicativo Spring Boot típico:

$ java -XX:NativeMemoryTracking=summary -Xms300m -Xmx300m -XX:+UseG1GC -jar app.jar

Aqui, estamos habilitando o NMT enquanto alocamos 300 MB de espaço de heap, com G1 como nosso algoritmo de GC.

3.1. Instantâneos instantâneos

Quando o NMT está ativado, podemos obter as informações da memória nativa a qualquer momento usando o scommandjcmd :

$ jcmd  VM.native_memory

Para encontrar o PID para um aplicativo JVM, podemos usar o comandojps :

$ jps -l
7858 app.jar // This is our app
7899 sun.tools.jps.Jps

Agora, se usarmosjcmd com opid apropriado, oVM.native_memory  faz com que a JVM imprima as informações sobre as alocações nativas:

$ jcmd 7858 VM.native_memory

Vamos analisar a saída NMT seção por seção.

3.2. Alocações totais

O NMT reporta o total de memória reservada e confirmada da seguinte maneira:

Native Memory Tracking:
Total: reserved=1731124KB, committed=448152KB

A memória reservada representa a quantidade total de memória que nosso aplicativo pode potencialmente usar. Por outro lado, a memória comprometida é igual à quantidade de memória que nosso aplicativo está usando agora.

Apesar de alocar 300 MB de heap, a memória total reservada para nosso aplicativo é de quase 1,7 GB, muito mais que isso. Da mesma forma, a memória confirmada tem cerca de 440 MB, o que é, novamente, muito mais do que 300 MB.

Após a seção total, o NMT reporta alocações de memória por fonte de alocação. Então, vamos explorar cada fonte em profundidade.

3.3. Heap

O NMT relata nossas alocações de heap conforme o esperado:

Java Heap (reserved=307200KB, committed=307200KB)
          (mmap: reserved=307200KB, committed=307200KB)

300 MB de memória reservada e confirmada, que correspondem às nossas configurações de tamanho de heap.

3.4. Metaspace

Aqui está o que o NMT diz sobre os metadados da classe para as classes carregadas:

Class (reserved=1091407KB, committed=45815KB)
      (classes #6566)
      (malloc=10063KB #8519)
      (mmap: reserved=1081344KB, committed=35752KB)

Quase 1 GB reservado e 45 MB comprometidos com o carregamento de 6566 classes.

3.5. Fio

E aqui está o relatório NMT sobre alocações de tópicos:

Thread (reserved=37018KB, committed=37018KB)
       (thread #37)
       (stack: reserved=36864KB, committed=36864KB)
       (malloc=112KB #190)
       (arena=42KB #72)

No total, 36 MB de memória são alocados para pilhas para 37 threads - quase 1 MB por pilha. A JVM aloca a memória para threads no momento da criação, portanto, as alocações reservadas e confirmadas são iguais.

3.6. Cache de código

Vamos ver o que o NMT diz sobre as instruções de montagem geradas e armazenadas em cache pelo JIT:

Code (reserved=251549KB, committed=14169KB)
     (malloc=1949KB #3424)
     (mmap: reserved=249600KB, committed=12220KB)

Atualmente, quase 13 MB de código estão sendo armazenados em cache e esse valor pode potencialmente subir até aproximadamente 245 MB.

3.7. GC

Aqui está o relatório NMT sobre o uso de memória do G1 GC:

GC (reserved=61771KB, committed=61771KB)
   (malloc=17603KB #4501)
   (mmap: reserved=44168KB, committed=44168KB)

Como podemos ver, quase 60 MB estão reservados e comprometidos em ajudar o G1.

Vamos ver como é o uso de memória para um GC muito mais simples, digamos Serial GC:

$ java -XX:NativeMemoryTracking=summary -Xms300m -Xmx300m -XX:+UseSerialGC -jar app.jar

O GC serial mal usa 1 MB:

GC (reserved=1034KB, committed=1034KB)
   (malloc=26KB #158)
   (mmap: reserved=1008KB, committed=1008KB)

Obviamente, não devemos escolher um algoritmo de GC apenas por causa de seu uso de memória, já que a natureza de parar o mundo do Serial GC pode causar degradação de desempenho. Existem, no entanto,several GCs to choose from, e cada um deles equilibra memória e desempenho de forma diferente.

3.8. Símbolo

Aqui está o relatório NMT sobre as alocações de símbolos, como a tabela de strings e o pool constante:

Symbol (reserved=10148KB, committed=10148KB)
       (malloc=7295KB #66194)
       (arena=2853KB #1)

Quase 10 MB são alocados aos símbolos.

3.9. NMT ao longo do tempo

The NMT allows us to track how memory allocations change over time. Primeiro, devemos marcar o estado atual de nosso aplicativo como uma linha de base:

$ jcmd  VM.native_memory baseline
Baseline succeeded

Depois de um tempo, podemos comparar o uso atual da memória com essa linha de base:

$ jcmd  VM.native_memory summary.diff

O NMT, usando os sinais + e -, nos diria como o uso da memória mudou nesse período:

Total: reserved=1771487KB +3373KB, committed=491491KB +6873KB
-             Java Heap (reserved=307200KB, committed=307200KB)
                        (mmap: reserved=307200KB, committed=307200KB)

-             Class (reserved=1084300KB +2103KB, committed=39356KB +2871KB)
// Truncated

O total de memória reservada e confirmada aumentou em 3 MB e 6 MB, respectivamente. Outras flutuações nas alocações de memória podem ser identificadas com a mesma facilidade.

3.10. NMT detalhado

O NMT pode fornecer informações muito detalhadas sobre um mapa de todo o espaço da memória. Para habilitar este relatório detalhado, devemos usar o sinalizador de atordoamento-XX:NativeMemoryTracking=detail .

4. Conclusão

Neste artigo, enumeramos diferentes contribuidores para alocações de memória nativa na JVM. Em seguida, aprendemos como inspecionar um aplicativo em execução para monitorar suas alocações nativas. Com essas informações, podemos ajustar com mais eficiência nossos aplicativos e dimensionar nossos ambientes de tempo de execução.