What's next : Do you really get memory ? par Jevgeni Kabanov
A l’occasion de la What’s Next, Jevgeni Kabanov, CTO et fondateur de ZeroTurnaround, société éditrice de JRebel et LiveRebel nous a présenté une session sur la gestion de la mémoire en Java « Do you really get memory ? »
Je vais essayer dans ce billet de vous retranscrire l’essence de sa présentation qui a été un grand moment.
Fil rouge
Tout a commencé pour Jevgeni il y a quelques années en essayant d’optimiser JRebel. Il nous explique qu’au final, un grand nombre des problèmes de performances que l’on rencontre trouvent leurs explications dans la gestion de la mémoire : cache CPU, fuite mémoire, swap, garbage collector.
Afin d’éclairer son propos, il modélise l’architecture d’un processeur et le modèle mémoire avec du code Java. Débutons simplement par un CPU à deux cores partageant de la RAM représentée par un gros tableau de byte. On rentre dans le vif du sujet avec l’accès nécessairement synchronisé à la mémoire.
Les registres
Etape suivante dans la modélisation : les registres, on peut les voir comme un petit ensemble d’emplacements mémoires nommés locaux à chaque core avec un accès très rapide en un seul cycle CPU. Un CPU (ou un thread dans la modélisation) peut travailler localement, vite et en tout sécurité en terme de concurrence sur sa copie de variable en registre. Le Just In Time Compiler (JIT) Java utilise par exemple ces registres pour cacher des variables non utilisées par d’autres threads.
Cache de niveau 1
Jevgeni introduit la notion de cache en rappelant que l’accès à la mémoire est vraiment très lent. On parle bien sûr en nombre d’instructions machines donc de très petites fractions de secondes. Toutefois, il rappelle que l’accès à un registre ne nécessite qu’un seul cycle CPU alors que l’accès à la mémoire peut en nécessiter plusieurs centaines, d’où la justification d’un cache.
Le cache de niveau un est local à un thread. Il s’agit d’une notion très complexe mais que l’on pourrait voir sous la forme d’une HashMap spécialisée, dont la clé serait l’adresse et la valeur les blocs de mémoire. L’écriture d’une valeur dans le cache est réalisée en premier, puis l’écriture en mémoire se fait par la suite du fait de se lenteur. Les écritures sont empilées dans une queue et le processeur peut continuer son travail, il s’agit du “write back cache”.
Se pose alors la question des accès en lecture des autres cores à cette zone mémoire, le write back cache ne propageant pas immédiatement l’écriture en mémoire. Jevgeni nous apprend que les architectures Intel sur x86 assurent que les caches des différents cores soient synchronisés, ce qui n’est pas forcément le cas des autres architectures telles qu’Intanium ou PowerPC. Un moyen d’assurer cette synchronisation est de broadcaster un message sur le bus système indiquant aux caches locaux d’invalider la valeur en question et que, s’ils lisent cette valeur, ils doivent arrêter leur travail et attendre que le write back soit terminé.
Toutefois, ce mécanisme n’est pas suffisant à assurer la synchronisation du fait du parallélisme entre les écritures sur les multiples caches dans le CPU et du réordonnancement d’instructions que le CPU est susceptible de faire afin de minimiser le nombre d’accès en mémoire. Pour contourner ce problème, l’instruction MFENCE (barrière mémoire) a été introduite sur les architectures x86 et basiquement, permet de garantir que toutes les écritures et lectures sont terminées, donc visibles globalement et interdit d’autre part les optimisations CPU telles que le réordonnancement; autrement dit, MFENCE garantit que tous les caches sont bel et bien synchronisés.
Hyperthreading
Même avec le cache de niveau 1, l’accès à la mémoire est si couteux qu’une technologie entière en tire partie : l’hyperthreading. Il s’agit d’une technologie permettant à l’OS de voir un simple core CPU comme deux cores, permettant par exemple ainsi que le premier core logique accède à la mémoire pendant que l’autre exécute des instructions. Cela peut en l’occurrence s’avérer plus lent selon la nature du programme.
Pagination
On en arrive à l’étape suivante de la modélisation : la pagination.
La table de pagination est utilisée pour faire la correspondance entre les adresses de mémoire virtuelle allouée à une application et les adresses permettant d’accéder à la mémoire réelle. Cela permet entre autres l’isolement des zones mémoires entre processus et également de ne pas allouer toute la mémoire physique requise par un processus directement.
Lorsqu’une zone mémoire est requise et que la table de pagination indique qu’elle n’est pas présente, cela déclenche une page fault que l’on peut voir comme un callback à l’OS. Lorsque la mémoire requise n’est pas suffisante, les OS utilisent couramment le mécanisme du swap pour étendre virtuellement la mémoire. Le swap est en réalité un fichier sur le disque dur, qui peut être représenté par un buffer de byte ; les changements sur le fichier ou le tableau de byte en mémoire sont synchronisés dans les deux sens.
Je ne résiste pas à vous montrer un extrait de code de la présentation :
int[] pageTable = new int[160]; Memory ram = new RamChip(); Memory virtualMemory = new Memory() { public byte get(int address) { if (pageTable[page(address)] == -1) pageFault(page(address)); ram.get(combine(pageTable[page(address)], address)); } public void set(int address, byte value) { if (pageTable[page(address)] == -1) pageFault(page(address)); ram.set(combine(pageTable[page(address)], address), value); } };
Toutes ces notions bas niveau désormais introduites, Jevgeni transcrit quelques aspects du fonctionnement de la mémoire en Java sur la base de cette abstraction.
Volatile
Commençons par le mot clé volatile
, sujet fréquent en entretien technique. La première réponse est que cela permet de partager la valeur d’une variable entre thread. On a vu que chaque core a ses propres registres. Une optimisation classique du compiler ou du JIT (Just In Time Compiler) est de cacher des variables fréquemment utilisées dans une méthode dans ces registres. Ces variables deviennent privées au CPU ou au thread.
Le mot clé volatile
permet d’interdire cette mise en registre. Ensuite, on a observé que sur les architectures Intel x86, les caches sont synchronisés, mais cela ne signifie pas que l’on aura le même comportement sur d’autres types d’architecture. Ceci peut être un problème dans le cas d’une JVM. Volatile utilise donc l’instruction MFENCE afin de synchroniser les caches entres core.
Ensuite encore, on sait désormais que les CPU sont libres de réordonnancer des instructions. Un MFENCE est introduit après chaque lecture et écriture sur une variable déclarée volatile
. Le CPU assure donc que la variable est visible aux autres threads juste après son utilisation.
Autre point, beaucoup d’opérations sont réalisées en deux cycles CPU telles que sur le type objet Long de 64 bits en Java pour lequel, sur certaines architectures, deux enregistrements sont nécessaires. Un thread pourrait donc lire ce Long
partiellement initialisé. On voudrait plutôt que la création de ce Long
soit atomique, volatile permet de s’en assurer. Enfin dernière chose, volatile
agit en interdisant certaines optimisations globales du JIT.
Selon Jevgeni, voir les problématiques de programmation concurrente sous l’angle architecture distribuée aide à la compréhension. On rencontre en effet des problèmes typiques de ce type d’architectures comme le partage de mémoire, les verrous, le broadcast, la consistance.
volatile
n’est qu’une partie du puzzle. A noter d’ailleurs qu’une barrière mémoire est aussi utilisée avec les blocs de code protégés par le mot clé synchronized
.
Heap
Jevgeni évoque ensuite la mémoire allouée à un programme en Java (heap ou tas) et comment la heap est vulnérable aux différents problèmes vu précédemment. Du point de vue du CPU ou de l’OS, la heap est un tableau de byte découpé en pages.
La JVM a une toute autre abstraction de la mémoire. La heap est en effet une structure complexe segmentée en partitions hébergeant des objets et des valeurs et dispose d’une gestion automatique de son espace mémoire à savoir le GC (Garbage Collector). On connaît tous ce mécanisme de nettoyage de la heap, les objets qui ne sont plus référencés par d’autres objets sont automatiquement collectés afin de libérer de la mémoire.
Cette structure étant très complexe, elle se montre aussi être souvent la source de désagréments en terme de performance. Le GC a en général sa part de responsabilité du fait de longues pauses pendant lesquelles l’application est figée. On parle de stop-the-world.
Il y existe toute une variété de GC dans la JVM mais presque tous sont basés sur le Mark Sweep Compact. Ce dernier s’appuie sur l’algorithme de parcours Breadth First Search.
Ce GC se déroule en trois phases :
- dans une première phase appelée mark, commence par parcourir tous les objets accessibles, on obtient alors un ensemble d’objets vivants, tous les autres objets sont considérés comme mort.
- la deuxième phase consiste à recopier tous ces objets vivants les uns à côté des autres en ignorant complétement les autres objets morts. Ainsi il n’y a plus d’espace libre perdu entre les objets, ce qui ralentirait l’allocation de nouveaux objets à recherche d’espaces libres.
- enfin, il faut parcourir à nouveau toute la heap et remplacer les pointeurs avec les nouvelles adresses mémoire, les objets ayant changé de place.
Cet algorithme pose de gros problèmes :
- la première phase est directement proportionnelle à la taille de la heap
- il n’est pas possible de réallouer les objets sur une application qui tourne, il faut donc stopper tous les threads utilisateurs pendant la réécriture des pointeurs, cause de longues pauses encore directement liées à la taille de la heap
A noter que si du swap est utilisé, il faut charger et décharger toutes les pages du swap (on verra les colonnes pi et po de vmstat s’affoler), garantissant aussi de très longues pauses. Jevgeni recommande donc de ne pas activer le swap sur les serveurs hébergeant des JVM.
On sait contourner les problème posés par le Mark Sweep Compact. Sur les machines multi processeurs, le parallel GC peut permettre de réduire la pause via l’utilisation de plusieurs threads concurrents. Avec le Concurrent Mark Sweep (CMS), il n’est pas nécessaire de stopper les threads pendant la première phase, on risque simplement de ne pas marquer certains objets en train de mourir. La phase mark peut donc être exécutée de manière concurrente avec les threads utilisateurs. On n’échappe pas à une seconde phase appelée remark stoppant les threads utilisateurs, mais qui s’exécute de manière concurrente, pour marquer les objets modifiés pendant la première phase. En outre cet algorithme ne recopie pas les objets les uns à côté des autres, la JVM doit donc maintenir des listes chaînées des espaces libres et la heap se retrouve fragmentée.
Cela ne solutionne donc pas tous les problèmes, c’est pourquoi de nouveaux algorithmes ont été développés tels que le Garbage First (G1) ou celui d’Azul.
G1
Il faut donc travailler de telle sorte à ce que l’on doive stopper les threads utilisateurs le moins souvent possible pour réduire les pauses. Si pauses il doit y avoir, elles doivent être prédictibles. Le G1 fonctionne en fragmentant la JVM en de petites régions de même taille. Chaque région est encore segmentée en de très petites sections appelées card.
L’idée est de savoir combien de déchets contient chaque région et ainsi quelle quantité de mémoire est récupérable. On commence (First) ainsi par collecter les régions contenant le plus de déchets. On peut donc, dans un temps prédéfini collecter les régions nécessaires pour satisfaire le besoin d’allocation de mémoire. On effectue donc des collections partielles et non complètes de la heap.
Il faut également savoir quelles sont les dépendances entre objets et régions. Pour cela, des write barriers sont utilisées. Basiquement, des instructions sont ajoutées par la JVM à chaque écriture ou assignation d’une variable afin de mémoriser quelles sont les régions impliquées. Ces informations sont stockées dans des tables de cards. Ainsi une écriture est légèrement plus couteuse mais le GC est beaucoup plus rapide par la suite.
Pauseless GC – Azul
Voyons maintenant ce que Jevgeni nous dit à propos d’Azul. Cette société propose une suite constituée de matériel spécifique et d’une JVM spécifique capable de pauses GC très courtes voire inexistantes sur de très grandes heap.
Toutes les étapes du garbage collector sont faites de manière concurrente, en permanence. Azul a adopté le même fonctionnement que le G1 mais au lieu d’utiliser des write barriers, ils s’appuient sur des read barriers. Lorsqu’un objet est déplacé d’un espace à un autre, son adresse mémoire d’origine devient invalide. Lorsqu’un autre thread veut y accéder, il ne le retrouve pas. On retrouve le concept de page fault exposé précédemment. Les pages de mémoire virtuelle impliquées par la réallocation sont toutes marquées comme marquées en tant que “page fault”.
Cette partie de la mémoire peut toujours être adressée par une autre adresse virtuelle, c’est ce que permet cette technologie, du Many To One paging. La table de pagination simple vue précédemment serait juste plus complexe. De multiples pages de mémoire virtuelles peuvent pointer sur la même partie de la mémoire physique avec des adresses virtuelles multiples. Ainsi au moment de la lecture et de l’occurrence de la “page fault”, le pointeur est réécrit et la région en mémoire peut à nouveau être accédée.
En conclusion, Jevgeni nous rappelle que l’on est toujours dans un système distribué, … même si on pense que non et que les trois couches matérielle, OS et JVM ont besoin de mieux fonctionner ensemble.
En résumé
Un grand nombre de points techniques ont dû être simplifiés pour illustrer le propos mais ce talk a vraiment donné une vision compréhensible par tous de problèmes complexes relatifs à la gestion de la mémoire en Java, avec une ouverture sur les améliorations du garbage collector qui nous attendent dans un futur très proche.
On a eu beaucoup de plaisir à accueillir Jevgeni en France et ce grâce à la What’s Next. C’est un speaker sympa et d’une très grande qualité que l’on espère revoir l’an prochain.
Et la présentation peut se voir là http://streaming.java.no/tcs/#page:… (date de JavaZone2010).
++