Java 中,提供了兩種方式來實現同步互斥訪問:synchronized 和 Lock
同步器的本質就是加鎖
加鎖目的:序列化訪問臨界資源,即同一時刻只能有一個線程訪問臨界資源(同步互斥訪問)
不過有一點需要區別的是:當多個線程執行一個方法時,該方法內部的局部變量 並不是臨界資源,因爲這些局部變量是在每個線程的私有棧中,因此不具有共享 性,不會導致線程安全問題。
synchronized原理詳解
synchronized內置鎖是一種對象鎖(鎖的是對象而非引用),作用粒度是對象,可以用來實現對臨界資源的同步互斥訪問,是可重入的。
加鎖的方式:
同步實例方法,鎖是當前實例對象
同步類方法,鎖是當前類對象
同步代碼塊,鎖是括號裏面的對象
synchronized底層原理
synchronized是基於JVM內置鎖實現,通過內部對象Monitor(監視器鎖)實現,基於進入與退出Monitor對象實現方法與代碼塊同步,監視器鎖的實現依賴底層操作系統的Mutex lock(互斥鎖)實現,它是一個重量級鎖性能較低。當然,JVM內置鎖在1.5之後版本做了重大的優化,如鎖粗化(Lock Coarsening)、鎖消除(Lock Elimination)、輕量級鎖(Lightweight Locking)、偏向鎖(Biased Locking)、適應性自旋(Adaptive Spinning)等技術來減少鎖操作的開銷,內置鎖的併發性能已經基本與Lock持平。
synchronized關鍵字被編譯成字節碼後會被翻譯成monitorenter 和 monitorexit 兩條指令分別在同步塊邏輯代碼的起始位置與結束位置。
每個同步對象都有一個自己的Monitor(監視器鎖),加鎖過程如下圖所示:
那麼有個問題來了,我們知道synchronized加鎖加在對象上,對象是如何記錄鎖狀態的呢?
答案是鎖狀態是被記錄在每個對象的對象頭(Mark Word)中,下面我們一起認識一下對象的內存佈局
對象的內存佈局
HotSpot虛擬機中,對象在內存中存儲的佈局可以分爲三塊區域:對象頭(Header)、實例數據(Instance Data)和對齊填充(Padding)。
對象頭:比如 hash碼,對象所屬的年代,對象鎖,鎖狀態標誌,偏向鎖(線程)ID,偏向時間,數組長度(數組對象)等
實例數據:即創建對象時,對象中成員變量,方法等
對齊填充:對象的大小必須是8字節的整數倍
對象頭
HotSpot虛擬機的對象頭包括兩部分信息,第一部分是“Mark Word”,用於存儲對象自身的運行時數據, 如哈希(HashCode)、GC分代年齡、鎖狀態標誌、線程持有的鎖、偏向線程ID、偏向時間戳等等,這部分數據的長度在32位和64位的虛擬機(暫 不考慮開啓壓縮指針的場景)中分別爲32個和64個Bits,官方稱它爲“Mark Word”。對象需要存儲的運行時數據很多,其實已經超出了32、64位Bitmap結構所能記錄的限度,但是對象頭信息是與對象自身定義的數據無關的額 外存儲成本,考慮到虛擬機的空間效率,Mark Word被設計成一個非固定的數據結構以便在極小的空間內存儲儘量多的信息,它會根據對象的狀態複用自己的存儲空間。例如在32位的HotSpot虛擬機 中對象未被鎖定的狀態下,Mark Word的32個Bits空間中的25Bits用於存儲對象哈希碼(HashCode),4Bits用於存儲對象分代年齡,2Bits用於存儲鎖標誌 位,1Bit固定爲0,在其他狀態(輕量級鎖定、重量級鎖定、GC標記、可偏向)下對象的存儲內容如下表所示。
但是如果對象是數組類型,則需要三個機器碼,因爲JVM虛擬機可以通過 Java對象的元數據信息確定Java對象的大小,但是無法從數組的元數據來確認數 組的大小,所以用一塊來記錄數組長度。 對象頭信息是與對象自身定義的數據無關的額外存儲成本,但是考慮到虛擬 機的空間效率,Mark Word被設計成一個非固定的數據結構以便在極小的空間內 存存儲儘量多的數據,它會根據對象的狀態複用自己的存儲空間,也就是說, Mark Word會隨着程序的運行發生變化,變化狀態如下(32位虛擬機):
鎖的膨脹升級過程
鎖的狀態總共有四種,無鎖狀態、偏向鎖、輕量級鎖和重量級鎖。隨着鎖的競爭,鎖可以從偏向鎖升級到輕量級鎖,再升級的重量級鎖,但是鎖的升級是單向的,也就是說只能從低到高升級,不會出現鎖的降級。下圖爲鎖的升級全過程:
偏向鎖
偏向鎖是Java 6之後加入的新鎖,它是一種針對加鎖操作的優化手段,經過研究發現,在大多數情況下,鎖不僅不存在多線程競爭,而且總是由同一線程多次獲得,因此爲了減少同一線程獲取鎖(會涉及到一些CAS操作,耗時)的代價而引入偏向鎖。偏向鎖的核心思想是,如果一個線程獲得了鎖,那麼鎖就進入偏向模式,此時Mark Word 的結構也變爲偏向鎖結構,當這個線程再次請求鎖時,無需再做任何同步操作,即獲取鎖的過程,這樣就省去了大量有關鎖申請的操作,從而也就提供程序的性能。所以,對於沒有鎖競爭的場合,偏向鎖有很好的優化效果,畢竟極有可能連續多次是同一個線程申請相同的鎖。但是對於鎖競爭比較激烈的場合,偏向鎖就失效了,因爲這樣場合極有可能每次申請鎖的線程都是不相同的,因此這種場合下不應該使用偏向鎖,否則會得不償失,需要注意的是,偏向鎖失敗後,並不會立即膨脹爲重量級鎖,而是先升級爲輕量級鎖。下面我們接着瞭解輕量級鎖。
輕量級鎖
倘若偏向鎖失敗,虛擬機並不會立即升級爲重量級鎖,它還會嘗試使用一種稱爲輕量級鎖的優化手段(1.6之後加入的),此時Mark Word 的結構也變爲輕量級鎖的結構。輕量級鎖能夠提升程序性能的依據是“對絕大部分的鎖,在整個同步週期內都不存在競爭”,注意這是經驗數據。需要了解的是,輕量級鎖所適應的場景是線程交替執行同步塊的場合,如果存在同一時間訪問同一鎖的場合,就會導致輕量級鎖膨脹爲重量級鎖。
自旋鎖
輕量級鎖失敗後,虛擬機爲了避免線程真實地在操作系統層面掛起,還會進行一項稱爲自旋鎖的優化手段。這是基於在大多數情況下,線程持有鎖的時間都不會太長,如果直接掛起操作系統層面的線程可能會得不償失,畢竟操作系統實現線程之間的切換時需要從用戶態轉換到核心態,這個狀態之間的轉換需要相對比較長的時間,時間成本相對較高,因此自旋鎖會假設在不久將來,當前的線程可以獲得鎖,因此虛擬機會讓當前想要獲取鎖的線程做幾個空循環(這也是稱爲自旋的原因),一般不會太久,可能是50個循環或100循環,在經過若干次循環後,如果得到鎖,就順利進入臨界區。如果還不能獲得鎖,那就會將線程在操作
系統層面掛起,這就是自旋鎖的優化方式,這種方式確實也是可以提升效率的。後沒辦法也就只能升級爲重量級鎖了。
鎖消除
消除鎖是虛擬機另外一種鎖的優化,這種優化更徹底,Java虛擬機在JIT編譯時(可以簡單理解爲當某段代碼即將第一次被執行時進行編譯,又稱即時編譯),通過對運行上下文的掃描,去除不可能存在共享資源競爭的鎖,通過這種方式消除沒有必要的鎖,可以節省毫無意義的請求鎖時間,如下StringBuffer的append是一個同步方法,但是在add方法中的StringBuffer屬於一個局部變量,並且不會被其他線程所使用,因此StringBuffer不可能存在共享資源競爭的情景,JVM會自動將其鎖消除。
逃逸分析
使用逃逸分析,編譯器可以對代碼做如下優化:
一、同步省略。如果一個對象被發現只能從一個線程被訪問到,那麼對於這個對象的操作可 以不考慮同步。
二、將堆分配轉化爲棧分配。如果一個對象在子程序中被分配,要使指向該對象的指針永遠 不會逃逸,對象可能是棧分配的候選,而不是堆分配。
三、分離對象或標量替換。有的對象可能不需要作爲一個連續的內存結構存在也可以被訪問 到,那麼對象的部分(或全部)可以不存儲在內存,而是存儲在CPU寄存器中。
是不是所有的對象和數組都會在堆內存分配空間?
不一定 在Java代碼運行時,通過JVM參數可指定是否開啓逃逸分析,-XX:+DoEscapeAnalysis : 表示開啓逃逸分析 -XX:-DoEscapeAnalysis : 表示關閉逃逸分析 從jdk 1.7開始已經默認開始逃逸分析,如需關閉,需要指定-XX:-DoEscapeAnalysis
/**
* 進行兩種測試
* 關閉逃逸分析,同時調大堆空間,避免堆內GC的發生,如果有GC信息將會被打印 出來
* VM運行參數:
-Xmx4G -Xms4G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError
*
* 開啓逃逸分析
* VM運行參數:
-Xmx4G -Xms4G -XX:+DoEscapeAnalysis -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError
*
* 執行main方法後
* jps 查看進程
* jmap histo 進程ID
*
*/
AbstractQueuedSynchronizer(AQS)
Java併發編程核心在於java.concurrent.util包而juc當中的大多數同步器實現都是圍繞着共同的基礎行爲,比如等待隊列、條件隊列、獨佔獲取、共享獲取等,而這個行爲的抽象就是基於AbstractQueuedSynchronizer簡稱AQS,AQS定義了一套多線程訪問共享資源的同步器框架,是一個依賴狀態(state)的同步器。
AQS具備特性
•阻塞等待隊列
•共享/獨佔
•公平/非公平
•可重入
•允許中斷
例如Java.concurrent.util當中同步器的實現如Lock,Latch,Barrier等,都是基於AQS框架實現
- 一般通過定義內部類Sync繼承AQS
- 將同步器所有調用都映射到Sync對應的方法
AQS內部維護屬性volatile int state (32位)
state表示資源的可用狀態
State三種訪問方式
getState()、setState()、compareAndSetState()
AQS定義兩種資源共享方式
- Exclusive-獨佔,只有一個線程能執行,如ReentrantLock
- Share-共享,多個線程可以同時執行,如Semaphore/CountDownLatch
AQS定義兩種隊列
- 同步等待隊列
- 條件等待隊列
不同的自定義同步器爭用共享資源的方式也不同。自定義同步器在實現時只需要實現共享資源state的獲取與釋放方式即可,至於具體線程等待隊列的維護(如獲取資源失敗入隊/喚醒出隊等),AQS已經在頂層實現好了。自定義同步器
實現時主要實現以下幾種方法:
- isHeldExclusively():該線程是否正在獨佔資源。只有用到condition才需要去實現它。
- tryAcquire(int):獨佔方式。嘗試獲取資源,成功則返回true,失敗則返回false。
- tryRelease(int):獨佔方式。嘗試釋放資源,成功則返回true,失敗則返回false。
- tryAcquireShared(int):共享方式。嘗試獲取資源。負數表示失敗;0表示成功,但沒有剩餘可用資源;正數表示成功,且有剩餘資源。
- tryReleaseShared(int):共享方式。嘗試釋放資源,如果釋放後允許喚醒後續等待結點返回true,否則返回false。
同步等待隊列
AQS當中的同步等待隊列也稱CLH隊列,CLH隊列是Craig、Landin、Hagersten三人發明的一種基於雙向鏈表數據結構的隊列,是FIFO先入先出線程等待隊列,Java中的CLH隊列是原CLH隊列的一個變種,線程由原自旋機制改爲阻塞機制。
條件等待隊列
Condition是一個多線程間協調通信的工具類,使得某個,或者某些線程一起等待某個條件(Condition),只有當該條件具備時,這些等待線程纔會被喚醒,從而重新爭奪鎖