Java 101: Java istodobnost bez muke, 2. dio

Prethodna 1 2 3 4 Stranica 3 Sljedeća Stranica 3 od 4

Atomske varijable

Višenitne aplikacije koje se izvode na višejezgrenim procesorima ili višeprocesorskim sustavima mogu postići dobru iskorištenost hardvera i biti vrlo skalabilne. Te ciljeve mogu postići tako da njihove niti provode većinu svog vremena obavljajući posao, umjesto da čekaju da se posao završi ili čekaju da nabave brave kako bi pristupili dijeljenim strukturama podataka.

Međutim, tradicionalni Java-ov mehanizam sinkronizacije, koji provodi međusobno isključivanje (nit koja drži bravu koja čuva skup varijabli ima ekskluzivan pristup njima) i vidljivost (promjene zaštićenih varijabli postaju vidljive ostalim nitima koje naknadno stječu zaključavanje), upotreba i skalabilnost hardvera, kako slijedi:

  • Privođena sinkronizacija (više niti se neprekidno natječe za zaključavanje) skupa je i rezultat toga je propusnost. Glavni razlog troškova je često mijenjanje konteksta koje se događa; operacija prebacivanja konteksta može potrajati do mnogih ciklusa procesora. Suprotno tome, neograničena sinkronizacija nije skupa na modernim JVM-ovima.
  • Kada nit koja zadržava bravu kasni (npr. Zbog kašnjenja rasporeda), niti jedna nit koja zahtijeva to zaključavanje ne napreduje, a hardver se ne koristi onako kako bi inače mogao biti.

Možda mislite da možete koristiti volatilekao alternativu za sinkronizaciju. Međutim, volatilevarijable rješavaju samo problem vidljivosti. Ne mogu se koristiti za sigurnu implementaciju atomskih sekvenci čitanja-izmjene-pisanja koje su potrebne za sigurnu implementaciju brojača i drugih entiteta koji zahtijevaju međusobno izuzeće.

Java 5 predstavila je alternativu za sinkronizaciju koja nudi međusobno isključivanje u kombinaciji s performansama sustava Windows volatile. Ova alternativa atomske varijable temelji se na uputi mikroprocesora za usporedbu i zamjenu i uglavnom se sastoji od tipova u java.util.concurrent.atomicpaketu.

Razumijevanje usporedbe i zamjene

Usporediti-i-zamjena (CAS) nastava je neprekidni pouku da čita memorijske lokacije, uspoređuje čitanja vrijednosti s očekivanim vrijednosti, i pohranjuje novu vrijednost u mjestu memorije kada je čitanje vrijednost odgovara očekivanu vrijednost. Inače se ništa ne poduzima. Stvarne upute mikroprocesora mogu se donekle razlikovati (npr. Vratiti true ako je CAS uspio ili inače false umjesto pročitane vrijednosti).

Upute za mikroprocesor CAS

Suvremeni mikroprocesori nude neku vrstu CAS uputa. Na primjer, Intel mikroprocesori nude cmpxchgobitelj uputa, dok PowerPC mikroprocesori nude upute za vezu učitavanja (npr. lwarx) I uvjete za pohranu (npr. stwcx) U istu svrhu.

CAS omogućuje podršku atomskim sekvencama čitanja-izmjene-pisanja. Obično biste koristili CAS na sljedeći način:

  1. Očitavanje vrijednosti v s adrese X.
  2. Izvedite višestepeno računanje da biste izveli novu vrijednost v2.
  3. Koristite CAS za promjenu vrijednosti X iz v u v2. CAS uspijeva kada se vrijednost X nije promijenila tijekom izvođenja ovih koraka.

Da biste vidjeli kako CAS nudi bolje performanse (i skalabilnost) tijekom sinkronizacije, razmotrite primjer brojača koji vam omogućuje čitanje njegove trenutne vrijednosti i povećanje brojača. Sljedeća klasa implementira brojač temeljen na synchronized:

Popis 4. Counter.java (verzija 1)

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

Velika prepirka za zaključavanje monitora rezultirat će pretjeranim prebacivanjem konteksta koje može odgoditi sve niti i rezultirati aplikacijom koja se ne prilagođava dobro.

CAS alternativa zahtijeva primjenu uputa usporedbe i zamjene. Sljedeća klasa oponaša CAS. Ona koristi synchronizedumjesto stvarnog hardvera instrukcije za pojednostavljenje kod:

Popis 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; } }

Ovdje valueidentificira memorijsko mjesto koje može doći do getValue(). Također, compareAndSwap()implementira CAS algoritam.

Sljedeća klasa koristi EmulatedCASza implementaciju synchronizedbrojača koji EmulatedCASnije brojač (pretvarajte se da to nije potrebno synchronized):

Unos 6. Counter.java (verzija 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; } }

Counterenkapsulira EmulatedCASinstancu i deklarira metode za dohvaćanje i povećanje brojača uz pomoć ove instance. getValue()dohvaća "trenutnu vrijednost brojača" instance i increment()sigurno povećava vrijednost brojača.

increment()opetovano poziva compareAndSwap()sve dok se readValuevrijednost ne promijeni. Tada možete besplatno promijeniti ovu vrijednost. Kad nije uključeno zaključavanje, izbjegava se prepirka zajedno s pretjeranim mijenjanjem konteksta. Izvedba se poboljšava, a kod je skalabilniji.

ReentrantLock i CAS

Ranije ste naučili da ReentrantLocknudi bolje performanse nego synchronizedpod visokim sukobom niti. Da bi se poboljšale performanse, ReentrantLocksinkronizacijom upravlja podklasa apstraktne java.util.concurrent.locks.AbstractQueuedSynchronizerklase. Zauzvrat, ova klasa koristi sun.misc.Unsafeklasu bez dokumenata i njezinu compareAndSwapInt()CAS metodu.

Istraživanje paketa atomskih varijabli

Ne morate implementirati compareAndSwap()putem neprenosivog Java Native Interface-a. Umjesto toga, Java 5 nudi ovu podršku putem java.util.concurrent.atomic: alata klasa koji se koriste za programiranje na pojedinačnim varijablama bez zaključavanja i niti sigurno.

Prema java.util.concurrent.atomic'Javadocu', ove klase

proširiti pojam volatilevrijednosti, polja i elemenata niza na one koji također pružaju atomsku uvjetnu operaciju ažuriranja obrasca boolean compareAndSet(expectedValue, updateValue). Ova metoda (koja se razlikuje u vrstama argumenata u različitim klasama) atomski postavlja varijablu na vrijednost updateValueako ona trenutno drži expectedValue, izvještavanje istinito o uspjehu.

Ovaj paket nudi klase za vrste Boolean ( AtomicBoolean), integer ( AtomicInteger), long integer ( AtomicLong) i reference ( AtomicReference). Ona također nudi array verzije integer, dugo cijeli broj i reference ( AtomicIntegerArray, AtomicLongArrayi AtomicReferenceArray), markable i udarao referentne klase za Atomička ažuriranje par vrijednosti ( AtomicMarkableReferencei AtomicStampedReference), i još mnogo toga.

Provedba compareAndSet ()

Java se implementira compareAndSet()putem najbrže dostupne izvorne konstrukcije (npr., cmpxchgIli load-link / store-conditional) ili (u najgorem slučaju) spin brave .

Razmotrite AtomicInteger, što vam omogućuje intatomsko ažuriranje vrijednosti. Ovu klasu možemo koristiti za implementaciju brojača prikazanog u Popisu 6. Popis 7 predstavlja ekvivalentni izvorni kod.

Popis 7. Counter.java (verzija 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.

Popis 8 predstavlja aplikaciju koja prikazuje primjer sortiranja u kontekstima koji nisu fork / join, kao i fork / join. Također sadrži neke informacije o vremenu koje kontrastiraju brzine sortiranja.