上一篇中學習了線程安全相關的知識,知道了線程安全問題主要來自JMM的設計,集中在主內存和線程的工做內存而致使的內存可見性問題,及重排序致使的問題。上一篇也提到共享數據會出現可見性和競爭現象,若是多線程間沒有共享的數據也就是說多線程間並無協做完成一件事情,那麼,多線程就不能發揮優點,不能帶來巨大的價值。而共享數據如何處理,一個很簡單的想法就是依次去讀寫共享變量,這樣就能保證讀寫的數據是最新的,就不會出現數據安全性問題,java中咱們使用synchronized關鍵字去作讓每一個線程依次排隊操做共享變量的功能。很明顯這樣作效率不高,可是這是基礎。java
數據庫
分類 | 具體分類 | 被鎖對象 | 僞代碼 |
方法 | 實例方法 | 類的實例對象 | public synchronized void method(){} |
靜態方法 | 類對象 | public static synchronized void method(){} | |
代碼塊 | 實例對象 | 類的實例對象 | synchronized(this){} |
class對象 | 類對象 | synchronized(Demo.class){} | |
任意實例對象的Object | 實例對象Object | String lock="";synchronized(lock){} |
編程
先看下下面這段代碼:數組
public class SynchronizedDemo1 { private int count; public void countAdd() { for (int i = 0; i < 5; i++) { try { System.out.println(Thread.currentThread().getName() + ":" + (count++)); Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } } } public static void main(String[] args) { SynchronizedDemo1 demo1 = new SynchronizedDemo1(); new Thread(new Runnable() { @Override public void run() { demo1.countAdd(); } }).start(); new Thread(new Runnable() { @Override public void run() { demo1.countAdd(); } }).start(); } }
這是未使用synchronized時,運行結果:安全
這裏能夠看出,兩個線程是同時運行的,並且出現了必定的線程安全問題,共享變量的數據存在問題,這個時候咱們加上關鍵字synchronized,只要改一行代碼。數據結構
public synchronized void countAdd()
運行結果以下:多線程
這裏能夠看到Thread-1增長數據是等Thread-0增長完數據以後才進行的,說明Thread-0和Thread-1是順序執行countAdd方法的。併發
其實咱們要理解這裏的原理,一個對象只有一把鎖,當一個線程獲取了該對象的鎖以後,其餘線程沒法獲取該對象的鎖,因此沒法訪問該對象的其餘synchronized方法,因此上面說被鎖的是這個實例對象,在想深刻一點,咱們再添加一個不加鎖的方法,會怎麼樣:app
public void print() { System.out.println(Thread.currentThread().getName() + "的打印方法:" + count); }
而後改下主方法:jvm
public static void main(String[] args) { SynchronizedDemo1 demo1 = new SynchronizedDemo1(); new Thread(new Runnable() { @Override public void run() { demo1.countAdd(); } }).start(); new Thread(new Runnable() { @Override public void run() { demo1.print(); demo1.countAdd(); } }).start(); }
很明顯這裏Thread-1的打印方法不須要等待Thread-0的增長方法結束,這個咱們要理解非synchronized方法是不須要獲取到對象鎖就能夠執行的。
還有一點,這裏鎖住的是實例對象,若是咱們生成了多個實例,那他們之間是不受影響的,也就是多個實例多個鎖。這裏可能須要本身理解理解,並非那麼簡單。
上面的方法主要是針對的普通方法,普通方法鎖住的是實例對象,咱們知道靜態方法是屬於類的,也就是說靜態方法鎖定的是這個類的全部對象,即無論建立多少個實例,都須要等待鎖釋放:
public class SynchronizedDemo2 { private static int count; public static synchronized void countAdd() { for (int i = 0; i < 5; i++) { try { System.out.println(Thread.currentThread().getName() + ":" + (count++)); Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } } } public static void main(String[] args) { SynchronizedDemo2 demo1 = new SynchronizedDemo2(); SynchronizedDemo2 demo2 = new SynchronizedDemo2(); new Thread(new Runnable() { @Override public void run() { demo1.countAdd(); } }).start(); new Thread(new Runnable() { @Override public void run() { demo2.countAdd(); } }).start(); } }
結果:
這裏能夠看到雖然是建立了兩個實例,可是他們之間仍是用了同一把鎖。
synchronized不只能夠做用某個方法,也能夠做用代碼塊,在某些狀況下,咱們編寫的方法體可能比較大,同時存在一些比較耗時的操做,而須要同步的代碼又只有一小部分,若是直接對整個方法進行同步操做,可能會得不償失,此時咱們可使用同步代碼塊的方式對須要同步的代碼進行包裹,這樣就無需對整個方法進行同步操做了。
public class SynchronizedDemo3 { private int count; public void countAdd() { synchronized (this) { for (int i = 0; i < 5; i++) { try { System.out.println(Thread.currentThread().getName() + ":" + (count++)); Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } } } } public static void main(String[] args) { SynchronizedDemo3 demo1 = new SynchronizedDemo3(); new Thread(new Runnable() { @Override public void run() { demo1.countAdd(); } }).start(); new Thread(new Runnable() { @Override public void run() { demo1.countAdd(); } }).start(); } }
運行結果與以前相同,這裏使用的是this,說明鎖住的是實例對象,結合前面的理解,若是建立多個實例也是不會鎖住的。
可是若是將所 synchronized (this)改爲synchronized (SynchronizedDemo3.class),那鎖住的就是類,那即便建立多個實例,也依然會被鎖住。
synchronized的使用最重要的是理解鎖住的對象是啥,下面來分析下底層的實現原理。
先看一段簡單的代碼:
public class SynchronizedDemo { public static void main(String[] args) { synchronized (SynchronizedDemo.class) { } method(); } private synchronized static void method() { } }
代碼中有同步代碼,鎖住類對象,編譯完成後,切至SynchronizedDemo.class目錄中,執行javap -v SynchronizedDemo.class查看字節碼文件。
在紅色框中咱們看到執行同步代碼塊後首先要先執行monitorenter指令,退出的時候monitorexit指令。使用Synchronized進行同步,其關鍵就是必需要對對象的監視器monitor進行獲取,當線程獲取monitor後才能繼續往下執行,不然就只能等待。而這個獲取的過程是互斥的,即同一時刻只有一個線程可以獲取到monitor。上面的demo中在執行完同步代碼塊以後緊接着再會去執行一個靜態同步方法,而這個方法鎖的對象依然就這個類對象,那麼這個正在執行的線程還須要獲取該鎖嗎?答案是沒必要的,從上圖中就能夠看出來,執行靜態同步方法的時候就只有一條monitorexit指令,並無monitorenter獲取鎖的指令。這就是鎖的重入性,即在同一鎖程中,線程不須要再次獲取同一把鎖。Synchronized先天具備重入性。每一個對象擁有一個計數器,當線程獲取該對象鎖後,計數器就會加一,釋放鎖後就會將計數器減一。
任意一個對象都擁有本身的監視器,當這個對象由同步塊或者這個對象的同步方法調用時,執行方法的線程必須先獲取該對象的監視器才能進入同步塊和同步方法,若是沒有獲取到監視器的線程將會被阻塞在同步塊和同步方法的入口處,進入到BLOCKED狀態。
任意線程對Object的訪問,首先要得到Object的監視器,若是獲取失敗,該線程就進入同步狀態,線程狀態變爲BLOCKED,當Object的監視器佔有者釋放後,在同步隊列中得線程就會有機會從新獲取該監視器。
咱們再看看Synchronized的happens-before規則,看下面代碼:
public class MonitorDemo { private int a = 0; public synchronized void writer() { // 1 a++; // 2 } // 3 public synchronized void reader() { // 4 int i = a; // 5 } }
該代碼的happens-before關係如圖所示:
在圖中每個箭頭鏈接的兩個節點就表明之間的happens-before關係,黑色的是經過程序順序規則推導出來,紅色的爲監視器鎖規則推導而出:線程A釋放鎖happens-before線程B加鎖,藍色的則是經過程序順序規則和監視器鎖規則推測出來happens-befor關係,經過傳遞性規則進一步推導的happens-before關係。如今咱們來重點關注2 happens-before 5,經過這個關係咱們能夠得出什麼?
根據happens-before的定義中的一條:若是A happens-before B,則A的執行結果對B可見,而且A的執行順序先於B。線程A先對共享變量A進行加一,由2 happens-before 5關係可知線程A的執行結果對線程B可見即線程B所讀取到的a的值爲1。
在上一篇文章提到過JMM核心爲兩個部分:happens-before規則以及內存抽象模型。咱們分析完Synchronized的happens-before關係後,仍是不太完整的,咱們接下來看看基於java內存抽象模型的Synchronized的內存語義。
廢話很少說依舊先上圖。
從上圖能夠看出,線程A會首先先從主內存中讀取共享變量a=0的值而後將該變量拷貝到本身的本地內存,進行加一操做後,再將該值刷新到主內存,整個過程即爲線程A 加鎖-->執行臨界區代碼-->釋放鎖相對應的內存語義。
線程B獲取鎖的時候一樣會從主內存中共享變量a的值,這個時候就是最新的值1,而後將該值拷貝到線程B的工做內存中去,釋放鎖的時候一樣會重寫到主內存中。
從總體上來看,線程A的執行結果(a=1)對線程B是可見的,實現原理爲:釋放鎖的時候會將值刷新到主內存中,其餘線程獲取鎖時會強制從主內存中獲取最新的值。另外也驗證了2 happens-before 5,2的執行結果對5是可見的。
在同步的時候是獲取對象的monitor,即獲取到對象的鎖。那麼對象的鎖怎麼理解?無非就是相似對對象的一個標誌,那麼這個標誌就是存放在Java對象的對象頭。
jvm中採用2個字來存儲對象頭(若是對象是數組則會分配3個字,多出來的1個字記錄的是數組長度),其主要結構是由Mark Word 和 Class Metadata Address 組成,其結構說明以下表:
其中Mark Word在默認狀況下存儲着對象的HashCode、分代年齡、鎖標記位等如下是32位JVM的Mark Word默認存儲結構
因爲對象頭的信息是與對象自身定義的數據沒有關係的額外存儲成本,所以考慮到JVM的空間效率,Mark Word 被設計成爲一個非固定的數據結構,以便存儲更多有效的數據,它會根據對象自己的狀態複用本身的存儲空間,如32位JVM下,除了上述列出的Mark Word默認存儲結構外,還有以下可能變化的結構:
從上面學習的內容來看,synchronized具備互斥性(排它性),這種方式效率很低下,由於每次都只能經過一個線程。
synchronized會讓沒有獲得鎖資源的線程進入BLOCKED狀態,然後在爭奪到鎖資源後恢復爲RUNNABLE狀態,這個過程當中涉及到操做系統用戶模式和內核模式的轉換,代價比較高。
咱們天天擠地鐵過安檢的時候,都須要排隊,排隊的時候咱們須要將包放入安檢機,但這些都很是耗時,想要加快速度,那這個時候就有了人工檢查,人工檢查大大的減小了檢查的時間,這樣咱們排隊速度也就更快,這就是一個優化思路。
CAS是一個很是重要的概念,可是又很抽象,沒辦法,只能死磕了。
從思想上來講,Synchronized屬於悲觀鎖,悲觀地認爲程序中的併發狀況嚴重,因此嚴防死守。CAS屬於樂觀鎖,它假設全部線程訪問共享資源的時候不會出現衝突,既然不會出現衝突天然而然就不會阻塞其餘線程的操做。所以,線程就不會出現阻塞停頓的狀態。那麼,若是出現衝突了怎麼辦?無鎖操做是使用CAS(compare and swap)又叫作比較交換來鑑別線程是否出現衝突,出現衝突就重試當前操做直到沒有衝突爲止。
CAS是英文單詞Compare And Swap的縮寫,翻譯過來就是比較並替換。
CAS機制當中使用了3個基本操做數:內存地址V,舊的預期值O,要修改的新值N。
更新一個變量的時候,只有當變量的預期值O和內存地址V當中的實際值相同時,纔會將內存地址V對應的值修改成N。
若是O和V不相同,代表該值已經被其餘線程改過了則該舊值O不是最新版本的值了,因此不能將新值N賦給V,返回V便可。當多個線程使用CAS操做一個變量時,只有一個線程會成功,併成功更新,其他會失敗。失敗的線程會從新嘗試,固然也能夠選擇掛起線程。
元老級的Synchronized(未優化前)最主要的問題是:在存在線程競爭的狀況下會出現線程阻塞和喚醒鎖帶來的性能問題,由於這是一種互斥同步(阻塞同步)。而CAS並非武斷的間線程掛起,當CAS操做失敗後會進行必定的嘗試,而非進行耗時的掛起喚醒的操做,所以也叫作非阻塞同步。這是二者主要的區別。
在J.U.C包中利用CAS實現類有不少,能夠說是支撐起整個concurrency包的實現,在Lock實現中會有CAS改變state變量,在atomic包中的實現類也幾乎都是用CAS實現,關於這些具體的實現場景在以後會詳細聊聊,如今有個印象就行了(微笑臉)。
1. ABA問題 由於CAS會檢查舊值有沒有變化,這裏存在這樣一個有意思的問題。好比一箇舊值A變爲了成B,而後再變成A,恰好在作CAS時檢查發現舊值並無變化依然爲A,可是實際上的確發生了變化。解決方案能夠沿襲數據庫中經常使用的樂觀鎖方式,添加一個版本號能夠解決。原來的變化路徑A->B->A就變成了1A->2B->3C。
2. 自旋時間過長
使用CAS時非阻塞同步,也就是說不會將線程掛起,會自旋(無非就是一個死循環)進行下一次嘗試,若是這裏自旋時間過長對性能是很大的消耗。若是JVM能支持處理器提供的pause指令,那麼在效率上會有必定的提高。
3. 只能保證一個共享變量的原子操做
當對一個共享變量執行操做時CAS能保證其原子性,若是對多個共享變量進行操做,CAS就不能保證其原子性。有一個解決方案是利用對象整合多個共享變量,即一個類中的成員變量就是這幾個共享變量。而後將這個對象作CAS操做就能夠保證其原子性。atomic中提供了AtomicReference來保證引用對象之間的原子性。
Java SE 1.6中,鎖一共有4種狀態,級別從低到高依次是:無鎖狀態、偏向鎖狀態、輕量級鎖狀態和重量級鎖狀態,這幾個狀態會隨着競爭狀況逐漸升級。鎖能夠升級但不能降級,意味着偏向鎖升級成輕量級鎖後不能降級成偏向鎖。這種鎖升級卻不能降級的策略,目的是爲了提升得到鎖和釋放鎖的效率。
通過研究發現,在大多數狀況下,鎖不只不存在多線程競爭,並且老是由同一線程屢次得到,所以爲了減小同一線程獲取鎖(會涉及到一些CAS操做,耗時)的代價而引入偏向鎖。
獲取偏向鎖
當一個線程訪問同步塊並獲取鎖時,會在對象頭和棧幀中的鎖記錄裏存儲鎖偏向的線程ID,之後該線程在進入和退出同步塊時不須要進行CAS操做來加鎖和解鎖,只需簡單地測試一下對象頭的Mark Word裏是否存儲着指向當前線程的偏向鎖。若是測試成功,表示線程已經得到了鎖。若是測試失敗,則須要再測試一下Mark Word中偏向鎖的標識是否設置成1(表示當前是偏向鎖):若是沒有設置,則使用CAS競爭鎖;若是設置了,則嘗試使用CAS將對象頭的偏向鎖指向當前線程。
撤銷偏向鎖
偏向鎖使用了一種等到競爭出現才釋放鎖的機制,因此當其餘線程嘗試競爭偏向鎖時,持有偏向鎖的線程纔會釋放鎖。
如圖,偏向鎖的撤銷,須要等待全局安全點(在這個時間點上沒有正在執行的字節碼)。它會首先暫停擁有偏向鎖的線程,而後檢查持有偏向鎖的線程是否活着,若是線程不處於活動狀態,則將對象頭設置成無鎖狀態;若是線程仍然活着,擁有偏向鎖的棧會被執行,遍歷偏向對象的鎖記錄,棧中的鎖記錄和對象頭的Mark Word要麼從新偏向於其餘線程,要麼恢復到無鎖或者標記對象不適合做爲偏向鎖,最後喚醒暫停的線程。
關閉偏向鎖
偏向鎖在Java 6和Java 7裏是默認啓用的,可是它在應用程序啓動幾秒鐘以後才激活,若有必要可使用JVM參數來關閉延遲:-XX:BiasedLockingStartupDelay=0。若是你肯定應用程序裏全部的鎖一般狀況下處於競爭狀態,能夠經過JVM參數關閉偏向鎖:-XX:-UseBiasedLocking=false,那麼程序默認會進入輕量級鎖狀態。
偏向鎖的核心思想是,若是一個線程得到了鎖,那麼鎖就進入偏向模式,此時Mark Word 的結構也變爲偏向鎖結構,當這個線程再次請求鎖時,無需再作任何同步操做,即獲取鎖的過程,這樣就省去了大量有關鎖申請的操做,從而也就提供程序的性能。因此,對於沒有鎖競爭的場合,偏向鎖有很好的優化效果,畢竟極有可能連續屢次是同一個線程申請相同的鎖。可是對於鎖競爭比較激烈的場合,偏向鎖就失效了,由於這樣場合極有可能每次申請鎖的線程都是不相同的,所以這種場合下不該該使用偏向鎖,不然會得不償失。
假若偏向鎖失敗,虛擬機並不會當即升級爲重量級鎖,它還會嘗試使用一種稱爲輕量級鎖的優化手段(1.6以後加入的),此時Mark Word 的結構也變爲輕量級鎖的結構。輕量級鎖可以提高程序性能的依據是「對絕大部分的鎖,在整個同步週期內都不存在競爭」,注意這是經驗數據。須要瞭解的是,輕量級鎖所適應的場景是線程交替執行同步塊的場合,若是存在同一時間訪問同一鎖的場合,就會致使輕量級鎖膨脹爲重量級鎖。
輕量級鎖失敗後,虛擬機爲了不線程真實地在操做系統層面掛起,還會進行一項稱爲自旋鎖的優化手段。這是基於在大多數狀況下,線程持有鎖的時間都不會太長,若是直接掛起操做系統層面的線程可能會得不償失,畢竟操做系統實現線程之間的切換時須要從用戶態轉換到核心態,這個狀態之間的轉換須要相對比較長的時間,時間成本相對較高,所以自旋鎖會假設在不久未來,當前的線程能夠得到鎖,所以虛擬機會讓當前想要獲取鎖的線程作幾個空循環(這也是稱爲自旋的緣由),通常不會過久,多是50個循環或100循環,在通過若干次循環後,若是獲得鎖,就順利進入臨界區。若是還不能得到鎖,那就會將線程在操做系統層面掛起,這就是自旋鎖的優化方式,這種方式確實也是能夠提高效率的。最後沒辦法也就只能升級爲重量級鎖了。
這篇的內容有些抽象,主要參考的是《Java併發編程藝術》