Ottimizzare una Spring Boot app significa migliorare latenza e throughput mantenendo affidabilità e costi sotto controllo. La chiave è evitare interventi “a intuito” e procedere con un ciclo ripetibile: misura → individua il collo di bottiglia → applica una modifica mirata → misura di nuovo. In questo articolo trovi un percorso completo: dalla definizione delle metriche, al profiling, fino alle ottimizzazioni su JVM, web server, thread pool, database, caching e deployment.
1) Parti dai numeri: obiettivi, metriche e baseline
Prima di cambiare codice o configurazioni, stabilisci una baseline riproducibile con carichi realistici. Definisci obiettivi come P95/P99 della latenza, RPS, error rate, CPU/memoria e saturazione I/O.
- Latenza: meglio percentili (P50/P95/P99) che la media.
- Throughput: richieste/secondo o messaggi/secondo.
- Risorse: CPU, heap, GC, file descriptor, connessioni DB, I/O disco, rete.
- Saturazione: thread pool esauriti, code in attesa, pool DB saturi, backpressure mancante.
Usa strumenti di load test (JMeter, Gatling, k6) e una strategia di test coerente: warm-up, durata, dataset realistico e target SLO (Service Level Objective).
2) Profiling e diagnosi: trova il collo di bottiglia prima di ottimizzare
Le ottimizzazioni efficaci nascono dal profiling. Due scenari ricorrenti: CPU-bound (codice che consuma CPU) e I/O-bound (attesa su DB/rete/disco).
2.1 Flight Recorder e Mission Control (JFR/JMC)
JFR è spesso la scelta migliore: overhead basso, dati ricchi (allocazioni, lock, GC, thread, hotspot). Avvia un recording durante un test di carico e analizza con JMC.
# Esempio: avvio con JFR (varia in base a JDK e ambiente)
java -XX:StartFlightRecording=filename=app.jfr,settings=profile -jar app.jar
2.2 Async-profiler, sampling e flame graph
Quando sospetti un problema CPU (hot path, allocazioni eccessive, lock contesi), uno profiler a sampling può evidenziare le funzioni più costose.
2.3 Log e tracing per individuare latenza “esterna”
Se la latenza è dominata da DB, chiamate HTTP o servizi esterni, il profiling CPU non basta. Introduci tracing distribuito e metriche per capire dove si accumula il tempo.
3) Configurazione della JVM: heap, GC e flags ragionevoli
Una configurazione JVM sana evita pause GC eccessive e riduce churn di memoria. Oggi, per molte app Spring Boot in produzione, G1GC è una scelta equilibrata. In ambienti con requisiti di latenza molto stringenti, valuta ZGC (a seconda del JDK e della versione).
3.1 Dimensionare l’heap (Xms/Xmx)
- Imposta
XmseXmxuguali o vicini per ridurre resize dell’heap in runtime. - Evita heap troppo piccoli (GC frequente) o troppo grandi (pause più lunghe, cache CPU peggiore).
- In container, assicurati che la JVM rilevi correttamente i limiti; con JDK recenti è in genere automatico.
3.2 Obiettivo pause: MaxGCPauseMillis (con cautela)
Con G1 puoi impostare un target di pause; non è una garanzia, ma un indicatore. Valuta impatti su throughput.
java -Xms512m -Xmx512m -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -jar app.jar
3.3 Ridurre le allocazioni
Molti problemi di performance derivano da troppe allocazioni (JSON, mapping DTO, concatenazioni stringhe, collezioni temporanee). Con JFR puoi vedere “Allocation in new TLAB” e hot spots di allocazione. Ottimizzare le allocazioni spesso migliora sia CPU sia GC.
4) Web server e stack HTTP: Tomcat/Jetty/Undertow e tuning dei thread
Spring Boot usa di default Tomcat (stack Servlet). La performance dipende da: numero di thread, gestione delle connessioni, keep-alive, timeouts, TLS e dimensione delle risposte.
4.1 Thread pool del server e throughput
Se la tua app è I/O-bound, aumentare indiscriminatamente i thread può peggiorare la latenza (context switch). Trova un compromesso misurando. Un punto di partenza ragionevole è correlare i thread al tipo di workload: per CPU-bound tieni i thread vicino ai core; per I/O-bound puoi salire, ma con limiti e backpressure.
server:
tomcat:
threads:
max: 200
min-spare: 20
accept-count: 100
connection-timeout: 5s
4.2 Compressione HTTP e payload
La compressione può ridurre banda e tempi di rete, ma costa CPU. Abilitala per payload grandi e misurane l’impatto. Ridurre il payload (campi inutili, serializzazione efficiente) spesso è più efficace.
4.3 Servlet vs WebFlux
WebFlux (Reactor) può aiutare in scenari con molte richieste concorrenti I/O-bound, ma richiede una catena completamente non bloccante (client HTTP reattivo, driver DB reattivo). Se chiami API bloccanti, rischi di peggiorare le cose.
5) Database: è spesso il vero collo di bottiglia
La maggior parte delle app enterprise passa più tempo in DB che nel codice Java. Ottimizzare il DB significa: query efficienti, indici corretti, pool connessioni ben dimensionato e transazioni ben delimitate.
5.1 Pool connessioni (HikariCP) e saturazione
Un pool troppo piccolo crea attesa; troppo grande può sovraccaricare il DB. Misura tempi di attesa sul pool e sul DB, poi aggiusta.
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 2000
idle-timeout: 600000
max-lifetime: 1800000
5.2 JPA/Hibernate: N+1, fetch plan e batch
- N+1: verifica che non stai caricando collezioni in loop con query separate.
- Fetch join o EntityGraph per controllare cosa viene caricato.
- Batch per insert/update massivi, evitando flush frequenti.
- Attenzione a serializzare entità direttamente in JSON: puoi innescare lazy loading inatteso.
public interface OrderRepository extends JpaRepository<Order, Long> {
@EntityGraph(attributePaths = {"items", "customer"})
@Query("select o from Order o where o.id = :id")
Optional<Order> findByIdWithItemsAndCustomer(@Param("id") Long id);
}
5.3 Query e indici: misura con EXPLAIN e slow query log
Ottimizza le query peggiori prima delle micro-ottimizzazioni applicative.
Usa EXPLAIN, controlla indici mancanti, cardinalità e selectività.
5.4 Transazioni: meno è meglio
Transazioni lunghe aumentano lock e contention. Riduci lo scope di @Transactional e separa operazioni
di lettura e scrittura. In molti casi, scegliere la corretta isolation level è determinante.
6) Cache: accelerare senza perdere correttezza
La cache è una leva potentissima, ma va gestita con disciplina: cosa cachare, per quanto, con quale strategia di invalidazione. In Spring Boot puoi partire con cache locali (Caffeine) o distribuite (Redis).
6.1 Cache applicativa con Spring Cache
@Service
public class ProductService {
private final ProductRepository repo;
public ProductService(ProductRepository repo) {
this.repo = repo;
}
@Cacheable(cacheNames = "productById", key = "#id")
public ProductDto getProduct(long id) {
return repo.findById(id)
.map(ProductDto::from)
.orElseThrow(() -> new IllegalArgumentException("Not found"));
}
@CacheEvict(cacheNames = "productById", key = "#id")
public void updateProduct(long id, UpdateProductCommand cmd) {
repo.update(id, cmd);
}
}
6.2 Cache stampede e protezioni
- Cache stampede: molte richieste simultanee per una chiave scaduta.
- Mitigazioni: TTL jitter, locking per chiave, refresh ahead, cache “soft” (serve stale e aggiorna in background).
7) Serializzazione e mapping: JSON, DTO e validazione
La serializzazione JSON può diventare costosa con payload grandi o mapping complessi. Riduci i campi, evita conversioni ripetute e usa DTO dedicati.
- Evita di esporre direttamente le entità JPA come response.
- Controlla la validazione: molte annotazioni Bean Validation su grandi payload possono costare CPU.
- Per flussi molto intensivi, valuta formati più compatti (ad esempio Protobuf) se il contesto lo consente.
8) Threading e asincronia: evitare blocchi e code infinite
Un classico problema di performance è l’uso improprio dell’asincronia: thread pool troppo piccoli, task che bloccano, code che crescono senza limiti.
8.1 Configurare un ThreadPoolTaskExecutor per @Async
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean(name = "appExecutor")
public Executor appExecutor() {
ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor();
exec.setCorePoolSize(16);
exec.setMaxPoolSize(64);
exec.setQueueCapacity(500);
exec.setThreadNamePrefix("app-");
exec.initialize();
return exec;
}
}
Dimensiona il pool in base al workload e imposta politiche chiare quando la coda è piena (ad esempio rifiutare, degradare, o applicare backpressure).
8.2 Virtual threads (JDK 21+) con Spring Boot
I virtual threads possono aumentare drasticamente la concorrenza percepita per workload bloccanti, riducendo la necessità di pool enormi. Non sono una bacchetta magica: DB e risorse esterne restano limiti reali, ma spesso migliorano la gestione di molte richieste simultanee.
spring:
threads:
virtual:
enabled: true
9) Ottimizzare startup e consumo memoria
In ambienti autoscalati, lo startup time conta. Le leve principali sono: ridurre auto-configurazioni inutili, limitare component scan, ottimizzare logging e usare lazy init dove sensato.
9.1 Lazy initialization
spring:
main:
lazy-initialization: true
Attenzione: il lazy init può spostare costi dal bootstrap al primo request (cold path). Valuta se è accettabile.
9.2 Immagini native (GraalVM) per casi specifici
Se il tuo obiettivo primario è startup ultra-rapido e memoria contenuta (serverless, CLI, microservizi molto elastici), una native image può essere utile. Richiede però attenzione su reflection, proxy e librerie compatibili.
10) Osservabilità: metriche, log strutturati e tracing
Senza osservabilità, l’ottimizzazione è cieca. Con Spring Boot Actuator e Micrometer puoi esporre metriche (Prometheus, OpenTelemetry, ecc.) e correlare log e trace.
10.1 Actuator e endpoint minimi
management:
endpoints:
web:
exposure:
include: "health,info,metrics,prometheus"
endpoint:
health:
probes:
enabled: true
10.2 Log strutturati e correlazione
Assicurati di avere un correlation id (ad esempio con MDC) per seguire una richiesta attraverso i servizi. In presenza di load balancer e microservizi, questa singola pratica accelera diagnosi e tuning.
11) Sicurezza e performance: TLS, JWT, rate limiting
Anche i controlli di sicurezza incidono. Alcuni esempi:
- TLS: preferisci terminazione TLS su proxy/ingress se l’architettura lo consente; abilita keep-alive.
- JWT: la verifica firma è CPU-bound; usa caching delle chiavi e riduci verifiche ripetute.
- Rate limiting: proteggi da picchi e abusi; migliora stabilità e quindi performance percepita.
12) Checklist pratica: cosa controllare quando la latenza sale
- Stai saturando CPU? Profiling CPU (JFR/flame graph) e verifica allocazioni.
- GC: pause aumentate? Controlla log GC, heap usage e churn.
- Thread pool server/app esauriti? Code lunghe? Context switch elevato?
- Pool DB saturo? Aumenti di wait time su Hikari? Slow query log indica regressioni?
- Chiamate esterne lente? Tracing distribuito mostra dove si accumula il tempo?
- Cache efficace? Hit ratio sufficiente? Stampede? TTL troppo corto?
- Payload troppo grandi? Compressione mal configurata? Serializzazione costosa?
- Timeout e retry: generano tempeste? Serve circuit breaker/backoff?
Conclusione
L’ottimizzazione in Spring Boot è un lavoro di sistema: JVM, thread, I/O, DB e rete. Le best practice più “profittevoli” sono quasi sempre: profilare prima, ottimizzare il database, introdurre caching dove corretto, e curare osservabilità per reagire rapidamente a regressioni. Una volta che hai una baseline e un set di metriche affidabili, ogni intervento diventa misurabile e sostenibile nel tempo.