Guide till det flyktiga nyckelordet i Java

1. Översikt

I avsaknad av nödvändiga synkroniseringar kan kompilatorn, körtiden eller processorerna använda alla möjliga optimeringar. Även om dessa optimeringar är fördelaktiga för det mesta, kan de ibland orsaka subtila problem.

Cachning och omordning är bland de optimeringar som kan överraska oss i samtidiga sammanhang. Java och JVM ger många sätt att kontrollera minnesordning, och det flyktiga nyckelordet är ett av dem.

I den här artikeln fokuserar vi på detta grundläggande men ofta missförstådda koncept på Java-språket - det flyktiga nyckelordet. Först börjar vi med lite bakgrund om hur den underliggande datorarkitekturen fungerar, och sedan blir vi bekanta med minnesordningen i Java.

2. Delad multiprocessorarkitektur

Processorer är ansvariga för att genomföra programinstruktioner. Därför måste de hämta både programinstruktioner och nödvändig data från RAM.

Eftersom processorer kan utföra ett stort antal instruktioner per sekund är det inte så idealiskt att hämta från RAM för dem. För att förbättra den här situationen använder processorer tricks som Out of Order Execution, Branch Prediction, Speculative Execution och, naturligtvis, Caching.

Det är här följande minneshierarki kommer till spel:

När olika kärnor utför fler instruktioner och manipulerar mer data fyller de sina cachar med mer relevanta data och instruktioner. Detta kommer att förbättra den totala prestandan på bekostnad av att införa cache-koherensutmaningar .

Enkelt uttryckt bör vi tänka två gånger på vad som händer när en tråd uppdaterar ett cachat värde.

3. När ska man använda flyktiga ämnen?

För att utöka mer om cachens sammanhang, låt oss låna ett exempel från boken Java Concurrency in Practice:

public class TaskRunner { private static int number; private static boolean ready; private static class Reader extends Thread { @Override public void run() { while (!ready) { Thread.yield(); } System.out.println(number); } } public static void main(String[] args) { new Reader().start(); number = 42; ready = true; } }

Den TaskRunner klassen håller två enkla variabler. I sin huvudmetod skapar den en annan tråd som snurrar på den färdiga variabeln så länge den är falsk. När variabeln blir sann, kommer tråden helt enkelt skriva ut numret variabel.

Många kan förvänta sig att detta program helt enkelt skriver ut 42 efter en kort fördröjning. I verkligheten kan dock förseningen vara mycket längre. Det kan till och med hänga för alltid, eller till och med skriva ut noll!

Orsaken till dessa avvikelser är bristen på korrekt minnessynlighet och omordning . Låt oss utvärdera dem mer detaljerat.

3.1. Minne synlighet

I det här enkla exemplet har vi två applikationstrådar: huvudtråden och läsartråden. Låt oss föreställa oss ett scenario där operativsystemet schemalägger dessa trådar på två olika CPU-kärnor, där:

  • Den röda tråden har sin kopia av färdiga och antalet variabler i sin kärna cache
  • Läsarens tråd slutar också med sina kopior
  • Huvudtråden uppdaterar de cachade värdena

På de flesta moderna processorer kommer skrivförfrågningar inte att tillämpas direkt efter att de har utfärdats. Faktum är att processorer tenderar att köa dessa skrivningar i en speciell skrivbuffert . Efter ett tag kommer de att tillämpa dessa skrivningar i huvudminnet på en gång.

Med allt detta sagt, när huvudtråden uppdaterar antalet och färdiga variabler, finns det ingen garanti för vad läsartråden kan se. Med andra ord kan läsartråden se det uppdaterade värdet direkt eller med viss fördröjning eller aldrig alls!

Den här minnessynligheten kan orsaka livsproblem i program som är beroende av synlighet.

3.2. Ombeställning

För att göra saken ännu värre kan läsartråden se de skrivna i vilken ordning som helst än den faktiska programordningen . Till exempel, eftersom vi uppdaterar först numret variabeln:

public static void main(String[] args) { new Reader().start(); number = 42; ready = true; }

Vi kan förvänta oss att läsarens trådavtryck 42. Det är dock möjligt att se noll som det tryckta värdet!

Ombeställningen är en optimeringsteknik för prestandaförbättringar. Intressant kan olika komponenter tillämpa denna optimering:

  • Processorn kan spola sin skrivbuffert i vilken ordning som helst än programordningen
  • Processorn kan tillämpa körningsteknik utan ordning
  • JIT-kompilatorn kan optimeras via ombeställning

3.3. flyktiga minnesordning

För att säkerställa att uppdateringar av variabler sprids förutsägbart till andra trådar, bör vi använda den flyktiga modifieraren på dessa variabler:

public class TaskRunner { private volatile static int number; private volatile static boolean ready; // same as before }

På detta sätt kommunicerar vi med runtime och processor för att inte ordna om någon instruktion som involverar den flyktiga variabeln. Processorer förstår också att de bör spola alla uppdateringar till dessa variabler direkt.

4. flyktig och trådsynkronisering

För flertrådade applikationer måste vi säkerställa ett par regler för konsekvent beteende:

  • Ömsesidig uteslutning - endast en tråd kör ett kritiskt avsnitt åt gången
  • Synlighet - ändringar som görs av en tråd till den delade data är synliga för andra trådar för att upprätthålla datakonsistens

synkroniserade metoder och block ger båda ovanstående egenskaper till kostnad för applikationsprestanda.

flyktigt är ett ganska användbart sökord eftersom det kan hjälpa till att säkerställa synlighetsaspekten av dataändringen utan naturligtvis att ge ömsesidig uteslutning . Således är det användbart på de ställen där det är ok med flera trådar som kör ett kodblock parallellt, men vi måste säkerställa synlighetsegenskapen.

5. Händer innan du beställer

The memory visibility effects of volatile variables extend beyond the volatile variables themselves.

To make matters more concrete, let's suppose thread A writes to a volatile variable, and then thread B reads the same volatile variable. In such cases, the values that were visible to A before writing the volatile variable will be visible to B after reading the volatile variable:

Technically speaking, any write to a volatile field happens before every subsequent read of the same field. This is the volatile variable rule of the Java Memory Model (JMM).

5.1. Piggybacking

Because of the strength of the happens-before memory ordering, sometimes we can piggyback on the visibility properties of another volatile variable. For instance, in our particular example, we just need to mark the ready variable as volatile:

public class TaskRunner { private static int number; // not volatile private volatile static boolean ready; // same as before }

Anything prior to writing true to the ready variable is visible to anything after reading the ready variable. Therefore, the number variable piggybacks on the memory visibility enforced by the ready variable. Put simply, even though it's not a volatile variable, it is exhibiting a volatile behavior.

Genom att använda denna semantik kan vi bara definiera några av variablerna i vår klass som flyktiga och optimera synlighetsgarantin.

6. Sammanfattning

I den här handledningen har vi utforskat mer om det flyktiga nyckelordet och dess funktioner, liksom de förbättringar som gjorts för det med början med Java 5.

Som alltid kan kodexemplen hittas på GitHub.