前言
Java內存模式: 是一種虛擬機規范,規定了一個線程如何和何時可以看到由其他線程修改過后的共享變量的值,以及在必須時如何同步的訪問共享變量。
根據jvm內存模型我們知道,調用棧和本地變量存放在線程棧上,對象存放在堆上
如下圖(JVM內存模型)我們可以知道
- 調用棧和本地變量是線程“私有”的
- 堆是線程“共有”的
JVM內存模型
那么到底什么是線程公有,什么是線程私有(可參考:一文搞懂: JVM內存模型以及GC回收機制)
一:硬件內存架構
現代硬件內存模型與Java內存模型有一些不同,理解內存模型架構以及Java內存模型如何與它協同工作也是非常重要的。
現代計算機硬件架構的簡單圖示:
- CPU寄存器:每個CPU都包含一系列的寄存器,它們是CPU內內存的基礎。CPU在寄存器上執行操作的速度遠大于在主存上執行的速度。這是因為CPU訪問寄存器的速度遠大于主存。
- 高速緩存cache:由于計算機的存儲設備與處理器的運算速度之間有著幾個數量級的差距,所以現代計算機系統都不得不加入一層讀寫速度盡可能接近處理器運算速度的高速緩存(Cache)來作為內存與處理器之間的緩沖:將運算需要使用到的數據復制到緩存中,讓運算能快速進行,當運算結束后再從緩存同步回內存之中,這樣處理器就無須等待緩慢的內存讀寫了。CPU訪問緩存層的速度快于訪問主存的速度,但通常比訪問內部寄存器的速度還要慢一點。每個CPU可能有一個CPU緩存層,一些CPU還有多層緩存。在某一時刻,一個或者多個緩存行(cache lines)可能被讀到緩存,一個或者多個緩存行可能再被刷新回主存。
- 內存:一個計算機還包含一個主存。所有的CPU都可以訪問主存。主存通常比CPU中的緩存大得多。
- 運作原理:通常情況下,當一個CPU需要讀取主存時,它會將主存的部分讀到CPU緩存中。它甚至可能將緩存中的部分內容讀到它的內部寄存器中,然后在寄存器中執行操作。當CPU需要將結果寫回到主存中去時,它會將內部寄存器的值刷新到緩存中,然后在某個時間點將值刷新回主存。
以上說了這么多,總結來說就是:在一個線程當中,如果我想改變一個對象的值的時候(這個對象在堆內存中,也就是我們所說的主內存),它會把這個值讀取到高速緩存中(也可以理解為copy了這個值到緩存中),然后把這個值讀取到CPU內部寄存器中進行相對應的改變,然后再某一時刻,把改變之后的值放到高速緩存中再刷新到主內存中
二:多線程中的主內存對象修改
多線程中的主內存對象修改,就涉及到了多個線程同時修改一個主內存對象,那么線程間通信必須要經過主內存。如下,如果線程A與線程B之間要通信的話,必須要經歷下面2個步驟:
1) 線程A把本地內存A中更新過的共享變量刷新到主內存中去
2) 線程B到主內存中去讀取線程A之前已更新過的共享變量
三:Java內存模型解決的問題
3.1 可見性
我們來看以下這種多線程場景:
跑在左邊CPU的線程拷貝這個共享對象到它的CPU緩存中,然后將count變量的值修改為2。這個修改對跑在右邊CPU上的其它線程是不可見的,因為修改后的count的值還沒有被刷新回主存中去
場景一
為了達到多個線程看到同一個值達到我們預期的效果,我們需要滿足線程的“可見性”
可見性(共享對象可見性):線程對共享變量修改的可見性。當一個線程修改了共享變量的值,其他線程能夠立刻得知這個修改
線程緩存導致的可見性問題:
如果兩個或者更多的線程在沒有正確的使用volatile聲明或者同步的情況下共享一個對象,一個線程更新這個共享對象可能對其它線程來說是不可見的:共享對象被初始化在主存中。跑在CPU上的一個線程將這個共享對象讀到CPU緩存中,然后修改了這個對象。只要CPU緩存沒有被刷新會主存,對象修改后的版本對跑在其它CPU上的線程都是不可見的。這種方式可能導致每個線程擁有這個共享對象的私有拷貝,每個拷貝停留在不同的CPU緩存中。
解決這個內存可見性問題你可以使用:
Java中的volatile關鍵字:volatile關鍵字可以保證直接從主存中讀取一個變量,如果這個變量被修改后,總是會被寫回到主存中去。Java內存模型是通過在變量修改后將新值同步回主內存,在變量讀取前從主內存刷新變量值這種依賴主內存作為傳遞媒介的方式來實現可見性的,無論是普通變量還是volatile變量都是如此,普通變量與volatile變量的區別是:volatile的特殊規則保證了新值能立即同步到主內存,以及每個線程在每次使用volatile變量前都立即從主內存刷新。因此我們可以說volatile保證了多線程操作時變量的可見性,而普通變量則不能保證這一點。
Java中的synchronized關鍵字:同步快的可見性是由“如果對一個變量執行lock操作,將會清空工作內存中此變量的值,在執行引擎使用這個變量前需要重新執行load或assign操作初始化變量的值”、“對一個變量執行unlock操作之前,必須先把此變量同步回主內存中(執行store和write操作)”這兩條規則獲得的。
3.2 原子性
我們再來看以下這種多線程場景:
果線程A讀一個共享對象的變量count到它的CPU緩存中,線程B也做了同樣的事情,但是往一個不同的CPU緩存中。現在線程A將count加1,線程B也做了同樣的事情。現在count已經被增加了兩次,每個CPU緩存中一次。如果這些增加操作被順序的執行,變量count應該被增加兩次,然后原值+2被寫回到主存中去。然而,兩次增加都是在沒有適當的同步下并發執行的。無論是線程A還是線程B將count修改后的版本寫回到主存中取,修改后的值僅會被原值大1(就是如果做了同步,此時應該為3),盡管增加了兩次:
很明顯,此時有個臨界條件,就是兩個線程同時把主存中的數據讀取到CPU緩存中,那么為了解決這個問題,我們就要滿足多線程的原子性
原子性:持有同一個鎖的兩個同步塊只能串行地進入
如果應用場景需要一個更大范圍的原子性保證,需要使用同步塊技術。Java內存模型提供了lock和unlock操作來滿足這種需求。虛擬機提供了字節碼指令monitorenter和monitorexist來隱式地使用這兩個操作,這兩個字節碼指令反映到Java代碼中就是同步快——synchronized關鍵字。
總結來說:volatile保證了可見性、synchronized保證了原子性和可見性