Graças à Memória

Entendendo como a JVM usa a memória nativa no Windows e Linux

Executar sem o heap Java™ não é a única causa de java.lang.OutOfMemoryError. Se a memória nativa se esgotar, OutOfMemoryErrors poderá ocorrer e as técnicas de depuração normais não conseguirão resolver o problema. Este artigo explica o que é a memória nativa, como é usada pelo Java runtime, como seria uma execução sem memória e como depurar um OutOfMemoryError nativo, no Windows® e no Linux®. Acompanha um arquivo que aborda os mesmos tópicos para sistemas AIX®.

Andrew Hall, Software Engineer, IBM

Andrew HallAndrew Hall entrou para o Centro de Tecnologia Java da IBM em 2004, iniciando na equipe de teste do sistema onde trabalhou por dois anos. Ele ficou 18 meses na equipe de serviço Java, onde depurou dezenas de problemas de memória nativa em várias plataformas. Atualmente ele é o desenvolvedor da equipe de Confiabilidade, Disponibilidade e Capacidade de Manutenção Java. Nas horas vagas ele se dedica à leitura, fotografia e diversões.



21/Abr/2009

O heap Java, onde cada objeto Java é alocado, é a área da memória com a qual você está mais diretamente conectado ao gravar aplicativos Java. A JVM foi designada para nos livrar das peculiaridades da máquina host, assim, é normal lembrar no heap quando pensamos em memória. Você tem a certeza de que o erro OutOfMemoryError — encontrado no heap Java foi causado por um vazamento de objeto ou pelo heap não ser grande o suficiente para armazenar os dados — e provavelmente aprendeu alguns macetes para depurar esses cenários. Mas conforme seus aplicativos Java manipulam mais dados e mais carregamentos simultâneos, pode ocorrer um OutOfMemoryError que você não conseguirá corrigi-lo usando seu conhecimento — de cenários nos quais os erros são emitidos mesmo se o heap Java não estiver cheio. Quando isso acontece, é necessário entender o que está havendo com o Java Runtime Environment (JRE).

Os aplicativos Java são executados no ambiente virtualizado do Java runtime, mas o próprio runtime é um programa nativo gravado em uma linguagem (como a linguagem C) que consome recursos nativos, incluindo memória nativa. A memória nativa é a memória disponível para o processo de tempo de execução, conforme diferenciado da memória heap Java que o aplicativo Java utiliza. Cada recurso virtualizado — incluindo o heap e os encadeamentos Java — deve ser armazenado na memória nativa, junto com os dados usados pela máquina virtual conforme ela é executada. Isso significa que as limitações na memória nativa impostas pelo hardware e pelo sistema operacional (S.O.) da máquina host afetam o que você pode fazer com o aplicativo Java.

Este artigo é um dos dois que abordam o mesmo tópico em plataformas diferentes. Neles, você aprenderá o que é a memória nativa, como é usada pelo Java runtime, como seria uma execução sem memória e como depurar um OutOfMemoryError nativo. Este artigo aborda o Windows e Linux e não enfoca nenhuma implementação particular de runtime. O artigo que acompanha aborda o AIX e enfoca o IBM® Developer Kit para Java. (As informações neste artigo sobre a implementação IBM também se aplicam para plataformas diferentes do AIX, assim, se você usar o IBM Developer Kit para Java no Linux ou o IBM Runtime Environment de 32 bits para Windows, este artigo também poderá ser útil para você).

Recapitulando a Memória Nativa

Eu começo explicando as limitações na memória nativa impostas pelo S.O. e pelos hardwares subjacentes. Se você estiver familiarizado com o gerenciamento de memória dinâmica em uma linguagem como C, poderá pular para a próxima seção.

Limitações de Hardware

Muitas das restrições que uma processo nativo possui são impostas pelo hardware, e não pelo S.O. Cada computador possui um processador e memória de acesso aleatório (RAM), também conhecida como memória física. O processador interpreta um fluxo de dados como instruções a serem executadas; ele possui uma ou mais unidades de processamento que executam uma aritmética inteira e de ponto flutuante, além de funções computacionais mais avançadas. O processador tem um número de registros — elementos de memória muito rápidos que são usados como armazenamento de trabalho para os cálculos que são executados; o tamanho do registro determina o número maior que um único cálculo pode usar.

O processador é conectado à memória física pelo barramento de memória. O tamanho do endereço físico (o endereço usado pelo processador para indexar a RAM física) limita a quantidade de memória que pode ser endereçada. Por exemplo, um endereço físico de 16 bits pode endereçar de 0x0000 a 0xFFFF, resultando em 2^16 = 65536 locações de memória exclusivas. Se cada endereço referenciar um byte de armazenamento, um endereço físico de 16 bits permitiria que um processador endereçasse 64 KB de memória.

Os processadores são descritos como tendo um determinado número de bits. Isso normalmente se refere ao tamanho dos registros, embora haja exceções — como 390 de 31 bits — fazendo referência ao tamanho do endereço físico. Para as plataformas de desktop e de servidor, este número é 31, 32 ou 64; já para dispositivos e microprocessadores integrados, ele pode cair para 4. O tamanho do endereço físico pode ser o mesmo que a largura do registro, podendo ser maior ou menor. A maioria dos processadores de 64 bits pode executar programas de 32 bits ao executar um S.O. adequado.

A Tabela 1 lista algumas arquiteturas populares Linux e Windows com os tamanhos do endereço físico e do registro:

Tabela 1. Tamanho do registro e do endereço físico de algumas arquiteturas populares de processador
ArquiteturaLargura do registro (bits)Tamanho do endereço físico (bits)
(Moderno) Intel® x863232
36 com o Physical Address Extension (Pentium Pro e superior)
x86 6464Atualmente 48 bits (podendo ser aumentado posteriormente)
PPC646450 bits no POWER 5
390 de 31 bits3231
390 de 64 bits6464

Sistemas Operacionais e Memória Virtual

Se você estivesse gravando aplicativos para serem executados diretamente no processador sem um S.O., seria possível usar toda a memória que o processador pode endereçar (supondo que a RAM física suficiente esteja conectada). Mas para aproveitar os recursos como multitarefas e abstração de hardware, quase todo mundo usa algum tipo de S.O. para executar seus programas.

Nos S.O. multitarefas como Windows e Linux, mais de um programa usa os recursos do sistema, incluindo a memória. Cada programa precisa que sejam alocadas regiões de memória física para poder funcionar. É possível designar um S.O. para que cada programa funcione diretamente com a memória física e que use apenas a memória que lhe foi fornecida. Alguns S.O. integrados funcionam dessa forma, mas não é viável em um ambiente que possui vários programas que não são testados, pois algum programa pode corromper a memória de outros programas ou o próprio S.O.

Memória virtual permite que vários processos compartilhem a memória física sem corromper os dados um do outro. Em um S.O. com memória virtual (como o Windows, Linux e vários outros), cada programa possui seu próprio espaço de endereço virtual — uma região lógica de endereços cujo tamanho é determinado pelo tamanho do endereço nesse sistema (assim, 31, 32 ou 64 bits para plataformas de desktop e de servidor). As regiões em um espaço de endereço virtual do processo podem ser mapeadas para a memória física, para um arquivo ou para qualquer outro armazenamento endereçável. O S.O. pode mover os dados mantidos na memória física para e de uma área de troca (o arquivo de página no Windows ou uma partição de troca no Linux) quando não estiver sendo usado, para fazer melhor uso da memória física. Quando um programa tenta acessar a memória usando um endereço virtual, o S.O. junto com um hardware on-chip mapeia esse endereço virtual para o local físico. Esse local pode ser uma RAM, um arquivo ou um arquivo de página/partição de troca. Se uma região de memória foi movida para um espaço de troca, ela será carregada de volta para a memória física antes de ser usada. A Figura 1 mostra como a memória virtual trabalha no mapeamento de regiões de espaço de endereço de processo para recursos compartilhados:

Figura 1. Espaços de endereço de processo de mapeamento de memória virtual para recursos físicos
Mapeamento de Memória Virtual

Cada instância de um programa executa um processo. Um processo no Linux e Windows é uma coleta de informações sobre os recursos controlados pelo S.O. (como informações de arquivo e de soquete), normalmente um espaço de endereço virtual (mais de um em algumas arquiteturas) e pelo menos um encadeamento de execução.

O tamanho do espaço de endereço virtual pode ser menor que o tamanho do endereço físico do processador. O Intel x86 de 32 bits originalmente tinha um endereço físico de 32 bits que permitia que o processador endereçasse 4 GB de armazenamento. Posteriormente, um recurso chamado Physical Address Extension (PAE) foi incluído para expandir o tamanho do endereço físico para 36 bits — permitindo chegar a 64 GB de RAM a ser instalado e endereçado. O PAE permite que os sistemas operacionais mapeiem espaços de endereço virtuais de 4 GB de 32 bits para um intervalo de endereço físico maior, mas não permite que cada processo possua um espaço de endereço virtual de 64 GB. Isso significa que se você colocar mais do que 4 GB de memória em um servidor Intel de 32 bits, não será possível mapear tudo diretamente para um único processo.

O recurso Address Windowing Extensions permite que um processo do Windows mapeie uma parte do espaço de endereço de 32 bits como uma "janela que se abre" para uma área de memória maior. O Linux usa tecnologias semelhantes baseadas nas regiões de mapeamento no espaço de endereço virtual. Isso significa que embora você não possa referenciar diretamente mais de 4 GB de memória, você pode trabalhar com regiões maiores de memória.

O Espaço de Kernel e o Espaço do Usuário

Embora cada processo possua seu próprio espaço de endereço, um programa normalmente não consegue usar tudo. O espaço de endereço é dividido em espaço do usuário e espaço de kernel. O kernel é o programa do sistema operacional principal e contém a lógica para estabelecer interface com o hardware do computador, com programas de planejamento e fornecer serviços como rede e memória virtual.

Como parte da sequência de boot do computador, o kernel do sistema operacional executa e inicia o hardware. Quando o kernel configurar o hardware e possuir seu próprio estado interno, o primeiro processo de espaço do usuário é iniciado. Se um programa do usuário precisar de um serviço a partir de um sistema operacional, ele poderá executar uma operação — denominada chamada do sistema — que chama o programa de kernel e executa o pedido. As chamadas do sistema geralmente são necessárias para operações como leitura e gravação de arquivos, rede e início de novos processos.

O kernel requer acesso a sua própria memória e para a memória do processo de chamada ao executar uma chamada do sistema. Como o processador que está executando o encadeamento atual está configurado para mapear endereços virtuais usando o mapeamento de espaço de endereço para o processo atual, a maioria dos sistemas operacionais mapeia uma parte de cada espaço de endereço de processo para uma região de memória de kernel comum. A parte do espaço de endereço mapeado para uso pelo kernel é chamado espaço de kernel; o restante, que pode ser usado pelo aplicativo de usuário, é chamado de espaço do usuário.

O equilíbrio entre o kernel e o espaço do usuário varia por sistema operacional e até mesmo entre as instâncias do mesmo sistema operacional em execução em arquiteturas de hardware diferentes. O equilíbrio é frequentemente configurável e pode ser ajustado para dar mais espaço para aplicativos do usuário ou para o kernel. Compactar a área do kernel pode causar problemas, como restringir o número de usuários que podem se conectar simultaneamente ou o número de processos que podem ser executados. Um espaço do usuário menor significa que o programador do aplicativo tem menos espaço para trabalhar.

Por padrão, um Windows de 32 bits tem 2 GB de espaço do usuário e 2 GB de espaço de kernel. O saldo pode ser mudado para 3 GB de espaço do usuário e 1 GB de espaço de kernel em algumas versões do Windows ao incluir a chave /3GB na configuração do boot e vincular novamente os aplicativos com o parâmetro /LARGEADDRESSAWARE . No Linux de 32 bits, o padrão é 3 GB de espaço do usuário e 1 GB de espaço do kernel. Algumas distribuições Linux fornecem um kernelhugemem que suporta um espaço de usuário de 4 GB. Para isso, o kernel recebe um espaço de endereço próprio que é usado quando uma chamada de sistema é feita. Os ganhos no espaço do usuário são compensados pelas chamadas do sistema mais lentas porque o sistema operacional deve copiar os dados entre os espaços de endereço e reconfigurar os mapeamentos do espaço de endereço do processo sempre que uma chamada do sistema for feita. A Figura 2 mostra o layout do espaço de endereço para Windows de 32 bits:

Figura 2. Layout de espaço de endereço para Windows de 32 bits
Espaço de endereço para Windows de 32 bits

A Figura 3 mostra a organização do espaço de endereço para Linux de 32 bits:

Figura 3. Layout de espaço de endereço para Linux de 32 bits
Espaço de endereço para Linux de 32 bits

Um espaço de endereço de kernel separado também é usado no Linux 390 de 31 bits, onde um espaço de endereço de 2 GB menor torna a divisão de um único espaço de endereço desnecessário. Porém, a arquitetura 390 pode trabalhar com vários espaços de endereço simultaneamente sem comprometer o desempenho.

O espaço de endereço de processo deve conter tudo o que um programa precisa— incluindo o programa em si e as bibliotecas compartilhadas (DLLs no Windows, arquivos .so no Linux) que ele utiliza. As bibliotecas compartilhadas podem não apenas ocupar um espaço que um programa não pode usar para armazenar os dados, como também podem fragmentar o espaço de endereço e reduzir a quantidade de memória que pode ser alocada como partes contínuas. Isso é observado em programas que são executados no Windows x86 com 3 GB de espaço do usuário. As DLLs são criadas com um endereço de carregamento preferido: quando uma DLL é carregada, ela é mapeada para um espaço de endereço em um local particular, a menos que esse local já esteja ocupado e, nesse caso, será restabelecido e carregado em outro lugar. Com o espaço do usuário de 2 GB disponível quando o Windows NT foi originalmente designado, é natural as bibliotecas do sistema serem criadas para carregar quase no limite de 2 GB — deixando, assim, a maior parte da região do usuário livre para uso do aplicativo. Quando a região do usuário for estendida para 3 GB, as bibliotecas compartilhadas do sistema continuam carregando próximo a 2 GB — agora no meio do espaço do usuário. Embora haja um espaço de usuário total de 3 GB, é impossível alocar um bloco de memória de 3 GB porque as bibliotecas compartilhadas estão no caminho.

Usar a chave /3GB no Windows reduz o espaço de kernel pela metade do que foi originalmente projetado. Em alguns casos é possível descarregar o espaço de kernel de 1 GB e ter uma E/S baixa ou problemas ao criar novas sessões do usuário. Embora a chave /3GB seja extremamente importante para alguns aplicativos, o carregamento de qualquer ambiente que o utilizar deve ser completamente testado antes de sua implementação. Consulte Recursos e acesse os links para mais informações sobre a chave /3GB e suas vantagens e desvantagens.

Um vazamento de memória nativa ou uma memória nativa excessiva causará problemas diferentes dependendo se você descarregar o espaço de endereço ou executar sem memória física. A descarga do espaço de endereço normalmente ocorre apenas com processos de 32 bits— porque o máximo de 4 GB é fácil de alocar. Um processo de 64 bits possui um espaço do usuário de centenas ou milhares de gigabytes, que é difícil de preencher mesmo se você tentar. Se você descarregar o espaço de endereço de um processo Java, o Java runtime poderá começar a exibir comportamentos estranhos que eu descreverei posteriormente neste artigo. Ao executar em um sistema com mais espaço de endereço de processo do que memória física, um vazamentode memória ou um uso excessivo de memória nativa força o sistema operacional a descarregar o armazenamento de suporte para a área de troca de algum espaço de endereço virtual do processo nativo. Acessar um endereço de memória que foi descarregado para a área de troca é bem mais lento do que ler um endereço residente (na memória física) porque o sistema operacional precisa extrair os dados da unidade de disco rígido. É possível alocar memória o suficiente para esgotar toda a memória física e toda a memória de troca (espaço de paginação); no Linux, isso ativa o inibidor out-of-memory (OOM) do kernel, que forçadamente interrompe o processo que mais precisa de memória. No Windows, as alocações começam a falhar da mesma forma que falhariam se o espaço de endereço estivesse cheio.

Se você ao mesmo tempo tentar usar mais memória virtual do que houver na memória física, logicamente ocorrerá um problema antes que o processo seja interrompido já que isso consome muita memória. O sistema vai entrar em thrash — ou seja, gastará mais tempo copiando a memória pra lá e pra cá a partir do espaço de memória swap. Quando isso acontecer, o desempenho do computador e dos aplicativos individuais ficará tão ruim que o usuário não deixará de notar que ocorreu um problema. Quando um heap Java da JVM for descarregado para a área de troca, o desempenho do garbage collector ficará tão ruim que parecerá que o aplicativo travou. Se vários tempos de execução Java estiverem em uso em uma única máquina ao mesmo tempo, a memória física deverá ser suficiente para ajustar todos os heaps Java.


Como o Java runtime usa a memória nativa

O Java runtime é um processo do sistema operacional sujeito às restrições de hardware e do sistema operacional que descrevi na seção anterior. Os ambientes do tempo de execução fornecem recursos que são conduzidos por algum código do usuário desconhecido, o que impossibilita prever quais recursos o ambiente de tempo de execução irá precisar em cada situação. Cada ação que um aplicativo Java executa dento do ambiente Java gerenciado pode potencialmente afetar os requisitos de recursos do runtime que fornece esse ambiente. Esta seção descreve como e porque os aplicativos Java consomem memória nativa.

O Heap Java e a Garbage Collection

O heap Java é a área da memória onde os objetos são alocados. A maioria das implementações Java SE possui um heap lógico, embora alguns tempos de execução Java especiais, como aqueles que implementam o Real Time Specification for Java (RTSJ), possuam vários heaps. Um heap físico único pode ser dividido em seções lógicas dependendo do algoritmo da garbage collection (GC) usada para gerenciar a memória de heap. Essas seções são normalmente implementadas como blocos contínuos de memória nativa controlados pelo gerenciador de memória Java (que inclui o garbage collector).

O tamanho do heap é controlado na linha de comandos Java usando as opções -Xmx e -Xms (mx é o tamanho máximo do heap, ms é o tamanho inicial). Embora o heap lógico (a área da memória que é atualmente usada) possa aumentar e diminuir de acordo com o número de objetos no heap e com a quantia de tempo gasto no GC, a quantia de memória nativa usada permanece constante e é dedicada pelo valor -Xmx: o tamanho máximo de heap. A maioria dos algoritmos do GC dependem do heap que está sendo alocado como um bloco contínuo de memória, portanto, é impossível alocar mais memória nativa do que o heap precisa para expandir. Toda a memória do heap deve ser reservada de antemão.

Reservar a memória nativa não é o mesmo que alocá-la. Quando a memória nativa é reservada, ela não é recuperada com a memória física ou outro armazenamento. Embora a reserva de partes do espaço de endereço não esgote os recursos físicos, ela não impede que a memória seja usada para outros fins. Um vazamento causado pela reserva de memória que nunca é usada é tão grave quanto o vazamento de memória alocada.

Alguns garbage collectors minimizam o uso de memória física realizando decommitting (liberando a área de armazenamento para) partes do heap à medida que a área usada pelo heap diminui.

Mais memória nativa é necessária para manter o estado do sistema de gerenciamento de memória que mantém o heap Java. As estruturas de dados devem ser alocadas para controlar o armazenamento livre e o progresso de registro na coleta de lixo. O tamanho e a natureza exatos dessas estruturas de dados variam com a implementação, mas muitas são proporcionais ao tamanho do heap.

O Compilador Just-in-time (JIT)

O compilador JIT compila o bytecode Java para o código nativo executável otimizado no runtime. Isso melhora enormemente a velocidade dos tempos de execução Java e permite que os aplicativos Java sejam executados em velocidades comparadas ao código nativo.

A compilação do bytecode usa a memória nativa (da mesma forma que um compilador estático, como gcc requer memória para executar), mas tanto a entrada (o bytecode) quanto a saída (o código executável) a partir do JIT também devem ser armazenados na memória nativa. Os aplicativos Java que contêm muitos métodos compilados pelo JIT usam mais memória nativa do que aplicativos menores.

Classes e Carregadores de Classe

Os aplicativos Java são compostos de classes que definem a estrutura do objeto e a lógica de método. Eles também usam classes de bibliotecas de classe de Java runtime (como java.lang.String) e podem usar bibliotecas de terceiro. Essas classes precisam ser armazenadas na memória enquanto elas estiverem sendo usadas.

O modo com que as classes são armazenadas varia de acordo com a implementação. O Sun JDK usa a área de heap de geração permanente (PermGen). A implementação da IBM a partir do Java 5 em diante aloca blocos de memória nativa para cada carregador de classe e armazena os dados de classe nele. Os tempos de execução modernos Java possuem tecnologias, como compartilhamento de classe, que podem requerer áreas de mapeamento de memória compartilhada no espaço de endereço. Para entender como esses mecanismos de alocação afetam a área de cobertura nativa do Java runtime, consulte a documentação técnica para essa implementação. Entretanto, algumas certezas universais afetam todas as implementações.

No nível mais básico, usar mais classes usa mais memória. (Isso pode significar que o uso da memória nativa só aumenta ou que é necessário redimensionar explicitamente uma área — como o PermGen ou o cache de classe compartilhada— para permitir que todas as classes se ajustem nela). Lembre-se de que não é só o seu aplicativo que precisa ser ajustado, as estruturas, os servidores de aplicativos, as bibliotecas de terceiro e os tempos de execução Java contêm classes que são carregadas on demand e ocupam espaço.

Os tempos de execução Java podem descarregar as classes para recuperar espaço, mas apenas sob condições estritas. É impossível descarregar uma única classe; os carregadores de classe são descarregados, englobando todas as classes que são carregadas com eles. Um carregador de classe pode ser descarregado apenas se:

  • O heap Java não contiver referências ao objeto java.lang.ClassLoader que representa esse carregador de classe.
  • O heap Java não contiver referências a nenhum objeto java.lang.Class que representa as classes carregadas por esse carregador de classe.
  • Nenhum objeto de nenhuma classe carregada por esse carregador de classe estiver ativo (referenciado) no heap Java.

Vale notar que os três carregadores de classe padrão que o Java runtime cria para todos os aplicativos Java — autoinicialização, extensão e aplicativo — nunca atendem a esses critérios, portanto, quaisquer classes de sistema (como java.lang.String) ou quaisquer classes de aplicativos carregadas através do carregador de classe do aplicativo não podem ser liberadas no tempo de execução.

Mesmo quando um carregador de classe for elegível para a coleta, o tempo de execução coleta os carregadores de classe apenas como parte de um ciclo do GC. Algumas implementações descarregam os carregadores de classe apenas em alguns ciclos do GC.

As classes também podem ser geradas no tempo de execução, sem concretizá-las. Muitos aplicativos do JEE usam a tecnologia JavaServer Pages (JSP) para criar as páginas da Web. Usar o JSP gera uma classe para cada página .jsp executada que preservará o tempo de vida do carregador de classe que os carregou — normalmente o tempo de vida do aplicativo da Web.

Outra maneira comum de gerar as classes é usar o reflexo Java. A maneira pela qual o reflexo trabalha varia entre as implementações Java, mas as implementações Sun e IBM usam o método que descreverei agora.

Ao usar a API java.lang.reflect, o Java runtime deve conectar os métodos de um objeto de reflexo (como java.lang.reflect.Field) para o objeto ou classe que está sendo refletido. Isso pode ser feito ao usar um acessador Java Native Interface (JNI), que requer bem pouca configuração mas é lento para usar, ou criar uma classe dinamicamente no tempo de execução para cada tipo de objeto que deseja refletir. O último método é mais lento para configurar, porém, mais rápido de executar, tornando-o ideal para aplicativos que são refletidos sempre em uma determinada classe.

O Java runtime usa o método JNI nas primeiras vezes em que uma classe é refletida, mas depois de ser usada várias vezes, o acessador é aumentado para um acessador de bytecode, que envolve a criação de uma classe e o carregamento dela através de um novo carregador de classe. Fazer muitos reflexos pode resultar na criação de muitas classes de acessador e carregadores de classe. Manter as referências nos objetos refletidos fazem com que essas classes permaneçam ativas e continuem ocupando espaço. Como a criação dos acessadores do bytecode é muito lenta, o Java runtime pode armazenar em cache esses acessadores para uso posterior. Alguns aplicativos e estruturas também armazenam em cache os objetos de reflexo, aumentando, assim, a área de cobertura nativa.

JNI

O JNI permite que o código nativo (aplicativos gravados nas linguagens compiladas nativas, como C e C++) para chamar os métodos Java e vice-versa. O próprio Java runtime depende decisivamente do código JNI para implementar as funções de biblioteca de classe, como E/S de arquivo e de rede. Um aplicativo JNI pode aumentar a área de cobertura nativa do Java runtime dessas três formas:

  • O código nativo para um aplicativo JNI é compilado em uma biblioteca compartilhada ou um executável que é carregado no espaço de endereço de processo. Aplicativos nativos maiores podem ocupar uma parte significativa do espaço de endereço de processo apenas ao ser carregado.
  • O código nativo deve compartilhar o espaço de endereço com o Java runtime. Quaisquer alocações de memória nativa ou mapeamentos de memória executados pelo código nativo tiram a memória do Java runtime.
  • Certas funções JNI podem usar a memória nativa como parte da operação normal. As funções GetTypeArrayElements e GetTypeArrayRegion podem copiar os dados de heap Java nos buffers de memória nativa para o código nativo com o qual irá trabalhar. Se a cópia deve ser feita ou não depende da implementação do runtime. (O IBM Developer Kit para Java 5.0 e superior faz uma cópia nativa). Acessar grandes quantidades de dados de heap Java desta maneira pode usar uma grande quantidade correspondente de heap nativo.

NIO

As classes new I/O (NIO) incluídas no Java 1.4 introduziram uma nova maneira de executar a E/S baseado em canais e buffers. Além dos buffers de E/S recuperados pela memória no heap Java, o NIO incluiu suporte para ByteBuffers diretos (alocados usando o métodojava.nio.ByteBuffer.allocateDirect() ) que são recuperados pela memória nativa em vez do heap Java. Os ByteBuffers diretos podem ser transmitidos diretamente para as funções da biblioteca do sistema operacional nativo para executar a E/S— tornando-os significativamente mais rápidos em alguns cenários porque eles evitam a cópia dos dados entre o heap Java e o heap nativo.

É fácil se confundir onde os dados do ByteBuffer direto estão sendo armazenados. O aplicativo ainda usa um objeto no heap Java para orquestrar as operações de E/S, mas o buffer que mantém os dados é mantido na memória nativa — o objeto de heap Java contém apenas uma referência ao buffer de heap nativo. Um ByteBuffer indireto mantém os dados em uma array byte[] no heap Java. A Figura 4 mostra a diferença entre objetos ByteBuffer diretos e indiretos:

Figura 4. Topologia de memória para java.nio.ByteBuffers direto e indireto
Organizações da memória ByteBuffer

Os objetos ByteBuffer diretos limpam os buffers nativos automaticamente, mas podem fazer isso apenas como parte do GC de heap Java — para que eles não respondam automaticamente à pressão no heap nativo. O GC ocorre apenas quando o heap Java ficar tão cheio de modo que ele não possa atender a um pedido de alocação de heap ou se o aplicativo Java solicitar isso explicitamente (não recomendado porque ele causa problemas de desempenho).

A causa desse problema seria que o heap nativo fica cheio e um ou mais ByteBuffers diretos são elegíveis para o GC (e que poderia liberar algum espaço no heap nativo), mas como o heap Java está quase vazio na maioria das vezes, o GC não ocorre.

Encadeamentos

Cada encadeamento em um aplicativo requer memória para armazenar a pilha (a área da memória usada para manter as variáveis locais e manter o estado ao chamar as funções). Cada encadeamento Java requer espaço de pilha para executar. Dependendo da implementação, um encadeamento Java pode ter pilhas Java e nativas separadas. Além do espaço de pilha, cada encadeamento requer alguma memória nativa para as estruturas de armazenamento de encadeamento local e de dados internos.

O tamanho da pilha varia por implementação Java e por arquitetura. Algumas implementações permitem especificar o tamanho da pilha para os encadeamentos Java. Os valores entre 256 KB e 756 KB são típicos.

Embora a quantidade de memória usada por encadeamento seja muito pequena, para um aplicativo com várias centenas de encadeamentos, o uso de memória total para as pilhas de encadeamento pode ser maior. Executar um aplicativo com mais encadeamentos do que processadores disponíveis para executá-los é geralmente ineficiente, podendo prejudicar o desempenho e aumentar o uso de memória.


Como eu posso saber se estou executando sem memória nativa?

O Java runtime é bem diferente quando executa sem o heap Java comparado à execução sem o heap nativo, embora as duas condições possam ocorrer com sintomas semelhantes. Um aplicativo Java tem muita dificuldade para funcionar quando o heap Java se esgotar — porque é difícil para ele fazer alguma coisa sem alocar objetos. O desempenho ruim do GC e o erro OutOfMemoryErrorsignificam que um heap Java completo será gerado assim que ele ficar cheio.

Em contraste, quando o Java runtime for iniciado e o aplicativo estiver em estado estável, ele poderá continuar funcionando depois que o heap nativo for esgotado completamente. Ele não mostra necessariamente nenhum comportamento estranho porque as ações que requerem uma alocação de memória nativa são mais raras do que as ações que requerem alocações de heap Java. Embora as ações que requerem memória nativa variem por implementação da JVM, alguns exemplos comuns são: iniciar um encadeamento, carregar uma classe e executar determinados tipos de E/S de rede e de arquivo.

O comportamento de falta de memória nativa também é menos consistente do que o comportamento de falta de memória do heap Java, porque não há nenhum ponto de controle único para alocações de heap nativo. Considerando que todos os aplicativos de heap Java estão sob o controle do sistema de gerenciamento de memória Java, qualquer código nativo — independente se estiver na JVM, nas bibliotecas de classe Java ou no código do aplicativo — pode executar uma alocação de memória nativa e esta falhará. O código que tentar a alocação poderá, em seguida, manipulá-lo, porém o designer pode verificar um dos seguintes procedimentos: emitir OutOfMemoryError através da interface JNI, imprimir um mensagem da tela, falhar silenciosamente e tentar de novo mais tarde, ou alguma outra ação.

A falta de um comportamento previsível significa que não há nenhuma maneira simples para identificar a descarga de memória nativa. Em vez disso, é necessário usar dados do sistema operacional a partir do Java runtime para confirmar o diagnóstico.


Exemplos de Execução sem Memória Nativa

Para ajudar a saber como a memória nativa esgotada afeta a implementação Java que você está usando, o código de amostra deste artigo (consulte Download) contém alguns programas Java que acionam a descarga de heap nativo de maneiras diferentes. Os exemplos usam uma biblioteca nativa gravada em linguagem C para consumir todo o espaço de endereço nativo e tentam executar alguma ação que usa a memória nativa. Os exemplos são fornecidos já prontos, embora as instruções sobre a compilação deles são fornecidos no arquivo README.html no diretório de nível superior do pacote de amostra.

A classe com.ibm.jtc.demos.NativeMemoryGlutton fornece o método gobbleMemory() , que chama malloc em um loop até quase toda a memória nativa ser esgotada. Quando ela concluir a tarefa, o número de bytes alocados para o erro padrão será impresso como a seguir:

Allocated 1953546736 bytes of native memory before running out

A saída de cada demonstração foi capturada para um Java runtime da Sun e da IBM em execução em um sistema Windows de 32 bits. Os binários fornecidos foram testados no:

  • Linux x86
  • Linux PPC 32
  • Linux 390 31
  • Windows x86

A seguinte versão do Java runtime da Sun foi usado para capturar a saída:

java versão "1.5.0_11"
Java(TM) 2 Runtime Environment, Standard Edition (build 1.5.0_11-b03)
Java HotSpot(TM) Client VM (build 1.5.0_11-b03, modo combinado)

A versão do Java runtime da IBM usado foi:

java versão "1.5.0"
Java(TM) 2 Runtime Environment, Standard Edition (build pwi32devifx-20071025 (SR
6b))
IBM J9 VM (build 2.3, J2RE 1.5.0 IBM J9 2.3 Windows XP x86-32 j9vmwi3223-2007100
7 (JIT enabled)
J9VM - 20071004_14218_lHdSMR
JIT  - 20070820_1846ifx1_r8
GC   - 200708_10)
JCL  - 20071025

Tentando iniciar um encadeamento quando faltar memória nativa

A classe com.ibm.jtc.demos.StartingAThreadUnderNativeStarvation tenta iniciar um encadeamento quando o espaço de endereço de processo for esgotado. Essa é uma maneira comum de saber se o seu processo Java está operando sem memória porque muitos aplicativos iniciam os encadeamentos durante o seu tempo de vida.

A saída da demo StartingAThreadUnderNativeStarvation ao executar no Java runtime da IBM é:

Allocated 1019394912 bytes of native memory before running out
JVMDUMP006I Processing Dump Event "systhrow", detail
"java/lang/OutOfMemoryError" - Please Wait.
JVMDUMP007I JVM Requesting Snap Dump using 'C:\Snap0001.20080323.182114.5172.trc'
JVMDUMP010I Snap Dump written to C:\Snap0001.20080323.182114.5172.trc
JVMDUMP007I JVM Requesting Heap Dump using 'C:\heapdump.20080323.182114.5172.phd'
JVMDUMP010I Heap Dump written to C:\heapdump.20080323.182114.5172.phd
JVMDUMP007I JVM Requesting Java Dump using 'C:\javacore.20080323.182114.5172.txt'
JVMDUMP010I Java Dump written to C:\javacore.20080323.182114.5172.txt
JVMDUMP013I Processed Dump Event "systhrow", detail "java/lang/OutOfMemoryError".
java.lang.OutOfMemoryError: ZIP006:OutOfMemoryError, ENOMEM error in ZipFile.open
   at java.util.zip.ZipFile.open(Native Method)
   at java.util.zip.ZipFile.<init>(ZipFile.java:238)
   at java.util.jar.JarFile.<init>(JarFile.java:169)
   at java.util.jar.JarFile.<init>(JarFile.java:107)
   at com.ibm.oti.vm.AbstractClassLoader.fillCache(AbstractClassLoader.java:69)
   at com.ibm.oti.vm.AbstractClassLoader.getResourceAsStream(AbstractClassLoader.java:113)
   at java.util.ResourceBundle$1.run(ResourceBundle.java:1101)
   at java.security.AccessController.doPrivileged(AccessController.java:197)
   at java.util.ResourceBundle.loadBundle(ResourceBundle.java:1097)
   at java.util.ResourceBundle.findBundle(ResourceBundle.java:942)
   at java.util.ResourceBundle.getBundleImpl(ResourceBundle.java:779)
   at java.util.ResourceBundle.getBundle(ResourceBundle.java:716)
   at com.ibm.oti.vm.MsgHelp.setLocale(MsgHelp.java:103)
   at com.ibm.oti.util.Msg$1.run(Msg.java:44)
   at java.security.AccessController.doPrivileged(AccessController.java:197)
   at com.ibm.oti.util.Msg.<clinit>(Msg.java:41)
   at java.lang.J9VMInternals.initializeImpl(Native Method)
   at java.lang.J9VMInternals.initialize(J9VMInternals.java:194)
   at java.lang.ThreadGroup.uncaughtException(ThreadGroup.java:764)
   at java.lang.ThreadGroup.uncaughtException(ThreadGroup.java:758)
   at java.lang.Thread.uncaughtException(Thread.java:1315)
K0319java.lang.OutOfMemoryError: Failed to fork OS thread
   at java.lang.Thread.startImpl(Native Method)
   at java.lang.Thread.start(Thread.java:979)
   at com.ibm.jtc.demos.StartingAThreadUnderNativeStarvation.main(
StartingAThreadUnderNativeStarvation.java:22)

Chamar java.lang.Thread.start() tenta alocar memória para um novo encadeamento de sistema operacional. Esta tentativa falha e causa um erro OutOfMemoryError emitido. A linha JVMDUMP notifica o usuário de que o Java runtime produziu seus dados de depuração OutOfMemoryError padrão.

Tentar manipular o primeiro OutOfMemoryError causou um segundo erro — o erro :OutOfMemoryError, ENOMEM em ZipFile.open. Vários erros OutOfMemoryErrors são comuns quando a memória nativa de processo for esgotada. A mensagem Falha ao bifurcar o encadeamento do sistema operacional provavelmente é o sinal mais comum de que a execução está sendo feita sem memória nativa.

Os exemplos fornecidos com esse artigo acionam os clusters de OutOfMemoryErrors que são os mais graves de tudo o que provavelmente possa acontecer com seus próprios aplicativos. Parte disso é devido virtualmente a toda a memória nativa que foi usada e, diferente de um aplicativo real, essa memória não será liberada posteriormente. Em um aplicativo real, como OutOfMemoryErrors são emitidos, os encadeamentos são encerrados e a pressão pela memória nativa será diminuída um pouco, permitindo que o tempo de execução manipule o erro. A natureza trivial das etapas de teste também significa que todas as seções da biblioteca de classe (como o sistema de segurança) ainda não foram inicializadas — e a inicialização é conduzida pelo tempo de execução que tenta manipular a condição de falta de memória. Em um aplicativo real, você poderá ver alguns dos erros mostrados aqui, mas é provável que você não consiga vê-los todos juntos.

Quando a mesma etapa de teste for executada no Java runtime, a seguinte saída do console será produzida:

Allocated 1953546736 bytes of native memory before running out
Exception in thread "main" java.lang.OutOfMemoryError: unable to create new native thread
   at java.lang.Thread.start0(Native Method)
   at java.lang.Thread.start(Thread.java:574)
   at com.ibm.jtc.demos.StartingAThreadUnderNativeStarvation.main(
StartingAThreadUnderNativeStarvation.java:22)

Embora o rastreio de pilha e a mensagem de erro sejam significativamente diferentes, o comportamento é essencialmente o mesmo: a alocação nativa falhará e um java.lang.OutOfMemoryError será emitido. A única coisa que diferencia os OutOfMemoryErrors emitidos neste cenário dos outros que foram emitidos, é o fato do esgotamento do heap Java ser a mensagem.

Tentando alocar um ByteBuffer direto quando a memória nativa estiver em falta

A classe com.ibm.jtc.demos.DirectByteBufferUnderNativeStarvation tenta alocar um objeto java.nio.ByteBuffer direto (ou seja, nativamente recuperado) quando o espaço de endereço estiver esgotado. Ao executar no Java runtime da IBM, a seguinte saída é gerada:

Allocated 1019481472 bytes of native memory before running out
JVMDUMP006I Processing Dump Event "uncaught", detail
"java/lang/OutOfMemoryError" - Please Wait.
JVMDUMP007I JVM Requesting Snap Dump using 'C:\Snap0001.20080324.100721.4232.trc'
JVMDUMP010I Snap Dump written to C:\Snap0001.20080324.100721.4232.trc
JVMDUMP007I JVM Requesting Heap Dump using 'C:\heapdump.20080324.100721.4232.phd'
JVMDUMP010I Heap Dump written to C:\heapdump.20080324.100721.4232.phd
JVMDUMP007I JVM Requesting Java Dump using 'C:\javacore.20080324.100721.4232.txt'
JVMDUMP010I Java Dump written to C:\javacore.20080324.100721.4232.txt
JVMDUMP013I Processed Dump Event "uncaught", detail "java/lang/OutOfMemoryError".
Exception in thread "main" java.lang.OutOfMemoryError:
Unable to allocate 1048576 bytes of direct memory after 5 retries
   at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:167)
   at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:303)
   at com.ibm.jtc.demos.DirectByteBufferUnderNativeStarvation.main(
   DirectByteBufferUnderNativeStarvation.java:29)
Caused by: java.lang.OutOfMemoryError
   at sun.misc.Unsafe.allocateMemory(Native Method)
   at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:154)
   ... 2 more

Neste cenário, um OutOfMemoryError é emitido e aciona a documentação de erro padrão. OutOfMemoryError atinge o topo da pilha do encadeamento principal e é impresso no stderr.

Ao executar no Java runtime da Sun, esta etapa de teste produz a seguinte saída do console:

Allocated 1953546760 bytes of native memory before running out
Exception in thread "main" java.lang.OutOfMemoryError
   at sun.misc.Unsafe.allocateMemory(Native Method)
   at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:99)
   at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:288)
   at com.ibm.jtc.demos.DirectByteBufferUnderNativeStarvation.main(
DirectByteBufferUnderNativeStarvation.java:29)

Depurando Abordagens e Técnicas

A primeira coisa a fazer quando receber um java.lang.OutOfMemoryError ou uma mensagem de erro sobre falta de memória é determinar qual tipo de memória foi esgotada. A maneira mais fácil de fazer isso é primeiro verificar se o heap Java está cheio. Se o heap Java não causou a condição OutOfMemory, então você deverá analisar um uso de heap nativo.

Consultando a Documentação do Fornecedor

As diretrizes neste artigo são princípios de depuração gerais aplicados ao entendimento de cenários de falta de memória nativa. Seu fornecedor do runtime pode fornecer suas próprias instruções de depuração que devem ser seguidas ao trabalhar com sua equipe de suporte. Se você estiver levantando um problema com seu fornecedor de runtime (incluindo a IBM), consulte sempre a documentação de depuração e de diagnóstico para ver quais etapas devem ser seguidas ao enviar um relatório de problema.

Verificando o Heap Java

O método para verificar o uso do heap varia entre as implementações Java. Nas implementações da IBM do Java 5 e 6, o arquivo javacore produzido quando o OutOfMemoryError foi emitido avisará você. O arquivo javacore é geralmente produzido no diretório de rede do processo Java e possui um nome no formato javacore.date.time.pid.txt. Se você abrir o arquivo em um editor de texto, haverá uma seção semelhante à seguinte:

0SECTION       MEMINFO subcomponent dump routine
NULL           =================================
1STHEAPFREE    Bytes of Heap Space Free: 416760
1STHEAPALLOC   Bytes of Heap Space Allocated: 1344800

Esta seção mostra quanto do heap Java foi liberado quando o javacore foi produzido. Observe que os valores estão em formato hexadecimal. Se o OutOfMemoryError foi emitido porque uma alocação não pôde ser atendida, então a seção de rasteio do GC mostrará isso:

1STGCHTYPE     GC History
3STHSTTYPE     09:59:01:632262775 GMT j9mm.80 -   J9AllocateObject() returning NULL!
32 bytes requested for object of class 00147F80

J9AllocateObject() returning NULL! significa que a rotina de alocação de heap Java foi concluída sem sucesso e um OutOfMemoryError será emitido.

OutOfMemoryError também pode ser emitido porque o garbage collector está executando muitas vezes (sinal de que o heap está cheio e o aplicativo Java terá pouco ou nenhum progresso). Nesse caso, você espera que o valor de Espaço Livre de Heap seja muito pequeno e o rastreio do GC mostrará umas dessas mensagens:

1STGCHTYPE     GC History
3STHSTTYPE     09:59:01:632262775 GMT j9mm.83 -     Forcing J9AllocateObject()
to fail due to excessive GC
1STGCHTYPE     GC History
3STHSTTYPE     09:59:01:632262775 GMT j9mm.84 -     Forcing
J9AllocateIndexableObject() to fail due to excessive GC

Quando a implementação Sun for executada sem memória de heap Java, ela usará a mensagem de exceção para mostrar que é o heap Java que se esgotou:

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

As implementações IBM e Sun possuem uma opção de GC detalhada que produz dados de rastreio que mostram quão cheio está o heap em cada ciclo do GC. Essas informações podem ser plotadas com uma ferramenta como o IBM Monitoring and Diagnostic Tools para Java - Garbage Collection and Memory Visualizer (GCMV) para mostrar se o heap Java está aumentando (consulte Recursos).

Medindo o Uso de Heap Nativo

Se você determinou que sua condição de falta de memória não foi causada pelo esgotamento de heap Java, o próximo estágio é traçar o perfil do uso da sua memória nativa.

A ferramenta PerfMon fornecida com o Windows permite monitorar e registrar vários sistemas operacionais e métricas de processo, incluindo o uso de memória nativa (consulte Recursos). Ele permite que os contadores sejam controlados em tempo real ou armazenados em um arquivo de log a ser revisto off-line. Use o contador Bytes Privados para mostrar o uso total do espaço de endereço. Se isso abranger o limite do espaço do usuário (entre 2 e 3 GB conforme discutido anteriormente), é provável que as condições de falta de memória nativa ocorram.

O Linux não possui nenhuma ferramenta PerfMon equivalente, mas há várias alternativas. As ferramentas da linha de comandos como ps, top e pmap pode mostrar uma área de cobertura de memória nativa do aplicativo. Embora fazer uma captura instantânea de um uso da memória do processo seja útil, você terá um maior entendimento sobre como a memória nativa está sendo usada ao fazer um gráfico da memória frequentemente. Uma maneira de fazer isso é usar o GCMV.

O GCMV foi originalmente gravado para gerar gráficos dos logs do GC, permitindo que os usuários vejam as alterações no uso de heap Java e o desempenho do GC ao ajustar o garbage collector. O GCMV foi posteriormente estendido para permitir a geração de gráficos de outras origens de dados, incluindo os dados de memória nativa do Linux e do AIX. O GCMV é fornecido como um plug-in para o IBM Support Assistant (ISA).

Para gerar um gráfico do perfil da memória nativa do Linux com o GCMV, você deve primeiro coletar os dados de memória nativa usando um script. O analisador de memória nativa do Linux do GCMV lê a saída a partir do comando ps do Linux intercalado com os registros de data e hora. Um script é fornecido na documentação de ajuda do GCMV que coleta os dados na forma correta. Para localizar o script:

  1. Faça download e instale o ISA Versão 4 (ou superior) e instale o plug-in da ferramenta GCMV.
  2. Inicie o ISA.
  3. Clique em Ajuda >> Conteúdo de Ajuda na barra de menu para abrir o menu de ajuda do ISA.
  4. Localize as instruções de memória nativa do Linux na área de janela à esquerda em Tool:IBM Monitoring and Diagnostic Tools para Java - Garbage Collection and Memory Visualizer >> Usando o Garbage Collection and Memory Visualizer >> Tipos de Dados Suportados >> Memória Nativa >> Memória nativa do Linux.

A Figura 5 mostra o local do script no arquivo de ajuda do ISA. Se você não tiver a entrada Ferramenta GCMV no seu arquivo de ajuda, é bem provável que o plug-in do GCMV não esteja instalado.

Figura 5. Local do script de captura de dados de memória nativa do Linux no diálogo de ajuda do ISA
Arquivo de Ajuda do IBM Support Assistant

O script fornecido na ajuda do GCMV usa um comando ps que funciona apenas com versões recentes do ps. Em algumas distribuições mais antigas do Linux, o comando no arquivo de ajuda não produz as informações corretas. Para verificar o comportamento na sua distribuição Linux, tente executar ps -o pid,vsz=VSZ,rss=RSS. Se sua versão do ps suportar a nova sintaxe de argumento da linha de comando, a saída será semelhante à seguinte:

  PID    VSZ   RSS
 5826   3772  1960
 5675   2492   760

Se sua versão do ps não suportar a nova sintaxe, a saída será semelhante à seguinte:

  PID VSZ,rss=RSS
 5826        3772
 5674        2488

Se você estiver executando um versão mais antiga do ps, modifique o script de memória nativa substituindo a linha

ps -p $PID -o pid,vsz=VSZ,rss=RSS

por

ps -p $PID -o pid,vsz,rss

Copie o script do painel de ajuda para um arquivo (neste exemplo chamado de memscript.sh), localize o ID do processo (PID) do processo Java que deseja monitorar (neste exemplo, 1234)e execute:

./memscript.sh 1234 > ps.out

Isso gravará o log de memória nativa no ps.out. Para gerar o gráfico do uso de memória:

  1. No ISA, selecione Analisar Problema no menu suspenso Ativar Atividade.
  2. Selecione a guia Ferramentas próximo à parte superior do painel Analisar Problema.
  3. Selecione IBM Monitoring and Diagnostic Tools para Java - Garbage Collection and Memory Visualizer.
  4. Clique no botão Ativar próximo à parte inferior do painel de ferramentas.
  5. Clique no botão Procurar e localize o arquivo de log. Clique em OK para ativar o GCMV.

Quando você tiver o perfil do uso da memória nativa no decorrer do tempo, decida se está havendo um vazamento de memória nativa ou se você está apenas tentando fazer muita coisa no espaço disponível. A área de cobertura da memória nativa para até mesmo um aplicativo Java com bom comportamento não é constante a partir da inicialização. Vários sistemas de Java runtime — especialmente o compilador JIT e os carregadores de classe— são inicializados durante o tempo, o que pode consumir memória nativa. O crescimento de memória a partir da inicialização será estabilizada, mas se seu cenário possuir uma área de cobertura de memória nativa inicial próxima ao limite do espaço de endereço, esta fase de aquecimento provavelmente já será o suficiente para ocorrer uma falta de memória nativa. A Figura 6 mostra um exemplo da geração do gráfico de memória nativa do GCMV a partir de um teste Java com a fase de aquecimento realçada.

Figura 6. Exemplo de geração de gráfico de memória nativa do Linux a partir do GCMV que mostra a fase de aquecimento
Plot da memória nativa do GCMV

Também é possível ter uma área de cobertura nativa que varia com a carga de trabalho. Se seu aplicativo cria mais encadeamentos para manipular a carga de trabalho recebida ou aloca armazenamento de recuperação nativa, como ByteBuffers diretos proporcionalmente à quantia de carregamento que está sendo aplicado ao seu sistema, é possível que você fique sem memória nativa quando executar um carregamento alto.

Executar sem memória nativa por causa do crescimento de memória nativa da fase de aquecimento da JVM e o crescimento proporcional para o carregamento são exemplos da tentativa de executar muita coisa no espaço disponível. Nesses cenários você tem as seguintes opções:

  • Reduzir o uso da memória nativa. Reduzir o tamanho do heap Java é um bom começo.
  • Restringir o uso da memória nativa. Se o crescimento da sua memória nativa é alterado com o carregamento, ache uma maneira de controlar o carregamento ou os recursos que são alocados por causa disso.
  • Aumentar a quantia de espaço de endereço disponível. Você pode fazer isso ajustando seu sistema operacional (aumentando o espaço do usuário com a chave /3GB no Windows ou um kernel maior no Linux, por exemplo), alterando a plataforma (normalmente o Linux tem mais espaço do usuário do que o Windows) ou movendo para um S.O. de 64 bits.

Um vazamento de memória nativa típico é manifestado como um crescimento contínuo no heap nativo que não é descartado quando o carregamento for removido ou quando o garbage collector é executado. A taxa de vazamento de memória pode variar de acordo com o carregamento, mas a memória dispersa total não será descartada. É provável que a memória vazada não seja referenciada, portanto, ela pode ser descarregada para a área de troca e continuar lá.

Quando ocorrer um vazamento, suas opções são limitadas. Você pode aumentar a quantia de espaço do usuário (para que haja mais espaço para o vazamento) mas isso só tomará seu tempo antes de executar eventualmente sem memória. Se você tiver memória física e espaço de endereço suficientes, poderá permitir que o vazamento continue contanto que você reinicie o aplicativo antes que o espaço de endereço de processo seja esgotado.

O que está usando minha memória nativa?

Quando você tiver determinado que está executando sem memória nativa, a próxima pergunta lógica será: O que está usando essa memória? É difícil responder a essa pergunta porque, por padrão, o Windows e o Linux não armazenam informações sobre qual caminho de código está alocado para uma determinada parte da memória.

A primeira etapa ao tentar saber para onde foi a memória nativa é calcular aproximadamente o quanto de memória nativa será usada com base nas configurações Java. É difícil calcular um valor preciso sem saber ao certo as funções da JVM, mas você pode fazer uma estimativa aproximada com base nas seguintes diretrizes:

  • O heap Java ocupa pelo menos o valor -Xmx.
  • Cada encadeamento Java requer espaço de pilha. O tamanho de pilha varia entre as implementações, mas com as configurações padrão, cada encadeamento pode ocupar até 756 KB de memória nativa.
  • Os ByteBuffers diretos ocupam pelo menos os valores fornecidos para a rotina allocate().

Se o total for muito menor do que o espaço máximo do usuário, você não estará necessariamente seguro. Vários outros componentes em um Java runtime podem alocar memória suficiente para causar problemas, porém, se os seus cálculos iniciais indicarem que você está próximo do espaço do usuário máximo, você provavelmente terá problemas com a memória nativa. Se você suspeitar que está havendo um vazamento de memória nativa ou quiser saber exatamente para onde sua memória está indo, várias ferramentas podem ajudar.

A Microsoft fornece as ferramentas user-mode dump heap (UMDH) e LeakDiag para depurar o crescimento de memória nativa no Windows (consulte Recursos). Os dois trabalham de maneira semelhante, pois registram qual caminho de código está alocado para uma determinada área de memória e fornece uma maneira de localizar seções de código que alocam memória que não é liberada posteriormente. Recomendo a consulta do artigo"Umdhtools.exe: Como usar o arquivo Umdh.exe para localizar vazamentos de memória no Windows" para obter instruções sobre o uso do UMDH (consulte Recursos). Neste artigo, eu comentoa respeito da aparência da saída do UMDH ao executar em um aplicativo JNI que esteja vazando.

O pacote de amostras para esse artigo contém um aplicativo Java chamado LeakyJNIApp, que é executado em um loop que chama um método JNI com vazamento da memória nativa. O comando do UMDH faz uma captura instantânea do heap nativo atual junto com os rastreios de pilha nativos dos caminhos de código que são alocados em cada região da memória. Ao fazer as duas capturas instantâneas e usar a ferramenta UMDH para analisar a diferença, você receberá um relatório do crescimento do heap entre as duas capturas.

Para LeakyJNIApp, o arquivo de diferença contém essas informações:

// _NT_SYMBOL_PATH set by default to C:\WINDOWS\symbols
//
// Each log entry has the following syntax:
//
// + BYTES_DELTA (NEW_BYTES - OLD_BYTES) NEW_COUNT allocs BackTrace TRACEID
// + COUNT_DELTA (NEW_COUNT - OLD_COUNT) BackTrace TRACEID allocations
//     ... stack trace ...
//
// where:
//
//     BYTES_DELTA - increase in bytes between before and after log
//     NEW_BYTES - bytes in after log
//     OLD_BYTES - bytes in before log
//     COUNT_DELTA - increase in allocations between before and after log
//     NEW_COUNT - number of allocations in after log
//     OLD_COUNT - number of allocations in before log
//     TRACEID - decimal index of the stack trace in the trace database
//         (can be used to search for allocation instances in the original
//         UMDH logs).
//

+  412192 ( 1031943 - 619751)    963 allocs     BackTrace00468

Total increase == 412192

A linha importante é + 412192 ( 1031943 - 619751) 963 allocs BackTrace00468. Ela mostra que um backtrace tem mais de 963 alocações que não foram liberadas — e que consumiram 412192 bytes de memória. Ao observar um dos arquivos de captura instantânea, você pode associar BackTrace00468 ao caminho de código significativo. Procurar por BackTrace00468 na primeira captura instantânea mostra:

000000AD bytes in 0x1 allocations (@ 0x00000031 + 0x0000001F) by: BackTrace00468
        ntdll!RtlpNtMakeTemporaryKey+000074D0
        ntdll!RtlInitializeSListHead+00010D08
        ntdll!wcsncat+00000224
        leakyjniapp!Java_com_ibm_jtc_demos_LeakyJNIApp_nativeMethod+000000D6

Isso mostra que o vazamento é proveniente do módulo leakyjniapp.dll na função Java_com_ibm_jtc_demos_LeakyJNIApp_nativeMethod.

No momento da gravação, o Linux não possui uma ferramenta UMDH ou LeakDiag equivalente. Mas ainda há formas de depurar vazamentos de memória nativa no Linux. Os vários depuradores de memória disponíveis no Linux pertencem a uma das seguintes categorias:

  • Nível de processador. Requer que um cabeçalho seja compilado com a origem sob teste. É possível recompilar suas próprias bibliotecas JNI com uma dessas ferramentas para controlar um vazamento de memória nativa no seu código. A menos que você tenha o código de origem para o Java runtime em si, um vazamento na JVM não pode ser localizado (e inclusive compilar esse tipo de ferramenta em um projeto maior certamente seria muito difícil e demorado). Dmalloc é um exemplo deste tipo de ferramenta (consulte Recursos).
  • Nível do vinculador. Requer que os binários sob teste sejam vinculados novamente com uma biblioteca de depuração. Novamente, isso é viável para bibliotecas JNI individuais, mas não recomendado para tempos de execução Java inteiros porque é pouco provável que o fornecedor do runtime suporte você executar com os binários modificados. Ccmalloc é um exemplo deste tipo de ferramenta (consulte Recursos).
  • Nível do vinculador do runtime. Esses usam a variável de ambiente LD_PRELOAD para pré-carregar uma biblioteca que substitui as rotinas de memória padrão pelas versões instrumentadas. Eles não requerem recompilação ou revinculação do código de origem, mas muitos deles não funcionam bem com os Java runtimes. Um Java runtime é um sistema complicado que pode usar memória e os encadeamentos de maneiras incomuns que podem confundir ou interromper esse tipo de ferramenta. Vale testar se eles funcionam no seu cenário. NJAMD é um exemplo deste tipo de ferramenta (consulte Recursos).
  • Baseado em emulador. A ferramenta Valgrind memcheck é o único exemplo deste tipo de depurador de memória (consulte Recursos). Ele emula o processador subjacente do mesmo modo como um Java runtime emula a JVM. É possível executar o Java no Valgrind, mas o forte impacto no desempenho (10 a 30 vezes mais lento) significa que é muito difícil executar aplicativos Java maiores e complexos desta maneira. O Valgrind está atualmente disponível no Linux x86, AMD64, PPC 32 e PPC 64. Se você usar o Valgrind, tente reduzir o problema para a menor etapa de teste possível (de preferência removendo o Java runtime inteiro, se possível) antes de usá-lo.

Para cenários simples que podem tolerar a sobrecarga de desempenho, o Valgrind memcheck é a ferramenta livre mais fácil e simples. Ela fornece um rastreio de pilha completo para os caminhos de código que estão vazando a memória do mesmo modo que o UMDH faz no Windows.

O atributo LeakyJNIApp é simples o suficiente para executar no Valgrind. A ferramenta Valgrind memcheck pode imprimir um resumo da memória vazada quando o programa emulado for encerrado. Por padrão, o programa LeakyJNIApp é executado indefinidamente; para encerrá-lo após um período de tempo determinado, mude o tempo de execução para segundos como o único argumento da linha de comandos.

Alguns tempos de execução Java usam pilhas de encadeamento e registros de processador de maneiras incomuns, o que pode confundir algumas ferramentas de depuração, que obrigam os programas nativos a usarem as convenções padrão de uso de registro e de estrutura de pilha. Ao usar o Valgrind para depurar os aplicativos JNI de vazamento, você poderá receber vários avisos sobre o uso de memória e algumas pilhas de encadeamento e achar tudo isso muito estranho, mas isso é devido ao modo com que o Java runtime estrutura seus dados internamente e isso é normal.

Para rastrear o LeakyJNIApp com a ferramenta Valgrind memcheck, use este comando (em uma linha única):

valgrind --trace-children=yes --leak-check=full
java -Djava.library.path=. com.ibm.jtc.demos.LeakyJNIApp 10

A opção --trace-children=yes para o Valgrind faz rastrear quaisquer processos que são iniciados pelo ativador Java. Algumas versões do ativador Java reexecutam a si mesmas (elas são reiniciadas desde o início, novamente tenho um conjunto de variáveis de ambiente para alterar o comportamento). Se você não especificar --trace-children, você pode não rastrear o Java runtime real.

A opção --leak-check=full requer que os rastreios de pilha completos de áreas de código de vazamento sejam impressos no final da execução, em vez de apenas resumir o estado da memória.

O Valgrind imprime muitos avisos e erros durante a execução do comando (a maioria deles desnecessários neste contexto) e por fim imprime uma lista de pilhas de chamada de vazamento em ordem crescente da quantia de memória vazada. O fim da seção de resumo da saída do Valgrind para LeakyJNIApp no Linux x86 é:

==20494== 8,192 bytes in 8 blocks are possibly lost in loss record 36 of 45
==20494==    at 0x4024AB8: malloc (vg_replace_malloc.c:207)
==20494==    by 0x460E49D: Java_com_ibm_jtc_demos_LeakyJNIApp_nativeMethod
(in /home/andhall/LeakyJNIApp/libleakyjniapp.so)
==20494==    by 0x535CF56: ???
==20494==    by 0x46423CB: gpProtectedRunCallInMethod
(in /usr/local/ibm-java2-i386-50/jre/bin/libj9vm23.so)
==20494==    by 0x46441CF: signalProtectAndRunGlue
(in /usr/local/ibm-java2-i386-50/jre/bin/libj9vm23.so)
==20494==    by 0x467E0D1: j9sig_protect
(in /usr/local/ibm-java2-i386-50/jre/bin/libj9prt23.so)
==20494==    by 0x46425FD: gpProtectAndRun
(in /usr/local/ibm-java2-i386-50/jre/bin/libj9vm23.so)
==20494==    by 0x4642A33: gpCheckCallin
(in /usr/local/ibm-java2-i386-50/jre/bin/libj9vm23.so)
==20494==    by 0x464184C: callStaticVoidMethod
(in /usr/local/ibm-java2-i386-50/jre/bin/libj9vm23.so)
==20494==    by 0x80499D3: main
(in /usr/local/ibm-java2-i386-50/jre/bin/java)
==20494==
==20494==
==20494== 65,536 (63,488 direct, 2,048 indirect) bytes in 62 blocks are definitely
lost in loss record 42 of 45
==20494==    at 0x4024AB8: malloc (vg_replace_malloc.c:207)
==20494==    by 0x460E49D: Java_com_ibm_jtc_demos_LeakyJNIApp_nativeMethod
(in /home/andhall/LeakyJNIApp/libleakyjniapp.so)
==20494==    by 0x535CF56: ???
==20494==    by 0x46423CB: gpProtectedRunCallInMethod
(in /usr/local/ibm-java2-i386-50/jre/bin/libj9vm23.so)
==20494==    by 0x46441CF: signalProtectAndRunGlue
(in /usr/local/ibm-java2-i386-50/jre/bin/libj9vm23.so)
==20494==    by 0x467E0D1: j9sig_protect
(in /usr/local/ibm-java2-i386-50/jre/bin/libj9prt23.so)
==20494==    by 0x46425FD: gpProtectAndRun
(in /usr/local/ibm-java2-i386-50/jre/bin/libj9vm23.so)
==20494==    by 0x4642A33: gpCheckCallin
(in /usr/local/ibm-java2-i386-50/jre/bin/libj9vm23.so)
==20494==    by 0x464184C: callStaticVoidMethod
(in /usr/local/ibm-java2-i386-50/jre/bin/libj9vm23.so)
==20494==    by 0x80499D3: main
(in /usr/local/ibm-java2-i386-50/jre/bin/java)
==20494==
==20494== LEAK SUMMARY:
==20494==    definitely lost: 63,957 bytes in 69 blocks.
==20494==    indirectly lost: 2,168 bytes in 12 blocks.
==20494==      possibly lost: 8,600 bytes in 11 blocks.
==20494==    still reachable: 5,156,340 bytes in 980 blocks.
==20494==         suppressed: 0 bytes in 0 blocks.
==20494== Reachable blocks (those to which a pointer was found) are not shown.
==20494== To see them, rerun with: --leak-check=full --show-reachable=yes

A segunda linha das pilhas mostra que a memória foi vazada pelo método com.ibm.jtc.demos.LeakyJNIApp.nativeMethod().

Vários aplicativos de depuração do proprietário que podem depurar o vazamento de memória nativa também estão disponíveis. Mais ferramentas (tanto software livre quanto de proprietário) estão sendo desenvolvidas o tempo todo e vale a pena pesquisar o nível de desenvolvimento delas.

Depurar atualmente os vazamentos de memória nativa no Linux com as ferramentas livres disponíveis é mais difícil do que fazer o mesmo no Windows. Enquanto que o UMDH permite que vazamentos nativos no Windows sejam depurados in situ, no Linux você provavelmente precisará fazer alguma depuração tradicional em vez de depender de uma ferramenta para resolver o problema. A seguir há algumas etapas de depuração sugeridas:

  • Extrair as etapas de teste. Produza um ambiente independente com o qual você possa reproduzir o vazamento nativo. A depuração ficará muito mais simples.
  • Limite as etapas de teste o máximo possível. Tente analisar as funções para identificar quais caminhos de código estão causando o vazamento nativo. Se você tiver suas próprias bibliotecas JNI, tente analisá-las todas de uma vez para determinar se elas estão causando o vazamento.
  • Reduzir o tamanho de heap Java. O heap Java é provavelmente o que mais consome espaço de endereço virtual no processo. Ao reduzir o heap Java, você libera mais espaço para outros usuários de memória nativa.
  • Correlacionar o tamanho do processo nativo. Quando você gerar um gráfico do uso de memória nativa ao longo do tempo, você pode compará-lo com a carga de trabalho do aplicativo e com os dados do GC. Se a taxa de vazamento for proporcional ao nível do carregamento, é sinal de que o vazamento está sendo causado por algo no caminho de cada transação ou operação. Se o tamanho do processo nativo cair significativamente quando o GC for executado, é sinal de que não está havendo um vazamento — e sim a criação de objetos com uma recuperação nativa (como ByteBuffers diretos). Você pode reduzir a quantia de memória tomada pelos objetos com recuperação nativa ao reduzir o tamanho de heap Java (forçando, assim, as coletas a ocorrerem mais vezes) ou ao gerenciá-los em um cache de objeto em vez de depender do garbage collector para fazer a limpeza para você.

Se você achar que um vazamento ou um crescimento de memória é devido ao próprio Java runtime, chame o fornecedor do runtime para fazer uma depuração mais detalhada.


Removendo o limite: Fazendo a mudança para 64 bits

É fácil atingir as condições de falta de memória nativa com os tempos de execução Java de 32 bits porque o espaço de endereço é relativamente pequeno. O espaço de usuário de 2 a 4 GB que o sistema operacional de 32 bits fornece é sempre menor do que a quantia de memória física anexada ao sistema e os aplicativos modernos de muitos dados podem facilmente ser dimensionados para preencher o espaço disponível.

Se o seu aplicativo não puder ser ajustado em um espaço de endereço de 32 bits, você poderá ganhar muito mais espaço do usuário se mudar para um Java runtime de 64 bits. Se você estiver executando um sistema operacional de 64 bits, um Java runtime de 64 bits permitirá enormes heaps Java e poucos problemas relacionados ao espaço de endereço ocorrerão. A Tabela 2 lista os espaços do usuário disponíveis no momento, com os sistemas operacionais de 64 bits:

Tabela 2. Tamanhos do espaço do usuário em sistemas operacionais de 64 bits
Sistema OperacionalTamanho do espaço do usuário padrão
Windows x86-648192 GB
Windows Itanium7152 GB
Linux x86-64500 GB
Linux PPC641648 GB
Linux 390 644EB

Mudar para 64 bits não é uma solução universal para todos os problemas de memória nativa, pois você ainda precisará de memória física suficiente para manter todos os dados. Se o seu Java runtime não for ajustado na memória física, o desempenho será extremamente ruim porque o sistema operacional terá que fazer trash dos dados do Java runtime no espaço de memória swap. Por esse mesmo motivo, mudar para 64 bits não é a solução definitiva para um vazamento de memória — já que você está apenas liberando mais espaço para ocorrer o vazamento, o que só tomará tempo entre os reinícios forçados.

Não é possível usar um código nativo de 32 bits com um runtime de 64 bits; quaisquer códigos nativos(agentes bibliotecas JNI, JVM Tool Interface [JVMTI], JVM Profiling Interface [JVMPI] e JVM Debug Interface [JVMDI]) devem ser recompilados para 64 bits. O desempenho do runtime de 64 bits também pode ser mais lento do que o runtime de 32 bits correspondente no mesmo hardware. Um runtime de 64 bits usa ponteiros de 64 bits (referencias de endereço nativas), portanto, o mesmo objeto Java no runtime de 64 bits ocupa mais espaço do que um objeto que contém os mesmos dados no runtime de 32 bits. Objetos maiores significam que um heap maior deve manter a mesma quantia de dados enquanto mantém o desempenho do GC semelhante, o que torna os caches do sistema operacional e do hardware menos eficientes. Surpreendentemente, um heap Java maior não significa necessariamente que o GC pode ter pausas mais longas já que a quantia de dados ativos no heap pode não ter aumentado e alguns algoritmos do GC são mais efetivos com heaps maiores.

Alguns tempos de execução Java modernos contém tecnologia para minimizar o "inchaço do objeto" de 64 bits e melhorar o desempenho. Esses recursos funcionam usando referências mais curtas nos tempos de execução de 64 bits. Isto é chamado de referências compactadas na implementação IBM e oops compactados nas implementações Sun.

Um estudo comparativo do desempenho do Java runtime vai além do escopo deste artigo, mas se você desejar mudar para 64 bits, é recomendado testar seu aplicativo antes para saber como ele será executado. Como alterar o tamanho do endereço afeta o heap Java, você deverá retornar suas configurações do GC na nova arquitetura em vez de apenas definir as configurações existentes.


Conclusão

Entender a memória nativa é essencial para designar e executar aplicativos Java maiores, mas isso não é levado a sério por ela estar ligada à detalhes inúteis de hardware e do sistema operacional dos quais o Java runtime foi projetado para nos livrar. O JRE é um processo nativo que deve trabalhar no ambiente definido por esses detalhes inúteis. Para obter um melhor desempenho do seu aplicativo Java, você deve entender como o aplicativo afeta o uso de memória nativa do Java runtime.

A execução sem memória nativa pode ser parecido com a execução sem o heap Java, mas isso requer um conjunto de ferramentas diferente para depurar e resolver. A chave para corrigir os problemas de memória nativa é entender os limites impostos pelo hardware e pelo sistema operacional nos quais o seu aplicativo Java é executado e juntar isso ao conhecimento das ferramentas do sistema operacional para monitorar o uso da memória nativa. Seguindo essa abordagem, você estará pronto para resolver alguns dos problemas mais difíceis que o seu aplicativo Java pode acarretar.


Download

DescriçãoNomeTamanho
Native memory example codej-nativememory-linux.zip115KB

Recursos

Aprender

Obter produtos e tecnologias

  • Valgrind: Download da estrutura de instrumentação do Valgrind, incluindo o detector de erro de memória.
  • Dmalloc: Download da biblioteca Debug Malloc.
  • ccmalloc: Download da biblioteca do depurador de memória ccmalloc.
  • NJAMD: Download da biblioteca do depurador de memória Not Just Another Malloc Debugger (NJAMD).
  • IBM Monitoring and Diagnostic Tools para Java: Visite a página de conjuntos de ferramentas IBM Java.
  • IBM Support Assistant (ISA): Essa estrutura de suporte livre contém ferramentas como Garbage Collection and Memory Visualizer e o IBM Guided Activity Assistant, que podem guiar você na depuração de uma condição de falta de memória nativa.

Discutir

Comentários

developerWorks: Conecte-se

Los campos obligatorios están marcados con un asterisco (*).


Precisa de um ID IBM?
Esqueceu seu ID IBM?


Esqueceu sua senha?
Alterar sua senha

Ao clicar em Enviar, você concorda com os termos e condições do developerWorks.

 


A primeira vez que você entrar no developerWorks, um perfil é criado para você. Informações no seu perfil (seu nome, país / região, e nome da empresa) é apresentado ao público e vai acompanhar qualquer conteúdo que você postar, a menos que você opte por esconder o nome da empresa. Você pode atualizar sua conta IBM a qualquer momento.

Todas as informações enviadas são seguras.

Elija su nombre para mostrar



Ao se conectar ao developerWorks pela primeira vez, é criado um perfil para você e é necessário selecionar um nome de exibição. O nome de exibição acompanhará o conteúdo que você postar no developerWorks.

Escolha um nome de exibição de 3 - 31 caracteres. Seu nome de exibição deve ser exclusivo na comunidade do developerWorks e não deve ser o seu endereço de email por motivo de privacidade.

Los campos obligatorios están marcados con un asterisco (*).

(Escolha um nome de exibição de 3 - 31 caracteres.)

Ao clicar em Enviar, você concorda com os termos e condições do developerWorks.

 


Todas as informações enviadas são seguras.


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=80
Zone=Linux
ArticleID=395339
ArticleTitle=Graças à Memória
publish-date=04212009