wandres.dev
MEMORY I · El heap snapshot

Tamaño superficial frente a tamaño retenido

Las dos formas de medir la memoria de un objeto, por qué la segunda es la única que responde a la pregunta útil, y el concepto de dominador que la define.

⏱ 17 min

Un objeto de sesenta bytes puede ser responsable de treinta megabytes. Un array de un millón de elementos puede ser responsable de cero. Las dos afirmaciones son ciertas a la vez y no son paradójicas: corresponden a dos formas distintas de atribuir memoria, y la diferencia entre ellas es el concepto central del análisis de montón. Ordenar la tabla por la columna equivocada es la razón por la que muchas sesiones de diagnóstico de memoria acaban mirando estructuras internas del motor en lugar del problema.

🎯 Al terminar esta lección sabrás
  • Definir tamaño superficial y tamaño retenido y calcular ambos en un caso concreto.
  • Explicar qué es un dominador y por qué determina el tamaño retenido.
  • Justificar por qué el tamaño retenido es la métrica de decisión.
  • Reconocer los casos donde el tamaño retenido resulta engañoso.

Las dos definiciones

El tamaño superficial es la memoria que ocupa el objeto en sí: su cabecera, sus campos, sus punteros a otros objetos. Es un número pequeño y bastante uniforme para la mayoría de los objetos, porque un puntero ocupa lo mismo apunte a lo que apunte. Las excepciones son los objetos que guardan datos directamente: cadenas, arrays tipados, y los almacenamientos internos de los arrays.

El tamaño retenido es la memoria que se liberaría si ese objeto desapareciera. Incluye su tamaño superficial más el de todos los objetos que solo son alcanzables a través de él. La palabra “solo” es la clave de todo.

Un ejemplo concreto lo aclara:

// Dos objetos con el mismo tamano superficial y tamanos retenidos opuestos
const datosCompartidos = new Array(100000).fill('x');

const a = { nombre: 'a', datos: datosCompartidos };
const b = { nombre: 'b', datos: datosCompartidos };

// Tamano superficial de a: unas decenas de bytes. Igual el de b.
// Tamano retenido de a: casi nada, porque si a desaparece,
//   datosCompartidos sigue vivo a traves de b.
// Tamano retenido de b: lo mismo, por el mismo motivo.
// Si eliminas b, el retenido de a pasa a incluir el array entero.

const soloDeC = new Array(100000).fill('y');
const c = { nombre: 'c', datos: soloDeC };
// Tamano superficial de c: identico al de a y al de b.
// Tamano retenido de c: el array entero, porque nadie mas lo referencia.

Los tres objetos son indistinguibles por tamaño superficial y radicalmente distintos por tamaño retenido. Solo la segunda medida responde a la pregunta que de verdad se hace un ingeniero: si me deshago de esto, ¿cuánta memoria recupero?

El dominador

El tamaño retenido se define formalmente a través de una relación del grafo que se llama dominación, y entenderla evita malinterpretar los números.

Un nodo A domina a un nodo B si todos los caminos desde cualquier raíz hasta B pasan por A. Es decir: si A desaparece, B se vuelve inalcanzable necesariamente.

El tamaño retenido de un objeto es la suma de los tamaños superficiales de todos los objetos que domina, incluido él mismo. Con esa definición, el ejemplo anterior se explica solo: a no domina al array compartido, porque hay otro camino a través de b. Y c sí domina al suyo, porque no hay otro camino.

De la definición se derivan tres propiedades útiles.

Los tamaños retenidos no se pueden sumar. Si tres objetos retienen diez megabytes cada uno, el total liberado al eliminarlos no es treinta: puede ser diez, si los tres dominan parcialmente estructuras compartidas que solo quedan libres cuando desaparecen todos.

Los dominadores forman un árbol. Cada objeto tiene exactamente un dominador inmediato, y esa estructura es la que la vista de contención presenta. Subir por ese árbol lleva siempre a la raíz.

Una referencia adicional puede reducir a cero el retenido de otro. Añadir una entrada en una caché que apunte a un objeto ya referenciado no cambia la memoria total, y sin embargo cambia por completo la atribución. Esto explica por qué a veces eliminar un objeto sospechoso no libera nada: había otro camino.

⚠️
Cuidado

Una consecuencia práctica que descoloca la primera vez: eliminar una única referencia puede no liberar absolutamente nada, y eliminar la última libera todo de golpe. En una fuga con dos caminos de retención, arreglar uno y comprobar que la memoria no baja lleva a la conclusión equivocada de que el arreglo no servía. La comprobación correcta no es medir la memoria sino volver a mirar el camino de retención y ver si ha desaparecido.

Ordenar por lo correcto

La consecuencia operativa es directa: en la vista de resumen, ordena por tamaño retenido. El orden por superficial pone arriba las estructuras internas del motor, los almacenamientos de arrays y las cadenas grandes, que son consecuencias y no causas.

Hay una excepción, y es cuando se buscan fugas por acumulación en lugar de por tamaño. Muchas fugas reales consisten en muchos objetos pequeños que se acumulan; ninguno destaca por tamaño retenido y el conjunto pesa. Para ese caso, la ordenación útil es por número de instancias, y la pregunta es si ese número tiene sentido: cuatro mil instancias de un componente en una aplicación que muestra veinte es un diagnóstico sin necesidad de mirar bytes.

La regla que combina las dos: por retenido para encontrar el objeto grande, por recuento para encontrar la acumulación. Y comprobar siempre las dos, porque las dos formas de fuga se manifiestan de manera opuesta.

Los casos donde el retenido engaña

Tres situaciones en las que este número lleva a conclusiones erróneas.

Estructuras compartidas legítimamente. Una caché de datos que muchas partes de la aplicación usan tendrá un tamaño retenido enorme concentrado en su objeto raíz. No es una fuga: es una caché, y está haciendo su trabajo. El tamaño retenido no distingue entre memoria útil y memoria desperdiciada.

El objeto global. Su tamaño retenido es prácticamente todo el montón, porque domina casi todo. Es un dato inútil y aparece siempre arriba.

Ciclos. Dos objetos que se referencian mutuamente y que juntos son alcanzables desde una raíz: individualmente ninguno domina al otro, así que sus retenidos son pequeños, y sin embargo el par entero está reteniendo memoria. El recolector maneja perfectamente los ciclos —marca desde las raíces, así que un ciclo aislado se recoge sin problema— pero la atribución de tamaño retenido los reparte de forma poco intuitiva.

Las vistas de cadena y de búfer. Como se vio en la lección anterior, una subcadena puede retener la original. El tamaño retenido lo refleja correctamente y aun así sorprende, porque el objeto que aparece como responsable es diminuto.

// Demostracion de la retencion por vista de cadena y como romperla
(() => {
  const original = 'contenido enorme '.repeat(200000); // varios MB
  const vista = original.slice(0, 20);                  // 20 caracteres
  const copiaReal = (original.slice(0, 20) + ' ').trim(); // fuerza materializacion

  window.__vista = vista;       // retiene la cadena original entera
  window.__copia = copiaReal;   // retiene solo 20 caracteres

  console.log('Toma un snapshot y compara el tamano retenido de __vista y __copia.');
  console.log('Las dos cadenas valen lo mismo:', vista === copiaReal.slice(0, 20));
})();

El truco de concatenar y recortar fuerza al motor a materializar una cadena nueva en lugar de guardar una referencia con desplazamiento. Es la clase de detalle que solo se descubre mirando tamaños retenidos, y que explica fugas donde alguien guardaba un identificador extraído de una respuesta enorme.

La pregunta correcta no es cuánto ocupa sino qué liberaría, y esa reformulación cambia toda la sesión

Casi todo el mundo llega al panel de memoria con la pregunta equivocada, y la pregunta equivocada produce sesiones estériles independientemente de la habilidad con la herramienta. La pregunta que se trae de fuera es “¿qué está ocupando memoria?”, que es la pregunta natural cuando uno viene de mirar un monitor del sistema. El problema es que en un montón sano la respuesta a esa pregunta es siempre la misma y siempre es aburrida: cadenas, arrays, código compilado y estructuras del motor. Son las categorías que dominan cualquier montón de cualquier aplicación, incluidas las que no tienen ningún problema, y perseguirlas no lleva a ninguna parte. La pregunta que hay que hacerse en su lugar es “¿qué debería haber desaparecido y sigue aquí?”, y esa reformulación tiene tres consecuencias inmediatas. Primera: convierte el análisis en una comparación, porque “debería haber desaparecido” solo tiene sentido respecto a un antes y un después. Segunda: pone el foco en objetos de tu dominio, con nombres que reconoces, en lugar de en categorías genéricas del motor; y si tus objetos no tienen nombres reconocibles porque son literales anónimos, eso ya es un hallazgo, porque significa que no puedes diagnosticar tu propia memoria y merece la pena darles clases con nombre solo por esto. Y tercera, la más importante: hace que el tamaño deje de ser el criterio de búsqueda. Un objeto que debería haberse liberado es un problema aunque ocupe cien bytes, porque si ocurre una vez por interacción, en una sesión larga son cientos de miles. Y un objeto que ocupa cincuenta megabytes no es ningún problema si es la caché de datos que la aplicación necesita. El tamaño retenido sigue siendo la columna por la que ordenar, pero como herramienta de priorización dentro del conjunto de sospechosos, no como criterio para decidir quién es sospechoso. Esa distinción entre criterio de sospecha y criterio de prioridad es lo que separa una sesión de memoria que encuentra la fuga en veinte minutos de otra que pasa dos horas admirando el tamaño del objeto global.