Java Virtual Machine - Guia Rápido

A JVM é uma especificação e pode ter diferentes implementações, desde que estejam de acordo com as especificações. As especificações podem ser encontradas no link abaixo -https://docs.oracle.com

A Oracle tem sua própria implementação JVM (chamada de HotSpot JVM), a IBM tem sua própria (a J9 JVM, por exemplo).

As operações definidas dentro das especificações são fornecidas abaixo (fonte - Oracle JVM Specs, consulte o link acima) -

  • O formato de arquivo da 'classe'
  • Tipos de dados
  • Tipos e valores primitivos
  • Tipos e valores de referência
  • Áreas de dados em tempo de execução
  • Frames
  • Representação de objetos
  • Aritmética de ponto flutuante
  • Métodos especiais
  • Exceptions
  • Resumo do conjunto de instruções
  • Bibliotecas de aulas
  • Projeto público, implementação privada

A JVM é uma máquina virtual, um computador abstrato que possui seu próprio ISA, sua própria memória, pilha, heap, etc. Ele é executado no sistema operacional host e coloca suas demandas de recursos nele.

A arquitetura do HotSpot JVM 3 é mostrada abaixo -

O mecanismo de execução compreende o coletor de lixo e o compilador JIT. O JVM vem em dois sabores -client and server. Ambos compartilham o mesmo código de tempo de execução, mas diferem no que o JIT é usado. Devemos aprender mais sobre isso mais tarde. O usuário pode controlar qual sabor usar, especificando os sinalizadores JVM -client ou -server . O servidor JVM foi projetado para aplicativos Java de longa execução em servidores.

O JVM vem nas versões 32b e 64b. O usuário pode especificar qual versão usar usando -d32 ou -d64 nos argumentos da VM. A versão 32b só pode endereçar até 4G de memória. Com aplicativos críticos mantendo grandes conjuntos de dados na memória, a versão 64b atende a essa necessidade.

A JVM gerencia o processo de carregamento, vinculação e inicialização de classes e interfaces de maneira dinâmica. Durante o processo de carregamento, oJVM finds the binary representation of a class and creates it.

Durante o processo de vinculação, o loaded classes are combined into the run-time state of the JVM so that they can be executed during the initialization phase. A JVM basicamente usa a tabela de símbolos armazenada no conjunto de constantes de tempo de execução para o processo de vinculação. A inicialização consiste em realmenteexecuting the linked classes.

Tipos de carregadores

o BootStrapo carregador de classes está no topo da hierarquia do carregador de classes. Ele carrega as classes JDK padrão no diretório lib do JRE .

o Extension O carregador de classes está no meio da hierarquia do carregador de classes e é o filho imediato do carregador de classes de bootstrap e carrega as classes no diretório lib \ ext do JRE.

o Applicationo carregador de classes está na parte inferior da hierarquia do carregador de classes e é o filho imediato do carregador de classes do aplicativo. Ele carrega os jars e classes especificadas peloCLASSPATH ENV variável.

Linking

O processo de vinculação consiste nas seguintes três etapas -

Verification- Isso é feito pelo verificador de Bytecode para garantir que os arquivos .class gerados (o Bytecode) são válidos. Caso contrário, um erro é gerado e o processo de vinculação é interrompido.

Preparation - A memória é alocada para todas as variáveis ​​estáticas de uma classe e elas são inicializadas com os valores padrão.

Resolution- Todas as referências de memória simbólica são substituídas pelas referências originais. Para fazer isso, a tabela de símbolos na memória constante de tempo de execução da área de método da classe é usada.

Inicialização

Esta é a fase final do processo de carregamento de classe. Variáveis ​​estáticas recebem valores originais e blocos estáticos são executados.

A especificação JVM define certas áreas de dados de tempo de execução que são necessárias durante a execução do programa. Alguns deles são criados durante a inicialização da JVM. Outros são locais para threads e são criados apenas quando um thread é criado (e destruídos quando o thread é destruído). Eles estão listados abaixo -

Registro de PC (contador de programa)

É local para cada encadeamento e contém o endereço da instrução JVM que o encadeamento está executando atualmente.

Pilha

É local para cada thread e armazena parâmetros, variáveis ​​locais e endereços de retorno durante chamadas de método. Um erro StackOverflow pode ocorrer se um thread exigir mais espaço de pilha do que o permitido. Se a pilha for expansível dinamicamente, ela ainda pode lançar OutOfMemoryError.

Heap

Ele é compartilhado entre todos os threads e contém objetos, metadados de classes, matrizes, etc., que são criados durante o tempo de execução. Ele é criado quando o JVM é iniciado e é destruído quando o JVM é encerrado. Você pode controlar a quantidade de heap que sua JVM exige do sistema operacional usando certos sinalizadores (mais sobre isso mais tarde). É preciso ter cuidado para não exigir muito menos ou muito da memória, pois isso tem implicações importantes no desempenho. Além disso, o GC gerencia este espaço e remove continuamente objetos mortos para liberar o espaço.

Área de Método

Esta área de tempo de execução é comum a todos os encadeamentos e é criada quando a JVM é inicializada. Ele armazena estruturas por classe, como o pool constante (mais sobre isso mais tarde), o código para construtores e métodos, dados de método, etc. O JLS não especifica se esta área precisa ser coletada como lixo e, portanto, as implementações do A JVM pode escolher ignorar o GC. Além disso, isso pode ou não se expandir de acordo com as necessidades do aplicativo. O JLS não impõe nada com relação a isso.

Pool constante de tempo de execução

A JVM mantém uma estrutura de dados por classe / por tipo que atua como a tabela de símbolos (uma de suas muitas funções) enquanto vincula as classes carregadas.

Pilhas de método nativo

Quando um encadeamento invoca um método nativo, ele entra em um novo mundo no qual as estruturas e restrições de segurança da máquina virtual Java não mais impedem sua liberdade. Um método nativo provavelmente pode acessar as áreas de dados de tempo de execução da máquina virtual (depende da interface do método nativo), mas também pode fazer qualquer outra coisa que desejar.

Coleta de lixo

A JVM gerencia todo o ciclo de vida dos objetos em Java. Depois que um objeto é criado, o desenvolvedor não precisa mais se preocupar com isso. Caso o objeto fique morto (isto é, não haja mais referência a ele), ele é ejetado da pilha pelo GC usando um dos muitos algoritmos - GC serial, CMS, G1, etc.

Durante o processo de GC, os objetos são movidos na memória. Conseqüentemente, esses objetos não podem ser usados ​​durante o processo. Todo o aplicativo deve ser interrompido durante o processo. Essas pausas são chamadas de pausas de 'parar o mundo' e são uma grande sobrecarga. Os algoritmos de GC visam principalmente reduzir esse tempo. Discutiremos isso em grande detalhe nos capítulos seguintes.

Graças ao GC, os vazamentos de memória são muito raros em Java, mas podem acontecer. Veremos nos capítulos posteriores como criar um vazamento de memória em Java.

Neste capítulo, aprenderemos sobre o compilador JIT e a diferença entre linguagens compiladas e interpretadas.

Linguagens compiladas vs. interpretadas

Linguagens como C, C ++ e FORTRAN são linguagens compiladas. Seu código é entregue como código binário direcionado à máquina subjacente. Isso significa que o código de alto nível é compilado em código binário de uma vez por um compilador estático escrito especificamente para a arquitetura subjacente. O binário produzido não será executado em nenhuma outra arquitetura.

Por outro lado, linguagens interpretadas como Python e Perl podem rodar em qualquer máquina, desde que tenham um interpretador válido. Ele examina linha por linha o código de alto nível, convertendo-o em código binário.

O código interpretado é normalmente mais lento do que o código compilado. Por exemplo, considere um loop. Um interpretado converterá o código correspondente para cada iteração do loop. Por outro lado, um código compilado tornará a tradução apenas uma. Além disso, como os intérpretes veem apenas uma linha por vez, eles são incapazes de executar qualquer código significativo, como alterar a ordem de execução de instruções como compiladores.

Veremos um exemplo dessa otimização abaixo -

Adding two numbers stored in memory. Como o acesso à memória pode consumir vários ciclos da CPU, um bom compilador emitirá instruções para buscar os dados da memória e executar a adição apenas quando os dados estiverem disponíveis. Não vai esperar e, entretanto, executa outras instruções. Por outro lado, essa otimização não seria possível durante a interpretação, uma vez que o intérprete não está ciente de todo o código em um determinado momento.

Mas então, as linguagens interpretadas podem ser executadas em qualquer máquina que tenha um interpretador válido dessa linguagem.

O Java é compilado ou interpretado?

Java tentou encontrar um meio-termo. Como a JVM fica entre o compilador javac e o hardware subjacente, o compilador javac (ou qualquer outro compilador) compila o código Java no Bytecode, que é compreendido por uma JVM específica da plataforma. A JVM então compila o Bytecode em binário usando a compilação JIT (Just-in-time), conforme o código é executado.

HotSpots

Em um programa típico, há apenas uma pequena seção de código que é executada com frequência e, freqüentemente, é esse código que afeta significativamente o desempenho de todo o aplicativo. Essas seções de código são chamadasHotSpots.

Se alguma seção do código for executada apenas uma vez, compilá-la seria uma perda de esforço e seria mais rápido interpretar o Bytecode. Mas se a seção for uma seção ativa e for executada várias vezes, a JVM a compilará. Por exemplo, se um método for chamado várias vezes, os ciclos extras que seriam necessários para compilar o código seriam compensados ​​pelo binário mais rápido que é gerado.

Além disso, quanto mais a JVM executa um determinado método ou loop, mais informações ela reúne para fazer diversas otimizações para que um binário mais rápido seja gerado.

Vamos considerar o seguinte código -

for(int i = 0 ; I <= 100; i++) {
   System.out.println(obj1.equals(obj2)); //two objects
}

Se este código for interpretado, o interpretador irá deduzir para cada iteração que classes de obj1. Isso ocorre porque cada classe em Java possui um método .equals (), que é estendido da classe Object e pode ser substituído. Portanto, mesmo que obj1 seja uma string para cada iteração, a dedução ainda será feita.

Por outro lado, o que realmente aconteceria é que a JVM notaria que para cada iteração, obj1 é da classe String e, portanto, geraria um código correspondente ao método .equals () da classe String diretamente. Portanto, nenhuma pesquisa será necessária e o código compilado será executado mais rapidamente.

Esse tipo de comportamento só é possível quando a JVM sabe como o código se comporta. Portanto, ele espera antes de compilar certas seções do código.

Abaixo está outro exemplo -

int sum = 7;
for(int i = 0 ; i <= 100; i++) {
   sum += i;
}

Um interpretador, para cada loop, busca o valor de 'soma' da memória, adiciona 'I' a ele e o armazena de volta na memória. O acesso à memória é uma operação cara e normalmente leva vários ciclos de CPU. Como esse código é executado várias vezes, ele é um HotSpot. O JIT irá compilar este código e fazer a seguinte otimização.

Uma cópia local de 'sum' seria armazenada em um registro, específico para um determinado segmento. Todas as operações seriam feitas para o valor no registro e quando o loop fosse concluído, o valor seria escrito de volta na memória.

E se outros threads acessarem a variável também? Como as atualizações estão sendo feitas em uma cópia local da variável por algum outro encadeamento, eles veriam um valor obsoleto. A sincronização de threads é necessária em tais casos. Uma primitiva de sincronização muito básica seria declarar 'soma' como volátil. Agora, antes de acessar uma variável, um encadeamento esvazia seus registradores locais e busca o valor da memória. Após acessá-lo, o valor é imediatamente gravado na memória.

Abaixo estão algumas otimizações gerais feitas pelos compiladores JIT -

  • Método inlining
  • Eliminação de código morto
  • Heurísticas para otimizar sites de chamadas
  • Dobra constante

JVM suporta cinco níveis de compilação -

  • Interpreter
  • C1 com otimização total (sem criação de perfil)
  • C1 com invocação e contadores de borda posterior (perfil de luz)
  • C1 com perfil completo
  • C2 (usa dados de criação de perfil das etapas anteriores)

Use -Xint se quiser desabilitar todos os compiladores JIT e usar apenas o interpretador.

Cliente vs. Servidor JIT

Use -client e -server para ativar os respectivos modos.

O compilador cliente (C1) começa a compilar o código antes do compilador servidor (C2). Portanto, no momento em que C2 começou a compilação, C1 já teria compilado seções de código.

Mas, enquanto espera, o C2 define o perfil do código para saber mais sobre ele do que o C1. Conseqüentemente, o tempo que ele espera se compensado pelas otimizações pode ser usado para gerar um binário muito mais rápido. Do ponto de vista do usuário, a compensação é entre o tempo de inicialização do programa e o tempo que leva para ser executado. Se o tempo de inicialização for o premium, então C1 deve ser usado. Se o aplicativo deve ser executado por um longo tempo (típico de aplicativos implantados em servidores), é melhor usar C2, pois ele gera um código muito mais rápido que compensa muito qualquer tempo de inicialização extra.

Para programas como IDEs (NetBeans, Eclipse) e outros programas GUI, o tempo de inicialização é crítico. O NetBeans pode demorar um minuto ou mais para iniciar. Centenas de classes são compiladas quando programas como o NetBeans são iniciados. Nesses casos, o compilador C1 é a melhor escolha.

Observe que existem duas versões de C1 - 32b and 64b. C2 vem apenas em64b.

Compilação em camadas

Em versões anteriores do Java, o usuário poderia ter selecionado uma das seguintes opções -

  • Intérprete (-Xint)
  • C1 (-cliente)
  • C2 (-server)

Ele veio em Java 7. Ele usa o compilador C1 para inicializar e, conforme o código fica mais quente, muda para o C2. Ele pode ser ativado com as seguintes opções de JVM: -XX: + TieredCompilation. o valor padrão éset to false in Java 7, and to true in Java 8.

Das cinco camadas de compilação, a compilação em camadas usa 1 -> 4 -> 5.

Em uma máquina 32b, apenas a versão 32b do JVM pode ser instalada. Em uma máquina 64b, o usuário pode escolher entre a versão 32b e a 64b. Mas há certas nuances nisso que podem afetar o desempenho de nossos aplicativos Java.

Se o aplicativo Java usa menos de 4G de memória, devemos usar o JVM 32b mesmo em máquinas 64b. Isso ocorre porque as referências de memória, neste caso, seriam apenas 32b e manipulá-las seria menos caro do que manipular endereços 64b. Nesse caso, o JVM 64b teria um desempenho pior, mesmo se estivermos usando OOPS (ponteiros de objeto comuns). Usando OOPS, a JVM pode usar endereços de 32b na JVM de 64b. No entanto, manipulá-los seria mais lento do que as referências reais de 32b, pois as referências nativas subjacentes ainda seriam de 64b.

Se nosso aplicativo vai consumir mais do que 4G de memória, teremos que usar a versão 64b, pois as referências de 32b podem endereçar no máximo 4G de memória. Podemos ter ambas as versões instaladas na mesma máquina e podemos alternar entre elas usando a variável PATH.

Neste capítulo, aprenderemos sobre otimizações JIT.

Método Inlining

Nessa técnica de otimização, o compilador decide substituir suas chamadas de função pelo corpo da função. Abaixo está um exemplo para o mesmo -

int sum3;

static int add(int a, int b) {
   return a + b;
}

public static void main(String…args) {
   sum3 = add(5,7) + add(4,2);
}

//after method inlining
public static void main(String…args) {
   sum3 = 5+ 7 + 4 + 2;
}

Usando essa técnica, o compilador salva a máquina da sobrecarga de fazer qualquer chamada de função (ela requer empurrar e colocar parâmetros na pilha). Assim, o código gerado é executado mais rápido.

O método inlining só pode ser feito para funções não virtuais (funções que não são substituídas). Considere o que aconteceria se o método 'add' fosse substituído em uma subclasse e o tipo de objeto que contém o método não fosse conhecido até o tempo de execução. Nesse caso, o compilador não saberia qual método embutir. Mas se o método foi marcado como 'final', então o compilador saberia facilmente que ele pode ser embutido porque não pode ser substituído por nenhuma subclasse. Observe que não é de todo garantido que um método final seria sempre in-lined.

Eliminação de código inatingível e morto

Código inacessível é o código que não pode ser alcançado por nenhum fluxo de execução possível. Devemos considerar o seguinte exemplo -

void foo() {
   if (a) return;
   else return;
   foobar(a,b); //unreachable code, compile time error
}

O código morto também é um código inacessível, mas o compilador cuspiu um erro neste caso. Em vez disso, apenas recebemos um aviso. Cada bloco de código, como construtores, funções, try, catch, if, while, etc., tem suas próprias regras para código inacessível definidas no JLS (Java Language Specification).

Dobramento Constante

Para entender o conceito de dobra constante, veja o exemplo abaixo.

final int num = 5;
int b = num * 6; //compile-time constant, num never changes
//compiler would assign b a value of 30.

O ciclo de vida de um objeto Java é gerenciado pela JVM. Depois que um objeto é criado pelo programador, não precisamos nos preocupar com o resto do seu ciclo de vida. A JVM encontrará automaticamente os objetos que não estão mais em uso e recuperará sua memória do heap.

A coleta de lixo é uma operação importante que a JVM faz e ajustá-la para nossas necessidades pode dar um aumento massivo de desempenho para nosso aplicativo. Há uma variedade de algoritmos de coleta de lixo fornecidos por JVMs modernos. Precisamos estar cientes das necessidades de nosso aplicativo para decidir qual algoritmo usar.

Você não pode desalocar um objeto programaticamente em Java, como você pode fazer em linguagens não-GC como C e C ++. Portanto, você não pode ter referências pendentes em Java. No entanto, você pode ter referências nulas (referências que se referem a uma área da memória onde a JVM nunca armazenará objetos). Sempre que uma referência nula é usada, a JVM lança uma NullPointerException.

Observe que, embora seja raro encontrar vazamentos de memória em programas Java graças ao GC, eles acontecem. Criaremos um vazamento de memória no final deste capítulo.

Os seguintes GCs são usados ​​em JVMs modernos

  • Coletor serial
  • Coletor de rendimento
  • Coletor CMS
  • Coletor G1

Cada um dos algoritmos acima faz a mesma tarefa - localizar objetos que não estão mais em uso e recuperar a memória que ocupam no heap. Uma das abordagens ingênuas para isso seria contar o número de referências que cada objeto tem e liberá-lo assim que o número de referências vire 0 (isso também é conhecido como contagem de referência). Por que isso é ingênuo? Considere uma lista ligada circular. Cada um de seus nós terá uma referência a ele, mas o objeto inteiro não está sendo referenciado de qualquer lugar e deve ser liberado, idealmente.

A JVM não apenas libera a memória, mas também une pequenos blocos de memória em outros maiores. Isso é feito para evitar a fragmentação da memória.

Em uma nota simples, um algoritmo de GC típico faz as seguintes atividades -

  • Encontrando objetos não usados
  • Libertando a memória que ocupam no heap
  • Coalescendo os fragmentos

O GC deve interromper os encadeamentos do aplicativo enquanto ele está em execução. Isso ocorre porque ele move os objetos ao redor quando é executado e, portanto, esses objetos não podem ser usados. Essas paradas são chamadas de 'pausas stop-the-world e minimizar a frequência e a duração dessas pausas é o que buscamos ao sintonizar nosso GC.

Coalescência de memória

Uma demonstração simples de coalescência de memória é mostrada abaixo

A parte sombreada são objetos que precisam ser liberados. Mesmo depois de todo o espaço ser recuperado, só podemos alocar um objeto de tamanho máximo = 75Kb. Isso ocorre mesmo depois de termos 200Kb de espaço livre, conforme mostrado abaixo

A maioria das JVMs divide o heap em três gerações - the young generation (YG), the old generation (OG) and permanent generation (also called tenured generation). Quais são as razões por trás desse pensamento?

Estudos empíricos mostraram que a maioria dos objetos criados têm uma vida útil muito curta -

Fonte

https://www.oracle.com

Como você pode ver, à medida que mais e mais objetos são alocados com o tempo, o número de bytes sobreviventes se torna menor (em geral). Objetos Java têm alta taxa de mortalidade.

Veremos um exemplo simples. A classe String em Java é imutável. Isso significa que toda vez que você precisa alterar o conteúdo de um objeto String, deve criar um novo objeto. Vamos supor que você faça alterações na string 1000 vezes em um loop, conforme mostrado no código a seguir -

String str = “G11 GC”;

for(int i = 0 ; i < 1000; i++) {
   str = str + String.valueOf(i);
}

Em cada loop, criamos um novo objeto string, e a string criada durante a iteração anterior torna-se inútil (ou seja, não é referenciada por nenhuma referência). O tempo de vida desse objeto foi apenas uma iteração - eles serão coletados pelo GC em nenhum momento. Esses objetos de vida curta são mantidos na área de geração jovem do heap. O processo de coleta de objetos da geração mais jovem é chamado de coleta de lixo secundária e sempre causa uma pausa para 'parar o mundo'.

À medida que a geração jovem vai ficando cheia, o GC faz uma pequena coleta de lixo. Objetos mortos são descartados e objetos vivos são movidos para a geração anterior. Os threads do aplicativo param durante este processo.

Aqui, podemos ver as vantagens que esse design de geração oferece. A geração jovem é apenas uma pequena parte da pilha e fica cheia rapidamente. Mas o processamento leva muito menos tempo do que o tempo necessário para processar todo o heap. Portanto, as pausas 'pare o mundo' neste caso são muito mais curtas, embora mais frequentes. Devemos sempre ter como objetivo pausas mais curtas em vez de pausas mais longas, embora possam ser mais frequentes. Discutiremos isso em detalhes em seções posteriores deste tutorial.

A geração jovem está dividida em dois espaços - eden and survivor space. Os objetos que sobreviveram durante a coleta do éden são movidos para o espaço do sobrevivente, e aqueles que sobrevivem no espaço do sobrevivente são movidos para a geração anterior. A geração jovem é compactada enquanto é coletada.

Conforme os objetos são movidos para a geração anterior, eles eventualmente se enchem e precisam ser coletados e compactados. Diferentes algoritmos têm diferentes abordagens para isso. Alguns deles param os encadeamentos do aplicativo (o que leva a uma longa pausa 'stop-the-world', já que a geração antiga é muito grande em comparação com a geração mais jovem), enquanto alguns deles o fazem simultaneamente, enquanto os encadeamentos do aplicativo continuam em execução. Este processo é denominado GC completo. Dois desses colecionadores sãoCMS and G1.

Vamos agora analisar esses algoritmos em detalhes.

Serial GC

é o GC padrão em máquinas de classe cliente (máquinas de processador único ou JVM 32b, Windows). Normalmente, os GCs são altamente multithread, mas o GC serial não. Ele tem um único thread para processar o heap e interromperá os threads do aplicativo sempre que estiver fazendo um GC secundário ou um GC principal. Podemos comandar a JVM para usar este GC, especificando o sinalizador:-XX:+UseSerialGC. Se quisermos que ele use algum algoritmo diferente, especifique o nome do algoritmo. Observe que a geração anterior é totalmente compactada durante um GC principal.

Taxa de transferência GC

Este GC é padrão em JVMs de 64b e máquinas multi-CPU. Ao contrário do GC serial, ele usa vários threads para processar a nova e a velha geração. Por causa disso, o GC também é chamado deparallel collector. Podemos comandar nossa JVM para usar este coletor usando o sinalizador:-XX:+UseParallelOldGC ou -XX:+UseParallelGC(para JDK 8 em diante). Os encadeamentos do aplicativo são interrompidos enquanto ele faz uma coleta de lixo principal ou secundária. Como o coletor serial, ele compacta totalmente a geração jovem durante um grande GC.

O throughput GC coleta o YG e o OG. Quando o eden está cheio, o coletor ejeta objetos vivos dele para o OG ou um dos espaços sobreviventes (SS0 e SS1 no diagrama abaixo). Os objetos mortos são descartados para liberar o espaço que ocupavam.

Antes do GC de YG

Depois de GC de YG

Durante um GC completo, o coletor de rendimento esvazia todo o YG, SS0 e SS1. Após a operação, o OG contém apenas objetos vivos. Devemos observar que ambos os coletores acima param os threads do aplicativo durante o processamento do heap. Isso significa longas pausas de 'parar o mundo' durante um grande GC. Os próximos dois algoritmos visam eliminá-los, ao custo de mais recursos de hardware -

Coletor CMS

Significa 'varredura de marcação simultânea'. Sua função é usar algumas threads de fundo para varrer a geração anterior periodicamente e se livrar de objetos mortos. Mas durante um GC menor, os threads do aplicativo são interrompidos. No entanto, as pausas são bastante pequenas. Isso torna o CMS um coletor de baixa pausa.

Este coletor precisa de tempo de CPU adicional para varrer o heap enquanto executa os threads do aplicativo. Além disso, os threads de fundo apenas coletam o heap e não executam nenhuma compactação. Eles podem fazer com que a pilha se torne fragmentada. À medida que isso continua, após um certo ponto de tempo, o CMS irá parar todos os threads do aplicativo e compactar o heap usando um único thread. Use os seguintes argumentos JVM para informar a JVM para usar o coletor CMS -

“XX:+UseConcMarkSweepGC -XX:+UseParNewGC” como argumentos JVM para instruí-lo a usar o coletor CMS.

Antes do GC

Depois de GC

Observe que a coleta está sendo feita simultaneamente.

G1 GC

Esse algoritmo funciona dividindo o heap em várias regiões. Como o coletor CMS, ele interrompe os encadeamentos do aplicativo enquanto faz um GC menor e usa encadeamentos em segundo plano para processar a geração anterior, enquanto mantém os encadeamentos do aplicativo em andamento. Como dividiu a geração anterior em regiões, ele continua compactando-as enquanto move objetos de uma região para outra. Portanto, a fragmentação é mínima. Você pode usar a bandeira:XX:+UseG1GCpara dizer ao seu JVM para usar este algoritmo. Como o CMS, ele também precisa de mais tempo de CPU para processar o heap e executar os threads do aplicativo simultaneamente.

Este algoritmo foi projetado para processar heaps maiores (> 4G), que são divididos em várias regiões diferentes. Algumas dessas regiões compreendem a geração jovem e o restante compreende a geração mais velha. O YG é limpo usando tradicionalmente - todos os encadeamentos do aplicativo são interrompidos e todos os objetos que ainda estão vivos para a geração anterior ou o espaço sobrevivente.

Observe que todos os algoritmos de GC dividiram o heap em YG e OG e usam um STWP para limpar o YG. Esse processo geralmente é muito rápido.

No último capítulo, aprendemos sobre vários Gcs Geracionais. Neste capítulo, discutiremos sobre como ajustar o GC.

Tamanho da pilha

O tamanho do heap é um fator importante no desempenho de nossos aplicativos Java. Se for muito pequeno, será preenchido com frequência e, como resultado, deverá ser coletado com frequência pelo CG. Por outro lado, se apenas aumentarmos o tamanho do heap, embora precise ser coletado com menos frequência, a duração das pausas aumentará.

Além disso, aumentar o tamanho do heap tem uma grande penalidade no sistema operacional subjacente. Usando paginação, o sistema operacional faz com que nossos programas de aplicativos vejam muito mais memória do que realmente está disponível. O sistema operacional gerencia isso usando algum espaço de troca no disco, copiando partes inativas dos programas para ele. Quando essas partes são necessárias, o sistema operacional as copia de volta do disco para a memória.

Suponhamos que uma máquina tenha 8 G de memória e a JVM veja 16 G de memória virtual, a JVM não saberia que de fato há apenas 8 G disponíveis no sistema. Ele apenas solicitará 16G do sistema operacional e, assim que conseguir essa memória, continuará a usá-la. O sistema operacional terá que trocar muitos dados para dentro e para fora, e isso é uma grande penalidade de desempenho do sistema.

E então vêm as pausas que ocorreriam durante o GC cheio dessa memória virtual. Visto que o GC atuará em todo o heap para coleta e compactação, ele terá que esperar muito para que a memória virtual seja trocada do disco. No caso de um coletor simultâneo, as threads de fundo terão que esperar muito para que os dados sejam copiados do espaço de troca para a memória.

Portanto, aqui vem a questão de como devemos decidir sobre o tamanho de heap ideal. A primeira regra é nunca solicitar ao SO mais memória do que a realmente presente. Isso evitaria totalmente o problema de troca frequente. Se a máquina tiver várias JVMs instaladas e em execução, a solicitação total de memória por todas elas combinadas será menor do que a RAM real presente no sistema.

Você pode controlar o tamanho da solicitação de memória pela JVM usando dois sinalizadores -

  • -XmsN - Controla a memória inicial solicitada.

  • -XmxN - Controla a memória máxima que pode ser solicitada.

Os valores padrão de ambos os sinalizadores dependem do sistema operacional subjacente. Por exemplo, para JVMs de 64b em execução no MacOS, -XmsN = 64M e -XmxN = mínimo de 1G ou 1/4 da memória física total.

Observe que a JVM pode se ajustar entre os dois valores automaticamente. Por exemplo, se notar que muito GC está acontecendo, ele continuará aumentando o tamanho da memória enquanto estiver abaixo de -XmxN e os objetivos de desempenho desejados forem atendidos.

Se você souber exatamente de quanta memória seu aplicativo precisa, poderá definir -XmsN = -XmxN. Nesse caso, a JVM não precisa descobrir um valor “ideal” do heap e, portanto, o processo de GC se torna um pouco mais eficiente.

Tamanhos de geração

Você pode decidir quanto do heap deseja alocar para o YG e quanto deseja alocar para o OG. Ambos os valores afetam o desempenho de nossos aplicativos da seguinte maneira.

Se o tamanho do YG for muito grande, ele será coletado com menos frequência. Isso resultaria em um menor número de objetos sendo promovidos ao OG. Por outro lado, se você aumentar muito o tamanho do OG, coletá-lo e compactá-lo levará muito tempo e levará a longas pausas STW. Assim, o usuário deve encontrar um equilíbrio entre esses dois valores.

Abaixo estão as sinalizações que você pode usar para definir esses valores -

  • -XX:NewRatio=N: Razão do YG para o OG (valor padrão = 2)

  • -XX:NewSize=N: Tamanho inicial de YG

  • -XX:MaxNewSize=N: Tamanho máximo de YG

  • -XmnN: Defina NewSize e MaxNewSize com o mesmo valor usando este sinalizador

O tamanho inicial do YG é determinado pelo valor de NewRatio pela fórmula fornecida -

(total heap size) / (newRatio + 1)

Como o valor inicial de newRatio é 2, a fórmula acima fornece o valor inicial de YG como 1/3 do tamanho total do heap. Você sempre pode substituir esse valor especificando explicitamente o tamanho do YG usando o sinalizador NewSize. Este sinalizador não tem nenhum valor padrão e, se não for definido explicitamente, o tamanho do YG continuará sendo calculado usando a fórmula acima.

Permagen e Metaspace

O permagen e o metaspace são áreas de heap onde a JVM mantém os metadados das classes. O espaço é chamado de 'permagen' em Java 7, e em Java 8, é chamado de 'metaspace'. Essas informações são usadas pelo compilador e pelo tempo de execução.

Você pode controlar o tamanho do permagen usando os seguintes sinalizadores: -XX: PermSize=N e -XX:MaxPermSize=N. O tamanho do Metaspace pode ser controlado usando:-XX:Metaspace- Size=N e -XX:MaxMetaspaceSize=N.

Existem algumas diferenças em como o permagen e o metaspace são gerenciados quando os valores dos sinalizadores não são definidos. Por padrão, ambos têm um tamanho inicial padrão. Mas enquanto o metaspace pode ocupar tanto do heap quanto for necessário, o permagen não pode ocupar mais do que os valores iniciais padrão. Por exemplo, o JVM 64b tem 82 MB de espaço de heap como tamanho máximo de permagen.

Observe que, uma vez que o metaspace pode ocupar quantidades ilimitadas de memória, a menos que não seja especificado, pode haver um erro de falta de memória. Um GC completo ocorre sempre que essas regiões são redimensionadas. Portanto, durante a inicialização, se houver muitas classes sendo carregadas, o metaspace pode continuar redimensionando, resultando em um GC completo a cada vez. Portanto, leva muito tempo para grandes aplicativos inicializarem, caso o tamanho do metaspace inicial seja muito baixo. É uma boa ideia aumentar o tamanho inicial, pois isso reduz o tempo de inicialização.

Embora o permagen e o metaspace contenham os metadados da classe, eles não são permanentes e o espaço é reclamado pelo GC, como no caso dos objetos. Isso normalmente ocorre no caso de aplicativos de servidor. Sempre que você faz uma nova implantação no servidor, os metadados antigos precisam ser limpos, pois os novos carregadores de classes agora precisam de espaço. Este espaço é liberado pelo GC.

Discutiremos sobre o conceito de vazamento de memória em Java neste capítulo.

O código a seguir cria um vazamento de memória em Java -

void queryDB() {
   try{
      Connection conn = ConnectionFactory.getConnection();
      PreparedStatement ps = conn.preparedStatement("query"); // executes a
      SQL
      ResultSet rs = ps.executeQuery();
      while(rs.hasNext()) {
         //process the record
      }
   } catch(SQLException sqlEx) {
      //print stack trace
   }
}

No código acima, quando o método termina, não fechamos o objeto de conexão. Assim, a conexão física permanece aberta antes que o GC seja acionado e veja o objeto de conexão como inacessível. Agora, ele chamará o método final no objeto de conexão, no entanto, ele pode não ser implementado. Portanto, o objeto não será coletado como lixo neste ciclo.

O mesmo acontecerá no próximo até que o servidor remoto veja que a conexão está aberta há muito tempo e a encerre à força. Assim, um objeto sem referência permanece na memória por muito tempo, o que cria um vazamento.