Java Stack vs. mémoire Heap
Différences entre la stack et le heap Java, ce qui y réside, et le cycle de vie des variables et des objets.
Au moment de l'exécution, la JVM divise la mémoire qu'elle gère en deux régions aux rôles très différents. La stack conserve le suivi des appels de méthodes — un frame par appel, contenant les variables locales et les valeurs primitives de la méthode. Le heap contient chaque objet créé avec new, partagé par l'ensemble du programme et récupéré par le ramasse-miettes. Presque tous les comportements Java déroutants — pourquoi une méthode « ne peut pas modifier » votre int, pourquoi deux variables « voient » la même modification, pourquoi une récursion profonde provoque un crash — découlent directement de cette séparation.
Ce chapitre couvre ce qui réside dans chaque région, comment leurs cycles de vie diffèrent, pourquoi la règle du passage par valeur de Java découle directement de cette séparation, le cas particulier du pool de chaînes, et ce qui se passe quand l'une ou l'autre région vient à manquer. Pour une vue d'ensemble de la façon dont ces régions s'insèrent dans l'environnement d'exécution, consultez Architecture JVM et le modèle mémoire Java.
Deux régions, deux cycles de vie
La stack est par thread et automatique : quand une méthode est appelée, la JVM pousse un frame, et quand la méthode retourne, ce frame est dépilé et ses variables locales disparaissent instantanément. Le heap est partagé et géré : les objets vivent jusqu'à ce qu'aucune référence ne pointe vers eux, après quoi le ramasse-miettes est libre de récupérer l'espace. Rien sur le heap ne disparaît au moment où une méthode retourne.
| Aspect | Stack | Heap |
|---|---|---|
| Contient | Frames : variables locales, primitives, références | Objets, tableaux, champs d'instance |
| Portée | Un par thread | Un partagé par toute la JVM |
| Durée de vie | Frame dépilé au retour de la méthode | Jusqu'à inaccessibilité, puis GC |
| Allocation | Push/pop, extrêmement rapide | new, géré par l'allocateur |
| Taille | Limitée (-Xss) ; le dépassement lève StackOverflowError | Limitée (-Xmx) ; l'épuisement lève OutOfMemoryError |
| Nettoyage | Automatique, déterministe | Ramasse-miettes, non déterministe |
Ce qui se trouve réellement où
Une variable locale réside toujours dans le frame de pile courant. Ce qu'elle contient dépend de son type. Pour une primitive, le frame contient la valeur elle-même. Pour un type objet, le frame ne contient qu'une référence — l'objet vers lequel elle pointe réside sur le heap.
void example() {
int count = 5; // the value 5 sits in the frame (stack)
double rate = 0.5; // likewise on the stack
int[] data = new int[3]; // 'data' (a reference) is on the stack,
// the 3-element array is on the heap
Point p = new Point(1, 2); // 'p' is on the stack, the Point is on the heap
} // frame popped: count, rate, data, p all gone;
// the array and Point survive until GCLes champs d'instance font partie de l'objet, ils résident donc sur le heap avec lui — même un champ de type primitif. Un private int balance à l'intérieur d'un objet Account est de la mémoire heap, pas de la mémoire stack, parce qu'il appartient à l'objet, et non à un appel de méthode particulier.
Java est passage par valeur — toujours
Java copie l'argument dans le paramètre à chaque appel. Pour une primitive, il copie la valeur ; pour un objet, il copie la référence. Il n'y a pas de passage par référence en Java, et cette règle unique (explorée plus en détail dans Paramètres de méthode) explique les trois surprises classiques :
static void bumpPrimitive(int n) { n++; } // changes the copy only
static void mutate(StringBuilder sb) { sb.append("!"); } // edits shared object
static void rebind(StringBuilder sb) { // points the copy elsewhere
sb = new StringBuilder("new"); // caller's variable unchanged
}bumpPrimitive ne peut pas affecter l'appelant : il a reçu une copie du nombre. mutate peut modifier ce que voit l'appelant, car la référence copiée pointe toujours vers l'objet de l'appelant sur le heap. rebind ne le peut pas, car réassigner le paramètre ne modifie que la copie locale de la référence, pas la variable de l'appelant.
Le pool de chaînes, un cas particulier du heap
Les littéraux String sont internés : des littéraux identiques partagent un seul objet dans le pool de chaînes, donc == (identité de référence) renvoie true pour eux. Écrire new String("hi") force la création d'un objet heap distinct, donc == renvoie false même si les caractères correspondent. C'est pourquoi vous comparez les chaînes avec .equals(), qui vérifie le contenu, pas l'identité.
String a = "hi";
String b = "hi";
String c = new String("hi");
a == b; // true — both point at the pooled literal
a == c; // false — c is a distinct heap object
a.equals(c); // true — same charactersQuand les régions viennent à manquer
Chaque région a une limite et son propre mode d'échec. Une récursion non bornée continue d'empiler des frames jusqu'à l'épuisement de la stack, levant StackOverflowError. Allouer des objets plus vite que le GC peut les récupérer épuise le heap, levant OutOfMemoryError. Les deux sont des Errors, pas des Exceptions — signes d'un problème structurel (un cas de base manquant, une fuite) plutôt que quelque chose à intercepter en routine.
static int countDown(int n) {
return countDown(n - 1); // no base case -> StackOverflowError
}Un exemple concret : observer les deux régions en action
Ce programme illustre toutes les règles ci-dessus en une seule exécution : une primitive passée par valeur, deux références vers un même objet heap, une mutation que l'appelant voit, une réassignation qu'il ne voit pas, le pool de chaînes, un dépassement de pile délibéré, et l'abandon de la dernière référence vers un objet.
Ce qu'il faut retenir de l'exécution :
scorereste10tandis que lende la méthode atteint110. La primitive a été copiée dans le nouveau frame, donc rien de ce que la méthode a fait ne pouvait atteindremain— c'est le passage par valeur pour les primitives, rendu visible.a.valueetb.valuevalent tous les deux42eta == besttrue, carCounter b = aa copié une référence, pas l'objet. Une seule instance sur le heap, deux variables de pile pointant dessus — modifiez via l'une et les deux « voient » la modification.- Après
mutateThroughReference(a),a.valuevaut999. La méthode a reçu une copie de la référence, mais la copie pointait toujours vers le même objet heap, donc la modification du champ est visible par l'appelant. - Après
reassignReference(a),a.valuevaut toujours999, pas-1. Réassigner le paramètre n'a modifié que la copie locale de la référence dans la méthode ; leade l'appelant n'a jamais bougé. Muter l'objet fonctionne ; rebinder la variable ne fonctionne pas. lit1 == lit2esttruemaislit1 == objestfalse, tandis que.equalsesttruepour les deux. Les littéraux dans le pool partagent un seul objet heap ;new Stringen force un distinct. LeStackOverflowErrorattrapé-mais-survivable et letemp = nullfinal illustrent les limites des deux régions et comment le heap devient récupérable quand sa dernière référence disparaît.
Exercices pratiques
Chapitres associés
- Architecture JVM — où la stack et le heap se situent dans l'environnement d'exécution.
- Modèle mémoire Java — comment la mémoire se comporte entre les threads.
- Ramasse-miettes — comment les objets heap inaccessibles sont récupérés.
- Paramètres de méthode — le passage par valeur en profondeur.
- Références — comment les variables pointent vers les objets heap.
- Le pool de chaînes — pourquoi les littéraux identiques partagent un seul objet.