網(wǎng)站建設(shè)的相關(guān)書籍今日頭條鄭州頭條新聞
java并發(fā)編程
線程堆棧大小
單線程的堆棧大小默認(rèn)為1M,1000個(gè)線程內(nèi)存就占了1G。所以,受制于內(nèi)存上限,單純依靠多線程難以支持大量任務(wù)并發(fā)。
上下文切換開銷
ReentrantLock
2個(gè)線程交替自增一個(gè)共享變量,使用ReentrantLock,每個(gè)線程1000w次,這是vmstat的結(jié)果:
procs -----------memory---------- —swap-- -----io---- -system-- -----cpu------
r b swpd free buff cache si so bi bo in cs us sy id wa st
0 0 20476 886508 207672 2901024 0 0 0 0 583 1128 0 0 100 0 0
2 0 20476 857280 207672 2901060 0 0 0 164 2612 4980 13 3 83 0 0
1 0 20476 832052 207672 2901060 0 0 0 0 7038 21799 40 2 57 0 0
3 0 20476 830336 207672 2901060 0 0 0 0 5591 14159 41 2 57 0 0
0 0 20476 887988 207672 2901060 0 0 0 0 5170 13119 28 2 70 0 0
1 0 20476 888068 207672 2901028 0 0 0 0 560 1117 0 0 100 0 0
vmstat輸出參數(shù)參看:
https://www.cnblogs.com/ggjucheng/archive/2012/01/05/2312625.html
我們注意到cs(上下文切換)達(dá)到過21799的峰值,相應(yīng)的,in(中斷次數(shù))、us(用戶cpu時(shí)間)也隨之上升,整體耗時(shí)在2.7s。
究其原因,鎖的爭(zhēng)用會(huì)觸發(fā)系統(tǒng)調(diào)用,迫使線程進(jìn)入沉睡,系統(tǒng)調(diào)用又增加了用戶態(tài)和內(nèi)核態(tài)的上下文切換次數(shù)。
CAS
2個(gè)線程交替自增一個(gè)共享變量,使用CAS,每個(gè)線程1000w次,這是vmstat的結(jié)果:
procs -----------memory---------- —swap-- -----io---- -system-- -----cpu------
r b swpd free buff cache si so bi bo in cs us sy id wa st
1 0 20476 879772 207672 2901068 0 0 0 0 873 1532 2 3 95 0 0
0 0 20476 887484 207672 2901076 0 0 0 0 2559 3206 30 3 67 0 0
0 0 20476 887484 207672 2901076 0 0 0 0 587 1065 1 0 99 0 0
cs峰值只到3206,整體耗時(shí)在400ms左右。
由于CAS是用戶態(tài)操作,不涉及上下文切換,所以cs次數(shù)較少,我們認(rèn)為這里的數(shù)值僅僅是線程正常切換導(dǎo)致。
無鎖
單線程自增2000w次
r b swpd free buff cache si so bi bo in cs us sy id wa st
1 0 20476 878564 207676 2901108 0 0 0 0 733 1228 1 1 98 0 0
0 0 20476 886216 207676 2901108 0 0 0 0 2453 3443 11 3 86 0 0
0 0 20476 886216 207676 2901104 0 0 0 0 662 1171 0 0 99 0 0
非常快,幾個(gè)毫秒跑完。本次cs與CAS下的cs差不多,印證了3000多次的cs只是正常的操作系統(tǒng)線程調(diào)度。然后我們會(huì)看到CAS下的us(值為30)明顯高于單線程(值為11)。這是因?yàn)镃AS的自增行為本質(zhì)上是一個(gè)循環(huán)CAS,不會(huì)釋放cpu,這是AtomicInteger自增的源碼:
public final int getAndAddInt(Object var1, long var2, int var4) {int var5;do {var5 = this.getIntVolatile(var1, var2);} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));return var5;}
我們看到getAndAddInt會(huì)反復(fù)嘗試,直到自增成功為止。代碼里的compareAndSwapInt就是CAS操作。
synchronized
r b swpd free buff cache si so bi bo in cs us sy id wa st
3 0 20476 885204 206452 2869528 0 0 0 0 2336 3461 14 5 81 0 0
2 0 20476 884668 206452 2869548 0 0 0 0 7332 19534 40 4 55 0 0
0 0 20476 911968 206452 2869520 0 0 0 0 3608 7762 20 3 77 0 0
0 0 20476 911968 206452 2869520 0 0 0 0 693 1290 0 0 100 0 0
耗時(shí)1.7s,cs的峰值高于CAS,但要低于ReentrantLock。具體原因我估計(jì)是因?yàn)閖vm1.6之后對(duì)synchronized做過優(yōu)化的緣故,synchronized并不會(huì)一開始就用lock那樣的重量級(jí)鎖,而是按照“偏向鎖–>自旋鎖–>重量級(jí)鎖”的順序來逐步升級(jí)的,前兩者都是用戶態(tài)的指令,并不觸發(fā)cs。但由于競(jìng)爭(zhēng)的存在,重量級(jí)鎖又不可能完全避免,所以synchronized下的cs要低于ReentrantLock,但又明顯高于完全用戶態(tài)的CAS。
總結(jié)
1、java并發(fā)編程下鎖的推薦使用順序(越前者越推薦):
無鎖 --> CAS --> synchronized --> ReentrantLock
2、上下文切換的耗時(shí)是用戶態(tài)CAS指令的6~7倍,應(yīng)盡量避免。
延伸討論
對(duì)于IO密集型應(yīng)用,如果無法做到“無鎖編程”,最佳的并發(fā)編程模型應(yīng)該是協(xié)程,而非使用多線程。我們以go語言來說明。
go語言
go的設(shè)計(jì)原則是:避免一切阻塞。
如果一個(gè)goroutine將要陷入系統(tǒng)調(diào)用,go調(diào)度器立刻從當(dāng)前線程分離它,轉(zhuǎn)而執(zhí)行其他goroutine。這一點(diǎn)跟python的greenlet是類似的處理。
舉個(gè)例子,goroutine A在等待channel的消息,阻塞的只是A,而不是執(zhí)行A的線程T,T會(huì)在A被阻塞的這段時(shí)間被調(diào)度去執(zhí)行g(shù)oroutine B。
另外,這里的系統(tǒng)調(diào)用,我理解不僅僅是IO,由于鎖爭(zhēng)用導(dǎo)致的線程掛起也是系統(tǒng)調(diào)用,同樣會(huì)導(dǎo)致goroutine的切換??傊涀∫稽c(diǎn):線程不會(huì)阻塞,阻塞的是goroutine。
volatile
volatile也是java里并發(fā)編程的手段之一。前面的例子之所以沒有提到,是因?yàn)関olatile不能保證自增的并發(fā)正確性(自增操作依賴于原值,其實(shí)是一個(gè)復(fù)合操作)。
首先,java字節(jié)碼層面沒法看出volatile與普通變量有何區(qū)別,比如下面代碼:
private static volatile int race_ = 0;
public static void main(String[] args)
{race_++;
}
翻譯成java字節(jié)碼是:
0: getstatic #2 // Field race_:I
3: iconst_1
4: iadd
5: putstatic #2 // Field race_:I
看起來就是操作一個(gè)普通的static變量嘛。
我們只能從JIT的反匯編才能看出一些端倪:
0x000000000257ce9e: mov rsi,0d59c01b0h ; {oop(a 'java/lang/Class' = 'com/lee/MainFlow')},獲得類的地址,race_在類地址的偏移為0x88處0x000000000257cea8: mov edi,dword ptr [rsi+88h] ;*getstatic race_; - com.lee.MainFlow::myincr@0 (line 59)0x000000000257ceae: inc edi0x000000000257ceb0: mov dword ptr [rsi+88h],edi0x000000000257ceb6: lock add dword ptr [rsp],0h ;*putstatic race_; - com.lee.MainFlow::myincr@5 (line 59)
race的地址是rsi+88h,dword ptr [rsi+88h]表示取得race_的內(nèi)存值,通過:
mov edi,dword ptr [rsi+88h]
將race的內(nèi)存值賦給edi寄存器,接著通過:
inc edi
實(shí)現(xiàn)自增,最后將自增的結(jié)果通過:
mov dword ptr [rsi+88h],edi
返回到內(nèi)存。
由于race_是int型,所以自增操作在32位寄存器edi里就可以完成了,無需使用rdi。
注意最后一條匯編指令:
lock add dword ptr [rsp],0h
該指令在race為非volatile類型下是沒有的,即非volatile版本執(zhí)行完:
mov dword ptr [rsi+88h],edi
對(duì)內(nèi)存的重新賦值就會(huì)返回了。
add dword ptr [rsp],0h指令把棧頂值加0,這是什么鬼?其實(shí)add是一個(gè)無意義的占位操作,只是由于lock后面必須跟特定的指令(例如ADD、XCHG等,MOV指令不能跟在lock后),所以才這么寫。lock會(huì)鎖內(nèi)存總線,保證將cpu高速緩存(L1/L2)里當(dāng)前緩存行的數(shù)據(jù)刷新到主存,同時(shí)使得其他cpu的高速緩存失效。lock之前的那條指令:
mov dword ptr [rsi+88h],edi
看似將寄存器的結(jié)果放到了內(nèi)存,但由于硬件操作的異步性,有可能只是放到了cpu高速緩存里,而并未真正寫到內(nèi)存。一般來說,cpu對(duì)內(nèi)存的寫分為兩種:write-through和write-back,前者同時(shí)寫內(nèi)存和高速緩存,后者只寫高速緩存,寫內(nèi)存則被推遲到隨后的某個(gè)時(shí)機(jī)。像linux操作系統(tǒng)使用的就是write-back,所以linux下的內(nèi)存賦值不是立即生效的。
我們寫一段偽碼來表示就更容易理解了:
inc edi
mov dword ptr [rsi+88h],edi
flush
由上可見volatile關(guān)鍵字的幾個(gè)特點(diǎn):
原子性;
多線程間可見性。
這兩個(gè)特點(diǎn)就來自于機(jī)器指令中的lock前綴(這里僅考慮多核情況,單核是無需lock前綴的,反正也沒人跟你搶),lock會(huì)鎖總線,禁止其他cpu對(duì)內(nèi)存的訪問(原子性),同時(shí)可能導(dǎo)致其他cpu緩存的失效,觸發(fā)重讀(多線程間可見性)。
還有一點(diǎn)需要特別指出,雖然volatile可以保證原子性,但反過來,指令的原子性并不是一定得靠volatile保證,例如java虛擬機(jī)規(guī)范就規(guī)定了除long和double外的基本類型的讀寫都是原子的,引用的讀寫也是原子的(見https://docs.oracle.com/javase/specs/jls/se7/html/jls-17.html#jls-17.7),這些都無需volatile來保證其原子性,在這些基本類型上使用volatile,僅僅利用的是volatile的“多線程間可見性”(例如bool型變量的多線程感知)或者“禁止指令重排序”作用(例如double-check)。
附錄
lock前綴簡(jiǎn)介
LOCK前綴導(dǎo)致處理器在執(zhí)行指令時(shí)會(huì)置上LOCK#信號(hào),于是該指令就被作為一個(gè)原子指令(atomic instruction)執(zhí)行。在多處理器環(huán)境下,置上LOCK#信號(hào)可以確保任何一個(gè)處理器能獨(dú)占使用任何共享內(nèi)存。
注意:后來的Intel64和IA32處理器(包括Pentium4,Intel Xeon, P6)有時(shí)即使沒有置上LOCK#信號(hào)也會(huì)產(chǎn)生鎖動(dòng)作的。
LOCK前綴只能放在下列指令前面: ADD, ADC, AND, BTC,BTR,BTS,CMPXCHG, CMPXCH8B, DEC,INC, NEG,NOT, OR, SBB, SUB, XOR, XADD以及XCHG。如果LOCK指令用在了非上述指令前則會(huì)引發(fā)#UD異常(undefined opcode exception,未定義操作數(shù)異常);而且LCOK前綴的指令的目標(biāo)操作數(shù)只能是內(nèi)存尋址方式,否則也會(huì)引發(fā)#UD異常的.XCHG指令不管前面有無LOCK前綴都會(huì)置上LOCK#信號(hào),即XCHG總是作為原子指令執(zhí)行。
LOCK前綴常常放在BTS前,用來實(shí)現(xiàn)對(duì)一個(gè)共享內(nèi)存的讀-修改-寫(read-modify-write)原子化操作。
內(nèi)存是否地址對(duì)齊并不影響LOCK前綴的功能。實(shí)際上,內(nèi)存鎖定對(duì)任何非對(duì)齊內(nèi)存地址都起作用的。
這個(gè)指令的操作在64位和非64位模式下是一致的。
vmstat關(guān)鍵輸出參數(shù)說明
cs 每秒上下文切換次數(shù),例如我們調(diào)用系統(tǒng)函數(shù),就要進(jìn)行上下文切換,線程的切換,也要進(jìn)程上下文切換,這個(gè)值要越小越好,太大了,要考慮調(diào)低線程或者進(jìn)程的數(shù)目,例如在apache和nginx這種web服務(wù)器中,我們一般做性能測(cè)試時(shí)會(huì)進(jìn)行幾千并發(fā)甚至幾萬并發(fā)的測(cè)試,選擇web服務(wù)器的進(jìn)程可以由進(jìn)程或者線程的峰值一直下調(diào),壓測(cè),直到cs到一個(gè)比較小的值,這個(gè)進(jìn)程和線程數(shù)就是比較合適的值了。系統(tǒng)調(diào)用也是,每次調(diào)用系統(tǒng)函數(shù),我們的代碼就會(huì)進(jìn)入內(nèi)核空間,導(dǎo)致上下文切換,這個(gè)是很耗資源,也要盡量避免頻繁調(diào)用系統(tǒng)函數(shù)。上下文切換次數(shù)過多表示你的CPU大部分浪費(fèi)在上下文切換,導(dǎo)致CPU干正經(jīng)事的時(shí)間少了,CPU沒有充分利用,是不可取的。
in 每秒CPU的中斷次數(shù),包括時(shí)間中斷
us 用戶CPU時(shí)間,我曾經(jīng)在一個(gè)做加密解密很頻繁的服務(wù)器上,可以看到us接近100,r運(yùn)行隊(duì)列達(dá)到80(機(jī)器在做壓力測(cè)試,性能表現(xiàn)不佳)。
id 空閑 CPU時(shí)間,一般來說,id + us + sy = 100,一般我認(rèn)為id是空閑CPU使用率,us是用戶CPU使用率,sy是系統(tǒng)CPU使用率。