本文主要參考《深入瞭解java虛擬機器器》高效並行章節
關於鎖升級,偏向鎖,輕量級鎖參考《Java並行程式設計的藝術》
關於執行緒安全和執行緒安全的程度參考了《Java並行程式設計實戰》
圖片參考https://www.processon.com/u/5dee0443e4b093b9f775065c#pc
多工處理已經是作業系統的必備技能,計算機被要求同時做好幾件事情,不僅是由於計算機計算能力強大了,還因為cpu的計算能力和儲存以及通訊子系統的速度差異太大了(指cpu工作的時候大部分時間花費在網路io,磁碟io上)所以人們開始讓處理器同時進行多個任務而不是浪費時間等待io操作的完成,從而提高TPS(每秒事務處理數)
解決cpu和記憶體運算能力差距巨大的問題
為了解決CPU和儲存子系統的存在幾個數量級的問題,現代計算機系統引入了多層的高速緩衝作為記憶體和處理器之間的緩衝(有點類似於使用redis提高系統的存取速度,當我們系統的TPS受限於資料的io時,常常把熱點資料放在基於記憶體的redis從而提高TPS)把運算需要資料複製到快取,提高運算速度,當運算結束後再從快取同步到記憶體,從而使處理器不必等待緩慢的記憶體讀寫了
快取記憶體引入的新問題
快取記憶體的引入確實很好的解決了處理器和記憶體速度的問題,但是同時也引入了新的問題——快取一致性:多處理器系統中,每個處理器擁有自己的快取記憶體且共用主記憶體,當多個處理器的運算涉及到同一主記憶體的時候,可能存在快取不一致的問題(ABC三個處理器,最開始快取資料為1,後續各自分別自加1,2,3,最後需要寫回主記憶體,這時候出現快取不一致的問題)
指令重排
為了使處理器內部運算單元可以被充分利用,處理器可能會對輸入的程式碼進行亂序執行的優化,處理器在計算之後把亂序執行結果重組,保證結果和程式設計師定義的程式碼順序執行結果相同,但是並不保證程式中每一個語句的計算先後順序與輸入程式碼中順序一致。所以如果存在一個計算任務依賴另一個任務的中間結果的時候將無法依靠程式碼的順序來進行保證,Java虛擬機器器的即時編譯器也存在這樣的指令重排技術
遮蔽了作業系統的差異,保證java程式碼在不同的作業系統上並行完全正常,Java記憶體模型簡稱為JMM
JMM定義了程式中共用變數的存取規則,關注於虛擬機器器把共用變數儲存到記憶體和從記憶體取值的底層細節。(共用變數指的是執行緒共用的欄位,常包括範例欄位,靜態欄位,構成數物件的元素,但是不包括區域性變數和方法引數,這裡的區域性變數是reference型別,雖然它指向的物件在堆中是執行緒共用的但是reference本身是在棧的區域性變數中,是執行緒私有的)。 JMM規定了:
JMM規定了一個變數如何從主記憶體拷貝到工作記憶體,如何從工作記憶體同步到主記憶體的實現細節,並且定義了8中類似的操作,Java虛擬機器器保證以下操作都是原子的不可再分的(這裡64位元的double型別,long型別在某寫平臺可能load,store,read,write並不保證原子性,也就意味著可能存在半讀半寫的情況,但是無需過於關注這一點)
賦值
給工作記憶體中的變數JMM要求一個變數從主記憶體拷貝工作記憶體,必須順序的執行read load
要把變數從工作記憶體同步到主記憶體,也要順序執行store write
但是兩個命令之間可以插入其他命令
到這裡我們可以簡單分析下為什麼i++這種操作執行緒不安全
首先i一開始位於主記憶體,被多個執行緒使用read 和load 從主記憶體複製到工作記憶體,
然後每一個執行緒使用use進行自增操作,
然後assign 賦值給執行緒私有的工作記憶體,
然後每一個執行緒使用store把工作記憶體中值傳送到主記憶體,
然後使用write操作寫到主記憶體
雖然這每一步都是原子性的,但是合在一起就不是原子性的,
也就是說執行緒A write到主記憶體的時候,執行緒B也進行了write 造成都寫入了2,
執行緒C也沒有線上程A寫回主記憶體後再拉去i的值進行自增,
依舊使用的是工作記憶體中的i
volatile是java提供的輕量級同步機制,當一個變數被定義成volatile後,它會具備以下兩個特性
執行緒可見性
volatile修飾的變數,在賦值後會多執行一個lock add$0xx,(%esp)
的操作,lock字首的指令會導致將當前工作記憶體此變數寫回到主記憶體
,寫回記憶體的操作會是其他CPU中的快取了此變數的資料無效
,每個處理通過嗅探匯流排上傳播的資料來判斷自己的快取資料是否過期,如果過期當處理器需要對資料進行修改的時候將從主記憶體中獲取最新的資料
禁止指令重排序
lock add$0xx,(%esp)
把修改同步到主記憶體意味著前面指令的操作以及執行完成,也就是說lock之前的指令不可在lock指令之後,從而實現重排序無法越過記憶體屏障的效果
這裡我們說下為什麼雙重if+synchronizated為什麼要把參照使用volatile修飾
建立一個物件範例,可以分為三步:
1.分配物件記憶體。
2.呼叫構造器方法,執行初始化。
3.將物件參照賦值給變數。
如果不適應volatile 可能出現被重排為
1.分配物件記憶體。
2.將物件參照賦值給變數。
3.呼叫構造器方法,執行初始化。
假設這個執行緒A正在執行new Instance()下執行倒2,
已經改變了instance的指向
這個時候執行緒B也呼叫getInstance 將導致執行緒B獲得還沒有執行初始化的物件範例
volatile在雙重if+synchronizated的單例實際是起到了禁止指令重排的作用
插入`lock add$0xx,(%esp)`保證了1,2,3都完成執行完,1,2,3可以進行重排,但是1,2,3必須都在`lock add$0xx,(%esp)`的前面執行
先行發生原子是我們判斷資料是否存在競爭,執行緒是否安全的有用手段。先行發生是java記憶體模型中定義兩項操作之前的偏序關係,並不意味著時間上的先後,而是說先行發生的操作對後續操作是可見的,比如我們說A先行發生於B,那麼意味著A操作造成的影響對B是可見的
private int value;
public void setValue(int value){
this.value=value;
}
public int getValue(){
return value;
}
如果執行緒A在10:20:01 呼叫了setValue(1)
,執行緒B在10:20:02呼叫了getValue()
那麼執行緒B得到的是什麼暱,顯然是無法確定的也許是0,也許是1,因為無法確定執行緒A重新整理工作記憶體到主記憶體和執行緒B重新獲取主記憶體中值的先後順序。
使用volatile修飾value
套用volatile 變數規則:同一個volatile變數的寫先行發生於後面對此變數的讀,所以執行緒A寫入是先於執行緒B的讀取,所以B可以正確拿到值
使用synchronized修飾方法
套用管程鎖定規則:unlock操作先行發生於後面對同一個鎖的lock操作(後面是時間上的先後),A釋放鎖在B拿到鎖之前,B也可以成功拿到值
同樣我們還可以使用基於AQS實現的ReentrantLock(見我的筆記《JUC原始碼學習筆記1——AQS獨佔模式和ReentrantLock》),我的理解是ReentrantLock套用的是volatile 變數規則,加鎖解鎖其實是修改state這個volatile變數,然後volatile可以防止指令重排,對value的設定肯定是位於鎖獲取和釋放之間的
執行緒是比程序更輕量級的排程執行單位,執行緒的引入,可以把一個程序的資源分配和執行排程分開,各個執行緒可以共用程序資源(記憶體地址,檔案io)又可以獨立排程。對於hotspot虛擬機器器來說,Java執行緒都是對映到一個作業系統原生執行緒來實現,而且中間沒有額外的間接結構,所以hotspot不會干預執行緒排程的(只能通過執行緒優先順序給作業系統排程的建議),何時凍結執行緒,給執行緒分配多少處理器執行時間,執行緒安排給哪個處理器核心執行都是由作業系統完成的且全權決定的。
執行緒的排程是指系統為執行緒分配處理器使用權的過程,排程方式主要有兩種:協同式排程(使用多少時間由執行緒本身控制)搶佔式——每個執行緒由系統來分配執行時間,執行緒的切換不由執行緒本身決定,但是執行緒可以主動讓出執行時間比如Thread::yield()
方法,但是想主動獲取處理器執行時間,執行緒本身無法做到,搶佔式排程避免了協同式排程執行緒處理器執行時間不可控制的問題
當多個執行緒同時存取一個物件時,如果不用考慮這些多執行緒在執行時環境下的排程和交替執行,也不需要進行額外的同步,或者呼叫方不需要進行任何其他的協調工作,呼叫這個物件的行為都可以獲取正確的結構,那麼我們稱這個物件時執行緒安全的
執行緒安全的物件封裝了必要的正確性保障手段,呼叫方無須過多擔心多執行緒問題
不可變物件一定時執行緒安全的,無論時方法的實現或者還是方法的呼叫者,都不需要過多的關注執行緒安全問題,比如說String(拋開反射修改char陣列的奇葩行為)大部分方法都是生產一個新的String物件,以及Integer等。在java中,如果一個多執行緒共用的資料是一個final修飾的基本型別,那麼必定不會再這個基本型別上存線上程安全問題,但是如果是一個參照型別那麼需要我們自己自行保證執行緒安全。
不管執行時環境如何,呼叫者都不需要額外的同步措施的多線是絕對執行緒安全的物件。這個絕對是比較難以理解的,比如Vector使用synchronized修飾方法,那麼就是執行緒絕對安全的麼?不是我們只能保證單獨的操作是執行緒安全的,但是比如先contain檢視是否包含然後add這種複合操作任然需要我們自己加鎖保證。這裡的絕對是說無論我如何使用當前這個類那麼都是執行緒安全的
這種就是我們常常說的執行緒安全,只需要保證這種物件單次的操作是執行緒安全的,不需要任何額外手段保證執行緒安全,但是一些連續的符合方法呼叫任然需要自行保證執行緒安全
執行緒相容是物件本身不是執行緒安全的,但是可以通過在呼叫端使用一些手段保證執行緒安全,那麼就可以保證這個物件在並行中是安全的。這就是我們常說的執行緒不安全
不論呼叫端是否採取同步措施,都無法保證執行緒安全的物件。比如Thread的suspend()
,resume
,如果兩個執行緒分別呼叫另外一個執行緒的這兩個方法,都存在死鎖的風險。
當執行緒A使用鎖L,如果執行緒A處於suspend狀態,而另一個執行緒B,需要L這把鎖才能resume執行緒A,因為執行緒A處理suspend狀態,是不會釋放L鎖,所以執行緒B線獲取不到這個鎖,而一直處於爭奪L鎖的狀態,最終導致死鎖。
最常見的執行緒安全手段,同步的意思時多個執行緒並行存取共用資料時,保證共用資料同一個時刻只能被一個或一些執行緒存取到。互斥是實現同步的一種方式,臨界區,互斥量,號誌是常見的互斥方式。
臨界區:保證在某一時刻只有一個執行緒能存取資料的簡便辦法。在任意時刻只允許一個執行緒對共用資源進行存取。如果有多個執行緒試圖同時存取臨界區,那麼 在有一個執行緒進入後其他所有試圖存取此臨界區的執行緒將被掛起,並一直持續到進入臨界區的執行緒離開。
互斥量:互斥量跟臨界區很相似,只有擁有互斥物件的執行緒才具有存取資源的許可權,由於互斥物件只有一個,因此就決定了任何情況下此共用資源都不會同時被多個執行緒所存取
常見是使用synchronized
和互斥鎖ReentrantLock
,前者編譯後生成monitorenter 和monitorexit指令,如果當前執行緒重複進行monitorenter那麼計數加1,多少次monitorenter那麼就是需要多少次monitorexit。計數為0表示放棄鎖,如果獲取鎖前被另外一個執行緒執行了monitorenter 那麼當前執行緒將一致被阻塞等待知道鎖被釋放。後者可以看我關於AQS的筆記
互斥同步屬於一種悲觀的並行策略(無論共用資料有無競爭,先上鎖)都會導致使用者態到核心態的切換。基於衝突檢測的樂觀並行策略——CAS(也有其他方式,但是提得最多的就是CAS了)比較並交換,CAS需要三個引數,第一個是共用資料的地址,第二個是預期的舊值,第三個是準備設定的新值,只有當前地址對應的值等於舊值的時候才會設定地址對應的值為新值,如果發現舊值不等於地址的值那麼將操作失敗。CAS的原子性由作業系統指令(如cmpxchg)來保證。Java中Unsafe類和原子類提供了CAS方法,可以看我關於原子類的筆記。
指在程式碼執行的任何時候都可以中斷它,去執行另外一段程式碼(包括呼叫自己)控制權回來的時候程式不會出現任何錯誤,對結果也不會有任何影響。(可重入程式碼,一般都不依賴於全域性變數,不依賴堆上的物件,狀態都從引數傳入,不呼叫其他不可重入的程式碼)有點類似無狀態的servlet
把共用資料的可見限制在一個執行緒之內,那麼無需同步也可以實現安全。關於ThreadLocal可以看我的相關原始碼學習
互斥同步由於執行緒的掛起和回覆執行緒的操作都需要從使用者態和核心態的切換,導致jvm並行效能的降低。但是共用資料的鎖定狀態有時候只會持續一小段時間,為了加鎖去掛起和恢復執行緒並不值得。自旋鎖應運而生,不讓執行緒立馬掛起而是讓執行緒執行自旋讓執行緒等待鎖的釋放。
自旋是執行緒執行迴圈,雖然避免了執行緒的切換,但是自旋是需要佔用處理器時間的,所以如果鎖被佔用的時間很短那麼自旋鎖能夠有一個很好的效果,但是如果鎖被霸佔的時間比較長,那麼執行緒一致進行自旋消耗cpu資源,將導致效能的浪費。因此自旋等待的時間需要有一定的限度,如果自旋的次數超過了限定的次數但是還是沒有獲取到鎖,這時候需要掛起執行緒。在這個背景下,JDK6進行了自適應自旋,自適應自旋意味著自旋的時間不在固定了,而是由上一次在同一個鎖上的自旋時間以及鎖擁有者的狀態來決定的。如果在同一個鎖上面,自旋等待剛剛獲取到了鎖,並且持有鎖的執行緒正在執行中,那麼虛擬機器器會認為這次自旋也很有可能再次成功,進而允許自旋等待持續相對更長的時間。如果一個鎖,自旋很少成功獲得鎖,那麼在後續對這個鎖的爭奪的時候將可能直接忽略自旋過程,避免處理器資源的浪費。有了自適應自旋,隨著程式執行的時間以及效能監控的完善,虛擬機器器對鎖的預測將越來越精準。
指虛擬器即時編譯器執行過程中,對於一段需要同步的程式碼,檢測其根本不存在共用資料的競爭,那麼將對競爭的鎖進行消除
public synchronized String concat(String a,String b){
return a+b;
}
//string的連線操作總是產生一個新的字串
//上面程式碼也許會被優化成棧中new一個 StringBuffer 進行append
//StringBuffer中的同步鎖,鎖的是StringBuffer物件,
//但是StringBuffer在棧中new出來的
//逃逸分析就發現動態作用域在concat內部那麼就不需要執行緒同步
虛擬機器器發現這段程式碼中,在堆中的資料都不會逃逸出被其他執行緒存取到,那麼就會視為棧上的資料對待,認為它們執行緒私有,那麼加鎖也就沒有了必要。
隨讓我們通常推薦鎖的粒度儘可能小,從而提高效能,但是如果對一個物件重複的進行加鎖解鎖,甚至是在for迴圈中加鎖解鎖,那麼即使沒有執行緒競爭,頻繁進行互斥同步也是非常浪費效能,也許虛擬機器器會幫我們擴大鎖的範圍,比如擴大到for迴圈的外部,這樣只需要進行一次加鎖解鎖了
以下是Java物件的記憶體佈局
Java物件頭儲存的資訊有:
其中markword儲存了許多和物件執行狀態相關的資料,且這一部分涉及的非常的靈活,在不同的情況下可以表示不同的資訊,下面是不同鎖狀態下32位元虛擬機器器markword儲存資訊的結構
JDK6為了解決獲取鎖釋放鎖的效能消耗,引入了偏向鎖和輕量級鎖。鎖的狀態從低到高依次是無鎖,偏向鎖,輕量級鎖,重量級鎖
hotspot的作者經過研究發現,大多數情況下,鎖不僅不存在多執行緒競爭,還常常是由同一個執行緒多次獲取,那麼能不能讓鎖記錄這個執行緒,下次這個執行緒來直接獲取鎖即可,從而降低獲得鎖的代價。當一個執行緒範圍同步塊並且獲取鎖時,會在物件頭和站在棧幀中記錄儲存鎖偏向的執行緒ID,以後進入和退出同步塊的時候都不需要進行CAS操作加鎖和解鎖。
首先直接看物件頭中是否儲存了當前執行緒的id,如果儲存了那麼當前執行緒拿到鎖,這就是偏向的含義,如果失敗,那麼再看下當前鎖標誌位是否是01,01表示無鎖或者偏向鎖,繼續判斷是否是偏向鎖,如果是偏向鎖那麼需要檢視是否儲存了當前執行緒id,如果沒有儲存任何執行緒id那就CAS設定執行緒id。如果當前時無鎖也是CAS設定執行緒Id
偏向鎖使用一種等待競爭出現的時候才釋放鎖的機制,當其他執行緒嘗試競爭偏向鎖時,持有偏向鎖的執行緒才會釋放鎖。偏向鎖的復原需要等待全域性安全點(這個時間點沒有正在執行的位元組碼)首先需要暫停持有偏向鎖的執行緒,然後檢查持有偏向鎖的執行緒是否存活,如果持有的執行緒不處於活動狀態那麼設定為無鎖狀態,如果任然存活,那麼擁有偏向鎖的棧才會被執行,遍歷偏向物件的鎖記錄,棧中的鎖記錄和物件頭的markword要麼偏向其他執行緒,要麼恢復到無鎖,要麼標記物件不適合偏向鎖。後續被標記不適合偏向鎖那麼將使用輕量級鎖
執行緒在執行同步塊之前,JVM會先在當前執行緒的棧幀中建立用於儲存鎖記錄的空間(lock record)並且將物件頭中的mark word 賦值到鎖記錄lock record中,這種操作叫做Displaced mark word。然後執行緒產品使用CAS把物件頭的mark word替換為指向鎖記錄的指標,如果成功那麼表示當前執行緒獲取到鎖,如果失敗,表示存在其他執行緒競爭鎖,當前執行緒將通過自旋的方式獲取鎖。輕量級鎖解決了競爭執行緒不多,並且鎖很快就釋放,這時候掛起喚醒執行緒不划算,通過自旋減少使用者態到核心態的切換。
解鎖,使用CAS操作把Displaced Mark Word替換回到物件頭,如果成功表示沒有不存在競爭,如果失敗表示當前鎖存在競爭,那麼鎖會膨脹為重量級鎖