Fontes, Bytecode, Depuração
Outras línguas: English Español 日本語 한국어 中文
Ao depurar programas Java, os desenvolvedores muitas vezes têm a impressão de que estão interagindo diretamente com o código-fonte. Isso não é surpreendente – as ferramentas do Java fazem um trabalho excelente de ocultar a complexidade que quase parece que o código-fonte existe em tempo de execução.
Se você está apenas começando com o Java, provavelmente se lembra daqueles diagramas mostrando como o compilador transforma o código-fonte em bytecode, que é então executado pela JVM. Você também pode se perguntar: se é esse o caso, por que examinamos e passamos pelo código-fonte em vez do bytecode? Como a JVM sabe algo sobre nossas fontes?
Este artigo é um pouco diferente das minhas postagens anteriores sobre depuração. Em vez de se concentrar em como depurar um problema específico, como um aplicativo sem resposta ou um vazamento de memória, ele explora como o Java e os depuradores funcionam nos bastidores. Fique por perto – como sempre, alguns truques úteis estão incluídos.

Bytecode
Vamos começar com uma rápida recapitulação. Os diagramas encontrados em livros e guias de Java são de fato corretos – a JVM executa bytecode.
Considere a seguinte classe como um exemplo:
package dev.flounder;
public class Calculator {
int sum(int a, int b) {
return a + b;
}
}
Quando compilado, o método sum()
se transformará no seguinte bytecode:
int sum(int, int);
descriptor: (II)I
flags: (0x0000)
Code:
stack=2, locals=3, args_size=3
0: iload_1
1: iload_2
2: iadd
3: ireturn
Você pode inspecionar o bytecode de suas classes usando o comando javap -v
incluído com o JDK. Se você estiver usando o IntelliJ IDEA, também pode fazer isso a partir do IDE:
após construir seu projeto, selecione uma classe, então
clique View | Show Bytecode.
Uma vez que os arquivos de classe são binários, citar seus conteúdos brutos não seria informativo.
Para facilitar a leitura, os exemplos neste artigo seguem o formato da saída javap -v
.
Bytecode consiste em uma série de instruções compactas independentes de plataforma. No exemplo acima:
iload_1
eiload_2
carregam as variáveis na pilha de operandosiadd
adiciona o conteúdo da pilha de operandos, deixando um único valor de resultado nelaireturn
retorna o valor da pilha de operandos
Além das instruções, os arquivos bytecode também incluem informações sobre as constantes, o número de parâmetros, variáveis locais e a profundidade da pilha de operandos. Isso é tudo que a JVM precisa para executar um programa escrito em uma linguagem JVM, como Java, Kotlin ou Scala.
Informações de depuração
Como o bytecode parece completamente diferente do seu código-fonte, referir-se a ele durante a depuração seria ineficiente. Por esse motivo, as interfaces dos depuradores Java – como o JDB (o debugger de console agrupado com o JDK) ou o do IntelliJ IDEA – exibem o código-fonte em vez do bytecode. Isso permite que você depure o código que você escreveu sem ter que pensar no bytecode subjacente sendo executado.
Por exemplo, sua interação com o JDB pode ser assim:
Initializing jdb ...
> stop at dev.flounder.Calculator:5
Deferring breakpoint dev.flounder.Calculator:5.
It will be set after the class is loaded.
> run
run dev/flounder/Main
Set uncaught java.lang.Throwable
Set deferred uncaught java.lang.Throwable
VM Started: Set deferred breakpoint dev.flounder.Calculator:5
Breakpoint hit: "thread=main", dev.flounder.Calculator.sum(), line=5 bci=0
> locals
Method arguments:
a = 1
b = 2
O IntelliJ IDEA exibirá as informações de depuração no editor e na janela de ferramentas Debug:


Como você pode ver, ambos os depuradores usam os nomes de variáveis corretos e referenciam linhas válidas de nosso snippet de código acima.
Como o tempo de execução não tem acesso aos arquivos fonte, ele deve coletar esses dados em outro lugar. É aqui que entra em jogo a informação de depuração. A informação de depuração (também chamada de símbolos de depuração) são dados compactos que ligam o bytecode às fontes do aplicativo. Ele é incluído nos arquivos .class durante a compilação.
Existem três tipos de informações de depuração:
Nos próximos capítulos, vou explicar brevemente cada tipo de informação de depuração e como o depurador a usa.
Números de linha
As informações do número da linha são armazenadas no atributo LineNumberTable dentro do arquivo bytecode, e parece com isto:
LineNumberTable:
line 5: 0
line 6: 2
A tabela acima informa ao depurador o seguinte:
- A linha 5 contém a instrução no deslocamento 0
- A linha 6 contém a instrução no deslocamento 2
Este tipo de informação de depuração ajuda as ferramentas externas, como depuradores ou profilers, traçar a linha exata onde o programa é executado no código-fonte.
Importante, as informações de número de linha também são usadas para referências de origem em rastreamentos de pilha de exceção. No exemplo a seguir, eu compilei o código de meu outro tutorial com e sem informações de número de linha. Aqui estão os rastreamentos de pilha produzidos pelos executáveis resultantes:
Exception in thread "main" java.lang.NumberFormatException: For input string: ""
at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
at java.base/java.lang.Integer.parseInt(Integer.java:672)
at java.base/java.lang.Integer.parseInt(Integer.java:778)
at dev.flounder.Airports.parse(Airports.java:53)
at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:197)
at java.base/java.util.Iterator.forEachRemaining(Iterator.java:133)
at java.base/java.util.Spliterators$IteratorSpliterator.forEachRemaining(Spliterators.java:1939)
at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
at java.base/java.util.stream.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:151)
at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:174)
at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
at java.base/java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:596)
at dev.flounder.Airports.main(Airports.java:39)
Exception in thread "main" java.lang.NumberFormatException: For input string: ""
at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
at java.base/java.lang.Integer.parseInt(Integer.java:672)
at java.base/java.lang.Integer.parseInt(Integer.java:778)
at dev.flounder.Airports.parse(Airports.java)
at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:197)
at java.base/java.util.Iterator.forEachRemaining(Iterator.java:133)
at java.base/java.util.Spliterators$IteratorSpliterator.forEachRemaining(Spliterators.java:1939)
at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
at java.base/java.util.stream.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:151)
at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:174)
at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
at java.base/java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:596)
at dev.flounder.Airports.main(Airports.java)
O executável compilado sem informações de número de linha produziu um rastreamento de pilha que carece de números de linha para as chamadas correspondentes ao código do meu projeto. As chamadas da biblioteca padrão e dependências ainda incluem números de linha porque foram compilados separadamente e não foram afetados.
Além dos rastreamentos de pilha, você pode encontrar uma situação semelhante em que os números de linha estão envolvidos, por exemplo, na guia Frames do IntelliJ IDEA:


Então, se você vê -1
em vez dos números de linha reais, e você não gosta disso,
certifique-se de que seu programa está sendo compilado com informações de número de linha.
Você pode ver o deslocamento do bytecode diretamente na guia Frames do IntelliJ IDEA.
Para isso, adicione a
seguinte chave de registro:
debugger.stack.frame.show.code.index=true


Nomes de variáveis
Como informações de número de linha, os nomes das variáveis são armazenados em arquivos de classe. A tabela de variáveis para nosso exemplo é a seguinte:
LocalVariableTable:
Start Length Slot Name Signature
0 4 0 this Ldev/flounder/Calculator;
0 4 1 a I
0 4 2 b I
Contém as seguintes informações:
- Start: O deslocamento do bytecode onde o escopo desta variável começa.
- Length: O número de instruções durante as quais essa variável permanece no escopo.
- Slot: O índice em que esta variável é armazenada para pesquisa.
- Name: O nome da variável conforme aparece no código-fonte.
- Signature: O tipo de dado da variável, expresso na notação de assinatura de tipo do Java.
Se as variáveis estiverem faltando nas informações de depuração, algumas funcionalidades do depurador podem não funcionar conforme o esperado,
e você verá slot_1
, slot_2
, etc. em vez dos nomes de variáveis reais.


Nomes de arquivo de origem
Este tipo de informação de depuração indica qual arquivo de origem foi usado para compilar a classe. Como informações de número de linha, sua presença nos arquivos de classe afeta não só as ferramentas externas, mas também os rastreamentos de pilha que seu programa gera.
Exception in thread "main" java.lang.NumberFormatException: For input string: ""
at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
at java.base/java.lang.Integer.parseInt(Integer.java:672)
at java.base/java.lang.Integer.parseInt(Integer.java:778)
at dev.flounder.Airports.parse(Airports.java:53)
at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:197)
at java.base/java.util.Iterator.forEachRemaining(Iterator.java:133)
at java.base/java.util.Spliterators$IteratorSpliterator.forEachRemaining(Spliterators.java:1939)
at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
at java.base/java.util.stream.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:151)
at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:174)
at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
at java.base/java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:596)
at dev.flounder.Airports.main(Airports.java:39)
Exception in thread "main" java.lang.NumberFormatException: For input string: ""
at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
at java.base/java.lang.Integer.parseInt(Integer.java:672)
at java.base/java.lang.Integer.parseInt(Integer.java:778)
at dev.flounder.Airports.parse(Unknown Source)
at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:197)
at java.base/java.util.Iterator.forEachRemaining(Iterator.java:133)
at java.base/java.util.Spliterators$IteratorSpliterator.forEachRemaining(Spliterators.java:1939)
at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
at java.base/java.util.stream.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:151)
at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:174)
at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
at java.base/java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:596)
at dev.flounder.Airports.main(Unknown Source)
Sem nomes de arquivos de origem, as chamadas de rastreamento de pilha correspondentes serão marcadas como Unknown Source
.
Flags de compilador
Como desenvolvedor, você tem controle sobre se inclui informações de depuração em seus executáveis
e, se sim, quais tipos incluir. Você pode gerenciar isso usando o argumento do compilador -g
, assim:
javac -g:lines,vars,source
Aqui está a sintaxe:
Comando | Resultado |
javac | Compila o aplicativo com números de linha e nomes de arquivos fonte (padrão para a maioria dos compiladores) |
javac -g | Compila o aplicativo com todas as informações de depuração disponíveis: números de linha, variáveis e nomes de arquivos de origem |
javac -g:lines,source | Compila o aplicativo com os tipos especificados de informações de depuração - números de linha e nomes de arquivo de origem neste exemplo |
javac -g:none | Compila o aplicativo sem as informações de depuração |
Os padrões podem variar entre os compiladores. Alguns deles excluem completamente as informações de depuração, a menos que instruídos de outra forma.
Se você estiver usando um sistema de construção, como o Maven ou Gradle, você pode passar as mesmas opções por meio de argumentos do compilador. Por exemplo:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<compilerArgs>
<arg>-g:vars,lines</arg>
</compilerArgs>
</configuration>
</plugin>
tasks.compileJava {
options.compilerArgs.add("-g:vars,lines")
}
Por que remover informações de depuração?
Como acabamos de ver, os símbolos de depuração permitem o processo de depuração, o que é conveniente durante o desenvolvimento. Por esse motivo, os símbolos de depuração geralmente estão incluídos nas construções de desenvolvimento. Nas construções de produção, eles geralmente são excluídos; no entanto, isso depende finalmente do tipo de projeto em que você está trabalhando.
Aqui estão algumas coisas que você pode querer considerar:
Segurança
Como um depurador pode ser usado para interferir em seu programa, incluir informações de depuração torna seu aplicativo um pouco mais vulnerável a hacking e engenharia reversa, o que pode ser indesejável para alguns aplicativos.
Embora a ausência de símbolos de depuração possa tornar um pouco mais difícil interferir com seu programa usando um depurador, não o protege totalmente. A depuração permanece possível mesmo com informações de depuração parciais ou ausentes, então isso sozinho não impedirá um indivíduo determinado de acessar os internos do seu programa. Portanto, se você está preocupado com o risco de engenharia reversa, você deve empregar medidas adicionais, como ofuscação de código-fonte.
Tamanho do executável
Quanto mais informações um executável contém, maior ele se torna. Exatamente quanto maior depende de vários fatores. O tamanho de um arquivo de classe específico pode facilmente ser dominado pelo número de instruções e o tamanho do pool de constantes, tornando impraticável fornecer uma estimativa universal. Ainda assim, para demonstrar que a diferença pode ser substancial, experimentei com Airports.java, que usamos anteriormente para comparar rastreamentos de pilha. Os resultados são 4,460 bytes sem informações de depuração comparados a 5,664 bytes com ela.
Na maioria dos casos, incluir símbolos de depuração não vai machucar. No entanto, se o tamanho do executável é uma preocupação, como é frequentemente o caso com sistemas embarcados, você pode querer excluir os símbolos de depuração dos seus binários.
Adicionando fontes para depuração
Normalmente, as fontes necessárias estão dentro do seu projeto, então o IDE não terá problemas para encontrá-las. No entanto, existem situações menos comuns - por exemplo, quando o código-fonte necessário para depuração está fora do seu projeto, como ao entrar em uma biblioteca usada pelo seu código.
Nesse caso, você precisa adicionar arquivos fonte manualmente: seja colocando-os em uma raiz de fontes ou especificando-os como uma dependência. Durante a depuração, o IntelliJ IDEA vai detectar e combinar esses arquivos automaticamente com as classes executadas pela JVM.
Quando o projeto está ausente
Na maioria dos casos, você construiria, lançaria e depuraria uma aplicação no mesmo IDE, usando o projeto original. Mas e se você tiver apenas alguns arquivos de origem, e o próprio projeto estiver ausente?
Aqui está uma configuração de depuração básica que resolverá este problema:
- Crie um projeto Java vazio
- Adicione os arquivos de origem em uma raiz de origem ou especifique-os como uma dependência
- Inicie o aplicativo alvo com o agente de depuração. Em Java, isso geralmente é feito adicionando uma opção VM, como:
-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005
- Crie uma Remote JVM Debug configuração de execução com os detalhes de conexão corretos. Use esta configuração de execução para anexar o depurador ao aplicativo alvo.
Com esta configuração, você pode depurar um programa sem acessar o projeto original. O IntelliJ IDEA irá combinar as fontes disponíveis com as classes de tempo de execução e permitirá que você as use em uma sessão de depuração. Desta forma, mesmo uma única classe de projeto ou biblioteca oferece um ponto de entrada para depuração.
Para um exemplo prático, confira Debugger.godMode() – Hackear uma Aplicação JVM com o Depurador, onde usamos essa técnica para alterar o comportamento de um programa sem acessar seu código fonte
Incompatibilidade de fonte
Uma situação confusa que você pode encontrar durante a depuração é quando seu aplicativo parece suspenso em uma linha em branco ou os números de linha na guia Frames não correspondem àquelas no editor:


Isso ocorre quando se está depurando código descompilado (o que discutiremos em outro artigo) ou quando o código fonte não corresponde totalmente ao bytecode que a JVM está executando.
Já que o único link entre o bytecode e um arquivo de origem específico é o nome do arquivo e suas classes, o depurador tem que se basear nessa informação, auxiliado por algumas heurísticas. Isso funciona bem em a maioria das situações; no entanto, a versão do arquivo no disco pode ser diferente daquela usada para compilar a aplicação. No caso de uma correspondência parcial, o depurador identificará as discrepâncias e tentará conciliar elam em vez de falhar rapidamente. Dependendo da extensão das diferenças, isso pode ser útil, por exemplo, se a única fonte que você possui não é a correspondência mais próxima.
No cenário mais afortunado onde você tem a versão exata das fontes em outro lugar, você pode reparar este problema adicionando elas ao projeto e rodando novamente a sessão de depuração.
Conclusão
Neste artigo, exploramos a conexão entre os arquivos fonte, o bytecode e o depurador. Enquanto não é estritamente necessário para a codificação do dia a dia, ter uma imagem mais clara do que acontece por baixo dos panos pode te dar uma compreensão mais forte do ecossistema e pode ocasionalmente te ajudar em situações e problemas de configuração não padronizados. Espero que você tenha achado a teoria e as dicas úteis!
Ainda existem muitos mais tópicos a serem abordados nesta série, então fique ligado para o próximo. Se houver algo específico que você gostaria de ver coberto, ou se você tiver ideias e feedback, adoraria ouvir de você!