Java Virtual Machine
Não é segredo para ninguém que A Máquina Virtual Java, ou JVM (Java Virtual Machine), é uma parte fundamental do ecossistema da linguagem de programação Java. Ela foi revolucionária em sua época, trazendo inovações que transformaram a forma como o software é desenvolvido, executado e distribuído. A JVM é uma máquina virtual que permite que os programas Java sejam executados em diferentes plataformas, proporcionando uma abstração eficiente e independente do sistema operacional subjacente.
Não se limitando apenas a esse escopo revolucionário, mas ela também trouxe revoluções no que diz respeito a performance de código, tendo em vista que a abstração por trás da JVM carregou consigo um conjunto de ferramentas para impressionar nesse contexto: JIT, Garbage Collection, técnicas de inling, otimização de laços, perfil de desempenho... Hoje falaremos sobre sua incrível capacidade de traçar um perfil durante a execução de um código, analisar trechos de códigos que são frequentemente executados durante o tempo de execução e transformá-los em código nativo de máquina.
Intrinsecamente multithreading
É crucial entender que a JVM opera intrinsecamente como se fosse um sistema multithread. Isso significa que tanto a interpretação do bytecode quanto a compilação para código nativo ocorrem em threads separadas, de forma simultânea, no contexto da utilização do compilador “Just in Time” (JIT).
A JVM HotSpot, que é a JVM padrão da Oracle/OpenJDK, é majoritariamente implementada em C++ (com partes em Assembly para otimizações específicas). O HotSpot usa um interpretador de bytecode e dois compiladores JIT principais (C1 e C2), que compilam bytecodes em código nativo para acelerar a execução. Esse mecanismo descrito acima é chamado de Tiered Compilation.
Abaixo há um trecho retirado do próprio código-fonte do Java que mostra essa divisão de threads interna do mecanismo de compilador, apesar dele não mostrar explicitamente a geração e criação.
1 CompileTaskWrapper::CompileTaskWrapper(CompileTask* task) {
2 CompilerThread* thread = CompilerThread::current();
3 thread->set_task(task);
4 CompileLog* log = thread->log();
5 if (log != nullptr && !task->is_unloaded()) task->log_task_start(log);
6 }
7
8 CompileTaskWrapper::~CompileTaskWrapper() {
9 CompilerThread* thread = CompilerThread::current();
10 CompileTask* task = thread->task();
11 CompileLog* log = thread->log();
12 if (log != nullptr && !task->is_unloaded()) task->log_task_done(log);
13 thread->set_task(nullptr);
14 thread->set_env(nullptr);
15 if (task->is_blocking()) {
16 bool free_task = false;
17 {
18 MutexLocker notifier(thread, CompileTaskWait_lock);
19 task->mark_complete();
20 #if INCLUDE_JVMCI
21 if (CompileBroker::compiler(task->comp_level())->is_jvmci()) {
22 if (!task->has_waiter()) {
23 free_task = true;
24 }
25 task->set_blocking_jvmci_compile_state(nullptr);
26 }
27 #endif
28 if (!free_task) {
29 CompileTaskWait_lock->notify_all();
30 }
31 }
32 if (free_task) {
33 delete task;
34 }
35 } else {
36 task->mark_complete();
37 delete task;
38 }
39 }
Em resumo, enquanto há a interpretação do próprio código, o compilador separa os "métodos críticos" e joga para uma thread específica que será responsável por transformá-lo em código nativo de máquina, otimizando-o. O pool de CompilerThreads é mantido e gerenciado pelo CompileBroker e outros componentes do runtime, que distribuem as CompileTasks para essas threads de compilação internamente.
Você pode encontrar o resto no repositório oficial da OpenJDK.
Como podemos visualizar, de forma analítica, esse processo, na prática? Nos vamos utilizar uma flag da máquina virtual denominada de: -XX:+PrintCompilation
adicionando ela como argumento no comando de execução java.
-XX:+PrintCompilation
Antes de estudarmos profundamente o que essa análise nos traz, vamos entender a estrutura dessa flag e o que ela significa conceitualmente:
O termo -XX
significa que é uma opção avançada, o sinal +
ou -
basicamente está apontando se queremos que essa opção seja habilitada ou não, e por fim, a última parte é o nome da opção que passamos de argumento para a JVM. Resumidamente, estamos habilitando uma opção avançada chamada de PrintCompilation. Importante mencionar e ressaltar que as flags devem obrigatoriamente respeitar letras maiúsculas e minúsculas, com cada letra inicial sendo maiúscula no que diz respeito ao nome da opção.
Para exemplificar a utilização dessa flag, vamos utilizar o seguinte código, não otimizado, que performa a sequência de Fibonacci até um número inteiro N:
1 public static long fibonacci(int n) {
2 if (n == 0) return 0;
3 else if (n == 1) return 1;
4 else return fibonacci(n - 1) + fibonacci(n - 2);
5 }
Na no método principal:
1 public static void main(String[] args) {
2 try {
3 int n = Integer.parseInt(args[0]);
4 Fibonacci fibonacci = new Fibonacci();
5 fibonacci.generateFibonacciSequence(n);
6 } catch (NumberFormatException e) {
7 System.out.println("Invalid Integer!");
8 }
9 }
java -XX:+PrintCompilation --class-path <caminho_do_arquivo_classes_ou_jar> seu.package.Main 10
Assim que executarmos esse comando, o programa será interpretado e na saída teremos o seguinte:
A primeira coluna representa o número de milissegundos desde que a máquina virtual foi iniciada. Por exemplo, na primeira linha da imagem, o primeiro valor da primeira coluna é: 558. Isso quer dizer que se passaram 558 ms desde o início da JVM quando aquela instrução foi invocada.
A segunda coluna representa a ordem que o método/instrução/bloco de código foi executado sequencialmente, isto é, em ordem. Na nossa imagem, na primeira linha, aquela instrução foi executada na posição 193, também conhecido como um identificador interno de tarefas. Observação: o fato de algumas partes não aparecerem em ordem seguidas uma das outras é consequência direta do fato que diferentes blocos de código demoram mais para compilar do que os outros.
Tempo desde a inicialização da JVM | Ordem de execução | Nível de otimização | Referência e tamanho da instrução |
---|---|---|---|
558 | 193 | 3 | java.lang.invoke.MemberName::initResolved (53 bytes) |
559 | 194 | n 0 | java.lang.invoke.MethodHandle::invokeBasic(LLLLL)L (native) |
559 | 192 | 1 | java.lang.invoke.MethodType$ConcurrentWeakInternSet$WeakEntry::hashCode (5 bytes) |
Note que há um espaço entre a segunda coluna e a próxima, e às vezes ele é preenchido por um símbolo. Eles têm significados:
O símbolo % faz referência a uma técnica chamada de on-stack replacement (OSR). Vamos lembrar que o JIT essencialmente falando é um processo assíncrono, então quando um escopo específico do nosso código é se torna uma opção viável para ser compilado em código nativo de máquina, devido a frequência de sua utilização ou relacionados, esse fragmento de escopo é colocado numa fila.
Ao invés de esperar a compilação, a máquina virtual vai continuar interpretando o código em sequência, mas na próxima vez que esse escopo que estamos lidando for chamado, a JVM executará ele em sua versão compilada nativamente.
Claro que aqui assumimos que a compilação foi finalizada na thread que sustenta essa fila mencionada. Quando isso acontece, o código estará sendo executado numa parte da memória especial denominada de code cache. Esse símbolo também garante que o escopo está rodando do jeito mais otimizado possível.
O símbolo n indica que a JVM criou um código compilado para tornar mais fácil a chamada a um método que é implementado em linguagem nativa. O s significa que é um método synchronized e ! representa que há tratamento de exceções no escopo referenciado.
Note que na próxima coluna (nível de otimização), o elemento está num padrão que varia de 0 até 4. Basicamente, são os níveis de compilação. No nível 0 o bloco de código não foi compilado a nível de máquina, ele apenas foi interpretado pela máquina virtual. Nos níveis 1,2, e 3 o código foi compilado pelo compilador C1 da máquina virtual. O nível 1 classicamente é o mais otimizado. No nível 4, o trecho mencionado foi compilado pelo C2 e agora ele está na versão mais alta possível de compilação, sendo ele adicionado no code cache.
1 nmethod* InterpreterRuntime::frequency_counter_overflow(JavaThread* current, address branch_bcp) {
2 MACOS_AARCH64_ONLY(ThreadWXEnable wx(WXWrite, current));
3 nmethod* nm = frequency_counter_overflow_inner(current, branch_bcp);
4 assert(branch_bcp != nullptr || nm == nullptr, "always returns null for non OSR requests");
5 if (branch_bcp != nullptr && nm != nullptr) {
6 LastFrameAccessor last_frame(current);
7 Method* method = last_frame.method();
8 int bci = method->bci_from(last_frame.bcp());
9 nm = method->lookup_osr_nmethod_for(bci, CompLevel_none, false);
10 BarrierSetNMethod* bs_nm = BarrierSet::barrier_set()->barrier_set_nmethod();
11 if (nm != nullptr) {
12 if (!bs_nm->nmethod_osr_entry_barrier(nm)) {
13 nm = nullptr;
14 }
15 }
16 }
17 if (nm != nullptr && current->is_interp_only_mode()) {
18 nm = nullptr;
19 }
Essa função é chamada quando o contador de execução de um método interpretado ultrapassa um limite (ou seja, o método está "quente" e merece ser compilado), o parâmetro branch_bcp
indica o bytecode program counter onde o OSR pode ser iniciado. Esse trecho do código-fonte da Java Virtual Machine é a transição do nível de otimização 3 para o 4, exatamente o precedente da transição.
Curiosamente, se o runtime está em modo interpreted only (sem JIT), essas instruções são ignoradas. Isso pode ser observado no trecho:
1 if (nm != nullptr && current->is_interp_only_mode()) {
2 nm = nullptr;
3 }
A última coluna da imagem é a referência a instrução da linha, e, por fim, mas não menos importante, o último campo é o tamanho em bytes do bytecode. Nos próximos artigos nos aventuraremos sobre: code cache, C1, C2, explorando internamente seus aspectos e fazendo paralelos com o código-fonte da JDK.