本文討論的重點(diǎn)在于多線程應(yīng)用程序的性能問(wèn)題。我們會(huì)先給性能和擴(kuò)展性下一個(gè)定義,然后再仔細(xì)學(xué)習(xí)一下Amdahl法則。下面的內(nèi)容我們會(huì)考察一下如何用不同的技術(shù)方法來(lái)減少鎖競(jìng)爭(zhēng),以及如何用代碼來(lái)實(shí)現(xiàn)。
1、性能
我們都知道,多線程可以用來(lái)提高程序的性能,背后的原因在于我們有多核的CPU或多個(gè)CPU。每個(gè)CPU的內(nèi)核都可以自己完成任務(wù),因此把一個(gè)大的任務(wù)分解成一系列的可彼此獨(dú)立運(yùn)行的小任務(wù)就可以提高程序的整體性能了。可以舉個(gè)例子,比如有個(gè)程序用來(lái)將硬盤上某個(gè)文件夾下的所有圖片的尺寸進(jìn)行修改,應(yīng)用多線程技術(shù)就可以提高它的性能。使用單線程的方式只能依次遍歷所有圖片文件并且執(zhí)行修改,如果我們的CPU有多個(gè)核心的話,毫無(wú)疑問(wèn),它只能利用其中的一個(gè)核。使用多線程的方式的話,我們可以讓一個(gè)生產(chǎn)者線程掃描文件系統(tǒng)把每個(gè)圖片都添加到一個(gè)隊(duì)列中,然后用多個(gè)工作線程來(lái)執(zhí)行這些任務(wù)。如果我們的工作線程的數(shù)量和CPU總的核心數(shù)一樣的話,我們就能保證每個(gè)CPU核心都有活可干,直到任務(wù)被全部執(zhí)行完成。
對(duì)于另外一種需要較多IO等待的程序來(lái)說(shuō),利用多線程技術(shù)也能提高整體性能。假設(shè)我們要寫這樣一個(gè)程序,需要抓取某個(gè)網(wǎng)站的所有HTML文件,并且將它們存儲(chǔ)到本地磁盤上。程序可以從某一個(gè)網(wǎng)頁(yè)開(kāi)始,然后解析這個(gè)網(wǎng)頁(yè)中所有指向本網(wǎng)站的鏈接,然后依次抓取這些鏈接,這樣周而復(fù)始。因?yàn)閺奈覀儗?duì)遠(yuǎn)程網(wǎng)站發(fā)起請(qǐng)求到接收到所有的網(wǎng)頁(yè)數(shù)據(jù)需要等待一段時(shí)間,所以我們可以將此任務(wù)交給多個(gè)線程來(lái)執(zhí)行。讓一個(gè)或稍微更多一點(diǎn)的線程來(lái)解析已經(jīng)收到的HTML網(wǎng)頁(yè)以及將找到的鏈接放入隊(duì)列中,讓其他所有的線程負(fù)責(zé)請(qǐng)求獲取頁(yè)面。與上一個(gè)例子不同的是,在這個(gè)例子中,你即便使用多于CPU核心數(shù)量的線程也仍然能夠獲得性能提升。
上面這兩個(gè)例子告訴我們,高性能就是在短的時(shí)間窗口內(nèi)做盡量多的事情。這個(gè)當(dāng)然是對(duì)性能一詞的最經(jīng)典解釋了。但是同時(shí),使用線程也能很好地提升我們程序的響應(yīng)速度。想象我們有這樣一個(gè)圖形界面的應(yīng)用程序,上方有一個(gè)輸入框,輸入框下面有一個(gè)名字叫“處理”的按鈕。當(dāng)用戶按下這個(gè)按鈕的時(shí)候,應(yīng)用程序需要重新對(duì)按鈕的狀態(tài)進(jìn)行渲染(按鈕看起來(lái)被按下了,當(dāng)松開(kāi)鼠標(biāo)左鍵時(shí)又恢復(fù)原狀),并且開(kāi)始對(duì)用戶的輸入進(jìn)行處理。如果處理用戶輸入的這個(gè)任務(wù)比較耗時(shí)的話,單線程的程序就無(wú)法繼續(xù)響應(yīng)用戶其他的輸入動(dòng)作了,比如,來(lái)自操作系統(tǒng)傳送過(guò)來(lái)的用戶單擊鼠標(biāo)事件或鼠標(biāo)指針移動(dòng)事件等等,這些事件的響應(yīng)需要有獨(dú)立的線程來(lái)響應(yīng)。
可擴(kuò)展性(Scalability)的意思是程序具備這樣的能力:通過(guò)添加計(jì)算資源就可以獲得更高的性能。想象我們需要調(diào)整很多圖片的大小,因?yàn)槲覀儥C(jī)器的CPU核心數(shù)是有限的,所以增加線程數(shù)量并不總能相應(yīng)提高性能。相反,因?yàn)檎{(diào)度器需要負(fù)責(zé)更多線程的創(chuàng)建和關(guān)閉,也會(huì)占用CPU資源,反而有可能降低性能。
1.1 Amdahl法則
上一段提到了在某些情形下,添加額外的運(yùn)算資源可以提高程序的整體性能。為了能夠計(jì)算出當(dāng)我們添加了額外的資源的時(shí)候到底能獲得多少性能提升,我們有必要來(lái)檢查一下程序有哪些部分是串行運(yùn)行(或同步運(yùn)行),有哪些部分是并行運(yùn)行的。如果我們把需要同步執(zhí)行的代碼占比量化為B(例如,需要同步執(zhí)行的代碼的行數(shù)),把CPU的總核心數(shù)記為n,那么,根據(jù)Amdahl法則,我們可以獲得的性能提升的上限是:
如果n趨于無(wú)窮大的話,(1-B)/n就收斂于0。因此,我們可以忽略這個(gè)表達(dá)式的值,因此性能提升位數(shù)收斂于1/B,這里面的B代表是那些必須同步運(yùn)行的代碼比例。如果B等于0.5的話,那意味著程序的一半代碼無(wú)法并行運(yùn)行,0.5的倒數(shù)是2,因此,即使我們添加無(wú)數(shù)個(gè)CPU核心,我們獲得的性能提升也最多是2倍。假設(shè)我們現(xiàn)在把程序修改了一下,修改之后只有0.25的代碼必須同步運(yùn)行,現(xiàn)在1/0.25=4,表示我們的程序如果在具有大量CPU的硬件上運(yùn)行時(shí)速度將會(huì)比在單核的硬件上快大概4倍。
另一方面,通過(guò)Amdahl法則,我們也能根據(jù)我們想獲得的提速的目標(biāo)計(jì)算出程序應(yīng)該的同步代碼的比例。如果我們想要達(dá)到100倍的提速,而1/100=0.01,意味著,我們程序同步執(zhí)行的代碼的數(shù)量最多不能超過(guò)1%。
總結(jié)Amdahl法則我們可以看出,我們通過(guò)添加額外CPU來(lái)獲得性能提升的最大值取決于程序同步執(zhí)行部分代碼所占的比例有多小。雖然在實(shí)際中,想要計(jì)算出這個(gè)比例并不總是那么容易,更別說(shuō)面對(duì)一些大型的商業(yè)系統(tǒng)應(yīng)用了,但是Amdahl法則給了我們很重要的啟示,那就是,我們必須非常仔細(xì)地去考慮那些必須同步執(zhí)行的代碼,并且力圖減少這部分代碼。
1.2 對(duì)性能的影響
文章寫到這里,我們已經(jīng)表明這樣一個(gè)觀點(diǎn):增加更多的線程可以提高程序的性能和響應(yīng)速度。但是另一方面,想要取得這些好處卻并非輕而易舉,也需要付出一些代價(jià)。線程的使用對(duì)性能的提升也會(huì)有所影響。
首先,第一個(gè)影響來(lái)自線程創(chuàng)建的時(shí)候。線程的創(chuàng)建過(guò)程中,JVM需要從底層操作系統(tǒng)申請(qǐng)相應(yīng)的資源,并且在調(diào)度器中初始化數(shù)據(jù)結(jié)構(gòu),以便決定執(zhí)行線程的順序。
如果你的線程的數(shù)量和CPU的核心數(shù)量一樣的話,每個(gè)線程都會(huì)運(yùn)行在一個(gè)核心上,這樣或許他們就不會(huì)經(jīng)常被打斷了。但是事實(shí)上,在你的程序運(yùn)行的時(shí)候,操作系統(tǒng)也會(huì)有些自己的運(yùn)算需要CPU去處理。所以,即使這種情形下,你的線程也會(huì)被打斷并且等待操作系統(tǒng)來(lái)重新恢復(fù)它的運(yùn)行。當(dāng)你的線程數(shù)量超過(guò)CPU的核心數(shù)量的時(shí)候,情況有可能變得更壞。在這種情況下,JVM的進(jìn)程調(diào)度器會(huì)打斷某些線程以便讓其他線程執(zhí)行,線程切換的時(shí)候,剛才正在運(yùn)行的線程的當(dāng)前狀態(tài)需要被保存下來(lái),以便等下次運(yùn)行的時(shí)候可以恢復(fù)數(shù)據(jù)狀態(tài)。不僅如此,調(diào)度器也會(huì)對(duì)它自己內(nèi)部的數(shù)據(jù)結(jié)構(gòu)進(jìn)行更新,而這也需要消耗CPU周期。所有這些都意味著,線程之間的上下文切換會(huì)消耗CPU計(jì)算資源,因此帶來(lái)相比單線程情況下沒(méi)有的性能開(kāi)銷。
多線程程序所帶來(lái)的另外一個(gè)開(kāi)銷來(lái)自對(duì)共享數(shù)據(jù)的同步訪問(wèn)保護(hù)。我們可以使用synchronized關(guān)鍵字來(lái)進(jìn)行同步保護(hù),也可以使用Volatile關(guān)鍵字來(lái)在多個(gè)線程之間共享數(shù)據(jù)。如果多于一個(gè)線程想要去訪問(wèn)某一個(gè)共享數(shù)據(jù)結(jié)構(gòu)的話,就發(fā)生了爭(zhēng)用的情形,這時(shí),JVM需要決定哪個(gè)進(jìn)程先,哪個(gè)進(jìn)程后。如果決定該要執(zhí)行的線程不是當(dāng)前正在運(yùn)行的線程,那么就會(huì)發(fā)生線程切換。當(dāng)前線程需要等待,直到它成功獲得了鎖對(duì)象。JVM可以自己決定如何來(lái)執(zhí)行這種“等待”,假如JVM預(yù)計(jì)離成功獲得鎖對(duì)象的時(shí)間比較短,那JVM可以使用激進(jìn)等待方法,比如,不停地嘗試獲得鎖對(duì)象,直到成功,在這種情況下這種方式可能會(huì)更高效,因?yàn)楸容^進(jìn)程上下文切換來(lái)說(shuō),還是這種方式更快速一些。把一個(gè)等待狀態(tài)的線程挪回到執(zhí)行隊(duì)列也會(huì)帶來(lái)額外的開(kāi)銷。
因此,我們要盡力避免由于鎖競(jìng)爭(zhēng)而帶來(lái)的上下文切換。下面一節(jié)將闡述兩種降低這種競(jìng)爭(zhēng)發(fā)生的方法。
1.3 鎖競(jìng)爭(zhēng)
像上一節(jié)所說(shuō)的那樣,兩個(gè)或更多線程對(duì)鎖的競(jìng)爭(zhēng)訪問(wèn)會(huì)帶來(lái)額外的運(yùn)算開(kāi)銷,因?yàn)楦?jìng)爭(zhēng)的發(fā)生逼迫調(diào)度器來(lái)讓一個(gè)線程進(jìn)入激進(jìn)等待狀態(tài),或者讓它進(jìn)行等待狀態(tài)而引發(fā)兩次上下文切換。有某些情況下,鎖競(jìng)爭(zhēng)的惡果可以通過(guò)以下方法來(lái)減輕:
1、減少鎖的作用域;
2、減少需要獲取鎖的頻率;
3、盡量使用由硬件支持的樂(lè)觀鎖操作,而不是synchronized;
4、盡量少用synchronized;
5、減少使用對(duì)象緩存
1.3.1 縮減同步域
如果代碼持有鎖超過(guò)必要的時(shí)間,那么可以應(yīng)用這第一種方法。通常我們可以將一行或多行代碼移出同步區(qū)域來(lái)降低當(dāng)前線程持有鎖的時(shí)間。在同步區(qū)域里運(yùn)行的代碼數(shù)量越少,當(dāng)前線程就會(huì)越早地釋放鎖,從而讓其他線程更早地獲得鎖。這與Amdahl法則相一致的,因?yàn)檫@樣做減少了需要同步執(zhí)行的代碼量。
為了更好地理解,看下面的源碼:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
public class ReduceLockDuration implements Runnable { private static final int NUMBER_OF_THREADS = 5 ; private static final Map<String, Integer> map = new HashMap<String, Integer>(); public void run() { for ( int i = 0 ; i < 10000 ; i++) { synchronized (map) { UUID randomUUID = UUID.randomUUID(); Integer value = Integer.valueOf( 42 ); String key = randomUUID.toString(); map.put(key, value); } Thread.yield(); } } public static void main(String[] args) throws InterruptedException { Thread[] threads = new Thread[NUMBER_OF_THREADS]; for ( int i = 0 ; i < NUMBER_OF_THREADS; i++) { threads[i] = new Thread( new ReduceLockDuration()); } long startMillis = System.currentTimeMillis(); for ( int i = 0 ; i < NUMBER_OF_THREADS; i++) { threads[i].start(); } for ( int i = 0 ; i < NUMBER_OF_THREADS; i++) { threads[i].join(); } System.out.println((System.currentTimeMillis()-startMillis)+ "ms" ); } } |
在上面的例子中,我們讓五個(gè)線程來(lái)競(jìng)爭(zhēng)訪問(wèn)共享的Map實(shí)例,為了在同一時(shí)刻只有一個(gè)線程可以訪問(wèn)到Map實(shí)例,我們將向Map中添加Key/Value的操作放到了synchronized保護(hù)的代碼塊中。當(dāng)我們仔細(xì)察看這段代碼的時(shí)候,我們可以看到,計(jì)算key和value的幾句代碼并不需要同步執(zhí)行,key和value只屬于當(dāng)前執(zhí)行這段代碼的線程,僅僅對(duì)當(dāng)前線程有意義,并且不會(huì)被其他線程所修改。因此,我們可以把這幾句移出同步保護(hù)。如下:
1
2
3
4
5
6
7
8
9
10
11
|
public void run() { for ( int i = 0 ; i < 10000 ; i++) { UUID randomUUID = UUID.randomUUID(); Integer value = Integer.valueOf( 42 ); String key = randomUUID.toString(); synchronized (map) { map.put(key, value); } Thread.yield(); } } |
降低同步代碼所帶來(lái)的效果是可以測(cè)量的。在我的機(jī)器上,整個(gè)程序的執(zhí)行時(shí)間從420ms降低到了370ms。看看吧,僅僅把三行代碼移出同步保護(hù)塊就可以將程序運(yùn)行時(shí)間減少11%。Thread.yield()這句代碼是為了誘發(fā)線程上下文切換的,因?yàn)檫@句代碼會(huì)告訴JVM當(dāng)前線程想要交出當(dāng)前使用的計(jì)算資源,以便讓其他等待運(yùn)行的線程運(yùn)行。這樣也會(huì)帶來(lái)更多的鎖競(jìng)爭(zhēng)的發(fā)生,因?yàn)椋绻蝗绱说脑捘骋粋€(gè)線程就會(huì)更久地占用某個(gè)核心繼而減少了線程上下文切換。
1.3.2 分拆鎖
另外一種減少鎖競(jìng)爭(zhēng)的方法是將一塊被鎖定保護(hù)的代碼分散到多個(gè)更小的保護(hù)塊中。如果你的程序中使用了一個(gè)鎖來(lái)保護(hù)多個(gè)不同對(duì)象的話,這種方式會(huì)有用武之地。假設(shè)我們想要通過(guò)程序來(lái)統(tǒng)計(jì)一些數(shù)據(jù),并且實(shí)現(xiàn)了一個(gè)簡(jiǎn)單的計(jì)數(shù)類來(lái)持有多個(gè)不同的統(tǒng)計(jì)指標(biāo),并且分別用一個(gè)基本計(jì)數(shù)變量來(lái)表示(long類型)。因?yàn)槲覀兊某绦蚴嵌嗑€程的,所以我們需要對(duì)訪問(wèn)這些變量的操作進(jìn)行同步保護(hù),因?yàn)檫@些操作動(dòng)作來(lái)自不同的線程。要達(dá)到這個(gè)目的,最簡(jiǎn)單的方式就是對(duì)每個(gè)訪問(wèn)了這些變量的函數(shù)添加synchronized關(guān)鍵字。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
public static class CounterOneLock implements Counter { private long customerCount = 0 ; private long shippingCount = 0 ; public synchronized void incrementCustomer() { customerCount++; } public synchronized void incrementShipping() { shippingCount++; } public synchronized long getCustomerCount() { return customerCount; } public synchronized long getShippingCount() { return shippingCount; } } |
這種方式也就意味著,對(duì)這些變量的每次修改都會(huì)引發(fā)對(duì)其他Counter實(shí)例的鎖定。其他線程如果想要對(duì)另外一個(gè)不同的變量調(diào)用increment方法,那也只能等待前一個(gè)線程釋放了鎖控制之后才能有機(jī)會(huì)去完成。在此種情況下,對(duì)每個(gè)不同的變量使用單獨(dú)的synchronized保護(hù)將會(huì)提高執(zhí)行效率。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
public static class CounterSeparateLock implements Counter { private static final Object customerLock = new Object(); private static final Object shippingLock = new Object(); private long customerCount = 0 ; private long shippingCount = 0 ; public void incrementCustomer() { synchronized (customerLock) { customerCount++; } } public void incrementShipping() { synchronized (shippingLock) { shippingCount++; } } public long getCustomerCount() { synchronized (customerLock) { return customerCount; } } public long getShippingCount() { synchronized (shippingLock) { return shippingCount; } } } |
這種實(shí)現(xiàn)為每個(gè)計(jì)數(shù)指標(biāo)引入了一個(gè)單獨(dú)synchronized對(duì)象,因此,一個(gè)線程想要增加Customer計(jì)數(shù)的時(shí)候,它必須等待另一個(gè)正在增加Customer計(jì)數(shù)的線程完成,而并不用等待另一個(gè)正在增加Shipping計(jì)數(shù)的線程完成。
使用下面的類,我們可以非常容易地計(jì)算分拆鎖所帶來(lái)的性能提升。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
|
public class LockSplitting implements Runnable { private static final int NUMBER_OF_THREADS = 5 ; private Counter counter; public interface Counter { void incrementCustomer(); void incrementShipping(); long getCustomerCount(); long getShippingCount(); } public static class CounterOneLock implements Counter { ... } public static class CounterSeparateLock implements Counter { ... } public LockSplitting(Counter counter) { this .counter = counter; } public void run() { for ( int i = 0 ; i < 100000 ; i++) { if (ThreadLocalRandom.current().nextBoolean()) { counter.incrementCustomer(); } else { counter.incrementShipping(); } } } public static void main(String[] args) throws InterruptedException { Thread[] threads = new Thread[NUMBER_OF_THREADS]; Counter counter = new CounterOneLock(); for ( int i = 0 ; i < NUMBER_OF_THREADS; i++) { threads[i] = new Thread( new LockSplitting(counter)); } long startMillis = System.currentTimeMillis(); for ( int i = 0 ; i < NUMBER_OF_THREADS; i++) { threads[i].start(); } for ( int i = 0 ; i < NUMBER_OF_THREADS; i++) { threads[i].join(); } System.out.println((System.currentTimeMillis() - startMillis) + "ms" ); } } |
在我的機(jī)器上,單一鎖的實(shí)現(xiàn)方法平均花費(fèi)56ms,兩個(gè)單獨(dú)鎖的實(shí)現(xiàn)是38ms。耗時(shí)大約降低了大概32%。
另外一種提升方式是,我們甚至可以更進(jìn)一步地將讀寫分開(kāi)用不同的鎖來(lái)保護(hù)。原來(lái)的Counter類提供了對(duì)計(jì)數(shù)指標(biāo)分別提供了讀和寫的方法,但是事實(shí)上,讀操作并不需要同步保護(hù),我們可以放心讓多個(gè)線程并行讀取當(dāng)前指標(biāo)的數(shù)值,同時(shí),寫操作必須得到同步保護(hù)。java.util.concurrent包里提供了有對(duì)ReadWriteLock接口的實(shí)現(xiàn),可以方便地實(shí)現(xiàn)這種區(qū)分。
ReentrantReadWriteLock實(shí)現(xiàn)維護(hù)了兩個(gè)不同的鎖,一個(gè)保護(hù)讀操作,一個(gè)保護(hù)寫操作。這兩個(gè)鎖都有獲取鎖和釋放鎖的操作。僅僅當(dāng)在沒(méi)有人獲取讀鎖的時(shí)候,寫鎖才能成功獲得。反過(guò)來(lái),只要寫鎖沒(méi)有被獲取,讀鎖可以被多個(gè)線程同時(shí)獲取。為了演示這種方法,下面的Counter類使用了ReadWriteLock,如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
|
public static class CounterReadWriteLock implements Counter { private final ReentrantReadWriteLock customerLock = new ReentrantReadWriteLock(); private final Lock customerWriteLock = customerLock.writeLock(); private final Lock customerReadLock = customerLock.readLock(); private final ReentrantReadWriteLock shippingLock = new ReentrantReadWriteLock(); private final Lock shippingWriteLock = shippingLock.writeLock(); private final Lock shippingReadLock = shippingLock.readLock(); private long customerCount = 0 ; private long shippingCount = 0 ; public void incrementCustomer() { customerWriteLock.lock(); customerCount++; customerWriteLock.unlock(); } public void incrementShipping() { shippingWriteLock.lock(); shippingCount++; shippingWriteLock.unlock(); } public long getCustomerCount() { customerReadLock.lock(); long count = customerCount; customerReadLock.unlock(); return count; } public long getShippingCount() { shippingReadLock.lock(); long count = shippingCount; shippingReadLock.unlock(); return count; } } |
所有的讀操作都被讀鎖保護(hù),同時(shí),所有的寫操作都被寫鎖所保護(hù)。如果程序中執(zhí)行的讀操作要遠(yuǎn)大于寫操作的話,這種實(shí)現(xiàn)可以帶來(lái)比前一節(jié)的方式更大的性能提升,因?yàn)樽x操作可以并發(fā)進(jìn)行。
1.3.3 分離鎖
上面一個(gè)例子展示了如何將一個(gè)單獨(dú)的鎖分開(kāi)為多個(gè)單獨(dú)的鎖,這樣使得各線程僅僅獲得他們將要修改的對(duì)象的鎖就可以了。但是另一方面,這種方式也增加了程序的復(fù)雜度,如果實(shí)現(xiàn)不恰當(dāng)?shù)脑捯部赡茉斐伤梨i。
分離鎖是與分拆鎖類似的一種方法,但是分拆鎖是增加鎖來(lái)保護(hù)不同的代碼片段或?qū)ο螅蛛x鎖是使用不同的鎖來(lái)保護(hù)不同范圍的數(shù)值。JDK的java.util.concurrent包里的ConcurrentHashMap即使用了這種思想來(lái)提高那些嚴(yán)重依賴HashMap的程序的性能。在實(shí)現(xiàn)上,ConcurrentHashMap內(nèi)部使用了16個(gè)不同的鎖,而不是封裝一個(gè)同步保護(hù)的HashMap。16個(gè)鎖每一個(gè)負(fù)責(zé)保護(hù)其中16分之一的桶位(bucket)的同步訪問(wèn)。這樣一來(lái),不同的線程想要向不同的段插入鍵的時(shí)候,相應(yīng)的操作會(huì)受到不同的鎖來(lái)保護(hù)。但是反過(guò)來(lái)也會(huì)帶來(lái)一些不好的問(wèn)題,比如,某些操作的完成現(xiàn)在需要獲取多個(gè)鎖而不是一個(gè)鎖。如果你想要復(fù)制整個(gè)Map的話,這16個(gè)鎖都需要獲得才能完成。
1.3.4 原子操作
另外一種減少鎖競(jìng)爭(zhēng)的方法是使用原子操作,這種方式會(huì)在其他文章中詳細(xì)闡述原理。java.util.concurrent包對(duì)一些常用基礎(chǔ)數(shù)據(jù)類型提供了原子操作封裝的類。原子操作類的實(shí)現(xiàn)基于處理器提供的“比較置換”功能(CAS),CAS操作只在當(dāng)前寄存器的值跟操作提供的舊的值一樣的時(shí)候才會(huì)執(zhí)行更新操作。
這個(gè)原理可以用來(lái)以樂(lè)觀的方式來(lái)增加一個(gè)變量的值。如果我們的線程知道當(dāng)前的值的話,就會(huì)嘗試使用CAS操作來(lái)執(zhí)行增加操作。如果期間別的線程已經(jīng)修改了變量的值,那么線程提供的所謂的當(dāng)前值已經(jīng)跟真實(shí)的值不一樣了,這時(shí)JVM來(lái)嘗試重新獲得當(dāng)前值,并且再嘗試一次,反反復(fù)復(fù)直到成功為止。雖然循環(huán)操作會(huì)浪費(fèi)一些CPU周期,但是這樣做的好處是,我們不需要任何形式的同步控制。
下面的Counter類的實(shí)現(xiàn)就利用了原子操作的方式,你可以看到,并沒(méi)有使用任何synchronized的代碼。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
public static class CounterAtomic implements Counter { private AtomicLong customerCount = new AtomicLong(); private AtomicLong shippingCount = new AtomicLong(); public void incrementCustomer() { customerCount.incrementAndGet(); } public void incrementShipping() { shippingCount.incrementAndGet(); } public long getCustomerCount() { return customerCount.get(); } public long getShippingCount() { return shippingCount.get(); } } |
與CounterSeparateLock類相比,平均運(yùn)行時(shí)間從39ms降低到了16ms,大約降低了58%。
1.3.5 避免熱點(diǎn)代碼段
一個(gè)典型的LIST實(shí)現(xiàn)通過(guò)會(huì)在內(nèi)容維護(hù)一個(gè)變量來(lái)記錄LIST自身所包含的元素個(gè)數(shù),每一次從列表里刪除或增加元素的時(shí)候,這個(gè)變量的值都會(huì)改變。如果LIST在單線程應(yīng)用中使用的話,這種方式無(wú)可厚非,每次調(diào)用size()時(shí)直接返回上一次計(jì)算之后的數(shù)值就行了。如果LIST內(nèi)部不維護(hù)這個(gè)計(jì)數(shù)變量的話,每次調(diào)用size()操作都會(huì)引發(fā)LIST重新遍歷計(jì)算元素個(gè)數(shù)。
這種很多數(shù)據(jù)結(jié)構(gòu)都使用了的優(yōu)化方式,當(dāng)?shù)搅硕嗑€程環(huán)境下時(shí)卻會(huì)成為一個(gè)問(wèn)題。假設(shè)我們?cè)诙鄠€(gè)線程之間共享一個(gè)LIST,多個(gè)線程同時(shí)地去向LIST里面增加或刪除元素,同時(shí)去查詢大的長(zhǎng)度。這時(shí),LIST內(nèi)部的計(jì)數(shù)變量成為一個(gè)共享資源,因此所有對(duì)它的訪問(wèn)都必須進(jìn)行同步處理。因此,計(jì)數(shù)變量成為整個(gè)LIST實(shí)現(xiàn)中的一個(gè)熱點(diǎn)。
下面的代碼片段展示了這個(gè)問(wèn)題:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
|
public static class CarRepositoryWithCounter implements CarRepository { private Map<String, Car> cars = new HashMap<String, Car>(); private Map<String, Car> trucks = new HashMap<String, Car>(); private Object carCountSync = new Object(); private int carCount = 0 ; public void addCar(Car car) { if (car.getLicencePlate().startsWith( "C" )) { synchronized (cars) { Car foundCar = cars.get(car.getLicencePlate()); if (foundCar == null ) { cars.put(car.getLicencePlate(), car); synchronized (carCountSync) { carCount++; } } } } else { synchronized (trucks) { Car foundCar = trucks.get(car.getLicencePlate()); if (foundCar == null ) { trucks.put(car.getLicencePlate(), car); synchronized (carCountSync) { carCount++; } } } } } public int getCarCount() { synchronized (carCountSync) { return carCount; } } } |
上面這個(gè)CarRepository的實(shí)現(xiàn)內(nèi)部有兩個(gè)LIST變量,一個(gè)用來(lái)放洗車元素,一個(gè)用來(lái)放卡車元素,同時(shí),提供了查詢這兩個(gè)LIST總共的大小的方法。采用的優(yōu)化方式是,每次添加一個(gè)Car元素的時(shí)候,都會(huì)增加內(nèi)部的計(jì)數(shù)變量的值,同時(shí)增加的操作受synchronized保護(hù),返回計(jì)數(shù)值的方法也是一樣。
為了避免帶來(lái)這種額外的代碼同步開(kāi)銷,看下面另外一種CarRepository的實(shí)現(xiàn):它不再使用一個(gè)內(nèi)部的計(jì)數(shù)變量,而是在返回汽車總數(shù)的方法里實(shí)時(shí)計(jì)數(shù)這個(gè)數(shù)值。如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
public static class CarRepositoryWithoutCounter implements CarRepository { private Map<String, Car> cars = new HashMap<String, Car>(); private Map<String, Car> trucks = new HashMap<String, Car>(); public void addCar(Car car) { if (car.getLicencePlate().startsWith( "C" )) { synchronized (cars) { Car foundCar = cars.get(car.getLicencePlate()); if (foundCar == null ) { cars.put(car.getLicencePlate(), car); } } } else { synchronized (trucks) { Car foundCar = trucks.get(car.getLicencePlate()); if (foundCar == null ) { trucks.put(car.getLicencePlate(), car); } } } } public int getCarCount() { synchronized (cars) { synchronized (trucks) { return cars.size() + trucks.size(); } } } } |
現(xiàn)在,僅僅在getCarCount()方法里,兩個(gè)LIST的訪問(wèn)需要同步保護(hù),像上一種實(shí)現(xiàn)那樣每次添加新元素時(shí)的同步開(kāi)銷已經(jīng)不存在了。
1.3.6 避免對(duì)象緩存復(fù)用
在JAVA VM的第一版里,使用new關(guān)鍵字來(lái)創(chuàng)建新對(duì)象的開(kāi)銷比較大,因此,很多開(kāi)發(fā)人員習(xí)慣了使用對(duì)象復(fù)用模式。為了避免一次又一次重復(fù)創(chuàng)建對(duì)象,開(kāi)發(fā)人員維護(hù)一個(gè)緩沖池,每次創(chuàng)建完對(duì)象實(shí)例之后可以把它們保存在緩沖池里,下次其他線程再需要使用的時(shí)候就可以直接從緩沖池里去取。
乍一看,這種方式是很合理的,但是這種模式在多線程應(yīng)用程序里會(huì)出現(xiàn)問(wèn)題。因?yàn)閷?duì)象的緩沖池在多個(gè)線程之間共享,因此所有線程在訪問(wèn)其中的對(duì)象時(shí)的操作需要同步保護(hù)。而這種同步所帶來(lái)的開(kāi)銷已經(jīng)大過(guò)了創(chuàng)建對(duì)象本身了。當(dāng)然了,創(chuàng)建過(guò)多的對(duì)象會(huì)加重垃圾回收的負(fù)擔(dān),但是即便把這個(gè)考慮在內(nèi),避免同步代碼所帶來(lái)的性能提升仍然要好過(guò)使用對(duì)象緩存池的方式。
本文所講述的這些優(yōu)化方案再一次的表明,每一種可能的優(yōu)化方式在真正應(yīng)用的時(shí)候一定需要多多仔細(xì)評(píng)測(cè)。不成熟的優(yōu)化方案表面看起來(lái)好像很有道理,但是事實(shí)上很有可能會(huì)反過(guò)來(lái)成為性能的瓶頸。