Outils avancés
Dans ce dernier cours, nous allons examiner des outils avancés en génie logiciel, pour analyser et améliorer la performance à l'exécution, rétro-concevoir le bytecode et protéger le bytecode contre la rétro-ingénierie.
Profileurs
Optimiser votre code pour la performance est un objectif noble, cependant...
Danger
L'optimisation prématurée est la racine de tous les maux.
-- Sir Tony Hoare / Donald Knuth
Quelle est l'essence de cette affirmation ?
- Optimiser "à l'œil" est une mauvaise idée :
- Les "bottlenecks" (goulots d'étranglement) ne sont pas là où on pense :
- Le code peut sembler complexe, mais consommer peu de ressources. Par exemple, un algorithme complexe rarement appelé n’est pas pertinent.
- Une instruction peut paraître anodine, mais un appel excessif peut en faire un gros consommateur de
ressources :
Sysouts
, opérationsIO
, fonctions de hachage, création d’objets.
- Ce que vous voyez n’est pas ce qui est exécuté :
- Le compilateur effectue toutes sortes d’optimisations "en coulisses". Le code que vous écrivez n’est pas nécessairement celui qui est exécuté.
- Une optimisation manuelle supposée peut ne générer aucun gain de performance.
- Si les optimisations nuisent à la lisibilité du code, c’est généralement la lisibilité qui est plus importante pour le succès du projet : Un programme plus rapide mais incompréhensible a moins de valeur qu’un programme plus lent mais maintenable.
- Les "bottlenecks" (goulots d'étranglement) ne sont pas là où on pense :
Optimisations efficaces
Si vous voulez optimiser, utilisez un outil d’analyse. Ne modifiez jamais votre code sans mesure empirique.
Consommation des ressources
Les goulots d'étranglement peuvent provenir de différentes limitations en ressources :
- Goulot computationnel :
- Un processeur plus rapide est coûteux et apporte un gain marginal.
- Un code plus efficace peut être peu coûteux et produire un gain énorme : exemple : passer du bubble-sort au quicksort.
- Goulot mémoire :
- Ajouter de la RAM ou du stockage est coûteux.
- Remplacer par une structure de données plus efficace peut être très économique.
- Goulot réseau :
- Une meilleure connexion ou de meilleurs switches réseau coûtent cher.
- Un protocole de communication plus léger peut être une alternative simple et économique.
Pas de goulot = pas besoin d'optimisation
Petit rappel : avant d'améliorer votre code, vous devez d'abord identifier le goulot d'étranglement. Sinon, il n'y a aucun gain de performance, juste un code plus complexe.
Exemples où la consommation de ressources n'est pas un problème :
- Vous pouvez acheter le même ordinateur personnel avec plus de RAM pour 300 $ de plus, mais dans 98 % des cas cela ne fera aucune différence.
- Jouer à Halma avec des IA MadMax sur un petit plateau. Cela ne prend que quelques itérations, moins d'une seconde de temps CPU.
- HTTP/REST n'est pas le protocole réseau le plus efficace, mais il est polyglotte et facile à apprendre. 85 % des services infonuagiques l'utilisent.
Notions de base sur les profileurs
Les profileurs sont comme des boîtes noires. Ils suivent le comportement d’un programme à l’exécution et enregistrent ce qui se passe à l’intérieur de la JVM.
Quel autre outil permet une analyse du programme basée sur l'exécution ?
Les débogueurs. Les débogueurs et les profileurs sont tous deux des outils d’analyse dynamique. Cependant, les débogueurs interfèrent avec l'exécution (points d’arrêt, modification de variables), alors que les profileurs non. L’intérêt des profileurs est d’observer silencieusement et de collecter ce qui est nécessaire pour analyser la performance.
- Techniquement, les profileurs interfèrent tout de même avec l’exécution :
- Il est impossible d'observer sans consommer de ressources, donc observer modifie déjà légèrement l’exécution.
- Cela dit, les profileurs professionnels ont une empreinte minimale. Le biais est négligeable.
Dans le cadre de ce cours, nous utiliserons le profileur CPU intégré à IntelliJ. Des alternatives pour d'autres métriques sont YourKit (commercial, sans licence éducative gratuite) et le profileur intégré au JVM HPROF.
Échantillonnage
- Mesurer quelle partie de votre code source consomme quel pourcentage du CPU n’est pas trivial :
- Regarder simplement le CPU ne suffit pas : le planificateur de l'OS alterne rapidement entre les tâches système, votre programme, et d'autres tâches.
- Imprimer des timestamps entre appels de méthode est une mauvaise idée : cela ralentit le programme et produit trop de sortie.
- La plupart des profileurs CPU fonctionnent par échantillonnage de la pile d'appels :
- Définition pile d'appels : liste des appels de méthode non terminés qui ont mené à l'exécution actuelle.
- Toutes les
x
millisecondes, prendre une capture de la pile complète.- La méthode en haut est celle qui consomme actuellement du CPU.
- Les méthodes en dessous ne consomment pas, elles font partie du chemin menant à l'appel.
- Plus une méthode apparaît en haut, plus elle consomme de CPU.
La fréquence d'échantillonnage est un compromis
Comme l’échantillonnage se fait à intervalles réguliers, il faut choisir la bonne fréquence. Si elle est trop élevée, cela interfère avec l'exécution. Si elle est trop basse, on risque de manquer des appels courts et de perdre en précision.
Exemple d’échantillonnage
- Supposons que notre programme contient les fonctions suivantes :
main()
foo()
bar()
f1()
f2()
f0()
b0()
b1()
- Nous échantillonnons la pile d'appels à l'exécution, après
10
,20
,30
,40
et50
millisecondes :
10ms | 20ms | 30ms | 40ms | 50ms |
---|---|---|---|---|
f0 |
b0 |
b0 |
||
f1 |
f2 |
b2 |
f1 |
b2 |
foo |
foo |
bar |
foo |
bar |
main |
main |
main |
main |
main |
Qui consomme le plus de cycles CPU ?
b0
. Bien que main
apparaisse le plus souvent dans les listes, nous nous intéressons uniquement aux fonctions les plus hautes (par pile), pour suivre la consommation CPU.
Graphiques de flammes
- Les longues listes de piles d'appels échantillonnées peuvent être difficiles à analyser.
- Une façon de contourner ce problème est une représentation visuelle appelée Flame Graph.
- Les graphiques de flammes sont une variante chronométrée, triée et fusionnée de la liste originale.
Teinte
- La reconnaissance de motifs humains fonctionne beaucoup mieux avec des couleurs qu'avec du texte.
- La liste d'échantillons précédente peut être teintée ainsi :
- Malheureusement, pour les longues listes, cela ne nous aide pas vraiment à repérer la méthode la plus consommatrice.
- Pour des milliers d'échantillons, nous verrions juste du bruit coloré, il serait difficile de "voir" quelque chose d'utile.
Ordre
- Le tri sert à regrouper les mêmes méthodes ensemble.
- Cependant, nous ne voulons pas briser les piles d'appels individuelles.
- Nous ne trions que l'ordre des colonnes, pas ce qui se passe à l'intérieur de chaque colonne.
- Cela se traduit par : nous ignorons l'ordre des échantillons collectés, mais nous laissons chaque échantillon intact.
- L'algorithme :
- Trier par ordre alphabétique
- Trier du bas vers le haut
Fusion
- Enfin, les blocs (représentant nos fonctions) ont toujours la même taille.
- Si nous fusionnons les fonctions identiques voisines, nous pouvons avoir une idée de la pertinence de la consommation CPU.
- De plus, puisque nous nous intéressons principalement à la "surface" de notre graphique de flammes, nous pouvons ajouter une barre noire à la fraction la plus haute.
- Important : les rapports de consommation sont cumulatifs !
- Une fonction
f
peut être appelée par plusieurs fonctions parentes. - Dans le graphique de flammes, cette fonction
f
n'est pas fusionnée en un seul bloc (car le tri préserve les piles individuelles). - Le survol d'une fonction spécifique indique toujours la fraction des cycles CPU pour cette fonction, quel que soit son appelant.
- Une fonction
Dans le graphique de flammes, b0
vient avant f0
. Que signifie cela ?
Absolument rien. Nous avons ordonné les colonnes par ordre alphabétique. La représentation résultante ne nous dit rien sur l'ordre chronologique des appels de méthode, mais uniquement sur la consommation proportionnelle du CPU.
Post-profiler
- Ce n'est pas parce qu'une fonction consomme la majorité des cycles CPU qu'elle peut être optimisée.
- Le graphique de flammes ne nous indique que là où les optimisations auraient le plus d'impact. Il ne nous dit pas si
des optimisations sont possibles.
- Il est probablement toujours bon de concentrer notre attention sur les méthodes suggérées par le graphique de flammes.
- À l'inverse, tout effort pour optimiser des fonctions qui ne sont pas exposées à la surface du graphique de flammes est probablement vain.
Profiling avec IntelliJ
IntelliJ dispose d'une série d'outils de profilage intégrés, dont une visualisation du temps CPU sous forme de graphique de flammes.
Démarrer le profiler
- Puisque les profileurs sont des outils d'analyse dynamique, nous devons d'abord exécuter quelque chose.
- Cela peut être notre programme principal ou un test.
- Profiler des exécutions très courtes peut être délicat, car l'exécution risque de ne pas produire suffisamment d' échantillons. Le graphique de flammes peut être déroutant pour des exécutions très courtes, car il y a toujours des méthodes internes de Java qui ont tendance à encombrer la sortie pour des exécutions courtes.
- Les tests d'intégration sont généralement meilleurs que les tests unitaires. Des IA concurrentes dans un jeu sont excellentes, tant que les IA ne sont pas trop sophistiquées et ne consomment pas une ressource significative elles-mêmes. #### Lire le graphique de flammes IntelliJ
La coloration dans le graphique de flammes IntelliJ est un peu différente :
- Violet foncé : Code natif (éléments de la JVM, rien à faire à ce sujet)
- Violet clair : Code de bibliothèque (appels de bibliothèque, rien à faire à ce sujet, sauf appeler moins souvent)
- Orange : Nos propres méthodes (Ici, nous pouvons optimiser)
Développez le cadre JUnit runner lors de l'invocation du code via des tests
Lors du profilage des tests unitaires, le profiler a tendance à dissimuler les détails dans un cadre JunitCore.run
. Développez le cadre et faites défiler jusqu'en haut pour voir où votre propre code consomme des cycles CPU.
La visualisation restante est identique à la précédente :
- Barre plus large : de nombreuses fusions de la même méthode. La méthode apparaît dans de nombreuses piles échantillonnées.
- Surface supérieure : la consommation réelle du CPU, sur une méthode spécifique sans sous-appels supplémentaires.
Des informations supplémentaires sont disponibles au survol, notamment la consommation cumulative d'une méthode, sur l'ensemble du graphique de flammes (consommation ajoutée de la même méthode, avec différents appelants)
Les cases du dessus sont violettes, cela signifie-t-il que je ne peux pas optimiser mon code ?
Si les cases du dessus sont violettes, cela signifie que la plupart du temps est consommé par des méthodes internes de Java. Bien qu'elles ne soient pas votre code, et bien que vous ne puissiez pas optimiser ces méthodes, vous pouvez enquêter si tous ces appels de méthode ont du sens, ou s'ils peuvent être remplacés par des appels de méthode plus efficaces.
Retour au code
- Finalement, ce qui nous intéresse, c'est le code, pas les graphiques.
- Nous voulons une manière pratique de repérer les problèmes de performance dans notre code.
- Heureusement, IntelliJ fait un excellent travail pour relier les résultats du graphique de flammes aux lignes de code.
- Une fois que nous avons exécuté le profiler, de nouvelles informations apparaissent dans la liste de code :
Pourquoi pas de plugin Maven ?
Jusqu'à présent, nous avons assez systématiquement ajouté des plugins non seulement à l'IDE, mais aussi au pipeline de construction Maven. Cependant, les profileurs ne sont généralement pas intégrés dans le pipeline de construction. Cela est dû au fait que les informations du profiler ne sont que des indicateurs là où les optimisations de code auront le plus d'impact. Cette métrique est utile pour les développeurs, mais pas pour la maintenance du projet. Les profileurs sont donc rarement présents dans les pipelines de construction ou d'intégration continue (CI).
Décompilateurs
- Depuis le début de ce cours, nous avons toujours zoomé sur le processus de construction, c'est-à-dire passer du code source à un produit livrable.
- Parfois, vous pouvez chercher à faire l'inverse : Jeter un œil à un produit et comprendre comment il fonctionne en interne.
- Revenir du bytecode au code source s'appelle "décompiler".
Avez-vous déjà utilisé un décompilateur dans ce cours ?
Oui ! Lors du débogage des tests unitaires fournis, IntelliJ est revenu du byte-code du test (téléchargé par maven) au code source du test. À mesure que vous avanciez dans les lignes de code du test, vous utilisiez implicitement le décompilateur d'IntelliJ.
Décompilation en action
Ensuite, nous allons compiler, puis décompiler une petite classe HelloWorld
et essayer de repérer les effets. Pour
cette démonstration, nous utiliserons le CFR (Class File Reader).
class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello, INF2050!");
}
}
Étape par étape :
- Comme d’habitude, on commence par compiler le code source en bytecode Java :
javac HelloWorld.java
-
Cela nous donne le fichier bytecode illisible pour un humain :
HelloWorld.class
:$ xxd -u -p HelloWorld.class | sed 's/..../& /g' CAFE BABE 0000 0042 001D 0A00 0200 0307 0004 0C00 0500 0601 0010 6A61 7661 2F6C 616E 672F 4F62 6A65 6374 0100 063C 696E 6974 3E01 0003 2829 5609 0008 0009 0700 0A0C 000B 000C 0100 106A 6176 612F 6C61 6E67 2F53 7973 7465 6D01 0003 6F75 7401 0015 4C6A 6176 612F 696F 2F50 7269 6E74 5374 7265 616D 3B08 000E 0100 0F48 656C 6C6F 2C20 494E 4632 3035 3021 0A00 1000 1107 0012 0C00 1300 1401 0013 6A61 7661 2F69 6F2F 5072 696E 7453 7472 6561 6D01 0007 7072 696E 746C 6E01 0015 284C 6A61 7661 2F6C 616E 672F 5374 7269 6E67 3B29 5607 0016 0100 0A48 656C 6C6F 576F 726C 6401 0004 436F 6465 0100 0F4C 696E 654E 756D 6265 7254 6162 6C65 0100 046D 6169 6E01 0016 285B 4C6A 6176 612F 6C61 6E67 2F53 7472 696E 673B 2956 0100 0A53 6F75 7263 6546 696C 6501 000F 4865 6C6C 6F57 6F72 6C64 2E6A 6176 6100 2000 1500 0200 0000 0000 0200 0000 0500 0600 0100 1700 0000 1D00 0100 0100 0000 052A B700 01B1 0000 0001 0018 0000 0006 0001 0000 0001 0009 0019 001A 0001 0017 0000 0025 0002 0001 0000 0009 B200 0712 0DB6 000F B100 0000 0100 1800 0000 0A00 0200 0000 0700 0800 0800 0100 1B00 0000 0200 1C
-
Finalement, on utilise
CFR
pour reconvertir le bytecode en code source :
Cela produit un nouveau fichier HelloWorld.java
. On est revenus au code source.
Pourquoi le bytecode commence encore par CAFE BABE
?
Ce qui précède est simplement une représentation en encodage hexadécimal. La manière plus naturelle de représenter le bytecode serait en binaire, c’est-à-dire uniquement des 0
et des 1
. On peut le faire avec xxd -b HelloWorld.class | cut -d\ -f2-7
, mais ce serait beaucoup plus verbeux. Passer à l’hexadécimal est pratique, car cela regroupe toujours 4 bits en une valeur hexadécimale. Les 8 premières valeurs en hexadécimal ne sont qu’un easter egg — les mêmes 32 bits sont ajoutés à tout bytecode Java.
Une comparaison approfondie
Si on regarde de plus près le code décompilé, on verra que l’équivalence est seulement sémantique, c’est-à-dire que le code décompilé est équivalent, mais pas identique au code original.
$ icdiff HelloWorld.java outdir/HelloWorld.java
HelloWorld.java outdir/HelloWorld.java
/*
* Decompiled with CFR 0.152.
*/
class HelloWorld { class HelloWorld {
HelloWorld() {
}
/**
* Our HelloWorld program.
*/
public static void main(String[] args) { public static void main(String[] stringArray) {
System.out.println("Hello, INF2050!"); System.out.println("Hello, INF2050!");
} }
} }
Pouvez-vous repérer toutes les différences ?
Nouveau commentaire de classe, nouveau constructeur par défaut, commentaire de la méthode principale supprimé, argument de la méthode principale renommé.
Un autre décompilateur
Des résultats similaires peuvent être obtenus avec le décompilateur Vineflower :
wget https://github.com/Vineflower/vineflower/releases/download/1.11.1/vineflower-1.11.1.jar
mkdir outdir2
java -jar vineflower-1.11.1.jar HelloWorld.class outdir2
cat outdir2/HelloWorld.java
Ce qui produit le code suivant :
class HelloWorld {
public static void main(String[] var0) {
System.out.println("Hello, INF2050!");
}
}
Informations perdues
Pourquoi le décompilateur ne produirait-il pas exactement le code source d'origine ? Parce qu'il ne peut pas !
- Les compilateurs effectuent de nombreuses modifications implicites (et optimisations).
- Ajout de constructeurs par défaut. (Nécessaire à la création de la classe)
- Suppression des commentaires. (Non pertinents pour le comportement du programme)
- Renommage des variables. (Non pertinents pour le comportement du programme)
- ...
- Même si le décompilateur est 100 % précis, revenir du byte-code au code source ne peut pas annuler les modifications effectuées par le compilateur.
- Cependant, si nous voulons vraiment supporter le décompilage, nous pouvons conseiller au compilateur de rester plus proche du code d'origine (par exemple, lorsque nous voulons que notre code soit facilement déboguable par les utilisateurs de la bibliothèque).
Pourquoi ne pas toujours garder les commentaires et les noms d'origine ?
Cela rendrait notre produit final plus volumineux. Les détails internes sont généralement non pertinents pour un utilisateur de bibliothèque ou produit et peuvent être supprimés pour alléger l'utilisation du disque.
Obscurcisseur
- Bien qu'il y ait de bonnes raisons d'instruire le compilateur à intégrer autant d'informations source que possible dans le byte-code, il existe aussi des cas d'utilisation où vous souhaitez que le code produit soit aussi mystérieux et difficile à décompiler que possible.
- C'est notamment le cas pour les produits commerciaux. Imaginez que vous veniez de passer des années et des millions dans le développement d'un produit - la dernière chose que vous voulez, c'est que votre concurrent décompile simplement votre code et vole votre propriété intellectuelle.
- Dans ce cas, nous pouvons étendre artificiellement les modifications naturelles effectuées par le compilateur, en nous
basant sur deux principes :
- Le code doit toujours être fonctionnel, et aussi performant qu'avant.
- Le code doit être inutile lorsqu'il est décompilé.
Mais comment rendre un code inutile pour les autres ? En le rendant difficile à lire !
Exemple d'obscurcissement avec ProGuard
Les obscurcisseurs comme ProGuard agissent sur le bytecode, c'est-à-dire qu'ils vont plus loin que les modifications effectuées par le compilateur. Cependant, leur objectif n'est pas d'optimiser, mais de protéger.
Dans cet exemple, nous allons obfusquer un fichier jar halma
. Nous commençons par construire notre application halma
pour obtenir un fichier jar (mvn clean package
).
- Ensuite, téléchargez l'obscurcisseurs ProGuard :
wget https://github.com/Guardsquare/proguard/releases/download/v7.7/proguard-7.7.0.zip
unzip proguard-7.7.0.zip
- Créez un fichier de configuration :
proguard.pro
-injars /Users/schieder/Desktop/build/HelloWorld.jar
-outjars output.jar
-libraryjars /Users/schieder/.sdkman/candidates/java/current/jmods/java.base.jmod
# Keep main method (Note: it MUST be public)
-keep public class HelloWorld {
public static void main(java.lang.String[]);
}
-dontoptimize
# Optional: Allow optimization
-optimizations !code/simplification/arithmetic
- Exécutez l'obscurcisseurs (depuis le répertoire bin décompressé), en utilisant le fichier de configuration ci-dessus :
Cela crée un nouveau fichier jar : halma-obfuscated.jar
. Si nous jetons un œil à l'intérieur, nous voyons :
ca
└── uqam
└── info
└── solanum
├── a
│ └── a
│ ├── a
│ │ ├── a.class
│ │ ├── b.class
│ │ ├── c.class
│ │ └── d.class
│ ├── b
│ │ ├── a.class
│ │ ├── b.class
│ │ ├── c.class
│ │ ├── d.class
│ │ ├── e.class
│ │ ├── f.class
│ │ └── g.class
│ └── c
│ ├── a.class
│ ├── b.class
│ ├── c.class
│ └── d.class
└── max
└── halma
├── a
│ └── a.class
├── b
│ ├── a.class
│ ├── b.class
│ ├── c.class
│ ├── d.class
│ └── e.class
├── c
│ ├── a.class
│ └── b.class
└── view
└── AdvancedConsoleLauncher.class
Ensuite, nous pouvons essayer de décompiler une classe :
$ cd Desktop
$ for i in $(find ca -name \*class ); do java -jar cfr-0.152.jar --outputdir outdir $i; done
Processing ca.uqam.info.solanum.a.a.a.b
Processing ca.uqam.info.solanum.a.a.a.d
Processing ca.uqam.info.solanum.a.a.a.a
Processing ca.uqam.info.solanum.a.a.a.c
Processing ca.uqam.info.solanum.a.a.c.b
...
Inspection du résultat
Lorsque nous tentons d'inspecter, par exemple, une classe e.java
:
/*
* Decompiled with CFR 0.152.
*/
package ca.uqam.info.solanum.max.halma.b;
import ca.uqam.info.solanum.a.a.b.f;
import ca.uqam.info.solanum.max.halma.b.a;
import ca.uqam.info.solanum.max.halma.b.b;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Set;
public class e extends a {
@Override
protected int a(int n) {
return n * 2 - 1;
}
@Override
protected int b(int n) {
return (n - 1) * 4 / 3 + 1;
}
@Override
protected ca.uqam.info.solanum.a.a.b.b[] a(int n, int n2) {
ArrayList<ca.uqam.info.solanum.a.a.b.b> arrayList = new ArrayList<ca.uqam.info.solanum.a.a.b.b>();
for (int i = 0; i < n2; ++i) {
for (int j = 0; j < n; ++j) {
ca.uqam.info.solanum.a.a.b.b b2 = new ca.uqam.info.solanum.a.a.b.b(j, i);
if (!this.a(b2, n, n2))
continue;
arrayList.add(b2);
}
}
return arrayList.toArray(new ca.uqam.info.solanum.a.a.b.b[arrayList.size()]);
}
Qu'est-ce qui a changé ?
Nous ne savons pas ce que ce code était avant, mais il semble que tous les noms de classes, de méthodes et de variables ont été renommés en a, b, c, ...
Test de fonctionnalité
L'obscurcissement ne doit pas casser la fonctionnalité de notre produit. C'est-à-dire que nous devons toujours pouvoir jouer à notre jeu en utilisant le jar obfusqué :
$ java -jar halma-obfuscated.jar 2 Max Ryan Quentin
y\x | 00 01 02 03 04 05 06 07 08
----+------------------------------------
00 | [ ] [1]
01 | [ ] [1]
02 | [ ] ( ) [1]
03 | ( ) ( )
04 | ( ) ( ) ( )
05 | [0] ( ) ( ) [ ]
06 | [0] ( ) ( ) ( ) [ ]
07 | [0] ( ) ( ) [ ]
08 | ( ) ( ) ( )
09 | ( ) ( )
10 | [ ] ( ) [2]
11 | [ ] [2]
12 | [ ] [2]
It's Max's turn. Max, your options are:
Available moves:
00: (01,05) -> (02,04) 01: (01,05) -> (02,06) 02: (00,06) => (02,04)
03: (00,06) => (02,08) 04: (01,07) -> (02,06) 05: (01,07) -> (02,08)
Enter move ID:
Littérature
Inspiration et lectures supplémentaires pour les esprits curieux :
- Brendan Gregg (inventor of flame graphs): On flame graphs
- JetBrains: IntelliJ Profiler guide
- Lee Benfield: Class File Loader Java Decompiler
- Jasmine Karthikeyan: Vineflower Decompiler
- Guardsquare: ProGuard on GitHub