Guida a java.util.concurrent.Future

1. Panoramica

In questo articolo, impareremo a conoscere il futuro . Un'interfaccia che esiste da Java 1.5 e può essere molto utile quando si lavora con chiamate asincrone ed elaborazione simultanea.

2. Creazione di future

In poche parole, la classe Future rappresenta un risultato futuro di un calcolo asincrono, un risultato che alla fine apparirà in Future una volta completata l'elaborazione.

Vediamo come scrivere metodi che creano e restituiscono un'istanza Future .

I metodi a esecuzione prolungata sono buoni candidati per l'elaborazione asincrona e l' interfaccia Future . Questo ci permette di eseguire qualche altro processo mentre aspettiamo il completamento dell'attività incapsulata in Future .

Alcuni esempi di operazioni che potrebbero sfruttare la natura asincrona di Future sono:

  • processi computazionali intensivi (calcoli matematici e scientifici)
  • manipolare strutture di dati di grandi dimensioni (big data)
  • chiamate a metodi remoti (download di file, scrapping HTML, servizi web).

2.1. Implementazione di future con FutureTask

Per il nostro esempio, creeremo una classe molto semplice che calcola il quadrato di un intero . Questo sicuramente non si adatta alla categoria dei metodi "di lunga durata", ma inseriremo una chiamata Thread.sleep () per farlo durare 1 secondo per il completamento:

public class SquareCalculator { private ExecutorService executor = Executors.newSingleThreadExecutor(); public Future calculate(Integer input) { return executor.submit(() -> { Thread.sleep(1000); return input * input; }); } }

Il bit di codice che esegue effettivamente il calcolo è contenuto nel metodo call () , fornito come espressione lambda. Come puoi vedere non c'è niente di speciale in questo, ad eccezione della chiamata sleep () menzionata in precedenza.

Diventa più interessante quando indirizziamo la nostra attenzione sull'utilizzo di Callable e ExecutorService .

Callable è un'interfaccia che rappresenta un'attività che restituisce un risultato e ha un unico metodo call () . Qui, ne abbiamo creato un'istanza utilizzando un'espressione lambda.

La creazione di un'istanza di Callable non ci porta da nessuna parte, dobbiamo comunque passare questa istanza a un esecutore che si occuperà di avviare quell'attività in un nuovo thread e restituirci il prezioso oggetto Future . È qui che entra in gioco ExecutorService .

Ci sono alcuni modi in cui possiamo entrare in possesso di un'istanza ExecutorService , la maggior parte di essi sono forniti dai metodi di fabbrica statici Executors della classe di utilità . In questo esempio, abbiamo utilizzato il newSingleThreadExecutor () di base , che ci fornisce un ExecutorService in grado di gestire un singolo thread alla volta.

Una volta che abbiamo un oggetto ExecutorService , dobbiamo solo chiamare submit () passando il nostro Callable come argomento. submit () si occuperà di avviare l'attività e restituirà un oggetto FutureTask , che è un'implementazione dell'interfaccia Future .

3. Consumo di futures

Fino a questo punto, abbiamo imparato come creare un'istanza di Future .

In questa sezione, impareremo come lavorare con questa istanza esplorando tutti i metodi che fanno parte dell'API di Future .

3.1. Utilizzo di isDone () e get () per ottenere risultati

Ora dobbiamo chiamare calcola () e utilizzare il futuro restituito per ottenere il numero intero risultante . Due metodi dell'API Future ci aiuteranno in questo compito.

Future.isDone () ci dice se l'esecutore ha terminato l'elaborazione dell'attività . Se l'attività è completata, restituirà true , altrimenti restituirà false .

Il metodo che restituisce il risultato effettivo del calcolo è Future.get () . Si noti che questo metodo blocca l'esecuzione fino al completamento dell'attività , ma nel nostro esempio, questo non sarà un problema poiché verificheremo prima se l'attività è stata completata chiamando isDone () .

Usando questi due metodi possiamo eseguire un altro codice mentre aspettiamo che l'attività principale finisca:

Future future = new SquareCalculator().calculate(10); while(!future.isDone()) { System.out.println("Calculating..."); Thread.sleep(300); } Integer result = future.get();

In questo esempio, scriviamo un semplice messaggio sull'output per far sapere all'utente che il programma sta eseguendo il calcolo.

Il metodo get () bloccherà l'esecuzione fino al completamento dell'attività. Ma non dobbiamo preoccuparci di questo poiché il nostro esempio arriva solo al punto in cui get () viene chiamato dopo essersi assicurati che l'attività sia terminata. Quindi, in questo scenario, future.get () tornerà sempre immediatamente.

Vale la pena ricordare che get () ha una versione sovraccarica che richiede un timeout e un TimeUnit come argomenti:

Integer result = future.get(500, TimeUnit.MILLISECONDS);

La differenza tra get (long, TimeUnit) e get () , è che il primo genererà un'eccezione TimeoutException se l'attività non ritorna prima del periodo di timeout specificato.

3.2. Annullare un futuro con cancel ()

Supponiamo di aver attivato un'attività ma, per qualche motivo, non ci interessa più il risultato. Possiamo usare Future.cancel (booleano) per dire all'esecutore di interrompere l'operazione e interrompere il thread sottostante:

Future future = new SquareCalculator().calculate(4); boolean canceled = future.cancel(true);

La nostra istanza di Future dal codice sopra non completerebbe mai la sua operazione. Infatti, se proviamo a chiamare get () da quell'istanza, dopo la chiamata a cancel () , il risultato sarebbe una CancellationException . Future.isCancelled () ci dirà se un Future è già stato cancellato. Questo può essere molto utile per evitare di ottenere un'eccezione CancellationException .

È possibile che una chiamata a cancel () fallisca. In tal caso, il valore restituito sarà falso . Si noti che cancel () accetta un valore booleano come argomento: questo controlla se il thread che esegue questa attività deve essere interrotto o meno.

4. Più multithreading con pool di thread

Il nostro ExecutorService corrente è a thread singolo poiché è stato ottenuto con Executors.newSingleThreadExecutor. Per evidenziare questa "singola filettatura", attiviamo due calcoli contemporaneamente:

SquareCalculator squareCalculator = new SquareCalculator(); Future future1 = squareCalculator.calculate(10); Future future2 = squareCalculator.calculate(100); while (!(future1.isDone() && future2.isDone())) { System.out.println( String.format( "future1 is %s and future2 is %s", future1.isDone() ? "done" : "not done", future2.isDone() ? "done" : "not done" ) ); Thread.sleep(300); } Integer result1 = future1.get(); Integer result2 = future2.get(); System.out.println(result1 + " and " + result2); squareCalculator.shutdown();

Ora analizziamo l'output per questo codice:

calculating square for: 10 future1 is not done and future2 is not done future1 is not done and future2 is not done future1 is not done and future2 is not done future1 is not done and future2 is not done calculating square for: 100 future1 is done and future2 is not done future1 is done and future2 is not done future1 is done and future2 is not done 100 and 10000

It is clear that the process is not parallel. Notice how the second task only starts once the first task is completed, making the whole process take around 2 seconds to finish.

To make our program really multi-threaded we should use a different flavor of ExecutorService. Let's see how the behavior of our example changes if we use a thread pool, provided by the factory method Executors.newFixedThreadPool():

public class SquareCalculator { private ExecutorService executor = Executors.newFixedThreadPool(2); //... }

With a simple change in our SquareCalculator class now we have an executor which is able to use 2 simultaneous threads.

If we run the exact same client code again, we'll get the following output:

calculating square for: 10 calculating square for: 100 future1 is not done and future2 is not done future1 is not done and future2 is not done future1 is not done and future2 is not done future1 is not done and future2 is not done 100 and 10000

This is looking much better now. Notice how the 2 tasks start and finish running simultaneously, and the whole process takes around 1 second to complete.

There are other factory methods that can be used to create thread pools, like Executors.newCachedThreadPool() that reuses previously used Threads when they are available, and Executors.newScheduledThreadPool() which schedules commands to run after a given delay.

For more information about ExecutorService, read our article dedicated to the subject.

5. Overview of ForkJoinTask

ForkJoinTask is an abstract class which implements Future and is capable of running a large number of tasks hosted by a small number of actual threads in ForkJoinPool.

In this section, we are going to quickly cover the main characteristics of ForkJoinPool. For a comprehensive guide about the topic, check our Guide to the Fork/Join Framework in Java.

Then the main characteristic of a ForkJoinTask is that it usually will spawn new subtasks as part of the work required to complete its main task. It generates new tasks by calling fork() and it gathers all results with join(), thus the name of the class.

There are two abstract classes that implement ForkJoinTask: RecursiveTask which returns a value upon completion, and RecursiveAction which doesn't return anything. As the names imply, those classes are to be used for recursive tasks, like for example file-system navigation or complex mathematical computation.

Let's expand our previous example to create a class that, given an Integer, will calculate the sum squares for all its factorial elements. So, for instance, if we pass the number 4 to our calculator, we should get the result from the sum of 4² + 3² + 2² + 1² which is 30.

First of all, we need to create a concrete implementation of RecursiveTask and implement its compute() method. This is where we'll write our business logic:

public class FactorialSquareCalculator extends RecursiveTask { private Integer n; public FactorialSquareCalculator(Integer n) { this.n = n; } @Override protected Integer compute() { if (n <= 1) { return n; } FactorialSquareCalculator calculator = new FactorialSquareCalculator(n - 1); calculator.fork(); return n * n + calculator.join(); } }

Notice how we achieve recursiveness by creating a new instance of FactorialSquareCalculator within compute(). By calling fork(), a non-blocking method, we ask ForkJoinPool to initiate the execution of this subtask.

The join() method will return the result from that calculation, to which we add the square of the number we are currently visiting.

Now we just need to create a ForkJoinPool to handle the execution and thread management:

ForkJoinPool forkJoinPool = new ForkJoinPool(); FactorialSquareCalculator calculator = new FactorialSquareCalculator(10); forkJoinPool.execute(calculator);

6. Conclusion

In this article, we had a comprehensive view of the Future interface, visiting all its methods. We've also learned how to leverage the power of thread pools to trigger multiple parallel operations. The main methods from the ForkJoinTask class, fork() and join() were briefly covered as well.

We have many other great articles on parallel and asynchronous operations in Java. Here are three of them that are closely related to the Future interface (some of them are already mentioned in the article):

  • Guide to CompletableFuture – an implementation of Future with many extra features introduced in Java 8
  • Guida al framework Fork / Join in Java: ulteriori informazioni su ForkJoinTask trattate nella sezione 5
  • Guida alla Java ExecutorService - dedicato al ExecutorService interfaccia

Controlla il codice sorgente utilizzato in questo articolo nel nostro repository GitHub.