Vanlige samtidige fallgruver i Java

1. Introduksjon

I denne opplæringen skal vi se noen av de vanligste samtidighetsproblemene i Java. Vi lærer også hvordan vi kan unngå dem og deres viktigste årsaker.

2. Bruke trådsikre objekter

2.1. Dele objekter

Tråder kommuniserer primært ved å dele tilgang til de samme objektene. Så, å lese fra et objekt mens det endres, kan gi uventede resultater. Samtidig kan endring av et objekt etterlate det i en ødelagt eller inkonsekvent tilstand.

Den viktigste måten vi kan unngå slike samtidige problemer og bygge pålitelig kode er å jobbe med uforanderlige gjenstander. Dette er fordi tilstanden deres ikke kan endres ved forstyrrelse av flere tråder.

Vi kan imidlertid ikke alltid jobbe med uforanderlige gjenstander. I disse tilfellene må vi finne måter å gjøre våre foranderlige objekter trådsikre.

2.2. Gjør samlinger trådsikre

Som ethvert annet objekt, opprettholder samlinger staten internt. Dette kan endres ved at flere tråder endrer samlingen samtidig. Så, en måte vi trygt kan jobbe med samlinger i et flertrådet miljø er å synkronisere dem:

Map map = Collections.synchronizedMap (new HashMap ()); Listeliste = Collections.synchronizedList (ny ArrayList ());

Generelt hjelper synkronisering oss til å oppnå gjensidig ekskludering. Mer spesifikt, disse samlingene kan nås med bare en tråd om gangen. Dermed kan vi unngå å etterlate samlinger i en inkonsekvent tilstand.

2.3. Spesialist flertrådede samlinger

La oss nå vurdere et scenario der vi trenger mer lesing enn skriving. Ved å bruke en synkronisert samling kan applikasjonen vår få store ytelseskonsekvenser. Hvis to tråder vil lese samlingen samtidig, må den ene vente til den andre er ferdig.

Av denne grunn gir Java samtidige samlinger som CopyOnWriteArrayList og ConcurrentHashMap som kan nås samtidig av flere tråder:

CopyOnWriteArrayList liste = ny CopyOnWriteArrayList (); Kartkart = nytt ConcurrentHashMap ();

De CopyOnWriteArrayList oppnår trådsikkerhet ved å lage en egen kopi av den underliggende matrisen for mutative operasjoner som legge til eller fjerne. Selv om den har dårligere ytelse for skriveoperasjoner enn en Collections.synchronizedList, det gir oss bedre ytelse når vi trenger betydelig flere lesninger enn skriver.

ConcurrentHashMap er fundamentalt trådsikker og er mer performant enn Collections.synchronizedMap vikle rundt et ikke-tråd-trygt Kart. Det er faktisk et trådsikkert kart med trådsikre kart, slik at forskjellige aktiviteter kan skje samtidig i sine underordnede kart.

2.4. Arbeide med ikke-trådsikre typer

Vi bruker ofte innebygde objekter som SimpleDateFormat for å analysere og formatere datoobjekter. De SimpleDateFormat klasse muterer sin interne tilstand mens den utfører sine operasjoner.

Vi må være veldig forsiktige med dem fordi de ikke er trådsikre. Deres tilstand kan bli inkonsekvent i en flertrådet applikasjon på grunn av ting som raseforhold.

Så hvordan kan vi bruke SimpleDateFormat trygt? Vi har flere alternativer:

  • Opprett en ny forekomst av SimpleDateFormat hver gang den brukes
  • Begrens antall objekter opprettet ved hjelp av a Trådlokal gjenstand. Det garanterer at hver tråd vil ha sin egen forekomst av SimpleDateFormat
  • Synkroniser samtidig tilgang med flere tråder med synkronisert nøkkelord eller en lås

SimpleDateFormat er bare ett eksempel på dette. Vi kan bruke disse teknikkene med en hvilken som helst ikke-trådsikker type.

3. Løpsforhold

En løpstilstand oppstår når to eller flere tråder får tilgang til delte data, og de prøver å endre det samtidig. Dermed kan løpsforhold forårsake kjøretidsfeil eller uventede resultater.

3.1. Race Condition Eksempel

La oss vurdere følgende kode:

klasseteller {privat int-teller = 0; offentlig ugyldig økning () {counter ++; } public int getValue () {return counter; }}

De Disk klasse er utformet slik at hver påkalling av trinnet metoden vil legge 1 til disk. Imidlertid, hvis en Disk objektet er referert fra flere tråder, kan interferensen mellom tråder forhindre at dette skjer som forventet.

Vi kan spalte teller ++ uttalelse i 3 trinn:

  • Hent gjeldende verdi av disk
  • Øk den hentede verdien med 1
  • Lagre den økte verdien igjen disk

La oss anta to tråder, tråd 1 og tråd2, påberope inkrementmetoden samtidig. Deres sammenflettede handlinger kan følge denne sekvensen:

  • tråd 1 leser nåværende verdi av disk; 0
  • tråd2 leser den nåværende verdien av disk; 0
  • tråd 1 øker den hentede verdien; resultatet er 1
  • tråd2 øker den hentede verdien; resultatet er 1
  • tråd 1 lagrer resultatet i disk; resultatet er nå 1
  • tråd2 lagrer resultatet i disk; resultatet er nå 1

Vi forventet verdien av disk å være 2, men det var 1.

3.2. En synkronisert løsning

Vi kan fikse inkonsekvensen ved å synkronisere den kritiske koden:

klasse SynchronizedCounter {private int counter = 0; offentlig synkronisert ugyldig økning () {counter ++; } offentlig synkronisert int getValue () {retur teller; }}

Bare en tråd har lov til å bruke synkronisert metoder for et objekt til enhver tid, så dette tvinger konsistens i lesing og skriving av disk.

3.3. En innebygd løsning

Vi kan erstatte koden ovenfor med en innebygd AtomicInteger gjenstand. Denne klassen tilbyr blant annet atommetoder for inkrementering av et heltall og er en bedre løsning enn å skrive vår egen kode. Derfor kan vi kalle metodene direkte uten behov for synkronisering:

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

I dette tilfellet løser SDK problemet for oss. Ellers kunne vi også ha skrevet vår egen kode, og innkapslet de kritiske delene i en tilpasset trådsikker klasse. Denne tilnærmingen hjelper oss med å minimere kompleksiteten og maksimere gjenbrukbarheten til koden vår.

4. Race forhold rundt samlinger

4.1. Problemet

En annen fallgruve vi kan falle i er å tenke at synkroniserte samlinger gir oss mer beskyttelse enn de faktisk gjør.

La oss undersøke koden nedenfor:

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

Hver operasjon på listen vår er synkronisert, men alle kombinasjoner av flere metodeanrop blir ikke synkronisert. Mer spesifikt, mellom de to operasjonene, kan en annen tråd endre samlingen vår og føre til uønskede resultater.

For eksempel kan to tråder komme inn i hvis blokkere samtidig, og oppdater deretter listen, hver tråd legger til foo verdi til listen.

4.2. En løsning for lister

Vi kan beskytte koden fra å få tilgang til mer enn én tråd om gangen ved hjelp av synkronisering:

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

Snarere enn å legge til synkronisert nøkkelord til funksjonene, har vi laget en kritisk del om liste, som bare tillater en tråd om gangen å utføre denne operasjonen.

Vi bør merke oss at vi kan bruke synkronisert (liste) på andre operasjoner på vårt listeobjekt, for å gi en garanterer at bare en tråd av gangen kan utføre noen av våre operasjoner på dette objektet.

4.3. En innebygd løsning for ConcurrentHashMap

La oss nå vurdere å bruke et kart av samme grunn, nemlig å legge til en oppføring bare hvis den ikke er til stede.

De ConcurrentHashMap tilbyr en bedre løsning for denne typen problemer. Vi kan bruke dets atom putIfAbsent metode:

Kartkart = nytt ConcurrentHashMap (); map.putIfAbsent ("foo", "bar");

Eller hvis vi vil beregne verdien, dens atom computeIfAbsent metode:

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

Vi bør merke oss at disse metodene er en del av grensesnittet til Kart der de tilbyr en praktisk måte å unngå å skrive betinget logikk rundt innsetting. De hjelper oss virkelig når vi prøver å ringe flertrådede samtaler.

5. Problemer med minnekonsistens

Problemer med minnekonsistens oppstår når flere tråder har inkonsekvente visninger av hva som skal være de samme dataene.

I tillegg til hovedminnet bruker de fleste moderne dataarkitekturer et hierarki av cacher (L1, L2 og L3 cacher) for å forbedre den generelle ytelsen. Dermed kan en hvilken som helst tråd cache variabler fordi den gir raskere tilgang sammenlignet med hovedminnet.

5.1. Problemet

La oss huske vår Disk eksempel:

klasseteller {privat int-teller = 0; offentlig ugyldig økning () {counter ++; } public int getValue () {return counter; }}

La oss vurdere scenariet hvor tråd 1 øker disk og så tråd2 leser verdien. Følgende hendelsesforløp kan skje:

  • tråd 1 leser tellerverdien fra sin egen cache; telleren er 0
  • thread1 øker disken og skriver den tilbake til sin egen cache; telleren er 1
  • tråd2 leser tellerverdien fra sin egen cache; telleren er 0

Selvfølgelig kan den forventede rekkefølgen av hendelser også skje og thread2 vil lese riktig verdi (1), men Det er ingen garanti for at endringer gjort av en tråd vil være synlige for andre tråder hver gang.

5.2. Løsningen

For å unngå minnekonsistensfeil, vi trenger å etablere et forhold som skjer før. Dette forholdet er rett og slett en garanti for at minneoppdateringer av en bestemt uttalelse er synlige for en annen spesifikk uttalelse.

Det er flere strategier som skaper hendelser før forhold. En av dem er synkronisering, som vi allerede har sett på.

Synkronisering sikrer både gjensidig ekskludering og minnekonsistens. Dette kommer imidlertid med en ytelseskostnad.

Vi kan også unngå problemer med minnekonsistens ved å bruke flyktige nøkkelord. For å si det enkelt, hver endring til en flyktig variabel er alltid synlig for andre tråder.

La oss skrive om vår Disk eksempel bruker flyktige:

klasse SyncronizedCounter {private volatile int counter = 0; offentlig synkronisert ugyldig økning () {counter ++; } public int getValue () {return counter; }}

Vi bør merke oss det vi fortsatt trenger å synkronisere trinnoperasjonen fordi flyktige sikrer oss ikke gjensidig utestenging. Å bruke enkel atomvariabel tilgang er mer effektiv enn tilgang til disse variablene gjennom synkronisert kode.

5.3. Ikke-atomisk lang og dobbelt Verdier

Så hvis vi leser en variabel uten riktig synkronisering, kan vi se en foreldet verdi. Feller lang og dobbelt verdier, ganske overraskende, er det til og med mulig å se helt tilfeldige verdier i tillegg til foreldede.

I følge JLS-17 kan JVM behandle 64-biters operasjoner som to separate 32-biters operasjoner. Derfor, når du leser en lang eller dobbelt verdi, er det mulig å lese en oppdatert 32-bit sammen med en foreldet 32-bit. Derfor kan vi observere tilfeldig utseende lang eller dobbelt verdier i samtidige sammenhenger.

På den annen side skriver og leser om flyktige lang og dobbelt verdier er alltid atomare.

6. Misbruk Synkroniser

Synkroniseringsmekanismen er et kraftig verktøy for å oppnå trådsikkerhet. Det er avhengig av bruk av indre og eksterne låser. La oss også huske det faktum at hvert objekt har en annen lås, og bare en tråd kan skaffe en lås om gangen.

Imidlertid, hvis vi ikke følger med og nøye velger de riktige låsene for den kritiske koden vår, kan uventet oppførsel oppstå.

6.1. Synkronisering på dette Henvisning

Metodesynkroniseringen kommer som en løsning på mange samtidige problemer. Det kan imidlertid også føre til andre problemer med samtidig hvis det er for mye. Denne synkroniseringsmetoden er avhengig av dette referanse som en lås, som også kalles en indre lås.

Vi kan se i de følgende eksemplene hvordan en synkronisering på metodenivå kan oversettes til en synkronisering på blokknivå med dette referanse som en lås.

Disse metodene er ekvivalente:

offentlig synkronisert ugyldig foo () {// ...}
offentlig ugyldig foo () {synkronisert (dette) {// ...}}

Når en slik metode kalles av en tråd, kan ikke andre tråder få tilgang til objektet samtidig. Dette kan redusere samtidig ytelse ettersom alt ender med å kjøre med en tråd. Denne tilnærmingen er spesielt dårlig når et objekt blir lest oftere enn det oppdateres.

Videre kan en klient av koden vår også skaffe seg dette låse. I verste fall kan denne operasjonen føre til en fastlåst situasjon.

6.2. Dødlås

Deadlock beskriver en situasjon der to eller flere tråder blokkerer hverandre, hver som venter på å skaffe seg en ressurs som holdes av en annen tråd.

La oss se på eksemplet:

offentlig klasse DeadlockExample {offentlig statisk Objektlås1 = nytt Objekt (); offentlig statisk Objektlås2 = nytt 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 ..."); synkronisert (lock2) {System.out.println ("ThreadA: Holding lock 1 & 2 ...");}}}); TrådtrådB = ny tråd (() -> {synkronisert (lås2) {System.out.println ("TrådB: Hold lås 2 ..."); hvilemodus (); System.out.println ("TrådB: Venter på lås 1 ... "); synkronisert (lås1) {System.out.println (" TrådB: Holdelås 1 & 2 ... ");}}}); threadA.start (); trådB.start (); }}

I koden ovenfor kan vi tydelig se det først tråd A. anskaffer lås 1 og trådB anskaffer lås2. Deretter, tråd A. prøver å få lås2 som allerede er anskaffet av trådB og trådB prøver å få lås 1 som allerede er anskaffet av tråd A.. Så ingen av dem vil fortsette, noe som betyr at de er i en fastlåst tilstand.

Vi kan enkelt løse dette problemet ved å endre rekkefølgen på låser i en av trådene.

Vi bør merke oss at dette bare er ett eksempel, og det er mange andre som kan føre til en fastlåst situasjon.

7. Konklusjon

I denne artikkelen undersøkte vi flere eksempler på samtidighetsproblemer som vi sannsynligvis vil støte på i våre flertrådede applikasjoner.

Først lærte vi at vi skulle velge objekter eller operasjoner som enten er uforanderlige eller trådsikre.

Så så vi flere eksempler på løpsforhold og hvordan vi kan unngå dem ved hjelp av synkroniseringsmekanismen. Videre lærte vi om hukommelsesrelaterte løpsforhold og hvordan vi kan unngå dem.

Selv om synkroniseringsmekanismen hjelper oss med å unngå mange samtidige problemer, kan vi enkelt misbruke den og skape andre problemer. Av denne grunn undersøkte vi flere problemer vi kan møte når denne mekanismen blir dårlig brukt.

Som vanlig er alle eksemplene som brukes i denne artikkelen tilgjengelig på GitHub.


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