I. Qu'est-ce qu'il y a dans une variable ?

La clé pour comprendre la façon dont fonctionne la mémoire en .NET, c'est de comprendre ce qu'est une variable et quelle est sa valeur. Au niveau le plus élémentaire, une variable est simplement une association entre un nom (utilisé dans le code source du programme) et une zone mémoire. Une variable a une valeur, qui est le contenu de la zone mémoire à laquelle elle est associée. La taille de cette zone et l'interprétation de la valeur dépendent du type de la variable — et c'est là où la différence entre les types valeur et les types référence entrent en jeu.

La valeur d'une variable de type référence est toujours soit une référence, soit null. Si c'est une référence, ce doit être une référence à un objet qui est compatible avec le type de la variable. Par exemple, une variable déclarée en tant que Stream aura toujours une valeur qui est null ou une référence à une instance de la classe Stream. (Notez que l'instance d'une sous-classe de Stream, par exemple FileStream, est également une instance de Stream.) La zone de mémoire associée à la variable est juste la taille d'une référence, quelle que soit la taille de l'objet réel auquel elle se réfère. (Sur la version 32 bits de .NET, par exemple, l'emplacement d'une variable de type référence a toujours une taille de quatre octets.)

La valeur d'une variable de type valeur est toujours les données pour une instance du type lui-même. Par exemple, supposons que nous ayons une structure déclarée :

 
Sélectionnez
struct PairOfInts
{
    public int a;
    public int b;
}

La valeur d'une variable déclarée comme PairOfInts pair est la paire d'entiers elle-même, pas une référence à une paire de nombres entiers. La zone de mémoire est assez grande pour contenir deux nombres entiers (elle doit être de huit octets). Notez qu'une variable de type valeur ne peut jamais avoir une valeur nulle — cela n'aurait pas de sens, car null est un concept de type référence, ce qui signifie « la valeur de cette variable de type référence n'est pas une référence à un objet du tout ».

II. Alors où sont stockées les choses ?


L'emplacement de mémoire pour une variable est stocké soit sur la pile soit sur le tas. Cela dépend du contexte dans lequel elle est déclarée :

- chaque variable locale (à savoir celle déclarée dans une méthode) est stockée dans la pile. Cela inclut les variables de type référence — la variable elle-même est sur la pile, mais rappelez-vous que la valeur d'une variable de type référence est seulement une référence (ou null) et non l'objet lui-même. Les paramètres de méthode comptent aussi comme des variables locales, mais si elles sont déclarées avec le modificateur ref, elles ne reçoivent pas leur propre zone mémoire, mais partagent une zone mémoire avec les variables utilisées dans le code appelant. Voir mon article sur le passage de paramètres pour plus de détails ;

- les instances des variables d'un type de référence sont toujours sur le tas. C'est là que l'objet lui-même « vit » ;

- les instances des variables d'un type valeur sont stockées dans le même contexte que la variable qui déclare le type de valeur. L'emplacement de mémoire pour l'instance contient effectivement des zones mémoire pour chaque champ dans l'instance. Cela signifie (compte tenu des deux points précédents) qu'une variable struct déclarée dans une méthode sera toujours sur la pile, alors qu'une variable struct qui est un champ d'instance d'une classe sera sur le tas ;

- chaque variable statique est stockée sur le tas, peu importe si elle est déclarée dans un type référence ou un type valeur. Il n'y a qu'une seule zone mémoire au total, quel que soit le nombre d'instances de la classe créées. (D'ailleurs, il n'y a pas besoin de créer des instances pour que la zone mémoire existe.) Les détails exacts du tas sur lequel ces variables existent sont compliqués, mais expliqués en détail dans un article MSDN sur le sujet.

Il y a quelques exceptions à la règle ci-dessus — les variables capturées (utilisées dans les méthodes anonymes et les expressions lambda) sont locales en termes de code C#, mais finissent par être compilées dans des variables d'instance dans un type associé avec le délégué créé par la méthode anonyme. La même chose vaut pour les variables locales dans un bloc itérateur.

III. Un exemple concret

Tout ce qui précède peut sembler un peu compliqué, mais un exemple complet devrait rendre les choses un peu plus claires. Voici un court programme qui ne fait rien d'utile, mais démontre les points soulevés ci-dessus.

 
Sélectionnez
using System;
 
struct PairOfInts
{
    static int counter=0;
    
    public int a;
    public int b;
    
    internal PairOfInts (int x, int y)
    {
        a=x;
        b=y;
        counter++;
    }
}
 
class Test
{
    PairOfInts pair;
    string name;
    
    Test (PairOfInts p, string s, int x)
    {
        pair = p;
        name = s;
        pair.a += x;
    }
    
    static void Main()
    {
        PairOfInts z = new PairOfInts (1, 2);
        Test t1 = new Test(z, "first", 1);
        Test t2 = new Test(z, "second", 2);
        Test t3 = null;
        Test t4 = t1;
        // XXX
    }
}

Regardons dans quel état est la mémoire à la ligne marquée avec le commentaire "XXX". (Supposons que rien n'est collecté par le ramasse-miettes.)

- Il y a une instance PairOfInts sur la pile, ce qui correspond à la variable z. Dans ce cas, a = 1 et b = 2. (La zone mémoire de huit octets nécessaires pour z pourrait alors être représentée dans la mémoire comme 01 00 00 00 02 00 00 00.)

- Il y a une référence test sur la pile, ce qui correspond à la variable t1. Cette référence renvoie à une instance sur le tas, qui occupe « quelque chose comme » 20 octets : huit octets d'informations d'entête (que tous les objets du tas ont), huit octets pour l'instance PairOfInts et quatre octets pour la référence string. (Le « quelque chose comme » c'est parce que la spécification ne dit pas comment cela doit être organisé, ou quelle est la taille de l'entête, etc.) La valeur de la variable pair au sein de cette instance aura a = 2 et b = 2 (pouvant être représentée en mémoire comme 02 00 00 00 02 00 00 00). La valeur de la variable name à l'intérieur de cette instance sera une référence à un objet string (qui est aussi sur le tas) et qui (probablement à travers d'autres objets, comme un tableau de caractères) représente la séquence de caractères du mot « first ».

- Il y a une deuxième référence test sur la pile, ce qui correspond à la variable t2. Cette référence renvoie à une seconde instance sur le tas, qui est très similaire à celle décrite ci-dessus, mais avec une référence à une chaîne représentant « second » au lieu de « first », et avec une valeur de pair où a = 3 (comme 2 a été ajouté à la valeur initiale de 1). Si PairOfInts était un type référence à la place d'un type valeur, il n'y aurait qu'une seule instance de ce dernier tout au long du programme, et seulement quelques références à l'instance unique, mais tel qu'il est, il y a plusieurs instances, chacune avec des valeurs différentes à l'intérieur.

- Il y a une troisième référence test sur la pile, ce qui correspond à la variable t3. Cette référence est nulle — elle ne fait pas référence à une instance de test. (Il y a une certaine ambiguïté quant à savoir si cela compte comme une référence test ou pas — il n'y a aucune différence, vraiment — ce que je pense, généralement c'est comme si null était une référence qui ne fait pas référence à un objet, plutôt que d'être une absence de référence en premier lieu. La spécification du langage Java donne une terminologie assez agréable, en disant qu'une référence est soit null soit un pointeur vers un objet du type approprié.)

- Il y a une quatrième référence test sur la pile, ce qui correspond à la variable t4. Cette référence fait référence à la même instance que t1 — c'est-à-dire les valeurs de t1 et t4 sont les mêmes. La modification de la valeur de l'une de ces variables ne modifiera pas la valeur de l'autre, mais en changeant une valeur dans l'objet auquel elles font référence (en utilisant une des références) fera que le changement sera aussi visible par l'intermédiaire de l'autre référence. (Par exemple, si vous définissez t1.name = « third » ; si vous examinez ensuite t4.name, vous trouveriez qu'il contient « third » lui aussi.)

- Enfin, il y a la variable PairOfInts.counter, qui est sur le tas (puisqu'elle est statique). Il y a une seule zone mémoire pour la variable, cependant il peut y avoir beaucoup (ou peu) de valeurs PairOfInts.

IV. Remerciements.

[Thomas Levesque] pour la relecture et la validation technique.

[Claude Leloup] pour la relecture orthographique.

V. Source.

Traduction de l'article de M. Jon Skeet - Memory in .NET - what goes where.