L'API java.nio du JDK 1.4

Romain Guy (Gfx)

De nombreux programmeurs critiquent Java pour des raisons de performances. Malgré les améliorations constantes de nos ordinateurs et des JVM, ce reproche perdure. Nous allons cependant voir que connaître les API suffit largement pour obtenir d'excellentes performances.

- Titre : L'API java.nio du JDK 1.4

- Auteur : Romain Guy (Gfx)

- Parution : 11 juin 2007

- Licence : Cette création est mise à disposition sous un contrat Creative Commons (Paternité - Partage des Conditions Initiales à l'Identique).

Sommaire

- I. Introduction

- II. Opérations de lecture

- III. Les nouvelles classes

- IV. Buffer directs et indirects

- V. Screenshots

I - Introduction

Aujourd'hui, les solutions pour accélérer un programme écrit en Java ne sont pas nombreuses. Nous pouvons changer de processeur pour un plus performant, utiliser l'une des dernières machines virtuelles ou compiler le code source en mode natif. Mais tout ceci se révèle bien souvent superflu. Prenons l'exemple des deux codes source populate1.java et populate2.java. Ceux-ci créent une chaîne de caractères contenant 10 000 entiers tirés au hasard. La première implémentation utilise un objet String et demande 39 secondes d'exécution sur un Pentium IV 1.6 Ghz nanti de 256 Mo de DDRAM. La seconde quant à elle, sur la même configuration, ne requière que 0.03 secondes. Le secret réside dans l'emploi de la classe StringBuffer, plus appropriée.

II - Opérations de lecture

L'exemple présenté ci-dessus s'avère particulièrement probant. Sachez qu'il en va de même pour de nombreux types d'opérations et notamment les opérations de lecture de fichiers. Depuis les premières versions du JDK, les classes de support des entrées/sorties résidaient dans le paquetage java.io. Avec l'apparition du JDK 1.4 est né le paquetage java.nio, acronyme signifiant "New Input/Output". Le rôle principal de ce paquetage consiste à améliorer les performances du vieillissant java.io ainsi que d'apporter de nouvelles fonctionnalités telles que le verrouillage des fichiers.
Les performances peuvent augmenter considérablement si l'on emploie cette nouvelle API. Néanmoins, nul n'est besoin de l'utiliser constamment. Pour déterminer si une application nécessite une optimisation de ses opérations de lecture ou d'écriture, nous pouvons faire appel à une option de la machine virtuelle qui réalise une trace de notre programme. Le fichier source SourceCodeLinesCounter0.java contient un programme parcourant récursivement l'intégralité des fichiers portant l'extension .java présents dans le répertoire courant et affichant le nombre de lignes total. L'exécution de notre outil sur les sources du JDK 1.4 nécessite 111 secondes. Que pouvons-nous faire pour améliorer ceci ? Exécutons tout d'abord l'option de trace :

java -Xrunhprof:cpu=sample,depth=15,file=prof0.txt SourceCodeLinesCounter0

Cette commande génère un fichier nommé prof0.txt dans lequel nous pouvons lire les lignes suivantes :

1 87.76% 87.76%     724    19 java.io.FileInputStream.read
2  5.45% 93.21%      45    20 java.io.FileInputStream.open

Ceci signifie que 87% du temps d'exécution est alloué à l'exécution de la méthode read() de la classe FileInputStream. Nous allons tâcher d'optimiser les performances à cet endroit. En parcourant le contenu de java.io, nous découvrons la présence de la classe BufferedReader censée accélérer les opérations de lecture. Le code source SourceCodeLinesCounter1.java emploie cette dernière. Le temps d'exécution tombe alors à 13 secondes. Pouvons-nous faire mieux ? Avec les API antérieures au JDK 1.4, non, à moins d'invoquer la méthode readLine() plutôt que de faire un parcours caractère par caractère.

III - Les nouvelles classes

La nouvelle API met à disposition quatre nouvelles familles de classes : les Buffer, séquences linéaires de données, les Charset, faisant la transition entre caractères Unicode et séquences d'octets, les Channels, qui représente des tuyaux de communication bi-directionnels et enfin les Selector, employés pour des opérations d'E/S asynchrones sur des threads. Cette API introduit notamment la notion d'opérations de lecture et d'écriture non bloquantes. Nous ne nous préoccuperons ce mois-ci que des Buffer et Channel.
La classe MappedByteBuffer sert à créer une représentation complète d'un fichier en mémoire. De ce fait, son emploi autorise au système à procéder à des optimisations importantes et les temps d'accès s'en trouvent amoindris. Le fichier source SourceCodeLinesCounter2 fait appel à cette classe. Voici un exemple de son utilisation :

in = new FileInputStream("fichier").getChannel();
int size = (int) in.size();
bytes = in.map(FileChannel.MapMode.READ_ONLY, 0, size);
char c = (char) bytes.get();

Les Buffer de l'API NIO nécessitent de créer un nouveau Channel que nous obtenons grâce à une instance de FileInputStream. La méthode map() des canaux autorise le mappage en mémoire d'une portion définie d'un fichier, ici la totalité. Cette implémentation diminue encore le temps d'exécution de notre programme pour le porter à 10 secondes.
Les Buffer possèdent des propriétés très intéressantes principalement en ce qui concerne le positionnement du pointeur de lecture/écriture. Nous pouvons aisément définir ou récupérer sa position courante. Un Buffer possède une capacité maximale et une limite de remplissage. Nous pouvons par exemple écrire 10 octets dans un buffer d'une capacité de 512 octets. Sa limite sera alors 10 octets. Trois méthodes se révèlent utiles : clear(), flip() et rewind(). La dernière replace le pointeur de lecture en début de séquence. La seconde place la limite sur la position courante et renvoie le pointeur en début de séquence. Enfin, clear() renvoie le pointeur au début et place la limite en fin de séquence. Ces notions de position se veulent extrêmement importantes puisque chaque action de lecture (par l'entremise des méthodes get()) ou d'écriture (par l'intermédiaire des méthodes put()) fait avance le pointeur.

IV - Buffer directs et indirects

Outre le tampon de mappage en mémoire, le nouveau paquetage propose des tampons directs ou indirects comme par exemple ByteBuffer ou FloatBuffer. Lorsque le développeur utilise un tampon direct, la machine virtuelle s'efforce de réaliser des opérations d'E/S natives directement dessus, sans passer par un tampon intermédiaire, spécifique à la JVM. Malgré leur rapidité d'exécution en règle générale, leur utilisation ne se justifie que dans le cas de manipulation de gros fichiers. En effet, le ramasse-miettes peut éprouver des difficultés à nettoyer ces objets et leur prolifération est vivement déconseillée. La création d'un tampon direct se révèle simple :

ByteBuffer bytes = ByteBuffer.allocateDirect(1024);
FileChannel in = new FileInputStream("fichier").getChannel();
int read = in.read(bytes);
char c = (char) bytes.get();

Pour créer un tampon indirect, il suffit d'appeler la méthode allocate() à la place de allocateDirect(). Ceux-ci font moins appel à des opérations purement natives du système. Toujours dans l'exemple de notre utilitaire de comptage de lignes, nous constatons que les tampons de type direct diminuent un petit peu les performances au regard de celles obtenues par le MappedByteBuffer. Le temps d'exécution est alors de 11 secondes. Enfin, les tampons indirects donnent un temps équivalent à celui obtenu lors de l'emploi d'un BufferedReader, soit 13 secondes.
Ces divers tests montrent bien que l'optimisation d'un programme écrit en Java ne demande pas nécessairement de grands efforts. La nouvelle API NIO du JDK 1.4, réellement simple d'emploi, autorise des gains de performances non négligeables et ce pour un coût de développement quasiment nul. Ne vous fiez donc plus aux personnes clamant que Java souffre de lenteur mais apprenez-leur plutôt à utiliser correctement son API.

V - Screenshots

La nouvelle API des Entrées/Sorties est apparue dans le JDK 1.4.

Résultats de nos tests avec les différentes méthodes d'Entrées/Sorties.

Exemple de contenu d'un tampon (direct ou indirect).