Introduzione
Spesso mi è capitato di scontrarmi (bonariamente si intende… :) con alcuni amici sull’efficienza e velocità di Java in applicazioni realtime, tanto che dopo l’ennesima discussione ho deciso di dimostrare le mie tesi programmando qualcosa che non avrebbe lasciato possibilità di risposta da parte dei miei interlocutori: un engine 3D completamente software. Il mio obiettivo era quello di ottenere un’applet compatta e veloce, che fosse compatibile con tutti i browser e che fosse facile da usare; finalmente, dopo molto tempo passato a programmare, ma soprattutto a cercare vie alternative per ottenere algoritmi efficienti, ho sviluppato un kit di sviluppo completo per importare scene create con 3D Studio Max e con VRML (e presto anche con LightWave) dentro ad un’applet.
Molti sono i problemi di efficienza e di occupazione con cui mi sono scontrato, ma che sono riuscito a risolvere grazie soprattutto ad alcuni interessanti trucchetti che ho scoperto giocando direttamente con codice in bytecode, che ricordiamo è il codice intermedio in cui è rappresentata una classe Java.
Il risultato è un’applet molto contenuta (poco più di 40 KB, che diventano 20 KB se la si comprime con il JAR) in grado di caricare scene salvate in un formato compresso particolare (A3D), compatibile con tutti i browser ma soprattutto in grado di aggiungere, con 50 KB di spazio totale occupato (classi, scene e texture), ottimi effetti 3D a qualsiasi pagina web supportando svariate modalità di rendering e filtri di post-processing digitale.
L’applet è visibile sul sito www.anfyjava.com sotto la voce Anfy3D, ed è ovviamente scaricabile da chiunque gratuitamente.
Come accennato prima, lo sviluppo di questo engine è stato molto utile per mettere a nudo la reale potenza di Java, ma soprattutto per farmi un’idea dei sistemi di ottimizzazione adottabili su questa architettura.
Ma a cosa può servire ottimizzare una classe Java?!?
Un esempio è proprio Anfy3D, che dovendo rappresentare scene tridimensionali aggiornate in realtime deve sottostare a certi vincoli temporali in modo da mostrare l’animazione in un modo «decentemente» fluido.
Ma l’ottimizzazione di una classe Java è altresì importante sia per la realizzazione di sistemi embedded (che sono, o meglio erano, lo scopo reale di Java), sia per interfacce di interazione con l’utente molto pesanti; inoltre una buona conoscenza del tipo di bytecode generato dal nostro sorgente Java permette di ottenere classi più compatte e più efficienti.
Le note che seguono, che indirizzano nella ricerca di vie più brevi per scrivere un algoritmo, tentano di minimizzare lo scarto tra le varie JVM, cioè sono sistemi che danno miglioramenti di performance su tutte le implementazioni di una JVM e non solo su una particolare. Ovviamente esistono ottimizzazioni specifiche per ciascuna implementazione e release della JVM, ma l’idea di fondo che mi ha spinto nella mia ricerca era quella di ottenere un buon bilanciamento globale delle prestazioni.
Possiamo suddividere i tipi di ottimizzazioni possibili in tre categorie:
- A — vincoli temporali intrinseci alla Java VM;
- B — vincoli temporali dovuti ai metodi nativi;
- C — garbage collector («lo spazzino»).
Vincoli temporali intrinseci alla Java VM
Uno studio approfondito della Java VM e di come i vari compilatori Java trasformano un sorgente in bytecode permette di ricavare le seguenti informazioni.
Se si ha a che fare con un loop molto pesante in termini di tempo, conviene osservare la presenza di eventuali termini costanti e tirarli fuori dal loop. Purtroppo i compilatori Java attuali non fanno questa semplice ottimizzazione, supportata invece dalla quasi totalità dei compilatori C/C++.
Esempio:
for (int i = 0; i < 12; i++) {
pippo[i] = a * b * c;
}
risulta più veloce se scritto così:
int d = a * b * c;
for (int i = 0; i < 12; i++) {
pippo[i] = d;
}
Durante i calcoli conviene osservare la presenza di parti uguali in modo da non ripeterle. Ad esempio:
int e = a * b * (c * d / 2); int f = c * d / 2;
diventa:
int f = c * d / 2; int e = a * b * f;
Le due seguenti operazioni, anche se identiche come risultato, sono diverse come codice:
a[i] = a[i] + 4;
e:
a[i] += 4;
La seconda è più veloce e più corta.
A causa della struttura della Java VM le prime quattro variabili di un metodo (tre se il metodo non è di tipo statico) sono richiamate più velocemente. I metodi dichiarati final sono chiamati più velocemente degli altri. I metodi synchronized sono i più lenti in assoluto, a causa ovviamente del check sui monitor.
Tipi supportati: la Java VM supporta per i calcoli matematici solo quattro tipi: int, float, double, long; per gli array supporta solamente il tipo int.
Se vengono usati byte oppure short, questi vengono convertiti internamente in int e poi riconvertiti in byte o short. Conviene usare gli int. Comunque questo è variabile dal JIT usato (gli int sono sempre i più veloci).
Vincoli temporali dovuti ai metodi nativi
Purtroppo per noi maniaci della velocità, le classi native fornite da Sun o da altri produttori di JVM non sono assolutamente lo stato dell’arte dell’ottimizzazione ed il risultato è che spesso conviene riscriversi le funzioni critiche perché quelle fornite da Java, sebbene native, sono più lente. Un esempio sono gli stream di dati (DataInputStream e DataOutputStream), che sebbene siano una delle parti teoricamente più utili, sono anche una delle parti più lente di tutte.
Un’altra parte notevolmente lenta è il supporto delle aree grafiche e delle funzioni matematiche: l’ImageProducer si è rivelato essere una delle parti più lente di tutte le librerie fornite con Java, ma purtroppo non può essere scavalcato; in compenso, una parte che spesso può essere critica può essere velocizzata con piccoli accorgimenti. Sto parlando delle funzioni matematiche di Java, ed in particolare della libreria matematica, che gestisce unicamente dati di tipo double (i più lenti in assoluto…).
Se dobbiamo usare funzioni quali la radice etc., purtroppo abbiamo poco da fare; nel migliore dei casi conviene all’inizio del programma precalcolarsi una tabella già convertita, magari in float, da usare per i nostri scopi. Supponiamo ad esempio di avere a che fare con funzioni trigonometriche (come in una routine per ruotare punti) e immaginiamo di usare molto spesso la funzione Math.cos(angle): la prima cosa che si può osservare è che tale funzione è periodica, e quindi in realtà ci interessano solamente i valori di angle compresi nell’intervallo [0, 2π). A questo punto possiamo decidere di suddividere questo intervallo in una quantità fissata a priori (ad esempio 32768 valori) e creare un array di float contenente i giusti valori:
float a[] = new float[32768];
for (int i = 0; i < 32768; i++) {
a[i] = Math.cos(i * 2 * Math.PI / 32768);
}
Ora, supponendo di utilizzare al posto dei radianti la nostra nuova unità di misura (che varia da 0 a 32767), possiamo ricavare il coseno con un unico lookup da tabella.
Grazie alle tabelle è possibile fare molti tipi di ottimizzazioni, che incrementano spesso notevolmente i calcoli matematici.
Ma cosa succede quando abbiamo troppi calcoli in float e desideriamo una velocità maggiore a discapito di un po’ di precisione?
Possiamo usare una tecnica chiamata Fixed Int: si tratta di usare un int opportunamente shiftato dove i 16 bit più significativi sono usati come parte intera e quelli meno significativi come parte decimale.
Ad esempio, le seguenti assegnazioni:
int a = 1 << 16; int b = (int) (1.25 * 65536);
assegnano 1 ad a, nel nuovo formato, e trasformano un float (1.25) nel nuovo formato; dopo ciò è possibile fare calcoli con i nostri numeri, tenendo conto delle ovvie limitazioni di scala, e alla fine:
float c = b / 65536.0;
in modo da riconvertire in float, supposto che ci servano i float. Oppure è possibile utilizzare il risultato di tipo int con un semplice shift. Questo tipo di rappresentazione è molto utile anche per altri motivi: ad esempio è molto semplice fare il modulo di un numero (basta un mascheramento con un AND) ed implementare le funzioni di ceiling e di floor, molto utili nel campo della grafica tridimensionale in realtime.
Un’altra classe fonte di lentezze inaspettate è la classe String; di regola non andrebbe usata molto, visto che ogni operazione fatta su una String genera sempre nuovi oggetti, e solitamente si tende a fare molte operazioni sulle String, con conseguente generazione di migliaia di oggetti, spesso senza neanche accorgersene. Se ad esempio ci servono molte concatenazioni è meglio utilizzare la classe StringBuffer.
Infine lo spazzino...
Il garbage collector fornito da Java è decisamente comodo e potente poiché ci libera completamente dalla necessità di tracciare la memoria disponibile, risolvendo molti problemi legati alla memoria che si incontrano quando si programma in C/C++ (e che sono fonte di innumerevoli bug); ed è proprio questa una delle cause principali di rallentamenti quando abbiamo bisogno di codice veloce. Purtroppo non è possibile determinare a priori quando il garbage collector entrerà in azione e, se in quel momento stavamo disegnando un nuovo schermo, il risultato sarà quello di una pausa molto fastidiosa tra un frame ed il successivo di un’animazione. Ma questo è un problema che può essere aggirato in un modo molto semplice: utilizzare il metodo gc() della classe System.
In linea di massima bisogna operare in questo modo:
- Preallocare tutti gli oggetti o array che saranno necessari in un loop che vogliamo sia veloce (purtroppo ogni
new, soprattutto nelle vecchie JVM, richiede parecchio tempo e quindi è meglio tenerlo fuori). - Forzare la JVM ad eseguire il garbage collector tramite il comando
System.gc()in punti non critici del codice. Ad esempio, dopo avere allocato una grossa struttura di dati, si può chiamare subitoSystem.gc(); volendo, prima di questo si può anche chiamarerunFinalization(), che fa parte della classe Runtime, in modo da essere sicuri che Java abbia liberato tutta la spazzatura generata dal processo di costruzione.
Tutte queste note vanno ovviamente prese come consigli per ottimizzare il codice, e non è detto che saranno sempre corrette: magari saranno anzi dannose per HotSpot o futuri JIT. Quel che è certo è che al momento la stragrande maggioranza del pianeta usa ancora Java 1.0 e 1.1, o comunque JIT della vecchia generazione, e questi sono validi sistemi per rubare un po’ di tempo macchina alla JVM.
