Ottimizzare la performance delle applicazioni Java Spring Boot

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 Xms e Xmx uguali 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

  1. Stai saturando CPU? Profiling CPU (JFR/flame graph) e verifica allocazioni.
  2. GC: pause aumentate? Controlla log GC, heap usage e churn.
  3. Thread pool server/app esauriti? Code lunghe? Context switch elevato?
  4. Pool DB saturo? Aumenti di wait time su Hikari? Slow query log indica regressioni?
  5. Chiamate esterne lente? Tracing distribuito mostra dove si accumula il tempo?
  6. Cache efficace? Hit ratio sufficiente? Stampede? TTL troppo corto?
  7. Payload troppo grandi? Compressione mal configurata? Serializzazione costosa?
  8. 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.

Torna su