目前手機SOC的性能越來越少,很多程式員在終端程式的開發過程中也不太注意性能方面的優化,尤其是不注意對齊和分支優化,但是這兩種問題一旦出現所引發的問題,是非常非常隐蔽難查的,不過好在項目中用到了移動端的性能排查神器 友盟U-APM工具
的支援下,最終幾個問題得到了圓滿解決。
我們先來看對齊的問題,對齊在沒有并發競争的情況下不會有什麼問題,編譯器一般都會幫助程式員按照CPU字長進行對齊,但這在終端多線程同時工作的情況下可能會隐藏着巨大的性能問題,在多線程并發的情況下,即使沒有共享變量,也可能會造成僞共享,由于具體的代碼涉密,是以我們來看以下抽象後的代碼。
public class Main {
public static void main(String[] args) {
final MyData data = new MyData();
new Thread(new Runnable() {
public void run() {
data.add(0);
}
}).start();
try{
Thread.sleep(100);
} catch (InterruptedException e){
e.printStackTrace();
long[][] arr=data.Getitem();
System.out.println("arr0 is "+arr[0]+"arr1 is"+arr[1]);
}
class MyData {
private long[] arr={0,0};
public long[] Getitem(){
return arr;
public void add(int j){
for (;true;){
arr[j]++;
在這段代碼中,兩個子線程執行類似任務,分别操作arr數組當中的兩個成員,由于兩個子線程的操作對象分别是arr[0]和arr[1]并不存在交叉的問題,是以當時判斷判斷不會造成并發競争問題,也沒有加synchronized關鍵字。
但是這段程式卻經常莫名的卡頓,後來經過多方的查找,并最終通過
友盟的卡頓分析功能我們最終定位到了上述代碼段,發現這是一個由于沒有按照緩存行進行對齊而産生的問題,這裡先将修改完成後的僞代碼向大家說明一下:
Thread.sleep(10);
System.out.println("arr0 is "+arr[0][0]+"arr1 is"+arr[1][0]);
private long[][] arr={{0,0,0,0,0,0,0,0,0},{0,0}};
public long[][] Getitem(){
arr[j][0]++;
可以看到整體程式沒有作何變化,隻是将原來的數組變成了二維數組,其中除了第一個數組中除arr[0][0]元素外,其餘arr[0][1]-a[0][8]元素除完全不起作何與程式運作有關的作用,但就這麼一個小小的改動,卻帶來了性能有了接近20%的大幅提升,如果并發更多的話提升幅度還會更加明顯。
緩存行對齊排查分析過程
首先我們把之前代碼的多線程改為單線程串行執行,結果發現效率與原始的代碼一并沒有差很多,這就讓我基本确定了這是一個由僞共享引發的問題,但是我初始代碼中并沒有變量共享的問題,是以這基本可以判斷是由于對齊惹的禍。
現代的CPU一般都不是按位進行記憶體通路,而是按照字長來通路記憶體,當CPU從記憶體或者磁盤中将讀變量載入到寄存器時,每次操作的最小機關一般是取決于CPU的字長。比如8位字是1位元組,那麼至少由記憶體載入1位元組也就是8位長的資料,再比如32位CPU每次就至少載入4位元組資料, 64位系統8位元組以此類推。那麼以8位機為例咱們來看一下這個問題。假如變量1是個bool類型的變量,它占用1位空間,而變量2為byte類型占用8位空間,假如程式目前要通路變量2那麼,第一次讀取CPU會從開始的0x00位置讀取8位,也就是将bool型的變量1與byte型變量2的高7位全部讀入記憶體,但是byte變量的最低位卻沒有被讀進來,還需要第二次的讀取才能把完整的變量2讀入。
也就是說變量的存儲應該按照CPU的字長進行對齊,當通路的變量長度不足CPU字長的整數倍時,需要對變量的長度進行補齊。這樣才能提升CPU與記憶體間的通路效率,避免額外的記憶體讀取操作。但在對齊方面絕大多數編譯器都做得很好,在預設情況下,C編譯器為每一個變量或是資料單元按其自然對界條件配置設定空間邊界。也可以通過pragma pack(n)調用來改變預設的對界條件指令,調用後C編譯器将按照pack(n)中指定的n來進行n個位元組的對齊,這其實也對應着彙編語言中的.align。那麼為什麼還會有僞共享的對齊問題呢?
現代CPU中除了按字長對齊還需要按照緩存行對齊才能避免并發環境的競争,目前主流ARM核移動SOC的緩存行大小是64byte,因為每個CPU都配備了自己獨享的一級高速緩存,一級高速緩存基本是寄存器的速度,每次記憶體通路CPU除了将要通路的記憶體位址讀取之外,還會将前後處于64byte的資料一同讀取到高速緩存中,而如果兩個變量被放在了同一個緩存行,那麼即使不同CPU核心在分别操作這兩個獨立變量,而在實際場景中CPU核心實際也是在操作同一緩存行,這也是造成這個性能問題的原因。
Switch的坑
但是處理了這個對齊的問題之後,我們的程式雖然在絕大多數情況下的性能都不錯,但是還是會有卡頓的情況,結果發現這是一個由于Switch分支引發的問題。
switch是一種我們在java、c等語言程式設計時經常用到的分支處理結構,主要的作用就是判斷變量的取值并将程式代碼送入不同的分支,這種設計在當時的環境下非常的精妙,但是在目前最新的移動SOC環境下運作,卻會帶來很多意想不到的坑。
出于涉與之前密的原因一樣,真實的代碼不能公開,我們先來看以下這段代碼:
long now=System.currentTimeMillis();
int max=100,min=0;
long a=0;
long b=0;
long c=0;
for(int j=0;j<10000000;j++){
int ran=(int)(Math.random()*(max-min)+min);
switch(ran){
case 0:
a++;
break;
case 1:
default:
c++;
long diff=System.currentTimeMillis()-now;
System.out.println("a is "+a+"b is "+b+"c is "+c);
其中随機數其實是一個rpc遠端調用的傳回,但是這段代碼總是莫名其妙的卡頓,為了複現這個卡頓,定位到這個代碼段也是通過
友盟U-APM的卡頓分析找到的,想複現這個卡頓隻需要我們再稍微把max範圍由調整為5。
int max=5,min=0;
那麼運作時間就會有30%的下降,不過從我們分析的情況來看,代碼一平均每個随機數有97%的概念要行2次判斷才能跳轉到最終的分支,總體的判斷語句執行期望為2*0.97+1*0.03約等于2,而代碼二有30%的概念隻需要1次判斷就可以跳轉到最終分支,總體的判斷執行期望也就是0.3*1+0.6*2=1.5,但是代碼二卻反比代碼一還慢30%。也就是說在代碼邏輯完全沒變隻是傳回值範圍的機率密度做一下調整,就會使程式的運作效率大大下降,要解釋這個問題要從指令流水線說起。
指令流水線原理
我們知道CPU的每個動作都需要用晶體震蕩而觸發,以加法ADD指令為例,想完成這個執行指令需要取指、譯碼、取操作數、執行以及取操作結果等若幹步驟,而每個步驟都需要一次晶體震蕩才能推進,是以在流水線技術出現之前執行一條指令至少需要5到6次晶體震蕩周期才能完成
指令/時刻 | T1 | T2 | T3 | T4 | T5 |
ADD | 取指 | 譯碼 | 取操作數 | 執行 | 取結果 |
為了縮短指令執行的晶體震蕩周期,晶片設計人員參考了工廠流水線機制的提出了指令流水線的想法,由于取指、譯碼這些子產品其實在晶片内部都是獨立的,完成可以在同一時刻并發執行,那麼隻要将多條指令的不同步驟放在同一時刻執行,比如指令1取指,指令2譯碼,指令3取操作數等等,就可以大幅提高CPU執行效率:
指令/時 | T6 | T7 | T8 | |||||
指令1 | ||||||||
指令2 | ||||||||
指令3 | ||||||||
指令4 | ||||||||
指令5 | ||||||||
指令6 | ||||||||
指令7 | ||||||||
指令8 |
以上圖流水線為例 ,在T5時刻之前指令流水線以每周期一條的速度不斷建立,在T5時代以後每個震蕩周期,都可以有一條指令取結果,平均每條指令就隻需要一個震蕩周期就可以完成。這種流水線設計也就大幅提升了CPU的運算速度。
但是CPU流水線高度依賴指指令預測技術,假如在流水線上指令5本是不該執行的,但卻在T6時刻已經拿到指令1的結果時才發現這個預測失敗,那麼指令5在流水線上将會化為無效的氣泡,如果指令6到8全部和指令5有強關聯而一并失效的話,那麼整個流水線都需要重建立立。
是以可以看出例子當中的這個效率差完全是CPU指令預測造成的,也就是說CPU自帶的機制就是會對于執行概比較高的分支給出更多的預測傾斜。
處理建議-用哈希表替代switch
我們上文也介紹過哈希表也就是字典,可以快速将鍵值key轉化為值value,從某種程度上講可以替換switch的作用,按照第一段代碼的邏輯,用哈希表重寫的方案如下:
import java.util.HashMap;
int max=6,min=0;
HashMap<Integer,Integer> hMap = new HashMap<Integer,Integer>();
hMap.put(0,0);
hMap.put(1,0);
hMap.put(2,0);
hMap.put(3,0);
hMap.put(4,0);
hMap.put(5,0);
int value = hMap.get(ran)+1;
hMap.replace(ran,value);
System.out.println(hMap);
System.out.println("time is "+ diff);
上述這段用哈希表的代碼雖然不如代碼一速度快,但是總體非常穩定,即使出現代碼二的情況也比較平穩。
經驗總結
一、有并發的終端程式設計一定要注意按照緩存行(64byte)對齊,不按照緩存行對齊的代碼就是每增加一個線程性能會損失20%。
二、重點關注switch、if-else分支的問題,一旦條件分支的取值條件有所變化,那麼應該首選用哈希表結構,對于條件分支進行優化。
三、選擇一款好用的性能監測工具,如:
友盟U-APM,不僅免費且捕獲類型較為全面,推薦大家使用。