No último post falamos sobre o funcionamento do JIT Compiler. Esse processo é necessário para uma melhor interpretação do bytecode para código nativo. O que não falamos é que quando o código é compilado para grau 4 (máxima otimização), o binário pode ser "guardado" na memória, em um espaço denominado code cache.
A ideia deste post é mostrar algumas características do code cache e como realizar tunnings para melhorar a performance da aplicação.
Recapitulando
Quando o código abaixo foi executado, foi possível extrair algumas informações da compilação usando a flag -XX:+PrintCompilation
import java.util.*;
public class Principal {
public static void main(String[] args) {
Integer numeroMaximo = Integer.parseInt(args[0]);
Principal principal = new Principal();
principal.guardarNumerosPares(numeroMaximo);
}
private void guardarNumerosPares(Integer numeroMaximo){
int numero = 0;
List<Integer> numerosPares = new ArrayList<>();
while(numero <= numeroMaximo) {
if(validarSeEPar(numero)) numerosPares.add(numero);
numero++;
}
}
private Boolean validarSeEPar(Integer numero) {
if (numero % 2 == 0) return true;
return false;
}
}
O comando usado foi o java -XX:+PrintCompilation Principal 50000
e o resultado foi o seguinte:
//Resto das informações omitidas para facilitar a visualização
68 54 2 Principal::validarSeEPar (19 bytes)
68 52 2 java.lang.Integer::<init> (10 bytes) made not entrant
69 60 4 java.lang.Integer::valueOf (32 bytes)
69 50 1 java.lang.Boolean::booleanValue (5 bytes)
70 55 2 java.lang.Boolean::valueOf (14 bytes)
70 53 2 java.lang.Integer::valueOf (32 bytes) made not entrant
71 61 4 Principal::validarSeEPar (19 bytes)
73 56 2 java.util.ArrayList::add (25 bytes)
75 54 2 Principal::validarSeEPar (19 bytes) made not entrant
76 57 2 java.util.ArrayList::add (23 bytes)
76 41 3 java.util.HashMap$Node::<init> (26 bytes)
77 62 4 java.util.ArrayList::add (25 bytes)
77 14 1 java.lang.module.ModuleReference::descriptor (5 bytes)
No trecho acima, percebemos alguns métodos com grau 4, ou seja, o grau máximo da otimização, porém o código nativo não esta "guardado" no code cache ainda. Como é que sabemos? Bom, o método que tem o binário salvo no code cache traz consigo uma informação adicional na terceira coluna, que é o carácter "%". Para garantir esta visualização, o código será executado com um número maior do que os exemplos anteriores. Usando o comando java -XX:+PrintCompilation Principal 10000000
obtemos o resultado esperado:
//Resto das informações omitidas para facilitar a visualização
277 286 ! 4 java.nio.DirectByteBuffer::get (28 bytes)
277 179 % 4 Principal::guardarNumerosPares @ 10 (50 bytes)
279 202 ! 3 java.nio.DirectByteBuffer::get (28 bytes) made not entrant
279 166 4 java.lang.Integer::valueOf (32 bytes)
305 322 3 java.lang.ClassLoader::loadClass (7 bytes)
305 323 3 jdk.internal.loader.BuiltinClassLoader::loadClass (22 bytes)
Podemos observar que o binário do método guardarNUmeros
foi salvo no code cache e agora faz todo sentido a JVM não precisar compilá-lo novamente, pois sempre que o mesmo for chamado, será usado o binário salvo na memória.
Mais sobre o code cache
Agora que vimos em qual momento o binário é salvo no code cache, precisamos pensar em alguns outros cenários. Ao longo da vida de uma aplicação, muitos métodos serão compilados e consequentemente serão guardados no code cache. E se esse espaço "acabar"? Qual será o comportamento? Esse cenário fará com que o método já salvo no code cache seja retirado para que o novo método possa ser salvo. Quando o método retirado é chamado novamente, irá ocorrer o mesmo cenário descrito anteriormente e ele será salvo no lugar de outro binário, prejudicando, e muito, a performance da aplicação. Pensando nesses cenários, que serão comuns à muitas aplicações, é possível tunnar o code cache, através do uso das flags.
Tunnando o code cache
Quando o cenário que descrevemos anteriormente ocorrer, a JVM enviará o seguinte alerta
VM warning: CodeCache is full. Compiler has been disabled.
Isso não significa que a aplicação ira parar de executar, apenas que não estará rodando de forma otimizada. Para verificar o tamanho do code cache, podemos utilizar a flag -XX:+PrintCodeCache
. Executando o comando java -XX:+PrintCodeCache Principal 5000
obtemos o resultado abaixo:
CodeHeap 'non-profiled nmethods': size=120032Kb used=38Kb max_used=38Kb free=119993Kb
bounds [0x00007f376befb000, 0x00007f376c16b000, 0x00007f3773433000]
No exemplo anterior, a aplicação não correria o risco do cenário citado antes, pois há muito espaço livre. Porém, pode-se pensar na situação em que o espaço livre já não correspondia a um número alto de disponibilidade, sendo necessário aumentar o tamanho do code cache
O espaço disponível para aumentar o code cache dependerá da versão da JVM que está sendo usada. Se a versão for 7 ou anterior, o máximo disponível será 32 mb para a VM 32 bits e 48 mb para a VM 64 bits. Para a JVM 8 ou posterior, o máximo disponível é de 240 mb.
Para informar o espaço que será reservado para o code cache, usamos a flag -XX:ReservedCodeCacheSize=XXmb
. Para colocar 240 MB, basta que o comando a seguir seja executado: java -XX:ReservedCodeCacheSize=240000k -XX:+PrintCodeCache Principal 5000
e o resultado será:
CodeCache: size=240000Kb used=1316Kb max_used=1316Kb free=238683Kb
bounds [0x00007f6ba49d3000, 0x00007f6ba4c43000, 0x00007f6bb3433000]
total_blobs=456 nmethods=173 adapters=195
compilation: enabled
stopped_count=0, restarted_count=0
full_count=0
Podemos perceber que foram feitas as mudanças no espaço disponível para o code cache, e a aplicação que estaria sofrendo com performance seria executada de maneira mais otimizada após o tunning.
Concluindo
O code cache é um forte aliado do JIT Compiler, complementando o trabalho de compilação em tempo de execução. A ideia do post foi mostrar algumas características deste espaço de memória reservado para guardar os binários e uma maneira de otimizá-lo. Para dúvidas, críticas e/ou sugestões, estou sempre à disposição. Até a próxima.
Top comments (2)
Muito bom o artigo
Obrigado, Aleatório!!