摘要:很多java入門新人一想到java多執行緒, 就會覺得很暈很繞,什麼可見不可見的,也不瞭解為什麼sync怎麼就鎖住了程式碼。
本文分享自華為雲社群《java多執行緒背後的彎彎繞繞到底是什麼? 7個連環問題為你逐步揭開背後的核心原理!》,作者:breakDraw 。
很多java入門新人一想到java多執行緒, 就會覺得很暈很繞,什麼可見不可見的,也不瞭解為什麼sync怎麼就鎖住了程式碼。
因此我在這裡會提多個問題,如果能很好地回答這些問題,那麼算是你對java多執行緒的原理有了一些瞭解,也可以藉此學習一下這背後的核心原理。
Q: java中的主記憶體和工作記憶體是指什麼?
A:java中, 主記憶體中的物件引用會被拷貝到各執行緒的工作記憶體中, 同時執行緒對變數的修改也會反饋到主記憶體中。
- 主記憶體對應於java堆中的物件例項部分(物理硬體的記憶體)
- 工作記憶體對應於虛擬機器棧中的部分割槽域( 暫存器,快取記憶體)
- 工作記憶體中是拷貝的工作副本
- 拷貝副本時,不會吧整個超級大的物件拷貝過來, 可能只是其中的某個基本資料型別或者引用。
因此我們知道各執行緒使用記憶體資料時,其實是有主記憶體和工作記憶體之分的。並不是一定每次都從同一個記憶體裡取資料。
或者理解為大家使用資料時之間有一個快取。
Q: 多執行緒不可見問題的原因是什麼?
A:這裡先講一下虛擬機器定義的記憶體原子操作:
- lock: 用於主記憶體, 把變數標識為一條執行緒獨佔的狀態
- unlock : 主記憶體, 把鎖定狀態的變數釋放
- read: 讀取, 從主記憶體讀到工作執行緒中
- load: 把read後的值放入到 工作副本中
- use: 使用工作記憶體變數, 傳給工作引擎
- assign賦值: 把工作引擎的值傳給工作記憶體變數
- store: 工作記憶體中的變數傳到主記憶體
- write: 把值寫入到主記憶體的變數中
根據這些指令,看一下面這個圖, 然後再看圖片之後的流程解釋,就好理解了。
- read和load、store、write是按順序執行的, 但是中間可插入其他的操作。不可單獨出現。
- assgin之後, 會同步後主記憶體。即只有發生過assgin,才會做工作記憶體同步到主記憶體的操作。
- 新變數只能在主記憶體中產生
- 工作記憶體中使用某個變數副本時,必須先經歷過assign或者load操作。 不可read後馬上就use
- lock操作可以被同一個執行緒執行多次,但相應地解鎖也需要多次。
- 執行lock時,會清空工作記憶體中該變數的值。 清空後如果要使用,必須重新做load或者assign操作
- unlock時,需要先把資料同步回主記憶體,再釋放。
因此多執行緒普通變數的讀取和寫入操作存在併發問題, 主要在於2點:
- 只有assgin時, 才會更新主記憶體, 但由於指令重排序的情況,導致有時候某個assine指令先執行,然後這個提前被改變的變數就被其他執行緒拿走了,以至於其他執行緒無法及時看到更新後的記憶體值。
- assgin時從工作記憶體到主記憶體之間,可能存在延遲,同樣會導致資料被提前取走存到工作執行緒中。
Q: 那麼volatile關鍵字為什麼就可以實現可見性?
可見性就是併發修改某個值後,這個值的修改對其他執行緒是馬上可見的。
A: java記憶體模型堆volatile定義了以下特殊規則:
- 當一個執行緒修改了該變數的值時,會先lock住主存, 再立刻把新資料同步回記憶體。
- 使用該值時,其他工作記憶體都要從主記憶體中重新整理!
- 這個期間會禁止對於該變數的指令重排序
禁止指令重排序的原理是在給volatile變數賦值時,會加1個lock動作, 而前面規定的記憶體模型原理中, lock之後才能做load或者assine,因此形成了1個記憶體屏障。
Q: 上面提到lock後會限制各工作記憶體要重新整理主存的值load進來後才能用, 這個在底層是怎麼實現的?
A:利用了cpu的匯流排鎖+ 快取一致性+ 嗅探機制實現, 屬於計算機組成原理部分的知識。
這也就是為什麼violate變數不能設定太多,如果設定太多,可能會引發匯流排風暴,造成cpu嗅探的成本大大增加。
Q: 那給方法加上synchronized關鍵字的原理是什麼?和volatie的區別是啥?
A:
- synchronized的重量級鎖是通過物件內部的監視器(monitor)實現
- monitor的執行緒互斥就是通過作業系統的mutex互斥鎖實現的,而作業系統實現執行緒之間的切換需要從使用者態到核心態的切換,所以切換成本非常高。
- 每個物件都持有一個moniter物件
具體流程如下:
- 首先,class檔案的方法表結構中有個訪問標誌access_flags, 設定ACC_SYNCHRONIZED標誌來表示被設定過synchronized。
- 執行緒在執行方法前先判斷access_flags是否標記ACC_SYNCHRONIZED,如果標記則在執行方法前先去獲取monitor物件。
- 獲取成功則執行方法程式碼且執行完畢後釋放monitor物件
- 如果獲取失敗則表示monitor物件被其他執行緒獲取從而阻塞當前執行緒
注意,如果是sync{}程式碼塊,則是通過在程式碼中新增monitorEnter和monitorExit指令來實現獲取和退出操作的。
如果對C語言有了解的,可以看看這個大哥些的文章Java精通併發-通過openjdk原始碼分析ObjectMonitor底層實現
Q: synchronized每次加鎖解鎖需要切換核心態和使用者態, jvm是否有對這個過程做過一些優化?
A:jdk1.6之後, 引入了鎖升級的概念,而這個鎖升級就是針對sync關鍵字的
鎖的狀態總共有四種,級別由低到高依次為:無鎖、偏向鎖、輕量級鎖、重量級鎖
四種狀態會隨著競爭的情況逐漸升級,而且是不可逆的過程,
只能進行鎖升級(從低階別到高階別),不能鎖降級(高階別到低階別)
因此sync關鍵字不是一開始就直接使用很耗時的同步。而是一步步按照情況做升級
- 當物件剛建立,不存在鎖競爭的時候, 每次進入同步方法/程式碼塊會直接使用偏向鎖
- 偏向鎖原理: 每次嘗試在物件頭裡設定當前使用這個物件的執行緒id, 只做一次,如果成功了就設定好threadId, 只要沒有出現新的thread訪問且markWord被修改,那麼久)
2. 當發現物件頭的執行緒id要被修改時,說明存在競爭時。升級為輕量級鎖
- 輕量級鎖採用的是自旋鎖,如果同步方法/程式碼塊執行時間很短的話,採用輕量級鎖雖然會佔用cpu資源但是相對比使用重量級鎖還是更高效的。 CAS的物件是物件頭的Mark Word, 此時仍然不會去調系統底層的方法做阻塞。
3. 但是如果同步方法/程式碼塊執行時間很長,那麼使用輕量級鎖自旋帶來的效能消耗就比使用重量級鎖更嚴重,這時候就會升級為重量級鎖,也就是上面那個問題中提到的操作。
Q: 鎖只可以升級不可以降級, 確定是都不能降級嗎?
A:有可能被降級, 不可能存在共享資源競爭的鎖。
java存在一個執行期優化的功能
需要開啟server模式外加+DoEscapeAnalysis表示開啟逃逸分析。
如果執行過程中檢測到共享變數確定不會逃逸,則直接在編譯層面去掉鎖
舉例:
StringBuffer.append().append()
例如如果發現stringBuffer不會逃逸,則就會去掉這裡append所攜帶的同步
而這種情況肯定只能發生在偏向鎖上, 所以偏向鎖可以被重置為無鎖狀態。