Sök…


Fallgrop: felaktig användning av vänta () / meddela ()

Metoderna object.wait() , object.notify() och object.notifyAll() är avsedda att användas på ett mycket specifikt sätt. (se http://stackoverflow.com/documentation/java/5409/wait-notify#t=20160811161648303307 )

Problemet "Lost Notification"

Ett vanligt misstag för nybörjare är att ovillkorligt ringa object.wait()

private final Object lock = new Object();

public void myConsumer() {
    synchronized (lock) {
        lock.wait();     // DON'T DO THIS!!
    }
    doSomething();
}

Anledningen till att detta är fel är att det beror på någon annan tråd att ringa lock.notify() eller lock.notifyAll() , men ingenting garanterar att den andra tråden inte ringde det samtalet innan konsumenttråden som heter lock.wait() .

lock.notify() och lock.notifyAll() gör ingenting alls om någon annan tråd inte redan väntar på meddelandet. Tråden som kallar myConsumer() i det här exemplet kommer att hänga för alltid om det är för sent att fånga meddelandet.

"Olaglig bildskärmstat" -bugg

Om du ringer wait() eller notify() om ett objekt utan att hålla låset, kommer JVM att kasta IllegalMonitorStateException .

public void myConsumer() {
    lock.wait();      // throws exception
    consume();
}

public void myProducer() {
    produce();
    lock.notify();    // throws exception
}

(Konstruktionen för att wait() / notify() kräver att låset hålls kvar eftersom detta är nödvändigt för att undvika systemiska rasförhållanden. Om det var möjligt att ringa wait() eller notify() utan låsning, skulle det vara omöjligt att implementera det primära användningsfallet för dessa primitiva: väntar på att ett tillstånd skulle uppstå.)

Vänta / meddela är för låg nivå

Det bästa sättet att undvika problem med wait() och notify() är att inte använda dem. De flesta synkroniseringsproblem kan lösas med hjälp av synkroniseringsobjekt på högre nivå (köer, barriärer, semaforer osv.) java.utils.concurrent finns tillgängliga i java.utils.concurrent paketet.

Fallgrop - Utvidgning av "java.lang.Thread"

Javadoc för Thread visar två sätt att definiera och använda en tråd:

Använda en anpassad trådklass:

 class PrimeThread extends Thread {
     long minPrime;
     PrimeThread(long minPrime) {
         this.minPrime = minPrime;
     }

     public void run() {
         // compute primes larger than minPrime
          . . .
     }
 }

 PrimeThread p = new PrimeThread(143);
 p.start();

Använda en Runnable :

 class PrimeRun implements Runnable {
     long minPrime;
     PrimeRun(long minPrime) {
         this.minPrime = minPrime;
     }

     public void run() {
         // compute primes larger than minPrime
          . . .
     }
 }

 PrimeRun p = new PrimeRun(143);
 new Thread(p).start();

(Källa: java.lang.Thread javadoc .)

Den anpassade trådklassmetoden fungerar, men den har några problem:

  1. Det är besvärligt att använda PrimeThread i ett sammanhang som använder en klassisk trådpool, en exekutiv eller ForkJoin-ramverket. (Det är inte omöjligt, eftersom PrimeThread indirekt implementerar Runnable , men att använda en anpassad Thread som en Runnable är verkligen klumpig och kanske inte är livskraftig ... beroende på andra aspekter av klassen.)

  2. Det finns större möjligheter till misstag i andra metoder. Om du till exempel förklarade en PrimeThread.start() utan att delegera till Thread.start() , skulle du sluta med en "tråd" som körde på den aktuella tråden.

Tillvägagångssättet att sätta Runnable i en Runnable undviker dessa problem. Faktum är att om du använder en anonym klass (Java 1.1 och framåt) för att implementera Runnable är resultatet mer kortfattat och mer läsbart än exemplen ovan.

 final long minPrime = ...
 new Thread(new Runnable() {
     public void run() {
         // compute primes larger than minPrime
          . . .
     }
 }.start();

Med ett lambda-uttryck (Java 8 och framåt) skulle exemplet ovan bli ännu mer elegant:

 final long minPrime = ...
 new Thread(() -> {
    // compute primes larger than minPrime
     . . .
 }).start();

Fallgrop - För många trådar gör en applikation långsammare.

Många människor som är nya med flertrådar tror att användningen av trådar automatiskt gör att en applikation går snabbare. Det är faktiskt mycket mer komplicerat än så. Men en sak som vi kan säga med säkerhet är att för alla datorer finns det en gräns för antalet trådar som kan köras samtidigt:

  • En dator har ett fast antal kärnor (eller hypertrådar ).
  • En Java-tråd måste planeras till en kärna eller hypertråd för att kunna köras.
  • Om det finns mer körbara Java-trådar än (tillgängliga) kärnor / hyperträngar, måste vissa av dem vänta.

Detta säger att helt enkelt att skapa fler och fler Java-trådar inte kan göra att applikationen går snabbare och snabbare. Men det finns andra överväganden också:

  • Varje tråd kräver ett minnesområde utanför högen för sin trådstapel. Den typiska (standard) trådstapelstorleken är 512Kbyte eller 1Mbyte. Om du har ett betydande antal trådar kan minnesanvändningen vara betydande.

  • Varje aktiv tråd kommer att hänvisa till ett antal objekt i högen. Det ökar arbetsuppsättningen för nåbara objekt, som påverkar avfallsuppsamlingen och fysisk minnesanvändning.

  • Omkostnaderna för att växla mellan trådar är icke triviala. Det innebär vanligtvis en växling till OS-kärnutrymmet för att fatta ett trådplaneringsbeslut.

  • Överhuvudena av trådsynkronisering och signalering mellan trådtråd (t.ex. vänta (), meddela () / meddela alla) kan vara betydande.

Beroende på detaljerna i din ansökan betyder dessa faktorer i allmänhet att det finns en "sweet spot" för antalet trådar. Utöver det, att lägga till fler trådar ger minimal prestandaförbättring och kan göra prestandan sämre.

Om din ansökan skapar för varje ny uppgift kan en oväntad ökning av arbetsbelastningen (t.ex. en hög begäran) leda till katastrofalt beteende.

Ett bättre sätt att hantera detta är att använda avgränsad trådpool vars storlek du kan styra (statiskt eller dynamiskt). När det är för mycket arbete att göra, måste applikationen köa förfrågningarna. Om du använder en ExecutorService kommer den att ta hand om trådpoolhantering och uppdragskö.

Fallgrop - Trådskapande är relativt dyrt

Tänk på dessa två mikrobenchmarker:

Det första riktmärket skapar, startar och sammanfogar trådar helt enkelt. Runnable fungerar inget.

public class ThreadTest {
    public static void main(String[] args) throws Exception {
        while (true) {
            long start = System.nanoTime();
            for (int i = 0; i < 100_000; i++) {
                Thread t = new Thread(new Runnable() {
                        public void run() {
                }});
                t.start();
                t.join();
            }
            long end = System.nanoTime();
            System.out.println((end - start) / 100_000.0);
        }
    }
}

$ java ThreadTest 
34627.91355
33596.66021
33661.19084
33699.44895
33603.097
33759.3928
33671.5719
33619.46809
33679.92508
33500.32862
33409.70188
33475.70541
33925.87848
33672.89529
^C

På en typisk modern dator som kör Linux med 64 bit Java 8 u101, visar detta riktmärke en genomsnittlig tid att skapa, starta och gå med i en tråd på mellan 33,6 och 33,9 mikrosekunder.

Det andra riktmärket motsvarar det första men använder en ExecutorService att skicka uppgifter och en Future att delta i slutet av uppgiften.

import java.util.concurrent.*;

public class ExecutorTest {
    public static void main(String[] args) throws Exception {
        ExecutorService exec = Executors.newCachedThreadPool();
        while (true) {
            long start = System.nanoTime();
            for (int i = 0; i < 100_000; i++) {
                Future<?> future = exec.submit(new Runnable() {
                    public void run() {
                    }
                });
                future.get();
            }
            long end = System.nanoTime();
            System.out.println((end - start) / 100_000.0);
        }
    }
}

$ java ExecutorTest
6714.66053
5418.24901
5571.65213
5307.83651
5294.44132
5370.69978
5291.83493
5386.23932
5384.06842
5293.14126
5445.17405
5389.70685
^C

Som ni ser är genomsnittet mellan 5,3 och 5,6 mikrosekunder.

Även om de faktiska tiderna kommer att bero på olika faktorer, är skillnaden mellan dessa två resultat betydande. Det är uppenbart snabbare att använda en trådpool för att återvinna trådar än att skapa nya trådar.

Fallgrop: Delade variabler kräver korrekt synkronisering

Tänk på detta exempel:

public class ThreadTest implements Runnable {
   
    private boolean stop = false;
    
    public void run() {
        long counter = 0;
        while (!stop) {
            counter = counter + 1;
        }
        System.out.println("Counted " + counter);
    }

    public static void main(String[] args) {
        ThreadTest tt = new ThreadTest();
        new Thread(tt).start();    // Create and start child thread
        Thread.sleep(1000);
        tt.stop = true;            // Tell child thread to stop.
    }
}

Syftet med detta program är avsett att starta en tråd, låta det gå i 1000 millisekunder och sedan få det att stoppa genom att ställa stop .

Fungerar det som avsett?

Kanske, kanske inte.

En ansökan inte nödvändigtvis stannar när main metoden avkastning. Om en annan tråd har skapats och den tråden inte har markerats som en daemontråd, kommer applikationen att fortsätta att köras efter att huvudtråden har avslutats. I det här exemplet betyder det att applikationen fortsätter att gå tills barntråden slutar. Det borde hända när tt.stop är inställt på true .

Men det är faktiskt inte strikt sant. I själva verket kommer barntråden att stanna efter att den har observerat stop med värdet true . Kommer det att hända? Kanske, kanske inte.

Java Language Specification garanterar att minnet läser och skriver som gjorts i en tråd är synliga för den tråden, enligt ordningsföljden för påståenden i källkoden. Men i allmänhet garanteras detta INTE när en tråd skriver och en annan tråd (därefter) läser. För att garantera synlighet måste det finnas en kedja av händelser innan förhållanden mellan en skrivning och en efterföljande läsning. I exemplet ovan finns det ingen sådan kedja för uppdateringen till stop , och därför är det inte garanterat att barntråden kommer att se stop ändras till true .

(Obs till författare: Det bör finnas ett separat ämne på Java Memory Model för att gå in i de djupa tekniska detaljerna.)

Hur löser vi problemet?

I det här fallet finns det två enkla sätt att se till att stop uppdatering syns:

  1. Förklara stop att vara volatile ; dvs.

     private volatile boolean stop = false;
    

    För en volatile variabel specificerar JLS att det finns ett händelse-före- samband mellan en skrivning av en tråd och en senare läst av en andra tråd.

  2. Använd en mutex för att synkronisera enligt följande:

public class ThreadTest implements Runnable {
   
    private boolean stop = false;
    
    public void run() {
        long counter = 0;
        while (true) {
            synchronize (this) {
                if (stop) {
                    break;
                }
            }
            counter = counter + 1;
        }
        System.out.println("Counted " + counter);
    }

    public static void main(String[] args) {
        ThreadTest tt = new ThreadTest();
        new Thread(tt).start();    // Create and start child thread
        Thread.sleep(1000);
        synchronize (tt) {
            tt.stop = true;        // Tell child thread to stop.
        }
    }
}

Förutom att säkerställa att det finns ömsesidig uteslutning, specificerar JLS att det finns ett händelse-före- samband mellan att släppa en mutex i en tråd och få samma mutex i en andra tråd.

Men är inte uppdraget atomiskt?

Ja det är det!

Detta faktum betyder dock inte att effekterna av uppdateringen kommer att synas samtidigt för alla trådar. Endast en ordentlig kedja av händer-före- relationer kommer att garantera det.

Varför gjorde de det här?

Programmerare som gör multigängad programmering i Java för första gången tycker att minnesmodellen är utmanande. Program uppför sig på ett ointuitivt sätt eftersom den naturliga förväntningen är att skrivningar är synliga likformigt. Så varför Java-designers designar minnesmodellen på detta sätt.

Det handlar faktiskt om en kompromiss mellan prestanda och användarvänlighet (för programmeraren).

En modern datorarkitektur består av flera processorer (kärnor) med individuella registeruppsättningar. Huvudminnet är tillgängligt antingen för alla processorer eller för grupper av processorer. En annan egenskap hos modern datormaskinvara är att åtkomst till register vanligtvis är storleksordrar snabbare att komma åt än åtkomst till huvudminnet. Eftersom antalet kärnor ökar, är det lätt att se att läsning och skrivning till huvudminnet kan bli ett systems viktigaste flaskhalsprestanda.

Denna missanpassning åtgärdas genom att implementera en eller flera nivåer av minnescachen mellan processorkärnorna och huvudminnet. Varje kärna får åtkomst till minneceller via sin cache. Normalt sker ett huvudminne som läses endast när det finns en cache-miss, och ett huvudminneskrivning sker bara när en cachelinje behöver spolas. För en applikation där varje kärnas arbetsuppsättning av minnesplatser passar in i sin cache begränsas inte längre kärnhastigheten av huvudminnets hastighet / bandbredd.

Men det ger oss ett nytt problem när flera kärnor läser och skriver delade variabler. Den senaste versionen av en variabel kan sitta i en kärnas cache. Såvida inte den kärnan spolar cachelinjen till huvudminnet, OCH andra kärnor ogiltiggör deras cachade kopia av äldre versioner, kan vissa av dem se inaktuella versioner av variabeln. Men om cacharna spolades till minnet varje gång det finns en cacheskrivning ("bara i fall" det fanns en läsning av en annan kärna) som skulle konsumera huvudminnets bandbredd onödigt.

Standardlösningen som används på maskinvaruinstruktionsnivån är att ge instruktioner för cache-ogiltigförklaring och en cache-genomskrivning och lämna den till kompilatorn för att bestämma när den ska användas.

Återgår till Java. minnesmodellen är utformad så att Java-kompilatorerna inte är skyldiga att utfärda cache-ogiltighets- och skrivinstruktioner där de egentligen inte behövs. Antagandet är att programmeraren kommer att använda en lämplig synkroniseringsmekanism (t.ex. primitiva mutexer, volatile , högre nivå samtidighetsklasser och så vidare) för att indikera att den behöver minnessynlighet. I avsaknad av en händelse-före- relation är Java-kompilatorerna fritt att anta att inga cache-operationer (eller liknande) krävs.

Detta har betydande prestandafördelar för flertrådiga applikationer, men nackdelen är att det är enkelt att skriva korrekta flertrådade applikationer. Programmeraren behöver förstå vad han eller hon gör.

Varför kan jag inte återge detta?

Det finns ett antal orsaker till att problem som detta är svåra att reproducera:

  1. Som förklarats ovan, på rätt sätt är en följd av att inte hantera minnet synlighet frågor typiska problem att din sammanställt ansökan inte hanterar minnes cachar korrekt. Men som vi nämnde ovan, blir cacheminnorna ofta spolade ändå.

  2. När du byter hårdvaruplattform kan minnescacheminnets egenskaper förändras. Detta kan leda till olika beteenden om din applikation inte synkroniseras korrekt.

  3. Du kanske observerar effekterna av serendipitös synkronisering. Om du till exempel lägger till spårutskrifter är det vanligtvis en viss synkronisering som sker bakom kulisserna i I / O-strömmarna som orsakar cache-flushes. Så att lägga till traceprints får ofta applikationen att bete sig annorlunda.

  4. Att köra en applikation under en felsökare gör att den sammanställs annorlunda av JIT-kompilatorn. Breakpoints och single stepping förvärrar detta. Dessa effekter förändrar ofta hur en applikation beter sig.

Dessa saker gör buggar som beror på otillräcklig synkronisering särskilt svåra att lösa.



Modified text is an extract of the original Stack Overflow Documentation
Licensierat under CC BY-SA 3.0
Inte anslutet till Stack Overflow