文章目錄
- 內存可見性
- 內存可見性問題代碼演示
- JMM(Java Memory Model)
- 指令重排序
- 指令重排序問題代碼演示
- 指令重排序分析
- volatile關鍵字
- volatile 保證內存可見性 & 禁止指令重排序
- volatile 不保證原子性
在上一節介紹線程安全問題的過程中,提到了產生線程安全的原因主要有
- 操作系統的線程隨機調度策略
- 對共享數據的寫操作
- 操作不具有原子性
- 內存可見性問題
- 指令重排序問題
這五點原因中線程的隨機調度是由操作系統調度模塊具體實現,無法干預,而多個線程對共享數據的寫操作,在某些情況下可以通過調整代碼結構進行避免。操作的原子性,可以通過加鎖來解決,這小節我們主要來看內存可見性和指令重排序是怎樣影響到線程安全的.
由于讀取內存,相比較讀取寄存器是一個非常慢的操作,編編譯器為了進一步提高代碼執行的效率,會在保持邏輯不變的前提下,調整生產的代碼內容,這樣的操作在單線程環境中不會有什么問題,但是,在多線程環境下,編譯器就可能會誤判,內存可見性和指令重排序都是有編譯器優化產生的問題
內存可見性
內存可見性問題代碼演示
我們先來觀察這段代碼:
import java.util.Scanner;public class Test6 {public static int isQuit = 0;// 內存可見性問題public static void main(String[] args) {Thread t1 = new Thread(() -> {while (isQuit == 0) {// 什么也不執行}System.out.println("t1 線程執行完畢");});Thread t2 = new Thread(() -> {System.out.println("請輸入isQuit:");Scanner scanner = new Scanner(System.in);isQuit = scanner.nextInt();System.out.println("t2線程執行完畢");});// 啟動線程t1.start();t2.start();}
}
執行結果~~
請輸入isQuit:
1
t2線程執行完畢
可以看到這里,輸入1之后線程并沒有執行完畢,那么不應該啊,isQuit的值不為0,t1線程應該會退出循環,可是并沒有。我們看一張圖。
在這個過程中,我么看看兩個線程都做了什么。t1 線程在一直在讀取主內存中isQuit的值,由于循環體沒有執行任何邏輯,所以這個速度非常之快。t2線程先將isQuit讀入工作內存,然后修改值為1后寫回主內存。
如果就這樣看,那么在isQuit的值被修改后t1線程也應該隨之終止。但事實上Java在運行時,編譯器發現在大量讀取isQuit的值后,發現isQuit的值并沒有改變。于是就做出來一種激進的優化(讀取內存要比讀取寄存器慢得多),不再讀取內存,直接從寄存器中取值,這就導致了后續t2線程在我們輸入值后,isQuit的值的確是改變了,但是t1線程并沒有取讀取內存中的isQuit,這就導致了t1線程對isQuit的內存不可見
在單線程中,編譯器這樣的優化一般是沒有問題的,但是在并發場景下,就不得不考慮這樣優化后對代碼的影響。于是Java提供了volatile關鍵字,被這個關鍵字修飾后,編譯器將不會進行優化。
JMM(Java Memory Model)
我們先了解一下JMM, Java虛擬機(JVM)規范文檔中定義了Java內存模型.。目的是屏蔽掉各種硬件和操作系統的內存訪問差異(跨平臺),以實現讓Java程序在各種平臺下都能達到一致的并發效果。
- 線程之間的共享變量存在 主內存 (Main Memory) - 相當于內存
- 每一個線程都有自己的 “工作內存” (Work Memory) - 相當于寄存器
- 當線程要讀取一個共享變量的時候, 會先把變量從主內存拷貝到工作內存, 再從工作內存讀取數據.
- 當線程要修改一個共享變量的時候, 也會先修改工作內存中的副本, 再同步回主內存.
指令重排序
???? 和內存可見性一樣,指令重排序也是在一定條件下觸發的編譯器的”優化“,目的是提高代碼效率,編譯器在“保持邏輯不發生變化的情況下”,針對指令執行的順序進行調整,這就是指令重排序。
指令重排序問題代碼演示
class SingletonLazy {private static volatile SingletonLazy instance = null;public static SingletonLazy getInstance() {if (instance == null) {synchronized (SingletonLazy.class) {if (instance == null) {instance = new SingletonLazy();}}}return instance;}private SingletonLazy() { }
}public class Demo22 {public static void main(String[] args) {}
}
在這個單例模式(懶漢模式)中,如果是第一次創建實例,那么會涉及到一個new操作。我們簡單的將new操作理解為三步:
- 申請內存空間
- 在內存空間上構造對象
- 把內存地址,復制給instance引用
指令重排序分析
在單線程下,先執行指令2,還是先執行指令3都可以,不影響最終的結果,但是在多線程下,就可能會出現問題。假設編譯器將new操作的執行順序優化為了 1 -> 3 -> 2,t1線程進入,創建單例,但是還沒構造對象,就已經將空引用返回(鎖已經釋放),這是如果t2線程進入,instance還是為空此時就可能會創建出多個實例。
解決方案和內存可見性一樣,使用volatile關鍵字,讓編譯器不要進行優化。
volatile關鍵字
volatile 保證內存可見性 & 禁止指令重排序
代碼在寫入 volatile 修飾的變量時
- 改變線程工作內存中volatile變量副本的值
- 將改變后的副本的值從工作內存刷新到主內存
代碼在讀取 volatile 修飾的變量時
- 從主內存中讀取volatile變量的最新值到線程的工作內存中
- 從工作內存中讀取volatile變量的副本
????讀取內存相較于讀取寄存器來說,非常慢,使用volatile修飾雖然強制讀寫內存,但是保證了代碼的正確性,一般來說,不會犧牲正確新來換取效率。
volatile 不保證原子性
volatile 和 synchronized 有著本質的區別. synchronized 能夠保證原子性,volatile保證的是內存可見性,禁止指令重排序。volatile只是強制cpu讀取內存,但是不會保證操作的原子性(不可分割)。
不管是原子性、內存可見性還是指令重排序,都可能產生線程安全問題,我們在進行并發編程時一定要謹慎!!