如何最佳化多執行緒上下文切換?

如果是單個執行緒,在CPU 呼叫之後,那麼它基本上是不會被排程出去的。如果可執行的執行緒數遠大於 CPU 數量,那麼作業系統最終會將某個正在執行的執行緒排程出來,從而使其它執行緒能夠使用 CPU

,這就會導致上下文切換。

在多執行緒中如果使用了競爭鎖,當執行緒由於等待競爭鎖而被阻塞時,JVM 通常會將這個鎖掛起,並允許它被交換出去。如果頻繁地發生阻塞,CPU 密集型的程式就會發生更多的上下文切換。

在某些場景下使用多執行緒是非常必要的,但多執行緒程式設計給系統帶來了上下文切換,從而增加的效能開銷也是實打實存在的。那麼我們該如何最佳化多執行緒上下文切換呢?

競爭鎖最佳化

多執行緒對鎖資源的競爭會引起上下文切換,還有鎖競爭導致的執行緒阻塞越多,上下文切換就越頻繁,系統的效能開銷也就越大。由此可見,在多執行緒程式設計中,鎖其實不是效能開銷的根源,競爭鎖才是。

1. 減少鎖的持有時間

鎖的持有時間越長,就意味著有越多的執行緒在等待該競爭資源釋放。如果是Synchronized 同步鎖資源,就不僅是帶來執行緒間的上下文切換,還有可能會增加程序間的上下文切換。

可以將一些與鎖無關的程式碼移出同步程式碼塊,尤其是那些開銷較大的操作以及可能被阻塞的操作。

2. 降低鎖的粒度

同步鎖可以保證物件的原子性,我們可以考慮將鎖粒度拆分得更小一些,以此避免所有執行緒對一個鎖資源的競爭過於激烈。具體方式有以下兩種:

鎖分離

與傳統鎖不同的是,讀寫鎖實現了鎖分離,也就是說讀寫鎖是由“讀鎖”和“寫鎖”兩個鎖實現的,其規則是可以共享讀,但只有一個寫。

這樣做的好處是,在多執行緒讀的時候,讀讀是不互斥的,讀寫是互斥的,寫寫是互斥的。而傳統的獨佔鎖在沒有區分讀寫鎖的時候,讀寫操作一般是:讀讀互斥、讀寫互斥、寫寫互斥。所以在讀遠大於寫的多執行緒場景中,鎖分離避免了在高併發讀情況下的資源競爭,從而避免了上下文切換。

鎖分段

我們在使用鎖來保證集合或者大物件原子性時,可以考慮將鎖物件進一步分解。例如,Java1。8 之前版本的 ConcurrentHashMap 就使用了鎖分段。

3. 非阻塞樂觀鎖替代競爭鎖

volatile 關鍵字的作用是保障可見性及有序性,volatile 的讀寫操作不會導致上下文切換, 因此開銷比較小。 但是,volatile 不能保證操作變數的原子性,因為沒有鎖的排他性。

而 CAS 是一個原子的 if-then-act 操作,CAS 是一個無鎖演算法實現,保障了對一個共享變數讀寫操作的一致性。CAS 操作中有 3 個運算元,記憶體值 V、舊的預期值 A 和要修改的新值 B,當且僅當 A 和 V 相同時,將 V 修改為 B,否則什麼都不做,CAS 演算法將不會導致上下文切換。Java 的 Atomic 包就使用了 CAS 演算法來更新資料,就不需要額外加鎖。

在 JDK1。6 中,JVM 將 Synchronized 同步鎖分為了偏向鎖、輕量級鎖、偏向鎖以及重量級鎖,最佳化路徑也是按照以上順序進行。JIT 編譯器在動態編譯同步塊的時候,也會透過鎖消除、鎖粗化的方式來最佳化該同步鎖。

wait/notify 最佳化

在 Java 中,我們可以透過配合呼叫 Object 物件的 wait() 方法和 notify() 方法或 notifyAll() 方法來實現執行緒間的通訊。

線上程中呼叫 wait() 方法,將阻塞等待其它執行緒的通知(其它執行緒呼叫 notify() 方法或 notifyAll() 方法),線上程中呼叫 notify() 方法或 notifyAll() 方法,將通知其它執行緒從wait() 方法處返回。

wait/notify 的使用導致了較多的上下文切換

結合以下圖片,我們可以看到,在消費者第一次申請到鎖之前,發現沒有商品消費,此時會執行 Object。wait() 方法,這裡會導致執行緒掛起,進入阻塞狀態,這裡為一次上下文切換。

當生產者獲取到鎖並執行 notifyAll() 之後,會喚醒處於阻塞狀態的消費者執行緒,此時這裡又發生了一次上下文切換。

被喚醒的等待執行緒在繼續執行時,需要再次申請相應物件的內部鎖,此時等待執行緒可能需要和其它新來的活躍執行緒爭用內部鎖,這也可能會導致上下文切換。

如果有多個消費者執行緒同時被阻塞,用 notifyAll() 方法,將會喚醒所有阻塞的執行緒。而某些商品依然沒有庫存,過早地喚醒這些沒有庫存的商品的消費執行緒,可能會導致執行緒再次進入阻塞狀態,從而引起不必要的上下文切換。

最佳化 wait/notify 的使用,減少上下文切換

首先,我們在多個不同消費場景中,可以使用 Object。notify() 替代 Object。notifyAll()。 因為 Object。notify() 只會喚醒指定執行緒,不會過早地喚醒其它未滿足需求的阻塞執行緒,所以可以減少相應的上下文切換。

其次,在生產者執行完 Object。notify() / notifyAll() 喚醒其它執行緒之後,應該儘快地釋放內部鎖,以避免其它執行緒在喚醒之後長時間地持有鎖處理業務操作,這樣可以避免被喚醒的執行緒再次申請相應內部鎖的時候等待鎖的釋放。

最後,為了避免長時間等待,我們常會使用 Object。wait (long)設定等待超時時間,但執行緒無法區分其返回是由於等待超時還是被通知執行緒喚醒,從而導致執行緒再次嘗試獲取鎖操作,增加了上下文切換。這裡我建議使用 Lock 鎖結合 Condition 介面替代 Synchronized 內部鎖中的 wait / notify,實現等待/通知。這樣做不僅可以解決上述的 Object。wait(long) 無法區分的問題,還可以解決執行緒被過早喚醒的問題。

Condition 介面定義的 await 方法 、signal 方法和 signalAll 方法分別相當於Object。wait()、 Object。notify() 和 Object。notifyAll()。

合理地設定執行緒池大小,避免建立過多執行緒

執行緒池的執行緒數量設定不宜過大,因為一旦執行緒池的工作執行緒總數超過系統所擁有的處理器數量,就會導致過多的上下文切換。

在有些建立執行緒池的方法裡,執行緒數量設定不會直接暴露給我們。比如,用 Executors。newCachedThreadPool() 建立的執行緒池,該執行緒池會複用其內部空閒的執行緒來處理新提交的任務,如果沒有,再建立新的執行緒(不受 MAX_VALUE 限制),這樣的執行緒池如果碰到大量且耗時長的任務場景,就會建立非常多的工作執行緒,從而導致頻繁的上下文切換。因此,這類執行緒池就只適合處理大量且耗時短的非阻塞任務。

使用協程實現非阻塞等待

協程是一種比執行緒更加輕量級的東西,相比於由作業系統核心來管理的程序和執行緒,協程則完全由程式本身所控制,也就是在使用者態執行。協程避免了像執行緒切換那樣產生的上下文切換,在效能方面得到了很大的提升。

減少 Java 虛擬機器的垃圾回收

很多 JVM 垃圾回收器(serial 收集器、ParNew 收集器)在回收舊物件時,會產生記憶體碎片,從而需要進行記憶體整理,在這個過程中就需要移動存活的物件。而移動記憶體物件就意味著這些物件所在的記憶體地址會發生變化,因此在移動物件前需要暫停執行緒,在移動完成後需要再次喚醒該執行緒。因此減少 JVM 垃圾回收的頻率可以有效地減少上下文切換。