淺談Java多線程與並發原理

echa攻城獅 發佈 2020-03-07T04:21:44+00:00

而作業系統切換線程時需要從用戶態轉換到核心態,時間較長,開銷較大java6以後Synchronized性能得到了很大提升:AdaptiveSpinning 自適應自旋Lock Eliminate 鎖消除Lock Coarsening 鎖粗化Lightweight Locking

前序

線程安全問題的主要誘因

  1. 存在共享數據(也稱臨界資源)
  2. 存在多條線程共同操作這些共享數據

解決方法:同一時刻有且只有一個線程在操作共享數據,其他線程必須等到該線程處理完數據後再對共享數據進行操作

互斥鎖的特徵

互斥性:即在同一時間只允許一個線程持有某個對象鎖,通過這種特性來實現多線程協調機制,這樣在同一時間只有一個線程對需要同步的代碼塊(複合操作)進行訪問。互斥性也稱為操作的原子性。可見性:必須確保在鎖被釋放之前,對共享變量所做的修改,對於隨後獲得該鎖的另一個線程是可見的(即在獲得鎖時應該獲得最新共享變量的值),否則另一個線程可能是在本地緩存的某個副本上繼續操作,從而引起不一致。註:synchronized 鎖的不是代碼,鎖的是對象

獲取鎖的分類:獲取對象鎖、獲取類鎖

獲取對象鎖的兩種用法:

  • 同步代碼塊(synchronized(this),synchronized(類實例對象)),鎖是小括號中的實例對象
  • 同步非靜態方法(synchronized method) 鎖是當前對象的實例對象

獲取類鎖的兩種用法:

  • 同步代碼塊(synchronized(類.class)),鎖是小括號中的類對象(Class對象)
  • 同步非靜態方法(synchronized static method) 鎖是當前對象的類對象(Class對象)

類鎖和對象鎖在鎖同一個對象的時候表現行為是一樣的,因為class也是對象鎖,只是比較特殊,所有的實例共享同一個類(同一個class對象)

如果鎖的是不同對象(同一個class的不同實例)表現就不一樣了,類鎖是全同步的,對象鎖是按對象區分同步的。

類鎖和對象鎖互不干擾的,因為對象實例和類是兩個不同的對象。

對象鎖和類鎖的終結

  • 有線程訪問對象的同步代碼塊時,另外的線程可以訪問該對象的非同步代碼塊
  • 若鎖住的是同一個對象,一個線程在訪問對象的同步代碼塊時,另一個訪問對象的同步代碼塊的線程會被阻塞
  • 若鎖住的是同一個對象,一個線程在訪問對象的同步方法時候另一個訪問對象同步方法的線程會被阻塞
  • 若鎖住的是同一個對象,一個線程在訪問對象的同步代碼塊時,另一個線程訪問對象同步方法會被阻塞,反之亦然
  • 同一個類的不同對象鎖互不干擾
  • 類鎖由於是一種特殊的對象鎖,因此表現和上述1、2、3、4一致,而由於一個類只有一把對象鎖,所以同一個類的不同對象使用類鎖將會是同步的
  • 類鎖和對象鎖互不干擾

樂觀鎖

樂觀鎖是一種樂觀思想,即認為讀多寫少,遇到並發寫的可能性低,每次去拿數據的時候都認為別人不會修改,所以不會上鎖,但是在更新的時候會判斷一下在此期間別人有沒有去更新這個數據,採取在寫時先讀出當前版本號,然後加鎖操作(比較跟上一次的版本號,如果一樣則更新),如果失敗則要重複讀-比較-寫的操作。

java中的樂觀鎖基本都是通過CAS操作實現的,CAS是一種更新的原子操作,比較當前值跟傳入值是否一樣,一樣則更新,否則失敗。

悲觀鎖

悲觀鎖是就是悲觀思想,即認為寫多,遇到並發寫的可能性高,每次去拿數據的時候都認為別人會修改,所以每次在讀寫數據的時候都會上鎖,這樣別人想讀寫這個數據就會block直到拿到鎖。java中的悲觀鎖就是Synchronized,AQS框架下的鎖則是先嘗試cas樂觀鎖去獲取鎖,獲取不到,才會轉換為悲觀鎖,如RetreenLock。

阻塞代價

java的線程是映射到作業系統原生線程之上的,如果要阻塞或喚醒一個線程就需要作業系統介入,需要在戶態與核心態之間切換,這種切換會消耗大量的系統資源,因為用戶態與內核態都有各自專用的內存空間,專用的寄存器等,用戶態切換至內核態需要傳遞給許多變量、參數給內核,內核也需要保護好用戶態在切換時的一些寄存器值、變量等,以便內核態調用結束後切換回用戶態繼續工作。

  • 如果線程狀態切換是一個高頻操作時,這將會消耗很多CPU處理時間;
  • 如果對於那些需要同步的簡單的代碼塊,獲取鎖掛起操作消耗的時間比用戶代碼執行的時間還要長,這種同步策略顯然非常糟糕的。

synchronized會導致爭用不到鎖的線程進入阻塞狀態,所以說它是java語言中一個重量級的同步操縱,被稱為重量級鎖,為了緩解上述性能問題,JVM從1.5開始,引入了輕量鎖與偏向鎖,默認啟用了自旋鎖,他們都屬於樂觀鎖。

深入理解synchronized底層實現原理:

Java對象頭和Monitor是實現synchronized的基礎

hotspot中對象在內存的布局是分3部分 :

  1. 對象頭
  2. 實例數據
  3. 對其填充
    這裡主要講對象頭:一般而言synchronized使用的鎖對象是存儲在對象頭裡的,對象頭是由Mark Word和Class Metadata Address組成

要詳細了解java對象的結構點擊:https://blog.csdn.net/zqz_zqz/article/details/70246212



mark word存儲自身運行時數據,是實現輕量級鎖和偏向鎖的關鍵,默認存儲對象的hasCode、分代年齡、鎖類型、鎖標誌位等信息。

mark word數據的長度在32位和64位的虛擬機(未開啟壓縮指針)中分別為32bit和64bit,它的最後2bit是鎖狀態標誌位,用來標記當前對象的狀態,對象的所處的狀態,決定了markword存儲的內容,如下表所示:

由於對象頭的信息是與對象定義的數據沒有關係的額外存儲成本,所以考慮到jvm的空間效率,mark word 被設計出一個非固定的存儲結構,以便存儲更多有效的數據,它會根據對象本身的狀態復用自己的存儲空間(輕量級鎖和偏向鎖是java6後對synchronized優化後新增加的)Monitor:每個Java對象天生就自帶了一把看不見的鎖,它叫內部鎖或者Monitor鎖(監視器鎖)。上圖的重量級鎖的指針指向的就是Monitor的起始地址。

每個對象都存在一個Monitor與之關聯,對象與其Monitor之間的關係存在多種實現方式,如Monitor可以和對象一起創建銷毀、或當線程獲取對象鎖時自動生成,當線程獲取鎖時Monitor處於鎖定狀態。

Monitor是虛擬機源碼裡面用C++實現的。

源碼解讀:_WaitSet 和_EntryList就是之前學的等待池和鎖池,_owner是指向持有Monitor對象的線程。當多個線程訪問同一個對象的同步代碼的時候,首先會進入到_EntryList集合裡面,當線程獲取到對象Monitor後就會進入到_object區域並把_owner設置成當前線程,同時Monitor裡面的_count會加一。當調用wait方法會釋放當前對象的Monitor,_owner恢復成null,_count減一,同時該線程實例進入_WaitSet集合中等待喚醒。如果當前線程執行完畢也會釋放Monitor鎖並復位對應變量的值。

接下來是字節碼的分析:

package interview.thread;

/**
 * 字節碼分析synchronized
 * @Author: hankli
 * @Date: 2019/5/20 13:50
 */
public class SyncBlockAndMethod {
    public void syncsTask() {
        synchronized (this) {
            System.out.println("Hello");
        }
    }

    public synchronized void syncTask() {
        System.out.println("Hello Again");
    }
}

然後控制台輸入 javac thread/SyncBlockAndMethod.java然後反編譯 javap -verbose thread/SyncBlockAndMethod.class先看看syncsTask方法裡的同步代碼塊:

從字節碼中可以看出 同步代碼塊 使用的是 monitorenter 和 monitorexit ,當執行monitorenter指令時當前線程講試圖獲取對象的鎖,當Monitor的count 為0時將獲的monitor,並將count設置為1表示取鎖成功。如果當前線程之前有這個monitor的持有權它可以重入這個Monnitor。monitorexit指令會釋放monitor鎖並將計數器設為0。為了保證正常執行monitorenter 和 monitorexit 編譯器會自動生成一個異常處理器,該處理器可以處理所有異常。主要保證異常結束時monitorexit(字節碼中多了個monitorexit指令的目的)釋放monitor鎖

註:重入是從互斥鎖的設計上來說的,當一個線程試圖操作一個由其他線程持有的對象鎖的臨界資源時,將會處於阻塞狀態,當一個線程再次請求自己持有對象鎖的臨界資源時,這種情況屬於重入。就像如下情況:hello2也是會輸出的,並不會鎖住。

再看看syncTask同步方法:

解讀:這個字節碼中沒有monitorenter和monitorexit指令並且字節碼也比較短,其實方法級的同步是隱式實現的(無需字節碼來控制)ACC_SYNCHRONIZED是用來區分一個方法是否同步方法,如果設置了ACC_SYNCHRONIZED執行線程將持有monitor,然後執行方法,無論方法是否正常完成都會釋放調monitor,在方法執行期間,其他線程都無法在獲得這個monitor。如果同步方法在執行期間拋出異常而且在方法內部無法處理此異常,那麼這個monitor將會在異常拋到方法之外時自動釋放。

java6之前Synchronized效率低下的原因:

在早期版本Synchronized屬於重量級鎖,性能低下,因為監視器鎖(monitor)是依賴於底層作業系統的的MutexLock實現的。

而作業系統切換線程時需要從用戶態轉換到核心態,時間較長,開銷較大

java6以後Synchronized性能得到了很大提升(hotspot從jvm層面做了較大優化,減少重量級鎖的使用):

  • Adaptive Spinning 自適應自旋
  • Lock Eliminate 鎖消除
  • Lock Coarsening 鎖粗化
  • Lightweight Locking 輕量級鎖
  • Biased Locking偏向鎖
  • ……

自旋鎖:

  • 許多情況下,共享數據的鎖定狀態持續時間較短,切換線程不值得
  • 通過讓線程執行while循環等待鎖的釋放,不讓出CPU
  • java4就引入了,不過默認是關閉的,java6後默認開啟的
  • 自旋本質和阻塞狀態並不相同,如果鎖占用時間非常短,那自旋鎖性能會很好

缺點:若鎖被其他線程長時間占用,會帶來許多性能上的開銷,因為自旋一直會占用CPU資源且白白消耗掉CPU資源。如果線程超過了限定次數還沒有獲取到鎖,就該使用傳統方式掛起線程(可以設置VM的PreBlockSpin參數來更改限定次數)

關鍵字: