天天看點

90%的人會遇到性能問題,如何用1行代碼快速定位?1 代碼相關2 CPU 相關3 記憶體相關4 磁盤I/O和網絡I/O5 有用的一行指令6 總結

在通過工具得到異常名額,初步定位瓶頸點後,如何進一步進行确認和調優?這裡将給出常見的一些調優分析思路,内容會按照CPU、記憶體、網絡、磁盤等進行組織。(性能工具的使用,高頻性能瓶頸點總結,請參考這篇文章: https://developer.aliyun.com/article/727675?spm=a2c6h.12873581.0.0.17de1febanow15&groupCode=othertech

1 代碼相關

遇到性能問題,首先應該做的是檢查否與業務代碼相關——不是通過閱讀代碼解決問題,而是通過日志或代碼,排除掉一些與業務代碼相關的低級錯誤。性能優化的最佳位置,是應用内部。

譬如,檢視業務日志,檢查日志内容裡是否有大量的報錯産生,應用層、架構層的一些性能問題,大多數都能從日志裡找到端倪(日志級别設定不合理,導緻線上瘋狂打日志);再者,檢查代碼的主要邏輯,如 for 循環的不合理使用、NPE、正規表達式、數學計算等常見的一些問題,都可以通過簡單地修改代碼修複問題。

别動辄就把性能優化和緩存、異步化、JVM 調優等名詞挂鈎,複雜問題可能會有簡單解,「二八原則」在性能優化的領域裡裡依然有效。當然了,了解一些基本的「代碼常用踩坑點」,可以加速我們問題分析思路的過程,從 CPU、記憶體、JVM 等分析到的一些瓶頸點優化思路,也有可能在代碼這裡展現出來。

下面是一些高頻的,容易造成性能問題的編碼要點。

一、正規表達式非常消耗 CPU(如貪婪模式可能會引起回溯),慎用字元串的 split()、replaceAll() 等方法;正規表達式表達式一定預編譯。

二、String.intern() 在低版本(Java 1.6 以及之前)的 JDK 上使用,可能會造成方法區(永久代)記憶體溢出。在高版本 JDK 中,如果 string pool 設定太小而緩存的字元串過多,也會造成較大的性能開銷。

三、輸出異常日志的時候,如果堆棧資訊是明确的,可以取消輸出詳細堆棧,異常堆棧的構造是有成本的。注意:同一位置抛出大量重複的堆棧資訊,JIT 會将其優化後成,直接抛出一個事先編譯好的、類型比對的異常,異常堆棧資訊就看不到了。

四、避免引用類型和基礎類型之間無謂的拆裝箱操作,請盡量保持一緻,自動裝箱發生太頻繁,會非常嚴重消耗性能。

五、Stream API 的選擇。複雜和并行操作,推薦使用 Stream API,可以簡化代碼,同時發揮來發揮出 CPU 多核的優勢,如果是簡單操作或者 CPU 是單核,推薦使用顯式疊代。

六、根據業務場景,通過 ThreadPoolExecutor 手動建立線程池,結合任務的不同,指定線程數量和隊列大小,規避資源耗盡的風險,統一命名後的線程也便于後續問題排查。

七、根據業務場景,合理選擇并發容器。如選擇 Map 類型的容器時,如果對資料要求有強一緻性,可使用 Hashtable 或者 「Map + 鎖」 ;讀遠大于寫,使用 CopyOnWriteArrayList;存取資料量小、對資料沒有強一緻性的要求、變更不頻繁的,使用 ConcurrentHashMap;存取資料量大、讀寫頻繁、對資料沒有強一緻性的要求,使用 ConcurrentSkipListMap。

八、鎖的優化思路有:減少鎖的粒度、循環中使用鎖粗化、減少鎖的持有時間(讀寫鎖的選擇)等。同時,也考慮使用一些 JDK 優化後的并發類,如對一緻性要求不高的統計場景中,使用 LongAdder 替代 AtomicLong 進行計數,使用 ThreadLocalRandom 替代 Random 類等。

代碼層的優化除了上面這些,還有很多就不一一列出了。我們可以觀察到,在這些要點裡,有一些共性的優化思路,是可以抽取出來的,譬如:

  1. 空間換時間:使用記憶體或者磁盤,換取更寶貴的CPU 或者網絡,如緩存的使用;
  2. 時間換空間:通過犧牲部分 CPU,節省記憶體或者網絡資源,如把一次大的網絡傳輸變成多次;
  3. 其他諸如并行化、異步化、池化技術等。

2 CPU 相關

前面講到過,我們更應該關注 CPU 負載,CPU 使用率高一般不是問題,CPU 負載 是判斷系統計算資源是否健康的關鍵依據。

2.1 CPU 使用率高&&平均負載高

這種情況常見于 CPU 密集型的應用,大量的線程處于可運作狀态,I/O 很少,常見的大量消耗 CPU 資源的應用場景有:

  1. 正則操作
  2. 數學運算
  3. 序列化/反序列化
  4. 反射操作
  5. 死循環或者不合理的大量循環
  6. 基礎/第三方元件缺陷

排查高 CPU 占用的一般思路:通過 jstack 多次(> 5次)列印線程棧,一般可以定位到消耗 CPU 較多的線程堆棧。或者通過 Profiling 的方式(基于事件采樣或者埋點),得到應用在一段時間内的 on-CPU 火焰圖,也能較快定位問題。

還有一種可能的情況,此時應用存在頻繁的 GC (包括 Young GC、Old GC、Full GC),這也會導緻 CPU 使用率和負載都升高。排查思路:使用 jstat -gcutil 持續輸出目前應用的 GC 統計次數和時間。頻繁 GC 導緻的負載升高,一般還伴随着可用記憶體不足,可用 free 或者 top 等指令檢視下目前機器的可用記憶體大小。

CPU 使用率過高,是否有可能是 CPU 本身性能瓶頸導緻的呢?也是有可能的。可以進一步通過 vmstat 檢視詳細的 CPU 使用率。使用者态 CPU 使用率(us)較高,說明使用者态程序占用了較多的 CPU,如果這個值長期大于50%,應該着重排查應用本身的性能問題。核心态 CPU 使用率(sy)較高,說明核心态占用了較多的 CPU,是以應該着重排查核心線程或者系統調用的性能問題。如果 us + sy 的值大于 80%,說明 CPU 可能不足。

2.2 CPU 使用率低&&平均負載高

如果CPU使用率不高,說明我們的應用并沒有忙于計算,而是在幹其他的事。CPU 使用率低而平均負載高,常見于 I/O 密集型程序,這很容易了解,畢竟平均負載就是 R 狀态程序和 D 狀态程序的和,除掉了第一種,就隻剩下 D 狀态程序了(産生 D 狀态的原因一般是因為在等待 I/O,例如磁盤 I/O、網絡 I/O 等)。

排查&&驗證思路:使用 vmstat 1 定時輸出系統資源使用,觀察 %wa(iowait) 列的值,該列辨別了磁盤 I/O 等待時間在 CPU 時間片中的百分比,如果這個值超過30%,說明磁盤 I/O 等待嚴重,這可能是大量的磁盤随機通路或直接的磁盤通路(沒有使用系統緩存)造成的,也可能磁盤本身存在瓶頸,可以結合 iostat 或 dstat 的輸出加以驗證,如 %wa(iowait) 升高同時觀察到磁盤的讀請求很大,說明可能是磁盤讀導緻的問題。

此外,耗時較長的網絡請求(即網絡 I/O)也會導緻 CPU 平均負載升高,如 MySQL 慢查詢、使用 RPC 接口擷取接口資料等。這種情況的排查一般需要結合應用本身的上下遊依賴關系以及中間件埋點的 trace 日志,進行綜合分析。

2.3 CPU 上下文切換次數變高

先用 vmstat 檢視系統的上下文切換次數,然後通過 pidstat 觀察程序的自願上下文切換(cswch)和非自願上下文切換(nvcswch)情況。自願上下文切換,是因為應用内部線程狀态發生轉換所緻,譬如調用 sleep()、join()、wait()等方法,或使用了 Lock 或 synchronized 鎖結構;非自願上下文切換,是因為線程由于被配置設定的時間片用完或由于執行優先級被排程器排程所緻。

如果自願上下文切換次數較高,意味着 CPU 存在資源擷取等待,比如說,I/O、記憶體等系統資源不足等。如果是非自願上下文切換次數較高,可能的原因是應用内線程數過多,導緻 CPU 時間片競争激烈,頻頻被系統強制排程,此時可以結合 jstack 統計的線程數和線程狀态分布加以佐證。

3 記憶體相關

前面提到,記憶體分為系統記憶體和程序記憶體(含 Java 應用程序),一般我們遇到的記憶體問題,絕大多數都會落在程序記憶體上,系統資源造成的瓶頸占比較小。對于 Java 程序,它自帶的記憶體管理自動化地解決了兩個問題:如何給對象配置設定記憶體以及如何回收配置設定給對象的記憶體,其核心是垃圾回收機制。

垃圾回收雖然可以有效地防止記憶體洩露、保證記憶體的有效使用,但也并不是萬能的,不合理的參數配置和代碼邏輯,依然會帶來一系列的記憶體問題。此外,早期的垃圾回收器,在功能性和回收效率上也不是很好,過多的 GC 參數設定非常依賴開發人員的調優經驗。比如,對于最大堆記憶體的不恰當設定,可能會引發堆溢出或者堆震蕩等一系列問題。

下面看看幾個常見的記憶體問題分析思路。

3.1 系統記憶體不足

Java 應用一般都有單機或者叢集的記憶體水位監控,如果單機的記憶體使用率大于 95%,或者叢集的記憶體使用率大于80%,就說明可能存在潛在的記憶體問題(注:這裡的記憶體水位是系統記憶體)。

除了一些較極端的情況,一般系統記憶體不足,大機率是由 Java 應用引起的。使用 top 指令時,我們可以看到 Java 應用程序的實際記憶體占用,其中 RES 表示程序的常駐記憶體使用,VIRT 表示程序的虛拟記憶體占用,記憶體大小的關系為:VIRT > RES > Java 應用實際使用的堆大小。除了堆記憶體,Java 程序整體的記憶體占用,還有方法區/元空間、JIT 緩存等,主要組成如下:

Java 應用記憶體占用 = Heap(堆區)+ Code Cache(代碼緩存區) + Metaspace(元空間)+ Symbol tables(符号表)+ Thread stacks(線程棧區)+ Direct buffers(堆外記憶體)+ JVM structures(其他的一些 JVM 自身占用)+ Mapped files(記憶體映射檔案)+ Native Libraries(本地庫)+ ...

Java 程序的記憶體占用,可以使用 jstat -gc 指令檢視,輸出的名額中可以得到目前堆記憶體各分區、元空間的使用情況。堆外記憶體的統計和使用情況,可以利用 NMT(Native Memory Tracking,HotSpot VM Java8 引入)擷取。線程棧使用的記憶體空間很容易被忽略,雖然線程棧記憶體采用的是懶加載的模式,不會直接使用 +Xss 的大小來配置設定記憶體,但是過多的線程也會導緻不必要的記憶體占用,可以使用 jstackmem 這個腳本統計整體的線程占用。

系統記憶體不足的排查思路:

  1. 首先使用 free 檢視目前記憶體的可用空間大小,然後使用 vmstat 檢視具體的記憶體使用情況及記憶體增長趨勢,這個階段一般能定位占用記憶體最多的程序;
  2. 分析緩存 / 緩沖區的記憶體使用。如果這個數值在一段時間變化不大,可以忽略。如果觀察到緩存 / 緩沖區的大小在持續升高,則可以使用 pcstat、cachetop、slabtop 等工具,分析緩存 / 緩沖區的具體占用;
  3. 排除掉緩存 / 緩沖區對系統記憶體的影響後,如果發現記憶體還在不斷增長,說明很有可能存在記憶體洩漏,具體分析過程見2.3 節。

3.2 Java 記憶體溢出

記憶體溢出是指應用建立一個對象執行個體時,所需的記憶體空間大于堆的可用空間。記憶體溢出的種類較多,一般會在報錯日志裡看到 OutOfMemoryError 關鍵字。常見記憶體溢出種類及分析思路如下:

  1. java.lang.OutOfMemoryError: Java heap space。原因:堆中(新生代和老年代)無法繼續配置設定對象了、某些對象的引用長期被持有沒有被釋放,垃圾回收器無法回收、使用了大量的 Finalizer 對象,這些對象并不在 GC 的回收周期内等。一般堆溢出都是由于記憶體洩漏引起的,如果确認沒有記憶體洩漏,可以适當通過增大堆記憶體。
  2. java.lang.OutOfMemoryError:GC overhead limit exceeded。原因:垃圾回收器超過98%的時間用來垃圾回收,但回收不到2%的堆記憶體,一般是因為存在記憶體洩漏或堆空間過小。
  3. java.lang.OutOfMemoryError: Metaspace或java.lang.OutOfMemoryError: PermGen space。排查思路:檢查是否有動态的類加載但沒有及時解除安裝,是否有大量的字元串常量池化,永久代/元空間是否設定過小等。
  4. java.lang.OutOfMemoryError : unable to create new native Thread。原因:虛拟機在拓展棧空間時,無法申請到足夠的記憶體空間。可适當降低每個線程棧的大小以及應用整體的線程個數。此外,系統裡總體的程序/線程建立總數也受到系統空閑記憶體和作業系統的限制,請仔細檢查。注:這種棧溢出,和 StackOverflowError 不同,後者是由于方法調用層次太深,配置設定的棧記憶體不夠建立棧幀導緻。

    此外,還有 Swap 分區溢出、本地方法棧溢出、數組配置設定溢出等 OutOfMemoryError 類型,由于不是很常見,就不一一介紹了。

3.3 Java 記憶體洩漏

Java 記憶體洩漏可以說是開發人員的噩夢,記憶體洩漏與記憶體溢出不同則,後者簡單粗暴,現場也比較好找。記憶體洩漏的表現是:應用運作一段時間後,記憶體使用率越來越高,響應越來越慢,直到最終出現程序「假死」。

Java 記憶體洩漏可能會造成系統可用記憶體不足、程序假死、OOM 等,排查思路卻不外乎下面兩種:

  1. 通過 jmap 定期輸出堆内對象統計,定位數量和大小持續增長的對象;
  2. 使用 Profiler 工具對應用進行Profiling,尋找記憶體配置設定熱點。

    此外,在堆記憶體持續增長時,建議 dump 一份堆記憶體的快照,後面可以基于快照做一些分析。快照雖然是瞬時值,但也是有一定的意義的。

3.4 垃圾回收相關

GC(垃圾回收,下同)的各項名額,是衡量 Java 程序記憶體使用是否健康的重要标尺。垃圾回收最核心名額:GC Pause(包括 MinorGC 和 MajorGC) 的頻率和次數,以及每次回收的記憶體詳情,前者可以通過 jstat 工具直接得到,後者需要分析 GC 日志。需要注意的是,jstat 輸出列中的 FGC/FGCT 表示的是一次老年代垃圾回收中,出現 GC Pause (即 Stop-the-World)的次數,譬如對于 CMS 垃圾回收器,每次老年代垃圾回收這個值會增加2(初始标記和重新标記着兩個 Stop-the-World 的階段,這個統計值會是 2。

什麼時候需要進行 GC 調優?這取決于應用的具體情況,譬如對響應時間的要求、對吞吐量的要求、系統資源限制等。一些經驗:GC 頻率和耗時大幅上升、GC Pause 平均耗時超過 500ms、Full GC 執行頻率小于1分鐘等,如果 GC 滿足上述的一些特征,說明需要進行 GC 調優了。

由于垃圾回收器種類繁多,針對不同的應用,調優政策也有所差別,是以下面介紹幾種通用的的 GC 調優政策。

1.選擇合适的 GC 回收器。根據應用對延遲、吞吐的要求,結合各垃圾回收器的特點,合理選用。推薦使用 G1 替換 CMS 垃圾回收器,G1 的性能是在逐漸優化的,在 8GB 記憶體及以下的機器上,其各方面的表現也在趕上甚至有超越之勢。G1 調參較友善,而 CMS 垃圾回收器參數太過複雜、容易造成空間碎片化、對 CPU 消耗較高等弊端,也使其目前處于廢棄狀态。Java 11 裡新引入的 ZGC 垃圾回收器,基本可用做到全階段并發标記和回收,值得期待。

2.合理的堆記憶體大小設定。堆大小不要設定過大,建議不要超過系統記憶體的 75%,避免出現系統記憶體耗盡。最大堆大小和初始化堆的大小保持一緻,避免堆震蕩。新生代的大小設定比較關鍵,我們調整 GC 的頻率和耗時,很多時候就是在調整新生代的大小,包括新生代和老年代的占比、新生代中 Eden 區和 Survivor 區的比例等,這些比例的設定還需要考慮各代中對象的晉升年齡,整個過程需要考慮的東西還是比較多的。如果使用 G1 垃圾回收器,新生代大小這一塊需要考慮的東西就少很多了,自适應的政策會決定每一次的回收集合(CSet)。新生代的調整是 GC 調優的核心,非常依賴經驗,但是一般來說,Young GC 頻率高,意味着新生代太小(或 Eden 區和 Survivor 配置不合理),Young GC 時間長,意味着新生代過大,這兩個方向大體不差。

3.降低 Full GC 的頻率。如果出現了頻繁的 Full GC 或者 老年代 GC,很有可能是存在記憶體洩漏,導緻對象被長期持有,通過 dump 記憶體快照進行分析,一般能較快地定位問題。除此之外,新生代和老年代的比例不合适,導緻對象頻頻被直接配置設定到老年代,也有可能會造成 Full GC,這個時候需要結合業務代碼和記憶體快照綜合分析。

此外,通過配置 GC 參數,可以幫助我們擷取很多 GC 調優所需的關鍵資訊,如配置-XX:+PrintGCApplicationStoppedTime-XX:+PrintSafepointStatistics-XX:+PrintTenuringDistribution,分别可以擷取 GC Pause 分布、安全點耗時統計、對象晉升年齡分布的資訊,加上 -XX:+PrintFlagsFinal 可以讓我們了解最終生效的 GC 參數等。

4 磁盤I/O和網絡I/O

磁盤 I/O 問題排查思路:

  1. 使用工具輸出磁盤相關的輸出的名額,常用的有 %wa(iowait)、%util,根據輸判斷磁盤 I/O 是否存在異常,譬如 %util 這個名額較高,說明有較重的 I/O 行為;
  2. 使用 pidstat 定位到具體程序,關注下讀或寫的資料大小和速率;
  3. 使用 lsof + 程序号,可檢視該異常程序打開的檔案清單(含目錄、塊裝置、動态庫、網絡套接字等),結合業務代碼,一般可定位到 I/O 的來源,如果需要具體分析,還可以使用 perf 等工具進行 trace 定位 I/O 源頭。

    需要注意的是,%wa(iowait)的升高不代表一定意味着磁盤 I/O 存在瓶頸,這是數值代表 CPU 上 I/O 操作的時間占用的百分比,如果應用程序的在這段時間内的主要活動就是 I/O,那麼也是正常的。

網絡 I/O 存在瓶頸,可能的原因如下:

  1. 一次傳輸的對象過大,可能會導緻請求響應慢,同時 GC 頻繁;
  2. 網絡 I/O 模型選擇不合理,導緻應用整體 QPS 較低,響應時間長;
  3. RPC 調用的線程池設定不合理。可使用 jstack 統計線程數的分布,如果處于 TIMED_WAITING 或 WAITING 狀态的線程較多,則需要重點關注。舉例:資料庫連接配接池不夠用,展現線上程棧上就是很多線程在競争一把連接配接池的鎖;
  4. RPC 調用逾時時間設定不合理,造成請求失敗較多;

Java 應用的線程堆棧快照非常有用,除了上面提到的用于排查線程池配置不合理的問題,其他的一些場景,如 CPU 飙高、應用響應較慢等,都可以先從線程堆棧入手。

5 有用的一行指令

這一小節給出若幹在定位性能問題的指令,用于快速定位。

1)檢視系統目前網絡連接配接數

netstat -n | awk '/^tcp/ {++S[$NF]} END {for(a in S) print a, S[a]}'
           

2)檢視堆内對象的分布 Top 50(定位記憶體洩漏)

jmap –histo:live $pid | sort-n -r -k2 | head-n 50
           

3)按照 CPU/記憶體的使用情況列出前10 的程序

#記憶體
ps axo %mem,pid,euser,cmd | sort -nr | head -10
#CPU
ps -aeo pcpu,user,pid,cmd | sort -nr | head -10
           

4)顯示系統整體的 CPU使用率和閑置率

grep "cpu " /proc/stat | awk -F ' ' '{total = $2 + $3 + $4 + $5} END {print "idle \t used\n" $5*100/total "% " $2*100/total "%"}'
           

5)按線程狀态統計線程數(加強版)

jstack $pid | grep java.lang.Thread.State:|sort|uniq -c | awk '{sum+=$1; split($0,a,":");gsub(/^[ \t]+|[ \t]+$/, "", a[2]);printf "%s: %s\n", a[2], $1}; END {printf "TOTAL: %s",sum}';
           

6)檢視最消耗 CPU 的 Top10 線程機器堆棧資訊

推薦大家使用 show-busy-java-threads 腳本,該腳本可用于快速排查 Java 的 CPU 性能問題(top us值過高),自動查出運作的 Java 程序中消耗 CPU 多的線程,并列印出其線程棧,進而确定導緻性能問題的方法調用,該腳本已經用于阿裡線上運維環境。連結位址:

https://github.com/oldratlee/useful-scripts/

7)火焰圖生成(需要安裝 perf、perf-map-agent、FlameGraph 這三個項目):

# 1. 收集應用運作時的堆棧和符号表資訊(采樣時間30秒,每秒99個事件);
sudo perf record -F 99 -p $pid -g -- sleep 30; ./jmaps

# 2. 使用 perf script 生成分析結果,生成的 flamegraph.svg 檔案就是火焰圖。
sudo perf script | ./pkgsplit-perf.pl | grep java | ./flamegraph.pl > flamegraph.svg
           

8)按照 Swap 分區的使用情況列出前 10 的程序

for file in /proc/*/status ; do awk '/VmSwap|Name|^Pid/{printf $2 " " $3}END{ print ""}' $file; done | sort -k 3 -n -r | head -10
           

9)JVM 記憶體使用及垃圾回收狀态統計

#顯示最後一次或目前正在發生的垃圾收集的誘發原因
jstat -gccause $pid

#顯示各個代的容量及使用情況
jstat -gccapacity $pid

#顯示新生代容量及使用情況
jstat -gcnewcapacity $pid

#顯示老年代容量
jstat -gcoldcapacity $pid

#顯示垃圾收集資訊(間隔1秒持續輸出)
jstat -gcutil $pid 1000

10)其他的一些日常指令

# 快速殺死所有的 java 程序
ps aux | grep java | awk '{ print $2 }' | xargs kill -9

# 查找/目錄下占用磁盤空間最大的top10檔案
find / -type f -print0 | xargs -0 du -h | sort -rh | head -n 10
           

6 總結

性能優化是一個很大的領域,這裡面的每一個小點,都可以拓展為數十篇文章去闡述。對應用進行性能優化,除了上面介紹的之外,還有前端優化、架構優化(分布式、緩存使用等)、資料存儲優化、代碼優化(如設計模式優化)等,限于篇幅所限,在這裡并未一一展開,本文的這些内容,隻是起一個抛磚引玉的作用。同時,本文的東西是我的一些經驗和知識,并不一定全對,希望大家指正和補充。

性能優化是一個綜合性的工作,需要不斷地去實踐,将工具學習、經驗學習融合到實戰中去,不斷完善,形成一套屬于自己的調優方法論。

此外,雖然性能優化很重要,但是不要過早在優化上投入太多精力(當然完善的架構設計和編碼是必要的),過早優化是萬惡之源。一方面,提前做的優化工作,可能會不适用快速變化的業務需求,反倒給新需求、新功能起了阻礙的作用;另一方面,過早優化使得應用複雜性升高,降低了應用的可維護性。何時進行優化、優化到什麼樣的程度,是一個需要多方權衡的命題。