Paolovox ha scritto:
So che ogni thread in (credo) qualsiasi OS, ha un proprio PC che mantiene traccia dell'istruzione che si sta eseguendo, di un proprio stack delle chiamate, dove ogni stack frame mantiene le variabili locali delle funzioni in esecuzione ancora non terminate, dei campi statici che sono visibili a tutti gli altri thread, e mantiene gli oggetti istanziati nell'heap. Nell'heap tutti gli oggetti sono condivisi quindi è possibile condividere informazioni sia utilizzando riferimenti da diversi thread allo stesso oggetto, o utilizzando campi statici.
Quello che non hai colto è un'altra cosa, più importante. Il Java Memory Model dice chiaramente che il compilatore (il Just-In-Time compiler in modo specifico, si intende) ma anche il processore fisico possono applicare ottimizzazioni e riordinamenti del codice, tali per cui all'interno di un singolo flusso di esecuzione non diano problemi fintanto che il risultato finale sia lo stesso che ci sarebbe senza tutte queste ottimizzazioni e riordinamenti. Se la semantica di un singolo flusso di esecuzione non cambia, compilatore/processore possono fare tutti i "giochi" che vogliono sui dati e sul codice.
Ma quando ci sono più thread le cose cambiano, queste ottimizzazioni e riordinamenti possono creare situazioni "paradossali" e portare a scenari in cui ciò che un thread si aspetta da un altro thread non è più così ovvio e scontato.
Andando al succo: senza preoccuparsi di mettere in atto meccanismi appositi di sincronizzazione, semplicemente NON è affatto garantito che ciò che fa un thread in memoria sia "visibile" ad un altro thread.
Usare la parola chiave
volatile su una variabile condivisa garantisce la "visibilità" delle modifiche ma non la mutua-esclusione, quindi non evita una race-condition. Per avere la mutua-esclusione bisogna mettere in atto una apposita sincronizzazione, sfruttanto metodi/blocchi synchronized o con altri meccanismi di sincronizzazione di alto livello offerti dal framework.
Esistono però dei punti di sincronizzazione impliciti, ben definiti dal Java Memory Model e riguardano anche l'avvio/terminazione di un thread.
Dato un t1.start(), è GARANTITO che tutto quello fatto prima di questa istruzione è "visibile" al codice del thread che si sta avviando.
In modo opposto:
Dato un t1.join(), è GARANTITO che tutto quello fatto dal thread t1 è "visibile" al codice dopo questa istruzione.
Questi due punti in sostanza sono il motivo per cui un codice come quello che hai postato può funzionare senza una evidente/esplicita sincronizzazione.