Article · C# & .NET
Où vivent vraiment les types valeur en C# ? Le mythe de la pile expliqué
Article 01 de la série C# et .NET, vu de l’intérieur, collection 1 : Types C#, mémoire et comportement à l’exécution. Vous découvrez la série ? Commencez par la feuille de route.
Demandez à un développeur C# où sont stockés les types valeur, et vous entendrez souvent la même réponse :
Les types valeur vivent sur la pile. Les types référence vivent sur le tas.
C’est une explication simple. C’est aussi une explication incomplète. Même les notes de version du runtime .NET 9 de Microsoft ouvrent leur section sur le boxing en affirmant que les types valeur « sont généralement alloués sur la pile ».
La distinction entre struct et class est fondamentale en C#, mais la réduire à une règle d’emplacement mémoire crée un modèle mental trompeur. Elle laisse penser que le type d’une valeur détermine où elle doit être stockée, quelle que soit la façon dont on l’utilise.
Prenons quelques cas. Une struct peut être un champ d’une instance de classe. Un type valeur peut être l’élément d’un tableau. Un entier peut être « boxé » dans un objet. Une variable locale peut être capturée par une lambda, ce qui change la façon dont le compilateur la représente.
Si les types valeur vivaient toujours sur la pile, comment expliquer ces cas ? Et si la réponse dépend des circonstances, qu’est-ce qui détermine réellement l’endroit où une valeur est stockée ?
Pour y répondre, il faut séparer trois niveaux : la façon dont C# définit la sémantique valeur et la sémantique référence, la façon dont le compilateur transforme notre code, et la façon dont le runtime .NET et son compilateur JIT représentent les valeurs et les objets. Nous partirons du langage, nous examinerons l’IL généré, le boxing et la disposition des objets en mémoire, nous mesurerons le coût avec BenchmarkDotNet sur .NET 9 et .NET 10, et nous terminerons par le code natif que RyuJIT produit réellement.
L’objectif n’est pas seulement de savoir où vit une struct. Il est de comprendre pourquoi l’emplacement de stockage n’est pas ce qui définit un type valeur en C#.
Tous les benchmarks de cet article sont publics dans le dépôt DotNetBoxingBenchmarks, avec leurs rapports bruts.
Sommaire
- 1. Types valeur et types référence : la vraie différence
- 2. Où vivent réellement les données ?
- 3. Boxing et unboxing : que se passe-t-il vraiment ?
- 4. Dans le runtime : objets, disposition et taille
- 5. Mesurer le coût du boxing avec BenchmarkDotNet
- 6. Le rebondissement du .NET moderne : quand la boîte disparaît
- 7. Conclusion : un meilleur modèle mental
- Reproduire les expériences
- Une remarque sur l’exactitude technique
- Références et lectures complémentaires
1. Types valeur et types référence : la vraie différence
C# a deux grandes catégories de types. Les types valeur comprennent les types numériques (int, double, decimal), bool, char, les énumérations et les types struct définis par l’utilisateur. Les types référence comprennent les classes, les tableaux, les interfaces, les délégués et string.
La différence fondamentale n’est pas l’endroit où ils vivent. C’est la façon dont les valeurs et les références se comportent lorsqu’on les affecte, qu’on les passe à des méthodes ou qu’on les stocke dans des variables.
Types valeur : l’affectation copie la valeur
public struct Point
{
public int X;
public int Y;
}
var first = new Point { X = 10, Y = 20 };
var second = first;
second.X = 99;
Console.WriteLine(first.X); // 10
Console.WriteLine(second.X); // 99
var second = first copie la valeur de first dans second. Les deux variables contiennent désormais deux valeurs Point indépendantes : modifier second.X modifie la copie, pas l’original.
Le même principe s’applique quand un type valeur est passé par valeur à une méthode : celle-ci reçoit une copie. C# propose aussi les paramètres ref, in et out, qui changent la façon dont les arguments sont transmis, mais l’essentiel est là : l’affectation ordinaire d’un type valeur copie sa valeur.
Types référence : l’affectation copie la référence
public sealed class Person
{
public string Name { get; set; } = string.Empty;
}
var first = new Person { Name = "Ayoub" };
var second = first;
second.Name = "Ahmed";
Console.WriteLine(first.Name); // Ahmed
Console.WriteLine(second.Name); // Ahmed
Le code est presque identique, mais le résultat diffère. var second = first copie la référence contenue dans first. Aucun nouvel objet Person n’est créé. Les deux variables désignent le même objet, donc une modification faite via second est visible via first.
Pour obtenir deux objets indépendants, il faut créer explicitement une autre instance :
var first = new Person { Name = "Ayoub" };
var second = new Person { Name = first.Name };
second.Name = "Ahmed";
Console.WriteLine(first.Name); // Ayoub
Console.WriteLine(second.Name); // Ahmed
On peut donc copier un type référence. La distinction importante est entre copier une référence et copier l’objet qu’elle désigne : l’affectation copie la référence, pas l’objet référencé.
Ce qui est copié, voilà la vraie distinction
| Opération | Type valeur | Type référence |
|---|---|---|
| Affectation ordinaire | Copie la valeur | Copie la référence |
| Modification de la variable copiée | Ne modifie pas la valeur d’origine | Peut modifier le même objet référencé |
| Création d’une instance indépendante | L’affectation suffit | Il faut créer ou copier un objet explicitement |
| Emplacement de stockage | Dépend du contexte et de l’implémentation | L’objet référencé est en général un objet du tas managé |
Les trois premières lignes décrivent la sémantique. La dernière concerne l’implémentation et le stockage. Ce sont deux questions différentes.
TYPE VALEUR (chaque variable contient sa propre valeur)
first = Point { X = 10, Y = 20 }
second = Point { X = 99, Y = 20 } (une copie indépendante)
TYPE RÉFÉRENCE
first ──┐
├──> Person { Name = "Ahmed" } (un seul objet partagé)
second ──┘
Un type valeur peut être stocké en ligne (inline) dans une instance de classe ou dans un tableau. Il peut être représenté par un objet boxé. Une valeur locale peut se trouver dans un registre du processeur, dans un emplacement de pile, ou être supprimée par les optimisations du JIT. On ne peut pas déterminer l’emplacement physique d’une valeur simplement en regardant si son type est une struct ou une classe. C’est l’idée reçue au cœur du mythe de la pile.
Copier une struct n’est pas une copie profonde
Les types valeur sont copiés par valeur, mais cela ne signifie pas que tous les objets accessibles depuis une valeur sont copiés.
public sealed class Address
{
public string City { get; set; } = string.Empty;
}
public struct Employee
{
public Address Address;
}
var first = new Employee
{
Address = new Address { City = "Casablanca" }
};
var second = first;
second.Address.City = "Rabat";
Console.WriteLine(first.Address.City); // Rabat
Console.WriteLine(second.Address.City); // Rabat
Affecter first à second a copié la struct Employee, y compris sa référence Address. Les deux structs désignent maintenant le même objet Address, et la ligne suivante modifie cet objet partagé.
Si le champ avait été une string et que nous avions écrit second.Name = "Ahmed", l’original n’aurait pas changé : nous aurions remplacé la référence contenue dans la copie, sans modifier d’objet partagé. Les types valeur ne sont pas copiés en profondeur. Les champs de type référence d’une struct sont copiés en tant que références.
Et les chaînes ?
string est un type référence. Affecter une variable chaîne à une autre copie la référence, pas les caractères. Pourtant, les chaînes se comportent souvent comme des valeurs, parce qu’elles sont immuables :
string first = "Hello";
string second = first;
second = "World";
Console.WriteLine(first); // Hello
Console.WriteLine(second); // World
second = "World" ne modifie pas la chaîne d’origine : second désigne simplement une autre chaîne. L’immuabilité, l’internement, l’égalité et Span<char> mériteront leur propre article plus loin dans la série. Pour l’instant, retenons qu’un comportement au quotidien ne dit pas où les données sont stockées.
L’angle entretien
Question : quelle est la différence entre un type valeur et un type référence en C# ?
Réponse classique : « Les types valeur contiennent leur valeur, les types référence contiennent une référence vers un objet. Les structs sont des types valeur, les classes des types référence. »
Réponse plus solide : « Ils diffèrent par leur sémantique. Affecter un type valeur copie sa valeur ; affecter un type référence copie la référence vers le même objet. Cela détermine le comportement des valeurs quand on les affecte ou qu’on les passe à des méthodes, mais cela n’établit aucune règle pile contre tas. L’endroit où les données vivent physiquement dépend de leur contexte et du runtime. »
La seconde réponse sépare la distinction du langage des décisions mémoire du runtime. C’est la base de tout ce qui suit.
2. Où vivent réellement les données ?
Si les types valeur ne vivent pas toujours sur la pile, où vivent-ils ? Cela dépend de l’usage de la valeur, de ce qui la contient et de la façon dont le compilateur et le runtime la représentent. Trois notions aident à y voir clair :
- Une variable est un emplacement de stockage qui peut contenir une valeur.
- Une valeur est la donnée représentée par un type donné.
- Un objet est une instance à l’exécution, dont la représentation dépend de son type et du contexte d’exécution.
Une variable de type référence contient une référence, qui peut désigner un objet. Une variable de type valeur contient directement une valeur de son type. La représentation physique de cette variable ou de cet objet est une question distincte, qui relève de l’implémentation. Examinons six situations.
Variables locales : un int vit-il toujours sur la pile ?
static int Calculate()
{
int x = 10;
int y = 20;
return x + y;
}
L’explication traditionnelle dit que le runtime réserve de la place sur la pile pour x et y, y écrit les valeurs, les relit, les additionne et renvoie le résultat. C’est une implémentation possible, mais rien ne la garantit.
Le compilateur C# traduit la méthode en langage intermédiaire (IL), puis le compilateur JIT traduit cet IL en code machine en l’optimisant autant que possible. Ici, le JIT peut voir que le résultat est une constante et produire l’équivalent de return 30;. Il peut alors n’exister aucun emplacement de pile pour x et y. Même lorsque les valeurs ne se réduisent pas à des constantes, le JIT les garde souvent dans des registres plutôt qu’en mémoire de pile. Et la liste des variables locales de l’IL ne prouve pas que des emplacements de pile correspondants existent dans le code machine final.
Une variable locale dans le code source C# ne garantit pas un emplacement de pile dédié à l’exécution. Elle peut vivre dans un registre, dans un emplacement de pile, ou disparaître du code optimisé.
À vous de jouer. Collez
Calculate()dans SharpLab, affichez la sortie IL puis JIT Asm en mode Release, et comparez les deux. Notez quelles variables locales existent dans l’IL et lesquelles survivent dans le code natif.
Une struct dans une classe
public struct Coordinates
{
public int Latitude;
public int Longitude;
}
public sealed class Location
{
public Coordinates Position;
}
var location = new Location
{
Position = new Coordinates { Latitude = 33, Longitude = -7 }
};
Coordinates est un type valeur et Location un type référence. Le champ Position appartient à l’objet Location. Pour une instance ordinaire de Location sur le tas managé, la valeur de Position est stockée directement dans les données d’instance de cet objet. Le runtime ne crée pas d’objet Coordinates boxé séparé simplement parce qu’une classe contient un champ struct.
Objet Location (vue conceptuelle)
┌──────────────────────────┐
│ Métadonnées de l’objet │
├──────────────────────────┤
│ Position │
│ Latitude = 33 │
│ Longitude = -7 │
└──────────────────────────┘
C’est une représentation conceptuelle, pas une disposition exacte octet par octet. L’observation clé est que les champs de la struct font partie des données de l’objet qui la contient : si l’objet est sur le tas managé, la struct l’est aussi. Voilà déjà un contre-exemple à « les structs vivent toujours sur la pile ».
Des structs dans un tableau
Point[] points = new Point[3];
points[0] = new Point { X = 10, Y = 20 };
points[1] = new Point { X = 30, Y = 40 };
points[2] = new Point { X = 50, Y = 60 };
Un tableau est un type référence, même quand ses éléments sont des types valeur. Dans la représentation ordinaire des tableaux .NET, l’objet tableau contient ses éléments en ligne :
Objet tableau Point[] (vue conceptuelle)
┌──────────────────────────┐
│ Métadonnées du tableau │
├──────────────────────────┤
│ [0] X = 10, Y = 20 │
├──────────────────────────┤
│ [1] X = 30, Y = 40 │
├──────────────────────────┤
│ [2] X = 50, Y = 60 │
└──────────────────────────┘
Le runtime ne crée pas trois objets boxés pour ces éléments : leurs valeurs font partie du stockage des éléments du tableau. Un tableau d’un type classe est différent : Person[] people = new Person[3]; contient trois emplacements de référence, et chaque Person est un objet distinct.
Cette représentation influe sur le nombre d’objets, sur les schémas d’accès mémoire et potentiellement sur les performances. Il n’en découle pas pour autant qu’un tableau de structs soit toujours plus rapide qu’un tableau de classes : cela dépend de la taille de la struct, du coût des copies, des schémas d’accès et du travail effectué.
Le boxing : un type valeur représenté comme un objet
int number = 42;
object boxed = number;
number = 100;
Console.WriteLine(number); // 100
Console.WriteLine(boxed); // 42
Convertir un int en object est une conversion de boxing. Dans l’implémentation habituelle du runtime, le boxing crée un objet contenant une copie de la valeur, ce qui explique que boxed contienne toujours 42 après la modification de number. Un type valeur peut donc être représenté à l’intérieur d’un objet managé. Nous approfondirons le boxing dans la section suivante.
Les variables locales capturées
static Func<int> CreateCounter()
{
int count = 0;
return () => ++count;
}
Func<int> counter = CreateCounter();
Console.WriteLine(counter()); // 1
Console.WriteLine(counter()); // 2
Console.WriteLine(counter()); // 3
count est une variable locale de CreateCounter(), et pourtant la lambda continue de la lire et de la modifier après le retour de CreateCounter(). Pour préserver ce comportement, le compilateur C# déplace la variable capturée dans une classe de fermeture générée, souvent appelée display class. Voici une illustration simplifiée :
// Illustration seulement : ce n’est pas le code exact généré par le compilateur.
sealed class DisplayClass
{
public int Count;
public int Increment()
{
return ++Count;
}
}
static Func<int> CreateCounter()
{
var closure = new DisplayClass();
closure.Count = 0;
return closure.Increment;
}
count est désormais un champ d’un objet dont la durée de vie peut dépasser l’appel de la méthode. Son type reste int, mais son stockage se trouve dans un objet de fermeture alloué sur le tas. La classification « type valeur » n’a pas changé ; c’est la représentation qui a changé, pour préserver la sémantique du programme.
À vous de jouer. Collez
CreateCounter()dans SharpLab et affichez la sortie C#. Cherchez un type généré dont le nom commence par<>c__DisplayClass, trouvez le champ qui contientcountet regardez comment le délégué y accède. Les noms exacts dépendent de la version du compilateur.
Les méthodes asynchrones
static async Task<int> CalculateAsync()
{
int value = 42;
await Task.Delay(100);
return value * 2;
}
value est déclarée avant le await et utilisée après. La méthode peut se suspendre pendant l’attente et reprendre plus tard ; le compilateur la transforme donc en machine à états qui conserve les valeurs nécessaires à la reprise. En simplifiant :
// Vue conceptuelle : la vraie machine à états a d’autres champs et une méthode MoveNext.
struct CalculateAsyncStateMachine
{
public int State;
public int Value;
}
value peut devenir un champ de cette machine à états, puisqu’elle est utilisée après le await. La machine à états générée n’est pas automatiquement un objet du tas du seul fait que la méthode est async : si la méthode se termine de façon synchrone, elle n’a pas besoin des mêmes allocations qu’une méthode qui se suspend réellement. Quand elle se suspend, l’état nécessaire à la continuation doit survivre, et le runtime peut alors utiliser le tas pour le conserver. Le comportement exact dépend du code généré, de l’objet attendu et des optimisations du runtime. Il serait donc faux d’affirmer que toutes les variables locales d’une méthode async vivent sur le tas, et tout aussi faux d’affirmer qu’elles vivent sur la pile.
Ce que montrent ces exemples
| Situation | Ce que nous avons observé |
|---|---|
| Variable locale de type valeur | Peut résider dans un registre, dans un emplacement de pile, ou disparaître |
| Champ struct dans une classe | Stocké en ligne dans les données de l’objet qui le contient |
| Élément struct d’un tableau | Stocké en ligne dans le stockage des éléments du tableau |
| Type valeur boxé | Représenté dans un objet boxé, normalement alloué |
| Variable locale capturée | Peut devenir un champ d’un objet de fermeture généré |
Variable locale utilisée après un await | Peut devenir un champ d’une machine à états générée |
Le langage définit la sémantique des valeurs et des références ; le compilateur et le runtime décident de leur implémentation. Plutôt que « les structs vont sur la pile, les classes sur le tas », un modèle mental plus utile est : les types valeur sont copiés selon une sémantique de valeur, et leur stockage physique dépend du contexte dans lequel ils sont utilisés et de l’implémentation du runtime.
3. Boxing et unboxing : que se passe-t-il vraiment ?
int number = 42;
object result = number;
Ce code compile sans cast, alors que int est un type valeur et object un type référence. Le pont entre ces deux mondes, c’est le boxing.
Pourquoi le boxing existe-t-il ?
Tout type C# dérive en fin de compte de System.Object, types valeur compris : un int peut donc appeler ToString() ou GetType(). C# et le CLR offrent un système de types unifié. Mais hériter de System.Object ne signifie pas que chaque entier soit un objet séparé sur le tas. Un int existe normalement comme un simple entier, sans enveloppe d’objet.
Quand le programme a besoin d’une représentation de type référence de cette valeur, le boxing la fournit. Conceptuellement, le runtime :
- crée une représentation boxée de l’entier,
- y copie la valeur de l’entier,
- produit une référence vers cet objet boxé.
En exécution ordinaire, cela crée un objet sur le tas managé. L’entier d’origine ne devient pas un type référence : c’est une copie boxée distincte de sa valeur qui est créée. Comme nous le verrons dans la section 6, le JIT peut parfois supprimer cet objet lorsque cela préserve le comportement du programme.
Le boxing crée une copie, pas un alias
Nous l’avons vu plus haut : après object boxed = number; number = 100;, boxed contient toujours 42. L’objet boxé possède sa propre copie de l’entier, pas une référence vers la variable d’origine.
number boxed
┌───────┐ │
│ 100 │ ▼
└───────┘ ┌─────────────────┐
│ Int32 boxé │
├─────────────────┤
│ Valeur : 42 │
└─────────────────┘
Le boxing change la représentation d’une valeur ; il ne transforme pas la variable d’origine en alias du stockage boxé.
L’instruction box dans l’IL
Le code C# ne montre pas les instructions qu’exécute le runtime. Pour voir le boxing de façon explicite, on peut examiner l’IL que le compilateur C# génère pour cette méthode :
static object BoxNumber(int number)
{
return number;
}
Une séquence IL représentative est :
ldarg.0
box [System.Runtime]System.Int32
ret
ldarg.0charge le premier argument sur la pile d’évaluation de l’IL. Cette pile fait partie du modèle d’exécution abstrait de l’IL ; ce n’est pas la pile physique du thread.boxeffectue l’opération de boxing et produit une référence d’objet.retrenvoie cette référence.
L’instruction box rend la conversion explicite, alors que le code C# ne contient aucun cast. Le formatage exact et la qualification d’assembly varient selon le compilateur et le framework cible.
Voir box dans l’IL établit que le programme a une sémantique de boxing. Cela ne prouve pas que le code machine optimisé alloue à chaque exécution. Cette distinction comptera plus loin.
L’unboxing : récupérer la valeur
int original = 42;
object boxed = original;
int restored = (int)boxed;
Console.WriteLine(restored); // 42
Le cast explicite est une conversion d’unboxing. Pour un type valeur non nullable, l’unboxing vérifie que l’objet contient une valeur boxée du bon type, puis en copie la valeur. L’IL utilise généralement unbox.any :
ldarg.0
unbox.any [System.Runtime]System.Int32
ret
| Instruction IL | Rôle |
|---|---|
box | Produit une représentation boxée d’une valeur |
unbox | Après vérification du type, produit un pointeur managé vers la valeur contenue dans l’objet boxé |
unbox.any | Pour un type valeur, obtient une copie de la valeur depuis sa représentation boxée |
Pour du C# ordinaire comme (int)boxed, c’est unbox.any que l’on rencontre en général. L’unboxing vers une variable locale ne crée pas d’autre objet : il copie la valeur hors de la boîte existante.
L’unboxing n’est pas une conversion numérique
int number = 42;
object boxed = number;
long result = (long)boxed; // InvalidCastException
C# autorise une conversion implicite de int vers long, donc ce code semble raisonnable. Mais l’objet contient un System.Int32 boxé, pas un System.Int64 boxé, et le cast tente de l’unboxer en long. La bonne approche consiste à unboxer d’abord vers le type réel, puis à convertir :
long result = (int)boxed;
Console.WriteLine(result); // 42
L’unboxing dépend strictement du type. Unboxer une valeur boxée d’un type incompatible lève une InvalidCastException, et unboxer null vers un type valeur non nullable lève une NullReferenceException.
Le boxing via les interfaces
Le boxing se produit aussi lorsqu’un type valeur est converti vers une interface qu’il implémente.
public interface ICounter
{
void Increment();
int GetValue();
}
public struct Counter : ICounter
{
private int _value;
public void Increment() => _value++;
public int GetValue() => _value;
}
Counter counter = new Counter();
ICounter interfaceCounter = counter;
interfaceCounter.Increment();
Console.WriteLine(counter.GetValue()); // 0
Console.WriteLine(interfaceCounter.GetValue()); // 1
Affecter la struct à une variable d’interface la boxe. La boîte contient une copie de counter, et Increment() modifie la copie dans la boîte, pas la variable d’origine. Les deux valeurs sont devenues indépendantes.
Cela ne veut pas dire qu’appeler une méthode d’interface sur une struct provoque toujours un boxing. Un appel direct sur la struct, ou un appel générique correctement contraint, peut l’éviter.
Les génériques et le préfixe constrained.
static void IncrementGeneric<T>(ref T value)
where T : struct, ICounter
{
value.Increment();
}
Counter counter = new Counter();
IncrementGeneric(ref counter);
Console.WriteLine(counter.GetValue()); // 1
La méthode est contrainte aux structs qui implémentent ICounter, et l’argument est passé par référence : elle agit donc sur la variable d’origine sans la convertir en référence d’interface. L’IL de l’appel contient :
constrained. !!T
callvirt instance void ICounter::Increment()
Le préfixe constrained. indique au runtime comment effectuer l’appel virtuel pour le paramètre de type générique. Quand le type valeur implémente lui-même la méthode, l’appel peut agir directement sur la valeur, sans boxing. C’est l’un des mécanismes qui rendent le code générique efficace en .NET : les génériques préservent la représentation des types valeur en plus de la sûreté de typage. Ils ne sont pas pour autant automatiquement exempts d’allocations ; il faut toujours examiner les opérations effectuées.
Le boxing dans les collections
Avant les génériques, on stockait souvent les valeurs dans des collections non génériques comme ArrayList, qui stocke des références object. Ajouter un int le boxe, et le relire en tant qu’int l’unboxe :
using System.Collections;
var numbers = new ArrayList();
numbers.Add(10); // boxing
int first = (int)numbers[0]; // unboxing
List<int> stocke ses entiers directement dans un tableau int[] interne : les mêmes opérations ne demandent aucun boxing. Elle apporte aussi la sûreté de typage à la compilation et supprime les casts. Nous mesurerons la différence dans la section 5.
Le boxing de Nullable<T>
Les types valeur nullables suivent des règles de boxing particulières :
int? first = 42;
int? second = null;
object? boxedFirst = first;
object? boxedSecond = second;
Console.WriteLine(boxedFirst.GetType()); // System.Int32
Console.WriteLine(boxedSecond is null); // True
Si HasValue vaut true, c’est la valeur sous-jacente qui est boxée : la boîte est un System.Int32, pas un Nullable<int>. Si HasValue vaut false, le boxing produit une référence nulle. Ce comportement est défini par le langage ; ce n’est pas une optimisation. Les types valeur nullables auront leur propre article plus loin dans la série.
Quand le boxing devient-il un problème de performance ?
Un boxing ordinaire peut impliquer une allocation managée, la copie de la valeur dans la boîte, le surcoût des métadonnées et de l’alignement de l’objet, davantage d’objets à suivre pour le ramasse-miettes, ainsi qu’une vérification de type et une copie au moment de l’unboxing.
Tout boxing n’est pas un problème. Boxer un entier de temps en temps au démarrage a peu de chances de compter. Un boxing répété dans une boucle critique ou un chemin à fort débit peut avoir un impact mesurable. Et la présence de box dans l’IL ne dit pas ce que fait le code machine final. C’est pourquoi les sections 5 et 6 s’appuient sur BenchmarkDotNet, sur les mesures d’allocation et sur le désassemblage.
4. Dans le runtime : objets, disposition et taille
Un int occupe quatre octets. Un int boxé en demande davantage. Pour comprendre pourquoi, il faut regarder comment le CLR représente les objets en mémoire.
Que contient un objet managé ?
Le runtime doit pouvoir identifier le type d’un objet et prendre en charge l’appel virtuel, la synchronisation et le ramasse-miettes. Dans une implémentation CoreCLR 64 bits typique, un objet du tas comporte trois parties conceptuelles :
- L’en-tête d’objet : des informations internes au runtime associées à l’objet.
- Le pointeur de table des méthodes : il identifie le type de l’objet à l’exécution et donne accès aux informations du type.
- Les données d’instance : les champs de l’objet, plus un éventuel remplissage (padding).
public sealed class Person
{
public int Age;
}
Instance de Person, CoreCLR 64 bits typique (vue conceptuelle)
┌───────────────────────────────┐ adresses basses
│ En-tête d’objet (8 octets)│
├───────────────────────────────┤ <- la référence pointe ici
│ Ptr table méthodes (8 octets)│
├───────────────────────────────┤
│ Age (4 octets)│
├───────────────────────────────┤
│ Remplissage / alignement │
└───────────────────────────────┘ adresses hautes
C’est une disposition CoreCLR 64 bits courante, pas une garantie de la spécification C#. La référence d’objet pointe normalement sur le pointeur de table des méthodes, l’en-tête se trouvant juste avant : une référence et le début de la mémoire allouée pour l’objet ne sont donc pas la même adresse.
- L’en-tête d’objet contient des informations de gestion comme l’état de synchronisation et, si nécessaire, des informations liées au code de hachage d’identité de l’objet. Sur les configurations 64 bits typiques, la zone qui précède le pointeur de table des méthodes occupe huit octets : la valeur du bloc de synchronisation en utilise quatre, le reste sert à l’alignement.
- Le pointeur de table des méthodes désigne des informations de type partagées par toutes les instances du type. Chaque instance ne transporte pas sa propre copie de la table des méthodes.
- Les données d’instance contiennent les champs et le remplissage exigé par les règles de disposition : la taille d’un objet n’est donc pas simplement la somme de ses champs.
Pourquoi un objet vide peut-il occuper 24 octets ?
public sealed class Empty
{
}
Un objet sans champ a quand même besoin de sa représentation à l’exécution. Dans une implémentation CoreCLR 64 bits typique, la taille minimale d’un objet managé est de 24 octets :
| Composant | Taille typique |
|---|---|
| Zone d’en-tête de l’objet | 8 octets |
| Pointeur de table des méthodes | 8 octets |
| Stockage minimal / alignement | 8 octets |
| Total | 24 octets |
Ce minimum est une propriété de l’implémentation du runtime, pas une règle universelle pour tous les runtimes et toutes les architectures. Il explique aussi pourquoi « les octets nécessaires pour représenter une valeur » et « les octets occupés par un objet contenant cette valeur » sont deux quantités différentes.
À quoi ressemble un entier boxé ?
System.Int32 boxé, CoreCLR 64 bits typique (vue conceptuelle)
┌───────────────────────────────┐
│ En-tête d’objet 8 octets │
├───────────────────────────────┤
│ Ptr table méthodes 8 octets │
├───────────────────────────────┤
│ Valeur : 42 4 octets │
├───────────────────────────────┤
│ Remplissage / min. 4 octets │
└───────────────────────────────┘
Total : 24 octets
| Représentation | Taille typique sur CoreCLR 64 bits |
|---|---|
Valeur int | 4 octets |
| Référence d’objet | 8 octets |
Objet int boxé | 24 octets |
Ce sont des choses différentes. La référence de 8 octets est distincte de la boîte de 24 octets ; si la référence est dans une variable locale, elle peut se trouver dans un registre ou un emplacement de pile. Et une allocation de 24 octets n’augmente pas forcément la mémoire du processus de 24 octets, car la gestion interne de l’allocateur et le ramasse-miettes influent sur ce que l’on observe. Reste que boxer une petite valeur coûte nettement plus de mémoire que de la stocker directement. La section 5 confirme ces 24 octets par des mesures réelles.
Une struct ne porte pas d’en-tête d’objet
public struct Point
{
public int X;
public int Y;
}
public sealed class Location
{
public Point Position;
}
Point contient deux entiers de 4 octets : sa taille managée est de 8 octets. Stockée comme variable locale, élément de tableau ou champ, elle n’a besoin ni de son propre en-tête ni de son propre pointeur de table des méthodes ; ses données font partie du stockage qui la contient. Le champ Position ci-dessus est intégré aux données d’instance de l’objet Location, à côté de l’unique en-tête de cet objet.
En revanche, si l’on boxe la struct (object boxedPoint = point;), le runtime a besoin d’une représentation objet, avec le surcoût habituel d’un objet en plus des 8 octets de données. Un type valeur ne porte pas d’en-tête d’objet par nature. Sa représentation boxée, si.
Mesurer les tailles avec Unsafe.SizeOf<T>()
Unsafe.SizeOf<T>(), dans System.Runtime.CompilerServices, renvoie la taille, en octets, d’une valeur de type T telle qu’elle est représentée en mémoire managée. Pour un type référence, elle mesure la référence elle-même, pas l’allocation de l’objet référencé.
using System.Runtime.CompilerServices;
Console.WriteLine($"int: {Unsafe.SizeOf<int>()}");
Console.WriteLine($"Point: {Unsafe.SizeOf<Point>()}");
Console.WriteLine($"Mixed: {Unsafe.SizeOf<Mixed>()}");
Console.WriteLine($"object reference: {Unsafe.SizeOf<object>()}");
public struct Point
{
public int X;
public int Y;
}
public struct Mixed
{
public byte Flag;
public int Value;
}
Il s’agit d’un exemple illustratif, pas d’un résultat mesuré dans cet article. Sur un runtime 64 bits typique, on s’attend à 4, 8, 8 et 8. Mixed est le cas intéressant : un champ de 1 octet et un champ de 4 octets font 5 octets au total, mais le runtime insère généralement 3 octets de remplissage pour aligner Value sur 4 octets :
Mixed (disposition typique)
┌─────────────────────┐
│ Flag 1 octet│
├─────────────────────┤
│ Remplissage 3 octets│
├─────────────────────┤
│ Value 4 octets│
└─────────────────────┘
Total : 8 octets
Le cas des types référence prête facilement à confusion. Unsafe.SizeOf<string>() renvoie 8 sur un runtime 64 bits typique : c’est la taille de la référence, pas celle de l’en-tête et des caractères de la chaîne.
À vous de jouer. Exécutez l’extrait ci-dessus dans une application console sur votre machine et comparez la sortie à ces valeurs attendues. Réordonnez ensuite les champs de
Mixed, ou ajoutez unlong, et observez l’évolution de la taille.
Marshal.SizeOf<T>() répond à une autre question
Marshal.SizeOf<T>() a un nom proche, mais renvoie la taille de la représentation non managée d’un type, pour l’interopérabilité. L’exemple le plus parlant est bool :
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
Console.WriteLine(Unsafe.SizeOf<bool>()); // 1
Console.WriteLine(Marshal.SizeOf<bool>()); // 4
Un bool managé occupe un octet. Par défaut, le marshaling d’interopérabilité représente bool comme un BOOL Win32 de 4 octets, et ce comportement par défaut s’applique sur toutes les plateformes. Ce n’est pas la preuve qu’un booléen C# occupe quatre octets en mémoire managée ; cela montre que les règles de marshaling produisent une représentation différente. N’utilisez pas Marshal.SizeOf<T>() pour mesurer des tailles managées.
Inspecter la disposition des objets avec ObjectLayoutInspector
Unsafe.SizeOf<Person>() ne mesure qu’une référence. Pour inspecter la disposition d’un objet managé (décalages des champs, remplissage, surcoût de l’en-tête), on peut utiliser ObjectLayoutInspector, un outil open source de Sergey Teplyakov.
À vous de jouer. Ajoutez le package NuGet
ObjectLayoutInspectorà une application console et affichez la disposition de cette classe :using ObjectLayoutInspector; TypeLayout.PrintLayout<Sample>(); public sealed class Sample { public byte Flag; public int Count; public long Timestamp; }Dans quel ordre le runtime range-t-il les champs ? Où apparaît le remplissage, et quelle part de l’espace est du surcoût ? Changez les types des champs et relancez. Gardez à l’esprit que ces outils exposent des détails d’implémentation du runtime que vous exécutez, pas des garanties valables pour tous les runtimes .NET.
Plus petit veut-il toujours dire plus rapide ?
On est tenté de conclure que les structs consomment moins de mémoire et sont donc plus rapides. C’est trop général.
public struct LargeValue
{
public long A, B, C, D, E, F;
}
public sealed class LargeObject
{
public long A, B, C, D, E, F;
}
LargeValue contient 48 octets de données, et chaque affectation les copie tous : LargeValue second = first; copie 48 octets. LargeObject second = first; ne copie qu’une référence de 8 octets, mais la classe a besoin d’un objet et, en exécution ordinaire, d’une allocation sur le tas.
Le meilleur choix dépend de la charge de travail. Une struct est intéressante quand elle est petite, souvent utilisée et pas boxée inutilement. Une classe convient mieux quand l’identité, un état mutable partagé ou l’évitement de grosses copies comptent. Choisissez une struct pour sa sémantique et ses performances mesurées, pas parce que vous pensez qu’elle vit sur la pile.
5. Mesurer le coût du boxing avec BenchmarkDotNet
Boxer un int crée normalement un objet de 24 octets. Mais savoir qu’il y a allocation ne dit ni ce qu’elle coûte, ni si .NET moderne supprime ce surcoût. Mesurons-le.
Pourquoi un chronomètre ne suffit pas
var stopwatch = Stopwatch.StartNew();
for (int i = 0; i < 1_000_000; i++)
{
object boxed = i;
}
stopwatch.Stop();
Console.WriteLine(stopwatch.ElapsedMilliseconds);
Cela ressemble à un benchmark, mais pose plusieurs problèmes. Les valeurs boxées ne sont jamais utilisées, donc le JIT peut supprimer le travail. La mesure inclut la compilation JIT, le démarrage du runtime et le bruit du ramasse-miettes. Une seule exécution ne fournit aucune statistique. Et elle ne dit rien de la mémoire, qui est précisément ce qui rend le boxing intéressant.
BenchmarkDotNet et les métriques utilisées
BenchmarkDotNet effectue des itérations de chauffe et des mesures répétées, calcule des statistiques et, avec [MemoryDiagnoser], rapporte les allocations.
| Métrique | Signification |
|---|---|
| Mean | Temps d’exécution moyen mesuré par opération |
| Allocated | Octets managés alloués par opération |
| Gen0 | Collectes de génération 0 pour 1 000 opérations |
| Gen1 | Collectes de génération 1 pour 1 000 opérations |
Une opération peut être rapide et allouer beaucoup : il ne faut donc pas juger les performances au seul temps d’exécution.
Toutes les exécutions ont eu lieu sur la même machine (Intel Core i7-6820HQ, Windows 10 22H2, BenchmarkDotNet 0.15.8), une fois sous .NET 9.0.22 et une fois sous .NET 10.0.14, à partir du même projet multi-cible. L’environnement complet et les commandes figurent dans Reproduire les expériences.
Les valeurs mesurées sont reproduites telles que BenchmarkDotNet les affiche, avec le point décimal, afin de pouvoir les comparer directement aux rapports.
Expérience 1 : boxer un entier
using BenchmarkDotNet.Attributes;
[MemoryDiagnoser]
public class BoxingBenchmarks
{
private int _number = 42;
[Benchmark(Baseline = true)]
public int WithoutBoxing()
{
return _number;
}
[Benchmark]
public object WithBoxing()
{
return _number;
}
}
WithBoxing renvoie l’entier en tant qu’object, ce qui impose un boxing. Renvoyer le résultat, plutôt que l’affecter à une variable locale inutilisée, est important : BenchmarkDotNet consomme les valeurs renvoyées, donc l’objet boxé s’échappe de la méthode et reste observable.
| Métrique | .NET 9.0.22 | .NET 10.0.14 |
|---|---|---|
Moyenne WithoutBoxing | 0.0029 ns | 0.0202 ns |
Moyenne WithBoxing | 3.9804 ns | 3.8452 ns |
Alloué WithoutBoxing | 0 B | 0 B |
Alloué WithBoxing | 24 B | 24 B |
Gen0 WithBoxing | 0.0057 | 0.0057 |
Les deux runtimes ont alloué exactement 24 octets par opération de boxing, ce qui correspond à la disposition de l’entier boxé vue dans la section 4. La valeur occupe 4 octets ; sa représentation boxée en occupe six fois plus, sans compter la référence qui la désigne.
Le temps demande plus de prudence. Le boxing a pris en moyenne environ 3.98 ns sous .NET 9 et 3.85 ns sous .NET 10, soit un écart de 3,4 % sur une seule session par runtime. Cela ne suffit pas pour affirmer que .NET 10 boxe plus vite. D’ailleurs, le benchmark EscapingBox de la section 6 effectue une opération équivalente, boxer un champ int et le renvoyer en tant qu’object, sur le même runtime .NET 10, et a mesuré 4.16 ns lors d’une autre session : 8 % d’écart entre deux sessions d’un travail équivalent, soit plus que l’écart entre les deux runtimes.
BenchmarkDotNet a aussi signalé un avertissement ZeroMeasurement pour WithoutBoxing sur les deux runtimes : sa durée était indiscernable de celle d’une méthode vide. Les valeurs 0.0029 ns et 0.0202 ns ne sont que du bruit, ce qui explique aussi le ? dans la colonne Ratio. Diviser 3.98 ns par 0.0029 ns donnerait un rapport spectaculaire et dénué de sens.
La conclusion fiable porte sur l’allocation : le boxing a alloué 24 octets, renvoyer l’entier n’a rien alloué. Le temps mesuré est une estimation pour ce microbenchmark, pas un coût universel du boxing.
Expérience 2 : ArrayList contre List<int>
Que se passe-t-il quand le boxing se répète des milliers de fois ? Nous comparons ArrayList, collection non générique qui stocke des références object, et List<int>, qui stocke les entiers dans son tableau interne :
using System.Collections;
using BenchmarkDotNet.Attributes;
[MemoryDiagnoser]
public class CollectionBenchmarks
{
[Params(100, 1_000, 10_000)]
public int Count { get; set; }
[Benchmark(Baseline = true)]
public ArrayList NonGenericCollection()
{
var list = new ArrayList(Count);
for (int i = 0; i < Count; i++)
{
list.Add(i);
}
return list;
}
[Benchmark]
public List<int> GenericCollection()
{
var list = new List<int>(Count);
for (int i = 0; i < Count; i++)
{
list.Add(i);
}
return list;
}
}
Les deux collections sont créées avec une capacité suffisante : aucune ne redimensionne son tableau interne pendant l’insertion. La différence porte sur le stockage de chaque entier : boxé dans ArrayList, en ligne dans List<int>.
Temps d’exécution
| Éléments | Runtime | ArrayList | List<int> | List<int> plus rapide de |
|---|---|---|---|---|
| 100 | .NET 9 | 669.3 ns | 191.4 ns | 3,50× |
| 100 | .NET 10 | 633.4 ns | 194.0 ns | 3,27× |
| 1 000 | .NET 9 | 7,079.8 ns | 1,742.1 ns | 4,06× |
| 1 000 | .NET 10 | 6,984.9 ns | 1,749.0 ns | 3,99× |
| 10 000 | .NET 9 | 95,358.9 ns | 16,862.1 ns | 5,66× |
| 10 000 | .NET 10 | 83,564.6 ns | 16,886.2 ns | 4,95× |
List<int> a été plus rapide à toutes les tailles et sur les deux runtimes, de 3,27× à 5,66×. Mais ce benchmark ne mesure pas le boxing seul. Il mesure la construction complète d’une collection : allocation de la collection et de son tableau interne, puis insertion des éléments. L’écart inclut le boxing, mais aussi des détails d’implémentation, les schémas d’accès mémoire et l’activité du ramasse-miettes. Ce sont donc des résultats de construction de collection, pas un coût de boxing pur.
Mémoire allouée
Les allocations ont été identiques sous .NET 9 et .NET 10 :
| Éléments | ArrayList | List<int> | Réduction |
|---|---|---|---|
| 100 | 3,256 B | 456 B | 86,0 % |
| 1 000 | 32,056 B | 4,056 B | 87,3 % |
| 10 000 | 320,056 B | 40,056 B | 87,5 % |
À 10 000 éléments, ArrayList a alloué 280 000 octets de plus que List<int>, soit environ 273,4 Kio d’allocation supplémentaire pour une seule collection.
Ces chiffres s’expliquent à l’octet près. Par élément, ArrayList a besoin d’un emplacement de référence de 8 octets dans son object[] et d’un entier boxé de 24 octets : 32 octets. List<int> a besoin de 4 octets dans son int[]. Cela fait 28 octets d’écart par élément, ce qui donne exactement 2 800, 28 000 et 280 000 octets pour 100, 1 000 et 10 000 éléments.
La partie fixe concorde aussi, si l’on suppose un en-tête de tableau de 24 octets et un objet collection de 32 octets. Ces deux tailles sont des hypothèses : elles sont cohérentes avec les dispositions CoreCLR 64 bits courantes et avec les totaux mesurés, mais nous n’avons pas mesuré la disposition de ces objets de façon indépendante.
| Objet collection | Tableau interne | Entiers boxés | Total pour n éléments | |
|---|---|---|---|---|
ArrayList | 32 B | 24 + 8n B | 24n B | 56 + 32n B |
List<int> | 32 B | 24 + 4n B | aucun | 56 + 4n B |
Avec ces hypothèses, les formules reproduisent exactement les six valeurs mesurées pour n = 100, 1 000 et 10 000. La mémoire supplémentaire allouée par ArrayList croît linéairement avec le nombre d’entiers boxés, et elle découle directement de la représentation des éléments.
Ramasse-miettes
Une allocation ne déclenche pas immédiatement une collecte, et le ramasse-miettes .NET gère efficacement les objets à courte durée de vie. Mais plus on alloue, plus il faudra collecter tôt ou tard. Sous .NET 10 :
| Éléments | Collection | Gen0 | Gen1 |
|---|---|---|---|
| 100 | ArrayList | 0.7782 | – |
| 100 | List<int> | 0.1090 | – |
| 1 000 | ArrayList | 7.6599 | 0.7629 |
| 1 000 | List<int> | 0.9689 | – |
| 10 000 | ArrayList | 65.1855 | 21.7285 |
| 10 000 | List<int> | 9.5215 | – |
Les chiffres .NET 9 sont identiques, à l’exception d’une valeur Gen1 de 21.8506 à 10 000 éléments. Il s’agit de collectes pour 1 000 opérations, pas par opération. La tendance est nette : ArrayList a provoqué beaucoup plus de collectes de génération 0 et une activité mesurable en génération 1, alors que List<int> n’a provoqué aucune collecte de génération 1 lors de ces exécutions.
Dans un service qui traite de nombreuses requêtes, des allocations inutiles dans les chemins critiques se traduisent par un taux d’allocation plus élevé et davantage de travail pour le ramasse-miettes, ce qui coûte du CPU et peut affecter la latence. L’ampleur de l’effet dépend de la fréquence des allocations, de la durée de vie des objets, de la pression mémoire globale et des exigences de latence de l’application.
.NET 10 a-t-il amélioré le boxing ?
| Benchmark | .NET 9 | .NET 10 | Évolution observée |
|---|---|---|---|
| Boxing d’un entier | 3.9804 ns | 3.8452 ns | −3,4 % |
ArrayList, 100 | 669.3 ns | 633.4 ns | −5,4 % |
List<int>, 100 | 191.4 ns | 194.0 ns | +1,4 % |
ArrayList, 1 000 | 7,079.8 ns | 6,984.9 ns | −1,3 % |
List<int>, 1 000 | 1,742.1 ns | 1,749.0 ns | +0,4 % |
ArrayList, 10 000 | 95,358.9 ns | 83,564.6 ns | −12,4 % |
List<int>, 10 000 | 16,862.1 ns | 16,886.2 ns | +0,1 % |
La plus forte évolution concerne ArrayList à 10 000 éléments, qui passe d’environ 95,4 µs à 83,6 µs. Faut-il en conclure que .NET 10 a introduit une optimisation du boxing ? Pas nécessairement. BenchmarkDotNet a signalé que cette mesure .NET 9 avait une distribution bimodale, avec un écart type de 9 635 ns, soit environ 10 % de sa moyenne. Nous n’avons exécuté qu’une session par runtime, et nous avons déjà vu 8 % d’écart entre deux sessions d’un travail équivalent. Établir une vraie amélioration au niveau du runtime demanderait des sessions répétées, un environnement contrôlé et probablement l’examen du code généré.
Surtout, les allocations n’ont pas changé. Les deux runtimes ont alloué 24 octets par entier boxé qui s’échappe, et des quantités identiques pour les collections. Ces expériences montrent que les entiers boxés qui s’échappent continuent d’allouer sur les deux runtimes. Elles ne montrent pas que .NET 10 supprime ces allocations.
Ce que cela signifie pour de vraies applications
Un entier boxé alloue 24 octets. Répété un million de fois, cela fait 24 millions d’octets, soit environ 22,9 Mio d’allocation managée, sans compter tout ce que le code alloue par ailleurs.
Cela ne veut pas dire qu’il faille éviter le boxing à tout prix. Un boxing au démarrage ne mérite pas qu’on s’y attarde ; un boxing répété des millions de fois dans un chemin critique, peut-être. L’objectif n’est pas d’éliminer toutes les allocations, mais de repérer celles qui sont inutiles et ont un effet mesurable. Les collections génériques, les API fortement typées et les méthodes génériques contraintes permettent souvent d’éviter le boxing, mais le choix doit suivre la charge de travail mesurée.
Jusqu’ici, tous les objets boxés que nous avons mesurés s’échappaient de leur méthode. D’où la question suivante : si un programme contient une instruction de boxing, le JIT doit-il toujours créer un objet ?
6. Le rebondissement du .NET moderne : quand la boîte disparaît
Code C#, IL et code machine sont des niveaux différents
Code source C#
│ Roslyn (compilateur C#)
▼
IL (Common Intermediate Language)
│ RyuJIT (à l’exécution)
▼
Code machine natif
│
▼
Exécution par le processeur
Roslyn traduit le C# en IL, qui décrit des opérations comme la création d’objets, le chargement de valeurs, l’appel de méthodes et les conversions de types. Le JIT traduit ensuite l’IL en code machine. Le JIT n’est pas tenu de reproduire littéralement chaque opération de l’IL. Il doit préserver le comportement observable du programme, ce qui lui permet de supprimer des instructions inutiles, de propager des constantes, d’intégrer des méthodes (inlining) et parfois d’éliminer des allocations. Nous avons déjà vu Calculate() se réduire à return 30. Le même principe peut s’appliquer aux objets : une allocation décrite par l’IL n’est pas forcément une allocation dans le code machine final.
Une opération de boxing qui pourrait disparaître
public static int BoxAndUnbox(int value)
{
object boxed = value;
return (int)boxed;
}
ldarg.0
box [System.Runtime]System.Int32
unbox.any [System.Runtime]System.Int32
ret
Lu littéralement, l’IL alloue une boîte puis l’unboxe. Mais la référence boxée n’est jamais renvoyée, stockée dans un champ, passée à une autre méthode ni observée par un autre code. La méthode renvoie l’entier d’origine. Un compilateur optimisant pourrait donc la transformer en return value;, sans aucun objet. Une instruction box dans l’IL ne prouve pas, à elle seule, qu’une allocation a lieu dans le code machine optimisé. Cela dépend de la version du runtime et du code environnant ; il faut donc examiner le résultat avant d’affirmer quoi que ce soit.
L’analyse d’échappement
Pour raisonner sur les allocations, un compilateur se pose la question suivante : une référence vers cet objet peut-elle s’échapper de la portée analysée ?
// L’objet s’échappe : l’appelant le reçoit.
public static object CreateBox(int value)
{
object boxed = value;
return boxed;
}
// L’objet ne s’échappe pas : la référence reste locale.
public static int ReadBoxedValue(int value)
{
object boxed = value;
return (int)boxed;
}
Dans CreateBox, l’appelant peut conserver la référence, inspecter son type, comparer son identité ou la stocker : à l’échelle de cette seule méthode, l’objet doit rester valide après son retour. S’échapper d’une méthode ne prouve pas à lui seul qu’une allocation doit avoir lieu : après inlining, un JIT peut parfois analyser aussi l’appelant. Nos benchmarks où l’objet s’échappe renvoient la boîte au consommateur de BenchmarkDotNet et, dans les conditions testées, ils ont alloué 24 octets.
Dans ReadBoxedValue, aucune référence ne quitte la méthode, ce qui offre une opportunité au JIT. Mais ne pas s’échapper ne garantit rien : le compilateur doit aussi comprendre chaque opération effectuée sur l’objet et prouver que la transformation préserve le comportement. L’identité, la synchronisation, des appels que le JIT ne peut pas analyser, les exceptions ou d’autres effets observables peuvent l’en empêcher. L’analyse d’échappement est un outil pour raisonner sur la durée de vie et les opportunités d’optimisation, pas une promesse.
Allocation sur la pile et élimination d’allocation sont deux choses différentes
Quand C# écrit new, un compilateur peut l’implémenter de façons conceptuellement différentes :
| Mécanisme | Ce qui se passe | Où vit l’objet |
|---|---|---|
| Allocation sur le tas | Un objet managé est alloué | Tas managé |
| Allocation sur la pile | Le stockage de l’objet est placé dans le cadre de pile de la méthode | Pile |
| Élimination d’allocation | L’optimiseur prouve que l’objet est inutile et le supprime | Nulle part : aucun objet n’existe |
Prenons cette fois une classe :
public sealed class PointObject
{
public PointObject(int x, int y)
{
X = x;
Y = y;
}
public int X { get; }
public int Y { get; }
}
public static int Compute()
{
var point = new PointObject(10, 20);
return point.X + point.Y;
}
Un optimiseur performant pourrait remplacer l’objet par les valeurs de ses champs et calculer directement le résultat, une technique proche du remplacement scalaire (scalar replacement). Cet exemple illustre ce qui est possible, pas ce que fait un runtime donné. Et si une allocation est entièrement éliminée, dire que l’objet « est passé sur la pile » est faux : il n’y a peut-être plus d’objet du tout.
Les deux mécanismes peuvent aussi être les étapes d’une même optimisation. Un JIT peut d’abord placer un objet qui ne s’échappe pas sur la pile, puis, ses champs étant devenus de simples variables locales, les optimiser davantage jusqu’à ce qu’il ne reste rien de l’objet dans le code final.
Ce que fait réellement le .NET moderne
Ce n’est pas une hypothèse. D’après la documentation du runtime de Microsoft :
- .NET 9 : « à partir de .NET 9, le compilateur 64 bits alloue sur la pile les boîtes qui ne s’échappent pas, ce qui ouvre la voie à plusieurs autres optimisations » (traduction). Dans l’exemple de Microsoft, une méthode qui boxait deux entiers pour les comparer est réduite à
mov eax, 100; ret: plus aucune allocation ne subsiste (What’s new in the .NET 9 runtime, en anglais). - .NET 10 : l’analyse d’échappement a été étendue. Le JIT peut allouer sur la pile de petits tableaux de taille fixe, de types valeur comme de types référence, les délégués qui ne s’échappent pas, ainsi que les objets référencés par les champs de structs locales qui ne s’échappent pas. Les fermetures restaient allouées sur le tas dans cette version (What’s new in the .NET 10 runtime, en anglais).
Les deux sont soumis à des conditions. Ils s’appliquent aux objets dont le JIT peut prouver qu’ils ne s’échappent pas, en général après inlining, et Microsoft les décrit pour « certains » cas. Ils ne signifient pas que toute boîte ou tout objet qui ne s’échappe pas est alloué sur la pile, et le langage C# ne promet rien à ce sujet.
stackalloc : l’allocation explicite sur la pile
C# dispose bien d’une construction explicite d’allocation sur la pile :
public static int Sum()
{
Span<int> numbers = stackalloc int[4];
numbers[0] = 10;
numbers[1] = 20;
numbers[2] = 30;
numbers[3] = 40;
int total = 0;
foreach (int number in numbers)
{
total += number;
}
return total;
}
stackalloc demande un bloc de mémoire de pile, accessible ici via un Span<int>, sans créer d’objet tableau managé. Il a des restrictions : il est destiné à des types d’éléments adaptés, comme les valeurs non managées, et le stockage alloué est lié au cadre de pile courant, dont il ne peut pas dépasser la durée de vie sans risque. Chaque exécution de stackalloc consomme de l’espace de pile : des allocations volumineuses ou répétées, par exemple un stackalloc dans une boucle, peuvent épuiser la pile disponible. C’est un outil explicite pour de petits tampons temporaires, pas la preuve que tous les types valeur vivent sur la pile.
ref struct et Span<T>
public ref struct BufferReader
{
private Span<byte> _buffer;
public BufferReader(Span<byte> buffer)
{
_buffer = buffer;
}
public byte ReadFirst() => _buffer[0];
}
Une ref struct est un type valeur byref-like. Ses restrictions l’empêchent de s’échapper vers le tas, par exemple en étant boxée ou stockée dans un champ de classe ; elle peut donc contenir sans risque des références vers de la mémoire de pile. Span<T> et ReadOnlySpan<T> en sont les exemples les plus connus.
Une ref struct n’est pas une instruction d’allocation sur la pile ; c’est un type soumis à des restrictions de durée de vie. Et un Span<T> ne pointe pas forcément vers la pile :
int[] values = { 10, 20, 30 };
Span<int> span = values;
Ce span est une vue sur les éléments d’un tableau ordinaire alloué sur le tas. Un span peut désigner un tableau, de la mémoire de pile ou d’autres zones mémoire contiguës : une raison de plus de ne pas assimiler types valeur et pile.
Expérience 3 : ce que RyuJIT génère réellement
Les benchmarks de la section 5 ne testaient que des boîtes qui s’échappent. Pour savoir si RyuJIT supprime une boîte qui ne s’échappe pas, nous avons exécuté trois benchmarks sous .NET 10.0.14 avec les diagnostics MemoryDiagnoser et DisassemblyDiagnoser de BenchmarkDotNet :
[MemoryDiagnoser]
[DisassemblyDiagnoser(
maxDepth: 2,
printSource: true,
printInstructionAddresses: false)]
public class JitOptimizationBenchmarks
{
private int _value = 42;
[Benchmark]
public int DirectReturn()
{
return _value;
}
[Benchmark]
public int BoxAndUnbox()
{
object boxed = _value;
return (int)boxed;
}
[Benchmark]
public object EscapingBox()
{
return _value;
}
}
| Méthode | Moyenne | Alloué | Taille du code natif |
|---|---|---|---|
DirectReturn | 0.0060 ns | 0 B | 4 B |
BoxAndUnbox | 0.0406 ns | 0 B | 4 B |
EscapingBox | 4.1574 ns | 24 B | 35 B |
BoxAndUnbox boxe puis unboxe dans son code source, et pourtant BenchmarkDotNet a mesuré zéro allocation. Les chiffres d’allocation seuls ne disent pas ce que le JIT a généré ; lisons donc le code natif. Voici ce que RyuJIT a produit pour chaque méthode, d’après la sortie de DisassemblyDiagnoser (x64, .NET 10.0.14).
DirectReturn :
mov eax,[rcx+8]
ret
; Total bytes of code 4
La méthode charge _value depuis l’instance du benchmark (rcx contient this, et le champ est au décalage 8) et la renvoie.
BoxAndUnbox :
mov eax,[rcx+8]
ret
; Total bytes of code 4
Ce sont exactement les mêmes instructions que pour DirectReturn. Pas d’appel d’allocation, pas de copie dans une boîte, pas de vérification de type. Combiné à la mesure de zéro allocation, cela montre que l’opération de boxing ne survit pas dans le code machine généré.
EscapingBox :
push rbx
sub rsp,20
mov rbx,rcx
mov rcx,offset MT_System.Int32
call CORINFO_HELP_NEWSFAST
mov ecx,[rbx+8]
mov [rax+8],ecx
add rsp,20
pop rbx
ret
; Total bytes of code 35
Dans ce benchmark, l’objet boxé est renvoyé au consommateur de BenchmarkDotNet, ce qui rend l’allocation observable et, dans les conditions testées, empêche le JIT de la supprimer. Le code charge la table des méthodes de System.Int32, appelle la fonction d’allocation du runtime CORINFO_HELP_NEWSFAST, puis copie _value dans le nouvel objet au décalage 8, juste après son pointeur de table des méthodes. BenchmarkDotNet a mesuré les 24 octets correspondants par appel, et le code est passé de 4 à 35 octets.
Interpréter les résultats
BenchmarkDotNet a émis des avertissements ZeroMeasurement pour DirectReturn et BoxAndUnbox : leurs moyennes ne peuvent pas servir à calculer un rapport de performance. La preuve repose sur quatre faits réunis :
DirectReturnetBoxAndUnboxsont compilées en instructions machine identiques.- Les deux ont alloué zéro octet.
EscapingBoxcontient un appel explicite à la fonction d’allocation.EscapingBoxa alloué 24 octets par appel.
Ce que cela établit, et ce que cela n’établit pas :
- Établi : sous .NET 10.0.14, la boîte inutile de
BoxAndUnboxn’existe pas dans le code natif final. Rien n’est alloué, ni sur le tas ni sur la pile. - Établi : quand la boîte est renvoyée au consommateur du benchmark, l’allocation demeure dans les conditions testées.
- Non établi : comment RyuJIT y est parvenu. Le désassemblage montre le résultat, pas les étapes intermédiaires. Depuis .NET 9, le JIT 64 bits peut allouer sur la pile des boîtes qui ne s’échappent pas, puis optimiser davantage ; il a donc pu traiter la boîte comme une allocation sur la pile avant de la supprimer entièrement. Notre sortie ne permet de trancher ni dans un sens ni dans l’autre.
- Non établi : que cette optimisation soit nouvelle dans .NET 10. Nous n’avons désassemblé cette expérience que sous .NET 10.
- Non établi : que toute boîte, ou tout objet, qui ne s’échappe pas soit supprimé ou alloué sur la pile.
À vous de jouer. Clonez le dépôt des benchmarks et exécutez l’expérience JIT avec
-f net9.0pour comparer le désassemblage .NET 9 aux résultats .NET 10 ci-dessus.
Deux expériences, deux questions
Le benchmark de boxing de la section 5 renvoyait la boîte : elle s’échappait, et les deux runtimes ont alloué 24 octets. Cela montre que l’allocation a subsisté quand la boîte était observable pour l’appelant, dans les conditions testées. Cela ne montre pas que toute instruction box alloue, et cela ne testait jamais une boîte qui ne s’échappe pas. L’expérience 3 l’a fait, et la boîte a disparu du code natif.
Un benchmark conçu pour rendre une allocation observable en mesure le coût. Un benchmark conçu pour offrir une opportunité à l’optimiseur vérifie s’il la saisit. Ils répondent à des questions différentes, et c’est en les confondant que naissent les mythes.
7. Conclusion : un meilleur modèle mental
Nous sommes partis de « les types valeur vivent sur la pile, les types référence sur le tas ». C’est simple, facile à retenir et trompeur. Voici ce que les faits permettent d’affirmer à la place.
Trois niveaux, pas une seule règle
- Le système de types de C# définit la sémantique valeur et la sémantique référence : ce qui est copié lors d’une affectation.
- Le modèle de stockage du runtime décide de la représentation des valeurs et des objets : en ligne dans un objet ou un tableau, dans un emplacement de pile ou un registre, ou dans un objet boxé.
- Le compilateur JIT décide quelles opérations survivent dans le code machine, et peut supprimer les allocations inutiles au comportement du programme.
Le mythe de la pile naît de la fusion de ces trois niveaux en une seule règle.
Idées reçues et formulations plus justes
| Idée reçue | Explication plus juste |
|---|---|
| Les types valeur vivent toujours sur la pile. | Les données d’un type valeur sont stockées en ligne dans leur contexte : objets du tas, tableaux, pile ou registres. |
| Un type référence implique toujours une allocation sur le tas. | Les objets sont en général alloués sur le tas quand un objet est nécessaire, mais le JIT peut allouer sur la pile ou supprimer certains objets qui ne s’échappent pas. |
| Toute variable locale occupe de la mémoire de pile. | Le JIT peut utiliser des registres, des emplacements de pile, ou aucun stockage. |
| Un champ struct dans une classe vit sur la pile. | Il est stocké en ligne dans l’objet qui le contient. |
| Le boxing n’est qu’une conversion sans coût mémoire. | Une boîte qui s’échappe a alloué 24 octets dans nos mesures et accroît la pression sur le ramasse-miettes. |
Toute instruction IL box crée un objet sur le tas. | Dans notre expérience .NET 10, une boîte qui ne s’échappe pas n’a laissé aucune trace dans le code natif. |
| Zéro allocation signifie que l’objet a été alloué sur la pile. | L’objet a pu être entièrement supprimé. |
stackalloc explique où vivent les types valeur. | stackalloc est un outil explicite pour des tampons temporaires, pas une règle sur les structs. |
Une liste de contrôle pratique
Quand vous étudiez la mémoire ou les performances en C#, demandez-vous :
- Quelle est la sémantique du type ? Que se passe-t-il quand on l’affecte ou qu’on le passe à une méthode ?
- Qu’est-ce qui contient les données ? Une variable locale, un élément de tableau, un champ d’instance, un objet boxé ?
- L’opération exige-t-elle une allocation ? Des objets sont-ils créés, des valeurs boxées, des collections agrandies ?
- Le JIT peut-il l’optimiser ? L’objet est-il observable en dehors du calcul local ?
- Que montrent les mesures ? Utilisez les diagnostics d’allocation, le profilage et le désassemblage plutôt que des suppositions fondées sur le code source.
Une struct n’est pas automatiquement plus rapide qu’une classe, une classe n’est pas automatiquement inefficace, et éviter toute allocation n’est pas un but en soi. Le bon choix dépend de la sémantique, de la mutabilité, de la taille, des copies, de l’identité, des schémas d’allocation et des exigences réelles de l’application.
Conclusion
Un type valeur peut être stocké dans un objet du tas. Une valeur locale peut n’exister que dans un registre. Une conversion de boxing peut allouer 24 octets ou, dans un cas que le JIT sait optimiser comme celui que nous avons mesuré, ne rien laisser du tout.
La bonne question n’est donc pas « ce type vit-il sur la pile ou sur le tas ? », mais : quelle sémantique ce type a-t-il, quel stockage son contexte exige-t-il, et quelles opérations survivent à l’optimisation du JIT ?
La pile et le tas sont des détails d’implémentation du stockage et de la durée de vie. Les types valeur et les types référence sont des concepts du système de types. Les distinguer est la première étape pour comprendre comment C# et .NET exécutent réellement notre code, et c’est l’exigence que le reste de cette série s’imposera.
Reproduire les expériences
Le projet de benchmarks, son code source et les rapports générés sont publics : github.com/Ayoub-EDAHLOULI/DotNetBoxingBenchmarks. Le projet cible à la fois net9.0 et net10.0 : il faut donc le SDK .NET 10 et le runtime .NET 9.
| Composant | Configuration |
|---|---|
| Système d’exploitation | Windows 10 22H2 (10.0.19045), x64 |
| Processeur | Intel Core i7-6820HQ, 2,70 GHz (Skylake) |
| Cœurs | 4 physiques, 8 logiques |
| SDK .NET | 10.0.403 |
| Runtimes | .NET 9.0.22 et .NET 10.0.14, X64 RyuJIT (x86-64-v3) |
| BenchmarkDotNet | 0.15.8 |
| Ramasse-miettes | Concurrent Workstation |
| Compilation | Release |
git clone https://github.com/Ayoub-EDAHLOULI/DotNetBoxingBenchmarks.git
cd DotNetBoxingBenchmarks
# Tous les benchmarks, une fois par runtime
dotnet run -c Release -f net9.0 -- --filter "*"
dotnet run -c Release -f net10.0 -- --filter "*"
# Uniquement l’expérience JIT (section 6)
dotnet run -c Release -f net10.0 -- --filter "*JitOptimizationBenchmarks*"
Les guillemets doubles fonctionnent dans Bash, PowerShell et l’invite de commandes Windows, et empêchent le shell d’interpréter lui-même le *.
Les expériences 1 et 2 ont été exécutées une fois par runtime ; l’expérience 3, avec désassemblage, uniquement sous .NET 10. BenchmarkDotNet écrit ses exports CSV, HTML et Markdown dans BenchmarkDotNet.Artifacts/results et les écrase à chaque exécution : les exports des deux runtimes ne coexistent donc pas. Les chiffres .NET 9 de cet article proviennent donc du journal de l’exécution .NET 9. Le dossier results du dépôt range les sorties par runtime : net9.0/ contient ce journal, et net10.0/ les exports .NET 10, le désassemblage et les deux journaux .NET 10. Si vous comparez vous-même les runtimes, conservez le fichier BenchmarkDotNet.Artifacts/BenchmarkRun-*.log de chaque exécution ou copiez les exports entre deux exécutions.
Dans cet article, les extraits présentent les déclarations de types avant le code qui les utilise, pour la lisibilité. Dans une application console à instructions de niveau supérieur (top-level statements), C# exige que les instructions viennent en premier : placez donc les déclarations de types en dessous, ou dans des fichiers séparés. Les exemples Unsafe.SizeOf et ObjectLayoutInspector respectent déjà cet ordre.
Les résultats varient selon le matériel, le système d’exploitation, la version du runtime, les décisions du JIT et l’activité en arrière-plan : attendez-vous à des temps absolus différents sur votre machine. Les tailles d’allocation sont bien plus stables que les temps, c’est pourquoi les conclusions de cet article reposent surtout sur les diagnostics d’allocation et le code machine généré. Les méthodes trop courtes déclenchent l’avertissement ZeroMeasurement de BenchmarkDotNet ; ne calculez pas de rapports à partir d’elles.
Une remarque sur l’exactitude technique
Les affirmations de cet article se situent à trois niveaux qu’il vaut la peine de distinguer :
- Les garanties du langage viennent de la spécification C# : la sémantique des types valeur, des types référence, de l’affectation et des conversions.
- Les détails d’implémentation du runtime décrivent comment un runtime .NET donné représente les valeurs et les objets. La disposition des objets, les stratégies d’allocation et les optimisations du JIT varient selon les implémentations et les versions.
- Les observations expérimentales décrivent ce qui s’est passé dans notre environnement de test. Ce sont des preuves concrètes, mais il ne faut pas les généraliser au-delà de ce qu’elles mesurent.
Une spécification dit ce que signifie un programme. Un runtime décide comment ce sens est réalisé. Un benchmark et son désassemblage montrent ce qui s’est réellement passé sur une machine. Si vous repérez une erreur ou obtenez des résultats différents, dites-le-moi : apprendre en public ne fonctionne que si les corrections le sont aussi.
Références et lectures complémentaires
Les ressources ci-dessous sont en anglais.
Spécification du langage C#
- Spécification C# : Types : §8.2 Reference types, §8.3 Value types, §8.3.13 Boxing and unboxing.
- Spécification C# : Conversions : §10.2.9 Boxing conversions, §10.3.7 Unboxing conversions.
Mémoire, boxing et runtime
- Eric Lippert, The Stack Is An Implementation Detail, Part One : l’explication de référence sur les raisons pour lesquelles la règle pile/tas est la mauvaise façon de penser les types valeur.
- Type conversions, casting and boxing (Microsoft Learn).
- Fundamentals of garbage collection (Microsoft Learn).
- System.ValueType (référence de l’API).
- What’s new in the .NET 9 runtime : object stack allocation for boxes.
- What’s new in the .NET 10 runtime : analyse d’échappement et allocation sur la pile des tableaux et des délégués.
- Object stack allocation, document de conception du dépôt dotnet/runtime.
Allocation sur la pile, spans et types byref-like
IL et JIT
- ECMA-335 : Common Language Infrastructure : le jeu d’instructions IL (
box,unbox,unbox.any,constrained.) et le modèle d’exécution. - dotnet/runtime, y compris le code source de RyuJIT. Les implémentations évoluent : rattachez toute conclusion à une version précise du runtime.
Outils
- BenchmarkDotNet et sa documentation des diagnostics (
MemoryDiagnoser,DisassemblyDiagnoser). - Documentation du désassembleur de BenchmarkDotNet.
- SharpLab : affiche le C#, l’IL et l’assembleur JIT générés pour un extrait de code.
- ObjectLayoutInspector : inspecte les décalages des champs, le remplissage et le surcoût des objets.
- DotNetBoxingBenchmarks : les benchmarks et résultats bruts de cet article.
Un avis sur cet article ?
Répondez par e-mail — je lis tout.

Discussion
Commentaires Aucun commentaire
Aucun commentaire pour l’instant. Lancez la discussion.