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:
- Ridurre latenza di I/O (DB, HTTP, filesystem) e migliorare caching.
- Ridurre allocazioni e pressione sul Garbage Collector.
- Ridurre contesa (lock, synchronized, strutture concorrenti) e migliorare parallelismo.
- 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 (
int→Integer) in collezioni o stream. - Creazione di
Stringe substring in loop. - Uso non necessario di
Optionalin 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.
-Xmse-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/LongStreamquando lavori su primitive per ridurre boxing.
9) Checklist operativa per un intervento efficace
- Definisci l’obiettivo: ridurre p95? aumentare throughput? ridurre GC pause?
- Raccogli baseline (metriche, profili, tracce) su un carico realistico.
- Identifica i colli di bottiglia principali (CPU, allocazioni, lock, I/O).
- Applica cambiamenti a impatto alto: algoritmi, query/indici, caching, riduzione allocazioni.
- Rivalida con lo stesso carico e confronta numeri (non sensazioni).
- Solo dopo, valuta tuning JVM e micro-ottimizzazioni locali.
- 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.