CodeGym /Java Blog /Random-IT /Meglio insieme: Java e la classe Thread. Parte III - Inte...
John Squirrels
Livello 41
San Francisco

Meglio insieme: Java e la classe Thread. Parte III - Interazione

Pubblicato nel gruppo Random-IT
Una breve panoramica dei dettagli di come interagiscono i thread. In precedenza, abbiamo esaminato come i thread sono sincronizzati tra loro. Questa volta ci addentreremo nei problemi che possono sorgere quando i thread interagiscono e parleremo di come evitarli. Forniremo anche alcuni link utili per approfondimenti. Meglio insieme: Java e la classe Thread.  Parte III - Interazione - 1

introduzione

Quindi, sappiamo che Java ha thread. Puoi leggerlo nella recensione intitolata Better together: Java and the Thread class. Parte I — Fili di esecuzione . E abbiamo esplorato il fatto che i thread possono sincronizzarsi tra loro nella recensione intitolata Better together: Java and the Thread class. Parte II — Sincronizzazione . È tempo di parlare di come i thread interagiscono tra loro. Come condividono le risorse condivise? Quali problemi potrebbero sorgere qui? Meglio insieme: Java e la classe Thread.  Parte III - Interazione - 2

Punto morto

Il problema più spaventoso di tutti è lo stallo. Deadlock è quando due o più thread aspettano eternamente l'altro. Prenderemo un esempio dalla pagina web di Oracle che descrive deadlock :

public class Deadlock {
    static class Friend {
        private final String name;
        public Friend(String name) {
            this.name = name;
        }
        public String getName() {
            return this.name;
        }
        public synchronized void bow(Friend bower) {
            System.out.format("%s: %s bowed to me!%n",
                    this.name, bower.getName());
            bower.bowBack(this);
        }
        public synchronized void bowBack(Friend bower) {
            System.out.format("%s: %s bowed back to me!%n",
                    this.name, bower.getName());
        }
    }

    public static void main(String[] args) {
        final Friend alphonse = new Friend("Alphonse");
        final Friend gaston = new Friend("Gaston");
        new Thread(() -> alphonse.bow(gaston)).start();
        new Thread(() -> gaston.bow(alphonse)).start();
    }
}
Il deadlock potrebbe non verificarsi qui la prima volta, ma se il tuo programma si blocca, allora è il momento di eseguire jvisualvm: Meglio insieme: Java e la classe Thread.  Parte III - Interazione - 3Con un plug-in JVisualVM installato (tramite Strumenti -> Plugin), possiamo vedere dove si è verificato il deadlock:

"Thread-1" - Thread t@12
   java.lang.Thread.State: BLOCKED
	at Deadlock$Friend.bowBack(Deadlock.java:16)
	- waiting to lock <33a78231> (a Deadlock$Friend) owned by "Thread-0" t@11
Il thread 1 sta aspettando il blocco dal thread 0. Perché succede? Thread-1avvia l'esecuzione ed esegue il Friend#bowmetodo. È contrassegnato con la synchronizedparola chiave, il che significa che stiamo acquisendo il monitor per this(l'oggetto corrente). L'input del metodo era un riferimento all'altro Friendoggetto. Ora, Thread-1vuole eseguire il metodo sull'altro Friende deve acquisire il suo blocco per farlo. Ma se l'altro thread (in questo caso Thread-0) è riuscito ad entrare nel bow()metodo, allora il lock è già stato acquisito e Thread-1attendeThread-0, e viceversa. Questo vicolo cieco è irrisolvibile e lo chiamiamo deadlock. Come una presa mortale che non può essere rilasciata, lo stallo è un blocco reciproco che non può essere spezzato. Per un'altra spiegazione del deadlock, puoi guardare questo video: Deadlock and Livelock Explained .

Livelock

Se c'è un deadlock, c'è anche un livelock? Sì, c'è :) Il livelock si verifica quando i thread esteriormente sembrano essere vivi, ma non sono in grado di fare nulla, perché le condizioni richieste per continuare il loro lavoro non possono essere soddisfatte. Fondamentalmente, il livelock è simile al deadlock, ma i thread non "si bloccano" in attesa di un monitor. Invece, fanno sempre qualcosa. Per esempio:

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class App {
    public static final String ANSI_BLUE = "\u001B[34m";
    public static final String ANSI_PURPLE = "\u001B[35m";
    
    public static void log(String text) {
        String name = Thread.currentThread().getName(); // Like "Thread-1" or "Thread-0"
        String color = ANSI_BLUE;
        int val = Integer.valueOf(name.substring(name.lastIndexOf("-") + 1)) + 1;
        if (val != 0) {
            color = ANSI_PURPLE;
        }
        System.out.println(color + name + ": " + text + color);
        try {
            System.out.println(color + name + ": wait for " + val + " sec" + color);
            Thread.currentThread().sleep(val * 1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) {
        Lock first = new ReentrantLock();
        Lock second = new ReentrantLock();

        Runnable locker = () -> {
            boolean firstLocked = false;
            boolean secondLocked = false;
            try {
                while (!firstLocked || !secondLocked) {
                    firstLocked = first.tryLock(100, TimeUnit.MILLISECONDS);
                    log("First Locked: " + firstLocked);
                    secondLocked = second.tryLock(100, TimeUnit.MILLISECONDS);
                    log("Second Locked: " + secondLocked);
                }
                first.unlock();
                second.unlock();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        };

        new Thread(locker).start();
        new Thread(locker).start();
    }
}
Il successo di questo codice dipende dall'ordine in cui lo scheduler dei thread Java avvia i thread. Se Thead-1inizia prima, allora otteniamo livelock:

Thread-1: First Locked: true
Thread-1: wait for 2 sec
Thread-0: First Locked: false
Thread-0: wait for 1 sec
Thread-0: Second Locked: true
Thread-0: wait for 1 sec
Thread-1: Second Locked: false
Thread-1: wait for 2 sec
Thread-0: First Locked: false
Thread-0: wait for 1 sec
...
Come puoi vedere dall'esempio, entrambi i thread tentano di acquisire entrambi i blocchi a turno, ma falliscono. Ma non sono in stallo. Esteriormente, va tutto bene e stanno facendo il loro lavoro. Meglio insieme: Java e la classe Thread.  Parte III - Interazione - 4Secondo JVisualVM, vediamo periodi di sospensione e un periodo di parcheggio (questo è quando un thread tenta di acquisire un blocco - entra nello stato di parcheggio, come abbiamo discusso in precedenza quando abbiamo parlato della sincronizzazione dei thread ) . Puoi vedere un esempio di livelock qui: Java - Thread Livelock .

Fame

Oltre a deadlock e livelock, c'è un altro problema che può verificarsi durante il multithreading: la fame. Questo fenomeno differisce dalle precedenti forme di blocco in quanto i thread non sono bloccati, semplicemente non dispongono di risorse sufficienti. Di conseguenza, mentre alcuni thread impiegano tutto il tempo di esecuzione, altri non sono in grado di eseguire: Meglio insieme: Java e la classe Thread.  Parte III - Interazione - 5

https://www.logicbig.com/

Puoi vedere un super esempio qui: Java - Thread Starvation and Fairness . Questo esempio mostra cosa succede con i thread durante la fame e come una piccola modifica da Thread.sleep()a Thread.wait()consente di distribuire il carico in modo uniforme. Meglio insieme: Java e la classe Thread.  Parte III - Interazione - 6

Condizioni di gara

Nel multithreading esiste una "condizione di competizione". Questo fenomeno si verifica quando i thread condividono una risorsa, ma il codice è scritto in modo da non garantire una corretta condivisione. Dai un'occhiata a un esempio:

public class App {
    public static int value = 0;

    public static void main(String[] args) {
        Runnable task = () -> {
            for (int i = 0; i < 10000; i++) {
                int oldValue = value;
                int newValue = ++value;
                if (oldValue + 1 != newValue) {
                    throw new IllegalStateException(oldValue + " + 1 = " + newValue);
                }
            }
        };
        new Thread(task).start();
        new Thread(task).start();
        new Thread(task).start();
    }
}
Questo codice potrebbe non generare un errore la prima volta. Quando lo fa, potrebbe apparire così:

Exception in thread "Thread-1" java.lang.IllegalStateException: 7899 + 1 = 7901
	at App.lambda$main$0(App.java:13)
	at java.lang.Thread.run(Thread.java:745)
Come puoi vedere, qualcosa è andato storto durante newValuel'assegnazione di un valore. newValueè troppo grande. A causa della race condition, uno dei thread è riuscito a modificare la variabile valuetra le due istruzioni. Si scopre che c'è una corsa tra i fili. Ora pensa a quanto sia importante non commettere errori simili con le transazioni monetarie... Esempi e diagrammi possono essere visti anche qui: Codice per simulare race condition in thread Java .

Volatile

Parlando dell'interazione dei thread, volatilevale la pena menzionare la parola chiave. Diamo un'occhiata a un semplice esempio:

public class App {
    public static boolean flag = false;

    public static void main(String[] args) throws InterruptedException {
        Runnable whileFlagFalse = () -> {
            while(!flag) {
            }
            System.out.println("Flag is now TRUE");
        };

        new Thread(whileFlagFalse).start();
        Thread.sleep(1000);
        flag = true;
    }
}
La cosa più interessante è che è molto probabile che non funzioni. Il nuovo thread non vedrà la modifica nel flagcampo. Per risolvere questo problema per il flagcampo, dobbiamo utilizzare la volatileparola chiave. Come e perché? Il processore esegue tutte le azioni. Ma i risultati dei calcoli devono essere archiviati da qualche parte. Per questo, c'è la memoria principale e c'è la cache del processore. Le cache di un processore sono come una piccola porzione di memoria utilizzata per accedere ai dati più rapidamente rispetto a quando si accede alla memoria principale. Ma tutto ha un rovescio della medaglia: i dati nella cache potrebbero non essere aggiornati (come nell'esempio sopra, quando il valore del campo flag non è stato aggiornato). Così ilvolatileparola chiave dice alla JVM che non vogliamo memorizzare nella cache la nostra variabile. Ciò consente di visualizzare il risultato aggiornato su tutti i thread. Questa è una spiegazione molto semplificata. Per quanto riguarda la volatileparola chiave, consiglio vivamente di leggere questo articolo . Per maggiori informazioni, ti consiglio anche di leggere Java Memory Model e Java Volatile Keyword . Inoltre, è importante ricordare che volatilesi tratta della visibilità e non dell'atomicità dei cambiamenti. Osservando il codice nella sezione "Condizioni di gara", vedremo un suggerimento in IntelliJ IDEA: Meglio insieme: Java e la classe Thread.  Parte III - Interazione - 7Questa ispezione è stata aggiunta a IntelliJ IDEA come parte del problema IDEA-61117 , elencato nelle Note di rilascio nel 2010.

Atomicita

Le operazioni atomiche sono operazioni che non possono essere divise. Ad esempio, l'operazione di assegnazione di un valore a una variabile deve essere atomica. Sfortunatamente, l'operazione di incremento non è atomica, perché l'incremento richiede fino a tre operazioni della CPU: ottieni il vecchio valore, aggiungine uno, quindi salva il valore. Perché l'atomicità è importante? Con l'operazione di incremento, se c'è una race condition, allora la risorsa condivisa (cioè il valore condiviso) può cambiare improvvisamente in qualsiasi momento. Inoltre, le operazioni che coinvolgono strutture a 64 bit, ad esempio longe double, non sono atomiche. Maggiori dettagli possono essere letti qui: Garantire l'atomicità durante la lettura e la scrittura di valori a 64 bit . I problemi relativi all'atomicità possono essere visti in questo esempio:

public class App {
    public static int value = 0;
    public static AtomicInteger atomic = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            for (int i = 0; i < 10000; i++) {
                value++;
                atomic.incrementAndGet();
            }
        };
        for (int i = 0; i < 3; i++) {
            new Thread(task).start();
        }
        Thread.sleep(300);
        System.out.println(value);
        System.out.println(atomic.get());
    }
}
La classe speciale AtomicIntegerci darà sempre 30.000, ma valuecambierà di volta in volta. C'è una breve panoramica di questo argomento: Introduzione alle variabili atomiche in Java . L'algoritmo "compare-and-swap" è al centro delle classi atomiche. Puoi leggere di più qui in Confronto tra algoritmi senza blocco - CAS e FAA sull'esempio di JDK 7 e 8 o nell'articolo Confronta e scambia su Wikipedia. Meglio insieme: Java e la classe Thread.  Parte III - Interazione - 9

http://jeremymanson.blogspot.com/2008/11/what-volatile-means-in-java.html

Succede prima

C'è un concetto interessante e misterioso chiamato "succede prima". Come parte del tuo studio sui thread, dovresti leggerlo. La relazione succede prima mostra l'ordine in cui verranno visualizzate le azioni tra i thread. Ci sono molte interpretazioni e commenti. Ecco una delle presentazioni più recenti su questo argomento: Java "Happens-Before" Relationships .

Riepilogo

In questa recensione, abbiamo esplorato alcune delle specifiche di come interagiscono i thread. Abbiamo discusso dei problemi che potrebbero sorgere, nonché dei modi per identificarli ed eliminarli. Elenco di materiali aggiuntivi sull'argomento: Meglio insieme: Java e la classe Thread. Parte I — Thread di esecuzione Meglio insieme: Java e la classe Thread. Parte II — Sincronizzazione Meglio insieme: Java e la classe Thread. Parte IV — Callable, Future e friends Better together: Java e la classe Thread. Parte V — Executor, ThreadPool, Fork/Join Better insieme: Java e la classe Thread. Parte VI - Spara via!
Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION