1. 程式人生 > >執行緒安全與鎖

執行緒安全與鎖

目前CPU的運算速度已經達到百億次每秒, 甚至更高的量級, 在現實場景中, 為了提高生產率和高效的完成任務, 處處均採用多執行緒和併發的運作方式

併發是指在某個時間段內, 多工交替處理的能力, 所謂不患寡而患不均, 每個CPU不可能只顧著執行某個程序, 讓其他執行緒進入等待狀態, 所以, CPU把可執行時間均勻的分成若干份, 每個執行緒執行一段時間後, 記錄當前的工作狀態, 釋放相關的執行資源並進入等待狀態, 讓其他程序搶佔CPU資源 並行是指同時處理多工的能力, 目前, CPU已經發展為多核, 可以同時執行多個互不依賴的指令及執行塊, 併發和並行兩個概念非常容易混淆, 它們的核心區別在於程序是否同時執行

併發環境下, 由於程式的封閉性被打破, 出現以下特點:

  • 1).併發程式之間有相互制約的關係, 直接制約體現為一個程式需要另一個程式的計算結果, 間接制約體現為多個程式競爭共享資源, 如處理器,緩衝區等
  • 2).併發程式的執行過程是斷斷續續的, 程式需要記憶現場指令及執行點
  • 3).當併發數設定合理並且CPU擁有足夠的處理能力時, 併發會提高程式的執行效率

(一).執行緒安全

執行緒是CPU排程和分派的基本單位, 為了更充分的利用CPU資源, 一般都會使用多執行緒進行處理, 多執行緒的作用是提高任務的平均執行速度, 但是會導致程式可理解性變差, 程式設計難度加大

執行緒可以擁有自己的操作棧, 程式計數器, 區域性變量表等資源, 它與同一程序內的其他執行緒共享該程序的所有資源, 執行緒在生命週期記憶體在多種狀態, 有NEW(新建狀態), RUNNABLE(就緒狀態), RUNNING(執行狀態), BLOCKED(阻塞狀態), DEAD(終止狀態)五種狀態

如圖: 在這裡插入圖片描述 1).NEW, 即新建狀態, 是執行緒被建立且未啟動的狀態, 建立執行緒的方式有三種:

  • 第一種是繼承自Thread類
  • 第二種是實現Runnable介面
  • 第三種是實現Callable介面

相比第一種, 推薦第二種方式, 因為繼承自Thread類往往不符合里氏替換原則, 而實現Runnable介面可以使程式設計更加靈活, 對外暴露的細節比較少, 讓使用者專注於實現執行緒的run()方法上 Callable與Runnable有兩點不同:

  • 第一, 可以通過call()獲得返回值, 前兩種方式都有一個共同的缺陷, 即在任務執行完成後, 無法直接獲取執行結果, 需要藉助共享變數等獲取, 而Callable和Future則很好的解決了這個問題
  • 第二, call()可以丟擲異常,而Runnable只有通過setDefaultUncaughtExceptionHandler()的方式才能在主執行緒中捕捉到子執行緒異常

2).RUNNABLE,即就緒狀態, 是呼叫start()之後執行之前的狀態, 執行緒start()()不能被多次呼叫, 否則會丟擲IllegalStateException異常

3).RUNNING,即執行狀態, 是run()正在執行時執行緒的狀態, 執行緒可能會由於某些因素而退出RUNNING, 如時間, 異常, 鎖, 排程等

4).BLOCKED,即阻塞狀態, 進入此狀態,有以下種情況:

  • 同步阻塞:鎖被其他執行緒佔用
  • 主動阻塞:呼叫Thread的某些方法,主動讓出CPU執行權,比如sleep(),join()等
  • 等待阻塞:執行了wait()

5).DEAD,即終止狀態,是run()執行結束,或因異常退出後的狀態,此狀態不可逆轉

為了保證執行緒安全,在多個執行緒併發的競爭共享資源時,通常採用同步機制協調各個執行緒的執行,以確保得到正確的結果

執行緒安全問題只在多執行緒環境下才出現, 單執行緒序列執行不存在此問題, 保證高併發場景下的執行緒安全, 可以從以下四個維度考量: 1).資料單執行緒內可見, 單執行緒總是安全的, 通過限制資料僅在單執行緒內可見, 可以避免資料被其他執行緒篡改, 最典型的就是執行緒區域性變數, 它儲存在獨立虛擬機器棧幀的區域性變量表中, 與其他執行緒毫無瓜葛, ThreadLocal就是採用這種方式來實現執行緒安全的

2).只讀物件, 只讀物件總是安全的, 它的特性是允許複製, 拒絕寫入, 最典型的只讀物件有String,Integer等, 一個物件想要拒絕任何寫入, 必須要滿足以下條件: 使用final關鍵字修飾類, 避免被繼承, 使用private final關鍵字避免屬性被中途修改, 沒有任何更新方法, 返回值不能可變物件為引用

3).執行緒安全類, 某些執行緒安全類的內部有非常明確的執行緒安全機制, 比如StringBuffier就是一個執行緒安全類, 它採用synchronized關鍵字來修飾相關方法

4).同步與鎖機制, 如果想要對某個物件進行併發更新操作, 但又不屬於上述三類, 需要開發工程師在程式碼中實現安全的同步機制

執行緒安全的核心理念就是"要麼只讀, 要麼加鎖", 合理利用好JDK提供的併發包, java併發包(java.util.concurrent, JUC)主要分成以下幾個類族: 1).執行緒同步類, 這些類是執行緒間的協調更加容易, 支援了更加豐富的執行緒協調場景, 逐步淘汰了使用Object的wait()和notify()進行同步的方式, 主要代表為CountDownLatch, Semaphore, CyclicBarrier等

2).併發集合類, 集合併發操作的要求是執行速度快, 提取資料準, 最著名的類非ConcurrentHashMap莫屬, 它不斷的優化, 由剛開始的鎖分段到後來的CAS, 不斷的提升併發效能, 其他還有ConcurrentSkipListMap, CopyOnWriteArrayList, BlockingQueue等

3).執行緒管理類, 雖然Thread和ThreadLocal在JDK1.0就已經引入, 但是真正把Thread發揚光大的是執行緒池, 根據實際場景的需要, 提供了多種建立執行緒池的快捷方式, 如使用Executors靜態工廠或者使用ThreadPoolExecutor等, 另外, 通過ScheduledExecutorService來執行定時任務

4).鎖相關類, 鎖以Lock介面為核心, 派生出在一些實際場景中進行互斥操作的鎖相關類, 最有名的是ReentrantLock, 鎖的很多概念在弱化, 是因為鎖的實現在各種場景中已經通過類庫封裝進去了

(二).什麼是鎖

現代的密碼鎖, 指紋鎖, 虹膜識別鎖等, 計算機的鎖也是從開始的悲觀鎖, 發展到後來的樂觀鎖, 偏向鎖, 分段鎖等, 鎖主要提供了兩種特性:互斥性和不可見性

1).用併發包中的鎖類 併發包的類族中, Lock是JUC包的頂層介面, 它的實現邏輯並未用到synchronized, 而是利用了volatile的可見性 Lock的繼承關係: 在這裡插入圖片描述 圖為Lock的繼承類圖, ReentrantLock對於Lock介面的實現主要依賴了Sync, 而Sync繼承了AbstractQueuedSynchronizer(AQS), 它是JUC包實現同步的基礎工具, 在AQS中, 定義了一個volatile int state變數作為共享資源, 如果現場獲取資源失敗, 則進入同步FIFO佇列中等待, 如果成功獲取資源就執行臨界區程式碼, 執行完釋放資源時, 會通知同步佇列中的等待執行緒來獲取資源後出隊並執行

AQS是抽象類, 內建自旋鎖實現的同步佇列, 封裝入隊和出隊的操作, 提供獨佔, 共享, 中斷等特性的方法, AQS的子類可以定義不同的資源實現不同性質的方法, 比如可重入鎖ReentrantLock, 定義state為0時可以獲取資源並置為1, 若已獲得資源, state不斷加1, 在釋放資源時state減1, 直至為0, CountDownLatch初始時定義了資源總量state=count, countDown()不斷將state減1, 當state=0時才能獲得鎖, 釋放後state就一直為0, 所有執行緒呼叫await()都不會等待, 所以CountDownLatch是一次性的, 用完後如果再想用就只能重新建立一個, 如果希望迴圈使用, 推薦使用基於ReentrantLock實現的CyclicBarrier, Semaphore與CountDownLatch略有不同, 同樣也是定義了資源總量state=permits, 當state>0時就能獲的鎖, 並將state減1, 當state=0時只能等待其他執行緒釋放鎖, 當釋放鎖時state加1, 其他等待執行緒又能獲得這個鎖, 當Semphore的permits定義了為1時, 就是互斥鎖, 當permits>1就是共享鎖 JDK8提出了一個新的鎖: StampedLock, 改進了讀寫鎖ReentrantReadWriteLock

2).利用同步程式碼塊 同步程式碼塊一般使用java的synchronized關鍵字來實現, 由兩種方式對方法進行加鎖操作, 第一, 在方法簽名處加synchronized關鍵字, 第二, 使用synchronized(物件或類)進行同步,這裡的原則是鎖的範圍儘可能的小,鎖的時間儘可能短,即能鎖物件,不鎖類,能鎖程式碼塊,不鎖方法,synchronized鎖特性由JVM負責實現,特別是偏向鎖的實現 JVM底層是通過監視鎖來實現synchronized同步的,監視鎖即monitor,是每個物件與生俱來的一個隱藏欄位,使用synchronized時,JVM會根據synchronized的當前使用環境,找到對應物件的monitor,在根據monitor的狀態進行加,解鎖的判斷,例如,執行緒在進入同步方法或程式碼塊時,會獲取改方法或程式碼塊所屬物件的monitor,進行加鎖判斷,如果成功加鎖就成為該monitor的唯一持有者,monitor在被釋放前,不能再被其他執行緒獲取 方法元資訊中會使用ACC_SYNCHRONIZED標識該方法是一個同步方法,同步程式碼塊中會使用monitorentermonitorexit兩個位元組碼指令獲取和釋放monitor,如果使用monitorenter進入時monitor為0,表示該執行緒可以持有monitor後續程式碼,並將monitor加1,如果當前執行緒已經持有了monitor,那麼monitor繼續加1,如果monitor非0,其他執行緒就會進入阻塞狀態,偏向鎖是為了在資源沒有被多執行緒競爭的情況下儘量減少鎖帶來的效能開銷