深入JVM(七)java內存模型與線程

併發處理的廣泛應用是使得Amdahl定律代替摩爾定律成爲計算機性能發展源動力的根本原因,也是人類“壓榨”計算機運算能力的最有力武器。


其實我很喜歡每篇文章標題的這一句話。雖然這篇這句我並不十分懂得。然後又去屁顛屁顛百度了一下。Amdahl定律(阿姆達爾定律) ,另一個是摩爾定律。感興趣的同學可以自己去看看這兩個定律的內容。
然後接下來說本章節的內容。

概述

多任務處理在現代計算機操作系統中幾乎已經是一項必備的功能了。許多情況下讓計算機同時去做幾件事,不僅是因爲計算機的運算能力強大。還有一個重要的原因是計算機的運算速度和他的存儲通信子系統素的的差距太大。大量的時間都花費在磁盤I/O,網絡通信或者數據庫訪問上。如果不想大部分時間都在等待,我們就應該讓計算機同時處理幾項任務。
服務端是java語言最擅長的領域之一。這個領域的應用佔了java應用的最大的一塊份額。不過如何寫好併發應用程序又是服務端程序開發的難點之一。處理好併發問題通常需要編碼經驗來支持。幸好java語言和虛擬機提供了很多工具。把併發編程的門欄降低了好多。並且各種中間件服務器,各類框架都努力的替程序員處理儘可能多的線程併發細節。使得程序員在編碼時可以更關注業務邏輯。但是無論語言,中間件如何先進,但是瞭解併發的內幕也是一個程序員不可或缺的課程。
“高效併發”是這本書講解java虛擬機的最後一部分。將會介紹虛擬機如何實現多線程,多線程之間由於共享和競爭數據而導致的一系列問題和解決方案。

硬件的效率與一致性

“緩存一致性:”在多處理器系統中,每個處理器都有自己的高速緩存,而他們又共用同一個主內存。當東哥處理器的運算任務都涉及同一塊主內存區域時,將可能導致各自的緩存數據不一致。如果真這樣了,那麼同步回主內存時以誰的緩存數據爲準呢?
基於這種情況,爲了解決緩存一致性的問題,需要各個處理器訪問緩存時都遵循着一些協議。在讀寫的時候要根據協議來進行操作。這類協議有MSI,MESI,MOSI,Synapse,Firefly等。本章提到的“內存模型”可以理解爲在特定的協議下,對特定的內存或者高速緩存進行讀寫訪問的過程抽象。

Java內存模型

java虛擬機規範中試圖定義一種java內存模型。使得java程序在各種平臺下都能達到一致的內存訪問效果。(C++等是直接使用物理硬件和操作系統的內存模型。所以不同平臺上內存模型會不一樣。所以必須針對平臺編程。這個也是java一次編程到處使用的原因)。
在jdk1.5以後,這個java內存模型已經成熟和完善起立了。
主內存和工作內存
java內存模型主要是定義程序中各個變量的訪問規則。也就是虛擬機將變量存儲到內存和從內存讀取的底層細節。此時的變量包括實例字段,靜態字段和構成數組對象的元素。不包括局部變量和方法參數。因爲後者是線程私有的,不會被共享,自然就不存在競爭問題。
java內存模型規定了所有的變量都存儲在主內存中。每條線程還有自己的工作內存。線程的工作內存保存了被該線程使用的主內存副本拷貝。線程對內存的所有讀寫操作都在工作內存中進行。不同的線程之間也不能直接訪問對方工作內存中的變量。線程間變量值的傳遞都通過主內存完成。


注意下這裏講的主內存和虛擬機內存是不一樣的。主內存就是直接對應物理硬件的內存。
內存間交互操作
關於主內存與工作內存之間的交互協議,即一個變量如何從主內存拷貝到工作內存。如何從工作內存同步到主內存中的實現細節。java內存模型定義了8種操作來完成。這8種操作每一種都是原子操作。

  • lock(鎖定):作用於主內存的變量,它把一個變量標記爲一條線程獨佔狀態。
  • unlock(解鎖):作用於主內存的變量,它將一個處於鎖定狀態的變量釋放出來,釋放後的變量才能夠被其他線程鎖定。
  • read(讀取):作用於主內存的變量,它把變量值從主內存傳送到線程的工作內存中,以便隨後的load動作使用。
  • write(寫入):作用於主內存的變量,它把store傳送值放到主內存中的變量中。

  • load(載入):作用於工作內存的變量,它把read操作的值放入工作內存中的變量副本中。
  • use(使用):作用於工作內存的變量,它把工作內存中的值傳遞給執行引擎,每當虛擬機遇到一個需要使用這個變量的指令時候,將會執行這個操作。
  • assign(賦值):作用於工作內存的變量,它把從執行引擎獲取的值賦值給工作內存中的變量,每當虛擬機遇到一個給變量賦值的指令時候,執行該操作。
  • store(存儲):作用於工作內存的變量,它把工作內存中的一個變量傳送給主內存中,以備隨後的write操作使用。

以上八個前四個是作用於主內存,後四個作用於工作內存。如果把一個變量從主內存複製到工作內存,就是先read在load。如果把工作內存的變量同步到主內存就是store在write。
java內存模型規定了執行上述八種基本操作時必要滿足的規則:

  1. 不允許read和load、store和write操作之一單獨出現(即不允許一個變量從主存讀取了但是工作內存不接受,或者從工作內存發起會寫了但是主存不接受的情況),以上兩個操作必須按順序執行,但沒有保證必須連續執行,也就是說,read與load之間、store與write之間是可插入其他指令的。
  2. 不允許一個線程丟棄它的最近的assign操作,即變量在工作內存中改變了之後必須把該變化同步回主內存。
  3. 不允許一個線程無原因地(沒有發生過任何assign操作)把數據從線程的工作內存同步回主內存中。
  4. 一個新的變量只能從主內存中“誕生”,不允許在工作內存中直接使用一個未被初始化(load或assign)的變量,換句話說就是對一個變量實施use和store操作之前,必須先執行過了assign和load操作。
  5. 一個變量在同一個時刻只允許一條線程對其執行lock操作,但lock操作可以被同一個條線程重複執行多次,多次執行lock後,只有執行相同次數的unlock操作,變量纔會被解鎖。
  6. 如果對一個變量執行lock操作,將會清空工作內存中此變量的值,在執行引擎使用這個變量前,需要重新執行load或assign操作初始化變量的值。
  7. 如果一個變量實現沒有被lock操作鎖定,則不允許對它執行unlock操作,也不允許去unlock一個被其他線程鎖定的變量。
  8. 對一個變量執行unlock操作之前,必須先把此變量同步回主內存(執行store和write操作)。

其實感覺上述的規則還是很明確清晰而且符合邏輯的。用我自己的話理解理解(這個是爲了加深我自己的印象。說的不準確勿噴)。
主內存和工作內存相當於兩個人或者程序。

  1. 主內存的讀和工作內存的加載是一個行爲的兩個部分。不可單獨。就好像我們測試時候的接口調用和被調用一樣。肯定是要兩方都有行爲啊!同理工作內存的存儲和主內存寫也是一樣的。
  2. 你賦值一個變量或者說改一個變量了必須交上去。不能自己偷偷的改了然後就沒然後了。換種思維理解也是一種不做無用功的規定。你賊喜歡你女神,然後天天微信框發八百字作文但是一次都不發出去。那樣的不是有病麼?也不符合邏輯啊~這條規定就很好,賦值了必須提交上去。
  3. 這個也是從正常邏輯就能理解。我都不知道咋解釋了,就是沒改動沒必要提交上去!svn我們常用吧?一般team開發都是每天提交進度。然後你要是今天啥也沒寫就摸魚了,有什麼好提交的?
  4. 首先都沒加載呢,所以不能用。也就是沒load並不能use。然後之前上條,沒有變化的不能再寫回主線程。而storehewrite必須同時使用。所以說沒有assign的不能store。這個其實聯繫聯繫都能分析出來。這裏還專門作爲一條規範了。
  5. 一個變量只能同時被一個線程鎖。我們上鎖的目的不就是爲了獨佔麼?還有啥可解釋的?至於這個鎖多次。現實中的比喻。我有一個大寶貝,怕丟了。所以藏起來鎖起來了。覺得不放心。然後鎖了18層。是不是可以?同樣道理,我現在想看看我的大寶貝,是不是得一層鎖一層鎖的解開?我只打開10層鎖也看不到我大寶貝啊!
  6. (這一點其實我看着有點小費解,但是找了半天沒找到解釋。所以把我的理解打出來,如果有大佬明確知道意思麻煩告知下)就是我覺得可能是
    第一:鎖之前,我們拿到的數據可能是沒那麼準確的。假如你前腳拿到數據後腳別的線程給改了呢?所以我們這個鎖機制是鎖上以後,再去read-load一次。這時候你能確保你手頭拿的肯定是最新的數據了。因爲你上鎖了,這時候包括以後別的線程都不能動這個數據了!同樣你自己也可以重新assign改這個值。(這點我不確定,反正我自己這麼理解了。)
    第二:這個時候所有其它使用到這個變量的線程中的所有的對這個變量的引用都清空。等解鎖以後纔可以從新read-load讀取。
  7. 這個簡單明瞭。沒上鎖的自然不能解鎖。你也不能拿着自家的鑰匙去解別人家的鎖。
  8. 在解鎖之前把變量同步到主內存。因爲變量解鎖後別的線程就可以read-load了。你不同步讓主內存read啥?
    volatile型變量的特殊規則
    關鍵字volatile是java虛擬機提供的最輕量級的同步機制。這個關鍵字有一個很有意思的代碼例子能讓大家更瞭解volatile。
package demo;

public class VolatileDemo {

    private static volatile int num = 0;

    public static void add() {
        num++;
    }

    public static void main(String[] args) {
        Thread[] threads = new Thread[20];
        for (Thread thread : threads) {
            thread = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < 10000; i++) {
                        add();
                    }
                }
            });         
            thread.start();
        }
        
        while (Thread.activeCount()>1)      
            Thread.yield();
        System.out.println(num);
    }

}

如果說線程安全的,最理想的情況下輸入的是20*10000.也就是結果是20w、但是真正跑起來卻不是.每次跑從六萬多到12萬多。我點了幾十次。都沒有20w的結果。而且每次運行的結果也不同。這是因爲num++這個操作在執行的時候分爲了四個字節碼指令。在獲取num的時候保證是正確的,但是在下面的指定的時候可能別的線程把這個num值改變了。反而這次提交把num值改小了。
由於volatile只能保證可見性。所以以下兩個場景中還是要synchronized或者concurrent來加鎖的。

  1. 運算結果並不依賴變量的當前值,或者能夠確保只有單一的線程修改變量值。
  2. 變量不需要與其他的狀態變量共同參與不變約束。

其實volatile的同步機制的性能是要優於鎖的。volatile的讀操作的性能幾乎與普通變量沒什麼區別。寫操作可能會慢一點。即便如此volatile的消耗也比鎖要低。我們在volatile與鎖的選擇中唯一的依據僅僅是volatile的語義能否滿足使用場景的需求。
long和double的特殊規則:這個簡單的說一下,long和double這樣的64位數據類型有一條特別的規定:沒有被volatile修飾的64爲數據的讀寫操作劃分爲兩次32爲的操作。這就是所謂的long和double的非原子性協定。雖然java內存模型允許虛擬機不把long和double的讀寫實現成原子操作。但是虛擬機本身都是當成原子對待的。
原子性,可見性,有序性
原子性:即一個操作或者多個操作,要麼全部執行並且執行的過程不會被任何因素打斷,要麼就都不執行。原子性就像數據庫裏面的事務一樣,他們是一個團隊,同生共死。我們大致可以認爲基本數據類型的訪問讀寫是具有原子性的。如果應用場景需要大範圍的原子性,我們可以用鎖。
可見性:可見性是指當多個線程訪問同一個變量時,一個線程修改了這個變量的值,其他線程能夠立即看得到修改的值。
我們上面講的volatile就是保證了多線程操作時變量的可見性。而普通變量不能保證。
有序性:總結成一句話:在本線程內觀察,所有的操作都是有序的。如果在一個線程內觀察另一個線程,所有的操作都是無序的。
先行發生原則

  • 程序次序規則:一個線程內,按照代碼順序,書寫在前面的操作先行發生於書寫在後面的操作。
    我的理解就是一段程序代碼的執行在單個線程中看起來是有序的。雖然這條規則中提到“書寫在前面的操作先行發生於書寫在後面的操作”,這個應該是程序看起來執行的順序是按照代碼順序執行的,因爲虛擬機可能會對程序代碼進行指令重排序。雖然進行重排序,但是最終執行的結果是與程序順序執行的結果一致的,它只會對不存在數據依賴性的指令進行重排序。因此,在單個線程中,程序執行看起來是有序執行的,這一點要注意理解。事實上,這個規則是用來保證程序在單線程中執行結果的正確性,但無法保證程序在多線程中執行的正確性。
  • 鎖定規則:一個unLock操作先行發生於後面對同一個鎖額lock操作。
    這個也比較容易理解,也就是說無論在單線程中還是多線程中,同一個鎖如果被鎖定的狀態,那麼必須先對鎖進行了釋放操作,後面才能繼續進行lock操作。
  • volatile變量規則:對一個變量的寫操作先行發生於後面對這個變量的讀操作。
    如果一個線程先去寫一個變量,然後一個線程去進行讀取,那麼寫入操作肯定會先行發生於讀操作
  • 傳遞規則:如果操作A先行發生於操作B,而操作B又先行發生於操作C,則可以得出操作A先行發生於操作C。
  • 線程啓動規則:Thread對象的start()方法先行發生於此線程的每個一個動作。
  • 線程中斷規則:對線程interrupt()方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生。
  • 線程終結規則:線程中所有的操作都先行發生於線程的終止檢測,我們可以通過Thread.join()方法結束、Thread.isAlive()的返回值手段檢測到線程已經終止執行。
  • 對象終結規則:一個對象的初始化完成先行發生於他的finalize()方法的開始。
java與線程

併發並不一定依賴於多線程。但在java裏談併發,大多數與線程脫不開關係。
線程的實現
我們知道線程是比進程更輕量級的調度執行單位。線程的引入,可以把一個進程的資源分配和執行調度分開。各個線程既可以共享進程的資源。又可以獨立調度。(線程的cpu調度的基本單位)
java語言中,每個已經執行start()且還未結束的java.lang.Thread類的實例就代表一個線程。Thread的所有關鍵方法都是Native的。一個Native方法往往意味着這個方法沒有使用或無法使用平臺無關的手段來實現。

java線程調度

線程調度值系統爲線程分配處理器使用權的過程。主要調度方式有兩種:

  • 協同式線程調度
  • 搶佔式線程調度
    協同式線程調度是線程的執行時間是線程本身來控制。線程把自己的工作執行完了以後主動通知系統切換到另外一個線程上。系統是多線程的最大好處是實現簡單。而且由於線程要把自己的事情做完後才進行線程切換。所以切換操作對線程自己是可知的,所以線程同步也沒什麼問題。他的壞處也很明顯。就是一個線程的執行時間不可控。甚至一個線程的編寫有問題,一直執行不完,那麼程序會一直阻塞在那裏。
    搶佔式線程調度是每個線程由系統分配執行時間。線程的切換不由線程本身決定。(thread.yield()可以讓出執行時間。但是獲取執行時間的話,線程本身沒啥辦法)
    在這種調度方式下,線程的執行時間是系統可控的。也不會因爲一個線程導致阻塞。java使用的線程調度方式就是搶佔式線程調度。
    雖然java線程的調度是系統自動完成的,但是我們還是可以“建議”系統給某個線程多或者少分配一點執行時間。這個就是設置線程的優先級。
    狀態轉換
    java語言定義了五種線程狀態(我查閱了一些資料,還有說六種,七種的。然後這個書裏也是6點。但是非要說是5種線程狀態.估計是本書作者把兩個等待算成一種了?)。在任意一個時間點,一個線程有且只能有一種狀態。、
  • 新建(NEW),也叫初始狀態
    實現Runnable接口和繼承Thread可以得到一個線程類,new一個實例出來,線程就進入了初始狀態。創建後尚未啓動的線程就是這種狀態
  • 運行(RUNNABLE):Java線程中將就緒(ready)和運行中(running)兩種狀態籠統的稱爲“運行”。
    線程對象創建後,其他線程(比如main線程)調用了該對象的start()方法。該狀態的線程位於可運行線程池中,等待被線程調度選中,獲取CPU的使用權,此時處於就緒狀態(ready)。就緒狀態的線程在獲得CPU時間片後變爲運行中狀態(running)。也就是處於此狀態的線程可能正在執行,也可能正在等待cpu給他分配時間。
  • 無限期等待(WAITING):不會被cpu分配時間。進入該狀態的線程必需要等待其他線程做出一些特定動作喚醒。下面的方法會讓線程進入無限期等待。
    1. 沒有設置Timeout參數的Object.wait().
    2. 沒有設置Timeout參數的Thread.join().
    3. LockSupport.park().
  • 限期等待(TIMED_WAITING):該狀態不同於WAITING,它可以在指定的時間後自行返回。以下方法會進入限期等待
    1. Thread.sleep()
    2. 設置了Timeout參數的Object.wait().
    3. 設置了Timeout參數的Thread.join().
    4. LockSupport.parkNanos().
    5. LockSupport.parkUntil().
  • 阻塞(BLOCKED):阻塞狀態是線程阻塞在進入synchronized關鍵字修飾的方法或代碼塊(獲取鎖)時的狀態。
  • 終止(TERMINATED):表示該線程已經執行完畢。

上述五種狀態的轉換關係:


本章小結

主要是瞭解了虛擬機java內訓模型的結構。講解了原子性,可見性,有序性。又介紹了先行發生原則的規則。另外還了解了線程在java語言中是如何實現的。

好了,全文手打不易,如果稍微幫到你了,請點個喜歡點個關注支持一下呦~~~~~~~祝大家工作順順利利。

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