Panduan untuk java.util.concurrent.Future

1. Ikhtisar

Pada artikel ini, kita akan belajar tentang Masa Depan . Antarmuka yang sudah ada sejak Java 1.5 dan bisa sangat berguna saat bekerja dengan panggilan asinkron dan pemrosesan bersamaan.

2. Menciptakan Futures

Sederhananya, kelas Future mewakili hasil komputasi asinkron di masa mendatang - hasil yang pada akhirnya akan muncul di Future setelah pemrosesan selesai.

Mari kita lihat cara menulis metode yang membuat dan mengembalikan instance Future .

Metode yang berjalan lama adalah kandidat yang baik untuk pemrosesan asinkron dan antarmuka Future . Ini memungkinkan kami untuk menjalankan beberapa proses lain sementara kami menunggu tugas yang dikemas di Future untuk diselesaikan.

Beberapa contoh operasi yang akan memanfaatkan sifat Asinkron Masa Depan adalah:

  • proses intensif komputasi (perhitungan matematika dan ilmiah)
  • memanipulasi struktur data besar (data besar)
  • panggilan metode jarak jauh (mengunduh file, scrapping HTML, layanan web).

2.1. Menerapkan Futures Dengan FutureTask

Untuk contoh kita, kita akan membuat kelas yang sangat sederhana yang menghitung kuadrat dari sebuah Integer . Ini jelas tidak cocok dengan kategori metode "berjalan lama", tetapi kita akan melakukan panggilan Thread.sleep () ke sana agar 1 detik terakhir selesai:

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

Bit kode yang benar-benar melakukan kalkulasi terdapat dalam metode call () , yang disediakan sebagai ekspresi lambda. Seperti yang Anda lihat, tidak ada yang istimewa tentang itu, kecuali untuk panggilan sleep () yang disebutkan sebelumnya.

Ini menjadi lebih menarik ketika kita mengarahkan perhatian kita pada penggunaan Callable dan ExecutorService .

Callable adalah antarmuka yang mewakili tugas yang mengembalikan hasil dan memiliki metode call () tunggal . Di sini, kami telah membuat sebuah instance menggunakan ekspresi lambda.

Membuat instance Callable tidak membawa kita ke mana-mana, kita masih harus meneruskan instance ini ke eksekutor yang akan mengurus memulai tugas itu di utas baru dan memberi kita kembali objek Future yang berharga . Di situlah ExecutorService masuk.

Ada beberapa cara kita bisa mendapatkan instance ExecutorService , kebanyakan dari mereka disediakan oleh metode pabrik statis Executor kelas utilitas . Dalam contoh ini, kita telah menggunakan newSingleThreadExecutor () dasar , yang memberi kita ExecutorService yang mampu menangani satu thread dalam satu waktu.

Setelah kita memiliki objek ExecutorService , kita hanya perlu memanggil submit () meneruskan Callable kita sebagai argumen. submit () akan menangani awal tugas dan mengembalikan objek FutureTask , yang merupakan implementasi antarmuka Future .

3. Mengkonsumsi Futures

Sampai saat ini, kami telah mempelajari cara membuat instance Future .

Di bagian ini, kita akan mempelajari cara bekerja dengan instance ini dengan menjelajahi semua metode yang merupakan bagian dari API Future .

3.1. Menggunakan isDone () dan get () untuk Mendapatkan Hasil

Sekarang kita perlu memanggil kalkulasi () dan menggunakan Future yang dikembalikan untuk mendapatkan Integer yang dihasilkan . Dua metode dari Future API akan membantu kami dalam tugas ini.

Future.isDone () memberi tahu kita jika eksekutor telah selesai memproses tugas. Jika tugas selesai, itu akan mengembalikan benar jika tidak, itu mengembalikan salah .

Metode yang mengembalikan hasil sebenarnya dari penghitungan adalah Future.get () . Perhatikan bahwa metode ini memblokir eksekusi hingga tugas selesai, tetapi dalam contoh kami, ini tidak akan menjadi masalah karena kami akan memeriksa terlebih dahulu apakah tugas tersebut diselesaikan dengan memanggil isDone () .

Dengan menggunakan kedua metode ini kita dapat menjalankan beberapa kode lain sambil menunggu tugas utama selesai:

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

Dalam contoh ini, kami menulis pesan sederhana pada output untuk memberi tahu pengguna bahwa program melakukan penghitungan.

Metode get () akan memblokir eksekusi hingga tugas selesai. Tetapi kita tidak perlu khawatir tentang itu karena contoh kita hanya sampai pada titik di mana get () dipanggil setelah memastikan bahwa tugas telah selesai. Jadi, dalam skenario ini, future.get () akan selalu kembali dengan segera.

Perlu disebutkan bahwa get () memiliki versi kelebihan beban yang memerlukan waktu tunggu dan TimeUnit sebagai argumen:

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

Perbedaan antara get (long, TimeUnit) dan get () , adalah bahwa get () akan memunculkan TimeoutException jika tugas tidak kembali sebelum periode waktu tunggu yang ditentukan.

3.2. Membatalkan Masa Depan dengan batal ()

Misalkan kita telah memicu tugas tetapi, karena alasan tertentu, kita tidak peduli lagi dengan hasilnya. Kita bisa menggunakan Future.cancel (boolean) untuk memberi tahu eksekutor untuk menghentikan operasi dan mengganggu thread yang mendasarinya:

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

Contoh Future kami dari kode di atas tidak akan pernah menyelesaikan operasinya. Faktanya, jika kita mencoba memanggil get () dari instance itu, setelah panggilan ke cancel () , hasilnya adalah CancellationException . Future.isCancelled () akan memberi tahu kita jika Future sudah dibatalkan. Ini bisa sangat berguna untuk menghindari terjadinya CancellationException .

Ada kemungkinan bahwa panggilan ke cancel () gagal. Dalam hal ini, nilai yang dikembalikan akan salah . Perhatikan bahwa cancel () mengambil nilai boolean sebagai argumen - ini mengontrol apakah utas yang menjalankan tugas ini harus diinterupsi atau tidak.

4. Lebih Banyak Multithreading Dengan Thread Pools

ExecutorService kami saat ini adalah single threaded karena diperoleh dengan Executors.newSingleThreadExecutor. Untuk menyorot "utas tunggal" ini, mari kita picu dua penghitungan secara bersamaan:

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();

Sekarang mari kita analisis keluaran untuk kode ini:

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
  • Panduan untuk Fork / Join Framework di Java - lebih lanjut tentang ForkJoinTask yang kita bahas di bagian 5
  • Panduan untuk Java ExecutorService - didedikasikan untuk antarmuka ExecutorService

Periksa kode sumber yang digunakan dalam artikel ini di repositori GitHub kami.