Ottimizzare la performance del codice Java

Ottimizzare la performance in Java non significa “scrivere codice più furbo” a caso: significa misurare, capire dove si spende tempo e memoria, scegliere le strutture dati e i pattern giusti, e configurare correttamente JVM e runtime. Questa guida è organizzata in passi concreti, con esempi e checklist per evitare ottimizzazioni premature.

1) Parti dalla misurazione: profiling e benchmark

Profiling in produzione e in test

Prima di cambiare una riga, stabilisci una baseline. In Java, spesso i colli di bottiglia reali sono diversi da quelli “intuitivi”. Usa:

  • Profiler: CPU (hot methods), allocazioni, lock contention, I/O.
  • Log e metriche: tempi di risposta (p95/p99), throughput, errori, GC pause, heap usage.
  • Tracing (servizi): per capire quali chiamate esterne dominano il tempo totale.

Microbenchmark corretti con JMH

I microbenchmark “a mano” (misurare con System.nanoTime() in un ciclo) sono quasi sempre fuorvianti: JIT, warm-up, dead-code elimination e ottimizzazioni del compilatore falsano i risultati. Per microbenchmark usa JMH. Esempio minimo:

import org.openjdk.jmh.annotations.*;
import java.util.concurrent.TimeUnit;

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 5, time = 500, timeUnit = TimeUnit.MILLISECONDS)
@Measurement(iterations = 10, time = 500, timeUnit = TimeUnit.MILLISECONDS)
@Fork(2)
@State(Scope.Thread)
public class StringConcatBenchmark {

    private String a = "hello";
    private String b = "world";

    @Benchmark
    public String plusOperator() {
        return a + "-" + b;
    }

    @Benchmark
    public String stringBuilder() {
        return new StringBuilder(a.length() + b.length() + 1)
                .append(a).append('-').append(b).toString();
    }
}

Nota: questo benchmark ha senso solo se la concatenazione è nel tuo hot path. In molti casi, il vero problema non è concatenare, ma creare troppe stringhe o fare logging sincrono.

2) Ottimizza dove conta: scegli le priorità

Spesso l’80% del tempo si concentra nel 20% del codice. Tipiche priorità in applicazioni Java moderne:

  1. Ridurre latenza di I/O (DB, HTTP, filesystem) e migliorare caching.
  2. Ridurre allocazioni e pressione sul Garbage Collector.
  3. Ridurre contesa (lock, synchronized, strutture concorrenti) e migliorare parallelismo.
  4. Ottimizzare algoritmi e strutture dati nel core business.

3) Algoritmi e strutture dati: la leva più potente

Complessità e costi nascosti

Prima di micro-ottimizzare, controlla la complessità algoritmica e i costi di strutture dati:

  • Ricerca lineare su una lista grande invece di una mappa o di un indice.
  • Ordinamenti ripetuti quando puoi ordinare una volta e riusare.
  • Copie inutili di collezioni o array in loop.

Scegli la collezione giusta

Esigenza Scelta tipica Nota
Lookup veloce per chiave HashMap Pre-dimensiona quando conosci la cardinalità per ridurre rehash.
Ordine naturale o range query TreeMap Più lento di HashMap ma utile per ordinamento e range.
Set senza duplicati HashSet Evita di usare List se fai solo “contains” frequenti.
Iterazione compatta e accesso per indice ArrayList Meglio di LinkedList nella maggior parte dei casi.
Coda FIFO ArrayDeque Spesso migliore di LinkedList come deque/queue.

Pre-sizing e riduzione delle riallocazioni

Se sai quante entry avrai, inizializza la capacità per evitare crescita e rehash:

int expected = 100_000;
// Per HashMap: capacity ~ expected / loadFactor + 1
int capacity = (int) (expected / 0.75f) + 1;
Map<String, Integer> map = new HashMap<>(capacity, 0.75f);

List<String> list = new ArrayList<>(expected);

4) Riduci allocazioni e GC pressure

Evita oggetti temporanei nel percorso caldo

Ogni allocazione è un costo: non solo per creare l’oggetto, ma perché aumenta il lavoro del GC. Fonti comuni di allocazioni “invisibili”:

  • Autoboxing (intInteger) in collezioni o stream.
  • Creazione di String e substring in loop.
  • Uso non necessario di Optional in hot path.
  • Lambda/stream su dataset enormi (dipende dal caso e dalla JVM, ma misuralo).

Preferisci primitive quando possibile

Se stai accumulando numeri, l’autoboxing può generare moltissimi oggetti:

// Potenzialmente crea molti Integer
long sumBoxed(List<Integer> values) {
    long s = 0;
    for (Integer v : values) {
        s += v; // unboxing
    }
    return s;
}

// Se puoi, conserva primitive (es. int[]) o strutture specializzate
long sumPrimitive(int[] values) {
    long s = 0;
    for (int v : values) {
        s += v;
    }
    return s;
}

Costruzione efficiente di stringhe

In loop, usa StringBuilder e pre-alloca quando puoi:

String joinWithDash(List<String> parts) {
    int approx = 0;
    for (String p : parts) approx += p.length() + 1;

    StringBuilder sb = new StringBuilder(approx);
    for (int i = 0; i < parts.size(); i++) {
        if (i > 0) sb.append('-');
        sb.append(parts.get(i));
    }
    return sb.toString();
}

Pool di oggetti: usali solo se hai misurato il beneficio

I pool manuali possono peggiorare le cose: aumentano complessità, rischio di leak e contesa. Sono utili soprattutto per oggetti costosi da creare o per limitare risorse esterne. Prima di introdurli, verifica con profiling che la creazione oggetti sia davvero il collo di bottiglia.

5) Concorrenza: riduci contesa e sfrutta parallelismo

Evita lock inutili e sincronizzazione grossolana

Lock ampi possono serializzare il throughput. Preferisci strutture concorrenti e sezioni critiche minime.

class CounterBad {
    private long value;
    synchronized void inc() { value++; }
    synchronized long get() { return value; }
}

class CounterBetter {
    private final java.util.concurrent.atomic.LongAdder adder = new java.util.concurrent.atomic.LongAdder();
    void inc() { adder.increment(); }
    long get() { return adder.sum(); }
}

LongAdder è spesso più scalabile sotto alta contesa rispetto a AtomicLong, perché distribuisce gli aggiornamenti su celle interne. Come sempre: misura sul tuo carico.

Thread pool: dimensionamento e backpressure

Creare thread a richiesta può degradare performance e stabilità. Usa pool e imponi limiti. Un esempio con ThreadPoolExecutor e coda limitata:

import java.util.concurrent.*;

ExecutorService ioPool = new ThreadPoolExecutor(
        50, 50,
        0L, TimeUnit.MILLISECONDS,
        new ArrayBlockingQueue<>(10_000),
        new ThreadPoolExecutor.CallerRunsPolicy() // backpressure: rallenta il chiamante
);

Il numero “giusto” dipende dal tipo di lavoro:

  • CPU-bound: vicino al numero di core (con piccoli aggiustamenti).
  • I/O-bound: può essere più alto, ma attenzione a saturare DB o servizi esterni.

CompletableFuture: composizione senza bloccare

Se devi combinare chiamate indipendenti, la composizione asincrona può migliorare latenza aggregata (sempre rispettando limiti e backpressure):

CompletableFuture<User> userF = CompletableFuture.supplyAsync(() -> loadUser(id), ioPool);
CompletableFuture<List<Order>> ordersF = CompletableFuture.supplyAsync(() -> loadOrders(id), ioPool);

CompletableFuture<Profile> profileF =
        userF.thenCombine(ordersF, (user, orders) -> buildProfile(user, orders));

Profile profile = profileF.join();

6) I/O e accesso dati: spesso è qui che si vince

Database: riduci round-trip e trasferimenti

  • Preferisci query mirate (proiezioni) invece di caricare intere entità quando non serve.
  • Evita N+1: usa join/fetch appropriati o batch.
  • Usa indici corretti e verifica i piani di esecuzione.
  • Cache dove ha senso: risultati stabili o configurazioni raramente variabili.

HTTP: riuso connessioni, timeouts, compressione

Per chiamate esterne, assicurati di:

  • Riutilizzare connessioni (keep-alive) e configurare pool.
  • Impostare timeout (connect/read) e retry con backoff, evitando retry illimitati.
  • Limitare concorrenza verso dipendenze esterne per evitare “meltdown”.

7) JVM e Garbage Collector: configura con attenzione

Osserva prima, poi cambia

Prima di cambiare parametri JVM, raccogli dati: heap usage, pause del GC, frequenza e durata. Non esiste una configurazione universale.

Linee guida pratiche

  • Imposta heap iniziale e massimo in modo coerente (es. -Xms e -Xmx) per ridurre resizing, ma senza esagerare e rubare memoria al sistema.
  • Monitora GC pause e allocation rate. Se l’allocation rate è altissimo, spesso la soluzione migliore è ridurre allocazioni nel codice.
  • Verifica se il GC scelto è adatto al tuo obiettivo (latenza vs throughput). Nelle JVM recenti, G1 è spesso default; ZGC e Shenandoah sono opzioni orientate alla bassa latenza in alcuni scenari.

Nota: la scelta del GC e i flag variano con la versione della JVM e con il workload. Evita di copiare “tuning” da blog senza riprodurre carico e misurazioni nel tuo ambiente.

8) Ottimizzazioni nel codice: consigli mirati

Evita lavoro ripetuto

Memoization e caching locale possono essere molto efficaci se i parametri si ripetono e i risultati sono stabili.

import java.util.concurrent.ConcurrentHashMap;

class PriceService {
    private final ConcurrentHashMap<String, Double> cache = new ConcurrentHashMap<>();

    double priceFor(String sku) {
        return cache.computeIfAbsent(sku, this::loadPriceFromDb);
    }

    private double loadPriceFromDb(String sku) {
        // chiamata reale a DB o servizio
        return 42.0;
    }
}

Attenzione: una cache senza limiti può crescere indefinitamente. Se il dominio delle chiavi è grande, usa cache con eviction (LRU/TTL) tramite librerie dedicate.

Logging: non pagare il costo quando il log è disabilitato

Evita concatenazioni e calcoli costosi nel messaggio. Preferisci logging parametrizzato:

// Meglio: evita stringhe temporanee quando il livello è disabilitato
logger.debug("User id={} requestSize={}", userId, payloadSize);

// Se devi fare calcoli costosi:
if (logger.isDebugEnabled()) {
    logger.debug("Payload preview: {}", expensivePreview(payload));
}

Stream API: usa con criterio

Gli stream rendono il codice espressivo, ma in hot path possono introdurre overhead (allocazioni, lambda, iterazioni). Regola pratica:

  • Usa stream per chiarezza su dataset medi e dove non sei in un percorso critico.
  • Per loop ultra-frequenti e grandi dataset, confronta con un for tradizionale usando JMH o profiling.
  • Preferisci IntStream/LongStream quando lavori su primitive per ridurre boxing.

9) Checklist operativa per un intervento efficace

  1. Definisci l’obiettivo: ridurre p95? aumentare throughput? ridurre GC pause?
  2. Raccogli baseline (metriche, profili, tracce) su un carico realistico.
  3. Identifica i colli di bottiglia principali (CPU, allocazioni, lock, I/O).
  4. Applica cambiamenti a impatto alto: algoritmi, query/indici, caching, riduzione allocazioni.
  5. Rivalida con lo stesso carico e confronta numeri (non sensazioni).
  6. Solo dopo, valuta tuning JVM e micro-ottimizzazioni locali.
  7. Proteggi la performance nel tempo: test di carico periodici e regressioni monitorate.

10) Conclusione

La performance in Java si ottimizza con un approccio disciplinato: misurare, intervenire sui colli di bottiglia reali, ridurre allocazioni e contesa, migliorare I/O e accesso dati, e solo infine fare tuning della JVM. Se segui questa sequenza, ottieni miglioramenti robusti e ripetibili senza trasformare il codice in un puzzle difficile da manutenere.

Torna su