La zone mémoire la plus connue d’une JVM est la heap mais ce n’est pas la seule zone de stockage disponible.
Nous verrons comment est structurée la mémoire d’une JVM et comment allouer de l’espace en dehors de la Heap ?
Et ce, dans le but de pouvoir conserver les données en mémoire même pour les applications les plus consommatrices.
Points abordés :
- Quelles sont les méthodes pour instancier de la mémoire hors heap
- Les cas d’applications (Cache, Memory Mapped File)
- Performances
- Problèmes rencontrés
Introduction et théorie
Voici les différentes zones que l’on peut retrouver dans un process Java :
On y trouve des zones techniques : JVM, OS.Des zones de stockage de données : Mémoire Heap et native.
La zone d’échange entre la JVM et l’OS : Librairies.
La gestion de la memoire Java est parfois assez déroutante pour les débutants : les paramètres de gestion les plus connus sont ms et mx qui régissent la taille de la Heap alors que nous venons de voir que ce n’est pas la seule zone mémoire d’une JVM.
Java nio a introduit le principe de mémoire off-heap (java 1.4). Java 7 l’a étendu (JSR-203/NIO2).
Les objets concernés sont les ByteBuffers (java.nio.ByteBuffer) avec allocation directe (maximum de 2 Go par buffer).
Concrètement il s’agit d’un tableau de Bytes qui sera stocké dans la mémoire native.
La nature même du stockage (sous forme binaire) impose une sérialisation/désérialisation des objets.
Tout d’abord le tableau suivant montre pourquoi il faut privilégier la mémoire aux autres types d’accès.
Comparaison des coûts de lecture en fonction du type de mémoire
**Technologie** | **Vitesse lecture** |
---|---|
Mémoire (RAM) | 10-60 ns |
Accès réseau | 10000-30000 ns |
Disques SSD | 70000-120000 ns |
Disques durs | 3000000-10000000 ns |
Gabarge Collector et problématiques
Longtemps la taille maximale allouée à une JVM a été limitée par différents facteurs :
- Architecture 32 bits du processeur.
- Système d’exploitation hébergeant la JVM (taille maximale d’un processus, maximum de RAM supportée)
Mais aussi, et c’était souvent le premier seuil atteint, par le comportement du Garbage Collector.
Il s’agissait de trouver le meilleur ratio entre taille de la Heap et péjoration des performances dues aux traitements du Garbage Collector.
De même, bien que plus rare que dans les langages sans gestion automatique de la mémoire, un programme Java n’est pas à l’abri des fuites mémoires.
C’est pourquoi, malgré la multiplication des architectures et OS 64 bits, cette barière n’a que peut évoluée depuis les débuts de Java.
On peut la fixer à 3-4 Go.
Conscients de cette limite, les éditeurs tentent de réagir.
Oracle à introduit le collecteur G1 (Garbage first) en version 6 de Java SE.
Ce collecteur a pour but de réduire les temps de passage du GC.
Azul System, s’est aussi positionné, avec la Zing Virtual Machine (Pauseless Garbage Collection).
En attendant, il est tentant d’utiliser le stockage hors heap dans des applications Java.
Mémoire off-heap
Une belle définition sous forme de lapalissade pourrait être : « tout ce qui n’est pas dans la heap est off heap ».
Tout d’abord voyons comment est structurée la mémoire dans la JVM
A – Zones méthodes (Method Area)
Cette zone est essentiellement alimentée par le classloader.
Quand la JVM charge une classe ou une interface, elle place les informations nécessaires dans cette zone.
Cette zone est éligible au Garbage Collector. Quand une classe passe hors scope elle sera collectée lors du prochain passage du GC.
Cette zone est partagée (accessible à tous les process de la JVM).
1 – Pool constantes :
Contient toutes les constantes définies par l’application :
Littérales : int, long, float, double ou bien String.
Références vers les types, les champs et les méthodes.
2 – Code méthode :
Implémentation (bytecode) de toutes les méthodes utilisées par la JVM.
3 – Zone d’informations :
Contient différents éléments d’information sur les classes.
Champs de classe :
- Nom du champ.
- Type du champ.
- Modificateurs du champ.
Méthodes :
- Nom de la méthode.
- Type retourné.
- Nombre et type des paramètres.
- Modificateurs de la méthode.
En plus pour les méthodes non abstraites ou natives :
- Taille de l’opérande et des variables locales (utilisée pour frame stack).
- Table d’exception.
4 – Variables de classes
Les variables de classes sont partagées par toutes les instances de la classe. De plus elles ne sont pas liées à l’instanciation d’une classe mais créées au chargement de la classe.
B – Heap
Lorsque la JVM doit instancier un objet java, elle le fait dans cette zone.
On y retrouve les classes, les objets, les primitives Java.
Cette zone est nettoyée par le GC.
Cette zone est partagée (accessible à tous les process de la JVM).
NB : il y a une seule Heap et Method Area par JVM.
C – Threads
Cette zone n’est pas partagée.
Pointeur d’instruction :
Indique la ligne de code actuellement exécutée.
Java stack :
Stocke les primitives et les références.
Stocke l’état des méthodes invoquées (non natives) par le thread, l’état comprend les variables locales à la méthode (ThreadLocal), les paramètres d’appel ainsi que la valeur retournée par la méthode.
Chaque état est stocké dans des frames (un par méthode) dont la durée de vie est liée au cycle d’appel de la méthode.
Cette zone est nettoyée par le GC.
Cette zone est parcourue par le GC afin d’identifier les objets non référencés dans la Heap.
Frames :
Trois sections compose les frames.
Piles opérande
- Pile FIFO 32/64 bits utilisée pour le stockage provisoire des variables ainsi que des calculs intermédiaires.
Données
- Références RCP (Runtime Constant Pool).
- Références vers le pool de constantes utilisées par le thread.
- Résultats des retours de méthodes.
Variables locales
- Tableau contenant les variables locales de la méthode. Soit les variables dont la durée de vie est celle de la méthode ainsi que les paramètres d’appel.
A l’image des Frames l’état des méthodes natives est stocké dans cette zone.
Mais contrairement à ces dernières, l’organisation est dépendante de l’OS et de l’implémentation de la méthode native.
Création d’objets en dehors de la heap Java
L’utilisation dans un programme Java de mémoire hors heap devra répondre aux problématiques suivantes :
- Accéder à la mémoire hors heap.
- Gestion de la mémoire.
- Référencement et accès à la mémoire off-heap.
A – Java Native Access (JNA)
JNA permet l’accès aux librairies natives (dll, so) à partir de code Java uniquement (sans utiliser JNI ou du C).
Une interface java décrit les méthodes et les structures de la librairie native.
Il n’y a pas de génération de code tout se fait au Runtime.
JNA est fourni avec certaines librairies natives courantes ainsi que des classes utilitaires d’accès à la mémoire native.
Plus d’informations :
Documentation : http://www.root-me.org/fr/Documentation/Hacking/Java-native-code-injection.html
Site web : https://github.com/twall/jna
Cette librairie fournie plusieurs méthodes pour créer des objets en dehors de la heap.
int value = 12345; int offset = 0; int size = 128; // on crée un objet mémory Memory m = new Memory(1024); // on alloue l'espace mémoire ByteBuffer buff = m.getByteBuffer(0, size); offset = offset + size; // On insère l'objet buff.putInt(value); // RAZ de la position courante du buffer buff.flip(); // On récupère et affiche l'objet System.out.println(buff.getInt()); // On déclare un deuxième buffer ByteBuffer buff2 = m.getByteBuffer(offset, size); // On insère l'objet buff2.putInt(value + 1); // RAZ de la position courante du buffer buff2.flip(); // On récupère et affiche l'objet System.out.println(buff2.getInt()); // on libère la mémoire m.clear(); // méthode 2 // on crée un objet mémory // et on insère directement les objets Memory m2 = new Memory(1024); // On insère l'objet m2.setInt(offset, value); // On récupère et affiche l'objet System.out.println(m2.getInt(offset)); // on libère la mémoire m2.clear();
B – Utilisation de la classe ByteBuffer
L’utilisation de la mémoire native avec cette API est simple, voire transparente.
Deux méthodes de création d’un ByteBuffer sont disponibles :
- ByteBuffer.allocateDirect()
- ByteBuffer.allocate()
Seule la première méthode instancie un objet en dehors de la heap.
Exemple de code :
byte[] bytes = ...; // allocate native memory to store our object ByteBuffer buf = ByteBuffer.allocateDirect(bytes.length); buf.put(bytes);
Remarque : Le contenu stocké doit être de type tableau de byte, ce qui explique que le contenu stocké hors heap doit être sérialisable.
Nota Bene : Ceux qui on déjà codé en C/C++ ne devront pas s’attendre a retrouver l’équivalent de malloc.
En effet les possibilités offertes sont très limitées et le plus surprenant étant qu’il n’y a pas de méthode pour désallouer un objet stocké hors heap.
Comment ça marche alors ?
En réalité une méthode de libération de la mémoire est crée automatiquement (sun.misc.Cleaner) et sera appelée par le GC lors de son prochain passage.
Conclusion :
Tout comme pour la heap, l’espace est libéré par le GC lorsque l’objet n’est plus référencé par le code.
Tout comme la heap il n’y a pas de relation directe entre le moment ou l’objet est libérable et le moment ou il est effectivement libéré.
Donc il n’y a pas de magie, les objets hors heap sont bien sensibles au GC.
Toutefois :
- Pas de phase de marquage des objets.
- Pas de phase de compaction (réorganisation de l’espace mémoire) pendant le passage du GC.
- Le nettoyage de la mémoire hors heap est donc plus rapide que son homologue de la heap.
Il est possible d’appeler la méthode de nettoyage à tout moment (encore une fois en fouillant dans les profondeurs de l’API) :
Method getCleanerMethod = buffer.getClass().getMethod("cleaner", new Class[0]); getCleanerMethod.setAccessible(true); sun.misc.Cleaner cleaner = (sun.misc.Cleaner)getCleanerMethod.invoke(buffer, new Object[0]); cleaner.clean();
C – Autres possibilité d’allocation directe
sun.misc.Unsafe
Son utilisation est un peu plus complexe, il s’agit de la classe qui est derrière ByteBuffer.allocateDirect().
Comme son nom l’indique cette classe est unsafe et doit donc être utilisée en toute connaissance de cause. En effet l’accès à une zone mémoire non allouée provoque immanquablement le crash de la JVM.
Pour plus de sécurité les constructeurs sont privés et la méthode de classe getUnsafe() ne peut être appelée que par un Bootloader (et donc par la JVM elle même).
Heureusement il est possible de contourner cette sécurité.
Méthode de récupération d’une instance :
private static Unsafe getUnsafeInstance() throws SecurityException, NoSuchFieldException, IllegalArgumentException, IllegalAccessException { Field theUnsafeInstance = Unsafe.class.getDeclaredField("theUnsafe"); theUnsafeInstance.setAccessible(true); return (Unsafe) theUnsafeInstance.get(Unsafe.class); }
Autres cas d’utilisation de la classe :
Cette classe permet de connaître l’adresse d’un objet stocké en mémoire.
Plus précisément la position d’un champ dans la zone mémoire allouée aux instances de la classe.
// Récupère une instance Unsafe Unsafe unsafe = getUnsafeInstance(); // Réserve de la mémoire directe Long allocateMemory = unsafe.allocateMemory(10); // Récupération de l'espace d'allocation du champs code Commune Field field = Commune.class.getDeclaredField("codeCommune"); Long offsetCodeCommune = unsafe.objectFieldOffset(field); // On affecte une valeur à l'emplacement du champ unsafe.putObject(allocateMemory, offsetCodeCommune, "325555");
NB : qui a dit qu’il fallait obligatoirement un setter public ?
MemoryMappedFile
Le contenu du fichier est mappé dans l’espace mémoire du process depuis le fichier source. Une fois le fichier chargé (ou une portion du fichier) tous les accès se font en mémoire.
Il en résulte des performances extrêmes par rapport aux méthodes traditionnelles qui utilisent les accès disques.
Cette méthode est la plus indiquée en cas de copie partielle ou totale d’un fichier.
File file = new File(path); // Creation d'un memory-mapped file en lecture seule FileChannel roChannel = new RandomAccessFile(file, "r").getChannel(); MappedByteBuffer mbb = roChannel.map(FileChannel.MapMode.READ_ONLY, 0, (int) roChannel.size()); // tableau de bits utilisé pour la lectre ByteArrayBuffer bits = new ByteArrayBuffer(); while(mbb.hasRemaining()) { // on lit le prochain octet byte b = mbb.get(); if (b == 10 || b == 13) { if (!firstLine) { // On ignore la première ligne (entête) firstLine = true; } else { // on affiche le contenu du fichier System.out.println(bits.toString()); } // on passe à une nouvelle ligne // on réinitialise le tableau bits = new ByteArrayBuffer(); } else // on ajoute l'octet au tableau bits.write(b); } // on ferme le canal roChannel.close(); // on vide le buffer mbb.clear(); ...
Particularités :
-
L’OS charge/décharge le contenu du fichier selon les besoins.
-
Utile pour les fichiers extrêmement volumineux.
-
Ou à l’inverse lorsque seul un fragment d’un fichier est utilisé.
Exemple
Mise en cache d’un extrait de la liste des communes mondiales de plus de 1000 habitants (+/- 114000) à partir d’un csv. Le code présenté a été teste avec la JVM OpenJDK 1.7 sur MAC (elle apporte un MBean de monitoring de la mémoire allouée par AllocateDirect()).
Quatre versions :
- Une classique avec une Map
- Une directbuffer en utilisant serialisation java
- Une directbuffer en utilisant externalizable java
- Une unsafe
public class MemoryCache { private Mapdata = new HashMap(); public void put(Object key, Commune object) throws IOException { data.put(key, object); } public Object get(Object key) throws ClassNotFoundException, IOException { return data.get(key); } … }
- & 3)
public class NativeMemoryCache { private Map data = new HashMap(); public void put(Object key, Serializable object) throws IOException { byte[] bytes = serialize(object); // allocate native memory to store our object ByteBuffer buf = ByteBuffer.allocateDirect(bytes.length); buf.put(bytes); buf.flip(); data.put(key, buf); } public Object get(Object key) throws ClassNotFoundException, IOException { ByteBuffer buf = data.get(key).duplicate(); byte[] bytes = new byte[buf.remaining()]; buf.get(bytes); return deserialize(bytes); } … }
public class NativeMemoryCacheUnSafe { /** * Création de la map pour stocker la clé * ainsi que l'adresse de l'objet en mémoire native / private Map data = new HashMap(120000); /* * Objet Unsafe pour accéder * à la mémoire native / private static Unsafe unsafe = null; /* * Offset de stockage dans la mémoire native / private static Long offset = new Long(0); static { try { unsafe = getUnsafeInstance(); } catch (SecurityException e) { System.out.println("Exception de sécurité :" + e); } catch (NoSuchFieldException e) { System.out.println("Impossible d'accéder au champ de la classe Unsafe :" + e); } catch (IllegalAccessException e) { System.out.println("Impossible d'accéder à la classe Unsafe :" + e); } } public void put(Long key, long codeCommune, String nomCommune, double latitude, double longitude, String codePays, String codeRegion, String codeAdministratif, int population, String timeZone) throws IOException, SecurityException, NoSuchFieldException, IllegalArgumentException, IllegalAccessException { //Taille max d'un objet (bytes) Long allocateMemory = unsafe.allocateMemory(400); unsafe.putObject(allocateMemory, offset, new Commune(codeCommune, nomCommune, latitude, longitude, codePays, codeRegion, codeAdministratif, population, timeZone)); data.put(key, allocateMemory); } /* * @param key identifiant unique de l'objet * @return * @throws ClassNotFoundException * @throws IOException * @throws SecurityException * @throws NoSuchFieldException * @throws IllegalArgumentException * @throws IllegalAccessException */ public Commune get(Object key) throws ClassNotFoundException, IOException, SecurityException, NoSuchFieldException, IllegalArgumentException, IllegalAccessException { Long adresse = data.get(key); return (Commune)unsafe.getObject(adresse, offset); } … }
Résultats
used : La quantité de mémoire réellement utilisée par l’application.
committed : La quantité de mémoire qui a été réservée auprès du système d’exploitation.
Paramètres JVM : -X:+AggressiveHeap
Test 1.
**Type de test** | **Classic** |
---|---|
Temps de traitement (ms) | 477 |
Collection Count | 0 |
Collection Time | 0 |
Utilisation heap | used=229749Ko committed=2636992Ko |
Utilisation non Heap | used=3729Ko committed = 23744Ko |
Buffer pool (si pas de libération de la mémoire) | _ |
**Type de test** | **Direct ** **(Serializable)** |
---|---|
Temps de traitement (ms) | 1697 |
Collection Count | 1 |
Collection Time | 1329 |
Utilisation heap | used=794123Ko committed=2652352Ko |
Utilisation non Heap | used=4615Ko committed=23744Ko |
Buffer pool (si pas de libération de la mémoire) | Type : Direct Count:113576 MemoryUsed:31849106 Total capacity:31849106 |
**Type de test** | **Direct ** **(Externalizable)** |
---|---|
Temps de traitement (ms) | 1473 |
Collection Count | 1 |
Collection Time | 413 |
Utilisation heap | used=288094Ko committed=2655296Ko |
Utilisation non Heap | used=4587Ko committed = 23744Ko |
Buffer pool (si pas de libération de la mémoire) | Type : Direct Count:113576 MemoryUsed:16175618 Total capacity:16175618 |
**Type de test** | **Direct** **(Unsafe)** |
---|---|
Temps de traitement (ms) | 490 |
Collection Count | 0 |
Collection Time | 0 |
Utilisation heap | used=232327Ko committed=2652480Ko |
Utilisation non Heap | used=3780Ko committed=23744Ko |
Buffer pool (si pas de libération de la mémoire) | – |
Terracota BigMemory (ehcache) : stockage de données dans la mémoire native
Coherence : Coherence propose le stockage off heap dans son cache
DirectMemory : Equivalent open source de Big Memory
Huge collections : Framework permettant de manipuler de grande collection en utilisant la mémoire native
Références
Java virtual machine specifications : http://java.sun.com/docs/books/jvms/
The Structure of the Java Virtual Machine : http://java.sun.com/docs/books/vmspec/2nd-edition/html/Overview.doc.html
Options de la JVM relatives à la mémoire directe.
-d64
Permet de passer la JVM en mode 64bits, utile pour adresser plus de mémoire.
-XX:MaxDirectMemorySize= ou -Dsun.nio.MaxDirectMemorySize=
Permet de définir la mémoire maximale réservées pour la mémoire off heap.
-XX:+PageAlignDirectMemory ou -Dsun.nio.PageAlignDirectMemory=true
Permet de s’assurer qu’un espace de mémoire native est alignée sur une page mémoire. Avant le JDK7, une page entière (ou plus) était allouée quelle que soit la taille de l’objet (4096 octets sur une majorité d’OS).
Conclusion
L’exemple retenu n’est pas le plus favorable aux cas d’utilisation de la mémoire off-heap, c’est pourquoi il met en exergue les inconvénients de cette solution.
L’utilisation de la mémoire native est une solution à envisager si votre programme est très consommateur en mémoire et pas forcement pour les programmes affectés par les passages du GC.
Ce n’est donc pas forcement le miracle attendu dans le sens ou ne vous dispensera pas des tracas du GC.
De plus le programme consomme temporairement autant de heap qu’un programme traditionnel (creation d’objets avant de stocker off-heap).
Enfin la sérialisation est coûteuse en terme de performances (mais seuls les programmes les plus exigeants la verront comme un véritable obstacle).
C’est pourquoi, sauf à utiliser la classe Unsafe, l’utilisation de la mémoire native est moins performante que son homologue Heap.
Conditions idéales d’utilisation de la mémoire native :
- Idéal quand la volumétrie de données est très importante (> 2 Go).
- Quand les données sont stables dans le temps (sinon les données sont collectées par le GC).
- Et enfin quand les données sont sérialisables.
L’utilisation des classes comme Unsafe est très dangereuse et réclamera une longue mise au point afin d’éviter un crash de la JVM avec pour seuls indices un code retour 139 (Segmentation violation) ou 134 (Dump core).
Pour les programmes affectés par le passage du Garbage Collector, c’est une solution parmi d’autres :
- Tuning de la JVM et du GC
- Création d’un pool d’objets réutilisables (crées à l’avance et jamais libérés).
Le code source est disponible ici : Source