Java Performances (3/3) – Optimisation de code

L’optimisation de code est une phase importante dans l’amélioration des performances d’une application. Néanmoins, elle doit être effectuée avec la plus grande attention. Si une application fonctionne et qu’aucun point de contention n’a été identifié, optimiser le code est une erreur.

L’optimisation peut réduire la lisibilité du code et ajouter des lignes de code uniquement dans l’objectif d’améliorer les performances. Ceci peut complexifier une application, la rendant plus difficile à maintenir et à débugger. Par conséquent, l’optimisation du code est souvent effectuée à la fin de la phase de développement.

Cet article appartient à la suite d’article (Configuration de la JVM, Dumps mémoire et Optimisation de code) qui a pour objectif de présenter les différentes étapes d’analyse d’un problème de performances d’une application en production sous un Système d’Exploitation de type Unix, en utilisant une approche bottom-up et ceci en nous focalisant uniquement au niveau de la JVM.

Donald Knuth résume en une phrase le risque que l’on encoure si nous développons en pensant uniquement en terme de performances : « Nous devrions oublier les petites améliorations, dans 97% des cas l’optimisation prématurée est à l’origine de tous les maux. »

Analyse de code statique

L’analyse statique est utilisée pour repérer des erreurs formelles de programmation ou de conception, d’aider à prioriser les points les plus critiques pour l’application, mais aussi pour déterminer la facilité ou la difficulté à maintenir le code.

Pour effectuer cette analyse, les outils présentés ci-dessous peuvent être simplement ajoutés à un pom Maven (cf. Fig. 1).

PMD

PMD [1] est un outil d’analyse du code source permettant d’identifier les problèmes suivants :

  • Bugs possibles – try/catch/finally/switch blocks vides
  • Code mort – variables, paramètres et méthodes privées non utilisées
  • Expressions if/while vides
  • Expressions trop compliquées – expressions if inutiles, boucles for pouvant être modifiées en while
  • Code non optimisé – gaspillage mémoire quant à l’utilisation de String/StringBuffer par exemple
  • Classes ayant un nombre cyclomatique trop élevé
  • Code dupliqué – du code copié/collé peut aussi signifier des bugs dupliqués, ainsi qu’une maintenabilité diminuée

Findbugs

Findbugs [2] est un outil d’analyse de byte-code. Il recherche des bugs en identifiant des patterns connus pouvant être/provoquer des bugs.

Intégration dans un pom Maven

    <project...>
		<!-- ... -->

        <build>
			<plugins>
				<plugin>
					<groupId>org.apache.maven.plugins</groupId>
					<artifactId>maven-site-plugin</artifactId>
					<version>3.1</version>
					<configuration>
						<reportPlugins>
							<plugin>
								<groupId>org.apache.maven.plugins</groupId>
								<artifactId>maven-pmd-plugin</artifactId>
								<version>2.7.1</version>
								<configuration>
									<targetJdk>1.7</targetJdk>
									<linkXref>true</linkXref>
								</configuration>
							</plugin>
							<plugin>
								<groupId>org.codehaus.mojo</groupId>
								<artifactId>findbugs-maven-plugin</artifactId>
								<version>2.5.2</version>
							</plugin>
						</reportPlugins>
					</configuration>
				</plugin>
			</plugins>
		</build>

		<!-- ... -->
    </project>
Fig. 1) Extrait d’un pom Maven intégrant PMD et Findbugs
        # mvn clean install site -DskipTests
        
Fig. 2) Génération du rapport d’analyse de code statique

Il suffit ensuite d’aller dans <projet maven>/target/site et d’ouvrir le page project-reports.html .

Fig. 3) Rapport d’analyse de code statique (CPD faisant parti de PMD)

14 exemples d’optimisations de code

Dans la suite de cet article, sont détaillées 14 optimisations de code que je considère comme les plus importantes et permettant soit un gain non négligeable en terme de performances, soit apportant une meilleure lisibilité du code.

Cette liste est bien évidemment non exhaustive. Vous trouverez, dans la section Ressources, divers articles permettant de la compléter.

Concaténation de chaînes de caractères

Concaténer des chaînes de caractères est très simple en utilisant l’opérateur ‘+’ ( String.concat() ), malheureusement il peut poser quelques problèmes en terme de performances [3]. Un objet de type String étant immuable, une nouvelle instance sera créée à chaque concaténation.

L’utilisation de l’opérateur ‘+’ doit donc être limitée à la concaténation de 2 ou 3 opérandes. Dans tous les autres cas, il est conseillé d’utiliser un StringBuilder et ses méthodes append() .

        public void method(final String name) {
			final StringBuilder string = new StringBuilder("Hello, ");
			string.append(name);
			string.append(" from Soat")
			// ...
        }
        

Convertir une chaîne de caractères en primitif

Si vous souhaitez convertir une chaîne de caractère en primitif, il est préférable d’utiliser la méthode parseX() des classes Integer, Long, Float, Double et Boolean, où X est le nom de la classe, au lieu de la méthode valueOf(), qui retourne une nouvelle instance.

Bien évidemment, si vous souhaitez obtenir un objet, vous devrez utiliser valueOf(). Néanmoins, il est bon de garder à l’esprit qu’une telle conversion est couteuse.

        // Incorrect
        public int getValue(final String string) {
			return Integer.valueOf(string).intValue();
        }

        // Correct
        public int getValue(final String string) {
			return Integer.parseInt(string);
        }
        

Duplication de tableaux

Pour dupliquer un tableau, il est préférable d’utiliser la méthode System.arraycopy() qui fait appel à du code natif.

        public int[] method(final int[] array) {
			final int length = array.length;
			final int[] copy = new int [length];
			System.arraycopy(array, 0, copy, 0, length);
			return copy;
        }
        

Classe sérialisable

La sérialisation étant un processus coûteux, les champs qui n’ont pas besoin d’être sérialisés, tels que ceux issus de calculs, doivent être transient .

        public class TransientKeyWord implements Serializable {
			private transient String field1;
			private String field2;
			private String field3;

			public TransientKeyWord(final int field2, final int field3) {
				this.field2 = field2;
				this.field3 = field3;
				this.field1 = field2 + field3;
			}
        }
        

Assignations Composites

Utiliser des assignations composites a plusieurs avantages et elles devraient être utilisées autant que possible [4].

Le principal avantage est une simplification du code, aussi bien pour le développeur que pour le compilateur, bien qu’aujourd’hui il soit capable, en partie, d’optimiser de telles assignations.

De plus, comme vous pouvez le constater dans l’exemple qui suit, l’assignation composite évite d’effectuer des casts.

        // Sans assignement composite
        public void method(final byte[] array, final int value) {
			for (int i = 0; i < array.length; i++) {
				array&#91;i&#93; = (byte)(array&#91;i&#93; + value);
			}
        }

        // Avec assignement composite
        public void method(final byte&#91;&#93; array, final int value) {
			for (int i = 0; i < array.length; i++) {
				array&#91;i&#93; += value;
			}
        }
        &#91;/sourcecode&#93;

</div>

<h3>Classe de constantes et/ou de méthodes statiques</h3>

Une classe contenant uniquement des constantes et/ou des méthodes statiques ne doit pas pouvoir être instanciée et doit donc avoir un constructeur privé.

<div>
        public class ASCIIUtils {
			// ...

			public final static char ZERO = 0x30;

			// ...

			private ASCIIUtils() {
				// Constructeur non utilisé
			}

			public static String charArrayToString(final char[] characters) {
			// ...
				return null;
			}

			// ...
        }
        

Mot clé final

Pour rendre le code plus lisible et pour éviter certaines erreurs, utiliser le mot clé final est toujours une bonne idée.

Classe immuable

        /*
         * La classe Soatien n’est pas héritable et une fois instanciée,
         * elle ne peut pas être modifiée.
         */
        public final class Soatien {
			private final String firstName;

			private final String lastName;

			public Soatien(final String firstName, final String lastName) {
				super();
				this.firstName = firstName;
				this.lastName = lastName;
			}

			public final String getFirstName() {
				return this.firstName;
			}

			public final String getLastName() {
				return this.lastName;
			}
        }
        

Variables locales

        public void method(final Soatien soatien) {
			final String soatienLastName = soatien.getLastName();
			// ... Manipule la chaîne de caractères soatienLastName
			// sans pouvoir la modifier localement ...
        }
        

Appels de méthodes dans une boucle

Une méthode retournant toujours le même résultat, indépendamment des objets sur lesquels on itère, doit impérativement être appelée à l’extérieur d’une boucle.

        public void method(final List<String> list) {
			Object obj = this.doSomething();
			for (int i = 0; i < list.size(); i++) {
				// ... Utilisation de obj
			}
        }
        &#91;/sourcecode&#93;

</div>

A noter qu'une méthode ayant pour seul objectif de retourner une valeur, telles que les méthodes <em>size()</em> ou <em>length()</em> - respectivement applicables aux listes et maps, et aux chaînes de caractères - sont "inlinées" par le compilateur JIT de la JVM HotSpot.

Par conséquent, en terme de performance, appeler ces méthodes lorsque l'on en a besoin ou utiliser une variable est strictement identique.

<h3>Itérer sur une Map</h3>

<div>
        public void method(final Map<String, String> map) {
			final Set<Entry<String, String>> entries = map.entrySet();

			for (Entry<String, String> entry : entries) {
				System.out.println(entry.getKey() + " = " + entry.getValue());
			}
        }
        

Retirer un élément d’une map

        public void method(final Map<String, String> map) {
			final Iterator<Entry<String, String>> iterator = map.entrySet().iterator();

			while (iterator.hasNext()) {
				final Entry<String, String> pairs = iterator.next();
				System.out.println(pairs.getKey() + " = " + pairs.getValue());
				iterator.remove();
			}
        }
        

Retirer un élément d’une liste

        public void method(final List<String> list) {
			final Iterator<String> iterator = list.iterator();

			while (iterator.hasNext()) {
				final String value = iterator.next();
				System.out.println(value);
				iterator.remove();
			}
        }
        

Méthodes synchronisées vs Blocs synchronisés

Lorsqu’une méthode synchronized est exécutée, l’instance de l’objet auquel elle appartient est lockée par le Thread ayant appelé la méthode. Le mécanisme est identique pour un bloc synchronized sur this .

        // Méthode synchronisée
        public void synchronized method() {
			// ...
        }

        // Bloc synchronisé couvrant toute la méthode
        public void method() {
			synchronized(this) {
				// ...
			}
        }
        

Si le lock complet d’une instance n’est pas nécessaire, – ce qui est le cas dans la majorité des situations – il est possible d’utiliser des mutex indépendants de ou des objets qui ont besoin d’être protégés.

Par exemple, ceci a l’avantage d’avoir un lock protégeant un champ lors d’opérations d’écriture, mais pas pour des opérations de lecture ( updateObject1() et getObject1() ). Néanmoins, pour éviter tout problème de visibilité de la variable par tous les autres Threads, elle doit être « volatile ». Le mot clé volatile ne rend pas l’accès bloquant pour un Thread, comme le fait synchronized. Il permet de signifier à la JVM que la valeur d’une variable sera modifiée par plusieurs Threads. En déclarant une variable volatile, sa valeur ne sera jamais placée dans le cache local du Thread en cours d’exécution. Toutes le opérations d’écriture et de lecture passeront forcément par la mémoire partagée entre les Threads.

De même, si plusieurs champs ont besoin d’être protégés dans une même transaction, il est conseillé d’utiliser un mutex ( updateObject1And2() ). L’utilisation de blocs synchronized imbriqués est très fortement déconseillé, pour éviter tout problème de deadlock.

Généralement, nous souhaitons avoir la possibilité de modifier parallèlement deux champs par deux Thread différents, sans qu’une action ne bloque l’autre. Par conséquent, il est déconseillé de partager les locks portant sur des scopes de protection différents.

		/**
		 * /!\ Attention! Le code suivant n'est qu'une présentation du mécanisme de lock.
		 * A titre d'exercice pour pouvez essayer d'identifier le problème de concurrence présent.
		 * Hint: les méthodes updateObject1() et updateObject1And2() peuvent être exécutées 
		 * parallèlement.
		 */
        public class ThreadSafeClass {
			private MyObject1 myObj1;
			private Object mutex1 = new Object();
			private MyObject2 myObj2;
			private Object mutex2 = new Object();

			// ...

			public void updateObject1() {
				// ...
				synchronized(mutex1) {
					// update myObj1 ...
				}
				// ...
			}

			/**
			 * L'accès à myObj1 devrait être protégé ou volatile, pour éviter tout problème
			 * de visibilité.
			 */
			public MyObject1 getObject1() {
				return this.myObj1;
			}

			public void updateObject1And2() {
				// ...
				synchronized(mutex2) {
					// update myObj1 and myObj2 ...
				}
				// ...
			}

			// ...
        }
        

Avec Java 5 sont apparues les classes java.util.concurrent.locks.Lock [5] et java.util.concurrent.locks.ReentrantLock [6] offrant de meilleures performances que synchronized, ainsi que de nouvelles fonctionnalités. Cependant, la complexité de leur mise en place et de leur maintenance implique une réflexion sur leur utilité, qui se devrait être limitée à des situations bien précises. [7]

Opérations de réduction

Inutile, puisque le compilateur effectue toutes les optimisations de « strength reduction » nécessaires, mais permet de briller en soirée…

Les opérations de division et de multiplication étant peu performantes, en comparaison à celles d’addition et de soustraction, dans certains cas, nous pouvons utiliser les opérateurs de bits shifting.

public void method() {
int x = 10;
int div = x >> 2; // x / 4;
int sum = x << 1; // x * 2; // ... } [/sourcecode]

Ressources

[1] http://pmd.sourceforge.net/

[2] http://findbugs.sourceforge.net/

[3] http://kaioa.com/node/59

[4] http://programmers.stackexchange.com/questions/134118/why-are-shortcuts-like-x-y-considered-good-practice

[5] http://docs.oracle.com/javase/1.5.0/docs/api/java/util/concurrent/locks/Lock.html

[6] http://docs.oracle.com/javase/1.5.0/docs/api/java/util/concurrent/locks/ReentrantLock.html

[7] http://www.ibm.com/developerworks/java/library/j-jtp10264/

[-] http://fr.slideshare.net/cdman83/performance-optimization-techniques-for-java-code

[-] ftp://ftp.glenmccl.com/pub/free/jperf.pdf

[-] http://www.ssw.uni-linz.ac.at/Research/Papers/Haeubl08Master/Haeubl08Master.pdf

Nombre de vue : 2292

COMMENTAIRES 6 commentaires

  1. Thomas dit :

    Bonjour,

    Merci pour cet article. J’ai 2 remarques :

    Concernant l’appel de méthodes dans le boucles, j’avais lu qu’il vallait mieux laisser “i<tab.length" quand on parcourait un tableau dans la boucle, car ça permettait au JIT de ne pas faire le bound checking lors de l'utilisation de "tab[i]". As-tu fait un benchmark pour évaluer le gain ?

    Concernant les méthodes synchronisées, l'utilisation d'un mutex seulement pour l'accès en écriture à un objet est une erreur (problème de visibilité mémoire + cohérence de l'objet, sauf pour les types primitifs).

    Thomas

  2. Yohan BESCHI dit :

    Salut Thomas,

    ‘length’ n’est pas une méthode mais un attribut ‘final’ de l’array, setté lors de l’instanciation de l’objet. Par conséquent, la partie « Appels de méthodes dans une boucle » ne s’applique pas à ce cas où il faut bien évidemment utiliser array.length directement dans la boucle.

    Judicieuse remarque concernant la partie sur la concurrence. Les précisions nécessaires ont été apportées.

    Yohan

  3. Nicolas dit :

    Bonjour,

    Merci pour cet article!

    Notez que pour l’appel de méthodes dans une boucle le compilateur Java inline le size() et les implémentations size ArrayList et LinkedList renvoient directement le champ size

  4. Yohan BESCHI dit :

    Salut Nicolas,

    Merci pour la remarque. L’erreur a été corrigée.

    Yohan

  5. Julien Sadaoui dit :

    Salut,

    Merci pour cette série d’articles trés intéressant sur les performances Java, c’est souvent un point laissé de côté dans les développements. Surtout que de nombreux outils par défaut sont disponibles pour analyser les performances d’une application Java.
    Hier matin, j’ai eu l’occasion de tester ces différents outils presentés dans les deux premiers articles. J’ai appris pas mal de choses, merci !

    Le dernier article est également trés intéressant, surtout la partie sur les optimisations de code ! Pour les outils d’analyse de code, PMD et findbugs sont utilisés par le tableau de bord Sonar. Couplé avec un outil d’intégration continue (jenkins), Sonar fournit des métriques en continue de la même manière que Maven site.

    Je suis impatient de lire ton deuxième article sur les nouveautés Java 8 !

    Merci.

  6. Haddad dit :

    Merci pour les 3 documents. D’une grande aide.

AJOUTER UN COMMENTAIRE