02.28 Java併發基礎知識

為什麼使用併發

1. 單核CPU時代,程序中會存在大量IO操作(磁盤I/O、網絡通信或者數據庫訪問),由於IO阻塞等待的時間消耗往往是遠遠大於線程切換所消耗的時間,使用併發可以提高CPU和IO設備的綜合利用率。

2.多核CPU時代,Amdahl定律代替摩爾定律成為計算機性能發展源動力的根本原因.每個線程可以使用自己的CPU運行,減少了線程上下文切換的開銷,互不干涉的多個線程可以真正的並行執行, 可以創建更多的程序提高程序性能和吞吐量,可以充分利用CPU的運算能力。

3.方便業務拆分,提升性能。把單獨的任務放在不同的線程裡面執行,通過共享存儲合併結果,

併發的注意事項

線程安全性問題

線程安全問題是指當多個線程同時讀寫一個共享資源並且沒有任何同步措施時,導致出現髒數據或者其他不可預見的結果的問題。

共享資源,就是說該資源被多個線程所持有或者說多個線程都可以去訪問該資源。

不同線程同一時間操作共享變量,讀取到的數據可能會不一致,寫入的數據更改也可能會丟失。

出現線程安全的問題一般是因為主內存和工作內存數據不一致性和指令重排序導致的。

頻繁的上下文切換

在多線程編程中,線程個數一般都大於CPU個數,而每個CPU同一時刻只能被一個線程使用,為了讓用戶感覺多個線程是在同時執行的,CPU資源的分配採用了時間片輪轉的策略,也就是給每個線程分配一個時間片,線程在時間片內佔用CPU執行任務。當前線程使用完時間片後,就會處於就緒狀態並讓出CPU讓其他線程佔用,這就是上下文切換,從當前線程的上下文切換到了其他線程。

死鎖

死鎖是指兩個或兩個以上的線程在執行過程中,因爭奪資源而造成的互相等待的現象,在無外力作用的情況下,這些線程會一直相互等待而無法繼續運行下去。


Java併發基礎知識

死鎖

線程A持有資源2不釋放的同時還想持有資源1;並且線程B持有資源1不釋放的同時還想持有資源2。線程A和線程B互相等待對方持有的資源,形成死鎖。

死鎖的四個必要條件:互斥條件,請求並持有條件,不可剝奪條件和環路等待條件。

避免死鎖的方法:1. 鎖設置過期時間 2.按某一順序申請資源,釋放資源則反序釋放

併發理論

高速緩存


Java併發基礎知識

高速緩存和緩存一致性協議

CPU的處理速度和主存的讀寫速度不是一個量級的,為了平衡這種巨大的差距,每個CPU處理器都有讀寫速度儘可能接近處理器運算速度的高速緩存(Cache)來作為內存與處理器之間的緩衝:將運算需要使用的數據複製到緩存中,讓運算能快速進行,當運算結束後再從緩存同步回內存之中。當多個處理器的運算任務都涉及同一塊主內存區域時,將可能導致各自的緩存數據不一致。為了解決一致性的問題,各個處理器在訪問緩存時都遵循某個協議(MSI等),在讀寫時根據協議來進行操作。

亂序執行優化/指令重排序

為了使處理器內部的運算單元能儘量被充分利用,處理器可能會對輸入代碼進行亂序執行(Out-Of-OrderExecution)優化,處理器會在計算之後將亂序執行的結果重組,保證該結果與順序執行的結果是一致的,但並不保證程序中各個語句計算的先後順序與輸入代碼中的順序一致,因此如果存在一個計算任務依賴另外一個計算任務的中間結果,那麼其順序性並不能靠代碼的先後順序來保證。

Java虛擬機的即時編譯器中有指令重排序(Instruction Reorder)優化,相當於亂序執行優化。

Java內存模型


Java併發基礎知識

Java內存模型

所有的變量都存儲在主內存(Main Memory)中(相當於主存,物理上它僅是虛擬機內存的一部分)。每條線程還有自己的工作內存(Working Memory,相當於處理器高速緩存),線程的工作內存中保存了被該線程使用的變量的主內存副本,線程對變量的所有操作(讀取、賦值等)都必須在工作內存中進行,而不能直接讀寫主內存中的數據。不同的線程之間也無法直接訪問對方工作內存中的變量,線程間變量值的傳遞均需要通過主內存來完成。主內存與工作內存之間具體的交互協議,即一個變量如何從主內存拷貝到工作內存、如何從工作內存同步回主內存,是由以下8種操作來完成。

  • lock(鎖定):作用於主內存的變量,它把一個變量標識為一條線程獨佔的狀態。對一個變量執行lock操作,那將會清空工作內存中此變量的值,在執行引擎使用這個變量前,需要重新執行load或assign操作以初始化變量的值。
  • unlock(解鎖):作用於主內存的變量,它把一個處於鎖定狀態的變量釋放出來,釋放後的變量才可以被其他線程鎖定。對一個變量執行unlock操作之前,必須先把此變量同步回主內存中(執行store、write操作)。
  • read(讀取):作用於主內存的變量,它把一個變量的值從主內存傳輸到線程的工作內存中,以便隨後的load動作使用。
  • load(載入):作用於工作內存的變量,它把read操作從主內存中得到的變量值放入工作內存的變量副本中。
  • use(使用):作用於工作內存的變量,它把工作內存中一個變量的值傳遞給執行引擎,每當虛擬機遇到一個需要使用變量的值的字節碼指令時將會執行這個操作。
  • assign(賦值):作用於工作內存的變量,它把一個從執行引擎接收的值賦給工作內存的變量,每當虛擬機遇到一個給變量賦值的字節碼指令時執行這個操作。
  • store(存儲):作用於工作內存的變量,它把工作內存中一個變量的值傳送到主內存中,以便隨後的write操作使用。
  • write(寫入):作用於主內存的變量,它把store操作從工作內存中得到的變量的值放入主內存的變量中。

Java內存模型是共享內存的併發模型,線程之間主要通過讀-寫共享變量來完成隱式通信。

在Java程序中所有實例域,靜態域和數組元素都是放在堆內存中(所有線程均可訪問到,是可以共享的);而局部變量,方法定義參數和異常處理器參數不會在線程間共享。共享數據可能會出現線程安全的問題。

因此,共享變量會先放在主內存中,每個線程都有屬於自己的工作內存,並且會把位於主內存中的共享變量拷貝到自己的工作內存,之後的讀寫操作均使用位於工作內存的變量副本, 並在某個時刻將工作內存的變量副本寫回到主存中去。

happens-before規則

如果一個操作happens-before另一個操作,那麼第一個操作的執行結果將對第二個操作可見,而且第一個操作的執行順序排在第二個操作之前。兩個操作之間存在happens-before關係,並不意味著Java平臺的具體實現必須要按照happens-before關係指定的順序來執行。如果重排序之後的執行結果,與按happens-before關係來執行的結果一致,那麼這種重排序並不非法(也就是說,JMM允許這種重排序)。

1. 程序順序規則:一個線程中的每個操作,happens-before於該線程中的任意後續操作。

2. 監視器鎖規則:對一個鎖的解鎖,happens-before於隨後對這個鎖的加鎖。

3. volatile變量規則:對一個volatile域的寫,happens-before於任意後續對這個volatile域的讀。

4. 傳遞性:如果A happens-before B,且B happens-before C,那麼A happens-before C。

5. start()規則:如果線程A執行操作ThreadB.start()(啟動線程B),那麼A線程的ThreadB.start()操作happens-before於線程B中的任意操作。

6. join()規則:如果線程A執行操作ThreadB.join()併成功返回,那麼線程B中的任意操作happens-before於線程A從ThreadB.join()操作成功返回。

7. 程序中斷規則:對線程interrupted()方法的調用先行於被中斷線程的代碼檢測到中斷時間的發生。

8. 對象finalize規則:一個對象的初始化完成(構造函數執行結束)先行於發生它的finalize()方法的開始。

原子性,有序性,可見性

在多線程下i++不加以注意的也容易出現線程安全的問題,這就是非原子操作導致的問題。

DCL(雙重檢驗鎖),這就是需要禁止重排序。

可能出現數據“髒讀”的現象,這就是數據可見性的問題。

線程


Java併發基礎知識

一個進程有多個線程

進程是代碼在數據集合上的一次運行活動,是系統進行資源分配和調度的基本單位,線程則是進程的一個執行路徑,一個進程中至少有一個線程,進程中的多個線程共享進程的資源。操作系統在分配資源時是把資源分配給進程的,但是CPU資源比較特殊,它是被分配到線程的,因為真正要佔用CPU運行的是線程,所以也說線程是CPU分配的基本單位。

線程是比進程更輕量級的調度執行單位,線程的引入,可以把一個進程的資源分配和執行調度分開,各個線程既可以共享進程資源(內存地址、文件I/O等),又可以獨立調度。

一個進程中有多個線程,在Java中多個線程共享進程的堆和方法區資源,但是每個線程有自己的程序計數器和棧區域。程序計數器是一塊內存區域,用來記錄線程當前要執行的指令地址。每個線程都有自己的棧資源,用於存儲該線程的局部變量和調用棧幀。堆是一個進程中最大的一塊內存,堆是被進程中的所有線程共享的,是進程創建時分配的,堆裡面主要存放使用new操作創建的對象實例。方法區則用來存放JVM加載的類、常量及靜態變量等信息,也是線程共享的。

Java線程的操作

  • wait 阻塞掛起當前線程,釋放鎖。(需要獲取到鎖才能調用)
  • notify和notifyAll 線程被喚醒後,需要重新競爭鎖,獲取到鎖才可以返回(需要獲取到鎖才能調用)
  • join 等待線程執行終止,當前線程會被阻塞。可以通過interrupt方法中斷當前線程,當線程運行結束的時候,notify是被線程的子系統調用的。
  • sleep 讓線程睡眠,睡眠期間不參與CPU的調度,不會釋放鎖。
  • yield 讓出CPU執行權,只是讓出剩餘的時間片,沒有阻塞掛起,處於就緒狀態。
  • interrupt 中斷線程,線程實際並沒有被中斷,它會繼續往下執行,java的中斷並不是真正的中斷線程,而只設置標誌位(中斷位)來通知用戶。如果因為wait系列函數,join方法或者sleep方法被阻塞掛起,會拋出InterruptedException異常而返回。一旦方法拋出InterruptedException,當前調用該方法的線程的中斷狀態就會被jvm自動清除,因為如果你捕獲到中斷異常,說明當前線程已經被中斷,不需要繼續保持中斷位。
  • isInterrupted 檢測當前線程是否被中斷,如果是返回true,否則返回false。
  • interrupted 獲取當前調用線程的中斷標誌,如果線程被中斷,會清除中斷標誌。

sleep和wait的區別

兩者相同之處在於:他們都可以暫停線程的執行。

兩者最主要的區別在於:sleep方法沒有釋放鎖,而wait方法釋放了鎖。

兩者的其他區別在於:wait方法需要獲取到鎖才能執行,sleep方法不需要。wait方法通常被用於線程間交互/通信,sleep方法通常被用於暫停執行。wait方法被調用後,線程不會自動甦醒,需要別的線程調用同一個對象上的notify或者 notifyAll方法。或者可以使用wait(long timeout)超時後線程會自動甦醒。sleep方法執行完成後,線程會自動甦醒。

Java線程的狀態切換

  • 初始狀態(NEW) :線程還沒有調用start方法
  • 運行狀態(RUNNABLE):包含操作系統線程狀態中的就緒和運行兩種狀態,就緒狀態是指獲取到除CPU資源外的其他資源,運行的狀態是指獲取到系統調度的CPU資源。yield會導致讓出CPU執行權,處於就緒狀態,有可能接著就獲取到CPU資源從而進行運行中狀態。
  • 阻塞狀態(BLOCKED):等待進入synchronized方法或者塊時會處於阻塞狀態,獲取到鎖進入運行狀態。
  • 等待狀態(WAITING):調用Object.wait方法,Thread.join方法,LockSupport.park方法會進入等待狀態,當調用Object.notify方法和notifyAll方法,以及LockSupport.unpark方法進入運行狀態。
  • 超時等待狀態(TIMED_WAITING):調用Thread.sleep(long)方法,Object.wait(long)方法,Thread.join(long)方法,LockSupport.parkNanos()方法,LockSupport.parkUnit()方法會進入超時等待狀態,當調用Object.notify方法和notifyAll方法,LockSupport.unpark方法,以及超時時間到進入運行狀態。
  • 終止狀態(TERMINATED): 任務正常執行完成

synchronized 關鍵字

synchronized 關鍵字可以保證被它修飾的方法或者代碼塊在任意時刻只能有一個線程執行。

synchronized 關鍵字加到 static 靜態方法和 synchronized(class)代碼塊上都是給 Class 類上鎖。

synchronized 關鍵字加到實例方法上是給對象實例上鎖。

synchronized 同步語句塊的實現使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代碼塊的開始位置,monitorexit 指令則指明同步代碼塊的結束位置。

JDK1.6 對鎖的實現引入了大量的優化,如適應性自旋(Adaptive Spinning)、鎖消除(Lock Elimination)、鎖膨脹(Lock Coarsening)、輕量級鎖(Lightweight Locking)、偏向鎖(Biased Locking)等技術來減少鎖操作的開銷。


Java併發基礎知識

自旋鎖是讓線程執行一個忙循環。

適應性自旋由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定的。如果在同一個鎖對象上,自旋等待剛剛成功獲得過鎖,並且持有鎖的線程正在運行中,那麼虛擬機就會認為這次自旋也很有可能再次成功,進而允許自旋等待持續相對更長的時間,比如持續100次忙循環。另一方面,如果對於某個鎖,自旋很少成功獲得過鎖,那在以後要獲取這個鎖時將有可能直接省略掉自旋過程,以避免浪費處理器資源。

輕量級鎖加鎖步驟是虛擬機首先將在當前線程的棧幀中建立一個名為鎖記錄(Lock Record)的空間,用於存儲鎖對象目前的Mark Word的拷貝,然後,虛擬機將使用CAS操作嘗試把對象的Mark Word更新為指向LockRecord的指針。如果這個更新動作成功了,即代表該線程擁有了這個對象的鎖,並且對象Mark Word的鎖標誌位(Mark Word的最後兩個比特)將轉變為“00”,表示此對象處於輕量級鎖定狀態。

偏向鎖加鎖過程是當鎖對象第一次被線程獲取的時候,虛擬機將會把對象頭中的標誌位設置為“01”、把偏向模式設置為“1”,表示進入偏向模式。同時使用CAS操作把獲取到這個鎖的線程的ID記錄在對象的Mark Word之中。如果CAS操作成功,持有偏向鎖的線程以後每次進入這個鎖相關的同步塊時,虛擬機都可以不再進行任何同步操作。一旦出現另外一個線程去嘗試獲取這個鎖的情況,偏向模式就馬上宣告結束。根據鎖對象目前是否處於被鎖定的狀態決定是否撤銷偏向(偏向模式設置為“0”),撤銷後標誌位恢復到未鎖定(標誌位為“01”)或輕量級鎖定(標誌位為“00”)的狀態。

鎖消除是指虛擬機即時編譯器在運行時,對一些代碼要求同步,但是對被檢測到不可能存在共享數據競爭的鎖進行消除。鎖消除的主要判定依據來源於逃逸分析的數據支持。

volatile 關鍵字

主要作用就是保證變量的可見性然後還有一個作用是防止指令重排序。

多線程訪問volatile關鍵字不會發生阻塞,而synchronized關鍵字可能會發生阻塞

volatile關鍵字能保證數據的可見性,但不能保證數據的原子性。synchronized關鍵字兩者都能保證。

volatile關鍵字主要用於解決變量在多個線程之間的可見性,而 synchronized關鍵字解決的是多個線程之間訪問資源的同步性。

ThreadLocal

提供了線程本地變量,創建一個ThreadLocal變量後,每個線程都會複製一個變量到自己的本地內存。當多個線程操作這個變量時,實際操作的是自己本地內存裡面的變量,從而避免了線程安全問題。


分享到:


相關文章: