讓你的程序跑的更快更好

1. 代碼相關

 

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

 

譬如,查看業務日誌,檢查日誌內容裏是否有大量的報錯產生,應用層、框架層的一些性能問題,大多數都能從日誌裏找到端倪(日誌級別設置不合理,導致線上瘋狂打日誌);再者,檢查代碼的主要邏輯,如 for 循環的不合理使用、NPE、正則表達式、數學計算等常見的一些問題,都可以通過簡單地修改代碼修復問題。

 

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

 

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

 

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

 

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

 

3)輸出異常日誌的時候,如果堆棧信息是明確的,可以取消輸出詳細堆棧,異常堆棧的構造是有成本的。注意:同一位置拋出大量重複的堆棧信息,JIT 會將其優化後成,直接拋出一個事先編譯好的、類型匹配的異常,異常堆棧信息就看不到了。

 

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

 

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

 

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

 

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

 

8)鎖的優化思路有:減少鎖的粒度、循環中使用鎖粗化、減少鎖的持有時間(讀寫鎖的選擇)等。同時,也考慮使用一些 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. 排除掉緩存 / 緩衝區對系統內存的影響後,如果發現內存還在不斷增長,說明很有可能存在內存泄漏。

 

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

 

4.1 磁盤 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,那麼也是正常的。

 

4.2 網絡 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#CPUps -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. 總結

 

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

 

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

 

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章