如果是單個線程跨扮,在CPU 調(diào)用之后,那么它基本上是不會被調(diào)度出去的验毡。如果可運(yùn)行的線程數(shù)遠(yuǎn)大于 CPU 數(shù)量衡创,那么操作系統(tǒng)最終會將某個正在運(yùn)行的線程調(diào)度出來,從而使其它線程能夠使用 CPU
晶通,這就會導(dǎo)致上下文切換璃氢。
在多線程中如果使用了競爭鎖,當(dāng)線程由于等待競爭鎖而被阻塞時狮辽,JVM 通常會將這個鎖掛起一也,并允許它被交換出去。如果頻繁地發(fā)生阻塞喉脖,CPU 密集型的程序就會發(fā)生更多的上下文切換椰苟。
在某些場景下使用多線程是非常必要的,但多線程編程給系統(tǒng)帶來了上下文切換树叽,從而增加的性能開銷也是實(shí)打?qū)嵈嬖诘挠吆D敲次覀冊撊绾蝺?yōu)化多線程上下文切換呢?
競爭鎖優(yōu)化
多線程對鎖資源的競爭會引起上下文切換题诵,還有鎖競爭導(dǎo)致的線程阻塞越多洁仗,上下文切換就越頻繁,系統(tǒng)的性能開銷也就越大性锭。由此可見赠潦,在多線程編程中,鎖其實(shí)不是性能開銷的根源篷店,競爭鎖才是祭椰。
1. 減少鎖的持有時間
鎖的持有時間越長,就意味著有越多的線程在等待該競爭資源釋放疲陕。如果是Synchronized 同步鎖資源方淤,就不僅是帶來線程間的上下文切換,還有可能會增加進(jìn)程間的上下文切換蹄殃。
可以將一些與鎖無關(guān)的代碼移出同步代碼塊携茂,尤其是那些開銷較大的操作以及可能被阻塞的操作。
2. 降低鎖的粒度
同步鎖可以保證對象的原子性诅岩,我們可以考慮將鎖粒度拆分得更小一些讳苦,以此避免所有線程對一個鎖資源的競爭過于激烈带膜。具體方式有以下兩種:
鎖分離
與傳統(tǒng)鎖不同的是,讀寫鎖實(shí)現(xiàn)了鎖分離鸳谜,也就是說讀寫鎖是由“讀鎖”和“寫鎖”兩個鎖實(shí)現(xiàn)的膝藕,其規(guī)則是可以共享讀,但只有一個寫咐扭。
這樣做的好處是芭挽,在多線程讀的時候,讀讀是不互斥的蝗肪,讀寫是互斥的袜爪,寫寫是互斥的。而傳統(tǒng)的獨(dú)占鎖在沒有區(qū)分讀寫鎖的時候薛闪,讀寫操作一般是:讀讀互斥辛馆、讀寫互斥、寫寫互斥豁延。所以在讀遠(yuǎn)大于寫的多線程場景中昙篙,鎖分離避免了在高并發(fā)讀情況下的資源競爭,從而避免了上下文切換术浪。
鎖分段
我們在使用鎖來保證集合或者大對象原子性時瓢对,可以考慮將鎖對象進(jìn)一步分解。例如胰苏,Java1.8 之前版本的 ConcurrentHashMap 就使用了鎖分段硕蛹。
3. 非阻塞樂觀鎖替代競爭鎖
volatile 關(guān)鍵字的作用是保障可見性及有序性,volatile 的讀寫操作不會導(dǎo)致上下文切換硕并, 因此開銷比較小法焰。 但是,volatile 不能保證操作變量的原子性倔毙,因?yàn)闆]有鎖的排他性埃仪。
而 CAS 是一個原子的 if-then-act 操作,CAS 是一個無鎖算法實(shí)現(xiàn)陕赃,保障了對一個共享變量讀寫操作的一致性卵蛉。CAS 操作中有 3 個操作數(shù),內(nèi)存值 V么库、舊的預(yù)期值 A 和要修改的新值 B傻丝,當(dāng)且僅當(dāng) A 和 V 相同時,將 V 修改為 B诉儒,否則什么都不做葡缰,CAS 算法將不會導(dǎo)致上下文切換。Java 的 Atomic 包就使用了 CAS 算法來更新數(shù)據(jù),就不需要額外加鎖泛释。
在 JDK1.6 中滤愕,JVM 將 Synchronized 同步鎖分為了偏向鎖、輕量級鎖怜校、偏向鎖以及重量級鎖间影,優(yōu)化路徑也是按照以上順序進(jìn)行。JIT 編譯器在動態(tài)編譯同步塊的時候茄茁,也會通過鎖消除宇智、鎖粗化的方式來優(yōu)化該同步鎖。
wait/notify 優(yōu)化
在 Java 中胰丁,我們可以通過配合調(diào)用 Object 對象的 wait() 方法和 notify() 方法或 notifyAll() 方法來實(shí)現(xiàn)線程間的通信。
在線程中調(diào)用 wait() 方法喂分,將阻塞等待其它線程的通知(其它線程調(diào)用 notify() 方法或 notifyAll() 方法)锦庸,在線程中調(diào)用 notify() 方法或 notifyAll() 方法,將通知其它線程從wait() 方法處返回蒲祈。
wait/notify 的使用導(dǎo)致了較多的上下文切換
結(jié)合以下圖片甘萧,我們可以看到,在消費(fèi)者第一次申請到鎖之前梆掸,發(fā)現(xiàn)沒有商品消費(fèi)扬卷,此時會執(zhí)行 Object.wait() 方法,這里會導(dǎo)致線程掛起酸钦,進(jìn)入阻塞狀態(tài)怪得,這里為一次上下文切換。
當(dāng)生產(chǎn)者獲取到鎖并執(zhí)行 notifyAll() 之后卑硫,會喚醒處于阻塞狀態(tài)的消費(fèi)者線程徒恋,此時這里又發(fā)生了一次上下文切換。
被喚醒的等待線程在繼續(xù)運(yùn)行時欢伏,需要再次申請相應(yīng)對象的內(nèi)部鎖入挣,此時等待線程可能需要和其它新來的活躍線程爭用內(nèi)部鎖,這也可能會導(dǎo)致上下文切換硝拧。
如果有多個消費(fèi)者線程同時被阻塞径筏,用 notifyAll() 方法,將會喚醒所有阻塞的線程障陶。而某些商品依然沒有庫存滋恬,過早地喚醒這些沒有庫存的商品的消費(fèi)線程,可能會導(dǎo)致線程再次進(jìn)入阻塞狀態(tài)咸这,從而引起不必要的上下文切換夷恍。
優(yōu)化 wait/notify 的使用,減少上下文切換
首先,我們在多個不同消費(fèi)場景中酿雪,可以使用 Object.notify() 替代 Object.notifyAll()遏暴。 因?yàn)?Object.notify() 只會喚醒指定線程,不會過早地喚醒其它未滿足需求的阻塞線程指黎,所以可以減少相應(yīng)的上下文切換朋凉。
其次,在生產(chǎn)者執(zhí)行完 Object.notify() / notifyAll() 喚醒其它線程之后醋安,應(yīng)該盡快地釋放內(nèi)部鎖杂彭,以避免其它線程在喚醒之后長時間地持有鎖處理業(yè)務(wù)操作,這樣可以避免被喚醒的線程再次申請相應(yīng)內(nèi)部鎖的時候等待鎖的釋放吓揪。
最后亲怠,為了避免長時間等待,我們常會使用 Object.wait (long)設(shè)置等待超時時間柠辞,但線程無法區(qū)分其返回是由于等待超時還是被通知線程喚醒团秽,從而導(dǎo)致線程再次嘗試獲取鎖操作,增加了上下文切換叭首。這里我建議使用 Lock 鎖結(jié)合 Condition 接口替代 Synchronized 內(nèi)部鎖中的 wait / notify习勤,實(shí)現(xiàn)等待/通知。這樣做不僅可以解決上述的 Object.wait(long) 無法區(qū)分的問題焙格,還可以解決線程被過早喚醒的問題图毕。
Condition 接口定義的 await 方法 、signal 方法和 signalAll 方法分別相當(dāng)于Object.wait()眷唉、 Object.notify() 和 Object.notifyAll()予颤。
合理地設(shè)置線程池大小,避免創(chuàng)建過多線程
線程池的線程數(shù)量設(shè)置不宜過大冬阳,因?yàn)橐坏┚€程池的工作線程總數(shù)超過系統(tǒng)所擁有的處理器數(shù)量荣瑟,就會導(dǎo)致過多的上下文切換。
在有些創(chuàng)建線程池的方法里摩泪,線程數(shù)量設(shè)置不會直接暴露給我們笆焰。比如,用 Executors.newCachedThreadPool() 創(chuàng)建的線程池见坑,該線程池會復(fù)用其內(nèi)部空閑的線程來處理新提交的任務(wù)嚷掠,如果沒有,再創(chuàng)建新的線程(不受 MAX_VALUE 限制)荞驴,這樣的線程池如果碰到大量且耗時長的任務(wù)場景不皆,就會創(chuàng)建非常多的工作線程,從而導(dǎo)致頻繁的上下文切換熊楼。因此霹娄,這類線程池就只適合處理大量且耗時短的非阻塞任務(wù)。
使用協(xié)程實(shí)現(xiàn)非阻塞等待
協(xié)程是一種比線程更加輕量級的東西,相比于由操作系統(tǒng)內(nèi)核來管理的進(jìn)程和線程犬耻,協(xié)程則完全由程序本身所控制踩晶,也就是在用戶態(tài)執(zhí)行。協(xié)程避免了像線程切換那樣產(chǎn)生的上下文切換枕磁,在性能方面得到了很大的提升渡蜻。
減少 Java 虛擬機(jī)的垃圾回收
很多 JVM 垃圾回收器(serial 收集器、ParNew 收集器)在回收舊對象時计济,會產(chǎn)生內(nèi)存碎片茸苇,從而需要進(jìn)行內(nèi)存整理,在這個過程中就需要移動存活的對象沦寂。而移動內(nèi)存對象就意味著這些對象所在的內(nèi)存地址會發(fā)生變化学密,因此在移動對象前需要暫停線程,在移動完成后需要再次喚醒該線程传藏。因此減少 JVM 垃圾回收的頻率可以有效地減少上下文切換则果。