W3docs

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.

AspectoStackHeap
ContieneFrames: variables locales, primitivos, referenciasObjetos, arrays, campos de instancia
ÁmbitoUno por hiloUno compartido por toda la JVM
Tiempo de vidaFrame extraído al retornar el métodoHasta ser inalcanzable, luego GC
AsignaciónPush/pop, extremadamente rápidonew, gestionado por el asignador
TamañoLimitado (-Xss); el desbordamiento lanza StackOverflowErrorLimitado (-Xmx); el agotamiento lanza OutOfMemoryError
LimpiezaAutomática, deterministaRecolector 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 GC

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

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

java— editable, runs on the server

Qué extraer de la ejecución:

  • score permanece en 10 mientras el n del método llega a 110. El primitivo fue copiado en el nuevo frame, por lo que nada de lo que hizo el método podía alcanzar de vuelta a main — esto es el paso por valor para primitivos, hecho visible.
  • a.value y b.value son ambos 42 y a == b es true, porque Counter b = a copió 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.value es 999. 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.value sigue siendo 999, no -1. Reasignar el parámetro solo cambió la copia local de la referencia del método; la a del llamador nunca se movió. Mutar el objeto funciona; reenlazar la variable no.
  • lit1 == lit2 es true pero lit1 == obj es false, mientras que .equals es true para ambos. Los literales agrupados comparten un objeto en el heap; new String fuerza uno distinto. El StackOverflowError capturado-pero-sobrevivido y el temp = null final muestran los límites de las dos regiones y cómo el heap se vuelve recolectable cuando se pierde su última referencia.

Práctica

Práctica
Un método recibe un parámetro de tipo StringBuilder. Dentro del método llamas a sb.append('x'). Después de que el método retorna, el StringBuilder del llamador muestra la 'x' añadida. ¿Por qué?
Un método recibe un parámetro de tipo StringBuilder. Dentro del método llamas a sb.append('x'). Después de que el método retorna, el StringBuilder del llamador muestra la 'x' añadida. ¿Por qué?

Capítulos relacionados

Was this page helpful?