首頁 > 軟體

Java面試必備之JMM高並行程式設計詳解

2022-07-16 18:00:21

一、什麼是JMM

JMM就是Java記憶體模型(java memory model)。因為在不同的硬體生產商和不同的作業系統下,記憶體的存取有一定的差異,所以會造成相同的程式碼執行在不同的系統上會出現各種問題。所以java記憶體模型(JMM)遮蔽掉各種硬體和作業系統的記憶體存取差異,以實現讓java程式在各種平臺下都能達到一致的並行效果。

Java記憶體模型規定所有的變數都儲存在主記憶體中,包括範例變數,靜態變數,但是不包括區域性變數和方法引數。每個執行緒都有自己的工作記憶體,執行緒的工作記憶體儲存了該執行緒用到的變數和主記憶體的副本拷貝,執行緒對變數的操作都在工作記憶體中進行。執行緒不能直接讀寫主記憶體中的變數。

不同的執行緒之間也無法存取對方工作記憶體中的變數。執行緒之間變數值的傳遞均需要通過主記憶體來完成。

每個執行緒的工作記憶體都是獨立的,執行緒運算元據只能在工作記憶體中進行,然後刷回到主記憶體。這是 Java 記憶體模型定義的執行緒基本工作方式。

溫馨提醒一下,這裡有些人會把Java記憶體模型誤解為Java記憶體結構,然後答到堆,棧,GC垃圾回收,最後和麵試官想問的問題相差甚遠。實際上一般問到Java記憶體模型都是想問多執行緒,Java並行相關的問題。

二、JMM定義了什麼

這個簡單,整個Java記憶體模型實際上是圍繞著三個特徵建立起來的。分別是:原子性,可見性,有序性。這三個特徵可謂是整個Java並行的基礎。

原子性

原子性指的是一個操作是不可分割,不可中斷的,一個執行緒在執行時不會被其他執行緒干擾。

面試官拿筆寫了段程式碼,下面這幾句程式碼能保證原子性嗎?

int i = 2;
int j = i;
i++;
i = i + 1;

第一句是基本型別賦值操作,必定是原子性操作。

第二句先讀取i的值,再賦值到j,兩步操作,不能保證原子性。

第三和第四句其實是等效的,先讀取i的值,再+1,最後賦值到i,三步操作了,不能保證原子性。

JMM只能保證基本的原子性,如果要保證一個程式碼塊的原子性,提供了monitorenter 和 moniterexit 兩個位元組碼指令,也就是 synchronized 關鍵字。因此在 synchronized 塊之間的操作都是原子性的。

可見性

可見性指當一個執行緒修改共用變數的值,其他執行緒能夠立即知道被修改了。Java是利用volatile關鍵字來提供可見性的。 當變數被volatile修飾時,這個變數被修改後會立刻重新整理到主記憶體,當其它執行緒需要讀取該變數時,會去主記憶體中讀取新值。而普通變數則不能保證這一點。

除了volatile關鍵字之外,final和synchronized也能實現可見性。

synchronized的原理是,在執行完,進入unlock之前,必須將共用變數同步到主記憶體中。

final修飾的欄位,一旦初始化完成,如果沒有物件逸出(指物件為初始化完成就可以被別的執行緒使用),那麼對於其他執行緒都是可見的。

有序性

在Java中,可以使用synchronized或者volatile保證多執行緒之間操作的有序性。實現原理有些區別:

volatile關鍵字是使用記憶體屏障達到禁止指令重排序,以保證有序性。

synchronized的原理是,一個執行緒lock之後,必須unlock後,其他執行緒才可以重新lock,使得被synchronized包住的程式碼塊在多執行緒之間是序列執行的。

三、八種記憶體互動操作

記憶體互動操作有8種:

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

我再補充一下JMM對8種記憶體互動操作制定的規則吧:

  • 不允許read、load、store、write操作之一單獨出現,也就是read操作後必須load,store操作後必須write。
  • 不允許執行緒丟棄他最近的assign操作,即工作記憶體中的變數資料改變了之後,必須告知主記憶體。
  • 不允許執行緒將沒有assign的資料從工作記憶體同步到主記憶體。
  • 一個新的變數必須在主記憶體中誕生,不允許工作記憶體直接使用一個未被初始化的變數。就是對變數實施use、store操作之前,必須經過load和assign操作。
  • 一個變數同一時間只能有一個執行緒對其進行lock操作。多次lock之後,必須執行相同次數unlock才可以解鎖。
  • 如果對一個變數進行lock操作,會清空所有工作記憶體中此變數的值。在執行引擎使用這個變數前,必須重新load或assign操作初始化變數的值。
  • 如果一個變數沒有被lock,就不能對其進行unlock操作。也不能unlock一個被其他執行緒鎖住的變數。
  • 一個執行緒對一個變數進行unlock操作之前,必須先把此變數同步回主記憶體。

四、volatile關鍵字

很多並行程式設計都使用了volatile關鍵字,主要的作用包括兩點:

  • 保證執行緒間變數的可見性。
  • 禁止CPU進行指令重排序。

可見性

volatile修飾的變數,當一個執行緒改變了該變數的值,其他執行緒是立即可見的。普通變數則需要重新讀取才能獲得最新值。

volatile保證可見性的流程大概就是這個一個過程:

volatile一定能保證執行緒安全嗎

先說結論吧,volatile不能一定能保證執行緒安全。

怎麼證明呢,我們看下面一段程式碼的執行結果就知道了:

public class VolatileTest extends Thread {
private static volatile int count = 0;
public static void main(String[] args) throws Exception {
Vector<Thread> threads = new Vector<>();
for (int i = 0; i < 100; i++) {
VolatileTest thread = new VolatileTest();
threads.add(thread);
thread.start();
}
//等待子執行緒全部完成
for (Thread thread : threads) {
thread.join();
}
//輸出結果,正確結果應該是1000,實際卻是984
System.out.println(count);//984
}
@Override
public void run() {
for (int i = 0; i < 10; i++) {
try {
//休眠500毫秒
Thread.sleep(500);
} catch (Exception e) {
e.printStackTrace();
}
count++;
}
}
}

為什麼volatile不能保證執行緒安全?

很簡單呀,可見性不能保證操作的原子性,前面說過了count++不是原子性操作,會當做三步,先讀取count的值,然後+1,最後賦值回去count變數。需要保證執行緒安全的話,需要使用synchronized關鍵字或者lock鎖,給count++這段程式碼上鎖:

private static synchronized void add() {
count++;
}

禁止指令重排序

首先要講一下as-if-serial語意,不管怎麼重排序,(單執行緒)程式的執行結果不能被改變。

為了使指令更加符合CPU的執行特性,最大限度的發揮機器的效能,提高程式的執行效率,只要程式的最終結果與它順序化情況的結果相等,那麼指令的執行順序可以與程式碼邏輯順序不一致,這個過程就叫做指令的重排序。

重排序的種類分為三種,分別是:編譯器重排序,指令級並行的重排序,記憶體系統重排序。整個過程如下所示:

指令重排序在單執行緒是沒有問題的,不會影響執行結果,而且還提高了效能。但是在多執行緒的環境下就不能保證一定不會影響執行結果了。

所以在多執行緒環境下,就需要禁止指令重排序。

volatile關鍵字禁止指令重排序有兩層意思:

  • 當程式執行到volatile變數的讀操作或者寫操作時,在其前面的操作的更改肯定全部已經進行,且結果已經對後面的操作可見,在其後面的操作肯定還沒有進行。
  • 在進行指令優化時,不能將在對volatile變數存取的語句放在其後面執行,也不能把volatile變數後面的語句放到其前面執行。

下面舉個例子:

private static int a;//非volatile修飾變數
private static int b;//非volatile修飾變數
private static volatile int k;//volatile修飾變數
private void hello() {
a = 1; //語句1
b = 2; //語句2
k = 3; //語句3
a = 4; //語句4
b = 5; //語句5
//...
}

變數a,b是非volatile修飾的變數,k則使用volatile修飾。所以語句3不能放在語句1、2前,也不能放在語句4、5後。但是語句1、2的順序是不能保證的,同理,語句4、5也不能保證順序。

並且,執行到語句3的時候,語句1,2是肯定執行完畢的,而且語句1,2的執行結果對於語句3,4,5是可見的。

volatile禁止指令重排序的原理

首先要講一下記憶體屏障,記憶體屏障可以分為以下幾類:

  • LoadLoad 屏障:對於這樣的語句Load1,LoadLoad,Load2。在Load2及後續讀取操作要讀取的資料被存取前,保證Load1要讀取的資料被讀取完畢。
  • StoreStore屏障:對於這樣的語句Store1, StoreStore, Store2,在Store2及後續寫入操作執行前,保證Store1的寫入操作對其它處理器可見。
  • LoadStore 屏障:對於這樣的語句Load1, LoadStore,Store2,在Store2及後續寫入操作被刷出前,保證Load1要讀取的資料被讀取完畢。
  • StoreLoad 屏障:對於這樣的語句Store1, StoreLoad,Load2,在Load2及後續所有讀取操作執行前,保證Store1的寫入對所有處理器可見。

在每個volatile讀操作後插入LoadLoad屏障,在讀操作後插入LoadStore屏障。

在每個volatile寫操作的前面插入一個StoreStore屏障,後面插入一個SotreLoad屏障。

大概的原理就是這樣。

五、總結

要學習並行程式設計,java記憶體模型是第一站了。原子性,有序性,可見性這三大特徵幾乎貫穿了並行程式設計,可謂是基礎知識。對於後面要深入學習起到鋪墊作用。

在這篇文章中,如果面試的話,重點是Java記憶體模型(JMM)的工作方式,三大特徵,還有volatile關鍵字。為什麼喜歡問volatile關鍵字呢,因為volatile關鍵字可以扯出很多東西,比如可見性,有序性,還有記憶體屏障等等,可以一針見血地看出面試者的技術水平。

到此這篇關於Java面試必備之JMM高並行程式設計詳解的文章就介紹到這了,更多相關Java高並行程式設計內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!


IT145.com E-mail:sddin#qq.com