Le fichier mappé : mieux que la sérialisation ?

(article soumis au club java en juillet 2002)

Gil Francopoulo

 

1 Les SGBDs

Tout d’abord, une mise en garde évidente : le présent article reflète l’avis personnel de l’auteur. Et cet avis n’est pas nécessairement celui des autres membres du club.

Les persistance des objets n’est pas un problème résolu en java.

Quand on a un modèle simple avec un nombre moyen d’objets, on peut se satisfaire d’un stockage via un SGBD relationnel. Mais si l’on a des millions d’objets et que l’on doit effectuer des jointures (c’est bien souvent le cas quand on a un modèle physique très éloigné du modèle conceptuel et que l’on doit recalculer le modèle conceptuel), alors le système s’écroule.

Si on a des objets plus complexes, plus interconnectés ou de l’héritage, il vaut mieux passer à un SGBD objets.

Dans un cas comme dans l’autre, cela passe par l’achat (donc la dépendance) d’un logiciel tiers.

En tout cas, java ne répond pas à cette problématique.

On peut toujours chipoter sur le thème : « est-ce qu’un langage de programmation doit comporter ou non un mécanisme de persistence ? », mais personnellement, je peux répondre « oui » sans hésiter une seconde, tout simplement parce que j’en ai souvent besoin et que la pérénité du langage est bien supérieure à celle des fournisseurs de logiciels tiers.

Notons qu’il existe un projet qui s’appelle JDO, qui sera peut-être intégré au JDK, mais ce ne sera pas avant plusieurs années, donc je n’en parlerai pas.

2 La sérialisation

Si l’on doit gérer un petit nombre d’objets, une autre possibilité est la sérialisation.

Le principe est assez simple : on convertit les objets en un format adapté à la sortie en flux. En anglais, on parle de "marshalling". La désérialisation est le processus inverse qui convertit un objet sérialisé en une instance d’objet. En anglais, on parle de "unmarshalling". Pour qu’un objet soit sérialisé, il doit implémenter l’interface « java.io.Serializable ».

3 Les problèmes posés par la sérialisation

La sérialisation pose trois problèmes :

Problème-1

Quand on désérialise des objets non-structurés ou structurés sous forme arborescente, le mécanisme est trivial. Mais, quand on a affaire à un graphe (avec ou sans cycle) c’est un peu plus délicat : il ne faut pas désérialiser les objets qui l’ont déjà été.

Il faut donc marquer les objets déjà sérialisés afin de ne pas les dupliquer artificiellement sur disque. On utilise pour cela, soit un attribut local de l’objet ou bien un bitSet général, ce qui prend moins de place.

Problème-2

Lors de la désérialisation, le problème se repose dans le sens inverse : a-t-on ou non déjà créé l’objet ? Comment relier les objets entre eux si celui-ci a été créé longtemps auparavant ? En d’autres termes, il faut se livrer à une édition des liens complète.

Problème-3

Mais surtout, cela prend beaucoup de temps quand on a beaucoup d’objets. J’ai eu récemment à gérer 5 millions d’objets. Il était hors de question de les gérer par sérialisation, car cela prenait plusieurs heures pour sérialiser et désérialiser.

4 Qu’est-ce qu’un fichier mappé ?

La technique du fichier mappé est un mécanisme utilisé depuis longtemps par certains développeurs C.

En langage C, les appels systèmes ne sont pas portables :

Sous Unix, il faudra faire :

« open » puis « mmap »

Sous Windows, il faudra faire :

« fOpen32 » puis « fMmap32 »

C’est un peu complexe à mettre en œuvre (et à mettre au point), mais cela marche et un certain nombre de gros logiciels professionnels en tirent parti.

Pour ce qui concerne java, la nouveauté depuis le JDK-1.4 (sorti en version de production en février 2002), c’est qu’on peut faire la même chose en java. Il va sans dire que le code est strictement identique qu’elle que soit la plate-forme.


Après avoir importé les packages qui vont bien (c’est toujours un peu pénible cette affaire) :

	import java.io.*;
	import java.nio.*; 
	import java.nio.channels.*;
	import java.nio.channels.FileChannel.*;

Il suffit des trois lignes magiques suivantes :

	RandomAccessFile raf= new RandomAccessFile(“f”,”rw”);
	FileChannel fc= raf.getChannel(); 
	MappedByteBuffer mbb= fc.map(MapMode.READ_WRITE,0L,fc.size());

Explication des trois lignes:


Ligne 1 : On crée un RandomAccessFile. C’est un fichier qui va nous permettre de nous déplacer dans toute sa longueur. Pour en augmenter la taille, il suffit d’écrire en fin de fichier. On va pouvoir lire et écrire sans autre formalité.

Ligne 2 : On obtient le canal du fichier.

Ligne 3 : On « mappe » le canal sur la mémoire. C’est cette instruction qui est magique. A partir de ce moment, on a une zone mémoire qui est associée à un fichier. L’association en question n’est pas définie strictement dans le langage. Le système d’exploitation (SE) gère l’association via son mécanisme de mémoire virtuelle. En général, ce sont des pages de 8K octets ou plus et dont la montée et la descente sont contrôlées par l’algorithme de gestion de la mémoire virtuelle du SE. Cet algorithme tire parti de la RAM disponible et est assisté par un chip de gestion mémoire prévu à cet effet.

Bref … Ce que l’on sait, c’est que les informations sont montées en mémoire à la demande et que lorsque l’on ferme le fichier, la totalité de la zone figure sur disque.

Notons qu’il est possible d’influencer la stratégie du SE depuis java avec des ordres spécifiques comme « load », mais cela n’a d’intérêt (vis-à-vis du reste de la charge de la machine) que lorsqu’on a plusieurs fichiers mappés et que l’on désire en favoriser l’un plutôt que l’autre. En fait, disons que ces considérations débordent le périmètre du présent article.

Pour plus de détails, vous pouvez consulter : J. Hart, Java J2SE 1.4 Core Platform Update [Wrox Press 2002].

5 L’usage du fichier mappé

Maintenant que le fichier est mappé, on dispose d’une zone d’octets : ce sont des « bytes ». Notons que j’utilise volontairement le terme de « zone » et non de tableau, car ne n’est pas réellement un tableau java.

On va pouvoir se déplacer en spécifiant le déplacement en octets (j’insiste, ce sont des octets).

On va écrire l’objet avec la méthode idoine.

Imaginons que l’on désire inscrire l’entier 7 à la position 36 du fichier, on fera :

	raf.seek(36L);
	raf.writeInt(7);

On notera que le parametre de “seek” est un entier long. C’est heureux, car sinon, on ne pourrait pas gérer des fichiers de taille supérieure à 2 gigas (231 – 1). On n’a pas forcément besoin de gérer des fichiers de cette taille-là tous les jours, mais cela m’est déjà arrivé et j’étais bien content de pouvoir le faire.

Inversement, pour lire, il suffit de faire :

	raf.seek(36L);
	int e= raf.readInt();

6 Un peu de pratique sur la sérialisation

Voici un petit exemple tout simple de sérialisation. Le programme principal prend un paramètre. Si l’on donne le paramètre « creation », l’objet est créé et sérialisé. Si l’on donne le paramètre « consultation », l’objet est désérialisé et interné en mémoire. C’est la classe A qui suit :

import java.io.*;

class A implements Serializable {
	int c1;
  
	public static void main(String[] args){
		String opt= args[0];
		if (opt.equals("creation")) creation();
		else if (opt.equals("consultation")) consultation();
	}
		
	static void creation(){
		try {
			A a= new A();
			a.c1= 7;
			ObjectOutputStream out= new ObjectOutputStream(new FileOutputStream("A.dmp"));
			out.writeObject(a);
			out.close();
		} catch (Exception e){
			e.printStackTrace();
		}
	}
	
	static void consultation(){
		try {
			ObjectInputStream in= new ObjectInputStream(new FileInputStream("A.dmp"));
			A a= (A)in.readObject();
			System.out.println("Aprs rechargement: "+a.c1);
			in.close();
		} catch (Exception e){
			e.printStackTrace();
		}
	}
}

Si l’on a besoin d’un traitement spécial (ce qui est souvent le cas), il faut définir les fonctions d’E/S notamment pour réaliser les opérations d’édition des liens dont il était question au chapitre 3. C’est la classe B qui suit :

import java.io.*;
	
class B implements Serializable {
	int c1;
	
	private void writeObject(ObjectOutputStream out)throws IOException {
    	out.writeInt(c1);
	}
	
	private void readObject(ObjectInputStream in)throws IOException {
		c1= in.readInt();
	}
	
	public static void main(String[] args){
		String opt= args[0];
		if (opt.equals("creation")) creation();
		else if (opt.equals("consultation")) consultation();
	}
	
	static void creation(){
		try {
			B b= new B();
			b.c1= 7;
			ObjectOutputStream out= new ObjectOutputStream(new FileOutputStream("B.dmp"));out.writeObject(b);out.close();
		} catch (Exception e){
			e.printStackTrace();
		}
	}
	
	static void consultation(){
		try {
			ObjectInputStream in= new ObjectInputStream(new FileInputStream("B.dmp"));
			B b= (B)in.readObject();
			System.out.println("Aprs rechargement: "+b.c1);
			in.close();
		} catch (Exception e){
			e.printStackTrace();
		}
	}
}

7 Un peu de pratique sur le fichier mappé

C’est la classe C qui suit :

import java.io.*;
import java.nio.*;
import java.nio.channels.*;
import java.nio.channels.FileChannel.*;
class C {
	int c1;
	
	static void ecrire(RandomAccessFile raf,C c) throws IOException {
		raf.writeInt(c.c1);
	}
	
	static Object lire(RandomAccessFile raf) throws IOException {
		C c= new C();
		c.c1= raf.readInt();
		return c;
	}
	
	public static void main(String[] args){
		String opt= args[0];
		if (opt.equals("creation")) creation();
	    else if  (opt.equals("consultation")) consultation();
 	}
    
	static void creation(){
		try {
			C c= new C();
		 	c.c1= 7;
			RandomAccessFile raf= new RandomAccessFile("C.dmp","rw");
			FileChannel fc= raf.getChannel();
			MappedByteBuffer mbb= fc.map(MapMode.READ_WRITE,0L,fc.size());
			ecrire(raf,c);
			fc.close();
		} catch (Exception e){
			e.printStackTrace();
		}
	
	static void consultation(){
		try {      
			RandomAccessFile 
			raf= new RandomAccessFile("C.dmp","rw");
			FileChannel fc= raf.getChannel();
			MappedByteBuffer mbb= fc.map(MapMode.READ_WRITE,0L,fc.size());
			C c= C)lire(raf);
			System.out.println("Après rechargement: "+c.c1);
			fc.close();
		} catch (Exception e){
			e.printStackTrace();
		}
	}
}

8 Discussion

Les trois exemples de classe ne sont destinés à servir de modèle à une gestion de persistence d’un graphe. Ils sont juste présentés pour établir une comparaison entre la sérialisation et le fichier mappé.

Ces deux mécanismes sont très différents.

La question qui est souvent posée est : « Est-ce que tous les objets sont chargés en mémoire ? »

Via la sérialisation, la réponse est clairement « oui », puisque les objets sont internés par création en début de session.

Via le fichier mappé, la réponse est « non ». Au début de session, on mappe le fichier : on prépare le terrain. Au cours de la session, le fichier est chargé à la demande (page par page) et les objets sont créés un par un à la demande. Les difficultés d’édition de liens (évoquées au chapitre 3) n’existent plus.

Ce qui peut paraître étrange, c’est que l’on arrive à gérer des millions d’objets comme cela. Et bien justement : pour gérer de grandes bases d’objets, il faut prendre des techniques simples. Si l’on utilise des moyens tortueux, on n’y arrive pas.

9 Conclusion

Le lecteur perspicace aura peut-être comprit qu’il est possible de se servir d’un fichier mappé pour implémenter un véritable système de gestion d’objets avec des index, des relations, des listes, de l’héritage et des transactions etc. C’est d’ailleurs comme cela que fonctionnent certains SGBD objets du commerce (présent ou passé). Mais ce n’est pas une mince affaire à développer. Ce sera peut-être le sujet d’un autre article ?