1. 程式人生 > 其它 >併發-安全[老的,有時間我重新整理一下]

併發-安全[老的,有時間我重新整理一下]

技術標籤:Java

併發-安全[老的,有時間我重新整理一下]

文章是直接從我本地word筆記貼上過來的,排版啥的可能有點亂,湊合看吧,有時間我會慢慢整理

什麼是執行緒安全性

併發程式設計最大的難點並不在於如何使用,而在於如何保證我們程式的執行緒安全.,如果我們能保證併發安全的話,那麼我們可以大膽的在程式裡面使用多執行緒.

在《Java併發程式設計實戰》中,定義如下:
當多個執行緒訪問某個類時,不管執行時環境採用何種排程方式或者這些執行緒將如何交替執行,並且在呼叫程式碼中不需要任何額外的同步或者協同,這個類都能表現出正確的行為,那麼就稱這個類是執行緒安全的。

(一)執行緒封閉

實現好的併發是一件困難的事情,所以很多時候我們都想躲避併發。避免併發最簡單的方法就是執行緒封閉。什麼是執行緒封閉呢?

就是把物件封裝到一個執行緒裡,只有這一個執行緒能看到此物件(別的執行緒無法看到這個物件)。那麼這個物件就算不是執行緒安全的也不會出現任何安全問題。實現執行緒封閉有哪些方法呢?

ad-hoc執行緒封閉(避免使用)

Ad-hoc 執行緒封閉描述了執行緒封閉的方式,由開發人員或從事該專案的開發人員確保僅在單個執行緒內使用此物件(執行緒安全做的好不好完全看開發人員的編碼水平)。 這種方式方法可用性不高,在大多數情況下應該避免。
Ad-hoc 執行緒封閉下的一個特例適用於 volatile 變數。 只要確保 volatile 變數僅從單個執行緒寫入,就可以安全地對共享 volatile 變數執讀 - 改 - 寫操作。在這種情況下,您將修改限制在單個執行緒以防止競爭條件,並且 volatile 變數的可見性保證確保其他執行緒看到最新值。

棧封閉(區域性變數)

棧封閉是我們程式設計當中遇到的最多的執行緒封閉。什麼是棧封閉呢?簡單的說就是區域性變數(方法中的變數)。多個執行緒訪問一個方法,此方法中的區域性變數都會被拷貝一份到執行緒棧中。所以區域性變數是不被多個執行緒所共享的,也就不會出現併發問題。所以能用區域性變數就別用全域性的變數,全域性變數容易引起併發問題。

ThreadLocal(執行緒隔離)

(二)無狀態的類

類的狀態就是指類的成員變數

沒有任何成員變數的類,就叫無狀態的類,這種類一定是執行緒安全的。如果這個類的方法引數中使用了物件,也是執行緒安全的嗎?比如:
![](https://img-blog.csdnimg.cn/img_convert/2baa33e9979b2290717b670bc28f1ff8.png#align=left&display=inline&height=103&margin=[object Object]&originHeight=114&originWidth=258&status=done&style=none&width=234)

當然也是,為何?因為多執行緒下的使用,固然user這個物件的例項會不正常,但是對於StatelessClass這個類的物件例項來說,它並不持有UserVo的物件例項,它自己並不會有問題,如果有執行緒安全問題的是UserVo這個類,而非StatelessClass本身。

(三)讓類不可變

讓狀態不可變,兩種方式:

1,加final關鍵字,對於一個類,所有的成員變數應該是私有的,同樣的只要有可能,所有的成員變數應該加上final關鍵字,但是加上final,要注意如果成員變數又是一個物件時,這個物件所對應的類也要是不可變,才能保證整個類是不可變的。

2、根本就不提供任何可供修改成員變數的地方(不提供get方法),同時成員變數也不作為方法的返回值。這樣的話別人就改不動內部的成員變數

但是要注意,一旦類的成員變數中有物件,上述的final關鍵字保證不可變並不能保證類的安全性,為何?

因為在多執行緒下,雖然物件的引用不可變,但是物件在堆上的例項是有可能被多個執行緒同時修改的,沒有正確處理的情況下,物件例項在堆中的資料是不可預知的。這就牽涉到了如何安全的釋出物件這個問題。
![](https://img-blog.csdnimg.cn/img_convert/f978b2a92be17fd192e4efe89c1b1ff8.png#align=left&display=inline&height=78&margin=[object Object]&originHeight=85&originWidth=279&status=done&style=none&width=255)
如果有getUser() 方法把物件暴露出去就不執行緒安全了.如果在getUser()方法上加個synchronized就是執行緒安全的了.

如果class類上加final 只是代表這個類不能繼承.

(四)volatile

並不能保證類的執行緒安全性,只能保證類的可見性,在某種特殊的情況下,它也能保證寫的原子性 ,volatile最適合一個執行緒寫,多個執行緒讀的情景。

(五)加鎖和CAS

我們最常使用的保證執行緒安全的手段,使用synchronized關鍵字,使用顯式鎖,使用各種原子變數,修改資料時使用CAS機制等等。

(六)安全的釋出

類中持有的成員變數,如果是基本型別,釋出出去,並沒有關係,因為釋出出去的其實是這個變數的一個副本,
但是如果類中持有的成員變數是物件的引用,如果這個成員物件不是執行緒安全的,通過get等方法釋出出去,會造成這個成員物件本身持有的資料在多執行緒下不正確的修改,從而造成整個類執行緒不安全的問題。 可以看見,
![](https://img-blog.csdnimg.cn/img_convert/ec56414b8baf124b10dcd883ba6c5b4e.png#align=left&display=inline&height=182&margin=[object Object]&originHeight=196&originWidth=336&status=done&style=none&width=312)
這個list釋出出去後,是可以被外部執行緒之間修改(把物件的引用複製了一份給外面的list),那麼在多個執行緒同時修改的情況下不安全問題是肯定存在的,怎麼修正這個問題呢?

我們在釋出這物件出去的時候,就應該用執行緒安全的方式包裝這個物件。 我們將list用Collections._synchronizedList_進行包裝以後,無論多少執行緒使用這個list,就都是執行緒安全的了。
![](https://img-blog.csdnimg.cn/img_convert/23a694371451a53ce5f7579cab65c9a2.png#align=left&display=inline&height=25&margin=[object Object]&originHeight=26&originWidth=439&status=done&style=none&width=415)
對於我們自己使用或者宣告的類,JDK自然沒有提供這種包裝類的辦法,但是我們可以仿造這種模式或者委託給執行緒安全的類,當然,對這種通過get等方法釋出出去的物件,最根本的解決辦法還是應該在實現上就考慮到執行緒安全問題,

(七)TheadLocal

案例:ZJJ_JavaBasic_2019/12/13_ 9:40:12_a7ke1

ThreadLocal是實現執行緒封閉的最好方法。ThreadLocal內部維護了一個Map,Map的key是每個執行緒的名稱,而Map的值就是我們要封閉的物件。每個執行緒中的物件都對應著Map中一個值,也就是ThreadLocal利用Map實現了物件的執行緒封閉。
是執行緒級別隔離的局表變數,即使是static變數,對於不同的執行緒也是不共享的,每個執行緒都互相獨立的,互相看不見,當使用ThreadLocal維護變數時,ThreadLocal為每個使用該變數的執行緒提供獨立的變數副本,所以每一個執行緒都可以獨立地改變自己的副本,而不會影響其它執行緒所對應的副本。

當前請求結束,ThreadLocal裡面的東西將消失,所以說ThreadLocal的宣告週期是執行緒結束內容就消失.因為ThreadLocal的key就是當前執行緒

主要用於將私有執行緒和該執行緒存放的副本物件做一個對映,各個執行緒之間的變數互不干擾,在高併發場景下,可以實現無狀態的呼叫,特別適用於各個執行緒依賴不通的變數值完成操作的場景。

需要注意:
在使用完了之後一定要使用 remove()清理掉,不然可能會有記憶體洩露問題.

Spring 在實現了事務的時候用到ThreadLocal .

總結
· 每個ThreadLocal只能儲存一個變數副本,如果想要上線一個執行緒能夠儲存多個副本以上,就需要建立多個ThreadLocal。
· ThreadLocal內部的ThreadLocalMap鍵為弱引用,會有記憶體洩漏的風險。
· 適用於無狀態,副本變數獨立後不影響業務邏輯的高併發場景。如果如果業務邏輯強依賴於副本變數,則不適合用ThreadLocal解決,需要另尋解決方案。

ThreadLocal實現原理
ThreadLocal通過map集合
Map.put(“當前執行緒”,值);

ThreadLocal的介面方法
只有4個方法:

1.void set(Object value) 設定當前執行緒的執行緒區域性變數的值。

2.public Object get() 該方法返回當前執行緒所對應的執行緒區域性變數。

3.public void remove() 將當前執行緒區域性變數的值刪除,目的是為了減少記憶體的佔用,該方法是JDK 5.0新增的方法。需要指出的是,當執行緒結束後,對應該執行緒的區域性變數將自動被垃圾回收,所以顯式呼叫該方法清除執行緒的區域性變數並不是必須的操作,但它可以加快記憶體回收的速度。

4.protected Object initialValue() 返回該執行緒區域性變數的初始值,該方法是一個protected的方法,顯然是為了讓子類覆蓋而設計的。這個方法是一個延遲呼叫方法,線上程第1次呼叫get()或set(Object)時才執行,並且僅執行1次。ThreadLocal中的預設實現直接返回一個null。

執行緒不安全問題出現

當ThreadLocal裡面儲存的是static修飾的類或者變數也會出現執行緒不安全問題,因為在ThreadLocal裡面持有的是物件的本身的引用,對於static變數而言,所有的類共享了static修飾的類或者變數的引用.

在ThreadLocal存放的是同一個物件的引用,所以多個執行緒看到的都是同一個物件的例項.所以ThreadLocal對static修飾的類或者變數不具備執行緒隔離.

解決辦法
把static變數去掉,讓它在每個執行緒都是獨立的(在每個執行緒都單獨new一個例項給ThreadLocal,這樣就不會共享了.)

案例:ZJJ_JavaBasic_2019/12/13_ 9:40:12_a7ke1的 ThreadLocalUnsafe 方法

記憶體洩露問題

  1. 記憶體洩漏memory leak :是指程式在申請記憶體後,無法釋放已申請的記憶體空間,一次記憶體洩漏似乎不會有大的影響,但記憶體洩漏堆積後的後果就是記憶體溢位。 
    
  2. 記憶體溢位 out of memory :沒記憶體可以分配給新的物件了。 
    

ThreadLocal的內部是ThreadLocalMap。ThreadLocalMap內部是由一個Entry陣列組成。Entry類的建構函式為 Entry(弱引用的ThreadLocal物件, Object value物件)。
因為Entry的key是一個弱引用的ThreadLocal物件,所以在 垃圾回收 之前,將會清除此Entry物件的key。那麼, ThreadLocalMap 中就會出現 key 為 null 的 Entry,就沒有辦法訪問這些 key 為 null 的 Entry 的 value。這些 value 被Entry物件引用(強引用).

線上程Thread物件中,每個執行緒物件內部都有一個的ThreadLocalMap物件。如果這個物件儲存了多個大物件,則可能早出記憶體溢位OOM。
Java為了最小化減少記憶體洩露的可能性和影響,在ThreadLocal進行get、set操作時會清除執行緒Map裡所有key為null的value。所以最怕的情況就是,ThreadLocal物件設null了,開始發生“記憶體洩露”,然後使用執行緒池,執行緒結束後被放回執行緒池中而不銷燬,那麼如果這個執行緒一直不被使用或者分配使用了又不再呼叫get/set方法,那麼這個期間就會發生真正的記憶體洩露。

ThreadLocalMap 中使用的 key 為 ThreadLocal 的弱引用,而 value 是強引用。所以,如果 ThreadLocal 沒有被外部強引用的情況下,在垃圾回收的時候會 key 會被清理掉,而 value 不會被清理掉,如果建立ThreadLocal的執行緒一直持續執行,那麼這個Entry物件中的value就有可能一直得不到回收,發生記憶體洩露。。這樣一來,ThreadLocalMap 中就會出現key為null的Entry。假如我們不做任何措施的話,value 永遠無法被GC 回收,這個時候就可能會產生記憶體洩露。ThreadLocalMap實現中已經考慮了這種情況,在呼叫 set()、get()、remove() 方法的時候,會清理掉 key 為 null 的記錄。使用完 ThreadLocal方法後 最好手動呼叫remove()方法

| ThreadLocal threadLocal = new ThreadLocal();
try {
threadLocal.set(new Session(1, “Misout的部落格”));
// 其它業務邏輯
} finally {
threadLocal.remove(); //清空
}

參考:
https://blog.csdn.net/yanluandai1985/article/details/82590336

原理,資料結構

  1. 每個Thread維護著一個ThreadLocalMap的引用
  2. ThreadLocalMap是ThreadLocal的內部類,用Entry來進行儲存
  3. 呼叫ThreadLocal的set()方法時,實際上就是往ThreadLocalMap設定值,key是ThreadLocal物件,值是傳遞進來的物件
  4. 呼叫ThreadLocal的get()方法時,實際上就是往ThreadLocalMap獲取值,key是ThreadLocal物件
  5. ThreadLocal本身並不儲存值,它只是作為一個key來讓執行緒從ThreadLocalMap獲取value。

原理和資料結構參考:

https://www.yuque.com/docs/share/9aa3ae62-cc8b-4e7a-a7b4-dc817d60e4eb?#

(八)Servlet辨析

Servlet不是執行緒安全的類,在設計上就沒有考慮做到執行緒安全的.為什麼我們平時沒感覺到:

1、在需求上,很少有共享的需求,一般會交給tomcat容器去做.

2、接收到了請求,返回應答的時候,一般都是由一個執行緒來負責的。

但是隻要Servlet中有成員變數,一旦有多執行緒下的寫,就很容易產生執行緒安全問題。比如說統計訪問次數,多執行緒的訪問肯定是不正確的,這時候應該用原子變數(AtomicInteger).

死鎖

| 案例程式碼:

zjj_parent_bb48ea12-4947-9b08-ac36-dc7620e7d289

白話:
A有B需要的資源,而B又有A需要的資源,但是A和B都不釋放,兩個都拿不到對方的資源.就會產生死鎖.

當執行緒任務中出現了多個同步(多個鎖)時,如果同步中嵌套了其他的同步。這時容易引發一種現象:程式出現無限等待,這種現象我們稱為死鎖。這種情況能避免就避免掉。

1.學術化的定義

死鎖的發生必須具備以下四個必要條件。

1)互斥條件:指程序對所分配到的資源進行排它性使用,即在一段時間內某資源只由一個程序佔用。如果此時還有其它程序請求資源,則請求者只能等待,直至佔有資源的程序用畢釋放。

2)請求和保持條件:指程序已經保持至少一個資源,但又提出了新的資源請求,而該資源已被其它程序佔有,此時請求程序阻塞,但又對自己已獲得的其它資源保持不放。

3)不剝奪條件:指程序已獲得的資源,在未使用完之前,不能被剝奪,只能在使用完時由自己釋放。

4)環路等待條件:指在發生死鎖時,必然存在一個程序——資源的環形鏈,即程序集合{P0,P1,P2,···,Pn}中的P0正在等待一個P1佔用的資源;P1正在等待P2佔用的資源,……,Pn正在等待已被P0佔用的資源。

理解了死鎖的原因,尤其是產生死鎖的四個必要條件,就可以最大可能地避免、預防和解除死鎖。只要打破四個必要條件之一就能有效預防死鎖的發生:

打破互斥條件:改造獨佔性資源為虛擬資源,大部分資源已無法改造。
打破不可搶佔條件:當一程序佔有一獨佔性資源後又申請一獨佔性資源而無法滿足,則退出原佔有的資源。
打破佔有且申請條件:採用資源預先分配策略,即程序執行前申請全部資源,滿足則執行,不然就等待,這樣就不會佔有且申請。
打破迴圈等待條件:實現資源有序分配策略,對所有裝置實現分類編號,所有程序只能採用按序號遞增的形式申請資源。

避免死鎖常見的演算法有有序資源分配法、銀行家演算法。

2.現象

簡單順序死鎖

就是多個方法加鎖的順序不一致,比如下面兩個方法,A方法加鎖順序是 valueSecond – valueFirst , B方法加鎖順序是valueFirst --valueSecond
解決辦法就是讓兩個加鎖順序一致,比如都是 valueSecond – valueFirst 或者都是 valueFirst --valueSecond

| **private static **Object _valueFirst _= **new **Object();//第一個鎖

**private static **Object _valueSecond _= **new **Object();//第二個鎖 /獲取鎖順序不對造成了死鎖/

//先拿第一個鎖,再拿第二個鎖

**private static void **fisrtToSecond() **throws **InterruptedException {

String threadName = Thread.currentThread().getName();

/*加鎖順序 valueSecond – valueFirst */

**synchronized **(valueSecond) {

  System.**_out_**.println(threadName + **" get 1st"**);

  Thread._sleep_(100);

  **synchronized **(_valueFirst_) {

     System.**_out_**.println(threadName + **" get 2nd"**);

  }

}

}

//先拿第二個鎖,再拿第一個鎖

**private static void **SecondToFisrt() **throws **InterruptedException {

String threadName = Thread.currentThread().getName();

/*加鎖順序 valueFirst – valueSecond */

**synchronized **(valueFirst) {

  System.**_out_**.println(threadName + **" get 2nd"**);

  Thread._sleep_(100);

  **synchronized **(_valueSecond_) {

     System.**_out_**.println(threadName + **" get 1st"**);

  }

}

}

動態順序死鎖
顧名思義也是和獲取鎖的順序有關,但是比較隱蔽,不像簡單順序死鎖,往往從程式碼一眼就看出獲取鎖的順序不對。

解決辦法 用顯示鎖
或者給引數生成hashCode碼進行比對來確定順序

詳細使用參考下面程式碼:

| 案例程式碼:

zjj_parent_bb48ea12-4947-9b08-ac36-dc7620e7d289

3.危害

1、執行緒不工作了,但是整個程式還是活著的
2、沒有任何的異常資訊可以供我們檢查,想找問題都不好找.
3、一旦程式發生了發生了死鎖,是沒有任何的辦法恢復的,只能重啟程式,對生產平臺的程式來說,這是個很嚴重的問題。

實際工作中的死鎖
時間不定,不是每次必現;一旦出現沒有任何異常資訊,只知道這個應用的所有業務越來越慢,最後停止服務,無法確定是哪個具體業務導致的問題;測試部門也無法復現,併發量不夠。

4.解決死鎖

定位
要解決死鎖,當然要先找到死鎖,怎麼找?
通過jps 查詢應用的 id,再通過jstack id 檢視應用的鎖的持有情況
![](https://img-blog.csdnimg.cn/img_convert/6c53b831995a7813fc7aff167dfb8aa5.png#align=left&display=inline&height=59&margin=[object Object]&originHeight=62&originWidth=439&status=done&style=none&width=415)
![](https://img-blog.csdnimg.cn/img_convert/4a48fe3601442508f4af08455e6c40d0.png#align=left&display=inline&height=52&margin=[object Object]&originHeight=55&originWidth=439&status=done&style=none&width=415)
![](https://img-blog.csdnimg.cn/img_convert/7fefaaa8e1e5625c46dc60256281ce1b.png#align=left&display=inline&height=236&margin=[object Object]&originHeight=250&originWidth=439&status=done&style=none&width=415)
修正
關鍵是保證拿鎖的順序一致
兩種解決方式
1、 內部通過順序比較,確定拿鎖的順序;
2、 採用嘗試拿鎖的機制。

 活鎖

兩個執行緒在嘗試拿鎖的機制中,發生多個執行緒之間互相謙讓,不斷髮生同一個執行緒總是拿到同一把鎖,在嘗試拿另一把鎖時因為拿不到,而將本來已經持有的鎖釋放的過程。

例子: 兩個人非常有禮貌,在獨木橋上相遇了,兩個人就互相讓,都換了別的路上了,然後又互相禮讓,最後誰也過不去.

解決辦法:每個執行緒休眠隨機數,錯開拿鎖的時間。

 執行緒飢餓

白話: 飢餓問題就是多個執行緒搶佔CPU,優先順序高的一直有,優先順序低的一直得不到.優先順序低就會出現飢餓問題.

高優先順序吞噬所有低優先順序的CPU時間片
執行緒被永久堵塞在一個等待進入同步塊的狀態
等待的執行緒永遠不被喚醒

如何儘量避免飢餓問題

設定合理的優先順序(優先順序低話出現飢餓問題.)

使用Lock鎖來代替synchronized

併發下的效能

使用併發的目標是為了提高效能,引入多執行緒後,其實會引入額外的開銷,如執行緒之間的協調、增加的上下文切換,執行緒的建立和銷燬,執行緒的排程等等。過度的使用和不恰當的使用,會導致多執行緒程式甚至比單執行緒還要低。
衡量應用的程式的效能:服務時間,延遲時間,吞吐量,可伸縮性等等,其中服務時間,延遲時間(多快),吞吐量(處理能力的指標,完成工作的多少)。多快和多少,完全獨立,甚至是相互矛盾的。
對伺服器應用來說:多少(可伸縮性,吞吐量)這個方面比多快更受重視。

我們做應用的時候:

1、先保證程式正確,確實達不到要求的時候,再提高速度。(黃金原則)
2、一定要以測試為基準。

1.執行緒引入的開銷

上下文切換

如果主執行緒是唯一的執行緒,那麼它基本上不會被排程出去。另一方面,如果可執行的執行緒數大於CPU的數量,那麼作業系統最終會將某個正在執行的執行緒排程出來,從而使其他執行緒能夠使用CPU。這將導致一次上下文切換,在這個過程中將儲存當前執行執行緒的執行上下文,並將新排程進來的執行緒的執行上下文設定為當前上下文。上下文切換有點像我們同時閱讀幾本書,在來回切換書本的同時我們需要記住每本書當前讀到的頁碼。

切換上下文需要一定的開銷,而線上程排程過程中需要訪問由作業系統和JVM共享的資料結構。應用程式、作業系統以及JVM都使用一組相同的CPU。在JVM和作業系統的程式碼中消耗越多的CPU時鐘週期,應用程式的可用CPU時鐘週期就越少。但上下文切換的開銷並不只是包含JVM和作業系統的開銷。當一個新的執行緒被切換進來時,它所需要的資料可能不在當前處理器的本地快取中,因此上下文切換將導致一些快取缺失,因而執行緒在首次排程執行時會更加緩慢。

當執行緒由於等待某個發生競爭的鎖而被阻塞時,JVM通常會將這個執行緒掛起,並允許它被交換出去。如果執行緒頻繁地發生阻塞,那麼它們將無法使用完整的排程時間片。在程式中發生越多的阻塞(包括阻塞IO,等待獲取發生競爭的鎖,或者在條件變數上等待),與CPU密集型的程式就會發生越多的上下文切換,從而增加排程開銷,並因此而降低吞吐量。

上下文切換是計算密集型操作。也就是說,它需要相當可觀的處理器時間。所以,上下文切換對系統來說意味著消耗大量的 CPU 時間,事實上,可能是作業系統中時間消耗最大的操作。上下文切換的實際開銷會隨著平臺的不同而變化,然而按照經驗來看:在大多數通用的處理器中,上下文切換的開銷相當於50~10000個時鐘週期,也就是幾微秒。

UNIX系統的 vmstat命令能報告上下文切換次數以及在核心中執行時間所佔比例等資訊。如果核心佔用率較高(超過10%),那麼通常表示排程活動發生得很頻繁,這很可能是由IO或競爭鎖導致的阻塞引起的。

記憶體同步

同步操作的效能開銷包括多個方面。在 synchronized和 volatile提供的可見性保證中可能會使用一些特殊指令,即記憶體柵欄( Memory Barrier)。
記憶體柵欄可以重新整理快取,使快取無效重新整理硬體的寫緩衝,以及停止執行管道。
記憶體柵欄可能同樣會對效能帶來間接的影響,因為它們將抑制一些編譯器優化操作。在記憶體柵欄中,大多數操作都是不能被重排序的。

阻塞

引起阻塞的原因:包括阻塞IO,等待獲取發生競爭的鎖,或者在條件變數上等待等等。
阻塞會導致執行緒掛起【掛起:掛起程序在作業系統中可以定義為暫時被淘汰出記憶體的程序,機器的資源是有限的,在資源不足的情況下,作業系統對在記憶體中的程式進行合理的安排,其中有的程序被暫時調離出記憶體,當條件允許的時候,會被作業系統再次調回記憶體,重新進入等待被執行的狀態即就緒態,系統在超過一定的時間沒有任何動作】。
很明顯這個操作至少包括兩次額外的上下文切換,還有相關的作業系統級的操作等等。

2.如何減少鎖的競爭

| 案例程式碼:

zjj_parent_b77e265b-e4eb-cd3d-af7a-0b2d2a775369

減少鎖的粒度

使用鎖的時候,鎖所保護的物件是多個,當這些多個物件其實是獨立變化的時候,不如用多個鎖來一一保護這些物件。但是如果有同時要持有多個鎖的業務方法,要注意避免發生死鎖

縮小鎖的範圍
對鎖的持有實現快進快出,儘量縮短持由鎖的的時間。將一些與鎖無關的程式碼移出鎖的範圍,特別是一些耗時,可能阻塞的操作

避免多餘的鎖
兩次加鎖之間的語句非常簡單,導致加鎖的時間比執行這些語句還長,這個時候應該進行鎖粗化—擴大鎖的範圍。

鎖分段
ConcurrrentHashMap就是典型的鎖分段。

替換獨佔鎖

在業務允許的情況下:
1、 使用讀寫鎖(替換可重入鎖),
2、 用自旋CAS這種無鎖操作,來取代加鎖
3、 使用系統安全的併發容器

JMM和底層實現原理

(一)JMM基礎-計算機原理

Java記憶體模型即Java Memory Model,簡稱JMM。

JMM定義了Java 虛擬機器(JVM)在計算機記憶體(RAM)中的工作方式。JVM是整個計算機虛擬模型,所以JMM是隸屬於JVM的。Java1.5版本對其進行了重構,現在的Java仍沿用了Java1.5的版本。Jmm遇到的問題與現代計算機中遇到的問題是差不多的。

物理計算機中的併發問題,物理機遇到的併發問題與虛擬機器中的情況有不少相似之處,物理機對併發的處理方案對於虛擬機器的實現也有相當大的參考意義。
根據《Jeff Dean在Google全體工程大會的報告》我們可以看到
![](https://img-blog.csdnimg.cn/img_convert/9ac67c2c5bc2687399d14d149edd71f4.png#align=left&display=inline&height=227&margin=[object Object]&originHeight=241&originWidth=426&status=done&style=none&width=402)
計算機在做一些我們平時的基本操作時,需要的響應時間是不一樣的。
(以下案例僅做說明,並不代表真實情況。)

如果從記憶體中讀取1M的int型資料由CPU進行累加,耗時要多久?

做個簡單的計算,1M的資料,Java裡int型為32位,4個位元組,共有1024*1024/4 = 262144個整數 ,則CPU 計算耗時:262144 0.6 = 157 286 納秒,而我們知道從記憶體讀取1M資料需要250000納秒,兩者雖然有差距(當然這個差距並不小,十萬納秒的時間足夠CPU執行將近二十萬條指令了),但是還在一個數量級上。但是,沒有任何快取機制的情況下,意味著每個數都需要從記憶體中讀取,這樣加上CPU讀取一次記憶體需要100納秒,262144個整數從記憶體讀取到CPU加上計算時間一共需要262144100+250000 = 26 464 400 納秒,這就存在著數量級上的差異了。

而且現實情況中絕大多數的運算任務都不可能只靠處理器“計算”就能完成,處理器至少要與記憶體互動,如讀取運算資料、儲存運算結果等,這個I/O操作是基本上是無法消除的(無法僅靠暫存器來完成所有運算任務)。早期計算機中cpu和記憶體的速度是差不多的,但在現代計算機中,cpu的指令速度遠超記憶體的存取速度,由於計算機的儲存裝置與處理器的運算速度有幾個數量級的差距,所以現代計算機系統都不得不加入一層讀寫速度儘可能接近處理器運算速度的快取記憶體(Cache)來作為記憶體與處理器之間的緩衝:將運算需要使用到的資料複製到快取中,讓運算能快速進行,當運算結束後再從快取同步回記憶體之中,這樣處理器就無須等待緩慢的記憶體讀寫了。

cpu在執行過程中,它的執行效率要比記憶體的運算速度要高的高的多.所以為了解決CPU裡面的運算效率和記憶體運算效率之間的差異,在我們的記憶體和cpu之間會存在一個cpu快取記憶體.

![](https://img-blog.csdnimg.cn/img_convert/6b166083fe292575cc88582044c10636.png#align=left&display=inline&height=314&margin=[object Object]&originHeight=328&originWidth=580&status=done&style=none&width=556)
在計算機系統中,暫存器劃是L0級快取,接著依次是L1,L2,L3(接下來是記憶體,本地磁碟,遠端儲存)。越往上的快取儲存空間越小,速度越快,成本也更高;越往下的儲存空間越大,速度更慢,成本也更低。從上至下,每一層都可以看做是更下一層的快取,即:L0暫存器是L1一級快取的快取,L1是L2的快取,依次類推;每一層的資料都是來至它的下一層,所以每一層的資料是下一層的資料的子集。

在現代CPU上,一般來說L0, L1,L2,L3都整合在CPU內部,而L1還分為一級資料快取(Data Cache,D-Cache,L1d)和一級指令快取(Instruction Cache,I-Cache,L1i),分別用於存放資料和執行資料的指令解碼。每個核心擁有獨立的運算處理單元、控制器、暫存器、L1、L2快取,然後一個CPU的多個核心共享最後一層CPU快取L3

![](https://img-blog.csdnimg.cn/img_convert/554c2f8733f318a449ebbbe0892294d0.png#align=left&display=inline&height=182&margin=[object Object]&originHeight=191&originWidth=488&status=done&style=none&width=464)

效能測試圖(L1 L2 L3圖)
![](https://img-blog.csdnimg.cn/img_convert/9913f2802b1d1afccae3941cca0d8591.png#align=left&display=inline&height=438&margin=[object Object]&originHeight=715&originWidth=720&status=done&style=none&width=441)

(二)實體記憶體模型帶來的問題

基於快取記憶體的儲存互動很好地解決了處理器與記憶體的速度矛盾,但是也為計算機系統帶來更高的複雜度,因為它引入了一個新的問題:快取一致性(Cache Coherence)。在多處理器系統中,每個處理器都有自己的快取記憶體,而它們又共享同一主記憶體(MainMemory)。當多個處理器的運算任務都涉及同一塊主記憶體區域時,將可能導致各自的快取資料不一致。

現代的處理器使用寫緩衝區臨時儲存向記憶體寫入的資料。寫緩衝區可以保證指令流水線持續執行,它可以避免由於處理器停頓下來等待向記憶體寫入資料而產生的延遲。同時,通過以批處理的方式重新整理寫緩衝區,以及合併寫緩衝區中對同一記憶體地址的多次寫,減少對記憶體匯流排的佔用。雖然寫緩衝區有這麼多好處,但每個處理器上的寫緩衝區,僅僅對它所在的處理器可見。這個特性會對記憶體操作的執行順序產生重要的影響:處理器對記憶體的讀/寫操作的執行順序,不一定與記憶體實際發生的讀/寫操作順序一致。

![](https://img-blog.csdnimg.cn/img_convert/5608d6e5be6b66491b4adfc13d1cc930.png#align=left&display=inline&height=145&margin=[object Object]&originHeight=152&originWidth=527&status=done&style=none&width=503)
![](https://img-blog.csdnimg.cn/img_convert/ea48489f0cf08305eea6faca8c75f523.png#align=left&display=inline&height=286&margin=[object Object]&originHeight=300&originWidth=499&status=done&style=none&width=475)
處理器A和處理器B按程式的順序並行執行記憶體訪問,最終可能得到x=y=0的結果。

處理器A和處理器B可以同時把共享變數寫入自己的寫緩衝區(步驟A1,B1),然後從記憶體中讀取另一個共享變數(步驟A2,B2),最後才把自己寫快取區中儲存的髒資料重新整理到記憶體中(步驟A3,B3)。當以這種時序執行時,程式就可以得到x=y=0的結果。

從記憶體操作實際發生的順序來看,直到處理器A執行A3來重新整理自己的寫快取區,寫操作A1才算真正執行了。雖然處理器A執行記憶體操作的順序為:A1→A2,但記憶體操作實際發生的順序卻是A2→A1。

如果真的發生這種情況,那同步回到主記憶體時以誰的快取資料為準呢?為了解決一致性的問題,需要各個處理器訪問快取時都遵循一些協議,在讀寫時要根據協議來進行操作,這類協議有MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly及Dragon Protocol等。

![](https://img-blog.csdnimg.cn/img_convert/e514d781d6b6e0c6e90ecf70cb220c78.png#align=left&display=inline&height=219&margin=[object Object]&originHeight=233&originWidth=398&status=done&style=none&width=374)

最出名的就是Intel 的MESI協議,MESI協議保證了每個快取中使用的共享變數的副本是一致的。它核心的思想是:當CPU寫資料時,如果發現操作的變數是共享變數,即在其他CPU中也存在該變數的副本,會發出訊號通知其他CPU將該變數的快取行置為無效狀態,因此當其他CPU需要讀取這個變數時,發現自己快取中快取該變數的快取行是無效的,那麼它就會從記憶體重新讀取。

具體參考百度百科

https://baike.baidu.com/item/%E7%BC%93%E5%AD%98%E4%B8%80%E8%87%B4%E6%80%A7/15814005?fr=aladdin

1.偽共享

演示程式碼:zjj_parent_b69dd541-bce8-5f8b-85db-03f7d1cc1c7a

前面我們已經知道,CPU中有好幾級快取記憶體。但是CPU快取系統中是以快取行(cache line)為單位儲存的。目前主流的CPU Cache的Cache Line(快取行)大小都是64Bytes。

Cache Line可以簡單的理解為CPU Cache中的最小快取單位,今天的CPU不再是按位元組訪問記憶體,而是以64位元組為單位的塊(chunk)拿取,稱為一個快取行(cache line)。當你讀一個特定的記憶體地址,整個快取行將從主存換入快取。

什麼是偽共享

一個快取行可以儲存多個變數(存滿當前快取行的位元組數);而CPU對快取的修改又是以快取行為最小單位的,在多執行緒情況下,如果需要修改“共享同一個快取行的變數”,就會無意中影響彼此的效能,這就是偽共享(False Sharing)。

![](https://img-blog.csdnimg.cn/img_convert/36574fa2cea281cdc450ee8892f9b4b7.png#align=left&display=inline&height=318&margin=[object Object]&originHeight=344&originWidth=319&status=done&style=none&width=295)
x和y毫無關係,但是放在同一個快取行裡面了,這就產生了競爭情況,對效能會產生影響

假如一個CPU核心的執行緒在對x進行修改,另一個CPU核心的執行緒卻對y進行讀取,當前者修改了x時,會把x和y同時載入到前者的核心的快取行中,更新完x後其它所有包含x的快取行都將失效,因為其它快取中的x已經不是最新的值了
而當後者讀取y的時候,發現快取行已經失效了,需要重寫從主記憶體中重寫載入.

我們的快取都是以快取行作為一個單位來處理的,所以失效x的快取的同時,也會把y失效,反之亦然.這就出現了一個問題,y和x完全不相干,每次卻因為x的更新需要重新從主記憶體讀取.它被快取未命中給拖慢了,這就是偽共享.

解決偽共享

為了避免偽共享,我們可以使用資料填充的方式來避免,即單個數據填充滿一個CacheLine。這本質是一種空間換時間的做法。但是這種方式在Java7以後可能失效。

Java8中已經提供了官方的解決方案,Java8中新增了一個註解@sun.misc.Contended。

比如JDK的ConcurrentHashMap中就有使用
![](https://img-blog.csdnimg.cn/img_convert/be1e814b509812c7c76165c286ffc7b1.png#align=left&display=inline&height=60&margin=[object Object]&originHeight=64&originWidth=416&status=done&style=none&width=392)
加上這個註解的類會自動補齊快取行,需要注意的是此註解預設是無效的,需要在jvm啟動時設定-XX:-RestrictContended才會生效。

一個類中,只有一個long型別的變數:
![](https://img-blog.csdnimg.cn/img_convert/b905a58108e71ea4f396212bda01edc0.png#align=left&display=inline&height=44&margin=[object Object]&originHeight=47&originWidth=331&status=done&style=none&width=307)
定義一個VolatileLong型別的陣列,然後讓多個執行緒同時併發訪問這個陣列,這時可以想到,在多個執行緒同時處理資料時,陣列中的多個VolatileLong物件可能存在同一個快取行中。
![](https://img-blog.csdnimg.cn/img_convert/34d4ad93e5a4ad406297a46287f65322.png#align=left&display=inline&height=70&margin=[object Object]&originHeight=74&originWidth=439&status=done&style=none&width=415)
執行後,可以得到執行時間
![](https://img-blog.csdnimg.cn/img_convert/10af0a0cedc4f2823f739a12015b25f2.png#align=left&display=inline&height=122&margin=[object Object]&originHeight=129&originWidth=440&status=done&style=none&width=416)
花費了39秒多。
我們改用進行了快取行填充的變數
![](https://img-blog.csdnimg.cn/img_convert/3921edded1b92c13b2677107b13cefda.png#align=left&display=inline&height=94&margin=[object Object]&originHeight=99&originWidth=440&status=done&style=none&width=416)
花費了8.1秒,如果任意註釋上下填充行的任何一行,時間表現不穩定,從8秒到20秒都有,但是還是比不填充要快。具體原因目前未知。
再次改用註解標識的變數,同時加入引數-XX:-RestrictContended
![](https://img-blog.csdnimg.cn/img_convert/7f65cd9190331700989260d32a2810d8.png#align=left&display=inline&height=78&margin=[object Object]&originHeight=86&originWidth=253&status=done&style=none&width=229)
![](https://img-blog.csdnimg.cn/img_convert/4db14b1d3422a754a3ae3d910de0c142.png#align=left&display=inline&height=90&margin=[object Object]&originHeight=95&originWidth=439&status=done&style=none&width=415)
花費了7.7秒。
由上述的實驗結果表明,偽共享確實會影響應用的效能。

(三)Java記憶體模型帶來的問題

1.Java記憶體模型概述

從抽象的角度來看,JMM定義了執行緒和主記憶體之間的抽象關係:執行緒之間的共享變數儲存在主記憶體(Main Memory)中,每個執行緒都有一個私有的本地記憶體(Local Memory),我們稱之為工作記憶體,本地記憶體(工作記憶體)中儲存了該執行緒以讀/寫共享變數的副本。

每個執行緒在對某個值進行操作的時候,它不是直接讀寫主記憶體裡面的這個值,而是把主記憶體的這個值拿出來讀取到工作記憶體裡面,每個執行緒的內部的工作記憶體就有了這個值副本,這個工作記憶體是每個執行緒自己看到,它們是執行緒隔離的,也就是說執行緒A的工作記憶體不能訪問執行緒B的工作記憶體.

本地記憶體(工作記憶體)是JMM的一個抽象概念,並不真實存在。它涵蓋了快取、寫緩衝區、暫存器以及其他的硬體和編譯器優化。

![](https://img-blog.csdnimg.cn/img_convert/ee71eb220a231761ddff6379b83d2d12.png#align=left&display=inline&height=239&margin=[object Object]&originHeight=344&originWidth=764&status=done&style=none&width=530)
![](https://img-blog.csdnimg.cn/img_convert/0f29e9b600b543ab2e5125f0e615c5a5.png#align=left&display=inline&height=205&margin=[object Object]&originHeight=214&originWidth=578&status=done&style=none&width=554)

2.可見性問題

總結:

當多個執行緒訪問同一個變數時,一個執行緒修改了這個變數的值,其他執行緒能夠立即看得到修改的值,這就是可見性.

若兩個執行緒在不同的cpu,那麼執行緒1改變了i的值還沒重新整理到主存,執行緒2又使用了i,那麼這個i值肯定還是之前的,執行緒1對變數的修改執行緒沒看到這就是可見性問題。

![](https://img-blog.csdnimg.cn/img_convert/0967a759dd64ff373556717fb00d0ed5.png#align=left&display=inline&height=212&margin=[object Object]&originHeight=222&originWidth=529&status=done&style=none&width=505)
執行緒2在對count進行操作了.在自己的工作記憶體給count改成了2, 此時執行緒1看不到count改成了2(執行緒1看不到執行緒2的工作記憶體的內容),只要執行緒2沒有把內容(count = 2)更新到主記憶體裡面,此時執行緒1讀取的永遠是舊的值(就是count為1),這就是所謂的可見性問題.

在多執行緒的環境下,假如兩個執行緒都是一次執行,此時工作記憶體裡面是沒有count變數的,如果某個執行緒首次讀取共享變數(count變數),則首先到主記憶體中獲取該變數,然後存入自己的工作記憶體中,以後只需要在工作記憶體中讀取該變數即可,在後面的運算過程中,它發現它的工作記憶體中已經有了這個count共享變數的時候,它就很有可能不會在主記憶體中讀取這個變量了.

同樣如果對該變數執行了修改的操作,則先將新值寫入自己的工作記憶體中,然後再重新整理至主記憶體中。但是工作記憶體什麼時候最新的值會被重新整理至主記憶體中是不太確定,一般來說會很快,但具體時間不知。

要解決共享物件可見性這個問題,我們可以使用volatile關鍵字或者是加鎖,CAS操作也行。

3.原子性問題(競爭問題)

原子性就是執行緒1對主記憶體裡面的count值進行操作的時候,執行緒2是不能進行操作的,只能等到執行緒1操作完了,執行緒2才能進行操作.
如果沒有原子性的話,就是兩個執行緒同時操作主記憶體裡面的count值,這樣就會存在問題了.

執行緒A和執行緒B共享一個物件obj。假設執行緒A從主存讀取Obj.count變數到自己的CPU快取,同時,執行緒B也讀取了Obj.count變數到它的CPU快取,並且這兩個執行緒都對Obj.count做了加1操作。此時,Obj.count加1操作被執行了兩次,不過都在不同的CPU快取中。

![](https://img-blog.csdnimg.cn/img_convert/683f4bffe9ac1b5fb00c7f23a0a42ee8.png#align=left&display=inline&height=219&margin=[object Object]&originHeight=230&originWidth=521&status=done&style=none&width=497)

如果這兩個加1操作是序列執行的,那麼Obj.count變數便會在原始值上加2,最終主存中的Obj.count的值會是3。

然而圖中兩個加1操作是並行的,不管是執行緒A還是執行緒B先flush計算結果到主存,最終主存中的Obj.count只會增加1次,結果是count=2,儘管一共有兩次加1操作,但是有的一個操作丟失了,這就是所謂的競爭問題。 要解決上面的問題我們可以使用java synchronized程式碼塊(逼迫兩個並行的操作變成序列操作).

原子性其實就是保證資料一致、執行緒安全一部分.

保證原子性: Synchronized , AtomicInteger , Lock

4.有序性問題

一般來說處理器為了提高程式執行效率,提高效能,可能會對輸入程式碼進行優化,它不保證程式中各個語句的執行先後順序同程式碼中的順序一致,但是它會保證程式最終執行結果和程式碼順序執行的結果是一致的。如下:

int a = 10; //語句1
int r = 2; //語句2
a = a + 3; //語句3
r = a*a; //語句4

則因為重排序,他還可能執行順序為 2-1-3-4,1-3-2-4
但絕不可能 2-1-4-3,因為這打破了依賴關係。 顯然重排序對單執行緒執行是不會有任何問題,而多執行緒就不一定了,所以我們在多執行緒程式設計時就得考慮這個問題了。

我們寫的程式碼,編譯器會把語句執行順序進行調整, cpu不是按順序執行的,會對指令進行重排序,會把多個指令重疊執行,甚至會提前執行,這就是重排序.

多執行緒重排序問題:

文件:[和筆記對接]深入理解Java記憶體模型—重…
連結:http://note.youdao.com/noteshare?id=7ffec6df578f5ad10e23138be32eaf47&sub=2D856F77F2924A0496B1961211A6C2BE

重排序型別

除了共享記憶體和工作記憶體帶來的問題,還存在重排序的問題:在執行程式時,為了提高效能,編譯器和處理器常常會對指令做重排序。重排序分3種類型。

1)編譯器優化的重排序。編譯器在不改變單執行緒程式語義的前提下,可以重新安排語句的執行順序。

2)指令級並行的重排序。現代處理器採用了指令級並行技術(Instruction-LevelParallelism,ILP)來將多條指令重疊執行。如果不存在資料依賴性,處理器可以改變語句對應機器指令的執行順序。

3)記憶體系統的重排序。由於處理器使用快取和讀/寫緩衝區,這使得載入和儲存操作看上去可能是在亂序執行。

資料依賴性

如果兩個操作訪問同一個變數,且這兩個操作中有一個為寫操作,此時這兩個操作之間就存在資料依賴性。資料依賴分為下列3種類型,上面3種情況,只要重排序兩個操作的執行順序,程式的執行結果就會被改變。
![](https://img-blog.csdnimg.cn/img_convert/d69cf5b88fbd903f1d7fc5ce5d132e27.png#align=left&display=inline&height=130&margin=[object Object]&originHeight=138&originWidth=408&status=done&style=none&width=384)
例如:
![](https://img-blog.csdnimg.cn/img_convert/5639eccde9df2ed085b86b6317f42c15.png#align=left&display=inline&height=78&margin=[object Object]&originHeight=93&originWidth=144&status=done&style=none&width=120)
很明顯,A和C存在資料依賴,B和C也存在資料依賴,而A和B之間不存在資料依賴,如果重排序了A和C或者B和C的執行順序,程式的執行結果就會被改變。
很明顯,不管如何重排序,都必須保證程式碼在單執行緒下的執行正確,連單執行緒下都無法正確,更不用討論多執行緒併發的情況,所以就提出了一個as-if-serial的概念。

as-if-serial

as-if-serial語義的意思是:不管怎麼重排序(編譯器和處理器為了提高並行度),(單執行緒)程式的執行結果不能被改變。編譯器、runtime和處理器都必須遵守as-if-serial語義。

為了遵守as-if-serial語義,編譯器和處理器不會對存在資料依賴關係的操作做重排序,因為這種重排序會改變執行結果。(強調一下,這裡所說的資料依賴性僅針對單個處理器中執行的指令序列和單個執行緒中執行的操作,不同處理器之間和不同執行緒之間的資料依賴性不被編譯器和處理器考慮。)但是,如果操作之間不存在資料依賴關係,這些操作依然可能被編譯器和處理器重排序。

A和C之間存在資料依賴關係,同時B和C之間也存在資料依賴關係。因此在最終執行的指令序列中,C不能被重排序到A和B的前面(C排到A和B的前面,程式的結果將會被改變)。但A和B之間沒有資料依賴關係,編譯器和處理器可以重排序A和B之間的執行順序。

![](https://img-blog.csdnimg.cn/img_convert/925d033b97d9c889e1c53d2124777882.png#align=left&display=inline&height=37&margin=[object Object]&originHeight=42&originWidth=202&status=done&style=none&width=178) ![](https://img-blog.csdnimg.cn/img_convert/5c8e5e7a5876a2c84ac1697099dad52a.png#align=left&display=inline&height=36&margin=[object Object]&originHeight=41&originWidth=208&status=done&style=none&width=184)

as-if-serial語義把單執行緒程式保護了起來,遵守as-if-serial語義的編譯器、runtime和處理器可以讓我們感覺到:單執行緒程式看起來是按程式的順序來執行的。asif-serial語義使單執行緒程式設計師無需擔心重排序會干擾他們,也無需擔心記憶體可見性問題。

控制依賴性

![](https://img-blog.csdnimg.cn/img_convert/9a435bd176351fb1c8d424b0168a01bb.png#align=left&display=inline&height=158&margin=[object Object]&originHeight=179&originWidth=205&status=done&style=none&width=181)

上述程式碼中,flag變數是個標記,用來標識變數a是否已被寫入,在use方法中變數i的賦值依賴if (flag)的判斷,這裡就叫控制依賴,如果發生了重排序,結果就不對了。
考察程式碼,我們可以看見,

操作1和操作2沒有資料依賴關係,編譯器和處理器可以對這兩個操作重排序;同樣,操作3和操作4沒有資料依賴關係,編譯器和處理器也可以對這兩個操作重排序。操作3和操作4則存在所謂控制依賴關係。

在程式中,當代碼中存在控制依賴性時,會影響指令序列執行的並行度。為此,編譯器和處理器會採用猜測(Speculation)執行來克服控制相關性對並行度的影響。以處理器的猜測執行為例,執行執行緒B的處理器可以提前讀取並計算a*a,然後把計算結果臨時儲存到一個名為重排序緩衝(Reorder Buffer,ROB)的硬體快取中。當操作3的條件判斷為真時,就把該計算結果寫入變數i中。猜測執行實質上對操作3和4做了重排序,問題在於這時候,a的值還沒被執行緒A賦值。

在單執行緒程式中,對存在控制依賴的操作重排序,不會改變執行結果(這也是as-if-serial語義允許對存在控制依賴的操作做重排序的原因)。

但是對多執行緒來說就完全不同了:這裡假設有兩個執行緒A和B,A首先執行init ()方法,隨後B執行緒接著執行use ()方法。執行緒B在執行操作4時,能否看到執行緒A在操作1對共享變數a的寫入呢?答案是:不一定能看到。

讓我們先來看看,當操作1和操作2重排序,操作3和操作4重排序時,可能會產生什麼效果?操作1和操作2做了重排序。程式執行時,執行緒A首先寫標記變數flag,隨後執行緒B讀這個變數。由於條件判斷為真,執行緒B將讀取變數a。此時,變數a還沒有被執行緒A寫入,這時就會發生錯誤!
所以在多執行緒程式中,對存在控制依賴的操作重排序,可能會改變程式的執行結果。

5.記憶體屏障

Java編譯器在生成指令序列的適當位置會插入記憶體屏障指令來禁止特定型別的處理器重排序,從而讓程式按我們預想的流程去執行。

1、保證特定操作的執行順序。
2、影響某些資料(或則是某條指令的執行結果)的記憶體可見性。

編譯器和CPU能夠重排序指令,保證最終相同的結果,嘗試優化效能。插入一條Memory Barrier會告訴編譯器和CPU:不管什麼指令都不能和這條Memory Barrier指令重排序。

Memory Barrier所做的另外一件事是強制刷出各種CPU cache,如一個Write-Barrier(寫入屏障)將刷出所有在Barrier之前寫入 cache 的資料,因此,任何CPU上的執行緒都能讀取到這些資料的最新版本。

JMM把記憶體屏障指令分為4類:

屏障型別指令示例說明
LoadLoad BarriersLoad1;LoadLoad;Load2確保Load1的資料的裝載先於Load2及所有後續裝載指令的裝載
StoreStoreBarriersStore1;StoreStore;Store2確保Store1資料對其他處理器可見(重新整理到記憶體)先於Store2及所有後續儲存指令的儲存
LoadStore BarriersLoad1;LoadStore;Store2確保Load1的資料的裝載先於Store2及所有後續儲存指令的儲存
StoreLoad BarriersStore1;StoreLoad;Load2確保Store1的資料對其他處理器可見(重新整理到記憶體)先 於Load2及所有後續的裝載指令的裝載.StoreLoad Barriers會使該屏障之前的所有記憶體訪問指令(儲存和裝載指令)完成之後,才執行該屏障之後的記憶體訪問指令

LoadLoad屏障:

抽象場景:Load1; LoadLoad; Load2

Load1 和 Load2 代表兩條讀取指令。在Load2要讀取的資料被訪問前,保證Load1要讀取的資料被讀取完畢。

StoreStore屏障:

抽象場景:Store1; StoreStore; Store2

Store1 和 Store2代表兩條寫入指令。在Store2寫入執行前,保證Store1的寫入操作對其它處理器可見

LoadStore屏障:

抽象場景:Load1; LoadStore; Store2

在Store2被寫入前,保證Load1要讀取的資料被讀取完畢。

StoreLoad屏障:

抽象場景:Store1; StoreLoad; Load2

在Load2讀取操作執行前,保證Store1的寫入對所有處理器可見。StoreLoad屏障的開銷是四種屏障中最大的。

StoreLoad Barriers是一個“全能型”的屏障,它同時具有其他3個屏障的效果。現代的多處理器大多支援該屏障(其他型別的屏障不一定被所有處理器支援)。

6.臨界區

臨界區就是鎖, 獲取鎖就是程式碼進入了臨界區,釋放鎖就是退出了臨界區.

![](https://img-blog.csdnimg.cn/img_convert/43be87a6c0b1fe49181b87ef1673a706.png#align=left&display=inline&height=221&margin=[object Object]&originHeight=245&originWidth=244&status=done&style=none&width=220)

JMM會在退出臨界區和進入臨界區這兩個關鍵時間點做一些特別處理,使得多執行緒在這兩個時間點按某種順序執行。

臨界區內的程式碼則可以重排序(但JMM不允許臨界區內的程式碼逃脫出臨界區範圍,那樣會破壞監視器的語義)。

雖然執行緒A在臨界區內做了重排序,但由於監視器互斥執行的特性,這裡的執行緒B根本無法“觀察”到執行緒A在臨界區內的重排序。這種重排序既提高了執行效率,又沒有改變程式的執行結果。

回想一下,為啥執行緒安全的單例模式中一般的雙重檢查不能保證真正的執行緒安全?
因為臨界區內進行了重排序的操作.

happens-before

happens-before的概念闡述操作之間的記憶體可見性,是用來避免胡亂的程式碼指令重排序而導致的程式碼出現一些問題.
happens-before規則制定了在一些特殊的情況下,不允許編輯器和指令器對你寫的程式碼進行指令重排,必須保證你的程式碼有序性.

JMM這麼做的原因是:程式設計師對於這兩個操作是否真的被重排序並不關心,程式設計師關心的是程式執行時的語義不能被改變(即執行結果不能被改變)。因此,happens-before關係本質上和as-if-serial語義是一回事。as-if-serial語義保證單執行緒內程式的執行結果不被改變,happens-before關係保證正確同步的多執行緒程式的執行結果不被改變。
A happens-before B,在Java記憶體模型中,happens-before 應該翻譯成:前一個操作的結果可以被後續的操作獲取。講白點就是前面一個操作把變數a賦值為1,那後面一個操作b肯定能知道a已經變成了1。

現在電腦都是多CPU,並且都有快取,導致多執行緒直接的可見性問題.所以為了解決多執行緒的可見性問題,就搞出了happens-before原則,讓執行緒之間遵守這些原則。編譯器還會優化我們的語句,所以等於是給了編譯器優化的約束。不能讓它優化的不知道東南西北了!

兩個操作之間具有happens-before關係,並不意味著前一個操作必須要在後一個操作之前執行!happens-before僅僅要求前一個操作(執行的結果)對後一個操作可見,且前一個操作按順序排在第二個操作之前(the first is visible to and ordered before the second).在JMM中,如果一個操作執行的結果需要對另一個操作可見,那麼這兩個操作之間必須要存在happens-before關係 。

加深理解

上面的定義看起來很矛盾,其實它是站在不同的角度來說的。
1)站在Java程式設計師的角度來說:JMM保證,如果一個操作happens-before另一個操作,那麼第一個操作的執行結果將對第二個操作可見,而且第一個操作的執行順序排在第二個操作之前。

2)站在編譯器和處理器的角度來說:JMM允許,兩個操作之間存在happens-before關係,不要求Java平臺的具體實現必須要按照happens-before關係指定的順序來執行。如果重排序之後的執行結果,與按happens-before關係來執行的結果一致,那麼這種重排序是允許的。

回顧我們前面存在資料依賴性的程式碼:
![](https://img-blog.csdnimg.cn/img_convert/773845f7a45b04306b766946222d8c2a.png#align=left&display=inline&height=56&margin=[object Object]&originHeight=68&originWidth=132&status=done&style=none&width=108)
站在我們Java程式設計師的角度:
![](https://img-blog.csdnimg.cn/img_convert/bc2803220eeaf0ffa3ab1deb789d63c8.png#align=left&display=inline&height=59&margin=[object Object]&originHeight=70&originWidth=150&status=done&style=none&width=126)
但是仔細考察,2、3是必需的,而1並不是必需的,因此JMM對這三個happens-before關係的處理就分為兩類:

1.會改變程式執行結果的重排序
2.不會改變程式執行結果的重排序

JMM對這兩種不同性質的重排序,採用了不同的策略,如下:

1.對於會改變程式執行結果的重排序,JMM要求編譯器和處理器必須禁止這種重排序;
2.對於不會改變程式執行結果的重排序,JMM對編譯器和處理器不做要求。

於是,站在我們程式設計師的角度,看起來這個三個操作滿足了happens-before關係,而站在編譯器和處理器的角度,進行了重排序,而排序後的執行結果,也是滿足happens-before關係的。
![](https://img-blog.csdnimg.cn/img_convert/b86de52c7de551d2d75aa6167dcbb668.png#align=left&display=inline&height=340&margin=[object Object]&originHeight=360&originWidth=424&status=done&style=none&width=400)

Happens-Before規則

JMM為我們提供了以下的Happens-Before規則:

程式順序規則:在一個執行緒內一段程式碼的執行結果是有序的。就是還會指令重排,但是隨便它怎麼排,結果是按照我們程式碼的順序生成的不會變!

監視器鎖規則:對一個鎖的解鎖,happens-before於隨後對這個鎖的加鎖(進入synchronized後加鎖,出去之後釋放鎖,一定是先加鎖再釋放鎖,順序必須是這樣的)。

volatile變數規則: 就是如果一個執行緒先去寫一個volatile變數,然後一個執行緒去讀這個變數,那麼這個寫操作的結果一定對讀的這個執行緒可見。

傳遞性:如果A happens-before B,且B happens-before C,那麼A一定是 happens-before C。

start()規則:如果執行緒A執行操作ThreadB.start()(啟動執行緒B),那麼A執行緒的ThreadB.start()操作happens-before於執行緒B中的任意操作。

join()規則:如果執行緒A執行操作ThreadB.join()併成功返回,那麼執行緒B中的任意操作happens-before於執行緒A從ThreadB.join()操作成功返回。

執行緒中斷規則:對執行緒interrupt方法的呼叫happens-before於被中斷執行緒的程式碼檢測到中斷事件的發生。(只有對呼叫了執行緒的interrupt方法,我被中斷的執行緒中才能感知到我對這個執行緒進行了中斷.)

編譯器和指令器可能會對程式碼重排序,亂排,要守一定的規則,happens-before原則,只要符合happens-before的原則,那麼就不能胡亂重排,如果不符合這些規則的話,那麼就可以自己排序.

  1. 程式次序規則:一個執行緒內,按照程式碼順序,書寫在前面的操作現行發生於書寫在後面的操作
    
  2. 鎖定規則:一個unlock操作先行發生於後面對同一個鎖的lock操作,比如說在程式碼裡面有先對一個lock.lock(),lock.unlock() ,lock.lock()
    
  3. volatile變數規則:對一個volatile變數的寫操作先發生於後面對這個volatile變數的讀操作.
    volatile變數寫,再是讀,必須保證是先寫,再讀.
    

    4.傳遞規則:如果操作A先行發生於操作B,而操作B又先行發生於操作C,則可以得出操作A先行發生於操作C.

  4. 執行緒啟動規則:Thread物件的start()方法先行發生於此執行緒的每一個動作

  5. 執行緒中斷規則: 對執行緒interrupt()方法的呼叫先行發生於被中斷執行緒的程式碼檢測到中斷事件的發生.

  6. 執行緒終結規則:執行緒中所有的操作都先行發生於執行緒的終止檢測,我們可以通過Thread.join()方法結束,Thread.isAlive()的返回值手段檢測到執行緒已經終止執行.

  7. 物件終結規則:一個物件的初始化完成先行發生於他的finalize()方法的開始.

上面這個八條原則的意思很顯而易見,就是程式中的程式碼如果滿足這個條件,就一定會按照這個規則來保證指令的順序.

記憶體屏障

1.為什麼會有記憶體屏障

每個CPU都會有自己的快取(有的甚至L1,L2,L3),快取的目的就是為了提高效能,避免每次都要向記憶體取。但是這樣的弊端也很明顯:不能實時的和記憶體發生資訊交換,分在不同CPU執行的不同執行緒對同一個變數的快取值不同。
用volatile關鍵字修飾變數可以解決上述問題,那麼volatile是如何做到這一點的呢?那就是記憶體屏障,記憶體屏障是硬體層的概念,不同的硬體平臺實現記憶體屏障的手段並不是一樣,java通過遮蔽這些差異,統一由jvm來生成記憶體屏障的指令。

2. 記憶體屏障是什麼

硬體層的記憶體屏障分為兩種:Load Barrier 和 Store Barrier即讀屏障和寫屏障。

記憶體屏障有兩個作用:

  1. 阻止屏障兩側的指令重排序;
    
  2. 強制把寫緩衝區/快取記憶體中的髒資料等寫回主記憶體,讓快取中相應的資料失效。
    

對於Load Barrier來說,在指令前插入Load Barrier,可以讓快取記憶體中的資料失效,強制從新從主記憶體載入資料;
對於Store Barrier來說,在指令後插入Store Barrier,能讓寫入快取中的最新資料更新寫入主記憶體,讓其他執行緒可見。

3.java記憶體屏障

java的記憶體屏障通常所謂的四種即LoadLoad,StoreStore,LoadStore,StoreLoad實際上也是上述兩種的組合,完成一系列的屏障和資料同步功能。

  1. LoadLoad屏障:對於這樣的語句Load1; LoadLoad; Load2,在Load2及後續讀取操作要讀取的資料被訪問前,保證Load1要讀取的資料被讀取完畢。
    
  2. StoreStore屏障:對於這樣的語句Store1; StoreStore; Store2,在Store2及後續寫入操作執行前,保證Store1的寫入操作對其它處理器可見。
    
  3. LoadStore屏障:對於這樣的語句Load1; LoadStore; Store2,在Store2及後續寫入操作被刷出前,保證Load1要讀取的資料被讀取完畢。
    
  4. StoreLoad屏障:對於這樣的語句Store1; StoreLoad; Load2,在Load2及後續所有讀取操作執行前,保證Store1的寫入對所有處理器可見。它的開銷是四種屏障中最大的。在大多數處理器的實現中,這個屏障是個萬能屏障,兼具其它三種記憶體屏障的功能
    

Volatile

volatile是併發程式設計裡面最輕量的同步機制,保證了可見性,經常出現在一些多讀的情況

(一)適用場景

valatile使用場景就是一個執行緒更新了一個值,其它執行緒需要立馬察覺到這個值發生變化.

併發專家建議我們遠離volatile是有道理的,這裡再總結一下:

1.volatile是在synchronized效能低下的時候提出的。如今synchronized的效率已經大幅提升,所以volatile存在的意義不大。
2.如今非volatile的共享變數,在訪問不是超級頻繁的情況下,已經和volatile修飾的變數有同樣的效果了。
3.volatile不能保證原子性,這點是大家沒太搞清楚的,所以很容易出錯。
4.volatile可以禁止重排序。

所以如果我們確定能正確使用volatile,那麼在禁止重排序時是一個較好的使用場景,否則我們不需要再使用它。

(二)volatile特性

可以把對volatile變數的單個讀/寫,看成是使用同一個鎖對這些單個讀/寫操作做了同步
![](https://img-blog.csdnimg.cn/img_convert/a5a78807a62cf2193eac4053a939cc6c.png#align=left&display=inline&height=202&margin=[object Object]&originHeight=215&originWidth=408&status=done&style=none&width=384)
可以看成
![](https://img-blog.csdnimg.cn/img_convert/90ca135b8b5dc07ced1d4e35986fb3d0.png#align=left&display=inline&height=206&margin=[object Object]&originHeight=217&originWidth=474&status=done&style=none&width=450)

volatile操作對於單個讀寫是原子性的,但是一旦是有複合操作(i++;)就不是原子性的,

所以volatile變數自身具有下列特性:

可見性。對一個volatile變數的讀,總是能看到(任意執行緒)對這個volatile變數最後的寫入。

原子性:對任意單個volatile變數的讀/寫具有原子性,但類似於volatile++這種複合操作不具有原子性。

(三)volatile的禁止指令重排序的記憶體語義實現

記憶體語義:可以簡單理解為 volatile,synchronize,atomic,lock 之類的在 JVM 中的記憶體方面實現原則。

volatile的記憶體屏障策略非常嚴格保守,非常悲觀且毫無安全感的心態,由於記憶體屏障的作用,避免了volatile變數和其它指令重排序、執行緒之間實現了通訊,使得volatile表現出了鎖的特性。
volatile寫的記憶體語義如下:

在每個volatile寫操作前插入StoreStore屏障,在寫操作後插入StoreLoad屏障;

當寫一個volatile變數時,JMM會把該執行緒對應的本地記憶體中的共享變數值重新整理到主記憶體。
執行緒對變數進行修改之後,要立刻回寫到主記憶體。

volatile讀的記憶體語義如下:

在每個volatile讀操作前插入LoadLoad屏障,在讀操作後插入LoadStore屏障;
當讀一個volatile變數時,JMM會把該執行緒對應的本地記憶體置為無效。執行緒接下來將從主記憶體中讀取共享變數。
執行緒對變數讀取的時候,要從主記憶體中讀,而不是快取。

所以對於程式碼
![](https://img-blog.csdnimg.cn/img_convert/1b0143a38060dc7981936a3084b09d99.png#align=left&display=inline&height=172&margin=[object Object]&originHeight=192&originWidth=226&status=done&style=none&width=202)
如果我們將flag變數以volatile關鍵字修飾,那麼實際上:執行緒A在寫flag變數後,本地記憶體A中被執行緒A更新過的兩個共享變數的值都被重新整理到主記憶體中。程式碼裡面的a變數也會跟著flag一起重新整理到主記憶體中.
![](https://img-blog.csdnimg.cn/img_convert/c483ee4efecfcba4893b95251e053b97.png#align=left&display=inline&height=219&margin=[object Object]&originHeight=235&originWidth=361&status=done&style=none&width=337)

在讀flag變數後,本地記憶體B包含的值已經被置為無效。此時,執行緒B必須從主記憶體中讀取共享變數。執行緒B的讀取操作將導致本地記憶體B與主記憶體中的共享變數的值變成一致。

![](https://img-blog.csdnimg.cn/img_convert/c1592405c1d826632e9138664b8e0a6e.png#align=left&display=inline&height=232&margin=[object Object]&originHeight=248&originWidth=379&status=done&style=none&width=355)

如果我們把volatile寫和volatile讀兩個步驟綜合起來看的話,在讀執行緒B讀一個volatile變數後,寫執行緒A在寫這個volatile變數之前所有可見的共享變數的值都將立即變得對讀執行緒B可見。

1.volatile記憶體語義的實現

volatile重排序規則表
![](https://img-blog.csdnimg.cn/img_convert/54e9ca34397e4e5be8412ffddb2aeb6a.png#align=left&display=inline&height=119&margin=[object Object]&originHeight=125&originWidth=462&status=done&style=none&width=438)

總結起來就是:

1.當第一個操作是volatile讀時,不管第二個操作是什麼,都不能重排序。這個規則確保volatile讀之後的操作不會被編譯器重排序到volatile讀之前。

2.當第一個操作是volatile寫,第二個操作是volatile讀時,不能重排序。

3.當第二個操作是volatile寫時,不管第一個操作是什麼,都不能重排序。這個規則確保volatile寫之前的操作不會被編譯器重排序到volatile寫之後。

volatile的記憶體屏障
在Java中對於volatile修飾的變數,編譯器在生成位元組碼時,會在指令序列中插入記憶體屏障來禁止特定型別的處理器重排序問題。

volatile寫
![](https://img-blog.csdnimg.cn/img_convert/b76cef28688b4f44e1b2073603dd26ff.png#align=left&display=inline&height=228&margin=[object Object]&originHeight=250&originWidth=269&status=done&style=none&width=245)

在寫操作前後各插入一個記憶體屏障

我插入storestore屏障之後,不管前面是什麼操作,愛怎麼排序怎麼排序,但是普通寫和普通讀不管怎麼操作,不能排序到volatile寫的後面來.這就是volatile寫的含義.

storestore屏障:對於這樣的語句store1; storestore; store2,在store2及後續寫入操作執行前,保證store1的寫入操作對其它處理器可見。(也就是說如果出現storestore屏障,那麼store1指令一定會在store2之前執行,CPU不會store1與store2進行重排序)
storeload屏障:對於這樣的語句store1; storeload; load2,在load2及後續所有讀取操作執行前,保證store1的寫入對所有處理器可見。(也就是說如果出現storeload屏障,那麼store1指令一定會在load2之前執行,CPU不會對store1與load2進行重排序)
volatile讀
![](https://img-blog.csdnimg.cn/img_convert/629f5823200117cbf58788139e6ef35e.png#align=left&display=inline&height=294&margin=[object Object]&originHeight=318&originWidth=315&status=done&style=none&width=291)

讀操作在後面連續插入兩個記憶體屏障

為什麼要插入兩個記憶體屏障:
要結合記憶體屏障的作用來看,LoadLoad 主要用來讀操作LoadStore主要用來寫操作,volition後面可能又有讀又有寫,為了防止有著兩個操作(讀和寫),就一口氣同時插入這兩個記憶體屏障了.(防止下面的讀和寫對volatile讀進行重排序)

volition如何防止指令重排序

在每個volatile讀操作的後面插入兩個屏障:LoadLoad 和loadstore屏障。

loadload屏障:對於這樣的語句load1; loadload; load2,在load2及後續讀取操作要讀取的資料被訪問前,保證load1要讀取的資料被讀取完畢。(也就是說,如果出現loadload屏障,那麼load1指令一定會在load2之前執行,CPU不會對load1與load2進行重排序)

loadstore屏障:對於這樣的語句load1; loadstore; store2,在store2及後續寫入操作被刷出前,保證load1要讀取的資料被讀取完畢。(也就是說,如果出現loadstore屏障,那麼load1指令一定會在store2之前執行,CPU不會對load1與store2進行重排序)

(四)volatile的底層實現原理

通過對OpenJDK中的unsafe.cpp原始碼的分析,會發現被volatile關鍵字修飾的變數會存在一個“lock:”的字首指令(cpu提供的)。

Lock字首,Lock不是一種記憶體屏障,但是它能完成類似記憶體屏障的功能。Lock會對CPU匯流排和快取記憶體加鎖,可以理解為CPU指令級的一種鎖。

同時該指令會將當前處理器快取行的資料直接寫會到主記憶體中,且這個寫回記憶體的操作會使在其他CPU裡快取了該地址的資料無效(工作記憶體要想使用改地址的資料必須要重新去主記憶體裡面獲取資料)。

“Lock:”字首有兩個功能:

  1.    會對cpu匯流排和快取進行加鎖
    
  2.    會把當前快取行的資料寫回到系統記憶體,同時使其它cpu裡面快取行的資料的相關快取無效,強迫其它cpu重新從系統記憶體中進行讀取,一旦執行lock指令,其它cpu對這個快取行的或者相關的主記憶體的資料的讀寫請求都會被阻塞,直到lock鎖釋放為止
    

(五)volatile與synchronized區別

僅靠volatile不能保證執行緒的安全性。(原子性)
①volatile輕量級,只能修飾變數。synchronized重量級,還可修飾方法
②volatile只能保證資料的可見性,不能用來同步,因為多個執行緒併發訪問volatile修飾的變數不會阻塞。
synchronized不僅保證可見性,而且還保證原子性,因為,只有獲得了鎖的執行緒才能進入臨界區,從而保證臨界區中的所有語句都全部執行。多個執行緒爭搶synchronized鎖物件時,會出現阻塞。
執行緒安全性
執行緒安全性包括兩個方面,①可見性。②原子性。
從上面自增的例子中可以看出:僅僅使用volatile並不能保證執行緒安全性。而synchronized則可實現執行緒的安全性。

(六)為何volatile不是執行緒安全的

![](https://img-blog.csdnimg.cn/img_convert/e67f5053e196dc0e476471991cb10fe2.png#align=left&display=inline&height=191&margin=[object Object]&originHeight=202&originWidth=439&status=done&style=none&width=415)

如果兩個執行緒同時對volatile變數做遞增的話,現在主記憶體中還是1,當要對volition count進行操作的時候會強制從主記憶體裡面讀取,此時執行緒a的工作記憶體count是1,而執行緒b的工作記憶體的count也是1,它們兩個同時進行操作,改成了2,然後它們又強制的給count =2 又刷回了主記憶體裡面上.此時執行緒2寫回主記憶體是2,執行緒1寫回的主記憶體也是2 ,最後主記憶體的count 值就是2 了.

Synchronized

synchronized:可以在任意物件及方法上加鎖,而加鎖的這段程式碼稱為"互斥區"或"臨界區",如果加了Synchronize 修飾符 就變成執行緒安全了

分析:當多個執行緒訪問myThread的run方法時,以排隊的方式進行處理(這裡排對是按照CPU分配的先後順序而定的),一個執行緒想要執行synchronized修飾的方法裡的程式碼:
1 嘗試獲得鎖 2 如果拿到鎖,執行synchronized程式碼體內容;拿不到鎖,這個執行緒就會不斷的嘗試獲得這把鎖,直到拿到為止,而且是多個執行緒同時去競爭這把鎖。(也就是會有鎖競爭的問題,會造成cpu短時間達到非常高的值)導致結果就是應用程式非常卡,甚至宕機.所以要避免導致鎖競爭問題.

(一)幾種加鎖方式

1.a普通同步方法,鎖是當前例項物件

同一個例項呼叫會阻塞(因為兩個執行緒用一個例項的話,就是一把鎖,t2只能等待t1執行完了釋放鎖,t2才能執行)

不同例項呼叫不會阻塞(兩個執行緒分別用兩個物件,兩個物件之間每個物件都有自己的一把鎖,所以t2執行緒不用等待t1執行緒執行完釋放鎖.因此兩個執行緒可以並行來執行.)

修飾普通方法案例

案例程式碼: zjj_parent_74e4d254-ed67-215e-8c57-1a4ec2e3939a

2.b同步程式碼塊兒傳參變數物件(鎖住的是變數物件)

|

zjj_parent_9ad7be5e-1e3a-67fc-3e16-b7fa80cd5980
  1. 兩個執行緒用的一個例項,變數也是一樣的,因此第二個執行緒必須得等第一個執行緒釋放鎖才能進入臨界區
    
  2. 如果兩個執行緒鎖的變數不一樣,兩個執行緒是可以並行的
    

3.c同步程式碼塊傳參this(鎖住的是當前例項物件)

同一個例項呼叫會阻塞
不同例項呼叫不會阻塞

案例程式碼:zjj_parent_da955de6-8ea1-a25f-487b-c2f29029f95d

4.d同步程式碼塊傳參class物件(全域性鎖)

全域性鎖是多個物件用的都是同一把鎖

zjj_parent_510687a6-8132-588c-b5ac-45241a2f3575

5.修飾靜態方法(全域性鎖)

全域性鎖是多個物件用的都是同一把鎖.

zjj_parent_ea5066b5-599d-5d0f-9eb5-580ea0724af5

在靜態方法上面加鎖,
類不是物件,每個類在進行類載入時候,都會在虛擬機器裡面有一個class物件

所以在靜態方法上加鎖意味著加鎖的是一個class物件,本質上還是物件鎖,只不過物件變成了虛擬機器裡面的每個類所擁有的唯一的一個class物件而已.

修飾靜態方法,作用於當前類物件加鎖,進入同步程式碼前要獲得當前類物件的鎖 。也就是給當前類加鎖,會作用於類的所有物件例項,因為靜態成員不屬於任何一個例項物件,是類成員( static 表明這是該類的一個靜態資源,不管new了多少個物件,只有一份,所以對該類的所有物件都加了鎖)。所以如果一個執行緒A呼叫一個例項物件的非靜態 synchronized 方法,而執行緒B需要呼叫這個例項物件所屬類的靜態 synchronized 方法,是允許的,不會發生互斥現象,因為訪問靜態 synchronized 方法佔用的鎖是當前類的鎖,而訪問非靜態 synchronized 方法佔用的鎖是當前例項物件鎖。

(二)雙重校驗鎖實現物件單例(執行緒安全)

| **public class **Singleton {

**private volatile static **Singleton uniqueInstance;

**private **Singleton() {

}

**public static **Singleton getUniqueInstance() {

  //先判斷物件是否已經例項過,沒有例項化過才進入加鎖程式碼

  **if **(_uniqueInstance _== **null**) {

     //類物件加鎖

     **synchronized **(Singleton.**class**) {

        **if **(_uniqueInstance _== **null**) {

           _uniqueInstance _= **new **Singleton();

        }

     }

  }

  **return **_uniqueInstance_;

}

}

另外,需要注意 uniqueInstance 採用 volatile 關鍵字修飾也是很有必要。
uniqueInstance 採用 volatile 關鍵字修飾也是很有必要的, uniqueInstance = new Singleton(); 這段程式碼其實是分為三步執行:

  1. 為 uniqueInstance 分配記憶體空間
    
  2. 初始化 uniqueInstance
    
  3. 將 uniqueInstance 指向分配的記憶體地址
    

但是由於 JVM 具有指令重排的特性,執行順序有可能變成 1->3->2。指令重排在單執行緒環境下不會出現問題,但是在多執行緒環境下會導致一個執行緒獲得還沒有初始化的例項。例如,執行緒 T1 執行了 1 和 3,此時 T2 呼叫 getUniqueInstance() 後發現 uniqueInstance 不為空,因此返回 uniqueInstance,但此時 uniqueInstance 還未被初始化。
使用 volatile 可以禁止 JVM 的指令重排,保證在多執行緒環境下也能正常執行。

(三)底層原理總結

在 Java 早期版本中,synchronized 屬於重量級鎖,效率低下,因為監視器鎖(monitor)是依賴於底層的作業系統的 Mutex Lock 來實現的,Java 的執行緒是對映到作業系統的原生執行緒之上的。如果要掛起或者喚醒一個執行緒,都需要作業系統幫忙完成,而作業系統實現執行緒之間的切換時需要從使用者態轉換到核心態,這個狀態之間的轉換需要相對比較長的時間,時間成本相對較高,這也是為什麼早期的 synchronized 效率低的原因。慶幸的是在 Java 6 之後 Java 官方對從 JVM 層面對synchronized 較大優化,所以現在的 synchronized 鎖效率也優化得很不錯了。JDK1.6對鎖的實現引入了大量的優化,如自旋鎖、適應性自旋鎖、鎖消除、鎖粗化、偏向鎖、輕量級鎖等技術來減少鎖操作的開銷。

Synchronized在JVM裡的實現都是基於進入和退出Monitor物件來實現方法同步和程式碼塊同步,雖然具體實現細節不一樣,但是都可以通過成對的MonitorEnter(程式碼開始的時候加這個指令)和MonitorExit(程式碼結束的時候加這個指令)指令來實現。

synchronized使用的鎖是存放在Java物件頭裡面,
![](https://img-blog.csdnimg.cn/img_convert/db46332e6deaa45d98b210e15c7961eb.png#align=left&display=inline&height=82&margin=[object Object]&originHeight=86&originWidth=574&status=done&style=none&width=550)
具體位置是物件頭裡面的MarkWord,MarkWord裡預設資料是儲存物件的HashCode等資訊,
![](https://img-blog.csdnimg.cn/img_convert/d27e58feb0d005bcb061b96038aadff9.png#align=left&display=inline&height=47&margin=[object Object]&originHeight=50&originWidth=354&status=done&style=none&width=330)
但是會隨著物件的執行改變而發生變化,不同的鎖狀態對應著不同的記錄儲存方式
![](https://img-blog.csdnimg.cn/img_convert/343f5ebbbb18c4fe1c83c4caa3dbabaa.png#align=left&display=inline&height=128&margin=[object Object]&originHeight=137&originWidth=357&status=done&style=none&width=333)

1.Synchronized 同步語句塊的情況

| **public class **SynchronizedDemo {

**public void **method() {

  **synchronized **(**this**) {

     System.**_out_**.println(**"synchronized 程式碼塊"**);

  }

}

}

通過 JDK 自帶的 javap 命令檢視 SynchronizedDemo 類的相關位元組碼資訊:首先切換到類的對應目錄執行 javac SynchronizedDemo.java 命令生成編譯後的 .class 檔案,然後執行javap -c -s -v -l SynchronizedDemo.class。
![](https://img-blog.csdnimg.cn/img_convert/c01bc7fa3013788925f769897767b98c.png#align=left&display=inline&height=345&margin=[object Object]&originHeight=362&originWidth=510&status=done&style=none&width=486)
從上面我們可以看出:

synchronized 同步語句塊的實現使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步程式碼塊的開始位置,monitorexit 指令則指明同步程式碼塊的結束位置。 當執行 monitorenter 指令時,執行緒試圖獲取鎖也就是獲取 monitor(monitor物件存在於每個Java物件的物件頭中,synchronized 鎖便是通過這種方式獲取鎖的,也是為什麼Java中任意物件可以作為鎖的原因) 的持有權.當計數器為0則可以成功獲取,獲取後將鎖計數器設為1也就是加1。相應的在執行 monitorexit 指令後,將鎖計數器設為0,表明鎖被釋放。如果獲取物件鎖失敗,那當前執行緒就要阻塞等待,直到鎖被另外一個執行緒釋放為止。

對同步塊,MonitorEnter指令插入在同步程式碼塊的開始位置,當代碼執行到該指令時,將會嘗試獲取該物件Monitor的所有權,即嘗試獲得該物件的鎖,而monitorExit指令則插入在方法結束處和異常處,JVM保證每個MonitorEnter必須有對應的MonitorExit。

2.Synchronized 修飾方法的的情況

| **public class **SynchronizedDemo2 {

**public synchronized void **method() {

  System.**_out_**.println(**"synchronized 方法"**);

}

}

![](https://img-blog.csdnimg.cn/img_convert/da0b3abb4e9b299f58e2147e410a5d0f.png#align=left&display=inline&height=234&margin=[object Object]&originHeight=245&originWidth=535&status=done&style=none&width=511)
synchronized 修飾的方法並沒有 monitorenter 指令和 monitorexit 指令,取得代之的確實是 ACC_SYNCHRONIZED 標識,該標識指明瞭該方法是一個同步方法,JVM 通過該 ACC_SYNCHRONIZED 訪問標誌來辨別一個方法是否宣告為同步方法,從而執行相應的同步呼叫。

對同步方法,從同步方法反編譯的結果來看,方法的同步並沒有通過指令monitorenter和monitorexit來實現,相對於普通方法,其常量池中多了ACC_SYNCHRONIZED標示符。

JVM就是根據該標示符來實現方法的同步的:當方法被呼叫時,呼叫指令將會檢查方法的 ACC_SYNCHRONIZED 訪問標誌是否被設定,如果設定了,執行執行緒將先獲取monitor,獲取成功之後才能執行方法體,方法執行完後再釋放monitor。在方法執行期間,其他任何執行緒都無法再獲得同一個monitor物件。

3.monitorenter和monitorexit指令的作用

每個物件都會有一個關聯的monitor,比如說一個物件例項就有一個monitor,一個類的class物件也有一個monitor,如果要對這個物件加鎖,那麼就必須獲取這個物件關聯的monitor的lock鎖,

它裡面的原理和思路大概是,monitor裡面有個計數器,從0開始的,如果一個執行緒要獲取monitor的鎖,就看看它的計數器是不是0,如果是0的話,那麼說明沒有人獲取鎖,這個執行緒就可以獲取鎖哦,然後對計數器加1.

這個monitor鎖是支援重入加鎖的,如果本執行緒又執行別的方法,獲取到了這個鎖物件,就給monitor計數器加1.

然後接著執行方法,如果執行方法走出了Synchronized修飾的程式碼片段的範圍,就會有一個monitorexit的指令,在底層,此時獲取鎖的執行緒就會對那個物件的monitor的計數器減1,如果有多次重入加鎖就會對應多次減少1,直到最後,計數器是0.
然後後面的沒有獲取到鎖的執行緒就會再次嘗試獲取鎖,但是隻有一個執行緒可以獲取到鎖,沒獲取鎖的執行緒會繼續阻塞.

(四)鎖升級降級問題

現代的(Oracle)JDK 中,JVM 對此進行了大刀闊斧地改進,提供了三種不同的 Monitor 實現,也就是常說的三種不同的鎖:偏斜鎖(Biased Locking)、輕量級鎖和重量級鎖,大大改進了其效能。
所謂鎖的升級、降級,就是 JVM 優化 synchronized 執行的機制,當 JVM 檢測到不同的競爭狀況時,會自動切換到適合的鎖實現,這種切換就是鎖的升級、降級。
當沒有競爭出現時,預設會使用偏斜鎖。JVM 會利用 CAS 操作(compare and swap),在物件頭上的 Mark Word 部分設定執行緒 ID,以表示這個物件偏向於當前執行緒,所以並不涉及真正的互斥鎖。這樣做的假設是基於在很多應用場景中,大部分物件生命週期中最多會被一個執行緒鎖定,使用偏斜鎖可以降低無競爭開銷。
如果有另外的執行緒試圖鎖定某個已經被偏斜過的物件,JVM 就需要撤銷(revoke)偏斜鎖,並切換到輕量級鎖實現。輕量級鎖依賴 CAS 操作 Mark Word 來試圖獲取鎖,如果重試成功,就使用普通的輕量級鎖;否則,進一步升級為重量級鎖。
我注意到有的觀點認為 Java 不會進行鎖降級。實際上據我所知,鎖降級確實是會發生的,當 JVM 進入安全點(SafePoint)的時候,會檢查是否有閒置的 Monitor,然後試圖進行降級。

一共有四種狀態,級別從低到高依次為:
無鎖狀態,偏向鎖狀態,輕量級鎖(自旋鎖)狀態和重量級鎖狀態,它會隨著競爭情況逐漸升級。鎖可以升級但不能降級,目的是為了提高獲得鎖和釋放鎖的效率。

(五)從偏向鎖到重量級鎖

1.鎖膨脹過程

![](https://img-blog.csdnimg.cn/img_convert/4b4522260f9e11f6b6866a3cbe785aa6.png#align=left&display=inline&height=249&margin=[object Object]&originHeight=261&originWidth=505&status=done&style=none&width=481)

synchronized鎖膨脹過程就是無鎖 → 偏向鎖 → 輕量級鎖 → 重量級鎖的一個過程。這個過程是隨著多執行緒對鎖的競爭越來越激烈,鎖逐漸升級膨脹的過程。

如下分析,從一個沒有執行緒訪問的鎖逐漸升級到重量級鎖的過程:

1)一個鎖物件剛剛開始建立的時候,沒有任何執行緒來訪問它,此時執行緒狀態為無鎖狀態。Mark word(鎖標誌位-01 是否偏向-0)
2)當執行緒A來訪問這個物件鎖時,它會偏向這個執行緒A。執行緒A檢查Mark word(鎖標誌位-01 是否偏向-0)為無鎖狀態。此時,有執行緒訪問鎖了,無鎖升級為偏向鎖,Mark word(鎖標誌位-01,是否偏向-1,執行緒ID-執行緒A的ID)
3)當執行緒A執行完同步塊時,不會主動釋放偏向鎖。持有偏向鎖的執行緒執行完同步程式碼後不會主動釋放偏向鎖,而是等待其他執行緒來競爭才會釋放鎖。Mark word不變(鎖標誌位-01,是否偏向-1,執行緒ID-執行緒A的ID)
4)當執行緒A再次獲取這個物件鎖時,檢查Mark word(鎖標誌位-01,是否偏向-1,執行緒ID-執行緒A的ID),偏向鎖且偏向執行緒A,可以直接執行同步程式碼。這樣偏向鎖保證了總是同一個執行緒多次獲取鎖的情況下,每次只需要檢查標誌位就行,效率很高。
5)當執行緒A執行完同步塊之後,執行緒B獲取這個物件鎖 檢查Mark word(鎖標誌位-01,是否偏向-1,執行緒ID-執行緒A的ID),偏向鎖且偏向執行緒A。有不同的執行緒獲取鎖物件,偏向鎖升級為輕量級鎖,並由執行緒B獲取該鎖。
6)當執行緒A正在執行同步塊時,也就是正持有偏向鎖時,執行緒B獲取來這個物件鎖。
檢查Mark word(鎖標誌位-01,是否偏向-1,執行緒ID-執行緒A的ID),偏向鎖且偏向執行緒A。

執行緒A撤銷偏向鎖:

  1. 等到全域性安全點執行撤銷偏向鎖,暫停持有偏向鎖的執行緒A並檢查程A的狀態;
    
  2. 如果執行緒A不處於活動狀態或者已經退出同步程式碼塊,則將物件鎖設定為無鎖狀態,然後再升級為輕量級鎖。由執行緒B獲取輕量級鎖。
    
  3. 如果執行緒A還在執行同步程式碼塊,也就是執行緒A還需要這個物件鎖,則偏向鎖膨脹為輕量級鎖。
    執行緒A膨脹為輕量級鎖過程:
    
  4. 在升級為輕量級鎖之前,持有偏向鎖的執行緒(執行緒A)是暫停的
    
  5. 執行緒A棧幀中建立一個名為鎖記錄的空間(Lock Record)
    
  6. 鎖物件頭中的Mark Word拷貝到執行緒A的鎖記錄中
    
  7. Mark Word的鎖標誌位變為00,指向鎖記錄的指標指向執行緒A的鎖記錄地址,Mark word(鎖標誌位-00,其他位-執行緒A鎖記錄的指標)
    
  8. 當原持有偏向鎖的執行緒(執行緒A)獲取輕量級鎖後,JVM喚醒執行緒A,執行緒A執行同步程式碼塊
    7)執行緒A持有輕量級鎖,執行緒A執行完同步塊程式碼之後,一直沒有執行緒來競爭物件鎖,正常釋放輕量級鎖。釋放輕量級鎖操作:CAS操作將執行緒A的鎖記錄(Lock Record)中的Mark Word替換回鎖物件頭中。
    8)執行緒A持有輕量級鎖,執行同步塊程式碼過程中,執行緒B來競爭物件鎖。Mark word(鎖標誌位-00,其他位-執行緒A鎖記錄的指標)
    
  9. 執行緒B會先在棧幀中建立鎖記錄,儲存鎖物件目前的Mark Word的拷貝
    
  10. 執行緒B通過CAS操作嘗試將鎖物件的Mark Word的指標指向執行緒B的Lock Record,如果成功,說明執行緒A剛剛釋放鎖,執行緒B競爭到鎖,則執行同步程式碼塊。
    
  11. 因為執行緒A一直持有鎖,大部分情況下CAS是會失敗的。CAS失敗之後,執行緒B嘗試使用自旋的方式來等待持有輕量級鎖的執行緒釋放鎖。
    
  12. 執行緒B不會一直自旋下去,如果自旋了一定次數後還是失敗,執行緒B會被阻塞,等待釋放鎖後喚醒。此時輕量級鎖就會膨脹為重量級鎖。Mark word(鎖標誌位-10,其他位-重量級鎖monitor的指標)
    
  13. 執行緒A執行完同步塊程式碼之後,執行釋放鎖操作,CAS 操作將執行緒A的鎖記錄(Lock Record)中的Mark Word 替換回鎖物件物件頭中,因為物件頭中已經不是原來的輕量級鎖的指標了,而是重量級鎖的指標,所以CAS操作會失敗。
    
  14. 釋放輕量級鎖CAS操作替換失敗之後,需要在釋放鎖的同時需要喚醒被掛起的執行緒B。執行緒B被喚醒,獲取重量級鎖monitor
    

2.物件頭

鎖實際上是加在物件上的,那麼被加了鎖的物件我們稱之為鎖物件,在java中,任何一個物件都能成為鎖物件。

物件在記憶體中的儲存結構

長度內容說明
32/64bitMark WorkhashCode,GC分代年齡,鎖資訊
32/64bitClass Metadata Address指向物件型別資料的指標
32/64bitArray Length陣列的長度(當物件為陣列時)

1.物件頭和MarkWord
java的物件由三部分組成:物件頭,例項資料,填充位元組(物件佔用記憶體是8bit的倍數,不足的需要填充)。
非陣列物件的物件頭由兩部分組成:指向類的指標和Mark Word。
陣列物件的物件頭由三部分組成,比非陣列物件多了塊用於記錄陣列長度。
Mark Word用於記錄物件的HashCode和鎖資訊等,在32位JVM中的Mark Word長度為32bit,
在64位JVM中的Mark Word長度為64bit。
Mark Word的最後2bit是鎖標誌位,代表當前物件處於哪種鎖狀態,當Mark Word處於不同的鎖狀態時,Mark Word記錄的資訊也有所不同。

![](https://img-blog.csdnimg.cn/img_convert/81ae2a05bd76ce68edfb6fe1d1b27912.png#align=left&display=inline&height=236&margin=[object Object]&originHeight=245&originWidth=656&status=done&style=none&width=632)

無鎖和偏向鎖的鎖標誌位都是01,只是在前面的1bit區分了這是無鎖狀態還是偏向鎖狀態。

隨著鎖的升級,Mark Word裡面的資料就會按照上表不斷變化,JVM也會按照Mark Word裡面的資訊來判斷物件鎖處於什麼狀態

| LockObject **lockObject **= new LockObject();//隨便建立一個物件

synchronized(lockObject){

  //程式碼

  }  |

| — |

當我們建立一個物件LockObject時,該物件的部分Markword關鍵資料如下。

bit fields是否偏向鎖鎖標誌位
hash001

從圖中可以看出,偏向鎖的標誌位是“01”,狀態是“0”,表示該物件還沒有被加上偏向鎖。(“1”是表示被加上偏向鎖)。該物件被創建出來的那一刻,就有了偏向鎖的標誌位,這也說明了所有物件都是可偏向的,但所有物件的狀態都為“0”,也同時說明所有被建立的物件的偏向鎖並沒有生效。

3.偏向鎖

不過,當執行緒執行到臨界區(critical section)時,此時會利用CAS(Compare and Swap)操作,將執行緒ID插入到Markword中,同時修改偏向鎖的標誌位。
所謂臨界區,就是隻允許一個執行緒進去執行操作的區域,即同步程式碼塊。CAS是一個原子性操作
此時的Mark word的結構資訊如下:

bit fields** **是否偏向鎖鎖標誌位
threadIdepoch101

此時偏向鎖的狀態為“1”,說明物件的偏向鎖生效了,同時也可以看到,哪個執行緒獲得了該物件的鎖。

那麼,什麼是偏向鎖?
偏向鎖是jdk1.6引入的一項鎖優化,其中的“偏”是偏心的偏。它的意思就是說,這個鎖會偏向於第一個獲得它的執行緒,在接下來的執行過程中,假如該鎖沒有被其他執行緒所獲取,沒有其他執行緒來競爭該鎖,那麼持有偏向鎖的執行緒將永遠不需要進行同步操作。
也就是說:
在此執行緒之後的執行過程中,如果再次進入或者退出同一段同步塊程式碼,並不再需要去進行加鎖或者解鎖操作,而是會做以下的步驟:

  1. Load-and-test,也就是簡單判斷一下當前執行緒id是否與Markword當中的執行緒id是否一致.
    
  2. 如果一致,則說明此執行緒已經成功獲得了鎖,繼續執行下面的程式碼.
    
  3. 如果不一致,則要檢查一下物件是否還是可偏向,即“是否偏向鎖”標誌位的值。
    
  4. 如果還未偏向,則利用CAS操作來競爭鎖,也即是第一次獲取鎖時的操作。
    

如果此物件已經偏向了,並且不是偏向自己,則說明存在了競爭。此時可能就要根據另外執行緒的情況,可能是重新偏向,也有可能是做偏向撤銷,但大部分情況下就是升級成輕量級鎖了。

可以看出,偏向鎖是針對於一個執行緒而言的,執行緒獲得鎖之後就不會再有解鎖等操作了,這樣可以省略很多開銷。假如有兩個執行緒來競爭該鎖話,那麼偏向鎖就失效了,進而升級成輕量級鎖了。
為什麼要這樣做呢?因為經驗表明,其實大部分情況下,都會是同一個執行緒進入同一塊同步程式碼塊的。這也是為什麼會有偏向鎖出現的原因。
在Jdk1.6中,偏向鎖的開關是預設開啟的,適用於只有一個執行緒訪問同步塊的場景。

4.輕量級鎖

當下一個執行緒參與到偏向鎖競爭時,會先判斷 markword 中儲存的執行緒 ID 是否與這個執行緒 ID 相等,如果不相等,會立即撤銷偏向鎖,升級為輕量級鎖。每個執行緒在自己的執行緒棧中生成一個 LockRecord ( LR ),然後每個執行緒通過 CAS (自旋 )的操作將鎖物件頭中的 markwork 設定為指向自己的 LR 的指標,哪個執行緒設定成功,就意味著獲得鎖。

鎖撤銷升級為輕量級鎖之後,那麼物件的Markword也會進行相應的的變化。下面先簡單描述下鎖撤銷之後,升級為輕量級鎖的過程:

  1. 執行緒在自己的棧楨中建立鎖記錄 LockRecord。
    
  2. 將鎖物件的物件頭中的MarkWord複製到執行緒的剛剛建立的鎖記錄中。
    
  3. 將鎖記錄中的Owner指標指向鎖物件。
    
  4. 將鎖物件的物件頭的MarkWord替換為指向鎖記錄的指標。
    

對應的圖描述如下(圖來自周志明深入java虛擬機器)
![](https://img-blog.csdnimg.cn/img_convert/395df2ab13a623ec1d3cfd1fe18fe3e9.png#align=left&display=inline&height=198&margin=[object Object]&originHeight=344&originWidth=721&status=done&style=none&width=414)
![](https://img-blog.csdnimg.cn/img_convert/dc067a423e631d678d1ade8c6af1a80e.png#align=left&display=inline&height=171&margin=[object Object]&originHeight=185&originWidth=313&status=done&style=none&width=289)

之後Markwork如下:

bit fields鎖標誌位
指向LockRecord的指標00

注:鎖標誌位”00”表示輕量級鎖

輕量級鎖主要有兩種:

  1. 自旋鎖
    
  2. 自適應自旋鎖
    

自旋鎖
所謂自旋,就是指當有另外一個執行緒來競爭鎖時,這個執行緒會在原地迴圈等待,而不是把該執行緒給阻塞,直到那個獲得鎖的執行緒釋放鎖之後,這個執行緒就可以馬上獲得鎖的。
注意,鎖在原地迴圈的時候,是會消耗cpu的,就相當於在執行一個啥也沒有的for迴圈。
所以,輕量級鎖適用於那些同步程式碼塊執行的很快的場景,這樣,執行緒原地等待很短很短的時間就能夠獲得鎖了。
經驗表明,大部分同步程式碼塊執行的時間都是很短很短的,也正是基於這個原因,才有了輕量級鎖這麼個東西。

自旋鎖的一些問題

  1. 如果同步程式碼塊執行的很慢,需要消耗大量的時間,那麼這個時侯,其他執行緒在原地等待空消耗cpu,這會讓人很難受。\
    
  2. 本來一個執行緒把鎖釋放之後,當前執行緒是能夠獲得鎖的,但是假如這個時候有好幾個執行緒都在競爭這個鎖的話,那麼有可能當前執行緒會獲取不到鎖,還得原地等待繼續空迴圈消耗cup,甚至有可能一直獲取不到鎖。
    

基於這個問題,我們必須給執行緒空迴圈設定一個次數,當執行緒超過了這個次數,我們就認為,繼續使用自旋鎖就不適合了,此時鎖會再次膨脹,升級為重量級鎖。

預設情況下,自旋的次數為10次,使用者可以通過-XX:PreBlockSpin來進行更改。

自適應自旋鎖

所謂自適應自旋鎖就是執行緒空迴圈等待的自旋次數並非是固定的,而是會動態著根據實際情況來改變自旋等待的次數。
其大概原理是這樣的:
假如一個執行緒1剛剛成功獲得一個鎖,當它把鎖釋放了之後,執行緒2獲得該鎖,並且執行緒2在執行的過程中,此時執行緒1又想來獲得該鎖了,但執行緒2還沒有釋放該鎖,所以執行緒1只能自旋等待,但是虛擬機器認為,由於執行緒1剛剛獲得過該鎖,那麼虛擬機器覺得執行緒1這次自旋也是很有可能能夠再次成功獲得該鎖的,所以會延長執行緒1自旋的次數。
另外,如果對於某一個鎖,一個執行緒自旋之後,很少成功獲得該鎖,那麼以後這個執行緒要獲取該鎖時,是有可能直接忽略掉自旋過程,直接升級為重量級鎖的,以免空迴圈等待浪費資源。

輕量級鎖也被稱為非阻塞同步、樂觀鎖,因為這個過程並沒有把執行緒阻塞掛起,而是讓執行緒空迴圈等待,序列執行。

5.重量級鎖

如果鎖競爭加劇(如執行緒自旋次數或者自旋的執行緒數超過某閾值, JDK1.6 之後,由 JVM 自己控制該規則),就會升級為重量級鎖。此時就會向作業系統申請資源,執行緒掛起,進入到作業系統核心態的等待佇列中,等待作業系統排程,然後映射回使用者態。在重量級鎖中,由於需要做核心態到使用者態的轉換,而這個過程中需要消耗較多時間,也就是"重"的原因之一。

輕量級鎖膨脹之後,就升級為重量級鎖了。重量級鎖是依賴物件內部的monitor鎖來實現的,而monitor又依賴作業系統的MutexLock(互斥鎖)來實現的,所以重量級鎖也被成為互斥鎖。
當輕量級所經過鎖撤銷等步驟升級為重量級鎖之後,它的Markword部分資料大體如下

bit fields鎖標誌位
指向Mutex的指標10

6.為什麼說重量級鎖開銷大呢

主要是,當系統檢查到鎖是重量級鎖之後,會把等待想要獲得鎖的執行緒進行阻塞,被阻塞的執行緒不會消耗cup。但是阻塞或者喚醒一個執行緒時,都需要作業系統來幫忙,這就需要從使用者態轉換到核心態,而轉換狀態是需要消耗很多時間的,有可能比使用者執行程式碼的時間還要長。
這就是說為什麼重量級執行緒開銷很大的。
互斥鎖(重量級鎖)也稱為阻塞同步、 悲觀鎖

(六)不同鎖的比較

這三種鎖是指鎖的狀態,並且是針對Synchronized。在Java 5通過引入鎖升級的機制來實現高效Synchronized。這三種鎖的狀態是通過物件監視器在物件頭中的欄位來表明的。

  1. 偏向鎖是指一段同步程式碼一直被一個執行緒所訪問,那麼該執行緒會自動獲取鎖。降低獲取鎖的代價。
    
  2. 輕量級鎖是指當鎖是偏向鎖的時候,被另一個執行緒所訪問(有了執行緒進行競爭),偏向鎖就會升級為輕量級鎖,其他執行緒會通過自旋的形式嘗試獲取鎖,不會阻塞,提高效能。
    
  3. 重量級鎖是指當鎖為輕量級鎖的時候,另一個執行緒雖然是自旋,但自旋不會一直持續下去,當自旋一定次數的時候,還沒有獲取到鎖,就會進入阻塞,該鎖膨脹為重量級鎖。重量級鎖會讓其他申請的執行緒進入阻塞,效能降低。
    

![](https://img-blog.csdnimg.cn/img_convert/263b27f5b52104408fc746130e124e69.png#align=left&display=inline&height=254&margin=[object Object]&originHeight=267&originWidth=493&status=done&style=none&width=469)

(七)JDK對鎖的更多優化措施

1.逃逸分析

如果jdk在分析編譯的時候發現,一個物件不會逃逸方法外或者執行緒外,則可針對此變數進行優化:
在寫程式碼的時候,即使方法加了synchronized關鍵字,編輯器也會把你這個方法的鎖消除掉.

2.鎖消除

虛擬機器的執行時編譯器在執行時如果檢測到一些要求同步的程式碼上不可能發生共享資料競爭,則會去掉這些鎖。

如果JVM檢測到某段程式碼不可能存在共享資料競爭,JVM會對這段程式碼的同步鎖進行鎖消除。
在動態編譯同步塊的時候,JIT編譯器可以藉助一種被稱為逃逸分析(Escape Analysis)的技術來判斷同步塊所使用的鎖物件是否只能夠被一個執行緒訪問而沒有被髮布到其他執行緒。
如果同步塊所使用的鎖物件通過這種分析被證實只能夠被一個執行緒訪問,那麼JIT編譯器在編譯這個同步塊的時候就會取消對這部分程式碼的同步。

舉例:

| public void vectorTest_() _{

  Vector<String**_> _**vector = **new **Vector<String**_>(_**);

  **for **(**int **i = 0; i < 10; i++) {

  vector.add(i + **""**);

  }



  System.out.println(vector);

  }  |

| — |

Vector的add方法是Synchronized修飾的。
在執行這段程式碼時,JVM可以明顯檢測到變數vector沒有逃逸出方法vectorTest()之外,所以JVM可以大膽地將vector內部的加鎖操作消除。

以上的作用

消除無意義的鎖獲取和釋放,可以提高程式執行效能。這也是說為什麼說在使用鎖的過程中,如果不是特別的業務需求(如中斷獲取鎖,嘗試獲取鎖),儘量使用synchronized關鍵字而不是顯示鎖,因為jdk在優化synchronized是下了非常大的功夫的.

3.鎖粗化

將臨近的程式碼塊用同一個鎖合併起來,就是jdk發現你一個方法兩次加鎖之間的執行程式碼非常非常的短,jdk會自動的把你這兩次加鎖合併成一次加鎖.

很多時候,我們提倡儘量減小鎖的粒度,可以避免不必要的阻塞。 讓同步塊的作用範圍儘可能小,僅在共享資料的實際作用域中才進行同步,如果存在鎖競爭,那麼等待鎖的執行緒也能儘快拿到鎖。
但是如果在一段程式碼中連續的用同一個監視器鎖反覆的加鎖解鎖,甚至加鎖操作出現在迴圈體中的時候,就會導致不必要的效能損耗,這種情況就需要鎖粗化。
鎖粗化就是將多個連續的加鎖、解鎖操作連線在一起,擴充套件成一個範圍更大的鎖。

舉例:

| for(**int **i=0;i<100000;i++){

synchronized(this){

  **do**();

  }  |

| — |

會被粗化成:

| synchronized(this){

  **for**(**int **i=0;i<100000;i++){

  **do**();

  }  |

| — |

以上的作用

消除無意義的鎖獲取和釋放,可以提高程式執行效能。這也是說為什麼說在使用鎖的過程中,如果不是特別的業務需求(如中斷獲取鎖,嘗試獲取鎖),儘量使用synchronized關鍵字而不是顯示鎖,因為jdk在優化synchronized是下了非常大的功夫的.

(八)悲觀鎖和樂觀鎖

悲觀鎖
總是假設最壞的情況,每次去拿資料的時候都認為別人會修改,所以每次在拿資料的時候都會上鎖,這樣別人想拿這個資料就會阻塞直到它拿到鎖(共享資源每次只給一個執行緒使用,其它執行緒阻塞,用完後再把資源轉讓給其它執行緒)。傳統的關係型資料庫裡邊就用到了很多這種鎖機制,比如行鎖,表鎖等,讀鎖,寫鎖等,都是在做操作之前先上鎖。Java中synchronized和ReentrantLock等獨佔鎖就是悲觀鎖思想的實現。

樂觀鎖
總是假設最好的情況,每次去拿資料的時候都認為別人不會修改,所以不會上鎖,但是在更新的時候會判斷一下在此期間別人有沒有去更新這個資料,可以使用版本號機制和CAS演算法實現。樂觀鎖適用於多讀的應用型別,這樣可以提高吞吐量,像資料庫提供的類似於write_condition機制,其實都是提供的樂觀鎖。在Java中java.util.concurrent.atomic包下面的原子變數類就是使用了樂觀鎖的一種實現方式CAS實現的。

兩種鎖的使用場景
從上面對兩種鎖的介紹,我們知道兩種鎖各有優缺點,不可認為一種好於另一種,像樂觀鎖適用於寫比較少的情況下(多讀場景),即衝突真的很少發生的時候,這樣可以省去了鎖的開銷,加大了系統的整個吞吐量。但如果是多寫的情況,一般會經常產生衝突,這就會導致上層應用會不斷的進行retry,這樣反倒是降低了效能,所以一般多寫的場景下用悲觀鎖就比較合適。

樂觀鎖一般會使用版本號機制或CAS演算法實現。

鎖的記憶體語義

當執行緒釋放鎖時,JMM會把該執行緒對應的本地記憶體中的共享變數重新整理到主記憶體中。
當執行緒獲取鎖時,JMM會把該執行緒對應的本地記憶體置為無效。從而使得被監視器保護的臨界區程式碼必須從主記憶體中讀取共享變數。
![](https://img-blog.csdnimg.cn/img_convert/1b37ef147f94eb85eee68753a5cd7230.png#align=left&display=inline&height=130&margin=[object Object]&originHeight=137&originWidth=439&status=done&style=none&width=415)
如果我們回顧第一章的VolatileCase,我們知道,為了讓子執行緒可以及時看到_ready_變數的修改,我們需要將ready變數以volatile來修飾。
![](https://img-blog.csdnimg.cn/img_convert/766bb9421dd1cce1e1ed011c8f546eca.png#align=left&display=inline&height=231&margin=[object Object]&originHeight=244&originWidth=439&status=done&style=none&width=415)
但是,當我們將程式做如下改造
![](https://img-blog.csdnimg.cn/img_convert/0b903aaacbfbadd898754d16a84ea7ab.png#align=left&display=inline&height=212&margin=[object Object]&originHeight=227&originWidth=357&status=done&style=none&width=333)
我們可以看見子執行緒同樣可以中止,為何?我們觀察System.out.println的實現,
![](https://img-blog.csdnimg.cn/img_convert/1e4bc4378956109643ce769a98eebb39.png#align=left&display=inline&height=67&margin=[object Object]&originHeight=75&originWidth=224&status=done&style=none&width=200)
結合前面鎖的記憶體語義,我們可以知道,當進入synchronized語句塊時,子執行緒會被強制從主記憶體中讀取共享變數,其中就包括了ready變數,所以子執行緒同樣中止了。