Désérialisation et cycle

Je vais vous exposer dans ce billet le cas de la désérialisation objets contenant des références circulaires et les problèmes que cela peut poser.

Introduction

Si deux objets :

se référencent l’un et l’autre et qu’une des deux références est présente dans une collection de type Set, c’est à dire une collection assurant l’unicité des ses éléments et donc utilisant la méthode hashCode()

que la méthode hashCode() utilise (comme les bonnes pratiques le définissent) un membre non primitif immuable

sont désérialisés

Alors on peut obtenir un magnifique NullPointerException (NPE).
Ce cas de figure est en réalité assez fréquent, on peut le retrouver sur un mapping Hibernate/JPA bidirectionnel de type OneToMany et ManyToOne dans une architecture où les objets transitent entre le client lourd et le serveur via du remoting utilisant la sérialisation/désérialisation Java (Spring HTTP Remoting).
Effectuer une modélisation avec des références circulaires n’est certes pas idéal mais plutôt pratique dans la vie réelle des projets.

Démonstration

Illustrons le cas avec un test unitaire simplifié au maximum.

Modèle

On va s’appuyer sur un modèle simple composé d’un parent, la classe Root contenant une association sur ses enfants, la classe Child.

public class Child implements Serializable {
	private static final long serialVersionUID = 1L;
	// Immuable et unique
	private String code;
	// ManyToOne
	private Root root;
	public Child(String code, Root root) {
		this.code = code;
		this.root = root;
	}
	public String getCode() {
		return code;
	}
	public Root getRoot() {
		return root;
	}
	@Override
	public int hashCode() {
		// KO - NPE
		return code.hashCode();
	}
	@Override
	public boolean equals(Object obj) {
		if (this == obj)
			return true;
		if (obj == null)
			return false;
		if (getClass() != obj.getClass())
			return false;
		Child other = (Child) obj;
		if (code == null) {
			if (other.code != null)
				return false;
		} else if (!code.equals(other.code))
			return false;
		return true;
	}
}
public class Root implements Serializable {
	private static final long serialVersionUID = 1L;
	// OneToMany
	private Collection<Child> children;
	public Collection<Child> getChildren() {
		return children;
	}
	public void setChildren(Collection<Child> children) {
		this.children = children;
	}
}

On a donc bien une classe Root avec une collection de Child ayant eux mêmes une référence en retour sur leur Root. La collection de Child est un HashSet vérifiant donc l’unicité des ses éléments.

Test

On va créer une instance de Root contenant deux instances de Child puis sérialiser et désérialiser un des Child.
Le child désérialisé devrait être le même que le child originel.

public class UnserializeCycle {
	private Root root;
	private Child child1;
	private Child child2;
	@Before
	public void initObjects() {
		root = new Root();
		child1 = new Child("child1", root);
		child2 = new Child("child2", root);
		Collection<Child> children = new HashSet<Child>();
		children.add(child1);
		children.add(child2);
		root.setChildren(children);
	}
	@Test
	public void testUnserialization() throws Exception {
		Child unserializedChild = serializeAndUnserializeChild(child1);
		Assert.assertNotNull(unserializedChild);
		Assert.assertEquals(child1, unserializedChild);
		Assert.assertEquals("child1", unserializedChild.getCode());
		Assert.assertEquals(child1.hashCode(), unserializedChild.hashCode());
		Collection<Child> children = root.getChildren();
		Collection<Child> unserializedChildren = unserializedChild.getRoot().getChildren();
		Assert.assertEquals(children, unserializedChildren);
		Assert.assertTrue(unserializedChildren.contains(child1));
	}
	private Child serializeAndUnserializeChild(Child child) throws Exception {
		String fileName = "child.ser";
		FileOutputStream fileOut = new FileOutputStream(fileName);
		ObjectOutputStream out = new ObjectOutputStream(fileOut);
		out.writeObject(child);
		fileOut.close();
		FileInputStream fileIn = new FileInputStream(fileName);
		ObjectInputStream in = new ObjectInputStream(fileIn);
		return (Child) in.readObject();
	}
}

Résultat du test

Le test échoue sur une exception. Plus précisément, la méthode hashCode() de Child lève un NPE car le code est null. Voici un extrait de la stacktrace :

java.lang.NullPointerException
	at Child.hashCode(UnserializeCycle.java:135)
	at java.util.HashMap.put(HashMap.java:372)
	at java.util.HashSet.readObject(HashSet.java:292)
	at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:39)
	at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:25)
	at java.lang.reflect.Method.invoke(Method.java:597)
	at java.io.ObjectStreamClass.invokeReadObject(ObjectStreamClass.java:974)
	at java.io.ObjectInputStream.readSerialData(ObjectInputStream.java:1848)
	at java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:1752)
	at java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1328)
	at java.io.ObjectInputStream.defaultReadFields(ObjectInputStream.java:1946)
	at java.io.ObjectInputStream.readSerialData(ObjectInputStream.java:1870)
	at java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:1752)
	at java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1328)
	at java.io.ObjectInputStream.defaultReadFields(ObjectInputStream.java:1946)
	at java.io.ObjectInputStream.readSerialData(ObjectInputStream.java:1870)
	at java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:1752)
	at java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1328)
	at java.io.ObjectInputStream.readObject(ObjectInputStream.java:350)
	at UnserializeCycle.serializeAndUnserializeChild(UnserializeCycle.java:90)
	at UnserializeCycle.testUnserialization(UnserializeCycle.java:24)

En effet, au moment d’insérer le child1 dans le Set, l’instance n’est pas encore complètement initialisée, tous ses membres sont donc null.
Cela fait un peu penser aux cas de lignes en base se référençant l’une et l’autre. On ne peut les insérer directement du fait des contraintes de clés étrangères. On insère donc les deux lignes avec null dans la colonne de lien d’une des deux lignes, puis on met à jour la colonne null avec l’identifiant de l’autre ligne.
La JVM sait très bien gérer les références circulaires lors de la désérialisation mais pas lorsqu’un Set est de la partie.
A noter qu’il faut se méfier de l’utilisation du helper HashCodeBuilder du la librairie Commons Lang qui ignore silencieusement une variable qui lui est passée si elle est null.
Notons également que le test n’échouerait pas sur un NPE si le code était de type primitif, tel qu’un int, sa valeur étant en effet initialisée. Cela pourrait faire l’objet d’un prochain billet afin de creuser les mécanismes de désérialisation internes de la JVM.

Recherche de solution

Tentons de trouver une solution.
Le hashCode de la classe Child pourrait être tolérant au NPE. On l’implémenterait de la sorte :

return (code != null) ? code.hashCode() : super.hashCode();

La méthode hashCode() ne lève alors plus de NPE. Toutefois, le test n’est toujours pas « vert ». On va voir pourquoi dans un instant.
Revenons rapidement sur cette alternative qui peut sembler séduisante à premier abord.
On dispose désormais de deux moyens de calculer le hashCode pour un même objet. Cela peut poser problème car rien ne garanti que le hashCode produit par la classe mère (Object ou autre) ne produira pas de collision avec le hashCode généré à partir du code lorsqu’il est non null.
Le test n’échoue donc pas sur la désérialisation et l’assertion de vérification d’égalité des deux collections originelles et désérialisées de Child renvoie bien true Assert.assertEquals(children, unserializedChildren);.
Toutefois, le test ne serait pas complet si l’on ne vérifiait pas que la collection désérialisée contient bien le child1 d’origine. C’est l’objet de l’assert Assert.assertTrue(unserializedChildren.contains(child));
Ce dernier nous donne un test « rouge » car la Entry table sous jacente à la HashMap derrière le Set possède bien le child1 mais « rangée » ailleurs. En effet les membres utilisés par le hashCode sont censés être immuables. Etant donné que la méthode hashCode() est invoquée par la JVM alors que l’objet n’est pas encore complètement initialisé, il se retrouve être « rangé » à la mauvaise position dans le tableau.
La récupération du child1 s’appuyant sur son hashCode et ce dernier renvoyant la bonne valeur car l’objet est maintenant complètement initialisé, on va chercher au mauvais endroit dans le tableau et on ne retrouve pas le child1.
Au final le Set de Child désérialisé est différent de celui d’origine même si le assert Assert.assertEquals(children, unserializedChildren); nous dit le contraire.
L’explication est toute simple à la lecture du code. En effet, le equals() sur les deux Set fonctionne car il utilise en sous main en un HashIterator et l’extrait de code suivant montre qu’il avance jusqu’au bon index en incrémentant ce dernier jusqu’à trouver l’objet même si il est rangé dans le mauvais bucket. while (index < t.length && (next = t[index++]) == null)
Un bug de la JVM semble référencer ce problème : Hash entries placed into wrong buckets during deserialization
Il est ouvert depuis la version 1.4 et même si des commentaires suggèrent des solutions, la correction n’est toujours pas dans nos JVM.
Quelles seraient les autres solutions en continuant de s’appuyer sur la sérialisation Java native :

recharger le Set avec un addAll() sur un nouveau HashSet de manière à ranger correctement les objets sur la base des bons hashCode : ce n’est pas propre et c’est difficilement industrialisable.

utiliser une ArrayList au lieu d’un HashSet pour peu que l’on n’ait pas besoin de contrôler l’unicité et que l’on soit attentif aux problèmes de produits cartésien sur des fetch de ces collections.

faire des manipulations de génie avec les méthodes magiques readObject(), writeObject() ou readResolve() mais c’est très dangereux et déconseillé pour notre cas de recherche de contournement.

… ?

Sérialisation alternative

Un rapide test avec la librairie Hessian qui fournit une alternative à la sérialisation Java se montre concluant.
Remplaçons la méthode de sérialisation par la méthode suivante :

	private Child serializeAndUnserializeChild(Child child) throws Exception {
		ByteArrayOutputStream bos = new ByteArrayOutputStream();
		Hessian2Output out = new Hessian2Output(bos);
		out.startMessage();
		out.writeObject(child);
		out.completeMessage();
		out.close();
		byte []data = bos.toByteArray();
		ByteArrayInputStream bin = new ByteArrayInputStream(data);
		Hessian2Input in = new Hessian2Input(bin);
		in.startMessage();
		Child unserializedChild = (Child) in.readObject();
		in.completeMessage();
		in.close();
		bin.close();
		return unserializedChild;
	}

Le test passe même sans devoir modifier la génération du hashCode. A noter tout de même qu’au premier passage dans la méthode hashCode() de Child, la variable root du child1 est à null tout comme dans la solution initiale à base de sérialisation Java. Ceci est « normal », la JVM initialise la référence circulaire en deux fois. Le child1 est au final bien initialisé avec son membre root.

Conclusion

J’ai rencontré ce problème au cours d’un projet et ai été très surpris de découvrir, a priori, un vieux bug JVM que l’on ne sait pas résoudre proprement ou sans faire de compromis.
Heureusement, d’autres solutions de sérialisation Java existent, elles sont en outre plus performantes mais pas forcément non intrusives. Celle que l’on a vue, à savoir Hessian n’est pas la plus rapide mais présente justement l’avantage de ne pas être intrusive sur les objets à traiter et ne nécessite pas non plus l’écriture de classe de mapping.

Laisser un commentaire

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.

%d blogueurs aiment cette page :