Intervju u Ant Groupu: Pričajmo o Java memorijskom modelu (JMM)
Jednog dana, Xiao Er je otišao na intervju u Ant Group. Odmah na početku, ispitivač stariji Lao Wang ga je pitao: „Pričaj mi, šta je Java memorijski model (JMM)?"
Xiao Er je u seši planuo od radosti i bez razmišljanja odgovorio: „Java memorija se uglavnom deli na pet velikih blokova: heap, metod area, JVM stack, native metod stack, PC registar, bla bla bla..."
Lao Wang se značajno nasmešio, blago prezirow prekinuo Xiao Wanga i rekao: „U redu, današnji intervju je ovde završen, idi kući i čekaj obaveštenje."
Čim je čuo reči o čekanju obaveštenja, Xiao Er je znao da je ovaj intervju verovatno propao. Zašto? Xiao Er to nije razumeo.
Kada se vratio u dom i prelistao „Ergeov put ka Javi — poglavlje o konkurentnom programiranju", Xiao Er je konačno shvatio: pomešao je pojmove. Ispitivač je želeo da proveri znanje o JMM-u, ali čim je Xiao Er čuo ključne reči Java memorija, počeo je da bubá napamet gradivo o Java izvršnim memorijskim područjima. Eh, razlika između Java memorijskog modela (JMM) i Java izvršnih memorijskih područja je velika.
Java memorijski model (Java Memory Model, JMM) definiše pravila po kojima promenljive i niti u Java programu komuniciraju sa glavnom memorijom i radnom memorijom. Uglavnom se bavi pitanjima vidljivosti deljenih promenljivih i preraspodele instrukcija u višenitnom okruženju, i predstavlja ključni koncept za razumevanje konkurentnog programiranja.
Između niti u konkurentnom programiranju postoje dva problema:
- Kako niti komuniciraju? Odnosno: kojim mehanizmom niti razmenjuju informacije.
- Kako se niti sinhronizuju? Odnosno: kojim mehanizmom nit kontroliše međusobni redosled događaja u različitim nitima.
Postoje dva konkurentna modela koja rešavaju ova dva problema:
- Model konkurentnosti sa prosleđivanjem poruka.
- Model konkurentnosti sa deljenom memorijom.
Razlika između ova dva modela prikazana je na sledećoj slici:

Java koristi model konkurentnosti sa deljenom memorijom.
Java memorijski model
Šta je deljena promenljiva?
Hajde prvo da pogledamo izvršne podatke — sigurno vam nisu nepoznati:

Za svaku nit, stack je privatan, dok je heap zajednički.
Odnosno, promenljive u stack-u (lokalne promenljive, parametri metoda, parametri obrade izuzetaka) se ne dele između niti, pa ne postoje problemi memorijske vidljivosti i ne podležu uticaju memorijskog modela. Promenljive u heap-u su deljene i opšte se nazivaju deljenim promenljivama.
Dakle, memorijska vidljivost se odnosi na deljene promenljive u heap-u.
Kako nastaje problem memorijske vidljivosti?
Neko će možda pitati: s obzirom na to da je heap deljen, zašto u heap-u postoji problem memorijske nevidljivosti?
Zato što moderni računari, zarad efikasnosti, često keširaju deljene promenljive u brzom kešu, jer CPU pristupa kešu mnogo brže nego memoriji.
Deljene promenljive između niti postoje u glavnoj memoriji, a svaka nit ima privatnu lokalnu memoriju u kojoj čuva kopije deljenih promenljivih koje ta nit čita i piše. Lokalna memorija je apstraktan pojam Java memorijskog modela i ne postoji fizički. Obuhvata keš, bafore za pisanje, registre i sl.
Komunikacija između Java niti kontroliše Java memorijski model (kraće JMM). Sa apstraktnog stanovišta, JMM definiše apstraktan odnos između niti i glavne memorije. Šematski prikaz apstrakcije JMM-a je na slici:

Sa slike se vidi:
- Sve deljene promenljive se nalaze u glavnoj memoriji.
- Svaka nit čuva kopiju deljenih promenljivih koje koristi.
- Ako nit A želi da komunicira sa niti B, moraju se obaviti sledeća 2 koraka:
- Nit A osvežava izmenjene deljene promenljive iz lokalne memorije A u glavnu memoriju.
- Nit B čita iz glavne memorije deljene promenljive koje je nit A prethodno izmenila.
Dakle, nit A ne može direktno da pristupi radnoj memoriji niti B; komunikacija između niti mora da prođe kroz glavnu memoriju.
Napomena: prema pravilima JMM-a, sve operacije niti nad deljenim promenljivama moraju se obavljati u sopstvenoj lokalnoj memoriji i ne mogu se čitati direktno iz glavne memorije.

- Glavna memorija: podaci instanci objekata u Java heap-u, odgovara fizičkoj memoriji hardvera.
- Radna memorija: deo Java stack-a, prvenstveno se čuva u registrima i brzom kešu.
Zato nit B ne čita vrednost deljene promenljive direktno iz glavne memorije, već prvo pronalazi tu deljenu promenljivu u lokalnoj memoriji B, otkriva da je ažurirana, zatim lokalna memorija B čita novu vrednost iz glavne memorije i kopira je u lokalnu memoriju B, i tek na kraju nit B čita novu vrednost iz lokalne memorije B.
Kako garantovati memorijsku vidljivost?
Otkuda znamo da je deljenu promenljivu neka druga nit ažurirala? Tu je zasluga JMM-a, i to je jedan od razloga njegovog postojanja. JMM obezbeđuje garanciju memorijske vidljivosti kontrolišući interakciju između glavne memorije i lokalne memorije svake niti.
Ključna reč volatile u Javi može da garantuje vidljivost deljenih promenljivih pri radu više niti i da zabrani preraspodelu instrukcija; ključna reč synchronized pored vidljivosti garantuje i atomičnost (isključivost).
Na nižem nivou, JMM memorijsku vidljivost i zabranu preraspodele ostvaruje putem memorijskih barijera. Da bi programerima olakšao razumevanje, dizajneri su predložili koncept happens-before (detaljno opisan u nastavku) koji je jednostavniji i razumljiviji, čime se izbegava da programeri moraju da uče složena pravila preraspodele i njihove konkretne metode implementacije kako bi razumeli memorijsku vidljivost.
Razlika između JMM-a i Java izvršnih memorijskih područja
Pomenuli smo JMM i podelu Java izvršnih memorijskih područja — ova dva pojma se i razlikuju i međusobno povezuju:
Razlika
To su različiti pojmovi. JMM je apstraktan i služi da opiše skup pravila kojima se kontroliše način pristupa promenljivima, oko atomičnosti, uređenosti, vidljivosti itd. Podela Java izvršne memorije je konkretna i predstavlja neophodnu podelu memorije kada JVM izvršava Java program.
Veza
Oba imaju privatne i deljene podatkovne oblasti. Generalno, glavna memorija u JMM-u pripada oblasti deljenih podataka i obuhvata heap i metod area; slično tome, lokalna memorija u JMM-u pripada oblasti privatnih podataka i obuhvata brojač programa, native metod stack i JVM stack.
Da sumiramo:
Java izvršna memorijska područja opisuju kako se pri izvršenju JVM-a memorija deli na različite oblasti, kao i funkcije i mehanizam rada svake oblasti. Uglavnom obuhvata sledeće delove:
- Metod area: čuva strukturne podatke svake klase, kao što su izvršni konstantni bazen, podaci o poljima i metodama, bajtkod konstruktorâ i običnih metodâ.
- Heap: ovde se memorija dodeljuje skoro svim instancama objekata i nizovima. Ovo je glavna oblast upravljanja Java memorijom.
- Stack: svaka nit ima sopstveni privatni stack. Pri svakom pozivu metoda kreira se novi stack frame za čuvanje lokalnih promenljivih, steka operanada, dinamičkih veza, izlaza iz metoda itd. Svi stack frame-ovi se kreiraju i uništavaju pri pozivu i završetku metoda.
- Native metod stack: slično stack-u, ali služi za native metode koje JVM koristi.
- Brojač programa: svaka nit ima nezavisan brojač programa koji pokazuje do koje linije bajtkoda je stiglo izvršenje te niti.
Java memorijski model (JMM) se uglavnom odnosi na to kako bezbedno izvršavati operacije između glavne memorije i radne memorije u višenitnom okruženju.

Obuhvata teme poput vidljivosti promenljivih, preraspodele instrukcija, atomičnih operacija i sl., sa ciljem da reši probleme koje donosi višenitno konkurentno programiranje.
- Vidljivost: kada jedna nit izmeni vrednost deljene promenljive, drugi niti odmah mogu da saznaju novu vrednost.
- Atomičnost: jedna ili više operacija tokom celog procesa neće biti prekinute drugim nitima ili operacijama; te operacije predstavljaju celinu — ili se izvrše sve, ili nijedna.
- Uređenost: redosled izvršenja programa prati redosled kako je kod napisan.
JMM i preraspodela
Pomenuli smo da JMM definiše pravila o tome kako više niti međusobno interaguje, sa glavnim ciljem da reši probleme vidljivosti, atomičnosti i uređenosti koji nastaju usled optimizacije kompajlera, optimizacije procesora i sistema keširanja.
Hajde sada da pričamo o preraspodeli i problemima redosleda koje ona donosi.
Zašto preraspodela instrukcija poboljšava performanse?
Svi znamo da računar, prilikom izvršenja programa, radi povećanja performansi često preraspoređuje instrukcije — i kompajler i procesor.
Neko će možda pitati: zašto preraspodela instrukcija poboljšava performanse?
Jednostavno rečeno, svaka instrukcija sadrži više koraka, a svaki korak možda koristi drugi hardver. Zato je nastala tehnika protoka (pipeline) — njen princip je da instrukcija 1 ne mora da se završi pre nego što počne izvršenje instrukcije 2, već se odmah nastavlja, čime se efikasnost znatno povećava.
Međutim, tehniku protoka najviše ometa prekid, a cena obnove prekida je velika, pa se trudimo na sve načine da sprečimo prekid protoka. Preraspodela instrukcija je jedna od tehnika koja smanjuje prekide.
Analizirajmo izvršenje sledećeg koda:
a = b + c;
d = e - f ;Prvo se učitavaju b i c (napomena: možda prvo b, a možda i prvo c), ali pri izvršenju add(b,c) mora se sačekati da se učitavanje b i c završi pre nego što se nastavi, odnosno mora se dodati pauza, pa će i sledeće instrukcije (učitavanje e i f) imati pauzu, što smanjuje efikasnost računara.
Da bismo smanjili pauze, nakon učitavanja b i c možemo odmah da učitamo i e i f, a zatim izvršimo add(b,c). To ne utiče na program (serijski), ali smanjuje pauze.
Drugim rečima, pošto add(b,c) ionako mora da pauzira, zašto ne bismo uradili nešto korisno (učitati e i f).
Zaključak: preraspodela instrukcija je neophodna za poboljšanje performansi CPU-a, ali donosi i problem poremećenog redosleda.
Koje vrste preraspodele postoje?
Preraspodela instrukcija se opšte deli u tri sledeće vrste:
Optimizacija kompajlera: kompajler, bez promene semantike jednonitnog programa, preuređuje redosled izvršenja naredbi.
Paralelizam instrukcija: moderni procesori koriste tehnike paralelizma na nivou instrukcija kako bi preklapali izvršenje više instrukcija. Ako ne postoji zavisnost u podacima (tj. naredna naredba ne zavisi od rezultata prethodne), procesor može da promeni redosled izvršenja mašinskih instrukcija koje odgovaraju naredbama.
Preraspodela memorijskog sistema: pošto procesor koristi keš i bafore za čitanje/pisanje, operacije učitavanja (load) i skladištenja (store) mogu izgledati kao da se izvršavaju van reda. Zbog keša trećeg nivoa, postoji vremenska razlika u sinhronizaciji podataka između memorije i keša.
Preraspodela instrukcija garantuje doslednost serijske semantike, ali nema obavezu da garantuje i doslednost semantike između više niti. Zato u višenitnom okruženju preraspodela instrukcija može izazvati probleme.
JMM i model sekvencijalne doslednosti
Kada program nije pravilno sinhronizovan, može doći do trke u podacima (data race).
Trka u podacima: jedna nit piše u promenljivu, druga nit čita istu promenljivu, i pisanje i čitanje nisu uređeni sinhronizacijom.
Ako program sadrži trku u podacima, rezultati izvršenja su često puni neizvesnosti — na primer, ako se čitanje desi pre pisanja, može se pročitati pogrešna vrednost. Ako jedna nit može da se pravilno sinhronizuje, onda ne postoji trka u podacima.
Java memorijski model (JMM) daje sledeću garanciju za memorijsku doslednost pravilno sinhronizovanih višenitnih programa: ako je program pravilno sinhronizovan, izvršenje programa imaće sekvencijalnu doslednost. Odnosno, rezultat izvršenja biće isti kao rezultat izvršenja u modelu sekvencijalne doslednosti.
Ovde sinhronizacija obuhvata sinhronizaciju ostvarenu ključnim rečima volatile, final, synchronized itd.
Ako mi programeri ne pravilno koristimo ključne reči poput volatile, final, synchronized, onda čak i uz sinhronizaciju JMM neće dati garanciju memorijske vidljivosti, što lako može dovesti do grešaka u programu koje je teško reprodukovati i otkriti.
Šta je model sekvencijalne doslednosti?
Model sekvencijalne doslednosti je idealizovani teorijski referentni model koji programu pruža veoma jaku garanciju memorijske vidljivosti. Model sekvencijalne doslednosti ima dve glavne osobine:
- Sve operacije u okviru jedne niti moraju se izvršavati po redosledu programa (tj. po redosledu Java koda).
- Bez obzira na to da li je program sinhronizovan, sve niti vide isti jedinstveni redosled izvršenja operacija. Odnosno, u modelu sekvencijalne doslednosti svaka operacija mora biti atomna i odmah vidljiva svim nitima.
Da bismo razumeli ove dve osobine, uzećemo primer. Pretpostavimo da se istovremeno izvršavaju dve niti, A i B. Nit A ima 3 operacije čiji je redosled u programu A1->A2->A3; nit B takođe ima 3 operacije, B1->B2->B3.
Pretpostavimo da je sinhronizacija pravilno korišćena: nit A izvrši svoje 3 operacije i pusti bravu, a nit B uzme istu bravu. Tada je efekat izvršenja u modelu sekvencijalne doslednosti prikazan ispod:

Izvršenje operacija je celokupno uređeno i obe niti vide taj isti redosled izvršenja.
Zašto JMM ne garantuje sekvencijalnu doslednost?
Pretpostavimo da sinhronizacija nije korišćena. Tada je efekat izvršenja u modelu sekvencijalne doslednosti sledeći:

Izvršenje operacija je celokupno neuređeno, ali obe niti vide taj isti redosled izvršenja. Ova garancija je moguća zato što svaka operacija u modelu sekvencijalne doslednosti mora odmah biti vidljiva bilo kojoj niti.
Ali JMM ne daje takvu garanciju.
Na primer, kada trenutna nit kešira podatke koje je upisala u lokalnu memoriju, pre nego što ih osveži u glavnu memoriju, taj upis je vidljiv samo trenutnoj niti; iz ugla drugih niti, taj upis uopšte nije izvršen.
Tek kada trenutna nit osveži podatke iz lokalne memorije u glavnu memoriju, taj upis postaje vidljiv drugim nitima. U tom slučaju, trenutna nit i druge niti vide različit redosled izvršenja.
U modelu sekvencijalne doslednosti sve operacije se izvršavaju striktno po redu programa, serijki. Ali u JMM-u, kod unutar kritične sekcije (u sinhronizovanom bloku ili sinhronizovanoj metodi) može biti preraspoređen (ali se ne dozvoljava da kod „pobegne" iz kritične sekcije, jer bi to narušilo memorijsku semantiku brave).
Iako je nit A izvršila preraspodelu unutar kritične sekcije, zbog osobina brave nit B ne može da primeti preraspodelu koju je nit A uradila. Ta preraspodela i poboljšava efikasnost izvršenja i ne menja rezultat izvršenja programa.
Istovremeno, JMM vrši posebnu obradu pri izlasku i ulasku u kritičnu sekciju, tako da program unutar kritične sekcije dobija isti memorijski prikaz kao u modelu sekvencijalne doslednosti.
Odatle se vidi da je konkretan pristup JMM-a: bez promene rezultata izvršenja (pravilno sinhronizovanog) programa, otvoriti vrata za optimizaciju što je više moguće tokom kompajliranja i izvršenja na procesoru.
Za ne-sinhronizovane višenitne programe, JMM pruža samo minimalnu bezbednost: vrednost koju nit pročita je ili vrednost koju je neka nit ranije upisala, ili podrazumevana vrednost — neće se stvoriti ni iz čega.
Da bi ostvario ovu bezbednost, JVM prilikom dodeljivanja objekta na heap-u prvo nulira memorijski prostor, a zatim tek na njemu dodeljuje objekat (ove dve operacije su sinhronizovane).
JMM ne garantuje da će rezultat izvršenja ne-sinhronizovanog programa biti dosledan rezultatu u modelu sekvencijalne doslednosti. Jer da bi se garantovala doslednost rezultata, JMM bi morao da zabrani veliki broj optimizacija, što bi znatno uticalo na performanse izvršenja programa.
Razlike u karakteristikama izvršenja ne-sinhronizovanih programa u JMM-u i modelu sekvencijalne doslednosti su sledeće:
- Sekvencijalna doslednost garantuje da će se operacije unutar jedne niti izvršavati po redu programa; JMM ne garantuje da će se operacije unutar jedne niti izvršavati po redu programa (zbog preraspodele, ali JMM garantuje da preraspodela u jednonitnom okruženju ne utiče na rezultat izvršenja).
- Model sekvencijalne doslednosti garantuje da sve niti vide dosledan redosled izvršenja operacija, dok JMM ne garantuje da će sve niti videti dosledan redosled (jer JMM ne garantuje da su sve operacije odmah vidljive).
- Model sekvencijalne doslednosti garantuje atomičnost svih operacija čitanja i pisanja memorije, dok JMM ne garantuje atomičnost operacija pisanja 64-bitnih promenljivih tipa long i double.
JMM i happens-before
Sa jedne strane, mi programeri treba da JMM pruži snažan memorijski model za pisanje koda; sa druge strane, kompajleri i procesori žele što manje ograničenja od JMM-a kako bi mogli da vrše što više optimizacija i poboljšaju performanse — žele slab memorijski model.
JMM je razmotrio obe potrebe i našao ravnotežu: za kompajlere i procesore, sve dok se ne promeni rezultat izvršenja programa (jednonitnog programa i pravilno sinhronizovanog višenitnog programa), kompajleri i procesori mogu da optimizuju kako god žele.
Za nas programere, JMM pruža happens-before pravila (specifikacija JSR-133) koja zadovoljavaju naše potrebe — jednostavna su za razumevanje i pružaju dovoljno jaku garanciju memorijske vidljivosti. Drugim rečima, ako mi programeri pratimo happens-before pravila, program koji napišemo garantovano će imati jaku memorijsku vidljivost u okviru JMM-a.
JMM koristi koncept happens-before da bi odredio redosled izvršenja između dve operacije. Te dve operacije mogu biti u okviru jedne niti ili u različitim nitima.
Definicija happens-before odnosa je sledeća:
- Ako jedna operacija happens-before drugu operaciju, onda će rezultat izvršenja prve operacije biti vidljiv drugoj operaciji, a redosled izvršenja prve operacije dolazi pre druge operacije.
- To što između dve operacije postoji happens-before odnos ne znači da konkretna implementacija Java platforme mora da se izvršava po redosledu koji navodi happens-before odnos. Ako rezultat izvršenja nakon preraspodele bude dosledan rezultatu izvršenja po happens-before odnosu, JMM dozvoljava takvu preraspodelu.
Happens-before odnos je suštinski ista stvar kao i as-if-serial semantika.
As-if-serial semantika garantuje da je rezultat izvršenja nakon preraspodele unutar jedne niti dosledan rezultatu koji bi programski kod trebalo da da, dok happens-before odnos garantuje da rezultat izvršenja pravilno sinhronizovanog višenitnog programa neće biti izmenjen preraspodelom.
Ukratko, ako operacija A happens-before operaciju B, onda su operacije koje je operacija A izvršila nad memorijom vidljive operaciji B, bez obzira na to da li su u istoj niti ili ne.
Koji happens-before odnosi postoje?
U Javi postoje sledeći prirodni happens-before odnosi:
- Pravilo redosleda programa: svaka operacija u okviru jedne niti happens-before bilo kojoj narednoj operaciji u toj niti.
- Pravilo brave monitora: otključavanje jedne brave happens-before narednom zaključavanju te iste brave.
- Pravilo volatile promenljive: pisanje u volatile polje happens-before svakom narednom čitanju tog volatile polja.
- Prenosivost: ako A happens-before B, i B happens-before C, onda A happens-before C.
- Start pravilo: ako nit A izvrši operaciju
ThreadB.start()kojom pokreće nit B, onda operacijaThreadB.start()niti A happens-before bilo kojoj operaciji u niti B. - Join pravilo: ako nit A izvrši operaciju
ThreadB.join()i ona se uspešno vrati, onda bilo koja operacija u niti B happens-before povratku niti A iz operacijeThreadB.join().
Primer:
int a = 1; // operacija A
int b = 2; // operacija B
int sum = a + b;// operacija C
System.out.println(sum);Prema gore navedenim happens-before pravilima, pretpostavimo da postoji samo jedna nit — lako je zaključiti:
1> A happens-before B
2> B happens-before C
3> A happens-before CNapomena: prilikom stvarnog izvršenja instrukcija, JVM zapravo može prerasporediti operacije A i B, jer bez obzira na to da li se prvo izvrši A ili B, one su međusobno vidljive i ne utiču na rezultat izvršenja.
Ako ovde dođe do preraspodele, to vizuelno narušava happens-before princip, ali JMM dozvoljava takvu preraspodelu.
Zato mi pazimo samo na happens-before pravila, a ne na to kako tačno JVM izvršava. Dovoljno je da utvrdimo da operacija A happens-before operaciju B.
Postoje dve vrste preraspodele i JMM ima različite strategije za njih:
- Preraspodela koja menja rezultat izvršenja programa, npr. A -> C; JMM zahteva da i kompajleri i procesori zabrane ovakvu preraspodelu.
- Preraspodela koja ne menja rezultat izvršenja programa, npr. A -> B; JMM ne postavlja zahteve pred kompajlere i procesore i dozvoljava ovakvu preraspodelu.
Rezime
- Java memorijski model (JMM) definiše pravila po kojima promenljive i niti u Java programu komuniciraju sa glavnom memorijom i radnom memorijom. Uglavnom se bavi pitanjima vidljivosti deljenih promenljivih i preraspodele instrukcija u višenitnom okruženju, i predstavlja ključni koncept za razumevanje konkurentnog programiranja.
- Java memorijski model (JMM) se uglavnom odnosi na to kako bezbedno izvršavati operacije između glavne memorije i radne memorije u višenitnom okruženju.
- Java izvršna memorijska područja opisuju kako se pri izvršenju JVM-a memorija deli na različite oblasti, kao i funkcije i mehanizam rada svake oblasti. Uglavnom obuhvata metod area, heap, stack, native metod stack i brojač programa.
- Preraspodela instrukcija služi da poboljša performanse CPU-a, ali može izazvati probleme, poput problema memorijske vidljivosti u višenitnom okruženju.
- Happens-before pravila su snažna garancija memorijske vidljivosti koju pruža JMM; ako pratimo happens-before pravila, program koji napišemo garantovano će imati jaku memorijsku vidljivost u okviru JMM-a.
Urednik: Chenmo Wang Er. Deo sadržaja pre uređivanja potiče iz otvorenog repozitorijuma mog prijatelja Lei Xiaoshuai-ja Java pitanja za intervju, toplo preporučujem. Deo sadržaja potiče iz ovog repozitorijuma mog prijatelja Xiaoqi Yinghuochong-a: Java višenitnost jednostavno objašnjena, toplo preporučujem.
