Astuce Java 130: Connaissez-vous la taille de vos données?

Récemment, j'ai aidé à concevoir une application serveur Java qui ressemblait à une base de données en mémoire. Autrement dit, nous avons orienté la conception vers la mise en cache de tonnes de données en mémoire pour fournir des performances de requête ultra-rapides.

Une fois que nous avons lancé le prototype, nous avons naturellement décidé de profiler l'empreinte mémoire des données après leur analyse et leur chargement à partir du disque. Les premiers résultats insatisfaisants m'ont cependant incité à chercher des explications.

Remarque: vous pouvez télécharger le code source de cet article à partir de Resources.

L'outil

Étant donné que Java masque délibérément de nombreux aspects de la gestion de la mémoire, la découverte de la quantité de mémoire consommée par vos objets demande du travail. Vous pouvez utiliser la Runtime.freeMemory()méthode pour mesurer les différences de taille de tas avant et après l'allocation de plusieurs objets. Plusieurs articles, tels que "Question of the Week No. 107" de Ramchander Varadarajan (Sun Microsystems, septembre 2000) et "Memory Matters" de Tony Sintes ( JavaWorld, décembre 2001), détaillent cette idée. Malheureusement, la solution du premier article échoue car la mise en œuvre utilise une mauvaise Runtimeméthode, tandis que la solution du dernier article a ses propres imperfections:

  • Un seul appel à se Runtime.freeMemory()révèle insuffisant car une JVM peut décider d'augmenter sa taille de tas actuelle à tout moment (en particulier lorsqu'elle exécute le garbage collection). À moins que la taille totale du tas ne soit déjà à la taille maximale -Xmx, nous devrions utiliser Runtime.totalMemory()-Runtime.freeMemory()comme taille de tas utilisée.
  • L'exécution d'un seul Runtime.gc()appel peut ne pas s'avérer suffisamment agressive pour demander le garbage collection. Nous pourrions, par exemple, demander à des finaliseurs d'objets de s'exécuter également. Et comme il Runtime.gc()n'est pas documenté de bloquer jusqu'à la fin de la collecte, il est judicieux d'attendre que la taille de tas perçue se stabilise.
  • Si la classe profilée crée des données statiques dans le cadre de son initialisation de classe par classe (y compris les initialiseurs de classe statique et de champ), la mémoire de tas utilisée pour la première instance de classe peut inclure ces données. Nous devons ignorer l'espace de tas consommé par la première instance de classe.

Compte tenu de ces problèmes, je présente Sizeofun outil avec lequel j'observe diverses classes de noyau et d'application Java:

public class Sizeof {public static void main (String [] args) throws Exception {// Réchauffe toutes les classes / méthodes que nous utiliserons runGC (); mémoire utilisée (); // Tableau pour conserver des références fortes aux objets alloués final int count = 100000; Objet [] objets = nouvel objet [nombre]; tas long1 = 0; // Alloue count + 1 objets, supprime le premier pour (int i = -1; i = 0) objets [i] = object; else {objet = null; // Supprime l'objet d'échauffement runGC (); heap1 = usedMemory (); // Prendre un instantané avant le tas}} runGC (); long tas2 = usedMemory (); // Prendre un instantané après le tas: final int size = Math.round (((float) (heap2 - heap1)) / count); System.out.println ("'avant' tas:" + tas1 + ", 'après' tas:" + tas2); System.out.println ("delta de tas:" + (tas2 - tas1) + ", {" + objets [0].getClass () + "} size =" + size + "octets"); pour (int i = 0; i <count; ++ i) objets [i] = null; objets = nul; } private static void runGC () throws Exception {// Cela permet d'appeler Runtime.gc () // en utilisant plusieurs appels de méthode: for (int r = 0; r <4; ++ r) _runGC (); } private static void _runGC () lève une exception {long usedMem1 = usedMemory (), usedMem2 = Long.MAX_VALUE; pour (int i = 0; (usedMem1 <usedMem2) && (i <500); ++ i) {s_runtime.runFinalization (); s_runtime.gc (); Thread.currentThread () .yield (); usedMem2 = usedMem1; usedMem1 = usedMemory (); }} statique privée longtemps usedMemory () {return s_runtime.totalMemory () - s_runtime.freeMemory (); } Runtime final statique privé s_runtime = Runtime.getRuntime (); } // Fin de coursi <compte; ++ i) objets [i] = nul; objets = nul; } private static void runGC () throws Exception {// Cela permet d'appeler Runtime.gc () // en utilisant plusieurs appels de méthode: for (int r = 0; r <4; ++ r) _runGC (); } private static void _runGC () lève une exception {long usedMem1 = usedMemory (), usedMem2 = Long.MAX_VALUE; pour (int i = 0; (usedMem1 <usedMem2) && (i <500); ++ i) {s_runtime.runFinalization (); s_runtime.gc (); Thread.currentThread () .yield (); usedMem2 = usedMem1; usedMem1 = usedMemory (); }} statique privée longtemps usedMemory () {return s_runtime.totalMemory () - s_runtime.freeMemory (); } Runtime final statique privé s_runtime = Runtime.getRuntime (); } // Fin de coursi <compte; ++ i) objets [i] = nul; objets = nul; } private static void runGC () throws Exception {// Cela permet d'appeler Runtime.gc () // en utilisant plusieurs appels de méthode: for (int r = 0; r <4; ++ r) _runGC (); } private static void _runGC () jette une exception {long usedMem1 = usedMemory (), usedMem2 = Long.MAX_VALUE; pour (int i = 0; (usedMem1 <usedMem2) && (i <500); ++ i) {s_runtime.runFinalization (); s_runtime.gc (); Thread.currentThread () .yield (); usedMem2 = usedMem1; usedMem1 = usedMemory (); }} statique privée longtemps usedMemory () {return s_runtime.totalMemory () - s_runtime.freeMemory (); } Runtime final statique privé s_runtime = Runtime.getRuntime (); } // Fin de coursgc () // en utilisant plusieurs appels de méthode: for (int r = 0; r <4; ++ r) _runGC (); } private static void _runGC () jette une exception {long usedMem1 = usedMemory (), usedMem2 = Long.MAX_VALUE; pour (int i = 0; (usedMem1 <usedMem2) && (i <500); ++ i) {s_runtime.runFinalization (); s_runtime.gc (); Thread.currentThread () .yield (); usedMem2 = usedMem1; usedMem1 = usedMemory (); }} statique privée longtemps usedMemory () {return s_runtime.totalMemory () - s_runtime.freeMemory (); } Runtime final statique privé s_runtime = Runtime.getRuntime (); } // Fin de coursgc () // en utilisant plusieurs appels de méthode: for (int r = 0; r <4; ++ r) _runGC (); } private static void _runGC () lève une exception {long usedMem1 = usedMemory (), usedMem2 = Long.MAX_VALUE; pour (int i = 0; (usedMem1 <usedMem2) && (i <500); ++ i) {s_runtime.runFinalization (); s_runtime.gc (); Thread.currentThread () .yield (); usedMem2 = usedMem1; usedMem1 = usedMemory (); }} statique privée longtemps usedMemory () {return s_runtime.totalMemory () - s_runtime.freeMemory (); } Runtime final statique privé s_runtime = Runtime.getRuntime (); } // Fin de coursThread.currentThread () .yield (); usedMem2 = usedMem1; usedMem1 = usedMemory (); }} statique privée longtemps usedMemory () {return s_runtime.totalMemory () - s_runtime.freeMemory (); } Runtime final statique privé s_runtime = Runtime.getRuntime (); } // Fin de coursThread.currentThread () .yield (); usedMem2 = usedMem1; usedMem1 = usedMemory (); }} statique privée longtemps usedMemory () {return s_runtime.totalMemory () - s_runtime.freeMemory (); } Runtime final statique privé s_runtime = Runtime.getRuntime (); } // Fin de cours

SizeofLes méthodes clés de runGC()et usedMemory(). J'utilise une runGC()méthode wrapper pour appeler _runGC()plusieurs fois car elle semble rendre la méthode plus agressive. (Je ne sais pas pourquoi, mais il est possible que la création et la destruction d'un cadre de pile d'appels de méthode entraîne une modification de l'ensemble de racines d'accessibilité et incite le ramasse-miettes à travailler plus dur. De plus, consommer une grande partie de l'espace du tas pour créer suffisamment de travail pour que le ramasse-miettes se mette en marche aide également. En général, il est difficile de s'assurer que tout est collecté. Les détails exacts dépendent de la JVM et de l'algorithme de ramassage des ordures.)

Notez attentivement les endroits où j'invoque runGC(). Vous pouvez modifier le code entre les déclarations heap1et heap2pour instancier tout ce qui vous intéresse.

Notez également comment Sizeofimprime la taille de l'objet: la fermeture transitive des données requises par toutes countles instances de classe, divisée par count. Pour la plupart des classes, le résultat sera la mémoire consommée par une seule instance de classe, y compris tous ses champs détenus. Cette valeur d'empreinte mémoire diffère des données fournies par de nombreux profileurs commerciaux qui signalent des empreintes mémoire peu profondes (par exemple, si un objet a un int[]champ, sa consommation de mémoire apparaîtra séparément).

Les resultats

Appliquons cet outil simple à quelques classes, puis voyons si les résultats correspondent à nos attentes.

Remarque: les résultats suivants sont basés sur le JDK 1.3.1 de Sun pour Windows. En raison de ce qui est garanti et non par le langage Java et les spécifications JVM, vous ne pouvez pas appliquer ces résultats spécifiques à d'autres plates-formes ou à d'autres implémentations Java.

java.lang.Object

Eh bien, la racine de tous les objets devait être mon premier cas. Pour java.lang.Object, j'obtiens:

tas 'avant': 510696, 'après' tas: 1310696 delta de tas: 800000, {class java.lang.Object} size = 8 octets 

Ainsi, un plain Objectprend 8 octets; bien sûr, personne ne devrait attendre la taille à 0, comme chaque instance doit transporter les champs que les opérations de base de soutien comme equals(), hashCode(), wait()/notify()et ainsi de suite.

java.lang.Integer

Mes collègues et moi enveloppent souvent natif intsdans des Integercas afin que nous puissions les stocker dans des collections Java. Combien cela nous coûte-t-il en mémoire?

'avant' tas: 510696, 'après' tas: 2110696 delta de tas: 1600000, {class java.lang.Integer} taille = 16 octets 

Le résultat de 16 octets est un peu pire que ce à quoi je m'attendais car une intvaleur peut tenir dans seulement 4 octets supplémentaires. L'utilisation d'un Integerme coûte une surcharge de mémoire de 300% par rapport au moment où je peux stocker la valeur en tant que type primitif.

java.lang.Long

Longdevrait prendre plus de mémoire que Integer, mais ce n'est pas le cas:

tas 'avant': 510696, 'après' tas: 2110696 delta de tas: 1600000, {class java.lang.Long} taille = 16 octets 

De toute évidence, la taille réelle de l'objet sur le tas est soumise à un alignement de mémoire de bas niveau effectué par une implémentation JVM particulière pour un type de processeur particulier. Cela ressemble à Long8 octets de Objectsurcharge, plus 8 octets de plus pour la valeur longue réelle. En revanche, il y Integeravait un trou de 4 octets inutilisé, probablement parce que la JVM que j'utilise force l'alignement d'objet sur une limite de mot de 8 octets.

Tableaux

Jouer avec des tableaux de types primitifs s'avère instructif, en partie pour découvrir toute surcharge cachée et en partie pour justifier une autre astuce populaire: envelopper les valeurs primitives dans un tableau de taille 1 pour les utiliser comme objets. En modifiant Sizeof.main()pour avoir une boucle qui incrémente la longueur du tableau créé à chaque itération, j'obtiens pour les inttableaux:

longueur: 0, {classe [I} taille = 16 octets longueur: 1, {classe [I} taille = 16 octets longueur: 2, {classe [I} taille = 24 octets longueur: 3, {classe [I} taille = Longueur 24 octets: 4, {classe [I} taille = 32 octets longueur: 5, {classe [I} taille = 32 octets longueur: 6, {classe [I} taille = 40 octets longueur: 7, {classe [I} taille = 40 octets longueur: 8, {classe [I} taille = 48 octets longueur: 9, {classe [I} taille = 48 octets longueur: 10, {classe [I} taille = 56 octets 

et pour les chartableaux:

longueur: 0, {classe [C} taille = 16 octets longueur: 1, {classe [C} taille = 16 octets longueur: 2, {classe [C} taille = 16 octets longueur: 3, {classe [C} taille = Longueur 24 octets: 4, {classe [C} taille = 24 octets longueur: 5, {classe [C} taille = longueur 24 octets: 6, {classe [C} taille = 24 octets longueur: 7, {classe [C}] taille = 32 octets longueur: 8, {classe [C} taille = 32 octets longueur: 9, {classe [C} taille = 32 octets longueur: 10, {classe [C} taille = 32 octets 

Ci-dessus, la preuve de l'alignement de 8 octets apparaît à nouveau. De plus, en plus de l'inévitable Objectsurcharge de 8 octets, un tableau primitif ajoute 8 octets supplémentaires (dont au moins 4 octets prennent en charge le lengthchamp). Et l'utilisation int[1]semble n'offrir aucun avantage de mémoire par rapport à une Integerinstance, sauf peut-être en tant que version mutable des mêmes données.

Tableaux multidimensionnels

Multidimensional arrays offer another surprise. Developers commonly employ constructs like int[dim1][dim2] in numerical and scientific computing. In an int[dim1][dim2] array instance, every nested int[dim2] array is an Object in its own right. Each adds the usual 16-byte array overhead. When I don't need a triangular or ragged array, that represents pure overhead. The impact grows when array dimensions greatly differ. For example, a int[128][2] instance takes 3,600 bytes. Compared to the 1,040 bytes an int[256] instance uses (which has the same capacity), 3,600 bytes represent a 246 percent overhead. In the extreme case of byte[256][1], the overhead factor is almost 19! Compare that to the C/C++ situation in which the same syntax does not add any storage overhead.

java.lang.String

Let's try an empty String, first constructed as new String():

'before' heap: 510696, 'after' heap: 4510696 heap delta: 4000000, {class java.lang.String} size = 40 bytes 

The result proves quite depressing. An empty String takes 40 bytes—enough memory to fit 20 Java characters.

Before I try Strings with content, I need a helper method to create Strings guaranteed not to get interned. Merely using literals as in:

 object = "string with 20 chars"; 

will not work because all such object handles will end up pointing to the same String instance. The language specification dictates such behavior (see also the java.lang.String.intern() method). Therefore, to continue our memory snooping, try:

 public static String createString (final int length) { char [] result = new char [length]; for (int i = 0; i < length; ++ i) result [i] = (char) i; return new String (result); } 

After arming myself with this String creator method, I get the following results:

length: 0, {class java.lang.String} size = 40 bytes length: 1, {class java.lang.String} size = 40 bytes length: 2, {class java.lang.String} size = 40 bytes length: 3, {class java.lang.String} size = 48 bytes length: 4, {class java.lang.String} size = 48 bytes length: 5, {class java.lang.String} size = 48 bytes length: 6, {class java.lang.String} size = 48 bytes length: 7, {class java.lang.String} size = 56 bytes length: 8, {class java.lang.String} size = 56 bytes length: 9, {class java.lang.String} size = 56 bytes length: 10, {class java.lang.String} size = 56 bytes 

The results clearly show that a String's memory growth tracks its internal char array's growth. However, the String class adds another 24 bytes of overhead. For a nonempty String of size 10 characters or less, the added overhead cost relative to useful payload (2 bytes for each char plus 4 bytes for the length), ranges from 100 to 400 percent.

Of course, the penalty depends on your application's data distribution. Somehow I suspected that 10 characters represents the typical String length for a variety of applications. To get a concrete data point, I instrumented the SwingSet2 demo (by modifying the String class implementation directly) that came with JDK 1.3.x to track the lengths of the Strings it creates. After a few minutes playing with the demo, a data dump showed that about 180,000 Strings were instantiated. Sorting them into size buckets confirmed my expectations:

[0-10]: 96481 [10-20]: 27279 [20-30]: 31949 [30-40]: 7917 [40-50]: 7344 [50-60]: 3545 [60-70]: 1581 [70-80]: 1247 [80-90]: 874 ... 

That's right, more than 50 percent of all String lengths fell into the 0-10 bucket, the very hot spot of String class inefficiency!

En réalité, les Strings peuvent consommer encore plus de mémoire que leur longueur ne le suggère: les Strings générés à partir de StringBuffers (soit explicitement, soit via l'opérateur de concaténation `` + '') ont probablement des chartableaux avec des longueurs plus grandes que les Stringlongueurs rapportées car StringBuffers commencent généralement avec une capacité de 16 , puis doublez-le sur les append()opérations. Ainsi, par exemple, createString(1) + ' 'se termine avec un chartableau de taille 16, pas 2.

Qu'est-ce qu'on fait?

"C'est très bien, mais nous n'avons pas d'autre choix que d'utiliser Strings et d'autres types fournis par Java, n'est-ce pas ?" Je vous entends demander. Découvrons-le.

Classes de wrapper