1. synchronized介紹
在java代碼中使用synchronized可以使用在代碼塊和方法中,根據Synchronized用的位置可以有這些使用場景:
如圖,synchronized可以用在方法上也可以使用在代碼塊中,其中方法是實例方法和靜態方法分別鎖的是該類的實例對象和該類的對象。而使用在代碼塊中也可以分為三種,具體的可以看上面的表格。這里需要注意的是:如果鎖的是類對象的話,盡管new多個實例對象,但他們仍然是屬于同一個類依然會被鎖住,即線程之間保證同步關系。
2.1 對象鎖(monitor)機制
2.2 synchronized修飾代碼塊
先寫一個簡單的demo:
public class SynchronizedDemo {
public static void main(String[] args) {
synchronized (SynchronizedDemo.class) {
}
method();
}
private static void method() {
}
}
上面的代碼中有一個同步代碼塊,鎖住的是類對象,并且還有一個同步靜態方法,鎖住的依然是該類的類對象。編譯之后,切換到SynchronizedDemo.class的同級目錄之后,然后用javap -v SynchronizedDemo.class查看字節碼文件:
如圖,上面用黃色高亮的部分就是需要注意的部分了,這也是添Synchronized關鍵字之后獨有的。執行同步代碼塊后首先要先執行monitorenter指令,退出的時候monitorexit指令。通過分析之后可以看出,使用Synchronized進行同步,其關鍵就是必須要對對象的監視器monitor進行獲取,當線程獲取monitor后才能繼續往下執行,否則就只能等待。而這個獲取的過程是互斥的,即同一時刻只有一個線程能夠獲取到monitor。上面的demo中在執行完同步代碼塊之后緊接著再會去執行一個靜態同步方法,而這個方法鎖的對象依然就這個類對象,那么這個正在執行的線程還需要獲取該鎖嗎?答案是不必的,從上圖中就可以看出來,執行靜態同步方法的時候就只有一條monitorexit指令,并沒有monitorenter獲取鎖的指令。這就是鎖的重入性,即在同一鎖程中,線程不需要再次獲取同一把鎖。Synchronized先天具有重入性。每個對象擁有一個計數器,當線程獲取該對象鎖后,計數器就會加一,釋放鎖后就會將計數器減一。
也即monitor的count計數器變化為:
(1)獲取鎖
count++
(2)獲取鎖,可重入,但是并沒有monitorenter指令
count++
(3)釋放鎖
count--
(4)釋放鎖
count--
2.3 synchronized修飾方法名
public class SyncMethod {
public int i;
public synchronized void syncTask(){
i++;
}
}
//==================syncTask方法======================
public synchronized void syncTask();
descriptor: ()V
//方法標識ACC_PUBLIC代表public修飾,ACC_SYNCHRONIZED指明該方法為同步方法
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=3, locals=1, args_size=1
0: aload_0
1: dup
2: getfield #2 // Field i:I
5: iconst_1
6: iadd
7: putfield #2 // Field i:I
10: return
LineNumberTable:
line 12: 0
line 13: 10
從字節碼可以看出,synchronized修飾的方法并沒有monitorenter和monitorexit指令。而是用ACC_SYNCHRONIZED的flag標記該方法是否是同步方法,從而執行相應的同步調用。
任意一個對象都擁有自己的監視器,當這個對象由同步塊或者這個對象的同步方法調用時,執行方法的線程必須先獲取該對象的監視器才能進入同步塊和同步方法,如果沒有獲取到監視器的線程將會被阻塞在同步塊和同步方法的入口處,進入到BLOCKED狀態。
下圖表現了對象,對象監視器,同步隊列以及執行線程狀態之間的關系:
該圖可以看出,任意線程對Object的訪問,首先要獲得Object的監視器,如果獲取失敗,該線程就進入同步狀態,線程狀態變為BLOCKED,當Object的監視器占有者釋放后,在同步隊列中的線程就會有機會重新獲取該監視器。
Java虛擬機中的同步(Synchronization)都是基于進入和退出Monitor對象實現,無論是顯示同步(同步代碼塊)還是隱式同步(同步方法)都是如此。
對于同步代碼塊
monitorenter指令插入到同步代碼塊的開始位置。monitorexit指令插入到同步代碼塊結束的位置。JVM需要保證每一個monitorenter都有一個monitorexit與之對應。
任何對象,都有一個monitor與之相關聯,當monitor被持有以后,它將處于鎖定狀態。線程執行到monitorenter指令時,會嘗試獲得monitor對象的所有權,即嘗試獲取鎖。
虛擬機規范對 monitorenter 和 monitorexit 的行為描述中,有兩點需要注意。首先 synchronized 同步快對于同一條線程來說是可重入的,也就是說,不會出現把自己鎖死的問題。其次,同步快在已進入的線程執行完之前,會阻塞后面其他線程的進入。(摘自《深入理解JAVA虛擬機》)
對于同步方法
synchronized方法則會被翻譯成普通的方法調用和返回指令如:invokevirtual、areturn指令,在JVM字節碼層面并沒有任何特別的指令來實現被synchronized修飾的方法,而是在Class文件的方法表中將該方法的access_flags字段中的synchronized標志位置1,表示該方法是同步方法并使用調用該方法的對象或該方法所屬的Class在JVM的內部對象表示Klass做為鎖對象。
而對于為什么對象加synchronized關鍵字可以實現鎖,為什么new一個對象和monitor產生關系,他們之前是怎么的結構,monitor對象又是由什么組成,參考如下
一、對象的內存布局
HotSpot虛擬機中,對象在內存中存儲的布局可以分為三塊區域:對象頭(Header)、實例數據(Instance Data)和對齊填充(Padding)。
從上面的這張圖里面可以看出,對象在內存中的結構主要包含以下幾個部分:
- Mark Word(標記字段):對象的Mark Word部分占4個字節,其內容是一系列的標記位,比如輕量級鎖的標記位,偏向鎖標記位等等。
- Klass Pointer(Class對象指針):Class對象指針的大小也是4個字節,其指向的位置是對象對應的Class對象(其對應的元數據對象)的內存地址
- 對象實際數據:這里面包括了對象的所有成員變量,其大小由各個成員變量的大小決定,比如:byte和boolean是1個字節,short和char是2個字節,int和float是4個字節,long和double是8個字節,reference是4個字節
- 對齊:最后一部分是對齊填充的字節,按8個字節填充。
1.1、對象頭
1.1.1、Mark Word(標記字段)
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位虛擬機):
注意偏向鎖、輕量級鎖、重量級鎖等都是jdk 1.6以后引入的。
其中輕量級鎖和偏向鎖是Java 6 對 synchronized 鎖進行優化后新增加的,稍后我們會簡要分析。這里我們主要分析一下重量級鎖也就是通常說synchronized的對象鎖,鎖標識位為10,其中指針指向的是monitor對象(也稱為管程或監視器鎖)的起始地址。每個對象都存在著一個 monitor 與之關聯,對象與其 monitor 之間的關系有存在多種實現方式,如monitor可以與對象一起創建銷毀或當線程試圖獲取對象鎖時自動生成,但當一個 monitor 被某個線程持有后,它便處于鎖定狀態。在Java虛擬機(HotSpot)中,monitor是由ObjectMonitor實現的,其主要數據結構如下(位于HotSpot虛擬機源碼ObjectMonitor.hpp文件,C++實現的)
ObjectMonitor() {
_header = NULL;
_count = 0; //記錄個數
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL; //處于wait狀態的線程,會被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //處于等待鎖block狀態的線程,會被加入到該列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
ObjectMonitor中有兩個隊列,_WaitSet 和 _EntryList,用來保存ObjectWaiter對象列表( 每個等待鎖的線程都會被封裝成ObjectWaiter對象),_owner指向持有ObjectMonitor對象的線程,當多個線程同時訪問一段同步代碼時,首先會進入 _EntryList 集合,當線程獲取到對象的monitor 后進入 _Owner 區域并把monitor中的owner變量設置為當前線程同時monitor中的計數器count加1,若線程調用 wait() 方法,將釋放當前持有的monitor,owner變量恢復為null,count自減1,同時該線程進入 WaitSe t集合中等待被喚醒。若當前線程執行完畢也將釋放monitor(鎖)并復位變量的值,以便其他線程進入獲取monitor(鎖)。如下圖所示
由此看來,monitor對象存在于每個Java對象的對象頭中(存儲的指針的指向),synchronized鎖便是通過這種方式獲取鎖的,也是為什么Java中任意對象可以作為鎖的原因,同時也是notify/notifyAll/wait等方法存在于頂級對象Object中的原因(關于這點稍后還會進行分析),ok~,有了上述知識基礎后,下面我們將進一步分析synchronized在字節碼層面的具體語義實現。
對象頭的另外一部分是類型指針,即是對象指向它的類的元數據的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例。并不是所有的虛擬機實現都必須在對象數據上保留類型指針,換句話說查找對象的元數據信息并不一定要經過對象本身。另外,如果對象是一個Java數組,那在對象頭中還必須有一塊用于記錄數組長度的數據,因為虛擬機可以通過普通Java對象的元數據信息確定Java對象的大小,但是從數組的元數據中無法確定數組的大小。
以下是HotSpot虛擬機markOop.cpp中的C++代碼(注釋)片段,它描述了32bits下MarkWord的存儲狀態:
// Bit-format of an object header (most significant first, big endian layout below):
//
// 32 bits:
// --------
// hash:25 ------------>| age:4 biased_lock:1 lock:2 (normal object)
// JavaThread*:23 epoch:2 age:4 biased_lock:1 lock:2 (biased object)
// size:32 ------------------------------------------>| (CMS free block)
// PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object)
1.2、實例數據(Instance Data)
接下來實例數據部分是對象真正存儲的有效信息,也既是我們在程序代碼里面所定義的各種類型的字段內容,無論是從父類繼承下來的,還是在子類中定義的都需要記錄下來。 這部分的存儲順序會受到虛擬機分配策略參數(FieldsAllocationStyle)和字段在Java源碼中定義順序的影響。HotSpot虛擬機 默認的分配策略為longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers),從分配策略中可以看出,相同寬度的字段總是被分配到一起。在滿足這個前提條件的情況下,在父類中定義的變量會出現在子類之前。如果 CompactFields參數值為true(默認為true),那子類之中較窄的變量也可能會插入到父類變量的空隙之中。
1.3、對齊填充(Padding)
第三部分對齊填充并不是必然存在的,也沒有特別的含義,它僅僅起著占位符的作用。由于HotSpot VM的自動內存管理系統要求對象起始地址必須是8字節的整數倍,換句話說就是對象的大小必須是8字節的整數倍。對象頭正好是8字節的倍數(1倍或者2倍),因此當對象實例數據部分沒有對齊的話,就需要通過對齊填充來補全。
二、對象的創建過程
Java是一門面向對象的編程語言,Java程序運行過程中無時無刻都有對象被創建出來。在語言層面上,創建對象通常(例外:克隆、反序列化)僅僅是一個 new關鍵字而已,而在虛擬機中,對象(本文中討論的對象限于普通Java對象,不包括數組和Class對象等)的創建又是怎樣一個過程呢?
虛擬機遇到一條new指令時,
1、首先jvm要檢查類A是否已經被加載到了內存,即類的符號引用是否已經在常量池中,并且檢查這個符號引用代表的類是否已被加載、解析和初始化過的。如果還沒有,需要先觸發類的加載、解析、初始化。然后在堆上創建對象。
2、為新生對象分配內存。
對象所需內存的大小在類加載完成后便可完全確定,為對象分配空間的任務具體便等同于一塊確定大小 的內存從Java堆中劃分出來,怎么劃呢?假設Java堆中內存是絕對規整的,所有用過的內存都被放在一邊,空閑的內存被放在另一邊,中間放著一個指針作 為分界點的指示器,那所分配內存就僅僅是把那個指針向空閑空間那邊挪動一段與對象大小相等的距離,這種分配方式稱為“指針碰撞”(Bump The Pointer)。如果Java堆中的內存并不是規整的,已被使用的內存和空閑的內存相互交錯,那就沒有辦法簡單的進行指針碰撞了,虛擬機就必須維護一個列表,記錄上哪些內存塊是可用的,在分配的時候從列表中找到一塊足夠大的空間劃分給對象實例,并更新列表上的記錄,這種分配方式稱為“空閑列表”(Free List)。選擇哪種分配方式由Java堆是否規整決定,而Java堆是否規整又由所采用的垃圾收集器是否帶有壓縮整理功能決定。因 此在使用Serial、ParNew等帶Compact過程的收集器時,系統采用的分配算法是指針碰撞,而使用CMS這種基于Mark-Sweep算法的 收集器時(說明一下,CMS收集器可以通過UseCMSCompactAtFullCollection或 CMSFullGCsBeforeCompaction來整理內存),就通常采用空閑列表。
除如何劃分可用空間之外,還有另外一個需要考慮的問題是對象創建在虛擬機中是非常頻繁的行為,即使是僅僅修改一個指針所指向的位置,在并發情況下也并不是 線程安全的,可能出現正在給對象A分配內存,指針還沒來得及修改,對象B又同時使用了原來的指針來分配內存。解決這個問題有兩個方案,一種是對分配內存空 間的動作進行同步——實際上虛擬機是采用CAS配上失敗重試的方式保證更新操作的原子性;另外一種是把內存分配的動作按照線程劃分在不同的空間之中進行, 即每個線程在Java堆中預先分配一小塊內存,稱為本地線程分配緩沖區,(TLAB ,Thread Local Allocation Buffer),哪個線程要分配內存,就在哪個線程的TLAB上分配,只有TLAB用完,分配新的TLAB時才需要同步鎖定。虛擬機是否使用TLAB,可以通過-XX:+/-UseTLAB參數來設定。
3. 完成實例數據部分的初始化工作(初始化為0值)
內存分配完成之后,虛擬機需要將分配到的內存空間都初始化為零值(不包括對象頭),如果使用TLAB的話,這一個工作也可以提前至TLAB分配時進行。這 步操作保證了對象的實例字段在Java代碼中可以不賦初始值就直接使用,程序能訪問到這些字段的數據類型所對應的零值。
4、 完成對象頭的填充:如對象自身的運行時數據、類型指針等。
接下來,虛擬機要對對象進行必要的設置,例如這個對象是哪個類的實例、如何才能找到類的元數據信息、對象的哈希碼、對象的GC分代年齡等信息。這些信息存放在對象的對象頭(Object Header)之中。根據虛擬機當前的運行狀態的不同,如是否啟用偏向鎖等,對象頭會有不同的設置方式。
在上面工作都完成之后,在虛擬機的視角來看,一個新的對象已經產生了。但是在Java程序的視角看來,初始化才正式開始,開始調用<init>方法完成初始復制和構造函數,所有的字段都為零值。因此一般來說(由字節碼中是否跟隨有invokespecial指令所決定),new指令之后會接著就是執 行<init>方法,把對象按照程序員的意愿進行初始化,這樣一個真正可用的對象才算完全創建出來。
下面代碼是HotSpot虛擬機bytecodeInterpreter.cpp中的代碼片段(這個解釋器實現很少機會實際使用,大部分平臺上都使用模板 解釋器;當代碼通過JIT編譯器執行時差異就更大了。不過這段代碼用于了解HotSpot的運作過程是沒有什么問題的)。
// 確保常量池中存放的是已解釋的類
if (!constants->tag_at(index).is_unresolved_klass()) {
// 斷言確保是klassOop和instanceKlassOop(這部分下一節介紹)
oop entry = (klassOop) *constants->obj_at_addr(index);
assert(entry->is_klass(), "Should be resolved klass");
klassOop k_entry = (klassOop) entry;
assert(k_entry->klass_part()->oop_is_instance(), "Should be instanceKlass");
instanceKlass* ik = (instanceKlass*) k_entry->klass_part();
// 確保對象所屬類型已經經過初始化階段
if ( ik->is_initialized() && ik->can_be_fastpath_allocated() ) {
// 取對象長度
size_t obj_size = ik->size_helper();
oop result = NULL;
// 記錄是否需要將對象所有字段置零值
bool need_zero = !ZeroTLAB;
// 是否在TLAB中分配對象
if (UseTLAB) {
result = (oop) THREAD->tlab().allocate(obj_size);
}
if (result == NULL) {
need_zero = true;
// 直接在eden中分配對象
retry:
HeapWord* compare_to = *Universe::heap()->top_addr();
HeapWord* new_top = compare_to + obj_size;
// cmpxchg是x86中的CAS指令,這里是一個C++方法,通過CAS方式分配空間,并發失敗的話,轉到retry中重試直至成功分配為止
if (new_top <= *Universe::heap()->end_addr()) {
if (Atomic::cmpxchg_ptr(new_top, Universe::heap()->top_addr(), compare_to) != compare_to) {
goto retry;
}
result = (oop) compare_to;
}
}
if (result != NULL) {
// 如果需要,為對象初始化零值
if (need_zero ) {
HeapWord* to_zero = (HeapWord*) result + sizeof(oopDesc) / oopSize;
obj_size -= sizeof(oopDesc) / oopSize;
if (obj_size > 0 ) {
memset(to_zero, 0, obj_size * HeapWordSize);
}
}
// 根據是否啟用偏向鎖,設置對象頭信息
if (UseBiasedLocking) {
result->set_mark(ik->prototype_header());
} else {
result->set_mark(markOopDesc::prototype());
}
result->set_klass_gap(0);
result->set_klass(k_entry);
// 將對象引用入棧,繼續執行下一條指令
SET_STACK_OBJECT(result, 0);
UPDATE_PC_AND_TOS_AND_CONTINUE(3, 1);
}
}
}
三、對象的訪問定位
建立對象是為了使用對象,我們的Java程序需要通過棧上的reference數據來操作堆上的具體對象。由于reference類型在Java虛擬機規范里面只規定了是一個指向對象的引用,并沒有定義這個引用應該通過什么種方式去定位、訪問到堆中的對象的具體位置,對象訪問方式也是取決于虛擬機實現而定的。主流的訪問方式有使用句柄和直接指針兩種。
如果使用句柄訪問的話,Java堆中將會劃分出一塊內存來作為句柄池,reference中存儲的就是對象的句柄地址,而句柄中包含了對象實例數據與類型數據的具體各自的地址信息。如圖1所示。
[圖片上傳失敗...(image-54ca4f-1548471344141)]
圖1 通過句柄訪問對象
如果使用直接指針訪問的話,Java堆對象的布局中就必須考慮如何放置訪問類型數據的相關信息,reference中存儲的直接就是對象地址,如圖2所示。
[圖片上傳失敗...(image-31205a-1548471344141)]
圖2 通過直接指針訪問對象
這兩種對象訪問方式各有優勢,使用句柄來訪問的最大好處就是reference中存儲的是穩定句柄地址,在對象被移動(垃圾收集時移動對象是非常普遍的行為)時只會改變句柄中的實例數據指針,而reference本身不需要被修改。
使用直接指針來訪問最大的好處就是速度更快,它節省了一次指針定位的時間開銷,由于對象訪問的在Java中非常頻繁,因此這類開銷積小成多也是一項非常可觀的執行成本。從上一部分講解的對象內存布局可以看出,就虛擬機HotSpot而言,它是使用第二種方式進行對象訪問,但在整個軟件開發的范圍來看,各種語言、框架中使用句柄來訪問的情況也十分常見。
四、示例
在Hotspot JVM中,32位機器下,Integer對象的大小是int的幾倍?
我們都知道在Java語言規范已經規定了int的大小是4個字節,那么Integer對象的大小是多少呢?要知道一個對象的大小,那么必須需要知道對象在虛擬機中的結構是怎樣的,根據上面的圖,那么我們可以得出Integer的對象的結構如下:
Integer只有一個int類型的成員變量value,所以其對象實際數據部分的大小是4個字節,然后再在后面填充4個字節達到8字節的對齊,所以可以得出Integer對象的大小是16個字節。
因此,我們可以得出Integer對象的大小是原生的int類型的4倍。
關于對象的內存結構,需要注意數組的內存結構和普通對象的內存結構稍微不同,因為數據有一個長度length字段,所以在對象頭后面還多了一個int類型的length字段,占4個字節,接下來才是數組中的數據,如下圖:
2.2 synchronized的happens-before關系
Synchronized的happens-before規則,即監視器鎖規則:對同一個監視器的解鎖,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
} // 6
}
該代碼的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。
2.3 鎖獲取和鎖釋放的內存語義
在上一篇文章提到過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是可見的。
從橫向來看,這就像線程A通過主內存中的共享變量和線程B進行通信,A 告訴 B 我們倆的共享數據現在為1啦,這種線程間的通信機制正好吻合java的內存模型正好是共享內存的并發模型結構。
3. synchronized優化
通過上面的討論現在我們對Synchronized應該有所印象了,它最大的特征就是在同一時刻只有一個線程能夠獲得對象的監視器(monitor),從而進入到同步代碼塊或者同步方法之中,即表現為互斥性(排它性)。這種方式肯定效率低下,每次只能通過一個線程,既然每次只能通過一個,這種形式不能改變的話,那么我們能不能讓每次通過的速度變快一點了。打個比方,去收銀臺付款,之前的方式是,大家都去排隊,然后去紙幣付款收銀員找零,有的時候付款的時候在包里拿出錢包再去拿出錢,這個過程是比較耗時的,然后,支付寶解放了大家去錢包找錢的過程,現在只需要掃描下就可以完成付款了,也省去了收銀員跟你找零的時間的了。同樣是需要排隊,但整個付款的時間大大縮短,是不是整體的效率變高速率變快了?這種優化方式同樣可以引申到鎖優化上,縮短獲取鎖的時間。
在聊到鎖的優化也就是鎖的幾種狀態前,有兩個知識點需要先關注:(1)CAS操作 (2)Java對象頭,這是理解下面知識的前提條件。
3.1 CAS操作
3.1.1 什么是CAS?
使用鎖時,線程獲取鎖是一種悲觀鎖策略,即假設每一次執行臨界區代碼都會產生沖突,所以當前線程獲取到鎖的時候同時也會阻塞其他線程獲取該鎖。而CAS操作(又稱為無鎖操作)是一種樂觀鎖策略,它假設所有線程訪問共享資源的時候不會出現沖突,既然不會出現沖突自然而然就不會阻塞其他線程的操作。因此,線程就不會出現阻塞停頓的狀態。那么,如果出現沖突了怎么辦?無鎖操作是使用CAS(compare and swap)又叫做比較交換來鑒別線程是否出現沖突,出現沖突就重試當前操作直到沒有沖突為止。
3.1.2 CAS的操作過程
CAS比較交換的過程可以通俗的理解為CAS(V,O,N),包含三個值分別為:V 內存地址存放的實際值;O 預期的值(舊值);N 更新的新值。當V和O相同時,也就是說舊值和內存中實際的值相同表明該值沒有被其他線程更改過,即該舊值O就是目前來說最新的值了,自然而然可以將新值N賦值給V。反之,V和O不相同,表明該值已經被其他線程改過了則該舊值O不是最新版本的值了,所以不能將新值N賦給V,返回V即可。當多個線程使用CAS操作一個變量是,只有一個線程會成功,并成功更新,其余會失敗。失敗的線程會重新嘗試,當然也可以選擇掛起線程
CAS的實現需要硬件指令集的支撐,在JDK1.5后虛擬機才可以使用處理器提供的CMPXCHG指令實現。
Synchronized VS CAS
元老級的Synchronized(未優化前)最主要的問題是:在存在線程競爭的情況下會出現線程阻塞和喚醒鎖帶來的性能問題,因為這是一種互斥同步(阻塞同步)。而CAS并不是武斷的線程掛起,當CAS操作失敗后會進行一定的嘗試,而非進行耗時的掛起喚醒的操作,因此也叫做非阻塞同步。這是兩者主要的區別。
3.1.3 CAS的應用場景
在J.U.C包中利用CAS實現類有很多,可以說是支撐起整個concurrency包的實現,在Lock實現中會有CAS改變state變量,在atomic包中的實現類也幾乎都是用CAS實現。
3.1.4 CAS的問題
1. ABA問題
因為CAS會檢查舊值有沒有變化,這里存在這樣一個有意思的問題。比如一個舊值A變為了成B,然后再變成A,剛好在做CAS時檢查發現舊值并沒有變化依然為A,但是實際上的確發生了變化。解決方案可以沿襲數據庫中常用的樂觀鎖方式,添加一個版本號可以解決。原來的變化路徑A->B->A就變成了1A->2B->3C。java這么優秀的語言,當然在java 1.5后的atomic包中提供了AtomicStampedReference來解決ABA問題,解決思路就是這樣的。
2. 自旋時間過長
使用CAS時非阻塞同步,也就是說不會將線程掛起,會自旋(無非就是一個死循環)進行下一次嘗試,如果這里自旋時間過長對性能是很大的消耗。如果JVM能支持處理器提供的pause指令,那么在效率上會有一定的提升。
3. 只能保證一個共享變量的原子操作
當對一個共享變量執行操作時CAS能保證其原子性,如果對多個共享變量進行操作,CAS就不能保證其原子性。有一個解決方案是利用對象整合多個共享變量,即一個類中的成員變量就是這幾個共享變量。然后將這個對象做CAS操作就可以保證其原子性。atomic中提供了AtomicReference來保證引用對象之間的原子性。
3.2 Java對象頭
在同步的時候是獲取對象的monitor,即獲取到對象的鎖。那么對象的鎖怎么理解?無非就是類似對對象的一個標志,那么這個標志就是存放在Java對象的對象頭。Java對象頭里的Mark Word里默認的存放的對象的Hashcode,分代年齡和鎖標記位。32位JVM Mark Word默認存儲結構為:
如圖在Mark Word會默認存放hasdcode,年齡值以及鎖標志位等信息。
Java SE 1.6中,鎖一共有4種狀態,級別從低到高依次是:無鎖狀態、偏向鎖狀態、輕量級鎖狀態和重量級鎖狀態,這幾個狀態會隨著競爭情況逐漸升級。鎖可以升級但不能降級,意味著偏向鎖升級成輕量級鎖后不能降級成偏向鎖。這種鎖升級卻不能降級的策略,目的是為了提高獲得鎖和釋放鎖的效率。對象的MarkWord變化為下圖:
3.2 偏向鎖
HotSpot的作者經過研究發現,大多數情況下,鎖不僅不存在多線程競爭,而且總是由同一線程多次獲得,為了讓線程獲得鎖的代價更低而引入了偏向鎖。
偏向鎖的獲取
當一個線程訪問同步塊并獲取鎖時,會在對象頭和棧幀中的鎖記錄里存儲鎖偏向的線程ID,以后該線程在進入和退出同步塊時不需要進行CAS操作來加鎖和解鎖,只需簡單地測試一下對象頭的Mark Word里是否存儲著指向當前線程的偏向鎖。如果測試成功,表示線程已經獲得了鎖。如果測試失敗,則需要再測試一下Mark Word中偏向鎖的標識是否設置成1(表示當前是偏向鎖):如果沒有設置,則使用CAS競爭鎖;如果設置了,則嘗試使用CAS將對象頭的偏向鎖指向當前線程
偏向鎖的撤銷
偏向鎖使用了一種等到競爭出現才釋放鎖的機制,所以當其他線程嘗試競爭偏向鎖時,持有偏向鎖的線程才會釋放鎖。
如圖,偏向鎖的撤銷,需要等待全局安全點(在這個時間點上沒有正在執行的字節碼)。它會首先暫停擁有偏向鎖的線程,然后檢查持有偏向鎖的線程是否活著,如果線程不處于活動狀態,則將對象頭設置成無鎖狀態;如果線程仍然活著,擁有偏向鎖的棧會被執行,遍歷偏向對象的鎖記錄,棧中的鎖記錄和對象頭的Mark Word要么重新偏向于其他線程,要么恢復到無鎖或者標記對象不適合作為偏向鎖,最后喚醒暫停的線程。
下圖線程1展示了偏向鎖獲取的過程,線程2展示了偏向鎖撤銷的過程。
如何關閉偏向鎖
偏向鎖在Java 6和Java 7里是默認啟用的,但是它在應用程序啟動幾秒鐘之后才激活,如有必要可以使用JVM參數來關閉延遲:-XX:BiasedLockingStartupDelay=0。如果你確定應用程序里所有的鎖通常情況下處于競爭狀態,可以通過JVM參數關閉偏向鎖:-XX:-UseBiasedLocking=false,那么程序默認會進入輕量級鎖狀態
3.3 輕量級鎖
加鎖
線程在執行同步塊之前,JVM會先在當前線程的棧楨中創建用于存儲鎖記錄的空間,并將對象頭中的Mark Word復制到鎖記錄中,官方稱為Displaced Mark Word。然后線程嘗試使用CAS將對象頭中的Mark Word替換為指向鎖記錄的指針。如果成功,當前線程獲得鎖,如果失敗,表示其他線程競爭鎖,當前線程便嘗試使用自旋來獲取鎖
解鎖
輕量級解鎖時,會使用原子的CAS操作將Displaced Mark Word替換回到對象頭,如果成功,則表示沒有競爭發生。如果失敗,表示當前鎖存在競爭,鎖就會膨脹成重量級鎖。下圖是兩個線程同時爭奪鎖,導致鎖膨脹的流程圖。
因為自旋會消耗CPU,為了避免無用的自旋(比如獲得鎖的線程被阻塞住了),一旦鎖升級成重量級鎖,就不會再恢復到輕量級鎖狀態。當鎖處于這個狀態下,其他線程試圖獲取鎖時,都會被阻塞住,當持有鎖的線程釋放鎖之后會喚醒這些線程,被喚醒的線程就會進行新一輪的奪鎖之爭。
3.5 各種鎖的比較
4. 一個例子
經過上面的理解,我們現在應該知道了該怎樣解決了。更正后的代碼為:
public class SynchronizedDemo implements Runnable {
private static int count = 0;
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(new SynchronizedDemo());
thread.start();
}
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("result: " + count);
}
@Override
public void run() {
synchronized (SynchronizedDemo.class) {
for (int i = 0; i < 1000000; i++)
count++;
}
}
}
開啟十個線程,每個線程在原值上累加1000000次,最終正確的結果為10X1000000=10000000,這里能夠計算出正確的結果是因為在做累加操作時使用了同步代碼塊,這樣就能保證每個線程所獲得共享變量的值都是當前最新的值,如果不使用同步的話,就可能會出現A線程累加后,而B線程做累加操作有可能是使用原來的就值,即“臟值”。這樣,就導致最終的計算結果不是正確的。而使用Syncnized就可能保證內存可見性,保證每個線程都是操作的最新值。