毀三觀!打破你對Java併發的認知!

大多數人對 Java 併發的理解可能只是 Thread.class 類,或者還有 synchronized、volatile 關鍵字等等,或者,再多一些,JUC、AQS 等等……

當然,本着不斷學習的精神,也是對前面一段時間的知識做一個總結,筆者打算把 Java 併發的知識做一個整理

因爲併發的知識不簡單,因爲它往往不侷限於 Java 語言本身:
它和 JavaVM、操作系統、硬件組成都有一定的聯繫。

什麼是線程

這個問題看起來很簡單,不過實際上,它涉及到的知識點還是很多的。

首先,一般一個普通的 Java 程序員會回答:Thread。

這沒錯,而且,一般我們也會回答道,創建線程的方式有很多,比如:
通過實現 Runnable 接口、或者 Callable 接口、或者還有線程池等等。

不過,這些方式的本質,都是 Thread.start(),線程在執行 run 方法。

不過,這就結束了嗎?
這樣未免太簡單了點。

因爲,這麼回答的話,其實並沒有很好的解釋什麼是線程。
爲什麼這麼說?
因爲,Thread.class 只是 Java 語言編寫出的一個類,而我們平時 new Thread 也僅僅只是創建了一個對象。
我們平時也會寫很多很多的類,以及創建很多很多的對象。
但是,爲什麼,我們寫的類,就不能夠成爲一個線程的對象???

所以,線程不僅僅只是一個 Thread.class 的定義,它其實封裝了很多更復雜的事情。

首先,要明白什麼是線程,得先明白,什麼是進程!
進程:是操作系統分配資源的基本單位。
而線程:則是執行調度的基本單位。

這麼說有些抽象,我來詳細地解釋一下:
首先,在以前,是沒有線程這個概念的,進程既充當現在的進程、又充當現在的線程。
也就是說,以前,只有進程這麼一種東西,去獨自承擔程序的資源和執行。

那麼,爲什麼需要有進程這麼一種東西?
我們可以先思考,假設,沒有進程。那麼,我們寫完一個程序,要怎樣去執行?
有些人會說,代碼都有了,CPU 直接跑不就好了。

但其實不然,因爲對於目前的計算機而言,都是多程序併發執行,所以,就需要對不同的執行進行管理。
所以,進程不僅僅只包含需要執行的代碼,它還包含了很多內容,如:
打開的文件、掛起的信號、內核內部數據、處理器狀態、具有內存映射的地址空間等等,
於是,就給進程定義了一個描述符,去記錄和表示一個進程的信息。

那麼,有了進程,爲什麼又要有線程?
比如,我們運行一個 qq 程序,我們可以一邊聊天,一邊語音,一邊還在傳文件,
也就是說,一個 qq 進程,它也可以同時執行多個任務。

而傳統的進程,它既是分配資源的唯一單位,也是執行的唯一單位,那麼,一個程序,就不能夠併發地執行多個任務。
而引入線程的概念之後,一個進程,可以創建多個線程,那麼就可以併發執行多個任務。

那麼,有人可能會問,單進程無法併發執行多任務,那麼不是可以採用多進程的方式嗎?
確實是可以。
不過,這就體現不出多線程的好處了。

引入了線程之後,線程就成爲了執行調度的最小單位,不過,系統分配資源的基本單位還是進程。
這就意味着,我們不用爲每一個線程去分配資源,那麼,線程就會更輕量級。
這樣,同一個進程的線程,它們就可以共享進程的資源,因此,線程之間的通信就可以無須系統的干預。
以及,系統對同一個進程的多個線程進行調度,就不用切換進程的運行環境,開銷就會降低。

不過,剛剛我所說的線程,都屬於操作系統所有線程。
而實際上,其實在用戶空間,也可以自己實現線程,
也就是所謂的纖程、協程。

那爲什麼會出現用戶級線程這樣的東西呢?
其實這很容易想明白。
對於系統級的線程,線程的調度、切換,就需要操作系統內核的干預。
因此,線程的調度切換就必須在內核態下才能完成。

而對於用戶級線程,則是應用程序自己編寫邏輯代碼,實現對多個用戶級線程的調度,
因此不需要操作系統的介入,從而線程調度就不需要進入內核態。
並且,由於用戶級線程的實現更加輕量,所以效率也會更高。

那麼,看到這,對於 Java 來說,創建的 Thread,是操作系統的線程,還是 Java 虛擬機自己實現的線程呢?
很多人可能會因此開始懷疑。
於是,我們可以查看源碼,看一下程序的調用鏈路,是否調用了操作系統的函數來產生線程,那麼就可以確定,Java 的線程和操作系統的線程是不是一一對應了。

首先,我們點開 Thread 類,點開 start() 方法:

public synchronized void start() {
	......
	
    try {
        start0();
        started = true;
    } finally {
        ......
    }
}

我們可以發現,調用了 native 方法,start0()。
這樣的話,我們就無法在 Java 的代碼中繼續深入找到線程的啓動方法了。

於是,我們可以查看 Hotspot 源碼:
源碼很長,我不可能全部放上來,有需要的可以自己去查看,這裏,我只放一小段重點,查看調用的方法

static JNINativeMethod methods[] = {
    {"start0",           "()V",        (void *)&JVM_StartThread},
    ......
};

這時,我們就可以查看到,start0 方法,就對應着 JVM_StartThread,所以我們就繼續找:

JVM_ENTRY(void, JVM_StartThread(JNIEnv* env, jobject jthread))
  JVMWrapper("JVM_StartThread");
  JavaThread *native_thread = NULL;
  ......
  {
  	  ......
      native_thread = new JavaThread(&thread_entry, sz);
      ......
  }
  ......
  Thread::start(native_thread);
JVM_END

我們可以發現,在 JavaVM 啓動線程的時候,在這裏就是 new 了一個 JavaThread,
所以,我們點進去,找到 JavaThread 的構造方法:

JavaThread::JavaThread(ThreadFunction entry_point, size_t stack_sz) :
  Thread()
  ......
  os::create_thread(this, thr_type, stack_sz);
}

然後,我們就會發現,這裏又是調用了 os:create_thread
看名稱,我們也可以大致猜到,這個應該就是操作系統線程創建的方法。

在這篇文章中,涉及操作系統,都是默認指 Linux。
別問我爲什麼,因爲其它操作系統我不會。。。

其它代碼我就省略了,我們繼續看線程的創建是如何創建的:

bool os::create_thread(Thread* thread, ThreadType thr_type, size_t stack_size) {
	......
	
	pthread_t tid;
    int ret = pthread_create(&tid, &attr, (void* (*)(void*)) java_start, thread);
    	
	......
}

這裏可以看到,用 pthread_create 創建了線程,而這個函數,就是一般在 Linux 中創建線程的一個函數。
所以,在這裏,已經可以證明,Java 在創建 Thread 的過程,就是調用 Linux 的函數來創建線程。

所以,到這裏,已經足以證明 Java 的線程,和操作系統的線程是一一對應的。
那麼,要明白 Java 的線程是什麼,就要去弄明白,操作系統創建的線程是什麼!

通過上面的呈現,已經確定了,Java 採用的線程,是和操作系統線程一一對應的,
在 Linux 操作系統中,就是通過 pthread_create,來創建一個線程,並且在 JavaVM 與之一一對應上。
那麼,在 Linux 操作系統中,又是如何實現線程的呢?

可能有些人要疑惑了,線程的知識,在上文不是描述了?

確實,對於線程的定義,其實現一般大體如此:
通過創建較重的進程,分配好相關的資源,然後只需要給進程,在創建更輕量的線程即可,
而線程,僅僅只需要一些寄存器、CPU 的資源。

不過,你要是去和面試官說,Linux 的線程是這樣的,那多半就直接讓回去等通知了。
因爲,在 Linux 中,是沒有專門的線程的!!!

Linux 實現線程的機制非常獨特,從內核的角度來說,它並沒有線程這個概念,
因爲 Linux 把所有的線程都當做進程來實現,而線程僅僅被視爲一個與其它進程共享某些資源的進程。

在操作系統的定義中,每一個進程要有一個 PCB,用於描述一個進程,從而對進程進行管理。
在 Linux 操作系統中,進程通常也叫做任務(task),爲了管理進程,於是定義了一個進程描述符,來記錄進程的各種信息,進程描述符的類型爲 task_struct。
在進程描述符中能完整地描述一個正在執行的程序,包括:它打開的文件、進程的地址空間、掛起的信號、進程的狀態等等……

在進程描述符中,有一個 pid,Linux 則內核通過這一個唯一的進程標示值(PID)來標識每個進程,
PID 的類型是一個叫 pid_t 的隱含類型,實際上就是一個 int 類型。
而 PID 的最大默認值就是 32768,所以,假設你的 Java 程序報了一個 error,
OutOfMemoryError:unable to create new native thread,
那就意味着,你的操作系統所能啓動的線程已經到達了最大值,無法再進行更多地創建了,
那麼,你就需要調整程序,降低線程數,或者,修改線程所能達到的最大數量。

知道了 Linux 的線程是什麼,那麼,我們就可以來研究,Linux 是如何創建一個線程(也就是進程)的:
首先,Linux 的進程創建很特別,一般的操作系統,都是通過:
在新的地址空間創建進程,然後讀入可執行文件,最後開始執行。

而 Linux,則是分爲兩個步驟:
fork() 和 exec()。
首先,通過 fork() 拷貝當前進程創建一個子進程;
然後,exec() 函數會讀取可執行文件並將其載入地址空間開始運行。

不過,Linux 的 fork() 函數有一個很大的特點,就是寫時複製
因爲,進程的創建是通過 fork 拷貝出來的,那麼,按照傳統的方式,新創建的進程,就會拷貝一份父進程的數據,假設父進程的數據有很多很多,那麼拷貝數據,就會消耗大量的時間、CPU 資源、以及內存資源,那麼,這是絕對不合理的。
所以,Linux 採用寫時複製機制,因此調用 fork() 的實際開銷就只是複製父進程的頁表以及創建唯一的進程描述符,而其它的很多資源,都是父子進程共享的,只有到任意一方進行修改數據,纔會將該數據拷貝,父子進程看到的數據纔會不同。
而一般,創建進程不是爲了共享大量的數據,而是作爲一個全新的進程的去使用,因此,假設在 fork() 調用後,就複製一大堆數據,這顯然是完全浪費的,因爲子進程根本不打算用這些數據。
因此,當創建的函數返回,內核會有意選擇讓子進程先執行,這樣,子進程馬上調用 exec() 函數,就能成爲一個新的進程,不再作爲父進程的副本,因此,也就不會再發生複製了。
不過,有趣的是,雖然內核有意讓子進程先執行,但是並不能保證如此。

實際上,在 Linux 中對 fork() 的實現實際上就是 clone(),然後 clone() 函數又去調用 do_fork(),在 do_fork() 中又調用了 copy_process() 函數,然後讓進程開始運行。
而整個 cpoy_process() 函數則做了很多事,包括:
爲新進程創建內核棧、task_truct,然後初始化 tsask_struct 部分成員;
然後狀態值要設置爲不可被打斷(TASK_UNINTERRUPTIBLE),因爲此刻該進程還未創建完全,不可以被調度執行;
之後,則調用 copy_flags() 更新 flags 成員;調用 alloc_pid() 爲新進程分配 PID;copy_process() 拷貝或共享打開的資源;
最後,返回一個指向子進程的指針。

對於一個普通的進程來說創建便是如此,假設現在不是創建一個普通進程,而是要實現線程,
那麼,實際上,區別則很小:
線程的創建和普通進程的創建類似,上文我也提過,Linux 對線程的實現,不過是和其它進程共享某些資源,
所以,Linux 對線程的創建,僅僅是在調用 clone() 函數的時候,傳入一些參數標誌來指明需要共享的資源。
這樣,一個意味着線程的進程就被創建了,
同時,也意味着,我們對應的 Java 線程被創建了,這樣,只要做一下初始化工作,我們的 Java 線程,就會投入運行了。

搞明白了線程的創建,那麼,線程的結束呢?
實際上,進程的結束,也不是想的那麼簡單。

我們一般使用 Java 語言時,一般都會給 Thread 運行 run 方法,然後運行完畢,就認爲線程已經死亡了。
其實不然,實際上,線程執行完 run 方法後,還會執行一段 exit() 方法進行退出。
如果,我們對 Java 代碼進行 debug,就可以發現,線程在執行完 run 方法之後,又接着執行了 exit() 方法。
而且,在執行時,我們也可以通過 debug 觀察到此時的線程狀態還是 RUNNABLE。

// Java Thread 在運行完run方法,會接着執行exit()方法,釋放相關的資源
private void exit() {
    ......
}

那麼,執行完這段方法線程就死亡了嗎?

其實也不是,因爲我們知道,在 Java 中的線程,是和操作系統的線程一一對應的。
在之前,我們也看到了,在 JavaVM 中創建了線程,然後讓線程執行 run 方法而已。
所以,這裏,僅僅只是在 Java 語言層面,線程的任務已經結束了,
不過,線程的退出,最終還是得由操作系統接管。

其實通過上面的描述,我們很容易就能想明白,進程不可能運行完就自動結束。
主要原因還是,進程還佔據着許多系統資源,並且還處於運行隊列或可運行隊列,也還管理着它的子進程。
所以,必須對進程進行資源釋放,並且移除出隊列,以及給它的子進程一個交代。

所以,進程的最後,會調用一個 exit() 系統調用,來結束自己的一生,
其中包括:將自己設置爲退出狀態、釋放沒有被共享的地址空間、離開等待調度隊列、告知父進程、以及給子進程重新找養父,
最終調用 schedule() 函數切換新的進程。

由於已經處於退出狀態(EXIT_ZOMBIE),所以進程永遠不會再被調度執行,因此,這是進程所執行的最後一段代碼,do_exit() 函數永不返回。

線程狀態和調度

瞭解了什麼是線程之後,基礎打牢,那麼,才能逐漸對併發的知識理解的深刻。

那麼,接下來,我們就要去探究一個讓無數程序員頭疼的問題:
程序的異步性

首先,進程的狀態有那麼幾種:

  • 創建態
  • 就緒態
  • 運行態
  • 阻塞態
  • 終止態

這裏,只涉及到一些基本的,所以那些掛起什麼的就不去探討了。
我們於是,就可以以此聯想到我們 Java 線程的幾種對應的狀態。

  • New:尚未啓動的線程的線程狀態
  • Runnable:可運行的線程狀態,等待 CPU 調度。
  • Blocked:線程阻塞等待監視器鎖定的線程狀態。
  • Waiting:等待線程的線程狀態。下列不帶超時的方式:
  • Object.wait、Thread.join、LockSupport.park
  • Timed Waiting:具有指定等待時間的等待線程的線程狀態。下列帶超時的方式:
  • Thread.sleep、Object.wait、Thread.join、LockSupport.parkNanos、LockSupport.parkUntil
  • Terminated:線程終止的狀態。線程正常完成執行或者出現異常。

我們便能很自然的與操作系統的進程狀態對應起來:

  • NEW:創建態
  • Runnable:就緒態和運行態
  • Blocked、Waiting、Timed Waiting:阻塞態
  • Terminated:終止態

所以,其實,WAITING 和 BLOCKED 狀態對應於操作系統的狀態是一樣的,
有些人把 WAITING 和 BLOCKED 分成兩種狀態,不過心裏得留個概念,就是對於操作系統來說,是沒有什麼區別的。

那麼,既然瞭解了這些,之後,我們就可以繼續探討,線程的異步性。

因爲線程的執行具有異步性,所以往往,我們無法判斷,哪個線程會先執行,哪個會後執行,什麼時候又會發生線程的上下文切換,所以程序的執行,往往結果是多種多樣,不確定性很大。
再加上,不同的操作系統,底層執行的邏輯也可能不一樣。

因此,就會有這麼幾個特點:

  • 所見非所得;
  • 無法用肉眼檢測程序的準確性;
  • 不同的運行平臺有不同的表現;
  • 錯誤很難重現。

比如一個 DCL 單例,可能有 9999W 次運行是正確的,但是,有一次它就出現錯誤了,
由於錯誤又無法重現,因此你很難找到它的問題。

當然,產生問題的原因有很多,不要想着一口氣吃成一個胖子,我們先來看,產生異步性的原因:
線程的調度原理。

當然,在 Linux 上得稱爲進程調度,畢竟 Linux 上準確的說沒有額外定義線程。

首先,假設沒有進程調度,那麼一個計算機就不能像我們現在這樣,可以一邊聽歌,一邊玩遊戲,一邊 qq 聊天,一邊傳送文件等等,同時做着各種各樣的事情,
計算機必須把一個程序執行完了,才能接着,去執行另一個事情。

這明顯不是我們期待的樣子,我們希望我們可以用一臺計算機,同時去做很多很多的任務。
因此,爲了實現併發,纔有了進程調度。

假設,此時,你的計算機是隻有一個 CPU 核心,那麼,同一時間,那就只有一個任務在被執行;
雖然看起來是有很多任務在處理,但實際上,所有的任務,只是被分成了一小片一小片,不斷地執行各個任務;
因此,從宏觀上看起來,就像是在執行很多任務一樣。
不過,假設,你的計算機上,有不止一個 CPU,那麼,就可以做到,同一時刻,在做着多個任務。

這些一般大家都知道,不過我還是簡單提一下,
多任務系統可以劃分爲兩類:

  • 搶佔式多任務系統
  • 非搶佔式多任務系統

Linux 則提供了搶佔式的多任務系統,在這個模式下,會由調度程序來決定什麼時候停止一個進程的運行,以便其它進程能夠得到執行機會,這個強制進程停止運行的動作就叫搶佔。
而進程在被搶佔前能夠運行的時間是被預先設置好的,而且有一個專門的名字,叫進程的時間片。

而非搶佔式多任務模式下,除非進程自己主動停止運行,否則它會一直執行。
進程主動停止自己讓其它線程執行的動作就叫做讓步。
這樣,進程自己做出讓步,好讓每個任務獲得足夠的處理時間,
但是,這有着極大的問題:
由於調度程序無法對每個進程該執行多長時間做出統一規定,所以進程獨佔處理器的時間可能超出預料;
而且,假設一個進程決不讓步,那麼,就會使得系統崩潰。

所以,毫無疑問,大部分操作系統都採用了搶佔式的多任務模型,包括 Linux。

在 Linux 的 2.5 版本,對調度程序進行了改進,採用了一種叫做 O(1) 調度程序的新調度程序。
它在數十個處理器的環境下可以表現出不錯的性能和可擴展性,
不過,它對於響應時間敏感的程序,也就是一般的交互程序,會有很多不足。

所以,在 2.6 的一個版本中,則將 O(1) 調度程序完全替換,它被成爲 “完全公平調度算法” ,或者簡稱 CFS。

那麼,爲了和 CFS 的特點突出出來,我們可以先看一下傳統 Unix 的進程調度。

首先,現代進程調度器有兩個通用的概念:進程優先級、時間片。
時間片是指每次進程得到處理器時可以執行的時間;
對於優先級,一般有更高優先級的進程可以運行得更頻繁,通常也會被賦予更多的時間片。

傳統 Unix 系統進程調度的第一個問題,就是 nice 值到時間片的映射。
若要將 nice 值映射到時間片,就需要將 nice 單位值對應到處理器的絕對時間,但是,這樣將導致進程切換無法最優化進行。
舉個例子,假設一個進程有默認 nice 值,也就是 0,對應一個 100 ms 的時間片;
然後,給另一個進程分配最高 nice 值(20),對應 5 ms 的時間片。
那麼,默認優先級的進程將會獲得 20/21 的處理器時間,而最高優先級的線程,將只有 1/21 的處理器時間。
假設,此時兩個進程的優先級都是 20,都處於低優先級,說明我們是希望兩個線程的個獲得相同的一半處理器的時間,那麼每個進程的時間片都是 5 ms,那麼每隔 10 ms,就會有兩次上下文切換。
假設,此時兩個進程的優先級都是 0,都處於默認優先級,那麼兩個線程同樣都是獲得相同的一半處理器的時間,然而這時每個進程的時間片都是 100 ms,每隔 100 ms 纔會有一次上下文切換。

明顯,這個分配方式不是很理想。
我們往往希望,前臺交互進程擁有高優先級(低 nice 值),而後臺進程,擁有低優先級(高 nice 值),
而這種時間片分配是難以符合的。

第二個問題就是,nice 值的絕對,引發了優先級的相對問題。
假設,一個進程 nice 值爲 0,一個進程 nice 值爲 1,它們的時間分配比則會是 100 : 95,
可以說,幾乎是沒有什麼差別的;
而,一個進程的 nice 值爲 19,一個進程的 nice 值爲 20,它們的時間分配比則會是 10 : 5,
也就是一個進程的時間片是另一個時間片的兩倍!
所以,很明顯,nice 值由於採用絕對值會使得相對的變化受到很大的影響,這很大程度上取決於 nice 的初始值,
所以,這是不夠理想的。

第三個問題,就是如果執行 nice 值到時間片的映射,那這個絕對時間片,則必須能在內核的測試範圍之內。
所以,就要求時間片必須是定時器節拍的整數倍,
所以,最小時間片,就可能至少要 1ms 或者 10ms,這取決於時鐘節拍的時間。
而且,時間片還會隨着定時器節拍而改變。
所以,這也是引入 CFS 的原因。

第四個問題,則是基於優先級的調度器優化交互任務的問題。
因爲,可能在交互系統中,爲了讓進程能更快地投入運行,而去對新要喚醒的進程提升優先級,即便它們的時間片已經用盡了。
雖然這可以提升交互性能,而這卻會打破進程公平原則。

雖然說,這些問題,可以通過修改來改善,不過,這終究沒有解決實際問題:
分配絕對的時間片引發的固定切換頻率,給公平性造成了很大變數。
所以,CFS 完全摒棄了時間片的概念,而是給進程分配處理器使用的比重。
於是,CFS 的出發點給予一個簡單的理念:進程調度的效果應該如同系統具備一個理想中完美多處理器。
也就是,在任意時刻,所有的進程都是在一起運行,而它們只是各自使用了處理器 1/n 的性能。

當然,CFS 的理想並不是現實,一個處理器同一時刻始終無法運行多個進程。
而讓每個進程運行時間過於小,來顯得同時運行也是不合理的,因爲這樣會導致頻繁搶佔帶來過大的開銷。
所以,CFS 考慮到了這一點,它並沒有讓進程的運行時間過於短暫。

CFS 的做法是:
允許每個進程運行一段時間,不斷循環,不過,每次都選擇運行最少的進程作爲下一個執行的進程。
而且,運行的分配也不是按照絕對時間片了,而是把 nice 值,作爲每個進程的權重。
這樣,每個進程的執行時間,都是按照它們各自的權重佔比來執行。

假設,現在有兩個任務,且權重都相同,一個執行週期爲 20 ms,那麼每個進程就會執行 10 ms;
假設,有 4 個進程,那麼每個進程就會佔用 5 ms 的執行時間;
可見,取消了絕對時間片,進程的執行時間分配顯得更合理了。

不過,假設,進程增加到 20 個,那麼,每個進程就只能執行到 1ms,
假設進程數目持續增多,那麼每個進程執行的時間,就會趨向於 0,那麼幾乎所有的開銷,都要花在上下文的切換上了,
所以,CFS 設置了一個最小閾值,每一個進程的執行時間,都不會讓其小於 1 ms。
這樣,即使進程再多,也能保證進程切換的開銷被限制在一定範圍之內。

那麼,CFS 調度是如何實現的呢?

首先,所有的調度器都必須對進程運行做時間記賬。
CFS 雖然不再有時間片的概念,但是,它也必須要維護每個進程執行的時間記賬,因爲它也要保證每個進程只在公平分配給它的處理器時間內運行。
因此,用了一個 vruntime 變量來存放進程的虛擬運行時間,並且和定時器節拍無關。

記錄了時間之後,CFS 就試圖利用一個簡單的規則去均衡進程的虛擬運行時間:
挑一個 vruntime 最小的進程投入執行。
這就是 CFS 算法的核心。

於是,CFS 使用紅黑樹來組織可運行進程隊列,並利用其迅速找到最小 vruntime 值的進程。
紅黑樹在 Linux 中則被稱爲 rbtree,樹上的進程則會按照 vruntime 作爲鍵值來進行排列,
於是,每次調度,都只要從根節點,不斷往左找,找到最後一個葉子結點,就是下一次投入運行的進程。

於是,進程調度,就會從入口函數 schedule(),來選擇進程投入執行。

除了 CFS,其它操作系統,也是各有各的調度算法,
所以,由於底層調度的不確定性,程序的執行時機很難被保證。

因此,線程安全的問題是會經常存在的,
由此,才需要程序員有敏銳的直覺和洞察力,去判斷程序的可能結果,去對臨界資源進行加鎖保護。

線程池

既然我們可以很輕易地創建線程,並且賦予它指定的任務加以執行,
可是,爲什麼會有線程池這種東西?

一方面,是因爲線程需要進行良好的管理,不然,要是漏掉了一些線程在瘋狂做別的事情,但是我們還不知道,這是非常影響一個系統穩定性的。
此外,線程來自於哪,要做什麼,都進行分類,命名,統一管理,這樣,即使出現了問題,也易於排查錯誤。

管理是一方面,另一方面,就是性能的考慮了。
很多人都知道,線程的創建和銷燬都是非常耗費系統資源的,大量創建銷燬,會十分影響系統的性能,
因此,可以採用池化技術,將線程集中創建,重複利用,而不是用一次創建一次銷燬一次,
這樣,可以省去了系統對線程的頻繁創建的開銷,一定程度上提高性能。

如果,你不瞭解線程的話,那麼,你可能就只能像一般人一樣,回答出這麼幾句話,僅僅只提到了消耗資源。
不過,既然你已經看了上面那以小節對線程的描述,想必,你應該很瞭解操作系統所定義的線程,以及 Linux 對線程的實現了,
那麼,爲什麼消耗資源,想必你也能夠理解:
因爲創建和銷燬線程的時候,操作系統做了那麼多的事情。

不過除了這些,還有一個原因:
像進程的管理和控制,這樣的程序運行的管控,一般來說是必須牢牢把握在操作系統的手中的,而不能任由用戶控制和管理,這樣,就很可能對計算機的其它進程進行攻擊,或者惡意搗毀我們的系統。

所以,對於計算機操作系統來說,是分爲用戶態內核態的:
一些核心的指令只能由內核態運行,一些普通的指令,則可以由用戶執行。
這樣,用戶沒有權限去執行一些高危操作,也就無法破壞整個計算機的安全了。

不過,如果只這樣的話,那麼用戶空間的程序就做不了什麼事了,因爲很多關鍵的指令都被限制在了內核態才能執行的範圍;
所以,比如我們要創建文件,訪問網頁,那就無法做到了。

因此,爲了能讓用戶態的程序去能做到這些功能,操作系統則開放了一個小的入口,叫做:
系統調用

在用戶需要的時候,系統切換到內核態,由內核來代表應用程序在內核空間執行系統調用。
比如用戶調用 print() 函數,就會調用 c 語言庫中 printf() 函數,然後再是調用 c 庫中的 write() 函數,最後則由內核來執行 write() 系統調用。

所以,我們可以明白,像創建線程這樣的操作,必須是交由操作系統內核來執行的。

但是,爲什麼系統調用效率就會低呢?

我們時長聽說,用戶態內核態切換,會導致效率不高,但是從來沒有聽誰解釋過爲什麼會這樣,

首先,我們需要知道,系統調用,是通過中斷來實現的,
因爲,中斷是唯一能使用戶態轉變爲內核態的方式!!!

所以,要理解系統調用效率低的原因,首先,要先理解,什麼是中斷:

計算機指令的執行,都是由 PC(程序計數器)來指定的,PC 中記錄了指令存放的地址,因此,計算機執行指令的時候,就會去 PC 所指的地址,去取出程序執行,
然後,執行下一跳指令的話,就只要把 PC 的值 +1,改成下一跳指令的地址即可。

所以,要執行中斷處理程序的話,也就只要把 PC 的值,改成中斷程序的地址,這樣,就可以從該地址,讀出中斷服務程序,從而執行。

所以,看起來,似乎要中斷程序,沒什麼特殊的,就像普通的程序一樣,可以執行。

不過,其實不然。
首先,在一條程序執行結束後,先去檢查是否有中斷信號,
假設有,那麼就把 PC 的地址改爲中斷服務程序的地址,從而去執行中斷,
然後,繼續回來執行的原來的程序的話,就會產生一個問題,原來的程序執行到哪了???

那麼,假設你來想,這應該怎麼辦?

如果你覺得,可以放在程序執行結束後,就保存一下 PC,
那麼,每次執行一次程序,都會花費這樣一個操作,這肯定是十分浪費效率的;

假設,那麼在中斷處理程序中保存呢?
你就會發現,這時候,由於已經切換在中斷處理執行了,所以 PC 記錄的,已經是中斷服務程序的地址了,所以,之前的 PC 的值,已經丟失了,這時去存已經來不及了。

所以,你會發現,用程序來實現,是一個非常不友好的行爲。
所以,在切換到中斷服務程序的時候,會由硬件來實現 PC 的保存,這個操作就叫做中斷隱指令

所以,在切換到中斷處理程序的時候,由硬件實現了中斷隱指令操作:

  • 一個很重要的就是關中斷
    因爲,我們這裏要執行的任務是保存中斷現場,而這個操作是不能被中斷的,
    因爲,如果這個操作如果被中斷,加雜入其它中斷操作,就會導致這個中斷現場沒有被保存,
    那麼,程序之後就無法恢復到原樣,那麼應用程序就會直接崩潰。
  • 既然有關中斷的操作了,那麼就可以把斷點,也就是 PC 的值,安全的保存起來,以便中斷程序結束後,原程序可以接着向後執行。
  • 第三個,就是引出中斷服務程序了,可以執行中斷的處理。

這時,中斷服務程序可以開始執行了。
於是,程序的第一步,就是把之前通用寄存器和狀態寄存器的內容,保存起來;
這個操作就不用中斷隱指令來做了,因爲中斷程序的執行,第一步就是修改 PC,所以 PC 的值,就沒法放到中斷服務程序之中去做;
而這些寄存器的數據,則中斷服務程序啓動時都還在,於是就可以在中斷服務程序中,再來進行保存。

於是,接着就可以執行主體的中斷服務程序的邏輯。
執行完了之後,就要返回之前的用戶程序了,於是,就要把之前打亂的場地恢復,也就是所謂的恢復現場,把 PC、各個寄存器的值全都給按照原來的樣子放回去。
當然,最後不要忘了,要把關掉的中斷再打開,程序就可以繼續響應新的中斷了。

那麼,這就是一箇中斷。

不過,剛剛也提到了,系統調用,就是用的中斷,
那麼,系統調用又是怎麼做到的呢?

其實很簡單,這裏也就不費太多筆墨了,
首先要做的第一步就是,調用 movl 指令,將系統調用的參數,存入到寄存器當中,
一般,在 x86-32 系統上,ebx、ecx、edx、esi、edi 按照順序存放前五個參數(6 個或以上不多見),

然後,執行陷入指令,比如大家都常聽說的 int 0x80,80 中斷,
後來,x86 處理器又添加了一條叫做 sysenter 的指令,這條指令相比 int 指令,可以更快速、更專業地陷入內核執行系統調用,
這時,CPU 收到中斷信號,就會觸發中斷服務程序,也就會執行我們系統調用的函數,

最後,返回值給用戶,放在寄存器中。
比如 x86 系統,就是放在 eax 寄存器中。

實際上,要注意的一點是,由於系統調用,是由內核空間代爲執行的,所以關乎系統的安全與穩定,因此,是不能對隨意使用用戶傳遞的參數的,
所以,系統調用,有很重要的一部分內容,就是檢查用戶參數的合法性!

比如,與文件 I/O 有關的系統調用,就必須檢查每一個文件描述符是否有效;
與進程相關的系統調用,就必須檢查提供的 PID 是否有效;
所以每一個參數,都必須保證全部合法、並且有效、正確。

並且,對於指針,也必須嚴格檢查:
指針指向的區域必須屬於用戶空間,因爲用戶決不能哄騙內核去讀取內核空間的數據;
指針指向的區域也必須屬於進程的地址空間中,因爲進程決不能去哄騙內核讀其它進程的數據;
此外,如果內存被標記爲可讀,才能去讀,可寫,才能去寫,可執行,才能去執行,因爲進程絕對不能繞過內存的訪問限制。

所以,我們到這裏可以發現,在一次系統調用的過程之中,需要做這麼多一系列複雜的事情,
因此,頻繁進行系統調用,效率是不高的。

所以,很多時候,我們不應該過度使用系統調用,而是合理地去利用。
比如,線程池就是很充分地利用了操作系統的線程資源,而不是用一個丟一個;
同樣的,爲了合理利用網絡資源,也有了連接池,來避免不斷 TCP 連接帶來的資源消耗。

還有,爲什麼會有用戶態線程,也就是纖程、協程的出現?
其實,說白了也就是因爲內核中線程的開銷比較大。

本來,系統中只有進程,每個進程都保存着大量的信息,每次切換都要置換掉一大堆狀態;
於是,後來引入了線程,每次切換的話,進程空間不用動,不用切換頁表,只要把寄存器讓出來,CPU 讓出來,給另一個線程就行了;
不過,由於線程還是由內核管理,每次調度,都需要內核來執行,並且線程的佔有資源,還是相對比較多的,也大約至少要 1M 大小。
而協程,或者說纖程,則 4K 就足夠,切換調度也不需要經過操作系統內核。

所以,一般的計算機,啓上千個線程,基本上所有的開銷都要花在線程的調度切換上了,計算機真正用於執行任務反而會變得很少;
而採用協程的話,就可以啓動很多,10W+也可以達到。

什麼是鎖

對於併發中的線程,我們現在已經理解了;
而且,對於線程的調度,由於存在異步性,所以會在多線程代碼中產生各種各樣的可能結果。

因此,對於很多共享資源,爲了保證數據的安全,我們才需要,對資源進行加鎖。

附:很多小白會不理解加鎖是對線程加鎖,還是對對象加鎖。
其實,不難理解,線程就像一個在做事情的人,它要保護一些資源(對象、變量……),於是,它把房間門上了一把鎖(把一個鎖對象掛在房門上)。
比如 synchronized(object),這個 object 就作爲了一把鎖;
lock.lock(),這個 lock 對象就作爲了一把鎖。

那麼,對於我們 Java 程序員來說,一般用到的最頻繁的就是 synchronized 關鍵字,或者 Lock 接口,來實現鎖的機制。

如果你比較瞭解 JavaVM 的 synchronized 鎖的話,你應該知道,自 1.6 版本以來,synchronized 關鍵字做了很多的優化,比如:
偏向鎖、輕量級鎖、重量級鎖、鎖粗化、鎖消除……

如果你還不知道 synchronized,可以去看我之前的博客(文末有鏈接)

如果是 Lock 接口,則提供了 ReentrantLock、ReentrantReadWriteLock 等等實現。
在 synchronized 還沒有優化之前,JUC 包下的 ReentrantLock 則會擁有更好的效率;
同時,Lock 接口也提供了更豐富的 API,可以執行更個性的加解鎖操作。

不過,這些併發實現類,都是通過 AQS 類來實現的,
不僅僅 Lock,也包括 CountDownLatch、CyclicBarrier、Semaphore 等等。

所以,要理解 Java 提供的併發工具類,就得去理解 AQS 的實現原理。
如果,你還不會 AQS,可以去看我之前的博客(文末有鏈接)

那麼,我們現在可以思考一個問題,爲什麼有的鎖,效率高,有的鎖,卻效率低?
synchronized 當初被吐槽效率低,再加上 ReentrantLock 出現,所以後來就做了優化,來提高鎖的性能。

其實,因爲開始時,synchronized 是純重量級鎖,也就是無論如何都會調用操作系統的鎖來實現一把鎖。
而 ReentrantLock,再調用操作系統鎖之前,在 Java 層面就已經嘗試過直接加鎖,也就是,在非競爭條件下,是可以不去操作系統申請鎖的。
而 synchronized 優化之後,由於有了偏向鎖,在單線程的情況下,都可以省略加鎖操作,性能極高的,即便是出現了競爭,還有一個輕量級鎖過度,同樣不用去申請操作系統的重量級鎖,因此,可以保證高效。

那麼,爲什麼,向操作系統申請重量級鎖,性能就會低?

因爲,重量級鎖,意味着,在搶不到的鎖情況下,會阻塞線程,
而線程的阻塞與喚醒,這個代價是比較高的,
比如在 Linux 下:
首先,進程需要把自己添加到等待隊列,並且從可執行紅黑樹中移出;
然後調用 prepare_to_wait() 把進程的狀態修改爲休眠狀態;
由於存在僞喚醒的可能,所以還需要用循環來判斷是否條件真的成立;
並且,這些操作可能存在競爭情況,因此也還需要加鎖。

只有當條件滿足,進程被喚醒,那麼此時檢查條件確實爲真,纔會退出循環,
然後調用 finish_wait() 方法把自己移除等待隊列。

所以,就可以有如下類似僞代碼:

DEFINE_WAIT(wait);

add_wait_queue(q, &wait);
while(!condition) {
    prepare_to_wait(&q, &wait, TASK_INTERRUPTIBLE);
    if(signal_pending(current))
        處理信號
    schedule();
}
finish_wait(&q, &wait);

不過,有人還說,ReentrantLock 由於採用了 park() 阻塞,和 synchronized 不同,所以效率更高;
還有人說,park 導致線程 WAITING,而 synchronized 阻塞則是 BLOCKED,所以效率不同。

不過,我在上文已經提過了,WAITING、BLOCKED 都是對應操作系統同一種狀態,阻塞態,所以不存在什麼不同。
不過,由於具體細節沒有看到,所以大家仍然會猜測,park() 的效率是否和 synchronized 阻塞有所不同。

於是,我特地翻了一下源碼,給大家看一下調用過程:

public static void park() {
    UNSAFE.park(false, 0L);
}

可以發現,實際上就是調用了 Unsafe 的 park() 方法。
Unsafe 類大家應該是很熟悉了,很多原子操作,都是可以通過 Unsafe 來調用了,
比如阻塞、喚醒、還有 CAS 等等。

我們繼續看 Unsafe 的 park() 方法:

public native void park(boolean isAbsolute, long time);

這時,我們就會發現,已經是 native 方法了,所以我們要查看 JVM 是如何實現的,
我們一點點點進去:

{CC"park",               CC"(ZJ)V",                  FN_PTR(Unsafe_Park)},
UNSAFE_ENTRY(void, Unsafe_Park(JNIEnv *env, jobject unsafe, jboolean isAbsolute, jlong time))
  UnsafeWrapper("Unsafe_Park");
  EventThreadPark event;

......

  JavaThreadParkedState jtps(thread, time != 0);
  thread->parker()->park(isAbsolute != 0, time);
  
......

UNSAFE_END

我們可以發現,這裏面就是調用了 thread 的 parker() 方法,然後調用返回的 parker 的 park() 方法,
所以,我們就可以點進去確認一下:

public:
  Parker*     parker() { return _parker; }

可以看到,parker() 確實是返回了一個 Parker
我們再看 park() 方法:

void Parker::park(bool isAbsolute, jlong time) {
  ......
  
  int status ;
  if (_counter > 0)  { // no wait needed
    _counter = 0;
    status = pthread_mutex_unlock(_mutex);
    ......
  }
  ......
}

我們可以發現,實際上 park,本質上就是調用了 Linux 底層提供的 pthread_mutex_unlock 函數,來實現阻塞。

那我們接下來看一下 synchronized 的重量級鎖是怎麼做的。
不過,由於 synchronized 有很多邏輯,偏向、輕量、批量重偏向,等等複雜的邏輯,我就不一一貼代碼了。
我們直接看重量級鎖是調用了什麼方法:

void ATTR ObjectMonitor::enter(TRAPS) {
    ......
    
    for (;;) {
      jt->set_suspend_equivalent();
      // cleared by handle_special_suspend_equivalent_condition()
      // or java_suspend_self()

      EnterI (THREAD) ;

      if (!ExitSuspendEquivalent(jt)) break ;

      ......
    }
    ......
}

發現,是在 EnterI 方法中進了阻塞。
繼續點進去:

void ATTR ObjectMonitor::EnterI (TRAPS) {
    ......
    
    Self->_ParkEvent->park() ;
    
    ......
}

發現同樣是 park() 方法

void os::PlatformEvent::park() {       // AKA "down()"
	......
	
	int status = pthread_mutex_lock(_mutex);
	
	......
}

同樣調用了底層 pthread_mutex_lock 方法。

可見,實際上,LockSupport 的 park() 方法,和 synchronized 的阻塞,實際上一樣的,
所以,也不會有什麼性能上的差別。

實際上,我們一般在使用鎖的時候,要考慮的,是要用一種什麼方式的鎖:
比如,
是採用自旋鎖?還是阻塞鎖?還是可以分段?還是讀寫鎖?或者可以寫時複製?或者甚至可以無鎖?

我們 Java 中,經常討論到的就是 CAS(CompareAndSet),也就是一個原子操作。
CAS 這樣的操作貼近硬件,所以一般是作爲操作系統的原語,
特點是,這樣的指令是原子的,指令會一次執行完,而不可能出現競爭條件。
這在計算機底層是通過關中斷來實現的,執行指令的時候,就關閉中斷,所以在執行中就無法被打斷,直到執行完畢,才把中斷重新打開。

我們在很多場景下都會使用到這樣的原子指令,比如 Atomic 類,就會通過這樣的方式,來對數值進行修改操作。
主要是因爲,這樣的原子指令,不會阻塞線程,而是會直接執行成功,或者由於競爭條件,直接返回失敗。
所以,在進行一些短小精悍的指令的時候,就很適合 CAS + 自旋,
因爲,這不會阻塞線程,帶來的開銷就只有原語的執行,和額外自旋少量次數的開銷。
而如果採用阻塞的方式,則會使得開銷大得多。

那麼,所以一般等到一些複雜冗長的代碼段需要在加鎖期間執行的話,
那麼,如果期間自旋,就會使得額外的自旋數量很多,比如上百上千,
那麼此時,自旋帶來的損耗就會很大了,不像之前簡單的指令,自旋幾次,就能執行成功。
所以,此時,就更傾向於使用重量級鎖,阻塞線程,那麼開銷就還是會和之前一樣多,相比這時的自旋,就會顯得很少了。

這是加鎖的方式的區別。
不過,其實加鎖還有一些可以細究的地方。
比如,我們很多時候,其實可以優化一部分內容,因爲其本身不需要被鎖定,比如讀寫鎖:
所以,讀的時候,如果沒有寫,本身是不加鎖也沒問題的,
但是,如果會產生寫操作,那就得加鎖,
所以,爲了保證安全,又得讓讀不會由於加鎖而喪失大量的性能,因此出現了讀寫鎖,
讀的時候,加讀鎖,那就只有 CAS + 自旋的開銷,
而加寫鎖的時候,纔會進行阻塞,這樣,對性能的影響,只要寫不是特別多,就不會特別大。

當然,儘量不加鎖的方式,還有寫時複製(Copy on write),
也就是不加鎖,採用寫數據時,將原有數據拷貝的方式,寫入數據,
這樣,也可以減少鎖的開銷。
不過,要注意的是,由於複製也有一定的開銷,那就要和阻塞的開銷進行衡量,
只有在阻塞開銷大於複製開銷的情況下,才適合用這個方式。

除此以外,還有一種思維就是儘量減少競爭。
分段鎖就是這個思想。
比如,數據庫的行鎖,相比表鎖,競爭就會減少;
此外,還有 1.7 版本的 ConcurrentHashMap,就是採用了分段鎖;
1.8 的 ConcurrentHashMap 則是變成了數組的每一個槽位就是一把鎖,分段更加細粒度;
在 CAS+自旋 方面,還有 LongAdder,也是通過分段的方式,減少了自旋鎖的競爭。

當然,鎖的知識還有很多,比如 Synchronized 的鎖就有那麼多的優化,自旋、偏向、阻塞、批量、等等;
比如大名鼎鼎的 ReentrantLock,內部也實現了非常多的機制,來保證其效率;
以及 ReentrantReadWriteLock,讀寫鎖也採取了非常精妙的設計……

篇幅有限,我不可能對其中細節一一道來,大家可以嘗試去閱讀源碼,
或者,到文末鏈接,查看筆者其它的文章。

volatile

對於併發而言,除去上面所列的知識,還有一個很重要的點。
相信學過 Java 的同學也都知道 volatile 這個關鍵字,它的意義在於保證可見性,和禁止指令重排序。

那麼,要理解這些,就需要先了解,爲什麼會出現指令重排序和不可見的情況。

首先,我們的 Java 語言編譯成 class 的時候,是不會把指令重新排序的。
不過,在 CPU 執行的時候,就可能會根據執行的情況,去對指令做動態的排序調整,
並且,JIT 編譯熱點代碼的時候,也會做出一定的優化。

那爲什麼指令會重排序呢?
這裏簡單舉個例子,比如,有這麼兩條指令:
x = a;
y = b;

可能 CPU 在執行的時候,就會發現,a 的值,還在主存,而 b 的值,還在 Cache 中,
那麼,這時候,要去內存中把數據讀出來,緩存都可以讀好幾個了,
所以,CPU 就可能在 a 的值還沒讀到的時候,就把 b 的值讀到了,
所以,y = b 這條指令,反而比上一條指令先執行完。

不過,排序也不是能隨便亂排的,否則,運行結果錯亂了怎麼整?

所以,在指令重排的過程,要遵循 as-if-serial 語義,
英文翻譯一下就是,好像就是串行的,
as-if-serial:不管按照什麼順序排序,單線程執行的結果不變,看上去像是在順序執行一樣。

所以,程序執行的時候,只是單線程,就不會因爲重排序產生錯誤。

爲了保證不會出錯,處理器在執行的時候,就不會對有上下文關係的指令進行重排,比如:

a = 1;
b = a;

這樣的話,因爲 b 的賦值,要依賴於 a,所以這裏就不能夠進行重排序,
否則,b 的值要從哪裏取?

不過,假設是多線程,就會出現有意思的情況,
假設此時,我們的 a 和 b 的變量,都是 0,然後分兩個線程,分別執行下面的代碼:
(左邊是一個線程,右邊時一個線程)

a = 1;              b = 1;
x = b;              y = a;

如果沒有重排序的話,那麼最終,結果應該是 x 和 y 至少有一個 1;
但是,實際上,這個程序,大約每運行 6000多次,就會因爲重排序,而產生 x 和 y 都爲 0 的結果,
你也可以回去試一下。

因爲,在每個獨立的線程中,兩條指令,都沒有上下依賴的關係,所以就可能產生重排序,
然而,再兩個線程的程序之中,卻有變量相互關聯影響。
而這時,我們的 CPU 是不知情的,它還是會按照自己的邏輯,去重排序我們的指令。

所以,我們在多線程的代碼中,就需要額外注意,要對不安全的數據,進行禁止指令重排!

在 Java 中,那就比較簡單,只需要加一個 volatile 關鍵字即可。
不過,這只是由於 Java 語言,幫我們屏蔽了底層的細節。
假設,我們用 c 語言,或者彙編,都需要手動去添加屏障。

我們再看另一個問題,就是可見性問題,

首先,我們都知道,我們的計算機一般都會在 CPU 和內存之間,還要有一層緩存,
比如,一般我們的電腦,都會有 L1、L2、L3 三級緩存。

每個 CPU 由於自己都有緩存,所以,就存在這樣一種情況,
一個 CPU,把 a 的值,讀入緩存;
另一個 CPU,把 a 的值,讀入它自己的緩存;
然後,一個 CPU 把 a 的值改了,另一個 CPU 緩存中的值,卻還是原來的!
那,這樣的話,結果肯定是有問題的,所以才需要去對這種情況做一個糾正。

對於緩存的一致性,一個廣泛應用的協議就是 MESI 協議,這個的話,瞭解即可,我們 Java 程序員不必過於深究。
它是對 CPU 中的每個緩存行,用額外兩個比特標記,一共有 4 種狀態:

  • 修改態(Modified)— 此 cache 行已被修改過(髒行),內容已不同於主存,爲此 cache 專有;
  • 專有態(Exclusive)— 此 cache 行內容同於主存,但不出現於其它 cache 中;
  • 共享態(Shared)— 此 cache 行內容同於主存,但也出現於其它 cache 中;
  • 無效態(Invalid)— 此 cache 行內容無效(空行)。

這樣,假設兩個 CPU 都把同一個數據讀入了緩存,那麼,此時就是共享態(Shared);
然後,一個 CPU 修改了緩存的值,於是這個 CPU 的緩存行狀態,就得修改爲 Modified,
並且,要通知其它的 CPU,這時,它們的狀態就會改成 Invalid,無效態;
因此,它們要使用該數據時,就會重新從內存中讀取,而不會使用緩存中不一致的值。

這樣的話,多處理器時,單個 CPU 對緩存中數據進行了改動,需要通知給其他 CPU。
也就是意味着,CPU 處理要控制自己的讀寫操作,還要監聽其他 CPU 發出的通知,從而保證最終一致性。

除了緩存帶來的可見性問題之外,還有其它原因,會造成數據之間的不可見,
比如下面這個很經典的小程序:

public class Demo1Visibility {
    int i = 0;
    boolean isRunning = true;

    public static void main(String args[]) throws InterruptedException {
        Demo1Visibility demo = new Demo1Visibility();
        // 新建線程,在true的情況下不斷print出i的值
        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("子線程開始i++操作");
                while(demo.isRunning){
                    demo.i++;
                }
                // 主線程將變量設置爲false時退出循環
                System.out.println("我已退出,當前值爲:" + demo.i);
            }
        }).start();
        // 3秒後設置變量爲false
        Thread.sleep(3000L);
        demo.isRunning = false;
        System.out.println("改變變量爲false,主線程結束...");
    }
}

這個程序,一般在 server VM 下,是不會結束的。
如果你以前沒有了解過,那麼可能會大跌眼鏡,竟然 isRunning 改爲 false,但是 while 卻不會終止循環???
當然,這個題有很多變種,我在這裏就不一一列出來了,想要了解的讀者可以從文末鏈接前往查看

這就既涉及到了可見性,又涉及到了重排序優化的問題:
因爲 while 循環被執行了很多很多次,所以被認爲是熱點代碼,就會觸發 JIT 優化,
於是,我們就會發現代碼發生如下變化:

while(isRunning){
    i++;
}
if(isRunning)
	while(true){
	    i++;
	}
}

發現,在編譯時,指令就被進行重排序了,因此,代碼陷入死循環,將無法結束。
所以,對於 while 循環條件來說,isRunning 的值已經不可見了,所以,也喪失了可見性。

於是,我們給原代碼,isRunning 字段添加 volatile 關鍵字之後,程序就會正常終止,
因爲 volatile 要保證可見性,同時也禁止了重排序,因此,JIT 就無法執行這個優化。

下面,瞭解了什麼是 volatile 之後,我們就繼續探究。

首先,Java 程序中的變量,添加了 volatile 關鍵字之後,
我們將其編譯,在 class 字節碼層面,就會被標記 ACC_VOLATILE,
隨後,JavaVM 對這些變量的讀寫,都會加上內存屏障。

在概念上,屏障分爲 4 種:

  • 讀讀屏障 LoadLoadBarrier
  • 讀寫屏障 LoadStoreBarrier
  • 寫讀屏障 StoreLoadBarrier
  • 寫寫屏障 StoreStoreBarrier

而 JVM,對於 volatile 變量的讀寫,就會分別加上這些屏障:

  • LoadLoadBarrier
    volatile 讀
    LoadStoreBarrier
  • StoreStoreBarrier
    volatile 寫
    StoreLoadBarrier

通過加上內存屏障,就可以保證指令不會重排序,同時也可以保證可見性。
因爲,屏障設立的目的就是,攔住前後的指令,不讓它們出錯,比如:

a = 1;
StoreStoreBarrier
b = a;

通過屏障,就是要保證,這個 b 獲取 a 的數據,要是準確的,
所以 b 要獲取 a 的時候,a 變量就必須已經被準確完好地賦值成功,然後由 b 讀取。

而內存屏障的實現,是我們 Java 程序員不用過多關心的,
因爲在不同的CPU架構上內存屏障的實現非常不一樣,所以,我們更多的,是知道內存屏障的作用即可。

在 Linux 內核中,則提供了 rmb() 讀屏障,wmb() 寫屏障,以及 mb() 讀寫屏障;
此外,Linux 還提供了 read_barrier_depends() 這樣的依賴讀屏障,也就是隻屏障住了那些有依賴關係的讀操作。

在 Intel x86 上,則提供了 ifence 讀屏障、sfence 寫屏障、mfence 讀寫屏障這樣的原語;
此外,”lock” 指令是一個Full Barrier,執行時會鎖住內存子系統來確保執行順序,甚至跨多個CPU。

文末

閱讀到這裏,相比你對併發的理解,應該算是更深刻些了吧。

畢竟文章篇幅有限,很多細節,我也沒法一一列舉出來,不過,筆者認爲,對於併發編程的重要知識,也算是大體的羅列了一些。

這裏,我偏重於講解了一些併發的偏基礎的一些知識,其實也不僅僅是對於 Java,對於任何一門語言都是如此。

而對於其它要學的知識,不可否認,還有很多,比如很多併發工具類等等,還有很多 Java 的源碼。

所以,學習的路還有很遠。

最後,給出一些,本文章涉及到知識點的筆者的其它博客,因爲篇幅有限,並且也已寫過,在這裏便沒有詳細寫。

99%的人答不對的併發題(可見性,原子性,synchronized 原理)

AQS互斥鎖源碼講解(基於ReentrantLock)

線程池源碼分析

synchronized(對象頭、批量重偏向、延遲偏向、線程欺騙)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章