Java Stack vs. Heap: Memoria
Diferencias entre el stack y el heap de Java, qué almacena cada uno y el ciclo de vida de variables y objetos.
En tiempo de ejecución, la JVM divide la memoria que gestiona en dos regiones con funciones muy distintas. El stack almacena la información de seguimiento de las llamadas a métodos — un frame por llamada, con las variables locales del método y los valores primitivos dentro de él. El heap almacena todos los objetos que se crean con new, compartidos por todo el programa y reclamados por el recolector de basura. Casi todo comportamiento confuso de Java — por qué un método "no puede cambiar" tu int, por qué dos variables "ven" la misma edición, por qué una recursión profunda falla — proviene directamente de esta división.
Este capítulo cubre qué vive en cada región, cómo difieren sus ciclos de vida, por qué la regla de paso por valor de Java se deriva directamente de la división, el caso especial del string pool, y qué ocurre cuando alguna región se agota. Para el panorama general de cómo estas regiones encajan en el entorno de ejecución, consulta Arquitectura de la JVM y el Modelo de Memoria de Java.
Dos regiones, dos ciclos de vida
El stack es por hilo y automático: cuando se invoca un método, la JVM empuja un frame, y cuando el método retorna ese frame se extrae y sus variables locales desaparecen al instante. El heap es compartido y gestionado: los objetos viven hasta que ninguna referencia apunta a ellos, momento en el que el recolector de basura puede reclamar el espacio. Nada en el heap desaparece en el momento en que un método retorna.
| Aspecto | Stack | Heap |
|---|---|---|
| Contiene | Frames: variables locales, primitivos, referencias | Objetos, arrays, campos de instancia |
| Ámbito | Uno por hilo | Uno compartido por toda la JVM |
| Tiempo de vida | Frame extraído al retornar el método | Hasta ser inalcanzable, luego GC |
| Asignación | Push/pop, extremadamente rápido | new, gestionado por el asignador |
| Tamaño | Limitado (-Xss); el desbordamiento lanza StackOverflowError | Limitado (-Xmx); el agotamiento lanza OutOfMemoryError |
| Limpieza | Automática, determinista | Recolector de basura, no determinista |
Qué se ubica realmente en cada lugar
Una variable local siempre vive en el frame del stack actual. Lo que almacena depende de su tipo. Para un primitivo, el frame contiene el valor en sí. Para un tipo objeto, el frame solo contiene una referencia — el objeto al que apunta vive en el 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 GCLos campos de instancia son parte del objeto, por lo que viven en el heap junto con él — incluso un campo de tipo primitivo. Un private int balance dentro de un objeto Account es memoria del heap, no del stack, porque pertenece al objeto, no a ninguna llamada de método en particular.
Java es paso por valor — siempre
Java copia el argumento en el parámetro en cada llamada. Para un primitivo, copia el valor; para un objeto, copia la referencia. No existe el paso por referencia en Java, y esa única regla (explorada con mayor detalle en Parámetros de Método) explica las tres sorpresas clásicas:
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 no puede afectar al llamador: recibió una copia del número. mutate puede cambiar lo que ve el llamador, porque la referencia copiada sigue apuntando al objeto del llamador en el heap. rebind no puede, porque reasignar el parámetro solo cambia la copia local de la referencia, no la variable del llamador.
El string pool, un caso especial del heap
Los literales de String son internados: los literales idénticos comparten un objeto en el string pool, por lo que == (identidad de referencia) devuelve true para ellos. Escribir new String("hi") fuerza un objeto separado en el heap, por lo que == devuelve false aunque los caracteres coincidan. Por eso se comparan strings con .equals(), que verifica el contenido, no la identidad.
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 charactersCuando las regiones se agotan
Cada región tiene un límite y su propio modo de fallo. La recursión sin límite continúa empujando frames hasta que el stack se agota, lanzando StackOverflowError. Asignar objetos más rápido de lo que el GC puede reclamarlos agota el heap, lanzando OutOfMemoryError. Ambos son Errors, no Exceptions — señales de un problema estructural (un caso base faltante, una fuga) en lugar de algo que se deba capturar habitualmente.
static int countDown(int n) {
return countDown(n - 1); // no base case -> StackOverflowError
}Un ejemplo elaborado: observar el comportamiento de las dos regiones
Este programa toca todas las reglas anteriores en una sola ejecución: un primitivo pasado por valor, dos referencias a un mismo objeto en el heap, una mutación que el llamador ve, una reasignación que no ve, el string pool, un desbordamiento deliberado del stack y el abandono de la última referencia a un objeto.
Qué extraer de la ejecución:
scorepermanece en10mientras elndel método llega a110. El primitivo fue copiado en el nuevo frame, por lo que nada de lo que hizo el método podía alcanzar de vuelta amain— esto es el paso por valor para primitivos, hecho visible.a.valueyb.valueson ambos42ya == bestrue, porqueCounter b = acopió una referencia, no el objeto. Una instancia en el heap, dos variables en el stack apuntando a ella — edita a través de cualquiera y ambas "ven" el cambio.- Después de
mutateThroughReference(a),a.valuees999. El método recibió una copia de la referencia, pero la copia seguía apuntando al mismo objeto en el heap, por lo que el cambio del campo es visible para el llamador. - Después de
reassignReference(a),a.valuesigue siendo999, no-1. Reasignar el parámetro solo cambió la copia local de la referencia del método; laadel llamador nunca se movió. Mutar el objeto funciona; reenlazar la variable no. lit1 == lit2estrueperolit1 == objesfalse, mientras que.equalsestruepara ambos. Los literales agrupados comparten un objeto en el heap;new Stringfuerza uno distinto. ElStackOverflowErrorcapturado-pero-sobrevivido y eltemp = nullfinal muestran los límites de las dos regiones y cómo el heap se vuelve recolectable cuando se pierde su última referencia.
Práctica
Capítulos relacionados
- Arquitectura de la JVM — dónde se encuentran el stack y el heap dentro del entorno de ejecución.
- Modelo de Memoria de Java — cómo se comporta la memoria entre hilos.
- Recolección de Basura — cómo se reclaman los objetos inalcanzables del heap.
- Parámetros de Método — el paso por valor en profundidad.
- Referencias — cómo las variables apuntan a objetos en el heap.
- El String Pool — por qué los literales idénticos comparten un solo objeto.