W3docs

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.

AspectStackHeap
ContientFrames : variables locales, primitives, référencesObjets, tableaux, champs d'instance
PortéeUn par threadUn partagé par toute la JVM
Durée de vieFrame dépilé au retour de la méthodeJusqu'à inaccessibilité, puis GC
AllocationPush/pop, extrêmement rapidenew, géré par l'allocateur
TailleLimitée (-Xss) ; le dépassement lève StackOverflowErrorLimitée (-Xmx) ; l'épuisement lève OutOfMemoryError
NettoyageAutomatique, déterministeRamasse-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 GC

Les 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 characters

Quand 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.

java— editable, runs on the server

Ce qu'il faut retenir de l'exécution :

  • score reste 10 tandis que le n de la méthode atteint 110. La primitive a été copiée dans le nouveau frame, donc rien de ce que la méthode a fait ne pouvait atteindre main — c'est le passage par valeur pour les primitives, rendu visible.
  • a.value et b.value valent tous les deux 42 et a == b est true, car Counter b = a a 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.value vaut 999. 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.value vaut toujours 999, pas -1. Réassigner le paramètre n'a modifié que la copie locale de la référence dans la méthode ; le a de l'appelant n'a jamais bougé. Muter l'objet fonctionne ; rebinder la variable ne fonctionne pas.
  • lit1 == lit2 est true mais lit1 == obj est false, tandis que .equals est true pour les deux. Les littéraux dans le pool partagent un seul objet heap ; new String en force un distinct. Le StackOverflowError attrapé-mais-survivable et le temp = null final 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

Pratique
Une méthode reçoit un paramètre de type StringBuilder. À l'intérieur de la méthode, vous appelez sb.append('x'). Après le retour de la méthode, le StringBuilder de l'appelant affiche le 'x' ajouté. Pourquoi ?
Une méthode reçoit un paramètre de type StringBuilder. À l'intérieur de la méthode, vous appelez sb.append('x'). Après le retour de la méthode, le StringBuilder de l'appelant affiche le 'x' ajouté. Pourquoi ?

Chapitres associés

Was this page helpful?