Java 8 → 17 sous Spark : la liste des surprises
Ce n'est pas un drapeau de compilation. Le système de modules ferme des accès dont Spark dépendait, le ramasse-miettes change, et les symptômes arrivent à l'exécution.
Sur le papier, monter un job Spark de Java 8 à Java 17 revient à changer une version dans le fichier de build. Dans les faits, la JVM 17 applique des restrictions que la 8 ne connaissait pas, et Spark s'appuyait largement sur ce qui vient d'être fermé. Voici ce qu'on rencontre, dans l'ordre.
Le système de modules ferme la porte
Depuis Java 9, l'encapsulation forte empêche l'accès par réflexion aux internes du JDK. Spark en a
besoin : son gestionnaire de mémoire manipule sun.misc.Unsafe, et ses sérialiseurs
accèdent à des champs privés de java.nio et java.util. Sous Java 11 on
obtenait un avertissement ; sous Java 17, c'est une erreur.
Le symptôme typique n'est pas explicite :
java.lang.reflect.InaccessibleObjectException: Unable to make field
private final byte[] java.lang.String.value accessible:
module java.base does not "opens java.lang" to unnamed module
Le correctif est un jeu d'options à passer au pilote et aux exécuteurs. L'oubli du second est l'erreur la plus fréquente : le job démarre, puis échoue à la première tâche distribuée.
--add-opens=java.base/java.lang=ALL-UNNAMED
--add-opens=java.base/java.lang.invoke=ALL-UNNAMED
--add-opens=java.base/java.io=ALL-UNNAMED
--add-opens=java.base/java.net=ALL-UNNAMED
--add-opens=java.base/java.nio=ALL-UNNAMED
--add-opens=java.base/java.util=ALL-UNNAMED
--add-opens=java.base/sun.nio.ch=ALL-UNNAMED
Dans spark-defaults.conf, ça donne :
spark.driver.extraJavaOptions --add-opens=java.base/java.lang=ALL-UNNAMED ...
spark.executor.extraJavaOptions --add-opens=java.base/java.lang=ALL-UNNAMED ...
Ces options ne sont pas une rustine temporaire : elles font partie de la configuration d'exécution normale de Spark sur une JVM moderne. Autant les versionner et les documenter tout de suite, plutôt que de les redécouvrir au prochain incident.
Le ramasse-miettes change de profil
Java 8 utilisait le Parallel GC par défaut, optimisé pour le débit. Java 9 et suivants basculent sur G1, orienté latence de pause. Pour un job batch, ce changement n'est pas neutre : G1 tolère mieux les gros tas mais peut consommer davantage de mémoire hors tas, et son comportement diffère sur des exécuteurs à longue durée de vie.
Le symptôme n'est pas une exception mais une dérive : des exécuteurs tués pour dépassement mémoire par le gestionnaire de ressources, alors que la même configuration passait sous Java 8. La cause est souvent que la mémoire hors tas n'a pas été réévaluée.
spark.executor.memory 8g
spark.executor.memoryOverhead 2g # à revoir à la hausse, pas seulement le tas
spark.executor.extraJavaOptions -XX:+UseG1GC -XX:MaxGCPauseMillis=200
La bonne pratique est de mesurer avant de conclure : activer les journaux de GC sur un exécuteur pendant un run de référence, et comparer le temps passé en collecte avant et après migration.
-Xlog:gc*:file=/tmp/gc-%p.log:time,uptime:filecount=5,filesize=20M
La compatibilité Scala
Le couple version de Scala / version de Java est contraint, et une erreur ici produit des messages
obscurs. Scala 2.12 supporte Java 17 à partir de ses versions récentes ; les versions plus anciennes
génèrent du bytecode que la JVM 17 refuse. Le symptôme est un
UnsupportedClassVersionError ou, plus perfidement, un
NoSuchMethodError à l'exécution sur une méthode qui existe pourtant.
La vérification qui évite des heures de recherche :
scala.util.Properties.versionString // version de Scala effectivement chargée
System.getProperty("java.version") // JVM du pilote
sc.range(0, 1).map(_ => System.getProperty("java.version")).collect() // JVM des exécuteurs
La dernière ligne est celle qui compte. Un pilote en Java 17 et des exécuteurs restés en Java 8 — parce que l'image du cluster n'a pas été mise à jour — donnent des erreurs de désérialisation qui ne ressemblent en rien à un problème de version.
Ce qui casse dans les tests avant la production
Trois familles, dans l'ordre de fréquence.
- Les bibliothèques de manipulation de bytecode. Mockito, ByteBuddy, cglib : toutes doivent
être montées de version. Une suite de tests qui compile mais échoue au premier
mock()est presque toujours ça. - Le formatage des nombres et des dates. Java 9 a adopté les données de locale CLDR. Un espace insécable fine remplace l'espace ordinaire comme séparateur de milliers en français. Tout test qui compare une chaîne formatée échoue — et ce qui est inquiétant, c'est quand un fichier de sortie change de format sans que personne ne le teste.
- Les dépendances transitives figées. Jackson, Netty et les codecs compressés doivent être alignés sur la version que Spark embarque, sinon on obtient des conflits de classes que le classpath masque jusqu'à l'exécution distribuée.
Un test de non-régression utile sur le formatage, précisément parce que le symptôme est invisible :
test("le séparateur de milliers reste celui attendu par le format de dépôt") {
val f = java.text.NumberFormat.getInstance(java.util.Locale.FRANCE)
assert(f.format(1234567) == "1 234 567") // vérifie le caractère réellement produit
}
L'ordre de migration qui limite les dégâts
La séquence que j'applique, et la raison de chaque étape :
- Compiler en Java 8, exécuter sur JVM 17. On sépare les problèmes de compilation des
problèmes d'exécution, et on découvre les
--add-opensmanquants sans changer une ligne de code. - Aligner les dépendances de test. Elles bloquent tout le reste tant qu'elles ne passent pas.
- Compiler en Java 17, sans utiliser les nouvelles fonctionnalités. La cible est la compatibilité, pas la modernisation ; mélanger les deux rend les régressions inanalysables.
- Comparer les sorties sur une journée de référence. C'est l'étape qui attrape les changements de formatage et les décalages de date — les deux défauts que la compilation ne voit pas.
- Reprendre le dimensionnement mémoire. Seulement à la fin, quand le comportement est stable.
Le principe général : une montée de JVM sur une plateforme critique se traite comme une migration de données, pas comme une mise à jour d'outillage. Ce qui décide du succès n'est pas la compilation, c'est la comparaison des sorties.
Chafiq Madkour est Tech Lead et Senior Data Engineer. Dix ans sur des plateformes data critiques en banque, en assurance et en finance de marché — déclaratifs réglementaires, détection de fraude, moteurs de calcul de risque.