Aller au contenu
BackJavaLoom

Révolutionnez votre programmation asynchrone avec Java 21 et Loom

Depuis sa version 19, Java a introduit le projet Loom, et avec lui, la promesse d'une programmation asynchrone non bloquante, tout en restant dans un paradigme impératif.

Project Loom
Java 21 et Loom

Depuis sa version 19, Java a introduit le projet Loom qui promet une programmation asynchrone non bloquante, tout en restant dans un paradigme impératif.

Quel problème essaie-t-on de résoudre  ?

Tout programme qui doit s'exécuter est associé à un Thread du Système d'Exploitation (OS). C'est ce dernier qui va décider de son exécution sur le CPU. Dans l'absolu, on ne peut exécuter qu'un seul thread à la fois sur un CPU, le parallélisme étant simulé par l'OS en faisant tourner les Threads qui s'exécutent. Depuis l'arrivée des processeurs multicœur, on peut exécuter réellement en parallèle autant de Thread que de cœur de processeur.

En Java, pour exécuter du code, on utilise la classe Thread, introduite en 1995, qui représente précisément un seul Thread système. On peut exécuter une portion de code sur un Thread en lui passant un Runnable (dans l'exemple ci-dessous, une lambda qui remplace l'interface fonctionnelle Runnable)

public class Main {
    public static void main(String[] args) throws Exception {
        Thread.ofPlatform()
                .start(() -> System.out.println("In thread"))
                .join();
    }
}

Il est important de noter qu'un Thread OS est relativement coûteux à créer et que, en général, les applications vont créer un Pool de Thread  pour optimiser l'exécution.

Tout cela fonctionne très bien, tant qu'on a une exécution continue. Dans l'idéal, on voudrait que le CPU soit utilisé au maximum de ses capacités. Dans la réalité, les Threads sont souvent bloqués par de l'attente IO. Typiquement, lors d'une requête à une base de données, le Thread va attendre plusieurs millisecondes la réponse avant d'avoir le retour pendant lequel il ne peut rien faire. Avec un peu de chance, l'OS passera à l'exécution d'un autre Thread mais, dans une application de gestion, il y a une forte probabilité que la plupart des Threads en cours soient pendus à l'attente d'une réponse. Une fois le pool de threads épuisé, l'application est paralysée.

Loom et les Threads Virtuels

Contrairement au Thread Plateforme qui est associé à un unique Thread de l'OS, qui sera d'ailleurs créé pour l'occasion, un Thread Virtuel n'est associé à aucun Thread. Au moment de son exécution, la JVM va choisir un Thread porteur (Carrier Thread) et y affecter le Runnable. Lorsque le code fait une opération bloquante, il va être détaché du Thread porteur (Yield) et son état va être stocké en RAM. Le thread porteur est alors libre d'exécuter le code d'un autre Thread virtuel (via une Continuation).

La plupart des opérations bloquantes de la JVM (sleep, opérations sur fichiers, ...) y compris le pilote JDBC, sont mises à jour pour tirer parti de ce mécanisme. On peut donc s'attendre à de grandes améliorations des performances, sans avoir à changer le code que l'on a l'habitude d'écrire.

Voici un petit exemple des capacités de Loom pour gérer le code bloquant

public class Main {
    public static void main(String[] args) throws Exception {
        LocalDateTime time = LocalDateTime.now();
        Runnable r = () -> {
            try{
                Thread.sleep(5000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        };
        List<Future<?>> futures = new ArrayList<>(100_000);
        ExecutorService virtualThreadExecutor = Executors.newVirtualThreadPerTaskExecutor();
        for (int i = 0; i < 100_000; i++){
            futures.add(virtualThreadExecutor.submit(r));
        }
        for(Future<?> f : futures){
            f.get();
        }
        System.out.println(ChronoUnit.MILLIS.between(time, LocalDateTime.now()));
    }
}

On déclare un Runnable qui va dormir 5 secondes. On va lancer ce Runnable 100.000 fois dans un ExecutorService qui lance un Thread Virtuel pour chaque nouvelle tâche. On attend ensuite la fin des threads via Future.get().

Sortie console :

6319

Process finished with exit code 0

Soit 6,3 secondes.

Je déconseille fortement de lancer tout ça dans un Executor classique. Pour espérer avoir une exécution dans les 5 secondes il faudrait créer un pool de 100.000 Threads, ce qui prendra bien plus que 5 secondes et sera très couteux en ressources système.

Limitations

  • L'utilisation des blocs de code synchronized contenant du code bloquant ne sera pas géré par Loom. Il faut éviter leur utilisation et préférer un ReentrantLock. Malheureusement, l'effet est aussi présent pour une librairie qui utiliserait un bloc synchronized.
  • L'utilisation de Threads Virtuels n'est pas recommandée si une exécution ne fait pas d'opération bloquante et fait surtout du calcul. On s'évite l'overhead de la gestion des Carriers.

Conclusion

L'utilisation de Loom et des Threads Virtuels va devenir fondamentale dans toutes les applications web/métiers qui font beaucoup d'opérations bloquantes (requêtes BDD, HTTP, ...). Des gains de performances importants seront constatés sans même avoir à changer le code.

L'équipe Spring Boot travaille sur son intégration depuis la préversion de Java 19. Il ne fait aucun doute qu'une version incluant ces fonctionnalités sera publiée peu de temps après la sortie de Java 21.

Dernier