Sources, Bytecode, Debogage

Autres langues : English Español Deutsch 日本語 한국어 Português 中文

Lors du debogage de programmes Java, les developpeurs ont souvent l’impression qu’ils interagissent directement avec le code source. Ce n’est pas surprenant - les outils Java font un si bon travail pour cacher la complexite qu’on a presque l’impression que le code source existe a l’execution.

Si vous debutez avec Java, vous vous souvenez probablement de ces diagrammes montrant comment le compilateur transforme le code source en bytecode, qui est ensuite execute par la JVM. Vous pourriez aussi vous demander : si c’est le cas, pourquoi examinons-nous et parcourons-nous le code source plutot que le bytecode ? Comment la JVM connait-elle quoi que ce soit sur nos sources ?

Cet article est un peu different de mes articles precedents sur le debogage. Au lieu de se concentrer sur comment deboguer un probleme specifique, comme une application qui ne repond pas ou une fuite memoire, il explore comment Java et les debogueurs fonctionnent en coulisses. Restez avec nous - comme toujours, quelques astuces pratiques sont incluses.

Bytecode

Commencons par un bref rappel. Les diagrammes trouves dans les livres et guides Java sont en effet corrects - la JVM execute du bytecode.

Considerons la classe suivante comme exemple :

package dev.flounder;

public class Calculator {
    int sum(int a, int b) {
        return a + b;
    }
}

Une fois compilee, la methode sum() se transformera en bytecode suivant :

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
Tip icon

Vous pouvez inspecter le bytecode de vos classes en utilisant la commande javap -v incluse avec le JDK. Si vous utilisez IntelliJ IDEA, vous pouvez aussi le faire depuis l’IDE : apres avoir construit votre projet, selectionnez une classe, puis cliquez sur View | Show Bytecode.

Info icon

Puisque les fichiers class sont binaires, citer leur contenu brut ne serait pas informatif. Pour la lisibilite, les exemples dans cet article suivent le format de sortie de javap -v.

Le bytecode consiste en une serie d’instructions compactes independantes de la plateforme. Dans l’exemple ci-dessus :

  1. iload_1 et iload_2 chargent les variables sur la pile d’operandes
  2. iadd additionne le contenu de la pile d’operandes, laissant une seule valeur de resultat dessus
  3. ireturn retourne la valeur de la pile d’operandes

En plus des instructions, les fichiers bytecode incluent aussi des informations sur les constantes, le nombre de parametres, les variables locales et la profondeur de la pile d’operandes. C’est tout ce dont la JVM a besoin pour executer un programme ecrit dans un langage JVM, comme Java, Kotlin ou Scala.

Informations de debogage

Puisque le bytecode est completement different de votre code source, s’y referer lors du debogage serait inefficace. Pour cette raison, les interfaces des debogueurs Java - comme le JDB (le debogueur console fourni avec le JDK) ou celui d’IntelliJ IDEA - affichent le code source plutot que le bytecode. Cela vous permet de deboguer le code que vous avez ecrit sans avoir a penser au bytecode sous-jacent en cours d’execution.

Par exemple, votre interaction avec le JDB pourrait ressembler a ceci :

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

IntelliJ IDEA affichera les informations liees au debogage dans l’editeur et dans la fenetre d’outil Debug :

IntelliJ IDEA met en évidence la ligne en cours d'exécution et fournit des informations sur les variables disponibles IntelliJ IDEA met en évidence la ligne en cours d'exécution et fournit des informations sur les variables disponibles

Comme vous pouvez le voir, les deux debogueurs utilisent les noms de variables corrects et referencent des lignes valides de notre extrait de code ci-dessus.

Puisque l’execution n’a pas acces aux fichiers sources, elle doit collecter ces donnees ailleurs. C’est la que les informations de debogage entrent en jeu. Les informations de debogage (aussi appelees symboles de debogage) sont des donnees compactes qui lient le bytecode aux sources de l’application. Elles sont incluses dans les fichiers .class lors de la compilation.

Il existe trois types d’informations de debogage :

Dans les chapitres suivants, j’expliquerai brievement chaque type d’information de debogage et comment le debogueur l’utilise.

Numeros de ligne

Les informations de numeros de ligne sont stockees dans l’attribut LineNumberTable dans le fichier bytecode, et ressemblent a ceci :

LineNumberTable:
line 5: 0
line 6: 2

Le tableau ci-dessus indique au debogueur ce qui suit :

Ce type d’information de debogage aide les outils externes, tels que les debogueurs ou les profileurs, a tracer la ligne exacte ou le programme s’execute dans le code source.

Fait important, les informations de numeros de ligne sont aussi utilisees pour les references sources dans les traces de pile d’exceptions. Dans l’exemple suivant, j’ai compile du code de mon autre tutoriel avec et sans informations de numeros de ligne. Voici les traces de pile produites par les executables resultants :


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)

L’executable compile sans informations de numeros de ligne a produit une trace de pile qui manque de numeros de ligne pour les appels correspondant au code de mon projet. Les appels de la bibliotheque standard et des dependances incluent toujours les numeros de ligne car ils ont ete compiles separement et n’ont pas ete affectes.

Outre les traces de pile, vous pouvez rencontrer une situation similaire ou les numeros de ligne sont impliques, par exemple, dans l’onglet Frames d’IntelliJ IDEA :

Frames sans numéros de ligne dans le débogueur d'IntelliJ IDEA Frames sans numéros de ligne dans le débogueur d'IntelliJ IDEA

Donc, si vous voyez -1 au lieu de numeros de ligne reels, et que vous n’aimez pas ca, assurez-vous que votre programme est compile avec les informations de numeros de ligne.

Crazy icon

Vous pouvez voir l’offset bytecode directement dans l’onglet Frames d’IntelliJ IDEA. Pour cela, ajoutez la cle de registre suivante : debugger.stack.frame.show.code.index=true

IntelliJ IDEA affiche le décalage du bytecode juste à côté du numéro de ligne dans l'onglet 'Frames' IntelliJ IDEA affiche le décalage du bytecode juste à côté du numéro de ligne dans l'onglet 'Frames'

Noms de variables

Comme les informations de numeros de ligne, les noms de variables sont stockes dans les fichiers class. La table de variables pour notre exemple ressemble a ceci :

LocalVariableTable:
Start  Length  Slot  Name   Signature
    0       4     0  this   Ldev/flounder/Calculator;
    0       4     1     a   I
    0       4     2     b   I

Elle contient les informations suivantes :

  1. Start : L’offset bytecode ou la portee de cette variable commence.
  2. Length : Le nombre d’instructions pendant lesquelles cette variable reste dans la portee.
  3. Slot : L’index auquel cette variable est stockee pour la recherche.
  4. Name : Le nom de la variable tel qu’il apparait dans le code source.
  5. Signature : Le type de donnees de la variable, exprime en notation de signature de type Java.

Si les variables sont absentes des informations de debogage, certaines fonctionnalites du debogueur pourraient ne pas fonctionner comme prevu, et vous verrez slot_1, slot_2, etc. au lieu des noms de variables reels.

IntelliJ IDEA affiche 'slot_1', 'slot_2', etc. au lieu des noms de variables dans le débogueur IntelliJ IDEA affiche 'slot_1', 'slot_2', etc. au lieu des noms de variables dans le débogueur

Noms de fichiers sources

Ce type d’information de debogage indique quel fichier source a ete utilise pour compiler la classe. Comme les informations de numeros de ligne, sa presence dans les fichiers class affecte non seulement les outils externes, mais aussi les traces de pile que votre programme genere.


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)

Sans les noms de fichiers sources, les appels de trace de pile correspondants seront marques comme Unknown Source.

Options du compilateur

En tant que developpeur, vous avez le controle sur l’inclusion ou non des informations de debogage dans vos executables et, si oui, quels types inclure. Vous pouvez gerer cela en utilisant l’argument du compilateur -g, comme ceci :

javac -g:lines,vars,source

Voici la syntaxe :

CommandeResultat
javacCompile l’application avec les numeros de ligne et les noms de fichiers sources (defaut pour la plupart des compilateurs)
javac -g

Compile l’application avec toutes les informations de debogage disponibles : numeros de ligne, variables et noms de fichiers sources

javac -g:lines,source

Compile l’application avec les types specifies d’informations de debogage - numeros de ligne et noms de fichiers sources dans cet exemple

javac -g:noneCompile l’application sans les informations de debogage
Info icon

Les valeurs par defaut peuvent varier entre les compilateurs. Certains d’entre eux excluent completement les informations de debogage sauf indication contraire.

Si vous utilisez un systeme de build, comme Maven ou Gradle, vous pouvez passer les memes options via les arguments du compilateur. Par exemple :


<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")
}

Pourquoi supprimer les informations de debogage ?

Comme nous venons de le voir, les symboles de debogage permettent le processus de debogage, ce qui est pratique pendant le developpement. Pour cette raison, les symboles de debogage sont generalement inclus dans les builds de developpement. Dans les builds de production, ils sont souvent exclus ; cependant, cela depend finalement du type de projet sur lequel vous travaillez.

Voici quelques points a considerer :

Securite

Puisqu’un debogueur peut etre utilise pour manipuler votre programme, inclure des informations de debogage rend votre application legerement plus vulnerable au piratage et a l’ingenierie inverse, ce qui peut etre indesirable pour certaines applications.

Bien que l’absence de symboles de debogage puisse rendre quelque peu plus difficile l’interference avec votre programme en utilisant un debogueur, elle ne le protege pas completement. Le debogage reste possible meme avec des informations de debogage partielles ou manquantes, donc cela seul n’empechera pas une personne determinee d’acceder aux composants internes de votre programme. Par consequent, si vous etes preoccupe par le risque d’ingenierie inverse, vous devriez employer des mesures supplementaires, comme l’obfuscation de code.

Taille de l’executable

Plus un executable contient d’informations, plus il devient volumineux. La taille exacte depend de divers facteurs. La taille d’un fichier class particulier peut facilement etre dominee par le nombre d’instructions et la taille du pool de constantes, ce qui rend impratique de fournir une estimation universelle. Toutefois, pour demontrer que la difference peut etre substantielle, j’ai experimente avec Airports.java, que nous avons utilise plus tot pour comparer les traces de pile. Les resultats sont 4 460 octets sans informations de debogage contre 5 664 octets avec.

Dans la plupart des cas, inclure les symboles de debogage ne fera pas de mal. Cependant, si la taille de l’executable est une preoccupation, comme c’est souvent le cas avec les systemes embarques, vous pourriez vouloir exclure les symboles de debogage de vos binaires.

Ajouter des sources pour le debogage

Typiquement, les sources requises resident dans votre projet, donc l’IDE n’aura aucun mal a les trouver. Cependant, il existe des situations moins courantes - par exemple, lorsque le code source necessaire pour le debogage est en dehors de votre projet, comme lors de l’entree dans une bibliotheque utilisee par votre code.

Dans ce cas, vous devez ajouter les fichiers sources manuellement : soit en les placant sous une racine de sources soit en les specifiant comme dependance. Pendant le debogage, IntelliJ IDEA detectera et associera automatiquement ces fichiers avec les classes executees par la JVM.

Quand le projet est manquant

Dans la plupart des cas, vous construiriez, lanceriez et debogueriez une application dans le meme IDE, en utilisant le projet original. Mais que faire si vous n’avez que quelques fichiers sources et que le projet lui-meme est manquant ?

Voici une configuration de debogage minimale qui fera l’affaire :

  1. Creez un projet Java vide
  2. Ajoutez les fichiers sources sous une racine de sources ou specifiez-les comme dependance
  3. Lancez l’application cible avec l’agent de debogage. En Java, cela se fait generalement en ajoutant une option VM, comme :
-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005
  1. Creez une configuration d’execution Remote JVM Debug avec les details de connexion corrects. Utilisez cette configuration d’execution pour attacher le debogueur a l’application cible.

Avec cette configuration, vous pouvez deboguer un programme sans acceder au projet original. IntelliJ IDEA associera les sources disponibles avec les classes d’execution et vous permettra de les utiliser dans une session de debogage. De cette facon, meme une seule classe de projet ou de bibliotheque vous donne un point d’entree pour le debogage.

Discordance de sources

Une situation confuse que vous pourriez rencontrer pendant le debogage est lorsque votre application semble suspendue a une ligne vide ou que les numeros de ligne dans l’onglet Frames ne correspondent pas a ceux dans l’editeur :

IntelliJ IDEA met en surbrillance une ligne vide comme si elle avait été exécutée IntelliJ IDEA met en surbrillance une ligne vide comme si elle avait été exécutée

Cela se produit lors du debogage de code decompile (que nous discuterons dans un autre article) ou lorsque le code source ne correspond pas entierement au bytecode que la JVM execute.

Puisque le seul lien entre le bytecode et un fichier source particulier est le nom du fichier et ses classes, le debogueur doit se fier a ces informations, assistees par quelques heuristiques. Cela fonctionne bien pour la plupart des situations ; cependant, la version du fichier sur disque peut differer de celle utilisee pour compiler l’application. En cas de correspondance partielle, le debogueur identifiera les divergences et tentera de les reconcilier plutot que d’echouer rapidement. Selon l’etendue des differences, cela peut etre utile, par exemple, si la seule source que vous avez n’est pas la correspondance la plus proche.

Dans le scenario fortune ou vous avez la version exacte des sources ailleurs, vous pouvez resoudre ce probleme en les ajoutant au projet et en relancant la session de debogage.

Conclusion

Dans cet article, nous avons explore la connexion entre les fichiers sources, le bytecode et le debogueur. Bien que ce ne soit pas strictement requis pour le codage quotidien, avoir une image plus claire de ce qui se passe sous le capot peut vous donner une meilleure comprehension de l’ecosysteme et peut occasionnellement vous aider a sortir de situations non standard et de problemes de configuration. J’espere que vous avez trouve la theorie et les astuces utiles !

Il reste encore de nombreux sujets a venir dans cette serie, alors restez a l’ecoute pour le prochain. S’il y a quelque chose de specifique que vous aimeriez voir couvert, ou si vous avez des idees et des commentaires, j’aimerais avoir de vos nouvelles !

all posts ->