深入理解Java內存模型

Java 內存模型是什麼,爲什麼要有 Java 內存模型,Java 內存模型解決了什麼問題

 

計算機內存模型:

現代計算機,CPU在計算時,並不總是從內存讀取數據,數據讀取順序優先級是:寄存器一高速緩存(多級緩存)一內存

 

使用CPU Cache原因:

計算機在執行程序時,每條指令都是在CPU中執行的,執行指令過程中,勢必涉及到數據的讀取和寫入。

由於程序運行過程中的臨時數據是存放在主存(物理內存)中的,由於CPU執行速度很快,而內存讀寫數據的過程遠遠慢於CPU執行指令的速度,【CPU的頻率太快,主存(內存)跟不上】,如果任何時候對數據的操作都要通過和內存的交互來進行,CPU常常要等待主存,會大大降低指令執行的速度。浪費資源。

高速緩存cache是爲了緩解CPU和內存之間速度的不匹配問題(各結構速度比較:cpu->cache->memory)

 

CPU cache的意義

1)時間局部性:如果某個數據被訪問,那麼在不久的將來它很可能再次被訪問

2)空間局部性:如果某個數據被訪問,那麼與它相鄰的數據很快也能被訪問。

 

緩存一致性問題(CPU多級緩存的緩存一致性問題) (Cache Coherence):

程序在運行過程中,會將運算需要的數據從主存複製一份到CPU的高速緩存中,CPU進行計算時就可直接從它的高速緩存讀取數據和向其中寫入數據,當運算結束後,再將高速緩存中的數據刷新到主存當中

如:i = i + 1;

當線程執行這個語句時,會先從主存中讀取i值,接着複製一份到高速緩存中,然後CPU執行指令對i進行加1操作,再將數據寫入高速緩存,最後將高速緩存中i最新的值刷新到主存當中。

 

這個代碼在單線程中沒有問題,但在多線程中運行就會有問題了。

在多核CPU中,每條線程可能運行於不同的CPU中,因此每個線程運行時有自己的高速緩存。

如果一個變量在多個CPU中都存在緩存(一般在多線程編程時纔會出現),那就可能存在緩存不一致的問題。

 

【由於緩存爲CPU私有,多線程下,將可能出現緩存一致性問題(內存可見性問題)】

 

緩存一致性的解決方案:

http://images.cnitblog.com/blog/288799/201408/212219343783699.jpg

爲了解決緩存不一致性問題,通常來說有以下2種解決方法(硬件方案):

1)在總線(數據總線)加LOCK#鎖的方式:效率低下

由於CPU和內存間通信是通過總線進行的。

在早期的CPU中,是通過在總線上加LOCK#鎖的形式來解決緩存不一致的問題。

因爲CPU和其他部件進行通信都是通過總線來進行的,如果對總線加LOCK#鎖的話,也就是說阻塞了其他CPU對其他部件訪問(如內存),從而使得只能有一個CPU能使用這個變量的內存。

如上面例子中 如果一個線程在執行 i = i +1,如果在執行這段代碼的過程中,在總線上發出了LCOK#鎖的信號,那麼只有等待這段代碼完全執行完畢之後,其他CPU才能從變量i所在的內存讀取變量,然後進行相應的操作。這樣就解決了緩存不一致的問題。

問題:由於在鎖住總線期間,其他CPU無法訪問內存,導致效率低下

 

2)緩存一致性協議

緩存一致性協議中,有MSI、MESI、MOSI及Dragon Protocol等,最出名的是Intel 的MESI協議,MESI協議保證了每個緩存中使用的共享變量的副本是一致的。

關於MESI(Modified Exclusive Shared Or Invalid)協議:

MESI (伊利諾斯協議)是一種廣泛使用的支持寫回策略的緩存一致性協議,該協議被應用在Intel奔騰系列的CPU中

 

核心的思想:

當CPU寫數據時,如果發現操作的變量是共享變量,即在其他CPU中也存在該變量的副本,會發出信號通知其他CPU將該變量的緩存行置爲無效狀態,因此當其他CPU需要讀取這個變量時,發現自己緩存中緩存該變量的緩存行是無效的,那麼它就會從內存(主存)重新讀取。

 

實現方式:

MESI爲了保證多個CPU緩存中共享數據的一致性,定義了cache line的四種狀態,CPU對cache的四種操作可能會產生不一致的狀態,因此緩存控制器監聽到本地操作和遠程操作時,需要對地址一致的cache 狀態進行一致性修改,保證數據在多個緩存之間保持一致性。

http://hi.csdn.net/attachment/201203/4/0_13308376919qw9.gif

MESI協議中的狀態:CPU中每個緩存行(caceh line)用4種狀態進行標記(使用額外的兩位(bit)表示): 狀態間相互轉換關係用上表表示

1.M(Modified): 被修改

該緩存行只被緩存在該CPU的緩存中,且是被修改過的(dirty),即與主存中的數據不一致,該緩存行中的內存需要在未來的某個時間點(允許其它CPU讀取請主存中相應內存之前)寫回(write back)主存。

當被寫回主存之後,該緩存行的狀態會變成獨享(exclusive)狀態。

2.E(Exclusive): 獨享的

該緩存行只被緩存在該CPU的緩存中,它是未被修改過的(clean),與主存中數據一致。

該狀態可以在任何時刻當有其它CPU讀取該內存時變成共享狀態(shared)。

同樣地,當CPU修改該緩存行中內容時,該狀態可以變成Modified狀態。

3.S(Shared): 共享的

該狀態意味着該緩存行可能被多個CPU緩存,並且各個緩存中的數據與主存數據一致(clean),當有一個CPU修改該緩存行中,其它CPU中該緩存行可以被作廢(變成無效狀態(Invalid))。

4.I(Invalid): 無效的

該緩存是無效的(可能有其它CPU修改了該緩存行)。

 

 

操作:

在典型系統中,可能會有幾個緩存(在多核系統中,每個核心都會有自己的緩存)共享主存總線,每個相應的CPU會發出讀寫請求,而緩存的目的是爲了減少CPU讀寫共享

主存的次數。

一個緩存除在Invalid狀態外都可以滿足cpu的讀請求,一個invalid的緩存行必須從主存中讀取(變成S或者 E狀態)來滿足該CPU的讀請求

一個寫請求只有在該緩存行是M或者E狀態時才能被執行如果緩存行處於S狀態,必須先將其它緩存中該緩存行變成Invalid狀態(也既是不允許不同CPU同時修改同一緩存行,即使修改該緩存行中不同位置的數據也不允許)。該操作經常作用廣播的方式來完成,如:RequestFor Ownership (RFO)

緩存可以隨時將一個非M狀態的緩存行作廢,或者變成Invalid狀態,而一個M狀態的緩存行必須先被寫回主存

一個處於M狀態的緩存行必須時刻監聽所有試圖讀該緩存行相對就主存的操作,這種操作必須在緩存將該緩存行寫回主存並將狀態變成S狀態之前被延遲執行。

一個處於S狀態的緩存行也必須監聽其它緩存使該緩存行無效或者獨享該緩存行的請求,並將該緩存行變成無效(Invalid)。

一個處於E狀態的緩存行也必須監聽其它緩存讀主存中該緩存行的操作,一旦有這種操作,該緩存行需要變成S狀態。

對於M和E狀態而言總是精確的,他們在和該緩存行的真正狀態是一致的。而S狀態可能是非一致的,如果一個緩存將處於S狀態的緩存行作廢了,而另一個緩存實際上可能已經獨享了該緩存行,但是該緩存卻不會將該緩存行升遷爲E狀態,這是因爲其它緩存不會廣播他們作廢掉該緩存行的通知,同樣由於緩存並沒有保存該緩存行的copy的數量,因此(即使有這種通知)也沒有辦法確定自己是否已經獨享了該緩存行。

從上面的意義看來E狀態是一種投機性的優化:如果一個CPU想修改一個處於S狀態的緩存行,總線事務需要將所有該緩存行的copy變成invalid狀態,而修改E狀態的緩存不需要使用總線事務。

 

 

處理器指令優化:

處理器爲提高運算速度而做出違背代碼原有順序的優化。

爲了使得處理器內部的運算單元能儘可能被充分利用,處理器可能會對輸入代碼進行亂序執行(Out-Of-Order Execution)優化,處理器會在計算後將對亂序執行的代碼進行結果重組,保證結果準確性(只是保證最終一致性)。

【保證了最終一致性,犧牲了順序指令】

與處理器的亂序執行優化類似,Java虛擬機的即時編譯器(JIT)中也有類似的指令重排序(Instruction Recorder)優化

 

每個CPU設計都是不同的,每個CPU對指令亂序的程度也是不一樣的。比較保守的如x86僅會對Store Load亂序,但是一些優化激進的CPU(PS的Power)會允許更多情況的亂序產生。

 

 

指令重排完全是因爲性能考慮。一條指令的執行是可以分爲很多步驟的。

彙編指令也不是一步就可以執行完畢的,在CPU中實際工作時,它還是需要分爲多個步驟依次執行的。當然,每個步驟所涉及的硬件也可能不同。如,取指時會用到PC寄存器和存儲器,譯碼時會用到指令寄存器組,執行時會使用ALU,寫回時需要寄存器組。

注意:ALU指算術邏輯單元。它是CPU的執行單元,是CPU的核心組成部分,主要 功能是進行二進制算術運算。

 

由於每一個步驟都可能使用不同的硬件完成,就發明了 流水線技術 來執行指令

當第2條指令執行時,第1條執行其實並未執行完,確切地說第一條指令還沒開始執行,只是剛剛完成了取值操作而己。這樣的好處大大性能提升。

有了流水線這個神器,CPU才能真正高效的執行,但是,別忘了一點,流水線總是害怕被中斷的。流水線滿載時,性能確實相當不錯,但是一旦中斷,所有的硬件設備都會進入一個停頓期,再次滿載又需要幾個週期,因此,性能損失會比較大。所以,必須要想辦法盡 量不讓流水線中斷!

之所以需要做指令重排,就是爲了儘量少的中斷流水線。當然了,指令重排只是減少中斷的一種技術,實際上,在CPU的設計中,還會使用更多的軟硬件技術來防止中斷。

 

 

指令亂序執行問題:

亂序分兩種,分別是編譯器的指令重排CPU的亂序執行。亂序是爲了優化指令執行的速度而產生的。且爲了維護程序原來的語義,編譯器和CPU不會對兩個有數據依賴的指令重排(reorder)。

這種優化在單線程下是安全的,但是在多線程下,就導致了亂序問題。

如:

CPU-0將要執行兩條指令,分別是:

STORE x

LOAD y

當CPU-0執行指令1時,發現這個變量x的當前狀態爲Shared,意味着其它CPU也持有了x,根據緩存一致性協議,CPU-0在修改x前必須通知其它CPU,直到收到來自其它CPU的ack纔會執行真正的修改x。

事情沒有這麼簡單。現代CPU緩存通常都有一個Store Buffer,作用是先將要Store的變量記下來,注意此時並不真的執行Store操作,然後待時機合適的時候再執行實際的Store

有了Store Buffer,CPU-0在向其它CPU發出disable消息後並不是乾等着,而是轉而執行指令2(由於指令1和指令2在CPU-0看來並不存在數據依賴)【CPU亂序執行】。

這樣做效率是有了,也帶來了亂序問題。

雖然寫程序時,是先STORE x再LOAD y,但實際上CPU卻是先LOAD y再STORE x,這個便是CPU亂序執行(reorder)的一種情況!

CPU這種優化沒有問題,但是CPU不知道指令間蘊含着什麼樣的邏輯順序。

 

單核下沒問題,但是多核下,每個核都可以操作數據,也有各自的緩存。就會出現後寫的將前面寫的覆蓋等。

單核處理器時代處理器能夠保證處理器做出的優化不會影響結果,但是多核時代就會造成亂序,使最終結果錯誤

所以,在多核環境下,需要額外執行一些事情,才能保證數據的正確性

 

 

指令亂序問題解決方案:

內存屏障(memory barrier)是一種讓CPU知道某段指令執行的順序是不可被重排的機制。

如果不想指令1、2被CPU重排,程序應該這麼寫:

STORE x

WMB (Write memory barrier)

LOAD y

通過在STORE x之後加上這個寫內存屏障,就能保證在之後LOAD y指令不會被重排到STORE x之前了。

 

 

關於指令重排:

計算機在執行程序時,爲了提高性能,編譯器和處理器的常常會對指令做重排,一般分以下3種

編譯器優化的重排

編譯器在不改變單線程程序語義的前提下,可以重新安排語句的執行順序。

 

編譯器重排的例子:

線程 1             線程 2

1: x2 = a ;      3: x1 = b ;

2: b = 1;         4: a = 2 ;

兩個線程同時執行,分別有1、2、3、4四段執行代碼,其中1、2屬於線程1 , 3、4屬於線程2 ,從程序的執行順序上看,似乎不太可能出現x1 = 1 和x2 = 2 的情況,但實際上這種情況是有可能發現的,因爲如果編譯器對這段程序代碼執行重排優化後,可能出現下列情況:

線程 1              線程 2

2: b = 1;          4: a = 2 ;

1:x2 = a ;        3: x1 = b ;

這種執行順序下就有可能出現x1 = 1 和x2 = 2 的情況,這也就說明在多線程環境下,由於編譯器優化重排的存在,兩個線程中使用的變量能否保證一致性是無法確定的。

 

指令並行的重排

現代處理器採用了指令級並行技術來將多條指令重疊執行。如果不存在數據依賴性(即後一個執行的語句無需依賴前面執行的語句的結果),處理器可以改變語句對應的機器指令的執行順序

 

先了解一下指令重排的概念,處理器指令重排是對CPU的性能優化,從指令的執行角度來說一條指令可以分爲多個步驟完成,如下

CPU在工作時,需要將上述指令分爲多個步驟依次執行(注意硬件不同有可能不一樣),由於每一個步會使用到不同的硬件操作,比如取指時會只有PC寄存器和存儲器,譯碼時會執行到指令寄存器組,執行時會執行ALU(算術邏輯單元)、寫回時使用到寄存器組。

爲了提高硬件利用率,CPU指令是按流水線技術來執行的.

 

雖然流水線技術可以大大提升CPU的性能,但不幸的是一旦出現流水中斷,所有硬件設備將會進入一輪停頓期,當再次彌補中斷點可能需要幾個週期,這樣性能損失也會很大,就好比工廠組裝手機的流水線,一旦某個零件組裝中斷,那麼該零件往後的工人都有可能進入一輪或者幾輪等待組裝零件的過程。因此我們需要儘量阻止指令中斷的情況,指令重排就是其中一種優化中斷的手段.

 

 

內存系統的重排

由於處理器使用緩存和讀寫緩存衝區,使得加載(load)和存儲(store)操作看上去可能是在亂序執行,因爲三級緩存的存在,導致內存與緩存的數據同步存在時間差。

 

其中編譯器優化的重排屬於編譯期重排,指令並行的重排和內存系統的重排屬於處理器重排,在多線程環境中,這些重排優化可能會導致程序出現內存可見性問題,下面分別闡明這兩種重排優化可能帶來的問題

 

 

總結:

線程計算時,原始的數據來自內存,在計算過程中,有些數據可能被頻繁讀取,這些數據被存儲在寄存器和高速緩存中,當線程計算完後,這些緩存的數據在適當的時候應該寫回內存,當多個線程同時讀寫某個內存數據時,由於涉及數據的可見性、操作的有序性,所以就會產生多線程併發問題。

 

 

併發編程三要素:

JMM的關鍵技術點都是圍繞着多線程的原子性、可見性和有序性來建立的。

緩存一致性問題其實就是可見性問題。處理器優化是可以導致原子性問題的。指令重排即會導致有序性問題。

1.原子性(Atomicity):

一個操作不能被打斷,要麼全部執行完畢,要麼不執行。

基本類型數據的訪問大都是原子操作,但在32位JVM中,32位的JVM會將64位數據(long、double)的讀寫操作分爲2次32位的讀寫操作來進行,這就導致了long、double類型的變量在32位虛擬機中是非原子操作,數據有可能會被破壞,也就意味着多個線程在併發訪問時是非線程安全的。

原子性變量操作包括read、load、assign、use、和write

 

例:32位JVM下,對64位long類型的數據的訪問的問題:

public class NotAtomicity {

    @Getter @Setter   public  static long t = 0;

    //改變變量t的線程

    public static class ChangeT implements Runnable{

        private long to;

        public ChangeT(long to) {

            this.to = to;

        }

        public void run() {

            //不斷的將long變量設值到 t中

            while (true) {

                NotAtomicity.setT(to);

                //將當前線程的執行時間片段讓出去,以便由線程調度機制重新決定哪個線程可以執行

                Thread.yield();

            }

        }

    }

    //讀取變量t的線程,若讀取的值和設置的值不一致,說明變量t的數據被破壞了,即線程不安全

    public static class ReadT implements Runnable{

        public void run() {

            //不斷的讀取NotAtomicity的t的值

            while (true) {

                long tmp = NotAtomicity.getT();

                //比較是否是自己設值的其中一個

                if (tmp != 100L && tmp != 200L && tmp != -300L && tmp != -400L) {

                    //程序若執行到這裏,說明long類型變量t,其數據已經被破壞了

                    System.out.println(tmp);

                }

                ////將當前線程的執行時間片段讓出去,以便由線程調度機制重新決定哪個線程可以執行

                Thread.yield();

            }

        }

    }

    public static void main(String[] args) {

        new Thread(new ChangeT(100L)).start();

        new Thread(new ChangeT(200L)).start();

        new Thread(new ChangeT(-300L)).start();

        new Thread(new ChangeT(-400L)).start();

        new Thread(new ReadT()).start();

    }

}

創建了4個線程來對long類型的變量t進行賦值,賦值分別爲100,200,-300,-400,有一個線程負責讀取變量t,如果正常的話,讀取到的t的值應該是我們賦值中的一個,但是在32的JVM中,事情會出乎預料。如果程序正常的話,我們控制檯不會有任何的輸出,可實際上,程序一運行,控制檯就輸出了下面的信息:

-4294967096

4294966896

-4294967096

-4294967096

4294966896

之所以會出現上面的情況,是因爲在32位JVM中,64位的long數據的讀和寫都不是原子操作,即不具有原子性,併發的時候相互干擾了。

32位的JVM中,要想保證對long、double類型數據的操作的原子性,可對訪問該數據的方法進行synchronized同步.保證對64位數據操作的原子性。

 

 

2.可見性(Visibility):

一個線程對共享變量做了修改之後,其他的線程立即能夠看到(感知到)該變量這種修改(變化)。

可見性問題是一個綜合性問題。除了緩存優化或者硬件優化(有些內存讀寫可能不會立即觸發,而會先進入一個硬件隊列等待)會導致可見性問題外,指令重排以及編輯器的優化,都有可能導致一個線程的修改不會立即被其他線程察覺。

 

3.有序性(Ordering):

有序性問題是因爲程序在執行時,可能會進行指令重排,重排後的指令與原指令的順序未必一致。

如果在本線程內觀察,所有的操作都是有序的;如果在一個線程中觀察另一個線程,所有的操作都是無序的。前半句是指“線程內表現爲串行的語義”,後半句是指“指令重排序”現象和“工作內存和主內存同步延遲”現象。

 

 

Java內存區域:

見JVM相關文章。包括PC、虛擬機棧、本地方法棧、方法區、Java堆、

https://blog.csdn.net/qq_34190023/article/details/84928012

 

Java線程與硬件處理器

在Window系統和Linux系統上,Java線程的實現是基於一對一的線程模型,一對一模型就是通過語言級別層面程序去間接調用系統內核的線程模型,即在用Java線程時,Java虛擬機內部是轉而調用當前操作系統的內核線程來完成當前任務。

內核線程(Kernel-Level Thread,KLT):由操作系統內核(Kernel)支持的線程,這種線程是由操作系統內核來完成線程切換,內核通過操作調度器進而對線程執行調度,並將線程的任務映射到各個處理器上。每個內核線程可以視爲內核的一個分身,這也就是操作系統可以同時處理多任務的原因。

由於編寫的多線程程序屬於語言層面的,程序一般不會直接去調用內核線程,取而代之的是一種輕量級的進程(Light Weight Process),也是通常意義上的線程,由於每個輕量級進程都會映射到一個內核線程,因此可通過輕量級進程調用內核線程,進而由操作系統內核將任務映射到各個處理器,這種輕量級進程與內核線程間1對1的關係就稱爲一對一的線程模型。如下圖:

https://img-blog.csdn.net/20170608094427710?watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvamF2YXplamlhbg==/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/SouthEast

如圖所示,每個線程最終都會映射到CPU中進行處理,如果CPU存在多核,那麼一個CPU將可以並行執行多個線程任務。

 

JMM與硬件內存架構的關係

多線程的執行最終都會映射到硬件處理器上進行執行,但Java內存模型和硬件內存架構並不完全一致。

對於硬件內存來說只有寄存器、緩存內存、主內存的概念,沒有工作內存(線程私有數據區域)和主內存(堆內存)之分,即Java內存模型對內存的劃分對硬件內存並沒有任何影響, JMM只是一種抽象的概念,並不實際存在,不管是工作內存的數據還是主內存的數據,對於計算機硬件來說都會存儲在計算機主內存中,當然也有可能存儲到CPU緩存或寄存器中,

總體上來說,Java內存模型和計算機硬件內存架構是一個相互交叉的關係,是一種抽象概念劃分與真實物理硬件的交叉。(注意對於Java內存區域劃分也是同樣的道理)

https://img-blog.csdn.net/20170611230502297?watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvamF2YXplamlhbg==/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/SouthEast

 

JMM內存模型(Java Memory Model):

對於處理器亂序執行問題及不同CPU優化的差異性問題等,如果需要寫跨平臺多線程程序,勢必要了解每一個CPU的細節,來插入確切的、足夠的內存屏障來保證程序的正確性。

爲了屏蔽各種硬件和操作系統的差異,以實現java在各種平臺下都能達到一致的併發效果,虛擬機規範中定義了JVM規範

JMM是一種規範,規範了JVM與計算機內存如何協同工作,規定了變量的訪問規則(JVM中將變量存儲到內存和從內存中取出變量這樣的底層細節.一個線程如何和何時可以看到其他線程修改過的共享變量的值),來屏蔽底層平臺內存管理細節。

JMM是圍繞原子性,有序性、可見性展開的

爲了獲得較好的執行性能,JVM並沒有限制執行引擎使用處理器的寄存器或高速緩存來提升指令執行速度,也沒有限制編譯器對指令進行重排序。即在java內存模型中,也會存在緩存一致性問題和指令重排序的問題,所以多線程環境中必須解決可見性和有序性的問題。

 

JMM規定了所有變量都存儲在主內存(Main Memory)中。每個線程有自己的工作內存(Working Memory,線程的工作內存中保存了該線程用到的變量的主內存的副本拷貝,線程對變量的所有操作(讀取、賦值等)都必須在工作內存中進行,而不能直接讀寫主內存中的變量(volatile變量仍然有工作內存的拷貝,但由於它特殊的操作順序性規定,所以看起來如同直接在主內存中讀寫訪問一般)。不同線程間也無法直接訪問對方工作內存中的變量,線程間值的傳遞都需要通過主內存來完成。

 

主內存(Main Memory)和工作內存(Working Memory):

主內存(線程共享):

即平時說的Java堆內存,存放程序中所有的類實例、靜態數據等變量,

主要存儲Java實例對象,所有線程創建的實例對象都存放在主內存中,不管該實例對象是成員變量還是方法中的本地變量(也稱局部變量),也包括共享的類信息、常量、靜態變量。由於是共享數據區域,多條線程對同一個變量進行訪問可能會發現線程安全問題

 

工作內存(線程私有):

存放的是該線程從主內存中拷貝過來的變最以及訪問方法所取得的局部變量,

主要存儲當前方法的所有本地變量信息(工作內存中存儲着主內存中的變量副本拷貝),每個線程只能訪問自己的工作內存,即線程中的本地變量對其它線程是不可見的,就算是兩個線程執行的是同一段代碼,它們也會各自在自己的工作內存中創建屬於當前線程的本地變量,當然也包括了字節碼行號指示器、相關Native方法的信息。注意由於工作內存是每個線程的私有數據,線程間無法相互訪問工作內存,因此存儲在工作內存的數據不存在線程安全問題。

局部變量,方法定義參數 和 異常處理器參數存儲在線程的本地內存

 

關係:

每個線程對變量的操作都是以先從主內存將其拷貝到工作內存再對其進行操作的方式進行,多個線程間不能直接互相傳遞數據通信,只能通過共享變量來進行

主內存和工作內存與 JVM 內存結構中的 Java 堆、棧、方法區等並不是同一個層次的內存劃分,無法直接類比。

 

數據存儲類型以及操作方式

對於一個實例對象中的成員方法而言,

如果方法中包含本地變量是基本數據類型(boolean,byte,short,char,int,long,float,double),將直接存儲在工作內存的幀棧結構中,

但倘若本地變量是引用類型,那麼該變量的引用會存儲在功能內存的幀棧中,而對象實例將存儲在主內存(共享數據區域,堆)中。

但對於實例對象的成員變量,不管它是基本數據類型或包裝類型、引用類型,都會被存儲到堆區。

static變量及類本身相關信息將會存儲在主內存中。

注:在主內存中的實例對象可被多線程共享,倘若兩個線程同時調用了同一個對象的同一個方法,那麼兩條線程會將要操作的數據拷貝一份到自己的工作內存中,執行完成操作後才刷新到主內存

 

 

內存間交互操作(每個操作都是原子性的)

主內存與工作內存間的具體交互協議,即一個變量如何從主內存拷貝到工作內存、如何從工作內存同步到主內存之間的實現細節,Java內存模型定義了以下八種操作來完成,虛擬機實現時必須保證下面提及的每一種操作都是原子操作。:

https://img-blog.csdn.net/20171024092853040?watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvc2luYXRfMzgyNTk1Mzk=/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/Center

•lock(鎖定):作用於主內存的變量,把一個變量標識爲一條線程獨佔狀態。

•unlock(解鎖):作用於主內存變量,把一個處於鎖定狀態的變量釋放出來,釋放後的變量纔可以被其他線程鎖定。

•read(讀取):作用於主內存變量,把一個變量值從主內存傳輸到線程的工作內存中,以便隨後的load動作使用

•load(載入):作用於工作內存的變量,它把read操作從主內存中得到的變量值放入工作內存的變量副本中。

•use(使用):作用於工作內存的變量,把工作內存中的一個變量值傳遞給執行引擎,每當虛擬機遇到一個需要使用變量的值的字節碼指令時將會執行這個操作。

•assign(賦值):作用於工作內存的變量,它把一個從執行引擎接收到的值賦值給工作內存的變量,每當虛擬機遇到一個給變量賦值的字節碼指令時執行這個操作。

•store(存儲):作用於工作內存的變量,把工作內存中的一個變量的值傳送到主內存中,以便隨後的write的操作。

•write(寫入):作用於主內存的變量,它把store操作從工作內存中一個變量的值傳送到主內存的變量中。

 

如果要把一個變量從主內存中複製到工作內存,就需要按順尋地執行read和load操作,

如果把變量從工作內存中同步回主內存中,就要按順序地執行store和write操作。

Java內存模型只要求上述操作必須按順序執行,而沒有保證必須是連續執行。也就是read和load間,store和write間是可以插入其他指令的,如對主內存中的變量a、b進行訪問時,可能的順序是read a,read b,load b, load a。

 

 

原子性問題:

在Java中,對基本數據類型的變量的讀取和賦值操作是原子性操作,即這些操作是不可被中斷的,要麼執行,要麼不執行。

x = 10;       //原子性操作

y = x;        //非原子性操作,包含2個操作,它先要去讀取x的值,再將x的值寫入工作內存,雖然讀取x的值以及 將x的值寫入工作內存 這2個操作都是原子性操作,但是合起來就不是原子性操作了.1.read a、2.assign b

x++;         //非原子性操作 1.read c 、2.add 3.assign to c

x = x + 1;     //非原子性操作 1.read c、2.add、3.assign to c;

 

只有簡單的讀取、賦值(而且必須是將數字賦值給某個變量,變量之間的相互賦值不是原子操作)纔是原子操作。

PS:引用類型的賦值也是原子性,如Object obj = obj2;這裏賦值的只是內存地址,4個字節

 

不過這裏有一點需要注意:在32位平臺下,對64位數據的讀取和賦值是需要通過兩個操作來完成的,不能保證其原子性。但是好像在最新的JDK中,JVM已經保證對64位數據的讀取和賦值也是原子性操作了。【未驗證】

 

Java內存模型只保證了基本讀取和賦值是原子性操作,如果要實現更大範圍操作的原子性,可通過synchronized和Lock來實現。由於synchronized和Lock能夠保證任一時刻只有一個線程執行該代碼塊,那麼自然就不存在原子性問題了,從而保證了原子性。

Java保證原子性的方案:

1)Atomic包

JDK5提供的併發包

2)CAS算法

 

3)Synchronized

 

4)Lock

 

 

可見性問題:

可見性是指當一個線程修改了共享變量的值,其它線程能夠立即得知這個修改。

JMM是通過在變量修改後將新值同步回主內存,在變量讀取前從主內存刷新變量值這種依賴主內存作爲傳遞媒介的方式來實現可見性的。

 

Java保證可見性的方案:

1)volatile關鍵字來保證可見性。

當一個共享變量被volatile修飾時,它會保證修改的值會立即被更新到主存,當有其他線程需要讀取時,會去內存(主存)中讀取新值。

普通的共享變量不能保證可見性,普通共享變量被修改後,寫入主存時間是不確定的,當其他線程去讀取時,此時內存中可能還是原來的舊值,因此無法保證可見性。

 

2)synchronized保證可見性:

使用synchronized關鍵字,在同步方法/同步塊開始時(Monitor Enter),使用共享變量時會從主內存中刷新變量值到工作內存中(即從主內存中讀取最新值到線程私有的工作內存中),在同步方法/同步塊結束時(Monitor Exit),會將工作內存中的變量值同步到主內存中去(即將線程私有的工作內存中的值寫入到主內存進行同步)。

 

3)Lock保證可見性:

使用Lock接口的最常用的實現ReentrantLock(重入鎖)來實現可見性:執行lock.lock()方法和synchronized開始位置(Monitor Enter)有相同的語義,即使用共享變量時會從主內存中刷新變量值到工作內存中(即從主內存中讀取最新值到線程私有的工作內存中),在方法的最後finally塊裏執行lock.unlock()方法和synchronized結束位置(Monitor Exit)有相同的語義,即會將工作內存中的變量值同步到主內存中去(即將線程私有的工作內存中的值寫入到主內存進行同步)。

 

synchronized和Lock能保證同一時刻只有一個線程獲取鎖然後執行同步代碼,並且在釋放鎖前會將對變量的修改刷新到主存當中。因此可以保證可見性。

 

4)final保證可見性:

被final修飾的變量,在構造方法數一旦初始化完成,並且在構造方法中並沒有把“this”的引用傳遞出去(“this”引用逃逸是很危險的,其他的線程很可能通過該引用訪問到只“初始化一半”的對象),那麼其他線程就可以看到final變量的值。

被 final 修飾的字段在聲明時或者構造器中,一旦初始化完成,那麼在其他線程無須同步就能正確看見 final 字段的值。這是因爲一旦初始化完成,final 變量的值立刻回寫到主內存。

 

 

有序性問題:

線程在引用變量時不能直接從主內存中引用,如果線程工作內存中沒有該變量,則會從主內存中拷貝一個副本到工作內存中,這個過程爲read-load,完成後線程會引用該副本。當同一線程再度引用該字段時,有可能重新從主存中獲取變量副本(read-load-use),也有可能直接引用原來的副本(use),也就是說readloaduse順序可以由JVM實現系統決定

這時線程與線程之間的操作的先後順序,會決定了程序對主內存區最後的修改是不是正確的。

 

另外,Java內存模型具備一些先天的“有序性”,即不需要通過任何手段就能夠得到保證的有序性,這個通常也稱爲 happens-before 原則。如果兩個操作的執行次序無法從happens-before原則推導出來,那麼它們就不能保證它們的有序性,虛擬機可以隨意地對它們進行重排序。

 

Java內存模型還規定了在執行上述八種基本操作時,必須滿足如下規則:

•不允許read和load、store和write操作之一單獨出現

•不允許一個線程丟棄它的最近assign的操作,即變量在工作內存中改變了之後必須同步到主內存中。

•不允許一個線程無原因地(沒有發生過任何assign操作)把數據從工作內存同步回主內存中。

•一個新的變量只能在主內存中誕生,不允許在工作內存中直接使用一個未被初始化(load或assign)的變量。即就是對一個變量實施use和store操作之前,必須先執行過了assign和load操作。

•一個變量在同一時刻只允許一條線程對其進行lock操作,lock和unlock必須成對出現

•如果對一個變量執行lock操作,將會清空工作內存中此變量的值,在執行引擎使用這個變量前需要重新執行load或assign操作初始化變量的值

•如果一個變量事先沒有被lock操作鎖定,則不允許對它執行unlock操作;也不允許去unlock一個被其他線程鎖定的變量。

•對一個變量執行unlock操作之前,必須先把此變量同步到主內存中(執行store和write操作)。

 

 

Happen-Before原則:哪些指令不能重排

雖然Java虛擬機和執行系統會對指令進行一定的重排,但指令重排是有原則的,並非所有的指令都可以隨便改變執行位置,以下羅列了一些基本原則, 這些原則是指令重排不可違背的

 

Java內存模型中定義的兩項操作之間的次序關係,如果說操作A先行發生於操作B,操作A產生的影響能被操作B觀察到,“影響”包含了修改了內存中共享變量的值、發送了消息、調用了方法等。

下面是Java內存模型下一些”天然的“happens-before關係,這些happens-before關係無須任何同步器協助就已經存在,可以在編碼中直接使用。如果兩個操作之間的關係不在此列,並且無法從下列規則推導出來的話,它們就沒有順序性保障,虛擬機可以對它們進行隨意地重排序。

 

a.程序次序規則(Pragram Order Rule):

在一個線程內,按照程序代碼順序,書寫在前面的操作先行發生於書寫在後面的操作。準確地說應該是控制流順序而不是程序代碼順序,因爲要考慮分支、循環結構。

一個線程內,按照代碼順序,書寫在前面的操作先行發生於書寫在後面的操作

這個規則是用來保證程序在單線程中執行結果的正確性,但無法保證程序在多線程中執行的正確性。

 

b.管程鎖定規則(Monitor Lock Rule):

一個unlock操作先行發生於後面對同一個鎖的lock操作。”後面“是指時間上的先後順序。

 

c.volatile變量規則(Volatile Variable Rule):

對一個volatile變量的寫操作先行發生於後面對這個變量的讀取操作,”後面“是指時間上的先後順序。

 

d.傳遞性(Transitivity):

如果操作A先行發生於操作B,操作B先行發生於操作C,那麼操作A先行發生於操作C。

 

e.線程啓動規則(Thread Start Rule):

Thread對象的start()方法先行發生於此線程的每一個動作(run方法)。

 

f.線程中斷規則(Thread Interruption Rule):

對線程interrupt()方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生,可通過Thread.interrupted()方法檢測是否有中斷髮生。【interrupt方法必須發生在捕獲該動作前】

 

g.線程終結規則(Thread Termination Rule):

線程中的所有操作都先行發生於對此線程的終止檢測,我們可以通過Thread.join()方法結束,Thread.isAlive()的返回值等作段檢測到線程已經終止執行。【所有操作都發生在線程死亡前】

 

h.對象終結規則(Finalizer Rule):

一個對象初始化完成(構造方法執行完成)先行發生於它的finalize()方法的開始。

 

 

Java保證有序性的方案:

在Java內存模型中,允許編譯器和處理器對指令進行重排序,但是重排序過程不會影響到單線程程序的執行,卻會影響到多線程併發執行的正確性。

1)volatile:

volatile關鍵字本身通過加入內存屏障來禁止指令的重排序。

2)synchronized:

synchronized關鍵字通過一個變量在同一時間只允許有一個線程對其進行加鎖的規則來實現

 

3)Lock:

synchronized和Lock保證每個時刻是有一個線程執行同步代碼,相當於是讓線程順序執行同步代碼,自然就保證了有序性。

 

 

 

在執行程序時爲了提高性能,編譯器和處理器經常會對指令進行重排序。重排序分成三種類型:

1.編譯器優化的重排序。編譯器在不改變單線程程序語義放入前提下,可以重新安排語句的執行順序。

2.指令級並行的重排序。現代處理器採用了指令級並行技術來將多條指令重疊執行。如果不存在數據依賴性,處理器可以改變語句對應機器指令的執行順序。

3.內存系統的重排序。由於處理器使用緩存和讀寫緩衝區,這使得加載和存儲操作看上去可能是在亂序執行。

 

從Java源代碼到最終實際執行的指令序列,會經過下面三種重排序:

https://images0.cnblogs.com/i/475287/201403/091511346284594.png

爲保證內存的可見性,Java編譯器在生成指令序列的適當位置會插入內存屏障指令來禁止特定類型的處理器重排序。Java內存模型把內存屏障分爲LoadLoad、LoadStore、StoreLoad和StoreStore四種:

屏障類型

指令示例

說明

LoadLoad Barriers

Load1; LoadLoad; Load2

確保Load1數據的裝載之前於Load2及所有後續裝載指令的裝載。

StoreStore Barriers

Store1; StoreStore; Store2

確保Store1數據對其他處理器可見(刷新到內存),之前於Store2及所有後續存儲指令的存儲。

LoadStore Barriers

Load1; LoadStore; Store2

確保Load1數據裝載之前於Store2及所有後續的存儲指令刷新到內存。

StoreLoad Barriers

Store1; StoreLoad; Load2

確保Store1數據對其他處理器變得可見(指刷新到內存),之前於Load2及所有後續裝載指令的裝載。StoreLoad Barriers會使該屏障之前的所有內存訪問指令(存儲和裝載指令)完成之後,才執行該屏障之後的內存訪問指令。

作用:通過內存屏障可以禁止特定類型處理器的重排序,從而讓程序按我們預想的流程去執行

 

基於保守策略的JMM內存屏障插入策略:

在每個volatile寫操作的前面插入一個StoreStore屏障。

在每個volatile寫操作的後面插入一個StoreLoad屏障。

在每個volatile讀操作的後面插入一個LoadLoad屏障。

在每個volatile讀操作的後面插入一個LoadStore屏障。

 

相關博文:

Java內存模型是什麼,爲什麼要有Java內存模型,Java內存模型解決了什麼問題?

Java內存模型

指令重排序

全面理解Java內存模型(JMM)及volatile關鍵字

Java內存模型原理,你真的理解嗎?

從JVM角度理解線程

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章