Čo je to Thread-Safety a ako to dosiahnuť?

1. Prehľad

Java podporuje viacvláknové spracovanie po vybalení z krabice. To znamená, že súčasným spustením bajtkódu v samostatných pracovných vláknach je JVM schopný zlepšiť výkon aplikácie.

Aj keď je multithreading výkonná funkcia, prichádza za určitú cenu. V prostrediach s viacerými vláknami musíme napísať implementácie spôsobom bezpečným pre vlákna. To znamená, že rôzne vlákna môžu pristupovať k rovnakým zdrojom bez toho, aby vystavili chybné správanie alebo priniesli nepredvídateľné výsledky. Táto metodika programovania je známa ako „bezpečnosť vlákien“.

V tejto príručke sa pozrieme na rôzne prístupy, ako to dosiahnuť.

2. Bez štátnej príslušnosti

Vo väčšine prípadov sú chyby vo viacvláknových aplikáciách výsledkom nesprávneho zdieľania stavu medzi niekoľkými vláknami.

Prvý prístup, na ktorý sa pozrieme, je preto dosiahnutie bezpečnosti závitov pomocou implementácií bez štátnej príslušnosti.

Aby sme tomuto prístupu lepšie porozumeli, pouvažujme nad jednoduchou triedou nástrojov so statickou metódou, ktorá počíta faktoriál čísla:

public class MathUtils {public static BigInteger factororial (int number) {BigInteger f = new BigInteger ("1"); pre (int i = 2; i <= číslo; i ++) {f = f.multiply (BigInteger.valueOf (i)); } návrat f; }} 

The faktoriál () metóda je bezstavová deterministická funkcia. Pri konkrétnom vstupe vytvára vždy rovnaký výstup.

Metóda nespolieha sa na vonkajší stav a vôbec ho neudržiava. Preto sa považuje za bezpečné pre vlákna a dá sa bezpečne volať viacerými vláknami súčasne.

Všetky vlákna môžu bezpečne volať faktoriál () metóda a získa očakávaný výsledok bez toho, aby sa navzájom rušili a bez zmeny výstupu, ktorý metóda generuje pre ďalšie vlákna.

Preto bezstavové implementácie sú najjednoduchším spôsobom, ako dosiahnuť bezpečnosť vlákien.

3. Nezmeniteľné implementácie

Ak potrebujeme zdieľať stav medzi rôznymi vláknami, môžeme vytvoriť triedy bezpečné pre vlákna tak, že ich urobíme nemennými.

Nezmeniteľnosť je silný, jazykovo agnostický koncept a je dosť ľahké ho dosiahnuť v Jave.

Zjednodušene povedané inštancia triedy je nemenná, keď jej vnútorný stav nie je možné po vytvorení zmeniť.

Najjednoduchší spôsob, ako vytvoriť nemennú triedu v Jave, je vyhlásenie všetkých polí súkromné a konečné a neposkytujúce nastavovače:

public class MessageService {private final String message; public MessageService (reťazcová správa) {this.message = správa; } // štandardný getter}

A MessageService objekt je skutočne nemenný, pretože jeho stav sa po konštrukcii nemôže zmeniť. Preto je bezpečný pre vlákna.

Navyše, ak MessageService boli skutočne premenlivé, ale viaceré vlákna majú k nej prístup iba na čítanie, rovnako sú bezpečné aj pre vlákna.

Teda nemennosť je len ďalší spôsob, ako dosiahnuť bezpečnosť závitov.

4. Polia miestnych vlákien

V objektovo orientovanom programovaní (OOP) musia objekty skutočne udržiavať stav prostredníctvom polí a implementovať správanie pomocou jednej alebo viacerých metód.

Ak skutočne potrebujeme udržiavať stav, môžeme vytvoriť triedy bezpečné pre vlákna, ktoré nezdieľajú stav medzi vláknami tak, že urobíme ich polia vláknami lokálne.

Môžeme ľahko vytvoriť triedy, ktorých polia sú miestne pre vlákno jednoduchým definovaním súkromných polí v Závit triedy.

Mohli by sme definovať napríklad a Závit trieda, ktorá uchováva pole z celé čísla:

public class ThreadA extends Thread {private final List numbers = Arrays.asList (1, 2, 3, 4, 5, 6); @Override public void run () {numbers.forEach (System.out :: println); }}

Zatiaľ čo iný môže držať pole z struny:

public class ThreadB extends Thread {private final List letters = Arrays.asList ("a", "b", "c", "d", "e", "f"); @Override public void run () {letters.forEach (System.out :: println); }}

V oboch implementáciách majú triedy svoj vlastný stav, ale nie je zdieľaný s inými vláknami. Triedy sú teda bezpečné pre vlákna.

Podobne môžeme priradením vytvoriť lokálne polia pre vlákna ThreadLocal inštancie do poľa.

Uvažujme napríklad o nasledujúcom Držiteľ štátu trieda:

public class StateHolder {private final String state; // štandardné konštruktory / geter}

Ľahko z neho môžeme vytvoriť lokálnu premennú takto:

public class ThreadState {public static final ThreadLocal statePerThread = new ThreadLocal () {@Override protected StateHolder initialValue () {return new StateHolder ("active"); }}; public static StateHolder getState () {return statePerThread.get (); }}

Lokálne polia vlákien sú skoro ako polia bežných tried, až na to, že každé vlákno, ktoré k nim pristupuje cez nastavovač / getter, dostane nezávisle inicializovanú kópiu poľa, takže každé vlákno má svoj vlastný stav.

5. Synchronizované zbierky

Zhromaždenia bezpečné pre vlákna môžeme ľahko vytvoriť pomocou sady obalov synchronizácie zahrnutých v rámci kolekcií.

Na vytvorenie kolekcie bezpečnej pre vlákna môžeme použiť napríklad jeden z týchto obalov synchronizácie:

Collection syncCollection = Collections.synchronizedCollection (nový ArrayList ()); Thread thread1 = new Thread (() -> syncCollection.addAll (Arrays.asList (1, 2, 3, 4, 5, 6))); Thread thread2 = new Thread (() -> syncCollection.addAll (Arrays.asList (7, 8, 9, 10, 11, 12))); thread1.start (); thread2.start (); 

Pamätajme na to, že synchronizované zbierky používajú vnútorné uzamykanie v každej metóde (na vnútorné uzamknutie sa pozrieme neskôr).

To znamená, že k metódam je prístupný iba jedným vláknom súčasne, zatiaľ čo ostatné vlákna budú blokované, kým sa metóda neodomkne prvým vláknom.

Synchronizácia má teda výkonnostnú pokutu kvôli základnej logike synchronizovaného prístupu.

6. Súbežné zbierky

Alternatívne k synchronizovaným zbierkam môžeme na vytvorenie zbierok bezpečných pre vlákna použiť súbežné zbierky.

Java poskytuje java.util.concurrent balíček, ktorý obsahuje niekoľko súbežných zbierok, ako napr ConcurrentHashMap:

Mapa concurrentMap = nová ConcurrentHashMap (); concurrentMap.put ("1", "jeden"); concurrentMap.put ("2", "dva"); concurrentMap.put ("3", "tri"); 

Na rozdiel od svojich synchronizovaných kolegov, súčasné zbierky dosahujú bezpečnosť vlákien rozdelením svojich údajov do segmentov. V ConcurrentHashMapnapríklad niekoľko vlákien môže získať zámky na rôznych segmentoch mapy, takže viac vlákien môže získať prístup k Mapa zároveň.

Súbežné zbierky súoveľa výkonnejšie ako synchronizované zbierky, kvôli inherentným výhodám súbežného prístupu k vláknam.

Za zmienku to stojí synchronizované a súčasné zbierky zabezpečia iba samotnú zbierku bezpečnú pre vlákna, a nie obsah.

7. Atómové objekty

Je tiež možné dosiahnuť bezpečnosť vlákien pomocou sady atómových tried, ktoré poskytuje Java, vrátane AtomicInteger, AtomicLong, AtomicBooleana Atómová referencia.

Atómové triedy nám umožňujú vykonávať atómové operácie, ktoré sú bezpečné voči vláknam, bez použitia synchronizácie. Atómová operácia sa vykoná v jednej operácii na úrovni stroja.

Aby sme pochopili problém, ktorý to rieši, pozrime sa na nasledujúce Počítadlo trieda:

public class Counter {private int counter = 0; public void incrementCounter () {counter + = 1; } public int getCounter () {vratne pult; }}

Predpokladajme, že v závodnom stave majú prístup k vláknu dve vlákna incrementCounter () súčasne.

Teoreticky konečná hodnota pult pole bude 2. Ale nemôžeme si byť istí výsledkom, pretože vlákna vykonávajú rovnaký blok kódu v rovnakom čase a prírastok nie je atómový.

Vytvorme implementáciu Počítadlo triedy pomocou AtomicInteger objekt:

public class AtomicCounter {private final AtomicInteger counter = new AtomicInteger (); public void incrementCounter () {counter.incrementAndGet (); } public int getCounter () {return counter.get (); }}

Toto je bezpečné pre vlákna, pretože aj keď prírastok ++ trvá viac ako jednu operáciu, incrementAndGet je atómový.

8. Synchronizované metódy

Aj keď sú predchádzajúce prístupy veľmi dobré pre zbierky a primitívy, občas budeme potrebovať väčšiu kontrolu.

Ďalším spoločným prístupom, ktorý môžeme použiť na dosiahnutie bezpečnosti vlákien, je implementácia synchronizovaných metód.

Jednoducho povedané, k synchronizovanej metóde môže naraz získať prístup iba jedno vlákno, pričom je blokovaný prístup k tejto metóde z iných vlákien. Ostatné vlákna zostanú blokované, kým sa prvé vlákno nedokončí alebo kým metóda nezruší výnimku.

Môžeme vytvoriť verziu bezpečnú pre vlákna incrementCounter () iným spôsobom urobením synchronizovanej metódy:

public synchronized void incrementCounter () {counter + = 1; }

Synchronizovanú metódu sme vytvorili predponou podpisu metódy znakom synchronizované kľúčové slovo.

Pretože jedno vlákno po druhom môže mať prístup k synchronizovanej metóde, jedno vlákno vykoná príkaz incrementCounter () metódou a na oplátku to urobia aj ostatní. Nenastane vôbec žiadne prekrývajúce sa vykonávanie.

Synchronizované metódy sa spoliehajú na použitie „vnútorných zámkov“ alebo „monitorovacích zámkov“.. Vnútorný zámok je implicitná vnútorná entita spojená s inštanciou konkrétnej triedy.

Vo viacvláknovom kontexte pojem monitor je iba odkaz na rolu, ktorú zámok vykonáva v pridruženom objekte, pretože vynucuje výlučný prístup k množine špecifikovaných metód alebo príkazov.

Keď vlákno volá synchronizovanú metódu, získa vnútornú zámku. Keď vlákno dokončí vykonávanie metódy, uvoľní zámok, čím umožní iným vláknam získať zámok a získať prístup k metóde.

Môžeme implementovať synchronizáciu v inštančných metódach, statických metódach a príkazoch (synchronizované príkazy).

9. Synchronizované výpisy

Niekedy môže byť synchronizácia celej metódy prehnaná, ak práve potrebujeme zaistiť, aby bola časť metódy bezpečná pre všetky vlákna.

Na ilustráciu tohto prípadu použitia urobme refaktor incrementCounter () metóda:

public void incrementCounter () {// synchronizované (toto) ďalšie nesynchronizované operácie {counter + = 1; }}

Príklad je triviálny, ale ukazuje, ako vytvoriť synchronizovaný príkaz. Za predpokladu, že metóda teraz vykonáva niekoľko ďalších operácií, ktoré nevyžadujú synchronizáciu, synchronizovali sme iba príslušnú sekciu upravujúcu stav zabalením do synchronizované blokovať.

Na rozdiel od synchronizovaných metód musia synchronizované príkazy špecifikovať objekt, ktorý poskytuje vnútorný zámok, zvyčajne toto odkaz.

Synchronizácia je drahá, takže s touto možnosťou môžeme synchronizovať iba príslušné časti metódy.

9.1. Ostatné objekty ako zámok

Môžeme mierne vylepšiť implementáciu Počítadlo triedy využitím iného objektu ako zámku monitora namiesto toto.

Poskytuje to nielen koordinovaný prístup k zdieľanému prostriedku vo viacvláknovom prostredí, ale tiež používa externú entitu na vynútenie výlučného prístupu k zdroju:

verejná trieda ObjectLockCounter {private int counter = 0; súkromný konečný zámok objektu = nový objekt (); public void incrementCounter () {synchronized (lock) {counter + = 1; }} // štandardný getter}

Používame obyčajný Objekt inštancie na presadzovanie vzájomného vylúčenia. Táto implementácia je o niečo lepšia, pretože podporuje zabezpečenie na úrovni zámku.

Pri použití toto pre vnútorné zaistenie, útočník by mohol spôsobiť zablokovanie získaním vnútorného zámku a spustením stavu odmietnutia služby (DoS).

Naopak, pri použití iných predmetov tento súkromný subjekt nie je prístupný zvonku. To útočníkovi sťažuje získanie zámku a zablokovanie.

9.2. Upozornenia

Aj keď môžeme použiť akýkoľvek objekt Java ako vnútorný zámok, nemali by sme sa používaniu vyhýbať Struny na zamykacie účely:

verejná trieda Class1 {private static final String LOCK = "Lock"; // používa LOCK ako vnútorný zámok} verejná trieda Class2 {private static final String LOCK = "Lock"; // používa LOCK ako vnútorný zámok}

Na prvý pohľad sa zdá, že tieto dve triedy používajú ako svoj zámok dva rôzne objekty. Avšak z dôvodu interringu reťazca môžu tieto dve hodnoty „Lock“ v skutočnosti odkazovať na rovnaký objekt v bazéne reťazcov. To znamená, že Trieda1 a Trieda 2 zdieľajú rovnaký zámok!

To zase môže spôsobiť neočakávané správanie v súbežných kontextoch.

Okrem tohoto Struny, nemali by sme sa vyhýbať použitiu akýchkoľvek cachovateľných alebo opakovane použiteľných objektov ako vnútorných zámkov. Napríklad Integer.valueOf () metóda ukladá do pamäti malé čísla. Preto volanie Integer.valueOf (1) vráti ten istý objekt aj v rôznych triedach.

10. Prchavé polia

Synchronizované metódy a bloky sú užitočné na riešenie problémov s viditeľnosťou premenných medzi vláknami. Aj napriek tomu môže CPU hodnoty bežných polí triedy uložiť do medzipamäte. Následné aktualizácie konkrétneho poľa preto nemusia byť viditeľné pre iné vlákna, aj keď sú synchronizované.

Aby sme zabránili tejto situácii, môžeme použiť prchavý polia triedy:

public class Counter {private volatile int counter; // štandardné konštruktory / geter}

Vďaka prchavý kľúčové slovo, dáme príkaz JVM a kompilátoru, aby uložili pult premenná v hlavnej pamäti. Týmto spôsobom zabezpečíme, aby zakaždým, keď JVM načíta hodnotu parametra pult premenná, bude ju skutočne načítať z hlavnej pamäte, namiesto z medzipamäte CPU. Rovnako vždy, keď JVM píše do pult premenná, hodnota sa zapíše do hlavnej pamäte.

Navyše, použitie a prchavý premenná zaisťuje, že všetky premenné, ktoré sú viditeľné pre dané vlákno, sa budú čítať tiež z hlavnej pamäte.

Uvažujme o nasledujúcom príklade:

public class User {private String name; súkromný volatilný vek; // štandardné konštruktory / getre}

V takom prípade zakaždým, keď JVM napíše Vekprchavý premenná do hlavnej pamäte, zapíše energeticky nezávislú premennú názov premennej aj do hlavnej pamäte. To zaisťuje, že najnovšie hodnoty oboch premenných sú uložené v hlavnej pamäti, takže následné aktualizácie premenných budú automaticky viditeľné pre ďalšie vlákna.

Podobne, ak vlákno číta hodnotu a prchavý premennej, všetky premenné viditeľné pre vlákno sa načítajú tiež z hlavnej pamäte.

Táto rozšírená záruka to prchavý premenné, ktoré sa poskytujú, sa označujú ako záruka plnej volatility viditeľnosti.

11. Reentrantné zámky

Java poskytuje vylepšenú sadu Zamknúť implementácie, ktorých správanie je o niečo sofistikovanejšie ako vyššie uvedené vnútorné zámky.

Pri vnútorných zámkoch je model získavania zámku dosť rigidný: jedno vlákno získa zámok, potom vykoná blok metódy alebo kódu a nakoniec zámok uvoľní, takže ho môžu získať ďalšie vlákna a získať prístup k metóde.

Neexistuje žiadny základný mechanizmus, ktorý by kontroloval vlákna vo fronte a umožňoval prednostný prístup k najdlhšie čakajúcim vláknam.

ReentrantLock prípady nám umožňujú presne to urobiť, teda zabránenie tomu, aby vlákna vo fronte utrpeli niektoré typy hladovania zdrojov:

public class ReentrantLockCounter {private int counter; súkromné ​​finále ReentrantLock reLock = nový ReentrantLock (true); public void incrementCounter () {reLock.lock (); skúsiť {counter + = 1; } nakoniec {reLock.unlock (); }} // štandardné konštruktory / getre}

The ReentrantLock konštruktor berie voliteľné férovosťboolovský parameter. Keď je nastavené na pravdaa viaceré vlákna sa pokúšajú získať zámok, JVM dá prednosť najdlhšiemu čakajúcemu vláknu a udelí prístup k zámku.

12. Zámky na čítanie a zápis

Ďalším účinným mechanizmom, ktorý môžeme použiť na dosiahnutie bezpečnosti vlákien, je použitie ReadWriteLock implementácie.

A ReadWriteLock zámok v skutočnosti používa pár združených zámkov, jeden pre operácie iba na čítanie a druhý pre operácie písania.

Ako výsledok, je možné mať veľa vlákien na čítanie zdroja, pokiaľ na ne nie je vlákno zapisujúce. Okrem toho zápis vlákna do zdroja zabráni iným vláknam v načítaní.

Môžeme použiť a ReadWriteLock uzamknúť nasledovne:

public class ReentrantReadWriteLockCounter {private int counter; súkromné ​​konečné ReentrantReadWriteLock rwLock = nový ReentrantReadWriteLock (); súkromný konečný zámok readLock = rwLock.readLock (); private final Lock writeLock = rwLock.writeLock (); public void incrementCounter () {writeLock.lock (); skúsiť {counter + = 1; } nakoniec {writeLock.unlock (); }} public int getCounter () {readLock.lock (); skus {vratit pult; } nakoniec {readLock.unlock (); }} // štandardné konštruktory} 

13. Záver

V tomto článku dozvedeli sme sa, čo je bezpečnosť vlákien v Jave, a podrobne sme sa pozreli na rôzne prístupy na jej dosiahnutie.

Ako obvykle sú všetky ukážky kódu zobrazené v tomto článku k dispozícii na GitHub.