《Java後端知識體系》系列之併發編程基礎(二)

併發編程二

1、多線程併發編程

  • 併發:同一時間段內多個任務同時都在執行

  • 並行:同一時刻多個任務同時在執行

  • 總結:多核CPU意味着每個線程可以有自己的CPU運行,這減少了線程上下文切換的開銷。

2、線程安全問題

線程安全:線程安全問題是指多個線程同時讀寫一個共享資源並且沒有任何同步措施,導致出現了髒數據或者其它不可預見的結果。
解決方法:爲了解決多線程訪問共享變量的問題,在Java中可以使用synchronized關鍵字來解決線程安全問題。

3、共享變量的內存可見性

對於內存可見性,可以通過多線程下處理共享變量時的Java模型,如下:
在這裏插入圖片描述
Java內存模型規定,將所有的變量都存放在主存中,當線程使用變量時,會把主內存中的變量複製到自己的工作空間或者工作內存中,線程讀寫操作時改變的是自己工作內存中的變量,處理完之後將變量更新到主內存中。

但是在共享變量中還存在的一個問題是共享變量內存不可見的問題,這是因爲多個線程在獲取數據時先從自己的工作內存中獲取,而其它線程已經修改了主內存中的值,就導致了其他線程並沒有獲取到主內存中的值。
爲了解決這個內存可見性的問題,可以使用Java中的volatile關鍵字。

4、synchronized

  • synchronized是JVM層面的同步鎖,Java中每個對象都可以把它當作同步鎖來使用,synchonized鎖是使用者看不到的內置鎖所以被成爲內置鎖,也叫做監視器鎖。
  • 線程的執行代碼進入synchronized代碼塊前會自動獲取內部鎖,這時候其它線程訪問該同步代碼塊時會被阻塞掛起。內置鎖是排他鎖,也就是當一個線程獲得這個鎖之後,其它線程必須等待該線程釋放鎖之後才能獲取該鎖。
  • 另外,由於Java的線程是與操作系統的原生線程一一對應的,所以當阻塞一個線程時,需要從用戶態切換到內核態執行阻塞操作,這是很耗時的操作,而synchronized的使用會導致上下文切換。

synchronized的內存語義:

共享變量的內存可見性主要是由於線程的工作內存導致的。進入synchronized塊的內存語義是把在synchronized塊內使用到的變量從線程的工作內存中清除,這樣synchronized塊內使用的該變量時就不會從線程的工作內存中獲取了,而是從主內存中獲取。退出synchronized塊內存語義是把synchronized塊內把共享變量的修改刷新到主內存中。

底層分析:

通過字節碼分析synchronized來說,synchronized修飾在方法上時,會通過一個ACC_SYNCHRONIZED的標誌,修飾代碼塊時是使用monitorenter和monitorexit關鍵字,不管採用哪種方式,在本質上都是對對象的監視器(monitor)進行獲取,而獲取監視器的過程是排他的,也就是同一個時刻只有一個線程可以獲得同步對象的監視器。

synchronized的同步鎖實際上是存在對象的對象頭(對象分爲三部分對象頭、實例數據、對齊填充)中,順着monitorenter進行分析可以看出,synchronized會沿着偏向鎖----》輕量級鎖----》重量級鎖的路徑來實現的加鎖過程的。

重量級鎖是通過對象內部的監視器(monitor)來實現的,而monitor的本質是依賴操作系統中的MutexLock來實現的,

鎖升級過程:

  1. 首先通過自旋操作來獲取監視器中的鎖,判斷該鎖是否爲重量級鎖,如果是重量級鎖那麼獲取對象監視器直接返回,
  2. 但是如果有其它線程操作了該監視器並且在進行鎖升級,那麼當前線程會通過自旋等待其它線程升級完成;
  3. 如果當前鎖是輕量級鎖,那麼首先獲取對象監視器,通過CAS設置對象頭中的屬性爲鎖升級狀態,如果CAS失敗,則繼續自旋;
  4. 如果當前是無鎖的狀態,那麼首先通過CAS操作來進行一個鎖競爭的過程,競爭到鎖那麼返回對象監視器。

因此鎖升級的過程其實就是獲得一個ObjectMonitor對象監視器。

鎖競爭的過程:

  1. 首先通過CAS操作將monitor的_owner設置爲當前線程,如果設置成功則直接返回,
  2. 如果之前的_owner指向的是當前線程,說明是重入,執行_recursions++增加重入次數,
  3. 如果當前線程獲取監視器鎖成功,將_recursions設置爲1,_owner設置爲當前線程,
  4. 如果獲取鎖失敗,則需要通過自旋的方式等待鎖釋放。

因此總結爲:通過自旋、CAS設置監視器monitor的_owner字段爲當前線程,如果成功則獲取到了鎖,如果失敗則繼續被掛起。

鎖釋放過程:

  1. 判斷當前線程中的owner有沒有指向該線程,如果owner指向在當前線程上,那麼將_owner指向當前線程
  2. 如果當前鎖對象中的_owner指向當前線程,則判斷當前鎖的重入次數,如果不爲0,繼續執行monitorexit,直到重入鎖次數爲0爲止。
  3. 釋放當前鎖,並根據QMode模式判斷,是否將掛起的線程喚醒,還是執行其它操作。

5、volitile

  • 通過synchronized來解決共享變量的內存可見性是比較笨重的,因爲這會帶來線程上下文的切換開銷。因此可以使用volatile來解決共享變量的內存可見性。
  • 該關鍵字可以保證一個線程對變量的更新其它線程馬上可見,當一個變量被聲明爲volatile時,線程在寫入變量時不會把值緩存在其它寄存器或者其它地方,而是直接刷回主內存中,這樣當其它線程訪問該變量時,會從主內存中獲取到最新值,而不是使用線程中工作內存中的值。
  • synchronized與volatile相比,都可以解決共享變量的內存可見性,但是synchronized是獨佔鎖,同時只有一個線程調用synchronized修飾的方法或者代碼塊,其它線程會被阻塞,同時也會存在線程上下文切換和線程調度的開銷,而volatile是非阻塞算法,不會造成線程上下文切換的開銷。volatile雖然可以實現共享變量的內存可見性,但是卻不能保證原子性。
  • volatile不僅能夠保證變量的內存可見性還可以實現禁止重排序,程序爲了提高性能,編譯器和處理器會對指令做重排序,重排序分爲三類:編譯器優化重排序、指令集並行的重排序、內存系統的重排序。而且有序性會帶來可見性的問題,所以可以通過內存屏障指令來進行特定類型的處理器重排序。
  • 說到可見性就要說到Java內存模型(JMM),內存模型分爲:原子性、可見性、有序性。其中原子性可以通過總線鎖或緩存鎖ll來實現,而有序性和可見性可以通過內存屏障來解決。JMM定義了線程和內存的交互方式,在JMM的抽象模型中,分爲主內存、工作內存,主內存是所有線程共享的,一般是實例對象、靜態字段、數組對象等存儲在堆內存中。工作內存是每個線程獨佔的,線程對變量的所有操作都必須在工作內存中進行,不能直接讀寫主內存中的變量,線程之間的共享變量值的傳遞都是基於主內存來完成。
  • 如果要把一個變量從主內存中複製到工作內存,就需要按照順序的執行read和load操作,如果把變量從工作內存中同步到主內存中,就需要按照順序執行store和write操作。爲了正確實現volatile的內存語義,JMM採用保守策略,在每個volatile寫的後面或者在每個volatile讀的前面插入一個StoreLoad屏障。

6、CAS操作

  • 通過以上的synchronized鎖的瞭解,當一個線程沒有獲得鎖的時候會被阻塞掛起,這就會導致線程的上下文切花和重新調度的開銷。雖然volatile可以解決可見性以及有序性,但是不能保證原子性。這時就需要CAS。CAS是一種樂觀鎖的思想,CAS即Compare
    And
    Swap(比較與交換),它是JDK提供的非阻塞原子性操作,並且JDK中的Unsafe類提供了一系列的compareAndSwap方法。
  • 在synchronized中的自旋操作就是通過CAS來實現的,同時在AQS中設置state狀態值時也是通過CAS來實現的,CAS能夠保證獲取到的值都是最新值,CAS中有四個操作數,分別是:對象的內存位置、對象中變量的偏移量、變量預期值、新值。CAS是處理器提供的一個原子性指令。
  • 但是CAS也存在ABA的問題,ABA的問題爲:線程1獲取到變量值爲A,等線程1修改了變量值準備修改變量值時,其它線程卻已經修改了變量的值爲B,並又修改回了A,因此線程1在更新變量值時發現值並沒有變,於是更新,但是變量已經被修改了但是又修改回了原值而已。對於ABA的問題可以通過版本控制來解決,每修改一次版本+1,還可以通過時間戳來解決,通過時間戳記錄每次修改的時間,JDK中的AtomicStampedReference類就是通過時間戳的方式來避免ABA的問題的。

7、Unsafe類

  • JDK中的Unsafe類提供了硬件級別的的原子性操作,Unsafe類中的方法都是native方法,它們使用JNI的方式訪問本地C++實現庫。
public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5);

該方法中通過比較對象var1中偏移量var2的變量值是否與var4相等,相等則使用update更新,然後返回true否則返回false。

void park(boolean isAbsolute, long time):

阻塞當前線程,其中參數isAbsolute等於false且time等於0表示一直阻塞,time大於0則表示等待時間time後阻塞線程會被喚醒

void unpark(Object thread)

該方法喚醒用park後阻塞的線程。

    public final int getAndSetInt(Object var1, long var2, int var4) {
        int var5;
        do {
            var5 = this.getIntVolatile(var1, var2);
        } while(!this.compareAndSwapInt(var1, var2, var5, var4));

        return var5;
    }

以上方法中通過getIntVolatile獲取當前變量的最新值,然後使用CAS原子操作設置新值,這裏使用while循環是因爲考慮到在多個線程同時調用的情況下CAS失敗時需要重試。

public final int getAndSetInt(Object var1, long var2, int var4) {
        int var5;
        do {
            var5 = this.getIntVolatile(var1, var2);
        } while(!this.compareAndSwapInt(var1, var2, var5, var4));

        return var5;
    }

在以上方法中通過CAS來操作最新值進行add操作,這樣add之後都是最新的值。

以上就是整理的多線程併發的第二部分的知識了,這其中synchronized和volatile大多來自一個大佬的文章,鏈接

會敲代碼的湯姆貓

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