詳解Java多執行緒之記憶體可見性
可見性:一個執行緒對共享變數值的修改,能夠及時的被其他執行緒看到。
共享變數:如果一個電量在多個執行緒的工作記憶體中都存在副本,那麼這個變數就是這幾個執行緒的共享變數。
JAVA記憶體模型(Java Memory Model)描述了Java程式中各種變數(執行緒共享變數)的訪問規則,以及在JVM中將變數儲存到記憶體和從記憶體中讀取出變數這樣的底層細節。
所有變數都儲存在主記憶體中。每個執行緒都有自己獨立的工作記憶體,裡面儲存該執行緒使用到的變數的副本(主記憶體跟變數的唯一一份拷貝)。
兩條規定
執行緒對共享變數的所有操作,都必須在自己的工作記憶體中進行,不能直接從主記憶體中讀寫;
不同執行緒之間無法訪問其他執行緒工作記憶體中的變數,執行緒間變數值的傳遞需要通過主記憶體來完成。
共享變數可見性實現的原理
執行緒1對工程變數的修改想要被執行緒2及時看到,必須經過以下2個步驟:
把工作記憶體1中更新過的共享變數重新整理的主體中;
將主記憶體中最新的共享變數的值更新到工作記憶體2中。
要實現共享變數的可見性,必須保證兩點:
執行緒修改後的共享變數值能夠及時從工作記憶體重新整理到主記憶體中;
其他執行緒能夠及時把共享變數的最新指從主記憶體更新到自己的工作記憶體中。
可見性的實現方式
Java語言層面支援的可見性實現方式(語言層面,不包括1.5後高階特性):
synchronized
volatile
synchronized實現可見性
synchronized能夠實現:
原子性(同步);
可見性。
都知道synchronized的原子性,很少知道它的可見性。
synchronized實現可見性
JVM關於synchronized的兩條規定:
執行緒解鎖前,必須把共享變數的最新值重新整理到主記憶體中;
執行緒加鎖時,將清空工作記憶體中共享變數的值,從而使用共享變數時需要從主記憶體中重新讀取最新的值(注意:加鎖解鎖需要是同一把鎖)
執行緒解鎖前對共享變數的修改在下次加鎖時對其他執行緒可見。
執行緒執行互斥程式碼的過程:
1、獲得互斥鎖;
2、清空工作記憶體
3、從主記憶體拷貝變數的最新副本到工作記憶體
4、執行程式碼
5、將更改後的共享變數的值重新整理到主記憶體
6、釋放互斥鎖。
重排序
程式碼書寫的順序與實施執行的順序不同,指令重排序是編譯器或處理器為了提高程式效能而做的優化。
1、編譯器優化的重排序(編譯器優化)
2、指令級並行重排序(處理器優化)
3、記憶體系統的重排序(處理器優化)
重排序有可能導致這樣的結果
程式碼順序
int number =1; int result = 0;
執行順序
int result = 0; int number = 1;
as-if-serial: 無論如何重排序,程式執行的結果應該與程式碼順序執行的結果一致(Java編譯器、執行時和處理器都會保證Java在單執行緒下遵循as-if-serial語句)。
int num1 = 1;
int num2 = 2;
int sum = num1+num2;
單執行緒:第1、2行的順序可以重排,但第3行不能。
重排序不會給單執行緒帶來記憶體可見性問題
多執行緒中程式互動執行時,重排序可能會造成記憶體可見性問題。
例項:
public class Syn01 { // 共享變數 private boolean ready = false; private int result = 0; private int number = 1; // 寫操作 public void write() { ready = true; // 1.1 number = 2; // 1.2 } // 讀操作 public void read() { if (ready) { // 2.1 result = number * 3; // 2.2 } System.out.println("result的值為:" + result); } private class ReadWriteThread extends Thread { // 根據構造方法中傳入的flag引數,確定執行緒執行讀操作還是寫操作 private boolean flag; public ReadWriteThread(boolean flag) { this.flag = flag; } @Override public void run() { if (flag) { // 構造方法中傳入true,執行寫操作 write(); } else { // 構造方法中傳入false,執行讀操作 read(); } } } public static void main(String[] args) { Syn01 synDemo = new Syn01(); // 啟動執行緒執行寫操作 synDemo.new ReadWriteThread(true).start(); // 啟動執行緒執行讀操作 synDemo.new ReadWriteThread(false).start(); } }
執行發現有時列印6有時列印0。
可見性分析:
列印6的情況下的可見性:
1、第一個執行緒(寫執行緒)執行完再執行第二個執行緒(讀執行緒)。
2、1.1 - 2.1 - 1.2 - 2.2
3、1.2 - 1.1 - 再執行第二個執行緒
等等。
得到6的情況,說明共享變數的值的修改得到的及時更新。但是這時沒有加synchronized關鍵字,為什麼可以讓執行緒一改變後的工作記憶體的值重新整理到主記憶體呢,這是因為編譯器做了優化,可能執行很多次才出現一次記憶體更新不及時的情況,但就這一次可能就會帶來嚴重後果,所以寫程式時最好在需要保證可見性時加入安全措施。
2.1和2.2也有可能會重排序,因為它們雖然存在控制依賴關係,但不是不構成標示重排序的約束,因為它們之間沒有資料依賴關係,只有資料依賴關係才會禁止指令重排序。
執行順序:1.1 - 2.1 - 2.2 - 1.2 result為3
執行順序:1.2 - 2.1 - 2.2 - 1.1 result為0
導致共享變數線上程見不可見的原因:
1、執行緒的交叉執行;
2、重排序結合線程交叉執行;
3、共享變數更新後的值沒有在工作記憶體與主記憶體間及時更新。
synchronized解決方案:
在讀操作和寫操作函式上分別加synchronized關鍵字。
加了synchronized保證的記憶體的可見性,所以結果不會為3,因為保證的原子性,不會交叉執行。結果只有兩種情況0和6,但因為兩個執行緒執行時間沒有先後,所以加sleep:
在第一個執行緒啟動後,讓主執行緒sleep一秒再建立並啟動第二個執行緒,可以使結果一直為6:
public static void main(String[] args) { SynchronizedDemo synDemo = new SynchronizedDemo(); synDemo .new ReadWriteThread(true).start(); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } synDemo.new ReadWriteThread(false).start(); }
當兩個併發執行緒訪問同一個物件object中的這個synchronized(this)同步程式碼塊時,一個時間內只能有一個執行緒得到執行。
當一個執行緒訪問object的一個synchronized(this)同步程式碼塊時,另一個執行緒仍然可以訪問該object中的非synchronized(this)同步程式碼塊。
當一個執行緒訪問object的一個synchronized(this)同步程式碼塊時,它就獲得了這個object的物件鎖。結果,其它執行緒對該object物件所有同步程式碼部分的訪問都被暫時阻塞。
volatile保證可見性:
volatile能保證可見性不能保證原子性
volatile關鍵字:
能夠保證volatile變數的可見性
不能保證volatile變數複合操作的原子性。
volatile如何實現記憶體可見性:
深入來說:通過加入記憶體屏障和禁止重排序優化來實現的。
對volatile變數執行寫操作時,會在寫操作後加入一條store屏障指令。
對volatile變數執行讀操作時,會在讀操作前加入一條load屏障指令。
通俗的講:volatile變數在每次被執行緒訪問時,都強迫從主記憶體中讀取該變數的值,而當該變數發生變化時,又會強迫執行緒將最新的值重新整理當地主記憶體。這樣任何時刻,不同的執行緒總能看到該變數的最新值。
執行緒寫volatile變數的過程:
1、改變執行緒工作記憶體中volatile變數副本的值
2、將改變後的副本的值從工作記憶體重新整理到主記憶體
執行緒讀volatile變數的過程:
1、從主記憶體中讀取volatile變數的最新值到執行緒的工作記憶體中
2、從工作記憶體中讀取volatile變數的副本
volatile不能保證原子性。
number++分三步
volatile不保證原子性示例程式碼:
public class Volatile01 { private volatile int number = 0;// volatile變數的改變總是被其他執行緒可見。 public int getNumber() { return this.number; } public void increase() { this.number++; } public static void main(String[] args) { final Volatile01 volDemo = new Volatile01();// for (int i = 0; i < 500; i++) {// 啟動500個執行緒 new Thread(new Runnable() { public void run() { volDemo.increase(); } }).start(); } // 如果還有子執行緒在執行,主執行緒就讓出CPU資源, // 直到所有的子執行緒都執行完了,主執行緒再繼續往下執行: while (Thread.activeCount() > 1) { Thread.yield(); } System.out.println("number : " + volDemo.getNumber()); } }
有時為500,有時為499。
如果在increase方法時裡再加休眠操作,會很多時候小於500:
public void increase() { try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } this.number++; }
synchronized保證number變數線上程中的原子性:
這時把number的volatile關鍵字去掉,因為synchronized在保證原子性時也可保證可見性:
private int number = 0; public synchronized void increase() { try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } this.number++; }
這樣每個執行緒並列休眠100毫秒,程式會執行很久,所以要縮小鎖的力度,這樣執行緒可交叉休眠100毫秒:
縮小鎖力度,把synchronized寫在裡面:
public void increase() { try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } synchronized (this) { this.number++; } }
用Reentrantlock實現nubmer變數線上程中的原子性(也可保證可見性):
private Lock lock = new ReentrantLock(); private int number = 0; public int getNumber(){ return this.number; } public void increase(){ try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } lock.lock(); try { this.number++; } finally { lock.unlock(); } }