0 前言
在過去單CPU時代,單任務在一個時間點只能執行單一程序。之后發展到多任務階段,計算機能在同一時間點並行執行多任務或多進程。雖然並不是真正意義上的“同一時間點”,而是 多個任務或進程共享一個CPU,並交由操作系統來完成多任務間對CPU的運行切換,以使得每個任務都有機會獲得一定的時間片運行。
再后來發展到多線程技術,使得在一個程序內部能擁有多個線程並行執行。一個線程的執行可以被認為是一個CPU在執行該程序。當一個程序運行在多線程下,就好像有多個CPU在同時執行該程序。
多線程比多任務更加有挑戰。多線程是在同一個程序內部並行執行,因此會對相同的內存空間進行並發讀寫操作。這可能是在單線程程序中從來不會遇到的問題。其中的一些錯誤也未必會在單CPU機器上出現,因為兩個線程從來不會得到真正的並行執行。然而,更現代的計算機伴隨着多核CPU的出現,也就意味着 不同的線程能被不同的CPU核得到真正意義的並行執行。
所以,在多線程、多任務情況下,線程上下文切換是必須的,然而對於CPU架構設計中的概念,應先熟悉了解,這樣會有助於理解線程上下文切換原理。
1 多核、多CPU、超線程、多線程
1.1 為什么要多核
先要說的是多核、多CPU、超線程,這三個其實都是CPU架構設計的概念,一個現代CPU除了處理器核心之外還包括寄存器、L1L2緩存這些存儲設備、浮點運算單元、整數運算單元等一些輔助運算設備以及內部總線等。一個多核的CPU也就是一個CPU上有多個處理器核心,這樣有什么好處呢?比如說現在我們要在一台計算機上跑一個多線程的程序,因為是一個進程里的線程,所以需要一些共享一些存儲變量,如果這台計算機都是單核單線程CPU的話,就意味着這個程序的不同線程需要經常在CPU之間的外部總線上通信,同時還要處理不同CPU之間不同緩存導致數據不一致的問題,所以在這種場景下多核單CPU的架構就能發揮很大的優勢,通信都在內部總線,共用同一個緩存。
1.2 為什么要多CPU
前面提了多核的好處,那為什么要多CPU呢?這個其實很容易想到,如果要運行多個程序(進程)的話,假如只有一個CPU的話,就意味着要經常進行進程上下文切換,因為單CPU即便是多核的,也只是多個處理器核心,其他設備都是共用的,所以 多個進程就必然要經常進行進程上下文切換,這個代價是很高的。
1.3 為什么要超線程
超線程這個概念是Intel提出的,簡單來說是在一個CPU上真正的並發兩個線程,聽起來似乎不太可能,因為CPU都是分時的啊,其實這里也是分時,因為前面也提到一個CPU除了處理器核心還有其他設備,一段代碼執行過程也不光是只有處理器核心工作,如果兩個線程A和B,A正在使用處理器核心,B正在使用緩存或者其他設備,那AB兩個線程就可以並發執行,但是如果AB都在訪問同一個設備,那就只能等前一個線程執行完后一個線程才能執行。實現這種並發的原理是 在CPU里加了一個協調輔助核心,根據Intel提供的數據,這樣一個設備會使得設備面積增大5%,但是性能提高15%~30%。
1.4 為什么要多線程
這個問題也許是面試中問的最多的一個經典問題了,一個進程里多線程之間可以共享變量,線程間通信開銷也較小,可以更好的利用多核CPU的性能,多核CPU上跑多線程程序往往會比單線程更快,有的時候甚至在單核CPU上多線程程序也會有更好的性能,因為雖然多線程會有上下文切換和線程創建銷毀開銷,但是單線程程序會被IO阻塞無法充分利用CPU資源,加上線程的上下文開銷較低以及線程池的大量應用,多線程在很多場景下都會有更高的效率。
1.5 線程與進程
進程是操作系統的管理單位,而線程則是進程的管理單位;一個進程至少包含一個執行線程。不管是在單線程還是多線程中,每個線程都有一個程序計數器(記錄要執行的下一條指令),一組寄存器(保存當前線程的工作變量),堆棧(記錄執行歷史,其中每一幀保存了一個已經調用但未返回的過程)。雖然線程寄生在進程中,但與他的進程是不同的概念,並且可以分別處理:進程是系統分配資源的基本單位,線程是調度CPU的基本單位。
一個線程指的是進程中一個單一順序的控制流,一個進程中可以並行多個線程,每條線程並行執行不同的任務。每個線程共享堆空間,擁有自己獨立的棧空間。
- 線程划分尺度小於進程,線程隸屬於某個進程;
- 進程是CPU、內存等資源占用的基本單位,線程是不能獨立占有這些資源的;
- 進程之間相互獨立,通信比較困難,而線程之間共享一塊內存區域,通信方便;
- 進程在執行過程中,包含:固定的入口、執行順序和出口,而進程的這些過程會被應用程序控制;

2 上下文切換
支持多任務處理是CPU設計史上最大的跨越之一。在計算機中,多任務處理是指同時運行兩個或多個程序。從使用者的角度來看,這看起來並不復雜或者難以實現,但是它確實是計算機設計史上一次大的飛躍。在多任務處理系統中,CPU需要處理所有程序的操作,當用戶來回切換它們時,需要記錄這些程序執行到哪里。上下文切換就是這樣一個過程,允許CPU記錄並恢復各種正在運行程序的狀態,使它能夠完成切換操作。
多任務系統往往需要同時執行多道作業。作業數往往大於機器的CPU數,然而一顆CPU同時只能執行一項任務,如何讓用戶感覺這些任務正在同時進行呢? 操作系統的設計者 巧妙地利用了時間片輪轉的方式, CPU給每個任務都服務一定的時間,然后把當前任務的狀態保存下來,在加載下一任務的狀態后,繼續服務下一任務。任務的狀態保存及再加載, 這段過程就叫做上下文切換。時間片輪轉的方式使多個任務在同一顆CPU上執行變成了可能。

2.1 基本概念
上下文切換(有時也稱做進程切換或任務切換)是指CPU從一個進程或線程切換到另一個進程或線程。
- 進程(有時候也稱做任務)是指一個程序運行的實例。
- 在Linux系統中,線程 就是能並行運行並且與他們的父進程(創建他們的進程)共享同一地址空間(一段內存區域)和其他資源的 輕量級的進程。
- 上下文 是指某一時間點 CPU 寄存器和程序計數器的內容。
- 寄存器 是 CPU 內部的數量較少但是速度很快的內存(與之對應的是 CPU 外部相對較慢的 RAM 主內存)。寄存器通過對常用值(通常是運算的中間值)的快速訪問來提高計算機程序運行的速度。
- 程序計數器是一個專用的寄存器,用於表明指令序列中 CPU 正在執行的位置,存的值為正在執行的指令的位置或者下一個將要被執行的指令的位置,具體依賴於特定的系統。
上下文切換可以認為是內核(操作系統的核心)在 CPU 上對於進程(包括線程)進行以下的活動:
- 掛起一個進程,將這個進程在 CPU 中的狀態(上下文)存儲於內存中的某處;
- 恢復一個進程,在內存中檢索下一個進程的上下文並將其在 CPU 的寄存器中恢復;
- 跳轉到程序計數器所指向的位置(即跳轉到進程被中斷時的代碼行),以恢復該進程。
2.2 切換種類
上下文切換在不同的場合有不同的含義,在下表中列出:
上下文切換種類 | 描述 |
---|---|
線程切換 | 同一進程中的兩個線程之間的切換 |
進程切換 | 兩個進程之間的切換 |
模式切換 | 在給定線程中,用戶模式和內核模式的切換 |
地址空間切換 | 將虛擬內存切換到物理內存 |
2.3 切換步驟
在上下文切換過程中,CPU會停止處理當前運行的程序,並保存當前程序運行的具體位置以便之后繼續運行。從這個角度來看,上下文切換有點像我們同時閱讀幾本書,在來回切換書本的同時我們需要記住每本書當前讀到的頁碼。在程序中,上下文切換過程中的“頁碼”信息是保存在進程控制塊(PCB, process control block)中的。PCB還經常被稱作“切換楨”(switchframe)。“頁碼”信息會一直保存到CPU的內存中,直到他們被再次使用。
PCB通常是系統內存占用區中的一個連續存區,它存放着操作系統用於描述進程情況及控制進程運行所需的全部信息,它使一個在多道程序環境下不能獨立運行的程序成為一個能獨立運行的基本單位或一個能與其他進程並發執行的進程。
- 保存進程A的狀態(寄存器和操作系統數據);
- 更新PCB中的信息,對進程A的“運行態”做出相應更改;
- 將進程A的PCB放入相關狀態的隊列;
- 將進程B的PCB信息改為“運行態”,並執行進程B;
- B執行完后,從隊列中取出進程A的PCB,恢復進程A被切換時的上下文,繼續執行A;
線程切換和進程切換的步驟也不同。進程的上下文切換分為兩步:
- 切換頁目錄以使用新的地址空間;
- 切換內核棧和硬件上下文;
對於Linux來說,線程和進程的最大區別就在於地址空間。對於線程切換,第1步是不需要做的,第2是進程和線程切換都要做的。所以明顯是進程切換代價大。線程上下文切換和進程上下文切換一個最主要的區別是 線程的切換虛擬內存空間依然是相同的,但是進程切換是不同的。這兩種上下文切換的處理都是 通過操作系統內核來完成的。內核的這種切換過程伴隨的 最顯著的性能損耗是將寄存器中的內容切換出。
對於一個正在執行的進程包括 程序計數器、寄存器、變量的當前值等 ,而這些數據都是 保存在CPU的寄存器中的,且這些寄存器只能是正在使用CPU的進程才能享用,在進程切換時,首先得保存上一個進程的這些數據(便於下次獲得CPU的使用權時從上次的中斷處開始繼續順序執行,而不是返回到進程開始,否則每次進程重新獲得CPU時所處理的任務都是上一次的重復,可能永遠也到不了進程的結束出,因為一個進程幾乎不可能執行完所有任務后才釋放CPU),然后將本次獲得CPU的進程的這些數據裝入CPU的寄存器從上次斷點處繼續執行剩下的任務。
操作系統為了便於管理系統內部進程,為每個進程創建了一張進程表項:

2.4 切換查看
在Linux系統下可以使用vmstat命令來查看上下文切換的次數,下面是利用vmstat查看上下文切換次數的示例:

vmstat 1指每秒統計一次, 其中cs列就是指上下文切換的數目. 一般情況下, 空閑系統的上下文切換每秒大概在1500以下.
3 切換原因
引起線程上下文切換的原因,主要存在三種情況如下:
- 中斷處理:在中斷處理中,其他程序”打斷”了當前正在運行的程序。當CPU接收到中斷請求時,會在正在運行的程序和發起中斷請求的程序之間進行一次上下文切換。中斷分為硬件中斷和軟件中斷,軟件中斷包括因為IO阻塞、未搶到資源或者用戶代碼等原因,線程被掛起。
- 多任務處理:在多任務處理中,CPU會在不同程序之間來回切換,每個程序都有相應的處理時間片,CPU在兩個時間片的間隔中進行上下文切換。
- 用戶態切換:對於一些操作系統,當進行用戶態切換時也會進行一次上下文切換,雖然這不是必須的。
對於我們經常 使用的搶占式操作系統 而言,引起線程上下文切換的原因大概有以下幾種:
- 當前執行任務的時間片用完之后,系統CPU正常調度下一個任務;
- 當前執行任務碰到IO阻塞,調度器將此任務掛起,繼續下一任務;
- 多個任務搶占鎖資源,當前任務沒有搶到鎖資源,被調度器掛起,繼續下一任務;
- 用戶代碼掛起當前任務,讓出CPU時間;
- 硬件中斷;
4 切換損耗
上下文切換會帶來 直接和間接 兩種因素影響程序性能的消耗。
- 直接消耗:指的是CPU寄存器需要保存和加載, 系統調度器的代碼需要執行, TLB實例需要重新加載, CPU 的pipeline需要刷掉;
- 間接消耗:指的是多核的cache之間得共享數據, 間接消耗對於程序的影響要看線程工作區操作數據的大小;
5 減少切換
既然上下文切換會導致額外的開銷,因此減少上下文切換次數便可以提高多線程程序的運行效率。但上下文切換又分為2種:
- 讓步式上下文切換:指執行線程主動釋放CPU,與鎖競爭嚴重程度成正比,可通過減少鎖競爭來避免;
- 搶占式上下文切換:指線程因分配的時間片用盡而被迫放棄CPU或者被其他優先級更高的線程所搶占,一般由於線程數大於CPU可用核心數引起,可通過調整線程數,適當減少線程數來避免。
所以,減少上下文切換的方法有無鎖並發編程、CAS算法、使用最少線程和使用協程。
- 無鎖並發:多線程競爭時,會引起上下文切換,所以多線程處理數據時,可以用一些辦法來避免使用鎖,如將數據的ID按照Hash取模分段,不同的線程處理不同段的數據;
- CAS算法:Java的Atomic包使用CAS算法來更新數據,而不需要加鎖;
- 最少線程:避免創建不需要的線程,比如任務很少,但是創建了很多線程來處理,這樣會造成大量線程都處於等待狀態;
- 使用協程:在單線程里實現多任務的調度,並在單線程里維持多個任務間的切換;
6 線程數目
合理設置線程數目,關鍵點是:1. 盡量減少線程切換和管理的開支;2. 最大化利用CPU;
對於1,要求線程數盡量少,這樣可以減少線程切換和管理的開支;
對於2,要求盡量多的線程,以保證CPU資源最大化的利用;
所以 對於任務耗時短的情況,要求線程盡量少,如果線程太多,有可能出現線程切換和管理的時間,大於任務執行的時間,那效率就低了;
對於耗時長的任務,要分是CPU任務,還是IO等類型的任務。如果是CPU類型的任務,線程數不宜太多;但是如果是IO類型的任務,線程多一些更好,可以更充分利用CPU。
高並發,低耗時的情況:建議少線程,只要滿足並發即可,因為上下文切換本來就多,並且高並發就意味着CPU是處於繁忙狀態的, 增加更多地線程也不會讓線程得到執行時間片,反而會增加線程切換的開銷;例如並發100,線程池可能設置為10就可以;
低並發,高耗時的情況:建議多線程,保證有空閑線程,接受新的任務;例如並發10,線程池可能就要設置為20;
高並發高耗時:1. 要分析任務類型;2. 增加排隊;3. 加大線程數;
文章目標
當Java項目出現性能瓶頸的時候,通常先是對資源消耗做分析,包括CPU,文件IO,網絡IO,內存;之后再結合相應工具查找消耗主體的程序代碼。本文主要介紹系統資源消耗的分析過程,以及常用的Java線程分析方法。
CPU分析
在Linux中,CPU主要用於處理中斷、內核及用戶任務,優先級為:中斷>內核>用戶。在分析CPU消耗狀況的時候,需要了解以下三個概念。
上下文切換
每個CPU(或多核CPU的每個核心)在同一時間只能執行一個線程<不包括超線程CPU>,Linux采用搶占式調度。當線程執行到達一個時間片后有高優先級線程要執行,或者線程有IO阻塞的時候,或者有線程中斷的時候,又或者有鎖資源競爭阻塞(sleep不涉及鎖資源競爭)或者用戶掛起線程(如wait())的時候Linux將執行線程切換,切換前先保存當前線程執行狀態(現場),並恢復待執行線程狀態,這個過程就叫做上下文切換。在Java應用中,文件IO、網絡IO、鎖等待、線程Sleep操作都會使該線程進行阻塞或睡眠狀態,從而觸發上下文切換。頻繁的上下文切換會造成內核占用較高的CPU,使得響應速度下降。
運行隊列
每個CPU核心都維護了一個可運行隊列,例如一個4核CPU,啟動8個線程,且8個線程都處於可運行狀態,平均分配情況下,每個核心的可運行隊列里就有2個線程。通常而言,系統的load是由CPU運行隊列決定的,假設以上狀態維持了1分鍾,則1分鍾內系統load就是2。運行隊列值越大,代表線程要消耗越長的時間才能執行完成。通常建議每個核心運行隊列為1-3個。
利用率
CPU利用率指在用戶進程,內核,中斷處理,IO等待以及空閑五個部分百分比,這五個值是用來分析CPU消耗情況的關鍵指標。Linux System and NetWork Performent Monitoring建議用戶進程/內核消耗比例為 65%-70% / 30%-35% 左右。
常用top, pidstat, sar, vmstat 1 分析占用情況,下圖是top示例
us:用戶進程處理占用百分比
sy:內核線程處理占用百分比
ni:被nice命令改變優先級的任務所占百分比
id:cpu空閑時間占用百分比
wa:在執行過程中等待IO所占百分比
hi:硬件中斷占用百分比
si:軟件中斷占用百分比
st:虛擬機偷取時間百分比
對Java應用而言,線程消耗主要體現在us, sy上:
us: 用戶進程處理占用百分比
us占用分析,需要依靠相關命令找出主體消耗線程ID(tid),然后轉化成十六進制(printf "%x\n" tid),再用 kill -3 java_pid或 jstack -l java_pid 命令dump出線程信息,通過之前的十六進制值在dump信息中找到nid相等的線程,即為消耗CPU的線程。采樣的時候要多做幾次,保證找到的是真實的消耗線程。
在Java應用中如果us占用過高,代表運行的應用程序消耗了大部分CPU,常見為線程一直處理可運行狀態(Runnable),並且無阻塞地執行循環,正則或復雜計算;也可能是每次請求都分配大量內存,導致頻繁GC甚至頻繁FullGC造成的,這時就需要依靠jvm工具查看了(jps, jmap, jstat等) 。
sy: 內核線程處理占用百分比
sy值過高表示Linux花費大量時間在線程切換上,Java造成原因通常是啟動大量線程,且多數線程處理不斷阻塞(如IO等待,鎖等待)和執行的狀態變化中,造成大量上下文切換。這時可通過 kill -3 java_pid或jstack -l java_pid 命令dump出線程信息,找出不斷切換線程執行狀態的原因(也可以通過TDA分析)。
如下使用 vmstat 1 查看上下文切換(cs)及sy占用
如果cs值很高的話,再使用 jstack -l java_pid 查看線程堆棧信息,通常可以發現大量線程處於TIMED_WAITING (on object monitor)與Runnable狀態轉化中,通過on object monitor可以找到鎖競爭激烈的代碼,從而找出上下文切換的原因。
文件IO分析
Linux在操作文件的時候,會將文件放入文件緩存區,直到內存不夠或系統要釋放內存給用戶進程使用時,才會交換出去。因此在查看內存狀態時經常發現可用(free)的物理內存不足,但cached用了很多,這是Linux提升文件IO速度的一種方法。這種情況下,如果物理內存足夠用,真正的文件IO只有寫文件和第一次讀的時候才會產生。
在Linux中文件IO主要通過 pidstat, iostat分析:
pidstat -d -p java_pid 1 3
KB_rd/s 表示每秒讀取的KB數, KB_wr/s表示每秒寫入的KB數, 還可以加入-t參數顯示具體的線程信息。
iostat
iostat只能看到整個系統的文件IO,不能查看具體進程消耗情況。Device表示設備卷標名或分區名,tps是每秒的IO請求,是IO消耗關鍵指標;Blk_read/s表示每秒讀的塊數量,Blk_wrtn/s表示每秒寫的塊數量;Blk_read, Blk_wrtn表示總共讀寫的塊數量;當%iowait占用很高的時候,就要關注IO消耗狀況了,這時可以使用 iostat -x 觀察:
r/s, w/s 表示每秒讀寫的請求數, await表示平均每次IO操作的等待時間,avgqu-sz表示等待請求的隊列的平均長度,svctm表示平均每次設備執行IO操作的時間,util表示一秒之中有百分之幾用於IO操作。
在Java應用中造成文件IO消耗嚴重的原因,通常是多個線程進行大量寫入操作(如頻繁寫入日志文件)。這時可以通過pidstat或iostat結合jstack線程信息,找到消耗主體程序。
網絡IO分析
在分布式Java應用中,網絡IO的消耗是非常值得關注的,尤其注意網卡中斷是不是均勻地分配到各CPU上(cat /proc/interrupts)。Linux使用sar分析網絡IO消耗情況:
sar -n ALL 1 2
主要觀注接包(rxpck/s),發包(txpck/s),接包失敗(rxerr/s),發包失敗(txerr/s),丟包(rxdrop/s),Socket信息(tcpsck , udpsck)。
由於無法觀察具體進程的網絡IO消耗,在網絡IO消耗高時,只能線程dump,通常這些線程都在進行網絡讀寫操作。在Java網絡通信中,通常將對象序列化為字節流發送,反序列化生成對象。
內存分析
從Java應用角度上看,內存可分為兩部分,即JVM內存與非JVM內存。在JVM中內存消耗主要體現在堆內存上,內存消耗過高會導致頻繁GC甚至FullGC,CPU占用高,可以通過jmap, jstat, mat, visualvm等工具跟蹤內存消耗情況;生產環境下,通常將 -Xms 和 -Xmx調整為相同的值,避免運行時不斷申請內存。非JVM內存通常只有在創建線程或使用DirectByteBuffer時才會產生,最值得關注的是swap的消耗與物理內存的消耗。
vmstat
swpd表示虛擬內存已使用的部分(kb),free空閑物理內存,buff表示用於緩沖的內存,cache表示用於作為緩存的內存。swap下的si表示每秒從disk讀到內存的數據量,so每秒從內存寫入disk的數據量。swpd過高表示物理內存不夠用,系統需要頻繁從虛擬內存與disk交換數據,嚴重影響系統的性能。
sar -r 2 5
通過sar工具可以看到內存占用,空閑,buff, cache的情況。當物理內存空閑時,Linux會使用一部分內存用於buffer以及cache,以提高系統運行效率。因此可認為系統可用物理內存為 kbmemfree + kbbuffers + kbcached。
此外還可以使用top, pidstat -r -p [pid][interval][times]
pidstat -r -p 2448 1 5
參考資料
中斷:http://blog.csdn.net/pxz_002/article/details/7327668
CPU占用分析:http://www.cnblogs.com/yjf512/p/3383915.html
林昊:分布式Java應用
JVM內存分析:http://my.oschina.net/feichexia/blog/196575
https://wenku.baidu.com/view/c7c38dbe4b35eefdc8d333a8.html