Časté úskalia súbežnosti v Jave

1. Úvod

V tomto výučbe sa dozvieme niektoré z najbežnejších problémov súbežnosti v Jave. Dozvieme sa tiež, ako sa im vyhnúť a ich hlavným príčinám.

2. Používanie objektov bezpečných pre vlákna

2.1. Zdieľanie objektov

Vlákna primárne komunikujú zdieľaním prístupu k rovnakým objektom. Takže čítanie z objektu, keď sa mení, môže priniesť neočakávané výsledky. Súčasná zmena objektu tiež môže spôsobiť jeho poškodenie alebo nekonzistentnosť.

Hlavným spôsobom, ako sa môžeme vyhnúť takýmto problémom so súbežnosťou a vytvoriť spoľahlivý kód, je práca s nemennými objektmi. Je to tak preto, lebo ich stav nie je možné upraviť interferenciou viacerých vlákien.

Nie vždy však môžeme pracovať s nemennými objektmi. V týchto prípadoch musíme nájsť spôsoby, ako zaistiť, aby boli naše premenlivé objekty chránené voči vláknam.

2.2. Vytváranie zbierok bezpečných pre vlákna

Rovnako ako akýkoľvek iný objekt, zbierky udržiavajú stav interne. To by sa dalo zmeniť tým, že viacero vlákien zmení kolekciu súčasne. Takže jedným zo spôsobov, ako môžeme bezpečne pracovať so zbierkami v prostredí s viacerými vláknami, je ich synchronizácia:

Mapa mapy = Collections.synchronizedMap (nový HashMap ()); Zoznam zoznam = Collections.synchronizedList (new ArrayList ());

Synchronizácia nám vo všeobecnosti pomáha dosiahnuť vzájomné vylúčenie. Konkrétnejšie, tieto zbierky sú prístupné iba jedným vláknom súčasne. Môžeme sa teda vyhnúť tomu, aby sme zbierky ponechali v nekonzistentnom stave.

2.3. Špeciálne viacvláknové kolekcie

Teraz uvažujme o scenári, keď potrebujeme viac čítaní ako zápisov. Použitím synchronizovanej kolekcie môže naša aplikácia utrpieť veľké dôsledky na výkon. Ak chcú dve vlákna čítať zbierku súčasne, jedno musí počkať, kým sa druhá nedokončí.

Z tohto dôvodu Java poskytuje súbežné zbierky ako napr CopyOnWriteArrayList a ConcurrentHashMap ktoré sú prístupné súčasne z viacerých vlákien:

CopyOnWriteArrayList list = nový CopyOnWriteArrayList (); Mapa mapy = nový ConcurrentHashMap ();

The CopyOnWriteArrayList dosahuje bezpečnosť vlákien vytvorením samostatnej kópie základného poľa pre mutačné operácie, ako je pridanie alebo odstránenie. Aj keď má horší výkon pri operáciách zápisu ako a Collections.synchronizedList, poskytuje nám lepší výkon, keď potrebujeme podstatne viac čítaní ako zápisov.

ConcurrentHashMap je v zásade bezpečný pre vlákna a je výkonnejší ako Zbierky.synchronizovanáMapa obal okolo vlákna, ktoré nie je bezpečné Mapa. Je to vlastne mapa bezpečná pre vlákna z vlákien bezpečných máp, umožňujúca rôzne aktivity prebiehať súčasne v jej podradených mapách.

2.4. Práca s typmi, ktoré nie sú bezpečné pre vlákna

Často používame zabudované objekty ako SimpleDateFormat analyzovať a formátovať dátumové objekty. The SimpleDateFormat trieda pri svojej činnosti mutuje svoj vnútorný stav.

Musíme s nimi byť veľmi opatrní, pretože nie sú bezpečné pre vlákna. Ich stav môže byť pri viacvláknovej aplikácii nekonzistentný v dôsledku napr. Rasových podmienok.

Ako teda môžeme použiť SimpleDateFormat bezpečne? Máme niekoľko možností:

  • Vytvorte novú inštanciu súboru SimpleDateFormat zakaždým, keď sa použije
  • Obmedzte počet objektov vytvorených pomocou a ThreadLocal objekt. Zaručuje, že každé vlákno bude mať svoju vlastnú inštanciu SimpleDateFormat
  • Synchronizujte súbežný prístup s viacerými vláknami s účtom synchronizované kľúčové slovo alebo zámok

SimpleDateFormat je len jedným príkladom toho. Tieto techniky môžeme použiť s akýmkoľvek typom, ktorý nie je bezpečný pre vlákna.

3. Podmienky pretekov

Podmienka rasy nastane, keď dva alebo viac vlákien pristupuje k zdieľaným údajom a pokúšajú sa ich zmeniť súčasne. Podmienky závodu teda môžu spôsobiť chyby za behu alebo neočakávané výsledky.

3.1. Príklad závodných podmienok

Uvažujme o nasledujúcom kóde:

počítadlo triedy {private int counter = 0; public void increment () {counter ++; } public int getValue () {vratne pocet; }}

The Počítadlo trieda je navrhnutá tak, aby každé vyvolanie metódy prírastku pridalo 1 k pult. Ak však a Počítadlo objekt je odkazovaný z viacerých vlákien, interferencia medzi vláknami môže zabrániť tomu, aby sa to stalo podľa očakávania.

Môžeme rozložiť pult ++ vyhlásenie do 3 krokov:

  • Načítať aktuálnu hodnotu pult
  • Zvýšiť načítanú hodnotu o 1
  • Uložte zvýšenú hodnotu späť pult

Teraz predpokladajme dve vlákna, vlákno1 a vlákno2, súčasne vyvolať metódu prírastku. Ich vložené akcie môžu nasledovať túto postupnosť:

  • vlákno1 prečíta aktuálnu hodnotu pult; 0
  • vlákno2 načíta aktuálnu hodnotu pult; 0
  • vlákno1 zvyšuje načítanú hodnotu; výsledok je 1
  • vlákno2 zvyšuje načítanú hodnotu; výsledok je 1
  • vlákno1 uloží výsledok do pult; výsledok je teraz 1
  • vlákno2 uloží výsledok do pult; výsledok je teraz 1

Očakávali sme hodnotu pult byť 2, ale to bolo 1.

3.2. Synchronizované riešenie

Túto nekonzistenciu môžeme vyriešiť synchronizáciou kritického kódu:

trieda SynchronizedCounter {private int counter = 0; public synchronized void increment () {counter ++; } public synchronized int getValue () {return counter; }}

Iba jedno vlákno je povolené používať synchronizované metódy objektu kedykoľvek, takže si to vynúti dôslednosť pri čítaní a zápise textu pult.

3.3. Zabudované riešenie

Vyššie uvedený kód môžeme nahradiť vstavaným AtomicInteger objekt. Táto trieda ponúka okrem iného atómové metódy zvyšovania celého čísla a je lepším riešením ako písanie nášho vlastného kódu. Preto môžeme jeho metódy nazývať priamo bez potreby synchronizácie:

AtomicInteger atomicInteger = nový AtomicInteger (3); atomicInteger.incrementAndGet ();

V takom prípade problém vyrieši SDK. V opačnom prípade by sme mohli napísať aj náš vlastný kód, ktorý by zapuzdril kritické časti do vlastnej triedy bezpečnej pre vlákna. Tento prístup nám pomáha minimalizovať zložitosť a maximalizovať opätovnú použiteľnosť nášho kódu.

4. Podmienky pretekov okolo zbierok

4.1. Problém

Ďalším úskalím, do ktorého môžeme spadnúť, je myslieť si, že synchronizované zbierky nám poskytujú väčšiu ochranu, ako v skutočnosti poskytujú.

Poďme preskúmať kód uvedený nižšie:

Zoznam zoznam = Collections.synchronizedList (new ArrayList ()); if (! list.contains ("foo")) {list.add ("foo"); }

Každá operácia nášho zoznamu je synchronizovaná, ale žiadne kombinácie vyvolaných viacerými metódami sa nesynchronizujú. Presnejšie povedané, medzi týmito dvoma operáciami môže iné vlákno upraviť našu kolekciu, čo vedie k nežiaducim výsledkom.

Napríklad dve vlákna by mohli vstúpiť do ak blokujte súčasne a potom aktualizujte zoznam a každé vlákno pridáva znak foo hodnotu do zoznamu.

4.2. Riešenie pre zoznamy

Pomocou synchronizácie môžeme chrániť kód pred prístupom viac ako jedného vlákna naraz:

synchronized (list) {if (! list.contains ("foo")) {list.add ("foo"); }}

Namiesto pridania súboru synchronizované kľúčové slovo k funkciám, vytvorili sme kritickú časť o zoznam, ktorá umožňuje túto operáciu vykonať iba jednému vláknu súčasne.

Mali by sme poznamenať, že môžeme použiť synchronizované (zoznam) o ďalších operáciách na našom zozname objekt, poskytnúť a zaručiť, že ktorúkoľvek z našich operácií môže súčasne vykonávať iba jedno vlákno na tomto objekte.

4.3. Zabudované riešenie pre ConcurrentHashMap

Teraz zvážme použitie mapy z rovnakého dôvodu, konkrétne pridania záznamu, iba ak nie je k dispozícii.

The ConcurrentHashMap ponúka lepšie riešenie tohto typu problému. Môžeme použiť jeho atómový putIfAbsent metóda:

Mapa mapy = nový ConcurrentHashMap (); map.putIfAbsent ("foo", "bar");

Alebo, ak chceme vypočítať hodnotu, jej atómovú computeIfAbsent metóda:

map.computeIfAbsent ("foo", klávesa -> klávesa + "lišta");

Mali by sme poznamenať, že tieto metódy sú súčasťou rozhrania s Mapa kde ponúkajú pohodlný spôsob, ako sa vyhnúť písaniu podmienenej logiky okolo vkladania. Skutočne nám pomáhajú, keď sa pokúšame uskutočniť viacvláknové hovory atómovými.

5. Problémy s konzistenciou pamäte

Problémy s konzistenciou pamäte sa vyskytujú, keď majú viaceré vlákna nekonzistentné pohľady na to, čo by malo byť rovnakými údajmi.

Okrem hlavnej pamäte väčšina moderných počítačových architektúr používa na zlepšenie celkového výkonu hierarchiu pamätí cache (cache L1, L2 a L3). Teda akékoľvek vlákno môže ukladať do pamäti premenné, pretože poskytuje rýchlejší prístup v porovnaní s hlavnou pamäťou.

5.1. Problém

Pripomeňme si naše Počítadlo príklad:

počítadlo triedy {private int counter = 0; public void increment () {counter ++; } public int getValue () {vratne pocitadlo; }}

Zvážme scenár kde vlákno1 zvyšuje pult a potom vlákno2 načíta jeho hodnotu. Môže sa vyskytnúť nasledujúci sled udalostí:

  • vlákno1 načíta hodnotu počítadla z vlastnej vyrovnávacej pamäte; počítadlo je 0
  • thread1 zvýši počítadlo a zapíše ho späť do vlastnej vyrovnávacej pamäte; počítadlo je 1
  • vlákno2 načíta hodnotu počítadla z vlastnej vyrovnávacej pamäte; počítadlo je 0

Môže sa samozrejme vyskytnúť aj očakávaný sled udalostí a thread2 prečíta správnu hodnotu (1), ale nie je zaručené, že zmeny vykonané jedným vláknom budú viditeľné vždy pre iné vlákna.

5.2. Riešenie

Aby sa zabránilo chybám v konzistencii pamäte, musíme nadviazať vzťah „pred dejom“. Tento vzťah je jednoducho zárukou, že aktualizácie pamäte jedným konkrétnym príkazom sú viditeľné pre iný konkrétny príkaz.

Existuje niekoľko stratégií, ktoré vytvárajú vzťahy predchádzajúce udalosti. Jednou z nich je synchronizácia, ktorej sme sa už venovali.

Synchronizácia zaisťuje vzájomné vylúčenie aj konzistenciu pamäte. To však prináša náklady na výkon.

Problémy s konzistenciou pamäte môžeme zabrániť aj použitím prchavý kľúčové slovo. Jednoducho povedané, každá zmena v premenlivej premennej je vždy viditeľná pre iné vlákna.

Prepíšme naše Počítadlo príklad pomocou prchavý:

trieda SyncronizedCounter {private volatile int counter = 0; public synchronized void increment () {counter ++; } public int getValue () {vratne pocitadlo; }}

Mali by sme si to všimnúť stále musíme synchronizovať operáciu prírastku, pretože prchavý nezabezpečuje vzájomné vylúčenie. Používanie jednoduchého prístupu k atómovým premenným je efektívnejšie ako prístup k týmto premenným prostredníctvom synchronizovaného kódu.

5.3. Neatomický dlho a dvojitý Hodnoty

Takže ak čítame premennú bez správnej synchronizácie, môžeme vidieť zastaranú hodnotu. Falebo dlho a dvojitý hodnoty, celkom prekvapivo, je dokonca možné vidieť okrem zatuchnutých aj úplne náhodné hodnoty.

Podľa JLS-17 môže JVM považovať 64-bitové operácie za dve samostatné 32-bitové operácie. Preto pri čítaní a dlho alebo dvojitý hodnotu, je možné prečítať aktualizovaný 32-bit spolu so zastaralým 32-bitom. V dôsledku toho môžeme pozorovať náhodné pohľady dlho alebo dvojitý hodnoty v súbežných kontextoch.

Na druhej strane píše a číta prchavo dlho a dvojitý hodnoty sú vždy atómové.

6. Zneužitie synchronizácie

Synchronizačný mechanizmus je mocným nástrojom na dosiahnutie bezpečnosti závitov. Spolieha sa na použitie vnútorných a vonkajších zámkov. Pamätajme tiež na skutočnosť, že každý objekt má iný zámok a zámok môže naraz získať iba jedno vlákno.

Ak však nebudeme venovať pozornosť a starostlivo vyberieme správne zámky pre náš kritický kód, môže dôjsť k neočakávanému správaniu.

6.1. Synchronizácia je zapnutá toto Odkaz

Synchronizácia na úrovni metódy je riešením mnohých problémov so súbežnosťou. Môže to však viesť aj k ďalším problémom so súbežnosťou, ak je nadmerne využívaná. Tento synchronizačný prístup sa opiera o toto odkaz ako zámok, ktorý sa tiež nazýva vnútorný zámok.

V nasledujúcich príkladoch vidíme, ako je možné synchronizáciu na úrovni metódy preložiť do synchronizácie na úrovni bloku s protokolom toto odkaz ako zámok.

Tieto metódy sú ekvivalentné:

verejné synchronizované void foo () {// ...}
public void foo () {synchronizované (toto) {// ...}}

Keď je takáto metóda volaná vláknom, iné vlákna nemôžu súčasne pristupovať k objektu. To môže znížiť výkonnosť súbežnosti, pretože všetko nakoniec bude bežať s jedným vláknom. Tento prístup je obzvlášť zlý, keď sa objekt číta častejšie, ako sa aktualizuje.

Klient nášho kódu môže navyše získať toto zámok. V najhoršom prípade môže táto operácia viesť k zablokovaniu.

6.2. Uviaznutie

Zablokovanie popisuje situáciu, keď sa navzájom blokujú dve alebo viac vlákien, z ktorých každý čaká na získanie zdroja v držbe nejakého iného vlákna.

Pozrime sa na príklad:

public class DeadlockExample {public static Object lock1 = new Object (); verejný statický zámok objektu2 = nový objekt (); public static void main (String args []) {Thread threadA = new Thread (() -> {synchronized (lock1) {System.out.println ("ThreadA: Holding lock 1 ..."); sleep (); System .out.println ("ThreadA: Waiting for lock 2 ..."); synchronized (lock2) {System.out.println ("ThreadA: Holding lock 1 & 2 ...");}}}); Thread threadB = new Thread (() -> {synchronized (lock2) {System.out.println ("ThreadB: Holding lock 2 ..."); sleep (); System.out.println ("ThreadB: Waiting for lock" 1 ... "); synchronized (lock1) {System.out.println (" ThreadB: Holding lock 1 & 2 ... ");}}}); threadA.start (); threadB.start (); }}

Vo vyššie uvedenom kóde to môžeme jasne vidieť ako prvé závitA nadobúda zámok1 a závitB nadobúda zámok2. Potom, závitA sa snaží získať zámok2 ktoré už nadobudol závitB a závitB sa snaží získať zámok1 ktoré už nadobudol závitA. Ani jeden z nich teda nebude pokračovať, čo znamená, že sú v mŕtvom bode.

Tento problém môžeme ľahko vyriešiť zmenou poradia zámkov v jednom z vlákien.

Mali by sme poznamenať, že je to iba jeden príklad, a existuje veľa ďalších, ktoré môžu viesť k slepej uličke.

7. Záver

V tomto článku sme preskúmali niekoľko príkladov problémov so súbežnosťou, s ktorými sa pravdepodobne stretneme v našich viacvláknových aplikáciách.

Najprv sme sa dozvedeli, že by sme sa mali rozhodnúť pre objekty alebo operácie, ktoré sú nemenné alebo bezpečné pre vlákna.

Potom sme videli niekoľko príkladov závodných podmienok a toho, ako sa im pomocou synchronizačného mechanizmu môžeme vyhnúť. Ďalej sme sa dozvedeli o rasových podmienkach súvisiacich s pamäťou a o tom, ako sa im vyhnúť.

Aj keď nám synchronizačný mechanizmus pomáha vyhnúť sa mnohým problémom so súbežnosťou, môžeme ho ľahko zneužiť a vytvoriť ďalšie problémy. Z tohto dôvodu sme preskúmali niekoľko problémov, s ktorými by sme sa mohli stretnúť, keď bude tento mechanizmus nesprávne používaný.

Ako obvykle sú všetky príklady použité v tomto článku dostupné na GitHub.


$config[zx-auto] not found$config[zx-overlay] not found