天天看點

java多線程解說【貳】_java記憶體模型

上文:java多線程解說【壹】_什麼是線程

上篇文章說到,在多線程下如果我們要保證原子性、有序性和可見性,那麼我們就要采取一些措施來實作。首先就有一個問題,為什麼在多線程下和單線程下的情況不同呢,因為這涉及到線程間通信。線程間通信的方式無非兩種,一種是共享記憶體,一種是消息傳遞。可能都知道java是通過第一種方式實作的線程通信,那麼具體是如何實作的呢,這就要從java記憶體模型說起。

java記憶體模型

這裡要先簡單說一下java虛拟機的知識。在java虛拟機中,主要有三塊區域用于存放變量:堆(heap)用于存放引用類型的對象;棧(Stack)用于存放基本類型的對象及局部變量;永久區(Perm Gen)用于存放靜态變量和final修飾的常量。其中,棧是線程私有的,而堆是公有的。每個線程都可以有自己的私有變量,儲存在自己的線程棧中,如果變量是引用類型,則棧上儲存的是引用位址,指向這個引用變量儲存在堆上的真實位址。

java多線程解說【貳】_java記憶體模型

那麼當線程間需要通信的時候,則分兩種情況:如果需要傳遞的對象是基本類型的,由于基本類型的對象儲存在私有的線程棧上,隻能線程自己通路,是以傳遞的是該變量的拷貝副本;而當傳遞的對象是引用類型的時候,該對象儲存在公有的堆上,是以隻需傳遞該變量的引用位址即可。這裡需要注意的一點是,如果一個引用類型變量的成員變量是基本類型,那麼它依然會随該引用類型變量儲存在堆上存儲。

但是多個線程都拿到了對象的拷貝或引用并不就萬事大吉了,因為有可能它拿到的并不是這個對象的最新狀态,這就要從計算機底層的設計說起。

計算機硬體架構

java多線程解說【貳】_java記憶體模型

如上圖所示,現代的計算機都是多核CPU,每個CPU中會維護一個CPU寄存器以提高計算速度。衆所衆知,CPU通路記憶體(主存)的速度是很慢的,是以CPU自身先內建了一級緩存和二級緩存以加快資源通路速度;而CPU内部寄存器的速度會比通路緩存更快一級,是以CPU會把正在或準備進行運算的資料儲存在CPU内部寄存器。

整體流程就是,當CPU需要通路主存時,會先讀取一部分主存資料到CPU緩存,進而在讀取CPU緩存到寄存器。當CPU需要寫資料到主存時,同樣會先flush寄存器到CPU緩存,然後再在某些節點把緩存資料flush到主存。

看了上面的圖示我們也了解到,計算機各級存儲并沒有堆和棧的概念,也就是說不論是什麼類型的對象,都是一樣存儲在各級存儲器中的。而且根據該對象的使用狀态,可能會儲存在各級存儲器中,如下圖所示:

java多線程解說【貳】_java記憶體模型

這就引發了多線程并發時可能出現的原子性、有序性和可見性問題。

線程安全

先說說原子性,其實就是指線程的任務是一個原子操作。那麼什麼是原子操作呢,原子操作就是不可分割的操作,其結果隻有兩種狀态:要麼全部成功,要麼全部失敗。在java語言中,除了long和double外,其他的基本類型的操作都是原子操作。引用類型的指派和引用操作也是原子的。但是需要注意的是,原子操作+原子操作!=原子操作。不用過多解釋,從原子操作的定義也可以輕松得知。

long和double類型的讀寫操作不是原子操作的原因是,目前的JVM(java虛拟機)都是将32位作為原子操作,并非64位。當線程把主存中的 long和double類型的值讀到線程記憶體中時,可能是兩次32位值的寫操作,這樣就不符合原子操作的定義。

再說說有序性,有序性就是在多線程情況下,執行指令的過程中沒有發生指令重排。指令重排說白了就是,源代碼順序和程式執行順序不一緻。如果在單線程下沒有問題,因為編譯器不會改變單線程程式語義;而在多線程下則有可能

出現如下三種情況:

1. 編譯器優化重排序,比如編譯器的優化;

2. 指令級并行的重排序,比如CPU指令執行的重排序;

3. 記憶體系統的重排序,比如緩存和讀寫緩沖區導緻的重排序;

可見性就是一個線程修改的狀态對另一個線程是可見的。如上文所說,由于現代CPU都有多級緩存,CPU的操作都是基于高速緩存的,而線程通信是基于記憶體的,這裡就可能有個時間差。比如線程A和線程B都同時操作一個對象,線程A對對象加載到工作記憶體修改後還沒來得及刷回主記憶體,線程B就去主存讀取了對象,此時線程B擷取的對象狀态并不是最新的,這就是可見性問題。

volatile和synchronized

先簡單粗暴一點。volatile可以解決有序性和可見性,synchronized可以解決原子性、有序性和可見性。

先說說volatile。很多書介紹說它是輕量級鎖,我感覺是不對的,因為它并沒有排他性。首先看看volatile的作用,它是可以保證線程去通路對象時,每次都會從主存中擷取而不是本地的副本。究其原理,它是借助于記憶體屏障(Memory Barrier)來實作的。記憶體屏障是一個CPU指令,它主要有兩個作用:

1.管什麼指令都不能和這條Memory Barrier指令重排序;

2.強制刷出CPU緩存,保證CPU緩存和主存的資料實時一緻;

在java語言中,如果一個變量是volatile修飾的,Java Memory Model(JMM)會在寫入這個字段之後插進一個Write-Barrier指令,并在讀這個字段之前插入一個Read-Barrier指令。這意味着操作一個volatile變量,就可以實作:

1. 寫變量後加寫屏障,保證CPU寫緩沖區的值強制重新整理回主記憶體;

2. 讀變量之前加讀屏障,使緩存失效,進而強制從主記憶體讀取變量最新值;

根據java記憶體模型的happen-before原則,對volatile字段的寫入操作先于讀操作,即使兩個線程同時修改和擷取volatile字段,get操作也能拿到最新的值。

再說說synchronized,也就是我們常說的代碼塊。synchronized可以保證同一個時刻隻能有一個線程進入臨界區(synchronized後面的大括号内),synchronized還能保證代碼塊中所有變量都将會從主存中讀,當線程退出代碼塊時,對所有變量的更新将會flush到主存,不管這些變量是不是volatile類型的。

其實synchronized就是java實作的一個隐式鎖,每個對象在底層都維護了一個螢幕鎖(monitor)。當monitor被占用時(進入數=1)就會處于鎖定狀态,進入monitor的線程即為該對象的持有者;此時如果有其他線程想進入monitor将阻塞,直到之前的線程退出monitor(進入數=0)時才能進入而成為該對象的持有者。

總結一下volatile和synchronized的差別,有如下幾點:

1.volatile不能保證原子性;而synchronized可以;

2. volatile僅能使用在變量級别,synchronized則可以使用在變量、方法、和類級别的;

3. volatile不會造成線程的阻塞,synchronized可能會造成線程的阻塞(其實就是上下文切換);

4. volatile的寫成本較synchronized臨界區低,但讀成本較高;

5. volatile标記的變量不會被編譯器優化(重排序);synchronized标記的變量可以被編譯器優化(重排序);

下篇文章:《java多線程解說【叁】_Thread的常用API實作》