Perché questo programma Java termina nonostante ciò apparentemente non dovrebbe (e non l'ha fatto)?


205

Un'operazione delicata nel mio laboratorio oggi è andata completamente storta. Un attuatore al microscopio elettronico ha superato il suo limite e dopo una catena di eventi ho perso $ 12 milioni di apparecchiature. Ho ristretto oltre 40K linee nel modulo difettoso a questo:

import java.util.*;

class A {
    static Point currentPos = new Point(1,2);
    static class Point {
        int x;
        int y;
        Point(int x, int y) {
            this.x = x;
            this.y = y;
        }
    }
    public static void main(String[] args) {
        new Thread() {
            void f(Point p) {
                synchronized(this) {}
                if (p.x+1 != p.y) {
                    System.out.println(p.x+" "+p.y);
                    System.exit(1);
                }
            }
            @Override
            public void run() {
                while (currentPos == null);
                while (true)
                    f(currentPos);
            }
        }.start();
        while (true)
            currentPos = new Point(currentPos.x+1, currentPos.y+1);
    }
}

Alcuni esempi dell'output che sto ricevendo:

$ java A
145281 145282
$ java A
141373 141374
$ java A
49251 49252
$ java A
47007 47008
$ java A
47427 47428
$ java A
154800 154801
$ java A
34822 34823
$ java A
127271 127272
$ java A
63650 63651

Dato che qui non esiste alcuna aritmetica in virgola mobile e sappiamo tutti che gli interi con segno si comportano bene in caso di overflow in Java, penso che non ci sia nulla di sbagliato in questo codice. Tuttavia, nonostante l'output indichi che il programma non ha raggiunto la condizione di uscita, ha raggiunto la condizione di uscita (è stato raggiunto e non raggiunto?). Perché?


Ho notato che ciò non accade in alcuni ambienti. Sono su OpenJDK 6 su Linux a 64 bit.


41
12 milioni di apparecchiature? sono davvero curioso di sapere come potrebbe accadere ... perché stai usando un blocco di sincronizzazione vuoto: sincronizzato (questo) {}?
Martin V.,

84
Questo non è nemmeno remoto thread-safe.
Matt Ball,

8
Interessante da notare: l'aggiunta del finalqualificatore (che non ha alcun effetto sul bytecode prodotto) nei campi xe y"risolve" il bug. Sebbene non influisca sul bytecode, i campi sono contrassegnati con esso, il che mi porta a pensare che questo sia un effetto collaterale di un'ottimizzazione JVM.
Niv Steingarten,

9
@Eugene: non dovrebbe finire. La domanda è "perché finisce?". Viene Point pcostruito A che soddisfa p.x+1 == p.y, quindi viene passato un riferimento al thread di polling. Alla fine il thread di polling decide di uscire perché pensa che la condizione non sia soddisfatta per uno dei messaggi Pointche riceve, ma poi l'output della console mostra che avrebbe dovuto essere soddisfatto. La mancanza di volatilequi significa semplicemente che il thread di polling potrebbe bloccarsi, ma questo chiaramente non è il problema qui.
Erma K. Pizarro,

21
@JohnNicholas: Il vero codice (che ovviamente non è questo) aveva una copertura dei test al 100% e migliaia di test, molti dei quali hanno testato cose in migliaia di vari ordini e permutazioni ... I test non trovano magicamente ogni caso limite causato da non deterministico JIT / cache / scheduler. Il vero problema è che lo sviluppatore che ha scritto questo codice non sapeva che la costruzione non avviene prima di usare l'oggetto. Notare come la rimozione del vuoto synchronizednon causa l'errore? Questo perché ho dovuto scrivere casualmente il codice fino a quando non ho trovato uno che riproducesse questo comportamento in modo deterministico.
Cane

Risposte:


140

Ovviamente la scrittura su currentPos non avviene prima della lettura, ma non vedo come possa essere questo il problema.

currentPos = new Point(currentPos.x+1, currentPos.y+1);fa alcune cose, inclusa la scrittura di valori predefiniti su xe y(0) e quindi la scrittura dei loro valori iniziali nel costruttore. Poiché il tuo oggetto non è pubblicato in modo sicuro, queste 4 operazioni di scrittura possono essere riordinate liberamente dal compilatore / JVM.

Quindi dal punto di vista del thread di lettura, è un'esecuzione legale da leggere xcon il suo nuovo valore ma ycon il suo valore predefinito di 0, ad esempio. Quando si raggiunge l' printlnistruzione (che tra l'altro è sincronizzata e quindi influenza le operazioni di lettura), le variabili hanno i loro valori iniziali e il programma stampa i valori previsti.

Contrassegnare currentPoscome volatilegarantirà una pubblicazione sicura poiché l'oggetto è effettivamente immutabile - se nel tuo caso d'uso reale l'oggetto è mutato dopo la costruzione, le volatilegaranzie non saranno sufficienti e potresti vedere di nuovo un oggetto incoerente.

In alternativa, puoi rendere l' Pointimmutabile che garantirà anche una pubblicazione sicura, anche senza usare volatile. Per ottenere l'immutabilità, devi semplicemente contrassegnare xe yfinalizzare.

Come nota a margine e come già accennato, synchronized(this) {}può essere trattato come no-op dalla JVM (ho capito che l'hai inclusa per riprodurre il comportamento).


4
Non sono sicuro, ma rendere xey finale non avrebbe lo stesso effetto, evitando la barriera di memoria?
Michael Böckling,

3
Un design più semplice è un oggetto punto immutabile che mette alla prova gli invarianti sulla costruzione. Quindi non rischi mai di pubblicare una configurazione pericolosa.
Ron,

@BuddyCasino Sì davvero - l'ho aggiunto. Ad essere sincero, non ricordo l'intera discussione 3 mesi fa (l'uso di final è stato proposto nei commenti, quindi non sono sicuro del perché non l'ho incluso come opzione).
Assylias,

2
L'immutabilità stessa non garantisce la pubblicazione sicura (se x an y fossero privati ​​ma esposti solo con getter, lo stesso problema di pubblicazione esisterebbe ancora). finale o volatile lo garantisce. Preferirei il finale al volatile.
Steve Kuo,

@SteveKuo L'immutabilità richiede final - senza final, il meglio che puoi ottenere è l'immutabilità efficace che non ha la stessa semantica.
Assylias,

29

Poiché currentPosviene modificato al di fuori del thread, deve essere contrassegnato come volatile:

static volatile Point currentPos = new Point(1,2);

Senza volatile, il thread non è garantito per la lettura negli aggiornamenti di currentPos che vengono creati nel thread principale. Quindi i nuovi valori continuano a essere scritti per currentPos ma il thread continua a utilizzare le versioni precedenti nella cache per motivi di prestazioni. Poiché solo un thread modifica currentPos, puoi cavartela senza blocchi che miglioreranno le prestazioni.

I risultati sembrano molto diversi se si leggono i valori una sola volta all'interno del thread per utilizzarli nel confronto e nella successiva visualizzazione di essi. Quando faccio quanto segue xviene sempre visualizzato come 1e yvaria tra 0e alcuni numeri interi di grandi dimensioni. Penso che il suo comportamento a questo punto sia in qualche modo indefinito senza la volatileparola chiave ed è possibile che la compilazione JIT del codice contribuisca ad agire in questo modo. Inoltre, se commento il synchronized(this) {}blocco vuoto, funziona anche il codice e sospetto che sia perché il blocco provoca un ritardo sufficiente che i currentPossuoi campi vengono riletti piuttosto che utilizzati dalla cache.

int x = p.x + 1;
int y = p.y;

if (x != y) {
    System.out.println(x+" "+y);
    System.exit(1);
}

2
Sì, e potrei anche mettere un lucchetto intorno a tutto. Qual è il tuo punto?
Cane

Ho aggiunto alcune spiegazioni aggiuntive per l'uso di volatile.
Ed Plese,

19

Hai una memoria ordinaria, il riferimento 'currentpos' e l'oggetto Point e i suoi campi dietro di esso, condivisi tra 2 thread, senza sincronizzazione. Pertanto, non esiste un ordinamento definito tra le scritture che si verificano in questa memoria nel thread principale e le letture nel thread creato (chiamarlo T).

Il thread principale sta eseguendo le seguenti scritture (ignorando l'impostazione iniziale di point, si tradurrà in px e py con valori predefiniti):

  • a px
  • py
  • a currentpos

Poiché non c'è nulla di speciale in queste scritture in termini di sincronizzazione / barriere, il runtime è gratuito per consentire al thread T di vederle avvenire in qualsiasi ordine (il thread principale ovviamente vede sempre le scritture e le letture ordinate secondo l'ordine del programma) e si verificano in qualsiasi punto tra le letture in T.

Quindi T sta facendo:

  1. legge currentpos a p
  2. leggi px e py (in entrambi gli ordini)
  3. confrontare e prendere il ramo
  4. leggi px e py (entrambi gli ordini) e chiama System.out.println

Dato che non ci sono relazioni di ordinamento tra le scritture in main e le letture in T, ci sono chiaramente diversi modi in cui questo può produrre il tuo risultato, poiché T può vedere la scrittura di main in currentpos prima delle scritture in currentpos.y o currentpos.x:

  1. Legge currentpos.x prima, prima che si sia verificata la scrittura x - ottiene 0, quindi legge currentpos.y prima che si sia verificata la scrittura y - ottiene 0. Confronta gli eval con il vero. Le scritture diventano visibili a T. System.out.println viene chiamato.
  2. Legge currentpos.x prima, dopo che si è verificata la scrittura x, quindi legge currentpos.y prima che si sia verificata la scrittura y - ottiene 0. Confronta gli eval con il vero. Le scritture diventano visibili a T ... ecc.
  3. Legge currentpos.y prima, prima che si sia verificata la scrittura y (0), quindi legge currentpos.x dopo la scrittura x, passa a true. eccetera.

e così via ... Ci sono un certo numero di gare di dati qui.

Sospetto che il presupposto errato qui stia pensando che le scritture risultanti da questa riga siano rese visibili attraverso tutti i thread nell'ordine del programma del thread che lo esegue:

currentPos = new Point(currentPos.x+1, currentPos.y+1);

Java non fornisce tale garanzia (sarebbe terribile per le prestazioni). È necessario aggiungere qualcosa in più se il programma necessita di un ordinamento garantito delle scritture rispetto alle letture in altri thread. Altri hanno suggerito di rendere definitivi i campi x, y o, in alternativa, di rendere volatile currentpos.

  • Se si rendono definitivi i campi x, y, Java garantisce che le scritture dei loro valori verranno eseguite prima che il costruttore ritorni, in tutti i thread. Pertanto, poiché l'assegnazione a currentpos è dopo il costruttore, il thread T è garantito per vedere le scritture nell'ordine corretto.
  • Se si rende volatile currentpos, allora Java garantisce che si tratta di un punto di sincronizzazione che sarà ordinato in totale per altri punti di sincronizzazione. Come in linea generale le scritture su xey devono avvenire prima della scrittura su currentpos, quindi qualsiasi lettura di currentpos in un altro thread deve vedere anche le scritture di x, y avvenute prima.

L'uso di final ha il vantaggio di rendere immutabili i campi e quindi di memorizzare nella cache i valori. L'uso volatile porta alla sincronizzazione su ogni scrittura e lettura di currentpos, il che potrebbe compromettere le prestazioni.

Vedi il capitolo 17 delle specifiche del linguaggio Java per i dettagli gory: http://docs.oracle.com/javase/specs/jls/se7/html/jls-17.html

(La risposta iniziale presupponeva un modello di memoria più debole, poiché non ero sicuro che la volatile garantita da JLS fosse sufficiente. Risposta modificata per riflettere il commento degli assylias, sottolineando che il modello Java è più forte - succede prima che sia transitivo - e quindi anche volatile su currentpos ).


2
Questa è la migliore spiegazione secondo me. Molte grazie!
skyde

1
@skyde ma sbagliato sulla semantica del volatile. le garanzie volatili che leggono una variabile volatile vedranno l'ultima scrittura disponibile di una variabile volatile e qualsiasi scrittura precedente . In questo caso, se currentPosreso volatile, l'assegnazione garantisce la pubblicazione sicura currentPosdell'oggetto e dei suoi membri, anche se non sono volatili.
Assylias,

Bene, stavo dicendo che non potevo, per me stesso, vedere esattamente come il JLS garantisse che la volatile costituisse una barriera con altre letture e scritture normali. Tecnicamente, non posso sbagliarmi su questo;). Quando si tratta di modelli di memoria, è prudente presumere che un ordine non sia garantito ed essere sbagliato (sei ancora al sicuro) rispetto al contrario ed essere sbagliato e insicuro. È fantastico se volatile offre questa garanzia. Puoi spiegarci come lo fornisce il ch 17 del JLS?
paulj,

2
In breve, in Point currentPos = new Point(x, y), hai 3 scritture: (w1) this.x = x, (w2) this.y = ye (w3) currentPos = the new point. L'ordine del programma garantisce che hb (w1, w3) e hb (w2, w3). Più avanti nel programma leggi (r1) currentPos. Se currentPosnon è volatile, non c'è hb tra r1 e w1, w2, w3, quindi r1 potrebbe osservarne una (o nessuna). Con volatile, si introduce hb (w3, r1). E la relazione hb è transitiva, quindi si introducono anche hb (w1, r1) e hb (w2, r1). Questo è riassunto in Java Concurrency in Practice (3.5.3. Idiomi di pubblicazione sicuri).
Assylias,

2
Ah, se hb è transitivo in quel modo, allora è una 'barriera' abbastanza forte, sì. Devo dire che non è facile determinare che 17.4.5 del JLS definisce hb per avere quella proprietà. Certamente non è nell'elenco delle proprietà fornite all'inizio del 17.4.5. La chiusura transitiva è menzionata più in basso solo dopo alcune note esplicative! Comunque, buono a sapersi, grazie per la risposta! :). Nota: aggiornerò la mia risposta per riflettere il commento di Assylias.
paulj

-2

È possibile utilizzare un oggetto per sincronizzare le scritture e le letture. Altrimenti, come altri hanno già detto, si verificherà una scrittura su currentPos nel mezzo delle due letture p.x + 1 e py

new Thread() {
    void f(Point p) {
        if (p.x+1 != p.y) {
            System.out.println(p.x+" "+p.y);
            System.exit(1);
        }
    }
    @Override
    public void run() {
        while (currentPos == null);
        while (true)
            f(currentPos);
    }
}.start();
Object sem = new Object();
while (true) {
    synchronized(sem) {
        currentPos = new Point(currentPos.x+1, currentPos.y+1);
    }
}

In realtà questo fa il lavoro. Nel mio primo tentativo ho inserito la lettura all'interno del blocco sincronizzato, ma in seguito ho capito che non era davvero necessario.
Germano Fronza,

1
-1 La JVM può dimostrare che semnon è condivisa e trattare l'istruzione sincronizzata come una non operatività ... Il fatto che risolva il problema è pura fortuna.
Assylias,

4
Odio la programmazione multi-thread, troppe cose funzionano per fortuna.
Jonathan Allen,

-3

Si sta accedendo a CurrentPos due volte e non si garantisce che non sia aggiornato tra questi due accessi.

Per esempio:

  1. x = 10, y = 11
  2. il thread di lavoro valuta px come 10
  3. il thread principale esegue l'aggiornamento, ora x = 11 e y = 12
  4. il thread di lavoro valuta py come 12
  5. il thread di lavoro nota che 10 + 1! = 12, quindi stampa ed esce.

Stai essenzialmente confrontando due diversi punti.

Si noti che anche rendere volatile currentPos non ti proteggerà da questo, poiché sono due letture separate da parte del thread di lavoro.

Aggiungi un

boolean IsValid() { return x+1 == y; }

metodo per la tua classe di punti. Questo assicurerà che venga usato solo un valore di currentPos quando si controlla x + 1 == y.


currentPos viene letto una sola volta, il suo valore viene copiato in p. p viene letto due volte, ma punta sempre nella stessa posizione.
Jonathan Allen,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.