摘要:很多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所攜帶的同步
而這種情況肯定只能發生在偏向鎖上, 所以偏向鎖可以被重置為無鎖狀態。