1. Introduzione alla profilazione
La profilazione è come un check-up medico per il tuo programma: non guardiamo solo la “temperatura” (monitoraggio), ma cerchiamo dove l'applicazione “fa male”, cosa funziona lentamente, dove si consuma troppa memoria o risorse.
La profilazione è il processo di raccolta e analisi di informazioni sul funzionamento del programma con l'obiettivo di individuare colli di bottiglia (bottlenecks) e parti di codice inefficienti. A differenza del monitoraggio, che di solito tiene traccia di indicatori generali (carico della CPU, memoria, numero di thread), la profilazione permette di guardare all'interno: scoprire quali metodi vengono chiamati più spesso, quanto tempo richiedono, quanti oggetti vengono creati e dove avvengono le perdite di memoria.
Quando la profilazione è davvero necessaria?
- L'applicazione “rallenta”, ma non è chiaro perché.
- Il consumo di memoria è aumentato all'improvviso.
- Dopo un aggiornamento del codice qualcosa ha iniziato a funzionare più lentamente.
- Serve capire perché al server non bastano le risorse.
A proposito, quasi ogni sviluppatore almeno una volta ha ottimizzato la parte sbagliata del codice. Perché? Perché “a occhio” è praticamente impossibile individuare il collo di bottiglia — per questo serve un profilatore.
Metriche principali della profilazione
- Tempo di esecuzione dei metodi (profilazione CPU): Quali metodi impiegano più tempo? Dove il programma “consuma” CPU?
- Uso della memoria (profilazione della memoria): Quali oggetti vengono creati più spesso? Dove restano in memoria più a lungo del necessario?
- Numero di oggetti: Stiamo creando troppi oggetti dello stesso tipo?
- Thread: Ci sono troppi thread? Ci sono blocchi (deadlock, contention)?
- Chiamate di metodo: Qual è la profondità dello stack? Avviene una ricorsione senza uscita?
2. Strumenti di profilazione
Nel mondo Java ci sono diversi strumenti classici (e gratuiti!) che permettono di effettuare la profilazione. Consideriamo i principali.
VisualVM
VisualVM è uno strumento gratuito, incluso nel JDK (a partire da JDK 6). Consente di:
- Collegarsi a JVM locali e remote.
- Osservare memoria, thread, CPU, garbage collection.
- Eseguire un heap dump e analizzarlo.
- Profilare l'applicazione per CPU e memoria.
Come avviare VisualVM?
Di solito si trova nella cartella del JDK: <percorso_al_JDK>/bin/jvisualvm
Lo avvii, scegli il processo dell'applicazione Java — e puoi osservarne la vita come pesci in un acquario (solo che qui i pesci sono oggetti e thread).
JProfiler, YourKit
Sono strumenti commerciali, ma molto potenti. Consentono di:
- Profilare memoria, CPU, thread.
- Analizzare gli “snapshot” di memoria (heap dump).
- Individuare leak, lock prolungati, metodi lenti.
- Integrarsi con IDE e CI/CD.
Per iniziare basta VisualVM, ma se passerai a progetti più grandi — prendi in considerazione questi strumenti.
Java Flight Recorder (JFR)
JFR è uno strumento integrato nel JDK per raccogliere eventi sull'attività della JVM. È molto leggero, quasi non influisce sulle prestazioni, e consente di raccogliere informazioni su:
- Tempo di esecuzione dei metodi.
- Garbage collection.
- Thread, lock, errori.
JFR è perfetto per l'uso in produzione, quando non si può rallentare l'applicazione.
3. Pratica: profiliamo una semplice applicazione
Creiamo un mini calcolatore che sappia eseguire calcoli lunghi e conservare la cronologia delle operazioni (così avremo cicli, collezioni e lavoro con la memoria).
Esempio di codice: “Calcolatore lento”
import java.util.ArrayList;
import java.util.List;
public class SlowCalculator {
private final List<String> history = new ArrayList<>();
public int add(int a, int b) {
simulateHeavyOperation();
int result = a + b;
history.add(a + " + " + b + " = " + result);
return result;
}
public int multiply(int a, int b) {
simulateHeavyOperation();
int result = a * b;
history.add(a + " * " + b + " = " + result);
return result;
}
public List<String> getHistory() {
return history;
}
// Simulazione di un'operazione "pesante"
private void simulateHeavyOperation() {
for (int i = 0; i < 5_000_000; i++) {
Math.sqrt(i);
}
}
}
E ora — la classe principale:
public class Main {
public static void main(String[] args) {
SlowCalculator calc = new SlowCalculator();
for (int i = 0; i < 10; i++) {
calc.add(i, i * 2);
calc.multiply(i, i + 5);
}
System.out.println("Cronologia delle operazioni:");
for (String entry : calc.getHistory()) {
System.out.println(entry);
}
}
}
Come profilare questa applicazione?
- Compila ed esegui l'applicazione.
- Apri VisualVM (jvisualvm).
- Trova il tuo processo (di solito per nome della classe Main).
- Vai alla scheda CPU Profiler e fai clic su Start.
- Lascia che il programma lavori (oppure premi di nuovo il pulsante lento).
- Guarda quali metodi occupano più tempo.
Domanda: Secondo te, quale metodo sarà il più “pesante”?
Risposta: Naturalmente simulateHeavyOperation() — perché esegue un enorme ciclo da 5_000_000 iterazioni e chiama Math.sqrt.
4. Problemi tipici di prestazioni
Algoritmi lenti
La causa più comune: scelta sbagliata dell'algoritmo o della struttura dati. Per esempio, cercare in una lista invece di usare HashMap, o l'ordinamento a bolla invece del quicksort.
Esempio:
// Ricerca lenta
for (String s : list) {
if (s.equals("target")) {
// trovato
}
}
Meglio usare Set o Map per una ricerca veloce.
Memory leak
Un memory leak è una situazione in cui gli oggetti rimangono “vivi” (esistono riferimenti a essi), anche se non servono più. Questo porta a un aumento del consumo di memoria e, alla fine, a OutOfMemoryError.
public class MemoryLeakDemo {
private static List<byte[]> leakyList = new ArrayList<>();
public static void main(String[] args) {
while (true) {
leakyList.add(new byte[1_000_000]); // 1 MB
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
}
}
}
Come trovare i leak?
Esegui un heap dump in VisualVM e guarda quali oggetti occupano più memoria e perché esistono riferimenti a essi.
Creazione eccessiva di oggetti
Se in un ciclo crei molti oggetti dello stesso tipo, non solo carichi il garbage collector, ma potresti anche rallentare l'applicazione.
for (int i = 0; i < 1_000_000; i++) {
String s = new String("hello"); // male!
}
Meglio usare costanti o il pool delle stringhe (String pool).
Blocchi tra thread
Se più thread si contendono la stessa risorsa (per esempio, un metodo sincronizzato), ciò può portare a lock e a un calo delle prestazioni.
public synchronized void doWork() {
// ...
}
Come individuarli?
Nella scheda Threads di VisualVM puoi vedere quali thread sono “bloccati” e perché.
5. Approcci all'ottimizzazione
Prima misura, poi ottimizza
Regola principale dell'ottimizzazione: Non ottimizzare ciò che non rallenta.
Per prima cosa profila, trova i “punti caldi”, e solo dopo modifica il codice. A volte la parte di codice più “ovvia” occupa solo 1 % del tempo, mentre il vero “mostro” è da qualche parte in una libreria o in un punto inatteso.
Uso del profilatore per trovare i punti caldi
Un punto caldo è un metodo o una porzione di codice che occupa la quota maggiore del tempo di esecuzione dell'applicazione.
In VisualVM è visibile nella scheda CPU Profiler:
- Ordina i metodi per tempo di esecuzione.
- Guarda lo stack trace: chi chiama chi.
- Ricorda che a volte il “colpevole” non è il tuo codice, ma una libreria o persino il JDK.
Esempi di ottimizzazione
Esempio 1: Sostituzione dell'algoritmo
Se hai visto che si spende più tempo nella ricerca in una lista, sostituisci List con HashSet.
Set<String> set = new HashSet<>(list);
if (set.contains("target")) {
// veloce!
}
Esempio 2: Riduzione del numero di allocazioni
Invece di creare nuovi oggetti nel ciclo, usa il riutilizzo o StringBuilder.
// Male:
for (int i = 0; i < 10000; i++) {
String s = "Risultato: " + i;
}
// Meglio:
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
sb.setLength(0);
sb.append("Risultato: ").append(i);
String s = sb.toString();
}
Esempio 3: Caching
Se vedi che un metodo pesante viene chiamato molte volte con gli stessi parametri, usa una cache.
Map<Integer, Double> sqrtCache = new HashMap<>();
public double cachedSqrt(int x) {
return sqrtCache.computeIfAbsent(x, Math::sqrt);
}
6. Dimostrazione: velocizziamo il nostro calcolatore
Problema: simulateHeavyOperation() consuma molto tempo
Passo 1. Profiliamo
In VisualVM si vede che quasi tutto il tempo va su Math.sqrt(i) dentro un ciclo da 5_000_000 iterazioni.
Passo 2. Ottimizziamo
Se è solo una simulazione di carico — rimuovila o riduci il numero di iterazioni.
Se è logica di business reale — valuta se puoi:
- Mettere in cache il risultato.
- Usare un algoritmo più veloce.
- Spostare i calcoli in un thread separato (se non è critico per l'utente).
Esempio di ottimizzazione:
private void simulateHeavyOperation() {
// Era 5_000_000, diventato 100_000
for (int i = 0; i < 100_000; i++) {
Math.sqrt(i);
}
}
Passo 3. Verifica del risultato
Eseguiamo di nuovo la profilazione — il programma gira più veloce e il carico sulla CPU è diminuito.
7. Visualizzazione: il processo di ottimizzazione
flowchart TD
A[Avvio dell'applicazione]
B["Profilazione (VisualVM)"]
C[Individuazione dei colli di bottiglia]
D[Ottimizzazione del codice]
E[Profilazione ripetuta]
F[Miglioramento delle prestazioni]
A --> B --> C --> D --> E --> F
E --> C
8. Errori tipici nella profilazione e ottimizzazione
Errore n. 1: Ottimizzare “a occhio”. Molto spesso gli sviluppatori iniziano a cambiare il codice senza misurare dove sia davvero il problema. Il risultato — tanto lavoro, effetti minimi.
Errore n. 2: Profilazione in condizioni “non realistiche”. Bisogna profilare su dati e carico simili a quelli reali. La profilazione “a vuoto” può non rivelare i veri problemi.
Errore n. 3: Ignorare i memory leak. Se non guardi l'heap dump e non analizzi i riferimenti, potresti non accorgerti a lungo che il programma “si gonfia” e sta per andare in crash.
Errore n. 4: Inseguire micro-ottimizzazioni. Non vale la pena spendere giorni per velocizzare il codice che occupa solo 0.1 % del tempo di esecuzione dell'applicazione. Prima — i colli di bottiglia principali.
Errore n. 5: Non considerare thread e sincronizzazione. Nelle applicazioni multithread i problemi di prestazioni spesso sono legati non agli algoritmi, ma a lock e attese (synchronized, contention).
Errore n. 6: Dimenticarsi della profilazione dopo le modifiche. Dopo l'ottimizzazione verifica necessariamente il risultato: a volte la “ottimizzazione” può perfino rallentare l'esecuzione!
GO TO FULL VERSION