Zoeken…


Valkuil: onjuist gebruik van wachten () / melden ()

De methoden object.wait() , object.notify() en object.notifyAll() zijn bedoeld om op een zeer specifieke manier te worden gebruikt. (zie http://stackoverflow.com/documentation/java/5409/wait-notify#t=20160811161648303307 )

Het probleem 'Verloren melding'

Een veel voorkomende beginnerfout is om object.wait() onvoorwaardelijk aan te roepen

private final Object lock = new Object();

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

De reden dat dit fout is, is dat het van een andere thread afhangt om lock.notify() of lock.notifyAll() aan te roepen, maar niets garandeert dat de andere thread die aanroep niet heeft gedaan voordat de consument-thread lock.wait() .

lock.notify() en lock.notifyAll() niet doen helemaal niets als een andere thread is niet al te wachten voor de melding. De thread die in dit voorbeeld myConsumer() aanroept, myConsumer() altijd hangen als het te laat is om de melding te ontvangen.

De fout 'Illegale monitorstatus'

Als u wait() of op de notify() op een object zonder het slot vast te houden, dan zal de JVM IllegalMonitorStateException gooien.

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

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

(Het ontwerp voor wait() / notify() vereist dat het slot wordt vastgehouden omdat dit noodzakelijk is om systemische race-omstandigheden te voorkomen. Als het mogelijk was om wait() of notify() te bellen zonder vergrendeling, dan zou het onmogelijk zijn om de primaire use-case voor deze primitieven: wachten op een voorwaarde.)

Wachten / melden is te laag

De beste manier om problemen met wait() en notify() is om ze niet te gebruiken. De meeste synchronisatieproblemen kunnen worden opgelost met behulp van de synchronisatieobjecten op een hoger niveau (wachtrijen, barrières, semaforen, enz.) Die beschikbaar zijn in het pakket java.utils.concurrent .

Valkuil - Uitbreiding van 'java.lang.Thread'

De javadoc voor de klasse Thread toont twee manieren om een thread te definiëren en te gebruiken:

Een aangepaste threadklasse gebruiken:

 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();

Een 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();

(Bron: java.lang.Thread javadoc .)

De aangepaste threadklassebenadering werkt, maar het heeft een paar problemen:

  1. Het is lastig om PrimeThread te gebruiken in een context die een klassieke thread pool, een uitvoerder of het ForkJoin-framework gebruikt. (Het is niet onmogelijk, omdat PrimeThread indirect implementeert Runnable , maar met behulp van een aangepaste Thread klasse als een Runnable is zeker onhandig, en kunnen niet levensvatbaar zijn ... afhankelijk van andere aspecten van de klas.)

  2. Er is meer kans op fouten in andere methoden. Als u bijvoorbeeld een PrimeThread.start() zonder te delegeren naar Thread.start() , krijgt u een "thread" die op de huidige thread wordt uitgevoerd.

De aanpak om de Runnable in een Runnable voorkomt deze problemen. Als u een anonieme klasse (Java 1.1 en hoger) gebruikt om de Runnable te implementeren, is het resultaat inderdaad beknopter en leesbaarder dan de bovenstaande voorbeelden.

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

Met een lambda-expressie (vanaf Java 8) zou het bovenstaande voorbeeld nog eleganter worden:

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

Valkuil - Te veel threads maken een applicatie langzamer.

Veel mensen die nieuw zijn in multi-threading denken dat het gebruik van threads een applicatie automatisch sneller maakt. In feite is het een stuk ingewikkelder dan dat. Maar een ding dat we met zekerheid kunnen stellen, is dat er voor elke computer een limiet is op het aantal threads dat tegelijkertijd kan worden uitgevoerd:

  • Een computer heeft een vast aantal cores (of hyperthreads ).
  • Een Java-thread moet worden gepland op een kern of hyperthread om te kunnen worden uitgevoerd.
  • Als er meer uitvoerbare Java-threads zijn dan (beschikbare) cores / hyperthreads, moeten sommige wachten.

Dit vertelt ons dat het simpelweg creëren van meer en meer Java-threads de toepassing niet sneller en sneller kan laten verlopen. Maar er zijn ook andere overwegingen:

  • Elke thread vereist een off-heap geheugengebied voor zijn thread-stack. De typische (standaard) thread-stackgrootte is 512 KB of 1 MB. Als u een aanzienlijk aantal threads heeft, kan het geheugengebruik aanzienlijk zijn.

  • Elke actieve thread verwijst naar een aantal objecten in de heap. Dat verhoogt de werkset van bereikbare objecten, wat invloed heeft op het verzamelen van afval en op fysiek geheugengebruik.

  • De overheadkosten bij het schakelen tussen threads zijn niet triviaal. Het houdt meestal een omschakeling in de kernelruimte van het besturingssysteem in om een beslissing over de threadplanning te maken.

  • De overheadkosten van thread-synchronisatie en inter-thread-signalering (bijvoorbeeld wachten (), kennis geven () / kennis geven) kunnen aanzienlijk zijn.

Afhankelijk van de details van uw toepassing, betekenen deze factoren over het algemeen dat er een "sweet spot" is voor het aantal threads. Bovendien geeft het toevoegen van meer threads minimale prestatieverbetering en kan de prestatie slechter worden.

Als uw toepassing voor elke nieuwe taak maakt, kan een onverwachte toename van de werklast (bijvoorbeeld een hoog aantal aanvragen) leiden tot catastrofaal gedrag.

Een betere manier om hiermee om te gaan, is om begrensde threadpool te gebruiken waarvan u de grootte kunt bepalen (statisch of dynamisch). Wanneer er te veel werk is, moet de toepassing de aanvragen in de wachtrij zetten. Als u een ExecutorService , zorgt deze voor het beheer van de threadpool en het in de wachtrij plaatsen van taken.

Valkuil - Discussie maken is relatief duur

Overweeg deze twee micro-benchmarks:

De eerste benchmark creëert, start en voegt threads samen. De thread Runnable werkt niet.

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

Op een typische moderne pc met Linux met 64bit Java 8 u101, toont deze benchmark een gemiddelde tijd die nodig is om een thread te maken, te starten en samen te voegen van 33,6 tot 33,9 microseconden.

De tweede benchmark doet het equivalent van de eerste, maar gebruikt een ExecutorService om taken en een Future in te dienen om af te spreken met het einde van de taak.

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

Zoals u kunt zien, liggen de gemiddelden tussen 5,3 en 5,6 microseconden.

Hoewel de werkelijke tijden afhankelijk zijn van verschillende factoren, is het verschil tussen deze twee resultaten aanzienlijk. Het is duidelijk sneller om een thread pool te gebruiken om threads te recyclen dan om nieuwe threads te maken.

Valkuil: gedeelde variabelen vereisen een juiste synchronisatie

Beschouw dit voorbeeld:

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.
    }
}

De bedoeling van dit programma is bedoeld om een discussie te starten, laat het uit te voeren voor 1000 milliseconden, en vervolgens leiden tot het stoppen door het instellen van de stop vlag.

Zal het werken zoals bedoeld?

Misschien wel misschien niet.

Een aanvraag niet noodzakelijkerwijs stoppen als de main retourneert. Als er een andere thread is gemaakt en die thread niet is gemarkeerd als een daemon-thread, blijft de toepassing actief nadat de hoofdthread is beëindigd. In dit voorbeeld betekent dit dat de toepassing actief blijft totdat onderliggende thread eindigt. Dat zou moeten gebeuren als tt.stop is ingesteld op true .

Maar dat is eigenlijk niet helemaal waar. In feite zal het kind thread stoppen nadat het heeft waargenomen stop met de waarde true . Zal dat gebeuren? Misschien wel misschien niet.

De Java-taalspecificatie garandeert dat geheugenlees- en schrijfbewerkingen in een thread zichtbaar zijn voor die thread, volgens de volgorde van de instructies in de broncode. Over het algemeen is dit echter NIET gegarandeerd wanneer een thread schrijft en een andere thread (vervolgens) leest. Om een gegarandeerde zichtbaarheid te krijgen, moet er een reeks van gebeurtenissen zijn - voordat relaties tussen een schrijfactie en een volgende leesactie. In het bovenstaande voorbeeld is er geen dergelijke keten voor de update van de stop en daarom kan niet worden gegarandeerd dat de onderliggende thread stop stopverandering in true .

(Opmerking voor auteurs: er moet een afzonderlijk onderwerp op het Java-geheugenmodel zijn om de diepgaande technische details te bespreken.)

Hoe lossen we het probleem op?

In dit geval zijn er twee eenvoudige manieren om ervoor te zorgen dat de stop update zichtbaar is:

  1. Verklaar stop als volatile ; d.w.z

     private volatile boolean stop = false;
    

    Voor een volatile variabele geeft de JLS aan dat er een vóór- relatie is tussen een schrijven door één thread en een later gelezen door een tweede thread.

  2. Gebruik een mutex om als volgt te synchroniseren:

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.
        }
    }
}

Naast het waarborgen dat er wederzijdse uitsluiting is, geeft de JLS aan dat er een relatie is vóór het loslaten van een mutex in één thread en het verkrijgen van dezelfde mutex in een tweede thread.

Maar is opdracht niet atomair?

Jawel!

Dat feit betekent echter niet dat de effecten van de update voor alle threads tegelijkertijd zichtbaar zijn. Alleen een behoorlijke reeks gebeurtenissen - voordat relaties dat zullen garanderen.

Waarom deden ze dit?

Programmeurs die voor het eerst multi-threaded programmeren in Java, vinden het Geheugenmodel een uitdaging. Programma's gedragen zich op een niet-intuïtieve manier omdat de natuurlijke verwachting is dat schrijvers uniform zichtbaar zijn. Dus waarom de Java-ontwerpers het Memory Model op deze manier ontwerpen.

Het komt eigenlijk neer op een compromis tussen prestaties en gebruiksgemak (voor de programmeur).

Een moderne computerarchitectuur bestaat uit meerdere processors (cores) met afzonderlijke registersets. Hoofdgeheugen is toegankelijk voor alle processors of voor groepen processors. Een andere eigenschap van moderne computerhardware is dat toegang tot registers doorgaans een orde van grootte sneller is dan toegang tot hoofdgeheugen. Naarmate het aantal cores toeneemt, is het gemakkelijk te zien dat lezen en schrijven naar het hoofdgeheugen de belangrijkste bottleneck van het systeem kan worden.

Deze mismatch wordt verholpen door een of meer niveaus van geheugencaching tussen de processorcores en het hoofdgeheugen te implementeren. Elke kern heeft toegang tot geheugencellen via zijn cache. Normaal gesproken vindt een hoofdgeheugenlezing alleen plaats als er een cachemist is en een hoofdgeheugenlezing gebeurt alleen wanneer een cacheregel moet worden leeggemaakt. Voor een toepassing waarbij de werkset met geheugenlocaties van elke kern in de cache past, wordt de kernsnelheid niet langer beperkt door de snelheid van het hoofdgeheugen / de bandbreedte.

Maar dat geeft ons een nieuw probleem wanneer meerdere cores gedeelde variabelen lezen en schrijven. De nieuwste versie van een variabele bevindt zich mogelijk in de cache van één kern. Tenzij de core de cache-regel naar het hoofdgeheugen spoelt, EN andere cores hun gecachte kopie van oudere versies ongeldig maken, kunnen sommigen van hen oude versies van de variabele zien. Maar als de caches elke keer naar het geheugen worden gespoeld, is er een cacheschrijfactie ("voor het geval dat er een andere kern heeft gelezen) die onnodig de geheugenbandbreedte zou verbruiken.

De standaardoplossing die wordt gebruikt op het niveau van de hardware-instructieset, is om instructies te geven voor cache-ongeldigheid en een cache-doorvoer en het aan de compiler over te laten om te beslissen wanneer ze worden gebruikt.

Terugkerend naar Java. het geheugenmodel is zo ontworpen dat de Java-compilers niet verplicht zijn om cache-ongeldigheid en doorschrijfinstructies uit te geven waar ze niet echt nodig zijn. De veronderstelling is dat de programmeur een geschikt synchronisatiemechanisme zal gebruiken (bijv. Primitieve mutexen, volatile , concurrency-klassen op een hoger niveau, enzovoort) om aan te geven dat het geheugenzichtbaarheid nodig heeft. Bij afwezigheid van een relatie die eerder is gebeurd , kunnen de Java-compilers ervan uitgaan dat er geen cachebewerkingen (of iets dergelijks) nodig zijn.

Dit heeft aanzienlijke prestatievoordelen voor toepassingen met meerdere threads, maar het nadeel is dat het schrijven van correcte toepassingen met meerdere threads niet eenvoudig is. De programmeur moet wel begrijpen wat hij of zij doet.

Waarom kan ik dit niet reproduceren?

Er zijn een aantal redenen waarom dit soort problemen moeilijk te reproduceren zijn:

  1. Zoals hierboven uitgelegd, is het gevolg van het niet goed omgaan met problemen met de zichtbaarheid van het geheugen meestal dat uw gecompileerde toepassing de geheugencaches niet correct verwerkt. Zoals we hierboven al hebben gezegd, worden geheugencaches echter vaak toch gespoeld.

  2. Wanneer u het hardwareplatform wijzigt, kunnen de kenmerken van de geheugencaches veranderen. Dit kan leiden tot ander gedrag als uw toepassing niet correct wordt gesynchroniseerd.

  3. U neemt mogelijk de effecten van serendipitaire synchronisatie waar. Als u bijvoorbeeld traceprints toevoegt, gebeurt er meestal enige synchronisatie achter de schermen in de I / O-streams die cache-flushes veroorzaakt. Dus het toevoegen van traceprints zorgt er vaak voor dat de toepassing zich anders gedraagt.

  4. Als u een toepassing uitvoert onder een debugger, wordt deze anders gecompileerd door de JIT-compiler. Breekpunten en single stepping verergeren dit. Deze effecten veranderen vaak het gedrag van een toepassing.

Deze dingen maken bugs die te wijten zijn aan onvoldoende synchronisatie bijzonder moeilijk op te lossen.



Modified text is an extract of the original Stack Overflow Documentation
Licentie onder CC BY-SA 3.0
Niet aangesloten bij Stack Overflow