Almindelige samtidige faldgruber i Java

1. Introduktion

I denne vejledning vil vi se nogle af de mest almindelige samtidige problemer i Java. Vi lærer også, hvordan man undgår dem og deres hovedårsager.

2. Brug af trådsikre objekter

2.1. Deling af objekter

Tråde kommunikerer primært ved at dele adgang til de samme objekter. Så læsning fra et objekt, mens det ændres, kan give uventede resultater. Samtidig kan samtidig ændring af et objekt efterlade det i en ødelagt eller inkonsekvent tilstand.

Den vigtigste måde, hvorpå vi kan undgå sådanne samtidige problemer og opbygge pålidelig kode, er at arbejde med uforanderlige objekter. Dette skyldes, at deres tilstand ikke kan ændres ved interferens af flere tråde.

Vi kan dog ikke altid arbejde med uforanderlige genstande. I disse tilfælde er vi nødt til at finde måder at gøre vores ændrede objekter trådsikre.

2.2. Gør samlinger trådsikre

Som ethvert andet objekt opretholder samlinger tilstanden internt. Dette kan ændres ved, at flere tråde ændrer samlingen samtidigt. Så, en måde, vi sikkert kan arbejde med samlinger i et multitrådet miljø er at synkronisere dem:

Kortkort = Collections.synchronizedMap (nyt HashMap ()); Liste liste = Collections.synchronizedList (ny ArrayList ());

Generelt hjælper synkronisering os med at opnå gensidig udelukkelse. Mere specifikt, disse samlinger kan kun fås med en tråd ad gangen. Således kan vi undgå at efterlade samlinger i en inkonsekvent tilstand.

2.3. Specialiserede multitrådede samlinger

Lad os nu overveje et scenario, hvor vi har brug for flere læsninger end skriver. Ved at bruge en synkroniseret samling kan vores applikation have store præstationskonsekvenser. Hvis to tråde ønsker at læse samlingen på samme tid, skal man vente, indtil den anden er færdig.

Af denne grund leverer Java samtidige samlinger som f.eks CopyOnWriteArrayList og ConcurrentHashMap der kan tilgås samtidigt af flere tråde:

CopyOnWriteArrayList liste = ny CopyOnWriteArrayList (); Kortkort = nyt ConcurrentHashMap ();

Det CopyOnWriteArrayList opnår trådsikkerhed ved at oprette en separat kopi af det underliggende array til mutative operationer som tilføj eller fjern. Selvom det har en dårligere ydeevne til skriveoperationer end en Collections.synchronizedList, det giver os bedre ydeevne, når vi har brug for betydeligt flere læsninger end skriver.

ConcurrentHashMap er grundlæggende trådsikker og er mere performant end Collections.synchronizedMap indpakning omkring en ikke-tråd-sikker Kort. Det er faktisk et trådsikkert kort over trådsikre kort, der tillader forskellige aktiviteter at ske samtidigt i dets underordnede kort.

2.4. Arbejde med ikke-trådsikre typer

Vi bruger ofte indbyggede objekter som f.eks SimpleDateFormat for at analysere og formatere datoobjekter. Det SimpleDateFormat klasse muterer sin interne tilstand, mens den udfører sine operationer.

Vi skal være meget forsigtige med dem, fordi de ikke er trådsikre. Deres tilstand kan blive inkonsekvent i en applikation med flere tråde på grund af ting som raceforhold.

Så hvordan kan vi bruge SimpleDateFormat sikkert? Vi har flere muligheder:

  • Opret en ny forekomst af SimpleDateFormat hver gang det bruges
  • Begræns antallet af objekter, der er oprettet ved hjælp af a Trådlokal objekt. Det garanterer, at hver tråd har sin egen forekomst af SimpleDateFormat
  • Synkroniser samtidig adgang med flere tråde med synkroniseret nøgleord eller en lås

SimpleDateFormat er blot et eksempel på dette. Vi kan bruge disse teknikker med enhver ikke-trådsikker type.

3. Race betingelser

En løbetilstand opstår, når to eller flere tråde får adgang til delte data, og de forsøger at ændre dem på samme tid. Således kan løbsbetingelser forårsage runtime-fejl eller uventede resultater.

3.1. Løbstilstandseksempel

Lad os overveje følgende kode:

klasse tæller {privat int tæller = 0; offentlig tomrumsforøgelse () {tæller ++; } public int getValue () {return counter; }}

Det Tæller klasse er designet således, at hver påkaldelse af inkrementmetoden føjer 1 til tæller. Men hvis en Tæller objekt refereres fra flere tråde, interferensen mellem tråde kan forhindre, at dette sker som forventet.

Vi kan nedbryde tæller ++ erklæring i 3 trin:

  • Hent den aktuelle værdi af tæller
  • Forøg den hentede værdi med 1
  • Gem den forøgede værdi tilbage i tæller

Lad os antage to tråde, tråd1 og tråd2, påberåbe sig trinvise metoden på samme tid. Deres sammenflettede handlinger kan følge denne sekvens:

  • tråd1 læser den aktuelle værdi af tæller; 0
  • tråd2 læser den aktuelle værdi af tæller; 0
  • tråd1 forøger den hentede værdi; resultatet er 1
  • tråd2 øger den hentede værdi; resultatet er 1
  • tråd1 gemmer resultatet i tæller; resultatet er nu 1
  • tråd2 gemmer resultatet i tæller; resultatet er nu 1

Vi forventede værdien af tæller at være 2, men det var 1.

3.2. En synkroniseret løsning

Vi kan løse inkonsekvensen ved at synkronisere den kritiske kode:

klasse SynchronizedCounter {private int counter = 0; offentlig synkroniseret tomrumsforøgelse () {tæller ++; } offentlig synkroniseret int getValue () {retur tæller; }}

Kun en tråd har tilladelse til at bruge synkroniseret metoder til et objekt til enhver tid, så dette tvinger konsistens i læsning og skrivning af tæller.

3.3. En indbygget løsning

Vi kan erstatte ovenstående kode med en indbygget AtomicInteger objekt. Denne klasse tilbyder blandt andet atomiske metoder til at øge et heltal og er en bedre løsning end at skrive vores egen kode. Derfor kan vi kalde dets metoder direkte uden behov for synkronisering:

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

I dette tilfælde løser SDK problemet for os. Ellers kunne vi også have skrevet vores egen kode og indkapslet de kritiske afsnit i en brugerdefineret trådsikker klasse. Denne tilgang hjælper os med at minimere kompleksiteten og maksimere genanvendeligheden af ​​vores kode.

4. Race betingelser omkring samlinger

4.1. Problemet

En anden faldgrube, vi kan falde i, er at tro, at synkroniserede samlinger giver os mere beskyttelse, end de rent faktisk gør.

Lad os undersøge koden nedenfor:

Liste liste = Collections.synchronizedList (ny ArrayList ()); hvis (! list.contains ("foo")) {list.add ("foo"); }

Hver operation på vores liste er synkroniseret, men enhver kombination af flere metodeopkald synkroniseres ikke. Mere specifikt kan en anden tråd mellem de to operationer ændre vores samling, hvilket fører til uønskede resultater.

For eksempel kunne to tråde komme ind i hvis blokere på samme tid, og opdater derefter listen, hvor hver tråd tilføjer foo værdi til listen.

4.2. En løsning til lister

Vi kan beskytte koden fra mere end en tråd ad gangen ved hjælp af synkronisering:

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

I stedet for at tilføje synkroniseret nøgleord til funktionerne, har vi oprettet et kritisk afsnit om liste, som kun tillader en tråd ad gangen at udføre denne handling.

Vi skal bemærke, at vi kan bruge synkroniseret (liste) på andre operationer på vores listeobjekt, for at give en garanterer, at kun en tråd ad gangen kan udføre nogen af ​​vores operationer på dette objekt.

4.3. En indbygget løsning til ConcurrentHashMap

Lad os nu overveje at bruge et kort af samme grund, nemlig kun at tilføje en post, hvis den ikke er til stede.

Det ConcurrentHashMap tilbyder en bedre løsning til denne type problemer. Vi kan bruge dets atomare putIfAbsent metode:

Kortkort = nyt ConcurrentHashMap (); map.putIfAbsent ("foo", "bar");

Eller hvis vi vil beregne værdien, dens atomare ComputeIfAbsent metode:

map.computeIfAbsent ("foo", nøgle -> nøgle + "bar");

Vi skal bemærke, at disse metoder er en del af grænsefladen til Kort hvor de tilbyder en bekvem måde at undgå at skrive betinget logik omkring indsættelse. De hjælper os virkelig, når vi prøver at foretage opkald med flere tråde atomiske.

5. Problemer med hukommelseskonsistens

Problemer med hukommelseskonsistens opstår, når flere tråde har inkonsekvente visninger af, hvad der skal være de samme data.

Ud over hovedhukommelsen bruger de fleste moderne computerarkitekturer et hierarki af cacher (L1, L2 og L3 caches) for at forbedre den samlede ydeevne. Således kan enhver tråd cache variabler, fordi den giver hurtigere adgang sammenlignet med hovedhukommelsen.

5.1. Problemet

Lad os huske vores Tæller eksempel:

klasse tæller {privat int tæller = 0; offentlig tomrumsforøgelse () {tæller ++; } public int getValue () {return counter; }}

Lad os overveje scenariet hvor tråd1 øger den tæller og så tråd2 læser dens værdi. Følgende rækkefølge af begivenheder kan ske:

  • tråd1 læser tællerværdien fra sin egen cache; tælleren er 0
  • thread1 forøger tælleren og skriver den tilbage til sin egen cache; tælleren er 1
  • tråd2 læser tællerværdien fra sin egen cache; tælleren er 0

Naturligvis kunne den forventede rækkefølge af begivenheder også ske, og thread2 vil læse den korrekte værdi (1), men der er ingen garanti for, at ændringer foretaget af en tråd vil være synlige for andre tråde hver gang.

5.2. Løsningen

For at undgå hukommelseskonsistensfejl, vi er nødt til at etablere et forhold, der sker før. Dette forhold er simpelthen en garanti for, at hukommelsesopdateringer med en bestemt sætning er synlige for en anden specifik erklæring.

Der er flere strategier, der skaber hændelser før forhold. En af dem er synkronisering, som vi allerede har set på.

Synkronisering sikrer både gensidig udelukkelse og hukommelseskonsistens. Dette kommer dog med en præstationsomkostning.

Vi kan også undgå hukommelseskonsistensproblemer ved at bruge flygtige nøgleord. Kort fortalt, enhver ændring til en flygtig variabel er altid synlig for andre tråde.

Lad os omskrive vores Tæller eksempel ved hjælp af flygtige:

klasse SyncronizedCounter {private volatile int counter = 0; offentlig synkroniseret tomrumsforøgelse () {tæller ++; } public int getValue () {return counter; }}

Det skal vi bemærke vi har stadig brug for at synkronisere trinvise operationer, fordi flygtige sikrer os ikke gensidig udstødelse. Brug af simpel atomvariabel adgang er mere effektiv end adgang til disse variabler via synkroniseret kode.

5.3. Ikke-atomisk lang og dobbelt Værdier

Så hvis vi læser en variabel uden korrekt synkronisering, kan vi muligvis se en uaktuel værdi. Feller lang og dobbelt værdier, ganske overraskende, er det endda muligt at se helt tilfældige værdier ud over forældede.

Ifølge JLS-17 kan JVM behandle 64-bit operationer som to separate 32-bit operationer. Derfor, når du læser en lang eller dobbelt værdi, er det muligt at læse en opdateret 32-bit sammen med en forældet 32-bit. Derfor ser vi muligvis tilfældigt ud lang eller dobbelt værdier i samtidige sammenhænge.

På den anden side skriver og læser om flygtige lang og dobbelt værdier er altid atomare.

6. Misbrug Synkroniser

Synkroniseringsmekanismen er et kraftfuldt værktøj til at opnå trådsikkerhed. Det er afhængig af brugen af ​​indre låse og ydre låse. Lad os også huske det faktum, at hvert objekt har en anden lås, og kun en tråd kan erhverve en lås ad gangen.

Men hvis vi ikke er opmærksomme og nøje vælger de rigtige låse til vores kritiske kode, kan der opstå uventet opførsel.

6.1. Synkronisering til det her Reference

Metodesynkroniseringen kommer som en løsning på mange samtidige problemer. Det kan dog også føre til andre samtidige problemer, hvis det er overbrugt. Denne synkroniseringsmetode er afhængig af det her henvisning som en lås, som også kaldes en indre lås.

Vi kan se i de følgende eksempler, hvordan en synkronisering på metodeniveau kan oversættes til en synkronisering på blokniveau med det her henvisning som en lås.

Disse metoder er ækvivalente:

offentlig synkroniseret ugyldig foo () {// ...}
offentlig ugyldighed foo () {synkroniseret (dette) {// ...}}

Når en sådan metode kaldes af en tråd, kan andre tråde ikke samtidig få adgang til objektet. Dette kan reducere samtidig ydeevne, da alt ender med at køre single-threaded. Denne tilgang er især dårlig, når et objekt læses oftere, end det opdateres.

Desuden kan en klient af vores kode også erhverve det her låse. I værste fald kan denne operation føre til en dødvande.

6.2. Dødlås

Deadlock beskriver en situation, hvor to eller flere tråde blokerer hinanden, hver venter på at erhverve en ressource, som ejes af en anden tråd.

Lad os overveje eksemplet:

offentlig klasse DeadlockExample {offentlig statisk objektlås1 = nyt objekt (); offentlig statisk objektlås2 = nyt 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 ..."); synkroniseret (lock2) {System.out.println ("ThreadA: Holding lock 1 & 2 ...");}}}); Tråd trådB = ny tråd (() -> {synkroniseret (lås2) {System.out.println ("TrådB: Hold lås 2 ..."); dvaletilstand (); System.out.println ("TrådB: Venter på lås 1 ... "); synkroniseret (lås1) {System.out.println (" TrådB: Holdelås 1 & 2 ... ");}}}); threadA.start (); threadB.start (); }}

I ovenstående kode kan vi tydeligt se det først tråd A. erhverver lås 1 og trådB erhverver lås2. Derefter, tråd A. forsøger at få lås2 som allerede er erhvervet af trådB og trådB forsøger at få lås 1 som allerede er erhvervet af tråd A.. Så ingen af ​​dem vil gå videre, hvilket betyder at de er i en fastlåst tilstand.

Vi kan nemt løse dette problem ved at ændre rækkefølgen af ​​låse i en af ​​trådene.

Vi skal bemærke, at dette kun er et eksempel, og at der er mange andre, der kan føre til et dødvande.

7. Konklusion

I denne artikel undersøgte vi flere eksempler på samtidige problemer, som vi sandsynligvis støder på i vores multitrådede applikationer.

For det første lærte vi, at vi skulle vælge objekter eller operationer, der enten er uforanderlige eller trådsikre.

Derefter så vi flere eksempler på raceforhold, og hvordan vi kan undgå dem ved hjælp af synkroniseringsmekanismen. Desuden lærte vi om hukommelsesrelaterede race betingelser og hvordan man undgår dem.

Selvom synkroniseringsmekanismen hjælper os med at undgå mange samtidige problemer, kan vi let misbruge den og oprette andre problemer. Af denne grund undersøgte vi flere problemer, som vi måske står over for, når denne mekanisme bruges dårligt.

Som sædvanligt er alle eksemplerne i denne artikel tilgængelige på GitHub.


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