Java 25 LTS : Nouveautés et guide de migration
Java 25 est enfin là, apportant son lot de nouveautés passionnantes pour les développeurs Java. Dans cet article, nous allons explorer en détail certaines des fonctionnalités les plus marquantes de cette version, accompagnées d'exemples concrets pour vous aider à les comprendre et à les adopter dans vos projets.
Contexte technique
Version :
- Java 25 LTS (sorti en septembre 2025)
- Certaines fonctionnalités sont en preview (à activer avec
--enable-preview) - D'autres sont finalisées et utilisables directement
Sommaire
- Flexible Constructor Bodies (JEP 482)
- Primitive Types in Patterns (JEP 455)
- Stream Gatherers (JEP 473)
- Structured Concurrency (Finalisé)
- Scoped Values (Finalisé)
- Améliorations de performance
- Migration vers Java 25
- Conclusion
Flexible Constructor Bodies (JEP 482)
Depuis les débuts de Java, une règle stricte s'imposait dans les constructeurs : l'appel à super() ou this() devait obligatoirement être la toute première instruction. Cette contrainte empêchait d'effectuer des validations ou des transformations de données avant d'initialiser la classe parente.
Pourquoi est-ce problématique ? Imaginez que vous voulez valider les paramètres du constructeur avant de les passer à la classe parente. Impossible ! Vous deviez d'abord appeler super(), puis valider, ce qui n'a aucun sens logique.
Java 25 supprime cette limitation avec les Flexible Constructor Bodies. Vous pouvez désormais exécuter du code avant l'appel à super().
Avant Java 25
public class User {
private final String username;
private final String email;
public User(String username, String email) {
// OBLIGÉ d'appeler super() en premier !
super();
// La validation vient APRÈS... pas logique !
if (username == null || username.isBlank()) {
throw new IllegalArgumentException("Username invalide");
}
this.username = username;
this.email = email;
}
}Le problème : Si l'username est invalide, on a déjà appelé super() et potentiellement initialisé des ressources inutilement.
Avec Java 25
public class User {
private final String username;
private final String email;
public User(String username, String email) {
// On peut maintenant valider AVANT super() !
if (username == null || username.isBlank()) {
throw new IllegalArgumentException("Username invalide");
}
// Normalisation des données
String normalizedEmail = email.toLowerCase().trim();
// super() peut être appelé après
super();
this.username = username;
this.email = normalizedEmail;
}
}L'avantage : Le code suit un ordre logique : validation, transformation, puis initialisation. Plus besoin de contorsions pour respecter une règle arbitraire !
Primitive Types in Patterns (JEP 455)
Le pattern matching existe en Java depuis la version 17, mais il était limité aux types objets (classes, interfaces, records). Les types primitifs (int, double, long, etc.) étaient exclus, ce qui était frustrant.
Pourquoi c'est important ? Parce que Java manipule constamment des types primitifs pour des raisons de performance. Devoir les "boxer" (transformer en Integer, Double, etc.) juste pour utiliser le pattern matching était inefficace et contre-intuitif.
Java 25 corrige cela : vous pouvez maintenant utiliser les types primitifs directement dans vos patterns !
Le pattern matching de base
Avant de voir la nouveauté, rappelons le pattern matching classique :
public static String describe(Object obj) {
return switch (obj) {
case String s -> "C'est une chaîne : " + s;
case Integer i -> "C'est un entier : " + i;
case null -> "C'est null";
default -> "Type inconnu";
};
}Avec les primitifs (Java 25)
public class NumberProcessor {
public static String classify(Object value) {
return switch (value) {
// Pattern matching direct avec int !
case int i when i > 0 -> "Entier positif: " + i;
case int i when i < 0 -> "Entier négatif: " + i;
case int i -> "Zéro";
// Fonctionne aussi avec d'autres primitifs
case long l -> "Long: " + l;
case double d -> "Double: " + d;
// Et bien sûr les objets classiques
case String s -> "Chaîne: " + s;
default -> "Type inconnu";
};
}
}Utilisation :
System.out.println(classify(42)); // Entier positif: 42
System.out.println(classify(-10)); // Entier négatif: -10
System.out.println(classify(3.14)); // Double: 3.14
System.out.println(classify("Hello")); // Chaîne: HelloPourquoi c'est puissant ? Le mot-clé when permet d'ajouter des conditions supplémentaires directement dans le pattern. C'est comme un if intégré dans le switch !
Exemple concret : Validation et conversion
Imaginez un système qui doit accepter différents formats de prix (entier, décimal, chaîne). Avant Java 25, ce code était verbeux. Maintenant :
public static double parsePrice(Object input) {
return switch (input) {
case int i -> (double) i; // Conversion int vers double
case double d -> d; // Déjà un double
case String s when s.matches("\\d+") -> // Chaîne numérique
Double.parseDouble(s);
default -> throw new IllegalArgumentException(
"Format invalide: " + input
);
};
}Test :
System.out.println(parsePrice(100)); // 100.0
System.out.println(parsePrice(99.99)); // 99.99
System.out.println(parsePrice("49")); // 49.0Ce qu'il faut retenir : Fini le boxing/unboxing manuel ! Le pattern matching avec primitifs rend le code plus court, plus rapide et plus lisible.
Stream Gatherers (JEP 473)
Les Streams Java que vous connaissez (map(), filter(), collect()) sont puissants, mais parfois limités. Certaines opérations nécessitent de garder un état entre les éléments, ce qui était difficile voire impossible.
Exemple classique : Comment créer des fenêtres glissantes dans un Stream ? Avant Java 25, vous deviez sortir du Stream, utiliser une boucle for, et gérer l'état manuellement. Frustrant !
Les Stream Gatherers sont la solution : une nouvelle opération intermédiaire qui peut maintenir un état et transformer les éléments de manière plus flexible.
Concept : Qu'est-ce qu'un Gatherer ?
Un Gatherer, c'est comme un map() ou un filter() sous stéroïdes. Il peut :
- Garder un état entre les éléments (contrairement à
map()) - Produire zéro, un ou plusieurs résultats par élément d'entrée
- Décider d'arrêter le traitement à tout moment
Les Gatherers prédéfinis
Java 25 fournit des Gatherers prêts à l'emploi pour les cas courants.
1. Fenêtres de taille fixe (windowFixed)
Divise le Stream en groupes de N éléments.
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
List<List<Integer>> groups = numbers.stream()
.gather(Gatherers.windowFixed(3)) // Groupes de 3
.toList();
System.out.println(groups);
// [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10]]Cas d'usage : Traiter des données par lots (batch processing).
2. Fenêtres glissantes (windowSliding)
Crée des groupes qui se chevauchent.
List<List<Integer>> windows = numbers.stream()
.gather(Gatherers.windowSliding(3)) // Fenêtre de 3 qui glisse
.toList();
System.out.println(windows);
// [1, 2, 3]
// [2, 3, 4] <- Le 2 et 3 sont répétés
// [3, 4, 5]
// ...Cas d'usage : Calculer des moyennes mobiles, détection de tendances.
3. Accumulation (scan)
Garde un total cumulé tout en émettant chaque étape intermédiaire.
List<Integer> runningSum = numbers.stream()
.gather(Gatherers.scan(() -> 0, (total, num) -> total + num))
.toList();
System.out.println(runningSum);
// [1, 3, 6, 10, 15, 21, 28, 36, 45, 55]
// ^ ^ ^ <- Somme cumulative à chaque étapeCas d'usage : Calculer des soldes de compte, des totaux cumulés.
Exemple complet : Analyse de températures
Imaginons qu'on mesure la température toutes les heures et qu'on veut calculer la moyenne mobile sur 3 heures.
record Temperature(int hour, double celsius) {}
List<Temperature> readings = List.of(
new Temperature(1, 20.5),
new Temperature(2, 21.0),
new Temperature(3, 22.3),
new Temperature(4, 23.1),
new Temperature(5, 22.8)
);
// Moyenne mobile sur 3 mesures
List<Double> movingAverage = readings.stream()
.map(Temperature::celsius) // Extraire les températures
.gather(Gatherers.windowSliding(3)) // Fenêtres de 3
.map(window -> window.stream() // Calculer la moyenne
.mapToDouble(d -> d)
.average()
.orElse(0.0)
)
.toList();
System.out.println(movingAverage);
// [21.27, 22.13, 22.73] <- Moyennes mobilesExplication étape par étape :
- On extrait juste les valeurs de température
windowSliding(3)crée[20.5, 21.0, 22.3], puis[21.0, 22.3, 23.1], etc.- Pour chaque fenêtre, on calcule la moyenne
- Résultat : une liste de moyennes mobiles !
Pourquoi c'est révolutionnaire ?
Avant les Gatherers, ce code aurait nécessité :
- Une boucle
formanuelle - Une queue ou une liste pour maintenir la fenêtre glissante
- Beaucoup plus de lignes de code
Maintenant, tout tient en quelques lignes dans le Stream, ce qui est plus lisible et idiomatique en Java moderne.
Structured Concurrency (Finalisé)
La programmation concurrente en Java a toujours été complexe. Avec Thread, ExecutorService, ou CompletableFuture, il était facile de créer des fuites de ressources (threads qui ne s'arrêtent jamais) ou des erreurs silencieuses (exceptions perdues).
Le problème fondamental : Quand vous lancez plusieurs tâches en parallèle, que se passe-t-il si l'une échoue ? Avec les anciennes API, les autres tâches continuent de tourner inutilement, consommant des ressources.
La Structured Concurrency résout ce problème en introduisant un principe simple : toutes les tâches lancées dans un scope doivent se terminer avant que le scope ne se ferme. C'est comme un try-with-resources, mais pour les threads !
Le problème avec CompletableFuture
Voici du code classique pour récupérer des données de 3 sources en parallèle :
public UserProfile getUserProfile(String userId) throws Exception {
CompletableFuture<User> userFuture =
CompletableFuture.supplyAsync(() -> fetchUser(userId));
CompletableFuture<List<Order>> ordersFuture =
CompletableFuture.supplyAsync(() -> fetchOrders(userId));
CompletableFuture<Account> accountFuture =
CompletableFuture.supplyAsync(() -> fetchAccount(userId));
// Attendre que tout soit terminé
CompletableFuture.allOf(userFuture, ordersFuture, accountFuture).get();
return new UserProfile(
userFuture.get(),
ordersFuture.get(),
accountFuture.get()
);
}Problèmes :
- Si
fetchUser()échoue,fetchOrders()etfetchAccount()continuent quand même ! - Gestion d'erreur complexe et verbeuse
- Risque de fuite de threads si on oublie de gérer les cas d'erreur
La solution : StructuredTaskScope
import java.util.concurrent.StructuredTaskScope;
public record UserProfile(User user, List<Order> orders, Account account) {}
public UserProfile getUserProfile(String userId) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// Lancement parallèle des tâches
var userTask = scope.fork(() -> fetchUser(userId));
var ordersTask = scope.fork(() -> fetchOrders(userId));
var accountTask = scope.fork(() -> fetchAccount(userId));
// Attente de toutes les tâches
scope.join(); // Bloque jusqu'à ce que tout soit terminé
scope.throwIfFailed(); // Lance une exception si une tâche a échoué
// Récupération des résultats
return new UserProfile(
userTask.get(),
ordersTask.get(),
accountTask.get()
);
}
// Les threads sont automatiquement nettoyés ici grâce au try-with-resources
}Avantages :
- ✅ Si une tâche échoue, toutes les autres sont automatiquement annulées
- ✅ Impossible d'oublier de nettoyer les threads (garanti par le
try-with-resources) - ✅ Code plus court et plus clair
ShutdownOnSuccess : Arrêt au premier succès
Parfois, vous voulez juste le premier résultat disponible et annuler le reste. Exemple : chercher une donnée dans plusieurs sources (cache, base de données, API externe).
public String searchInMultipleSources(String query) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
// Recherche parallèle dans 3 sources
scope.fork(() -> searchInCache(query)); // Le plus rapide
scope.fork(() -> searchInDatabase(query)); // Moyen
scope.fork(() -> searchInAPI(query)); // Le plus lent
// Attend le premier succès et annule les autres
scope.join();
return scope.result(); // Retourne le premier résultat obtenu
}
}Scénario :
- Le cache répond en 50ms : Succès ! Les 2 autres sont annulés immédiatement
- Gain : Pas besoin d'attendre les réponses inutiles de la base de données (200ms) et de l'API (300ms)
C'est exactement le comportement qu'on veut : efficace et économe en ressources !
Scoped Values (Finalisé)
Si vous avez déjà utilisé ThreadLocal, vous connaissez le problème : c'est pratique pour partager des données entre méthodes sans passer des paramètres partout, mais c'est dangereux.
Les dangers de ThreadLocal :
- Fuites mémoire : Si vous oubliez d'appeler
remove(), les données restent en mémoire - Mutabilité : N'importe qui peut modifier la valeur à tout moment
- Problèmes avec les thread pools : Les valeurs persistent entre les requêtes
Les Scoped Values sont la solution moderne : immuables, automatiquement nettoyés, et optimisés pour les Virtual Threads de Java 21.
Avant : ThreadLocal (problématique)
public class RequestContext {
private static final ThreadLocal<String> userId = new ThreadLocal<>();
public static void setUserId(String id) {
userId.set(id);
}
public static String getUserId() {
return userId.get();
}
// DANGER : Si on oublie d'appeler remove(), fuite mémoire !
public static void clear() {
userId.remove();
}
}
// Utilisation
RequestContext.setUserId("user123");
doSomething();
RequestContext.clear(); // On peut facilement oublier !Après : Scoped Values (Java 25)
public class RequestContext {
public static final ScopedValue<String> USER_ID = ScopedValue.newInstance();
public static void handleRequest(String userId, Runnable handler) {
// La valeur est définie pour le scope
ScopedValue.where(USER_ID, userId).run(handler);
// Automatiquement nettoyée ici !
}
}
// Utilisation
RequestContext.handleRequest("user123", () -> {
doSomething(); // USER_ID est accessible ici
});
// USER_ID est automatiquement supprimé, pas de fuite !Avantages :
- ✅ Immutable : Impossible de modifier la valeur par accident
- ✅ Auto-nettoyage : Pas besoin de
remove(), c'est automatique - ✅ Scope clair : La valeur n'existe que dans le bloc
run()oucall()
Exemple avec Spring Boot
Imaginons une API REST où on veut tracer l'utilisateur et la session pour chaque requête.
@RestController
public class UserController {
// Scoped value pour le contexte
private static final ScopedValue<String> USER_ID = ScopedValue.newInstance();
@GetMapping("/profile")
public UserProfile getProfile(@RequestHeader("X-User-Id") String userId) {
// Exécution dans le scope
return ScopedValue.where(USER_ID, userId).call(() -> {
// Le userId est accessible dans toute la chaîne d'appels
return processRequest();
});
}
private UserProfile processRequest() {
String userId = USER_ID.get(); // Récupère l'ID du scope
log.info("Processing request for user: {}", userId);
// Le service peut aussi accéder à USER_ID sans paramètres
return userService.getProfile(userId);
}
}Pourquoi c'est mieux ? Pas besoin de passer userId en paramètre partout ! Il est accessible depuis n'importe quelle méthode appelée dans le scope, et automatiquement nettoyé à la fin.
Améliorations de performance
Generational ZGC par défaut
Java 25 active par défaut le Generational ZGC, offrant des pauses GC ultra-courtes (moins de 1ms) même pour de gros heaps.
Qu'est-ce que le GC (Garbage Collector) ? C'est le système qui libère automatiquement la mémoire des objets non utilisés. Le problème : pendant qu'il nettoie, votre application se met en pause. C'est embêtant pour les applications qui doivent répondre vite !
ZGC minimise ces pauses à moins d'1 milliseconde, même avec des dizaines de gigaoctets de mémoire. Idéal pour les applications web et microservices.
# Plus besoin de flags spéciaux !
java -XX:+UseZGC MyApplication
# Avant Java 25, il fallait :
# java -XX:+UseZGC -XX:+ZGenerational MyApplicationVector API (Preview)
L'API Vector permet d'exploiter les instructions SIMD (Single Instruction, Multiple Data) pour effectuer la même opération sur plusieurs données en parallèle.
En clair : Au lieu de faire a[0] + b[0], puis a[1] + b[1], puis a[2] + b[2], etc., le processeur peut faire toutes ces additions en même temps grâce à des instructions spéciales.
Gain de performance : Jusqu'à 4x à 8x plus rapide pour les calculs scientifiques, le traitement d'images, ou l'apprentissage automatique.
import jdk.incubator.vector.*;
public class VectorExample {
static final VectorSpecies<Float> SPECIES = FloatVector.SPECIES_PREFERRED;
public static float[] addArrays(float[] a, float[] b) {
float[] result = new float[a.length];
int i = 0;
// Traitement vectorisé (plusieurs additions en parallèle)
for (; i < SPECIES.loopBound(a.length); i += SPECIES.length()) {
var va = FloatVector.fromArray(SPECIES, a, i);
var vb = FloatVector.fromArray(SPECIES, b, i);
var vc = va.add(vb);
vc.intoArray(result, i);
}
// Reste des éléments (traitement classique)
for (; i < a.length; i++) {
result[i] = a[i] + b[i];
}
return result;
}
}Note : L'API Vector est encore en Preview, donc elle peut évoluer dans les prochaines versions.
Migration vers Java 25
Checklist de migration
- Vérifier la compatibilité des dépendances
<!-- Exemple Maven -->
<properties>
<java.version>25</java.version>
<maven.compiler.source>25</maven.compiler.source>
<maven.compiler.target>25</maven.compiler.target>
</properties>- Activer les preview features (si nécessaire)
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<compilerArgs>
<arg>--enable-preview</arg>
</compilerArgs>
</configuration>
</plugin>
</plugins>
</build>- Remplacer ThreadLocal par Scoped Values
- Migrer vers Structured Concurrency pour le code concurrent
- Tester avec Generational ZGC
Exemple de refactoring Spring Boot
// AVANT Java 25
@Service
public class OrderService {
public CompletableFuture<Order> processOrder(OrderRequest request) {
return CompletableFuture.supplyAsync(() -> validateOrder(request))
.thenCompose(order ->
CompletableFuture.supplyAsync(() -> processPayment(order)))
.thenCompose(payment ->
CompletableFuture.supplyAsync(() -> shipOrder(payment)))
.exceptionally(ex -> {
log.error("Error processing order", ex);
return null;
});
}
}
// APRÈS Java 25
@Service
public class OrderService {
public Order processOrder(OrderRequest request) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// Validation
var order = validateOrder(request);
// Traitements parallèles
var paymentTask = scope.fork(() -> processPayment(order));
var inventoryTask = scope.fork(() -> updateInventory(order));
var notificationTask = scope.fork(() -> sendNotification(order));
scope.join().throwIfFailed();
// Expédition après confirmation paiement
return shipOrder(order, paymentTask.get());
}
}
}Conclusion
Java 25 apporte des améliorations significatives qui modernisent le langage tout en préservant sa stabilité légendaire. Les Flexible Constructor Bodies simplifient l'initialisation, les Stream Gatherers offrent plus de flexibilité dans le traitement des données, et la Structured Concurrency révolutionne la programmation concurrente.
Ces fonctionnalités, combinées aux améliorations de performance du Generational ZGC et aux Scoped Values, font de Java 25 une version incontournable pour les applications modernes.
Ressources
- JEP 482: Flexible Constructor Bodies
- JEP 455: Primitive Types in Patterns
- JEP 473: Stream Gatherers
- Documentation Oracle Java 25
N'hésitez pas à expérimenter ces nouvelles fonctionnalités dans vos projets et à partager vos retours d'expérience !