Wat is draadveiligheid en hoe bereikt u dit?

1. Overzicht

Java ondersteunt standaard multithreading. Dit betekent dat door het gelijktijdig uitvoeren van bytecode in afzonderlijke werkthreads, de JVM in staat is de prestaties van de applicatie te verbeteren.

Hoewel multithreading een krachtige functie is, heeft dit een prijs. In omgevingen met meerdere threads moeten we implementaties schrijven op een threadveilige manier. Dit betekent dat verschillende threads toegang hebben tot dezelfde bronnen zonder foutief gedrag bloot te leggen of onvoorspelbare resultaten te produceren. Deze programmeermethodiek staat bekend als "thread-safety".

In deze tutorial zullen we verschillende benaderingen bekijken om dit te bereiken.

2. Staatloze implementaties

In de meeste gevallen zijn fouten in toepassingen met meerdere threads het resultaat van het onjuist delen van de status tussen verschillende threads.

Daarom is de eerste benadering die we zullen bekijken het bereiken van draadveiligheid met behulp van stateless implementaties.

Laten we, om deze benadering beter te begrijpen, een eenvoudige utility-klasse bekijken met een statische methode die de faculteit van een getal berekent:

openbare klasse MathUtils {openbare statische BigInteger faculteit (int nummer) {BigInteger f = nieuwe BigInteger ("1"); voor (int i = 2; i <= nummer; i ++) {f = f.multiply (BigInteger.valueOf (i)); } terugkeer f; }} 

De faculteit methode is een staatloze deterministische functie. Gegeven een specifieke input, levert het altijd dezelfde output op.

De methode vertrouwt niet op een externe staat en handhaaft helemaal geen staat. Daarom wordt het als thread-safe beschouwd en kan het veilig door meerdere threads tegelijkertijd worden aangeroepen.

Alle threads kunnen veilig het faculteit method en krijgt het verwachte resultaat zonder met elkaar te interfereren en zonder de uitvoer te wijzigen die de methode genereert voor andere threads.

Daarom stateless implementaties zijn de eenvoudigste manier om thread-safety te bereiken.

3. Onveranderlijke implementaties

Als we de status tussen verschillende threads moeten delen, kunnen we threadveilige klassen maken door ze onveranderlijk te maken.

Onveranderlijkheid is een krachtig, taalonafhankelijk concept en het is vrij eenvoudig te bereiken in Java.

Simpel gezegd, een klasse-instantie is onveranderlijk als de interne status ervan niet kan worden gewijzigd nadat deze is geconstrueerd.

De eenvoudigste manier om een ​​onveranderlijke klasse in Java te maken, is door alle velden te declareren privaat en laatste en het niet verstrekken van setters:

openbare klasse MessageService {privé laatste String-bericht; openbare MessageService (String-bericht) {this.message = message; } // standaard getter}

EEN MessageService object is in feite onveranderlijk omdat de toestand ervan niet kan veranderen na de constructie. Daarom is het draadveilig.

Bovendien, als MessageService waren eigenlijk veranderlijk, maar meerdere threads hebben er alleen alleen-lezen toegang toe, het is ook thread-safe.

Dus, onveranderlijkheid is gewoon een andere manier om draadveiligheid te bereiken.

4. Thread-Local-velden

Bij objectgeoriënteerd programmeren (OOP) moeten objecten feitelijk de status behouden via velden en gedrag implementeren via een of meer methoden.

Als we echt de staat moeten behouden, we kunnen threadveilige klassen maken die geen status tussen threads delen door hun velden thread-local te maken.

We kunnen eenvoudig klassen maken waarvan de velden thread-local zijn door simpelweg privévelden te definiëren in Draad klassen.

We zouden bijvoorbeeld een Draad klasse waarin een array van gehele getallen:

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

Terwijl een ander misschien een array van snaren:

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

In beide implementaties hebben de klassen hun eigen status, maar deze wordt niet gedeeld met andere threads. De klassen zijn dus thread-safe.

Evenzo kunnen we thread-local velden maken door ThreadLocal instanties naar een veld.

Laten we bijvoorbeeld eens kijken naar het volgende Staathouder klasse:

openbare klasse StateHolder {privé laatste String-staat; // standard constructors / getter}

We kunnen er als volgt gemakkelijk een thread-local variabele van maken:

openbare klasse ThreadState {openbare statische laatste ThreadLocal statePerThread = nieuwe ThreadLocal () {@Override beschermde StateHolder initialValue () {retourneer nieuwe StateHolder ("actief"); }}; openbare statische StateHolder getState () {terugkeer statePerThread.get (); }}

Thread-local velden lijken veel op normale klassevelden, behalve dat elke thread die ze benadert via een setter / getter een onafhankelijk geïnitialiseerde kopie van het veld krijgt, zodat elke thread zijn eigen status heeft.

5. Gesynchroniseerde verzamelingen

We kunnen eenvoudig threadveilige collecties maken door de set synchronisatiewrappers te gebruiken die zijn opgenomen in het framework voor collecties.

We kunnen bijvoorbeeld een van deze synchronisatiewrappers gebruiken om een ​​threadveilige verzameling te maken:

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

Laten we in gedachten houden dat gesynchroniseerde verzamelingen intrinsieke vergrendeling gebruiken bij elke methode (we zullen later naar intrinsieke vergrendeling kijken).

Dit betekent dat de methoden slechts voor één thread tegelijk toegankelijk zijn, terwijl andere threads worden geblokkeerd totdat de methode wordt ontgrendeld door de eerste thread.

Synchronisatie heeft dus een nadelige invloed op de prestaties vanwege de onderliggende logica van gesynchroniseerde toegang.

6. Gelijktijdige incasso's

Als alternatief voor gesynchroniseerde verzamelingen kunnen we gelijktijdige verzamelingen gebruiken om threadveilige verzamelingen te maken.

Java biedt het java.util.concurrent pakket, dat verschillende gelijktijdige verzamelingen bevat, zoals ConcurrentHashMap:

Map concurrentMap = nieuwe ConcurrentHashMap (); concurrentMap.put ("1", "een"); concurrentMap.put ("2", "twee"); concurrentMap.put ("3", "drie"); 

In tegenstelling tot hun gesynchroniseerde tegenhangers, gelijktijdige verzamelingen bereiken thread-veiligheid door hun gegevens in segmenten te verdelen. In een ConcurrentHashMapZo kunnen meerdere threads vergrendelingen op verschillende kaartsegmenten verkrijgen, zodat meerdere threads toegang hebben tot het Kaart tegelijkertijd.

Gelijktijdige collecties zijnveel performanter dan gesynchroniseerde collecties, vanwege de inherente voordelen van gelijktijdige threadtoegang.

Dat is het vermelden waard gesynchroniseerde en gelijktijdige verzamelingen maken alleen de verzameling zelf thread-safe en niet de inhoud.

7. Atomaire objecten

Het is ook mogelijk om thread-safety te bereiken met behulp van de set atoomklassen die Java biedt, inclusief AtomicInteger, AtomicLong, AtomicBoolean, en AtomicReference.

Met atomaire klassen kunnen we atomaire bewerkingen uitvoeren, die thread-safe zijn, zonder gebruik te maken van synchronisatie. Een atomaire bewerking wordt uitgevoerd in één bewerking op machineniveau.

Laten we het volgende bekijken om het probleem te begrijpen dat dit oplost Teller klasse:

openbare klasse Counter {private int counter = 0; openbare ongeldige incrementCounter () {teller + = 1; } public int getCounter () {retour teller; }}

Stel dat in een raceconditie twee threads toegang hebben tot het incrementCounter () methode tegelijkertijd.

In theorie is de uiteindelijke waarde van de teller veld zal 2 zijn. Maar we kunnen gewoon niet zeker zijn van het resultaat, omdat de threads hetzelfde codeblok tegelijkertijd uitvoeren en de toename niet atomair is.

Laten we een threadveilige implementatie maken van het Teller klasse met behulp van een AtomicInteger voorwerp:

openbare klasse AtomicCounter {privé finale AtomicInteger teller = nieuwe AtomicInteger (); openbare ongeldige incrementCounter () {counter.incrementAndGet (); } public int getCounter () {return counter.get (); }}

Dit is thread-safe omdat, hoewel incrementatie, ++, meer dan één bewerking vereist, incrementAndGet is atomair.

8. Gesynchroniseerde methoden

Hoewel de eerdere benaderingen erg goed zijn voor verzamelingen en primitieven, hebben we soms meer controle nodig dan dat.

Een andere veel voorkomende benadering die we kunnen gebruiken om thread-veiligheid te bereiken, is het implementeren van gesynchroniseerde methoden.

Simpel gezegd, heeft slechts één thread tegelijk toegang tot een gesynchroniseerde methode, terwijl de toegang tot deze methode van andere threads wordt geblokkeerd. Andere threads blijven geblokkeerd totdat de eerste thread is voltooid of de methode een uitzondering genereert.

We kunnen een threadveilige versie van incrementCounter () op een andere manier door er een gesynchroniseerde methode van te maken:

openbare gesynchroniseerde ongeldige incrementCounter () {teller + = 1; }

We hebben een gesynchroniseerde methode gemaakt door de methodehandtekening vooraf te laten gaan met de gesynchroniseerd trefwoord.

Aangezien één thread tegelijk toegang heeft tot een gesynchroniseerde methode, voert één thread de incrementCounter () methode, en op hun beurt zullen anderen hetzelfde doen. Er vindt geen enkele overlappende uitvoering plaats.

Gesynchroniseerde methoden zijn afhankelijk van het gebruik van "intrinsieke vergrendelingen" of "monitorvergrendelingen". Een intrinsiek slot is een impliciete interne entiteit die is gekoppeld aan een bepaalde klasse-instantie.

In een multithread-context is de term monitor is slechts een verwijzing naar de rol die het slot op het bijbehorende object vervult, aangezien het exclusieve toegang afdwingt tot een set gespecificeerde methoden of instructies.

Wanneer een thread een gesynchroniseerde methode aanroept, verkrijgt deze de intrinsieke vergrendeling. Nadat de thread klaar is met het uitvoeren van de methode, wordt de vergrendeling vrijgegeven, waardoor andere threads de vergrendeling kunnen verwerven en toegang krijgen tot de methode.

We kunnen synchronisatie implementeren in instantiemethoden, statische methoden en instructies (gesynchroniseerde instructies).

9. Gesynchroniseerde verklaringen

Soms kan het synchroniseren van een hele methode overdreven zijn als we slechts een deel van de methode thread-safe moeten maken.

Laten we, om deze use case te illustreren, het incrementCounter () methode:

public void incrementCounter () {// aanvullende niet-gesynchroniseerde bewerkingen gesynchroniseerd (dit) {counter + = 1; }}

Het voorbeeld is triviaal, maar het laat zien hoe je een gesynchroniseerde instructie maakt. Ervan uitgaande dat de methode nu een paar extra bewerkingen uitvoert, die geen synchronisatie vereisen, hebben we alleen de relevante statuswijzigende sectie gesynchroniseerd door deze in een gesynchroniseerd blok.

In tegenstelling tot gesynchroniseerde methoden moeten gesynchroniseerde instructies het object specificeren dat de intrinsieke vergrendeling biedt, meestal de dit referentie.

Synchronisatie is duur, dus met deze optie kunnen we alleen de relevante delen van een methode synchroniseren.

9.1. Andere objecten als een slot

We kunnen de threadveilige implementatie van het Teller class door een ander object te exploiteren als een monitorvergrendeling, in plaats van dit.

Dit biedt niet alleen gecoördineerde toegang tot een gedeelde bron in een multithread-omgeving, maar het gebruikt ook een externe entiteit om exclusieve toegang tot de bron af te dwingen:

openbare klasse ObjectLockCounter {privé int teller = 0; privé definitief objectvergrendeling = nieuw object (); openbare ongeldige incrementCounter () {gesynchroniseerd (vergrendelen) {teller + = 1; }} // standaard getter}

We gebruiken een vlakte Voorwerp instantie om wederzijdse uitsluiting af te dwingen. Deze implementatie is iets beter, omdat het de beveiliging op slotniveau bevordert.

Tijdens gebruik dit voor intrinsieke vergrendeling, een aanvaller kan een impasse veroorzaken door de intrinsieke vergrendeling te verwerven en een denial of service (DoS) -conditie te activeren.

Integendeel, bij gebruik van andere objecten, die privé-entiteit is van buitenaf niet toegankelijk. Dit maakt het voor een aanvaller moeilijker om het slot te bemachtigen en een impasse te veroorzaken.

9.2. Waarschuwingen

Hoewel we elk Java-object als een intrinsiek slot kunnen gebruiken, moeten we het gebruik van Snaren voor vergrendelingsdoeleinden:

public class Class1 {private static final String LOCK = "Lock"; // gebruikt het LOCK als het intrinsieke slot} public class Class2 {private static final String LOCK = "Lock"; // gebruikt het SLOT als het intrinsieke slot}

Op het eerste gezicht lijkt het erop dat deze twee klassen twee verschillende objecten als hun slot gebruiken. Echter, Vanwege string-interning kunnen deze twee "Lock" -waarden in feite verwijzen naar hetzelfde object in de stringpool. Dat is de Klas 1 en Klasse2 delen hetzelfde slot!

Dit kan op zijn beurt leiden tot onverwacht gedrag in gelijktijdige contexten.

In aanvulling op Snaren, we moeten vermijden om cachebare of herbruikbare objecten als intrinsieke vergrendelingen te gebruiken. Bijvoorbeeld de Integer.valueOf () methode cachet kleine getallen. Daarom bellen Integer.valueOf (1) geeft hetzelfde object terug, zelfs in verschillende klassen.

10. Vluchtige velden

Gesynchroniseerde methoden en blokken zijn handig voor het aanpakken van problemen met variabele zichtbaarheid tussen threads. Toch kunnen de waarden van reguliere klassevelden door de CPU in de cache worden opgeslagen. Daarom zijn consequente updates van een bepaald veld, zelfs als ze zijn gesynchroniseerd, mogelijk niet zichtbaar voor andere threads.

Om deze situatie te voorkomen, kunnen we gebruik maken van vluchtig klasse velden:

openbare klasse Counter {privé vluchtige int-teller; // standard constructors / getter}

Met de vluchtig trefwoord, instrueren we de JVM en de compiler om het teller variabele in het hoofdgeheugen. Op die manier zorgen we ervoor dat elke keer dat de JVM de waarde van het teller variabele, zal het het daadwerkelijk lezen uit het hoofdgeheugen, in plaats van uit de CPU-cache. Evenzo, elke keer dat de JVM naar het teller variabele, wordt de waarde naar het hoofdgeheugen geschreven.

Bovendien, het gebruik van een vluchtig variabele zorgt ervoor dat alle variabelen die zichtbaar zijn voor een bepaalde thread ook uit het hoofdgeheugen worden gelezen.

Laten we eens kijken naar het volgende voorbeeld:

openbare klasse Gebruiker {privé Stringnaam; privé vluchtige int leeftijd; // standard constructors / getters}

In dit geval schrijft de JVM elke keer dat het leeftijdvluchtig variabele naar het hoofdgeheugen, zal het het niet-vluchtige naam ook variabel in het hoofdgeheugen. Dit zorgt ervoor dat de laatste waarden van beide variabelen worden opgeslagen in het hoofdgeheugen, zodat daaropvolgende updates van de variabelen automatisch zichtbaar zullen zijn voor andere threads.

Evenzo, als een thread de waarde van een vluchtig variabele, worden alle variabelen die zichtbaar zijn voor de thread ook uit het hoofdgeheugen gelezen.

Deze uitgebreide garantie dat vluchtig variabelen bieden staat bekend als de garantie voor volledige vluchtige zichtbaarheid.

11. Reentrant Sloten

Java biedt een verbeterde set Slot implementaties, waarvan het gedrag iets geavanceerder is dan de intrinsieke vergrendelingen die hierboven zijn besproken.

Bij intrinsieke sloten is het model voor het verwerven van sloten nogal rigide: één thread verwerft de vergrendeling, voert vervolgens een methode of codeblok uit en geeft tenslotte de vergrendeling vrij, zodat andere threads deze kunnen verkrijgen en toegang krijgen tot de methode.

Er is geen onderliggend mechanisme dat de threads in de wachtrij controleert en prioriteitstoegang geeft tot de threads die het langst wachten.

ReentrantLock gevallen stellen ons in staat om precies dat te doen, waardoor wordt voorkomen dat threads in de wachtrij te lijden hebben onder bepaalde soorten uithongering van bronnen:

openbare klasse ReentrantLockCounter {privé int teller; private finale ReentrantLock reLock = nieuwe ReentrantLock (true); openbare ongeldige incrementCounter () {reLock.lock (); probeer {counter + = 1; } eindelijk {reLock.unlock (); }} // standard constructors / getter}

De ReentrantLock constructor neemt een optioneel eerlijkheidboolean parameter. Indien ingesteld op waar, en meerdere threads proberen een slot te krijgen, de JVM geeft prioriteit aan de langst wachtende thread en verleent toegang tot het slot.

12. Lees- / schrijfvergrendelingen

Een ander krachtig mechanisme dat we kunnen gebruiken om draadveiligheid te bereiken, is het gebruik van ReadWriteLock implementaties.

EEN ReadWriteLock lock gebruikt eigenlijk een paar bijbehorende vergrendelingen, een voor alleen-lezen bewerkingen en een andere voor schrijfbewerkingen.

Als resultaat, het is mogelijk dat veel threads een bron lezen, zolang er geen thread naar schrijft. Bovendien zal de thread die naar de bron schrijft, voorkomen dat andere threads deze lezen.

We kunnen een ReadWriteLock vergrendel als volgt:

openbare klasse ReentrantReadWriteLockCounter {privé int teller; private finale ReentrantReadWriteLock rwLock = nieuwe ReentrantReadWriteLock (); privé definitief slot readLock = rwLock.readLock (); privé definitief slot writeLock = rwLock.writeLock (); openbare ongeldige incrementCounter () {writeLock.lock (); probeer {counter + = 1; } eindelijk {writeLock.unlock (); }} openbare int getCounter () {readLock.lock (); probeer {retourteller; } eindelijk {readLock.unlock (); }} // standaard constructeurs} 

13. Conclusie

In dit artikel, we hebben geleerd wat thread-safety is in Java, en hebben de verschillende benaderingen om dit te bereiken grondig onderzocht.

Zoals gewoonlijk zijn alle codevoorbeelden die in dit artikel worden getoond, beschikbaar op GitHub.