CodeGym /Cursos /JAVA 25 SELF /Variables locales, fugas de memoria y referencias débiles...

Variables locales, fugas de memoria y referencias débiles

JAVA 25 SELF
Nivel 64 , Lección 2
Disponible

1. Dónde se almacenan las variables locales

Empecemos por lo más sencillo: las variables locales. Son variables que se declaran dentro de un método y existen solo durante su ejecución. Su vida es corta y dramática: en cuanto el método termina, todas sus variables locales desaparecen sin dejar rastro.

En Java, las variables locales se almacenan en la pila (stack). Cada hilo tiene su propia pila. Si un método invoca a otro, se añade a la pila un nuevo «frame» (stack frame) con las variables locales y la dirección de retorno. Cuando el método termina, su frame se retira de la pila.

Ejemplo: vida de una variable local

public class LocalVariableDemo {
    public static void main(String[] args) {
        int a = 42; // la variable local a vive solo en main
        printSquare(a);
        // ¡Aquí la variable b ya no existe!
    }

    public static void printSquare(int b) {
        int square = b * b; // variable local square
        System.out.println("Cuadrado: " + square);
        // Tras salir de printSquare todas las variables locales desaparecen
    }
}

Punto importante: si una variable local es una referencia a un objeto (por ejemplo, String, Scanner, un array), entonces la referencia vive en la pila, pero el objeto está en el heap. Cuando la referencia desaparece y no hay otras referencias al objeto, el recolector de basura puede eliminarlo.

Ilustración

Stack (para main):
| int a = 42      |
| args            |
-------------------
Heap:
| [objetos creados con new] |

2. Fugas de memoria en Java: ¿mito o realidad?

Muchos principiantes piensan: «¡En Java hay recolector de basura! ¡Entonces no puede haber fugas de memoria!». Por desgracia, es un mito que se desvanece en el primer proyecto grande.

El recolector de basura elimina solo aquellos objetos a los que no apunta ninguna referencia viva. Si en algún lugar quedó una referencia (aunque sea en el lugar más inesperado), el objeto permanecerá en memoria hasta el final. O hasta un OutOfMemoryError.

Ejemplo 1: Colección estática trampa

import java.util.ArrayList;
import java.util.List;

public class MemoryLeakDemo {
    // Ay, esta colección estática
    private static final List<String> BIG_LIST = new ArrayList<>();

    public static void main(String[] args) {
        for (int i = 0; i < 1_000_000; i++) {
            BIG_LIST.add("Cadena número " + i);
        }
        System.out.println("Se han añadido un millón de cadenas");
        // Incluso si main termina, BIG_LIST permanecerá en memoria mientras viva la JVM
    }
}

¿Qué ocurre?

  • La variable estática BIG_LIST vive tanto como el propio class (normalmente, hasta el final de la vida de la JVM).
  • Todas las cadenas añadidas a la lista no pueden ser eliminadas por el recolector de basura: siempre hay una referencia a ellas a través de BIG_LIST.
  • Si olvidas limpiar colecciones de este tipo, tendrás una fuga de memoria.

Ejemplo 2: Listeners no desregistrados (listeners)

import java.util.ArrayList;
import java.util.List;

class EventSource {
    private final List<Runnable> listeners = new ArrayList<>();

    public void addListener(Runnable listener) {
        listeners.add(listener);
    }

    // ... otros métodos ...
}

public class ListenerLeakDemo {
    public static void main(String[] args) {
        EventSource source = new EventSource();
        Runnable listener = () -> System.out.println("¡Evento!");
        source.addListener(listener);
        // Si se olvida llamar a source.removeListener(listener), ¡el listener permanecerá en memoria para siempre!
    }
}

Problema: si el listener ya no es necesario pero no se elimina de la lista, él y todos los objetos a los que hace referencia permanecerán en memoria.

Ejemplo 3: Una caché que nunca se vacía

import java.util.HashMap;
import java.util.Map;

public class CacheLeakDemo {
    private static final Map<String, byte[]> CACHE = new HashMap<>();

    public static void main(String[] args) {
        for (int i = 0; i < 1_000_000; i++) {
            // Cada vez creamos un array de 1 KB
            CACHE.put("key" + i, new byte[1024]);
        }
        System.out.println("Se han añadido un millón de elementos a la caché");
        // La caché crece, la memoria se acaba, ¡OutOfMemoryError!
    }
}

Conclusión: ¡incluso con GC es fácil obtener una fuga de memoria si no controlas el ciclo de vida de los objetos!

3. Referencias débiles (WeakReference) y compañía

A veces necesitamos cachés o colecciones donde los objetos puedan ser eliminados por el recolector de basura si ya nadie los referencia. Para ello se inventaron las referencias débiles (WeakReference).

Referencias normales (strong)

String s = new String("hello"); // referencia strong

El objeto s vivirá en memoria mientras exista al menos una referencia strong.

Referencia débil (WeakReference)

import java.lang.ref.WeakReference;

public class WeakRefDemo {
    public static void main(String[] args) {
        String strong = new String("¡Hola, mundo!");
        WeakReference<String> weak = new WeakReference<>(strong);

        System.out.println("Antes de la limpieza: " + weak.get()); // hay referencia

        strong = null; // eliminamos la referencia strong

        System.gc(); // pedimos al GC que limpie memoria (¡no garantizado!)

        // Al cabo de un tiempo, weak.get() puede volverse null
        System.out.println("Después del GC: " + weak.get());
    }
}

¿Cómo funciona?

  • Mientras exista al menos una referencia strong al objeto, el GC no lo eliminará.
  • Si solo quedan referencias débiles, el objeto puede eliminarse en la siguiente pasada del recolector.
  • El método weak.get() devuelve el objeto si todavía está vivo, o null si ya ha sido eliminado.

¿Dónde se usan las referencias débiles?

El uso principal son las cachés, donde no es crítico si el objeto se elimina de memoria. Por ejemplo, si cacheas imágenes pero no quieres que la caché ocupe toda la memoria.

Ejemplo: WeakHashMap

WeakHashMap es una colección donde las claves se almacenan mediante referencias débiles. Si ya nadie referencia la clave, el par correspondiente se elimina del mapa.

import java.util.Map;
import java.util.WeakHashMap;

public class WeakHashMapDemo {
    public static void main(String[] args) {
        Map<Object, String> map = new WeakHashMap<>();
        Object key = new Object();
        map.put(key, "Valor");

        System.out.println("Antes de la limpieza: " + map);

        key = null; // eliminamos la referencia strong a la clave

        System.gc(); // pedimos al GC que trabaje

        // ¡Al cabo de un tiempo el mapa se quedará vacío!
        try { Thread.sleep(100); } catch (InterruptedException ignored) {}
        System.out.println("Después del GC: " + map);
    }
}

Atención: WeakHashMap funciona solo para claves: los valores se almacenan con referencias strong normales.

Soft, Weak, Phantom: toda la familia de referencias

En Java hay cuatro tipos de referencias (ordenadas por «fuerza»):

Tipo de referencia ¿Cuándo elimina el GC el objeto? ¿Dónde se aplica?
Strong
Solo cuando no queda ninguna referencia strong Variables normales, colecciones
Soft
Cuando falta memoria Caché que conviene mantener más tiempo
Weak
En el siguiente ciclo del GC, si no hay referencias strong Cachés, WeakHashMap, listeners
Phantom
Tras la finalización, para seguir la eliminación del objeto Tareas especiales, limpieza fuera del heap
  • SoftReference — el objeto se elimina cuando falta memoria (apto para cachés de imágenes y otros).
  • WeakReference — se elimina en la primera recolección si no hay otras referencias.
  • PhantomReference — el tipo más «fantasmal», necesario para escenarios complejos; los principiantes rara vez lo usan.

4. Práctica: ejemplo de fuga de memoria y su corrección

Ejemplo de fuga: lista estática

import java.util.ArrayList;
import java.util.List;

public class LeakExample {
    private static final List<byte[]> list = new ArrayList<>();

    public static void main(String[] args) {
        for (int i = 0; i < 100_000; i++) {
            list.add(new byte[1024 * 1024]); // 1 MB
            if (i % 10 == 0) System.out.println("Añadidos " + i + " MB");
        }
    }
}

¿Qué ocurrirá?
El programa consumirá rápidamente toda la memoria disponible y fallará con un OutOfMemoryError, porque la lista estática guarda referencias a todos los arrays creados.

Corrección: usar referencias débiles

Si no es crítico que todos los objetos estén siempre disponibles, puedes almacenarlos mediante referencias débiles:

import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.List;

public class LeakFixed {
    private static final List<WeakReference<byte[]>> list = new ArrayList<>();

    public static void main(String[] args) {
        for (int i = 0; i < 100_000; i++) {
            list.add(new WeakReference<>(new byte[1024 * 1024]));
            if (i % 10 == 0) System.out.println("Añadidos " + i + " MB");
            System.gc(); // Sugerencia al GC (¡no garantiza limpieza inmediata!)
        }
    }
}

Ahora los arrays pueden ser eliminados por el GC si ya nadie los referencia: la lista solo almacena referencias débiles.

5. Escenarios típicos de fugas de memoria

  • Listeners de eventos: si olvidas eliminar el listener, el objeto vive para siempre.
  • Colecciones estáticas: cachés que no se limpian, listas globales: todo ello puede provocar fugas.
  • Clases internas y lambdas: si una clase interna o una lambda captura una referencia al objeto externo, no se eliminará mientras viva el objeto externo.

Ejemplo con clase interna

public class Outer {
    private byte[] bigArray = new byte[1024 * 1024 * 100]; // 100 MB

    public Runnable createTask() {
        // ¡La clase interna anónima captura una referencia a Outer!
        return new Runnable() {
            @Override
            public void run() {
                System.out.println("La tarea se está ejecutando");
            }
        };
    }

    public static void main(String[] args) {
        Outer outer = new Outer();
        Runnable task = outer.createTask();
        // ¡Incluso si outer = null, task seguirá manteniendo una referencia a bigArray!
    }
}

Solución: utiliza clases internas estáticas o extrae la lógica a clases independientes para no mantener referencias innecesarias.

6. Práctica: uso de referencias débiles en una caché

Añadamos a la aplicación de ejemplo una caché sencilla usando WeakHashMap.

import java.util.Map;
import java.util.WeakHashMap;

public class ImageCache {
    private final Map<String, byte[]> cache = new WeakHashMap<>();

    public void put(String name, byte[] data) {
        cache.put(name, data);
    }

    public byte[] get(String name) {
        return cache.get(name);
    }

    public static void main(String[] args) {
        ImageCache cache = new ImageCache();
        cache.put("cat", new byte[1024 * 1024]); // 1 MB
        System.out.println("Gatito añadido a la caché");

        // Si ya no hay referencias a la clave "cat", el objeto puede ser eliminado por el GC
    }
}

En aplicaciones reales (por ejemplo, en bibliotecas de imágenes) las referencias débiles ayudan a evitar el desbordamiento de memoria eliminando automáticamente los datos poco usados.

7. Strong vs Weak: ¿cuándo usar qué?

  • Referencias strong — por defecto: utilízalas para todo lo que deba vivir con garantía.
  • Referencias débiles — para cachés, listeners, cuando no es crítico si el objeto es eliminado.
  • Referencias soft — para cachés que conviene mantener el mayor tiempo posible, pero que pueden eliminarse cuando falte memoria.
  • Referencias phantom — para escenarios avanzados (por ejemplo, finalización fuera del heap).

8. Errores típicos al trabajar con memoria y referencias

Error n.º 1: «¡El GC lo limpiará todo por mí!» El recolector de basura elimina solo los objetos a los que no apunta ninguna referencia strong viva. Si «olvidas» una referencia en algún sitio (por ejemplo, en una colección static), el objeto vivirá eternamente.

Error n.º 2: listeners olvidados. ¿Añadiste un listener a un objeto y no lo eliminaste al destruirlo? El listener y todo lo que haya capturado permanecerán en memoria.

Error n.º 3: caché sin referencias débiles. ¿Usas un HashMap normal para una caché que debería limpiarse automáticamente? Mejor usa WeakHashMap o SoftReference.

Error n.º 4: las clases internas y las lambdas capturan el objeto externo. Las clases internas (y las expresiones lambda) almacenan implícitamente una referencia al objeto externo. Si guardas instancias de tales clases más tiempo que el propio objeto externo, tendrás una fuga.

Error n.º 5: esperar que el GC actúe de inmediato. La llamada a System.gc() no garantiza que la recolección se produzca de inmediato. Es solo una «petición» a la JVM, no una orden.

Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION