Java中的內存模型JMM詳細解讀
一、CPU緩存一致性問題
1. CPU緩存模型
CPU Cache 通常分為三級緩存:L1 Cache、L2 Cache、L3 Cache,級別越低的離 CPU 核心越近,訪問速度也快,但是存儲容量相對就會越小。其中,在多核心的 CPU 里,每個核心都有各自的 L1/L2 Cache,而 L3 Cache 是所有核心共享使用的。
2. MESI緩存一致性協(xié)議
多核CPU緩存則必然會有緩存與主存之間的一致性的問題,例如在核心1的L1/L2 cache中修改了某項數據但還沒寫回主存,那么核心2再讀取這項數據時則讀的舊的錯誤數據。
要想實現緩存一致性,要滿足以下兩點:
寫傳播:某個 CPU 核心里的 Cache 數據更新時,必須要傳播到其他核心的 Cache。
事務的串行化:多個 CPU 核心對一個數據的操作順序,必須在其他核心看起來順序是一樣的。
基于總線嗅探機制的緩存一致性協(xié)議 MESI 就滿足上面了這兩點。CPU緩存中的每塊數據中都有如下其中的一個狀態(tài)標記,當數據變化時通過總線嗅探監(jiān)聽機制使其它CPU核心感知到并修改緩存數據的狀態(tài):
3. 弱緩存一致性
上述MESI協(xié)議雖然可以保證緩存的一致性,但又會影響性能,因此現代計算機中并不是完全遵守。關于這個問題的發(fā)展歷程如下:
CPU 從單核發(fā)展為多核,導致出現了多個核間的緩存一致性問題 --> 為了解決緩存一致性問題,提出了 MESI 協(xié)議 --> 完全遵守 MESI 又會給 CPU 帶來性能問題 --> CPU 設計者為了提高性能又在cache基礎上增加 store buffer 和 invalid queue --> 又導致了緩存的順序一致性變?yōu)榱巳蹙彺嬉恢滦?--> 需要緩存的順序一致性的,就需要軟件工程師自己在合適的地方添加內存屏障,volatile 的作用之一就是給虛擬機看讓其在對應的指令加入內存屏障。防止cpu級別的重排序,從而避免緩存一致性問題。
因此由于CPU弱緩存一致性的問題,在多線程中,一個線程對于一個共享變量的修改對其它線程可能是不可見的。
二、指令的重排序問題
指令重排序: 在不影響單線程程序執(zhí)行結果的前提下,計算機為了最大限度的發(fā)揮機器性能,會對機器指令重排序優(yōu)化 Java 源代碼會經歷 編譯器優(yōu)化重排 —> 指令并行重排 —> 內存系統(tǒng)重排 的過程,最終才變成操作系統(tǒng)可執(zhí)行的指令序列。
指令重排序會保證串行語義一致,但是沒有義務保證多線程間的語義也一致 ,所以在多線程下,指令重排序可能會導致一些問題。
例如如下創(chuàng)建單例對象的代碼
uniqueInstance = new Singleton();
這段代碼其實是分為三步執(zhí)行:
- 為 uniqueInstance 分配內存空間
- 初始化uniqueInstance
- 將 uniqueInstance指向分配的內存地址
但是由于 JVM 具有指令重排的特性,執(zhí)行順序有可能變成 1->3->2。指令重排在單線程環(huán)境下不會出現問題,但是在多線程環(huán)境下會導致一個線程獲得還沒有初始化的實例。例如,線程 T1 執(zhí)行了 1 和 3,此時 T2 調用 getUniqueInstance() 后發(fā)現 uniqueInstance 不為空,因此返回 uniqueInstance,但此時 uniqueInstance 還未被初始化,從而導致出錯。
三、Java 內存模型(JMM)詳解
Java是跨平臺的,為解決不同平臺下上述CPU弱緩存一致性帶來的共享變量可見性以及指令的重排序等問題,并且方便程序員更加安全高效地實現多線程編程,Java提供一套內存模型以及并發(fā)編程規(guī)范以屏蔽系統(tǒng)差異。
對于 Java 開發(fā)者說,你不需要了解底層原理,直接使用并發(fā)相關的一些關鍵字和類(比如 volatile、synchronized、各種 Lock)即可開發(fā)出并發(fā)安全的程序。
1. Java 內存模型
Java 對內存的抽象模型如下,每個線程都有一塊自己的私有內存(也稱為工作內存),當線程使用變量時,會把主內存里面的變量復制到工作內存,線程讀寫變量時操作的是自己工作內存中的變量。線程的工作內存實際上就是對CPU緩存和寄存器的統(tǒng)一抽象。
為實現線程工作內存與主內存的同步,Java規(guī)范在內存模型中定義了以下八種同步操作(了解即可,無需死記硬背):
- lock(鎖定): 作用于主內存中的變量,將他標記為一個線程獨享變量。
- unlock(解鎖): 作用于主內存中的變量,解除變量的鎖定狀態(tài),被解除鎖定狀態(tài)的變量才能被其他線程鎖定。
- read(讀?。鹤饔糜谥鲀却娴淖兞?,它把一個變量的值從主內存?zhèn)鬏數骄€程的工作內存中,以便隨后的 load 動作使用。
- load(載入):把 read 操作從主內存中得到的變量值放入工作內存的變量的副本中。
- use(使用):把工作內存中的一個變量的值傳給執(zhí)行引擎,每當虛擬機遇到一個使用到變量的指令時都會使用該指令。
- assign(賦值):作用于工作內存的變量,它把一個從執(zhí)行引擎接收到的值賦給工作內存的變量,每當虛擬機遇到一個給變量賦值的字節(jié)碼指令時執(zhí)行這個操作。
- store(存儲):作用于工作內存的變量,它把工作內存中一個變量的值傳送到主內存中,以便隨后的 write 操作使用。
- write(寫入):作用于主內存的變量,它把 store 操作從工作內存中得到的變量的值放入主內存的變量中。
我們在編寫程序代碼時使用volatile、synchronized和各種 Lock等關鍵字即可間接實現這些同步操作來解決前面提到的在多線程中可能會出現的問題。
以如下程序為例:
public class JMMTest { private boolean initFlag = false; // private volatile boolean initFlag = false;//volatile關鍵字可保證變量的可見性以及指令的有序性 public static void main(String[] args) throws InterruptedException { JMMTest jmmTest = new JMMTest(); new Thread(() -> { System.out.println("Thread1-start"); //線程2對flag的修改對線程1不可見,故會陷入死循環(huán) while (!jmmTest.initFlag){ } System.out.println("Thread1-end"); }).start(); Thread.sleep(100); new Thread(() -> { System.out.println("Thread2-start"); jmmTest.initFlag = true; System.out.println("Thread2-end"); }).start(); } }
當成員變量initFlag沒有用volatile修飾時,線程1首先用read操作從主內存中讀取initFlag的值,然后用load操作加載到工作內存的副本中,再用use操作使用值后進入循環(huán),輪到線程2執(zhí)行,也是先read -> load -> use,然后用assign操作將從執(zhí)行引擎接收到的true值復制給工作內存中的initFlag副本,最后在某個時候用store -> write操作寫入主內存。但是此時線程1仍然讀取的是其工作內存中的值,因此就陷入了死循環(huán)。
當成員變量initFlag使用volatile修飾后,線程2修改initFlag后會立即寫回主內存并且讓線程1中的變量副本失效,因此線程1需要從主內存中重新讀取最新的值,以此實現了變量的可見性,從而能夠退出循環(huán)。
2. 內存屏障
內存屏障表示隔開兩個內存同步操作,使其能夠有序執(zhí)行而不被重排序
內存屏障只是一種規(guī)范,真正落地的實現屬于底層的細節(jié),比如volatile的內存屏障底層是通過lock匯編指令實現的。
3. happens-before 原則
happens-before 原則表示程序中某些指令操作必發(fā)生在另一些指令操作前面,不允許重排序。
happens-before 原則的設計思想:
為了對編譯器和處理器的約束盡可能少,只要不改變程序的執(zhí)行結果(單線程程序和正確執(zhí)行的多線程程序),編譯器和處理器怎么進行重排序優(yōu)化都行。
- 對于會改變程序執(zhí)行結果的重排序,JMM 要求編譯器和處理器必須禁止這種重排序。
- happens-before 的規(guī)則共 8 條,重點了解下面5 條即可:
- 程序順序規(guī)則 :一個線程內,按照代碼順序,書寫在前面的操作 happens-before 于書寫在后面的操作;
- 解鎖規(guī)則 :解鎖 happens-before 于加鎖;
- volatile 變量規(guī)則 :對一個 volatile 變量的寫操作 happens-before 于后面對這個 volatile 變量的讀操作。說白了就是對 volatile 變量的寫操作的結果對于發(fā)生于其后的任何操作都是可見的。
- 傳遞規(guī)則 :如果 A happens-before B,且 B happens-before C,那么 A happens-before C;
- 線程啟動規(guī)則 :Thread 對象的 start()方法 happens-before 于此線程的每一個動作。
如果兩個操作不滿足任意一個 happens-before 規(guī)則,那么這兩個操作就沒有順序的保障,JVM 可以對這兩個操作進行重排序。程序員則基于happens-before規(guī)則提供的內存可見性保證來編程。
四、并發(fā)編程的三個重要特性
1. 原子性
原子性:一次操作或者多次操作,要么所有的操作全部都得到執(zhí)行并且不會受到任何因素的干擾而中斷,要么都不執(zhí)行。 在 Java 中,可以借助synchronized 、各種 Lock 以及各種原子類實現原子性。 synchronized 和各種 Lock 可以保證任一時刻只有一個線程訪問該代碼塊,因此可以保障原子性。各種原子類是利用 CAS (compare and swap) 操作(可能也會用到 volatile或者final關鍵字)來保證原子操作。
2. 可見性
可見性:當一個線程對共享變量進行了修改,那么另外的線程都是立即可以看到修改后的最新值。 在 Java 中,可以借助synchronized 、volatile 以及各種 Lock 實現可見性。如果我們將變量聲明為 volatile ,這就指示 JVM,這個變量是共享且不穩(wěn)定的,每次使用它都到主存中進行讀取。
3. 有序性
有序性:指令執(zhí)行順序在并發(fā)環(huán)境下依然能按預期執(zhí)行,不會因為重排序而產生錯亂。 由于指令重排序問題,代碼的執(zhí)行順序未必就是編寫代碼時候的順序。我們上面講重排序的時候也提到過:指令重排序可以保證串行語義一致,但是沒有義務保證多線程間的語義也一致 ,所以在多線程下,指令重排序可能會導致一些問題。在 Java 中,volatile 關鍵字可以禁止指令進行重排序優(yōu)化。
到此這篇關于Java中的內存模型JMM詳細解讀的文章就介紹到這了,更多相關Java內存模型JMM內容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關文章希望大家以后多多支持腳本之家!
相關文章
Spring實戰(zhàn)之XML與JavaConfig的混合配置詳解
大家都知道Spring的顯示配置方式有兩種,一種是基于XML配置,一種是基于JavaConfig的方式配置。那么下這篇文章主要給大家分別介紹如何在JavaConfig中引用XML配置的bean以及如何在XML配置中引用JavaConfig,需要的朋友可以參考下。2017-07-07SpringBoot測試時卡在Resolving Maven dependencies的問題
這篇文章主要介紹了SpringBoot測試時卡在Resolving Maven dependencies的問題及解決方案,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2022-02-02Spring Boot2中如何優(yōu)雅地個性化定制Jackson實現示例
這篇文章主要為大家介紹了Spring Boot2中如何優(yōu)雅地個性化定制Jackson實現示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2023-05-05