Gestione Memoria e Ottimizzazione JavaScript

Edoardo Midali
Edoardo Midali

La gestione della memoria in JavaScript è un aspetto cruciale per le performance delle applicazioni web moderne. Anche se JavaScript gestisce automaticamente la memoria tramite garbage collection, comprendere come funziona e come ottimizzare l’uso della memoria può fare la differenza tra un’applicazione veloce e una lenta.

Come Funziona la Memoria in JavaScript

JavaScript utilizza due aree principali di memoria: lo Stack per i tipi primitivi e le chiamate di funzione, e l’Heap per gli oggetti e le strutture dati complesse. Lo stack è veloce ma limitato in dimensione, mentre l’heap è più grande ma con accesso più lento.

// Memorizzato nello stack
let numero = 42;
let stringa = "ciao";
let booleano = true;

// Memorizzato nell'heap
let oggetto = { nome: "Mario", età: 30 };
let array = [1, 2, 3, 4, 5];
let funzione = function () {
  return "test";
};

Quando crei variabili primitive, vengono allocate direttamente nello stack. Gli oggetti vengono allocati nell’heap, mentre nello stack viene memorizzato solo un riferimento (puntatore) all’oggetto nell’heap.

Il Garbage Collector

Il garbage collector di JavaScript si occupa automaticamente di liberare la memoria non più utilizzata. Utilizza principalmente due strategie: il reference counting (conteggio dei riferimenti) e il mark-and-sweep (marca e spazza).

Il reference counting tiene traccia di quanti riferimenti puntano a ogni oggetto, ma ha problemi con i riferimenti circolari. Il mark-and-sweep, più moderno, parte dalle “radici” (variabili globali, stack) e marca tutti gli oggetti raggiungibili, poi elimina tutto ciò che non è stato marcato.

// Questo crea un riferimento circolare
function creaRiferimentoCircolare() {
  let obj1 = {};
  let obj2 = {};

  obj1.riferimento = obj2;
  obj2.riferimento = obj1;

  return obj1;
}

// Il mark-and-sweep gestisce correttamente questa situazione
let obj = creaRiferimentoCircolare();
obj = null; // Gli oggetti saranno raccolti dal garbage collector

Identificare i Memory Leak

I memory leak si verificano quando la memoria occupata da oggetti non più necessari non viene liberata. I casi più comuni includono event listener non rimossi, timer attivi, closures che mantengono riferimenti e variabili globali non necessarie.

Event Listener Non Rimossi

// Problematico
function aggiungiListener() {
  let datiGrandi = new Array(1000000).fill("data");

  document.addEventListener("click", function () {
    console.log(datiGrandi.length); // Mantiene datiGrandi in memoria
  });
}

// Soluzione
function aggiungiListenerCorretto() {
  let datiGrandi = new Array(1000000).fill("data");

  function handleClick() {
    console.log(datiGrandi.length);
  }

  document.addEventListener("click", handleClick);

  // Importante: rimuovere quando non serve più
  return function cleanup() {
    document.removeEventListener("click", handleClick);
  };
}

Timer Non Cancellati

// Problematico
function avviaTimer() {
  let datiGrandi = new Array(1000000).fill("data");

  setInterval(() => {
    console.log(datiGrandi.length); // Mantiene datiGrandi sempre in memoria
  }, 1000);
}

// Soluzione
function avviaTimerCorretto() {
  let datiGrandi = new Array(1000000).fill("data");

  const intervalId = setInterval(() => {
    console.log(datiGrandi.length);
  }, 1000);

  // Restituisce funzione per fermare il timer
  return function stop() {
    clearInterval(intervalId);
  };
}

Strategie di Ottimizzazione

Object Pooling

Invece di creare e distruggere continuamente oggetti, mantieni un pool di oggetti riutilizzabili:

class ObjectPool {
  constructor(createFn, resetFn) {
    this.createFn = createFn;
    this.resetFn = resetFn;
    this.pool = [];
  }

  get() {
    if (this.pool.length > 0) {
      return this.pool.pop();
    }
    return this.createFn();
  }

  release(obj) {
    this.resetFn(obj);
    this.pool.push(obj);
  }
}

// Esempio di utilizzo
const particlePool = new ObjectPool(
  () => ({ x: 0, y: 0, velocityX: 0, velocityY: 0 }),
  (particle) => {
    particle.x = 0;
    particle.y = 0;
    particle.velocityX = 0;
    particle.velocityY = 0;
  }
);

Evitare Allocazioni Eccessive

Riduci le allocazioni di memoria nei cicli critici:

// Problematico - crea molti array temporanei
function processaNumeri(numeri) {
  return numeri
    .filter((n) => n > 0)
    .map((n) => n * 2)
    .reduce((sum, n) => sum + n, 0);
}

// Ottimizzato - una sola iterazione
function processaNumeriOttimizzato(numeri) {
  let somma = 0;
  for (let i = 0; i < numeri.length; i++) {
    if (numeri[i] > 0) {
      somma += numeri[i] * 2;
    }
  }
  return somma;
}

Gestione delle Stringhe

Le stringhe in JavaScript sono immutabili, quindi ogni concatenazione crea una nuova stringa:

// Inefficiente per molte concatenazioni
let risultato = "";
for (let i = 0; i < 1000; i++) {
  risultato += "testo " + i + " ";
}

// Più efficiente
let parti = [];
for (let i = 0; i < 1000; i++) {
  parti.push("testo " + i + " ");
}
let risultato = parti.join("");

// Ancora meglio con template literals
let risultato = Array.from({ length: 1000 }, (_, i) => `testo ${i} `).join("");

Weak References

JavaScript moderno offre WeakMap e WeakSet per riferimenti “deboli” che non impediscono la garbage collection:

// Map normale - impedisce garbage collection
const cache = new Map();
function cacheOggetto(obj, data) {
  cache.set(obj, data); // obj non può essere raccolto finché è nella cache
}

// WeakMap - permette garbage collection
const weakCache = new WeakMap();
function cacheOggettoWeak(obj, data) {
  weakCache.set(obj, data); // obj può essere raccolto normalmente
}

// Quando obj non è più referenziato altrove, viene automaticamente
// rimosso anche dalla WeakMap

Monitoraggio delle Performance

Utilizzo delle Dev Tools

I browser moderni offrono strumenti potenti per analizzare l’uso della memoria:

// Monitoraggio programmatico della memoria (Chrome)
if (performance.memory) {
  console.log("Heap usato:", performance.memory.usedJSHeapSize);
  console.log("Heap totale:", performance.memory.totalJSHeapSize);
  console.log("Limite heap:", performance.memory.jsHeapSizeLimit);
}

// Profilazione custom
function profilaFunzione(fn, nome) {
  const inizio = performance.now();
  const memoriaInizio = performance.memory?.usedJSHeapSize || 0;

  const risultato = fn();

  const fine = performance.now();
  const memoriaFine = performance.memory?.usedJSHeapSize || 0;

  console.log(`${nome}:`);
  console.log(`  Tempo: ${fine - inizio}ms`);
  console.log(`  Memoria: ${(memoriaFine - memoriaInizio) / 1024}KB`);

  return risultato;
}

Tecniche di Misurazione

// Benchmark semplice
function benchmark(fn, iterazioni = 1000) {
  const inizio = performance.now();

  for (let i = 0; i < iterazioni; i++) {
    fn();
  }

  const fine = performance.now();
  return (fine - inizio) / iterazioni; // Tempo medio per iterazione
}

Best Practices

Rimuovi sempre event listener: Quando rimuovi elementi dal DOM, assicurati di rimuovere anche i loro event listener.

Cancella timer e intervalli: Usa sempre clearTimeout e clearInterval quando non servono più.

Limita le variabili globali: Le variabili globali non vengono mai raccolte dal garbage collector.

Usa WeakMap e WeakSet: Quando possibile, per evitare riferimenti che impediscono la garbage collection.

Profila regolarmente: Usa gli strumenti di sviluppo per identificare memory leak e colli di bottiglia.

Ottimizza i loop critici: Riduci le allocazioni nei percorsi di codice che vengono eseguiti frequentemente.

Una gestione efficiente della memoria non solo migliora le performance dell’applicazione, ma garantisce anche un’esperienza utente più fluida, specialmente su dispositivi con risorse limitate. L’importante è trovare il giusto equilibrio tra ottimizzazione e leggibilità del codice.