1. 程式人生 > 實用技巧 >2020-9-4未命名檔案

2020-9-4未命名檔案

2020-9-4未命名檔案

多執行緒

jmm是個什麼玩意兒?一文理解java記憶體模型

前言-執行緒安全問題

多執行緒環境下,容易出一些問題,尤其是在操作共享資料的時候,這些問題歸根到底是由三個原因引起的。

  • 原子性
  • 可見性
  • 有序性

原子性

這個比較好理解,所謂原子就是不可再分割,對於方法來說就是這個方法一旦開始執行,在結束之前是不能被其他方法打斷的。看下面這段程式碼



模擬的是賣票問題,在多執行緒環境下執行,我們會發現以下問題

  • 同一張票賣了多次
  • 有可能出現負數票

出現問題的原因就是我們的賣票方法不具備原子性,也就是紅框裡的內容,有可能執行緒1、執行緒2都進入了while迴圈內,執行緒1此時剛好執行到輸出了當前票數,被執行緒2搶走了cpu執行權,此時執行緒2也執行了一遍輸出當前票數,然後執行緒1再次搶走執行權,執行了票的數量加減操作。因此同一張票被執行緒1和執行緒2都列印了,這就是因為方法不具有原子性,cpu來回切換導致的問題。

可見性

為了後面更好的理解jmm,這裡我們需要講一下廣義上的執行緒問題,下面的內容指的是廣義上的多執行緒(作業系統級別)。

電子硬體的發展非常的迅速, CPU、記憶體、I/O 裝置都在不斷迭代,不斷朝著更快的方向努力,行業內有種說法是每隔十八個月硬體就會大幅度更新一次。目前,硬體的發展已經逐漸達到了瓶頸,所以在無法大幅提高硬體速度的前提下,我們開始從作業系統和程式設計上做研究去提升效能。

在電腦發展的過程中,有一個核心矛盾一直存在,就是這三者的速度差異。CPU 和記憶體的速度差異可以形象地描述為:CPU 是天上一天,記憶體是地上一年(假設 CPU 執行一條普通指令需要一天,那麼 CPU 讀寫記憶體得等待一年的時間)。記憶體和 I/O 裝置的速度差異就更大了,記憶體是天上一天,I/O 裝置是地上十年


序裡大部分語句都要訪問記憶體,有些還要訪問 I/O,根據木桶理論(一隻水桶能裝多少水取決於它最短的那塊木板),程式整體的效能取決於最慢的操作——讀寫 I/O 裝置,也就是說單方面提高 CPU 效能是無效的。

為了合理利用 CPU 的高效能,平衡這三者的速度差異,計算機體系結構、作業系統、編譯程式都做出了貢獻,主要體現為:

  • CPU 增加了快取,以均衡與記憶體的速度差異
  • 作業系統增加了程序、執行緒,以分時複用 CPU,進而均衡 CPU 與 I/O 裝置的速度差異;
  • 編譯程式優化指令執行次序,使得快取能夠得到更加合理地利用。-----指令重排序

針對快取問題,在單核時代,所有的執行緒都是在一顆 CPU 上執行,CPU 快取與記憶體的資料一致性容易解決。因為所有執行緒都是操作同一個 CPU 的快取,一個執行緒對快取的寫,對另外一個執行緒來說一定是可見的。例如在下面的圖中,執行緒 1 和執行緒 2都是操作同一個 CPU 裡面的快取,所以執行緒 A 更新了變數 V 的值,那麼執行緒 B 之後再訪問變數 V,得到的一定是 V 的最新值(執行緒 A 寫過的值)。


多核時代,每顆 CPU 都有自己的快取,這時 CPU 快取與記憶體的資料一致性就沒那麼容易解決了,當多個執行緒在不同的 CPU 上執行時,這些執行緒操作的是不同的 CPU 快取。比如下圖中,執行緒 A 操作的是 CPU-1 上的快取,而執行緒 B 操作的是 CPU-2 上的快取,很明顯,這個時候執行緒 A 對變數 V 的操作對於執行緒 B 而言就不具備可見性了。這個就屬於硬體程式設計師給軟體程式設計師挖的“坑”。
當然就算是在單核cpu下執行多執行緒也會有執行緒問題,這個主要還是原子性導致的。


一個執行緒對共享變數的修改,另外一個執行緒能夠立刻看到,我們稱為可見性

有序性(指令重排序)

cpu為了提高執行效率,會在保證依賴性不損壞的前提下,把你寫好的程式碼打亂,按照一個效率更高的順序去執行
例如下面程式碼:

int a1 = x;
int a2 = y;
int a3 = x;

在編譯過程中可能會被轉化為:

int a2 = y;
int a1 = x;
int a3 = x;

甚至轉化為:

int a1 = x;
int a2 = y;
int a3 = a1;

這樣和最初的程式碼相比,少讀x一次。

重排序分為三種類型

  • 編譯器優化的重排序
  • 指令級並行的重排序
  • 記憶體系統的重排序

一份程式碼從寫完到最終執行,要經過上面三次的重排序。

以上是執行緒問題的三個根源,原子性、可見性、有序性。這是從廣義層面解讀的,或者可以理解為硬體或作業系統級別的多執行緒。
為了解決執行緒問題,每種程式語言都會做自己的執行緒處理,說白了就是定義自己操作多執行緒時的一些規則。

JMM

java記憶體模型(java memory model)

導致可見性的原因是快取,導致有序性的原因是編譯優化,那解決可見性、有序性最直接的辦法就是禁用快取和編譯優化,但是這樣問題雖然解決了,我們程式的效能可就堪憂了。
合理的方案應該是按需禁用快取以及編譯優化。那麼,如何做到“按需禁用”呢?對於併發程式,何時禁用快取以及編譯優化只有程式設計師知道,那所謂“按需禁用”其實就是指按照程式設計師的要求來禁用。
所以,為了解決可見性和有序性問題,只需要提供給程式設計師按需禁用快取和編譯優化的方法即可。
Java 記憶體模型是個很複雜的規範,可以從不同的視角來解讀,站在我們這些程式設計師的視角,本質上可以理解為,Java 記憶體模型規範了 JVM 如何提供按需禁用快取和編譯優化的方法。
具體來說,這些方法包括 volatile、synchronized 和 final 三個關鍵字,以及六項 Happens-Before 規則

上文講到,記憶體模型的概念不只是java有,所有涉及到多執行緒的程式語言都會涉及到記憶體模型,比如C和C++。
針對硬體原生的執行緒問題,java再此基礎上提出了自己的規範,也就是java定義了自己操作多執行緒時和cpu、記憶體互動的一些規則。

Java執行緒之間的通訊採用的是共享記憶體模型,這裡提到的共享記憶體模型指的就是Java記憶體模型(簡稱JMM)是一種虛擬機器規範,用於遮蔽掉各種硬體和作業系統的記憶體訪問差異,以實現讓Java程式在各種平臺下都能達到一致的併發效果JMM決定一個執行緒對共享變數的寫入何時對另一個執行緒可見
從抽象的角度來看,JMM定義了執行緒和主記憶體之間的抽象關係

  • 執行緒之間的共享變數儲存在主記憶體(main memory)中
  • 執行緒被CPU執行,每個執行緒都有一個私有的本地記憶體(如CPU的快取記憶體),本地記憶體中儲存了該執行緒以讀/寫共享變數的副本
  • (重點)本地記憶體是JMM的一個抽象概念,並不真實存在;它涵蓋了==快取,寫緩衝區,暫存器==以及其他的硬體和編譯器優化。


jmm解決可見性問題

關於主記憶體與工作記憶體之間的具體互動協議,即一個變數如何從主記憶體拷貝到工作記憶體、如何從工作記憶體同步到主記憶體之間的實現細節,Java記憶體模型定義了以下八種操作來完成:

  • lock(鎖定):作用於主記憶體的變數,把一個變數標識為一條執行緒獨佔狀態。程式設計師自己實現
  • unlock(解鎖):作用於主記憶體變數,把一個處於鎖定狀態的變數釋放出來,釋放後的變數才可以被其他執行緒鎖定。程式設計師自己實現
  • read(讀取):作用於主記憶體變數,把一個變數值從主記憶體傳輸到執行緒的工作記憶體中,以便隨後的load動作使用
  • load(載入):作用於工作記憶體的變數,它把read操作從主記憶體中得到的變數值放入工作記憶體的變數副本中。
  • use(使用):作用於工作記憶體的變數,把工作記憶體中的一個變數值傳遞給執行引擎,每當虛擬機器遇到一個需要使用變數的值的位元組碼指令時將會執行這個操作。
  • assign(賦值):作用於工作記憶體的變數,它把一個從執行引擎接收到的值賦值給工作記憶體的變數,每當虛擬機器遇到一個給變數賦值的位元組碼指令時執行這個操作。
  • store(儲存):作用於工作記憶體的變數,把工作記憶體中的一個變數的值傳送到主記憶體中,以便隨後的write的操作。
  • write(寫入):作用於主記憶體的變數,它把store操作從工作記憶體中一個變數的值傳送到主記憶體的變數中。


enter description here

上圖標註了這些操作所處的流程,每個執行緒和主記憶體之間都是這樣一種操作流程。
換句話說如果執行緒1和執行緒2都需要對共享變數x做操作,而執行緒1沒有及時把本地對x的修改重新整理回主存,或者執行緒2沒有及時從主存讀最新的值,就會出現資料不一致問題。這就是我們一直說的可見性問題。

Java記憶體模型還規定了在執行上述八種基本操作時,必須滿足如下規則:

  • 如果要把一個變數從主記憶體中複製到工作記憶體,就需要按順尋地執行read和load操作, 如果把變數從工作記憶體中同步回主記憶體中,就要按順序地執行store和write操作。但Java記憶體模型只要求上述操作必須按順序執行,而沒有保證必須是連續執行。
  • 不允許read和load、store和write操作之一單獨出現
  • 不允許一個執行緒丟棄它的最近assign的操作,即變數在工作記憶體中改變了之後必須同步到主記憶體中。
  • 不允許一個執行緒無原因地(沒有發生過任何assign操作)把資料從工作記憶體同步回主記憶體中。
  • 一個新的變數只能在主記憶體中誕生,不允許在工作記憶體中直接使用一個未被初始化(load或assign)的變數。即就是對一個變數實施use和store操作之前,必須先執行過了assign和load操作。
  • 一個變數在同一時刻只允許一條執行緒對其進行lock操作,但lock操作可以被同一條執行緒重複執行多次,多次執行lock後,只有執行相同次數的unlock操作,變數才會被解鎖。lock和unlock必須成對出現
  • 如果對一個變數執行lock操作,將會清空工作記憶體中此變數的值,在執行引擎使用這個變數前需要重新執行load或assign操作初始化變數的值
  • 如果一個變數事先沒有被lock操作鎖定,則不允許對它執行unlock操作;也不允許去unlock一個被其他執行緒鎖定的變數。
  • 對一個變數執行unlock操作之前,必須先把此變數同步到主記憶體中(執行store和write操作)。

public class VolatileDemo {
//	static volatile int  num=0;
	static  int  num=0;
	
	public static void main(String[] args) {
		new Thread(new Runnable() {
			
			@Override
			public void run() {
				try {
					Thread.sleep(1000);
				} catch (InterruptedException e) {
					// TODO Auto-generated catch block
					e.printStackTrace();
				}
				num=1;
				System.out.println(num);
				
			}
		}).start();
		
		while(num==0) {
			
		}
	}
}

這段程式碼是很典型的一段程式碼,很多人在中斷執行緒時可能都會採用這種標記辦法。但是事實上,這段程式碼會完全執行正確麼?即一定會將執行緒中斷麼?不一定,也許在大多數時候,這個程式碼能夠把執行緒中斷,但是也有可能會導致無法中斷執行緒(雖然這個可能性很小,但是隻要一旦發生這種情況就會造成死迴圈了)。

下面解釋一下這段程式碼為何有可能導致無法中斷執行緒。

在前面已經解釋過,每個執行緒在執行過程中都有自己的工作記憶體,那麼執行緒1在執行的時候,會將stop變數的值拷貝一份放在自己的工作記憶體當中。那麼當執行緒2更改了stop變數的值之後,但是還沒來得及寫入主存當中,執行緒2轉去做其他事情了,那麼執行緒1由於不知道執行緒2對stop變數的更改,因此還會一直迴圈下去。

解決方法:加入volatile關鍵字

volatile相當於一個輕量級的鎖,synchronized是重量級鎖。jmm定義了變數傳遞的機制並提出瞭解決可見性問題的方法,卻沒有給預設加上(lock和unlock需要程式設計師自己實現),目的是為了給程式設計師最大的自主性,如果程式設計師需要,就自己實現lock和unlock這兩個操作,也就是自己加鎖。

jmm解決有序性問題

重排序帶來的問題


flag變數是個標記,用來標識變數a是否已被寫入。這裡假設有兩個執行緒A和B,A首先執行writer()方法,隨後B執行緒接著執行reader()方法。執行緒B在執行操作4時,能否看到執行緒A在操作1對共享變數a的寫入呢?

答案是:不一定能看到。

由於操作1和操作2沒有資料依賴關係,編譯器和處理器可以對這兩個操作重排序;同樣,操作3和操作4沒有資料依賴關係,編譯器和處理器也可以對這兩個操作重排序。



操作1和操作2做了重排序,按照上圖順序執行,當執行緒B讀取變數a時,a的值還沒有被執行緒A寫入。重排序在這裡就破壞了程式


jmm屬於語言級的記憶體模型,確保在不同編譯器和不同處理器平臺上,通過進位制特定型別的編譯器重排序和處理器重排序,為程式設計師提供一致的記憶體可見性保證。

對於編譯器重排序,jmm會禁止特定型別的編譯器重排序
對於處理器重排序,jmm會要求java編譯器在生成指令序列時,插入特定型別的記憶體屏障指令來禁止處理器重排序

jmm裡的一些規範和定義

as-if-serial語義:

不管怎麼重排序,(單執行緒)程式的執行結果不能被改變。(編譯器、runtime和處理器都必須遵守as-if-serial語義)

happens before:

class VolatileExample {
  int x = 0;
  volatile boolean v = false;
  public void writer() {
    x = 42;
    v = true;
  }
  public void reader() {
    if (v == true) {
      // 這裡x會是多少呢?
    }
  }
}

如果在低於 1.5 版本上執行,x 可能是 42,也有可能是 0;如果在 1.5 以上的版本上執行,x 就是等於 42。
為什麼 1.5 以前的版本會出現 x = 0 的情況呢?
變數 x 可能被 CPU 快取而導致可見性問題。這個問題在 1.5 版本已經被圓滿解決了。Java 記憶體模型在 1.5 版本對 volatile 語義進行了增強。靠的就是Happens-Before 規則。

從JDK 5開始,Java使用新的JSR-133記憶體模型,JSR-133使用happens-before的概念來闡述操作之間的記憶體可見性:在JMM中,如果一個操作執行的結果需要對另一個操作可見(兩個操作既可以是在一個執行緒之內,也可以是在不同執行緒之間)

很多地方喜歡把Happens-Before翻譯為“先行先發生”,這是歧義的,它真正要表達的是:前面一個操作的結果對後續操作是可見的

Happens-Before 規則應該是 Java 記憶體模型裡面最晦澀的內容了,和程式設計師相關的規則一共有如下六項,都是關於可見性的。

  1. 單執行緒中,前面的程式碼應該happens before於後面的程式碼。
  2. volatile欄位的寫入應該happens before於後面對同一個volatile欄位的讀取。
  3. 對同一個監視器(鎖)的解鎖應該happens before於後面的加鎖。(一個監視器只能同時被一個執行緒持有,前一個執行緒解鎖,後面的執行緒才能加鎖,這也是synchronized遵守的規則之一)
  4. 主執行緒中啟動子執行緒,子執行緒能看到啟動前主執行緒中的所有操作。
  5. 主執行緒中啟動子執行緒,然後子執行緒呼叫join方法,主執行緒等待子執行緒執行結束,執行結束返回後,主執行緒對看到子執行緒的所有操作。
  6. 如果 A Happens-Before B,且 B Happens-Before C,那麼 A Happens-Before C。也就是具有傳遞性

volatile

特點、作用

volatile 關鍵字並不是 Java 語言的特產,古老的 C 語言裡也有,它最原始的意義就是禁用 CPU 快取。
上面例子裡已經見過了volatile的使用。下面總結一下它的特點和原理

  • 使用volatile關鍵字會強制將修改的值立即寫入主存---即禁用快取,保證可見性
  • 禁止指令重排,保證有序性----這種說法其實不嚴謹,只是保證被volatile修飾的變數有序,並不能保證其他變數有序。例子在後面
  • 不保證原子性。例子在後面

一定程度上保證有序性的例子

//x、y為非volatile變數
//flag為volatile變數
 
x = 2;        //語句1
y = 0;        //語句2
flag = true;  //語句3
x = 4;         //語句4
y = -1;       //語句5

由於flag變數為volatile變數,那麼在進行指令重排序的過程的時候,不會將語句3放到語句1、語句2前面,也不會講語句3放到語句4、語句5後面。但是要注意語句1和語句2的順序、語句4和語句5的順序是不作任何保證的。

並且volatile關鍵字能保證,執行到語句3時,語句1和語句2必定是執行完畢了的,且語句1和語句2的執行結果對語句3、語句4、語句5是可見的。

不保證原子性的例子

public class SumDemo {
	
	 private static long count = 0;
	  private void add() {
	    int i = 0;
	    while(i < 10000) {
	      count += 1;
	      i++;
	    }
	  }
	  public static void main(String[] args) throws InterruptedException{
		  SumDemo test = new SumDemo();
		    // 建立兩個執行緒,執行add()操作
		    Thread th1 = new Thread(()->{
		      test.add();
		    });
		    Thread th2 = new Thread(()->{
		      test.add();
		    });
		    // 啟動兩個執行緒
		    th1.start();
		    th2.start();
		    // 等待兩個執行緒執行結束
		    th1.join();
		    th2.join();
		    System.out.println(count);
		
	}
}

直覺告訴我們應該是 20000,因為在單執行緒裡呼叫兩次 add() 方法,count 的值就是 20000,但實際上 calc() 的執行結果是個 10000 到 20000 之間的隨機數。為什麼呢?我們假設執行緒 A 和執行緒 B 同時開始執行,那麼第一次都會將 count=0 讀到各自的 CPU 快取裡,執行完 count+=1 之後,各自 CPU 快取裡的值都是 1,同時寫入記憶體後,我們會發現記憶體中是 1,而不是我們期望的 2。之後由於各自的 CPU 快取裡都有了 count 的值,兩個執行緒都是基於 CPU 快取裡的 count 值來計算,所以導致最終 count 的值都是小於 20000 的。這就是快取的可見性問題。通過加volatile可以解決可見性問題,但結果依舊不對,這是由於原子性造成的。解決辦法是方法上加鎖

原理

觀察加入volatile關鍵字和沒有加入volatile關鍵字時所生成的彙編程式碼發現,加入volatile關鍵字時,會多出一個lock字首指令

  lock字首指令實際上相當於一個記憶體屏障(也成記憶體柵欄),記憶體屏障會提供3個功能:

  1)它確保指令重排序時不會把其後面的指令排到記憶體屏障之前的位置,也不會把前面的指令排到記憶體屏障的後面;即在執行到記憶體屏障這句指令時,在它前面的操作已經全部完成;

  2)它會強制將對快取的修改操作立即寫入主存;

  3)如果是寫操作,它會導致其他CPU中對應的快取行無效。