Una delle caratteristiche principali di java è il supporto al multithreading o programmazione concorrente. Ogni programma che viene mandato in esecuzione sulla JVM dà origine ad un processo. Per la gestione del multithreading la JVM non si affida al sistema operativo, dovendo garantire la portabilità, ed utilizza una politica di gestione dei thread di tipo fixed-priority scheduling. Questa é basata essenzialmente sulla priorità ed è preemptive poiché garantisce che, se in qualunque momento si rende eseguibile un thread con priorità maggiore di quella del thread attualmente in esecuzione, il thread a maggiore priorità prevalga sull’altro, fatto salvo casi particolari in cui debbono essere gestite potenziali situazioni di stallo.
Creazione di thread
In java il supporto alla programmazione concorrente è realizzato attraverso l’utilizzo della classe java.lang.Thread
e l’interfaccia java.lang.Runnable
. Cone è naturale aspettarsi un un linguaggio ad oggetti come è java, un processo in esecuzione è un oggetto che può essere di tipo java.lang.Thread
o appartenere ad una sua sottoclasse, oppure può implementare l’interfaccia java.lang.Runnable
.
La classe Thread
è una classe concreta che espone un metodo run()
. Sebbene sia possibile istanziare un oggetto Thread
, la sua esecuzione terminerebbe immediatamente perché il metodo run
non fa nulla. E’ necessario quindi estendere tale classe e ridefinire il metodo per fare in modo che il nostro thread esegua un task, come nell’esempio seguente.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
public class Counter extends Thread { private int n; public Counter(int n) { this.n = n; } public void run() { for (int i = 1; i <= n; i++) { System.out.println( this.getName() + ": " + i ); } } public static void main(String[] args) { Counter c = new Counter( 100 ); c.start(); c = null; // GC optimized } } |
Come si evince dall’esempio, il thread è avviato invocando il metodo start()
il quale provoca l’invocazione del metodo run()
(invocare il metodo run
direttamente non comporta l’esecuzione del metodo in un thread separato dal thread corrente). Terminata tale esecuzione le risorse impiegate vengono rilasciate. Ai fini dell’ottimizzazione della memoria è sempre opportuno impostare i riferimenti al thread a null
, soprattutto se se ne utilizzano parecchi. In alternativa è possibile creare thread senza riferimenti:
1 |
new Counter( 100 ).start(); |
Un metodo alternativo per creare ed eseguire un thread è l’implementazione dell’interfaccia Runnable
. Essa contiene la definizione di un unico metodo run()
, analogamente alla classe Thread
. Questa stessa classe, infatti, è una implementazione di tale interfaccia. L’esempio precedente con l’utilizzo dell’interfaccia Runnable
diviene:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
public class Counter implements Runnable { private int n; public Counter(int n) { this.n = n; } public void run() { for (int i = 1; i <= n; i++) { System.out.println( i ); } } public static void main(String[] args) { Counter cc = new Counter( 100 ); Thread t = new Thread( c ); t.start(); } } |
Si nota immediatamente che per eseguire il task abbiamo ancora bisogno della classe Thread
, al quale passiamo l’oggetto Runnable
come parametro del costruttore. Può quindi sembrare, a prima vista, che tale interfaccia sia inutile potendo implementare un thread direttamente utilizzando la classe Thread
. In realtà ricordiamo che java non supporta l’ereditarietà multipla. Questo implica che una classe non può implementare contemporaneamente la funzione di thread e specializzare un’altra classe concreta. A tale scopo ci viene in aiuto l’interfaccia Runnable
che permette di avere una classe che ne estende un’altra pur essendo utilizzabile come thread:
1 2 3 |
public class Counter extends OtherClass implements Runnable { ... } |
Controllo dell’esecuzione
L’esempio che abbiamo visto, oltre ad essere molto semplice, ha il vantaggio di generare un thread che ha una vita limitata. Conta fino a 100 e poi termina. In generale questa situazione è abbastanza rara, un thread può non terminare mai o comunque eseguire task molto complessi e che richiedono un tempo indefinito. Poiché l’implementazione della JVM non è detto che supporti il time-slicing, un thread potenzialmente può prendere il controllo in modo indefinito e senza rilasciare le risorse, provocando un rallentamento degli altri thread in attesa. Il programmatore può venire in aiuto al sistema con due accorgimenti descritti nel seguito.
Il primo consiste nell’utilizzare il metodo sleep()
, che consente di sospendere l’esecuzione del thread per un periodo ti tempo specificato in millisecondi. Il metodo run()
del nostro esempio potrebbe quindi divenire:
1 2 3 4 5 6 |
public void run() { for (int i = 1; i <= n; i++) { System.out.println( i ); Thread.sleep(1000); } } |
L’effetto di tale invocazione è quello di mettere in attesa il thread corrente e l’esecuzione del primo thread disponibile, con un conseguente e non previsto cambio di contesto. Quindi se da un lato libera delle risorse, dall’altro comporta un costo computazionale del quale va tenuto conto.
Altra possibilità è l’utilizzo del metodo yield(), che consente di cedere l’uso del processore a un altro thread in attesa, con il vantaggio che, nel caso in cui nessuno sia in attesa di essere servito, permette al thread corrente il proseguimento delle operazioni senza un inutile e costoso cambio di contesto. Il thread sospeso è portato in fondo alla coda dei thread di uguale priorità, comportando che i thread a priorità inferiore non potranno comunque prendere il posto del thread corrente.
Un altro modo per controllare l’esecuzione dei thread è l’utilizzo della priorità. Come detto la virtual machine adotta uno scheduling di tipo preemptive, basato sulla priorità, ed un thread a priorità maggiore può interrompere quello in esecuzione. In linea di principio quindi, il thread in esecuzione è sempre quello a massima priorità. Inoltre, se la JVM dovesse supportare il time-slicing, il thread a priorità maggiore sarebbe anche quello che occupa la CPU per un tempo maggiore.
La priorità può essere impostata utilizzando il metodo setPriority
dell’oggetto Thread
, considerando che la massima priorità è 10 (Thread.MAX_PRIORITY
) mentre la minima è 1 (Thread.MIN_PRIORITY
). Per default i thread partono con la priorità 5 (Thread.NORM_PRIORITY
).
Sincronizzazione tra thread
Attraverso il metodo join
della classe Thread
è possibile attendere la terminazione sul quale è stato invocato. Questo è utile, ad esempio, quando per continuare l’esecuzione è necessario disporre dei risultati dell’elaborazione di un altro thread. Consideriamo ad esempio un thread che calcolo l’n-esimo numero di Fibonacci:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
public class Fibonacci implements Runnable { private int n; private int result; public Fibonacci(int n) { this.n = n; } public int getResult() { return result; } public void run() { int prec = 1; // Fibonacci(0) int succ = 1; // Fibonacci(1) result = 1; if ( n > 1 ) { for ( int i = 2; i <= n; i++ ) { result = prec + succ; prec = succ; succ = result; } } } public static void main(String[] args) { Fibonacci fibonacci = new Fibonacci(30); Thread thread = new Thread(fibonacci); thread.start(); try { thread.join(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println( fibonacci.getResult() ); } } |
Il metodo main istanzia il thread richiedendo il 30-esimo numero di Fibonacci, lo avvia ed attende il completamento dello stesso per leggere il risultato della computazione. Il metodo join
sospende il thread corrente e lo mette in attesa, analogamente allo sleep
. La differenza è che l’attesa potrebbe protrarsi per un tempo indefinito, anche non terminare mai se siamo in presenza di errori. Per ovviare a ciò è possibile passare al metodo join
, analogamente allo sleep
, il numero massimo di millisecondi che si è disposti ad attendere la terminazione del thread.
Un’altro caratteristica dei thread è quella di poter essere interrotti. Tale possibilità si ottiene invocando il metodo interrupt()
sulla variabile di istanza del thread che si vuole interrompere. L’aspetto importante è che l’interruzione avviene anche nel caso in cui il thread si trovi in stato di attesa, ad esempio dovuto ad una chiama sleep()
o join()
. Questo è il motivo per cui negli esempi precedenti tali metodi erano inseriti all’interno di un blocco try...catch
che intercetta l’eccezione InterruptedException
.
Nel caso in cui l’interruzione avviene quando il thread è i esecuzione, la JVM pone TRUE
una variabile di stato che può essere testata dal thread invocando il metodo isInterrupted()
(disponibile solamente se si estende la classe Thread
). Si noti comunque che l’effetto non è, come ci si potrebbe aspettare, quello di interrompere l’esecuzione del thread ma semplicemente di notificare la richiesta di interruzione. Ad esempio se modifichiamo la classe Fibonacci
come mostrato di seguito:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
public class Fibonacci implements Runnable { private int n; private int result; public Fibonacci(int n) { this.n = n; } public int getResult() { return result; } public void run() { int prec = 1; // Fibonacci(0) int succ = 1; // Fibonacci(1) result = 1; if (n > 1) { try { Thread.sleep( 600 ); } catch (InterruptedException e) { System.out.println( "Sono stato interrotto durante lo sleep." ); } for (int i = 2; i <= n; i++) { result = prec + succ; prec = succ; succ = result; } } System.out.println( "Fibbonacci ended" ); } public static void main(String[] args) { Fibonacci fibonacci = new Fibonacci(30); // output 1346269 Thread thread = new Thread(fibonacci); thread.start(); try { Thread.sleep( 500 ); } catch (InterruptedException e) { e.printStackTrace(); } thread.interrupt(); System.out.println( fibonacci.getResult() ); } } |
L’output dell’esecuzione è:
1 2 3 |
1 Sono stato interrotto durante lo sleep. Fibbonacci ended |
Dove è evidente che nonostante l’interruzione il metodo run()
è arrivato alla sua naturale conclusione.
Un altro utile meccanismo di sincronizzazione tra thread è l’utilizzo dei metodo wait()
e notify()
. Si tratta di metodi della classe Object
e quindi invocabili su un qualsiasi oggetto. Se un thread esegue il wait su un oggetto, la sua esecuzione sarà interrotta fino a un altro thread non esegua il notify sullo stesso oggetto. Unico vincolo è l’aver ottenuto prima il lock
sull’oggetto con un blocco sincronizzato, descritto nel prossimo paragrafo. Di seguito un esempio complesso con due thread, un Trasmitter
ed un Receiver
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
public class Trasmitter extends Thread { private List<Integer> channel; public Trasmitter( List<Integer> channel ) { this.channel = channel; } @Override public void run() { for ( int i = 0; i < 10; i++ ) { synchronized( channel ) { System.out.println( "Sending: " + i ); channel.add( i ); channel.notify(); } try { Thread.sleep( 100 ); // Consente l'esecuzione dell'altro thread } catch (InterruptedException e) { e.printStackTrace(); } } } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
public class Receiver extends Thread { private List<Integer> channel; public Receiver( List<Integer> channel ) { this.channel = channel; } @Override public void run() { while ( !isInterrupted() ) { synchronized( channel ) { try { channel.wait(); System.out.println( "Received " + channel ); } catch (InterruptedException e) { return; } } } } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
public class MainTrasmitterReceiver { public static void main(String[] args) { List<Integer> channel = new ArrayList<Integer>(); Trasmitter tr = new Trasmitter( channel ); Receiver rc = new Receiver( channel ); tr.start(); rc.start(); try { tr.join(); } catch (InterruptedException e) { } rc.interrupt(); } } |
Se i thread in accesso concorrente sono più di uno, e tutti in attesa a causa di una wait(), allora una chiamata alla notify()
sbloccherà uno solo dei thread in attesa, senza la possibilità di sapere quale. In questo caso il metodo notifyAll()
, consente di inviare la notifica a tutti i thread in attesa.
Accesso concorrente
Molto spesso è necessario che più thread accedano ad una risorsa condivisa, come ad esempio un’area di memoria, il cui accesso contemporaneo può provocare situazioni inaspettate e comunque non corrette. Nella programmazione concorrente questi casi cono gestiti attraverso meccanismi di looking basati sull’utilizzo di semafori. I semafori può essere visti come variabile visibile a tutti i thread ed utilizzate da questi per indicare che si è entrati in una porzione di codice critica. Il meccanismo di schedulazione si fa carico di garantire che, fin tanto che un thread ha eseguito un lock sul semaforo nessun altro thread vi può accedere. Eventuali thread che chiedono l’accesso sono messi in attesa finché il thread corrente non ha rilasciato il lock sul semaforo.
Java offre diversi meccanismi di looking. Il primo e più semplice è l’utilizzo della keyword synchronized
applicato ad un metodo:
1 2 3 |
public synchronized void metodoSincronizzato() { .... } |
Dietro le quinte la JVM prima di eseguire il metodo acquisisce il lock sull’oggetto stesso (che in questo caso funge da semaforo) e lo rilascia al termine. Con questa modalità tutti i metodo dell’oggetto che sono synchronized
non possono essere eseguiti se non dal thread che detiene il lock.
Può capitare però che solo una porzione di codice all’interno di un metodo necessiti di sincronizzazione. In questi casi può essere opportuno usare un blocco sincronizzato:
1 2 3 4 5 6 7 |
public void metodoSincronizzato() { .... synchronized (this) { .... } .... } |
come si nota in questo caso deve essere indicato l’oggetto su cui eseguire il lock, nell’esempio this
, che può essere di qualsiasi tipo. Utilizzare la sincronizzazione a livello di metodo o di blocco è una scelta che dipende dalla specifica situazione. In generale blocchi sincronizzati di grandi dimensioni possono provocare inutili rallentamenti ed occasionalmente generare deadlock.
Una classe che ha i blocchi di codice critici sincronizzati è detta thread safe.
Una alternativa ai blocchi di sincronizzazione per rendere l’accesso alle variabili thread safe è l’utilizzo della classe ThreadLocal
. Questa consente di istanziare variabili locali al thread in modo che gli altri thread non possano accedervi. Un esempio d’uso è quando abbiamo la necessità di associare uno stato specifico ad un thread (es. uno user id o un transaction id).
L’esempio che segue assegna un ID ad ogni esecuzione del thread:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
// Questa classe fornisce un ID a ciascun thread class ThreadId { // Atomic integer restituisce il successivo ID da associare al thread private static final AtomicInteger nextId = new AtomicInteger(0); // variabile locale contenente l'ID del thread private static final ThreadLocal<Integer> threadId = new ThreadLocal<Integer>() { @Override protected Integer initialValue() { return nextId.getAndIncrement(); } }; // Restituisce l'ID del thread corrente, eseguendo l'inizializzazione se necessario public static int get() { return threadId.get(); } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
class ThreadLocalDemo implements Thread{ @Override public void run() { System.out.println("Thread " + Thread.currentThread().getName() + " Value - " + ThreadId.get()); } public static void main(String[] args) { ThreadLocalDemo thread1 = new ThreadLocalDemo("Thread-1"); ThreadLocalDemo thread2 = new ThreadLocalDemo("Thread-2"); ThreadLocalDemo thread3 = new ThreadLocalDemo("Thread-3"); thread1.start(); thread2.start(); thread3.start(); } } |
Thread Thread-1 Value - 0
Thread Thread-2 Value - 1
Thread Thread-3 Value - 2