Java 25 : Scoped Values (JEP 506)

Avec Java 25, la JEP 506 finalise les Scoped Values, une nouvelle API pour partager des données immuables entre méthodes sans les passer explicitement en paramètres. Cette fonctionnalité est une alternative moderne et performante à ThreadLocal, spécialement conçue pour fonctionner efficacement avec les Virtual Threads.

Cette nouveauté fait partie des JEPs (Java Enhancement Proposals) finalisées dans Java 25.

Pourquoi remplacer ThreadLocal ?

ThreadLocal existe depuis Java 1.2 et permet de stocker des données spécifiques à un thread. Cependant, cette API présente plusieurs problèmes :

Problèmes de ThreadLocal

  • Mutabilité : Les valeurs peuvent être modifiées à tout moment, rendant le comportement difficile à prédire
  • Fuites mémoire : Si remove() n'est pas appelé, les valeurs restent en mémoire, particulièrement problématique avec les pools de threads
  • Héritage complexe : InheritableThreadLocal copie les valeurs aux threads enfants, ce qui est coûteux avec des milliers de Virtual Threads
  • Durée de vie illimitée : Une valeur ThreadLocal vit tant que le thread existe
// Problèmes typiques avec ThreadLocal
public class ExempleThreadLocal {
    private static final ThreadLocal<User> currentUser = new ThreadLocal<>();

    public void handleRequest(User user) {
        currentUser.set(user);
        try {
            processRequest();
        } finally {
            currentUser.remove(); // Facile à oublier !
        }
    }

    // Avec Virtual Threads, chaque requête crée un nouveau thread
    // → Des milliers de copies avec InheritableThreadLocal
}

Introduction aux Scoped Values

Les ScopedValue résolvent ces problèmes en offrant :

  • Immuabilité : Une fois définie, la valeur ne peut pas être modifiée
  • Durée de vie définie : La valeur n'existe que dans un scope précis
  • Pas de fuites mémoire : Nettoyage automatique à la sortie du scope
  • Performances optimisées : Conçues pour les Virtual Threads

Exemple de base

import java.lang.ScopedValue;

public class ExempleScopedValue {
    // Déclaration d'une ScopedValue (comme une constante)
    private static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();

    public void handleRequest(User user) {
        // Création d'un scope avec la valeur
        ScopedValue.runWhere(CURRENT_USER, user, () -> {
            // Dans ce bloc, CURRENT_USER.get() retourne user
            processRequest();
            validatePermissions();
            saveAuditLog();
        });
        // Ici, CURRENT_USER n'est plus disponible
    }

    private void processRequest() {
        // Accès à la valeur dans le scope
        User user = CURRENT_USER.get();
        System.out.println("Traitement pour: " + user.getName());
    }

    private void validatePermissions() {
        User user = CURRENT_USER.get();
        if (!user.hasPermission("READ")) {
            throw new SecurityException("Permission refusée");
        }
    }

    private void saveAuditLog() {
        User user = CURRENT_USER.get();
        AuditLogger.log("Action par " + user.getId());
    }
}

API ScopedValue en détail

Création d'une ScopedValue

// Déclaration typique (static final)
private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
private static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();
private static final ScopedValue<Transaction> TRANSACTION = ScopedValue.newInstance();

Exécution dans un scope

// Avec runWhere (void)
ScopedValue.runWhere(CURRENT_USER, user, () -> {
    // Code qui peut accéder à CURRENT_USER.get()
    doSomething();
});

// Avec callWhere (retourne une valeur)
String result = ScopedValue.callWhere(CURRENT_USER, user, () -> {
    return processAndReturn();
});

// Avec getWhere (accès direct à la valeur)
String name = ScopedValue.where(CURRENT_USER, user)
    .call(() -> CURRENT_USER.get().getName());

Lecture de la valeur

// Lecture simple (lève NoSuchElementException si hors scope)
User user = CURRENT_USER.get();

// Vérification de la présence
if (CURRENT_USER.isBound()) {
    User user = CURRENT_USER.get();
}

// Avec valeur par défaut
User user = CURRENT_USER.orElse(User.ANONYMOUS);

// Avec supplier
User user = CURRENT_USER.orElseGet(() -> loadDefaultUser());

Scopes imbriqués

Les Scoped Values supportent les scopes imbriqués. Un scope interne peut "masquer" la valeur d'un scope externe.

private static final ScopedValue<String> CONTEXT = ScopedValue.newInstance();

public void demonstrerImbrication() {
    ScopedValue.runWhere(CONTEXT, "externe", () -> {
        System.out.println(CONTEXT.get()); // "externe"

        ScopedValue.runWhere(CONTEXT, "interne", () -> {
            System.out.println(CONTEXT.get()); // "interne"
        });

        System.out.println(CONTEXT.get()); // "externe" (restauré)
    });
}

Rebinding dans le scope

// Utiliser where().run() pour combiner plusieurs valeurs
ScopedValue.where(USER, user)
    .where(TRANSACTION, tx)
    .where(REQUEST_ID, requestId)
    .run(() -> {
        processWithContext();
    });

Utilisation avec les Virtual Threads

Les Scoped Values sont optimisées pour les Virtual Threads introduits dans Java 21. Contrairement à InheritableThreadLocal qui copie les valeurs, les Scoped Values sont automatiquement héritées sans copie.

private static final ScopedValue<User> USER = ScopedValue.newInstance();

public void handleRequestWithVirtualThreads(User user) {
    ScopedValue.runWhere(USER, user, () -> {
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            // Les Virtual Threads créés ici héritent automatiquement de USER
            executor.submit(() -> {
                // USER.get() fonctionne ici sans copie !
                processInVirtualThread();
            });

            executor.submit(() -> {
                // Et ici aussi
                anotherTask();
            });
        }
    });
}

Cas d'utilisation pratiques

1. Contexte de requête web

public class RequestContext {
    public static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
    public static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();
    public static final ScopedValue<Locale> LOCALE = ScopedValue.newInstance();
}

@WebFilter("/*")
public class ContextFilter implements Filter {
    @Override
    public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) {
        HttpServletRequest httpReq = (HttpServletRequest) req;

        String requestId = UUID.randomUUID().toString();
        User user = authenticateUser(httpReq);
        Locale locale = determineLocale(httpReq);

        ScopedValue.where(RequestContext.REQUEST_ID, requestId)
            .where(RequestContext.CURRENT_USER, user)
            .where(RequestContext.LOCALE, locale)
            .run(() -> {
                chain.doFilter(req, resp);
            });
    }
}

// Dans n'importe quel service
public class OrderService {
    public Order createOrder(List<Item> items) {
        User user = RequestContext.CURRENT_USER.get();
        String requestId = RequestContext.REQUEST_ID.get();

        logger.info("[{}] Création commande pour {}", requestId, user.getId());
        // ...
    }
}

2. Contexte de transaction

public class TransactionContext {
    public static final ScopedValue<Connection> CONNECTION = ScopedValue.newInstance();
    public static final ScopedValue<String> TRANSACTION_ID = ScopedValue.newInstance();

    public static <T> T executeInTransaction(Supplier<T> operation) {
        try (Connection conn = dataSource.getConnection()) {
            conn.setAutoCommit(false);
            String txId = generateTxId();

            try {
                T result = ScopedValue.where(CONNECTION, conn)
                    .where(TRANSACTION_ID, txId)
                    .call(operation::get);

                conn.commit();
                return result;
            } catch (Exception e) {
                conn.rollback();
                throw e;
            }
        }
    }
}

// Utilisation
public void transferMoney(Account from, Account to, BigDecimal amount) {
    TransactionContext.executeInTransaction(() -> {
        Connection conn = TransactionContext.CONNECTION.get();
        // Utiliser la connexion pour les opérations
        accountRepository.debit(from, amount);
        accountRepository.credit(to, amount);
        return null;
    });
}

3. Contexte de sécurité

public class SecurityContext {
    public static final ScopedValue<Principal> PRINCIPAL = ScopedValue.newInstance();
    public static final ScopedValue<Set<String>> ROLES = ScopedValue.newInstance();

    public static void requireRole(String role) {
        Set<String> roles = ROLES.orElse(Set.of());
        if (!roles.contains(role)) {
            throw new AccessDeniedException("Rôle requis: " + role);
        }
    }

    public static <T> T runAs(Principal principal, Set<String> roles, Supplier<T> action) {
        return ScopedValue.where(PRINCIPAL, principal)
            .where(ROLES, roles)
            .call(action::get);
    }
}

4. Contexte de logging

public class LogContext {
    public static final ScopedValue<Map<String, String>> MDC = ScopedValue.newInstance();

    public static void withContext(Map<String, String> context, Runnable action) {
        Map<String, String> merged = new HashMap<>(MDC.orElse(Map.of()));
        merged.putAll(context);

        ScopedValue.runWhere(MDC, Map.copyOf(merged), action);
    }
}

// Utilisation
LogContext.withContext(Map.of("orderId", "12345"), () -> {
    // Tous les logs dans ce scope auront orderId
    logger.info("Traitement commande"); // [orderId=12345] Traitement commande
});

Comparaison : ThreadLocal vs ScopedValue

Aspect ThreadLocal ScopedValue
Mutabilité Mutable (set/get) Immuable dans le scope
Durée de vie Durée du thread Durée du scope
Nettoyage Manuel (remove()) Automatique
Héritage Copie coûteuse Sans copie
Virtual Threads Problématique Optimisé

Migration de ThreadLocal vers ScopedValue

// Avant - ThreadLocal
public class AvantMigration {
    private static final ThreadLocal<User> currentUser = new ThreadLocal<>();

    public void setCurrentUser(User user) {
        currentUser.set(user);
    }

    public User getCurrentUser() {
        return currentUser.get();
    }

    public void clearCurrentUser() {
        currentUser.remove();
    }

    public void handleRequest(User user) {
        setCurrentUser(user);
        try {
            processRequest();
        } finally {
            clearCurrentUser();
        }
    }
}

// Après - ScopedValue
public class ApresMigration {
    private static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();

    public static User getCurrentUser() {
        return CURRENT_USER.get();
    }

    public void handleRequest(User user) {
        ScopedValue.runWhere(CURRENT_USER, user, this::processRequest);
    }
}

Bonnes pratiques

  • Déclarez les ScopedValue en static final : comme des constantes
  • Utilisez des noms en SCREAMING_CASE : convention pour les constantes
  • Préférez les types immuables : évitez de stocker des objets mutables
  • Gardez les scopes courts : uniquement pour la durée nécessaire
  • Documentez l'usage : indiquez quand une ScopedValue doit être bound

Conclusion

Les Scoped Values de Java 25 (JEP 506) représentent une évolution majeure pour le partage de contexte en Java. Cette API moderne, immuable et performante remplace avantageusement ThreadLocal, particulièrement dans le contexte des Virtual Threads.

Pour les nouvelles applications Java 25, préférez systématiquement les Scoped Values. Pour les applications existantes, planifiez une migration progressive lors des refactorisations.

Ressources