Java 101: Kesesuaian Java tanpa rasa sakit, Bahagian 2

Sebelumnya 1 2 3 4 Halaman 3 Seterusnya Halaman 3 daripada 4

Pemboleh ubah atom

Aplikasi multithreaded yang berjalan pada pemproses multicore atau sistem multiprosesor dapat mencapai penggunaan perkakasan yang baik dan sangat berskala. Mereka dapat mencapai tujuan ini dengan meminta benang mereka menghabiskan sebahagian besar waktunya untuk melakukan kerja daripada menunggu kerja diselesaikan, atau menunggu untuk memperoleh kunci untuk mengakses struktur data bersama.

Walau bagaimanapun, mekanisme penyegerakan tradisional Java, yang menguatkan pengecualian bersama (utas memegang kunci yang menjaga sekumpulan pemboleh ubah mempunyai akses eksklusif kepada mereka) dan keterlihatan (perubahan pada pemboleh ubah terlindung menjadi kelihatan oleh utas lain yang kemudian memperoleh kunci), memberi kesan penggunaan perkakasan dan skalabiliti, seperti berikut:

  • Penyegerakan yang diperjuangkan (beberapa utas yang selalu bersaing untuk mengunci) mahal dan hasilnya menderita akibatnya. Sebab utama perbelanjaan adalah pertukaran konteks yang kerap berlaku; operasi pertukaran konteks boleh memerlukan banyak kitaran pemproses untuk diselesaikan. Sebaliknya, penyegerakan yang tidak terkawal adalah murah untuk JVM moden.
  • Apabila utas yang memegang kunci tertunda (mis., Kerana penundaan penjadwalan), tidak ada utas yang memerlukan kunci itu membuat kemajuan, dan perkakasan tidak digunakan sebagaimana mestinya.

Anda mungkin berfikir bahawa anda boleh menggunakan volatilesebagai alternatif penyegerakan. Walau bagaimanapun, volatilepemboleh ubah hanya menyelesaikan masalah penglihatan. Mereka tidak boleh digunakan untuk melaksanakan urutan membaca-ubah suai-menulis atom yang diperlukan untuk melaksanakan kaunter dan entiti lain yang memerlukan pengecualian bersama dengan selamat.

Java 5 memperkenalkan alternatif penyegerakan yang menawarkan pengecualian bersama yang digabungkan dengan prestasi volatile. Ini berubah-ubah atom alternatif adalah berdasarkan kepada arahan perbandingan-dan-swap mikropemproses dan sebahagian besarnya terdiri daripada jenis dalam java.util.concurrent.atomicpakej.

Memahami membandingkan dan menukar

Arahan membandingkan-dan-pertukaran (CAS) adalah arahan tidak terganggu yang membaca lokasi memori, membandingkan nilai baca dengan nilai yang diharapkan, dan menyimpan nilai baru di lokasi memori apabila nilai baca sepadan dengan nilai yang diharapkan. Jika tidak, tidak ada yang dilakukan. Arahan mikropemproses sebenarnya mungkin sedikit berbeza (contohnya, kembali benar jika CAS berjaya atau salah sebaliknya bukan nilai baca).

Arahan CAS mikropemproses

Mikroprosesor moden menawarkan beberapa jenis arahan CAS. Sebagai contoh, mikropemproses Intel menawarkan sekumpulan cmpxchgarahan, sedangkan mikropemproses PowerPC menawarkan arahan pautan beban (mis. lwarx) Dan stwcxarahan bersyarat (contohnya ) untuk tujuan yang sama.

CAS memungkinkan untuk menyokong urutan baca-ubah-tulis atom. Anda biasanya menggunakan CAS seperti berikut:

  1. Baca nilai v dari alamat X.
  2. Lakukan pengiraan pelbagai langkah untuk memperoleh nilai baru v2.
  3. Gunakan CAS untuk menukar nilai X dari v ke v2. CAS berjaya apabila nilai X tidak berubah semasa melakukan langkah-langkah ini.

Untuk melihat bagaimana CAS menawarkan prestasi yang lebih baik (dan skalabilitas) berbanding penyegerakan, pertimbangkan contoh pembilang yang membolehkan anda membaca nilai semasa dan menambah pembilang. Kelas berikut melaksanakan pembilang berdasarkan synchronized:

Penyenaraian 4. Counter.java (versi 1)

public class Counter { private int value; public synchronized int getValue() { return value; } public synchronized int increment() { return ++value; } }

Persengketaan yang tinggi untuk kunci monitor akan mengakibatkan peralihan konteks yang berlebihan yang dapat menunda semua utas dan menghasilkan aplikasi yang tidak sesuai dengan skala.

Alternatif CAS memerlukan pelaksanaan arahan membandingkan dan menukar. Kelas berikut mencontohi CAS. Ia menggunakan synchronizedbukannya arahan perkakasan sebenar untuk mempermudah kod:

Penyenaraian 5. EmulatedCAS.java

public class EmulatedCAS { private int value; public synchronized int getValue() { return value; } public synchronized int compareAndSwap(int expectedValue, int newValue) { int readValue = value; if (readValue == expectedValue) value = newValue; return readValue; } }

Di sini, valuemengenal pasti lokasi memori, yang dapat diambil oleh getValue(). Juga, compareAndSwap()menerapkan algoritma CAS.

Kelas berikut digunakan EmulatedCASuntuk melaksanakan bukan synchronizedkaunter (berpura-pura EmulatedCAStidak memerlukan synchronized)

Penyenaraian 6. Counter.java (versi 2)

public class Counter { private EmulatedCAS value = new EmulatedCAS(); public int getValue() { return value.getValue(); } public int increment() { int readValue = value.getValue(); while (value.compareAndSwap(readValue, readValue+1) != readValue) readValue = value.getValue(); return readValue+1; } }

Countermerangkum EmulatedCAScontoh dan menyatakan kaedah untuk mendapatkan dan menambah nilai pembilang dengan bantuan dari contoh ini. getValue()mengambil "nilai pembilang semasa" contoh dan increment()menambah nilai pembilang dengan selamat.

increment()berulang kali meminta compareAndSwap()sehingga readValuenilai tidak berubah. Kemudian bebas untuk menukar nilai ini. Apabila tidak ada kunci yang terlibat, pertengkaran dihindari bersamaan dengan pertukaran konteks yang berlebihan. Prestasi bertambah baik dan kodnya lebih sesuai.

ReentrantLock dan CAS

Anda sebelum ini mengetahui bahawa ia ReentrantLockmenawarkan prestasi yang lebih baik daripada yang synchronizeddiperjuangkan. Untuk meningkatkan prestasi, ReentrantLockpenyelarasan dikendalikan oleh subkelas java.util.concurrent.locks.AbstractQueuedSynchronizerkelas abstrak . Sebaliknya, kelas ini memanfaatkan sun.misc.Unsafekelas tanpa dokumen dan compareAndSwapInt()kaedah CASnya.

Meneroka pakej pemboleh ubah atom

Anda tidak perlu melaksanakannya compareAndSwap()melalui Java Native Interface. Sebagai gantinya, Java 5 menawarkan sokongan ini melalui java.util.concurrent.atomic: sekumpulan alat kelas yang digunakan untuk pengaturcaraan tanpa kunci dan selamat pada benang tunggal.

Menurut java.util.concurrent.atomicJavadoc, kelas-kelas ini

memanjangkan pengertian volatilenilai, medan, dan elemen array kepada elemen yang juga menyediakan operasi kemas kini bersyarat atom borang boolean compareAndSet(expectedValue, updateValue). Kaedah ini (yang berbeza-beza dalam jenis argumen di kelas yang berbeza) secara atomik menetapkan pemboleh ubah ke updateValuejika ia saat ini memegang expectedValue, melaporkan benar mengenai kejayaan.

Pakej ini menawarkan kelas untuk jenis Boolean ( AtomicBoolean), integer ( AtomicInteger), integer panjang ( AtomicLong) dan rujukan ( AtomicReference). Ia juga menawarkan versi pelbagai integer, integer panjang, dan rujukan ( AtomicIntegerArray, AtomicLongArray, dan AtomicReferenceArray), kelas rujukan markable dan dicop untuk atom mengemaskini sepasang nilai ( AtomicMarkableReferencedan AtomicStampedReference), dan banyak lagi.

Melaksanakan membandingkanAndSet ()

Java mengimplementasikan compareAndSet()melalui konstruksi asli yang paling cepat tersedia (contohnya, cmpxchgatau load-link / store-conditional) atau (dalam keadaan terburuk) kunci putaran .

Pertimbangkan AtomicInteger, yang membolehkan anda mengemas kini intnilai secara automatik. Kita boleh menggunakan kelas ini untuk melaksanakan pembilang yang ditunjukkan dalam Penyenaraian 6. Penyenaraian 7 menunjukkan kod sumber yang setara.

Penyenaraian 7. Counter.java (versi 3)

import java.util.concurrent.atomic.AtomicInteger; public class Counter { private AtomicInteger value = new AtomicInteger(); public int getValue() { return value.get(); } public int increment() { int readValue = value.get(); while (!value.compareAndSet(readValue, readValue+1)) readValue = value.get(); return readValue+1; } }

Listing 7 is very similar to Listing 6 except that it replaces EmulatedCAS with AtomicInteger. Incidentally, you can simplify increment() because AtomicInteger supplies its own int getAndIncrement() method (and similar methods).

Fork/Join framework

Computer hardware has evolved significantly since Java's debut in 1995. Back in the day, single-processor systems dominated the computing landscape and Java's synchronization primitives, such as synchronized and volatile, as well as its threading library (the Thread class, for example) were generally adequate.

Multiprocessor systems became cheaper and developers found themselves needing to create Java applications that effectively exploited the hardware parallelism that these systems offered. However, they soon discovered that Java's low-level threading primitives and library were very difficult to use in this context, and the resulting solutions were often riddled with errors.

What is parallelism?

Parallelism is the simultaneous execution of multiple threads/tasks via some combination of multiple processors and processor cores.

The Java Concurrency Utilities framework simplifies the development of these applications; however, the utilities offered by this framework do not scale to thousands of processors or processor cores. In our many-core era, we need a solution for achieving a finer-grained parallelism, or we risk keeping processors idle even when there is lots of work for them to handle.

Professor Doug Lea presented a solution to this problem in his paper introducing the idea for a Java-based fork/join framework. Lea describes a framework that supports "a style of parallel programming in which problems are solved by (recursively) splitting them into subtasks that are solved in parallel." The Fork/Join framework was eventually included in Java 7.

Overview of the Fork/Join framework

The Fork/Join framework is based on a special executor service for running a special kind of task. It consists of the following types that are located in the java.util.concurrent package:

  • ForkJoinPool: an ExecutorService implementation that runs ForkJoinTasks. ForkJoinPool provides task-submission methods, such as void execute(ForkJoinTask task), along with management and monitoring methods, such as int getParallelism() and long getStealCount().
  • ForkJoinTask: an abstract base class for tasks that run within a ForkJoinPool context. ForkJoinTask describes thread-like entities that have a much lighter weight than normal threads. Many tasks and subtasks can be hosted by very few actual threads in a ForkJoinPool instance.
  • ForkJoinWorkerThread: a class that describes a thread managed by a ForkJoinPool instance. ForkJoinWorkerThread is responsible for executing ForkJoinTasks.
  • RecursiveAction: an abstract class that describes a recursive resultless ForkJoinTask.
  • RecursiveTask: an abstract class that describes a recursive result-bearing ForkJoinTask.

The ForkJoinPool executor service is the entry-point for submitting tasks that are typically described by subclasses of RecursiveAction or RecursiveTask. Behind the scenes, the task is divided into smaller tasks that are forked (distributed among different threads for execution) from the pool. A task waits until joined (its subtasks finish so that results can be combined).

ForkJoinPool manages a pool of worker threads, where each worker thread has its own double-ended work queue (deque). When a task forks a new subtask, the thread pushes the subtask onto the head of its deque. When a task tries to join with another task that hasn't finished, the thread pops another task off the head of its deque and executes the task. If the thread's deque is empty, it tries to steal another task from the tail of another thread's deque. This work stealing behavior maximizes throughput while minimizing contention.

Using the Fork/Join framework

Fork/Join was designed to efficiently execute divide-and-conquer algorithms, which recursively divide problems into sub-problems until they are simple enough to solve directly; for example, a merge sort. The solutions to these sub-problems are combined to provide a solution to the original problem. Each sub-problem can be executed independently on a different processor or core.

Lea's paper presents the following pseudocode to describe the divide-and-conquer behavior:

Result solve(Problem problem) { if (problem is small) directly solve problem else { split problem into independent parts fork new subtasks to solve each part join all subtasks compose result from subresults } }

The pseudocode presents a solve method that's called with some problem to solve and which returns a Result that contains the problem's solution. If the problem is too small to solve via parallelism, it's solved directly. (The overhead of using parallelism on a small problem exceeds any gained benefit.) Otherwise, the problem is divided into subtasks: each subtask independently focuses on part of the problem.

Operation fork launches a new fork/join subtask that will execute in parallel with other subtasks. Operation join delays the current task until the forked subtask finishes. At some point, the problem will be small enough to be executed sequentially, and its result will be combined along with other subresults to achieve an overall solution that's returned to the caller.

The Javadoc for the RecursiveAction and RecursiveTask classes presents several divide-and-conquer algorithm examples implemented as fork/join tasks. For RecursiveAction the examples sort an array of long integers, increment each element in an array, and sum the squares of each element in an array of doubles. RecursiveTask's solitary example computes a Fibonacci number.

Penyenaraian 8 menyajikan aplikasi yang menunjukkan contoh penyortiran dalam konteks bukan garpu / gabung serta garpu / gabung. Ia juga menyajikan beberapa maklumat masa untuk membandingkan kelajuan penyortiran.