JMM記憶體模型 定義 java記憶體模型(即 java Memory Model,簡稱JMM),不存在的東西,是一個概念,約定 主要分成兩部分來看,一部分叫做主記憶體,另一部分叫做工作記憶體。 java當中的共用變數;都放在主記憶體當中,如類的成員變數(實例變數),還有靜態的成員變數(類變數),都是存儲在主 ...
JMM記憶體模型
定義
java記憶體模型(即 java Memory Model,簡稱JMM),不存在的東西,是一個概念,約定
主要分成兩部分來看,一部分叫做主記憶體,另一部分叫做工作記憶體。
-
java當中的共用變數;都放在主記憶體當中,如類的成員變數(實例變數),還有靜態的成員變數(類變數),都是存儲在主記憶體中的。每一個線程都可以訪問主記憶體;
-
每一個線程都有其自己的工作記憶體,當線程要執行代碼的時候,就必須在工作記憶體中完成。比如線程操作共用變數,它是不能直接在主記憶體中操作共用變數的,只能夠將共用變數先複製一份,放到線程自己的工作記憶體當中,線程在其工作記憶體對該複製過來的共用變數處理完後,再將結果同步回主記憶體中去。
主記憶體是 所有線程都共用的,都能訪問的。所有的共用變數都存儲於主記憶體;共用變數主要包括類當中的成員變數,以及一些靜態變數等。局部變數是不會出現在主記憶體當中的,因為局部變數只能線程自己使用;工作記憶體每一個線程都有自己的工作記憶體,工作記憶體只存儲 該線程對共用變數的副本。線程對變數的所有讀寫操作都必須在工作記憶體中完成,而不能直接讀寫主記憶體中的變數,不同線程之間也不能直接訪問 對方工作記憶體中的 變數;線程對共用變數的操作都是對其副本進行操作,操作完成之後再同步回主記憶體當中去;
JMM的同步約定:
-
線程解鎖前,必須把共用變數立刻刷回主存
-
線程加鎖前,必須讀取主存中的最新值到工作記憶體中
-
加鎖和解鎖是同一把鎖
也就是說,JMM是一種抽象的結構,它提供了合理的禁用緩存和禁止重排序的方案來解決可見性、有序性的問題
作用:主要目的就是在多線程對共用變數進行讀寫時,來保證共用變數的可見性、有序性、原子性;在編程當中是通過兩個關鍵字 synchronized 和 volatile 來保證共用變數的三個特性的。
主記憶體與工作記憶體交互
一個變數如何從主記憶體拷貝到工作記憶體、如何從工作記憶體同步回主記憶體的呢?
Java記憶體模型中定義了上圖中的 8 種操作(橙色箭頭)來完成,虛擬機實現時必須保證每一種操作都是原子的、不可再分的。
舉個例子:假設現線上程1想要來訪問主記憶體當中的共用變數 x ,即當前主記憶體中的共用變數x的取值為 boolean x = true;
- 線程1首先會做一個原子操作叫做Read,讀取主記憶體當中的共用變數x的取值,即 boolean x = true;
- 接下來就是 Load 操作,把在主記憶體中讀取到的共用變數載入到了工作記憶體當中(副本);
- 接著執行 Use 操作,如果線程1需要對共用變數x進行操作,即會取到從主記憶體中載入過來的共用變數x的取值去進行一些操作;
- 操作之後會有一個新的結果返回,假設令這個共用變數的取值變為false,完成 Assign 操作,即給共用變數x賦新值;
- 操作完成之後;就需要同步回主記憶體,首先會完成一個 Store 的原子操作,來保存這個處理結果;
- 接著執行Write操作,即在工作記憶體中,Assign 賦值給共用變數的值同步到主記憶體當中,主記憶體中共用變數取值x由true更改為false。
- 另外還有兩個與鎖相關的操作,Lock與unlock,比如說加了synchronized,才會產生有lock與unlock操作;如果對共用變數的操作沒有加鎖,那麼也就不會有lock與unlock操作。
註意:如果對共用變數執行 lock 操作,該線程就會去主記憶體中獲取到共用變數的最新值,刷新工作記憶體中的舊值,保證可見性;(加鎖說明要對這個共用變數進行寫操作了,先刷新舊值,再操作新值)對共用變數執行 unlock 操作,必須先把此變數同步回主記憶體中,再執行 unlock;(因為對共用變數釋放鎖,接下來其他線程就能訪問到這個共用變數,就必須使這個共用變數呈現的是最新值)這兩點就是 synchronized為什麼能保證“可見性”的原因。
規則:
- 不允許read、load、store、write操作之一單獨出現,也就是read操作後必須load,store操作後必須write。
- 不允許線程丟棄他最近的assign操作,即工作記憶體中的變數數據改變了之後,必須告知主存。
- 不允許線程將沒有assign的數據從工作記憶體同步到主記憶體。
- 一個新的變數必須在主記憶體中誕生,不允許工作記憶體直接使用一個未被初始化的變數。就是對變數實施use、store操作之前,必須經過load和assign操作。
- 一個變數同一時間只能有一個線程對其進行lock操作。多次lock之後,必須執行相同次數unlock才可以解鎖。
- 如果對一個變數進行lock操作,會清空所有工作記憶體中此變數的值。在執行引擎使用這個變數前,必須重新load或assign操作初始化變數的值。
- 如果一個變數沒有被lock,就不能對其進行unlock操作。也不能unlock一個被其他線程鎖住的變數。
- 一個線程對一個變數進行unlock操作之前,必須先把此變數同步回主記憶體。
總結
主記憶體 與 工作記憶體 之間的 數據交互過程(即主記憶體與工作記憶體的交互是通過這8個原子操作來保證數據的正確性的):lock → read → load → use → assign → store → write → unlock
併發編程中的三個問題
線程不安全示例
// 案例演示:5個線程各執行1000次i++操作:
public class Test01Atomicity {
private static int number = 0;
public static void main(String[] args) throws InterruptedException {
// 5個線程都執行1000次 i++
Runnable increment = () -> {
for (int i = 0; i < 1000; i++) {
number++;
}
}; // 5個線程
ArrayList<Thread> ts = new ArrayList<>();
for (int i = 0; i < 5; i++) {
Thread t = new Thread(increment);
t.start();
ts.add(t);
}
for (Thread t : ts) {
t.join();
}
/* 最終的效果即,加出來的效果不是5000,可能會少於5000
那麼原因就在於 i++ 並不是一個原子操作
下麵會通過java反彙編的方式來進行演示和分析,這個 i++ 其實有4條指令 */
System.out.println("number = " + number);
}
}
可見性:CPU緩存引起
可見性:是指當一個線程對共用變數進行了修改,那麼另外的線程可以立即看到修改後的最新值。
//線程1執行的代碼
int i = 0;
i = 10;
//線程2執行的代碼
j = i;
假設執行線程1的是CPU1,執行線程2的是CPU2。由上面的分析可知,當線程1執行 i =10這句時,會先把i的初始值載入到CPU1的高速緩存中,然後賦值為10,那麼在CPU1的高速緩存當中i的值變為10了,卻沒有立即寫入到主存當中。
此時線程2執行 j = i,它會先去主存讀取i的值並載入到CPU2的緩存當中,註意此時記憶體當中i的值還是0,那麼就會使得j的值為0,而不是10.
解決可見性:
-
在共用變數前面加上volatile關鍵字修飾;volatile 的底層實現原理是記憶體屏障(Memory Barrier),保證了對 volatile 變數的寫指令後會加入寫屏障,對 volatile 變數的讀指令前會加入讀屏障。
-
寫屏障(sfence)保證在寫屏障之前的,對共用變數的改動,都同步到主存當中;
-
讀屏障(lfence)保證在讀屏障之後,對共用變數的讀取,載入的是主存中最新數據;
-
-
,通過synchronized和Lock也能夠保證可見性,synchronized和Lock能保證同一時刻只有一個線程獲取鎖然後執行同步代碼,並且在釋放鎖之前會將對變數的修改刷新到主存當中。這是因為synchronized 同步時會對應 JMM 中的 lock 原子操作,lock 操作會刷新工作記憶體中的變數的值,得到共用記憶體(主記憶體)中最新的值,從而保證可見性。
- synchronized 同步的時候會對應8個原子操作當中的 lock 與 unlock 這兩個原子操作,lock操作執行時該線程就會去主記憶體中獲取到共用變數最新值,刷新工作記憶體中的舊值,從而保證可見性。
原子性: 分時復用引起
原子性(Atomicity): 在一次或多次操作中,要麼所有的操作都執行,並且不會受其他因素干擾而中斷,要麼所有的操作都不執行;
int i = 1;
// 線程1執行
i += 1;
// 線程2執行
i += 1;
這裡需要註意的是:i += 1需要三條 CPU 指令
- 將變數 i 從記憶體讀取到 CPU寄存器;
- 在CPU寄存器中執行 i + 1 操作;
- 將最後的結果i寫入記憶體(緩存機制導致可能寫入的是 CPU 緩存而不是記憶體)。
由於CPU分時復用(線程切換)的存在,線程1執行了第一條指令後,就切換到線程2執行,假如線程2執行了這三條指令後,再切換會線程1執行後續兩條指令,將造成最後寫到記憶體中的i值是2而不是3。
x = 10; //語句1: 直接將數值10賦值給x,也就是說線程執行這個語句的會直接將數值10寫入到工作記憶體中
y = x; //語句2: 包含2個操作,它先要去讀取x的值,再將x的值寫入工作記憶體,雖然讀取x的值以及 將x的值寫入工作記憶體 這2個操作都是原子性操作,但是合起來就不是原子性操作了。
x++; //語句3: x++包括3個操作:讀取x的值,進行加1操作,寫入新的值。
x = x + 1; //語句4: 同語句3
上面4個語句只有語句1的操作具備原子性。也就是說,只有簡單的讀取、賦值(而且必須是將數字賦值給某個變數,變數之間的相互賦值不是原子操作)才是原子操作。
解決原子性:
Java記憶體模型只保證了基本讀取和賦值是原子性操作,如果要實現更大範圍操作的原子性,可以通過synchronized和Lock來實現。由於synchronized和Lock能夠保證任一時刻只有一個線程執行該代碼塊,那麼自然就不存在原子性問題了,從而保證了原子性。
有序性: 重排序引起
有序性(Ordering):是指程式代碼在執行過程中的先後順序,由於java在編譯器以及運行期的優化,導致了代碼的執行順序未必就是開發者編寫代碼的順序。
int i = 0;
boolean flag = false;
i = 1; //語句1
flag = true; //語句2
為什麼要重排序?一般會認為編寫代碼的順序就是代碼最終的執行順序,那麼實際上並不一定是這樣的,為了提高程式的執行效率,java在編譯時和運行時會對代碼進行優化(JIT即時編譯器),會導致程式最終的執行順序不一定就是編寫代碼時的順序。重排序 是指 編譯器 和 處理器 為了優化程式性能 而對 指令序列 進行 重新排序 的一種手段;
從 java 源代碼到最終實際執行的指令序列,會分別經歷下麵三種重排序:
- 編譯器優化的重排序。編譯器在不改變單線程程式語義的前提下,可以重新安排語句的執行順序。
- 指令級並行的重排序。現代處理器採用了指令級並行技術(Instruction-Level Parallelism, ILP)來將多條指令重疊執行。如果不存在數據依賴性,處理器可以改變語句對應機器指令的執行順序。
- 記憶體系統的重排序。由於處理器使用緩存和讀 / 寫緩衝區,這使得載入和存儲操作看上去可能是在亂序執行。
上述的 1 屬於編譯器重排序,2 和 3 屬於處理器重排序。這些重排序都可能會導致多線程程式出現記憶體可見性問題。對於編譯器,JMM 的編譯器重排序規則會禁止特定類型的編譯器重排序(不是所有的編譯器重排序都要禁止)。對於處理器重排序,JMM 的處理器重排序規則會要求 java 編譯器在生成指令序列時,插入特定類型的記憶體屏障(memory barriers,intel 稱之為 memory fence)指令,通過記憶體屏障指令來禁止特定類型的處理器重排序(不是所有的處理器重排序都要禁止)。
解決有序性:
-
可以使用 synchronized 同步代碼塊來保證有序性;加了synchronized,依然會發生指令重排序(可以看看DCL單例模式),只不過,由於存在同步代碼塊,可以保證只有一個線程執行同步代碼塊當中的代碼,也就能保證有序性。
-
給共用變數加volatile關鍵字來解決有序性問題。
-
寫屏障會確保指令重排序時,不會將寫屏障之前的代碼排在寫屏障之後;
-
讀屏障會確保指令重排序時,不會將讀屏障之後的代碼排在讀屏障之前;
-
Happens-Before 規則
Happens-Before是一種可見性規則,它表達的含義是前面一個操作的結果對後續操作是可見的。解釋為 “先行發生於...”
A happens-before B,也就意味著A的執行結果對B是可見的
單一線程(程式順序)原則
Single Thread rule:在一個線程內,在程式前面的操作先行發生於後面的操作。
as-id-serio 語義
管程鎖定(監視器鎖)規則
Monitor Lock Rule :對一個鎖的解鎖 Happens-Before 於後續對這個鎖 的加鎖
volatile 變數規則
Volatile Variable Rule:對一個volatile域的寫,happens-before於任意後續對這個volatile域的讀
線程啟動start規則
Thread Start Rule:Thread 對象的 start() 方法調用先行發生於此線程的每一個動作。
如果線程A執行操作ThreadB.start()(啟動線程B),那麼A線程的ThreadB.start()操作happens-before於線程B中的任意操作
線程加入join規則
Thread Join Rule:Thread 對象的結束先行發生於 join() 方法返回。
如果線程A執行操作ThreadB.join()併成功返回,那麼線程B中的任意操作happens-before於線程A從ThreadB.join()操作成功返回
線程中斷規則
Thread Interruption Rule:對線程 interrupt() 方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生,可以通過 interrupted() 方法檢測到是否有中斷發生。
對象終結規則
Finalizer Rule:一個對象的初始化完成(構造函數執行結束)先行發生於它的 finalize() 方法的開始。
傳遞性
Transitivity:如果操作 A 先行發生於操作 B,操作 B 先行發生於操作 C,那麼操作 A 先行發生於操作 C。
安全發佈對象
發佈與逃逸
發佈的意思是使一個對象能夠被當前範圍之外的代碼所使用
public static Hashset<Person> persons;
public void init(){
persons = new HashSet<Person>;
}
不安全發佈:私有數組,但外部範圍也能使用,導致不安全發佈
private string[] states = {"a","b","c","d"};
//發佈出去一個
public string[] getstates(){
return states;
}
public static void main(string[] args){
App unSafePub = new App();
System.out.printIn("Init array is:" + Arrays.tostring(unsafePub.getstates()));
unsafePub.getstates()[0] = "Seven!";
System.out.printin("After modify.the array is: " + Arrays.tostring(unsafePub.getstates()));
}
對象溢出:
一種錯誤的發佈,當一個對象還沒有構造完成時,就使它被其他線程所見
public cass FinalReferenceEscapeExample {
final int i;
static FinalReferenceEscapeExample obj;
public FinalReferenceEscapeExample() {
i = 1; //1.寫fina]域
obj = this; //2.this 引用"逃逸"
}
public static void writer() {
new FinalReferenceEscapeExample();
}
public static void reader() {
if(obj != null){ //3.
int temp = obj.i; //4.
}
}
}
逃逸帶來的問題
安全發佈對象的四種方法
-
在靜態初始化函數中初始化一個對象引用
-
將對象的引用保存到volatile類型的域或者AtomicReference對象中(利用volatile happen-before規則)
-
將對象的引用保存到某個正確構造對象的final類型域中(初始化安全性)
-
將對象的引用保存到一個由鎖保護的域中(讀寫都上鎖)
線程安全的實現方法
互斥同步
synchronized 和 ReentrantLock。
非阻塞同步
互斥同步最主要的問題就是線程阻塞和喚醒所帶來的性能問題,因此這種同步也稱為阻塞同步。
互斥同步屬於一種悲觀的併發策略,總是認為只要不去做正確的同步措施,那就肯定會出現問題。無論共用數據是否真的會出現競爭,它都要進行加鎖(這裡討論的是概念模型,實際上虛擬機會優化掉很大一部分不必要的加鎖)、用戶態核心態轉換、維護鎖計數器和檢查是否有被阻塞的線程需要喚醒等操作。
CAS
隨著硬體指令集的發展,我們可以使用基於衝突檢測的樂觀併發策略: 先進行操作,如果沒有其它線程爭用共用數據,那操作就成功了,否則採取補償措施(不斷地重試,直到成功為止)。這種樂觀的併發策略的許多實現都不需要將線程阻塞,因此這種同步操作稱為非阻塞同步。
樂觀鎖需要操作和衝突檢測這兩個步驟具備原子性,這裡就不能再使用互斥同步來保證了,只能靠硬體來完成。硬體支持的原子性操作最典型的是: 比較並交換(Compare-and-Swap,CAS)。CAS 指令需要有 3 個操作數,分別是記憶體地址 V、舊的預期值 A 和新值 B。當執行操作時,只有當 V 的值等於 A,才將 V 的值更新為 B。
AtomicInteger
J.U.C 包裡面的整數原子類 AtomicInteger,其中的 compareAndSet() 和 getAndIncrement() 等方法都使用了 Unsafe 類的 CAS 操作。
以下代碼使用了 AtomicInteger 執行了自增的操作。
private AtomicInteger cnt = new AtomicInteger();
public void add() {
cnt.incrementAndGet();
}
以下代碼是 incrementAndGet() 的源碼,它調用了 unsafe 的 getAndAddInt() 。
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
以下代碼是 getAndAddInt() 源碼,var1 指示對象記憶體地址,var2 指示該欄位相對對象記憶體地址的偏移,var4 指示操作需要加的數值,這裡為 1。通過 getIntVolatile(var1, var2) 得到舊的預期值,通過調用 compareAndSwapInt() 來進行 CAS 比較,如果該欄位記憶體地址中的值等於 var5,那麼就更新記憶體地址為 var1+var2 的變數為 var5+var4。
可以看到 getAndAddInt() 在一個迴圈中進行,發生衝突的做法是不斷的進行重試。
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
ABA
如果一個變數初次讀取的時候是 A 值,它的值被改成了 B,後來又被改回為 A,那 CAS 操作就會誤認為它從來沒有被改變過。
J.U.C 包提供了一個帶有標記的原子引用類 AtomicStampedReference 來解決這個問題,它可以通過控制變數值的版本來保證 CAS 的正確性。大部分情況下 ABA 問題不會影響程式併發的正確性,如果需要解決 ABA 問題,改用傳統的互斥同步可能會比原子類更高效。
無同步方案
要保證線程安全,並不是一定就要進行同步。如果一個方法本來就不涉及共用數據,那它自然就無須任何同步措施去保證正確性。
棧封閉
多個線程訪問同一個方法的局部變數時,不會出現線程安全問題,因為局部變數存儲在虛擬機棧中,屬於線程私有的。
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class StackClosedExample {
public void add100() {
int cnt = 0;
for (int i = 0; i < 100; i++) {
cnt++;
}
System.out.println(cnt);
}
}
線程本地存儲(Thread Local Storage)
如果一段代碼中所需要的數據必須與其他代碼共用,那就看看這些共用數據的代碼是否能保證在同一個線程中執行。如果能保證,就可以把共用數據的可見範圍限制在同一個線程之內,這樣,無須同步也能保證線程之間不出現數據爭用的問題。
符合這種特點的應用並不少見,大部分使用消費隊列的架構模式(如“生產者-消費者”模式)都會將產品的消費過程儘量在一個線程中消費完。其中最重要的一個應用實例就是經典 Web 交互模型中的“一個請求對應一個伺服器線程”(Thread-per-Request)的處理方式,這種處理方式的廣泛應用使得很多 Web 服務端應用都可以使用線程本地存儲來解決線程安全問題。
可以使用 java.lang.ThreadLocal 類來實現線程本地存儲功能。
對於以下代碼,thread1 中設置 threadLocal 為 1,而 thread2 設置 threadLocal 為 2。過了一段時間之後,thread1 讀取 threadLocal 依然是 1,不受 thread2 的影響。
public class ThreadLocalExample {
public static void main(String[] args) {
ThreadLocal threadLocal = new ThreadLocal();
Thread thread1 = new Thread(() -> {
threadLocal.set(1);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(threadLocal.get());
threadLocal.remove();
});
Thread thread2 = new Thread(() -> {
threadLocal.set(2);
threadLocal.remove();
});
thread1.start();
thread2.start();
}
}
ThreadLocal 從理論上講並不是用來解決多線程併發問題的,因為根本不存在多線程競爭。
在一些場景 (尤其是使用線程池) 下,由於 ThreadLocal.ThreadLocalMap 的底層數據結構導致 ThreadLocal 有記憶體泄漏的情況,應該儘可能在每次使用 ThreadLocal 後手動調用 remove(),以避免出現 ThreadLocal 經典的記憶體泄漏甚至是造成自身業務混亂的風險。
可重入代碼(Reentrant Code)
這種代碼也叫做純代碼(Pure Code),可以在代碼執行的任何時刻中斷它,轉而去執行另外一段代碼(包括遞歸調用它本身),而在控制權返回後,原來的程式不會出現任何錯誤。
可重入代碼有一些共同的特征,例如不依賴存儲在堆上的數據和公用的系統資源、用到的狀態量都由參數中傳入、不調用非可重入的方法等。
關於作者
來自一線程式員Seven的探索與實踐,持續學習迭代中~
本文已收錄於我的個人博客:https://www.seven97.top
公眾號:seven97,歡迎關註~
本文來自線上網站:seven的菜鳥成長之路,作者:seven,轉載請註明原文鏈接:www.seven97.top