設計同步器的意義
多線程編程中,有可能會出現多個線程同時訪問同一個共享、可變資源的情況,這個資源我們稱之其為臨界資源;這種資源可能是:對象、變量、文件等。
共享:資源可以由多個線程同時訪問
可變:資源可以在其生命周期內被修改
引出的問題:
由于線程執行的過程是不可控的,所以需要采用同步機制來協同對對象可變狀
態的訪問
那么我們怎么解決線程并發安全問題?
實際上,所有的并發模式在解決線程安全問題時,采用的方案都是 序列化訪問臨界資源。即在同一時刻,只能有一個線程訪問臨界資源,也稱作同步互斥訪問。
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虛擬機 中對象未被鎖定的狀態下,MarkWord的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
*/