一.概述
在Java并發(fā)編程學(xué)習(xí)(一)——線程一文中,我們詳細(xì)了解了有關(guān)線程的相關(guān)話題,其中多次提到了synchronized這個(gè)關(guān)鍵字,今天我們就來聊一聊synchronized。
synchronized是java內(nèi)置的一種用于實(shí)現(xiàn)線程間同步的簡單、有效機(jī)制,是java提供給我們的內(nèi)置鎖。我們可以使用synchronized來修飾方法、代碼塊,使被修飾的代碼一段時(shí)間內(nèi)只能由一個(gè)線程進(jìn)入,從而實(shí)現(xiàn)了共享變量被正確的并發(fā)訪問。
二.相關(guān)概念
在進(jìn)一步了解synchronized方法的使用之前,我們先來看幾個(gè)有關(guān)概念:
線程安全
在《java并發(fā)編程實(shí)戰(zhàn)》中,作者給出了這樣的定義:
當(dāng)多個(gè)線程訪問某個(gè)類時(shí),不管運(yùn)行時(shí)環(huán)境采用何種調(diào)度方式或者這些線程將如何交替執(zhí)行,并且在主調(diào)代碼中不需要任何額外的同步或協(xié)同,這個(gè)類都能表現(xiàn)出正確的行為,那么就稱這個(gè)類是線程安全的。
簡而言之:當(dāng)多個(gè)線程訪問某個(gè)類時(shí),這個(gè)類始終都能表現(xiàn)出正確的行為,那么就稱這個(gè)類是線程安全的。
競態(tài)條件
看完了線程安全的概念,我們可能會問,線程不安全的情況是如何產(chǎn)生的呢?
一種常見的情況就是:兩個(gè)線程競爭同一資源時(shí),并且對資源的訪問順序敏感,這種情況叫做存在競態(tài)條件。
舉個(gè)例子:
public class Counter {
private int number;
public int add() {
return number++;
}
}
在上面的add方法中,我們希望每次調(diào)用,number值都會加1,在單線程中執(zhí)行,完全沒有問題,但是在多線程時(shí),結(jié)果有時(shí)會變得不可預(yù)料。
原因就在于,number++
這一行代碼在cpu中是由三條指令構(gòu)成的:
(1)讀取number的值放到寄存器;
(2)將寄存器的值+1;
(3)將寄存器的值寫入number。
當(dāng)有兩個(gè)線程都需要執(zhí)行add方法時(shí),實(shí)際上cpu中就有6條執(zhí)行需要執(zhí)行,由于cpu會在不同的線程間切換,而這種切換的時(shí)機(jī)是未知的,因此6條指令的執(zhí)行順序很可能是交替進(jìn)行的,從而導(dǎo)致了意向不到的結(jié)果。
根據(jù)我們上面的定義,上面的add方法(準(zhǔn)確的說是number++語句)就存在競態(tài)條件。
臨界區(qū)
導(dǎo)致競態(tài)條件發(fā)生的代碼區(qū)域叫做臨界區(qū),上面Counter類中的add方法就是一個(gè)臨界區(qū)。
可見性
可見性是多線程執(zhí)行中的另一個(gè)重要問題,為了保證程序的正確執(zhí)行,一個(gè)線程對共享變量的訪問,應(yīng)該及時(shí)被其他線程看到,這叫做內(nèi)存可見性。
同樣以上面的代碼為例,如果不加任何的線程同步操作,那么當(dāng)一個(gè)線程修改number時(shí),其他線程并不能及時(shí)感知到,反而有可能拿到失效的結(jié)果,這就沒有滿足可見性。
上面我們談到了線程安全,競態(tài)條件&臨界區(qū)和內(nèi)存可見性,之所以提及這些概念,是因?yàn)樗鼈兪嵌嗑€程并發(fā)執(zhí)行中不容忽視的問題,也是java多線程機(jī)制引入背后的原因,而synchronized就是解決上面問題的一個(gè)最簡單的方法。下面我們就來看看具體用法。
三.用法
大體來說,synchronized關(guān)鍵字有兩種用法:修飾方法或者修飾代碼塊。
修飾方法
在java中,方法分為對象方法和類方法,synchronized可以修飾這兩種方法。
(1)修飾對象方法
public class Counter {
private int number;
public synchronized int add() {
return number++;
}
}
為了使我們前面提到的Counter類線程安全,我們只需要使用synchronized
關(guān)鍵字修飾add方法即可,很簡單有沒有?加上synchronized關(guān)鍵字后,訪問某個(gè)Counter對象的所有線程只能互斥的進(jìn)入add方法。
(2)修飾類方法
public class Counter {
private static int value;
public synchronized static int add() {
return value++;
}
}
修飾類方法同樣是在方法聲明中添加synchronized
關(guān)鍵字,只是影響的范圍不同:修飾靜態(tài)方法時(shí),將導(dǎo)致線程在訪問Counter類的所有對象時(shí),都將互斥的進(jìn)入add方法,我們驗(yàn)證一下:
class CounterThread extends Thread {
private Counter counter;
public CounterThread(String name, Counter counter) {
super(name);
this.counter = counter;
}
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + ",value=" + counter.add());
}
}
public class Counter {
private static int value;
public static int add() {
System.out.println(Thread.currentThread().getName() + " begin add");
try {
Thread.sleep(2000); // 線程sleep時(shí)會讓出cpu,但不會釋放鎖
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " has sleep 2000 ms");
return value++;
}
public static void main(String[] args) {
Counter counter1 = new Counter();
Counter counter2 = new Counter();
CounterThread thread1 = new CounterThread("thread1", counter1);
CounterThread thread2 = new CounterThread("thread2", counter2);
thread1.start();
thread2.start();
}
}
在上面的代碼中,我們定義了一個(gè)線程類,其中有一個(gè)Counter類的引用,用于執(zhí)行add操作。
當(dāng)前,add方法并沒有加synchronized
關(guān)鍵字,線程進(jìn)入后,會休眠2秒鐘,讓出cpu讓其他線程執(zhí)行。
執(zhí)行結(jié)果如下:
thread1 begin add
thread2 begin add
thread1 has sleep 2000 ms
thread1,value=0
thread2 has sleep 2000 ms
thread2,value=1
可以看出,thread2在thread1沒有執(zhí)行休眠完成前就進(jìn)入了add方法,兩個(gè)線程交替執(zhí)行。
我們使用synchronized方法修飾add方法,再看看效果:
public static synchronized int add() {
System.out.println(Thread.currentThread().getName() + " begin add");
……
return value++;
}
執(zhí)行結(jié)果如下:
thread2 begin add
thread2 has sleep 2000 ms
thread2,value=0
thread1 begin add
thread1 has sleep 2000 ms
thread1,value=1
可以看出,thread1在thread2退出add方法后,才進(jìn)入,兩個(gè)線程是同步的。雖然,兩個(gè)線程中的Counter引用并不是同一個(gè)對象,但是由于add方法是靜態(tài)方法,因此該類的所有對象的add方法都將被鎖定,只有獲得鎖才可以進(jìn)入。
修飾代碼塊
效果如下:
public class Counter {
private int value;
public int add() {
synchronized (this) {
return value++;
}
}
}
被synchronized修飾的代碼塊需要獲得鎖才可以進(jìn)入。效果與修飾方法相同,但是加鎖的粒度更細(xì),可以只將存在競態(tài)條件的臨界區(qū)加鎖,其他不需要同步的代碼不加鎖,這樣可以提高多線程的并發(fā)執(zhí)行效率。
synchronized (this)
中,this關(guān)鍵字指代當(dāng)前對象,意味著,線程在執(zhí)行該對象時(shí),需要取得鎖。
另外,還可以寫synchronized (Counter.class)
,將使線程在執(zhí)行Counter類的所有對象時(shí),都將競爭鎖。
無論是修飾方法還是代碼塊,都存在著對象鎖和類鎖的概念,對象鎖只對執(zhí)行該對象的線程有效,對不執(zhí)行該對象的線程無效;類鎖則對執(zhí)行該類的所有對象的線程都有效。
打個(gè)比方,我們的家里有一個(gè)大門,進(jìn)到家里每個(gè)房間都有自己的小門,大門有一把大鎖,用于控制所有人的進(jìn)入,每個(gè)小門的鎖只控制進(jìn)入該房間的人。大門的鎖相當(dāng)于類鎖,房間的鎖相當(dāng)于對象鎖,進(jìn)入家里的人就是一個(gè)個(gè)的線程。
四.實(shí)現(xiàn)
看起來,synchronized的使用非常簡單,那么背后的實(shí)現(xiàn)原理是什么樣的呢?
java虛擬機(jī)是通過Java對象頭和monitor這兩者結(jié)合來實(shí)現(xiàn)synchronized同步的。
java對象頭
jvm堆中創(chuàng)建的每個(gè)對象都包含以下幾個(gè)區(qū)域:
對象頭:記錄了對象的hash碼、鎖信息、分代信息等;
實(shí)例變量:存儲了對象中的屬性信息;
填充數(shù)據(jù):由于虛擬機(jī)要求對象起始地址必須是8字節(jié)的整數(shù)倍。為了字節(jié)對齊,有時(shí)需要做字節(jié)填充
我們重點(diǎn)看對象頭部分,對象頭占2個(gè)字的內(nèi)存空間(數(shù)組對象占3個(gè)字,多出來的一個(gè)字記錄數(shù)組長度),包含以下兩個(gè)部分信息:
Mark Word:存儲對象的hashCode、鎖信息或分代年齡或GC標(biāo)志等信息。
Class Metadata Address:類型指針指向?qū)ο蟮念愒獢?shù)據(jù),JVM通過這個(gè)指針確定該對象是哪個(gè)類的實(shí)例。
考慮到JVM的空間效率,Mark Word 被設(shè)計(jì)成為一個(gè)非固定的數(shù)據(jù)結(jié)構(gòu),以便存儲更多有效的數(shù)據(jù),它會根據(jù)對象本身的狀態(tài)復(fù)用自己的存儲空間。32位的jvm,隨著對象的狀態(tài)不同,可能的數(shù)據(jù)結(jié)構(gòu)如下:
其中,輕量級鎖和偏向鎖是jdk 6.0新增的,之前只有重量級鎖,當(dāng)鎖狀態(tài)為重量級鎖時(shí),其中指針指向的是monitor對象,每個(gè)對象都存在著一個(gè)monitor對象與之關(guān)聯(lián)。
monitor
在jvm中,monitor是由ObjectMonitor實(shí)現(xiàn)的(位于HotSpot虛擬機(jī)源碼ObjectMonitor.hpp文件,C++實(shí)現(xiàn)的),其主要數(shù)據(jù)結(jié)構(gòu)如下:
ObjectMonitor() {
_header = NULL;
_count = 0; //記錄個(gè)數(shù)
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL; // 指向持有該對象的線程
_WaitSet = NULL; //處于wait狀態(tài)的線程,會被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //處于等待鎖block狀態(tài)的線程,會被加入到該列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
ObjectMonitor中有兩個(gè)隊(duì)列,_WaitSet 和 _EntryList,用來保存ObjectWaiter對象列表( 每個(gè)等待鎖的線程都會被封裝成ObjectWaiter對象),_owner指向持有ObjectMonitor對象的線程,當(dāng)多個(gè)線程同時(shí)訪問一段同步代碼時(shí),首先會進(jìn)入 _EntryList 集合,當(dāng)線程獲取到對象的monitor 后進(jìn)入 _Owner 區(qū)域并把monitor中的owner變量設(shè)置為當(dāng)前線程同時(shí)monitor中的計(jì)數(shù)器count加1,若線程調(diào)用 wait() 方法,將釋放當(dāng)前持有的monitor,owner變量恢復(fù)為null,count自減1,同時(shí)該線程進(jìn)入 WaitSet集合中等待被喚醒。若當(dāng)前線程執(zhí)行完畢也將釋放monitor(鎖)并復(fù)位變量的值,以便其他線程進(jìn)入獲取monitor(鎖)。
在了解了對象頭和monitor之后,我們再來看synchronized加鎖的實(shí)現(xiàn)原理:
- 當(dāng)一個(gè)線程想要進(jìn)入synchronized代碼塊或者被synchronized修飾的方法時(shí),就會改變對象頭中鎖狀態(tài),并設(shè)置monitor指針指向monitor對象;
- 所有想要獲得鎖的線程首先會被放到_EntryList列表中;
- 先判斷monitor對象中的_count字段是否為0,如果為0,則說明當(dāng)前對象沒有被其他線程占用,將_count加1,,將_owner指向當(dāng)前線程;
- 當(dāng)線程執(zhí)行完畢,需要釋放鎖時(shí),再將_count減1,將_owner置為null;
- 在某個(gè)線程已經(jīng)取得鎖,并且還沒有釋放時(shí),如果有其他線程嘗試獲得鎖,就會被添加到_EntryList列表中,被阻塞,直到鎖被釋放再根據(jù)某種策略允許阻塞的線程進(jìn)入。
至于類鎖,個(gè)人理解加鎖過程中操作的是類對應(yīng)的Class對象。
五.jvm對synchronized的優(yōu)化
由于monitor是依賴于操作系統(tǒng)底層的Mutex Lock來實(shí)現(xiàn)的,而操作系統(tǒng)實(shí)現(xiàn)線程之間的切換時(shí)需要從用戶態(tài)轉(zhuǎn)換到核心態(tài),這個(gè)狀態(tài)之間的轉(zhuǎn)換需要相對比較長的時(shí)間,時(shí)間成本相對較高。在jdk 1.6之前,synchronized就屬于這種重量級鎖,需要經(jīng)常進(jìn)行用戶態(tài)到核心態(tài)的切換,效率不高,因此1.6中對synchronized進(jìn)行了優(yōu)化,引入了偏向鎖、輕量級鎖、自旋鎖等概念。
偏向鎖
經(jīng)過研究發(fā)現(xiàn),在大多數(shù)情況下,鎖不僅不存在多線程競爭,而且總是由同一線程多次獲得,因此為了減少同一線程獲取鎖(會涉及到一些CAS操作,耗時(shí))的代價(jià)而引入偏向鎖。偏向鎖的核心思想是,如果一個(gè)線程獲得了鎖,那么鎖就進(jìn)入偏向模式,此時(shí)Mark Word 的結(jié)構(gòu)也變?yōu)槠蜴i結(jié)構(gòu),當(dāng)這個(gè)線程再次請求鎖時(shí),無需再做任何同步操作,即獲取鎖的過程,這樣就省去了大量有關(guān)鎖申請的操作,從而也就提供程序的性能。
輕量級鎖
倘若偏向鎖失敗,虛擬機(jī)并不會立即升級為重量級鎖,它還會嘗試使用一種稱為輕量級鎖的優(yōu)化手段(1.6之后加入的),此時(shí)Mark Word 的結(jié)構(gòu)也變?yōu)檩p量級鎖的結(jié)構(gòu)。輕量級鎖能夠提升程序性能的依據(jù)是“對絕大部分的鎖,在整個(gè)同步周期內(nèi)都不存在競爭”,注意這是經(jīng)驗(yàn)數(shù)據(jù)。需要了解的是,輕量級鎖所適應(yīng)的場景是線程交替執(zhí)行同步塊的場合,如果存在同一時(shí)間訪問同一鎖的場合,就會導(dǎo)致輕量級鎖膨脹為重量級鎖。
如下圖所示:
自旋鎖
輕量級鎖失敗后,虛擬機(jī)為了避免線程真實(shí)地在操作系統(tǒng)層面掛起,還會進(jìn)行一項(xiàng)稱為自旋鎖的優(yōu)化手段。這是基于在大多數(shù)情況下,線程持有鎖的時(shí)間都不會太長,如果直接掛起操作系統(tǒng)層面的線程可能會得不償失,畢竟操作系統(tǒng)實(shí)現(xiàn)線程之間的切換時(shí)需要從用戶態(tài)轉(zhuǎn)換到核心態(tài),這個(gè)狀態(tài)之間的轉(zhuǎn)換需要相對比較長的時(shí)間,時(shí)間成本相對較高,因此自旋鎖會假設(shè)在不久將來,當(dāng)前的線程可以獲得鎖,因此虛擬機(jī)會讓當(dāng)前想要獲取鎖的線程做幾個(gè)空循環(huán)(這也是稱為自旋的原因),一般不會太久,可能是50個(gè)循環(huán)或100循環(huán),在經(jīng)過若干次循環(huán)后,如果得到鎖,就順利進(jìn)入臨界區(qū)。如果還不能獲得鎖,那就會將線程在操作系統(tǒng)層面掛起,這就是自旋鎖的優(yōu)化方式,這種方式確實(shí)也是可以提升效率的。最后沒辦法也就只能升級為重量級鎖了。
我們上面說到,鎖一共有四種狀態(tài):無鎖狀態(tài),偏向鎖狀態(tài),輕量級鎖狀態(tài)和重量級鎖狀態(tài),它們會隨著競爭情況逐漸升級,但是不能夠降級。
鎖升級的過程大概是這樣的,剛開始處于無鎖狀態(tài),當(dāng)線程第一次申請時(shí),會先進(jìn)入偏向鎖狀態(tài),然后如果出現(xiàn)鎖競爭,就會升級為輕量級鎖(這升級過程中可能會牽扯自旋鎖),如果輕量級鎖還是解決不了問題,則會進(jìn)入重量級鎖狀態(tài),從而徹底解決并發(fā)的問題。
參考資料:
- 《java并發(fā)編程實(shí)戰(zhàn)》
- 競態(tài)條件與臨界區(qū)
- 深入理解Java并發(fā)之synchronized實(shí)現(xiàn)原理
- Java中的鎖機(jī)制 synchronized & 偏向鎖 & 輕量級鎖 & 重量級鎖 & 各自優(yōu)缺點(diǎn)及場景 & AtomicReference
本文已遷移至我的博客:http://ipenge.com/25781.html