1. Bases de la mémoire : Stack et Heap (Stack & Heap)
Quand votre programme démarre, il obtient de la mémoire. Dans le contexte .NET (CLR — Common Language Runtime), cette mémoire est gérée automatiquement et se divise en deux zones principales : le stack et le heap. Comprendre où et comment les données sont stockées est crucial pour écrire du code efficace et stable.
Stack
Imaginez le stack comme une pile d'assiettes : vous posez toujours une nouvelle assiette sur le dessus et vous prenez aussi du dessus. C'est le principe LIFO (Last In, First Out) — « dernier entré, premier sorti ». Le stack est très rapide, mais limité en taille.
Ce qui est stocké sur le stack :
- Types value : les valeurs elles-mêmes des variables déclarées directement.
- Références vers des objets : pour les reference types, le stack contient seulement l'adresse vers l'objet dans le heap.
- Paramètres de méthodes : les valeurs passées aux fonctions.
- Variables locales : variables déclarées à l'intérieur des méthodes.
- Adresses de retour : où revenir après l'exécution de la méthode.
La mémoire sur le stack est allouée et libérée automatiquement et très rapidement, dès que la méthode correspondante se termine ou que la variable sort de la portée.
Exemple : Types value sur le stack
void MyMethod()
{
int a = 10; // 'a' et la valeur 10 - sur le stack
bool flag = true; // 'flag' et la valeur true - sur le stack
char initial = 'Z'; // 'initial' et la valeur 'Z' - sur le stack
// ...
} // Quand MyMethod se termine, 'a', 'flag', 'initial' sont retirés du stack.
Exemple : Références sur le stack
class MyObject { }
void AnotherMethod()
{
MyObject objRef; // 'objRef' (référence) - sur le stack. L'objet lui-même n'est pas encore créé.
// ...
} // Quand AnotherMethod se termine, 'objRef' (référence) est retirée du stack.
Heap
Le heap est une zone mémoire bien plus grande et plus flexible. Il n'y a pas d'ordre LIFO strict ; les données peuvent être placées n'importe où. Le heap est utilisé pour des objets plus gros et/ou de durée de vie plus longue.
Ce qui est stocké dans le heap :
- Objets des reference types : données des classes, tableaux, strings — créés via new.
- Types value imbriqués : si un type value (struct) est un champ d'un reference type (class), il est stocké à l'intérieur de l'objet dans le heap.
La gestion de la mémoire dans le heap est automatique grâce au GC (Garbage Collector).
Exemple : Objets de classes dans le heap
class Person { public string Name; public int Age; }
void CreatePerson()
{
Person p = new Person(); // L'objet Person - dans le heap. 'p' (référence) - sur le stack.
p.Name = "Alice"; // La string "Alice" (aussi un objet) - dans le heap.
p.Age = 30; // 30 (int, type value) - à l'intérieur de l'objet Person dans le heap.
// ...
} // Quand CreatePerson se termine, 'p' (référence) est retirée du stack.
// L'objet Person dans le heap devient inatteignable et candidat au garbage collection.
Exemple : Tableaux dans le heap
void ProcessArray()
{
int[] numbers = new int[5]; // Tableau de 5 int - dans le heap. 'numbers' (référence) - sur le stack.
numbers[0] = 10; // L'élément 10 - fait partie du tableau dans le heap.
// ...
} // Le tableau sera nettoyé par le GC lorsqu'il n'y aura plus de références vers lui.
2. Garbage Collector (Garbage Collection)
La gestion de la mémoire dans le heap se fait automatiquement grâce au GC. C'est une des fonctionnalités clés de .NET qui évite la libération manuelle de la mémoire (source typique de fuites dans les langages sans GC).
But et principe de fonctionnement
Le but principal du GC est de libérer automatiquement la mémoire occupée par des objets dans le heap quand ils ne sont plus utilisés par le programme.
- Création d'un objet : lors d'un new, le CLR alloue de la place dans le heap.
- Suivi des références : le GC suit les références actives ; un objet est « reachable » s'il est référencé depuis le stack, un champ statique ou un autre objet reachable.
- Détermination du « garbage » : s'il n'y a pas de références — l'objet est « unreachable » et considéré comme garbage.
- Collecte : si nécessaire, le GC marque les objets reachables et libère la mémoire des objets unreachable.
- Compaction : pour réduire la fragmentation, les objets restants peuvent être déplacés, formant des zones contiguës.
Générations dans le GC (Generational GC)
Le GC utilise des générations parce que la plupart des objets « meurent jeunes » :
- Génération 0 (Gen 0) : nouveaux objets ; collectes fréquentes et rapides.
- Génération 1 (Gen 1) : objets survivants de Gen 0 ; collectes moins fréquentes et plus longues.
- Génération 2 (Gen 2) : objets longue durée ; collectes les plus rares et les plus coûteuses.
Quand le GC se lance ?
- Manque d'espace libre pour une nouvelle allocation.
- Vérifications périodiques durant les périodes d'inactivité.
- Appel explicite (généralement non recommandé) à GC.Collect().
Performance du GC
Le déclenchement du GC peut causer des pauses courtes, car l'exécution du code utilisateur est suspendue. La création excessive d'objets à courte durée de vie dans le heap provoque des collectes plus fréquentes et dégrade les performances.
Exemple : Un objet devient inatteignable
class DataBlock { public byte[] Data; public DataBlock() => Data = new byte[1024 * 1024]; } // 1 Mo de données
void AllocateAndLose()
{
DataBlock block1 = new DataBlock(); // L'objet block1 dans le heap, la référence 'block1' sur le stack
// ... bloc de code ...
block1 = null; // Maintenant il n'y a plus de références actives vers l'objet DataBlock. Il est devenu inatteignable.
// À ce stade le GC N'A PAS ENCORE forcément supprimé l'objet, mais il est candidat à la suppression.
// Appel de GC.Collect() ici (juste pour la démo, ne faites pas ça en production !)
Console.WriteLine("Calling GC.Collect()");
GC.Collect(); // Le GC peut (mais pas garanti) libérer la mémoire maintenant
}
AllocateAndLose();
// Quand AllocateAndLose se termine, la référence 'block1' est retirée du stack,
// et l'objet DataBlock dans le heap devient inatteignable, susceptible d'être collecté.
3. Gestion des ressources non managées
Le GC gère la mémoire « managée » de .NET. Mais il existe des ressources non managées qui sont hors du contrôle du GC et nécessitent une libération explicite :
- Descripteurs de fichiers (fichiers ouverts).
- Sockets réseau.
- Descripteurs de fenêtres/objets graphiques (par ex. GDI+).
- Mémoire allouée par l'OS (par ex. via P/Invoke).
Finalizers (Destructeurs)
Le destructeur est une méthode spéciale qui effectue un nettoyage final des ressources non managées avant que l'objet soit retiré par le GC. La syntaxe ressemble au constructeur, mais avec une tilde :
class MyClassWithFinalizer
{
~MyClassWithFinalizer() { /* Libération des ressources non managées */ }
}
Le GC appelle les finalizers de façon imprévisible, avec un délai, sur un thread séparé de finalization. Inconvénients : imprévisibilité, overhead (les objets avec finalizer nécessitent deux passes). Donc les finalizers sont une « assurance », pas le mécanisme principal de nettoyage.
Exemple : Destructeur comme "assurance"
class UnmanagedResourceHolder
{
private bool _resourceReleased = false;
// Simulation d'une ressource non managée
public UnmanagedResourceHolder() => Console.WriteLine("Ressource créée.");
public void ReleaseResource()
{
if (!_resourceReleased)
{
Console.WriteLine("Ressource LIBÉRÉE explicitement.");
_resourceReleased = true;
}
}
// Destructeur (Finalizer) - appelé par le GC en dernier recours
~UnmanagedResourceHolder()
{
Console.WriteLine("Destructeur APPELÉ (la ressource N'A PAS été libérée explicitement).");
ReleaseResource(); // Tentative de libération de la ressource
}
}
Interface IDisposable
IDisposable est le mécanisme préféré pour libérer explicitement les ressources. Contient une seule méthode : void Dispose(). On utilise souvent un pattern où Dispose() est réentrable et supprime le finalizer via GC.SuppressFinalize(this).
Exemple : Implémentation de IDisposable
using System.IO;
class MyFileWriter : IDisposable
{
private StreamWriter _writer;
private bool _disposed = false;
public MyFileWriter(string path)
{
_writer = new StreamWriter(path, true);
Console.WriteLine($"Fichier '{path}' ouvert.");
}
public void WriteLog(string message) => _writer.WriteLine(message);
// Implémentation de IDisposable
public void Dispose()
{
Dispose(true); // Appel de la logique principale de Dispose
GC.SuppressFinalize(this); // On dit au GC que le finalizer n'est plus nécessaire
_disposed = true;
}
// Méthode protégée virtuelle pour le pattern Dispose
protected virtual void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing)
{
// Libérer les ressources managées
_writer?.Dispose();
}
// Libérer les ressources non managées (si présentes)
Console.WriteLine("Fichier FERMÉ via Dispose.");
}
}
// Destructeur optionnel comme "assurance"
~MyFileWriter()
{
Console.WriteLine("Fichier FERMÉ via le destructeur (Dispose() n'a pas été appelé).");
Dispose(false); // Appel de Dispose en indiquant que c'est un appel non explicite
}
}
Opérateur using
L'opérateur using est du sucre syntaxique qui garantit l'appel de Dispose() quand l'objet sort de la portée, même en cas d'exception. C'est la manière recommandée de travailler avec des objets implémentant IDisposable.
Exemple : Nettoyage automatique avec using
// Utilisation de MyFileWriter de l'exemple ci-dessus
void ProcessFile(string fileName)
{
// L'objet MyFileWriter sera automatiquement "disposé" après le bloc using
using (var writer = new MyFileWriter(fileName))
{
writer.WriteLog("Première ligne.");
writer.WriteLog("Deuxième ligne.");
// Même si une exception survient ici, Dispose() sera quand même appelé
} // Ici writer.Dispose() est appelé automatiquement
Console.WriteLine("Bloc using terminé, fichier fermé.");
}
ProcessFile("testlog.txt");
// Dans ce cas le destructeur MyFileWriter ne sera pas appelé, puisque Dispose() a été appelé explicitement.
Comprendre ces concepts est la clé pour écrire des applications C# performantes, fiables et évolutives.
GO TO FULL VERSION