Analiza Java ključne reči volatile
inc output:" + test.inc); } }
Izlaz testa:inc output:8182
„Zašto, Erge?“ upitala je Sanme zbunjeno videći taj rezultat.
„Zato što inc++ nije atomska operacija ([kao što smo već rekli](https://javasi.dev/thread/thread-bring-some-problem.html)), već se sastoji iz čitanja, sabiranja i dodeljivanja — tri koraka — pa rezultat ne može dostići 10000.“ strpljivo sam odgovorio.
„Aha, tako mi je jasno“, klimnula je glavom Sanme.
Kako rešiti?
01. Iskoristiti [synchronized](https://javasi.dev/thread/synchronized-1.html) (obradićemo u sledećem članku — kliknite na link), izdvojiti `inc++` i dodati mu ključnu reč synchronized:
```java
public class volatileTest1 {
public int inc = 0;
public synchronized void increase() {
inc++;
}
public static void main(String[] args) {
final volatileTest1 test = new volatileTest1();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
while(Thread.activeCount()>1) //osigurava da sve prethodne niti završe
Thread.yield();
System.out.println("add synchronized, inc output:" + test.inc);
}
}- Iskoristiti Lock, tako što će se reentrantna brava ReentrantLock zaključati oko
inc++(sve će biti detaljno objašnjeno kasnije — kliknite na link):
public class volatileTest2 {
public int inc = 0;
Lock lock = new ReentrantLock();
public void increase() {
lock.lock();
inc++;
lock.unlock();
}
public static void main(String[] args) {
final volatileTest2 test = new volatileTest2();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
while(Thread.activeCount()>1) //osigurava da sve prethodne niti završe
Thread.yield();
System.out.println("add lock, inc output:" + test.inc);
}
}- Iskoristiti atomsku klasu AtomicInteger (takođe će biti detaljno objašnjena — kliknite na link):
public class volatileTest3 {
public AtomicInteger inc = new AtomicInteger();
public void increase() {
inc.getAndIncrement();
}
public static void main(String[] args) {
final volatileTest3 test = new volatileTest3();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<100;j++)
test.increase();
};
}.start();
}
while(Thread.activeCount()>1) //osigurava da sve prethodne niti završe
Thread.yield();
System.out.println("add AtomicInteger, inc output:" + test.inc);
}
}Sva tri daju izlaz 1000:
add synchronized, inc output:1000
add lock, inc output:1000
add AtomicInteger, inc output:1000Dvostruka brava volatile u obrascu singletona
Ispod je primer obrasca singletona (Singleton Pattern) koji koristi „dvostruku proveru zaključavanja“ (double-checked locking).
public class Penguin {
private static volatile Penguin m_penguin = null;
// članska promenljiva money
private int money = 10000;
// da bi se izbeglo inicijalizovanje objekta preko new, konstruktor treba biti private
private Penguin() {}
public void beating() {
System.out.println("bijem doudou" + money);
}
public static Penguin getInstance() {
if (m_penguin == null) {
synchronized (Penguin.class) {
if (m_penguin == null) {
m_penguin = new Penguin();
}
}
}
return m_penguin;
}
}U ovom primeru klasa Penguin se može instancirati samo jednom. Pogledajmo objašnjenje koda:
- Deklarisana je volatile promenljiva m_penguin tipa Penguin; ona je statička promenljiva klase i služi za čuvanje jedinstvene instance klase Penguin.
- Konstruktor
Penguin()deklarisan je kao private, čime se spoljni kod sprečava da kreira Penguin instancu preko new, pa instanca može biti dobavljena samo metodomgetInstance(). - Metod
getInstance()je javni statički metod za dobavljanje jedinstvene instance klase Penguin. - Prva provera
if (null == m_penguin)proverava da li Penguin instanca već postoji; ako ne postoji, ulazi se u sinhronizovani blok. synchronized(penguin.class)zaključava Class objekat klase, osiguravajući da u višenitnom okruženju samo jedna nit može ući u sinhronizovani blok. U tom bloku ponovo se izvršava proveraif (null == m_penguin)— ako instanca ne postoji, kreira se nova. To je tzv. „dvostruka provera zaključavanja“ — ukupno dva puta.- Konačno se vraća m_penguin, jedinstvena instanca Penguin-a.
Korišćenje ključne reči volatile ovde služi da se spreči preurednjivanje instrukcija u koraku m_penguin = new Penguin(). Jer taj red koda se zapravo deli u tri podkoraka:
- Korak 1: alocira se dovoljno memorije za objekat Penguin — pseudokôd
memory = allocate(). - Korak 2: poziva se konstruktor Penguin, inicijalizuju se članske promenljive objekta — pseudokôd
ctorInstanc(memory). - Korak 3: memorijska adresa se dodeljuje promenljivoj m_penguin, tako da ona pokazuje na novokreirani objekat — pseudokôd
instance = memory.
Ako se ne koristi volatile, JVM može preurediti ova tri podkoraka:
- alocira memoriju za Penguin objekat;
- dodeljuje objekat referenci m_penguin;
- poziva konstruktor radi inicijalizacije članskih promenljivih.
To preurednjivanje može dovesti do toga da referenca m_penguin postane dostupna drugim nitima pre nego što je objekat potpuno inicijalizovan. Konkretno, ako jedna nit stigne do koraka 2 i postavi referencu m_penguin, a da objekat još nije inicijalizovan, druga nit može videti „polu-inicijalizovan“ Penguin objekat.
Pretpostavimo da postoje dve niti A i B koje izvršavaju metod getInstance():
public static Penguin getInstance() {
if (m_penguin == null) {
synchronized (Penguin.class) {
if (m_penguin == null) {
m_penguin = new Penguin();
}
}
}
return m_penguin;
}- Nit A izvršava
if (m_penguin == null), procenjuje true i ulazi u sinhronizovani blok. - Nit B izvršava
if (m_penguin == null), procenjuje true i ulazi u sinhronizovani blok.
Ako se pri izvršenju m_penguin = new Penguin() niti A dogodi preurednjivanje instrukcija:
- Nit A alocira memoriju i postavlja referencu, ali još nije pozvala konstruktor da dovrši inicijalizaciju.
- Nit B tada procenjuje
m_penguin != nulli neposredno vraća taj „polu-inicijalizovani“ objekat.
To dovodi do toga da nit B dobije nepotpun Penguin objekat, što može izazvati izuzetak null pokazivača ili druge probleme.
Zato promenljivoj m_penguin dodajemo ključnu reč volatile da bismo zabranili preurednjivanje instrukcija, osiguravajući da se objekat inicijalizuje pre nego što se dodeli m_penguin.
Kratak pregled
„Dobro, Sanme, hajde da sumiramo“, uzdahnuo sam.
volatile može da osigura vidljivost niti i pruža izvesnu uređenost, ali ne može da osigura atomičnost. Na nivou JVM-a volatile se realizuje „memorijskom barijerom“.
Posmatranjem asemblerskog koda generisanog sa i bez ključne reči volatile može se uočiti da se pri dodavanju volatile pojavljuje dodatna instrukcija sa prefiksom lock; instrukcija sa prefiksom lock zapravo deluje kao memorijska barijera (još poznata kao memorijska ograda), koja pruža tri funkcije:
- osigurava da pri preurednjivanju instrukcija one koje slede ne budu pomerene ispred memorijske barijere, niti one ispred barijere iza nje; drugim rečima, u trenutku izvršenja instrukcije memorijske barijere sve prethodne operacije su već završene;
- prisiljava da se izmene keša odmah upišu u glavnu memoriju;
- ako je u pitanju operacija pisanja, dovodi do toga da odgovarajući keš redovi u drugim CPU-ima postanu nevažeći.
Na kraju smo naučili scenarije u kojima volatile ne važi, metode rešavanja, kao i zašto dvostruka provera zaključavanja u obrascu singletona zahteva volatile.
Urednik: Chenmo Wang Er, sadržaj pre uređivanja pretežno potiče od Lou Zai-ja, člana Ergovog tehničkog tima Paicoding; originalni link: volatile.
Na GitHub-u je konačno stigao drugi PDF „Mali priručnik o konkurentnom programiranju“open-source baze znanja sa preko 17000 zvezdica „Ergov put ka naprednom Javom“! Obuhvata osnovne pojmove i načine upotrebe niti, memorijski model Java-e, synchronized, volatile, CAS, AQS, ReentrantLock, bazene niti, konkurentne kontejnere, ThreadLocal, model proizvođač-potrošač i druge teme koje su obavezne za intervjue i razvoj — ukupno preko 150 000 reči i preko 200 ručno nacrtanih ilustracija, što se može opisati kao pristupačno i sa humorom... Više detalja: Odlično, Ergov put ka naprednom konkurentnom programiranju.pdf
