Bytecode: Branching, Returns e Synchronization
Objective
Entender as instruções de bytecode que controlam o fluxo de execução em vez de computar valores: os branches condicionais que implementam if/while/for (incluindo comparações de identidade de referência), as duas instruções diferentes para as quais um switch pode compilar, as instruções específicas de tipo que retornam de um método, e como synchronized compila para dois mecanismos completamente diferentes dependendo de ser um modificador de método ou um bloco — incluindo o maquinário de tratamento de exceção que o compilador insere silenciosamente para tornar um bloco synchronized exception-safe.
Use Cases
- Ler a saída de
javap -cpara rastrear qual branch umif/elsecompilado realmente toma, e casar jump targets (ifne 6,goto 27) de volta às linhas do source. - Explicar por que
==em dois objetos nunca chamaequals()— éif_acmpeq/if_acmpne, uma comparação de identidade, do início ao fim no nível do bytecode. - Reconhecer se um
switchcompilou paratableswitch(labels de case densos, jump table O(1)) oulookupswitch(labels esparsos, busca binária O(log n)) ao ler um disassembly. - Explicar por que o
returnde um método sempre compila para um opcode específico do tipo, e por que um mismatch (por exemplo,ireturnnum método declarado para retornarlong) é rejeitado pelo bytecode verifier, não só um erro em runtime. - Entender por que um bloco
synchronizedsempre compila para mais instruções que um métodosynchronized, e por que o bytecode desse bloco contém um exception handler que você nunca escreveu no source. - Reconhecer
athrownum disassembly como a única instrução para a qual todothrow— checked ou unchecked, seu ou umaNullPointerExceptiondo próprio JVM — compila.
Deep Dive
Branches condicionais: comparar contra zero vs. comparar dois valores
O JVM tem duas famílias de jump condicional. Uma compara um único valor contra zero (ifeq, ifne, iflt, ifle, ifgt, ifge, mais ifnull/ifnonnull para referências); a outra compara dois valores diretamente da stack (if_icmpeq, if_icmpne, if_icmplt, if_icmple, if_icmpgt, if_icmpge para int, e if_acmpeq/if_acmpne para referências). As duas existem para que o compilador nunca precise sintetizar um zero para comparar quando já tem dois valores vivos na stack:
javastatic int classify(int x) {
if (x == 0) return 0;
if (x > 0) return 1;
return -1;
}
static boolean sameOrder(int a, int b) {
return a < b;
}plaintextstatic int classify(int); iload_0 ifne 6 // x == 0 reduces to a single zero-comparison iconst_0 ireturn iload_0 ifle 12 // x > 0 is still a zero-comparison, just a different one iconst_1 ireturn iconst_m1 ireturn static boolean sameOrder(int, int); iload_0 iload_1 if_icmpge 9 // a < b compares two live stack values directly, no zero involved iconst_1 goto 10 iconst_0 ireturn
Todo operador de comparação é compilado para seu branch inverso — x == 0 vira ifne (pula quando não igual), a < b vira if_icmpge (pula quando não menor) — porque um branch-na-condição-inversa deixa o caminho "verdadeiro" passar direto sem um goto, e só o caminho "falso" precisa de um jump explícito.
ifnull/ifnonnull seguem a mesma família de comparação-com-zero para referências — uma referência null é representada da mesma forma que um int zero no nível do bytecode, e é por isso que s == null compila de forma idêntica em formato a x == 0:
plaintextstatic boolean isNull(java.lang.String); aload_0 ifnonnull 8 iconst_1 goto 9 iconst_0 ireturn
if_acmpeq/if_acmpne são a contraparte de referência da família de dois valores para if_icmpeq/if_icmpne — elas comparam identidade, não conteúdo, que é exatamente por que == em dois objetos significa "mesma referência" no nível do bytecode, independentemente do que equals() diria:
javastatic boolean refEquals(Object a, Object b) { return a == b; }plaintextstatic boolean refEquals(java.lang.Object, java.lang.Object); aload_0 aload_1 if_acmpne 9 // a == b compiles to "jump away if references differ" iconst_1 goto 10 iconst_0 ireturn
Não existe equivalente de if_acmpeq/if_acmpne que chame .equals() — comparação de conteúdo é sempre uma chamada invokevirtual explícita que o código-fonte tem que escrever, nunca algo que o operador == dispara por conta própria para tipos de referência.
Branching multi-way: tableswitch vs. lookupswitch
Um switch em int (ou um tipo que reduz para int — char, byte, short, ou o ordinal de um enum) compila para uma de duas instruções dedicadas, escolhida pelo compilador com base em como os labels de case estão distribuídos, não por nada visível na sintaxe do source:
javastatic int denseSwitch(int x) {
switch (x) {
case 0: return 10;
case 1: return 20;
case 2: return 30;
default: return -1;
}
}
static int sparseSwitch(int x) {
switch (x) {
case 1: return 1;
case 100: return 2;
case 10000: return 3;
default: return -1;
}
}plaintextstatic int denseSwitch(int); iload_0 tableswitch { // 0 to 2 0: 28 1: 31 2: 34 default: 37 } ... static int sparseSwitch(int); iload_0 lookupswitch { // 3 1: 36 100: 38 10000: 40 default: 42 } ...
tableswitch é uma jump table indexada diretamente — é O(1): o próprio valor do case é o offset num array contíguo de branch targets, o que é exatamente por que só funciona quando os labels são densos o bastante para que construir esse array não seja um desperdício. lookupswitch armazena pares explícitos (valor, target) ordenados por valor e o JVM faz busca binária neles — O(log n), mas sem entradas de tabela desperdiçadas para os vãos entre 1, 100 e 10000. O compilador escolhe o que custa menos espaço para a distribuição real dos labels; ambos compilam a mesma construção do source, então nada sobre qual você recebe está sob controle do programador.
Instruções de return específicas por tipo
Assim como a aritmética, return não é uma instrução — são seis, uma por categoria de valor, e um método void usa uma sétima que não retorna nenhum valor:
plaintextireturn // int, boolean, byte, short, char lreturn // long freturn // float dreturn // double areturn // object reference return // void — no value on the stack to return
O verifier checa o tipo do valor retornado contra o descriptor de retorno declarado do método no momento de carregamento da classe — um método compilado (por bytecode montado à mão, já que o javac nunca geraria isso) com ireturn onde o descriptor diz J (long) é rejeitado antes mesmo do método poder rodar, da mesma forma que um arquivo .class com o magic number errado é rejeitado antes de suas instruções serem lidas.
synchronized: uma flag de método vs. um par explícito de monitor
Um método synchronized não adiciona nenhuma instrução de bytecode ao corpo do método — ele define a access flag ACC_SYNCHRONIZED, e o JVM adquire o monitor como parte da invocação do método:
javapublic synchronized void incSynchronizedMethod() {
count++;
}plaintextpublic synchronized void incSynchronizedMethod(); flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED Code: ... // ordinary field-increment bytecode — no monitorenter/monitorexit here
Um bloco synchronized, em contraste, não tem access flag para se apoiar — o escopo do monitor é arbitrário, decidido no nível do source — então o compilador emite instruções explícitas monitorenter/monitorexit ao redor dele:
javapublic void incSynchronizedBlock() {
synchronized (lock) {
count++;
}
}plaintextpublic void incSynchronizedBlock(); Code: 0: aload_0 1: getfield #7 // Field lock:Ljava/lang/Object; 4: dup 5: astore_1 6: monitorenter // acquire the lock 7: aload_0 8: dup 9: getfield #13 // Field count:I 12: iconst_1 13: iadd 14: putfield #13 17: aload_1 18: monitorexit // release the lock — normal exit 19: goto 27 22: astore_2 23: aload_1 24: monitorexit // release the lock — exceptional exit 25: aload_2 26: athrow 27: return Exception table: from to target type 7 19 22 any 22 25 22 any
Essa exception table — que nunca aparece no source — é o que torna um bloco synchronized exception-safe: o compilador gera uma segunda cópia de monitorexit, coberta por um handler catch-all sobre todo o corpo do bloco, especificamente para que um lock adquirido por monitorenter ainda seja liberado por monitorexit se count++ (ou qualquer coisa dentro do bloco) lançar uma exceção. Não há nenhum try/finally escrito no nível do source aqui — o compilador insere o equivalente de um automaticamente, puramente porque um lock com escopo de bloco não tem outra forma de garantir liberação em todo caminho de saída.
athrow
Todo throw em Java — exceção checked, unchecked, ou uma NullPointerException que o próprio JVM levanta para uma desreferência inválida — compila para a única instrução athrow, que desempilha uma referência Throwable da stack e transfere o controle para o handler correspondente mais próximo na exception table do método (ou desenrola o frame se não houver nenhum):
javapublic void fail() {
throw new IllegalStateException("bad state");
}plaintextpublic void fail(); Code: 0: new #17 // class java/lang/IllegalStateException 3: dup 4: ldc #19 // String bad state 6: invokespecial #21 // Method IllegalStateException."<init>":(Ljava/lang/String;)V 9: athrow
Construir a exceção é apenas a já familiar sequência de criação de objeto new/dup/invokespecial — o athrow em si não faz nada além de entregar o objeto já construído para o maquinário de despacho de exceção do JVM.
Trade-offs
- Todo branch é compilado invertido — o compilador sempre emite o oposto da condição do source (
==viraifne,<viraif_icmpge) para que o caminho "verdadeiro" comum passe direto sem um jump; isso torna a leitura manual de condicionais desmontados contraintuitiva até você internalizar que o branch target é sempre o caminho else, não o then. - Um método
synchronizednão paga nada extra em bytecode, um blocosynchronizedpaga pela própria segurança contra exceções — a forma de método é um único bit de access flag que o JVM trata no momento da invocação, enquanto a forma de bloco custa ummonitorexitduplicado e uma exception table gerada pelo compilador, porque o JVM não tem um equivalente de "libere esse monitor quando essa região arbitrária de código sair, de qualquer forma que ela saia" sem esse maquinário explícito.
javasynchronized (lock) {
doSomethingThatThrows(); // monitorexit still runs — the compiler's exception table guarantees it
}- O verifier exige um
Throwableemathrow, não nenhum tipo de exceção em particular — qualquer objeto atribuível ajava.lang.Throwablepode ser lançado, o que é por que oathrowsozinho não consegue distinguir uma exceção checked de uma unchecked; essa distinção é um conceito de níveljavac, não de nível bytecode — o compilador checa cláusulasthrowsem tempo de compilação, mas nada no arquivo.classreverifica isso em runtime. jsr/retsão documentadas na JVMS mas nenhumjavacdesde o Java 6 as emite, e nenhum JVM desde a versão 51 de class file (Java 7) sequer as carrega — material mais antigo (e ferramentas antigas de manipulação de bytecode) ainda as descrevem como o mecanismo para o qual blocosfinallycostumavam compilar: uma chamada de subrotina (jsr) que empilhava um endereço de retorno para oretpular de volta, deixando uma única cópia do corpo dofinallyservir todo caminho de saída. Ojavacpassou a duplicar o corpo dofinallyinline em cada saída em vez disso, e a especificação agora proíbejsr/retcompletamente — um class file mirando um release moderno que ainda as contivesse falharia na verificação, não rodaria com performance reduzida.goto_w(a contraparte de offset de 32 bits dogoto) não é afetada por isso e continua legal, mas só é emitida para um corpo de método grande o bastante para que um offset de branch de 16 bits não alcance o target — efetivamente nunca, fora de código gerado.