W3docs

Sérialisation Java

Sérialisez des objets Java en octets avec l'interface Serializable, ObjectOutputStream et serialVersionUID.

Les chapitres précédents traitaient de flux de contenu — octets, caractères, primitives, lignes. La sérialisation est un niveau au-dessus : un flux d'objets. Vous appelez writeObject(someObject) et le JDK parcourt l'intégralité du graphe de références à partir de cet objet, encode chaque champ de chaque objet accessible sous forme d'octets, et écrit le résultat dans le flux. Du côté lecture, readObject() reconstruit le graphe.

C'est une affirmation importante assortie d'un gros bémol. La sérialisation fonctionne, elle fonctionne depuis Java 1.1, et vous la verrez dans d'anciennes bases de code (RMI, EJB, réplication de session, certaines couches de cache). Mais cette conception présente des problèmes bien connus — versionnement fragile, failles de sécurité, couplage fort entre la persistance et la structure des classes — et Oracle tente publiquement de la supprimer depuis des années. Pour le nouveau code, la réponse est presque toujours JSON ou Protocol Buffers. Ce chapitre existe pour que vous puissiez lire et maintenir le code qui existe déjà.

Le mécanisme

Trois éléments :

  1. L'interface marqueur Serializable. Une classe déclare qu'elle peut être sérialisée en implémentant java.io.Serializable. L'interface n'a pas de méthodes ; c'est un indicateur que le JDK vérifie à l'exécution.
  2. ObjectOutputStream. Un décorateur qui enveloppe n'importe quel OutputStream et ajoute writeObject(Object). C'est le moteur qui parcourt le graphe et écrit les octets.
  3. ObjectInputStream (chapitre suivant). Le miroir qui lit les octets et reconstruit le graphe.
class User implements Serializable {                 // the marker
  private static final long serialVersionUID = 1L;
  String name;
  int age;
  User(String name, int age) { this.name = name; this.age = age; }
}

try (ObjectOutputStream out = new ObjectOutputStream(Files.newOutputStream(path))) {
  out.writeObject(new User("alice", 30));            // the user is now on disk
}

Voilà la recette minimale. La classe implémente Serializable ; le writer est ObjectOutputStream ; l'appel est writeObject. À la prochaine lecture de ce fichier (couverte dans le chapitre suivant) vous récupérez une instance de User.

Ce qui est écrit

Tout ce qui est accessible depuis l'objet, par défaut :

  • Chaque champ non transient et non static, par réflexion, dans l'ordre de déclaration.
  • Récursivement, chaque objet référencé par ces champs.
  • Pour chaque classe impliquée, un descripteur (le nom de la classe, les types des champs et le serialVersionUID) afin que le lecteur puisse valider le format.

Le format est binaire, auto-descriptif (il contient les métadonnées de classe), et non lisible par un humain. Il est également spécifique au système de types Java — les octets encodent des décalages de champs, des noms de types et des hiérarchies d'héritage qui n'ont aucune signification en dehors de Java. C'est la limitation cardinale : un fichier User.bin ne peut pas être lu par Python, Go ou JavaScript sans un parseur personnalisé.

transient : les champs que vous ne souhaitez pas sérialiser

Un champ marqué transient est ignoré lors de la sérialisation. Le lecteur le voit avec la valeur par défaut de son type — null, 0, false. Utilisez-le pour :

  • Les caches qui peuvent être reconstruits : transient Map<String, Result> cache;
  • Les champs qui n'ont pas de sens entre JVMs : transient Thread worker;, transient Connection db;
  • Les données sensibles que vous ne voulez pas sur disque : transient String password;
class Session implements Serializable {
  private static final long serialVersionUID = 1L;
  String userId;
  long createdAt;
  transient byte[] sessionToken;                     // never gets written
}

La Session désérialisée aura sessionToken == null. Votre code doit gérer l'absence du champ après la reconstruction.

Les champs statiques sont également ignorés — static appartient à la classe, pas à l'instance, donc il ne fait pas partie de l'état par objet.

serialVersionUID : déclarez-le explicitement

Chaque classe sérialisable possède un serialVersionUID — un numéro de version sur 64 bits écrit dans le flux et vérifié par rapport à la classe du côté lecture. S'ils ne correspondent pas, la désérialisation lève une InvalidClassException.

Vous devriez toujours le déclarer :

private static final long serialVersionUID = 1L;

Si vous ne le faites pas, la JVM en calcule un à partir de la forme de la classe — chaque champ, chaque signature de méthode, chaque interface. Ajoutez un champ, changez le type de retour d'une méthode, renommez un paramètre, et l'UID calculé change. Le code qui a écrit User.bin avec la classe de la semaine dernière ne peut pas le lire avec la classe de cette semaine. Vous ne le détecterez pas dans les tests unitaires car les deux côtés voient la même classe. Vous le détecterez en production quand un utilisateur mettra à jour.

Déclarer l'UID explicitement vous donne le contrôle. Incrémentez-le manuellement seulement quand vous avez effectué un changement incompatible. (Consultez la Javadoc de Serializable pour les règles d'évolution complètes — elles sont complexes.)

Ce que vous pouvez changer entre les versions

Les règles pour les changements « compatibles » sont étonnamment strictes. En gros :

  • Sûr : ajouter de nouveaux champs, supprimer des champs transient/static, élargir l'accès (privatepublic).
  • Non sûr : supprimer des champs non transient, changer le type d'un champ, changer le serialVersionUID d'une classe, modifier la chaîne d'héritage.

Le point essentiel : les octets sur disque sont couplés à la forme de la hiérarchie de classes, pas seulement aux données. Les formats de stockage à long terme ont besoin de leur propre schéma. La sérialisation convient pour les caches de courte durée et le transport intra-JVM, mais elle est fragile pour tout ce qui doit survivre à un déploiement.

Le graphe entier, y compris les cycles

writeObject suit chaque référence. Si User contient un Team et que le Team contient une List<User> incluant le premier User, le cycle est géré : le JDK suit l'identité de chaque objet qu'il écrit et, lorsqu'il en rencontre un une seconde fois, écrit une référence arrière au lieu de récursiver. Le graphe reconstruit de l'autre côté conserve les mêmes relations d'identité.

C'est puissant et c'est aussi un piège. Un objet sérialisable entraîne tout ce qu'il peut atteindre — et si l'un de ces objets accessibles n'est pas Serializable, l'écriture échoue avec une NotSerializableException nommant le type fautif. La correction est l'une des suivantes : implémenter Serializable sur le fautif, marquer le champ transient, ou restructurer la classe pour qu'elle ne tienne pas la référence.

Sécurité : ne désérialisez jamais des octets non fiables

C'est principalement un sujet du chapitre suivant, mais la conséquence influence aussi le côté écriture. Le format de sérialisation Java exécute du code côté lecteur — des constructeurs de classe et des hooks readObject — lors de la désérialisation. Des flux d'octets forgés ont été utilisés pour l'exécution de code à distance contre tous les principaux serveurs d'applications Java. La règle qui a émergé de ces années de CVEs :

Ne désérialisez pas des octets provenant d'une source que vous ne contrôlez pas entièrement.

Du côté écriture, cela signifie : ne concevez pas de protocoles où une partie sérialise des données avec ObjectOutputStream et une autre les désérialise avec ObjectInputStream. Utilisez JSON ou Protocol Buffers à travers les frontières de confiance ; réservez la sérialisation aux cas d'utilisation « même JVM, même chargeur de classe, même domaine de confiance ».

Quand utiliser la sérialisation (et quand ne pas l'utiliser)

Optez pour elle quand :

  • Vous devez effectuer un point de contrôle d'un graphe d'objets dans la même JVM pour la récupération après redémarrage.
  • Vous travaillez avec un framework existant (RMI, JMX, EJB, certaines réplications de session) qui l'exige.
  • Vous voulez une implémentation en 10 lignes pour un fichier « save game » dont vous pouvez briser la compatibilité à tout moment.

Ne l'utilisez pas quand :

  • Le format doit survivre à un déploiement. Utilisez plutôt un format versionné par schéma (JSON + un champ de version, Protobuf, Avro).
  • Les données traversent une frontière de confiance. Utilisez JSON ou Protobuf.
  • Un autre langage doit lire ou écrire les données. Le format de sérialisation Java est spécifique à Java.

Pour la plupart des nouveaux codes, Jackson.writeValueAsString(obj) vers un fichier JSON est le meilleur choix. C'est sans schéma mais flexible, lisible par un humain, et analysable depuis n'importe quel langage.

Un exemple concret : écriture d'un graphe d'enregistrements

Le programme ci-dessous définit deux types sérialisables simples, Department et Employee, avec une référence arrière (chaque Employee connaît son Department, et chaque Department conserve une liste de ses Employees — un cycle). Il écrit le graphe avec ObjectOutputStream, affiche le nombre d'octets, et montre la NotSerializableException que vous obtenez quand un champ non sérialisable s'infiltre. La lecture des octets en retour est le sujet du chapitre suivant ; ici nous nous concentrons sur le côté écriture.

java— editable, runs on the server

Ce qu'il faut retenir de l'exécution :

  • Un seul appel writeObject(eng) a sérialisé le Department, les trois Employees, les références arrière d'Employee vers Department, et la liste à l'intérieur de Department. C'est la fonctionnalité phare de la sérialisation : des graphes, pas des enregistrements. Les cycles gérés, l'identité préservée, sans parcours manuel.
  • Les quatre premiers octets étaient AC ED 00 05 — le « nombre magique » de la sérialisation Java et la version du flux. Chaque fichier sérialisé commence par ceux-ci. Si vous voyez cet en-tête sur un fichier trouvé en production, vous regardez la sortie d'ObjectOutputStream.
  • Le dump d'octets contenait "alice" (un champ non transient) et ne contenait pas "hash-A" (un champ transient). Marquer un champ transient est la méthode supportée pour l'exclure. Les champs sensibles (mots de passe, tokens, clés de session) appartiennent à transient.
  • L'écriture de BadEmployee a levé une NotSerializableException et le message a nommé Settings — le type non sérialisable exact. Voilà comment trouver les fautifs : essayez d'écrire, lisez l'exception, corrigez la classe nommée (ou marquez le champ transient). La vérification se produit au niveau du champ, pas au niveau de la classe — une seule référence non sérialisable égarée suffit.
  • serialVersionUID = 1L a été déclaré sur chaque classe sérialisable. L'exécution actuelle ne remarquerait pas s'il était absent, mais un futur vous qui refactoriserait la classe et tenterait de charger un ancien fichier avec le nouveau code le remarquerait immédiatement. Déclarez-le ; incrémentez-le délibérément quand vous effectuez un changement incompatible.

La suite

Ce chapitre a couvert l'écriture — Serializable, ObjectOutputStream, le parcours du graphe, le format. La lecture et la reconstruction du graphe sont l'opération miroir avec son propre ensemble d'écueils (celui de la sécurité étant le plus important). C'est le chapitre suivant, Java Deserialization.

Pratique

Pratique
Une classe `Employee` a un champ `transient String sessionToken`. Le token vaut `'abc123'` au moment de la sérialisation. Après désérialisation dans une nouvelle JVM, quelle est la valeur de `sessionToken` sur l'objet reconstruit ?
Une classe `Employee` a un champ `transient String sessionToken`. Le token vaut `'abc123'` au moment de la sérialisation. Après désérialisation dans une nouvelle JVM, quelle est la valeur de `sessionToken` sur l'objet reconstruit ?
Was this page helpful?