Gyakori párhuzamossági csapdák a Java-ban

1. Bemutatkozás

Ebben az oktatóanyagban megnézzük a Java leggyakoribb párhuzamossági problémáit. Megtanuljuk azt is, hogyan lehet ezeket elkerülni és azok fő okait.

2. Menetbiztos objektumok használata

2.1. Objektumok megosztása

A szálak elsősorban ugyanazon objektumokhoz való hozzáférés megosztásával kommunikálnak. Tehát egy objektumból olvasás közben annak változása váratlan eredményeket hozhat. Az objektum egyidejű megváltoztatása sérült vagy inkonzisztens állapotba kerülhet.

Az ilyen párhuzamossági problémák elkerülése és a megbízható kód összeállításának fő módja az, hogy megváltoztathatatlan objektumokkal dolgozzunk. Ennek az az oka, hogy állapotukat több szál interferenciája nem módosíthatja.

Azonban nem mindig dolgozhatunk változhatatlan tárgyakkal. Ezekben az esetekben meg kell találnunk a módját annak, hogyan változtathatjuk objektumainkat menetbiztonságban.

2.2. Gyűjtemények szálbiztonságossá tétele

Mint minden más objektum, a gyűjtemények is fenntartják belső állapotukat. Ezt meg lehet változtatni úgy, hogy több szál egyidejűleg megváltoztatja a gyűjteményt. Így, az egyik módja annak, hogy biztonságosan dolgozhassunk a gyűjteményekkel egy többszálas környezetben, az a szinkronizálás:

Térképtérkép = Collections.synchronizedMap (új HashMap ()); Lista lista = Collections.synchronizedList (új ArrayList ());

Általában a szinkronizálás segíti a kölcsönös kirekesztés elérését. Pontosabban, ezekhez a gyűjteményekhez egyszerre csak egy szál férhet hozzá. Így elkerülhetjük a gyűjtemények inkonzisztens állapotban hagyását.

2.3. Speciális többszálas gyűjtemények

Vegyünk egy forgatókönyvet, ahol több olvasásra van szükségünk, mint írásra. A szinkronizált gyűjtemény használatával alkalmazásunk jelentős következményekkel járhat. Ha két szál egyszerre akarja elolvasni a gyűjteményt, az egyiknek meg kell várnia, amíg a másik befejezi.

Emiatt a Java egyidejű gyűjteményeket biztosít, mint pl CopyOnWriteArrayList és ConcurrentHashMap amelyhez egyszerre több szál is hozzáférhet:

CopyOnWriteArrayList list = new CopyOnWriteArrayList (); Térképtérkép = new ConcurrentHashMap ();

A CopyOnWriteArrayList eléri a szálbiztonságot azáltal, hogy létrehoz egy külön másolatot az alapul szolgáló tömbből a mutatív műveletekhez, mint például az add vagy az remove. Bár az írási műveleteknél gyengébb a teljesítménye, mint a Collections.synchronizedList, jobb teljesítményt nyújt számunkra, amikor lényegesen több olvasásra van szükségünk, mint írásra.

ConcurrentHashMap alapvetően szálbiztos és jobban teljesít, mint a Collections.synchronizedMap tekerje körbe egy nem szálkás biztonságos Térkép. Ez valójában egy szálbiztos térkép a szálbiztos térképekről, amely lehetővé teszi a különböző tevékenységek egyidejű végrehajtását a gyermektérképekben.

2.4. Munka nem szálbiztos típusokkal

Gyakran használunk beépített objektumokat, például SimpleDateFormat a dátumobjektumok elemzése és formázása. A SimpleDateFormat osztály műveletei közben mutálja belső állapotát.

Nagyon óvatosnak kell lennünk velük, mert nem biztonságos a menet. Állapotuk sokféle szálú alkalmazásban következetlenné válhat, például a versenykörülmények miatt.

Szóval, hogyan használhatjuk a SimpleDateFormat biztonságban? Számos lehetőségünk van:

  • Hozzon létre egy új példányt SimpleDateFormat minden használatkor
  • Korlátozza az a használatával létrehozott objektumok számát ThreadLocal tárgy. Garantálja, hogy minden szálnak megvan a maga példánya SimpleDateFormat
  • Szinkronizálja az egyidejű hozzáférést több szálon a szinkronizált kulcsszó vagy zár

SimpleDateFormat csak egy példa erre. Ezeket a technikákat bármilyen nem szálbiztos típushoz alkalmazhatjuk.

3. Versenyfeltételek

Versenyfeltétel akkor fordul elő, amikor két vagy több szál hozzáfér a megosztott adatokhoz, és megpróbálják azokat egyszerre megváltoztatni. Így a versenykörülmények futásidejű hibákat vagy váratlan eredményeket okozhatnak.

3.1. Versenyfeltétel példa

Vegyük fontolóra a következő kódot:

class Counter {privát int számláló = 0; public void increment () {számláló ++; } public int getValue () {return counter; }}

A Számláló osztály úgy van megtervezve, hogy az inkrement metódus minden meghívása 1-et adjon a számláló. Ha azonban a Számláló Az objektumra több szál hivatkozik, a szálak közötti interferencia megakadályozhatja, hogy ez a várt módon történjen.

Bonthatjuk a számláló ++ utasítás 3 lépésben:

  • Az aktuális érték lekérése számláló
  • Növelje a lekért értéket 1-gyel
  • Tárolja vissza a növelt értéket számláló

Tegyük fel, hogy két szál, szál1 és menet2, egyszerre hívja meg az inkrement metódust. Összefűzött cselekedeteik ezt a sorrendet követhetik:

  • szál1 beolvassa az aktuális értékét számláló; 0
  • menet2 beolvassa az aktuális értékét számláló; 0
  • szál1 növeli a lekért értéket; az eredmény 1
  • menet2 növeli a lekért értéket; az eredmény 1
  • szál1 tárolja az eredményt számláló; az eredmény most 1
  • menet2 tárolja az eredményt számláló; az eredmény most 1

Számítottunk a számláló hogy 2 legyen, de 1 volt.

3.2. Szinkronizált megoldás

Az inkonzisztenciát kijavíthatjuk a kritikus kód szinkronizálásával:

class SynchronizedCounter {private int counter = 0; public synchronized void increment () {számláló ++; } public synchronized int getValue () {return counter; }}

Csak egy szál használhatja a szinkronizált az objektum metódusai egyszerre, ezért ez következetességet kényszerít a számláló.

3.3. Beépített megoldás

A fenti kódot beépítettre cserélhetjük AtomicInteger tárgy. Ez az osztály többek között atomi módszereket kínál egy egész szám növelésére, és jobb megoldás, mint a saját kódunk megírása. Ezért metódusait közvetlenül szinkronizálás nélkül hívhatjuk meg:

AtomicInteger atomicInteger = új AtomicInteger (3); atomicInteger.incrementAndGet ();

Ebben az esetben az SDK megoldja helyettünk a problémát. Ellenkező esetben a saját kódunkat is megírhatnánk, a kritikus szakaszokat egy egyedi szálbiztonsági osztályba foglalva. Ez a megközelítés segít minimalizálni a bonyolultságot és maximalizálni a kódunk újrafelhasználhatóságát.

4. Versenyfeltételek a gyűjtemények körül

4.1. A probléma

Egy másik buktató, amibe beleeshetünk, az, hogy azt gondoljuk, hogy a szinkronizált gyűjtemények nagyobb védelmet nyújtanak számunkra, mint valójában.

Vizsgáljuk meg az alábbi kódot:

Lista lista = Collections.synchronizedList (új ArrayList ()); if (! list.contains ("foo")) {list.add ("foo"); }

A listánk minden művelete szinkronizálva van, de a több metódus meghívás kombinációi nincsenek szinkronizálva. Pontosabban, a két művelet között egy másik szál módosíthatja gyűjteményünket, ami nem kívánt eredményekhez vezet.

Például két szál léphet be a ha egyszerre blokkolja, majd frissítse a listát, minden szál hozzáadva a foo értéket a listához.

4.2. Megoldás listákra

Szinkronizálással megvédhetjük a kódot attól, hogy egyszerre több szál érje el:

szinkronizált (lista) {if (! list.contains ("foo")) {list.add ("foo"); }}

Ahelyett, hogy hozzáadná a szinkronizált kulcsszó a funkciókhoz, létrehoztunk egy kritikus részt a lista, amely egyszerre csak egy szálat enged meg ennek a műveletnek a végrehajtására.

Meg kell jegyeznünk, hogy használhatjuk szinkronizált (lista) a listaobjektumunk egyéb műveleteiről, hogy a garantáljuk, hogy egyszerre csak egy szál hajthatja végre bármely műveletünket ezen a tárgyon.

4.3. Beépített megoldás ConcurrentHashMap

Vizsgáljuk meg ugyanezen okból a térkép használatát, nevezetesen egy bejegyzés hozzáadását, csak ha nincs.

A ConcurrentHashMap jobb megoldást kínál az ilyen típusú problémákra. Használhatjuk atomját putIfAbsent módszer:

Térképtérkép = new ConcurrentHashMap (); map.putIfAbsent ("foo", "bar");

Vagy ha ki akarjuk számolni az értéket, annak atomját computeIfAbsent módszer:

map.computeIfAbsent ("foo", kulcs -> kulcs + "sáv");

Meg kell jegyeznünk, hogy ezek a módszerek a Térkép ahol kényelmes módot kínálnak a feltételes logika írásának elkerülésére a beszúrás körül. Nagyon segítenek bennünket, amikor megpróbálunk több szálú atomi hívásokat kezdeményezni.

5. Memória-konzisztencia kérdések

A memória-konzisztencia problémái akkor fordulnak elő, ha több szálnak ellentmondásos a nézete arról, hogy mi legyen ugyanaz az adat.

A fő memória mellett a legtöbb modern számítógépes architektúra a gyorsítótárak (L1, L2 és L3 gyorsítótárak) hierarchiáját használja az általános teljesítmény javítása érdekében. Így bármely szál tárolhatja a változókat, mert gyorsabb hozzáférést biztosít a fő memóriához képest.

5.1. A probléma

Emlékezzünk a mi Számláló példa:

class Counter {privát int számláló = 0; public void increment () {számláló ++; } public int getValue () {return counter; }}

Vegyük fontolóra azt a forgatókönyvet, ahol szál1 növeli a számláló és akkor menet2 kiolvassa az értékét. A következő eseménysor fordulhat elő:

  • szál1 kiolvassa a számláló értékét a saját gyorsítótárából; számláló értéke 0
  • thread1 növeli a számlálót, és visszaírja a saját gyorsítótárába; számláló 1
  • menet2 kiolvassa a számláló értékét a saját gyorsítótárából; számláló értéke 0

Természetesen a várható eseménysor is megtörténhet, és a thread2 beolvassa a helyes értéket (1), de nincs garancia arra, hogy az egy szál által végrehajtott változtatások minden alkalommal láthatók lesznek a többi szál számára is.

5.2. A megoldás

A memória-konzisztencia hibák elkerülése érdekében létre kell hoznunk egy történés előtti kapcsolatot. Ez a kapcsolat egyszerűen garancia arra, hogy egy adott utasítás által végrehajtott memóriafrissítések láthatók egy másik konkrét utasítás számára.

Számos stratégia létezik, amelyek kapcsolatokat hoznak létre. Az egyik a szinkronizálás, amelyet már megvizsgáltunk.

A szinkronizálás biztosítja a kölcsönös kizárást és a memória konzisztenciáját. Ez azonban teljesítményköltséggel jár.

A memória konzisztencia problémáit elkerülhetjük a illó kulcsszó. Egyszerűen fogalmazva, az illékony változó minden változása mindig látható a többi szál számára.

Írjuk át a sajátunkat Számláló példa használatával illó:

class SyncronizedCounter {private volatile int counter = 0; public synchronized void increment () {számláló ++; } public int getValue () {return counter; }}

Meg kell jegyeznünk még mindig szinkronizálnunk kell az inkrement műveletet, mert illó nem biztosítja számunkra a kölcsönös kirekesztést. Az egyszerű atomi változóhoz való hozzáférés hatékonyabb, mint ezekhez a változókhoz szinkronizált kódon keresztül jutni.

5.3. Nem atomi hosszú és kettős Értékek

Tehát, ha egy változót megfelelő szinkronizálás nélkül olvasunk, akkor elavult értéket láthatunk. Fvagy hosszú és kettős értékek, meglepetésre meglehetősen véletlenszerű értékek láthatók az elavult értékek mellett.

A JLS-17 szerint a JVM a 64 bites műveleteket két külön 32 bites műveletként kezelheti. Ezért az a hosszú vagy kettős érték, lehetséges egy frissített 32 bites és egy elavult 32 bites olvasás. Következésképpen véletlenszerű megjelenést figyelhetünk meg hosszú vagy kettős értékek egyidejű összefüggésekben.

Másrészt ingadozó ír és olvas hosszú és kettős az értékek mindig atomiak.

6. Visszaélés a szinkronizálással

A szinkronizációs mechanizmus hatékony eszköz a szálbiztonság elérésére. Belső és külső zárak használatára támaszkodik. Emlékezzünk arra is, hogy minden objektumnak más a zárja, és egyszerre csak egy szál szerezhet zárat.

Ha azonban nem figyelünk oda, és gondosan kiválasztjuk a megfelelő zárakat a kritikus kódunkhoz, akkor váratlan viselkedés léphet fel.

6.1. Szinkronizálás be ez Referencia

A módszer-szintű szinkronizálás számos párhuzamossági kérdés megoldására szolgál. Ez azonban más párhuzamossági problémákhoz is vezethet, ha túlzott mértékben használják. Ez a szinkronizációs megközelítés a ez hivatkozás, mint zár, amelyet belső zárnak is neveznek.

A következő példákban láthatjuk, hogyan lehet egy módszer szintű szinkronizálást blokk szintű szinkronizációvá alakítani a ez hivatkozás zárként.

Ezek a módszerek egyenértékűek:

nyilvános szinkronizált void foo () {// ...}
public void foo () {szinkronizált (ez) {// ...}}

Amikor egy ilyen módszert egy szál hív meg, más szálak nem férhetnek hozzá egyidejűleg az objektumhoz. Ez csökkentheti a párhuzamossági teljesítményt, mivel minden egyszálasan fut. Ez a megközelítés különösen rossz, ha egy objektumot gyakrabban olvasnak, mint frissítenek.

Sőt, a kódunk ügyfele is megszerezheti a ez zár. A legrosszabb esetben ez a művelet holtponthoz vezethet.

6.2. Holtpont

A holtpont olyan helyzetet ír le, amikor két vagy több szál blokkolja egymást, mindegyik más szál birtokában lévő erőforrás megszerzésére vár.

Vizsgáljuk meg a példát:

public class DeadlockExample {public static Object lock1 = new Object (); public static Object lock2 = new Object (); public static void main (String args []) {Menetszál = új szál (() -> {szinkronizált (zár1) {System.out.println ("MenetA: Holding lock 1 ..."); sleep (); Rendszer .out.println ("ThreadA: Várakozás a 2-es zárolásra ..."); szinkronizált (lock2) {System.out.println ("ThreadA: 1. és 2. zár rögzítése ...");}}}); Menetszál B = új Szál (() -> {szinkronizált (zár2) {System.out.println ("MenetB: Zár 2-es tartó ..."); sleep (); System.out.println ("MenetB: Zárra vár 1 ... "); szinkronizált (lock1) {System.out.println (" ThreadB: 1. és 2. zár rögzítése ... ");}}}); menetA.start (); threadB.start (); }}

A fenti kódban ezt egyértelműen láthatjuk először menetA megszerzi zár1 és menetB megszerzi lock2. Azután, menetA megpróbálja megszerezni a lock2 amelyet már megszerzett menetB és menetB megpróbálja megszerezni a zár1 amelyet már megszerzett menetA. Tehát egyikük sem folytatja, vagyis holtponton van.

Könnyen megoldhatjuk ezt a problémát azáltal, hogy megváltoztatjuk a zárak sorrendjét az egyik szálban.

Meg kell jegyeznünk, hogy ez csak egy példa, és sok más is vezethet holtponthoz.

7. Következtetés

Ebben a cikkben több olyan párhuzamossági kérdést tártunk fel, amelyekkel valószínűleg találkozhatunk többszálú alkalmazásainkban.

Először megtudtuk, hogy olyan tárgyakat vagy műveleteket kell választanunk, amelyek változhatatlanok vagy szálbiztosak.

Ezután számos példát láttunk a versenykörülményekről és arról, hogyan kerülhetjük el ezeket a szinkronizációs mechanizmus segítségével. Ezenkívül megismertük a memóriával kapcsolatos versenyfeltételeket és azok elkerülésének módját.

Bár a szinkronizációs mechanizmus segít elkerülni számos egyidejűségi kérdést, könnyen visszaélhetünk vele, és más kérdéseket hozhatunk létre. Emiatt több olyan problémát is megvizsgáltunk, amelyekkel szembesülhetünk, ha ezt a mechanizmust rosszul alkalmazzák.

Szokás szerint az ebben a cikkben használt összes példa elérhető a GitHubon.