Avant-propos
Le 22/09/2022, JDK19 est sorti. La principale nouveauté de cette version est la prise en charge des threads virtuels, qui viennent renforcer la famille des threads légers. Les threads virtuels libèrent la JVM de la contrainte de faire ordonnancer les threads par le système d’exploitation : elle les ordonnance elle-même. En réalité, Sun avait déjà mis en œuvre l’ordonnancement des threads par la JVM dans les premières machines virtuelles sous Solaris. Pour des raisons de complexité et de maintenabilité, ces implémentations ont finalement toutes été ramenées au modèle d’ordonnancement par le système d’exploitation.
长安归来锦衣客,昨日城南起新宅。(Revenu de Chang’an, le voyageur vêtu de brocart a fait bâtir hier une nouvelle demeure au sud de la ville.)En repensant à tout ce chemin parcouru, les concepts du multithreading donnent le tournis, comme un foisonnement de feux d’artifice ; les explications sur Internet sont innombrables, mais il semble toujours manquer une vue d’ensemble. J’ai donc retracé méthodiquement l’évolution du multithreading en Java, dans l’espoir de vous aider à en maîtriser les notions.
Ce que cet article n’aborde pas :
1 Cet article ne détaille pas les principes de mise en œuvre de certains points techniques, n’analyse pas le code source et ne comporte pas de schémas ; si vous y trouvez des concepts et des techniques qui vous intéressent, vous pouvez effectuer vos propres recherches 2 Il ne présente pas les bibliothèques et les frameworks prenant en charge la concurrence, comme Quasar, Akka, Guava, etc.
De quoi parle cet article
1 Présenter l’histoire de l’évolution du multithreading dans le JDK 2 Expliquer le fonctionnement et le contexte de certains éléments techniques de cette évolution, ainsi que les problèmes qu’ils ont résolus 3 Présenter mon point de vue sur certains de ces éléments techniques ; les personnes ayant un avis différent sont invitées à en discuter dans les commentaires
Jalon
Comme d’habitude, commençons par un tableau récapitulatif. Il rassemble les concepts essentiels liés aux threads dans les différentes générations du JDK. Au risque d’une comparaison un peu imparfaite, on peut rapprocher l’évolution du multithreading de celle de l’automobile : l’ère de la boîte manuelle (JDK1.4 et versions antérieures), celle de la boîte automatique (JDK5-JDK18), puis celle de la conduite autonome (JDK19 et versions ultérieures). Cette comparaison sert simplement à montrer qu’à partir de JDK5, on peut piloter le multithreading plus confortablement. Avec JDK19, on dépasse même la simple commodité : les performances des services à fortes entrées-sorties connaissent un saut qualitatif.
| Époque | Version | Date de publication | Concepts fondamentaux |
|---|---|---|---|
| Mode manuel | JDK1.0 | 1996-01-23 | Thread et Runnable |
| Mode manuel | JDK1.2 | 1998-12-04 | ThreadLocal、Collections |
| Mode automatique | JDK1.5/5.0 | 2004-09-30 | Clarification du modèle mémoire Java et introduction du paquet de concurrence |
| Mode automatique | JDK1.6/6.0 | 2006-12-11 | Optimisation de synchronized |
| Mode automatique | JDK1.7/7.0 | 2011-07-28 | Framework Fork/Join |
| Mode automatique | JDK1.8/8.0 | 2014-03-18 | CompletableFuture、Stream |
| Mode automatique | JDK1.9/9.0 | 2014-09-08 | Améliorer le mécanisme de contention des verrous |
| Mode automatique | JDK10 | 2018-03-21 | Gestion locale aux threads |
| Mode automatique | JDK15 | 2020-09-15 | Désactivation et abandon du verrouillage biaisé |
| Conduite autonome | JDK19 | 2022-09-22 | Threads virtuels |

L’ère de la boîte manuelle
L’auteur appelle l’époque de JDK1.4 et des versions antérieures l’ère du multithreading « à boîte manuelle », ou encore l’ère du multithreading natif. La manipulation des threads restait relativement rudimentaire, sans pool de threads disponible. Les développeurs devaient écrire eux-mêmes des outils pour éviter le gaspillage de ressources causé par la création fréquente de threads, et verrouiller manuellement les ressources partagées. C’est aussi à cette époque que sont nés de nombreux excellents frameworks de multithreading, dont le plus célèbre a été intégré à JDK5.0.
JDK 1.0 Thread et Runnable
La version JDK1.0, publiée au mois 1 de l’année 1996, a établi dès le départ le modèle fondamental des threads de Java. Malgré les corrections et ajustements ultérieurs, ce modèle n’a pas subi de modification substantielle : on peut donc parler d’une conception réussie qui s’est pérennisée. La préemption et la coopération sont deux modes courants d’ordonnancement des processus et des threads. Le mode préemptif convient très bien à un système d’exploitation pour ordonnancer ses processus : il attribue des tranches de temps aux différents processus et peut retirer ses ressources à un processus qui ne répond plus depuis longtemps, voire l’arrêter de force. Le mode coopératif exige que les processus libèrent volontairement leurs ressources. Avec ce mode d’ordonnancement, un thread dont l’exécution dure longtemps peut entraîner une « famine » pour tous les autres threads ayant besoin du CPU. La machine virtuelle Java HotSpot utilise un ordonnancement préemptif ; le langage Java a donc adopté ce mode d’ordonnancement des threads dès ses débuts. Dans JDK 1.0, les threads sont principalement créés en héritant de la classe Thread ou en implémentant l’interface Runnable. Ils sont démarrés par la méthode start de l’instance, et le code devant être traité en parallèle est placé dans la méthode run. La coopération et la communication entre threads reposent sur des méthodes rudimentaires telles que stop/resume/suspend.

Comment expliquer les notions de stop/resume/suspend ? Le thread principal peut appeler directement les méthodes d’arrêt, de pause et de reprise des threads enfants. Si vous avez utilisé un baladeur dans votre enfance, il possédait trois boutons : arrêt, pause et reprise. Imaginez que vous écoutiez simultanément 3 baladeurs : les trois baladeurs représentent trois threads enfants et vous représentez le thread principal. Vous pouvez librement contrôler le démarrage et l’arrêt de ces trois appareils.
Ce mécanisme présente un problème fatal : il provoque facilement des interblocages. En effet, lorsque le thread A a verrouillé une ressource et que le thread principal le suspend avant qu’il ne l’ait libérée (la méthode suspend ne libère pas le verrou), si le thread B souhaite alors acquérir cette ressource, il doit attendre que le thread A reprenne son exécution (resume) et la libère ; sinon, il ne pourra jamais l’obtenir, ce qui entraîne un interblocage.
JDK 1.2
Les mécanismes brutaux stop/resume/suspend ont été interdits dans cette version, au profit de méthodes comme wait/notify/sleep, qui permettent à plusieurs threads de coordonner leurs actions. Il convient de mentionner que les objets atomiques AtomicityXXX avaient déjà été conçus dans cette version, principalement pour résoudre le problème du caractère non atomique de i++. L’ajout de ThreadLocal et de Collections a élargi les façons d’utiliser le multithreading ; grâce à ces deux technologies, je qualifie cette période d’ère du turbocompresseur de Java.
ThreadLocal
ThreadLocal est une solution permettant à plusieurs threads d’utiliser des objets non sûrs pour les threads, sans recourir à des verrous. Elle ne résout pas les problèmes du type « crédit et débit d’un compte bancaire ou augmentation et diminution d’un stock ». Elle convient particulièrement aux classes ayant une fonction d’« outil », dont elle permet d’exécuter les méthodes de manière sûre au moyen de copies distinctes. SimpleDateFormat et les connexions aux bases de données en sont des exemples typiques. Sa conception est particulièrement ingénieuse. Imaginez que vous deviez la concevoir : une approche simple et courante consisterait à maintenir dans ThreadLocal une Map globale sûre pour les threads, avec le thread comme clé et l’objet partagé comme valeur. Cette conception présente un inconvénient : les fuites de mémoire, car la Map grossirait sans limite à mesure que de nouveaux threads s’y ajoutent. Pour résoudre ces fuites de mémoire, il faudrait nettoyer cette Map à la fin de chaque thread, ce qui nécessiterait encore de renforcer les capacités du GC ; le rapport entre effort et bénéfice serait manifestement peu intéressant. ThreadLocal a donc été conçu de sorte que la Map soit détenue par Thread lui-même, plutôt que par ThreadLocal. La clé est la variable ThreadLocal, et la valeur est la valeur correspondante. Chaque Thread y place les ThreadLocal qu’il utilise (cette conception entraîne bien sûr d’autres problèmes, qui ne sont pas abordés ici ; les lecteurs intéressés peuvent faire leurs propres recherches).
Collections
La classe utilitaire Collections a été conçue dans cette version. Elle fournit des enveloppes pour certaines collections afin de les rendre sûres en environnement multithread, comme SynchronizedList. À une époque où les seules collections de ce type étaient notamment Hashtable, Vector et Stack, son apparition a marqué son temps. L’idée fondamentale de l’outil Collections est la suivante : je transforme pour vous les collections non sûres en environnement multithread en collections sûres. Ainsi, vous n’avez pas à consacrer beaucoup de temps à la mise à niveau de votre code existant ; il suffit d’initialiser les collections avec les méthodes que je fournis au moment de leur création. Cela ressemble à la suralimentation par turbocompresseur des voitures, qui augmente la puissance et le couple du moteur sans modifier sa cylindrée. L’ère du turbo de Java est arrivée ^\_^
L’ère de la boîte automatique
JDK 5.0
Importer le paquet de concurrence
Doug Lea, dont le nom s’écrit 道格·利 en chinois, est un enseignant universitaire américain, une véritable sommité : J.U.C est son œuvre. Avant JDK1.5, nous ne pouvions utiliser que synchronized pour synchroniser les accès concurrents au code ; ses performances n’avaient alors pas encore été optimisées et étaient médiocres. Pour contrôler les threads, nous ne disposions également que des méthodes wait et notify d’Object. C’est alors que Doug Lea a soumis la proposition JSR-166 au JCP. Avant de soumettre JSR-166, il utilisait déjà depuis plus de trois ans du code offrant des fonctionnalités similaires à celles du package J.U.C ; ce code en constituait le prototype.
J.U.C propose plusieurs grandes catégories d’outils : objets atomiques, verrous et utilitaires, pools de threads, conteneurs thread-safe, etc. Les développeurs peuvent combiner librement ces capacités pour construire leurs produits : ils peuvent aussi bien utiliser ReentrantLock pour bâtir les fondations d’un framework qu’employer directement les outils ou conteneurs existants pour écrire leur code métier. Avec le recul historique, J.U.C méritait sans conteste le titre de « produit technologique de pointe » en 2004. Il a joué un rôle considérable dans la diffusion de Java. L’ère de la boîte automatique était arrivée pour Java : de même qu’une voiture automatique facilite la conduite, J.U.C a considérablement facilité l’utilisation du multithreading pour les programmeurs. Ce produit a ouvert une nouvelle ère.
Bien entendu, J.U.C présente également quelques imperfections :
Coût élevé en ressources CPU:si les tentatives CAS en boucle échouent pendant longtemps, elles imposent une charge très importante au processeur.
Solution : dans JUC, certaines implémentations limitent le nombre d’itérations de la boucle CAS, par exemple SynchronousQueue parmi les BlockingQueue.
Problème ABA:si une valeur, initialement A, devient B puis redevient A, la vérification CAS conclura qu’elle n’a pas changé, alors qu’elle a bel et bien changé : c’est le problème ABA. Dans la plupart des cas, le problème ABA n’affecte pas la correction du programme en situation de concurrence.
Solution : ajouter un numéro de version à chaque variable et l’incrémenter de 1 à chaque modification ; ainsi, A —> B —> A devient 1A —> 2B —> 3A. Java fournit AtomicStampedReference pour résoudre ce problème. AtomicStampedReference encapsule un tuple [E,Integer] afin d’associer à l’objet une estampille de version (stamp), évitant ainsi le problème ABA.
Ne peut garantir l’atomicité d’une opération que sur une seule variable partagée : le mécanisme CAS garantit uniquement l’atomicité d’une opération sur une variable, sans garantir celle de l’ensemble du bloc de code.
Solution : si l’on doit, par exemple, garantir la mise à jour atomique conjointe de 3 variables, il faut recourir à Synchronized. On peut aussi envisager de regrouper plusieurs variables dans une AtomicReference pour gérer ainsi plusieurs variables partagées.
Clarifier le modèle mémoire de Java
Cette version du JDK a redéfini clairement le modèle mémoire de Java. Auparavant, les modèles mémoire courants comprenaient le modèle de cohérence séquentielle et le modèle happens-before. Dans un modèle de cohérence séquentielle, l’ordre d’exécution du programme correspond exactement à l’ordre visible dans le code. C’est très difficile à garantir sur les processeurs modernes multicœurs qui optimisent l’exécution des instructions. De plus, garantir la cohérence séquentielle limite fortement les optimisations du code que la JVM peut effectuer à l’exécution.
Cependant, la relation « se produit avant » (Happens-before) définie par la spécification JSR 133 de cette version assouplit l’ordre d’exécution des instructions :
Dans un même thread, selon l’ordre d’exécution du code (c’est-à-dire son ordre sémantique), une opération précède toute opération suivante dans la relation happens-before. Le déverrouillage d’un objet monitor précède, dans cette relation, tout verrouillage ultérieur du même objet monitor. Une écriture dans un champ volatile précède toute lecture ultérieure de ce champ. Le démarrage d’un thread (l’appel de la méthode start() de l’objet thread) précède toute autre opération de ce thread. Toutes les opérations d’un thread précèdent l’appel de la méthode join() sur ce thread par tout autre thread. Si l’opération A précède B et que B précède C, alors A précède C.
En matière d’allocation mémoire, séparer de la mémoire principale la mémoire de travail propre à chaque thread donne à la JVM une grande marge pour optimiser l’exécution des instructions au sein d’un thread. Les variables de la mémoire principale peuvent être copiées dans la mémoire de travail d’un thread pour y être traitées séparément, puis les résultats peuvent être réécrits dans la mémoire principale à un moment donné après la fin de l’exécution : Mais comment garantir la cohérence des données entre les threads ? La solution donnée par la JLS (Java Language Specification) est la suivante : par défaut, la cohérence des données ne peut pas être garantie à tout instant, mais l’utilisation des mots-clés synchronized, volatile et final, dont la sémantique a été renforcée, permet de l’assurer.
Optimisation de synchronized dans le JDK 6.0
Le mot-clé synchronized, cet « aîné de la République », s’est fait voler la vedette par ReentrantLock dans la version 5.0. Il devait donc prendre sa revanche dans cette version : le JDK 6.0 a ainsi introduit des optimisations des verrous, comme l’attente active, l’élimination et la fusion des verrous, les verrous légers et les verrous biaisés. Cette optimisation illustre le principe d’une « gestion fine ». Avant cela, un objet verrouillé avec synchronized ne pouvait avoir que deux états : sans verrou ou verrouillé (verrou lourd). Après optimisation, les verrous possèdent 4 états, classés du niveau le plus bas au plus élevé : sans verrou, verrou biaisé, verrou léger, verrou lourd. Ces états montent progressivement en niveau selon la contention, mais ne peuvent pas redescendre, afin d’améliorer l’efficacité de l’acquisition et de la libération des verrous (à mon avis, c’est surtout que c’était trop complexe et que les développeurs de la JVM ont reculé devant la difficulté).
Cette optimisation a permis à synchronized de relever la tête : désormais, plus question de dire que ses performances étaient inférieures à celles de ReentrantLock. Mais la suite réserve encore des surprises : le verrouillage biaisé a été déprécié dans JDK 15(─.─||). À mon avis, synchronized est désavantagé parce qu’il n’est qu’un mot-clé : la JVM gère ses opérations sous-jacentes et doit « deviner » quelle manière de verrouiller convient le mieux à l’application. ReentrantLock, lui, confie directement cette décision au programmeur : si vous souhaitez de l’équité, utilisez un verrou équitable ; si vous préférez l’absence d’équité, utilisez un verrou non équitable. Du point de vue de la conception, c’est une forme de facilité, mais aussi de souplesse.
Framework Fork/Join de JDK 7.0
L’apparition de Fork/Join a également constitué une avancée technique. Son principal atout est de permettre la décomposition récursive des tâches, puis la fusion de leurs résultats. Pourtant, cette technologie nous est à la fois familière et étrangère : familière parce qu’elle est employée dans de nombreux contextes, notamment dans CompletableFuture et Stream, que nous aborderons ensuite avec JDK8.0 ; étrangère parce que nous semblons rarement l’utiliser dans le développement de nos applications métier.
Certains vont même jusqu’à la trouver sans grand intérêt. À mon avis, si vous développez des applications répondant à des besoins métier, elle présente peu d’intérêt, car vous n’en aurez pratiquement jamais besoin : les outils d’entrepôt de données couvrent les situations impliquant de gros volumes de données, et un pool de threads peut la remplacer dans les autres cas ; si vous développez des frameworks de middleware, elle a son utilité et pourrait vous servir. Peu de personnes remettent cette technologie en question sur le Web chinois, mais des discussions ont déjà lieu à l’étranger ; les personnes intéressées peuvent consulter directement Le framework Fork-Join de Java est-il défectueux ?
JDK 8.0
La sortie de cette version a marqué un tournant historique pour Java, au point qu’elle représente aujourd’hui plus de la moitié des programmes Java exécutés dans le monde. Toutefois, les mises à jour relatives au multithreading sont moins révolutionnaires que celles du JDK5.0. Outre l’ajout de quelques objets atomiques, les deux nouveautés suivantes sont les plus remarquables.
CompletableFuture
On trouve sur Internet de nombreuses présentations de CompletableFuture, qui expliquent pour la plupart son fonctionnement et son utilisation. Pourtant, une question m’a longtemps échappé : pourquoi CompletableFuture a-t-il vu le jour alors qu’il existait déjà tant d’outils de pools de threads, et quelle difficulté résout-il ? Quel est au juste son atout essentiel ? Si vous avez réfléchi au sujet, vous vous êtes sans doute posé cette question vous aussi. Rassurez-vous, j’ai trouvé la réponse pour vous.
Conclusion : le principal atout de CompletableFuture est Orchestration des tâches。CompletableFuture reprend les fonctionnalités de l’interface Future, notamment l’exécution concurrente de tâches, mais il existe d’autres moyens d’obtenir ces capacités. En revanche, sa capacité d’orchestration des tâches est irremplaçable : ses API centrales, qui permettent entre autres de construire des chaînes de tâches et de combiner leurs résultats, sont conçues à cette fin. Si le JDK a introduit ce cadre dans cette version, c’est donc principalement pour répondre aux besoins d’orchestration des tâches, de plus en plus difficiles à gérer dans le développement métier.
Une dernière précision : CompletableFuture repose sur le framework Fork/Join.
Stream
Clean Architecture évoque trois paradigmes de programmation : la programmation structurée (procédurale), la programmation orientée objet et la programmation fonctionnelle. Stream est une manifestation de la programmation fonctionnelle dans le langage Java. Selon moi, maîtriser Stream est une étape incontournable pour passer du niveau débutant au niveau intermédiaire. Au premier contact, Stream paraît certainement très déroutant, mais une fois familiarisé avec lui, vous découvrirez une nouvelle manière d’envisager la programmation. En tant que développeurs, nous confondons souvent trois notions : la programmation fonctionnelle, Stream et les expressions lambda, en pensant qu’elles désignent la même chose. Voici mon interprétation :
•La programmation fonctionnelle est une approche de programmation mise en pratique dans divers langages
•Stream est une nouveauté du JDK8.0. On peut aussi le considérer comme un nouveau concept conçu pour répondre à l’approche de la programmation fonctionnelle : Stream permet de programmer de manière fonctionnelle sur les classes de collections
•Une expression lambda est une fonction anonyme qui permet d’exprimer la programmation fonctionnelle de manière plus concise et plus efficace
Après toutes ces explications, quel rapport y a-t-il entre Stream et le multithreading ? Les méthodes de parallélisation de Stream reposent sur le framework Fork/Join. Effective Java contient une recommandation à ce sujet : « Utilisez la parallélisation des Stream avec prudence ». En effet, tous ces traitements parallèles s’exécutent dans un pool Fork/Join commun ; un dysfonctionnement dans un pipeline peut nuire aux performances d’autres parties sans rapport avec lui.
JDK 9.0
Améliorer le mécanisme de contention des verrous
La contention sur les verrous limite les performances de nombreuses applications Java multithreads. Le nouveau mécanisme de contention améliore les performances des moniteurs d’objets Java, comme l’ont confirmé plusieurs tests de performance (par exemple Volano), qui permettent d’estimer le débit maximal de la JVM. En pratique, ce nouveau mécanisme a obtenu d’excellents résultats dans 22 tests de performance différents. S’il pouvait être intégré à Java 9, les performances des applications augmenteraient considérablement. Pour l’expliquer simplement, lorsque plusieurs threads se disputent un verrou, avant l’optimisation : les threads arrivés plus tard suivent tous la même procédure standard d’attente. Après l’optimisation : lorsque la JVM reconnaît certains cas optimisables, elle permet directement aux threads arrivés plus tard de tenter d’acquérir le verrou par une sorte de « voie VIP ».
Pour une explication détaillée, consultez : Comprendre les verrous soumis à contention – une approche axée sur les performances
Flux réactifs
Les flux réactifs (Reactive Streams) constituent une norme de traitement des flux de données asynchrones avec une contre-pression non bloquante, qui fournit un ensemble minimal d’interfaces, de méthodes et de protocoles décrivant les opérations et les entités nécessaires.
Qu’est-ce que la contre-pression non bloquante ? Le terme chinois 背压 est une forme abrégée de back pressure. Pour faire simple, le producteur envoie des données au consommateur ; lorsque celui-ci n’arrive plus à les traiter, il en informe le producteur, qui réduit alors sa cadence de production. Le moyen le plus simple de mettre en œuvre ce mécanisme est une approche bloquante : les informations de pression sont directement renvoyées lors de l’envoi. Une mise en œuvre non bloquante rend la conception plus complexe, tout en améliorant les performances. PS: je trouve que 背压 est une mauvaise traduction ; on ne peut pas en déduire le sens à partir des caractères. 反压(« pression en sens inverse »)ne serait-il pas mieux ? ^\_^
Pour éviter qu’un consommateur ne plante sous une pression excessive sur ses ressources (pressure), il faut contrôler la vitesse du flux de données, c’est-à-dire appliquer un contrôle de flux (flow control), afin qu’un flux rapide ne submerge pas la destination. Il faut donc une contre-pression (back pressure) : le producteur et le consommateur doivent interagir au moyen d’un mécanisme de contre-pression. Ce mécanisme doit être asynchrone et non bloquant. S’il était synchrone et bloquant, le producteur devrait attendre pendant que le consommateur traite les données, ce qui entraînerait des problèmes de performances.
Les flux réactifs (Reactive Streams) définissent un ensemble d’entités, d’interfaces et de méthodes d’interopérabilité qui constituent une norme pour mettre en œuvre une contre-pression non bloquante. Des tiers suivent cette norme pour proposer des solutions concrètes, parmi lesquelles Reactor, RxJava, Akka Streams et Ratpack.
JDK 10 Contrôle local aux threads
Safepoint et ses limites :
Un Safepoint est un mécanisme de la JVM HotSpot qui permet d’arrêter toutes les applications. Pour effectuer certaines opérations de bas niveau, la JVM doit procéder à un Stop The World et suspendre tous les threads applicatifs. Elle ne peut toutefois pas les arrêter brutalement : elle leur envoie un signal leur indiquant qu’ils doivent s’arrêter. Lorsqu’un thread applicatif atteint un Safepoint, il suit cette instruction et y répond. C’est de là que vient le nom Safepoint. Le terme safe souligne que la JVM doit effectuer certaines opérations globales en toute sécurité ; ce point est donc qualifié de safe.
Les opérations nécessitant un point de sûreté global comprennent : 1、les pauses du ramasse-miettes 2、la désoptimisation du code (Code deoptimization). 3、la purge du cache de code. 4、la redéfinition des fichiers de classes (Class redefinition, par exemple le remplacement à chaud ou l’instrumentation). 5、la révocation des verrous biaisés (Biased lock revocation). 6、diverses opérations de débogage (par exemple : la détection des interblocages ou l’extraction des traces de pile).
Cependant, amener tous les threads à s’arrêter au safepoint le plus proche prend déjà beaucoup de temps. Et faire s’arrêter tous les threads ne semble-t-il pas un peu trop brutal et arbitraire ? Pour cette raison, Java 10 a introduit un mécanisme qui permet de ne pas arrêter tous les threads : la coordination locale des threads (Thread Local Handshake).
Voici, par exemple, des situations qui peuvent être traitées sans arrêter tous les threads : 1、Révocation d’un verrou biaisé. Il suffit d’arrêter un seul thread pour révoquer ce verrou, sans devoir tous les arrêter. 2、Réduction de l’incidence globale sur la latence de la VM des différents types de requêtes de diagnostic ; par exemple, récupérer les traces de pile de tous les threads d’une VM comportant de nombreux threads Java peut être lent. 3、Échantillonnage plus sûr des traces de pile grâce à une dépendance réduite aux signaux (signals). 4、Suppression de certaines barrières mémoire par un protocole de poignée de main avec les threads Java, grâce à la technique dite de synchronisation asymétrique de Dekker. Par exemple, le « code de marquage conditionnel des cartes » (conditional card mark code) utilisé dans G1 et CMS n’aura plus besoin de « barrières mémoire ». La « barrière d’écriture (write barrier) » émise par G1 pourra ainsi être optimisée, et les branches qui tentent de contourner les « barrières mémoire » pourront également être supprimées.
JDK 15 désactive et rend obsolètes les verrous biaisés
Pourquoi abandonner les verrous biaisés ? Le gain de performances qu’ils apportaient autrefois est aujourd’hui moins évident. Les applications qui en bénéficient utilisent souvent les premières API de collections Java (JDK 1.1), dont Hashtable et Vector, qui synchronisent chaque accès. JDK 1.2 a introduit des collections non synchronisées pour les usages à un seul thread (HashMap et ArrayList), et JDK 1.5 a apporté des structures de données concurrentes plus performantes pour les usages multithreads. Cela signifie qu’en adoptant les classes plus récentes, les applications qui tiraient profit des verrous biaisés à cause d’une synchronisation superflue pourraient gagner considérablement en performances. Par ailleurs, les applications organisées autour de files d’attente de pools de threads et de threads de travail sont généralement plus performantes lorsque les verrous biaisés sont désactivés.
Voici une implémentation utilisant les API de Hashtable et Vector : java.lang.Classloader utilise Vector java.util.Properties extends Hashtable java.security.Provider extends Properties java.net.URL utilise Hashtable java.net.URConnection utilise Hashtable java.util.ZipOutputStream utilise Vector javax.management.timer.TimerMBean expose Vector dans son interface
L’ère de la conduite autonome
Les threads virtuels font entrer Java dans l’ère de la conduite autonome. De nombreux langages, comme Go, C#, Erlang et Lua, possèdent des technologies semblables aux « threads virtuels », appelées « coroutines ». Cette fois, Java n’ajoute aucun mot-clé, ni même de nouveau concept. Les threads virtuels sont bien plus faciles à comprendre que les goroutines ou les coroutines : leur nom suffit à donner une idée de leur fonctionnement.
Threads virtuels de JDK 19
Dans le modèle traditionnel de Java, les threads correspondent à ceux du système d’exploitation selon un rapport 1:1. Leur création et les changements de contexte sont coûteux, et les limites du système d’exploitation restreignent leur nombre. Lorsque le nombre de requêtes simultanées est très élevé, il est impossible de créer un thread pour chacune. Un pool de threads peut atténuer ce problème en réduisant le coût de création des threads, mais il ne permet pas d’en augmenter le nombre. Si 2000 requêtes arrivent simultanément et que le pool ne contient que 1000 threads, seules 1000 requêtes peuvent être traitées au même instant. Les 1000 autres ne peuvent pas être traitées : elles peuvent être rejetées ou mises en attente jusqu’à ce qu’un thread se libère.
Avant les threads virtuels, la solution consistait à adopter un style asynchrone. De nombreux frameworks avaient déjà mis en œuvre la programmation concurrente asynchrone(comme Reactor dans Spring5), en partageant les threads pour améliorer la disponibilité. Le principe consiste à réduire les changements de contexte grâce au partage des threads, ce qui diminue les coûts et évite également les blocages : les threads ne sont utilisés que pendant l’exécution du programme et ne restent pas occupés lorsque celui-ci doit attendre. Le style asynchrone apporte effectivement de nombreuses améliorations, mais présente aussi des inconvénients. La plupart des frameworks asynchrones utilisent des appels chaînés qui divisent le programme en de nombreuses étapes, chacune pouvant s’exécuter dans un thread différent. Vous ne pouvez plus utiliser les API familières de programmation concurrente, comme ThreadLocal, sous peine de provoquer des erreurs. Le style de programmation change lui aussi considérablement : il est beaucoup plus complexe que le modèle traditionnel, exige un apprentissage important et peut nécessiter de remanier de nombreux modules existants du projet pour les adapter au mode asynchrone.
Le principe de fonctionnement des threads virtuels ressemble à celui de certains frameworks asynchrones : ils partagent eux aussi des threads et n’ont donc pas besoin d’être regroupés en pools. À l’usage, vous pouvez considérer que les threads virtuels sont disponibles en quantité illimitée et en créer autant que vous le souhaitez, sans craindre de problème. De plus, ils permettent le débogage et sont pris en charge par les outils de surveillance Java, ce qui est important. Les threads virtuels réduisent considérablement l’empreinte mémoire de votre programme. Toutes les applications à fortes entrées-sorties, comme les serveurs Web, peuvent ainsi augmenter fortement leur débit d’entrées-sorties à matériel égal. Là où 1 Go de mémoire permettaient auparavant de prendre en charge 1000 accès simultanés, les threads virtuels permettraient, selon les déclarations officielles, de gérer facilement 100 dizaines de milliers d’accès simultanés. Des tests de charge restent nécessaires pour savoir si cela tient dans un contexte métier précis, mais même en réduisant cette estimation, 10 dizaines de milliers devraient être facilement atteignables, sans aucun effort supplémentaire de votre part, peut-être même sans modifier le code. Les threads virtuels permettent en effet de conserver le style de programmation traditionnel, avec un thread par requête : on les utilise comme des threads, et le programme ne nécessite que très peu de modifications. Ils n’introduisent aucune nouvelle syntaxe ; le coût d’apprentissage et de migration est donc extrêmement faible.
Il convient de mentionner que les threads virtuels utilisent toujours le framework Fork/Join en interne.