Veelvoorkomende valkuilen bij gelijktijdigheid in Java

1. Inleiding

In deze tutorial gaan we enkele van de meest voorkomende gelijktijdigheidsproblemen in Java bekijken. We zullen ook leren hoe we ze en hun belangrijkste oorzaken kunnen vermijden.

2. Gebruik van draadveilige objecten

2.1. Objecten delen

Threads communiceren voornamelijk door de toegang tot dezelfde objecten te delen. Het lezen van een object terwijl het verandert, kan dus onverwachte resultaten opleveren. Ook kan het gelijktijdig wijzigen van een object het in een beschadigde of inconsistente staat achterlaten.

De belangrijkste manier waarop we dergelijke problemen met gelijktijdigheid kunnen vermijden en betrouwbare code kunnen bouwen, is door met onveranderlijke objecten te werken. Dit komt omdat hun toestand niet kan worden gewijzigd door de tussenkomst van meerdere threads.

We kunnen echter niet altijd met onveranderlijke objecten werken. In deze gevallen moeten we manieren vinden om onze veranderlijke objecten draadveilig te maken.

2.2. Verzamelingen draadveilig maken

Net als elk ander object behouden collecties hun status intern. Dit kan worden gewijzigd door meerdere threads tegelijkertijd de verzameling te wijzigen. Zo, een manier waarop we veilig met verzamelingen in een multithread-omgeving kunnen werken, is door ze te synchroniseren:

Map map = Collections.synchronizedMap (nieuwe HashMap ()); List list = Collections.synchronizedList (nieuwe ArrayList ());

Over het algemeen helpt synchronisatie ons om wederzijdse uitsluiting te bereiken. Specifieker, deze collecties zijn slechts voor één thread tegelijk toegankelijk. Zo kunnen we voorkomen dat collecties in een inconsistente staat achterblijven.

2.3. Gespecialiseerde multithread-collecties

Laten we nu eens kijken naar een scenario waarin we meer lezen dan schrijven nodig hebben. Door een gesynchroniseerde verzameling te gebruiken, kan onze applicatie grote gevolgen hebben voor de prestaties. Als twee threads de collectie tegelijkertijd willen lezen, moet de een wachten tot de andere klaar is.

Om deze reden biedt Java gelijktijdige verzamelingen zoals CopyOnWriteArrayList en ConcurrentHashMap die gelijktijdig toegankelijk zijn via meerdere threads:

CopyOnWriteArrayList lijst = nieuwe CopyOnWriteArrayList (); Map map = new ConcurrentHashMap ();

De CopyOnWriteArrayList bereikt thread-veiligheid door een afzonderlijke kopie van de onderliggende array te maken voor mutatieve bewerkingen zoals toevoegen of verwijderen. Hoewel het slechter presteert voor schrijfbewerkingen dan een Collections.synchronizedList, het biedt ons betere prestaties wanneer we aanzienlijk meer leesbewerkingen dan schrijfbewerkingen nodig hebben.

ConcurrentHashMap is fundamenteel thread-safe en presteert beter dan het Collections.synchronizedMap wikkel rond een niet-thread-safe Kaart. Het is eigenlijk een draadveilige kaart van draadveilige kaarten, waardoor verschillende activiteiten tegelijkertijd kunnen plaatsvinden in de onderliggende kaarten.

2.4. Werken met typen die niet geschikt zijn voor schroefdraad

We gebruiken vaak ingebouwde objecten zoals SimpleDateFormat om datumobjecten te ontleden en op te maken. De SimpleDateFormat class muteert zijn interne status tijdens het uitvoeren van zijn bewerkingen.

We moeten er heel voorzichtig mee zijn, omdat ze niet draadveilig zijn. Hun toestand kan inconsistent worden in een multithread-applicatie vanwege zaken als race-omstandigheden.

Dus, hoe kunnen we de SimpleDateFormat veilig? We hebben verschillende mogelijkheden:

  • Maak een nieuw exemplaar van SimpleDateFormat elke keer dat het wordt gebruikt
  • Beperk het aantal objecten dat wordt gemaakt door een ThreadLocal voorwerp. Het garandeert dat elke thread zijn eigen exemplaar van SimpleDateFormat
  • Synchroniseer gelijktijdige toegang door meerdere threads met de gesynchroniseerd trefwoord of een slot

SimpleDateFormat is hier slechts een voorbeeld van. We kunnen deze technieken gebruiken met elk niet-draadveilig type.

3. Wedstrijdvoorwaarden

Een racevoorwaarde doet zich voor wanneer twee of meer threads toegang hebben tot gedeelde gegevens en ze deze tegelijkertijd proberen te wijzigen. Racecondities kunnen dus runtime-fouten of onverwachte resultaten veroorzaken.

3.1. Voorbeeld van raceconditie

Laten we eens kijken naar de volgende code:

klasse Counter {private int counter = 0; public void increment () {counter ++; } public int getValue () {retour teller; }}

De Teller class is zo ontworpen dat bij elke aanroep van de increment-methode 1 wordt opgeteld bij de teller. Als een Teller object wordt gerefereerd vanuit meerdere threads, kan de interferentie tussen threads dit verhinderen zoals verwacht.

We kunnen het teller ++ verklaring in 3 stappen:

  • Haal de huidige waarde op van teller
  • Verhoog de opgehaalde waarde met 1
  • Sla de verhoogde waarde weer op in teller

Laten we nu eens veronderstellen dat er twee draden zijn, draad1 en draad2, roep tegelijkertijd de increment-methode aan. Hun doorschoten acties kunnen deze volgorde volgen:

  • draad1 leest de huidige waarde van teller; 0
  • draad2 leest de huidige waarde van teller; 0
  • draad1 verhoogt de opgehaalde waarde; het resultaat is 1
  • draad2 verhoogt de opgehaalde waarde; het resultaat is 1
  • draad1 slaat het resultaat op in teller; het resultaat is nu 1
  • draad2 slaat het resultaat op in teller; het resultaat is nu 1

We verwachtten de waarde van de teller 2, maar het was 1.

3.2. Een gesynchroniseerde oplossing

We kunnen de inconsistentie oplossen door de kritieke code te synchroniseren:

class SynchronizedCounter {privé int teller = 0; openbare gesynchroniseerde ongeldige increment () {counter ++; } publiek gesynchroniseerd int getValue () {retour teller; }}

Slechts één thread mag de gesynchroniseerd methoden van een object op elk moment, dus dit dwingt consistentie in het lezen en schrijven van de teller.

3.3. Een ingebouwde oplossing

We kunnen de bovenstaande code vervangen door een ingebouwde AtomicInteger voorwerp. Deze klasse biedt onder andere atomaire methoden om een ​​geheel getal te verhogen en is een betere oplossing dan het schrijven van onze eigen code. Daarom kunnen we de methoden rechtstreeks aanroepen zonder dat ze hoeven te worden gesynchroniseerd:

AtomicInteger atomicInteger = nieuw AtomicInteger (3); atomicInteger.incrementAndGet ();

In dit geval lost de SDK het probleem voor ons op. Anders hadden we ook onze eigen code kunnen schrijven, waarbij we de kritieke secties hebben ingekapseld in een aangepaste threadveilige klasse. Deze aanpak helpt ons de complexiteit te minimaliseren en de herbruikbaarheid van onze code te maximaliseren.

4. Racevoorwaarden rond collecties

4.1. Het probleem

Een andere valkuil waar we in kunnen vallen, is te denken dat gesynchroniseerde collecties ons meer bescherming bieden dan ze in werkelijkheid doen.

Laten we de onderstaande code eens bekijken:

List list = Collections.synchronizedList (nieuwe ArrayList ()); if (! list.contains ("foo")) {list.add ("foo"); }

Elke bewerking van onze lijst wordt gesynchroniseerd, maar alle combinaties van aanroepen van meerdere methoden worden niet gesynchroniseerd. Meer specifiek kan tussen de twee bewerkingen een andere thread onze verzameling wijzigen, wat tot ongewenste resultaten leidt.

Twee threads kunnen bijvoorbeeld de als block tegelijkertijd en werk vervolgens de lijst bij, waarbij elke thread de foo waarde toe aan de lijst.

4.2. Een oplossing voor lijsten

We kunnen de code beschermen tegen toegang door meer dan één thread tegelijk met behulp van synchronisatie:

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

In plaats van het gesynchroniseerd sleutelwoord voor de functies, we hebben een kritische sectie gemaakt over lijst, waardoor slechts één thread tegelijk deze bewerking kan uitvoeren.

We moeten er rekening mee houden dat we gesynchroniseerd (lijst) op andere bewerkingen op ons lijstobject, om een garanderen dat slechts één thread tegelijk onze bewerkingen kan uitvoeren op dit object.

4.3. Een ingebouwde oplossing voor ConcurrentHashMap

Laten we nu overwegen om een ​​kaart om dezelfde reden te gebruiken, namelijk alleen een vermelding toevoegen als deze niet aanwezig is.

De ConcurrentHashMap biedt een betere oplossing voor dit soort problemen. We kunnen zijn atomaire gebruiken putIfAbsent methode:

Map map = new ConcurrentHashMap (); map.putIfAbsent ("foo", "bar");

Of, als we de waarde willen berekenen, zijn atomaire waarde computeIfAbsent methode:

map.computeIfAbsent ("foo", key -> key + "bar");

We moeten opmerken dat deze methoden deel uitmaken van de interface naar Kaart waar ze een gemakkelijke manier bieden om te voorkomen dat voorwaardelijke logica rond invoeging moet worden geschreven. Ze helpen ons echt bij het maken van atomische oproepen met meerdere threads.

5. Problemen met geheugenconsistentie

Problemen met geheugenconsistentie treden op wanneer meerdere threads inconsistente weergaven hebben van wat dezelfde gegevens zouden moeten zijn.

Naast het hoofdgeheugen gebruiken de meeste moderne computerarchitecturen een hiërarchie van caches (L1-, L2- en L3-caches) om de algehele prestaties te verbeteren. Elke thread kan dus variabelen in de cache opslaan omdat deze snellere toegang biedt in vergelijking met het hoofdgeheugen.

5.1. Het probleem

Laten we ons herinneren Teller voorbeeld:

klasse Counter {private int counter = 0; public void increment () {counter ++; } public int getValue () {retour teller; }}

Laten we eens kijken naar het scenario waar draad1 verhoogt de teller en dan draad2 leest de waarde ervan. De volgende reeks gebeurtenissen kan zich voordoen:

  • draad1 leest de tellerwaarde uit zijn eigen cache; teller is 0
  • thread1 verhoogt de teller en schrijft deze terug naar zijn eigen cache; teller is 1
  • draad2 leest de tellerwaarde uit zijn eigen cache; teller is 0

Natuurlijk kan de verwachte volgorde van gebeurtenissen ook plaatsvinden en de thread2 leest de juiste waarde (1), maar er is geen garantie dat wijzigingen die door een thread worden aangebracht, elke keer zichtbaar zijn voor andere threads.

5.2. De oplossing

Om fouten in de geheugenconsistentie te voorkomen, we moeten een 'happening-before'-relatie tot stand brengen. Deze relatie is gewoon een garantie dat geheugenupdates door een specifieke instructie zichtbaar zijn voor een andere specifieke instructie.

Er zijn verschillende strategieën die 'vóór-relaties' creëren. Een daarvan is synchronisatie, waar we al naar hebben gekeken.

Synchronisatie zorgt voor zowel wederzijdse uitsluiting als geheugenconsistentie. Dit brengt echter prestatiekosten met zich mee.

We kunnen ook problemen met de consistentie van het geheugen voorkomen door de vluchtig trefwoord. Simpel gezegd, elke verandering in een vluchtige variabele is altijd zichtbaar voor andere threads.

Laten we onze Teller voorbeeld met vluchtig:

class SyncronizedCounter {privé vluchtige int teller = 0; openbare gesynchroniseerde ongeldige increment () {counter ++; } public int getValue () {retour teller; }}

Dat moeten we opmerken we moeten de increment-bewerking nog steeds synchroniseren omdat vluchtig verzekert ons niet van wederzijdse uitsluiting. Het gebruik van eenvoudige atomaire variabele toegang is efficiënter dan toegang tot deze variabelen via gesynchroniseerde code.

5.3. Niet-atomair lang en dubbele Waarden

Dus als we een variabele lezen zonder de juiste synchronisatie, zien we mogelijk een verouderde waarde. F.of lang en dubbele waarden, het is nogal verrassend dat het zelfs mogelijk is om volledig willekeurige waarden te zien naast verouderde waarden.

Volgens JLS-17 kan JVM 64-bits bewerkingen behandelen als twee afzonderlijke 32-bits bewerkingen. Daarom, bij het lezen van een lang of dubbele waarde, is het mogelijk om een ​​bijgewerkte 32-bits samen met een verouderde 32-bits te lezen. Daarom kunnen we willekeurig kijken observeren lang of dubbele waarden in gelijktijdige contexten.

Aan de andere kant, schrijft en leest vluchtig lang en dubbele waarden zijn altijd atomair.

6. Misbruik van Synchronize

Het synchronisatiemechanisme is een krachtig hulpmiddel om draadveiligheid te bereiken. Het is gebaseerd op het gebruik van intrinsieke en extrinsieke sloten. Laten we ook niet vergeten dat elk object een ander slot heeft en dat slechts één draad tegelijk een slot kan krijgen.

Als we echter niet opletten en zorgvuldig de juiste vergrendelingen kiezen voor onze kritieke code, kan onverwacht gedrag optreden.

6.1. Synchroniseren op dit Referentie

De synchronisatie op methodniveau is een oplossing voor veel gelijktijdigheidsproblemen. Het kan echter ook leiden tot andere gelijktijdigheidsproblemen als het te veel wordt gebruikt. Deze synchronisatiebenadering is gebaseerd op de dit referentie als een slot, dat ook wel een intrinsiek slot wordt genoemd.

We kunnen in de volgende voorbeelden zien hoe een synchronisatie op werkwijzenniveau kan worden vertaald in een synchronisatie op blokniveau met de dit referentie als een slot.

Deze methoden zijn gelijkwaardig:

openbare gesynchroniseerde leegte foo () {// ...}
public void foo () {gesynchroniseerd (dit) {// ...}}

Wanneer een dergelijke methode wordt aangeroepen door een thread, kunnen andere threads niet gelijktijdig toegang krijgen tot het object. Dit kan de gelijktijdigheidsprestaties verminderen, aangezien alles single-threaded wordt. Deze benadering is vooral slecht wanneer een object vaker wordt gelezen dan dat het wordt bijgewerkt.

Bovendien kan een klant van onze code ook het dit slot. In het ergste geval kan deze operatie tot een impasse leiden.

6.2. Impasse

Deadlock beschrijft een situatie waarin twee of meer threads elkaar blokkeren, elk wachtend om een ​​bron te verwerven die door een andere thread wordt vastgehouden.

Laten we het voorbeeld eens bekijken:

public class DeadlockExample {public static Object lock1 = new Object (); openbaar statisch objectvergrendeling2 = nieuw object (); 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 (); }}

In de bovenstaande code kunnen we dat eerst duidelijk zien draad A. verwerft slot 1 en threadB verwerft slot2. Dan, draadA probeert het slot2 die al is verworven door draadB en draadB probeert het slot 1 die al is verworven door draadA. Dus geen van beiden zal doorgaan, wat betekent dat ze in een impasse zitten.

We kunnen dit probleem eenvoudig oplossen door de volgorde van de vergrendelingen in een van de threads te wijzigen.

We moeten opmerken dat dit slechts één voorbeeld is, en er zijn er vele die tot een impasse kunnen leiden.

7. Conclusie

In dit artikel hebben we verschillende voorbeelden onderzocht van gelijktijdigheidsproblemen die we waarschijnlijk tegenkomen in onze multithread-applicaties.

Ten eerste hebben we geleerd dat we moeten kiezen voor objecten of bewerkingen die ofwel onveranderlijk ofwel thread-safe zijn.

Vervolgens zagen we verschillende voorbeelden van race-omstandigheden en hoe we deze kunnen vermijden met behulp van het synchronisatiemechanisme. Verder leerden we over geheugengerelateerde race-omstandigheden en hoe deze te vermijden.

Hoewel het synchronisatiemechanisme ons helpt veel gelijktijdigheidsproblemen te vermijden, kunnen we het gemakkelijk misbruiken en andere problemen creëren. Om deze reden hebben we verschillende problemen onderzocht die we kunnen tegenkomen als dit mechanisme slecht wordt gebruikt.

Zoals gewoonlijk zijn alle voorbeelden die in dit artikel worden gebruikt, beschikbaar op GitHub.