java兩種同步機制的實現 synchronized和reentrantlock
我們知道,java是一種高級語言,java運行在jvm中,java編譯器會把我們程序猿寫的java代碼編譯成.class文件,這個.class對於jvm就是相當於匯編對於操作系統(jvm也有類似操作系統一樣的指令集),當jvm運行的時候,它會把.class翻譯成操作系統認識的指令集然后運行在操作系統(這里對java是解釋型還是編譯型語言不做深究),當然除此之外,java還可以通過jni(java native interface)調用c++,c的代碼(c++,c還可以內嵌匯編代碼,所以java是可以間接調用匯編的),jvm內存模型中有一個是Native Method Stacks,這里就是我們調用本地方法的棧,我們今天講的java同步,就同時和java指令集和jni有關,下面進入正題。
當在java中使用多線程的時候,我們肯定要考慮到線程安全,線程安全簡單說就是多個線程操作同一個變量不會出現結果不確定性,那為什么會出現線程不安全?那是因為java的內存模型決定,jvm有一個主內存,還有線程獨有的線程上的工作內存,我打個比方,一個線程就是一個cpu,cpu有自己的多級緩存,還有一個內存條的內存。下面就盜個圖簡單說明一下:

因為這個原因,所以當多個線程修改同一個變量的時候,會出現不確定的結果,這就導致了線程不安全,那怎么樣我們才能確保線程安全呢?說的通俗一點,就是排隊,沒錯,當多個線程需要操作同一個變量的時候,排隊一個一個來,那要怎樣才能實現排隊?機器不像人這么聰明,看到前面有“人”就站着等,那最簡單粗暴的方法就是加鎖,可以想象一下,一個線程要去修改一個變量的時候,前面有一道門,當門被鎖住的時候,線程就只能等待或者干其他事。到了java的世界,在語言層面,我們有兩種方法去實現我們鎖的功能,下面就是我要重點講的東西,第一個是jdk自帶synchronized(關鍵字),這個就是前面說的利用java自己的指令集實現的鎖,第二個是我們 Doug Lea 大神主導的並發包中的 reentrantlock(類),這個底層就是通過jni是調用了虛擬機中的C++代碼(parker類),下面我們就依次對這兩個"東西"進行詳細的展開。
synchronized,顧名思義,提供同步的語義,在java語言層面中,可以通過synchronized來控制不同的作用域,可以是類,可以是對象,可以是方法,可以是代碼塊,我們來看它是怎么來實現鎖的功能的,我們直接實戰,我先寫一個測試類:
public class SyncTest {
Object lock = new Object();
public void sync(){
synchronized (lock) {
System.out.println("get lock");
}
}
}
很簡單,我們先new了一個鎖,當多個線程需要去調用sync方法然后輸出信息的之前,需要這個鎖打開才能輸出,這是一段典型的同步代碼塊代碼,前面說了,synchronized是利用jvm自帶的指令集來實現鎖的功能的,那我們現在就利用java自帶的反編譯工具(javap
-v SyncTest.class),把指令集輸出來,我們重點分析這個sync方法:
這個就是反編譯class文件的sync方法的結果,下面我們一行一行來分析:
descriptor:()V,這行是方法的說明,表示這個方法沒有入參,返回類型是void
flags:ACC_PUBLIC,說明是public方法
code:說明下面是方法代碼區域
stack=2,locals=3,args_size=1,表示這個執行這個方法虛擬機棧深度只需要2,本地變 量有3個,有1個參數,1個參數就是this(java方法的第一個參數都是this,只不過隱藏掉了)
aload_0:裝載第一個局部變量到操作棧,這里就是this
getfield #3 :#3指向常量池第三個位置,我沒有貼出常量池的反編譯視圖,在這里就是代 碼中lock對象,整行指令意思就是訪問這個lock對象的引用
dup:復制上面getfield獲取的引用壓入棧,這里就是lock的引用
astore_1:彈出棧頂的引用,然后放入局部變量1的位置中
monitorenter:得到lock對象的monitor,monitor的進入計數count+1,這個指令就是我們
Synchronized在jvm的底層實現,線程在打印之前需要得到lock的monitor,如果獲取不 到,則被掛起,獲取到了就繼續執行,在monitorenter下面,還有操作系統級別的 mutex重量級鎖以及jvm利用cas優化的jvm級的輕量鎖,這個不在我們這次討論范圍。
getstatic #4:訪問靜態變量System.out:PrintStream
ldc #5:將常量池第5個常量“get lock”壓入棧
invokevirtual #6:執行數據輸出函數
aload_1:裝載第二個局部變量,這里就是astore_1從棧上彈出的lock對象的引用
monitorexit:monitor的計數-1,因為是可重入的,就是一個線程可以多次拿到monitor, 所以當monitor計數為0的時候釋放lock鎖。
goto 25:看一下25行是return,就是返回,如果代碼正常執行,那么久流程就結束了,如 果代碼中間出現了異常,則繼續走
astore_2:彈出棧頂引用,放到局部變量2
aload_1:裝載第二個局部變量,這里就是astore_1從棧上彈出的lock對象的引用
monitorexit:monitor的計數-1,出現異常也釋放掉鎖。
aload_2:裝載第三個局部變量,這里就是astore_2從棧頂彈出來的引用。
athrow:將棧頂的數據作為異常拋出,如果為null,則拋空指針異常
return:結束本方法調用的棧幀
下面的異常表,調試行信息和棧圖就不解釋了,我也不是很懂,以免誤導,想深入研究可以找資料學習
到這里,這個方法在jvm指令層面的執行順序就結束了,java 關鍵字Synchronized就是通 過monitorenter,monitorexit兩個指令來實現的,在monitor下面還有jvm通過cas優化過2的輕量鎖以及操作系統級別的重量互斥鎖mutex,這個本期不做討論。
reentrantlock:這個鎖在java層面就是一個類,不像上面的Synchronized是一個關鍵字他是doug lea大神在 java1.5中主導的因為解決那個時候Synchronized效率低下的出現的,這個類可以有很多東西講,在這里我抽重點講,首先在java層面,reentrantlock以及其他同步工具類,比如ConcurrentHashMap(java 1.8改動很大,沒仔細看源碼,好像和以前不太一致了,1.8以前是),CountDownLatch,CyclicBarrier,以及信號量 semaphore,在底層都是通過實現抽象類AbstractQueuedSynchronizer(AQS)來實現各種鎖功能,比如樂觀鎖,悲觀鎖,互斥鎖,共享鎖,讀寫鎖等。先來說一下reentrantlock的lock實現原理。
reentrantlock.lock:reentrantlock類自己實現了AQS,內部有兩種實現方式,有公平實現 類FairSync和不公平實現類NonfairSync,這里我們說默認的使用不公平sync:
lock方法首先會用CAS采用樂觀鎖方式獲取一次鎖,(CAS操作線程安全,因為通過jni直接調用的操作系統cmpxchg指令,(如果是多核CPU,則需要調用lock cmpxchg利用內存屏障來保障指令的原子性)如果成功了,則設置當前線程占有這個鎖,如果失敗了,則去acquire(1),意思是提交一個獲取鎖的申請:
首先tryAcquire(1),調用到NonfairSync的nonfairTryAcquire方法:
首先獲取一下state,如果是0則說明還沒占有鎖,則再去CAS獲取一次,意圖是代碼從外面的CAS走到這的時間里,可能別的線程已經釋放了鎖,所以在進行一次CAS,如果獲取到了則返回成功。如果state不是0,且占有鎖的線程是當前線程,因為是可重入的鎖,則在計數上加1,代表重入一次,等到時候釋放鎖的時候,要全部釋放完等計數為0才表示釋放完全。
如果都不是以上情況,則返回false,進行后面的操作:
new一個當前線程的node,然后通過CAS操作,把這個node排在node雙向鏈表的尾部,如果CAS失敗,則進行enq:
enq很簡單,一個死循環既自旋,如果鏈表是空的,則初始化,否則一直CAS到node排到鏈表尾部為止。添加等待node成功之后,執行以下方法:
進行循環,Node p就是當前線程node前面的一個node,如果前一個node就是鏈表頭結點以及馬上進行一次CAS,如果成功獲取到鎖了,那么把當前線程的node設置成head,且斷開當前node和前node的鏈,返回fase,表示獲取到鎖。如果不是,則進行下一步:
這個方法的目的是返回當前線程是否應該被阻塞,pred node就是當前線程node的前面一個等待node,注釋說的很清楚,如果前面的node狀態是signal,表示當前線程可以安全的park住,等待被喚醒,如果WS>0就是取消狀態,如果前置node是取消狀態,則雙向鏈表往前回溯,直到找到當前node的前置node的狀態不是取消狀態,然后把當前階段的前置node指向這個節點,如果是其他狀態,則要把前置階段設置成signal狀態,等下次循環到這個方法的時候,就可以判斷出這個節點是需要park的,當判斷需要阻塞線程之后,執行:
這個方法就是調用了unsafe類的park方法,unsafe通過JNI調用了本地的C++實現的mutex鎖,這個也不做展開。當這個線程被unpark喚醒的時候,返回的線程的中斷狀態。到這里,reentrantlock的非公平鎖的原理就說完了,公平鎖的原理比這個還要簡單一些,想知道原理的可以自行查閱資料研究。說到底,reentrantlock以及其他的concurrent包下的同步類,在java層面都是利用集成了AQS的子類,去實現鎖,怎樣鎖則是通過unsafe類調用了C++代碼實現的mutex互斥鎖。釋放鎖unlock操作,則是通過釋放當前線程的充入數量,然后通過之前的雙向鏈表,通過unpark方法去喚醒next node,當next node被喚醒之后,怎會繼續執行
這個代碼塊,如果這個時候沒有其他的線程去搶占這個鎖,那么tryAcquire操作就會成功占有鎖,如果這個時候有另外一個線程搶先CAS成功了,則這線程繼續阻塞,因為這個是非公平鎖。
到這里,鎖就java的兩個鎖就說完了,總結一下,synchronized關鍵字是通過對象的monitor,然后通過monitorenter和monitorexit jvm指令來完成的。而Doug lea大神編寫的concurrent包的同步類,在java層面通過繼承AQS,然后配合unsafe提供的CAS操作以及鏈表這個數據結構,去實現樂觀鎖,互斥,共享,自旋等鎖,當需要阻塞線程的時候,通過unsafe類調用操作系統的mutex互斥鎖去實現阻塞。
總體已經說完了,java多線程並發涉及的知識點如果要深挖其實還有非常多,如果要一篇說全,包含了太多東西,java內存結構,jvm虛擬機,指令集,第一沒這么多時間,很多大神用一本書都說不完,第二,本人知識儲備還不夠,沒掌握的東西就不說了,以免誤導,這邊文章就介紹java的兩種同步機制,synchronized以及reentrantlock。
如有不對的地方,希望可以及時指正,后面我會抽時間再來講一下java的內存模型,內存分區,垃圾回收機制,以及多線程,線程池等相關的知識點,用來記憶和鞏固,28歲了,有些東西不常用的話會忘。。。