Ingenieria de software

Stack vs Heap: los dos tipos de memoria que debes entender

Stack vs Heap
Stack vs Heap

Cuando declaras una variable en un programa, esa variable vive en algún lugar de la memoria RAM. No en cualquier lugar: hay dos regiones muy distintas que tu programa gestiona, cada una con reglas propias. Se llaman stack (pila) y heap (montón). Entender la diferencia es lo que separa a quien programa copiando y pegando de quien sabe explicar por qué un NullPointerException aparece donde aparece, o por qué pasar un objeto a un método "por referencia" puede modificarlo desde fuera.

Este artículo cierra la serie Pensar como la máquina. Después de iteración, recursión y complejidad, ahora toca ver dónde vive cada cosa cuando tu código corre.

El mapa de memoria de un programa

Cuando la JVM (Java) o el engine de PHP arrancan tu programa, piden al sistema operativo un bloque de RAM. Ese bloque se organiza internamente en varias zonas. Las dos que nos importan son:

┌───────────────────────────┐
│           STACK           │  ← crece hacia abajo
│ (frames de función:       │
│  locales, parámetros,     │
│  direcciones de retorno)  │
├───────────────────────────┤
│                           │
│      memoria libre        │
│                           │
├───────────────────────────┤
│                           │
│ (objetos, arrays,         │
│  estructuras dinámicas)   │
│           HEAP            │  ← crece hacia arriba
└───────────────────────────┘

El stack crece desde arriba hacia abajo, el heap desde abajo hacia arriba, y en medio queda espacio libre para que cualquiera de los dos se expanda. Si se encuentran (se quedaron sin memoria entre ambos), el programa crashea.

Stack: rápido, pequeño, ordenado

El stack es una estructura LIFO (Last In, First Out): lo último que entra es lo primero que sale. Cada vez que llamas a una función, el runtime pone un nuevo stack frame en el tope del stack. Ese frame contiene:

  • Los parámetros que pasaste a la función.

  • Las variables locales declaradas dentro de la función.

  • La dirección de retorno: a qué línea del código volver cuando la función termine.

Cuando la función retorna, su frame se desapila y todo su contenido desaparece automáticamente. No hay limpieza manual: el stack "se olvida" de esas variables con solo mover un puntero.

Características:

  • Velocidad extrema: push y pop son dos instrucciones del procesador.

  • Tamaño limitado: por thread, típicamente entre 512 KB y 8 MB. Si lo excedes, StackOverflowError (Java) o el error equivalente en PHP.

  • Vida fija: las variables locales viven exactamente mientras dura la función. Cuando ésta retorna, se van.

  • Acceso secuencial: no puedes saltar al medio del stack; solo puedes tocar el tope.

Qué va al stack en Java

Los tipos primitivos declarados como variables locales: int, long, double, boolean, char, etc. También las referencias a objetos (la "dirección" del objeto, no el objeto en sí).

void ejemplo() {
    int x = 42;              // x vive en el stack
    double ratio = 1.5;      // ratio vive en el stack
    String nombre = "Carlos";// la referencia vive en el stack, el objeto String en el heap
}

Cuando ejemplo() retorna, las tres variables se desapilan. La referencia a "Carlos" desaparece, pero el objeto String en el heap sigue existiendo hasta que el GC lo limpie.

Qué va al stack en PHP

PHP es más dinámico y su modelo es distinto. Internamente, PHP usa una estructura llamada zval que contiene el valor y su tipo, y vive en el heap en la mayoría de los casos. El "stack" en PHP es más bien una pila de tablas de símbolos: cada función tiene su propia tabla de variables, que se apila al llamar y se desapila al retornar.

Conceptualmente el efecto es el mismo —las locales desaparecen al retornar— pero físicamente la implementación difiere del modelo clásico de C/Java.

Heap: dinámico, flexible, costoso

El heap es una región de memoria sin un orden predefinido. Cuando pides un objeto nuevo, el runtime busca un hueco libre lo suficientemente grande y te devuelve su dirección. No hay un "tope" como en el stack; la memoria se reserva y libera en cualquier orden.

Características:

  • Tamaño grande: limitado solo por la RAM disponible (en Java, configurable con -Xmx).

  • Vida arbitraria: los objetos viven mientras alguien los referencie, sin importar en qué función se hayan creado.

  • Acceso indirecto: siempre llegas a un objeto del heap a través de una referencia (puntero).

  • Costoso: reservar memoria en heap requiere buscar un hueco, registrar el objeto con el GC, etc. Más lento que el stack.

  • Gestionado por GC (en Java, PHP, Python, JavaScript) o manualmente (en C, C++). En los primeros, cuando nadie referencia más un objeto, el Garbage Collector lo recoge eventualmente.

Qué va al heap

Todos los objetos y arrays. Sin excepción (en Java).

Persona p = new Persona("Ana");
int[] numeros = new int[1000];
List<String> nombres = new ArrayList<>();

En estas tres líneas:

  • La referencia p, el puntero numeros y la referencia nombres viven en el stack (si es código dentro de una función).

  • El objeto Persona, el array de mil enteros y el ArrayList viven en el heap.

Ese matiz —la referencia en stack, el objeto en heap— es la causa de la mayoría de confusiones sobre "paso por valor" vs "paso por referencia".

Paso de parámetros: el malentendido eterno

En Java, todo se pasa por valor. Lo que confunde es qué se copia: en el caso de un primitivo, se copia el valor numérico; en el caso de un objeto, se copia la referencia. Ambas copias apuntan al mismo objeto del heap.

void mutar(List<Integer> xs) {
    xs.add(99);  // mutamos el objeto al que apunta la referencia
}

List<Integer> original = new ArrayList<>(List.of(1, 2, 3));
mutar(original);
System.out.println(original); // [1, 2, 3, 99]

La función recibió una copia de la referencia, pero esa copia apunta al mismo ArrayList del heap. Al mutarlo desde dentro de la función, el cambio se ve desde fuera.

void reasignar(List<Integer> xs) {
    xs = new ArrayList<>(List.of(999));  // reasignamos la copia local
}

List<Integer> original = new ArrayList<>(List.of(1, 2, 3));
reasignar(original);
System.out.println(original); // [1, 2, 3] — sin cambios

Reasignar la variable dentro de la función solo cambia la copia local de la referencia. El objeto original del heap sigue intacto.

PHP maneja un modelo similar pero con un giro: puedes usar & para declarar que un parámetro se pasa por referencia literal.

function mutar(array &$xs) {   // note el &
    $xs[] = 99;
}

$original = [1, 2, 3];
mutar($original);
print_r($original); // [1, 2, 3, 99]

Sin el &, PHP copia el array (copy-on-write) y las modificaciones no se reflejan fuera.

Garbage Collection: cómo se libera el heap

En lenguajes como Java o PHP, no llamas a free() para liberar memoria. El Garbage Collector (GC) se encarga: periódicamente recorre el heap, identifica qué objetos son inalcanzables desde el stack (no hay ninguna referencia activa hacia ellos), y los libera.

Un objeto es alcanzable si:

  • Alguna variable local en algún stack frame lo referencia, o

  • Un objeto alcanzable lo referencia (la alcanzabilidad es transitiva).

Si todas las referencias mueren, el objeto se vuelve "basura" y el GC lo recogerá.

void ejemplo() {
    Persona p = new Persona("Ana");  // objeto alcanzable desde p
    p = null;                         // objeto ahora inalcanzable → GC
}

En la práctica, el GC no corre inmediatamente cuando una referencia muere. Tiene heurísticas para cuándo limpiar (generacional en Java, reference counting en PHP, mark-and-sweep en ambos en algún momento). Tú solo te encargas de dejar de referenciar; el runtime se encarga del resto.

Tipos de memoria y performance

La diferencia de velocidad entre stack y heap es enorme:

  • Acceso a stack: un puntero al frame actual + un offset → una instrucción.

  • Acceso a heap: seguir una referencia, posible fallo de cache, posible indirect lookup.

  • Allocación en stack: decrementar el stack pointer.

  • Allocación en heap: buscar hueco libre, marcarlo, registrar en GC.

En código crítico de performance, evitar allocaciones en heap es una técnica común. En Java esto se traduce en reutilizar objetos, usar StringBuilder en lugar de concatenar String, evitar autoboxing de primitivos, etc.

Errores comunes

1. StackOverflowError / stack overflow

Causa típica: recursión profunda sin caso base, o caso base que nunca se alcanza. Como vimos en el artículo de recursión, cada llamada apila un frame y el stack es limitado.

También puede pasar por un array gigante declarado como variable local en C (en Java los arrays ya van al heap, así que esto no aplica).

2. OutOfMemoryError: Java heap space

El heap se llenó. Causas típicas: fuga de memoria (mantienes referencias a objetos que ya no necesitas), cargar un archivo enorme en memoria, crear un número desbordante de objetos por iteración.

Se resuelve identificando qué está ocupando el heap —con profilers como VisualVM o YourKit— y eliminando las referencias innecesarias.

3. Memory leaks en lenguajes con GC

Aunque el GC limpia objetos inalcanzables, si mantienes referencias innecesarias (por ejemplo, en un cache que nunca limpias, o en un listener que nunca se desregistra), esos objetos nunca se vuelven basura. Clásico en aplicaciones long-running: servidores web, procesos de backend.

4. Suponer que un objeto "muere" cuando sale del scope

Falso. Lo que muere al salir del scope es la referencia local. El objeto del heap solo muere cuando nadie lo referencia. Si lo guardaste en una estructura de fuera (una lista estática, un cache), sigue vivo.

static List<Persona> registro = new ArrayList<>();

void agregar() {
    Persona p = new Persona("Ana");
    registro.add(p);
    // al salir de agregar(), p (la referencia local) muere
    // pero el objeto Persona sigue vivo mientras registro lo contenga
}

5. NullPointerException

No es estrictamente un bug de memoria, pero está relacionado. Ocurre cuando intentas usar una referencia que no apunta a ningún objeto. La referencia vive en el stack pero tiene el valor especial null; al dereferenciarla, el runtime tira la excepción.

String nombre = null;
nombre.length(); // NullPointerException

Usos prácticos de entender esto

Saber stack vs heap te ayuda a:

  • Debuggear stack overflows reconociendo que son casi siempre recursión mal terminada.

  • Diseñar APIs: decidir si un método recibe datos por valor (copia) o por referencia (puede mutar), y documentarlo explícitamente.

  • Razonar sobre concurrencia: dos threads nunca comparten stack, pero sí comparten heap. Todos los problemas de thread safety tienen que ver con el heap.

  • Optimizar performance: reducir allocaciones en heap cuando el hot path lo requiere.

  • Interpretar stack traces: leer una excepción y entender qué frames estaban activos cuando ocurrió.

Resumen

El stack es memoria LIFO rápida y limitada donde viven variables locales, parámetros y direcciones de retorno de cada función. Los frames se apilan al llamar y se desapilan al retornar.

El heap es memoria dinámica de gran tamaño donde viven todos los objetos y arrays. Se accede por referencia desde el stack, y el Garbage Collector libera los objetos inalcanzables.

En Java y PHP todo se pasa "por valor", pero en el caso de objetos el valor que se copia es la referencia, así que ambos lados terminan apuntando al mismo objeto del heap —y las mutaciones se ven desde fuera—.

Con esto cierra la serie Pensar como la máquina. Tienes ya las herramientas mentales para afrontar la Serie: Por dentro de las estructuras, donde arrancamos con arrays, listas enlazadas y el resto de las estructuras de datos clásicas, implementadas en PHP y Java.

Comentarios (0)

Sé el primero en comentar.

Deja un comentario

Protegido con reCAPTCHA — Privacidad · Términos

Historias relacionadas