提到并發程式設計大多數第一時刻想到的就是synchronized同步鎖了,synchronized也是面試中問的比較多的一個問題。在之前的文章中我們提到過線程安全的三個特性:原子性、可見性和有序性,并且說到了java中定義了一個關鍵字synchronized對于線程的這三個特性都實作了,這麼說來synchronized關鍵字是可以保證線程安全的,那麼如何使用synchronized來實作線程安全?它又是怎麼樣的一個實作原理呢?這篇文章我們将從下面的幾個内容來聊聊synchronized這個關鍵字。
1.synchronized介紹
synchronized是java中的一個關鍵字,翻譯成漢語是同步的意思,它的作用呢就是被其修飾的方法或者代碼塊在同一時刻隻能有一個線程進行通路和執行,隻有當該線程執行完畢之後其他的線程才可以進行競争通路,并且目前通路的線程可以重複的申請競争通路資源。
可以想象為大家都在一個視窗買東西,同一時刻隻能有一個人在買,其他人都在等待,不一樣的是外面等待的人并不會排好隊而是都在等着正在買東西的人買完之後争奪到買東西的機會,并且現在買的人買完之後還能申請繼續争奪買東西的機會。
簡單來說:synchronized可以修飾代碼塊和方法,它可以保證同一時刻隻能有一個線程通路被修飾的代碼塊或者方法,進而保證了線程的安全。synchronized猶如一把鎖,當一個線程通路之後就鎖定通路的共享資源代碼段,達到互斥的效果,進而保證了線程的安全,并且同一個線程可以擷取同一把鎖多次,達到可重入的效果。
· synchronized特性
synchronized具有、、和;
原子性
之前文章說到過,JVM中定義的8種原子性操作中的lock和unlock就是把非原子性操作變成原子性操作,例如a+=1為非原子性操作。JVM中使用的兩個位元組碼指令monitorenter和monitorexit來實作lock和unlock操作,但是在java代碼中沒有直接操作JVM指令的,而是把這兩個指令都封裝到了synchronized關鍵字中來實作原子性操作。(講解synchronized原理的時候會看到monitorenter和monitorexit指令封裝)
可見性
synchronized保證有序性是因為unlock解鎖操作之前必須把工作記憶體中資料同步回主記憶體來實作的;而主記憶體是所有線程都可以通路的共享記憶體,是以修改之後其它線程操作該資料時都可以看到被修改後的值。
有序性
synchronized實作有序性是因為當一個共享資源變量被lock鎖定操作之後,同一時刻隻能被一個線程使用,而單線程執行代碼是沒有指令重排等問題的,是以線程也是有序的。同樣被lock鎖定的共享資源排斥其他線程通路是以Synchronized也具有互斥性。
可重入性
synchronized的可重入性就是當一個線程調用synchronized代碼持有對象鎖的時候,如果調用了該對象的其他synchronized代碼,那麼可以重新持有該鎖,即同一個線程可以擷取同一把鎖多次,是以synchronized具有可重入性。
2.synchronized的使用
synchronized同步鎖主要分兩種,一種是,另外一種是;
· 對象鎖
對象鎖顧名思義鎖的作用對象是執行個體對象,當synchronized修飾普通的方法或者代碼塊的時候,都可以指定鎖的對象。因一個類可以有很多對象,是以對象鎖是可以有多個的。
修飾普通方法:被稱為同步方法,其鎖作用範圍是這個普通方法的所有代碼,作用的對象是調用這個普通方法的對象。
修飾代碼塊:被稱為同步代碼塊,其鎖作用範圍是這個代碼塊的所有代碼,作用的對象是調用這個代碼塊的對象。
· 類鎖
每個類都隻有一個對應的Class對象(反射對象),類鎖其作用的對象就是類的Class對象了,或者當鎖的對象為一個靜态對象的時候也是類鎖。當synchronized修飾靜态方法或者代碼塊的時候都可以使用類鎖。
修飾靜态方法/代碼塊:其鎖的作用範圍為定義的靜态方法或代碼塊的代碼。作用的對象就是在調用該靜态方法或代碼塊的所有對象。
下面我們還是以之前賣票的MyRunnable為例看看對象鎖和類鎖的使用。
對象鎖的使用代碼如下:
運作結果如圖:
分析:通過運作我們發現,當使用了同步代碼塊或者同步方法的對象鎖方法實作,線程就是同步執行的了。
值得注意的是對象鎖中同步普通的方法鎖的對象是this即鎖的作用對象是目前的MyRunnable對象,而對象鎖的同步代碼塊鎖的對象可以是this也可以是Object類型。
synchronized(this)和synchronized(obj)的差別:
synchronized(this)所的作用對象是目前通路的對象,而synchronized(obj)的作用對象是obj,如果多個線程共用一個obj對象那麼執行的時候還是同步執行的,如果每個線程obj鎖的對象不同那麼還是異步執行。如下代碼就是異步執行:
另外同步代碼塊的方式因為可以控制鎖的代碼範圍即控制鎖的粒度,是以有些場合下使用同步代碼塊的效率要更高。
類鎖的使用代碼如下:
因為鎖的是class對象或者靜态對象,是以我們測試時候可以每個線程建立不一樣的MyRunable2對象來進行測試,代碼如下:
運作結果如下:
分析:通過測試我們發現類鎖對于鎖定的類class的所有對象都成立。
3.synchronized原理
為了研究synchronized的原理,我們就需要對使用這個關鍵字的java檔案編譯之後生成的class檔案進行反編譯,檢視下java位元組碼對應的機器指令是怎麼樣的。
Java代碼是這樣的:
通過jdk自帶的javap工具對SyncTest.class檔案進行反編譯擷取位元組碼指令,執行指令“javap-v SyncTest”,然後擷取到反編譯的結果如圖所示:
同步代碼塊
同步方法
我們可以看到使用同步代碼塊的test方法中看到兩個熟悉的指令monitorenter、monitorexit,即遇到synchronized的時候執行monitorenter指令擷取到鎖,而當方法運作結束時執行monitorexit指令釋放鎖。其他指令有興趣的話可以百度“JVM虛拟機位元組碼指令表”檢視具體含義。
monitorenter和monitorexit通過官方介紹的這兩個指令進行翻譯之後的大體上是這樣的:
“對象都有一個螢幕鎖(monitor)關聯,當且僅當擁有所有者時,monitor才會被鎖定,并且會有一個計數器記錄着鎖的次數,如果未擷取到monitor鎖那麼計數為0。執行到monitorenter指令的線程,會嘗試去獲得對應的monitor鎖,如果擷取成功則計數加1,當同一個線程再次獲得該對象的鎖的時候,計數器再次+1,當其他線程想獲得該monitor的時候,就會阻塞,直到計數器為0才能成功。線程執行monitorexit指令,就會讓monitor的計數器-1。如果計數器為0,表明該線程不再擁有monitor鎖。其他線程就允許嘗試去獲得該monitor鎖”。
monitorenter和monitorexit的執行流程圖如下:
而在同步方法test2的反編譯位元組碼中并沒有看到monitorenter和monitorexit兩個指令,但是發現圖中紅色框中标記了一個flags值為ACC_SYNCHRONIZED。ACC_SYNCHRONIZED介紹如下:
“方法級同步是隐式執行的,作為方法調用和傳回的一部分。同步方法在運作時常量池的methodinfo結構中通過ACCSYNCHRONIZED标志進行區分,該标志由方法調用指令檢查。當調用為其設定了ACC_SYNCHRONIZED的方法時,執行線程進入monitor螢幕,調用方法本身,然後退出螢幕,不管方法調用是正常完成還是突然完成。在執行線程擁有螢幕期間,其他線程不能進入螢幕。如果在調用synchronized方法的過程中抛出異常,并且synchronized方法不處理該異常,則在将異常從synchronized方法中重新抛出之前,該方法的螢幕将自動退出”。
通過上面的描述我們知道了同步方法通過标志值為ACC_SYNCHRONIZED也可以擷取到monitor鎖,并在方法結束的時候會釋放monitor鎖,進而也達到了同步的效果。
4.JDK1.6對synchronized的優化
上述介紹了synchronized的使用和原理,我們發現雖然synchronized鎖實作了并發安全,但是它有點“重”,因為當一個線程通路同步方法或者代碼塊擷取鎖了之後,其他的線程都處于等待阻塞狀态,浪費CPU的資源,并且頻繁的擷取和釋放鎖也消耗CPU的性能等等,是以以前一提到synchronized大家都說它是一個重量級鎖。但是到JDK1.6的時候就對synchronized進行了各種優化來提高它的效率,如JVM會對java代碼進行鎖粗化、鎖消除處理,适應性自旋解決自旋占用大量CPU資源問題,并且加入了偏向鎖和輕量級鎖等對鎖進行了更新優化,最後才是重量級鎖。
· 鎖粗化
加鎖的共享資源範圍越小,那麼其他線程等待阻塞的時間就會越短,這樣明顯比對大範圍資源加鎖效率高。但是加鎖和釋放鎖也需要時間和消耗資源的,如果出現頻繁的加鎖和釋放鎖操作那麼就會導緻消耗CPU性能,鎖粗化就是解決這種問題的。鎖粗化就是在出現很小範圍内代碼進行連續加鎖釋放鎖操作的時候,對其鎖的範圍進行擴大,這樣鎖就變成了外部的一個,避免了小範圍頻繁的鎖操作。典型的案例就是for循環,如下:
· 鎖消除
鎖消除是指當java進行JIT(Just-In-Time)編譯(即時編譯:程式運作時把Class檔案位元組碼編譯成本地機器碼來提高執行效率)運作程式的時候,通過上下文進行逃逸分析(逃逸分析:如果變量被方法中使用,又被方法外使用,那麼這個變量就發生了逃逸)發現如果變量發生了逃逸那麼應該保持鎖,如果沒有發生逃逸那麼不存在競争資源的問題進而會把鎖消除掉,案例如下:
我們知道StringBuffer是一個線程安全的類,它的append方法被synchronized修飾,但是此處因為sb變量隻是一個局部變量,sb 的所有引用不會 “逃逸” 到 test方法之外其他線程無法通路控制到它,是以即使append方法操作有鎖,JVM即使編譯後就會把這個鎖消除掉,上述代碼就會忽略掉同步鎖而執行。
· 悲觀鎖、樂觀鎖、CAS的概念
悲觀鎖:在使用synchronized的時候,如果一個線程擷取到鎖,那麼它就非常的悲觀,認為其他線程通路共享資源會出現沖突,是以其他線程會被阻塞。
CAS操作:compare and swap意思是比較并交換,CAS操作中有三個參數:記憶體位置(V)、預期原值(A)和新值(B);如果記憶體位置的值與預期原值相比對,那麼會自動将該值更新為新值 ,如果不一樣那麼重新計算直到一直為止。
樂觀鎖:CAS的操作就屬于樂觀鎖,不加鎖,而是認為多線程通路共享資源不會出現沖突的情況,如果出現了沖突那麼就重試,直到記憶體值和預期值不沖突為止。
· 鎖更新
JDK1.6後synchronized的鎖狀态總共有4種:無鎖—>偏向鎖—>輕量級鎖—>重量級鎖,鎖的更新順序是從無鎖到重量級的順序,鎖隻能更新不能降級。
在說鎖的更新原理之前呢,我們先了解下我們的對象,大部分對象都是存儲在堆中,而對象的組成主要有三部分:、、;
對象頭由MarkWord 、指向類的指針、以及數組長度三部分組成,這裡我們需要着重熟悉的就是MarkWord部分。MarkWord 用于存儲對象的運作時資料,如HashCode、GC分代年齡、鎖狀态标志、線程持有的鎖、偏向線程ID等。MarkWord 的内容變化會随着鎖的更新而變化。具體變化如下表:
對象真正存儲的有效資訊,也就是代碼中定義的各種字段内容。
對齊填充沒有特别的含義不是必然存在的,它僅僅起着占位符的作用。
無鎖
當對象已建立存儲在記憶體中,它對象頭MarkWord鎖标志預設就是無鎖狀态,無鎖狀态不存在資源的鎖定。
偏向鎖
很多時候可能一段同步代碼總是被一個線程多次通路,這時候并不存在多線程競争的問題,這時候就是加入偏向鎖,使得該線程在後續通路中自動擷取到鎖,降低了頻繁擷取鎖釋放鎖代碼的資源消耗。
原理是當一個線程執行同步方法或者代碼塊的時候,首先從對象頭中的MarkWord中擷取是否是偏向鎖标志:
(1)如果标志為0證明目前為無鎖狀态,就會将目前線程的ID添加到對象頭的MarkWord中,然後将是否是偏向鎖标志改為1,再執行同步代碼;
(2)如果标志為1證明已經是偏向鎖狀态,那就從MarkWord中擷取到偏向線程ID跟目前線程ID比較,如果一樣則不需要再次擷取鎖直接執行同步代碼;如果不一樣執行CAS操作将MarkWord的線程ID設定為目前線程ID,設定成功則執行同步代碼,如果CAS操作失敗證明存在多線程競争情況,需要撤銷已獲得偏向鎖的線程,并且把它持有的鎖更新為輕量級鎖。
輕量級鎖
輕量級鎖是指目前線程是偏向鎖但是被其他線程通路的時候則更新為輕量級鎖,其他線程會通過自旋的形式嘗試擷取鎖,線程不會阻塞,進而提高性能。
自旋:線程的阻塞和喚醒需要C P U從使用者态和核心态進行轉換,比較耗時,是以JVM在發現鎖被一個線程占用的時候,并不會讓其他線程阻塞而是一直循環檢測鎖是否被釋放,當然自旋的次數有限制(可以通過JVM參數-XX:PreBlockSpin 修改),如果達到次數還是沒有擷取鎖才會被挂起。
更新為輕量級鎖主要有兩種情況,第一種就是我們說的目前線程的偏向鎖被其他線程通路的時候會把目前線程更新為輕量級鎖;另外一種就是關閉了偏向鎖功能(JVM參數 -XX:-UseBiasedLocking )。
如果目前線程擷取到的是輕量級鎖,鎖标志為00,如果還有一個線程通路的時候就會進行自旋,但是如果自旋超過了設定的自旋次數,這個線程還是會阻塞,或者線上程自旋的過程中又有其他線程通路了那麼就會把輕量級鎖更新為重量級鎖。
重量級鎖
當輕量級鎖更新為重量級鎖之後,鎖的标志改為10,就會變成我們最初所說的現象,一個線程通路其他線程都阻塞,并且重量級鎖底層依賴的是作業系統的互斥鎖(Mutex Lock)實作的,線程的切換需要使用者态和核心态的轉換,比較耗時效率低。
最後我們使用張流程圖簡單的來總結下鎖的更新過程: