摘要:在併發程序中,並不是啓動更多的線程就能讓程序最大限度地併發執行。線程數量設置太小,會導致程序不能充分地利用系統資源;線程數量設置太大,又可能帶來資源的過度競爭,導致上下文切換帶來額外的系統開銷。本文就多線程中的上下文切換中的性能問題進行了探討。 參考資料:劉超的《Java性能調優實戰》
1、上下文切換是什麼?
概念: 當一個線程的時間片用完了,或者因自身原因被迫暫停運行了,這個時候,另外一個線程(可以是同一個線程或者其它進程的線程)就會被操作系統選中,來佔用處理器。這種一個線程被暫停剝奪使用權,另外一個線程被選中開始或者繼續運行的過程就叫做上下文切換(Context Switch)。
相關概念 | 詳情 |
---|---|
“切出” | 一個線程被剝奪處理器的使用權而被暫停運行 |
“切入” | 一個線程被選中佔用處理器開始或者繼續運行 |
“上下文” | 在這種切出切入的過程中,操作系統需要保存和恢復相應的進度信息,這個進度信息就是“上下文”了,(它包括了寄存器的存儲內容以及程序計數器存儲的指令內容) |
2、多線程上下文切換原因?
2.1 Java線程的生命週期狀態
線程主要有“新建”(NEW)、“就緒”(RUNNABLE)、“運行”(RUNNING)、“阻塞”(BLOCKED)、“死亡”(DEAD)五種狀態。線程由 RUNNABLE 轉爲非 RUNNABLE 的過程就是線程上下文切換
- 線程狀態由 RUNNING 轉爲 BLOCKED 或者由 BLOCKED 轉爲 RUNNABLE,這又是什麼誘發的呢?
原因 | 詳情 |
---|---|
一種是程序本身觸發的切換,這種我們稱爲自發性上下文切換 | 自發性上下文切換指線程由 Java 程序調用導致切出。在多線程編程中,執行調用以下方法或關鍵字,常常就會引發自發性上下文切換。sleep() 、wait()、yield()、join()、park()、synchronized、lock |
另一種是由系統或者虛擬機誘發的非自發性上下文切換 | 非自發性上下文切換指線程由於調度器的原因被迫切出。常見的有:線程被分配的時間片用完,虛擬機垃圾回收導致或者執行優先級的問題導致 |
- 虛擬機垃圾回收爲什麼會導致上下文切換?
在Java虛擬機中,對象的內存都是由虛擬機中的堆分配的,在程序運行過程中,新的對象將不斷被創建,如果舊的對象使用後不進行回收,堆內存將很快被耗盡。Java 虛擬機提供了一種回收機制,對創建後不再使用的對象進行回收,從而保證堆內存的可持續性分配。而這種垃圾回收機制的使用有可能會導致 stop-the-world 事件的發生,這其實就是一種線程暫停行爲。
2.2、系統開銷發生在切換過程中的哪些具體環節
- 操作系統保存和恢復上下文;
- 調度器進行線程調度;
- 處理器高速緩存重新加載;
- 上下文切換也可能導致整個高速緩存區被沖刷,從而帶來時間開銷。
3、總結
上下文切換就是一個工作的線程被另外一個線程暫停,另外一個線程佔用了處理器開始執行任務的過程。系統和 Java 程序自發性以及非自發性的調用操作,就會導致上下文切換,從而帶來系統開銷。
-
線程越多,系統的運行速度不一定越快。那麼我們平時在併發量比較大的情況下,什麼時候用單線程,什麼時候用多線程呢?
一般在單個邏輯比較簡單,而且速度相對來非常快的情況下,我們可以使用單線程。例如,我們前面講到的 Redis,從內存中快速讀取值,不用考慮 I/O 瓶頸帶來的阻塞問題。而在邏輯相對來說很複雜的場景,等待時間相對較長又或者是需要大量計算的場景,我建議使用多線程來提高系統的整體性能。例如,NIO 時期的文件讀寫操作、圖像處理以及大數據分析等。
4、如何優化多線程上下文切換?
- 在某些場景下使用多線程是非常必要的,但多線程編程給系統帶來了上下文切換,從而增加的性能開銷也是實打實存在的。那麼該如何優化多線程上下文切換呢?
4.1 競爭鎖優化
方法 | 詳情 |
---|---|
1. 減少鎖的持有時間 | 可以將一些與鎖無關的代碼移出同步代碼塊,尤其是那些開銷較大的操作以及可能被阻塞的操作 |
2. 降低鎖的粒度 | 可以考慮將鎖粒度拆分得更小一些,以此避免所有線程對一個鎖資源的競爭過於激烈。具體方式有以下兩種:鎖分離(讀寫鎖實現了鎖分離,讀讀不互斥)和鎖分段( Java1.8 之前版本的 ConcurrentHashMap 就使用了鎖分段) |
3. 非阻塞樂觀鎖替代競爭鎖 | CAS 是一個無鎖算法實現,保障了對一個共享變量讀寫操作的一致性。在 JDK1.6 中,JVM 將 Synchronized 同步鎖分爲了偏向鎖、輕量級鎖、偏向鎖以及重量級鎖,優化路徑也是按照以上順序進行。JIT 編譯器在動態編譯同步塊的時候,也會通過鎖消除、鎖粗化的方式來優化該同步鎖 |
4.2 wait/notify 優化
可以通過配合調用 Object 對象的 wait() 方法和 notify() 方法或 notifyAll() 方法來實現線程間的通信。
- 下面我們通過 wait() / notify() 來實現一個簡單的生產者和消費者的案例
public class WaitNotifyTest {
public static void main(String[] args) {
Vector<Integer> pool=new Vector<Integer>();
Producer producer=new Producer(pool, 10);
Consumer consumer=new Consumer(pool);
new Thread(producer).start();
new Thread(consumer).start();
}
}
/**
* 生產者
* @author admin
*/
class Producer implements Runnable{
private Vector<Integer> pool;
private Integer size;
public Producer(Vector<Integer> pool, Integer size) {
this.pool = pool;
this.size = size;
}
public void run() {
for(;;){
try {
System.out.println(" 生產一個商品 ");
produce(1);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
private void produce(int i) throws InterruptedException{
while(pool.size()==size){
synchronized (pool) {
System.out.println(" 生產者等待消費者消費商品, 當前商品數量爲 "+pool.size());
pool.wait();// 等待消費者消費
}
}
synchronized (pool) {
pool.add(i);
pool.notifyAll();// 生產成功,通知消費者消費
}
}
}
/**
* 消費者
* @author admin
*/
class Consumer implements Runnable{
private Vector<Integer> pool;
public Consumer(Vector<Integer> pool) {
this.pool = pool;
}
public void run() {
for(;;){
try {
System.out.println(" 消費一個商品 ");
consume();
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
private void consume() throws InterruptedException{
while(pool.isEmpty()){
synchronized (pool) {
System.out.println(" 消費者等待生產者生產商品, 當前商品數量爲 "+pool.size());
pool.wait();// 等待生產者生產商品
}
}
synchronized (pool) {
pool.remove(0);
pool.notifyAll();// 通知生產者生產商品
}
}
}
wait/notify 的使用導致了較多的上下文切換
- 在多個不同消費場景中,可以使用 Object.notify() 替代 Object.notifyAll()。 因爲 Object.notify() 只會喚醒指定線程,不會過早地喚醒其它未滿足需求的阻塞線程,所以可以減少相應的上下文切換。
- 在生產者執行完 Object.notify() / notifyAll() 喚醒其它線程之後,應該儘快地釋放內部鎖,以避免其它線程在喚醒之後長時間地持有鎖處理業務操作,這樣可以避免被喚醒的線程再次申請相應內部鎖的時候等待鎖的釋放。
- 最後,爲了避免長時間等待,我們常會使用 Object.wait (long)設置等待超時時間,但線程無法區分其返回是由於等待超時還是被通知線程喚醒,從而導致線程再次嘗試獲取鎖操作,增加了上下文切換。(建議使用 Lock 鎖結合 Condition 接口替代 Synchronized 內部鎖中的 wait / notify,實現等待/通知)
4.3 合理地設置線程池大小,避免創建過多線程
線程池的線程數量設置不宜過大,因爲一旦線程池的工作線程總數超過系統所擁有的處理器數量,就會導致過多的上下文切換
在有些創建線程池的方法裏,線程數量設置不會直接暴露給我們。比如,用 Executors.newCachedThreadPool() 創建的線程池,該線程池會複用其內部空閒的線程來處理新提交的任務,如果沒有,再創建新的線程(不受 MAX_VALUE 限制),這樣的線程池如果碰到大量且耗時長的任務場景,就會創建非常多的工作線程,從而導致頻繁的上下文切換(只適合處理大量且耗時短的非阻塞任務)
4.4 使用協程實現非阻塞等待
- 協程是一種比線程更加輕量級的東西,相比於由操作系統內核來管理的進程和線程,協程則完全由程序本身所控制,也就是在用戶態執行。協程避免了像線程切換那樣產生的上下文切換,在性能方面得到了很大的提升
4.5 減少 Java 虛擬機的垃圾回收
- 很多 JVM 垃圾回收器(serial 收集器、ParNew 收集器)在回收舊對象時,會產生內存碎片,從而需要進行內存整理,在這個過程中就需要移動存活的對象。而移動內存對象就意味着這些對象所在的內存地址會發生變化,因此在移動對象前需要暫停線程,在移動完成後需要再次喚醒該線程。因此減少 JVM 垃圾回收的頻率可以有效地減少上下文切換
補充:思考題1:在JDK的Lock中,或者AQS中,線程“掛起”這個動作又是怎麼實現的呢?爲什麼不會產生進程級別的上下文切換呢?
AQS掛起是通過LockSupport中的park進入阻塞狀態,這個過程也是存在進程上下文切換的。但被阻塞的線程再次獲取鎖時,不會產生進程上下文切換,而synchronized阻塞的線程每次獲取鎖資源都要通過系統調用內核來完成,這樣就比AQS阻塞的線程更消耗系統資源了